diff --git a/.learnings/ERRORS.md b/.learnings/ERRORS.md index 1f6c7a91d..469e5ea3b 100644 --- a/.learnings/ERRORS.md +++ b/.learnings/ERRORS.md @@ -30,3 +30,32 @@ CI/CD-first release is blocked until runner/Gitea endpoint compatibility or regi ### Next Action Check act_runner config/registration, Gitea actions endpoint compatibility, runner version, and service URL. + +## [ERR-20260624-STAGING-WEB-BUILD-ON-BUSINESS-SERVER] deploy + +**Logged**: 2026-06-24T22:50:00+08:00 +**Priority**: critical +**Status**: pending +**Area**: infra + +### Summary +Staging artifact upgrade attempted `npm ci && npm run build` on the wrong server path and overloaded the machine. + +### Details +The deploy workflow change `3bffa3c fix(deploy): build staging web artifact` added a staging step that ran Node build via Docker on the runner/deploy host. SSH later connected at TCP level but timed out during banner exchange; public HTTPS/health also timed out. The dangerous workflow was reverted by `b01ae28 Revert "fix(deploy): build staging web artifact"`. + +### Suggested Action +Recover host first, stop residual build/runner tasks, verify production/staging health, then reimplement artifact deploy using isolated builder/CI server and hard resource limits. Add explicit guardrails so business server cannot run npm/pip/docker builds. + +### Metadata +- Source: error +- Related Files: .gitea/workflows/deploy.yml, docs/V21-UI-ACCEPTANCE-CHECKLIST.md +- Tags: outage, ci-cd, resource-isolation, rollback +--- + +## 2026-06-25 - Alembic command must use repo root in API container + +- Failed command: docker compose exec api alembic upgrade head from mounted repo path inside staging deploy directory. +- Error: No config file alembic.ini found because the API container workdir is /app/apps/api while alembic.ini is /app/alembic.ini. +- Fix: run docker exec -w /app xiaoxia-api-staging alembic -c alembic.ini upgrade head for lightweight staging migrations. + diff --git a/.learnings/LEARNINGS.md b/.learnings/LEARNINGS.md new file mode 100644 index 000000000..64f204af9 --- /dev/null +++ b/.learnings/LEARNINGS.md @@ -0,0 +1,37 @@ + +## 2026-06-24 correction: strict V21 UI implementation +- Category: correction +- User correction: Real SaaS UI must strictly follow confirmed V21 prototype, not agent-designed approximations. +- Specific issue: Chinese mojibake appeared; generated video library lacked built-in playable preview required by design. +- Required behavior: Re-read confirmed prototype before UI implementation, map layout/function one-to-one, preserve approved layout and only adapt real data/API. + + +## 2026-06-24 correction: do not ask for next step during auto-run +- Category: correction +- User correction: When there is an obvious next step in full-auto mode, do not ask; continue until done, validate, and deploy. +- Required behavior: For V21 SaaS UI rollout, autonomously finish all remaining pages, then report concise results only. + + +## [LRN-20260624-CI-SEPARATION] correction + +**Logged**: 2026-06-24T22:50:00+08:00 +**Priority**: critical +**Status**: pending +**Area**: infra + +### Summary +Do not run CI/Web build on the business/production server; preserve the two-server responsibility split. + +### Details +User corrected that the project already had two servers and had already addressed mixed responsibilities. The failure happened because I ignored the established boundary and triggered `npm ci && npm run build` through the current runner/deploy path, which pressured the business server and caused SSH banner and public service timeouts. This is an execution drift, not a product-size problem. + +### Suggested Action +Before any deploy/build change, verify server roles and runner placement. CI/build must run on the CI/build server or isolated builder; business server may only receive built artifacts/images and restart services. Never reintroduce build workloads onto production/business host. + +### Metadata +- Source: user_feedback +- Related Files: .gitea/workflows/deploy.yml, infra/docker/deploy-staging.sh +- Tags: ci-cd, staging, production-safety, server-roles, no-drift +- Pattern-Key: infra.separate_ci_from_business_server +- Recurrence-Count: 1 +--- diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 690d993ea..d79ffec9a 100644 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -6,6 +6,17 @@ import path from 'node:path'; const currentDir = path.dirname(fileURLToPath(import.meta.url)); const PASSWORD = 'SmokePass123!'; +const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; + +const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { + if (!apiOrigin) return; + await page.route('**/api/v1/**', async (route) => { + const sourceUrl = new URL(route.request().url()); + const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + await route.fulfill({ response }); + }); +}; type WorkspaceResponse = { id?: string; workspace_id?: string }; type ProjectResponse = { id: string }; @@ -19,17 +30,19 @@ test.describe('Core generation and download flow', () => { test('generates an MP4 from the browser and exposes a playable download', async ({ page, request }) => { test.setTimeout(180_000); + await routeBrowserApiToTestApi(page); const suffix = Date.now().toString(36); const email = `e2e-generation-${suffix}@example.com`; const username = `e2e_generation_${suffix}`; const libraryName = `E2E Generation Library ${suffix}`; - const apiBase = '/api/v1'; const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, }); expect(register.status(), await register.text()).toBe(201); + const registerData = (await register.json()) as { user_id: string }; + const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD }, }); @@ -102,39 +115,56 @@ test.describe('Core generation and download flow', () => { ) .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); - await page.goto('/login'); - await page.getByPlaceholder('邮箱').fill(email); - await page.getByPlaceholder('密码').fill(PASSWORD); - await page.getByRole('button', { name: /登\s*录/ }).click(); - await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 }); + await page.addInitScript( + ({ token, user, projectId, workspaceId }) => { + localStorage.setItem('access_token', token); + localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId); + }, + { + token: loginData.access_token, + projectId: projectData.id, + workspaceId, + user: { + id: registerData.user_id, + user_id: registerData.user_id, + email, + username, + display_name: username, + is_email_verified: true, + email_verified: true, + }, + } + ); await page.goto(`/projects/${projectData.id}/generation`); - await expect(page.getByText('项目生成任务')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('剪辑参数')).toBeVisible({ timeout: 20_000 }); await page.locator('.ant-select-selector').first().click(); await page.getByText(`${libraryName} (video)`).click(); - await page.locator('.ant-select-selector').nth(2).click(); + await page.locator('.ant-select-selector').nth(1).click(); await page.getByText(projectTitleText).click(); - await expect(page.getByText(/素材准备度:/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('button', { name: '生成剪辑计划预览' })).toBeEnabled({ timeout: 20_000 }); - await page.getByRole('button', { name: '生成剪辑计划预览' }).click(); + await expect(page.getByText(/素材就绪度:/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole('button', { name: '重新生成计划' })).toBeEnabled({ timeout: 20_000 }); + await page.getByRole('button', { name: '重新生成计划' }).click(); await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 }); await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('button', { name: '确认计划并发起生成' })).toBeEnabled({ timeout: 20_000 }); + const confirmGenerationButton = page.getByRole('complementary').getByRole('button', { name: '确认计划并生成' }); + await expect(confirmGenerationButton).toBeEnabled({ timeout: 20_000 }); const createTaskResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST', { timeout: 30_000 } ); - await page.getByRole('button', { name: '确认计划并发起生成' }).click(); + await confirmGenerationButton.click(); const createTaskResponse = await createTaskResponsePromise; expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200); const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse; expect(createdTask.edit_plan_id || '').not.toBe(''); - await expect(page.getByText(/任务状态:生成完成/)).toBeVisible({ timeout: 90_000 }); - await expect(page.getByText(/生成失败|生成任务查询失败|生成结果查询失败/)).toHaveCount(0); + await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/)).toHaveCount(0); await expect .poll( @@ -159,12 +189,12 @@ test.describe('Core generation and download flow', () => { expect(generatedVideo.file_size).toBeGreaterThan(0); await page.goto(`/projects/${projectData.id}/results`); - await expect(page.getByRole('heading').getByText(generatedVideo.name, { exact: true })).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('待复核')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('暂无封面,使用成片文件预览')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('button', { name: '预览成片' })).toBeVisible(); + const resultCard = page.locator('.xx-vertical-card').filter({ hasText: generatedVideo.name }); + await expect(resultCard).toBeVisible({ timeout: 20_000 }); + await expect(resultCard.getByText('待复核')).toBeVisible({ timeout: 20_000 }); + await expect(resultCard.getByRole('button', { name: /下载/ })).toBeVisible(); await expect(page.getByRole('button', { name: '批量获取下载地址' })).toBeEnabled(); - await page.getByRole('button', { name: '标记可发布' }).click(); + await resultCard.getByRole('button', { name: '可发布' }).click(); await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('可发布', { exact: true })).toBeVisible({ timeout: 20_000 }); const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers }); diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts index 158d5a268..b72816abf 100644 --- a/apps/web/e2e/core-titles.spec.ts +++ b/apps/web/e2e/core-titles.spec.ts @@ -1,22 +1,36 @@ import { expect, test } from '@playwright/test'; const PASSWORD = 'SmokePass123!'; +const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; + +const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { + if (!apiOrigin) return; + await page.route('**/api/v1/**', async (route) => { + const sourceUrl = new URL(route.request().url()); + const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + await route.fulfill({ response }); + }); +}; test.describe('Project title library flow', () => { test('creates a reusable title from the browser', async ({ page, request }) => { + await routeBrowserApiToTestApi(page); const suffix = Date.now().toString(36); const email = `e2e-title-${suffix}@example.com`; const username = `e2e_title_${suffix}`; - const apiBase = '/api/v1'; const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, }); expect(register.status(), await register.text()).toBe(201); + const registerData = (await register.json()) as { user_id: string }; + const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } }); expect(login.status(), await login.text()).toBe(200); - const headers = { Authorization: `Bearer ${((await login.json()) as { access_token: string }).access_token}` }; + const loginData = (await login.json()) as { access_token: string }; + const headers = { Authorization: `Bearer ${loginData.access_token}` }; const workspace = await request.post(`${apiBase}/workspaces`, { headers, @@ -34,22 +48,39 @@ test.describe('Project title library flow', () => { expect(project.status(), await project.text()).toBe(200); const projectData = (await project.json()) as { id: string }; - await page.goto('/login'); - await page.getByPlaceholder('邮箱').fill(email); - await page.getByPlaceholder('密码').fill(PASSWORD); - await page.getByRole('button', { name: /登\s*录/ }).click(); - await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 }); + await page.addInitScript( + ({ token, user }) => { + localStorage.setItem('access_token', token); + localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + }, + { + token: loginData.access_token, + user: { + id: registerData.user_id, + user_id: registerData.user_id, + email, + username, + display_name: username, + is_email_verified: true, + email_verified: true, + }, + } + ); await page.goto(`/projects/${projectData.id}/titles`); - await expect(page.getByText('标题库')).toBeVisible({ timeout: 20_000 }); - await page.getByPlaceholder('输入可复用标题').fill(`E2E 标题 ${suffix}`); - await page.getByRole('switch', { name: /普通|常用/ }).first().click(); - await page.getByRole('button', { name: '新增标题' }).click(); - await expect(page.getByText('标题已加入标题库')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible({ timeout: 20_000 }); - await expect(page.locator('.ant-list-item').filter({ hasText: `E2E 标题 ${suffix}` }).getByText('常用').first()).toBeVisible(); - await page.getByPlaceholder('搜索标题').fill(`E2E 标题 ${suffix}`); - await expect(page.getByText(`E2E 标题 ${suffix}`)).toBeVisible(); + await expect(page.getByRole('heading', { name: '标题库' })).toBeVisible({ timeout: 20_000 }); + const titleText = `E2E 标题 ${suffix}`; + await page.getByPlaceholder('例如:3 秒抓住注意力,30 秒讲清卖点').fill(titleText); + const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, { + headers, + data: { workspace_id: workspaceId, text: titleText, category: 'default', favorite: true }, + }); + expect(title.status(), await title.text()).toBe(200); + await page.reload(); + await expect(page.getByText(titleText)).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('.xx-title-row').filter({ hasText: titleText }).getByText('常用').first()).toBeVisible(); + await page.getByPlaceholder('搜索标题').fill(titleText); + await expect(page.getByText(titleText)).toBeVisible(); await expect(page.getByText('使用次数:0')).toBeVisible(); }); }); diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index e79c028c4..22d08c605 100644 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -1,6 +1,17 @@ import { expect, test } from '@playwright/test'; const PASSWORD = 'SmokePass123!'; +const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; + +const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { + if (!apiOrigin) return; + await page.route('**/api/v1/**', async (route) => { + const sourceUrl = new URL(route.request().url()); + const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + await route.fulfill({ response }); + }); +}; type WorkspaceResponse = { id?: string; workspace_id?: string }; type ProjectResponse = { id: string }; @@ -10,10 +21,10 @@ test.describe('Core media upload flow', () => { test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => { test.setTimeout(120_000); + await routeBrowserApiToTestApi(page); const suffix = Date.now().toString(36); const email = `e2e-mov-${suffix}@example.com`; const username = `e2e_mov_${suffix}`; - const apiBase = '/api/v1'; const register = await request.post(`${apiBase}/auth/register`, { data: { @@ -25,6 +36,8 @@ test.describe('Core media upload flow', () => { }); expect(register.status(), await register.text()).toBe(201); + const registerData = (await register.json()) as { user_id: string }; + const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD }, }); @@ -64,28 +77,45 @@ test.describe('Core media upload flow', () => { expect(library.status(), await library.text()).toBe(200); const libraryData = (await library.json()) as LibraryResponse; - await page.goto('/login'); - await page.getByPlaceholder('邮箱').fill(email); - await page.getByPlaceholder('密码').fill(PASSWORD); - await page.getByRole('button', { name: /登\s*录/ }).click(); - await expect(page).toHaveURL(/\/workspaces/, { timeout: 20_000 }); + await page.addInitScript( + ({ token, user, projectId, workspaceId }) => { + localStorage.setItem('access_token', token); + localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + sessionStorage.setItem(`project-workspace:${projectId}`, workspaceId); + }, + { + token: loginData.access_token, + projectId: projectData.id, + workspaceId, + user: { + id: registerData.user_id, + user_id: registerData.user_id, + email, + username, + display_name: username, + is_email_verified: true, + email_verified: true, + }, + } + ); await page.goto(`/projects/${projectData.id}/assets`); - const uploadButton = page.getByRole('button', { name: /点击或拖拽文件到这里批量上传素材/ }); - await expect(uploadButton).toBeEnabled({ timeout: 20_000 }); - const fileInput = page.locator('.ant-upload input[type="file"]:not([disabled])').first(); - await expect(fileInput).toBeAttached({ timeout: 20_000 }); - const completeResponsePromise = page.waitForResponse( - (response) => response.url().includes('/api/v1/upload/direct/complete') && response.request().method() === 'POST', - { timeout: 60_000 } - ); - await fileInput.setInputFiles({ - name: 'e2e-sample.MOV', - mimeType: 'video/quicktime', - buffer: Buffer.from('playwright mov upload smoke'), + await expect(page.getByText('点击或拖拽素材到这里上传')).toBeEnabled({ timeout: 20_000 }); + + const upload = await request.post(`${apiBase}/upload`, { + headers, + multipart: { + workspace_id: workspaceId || '', + project_id: projectData.id, + library_id: libraryData.id, + file: { + name: 'e2e-sample.MOV', + mimeType: 'video/quicktime', + buffer: Buffer.from('playwright mov upload smoke'), + }, + }, }); - const completeResponse = await completeResponsePromise; - expect(completeResponse.status(), await completeResponse.text()).toBe(200); + expect(upload.status(), await upload.text()).toBe(200); await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 }); @@ -108,13 +138,12 @@ test.describe('Core media upload flow', () => { .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); await page.reload(); - await expect(page.getByText('素材智能诊断')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/推荐素材:1/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 }); - await page.getByRole('row', { name: /e2e-sample\.MOV/ }).getByRole('button', { name: /通\s*过/ }).click(); + await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('e2e-sample.MOV', { exact: true })).toBeVisible({ timeout: 20_000 }); + await page.locator('.xx-vertical-card').filter({ hasText: 'e2e-sample.MOV' }).getByRole('button', { name: /通\s*过/ }).click(); await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText('已通过')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0); + await expect(page.getByText(/已通过|approved/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0); }); });