From cd7e39479a83254dd3ca4071b95e36836f5983e5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 19 Sep 2026 00:46:27 +0800 Subject: [PATCH] test(e2e): update smoke tests for #1970 smart-edit 5-step flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core-generation.spec.ts: rewrite for new flow (random + narrative paths) - random mode: Step1 default random → voice select modal (mock voice asset) → Step2 material → count modal → Step3 title → Step4 confirm → assert - narrative mode: Step1 switch to narrative → script select modal (mock list) → TTS modal (mock preset voices + synthesize + poll + save) → Step2 AI hint card visible → material → count → title → confirm → assert - Mock TTS endpoints to avoid real synthesis (avoids 10-30s wait per run) - core-douyin-extract.spec.ts: new test for #1972 douyin script extraction - open scripts page → click 从抖音提取 → paste share text → 开始提取 - mock extract API → assert 新建文案 modal opens with prefilled text - core-navigation.spec.ts: new smoke for nav routes - 9 core pages (dashboard/generate/assets/scripts/voices/products/history/tasks/points) - visits each, captures pageerrors, asserts key text visible --- apps/web/e2e/core-douyin-extract.spec.ts | 117 +++++ apps/web/e2e/core-generation.spec.ts | 565 +++++++++++++---------- apps/web/e2e/core-navigation.spec.ts | 105 +++++ 3 files changed, 549 insertions(+), 238 deletions(-) create mode 100755 apps/web/e2e/core-douyin-extract.spec.ts create mode 100755 apps/web/e2e/core-navigation.spec.ts diff --git a/apps/web/e2e/core-douyin-extract.spec.ts b/apps/web/e2e/core-douyin-extract.spec.ts new file mode 100755 index 000000000..206564ac1 --- /dev/null +++ b/apps/web/e2e/core-douyin-extract.spec.ts @@ -0,0 +1,117 @@ +import { expect, test, type APIRequestContext, type Page } 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) : "" + +async function routeBrowserApiToTestApi(page: 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 }) + }) +} + +async function loginWithRetry(request: APIRequestContext, email: string, password: string) { + for (let i = 0; i <= 2; i++) { + const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (r.status() !== 429) { + expect(r.ok(), `login: ${await r.text()}`).toBeTruthy() + return (await r.json()).access_token as string + } + console.log(`[douyin] 429 retry ${i + 1}/2`) + await new Promise((res) => setTimeout(res, 65000)) + } + throw new Error("Login retries exhausted") +} + +/** + * #1972 抖音文案提取冒烟 + * + * 路径:文案库页面 → 点「🎬 从抖音提取」→ 粘贴分享文案 → 点「开始提取」 + * → mock /api/v1/scripts/extract-from-douyin 返回稳定文案 → 断言「新建文案」弹窗中预填了非空文案 + */ +test.describe("Douyin Script Extraction (#1972)", () => { + test("extract flow: open modal, paste link, text prefilled in create modal", async ({ + page, + request, + }) => { + test.setTimeout(180_000) + await page.setViewportSize({ width: 1440, height: 900 }) + + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-douyin-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_dy_${suffix}` }, + }) + const token = await loginWithRetry(request, email, PASSWORD) + const authHeader = { Authorization: `Bearer ${token}` } + + const proj = await request.post(`${apiBase}/projects`, { + headers: authHeader, + data: { name: `Smoke Douyin ${suffix}` }, + }) + const projectId = (await proj.json()).id ?? (await proj.json()).project_id + await request.post(`${apiBase}/video-libraries`, { + headers: authHeader, + data: { project_id: projectId, name: "Smoke" }, + }) + + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, token) + await routeBrowserApiToTestApi(page) + + // Mock 抖音提取接口返回稳定文案 + const extractedText = "大家好,今天给大家推荐一款超好用的产品,性价比非常高,快来看看吧!" + await page.route("**/api/v1/scripts/extract-from-douyin", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ text: extractedText, duration_seconds: 15 }), + }), + ) + // 文案列表空态 + await page.route( + (url) => url.pathname.endsWith("/scripts") && !url.pathname.includes("extract-from-douyin"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [], total: 0, page: 1, page_size: 20 }), + }), + ) + + await page.goto("/app/scripts") + // 文案库页面加载 + await expect(page.getByText(/文案库|文案/).first()).toBeVisible({ timeout: 30000 }) + + // 点「🎬 从抖音提取」按钮 + await page.getByRole("button", { name: /从抖音提取/ }).click() + await expect(page.getByText("从抖音视频提取文案")).toBeVisible({ timeout: 5000 }) + + // 在 TextArea 粘贴"抖音分享文案" + const textarea = page.locator(".ant-modal textarea").first() + await expect(textarea).toBeVisible() + await textarea.fill("8.88 复制打开抖音,看看【推荐视频】https://v.douyin.com/abcDEF/") + + // 点「开始提取」 + await page.getByRole("button", { name: "开始提取" }).click() + await expect(page.getByText(/提取中/)).toBeVisible({ timeout: 3000 }) + + // 等待抖音弹窗关闭,「新建文案」弹窗打开并预填提取文案 + await expect(page.getByText("从抖音视频提取文案")).not.toBeVisible({ timeout: 15000 }) + await expect(page.getByText("新建文案")).toBeVisible({ timeout: 5000 }) + const createTextarea = page.locator(".ant-modal textarea").first() + await expect(createTextarea).toBeVisible() + await expect(createTextarea).toHaveValue(new RegExp(extractedText.slice(0, 10))) + console.log("[douyin] Extraction flow completed ✓, text length:", extractedText.length) + }) +}) diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index da26cb324..8c2269d06 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type APIRequestContext } from "@playwright/test" +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" import * as fs from "node:fs" import * as path from "node:path" import { fileURLToPath } from "node:url" @@ -8,7 +8,8 @@ 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) => { +/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */ +async function routeBrowserApiToTestApi(page: Page) { if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()) @@ -24,276 +25,364 @@ async function loginWithRetry( email: string, password: string, maxRetries = 2, -) { +): Promise { for (let i = 0; i <= maxRetries; i++) { - const response = await request.post(`${apiBase}/auth/login`, { - data: { email, password }, - }) - if (response.status() !== 429) return response - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + const resp = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (resp.status() !== 429) { + expect(resp.ok(), `Login should succeed: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.access_token + } + console.log(`[login] 429 rate limited, retry ${i + 1}/${maxRetries} after 65s`) await new Promise((r) => setTimeout(r, 65000)) } - return request.post(`${apiBase}/auth/login`, { - data: { email, password }, + throw new Error("Login failed after retries") +} + +/** + * 注册新用户 + 建项目/视频库/上传 sample.mp4,等素材 ready。返回 { token, projectId, libraryId, assetId }。 + */ +async function setupFreshUser( + request: APIRequestContext, + label: string, +): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> { + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-${label}-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_${label}_${suffix}` }, }) -} + const token = await loginWithRetry(request, email, PASSWORD) + const auth = { Authorization: `Bearer ${token}` } -type ProjectResponse = { id: string } -type LibraryResponse = { id: string } -type AssetListResponse = { - items: Array<{ - id: string - name: string - status: string - }> -} + const proj = await request.post(`${apiBase}/projects`, { + headers: auth, + data: { name: `Smoke ${label} ${suffix}` }, + }) + expect(proj.ok(), `create project: ${await proj.text()}`).toBeTruthy() + const projectId = (await proj.json()).id ?? (await proj.json()).project_id -test.describe("Core generation flow", () => { - test.describe.configure({ timeout: 360_000 }) + const lib = await request.post(`${apiBase}/video-libraries`, { + headers: auth, + data: { project_id: projectId, name: "Smoke" }, + }) + expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy() + const libraryId = (await lib.json()).id - test("walks through wizard with count modal and starts generation", async ({ page, request }) => { - test.setTimeout(360_000) - - await routeBrowserApiToTestApi(page) - const suffix = Date.now().toString(36) - const email = `e2e-gen-${suffix}@example.com` - const username = `e2e_gen_${suffix}` - const libraryName = `E2E Gen Lib ${suffix}` - - // Register - const register = await request.post(`${apiBase}/auth/register`, { - data: { email, username, password: PASSWORD, display_name: username }, - }) - expect(register.status()).toBe(201) - const registerData = (await register.json()) as { user_id: string } - - // Login - const login = await loginWithRetry(request, email, PASSWORD) - expect(login.status()).toBe(200) - const loginData = (await login.json()) as { access_token: string } - const headers = { Authorization: `Bearer ${loginData.access_token}` } - - // Create project - const project = await request.post(`${apiBase}/projects`, { - headers, - data: { name: `E2E Gen Proj ${suffix}` }, - }) - expect(project.status()).toBe(200) - const projectData = (await project.json()) as ProjectResponse - - // Create asset library - const library = await request.post(`${apiBase}/asset-libraries`, { - headers, - data: { project_id: projectData.id, name: libraryName, kind: "video" }, - }) - expect(library.status()).toBe(200) - const libraryData = (await library.json()) as LibraryResponse - - // Upload source video - const sourceFileName = "e2e-gen-source.mp4" - const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4") - const sampleVideoBuffer = fs.readFileSync(sampleVideoPath) - const upload = await request.post(`${apiBase}/upload`, { - headers, - multipart: { - project_id: projectData.id, - library_id: libraryData.id, - file: { - name: sourceFileName, - mimeType: "video/mp4", - buffer: sampleVideoBuffer, - }, + const sig = await request.post(`${apiBase}/assets/sign`, { + headers: auth, + data: { + filename: "sample.mp4", + content_type: "video/mp4", + library_id: libraryId, + source: "local", + }, + }) + expect(sig.ok(), `sign upload: ${await sig.text()}`).toBeTruthy() + const signData = await sig.json() + const assetId = signData.asset_id + const samplePath = path.join(__dirname, "fixtures", "sample.mp4") + await request.put(signData.upload_url || signData.url, { + multipart: { + file: { + name: "sample.mp4", + mimeType: "video/mp4", + buffer: fs.readFileSync(samplePath), }, - }) - expect(upload.status()).toBe(200) + }, + }) + await expect + .poll( + async () => { + const r = await request.get(`${apiBase}/assets/${assetId}`, { headers: auth }) + return r.ok() ? (await r.json()).status : "pending" + }, + { timeout: 90_000, intervals: [3000, 3000, 5000] }, + ) + .toBe("ready") + return { token, libraryId, assetId, suffix } +} - // Wait for asset to be ready - await expect - .poll( - async () => { - const assets = await request.get(`${apiBase}/assets`, { - headers, - params: { library_id: libraryData.id }, - }) - if (!assets.ok()) return `http_${assets.status()}` - const data = (await assets.json()) as AssetListResponse - const asset = data.items.find((a) => a.name === sourceFileName) - if (!asset) return "missing" - return asset.status - }, - { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, +/** + * #1970 智能剪辑核心冒烟(新 5 步向导) + * + * 新流程:选择模式 → 选择素材 → 选择标题 → 确认生成 → 选择封面 + * + * 两条路径: + * 1) 随机混剪(默认)→ Step1 下一步 → 配音选择弹窗 → Step2 选素材 → 数量弹窗 + * → Step3 标题 → Step4 确认生成 → 断言任务创建 + * 2) 叙事剪辑 → Step1 切模式 → 下一步 → 文案选择弹窗 → TTS 弹窗选音色(mock 合成) + * → Step2 AI 提示卡可见 + 选素材 → 数量弹窗 → Step3 标题 → Step4 确认生成 + * → 断言任务创建 + */ +test.describe("Core Smart-Edit Flow (#1970)", () => { + test("random mode: 5-step wizard creates generation task", async ({ page, request }) => { + test.setTimeout(600_000) + await page.setViewportSize({ width: 1440, height: 1000 }) + const { token, suffix } = await setupFreshUser(request, "random") + const authHeader = { Authorization: `Bearer ${token}` } + + // 确保默认模板存在(智能剪辑页依赖模板) + const tmpls = await request.get(`${apiBase}/templates`, { headers: authHeader }) + const tmplsJson = await tmpls.json() + const templates = Array.isArray(tmplsJson) + ? tmplsJson + : Array.isArray(tmplsJson.items) + ? tmplsJson.items + : [] + expect(templates.length).toBeGreaterThan(0) + + // 注入登录态 + 路由 API + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), ) - .toBe("ready") + }, token) + await routeBrowserApiToTestApi(page) - // GET /templates auto-creates a default template for new users - const templatesResp = await request.get(`${apiBase}/templates`, { headers }) - expect(templatesResp.status(), await templatesResp.text()).toBe(200) - const templatesData = (await templatesResp.json()) as { - items: Array<{ id: string }> - } - expect(Array.isArray(templatesData.items)).toBe(true) - expect(templatesData.items.length).toBeGreaterThan(0) - const templateId = templatesData.items[0].id - expect(templateId).toBeTruthy() - - // Set auth in localStorage - await page.addInitScript( - ({ token, user }) => { - localStorage.setItem("access_token", token) - localStorage.setItem( - "auth-storage", - JSON.stringify({ - state: { user, isAuthenticated: true }, - version: 0, + // ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ── + await page.route( + (url) => url.pathname.endsWith("/assets") && url.searchParams.get("kind") === "voice", + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: `asset-voice-${suffix}`, + name: "测试配音.mp3", + file_url: "data:audio/mpeg;base64,", + duration: 10, + file_size: 1024, + kind: "voice", + status: "ready", + }, + ], + total: 1, }), - ) - }, - { - 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, - }, - }, + }), ) - // Navigate to generate page await page.goto("/app/generate") await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ - timeout: 20_000, + timeout: 30000, }) - // 5步向导:素材(1)→配音(2)→标题(3)→确认生成(4)→封面(5) + // ── Step 1:默认随机混剪选中,点下一步 ────────────────────────── + await expect(page.getByText("选择模式", { exact: true })).toBeVisible() + await expect(page.getByText("随机混剪")).toBeVisible() + await page.getByRole("button", { name: /下一步/ }).click() - // ── Step 1: 素材选择 ── - await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() - const librarySelect = page.locator("select").first() - await librarySelect.selectOption({ label: libraryName }) - const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName }) - await expect(materialCard).toBeVisible({ timeout: 10_000 }) - await materialCard.click({ position: { x: 15, y: 15 } }) - await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 }) - await page.getByRole("button", { name: "下一步" }).click() + // ── 配音选择弹窗:选第一个配音 → 确认 ───────────────────────── + await expect(page.getByText("🎙️ 选择配音")).toBeVisible({ timeout: 5000 }) + await page.getByText("测试配音.mp3").first().click() + await page.getByRole("button", { name: "确认选择" }).click() + await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible() - // ── 数量弹窗(PreviewCountModal) ── - await expect(page.getByRole("heading", { name: "要生成几个视频?" })).toBeVisible({ - timeout: 5_000, - }) + // ── Step 2:选择素材 ────────────────────────────────────────── + await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 }) + await page.getByTestId("material-card").first().click() + await page.getByRole("button", { name: /下一步/ }).click() + + // ── 数量弹窗:默认 1 个 → 确认 ─────────────────────────────── + await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 }) await page.getByRole("button", { name: "生成 1 个视频" }).click() - // ── Step 2: 配音(新注册用户无配音素材,跳过) ── - await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 }) - await page.getByRole("button", { name: "下一步" }).click() - - // ── Step 3: 标题设置 ── - await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 }) - await page.waitForTimeout(2000) - - const titleInput = page.locator(".ant-select-auto-complete input") + // ── Step 3:填写标题 ────────────────────────────────────────── + await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 }) + const titleInput = page.getByPlaceholder("输入或从标题库选择") await expect(titleInput).toBeVisible({ timeout: 5000 }) - await titleInput.fill(`E2E Test ${suffix}`) + await titleInput.fill(`测试随机剪辑 ${suffix}`) + await page.getByRole("button", { name: /下一步/ }).click() - // Step 3 底部是「下一步 →」,点击进入 Step 4(确认生成) - await page.getByRole("button", { name: "下一步" }).click() + // ── Step 4:确认生成 ────────────────────────────────────────── + await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 }) + await expect(page.getByText("随机混剪")).toBeVisible() + const confirmBtn = page.getByRole("button", { name: /确认生成视频/ }) + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }) - // ── Step 4: 确认生成 ── - // 等待实时预览就绪(占位消失) - await page - .getByText("准备预览素材") - .waitFor({ state: "detached", timeout: 30_000 }) - .catch(() => {}) + const createTask = page.waitForResponse( + (r) => r.url().includes("/generation/tasks") && r.request().method() === "POST", + { timeout: 30000 }, + ) + await confirmBtn.click() + const taskResp = await createTask + expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy() + const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id + console.log("[random] Generation task created:", taskId) + await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 }) + console.log("[random] Wizard flow completed ✓") + }) - // Step 4 底部是「✨ 确认生成视频」 - const confirmBtn = page.locator(".xx-step-actions .xx-btn-primary").first() - await expect(confirmBtn).toBeVisible({ timeout: 15_000 }) + test("narrative mode: select script + mock TTS, create generation task", async ({ + page, + request, + }) => { + test.setTimeout(600_000) + await page.setViewportSize({ width: 1440, height: 1000 }) + const { token, suffix } = await setupFreshUser(request, "narrative") - // 先挂 API 监听再点击 - const generatePromise = page.waitForResponse( - (response) => { - const url = response.url() - const path = new URL(url).pathname - return response.request().method() === "POST" && path.endsWith("/generation/tasks") - }, - { timeout: 30_000 }, + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, token) + await routeBrowserApiToTestApi(page) + + // ── Mock 文案列表、音色、TTS 合成(避免真实合成) ────────────── + const mockScriptId = `script-mock-${suffix}` + const mockVoiceId = `preset-voice-${suffix}` + const mockJobId = `tts-job-${suffix}` + + // 文案列表(ScriptSelectModal 查询 /scripts) + await page.route("**/api/v1/scripts**", (route) => { + const url = new URL(route.request().url()) + if (url.pathname.includes("/extract-from-douyin")) { + route.continue() + return + } + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: mockScriptId, + title: "测试带货文案", + content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。", + tags: ["带货"], + title_category: "daihuo", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + page: 1, + page_size: 200, + }), + }) + }) + + // 预设音色(TtsVoiceModal 查询 GET /voices/presets) + await page.route("**/api/v1/voices/presets**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + voice_id: mockVoiceId, + name: "晓晓(女声)", + description: "温柔女声", + gender: "female", + language: "zh-CN", + preview_url: null, + tags: ["温柔"], + }, + ], + total: 1, + }), + }), ) - await confirmBtn.click() + // 克隆音色:空列表 + await page.route( + (url) => url.pathname.endsWith("/voice-clones"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }), + ) - // 验证生成 API 被调用 - const genResp = await generatePromise.catch(() => null) - if (!genResp) { - // staging 预览未就绪导致按钮校验拦截,未触发 API — 向导导航仍通过 - console.log( - "[E2E] Generation API not triggered (preview not ready) — wizard navigation verified", - ) - } else if (genResp.ok()) { - const genData = (await genResp.json()) as { - items: Array<{ id: string; status: string }> - total: number - } - expect(genData.items.length).toBeGreaterThan(0) + // TTS 合成:直接返回 completed 任务 + await page.route("**/api/v1/tts/synthesize", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ job_id: mockJobId, status: "queued" }), + }), + ) + await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + job_id: mockJobId, + status: "completed", + progress: 100, + audio_url: "data:audio/mpeg;base64,", + duration: 5, + }), + }), + ) + await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }), + }), + ) - // race:渲染完成 vs 生成失败/超时 - const downloadReady = page - .getByText("视频生成完成") - .isVisible({ timeout: 180_000 }) - .then((v) => (v ? "completed" : null)) - const generationFailed = page - .getByText(/生成失败|重新生成/) - .isVisible({ timeout: 180_000 }) - .then((v) => (v ? "failed" : null)) - - const outcome = await Promise.any([downloadReady, generationFailed]).catch(() => "timeout") - - if (outcome === "completed") { - await page.getByRole("button", { name: /下一步:选择封面/ }).click() - await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ - timeout: 30_000, - }) - } else { - console.log(`[E2E] Video rendering ${outcome} on staging — wizard flow verified`) - } - } else { - console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) - } - - // 验证成品库页面加载 - await page.goto("/app/products") - await expect(page).toHaveURL(/\/app\/products/) - await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000 }) - - await page.unrouteAll({ behavior: "ignoreErrors" }) - }) - - test("generation task API creates and lists tasks", async ({ request }) => { - const suffix = Date.now().toString(36) - const email = `e2e-gen-api-${suffix}@example.com` - const username = `e2e_gen_api_${suffix}` - - const register = await request.post(`${apiBase}/auth/register`, { - data: { email, username, password: PASSWORD, display_name: username }, + await page.goto("/app/generate") + await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ + timeout: 30000, }) - expect(register.status()).toBe(201) - const login = await loginWithRetry(request, email, PASSWORD) - expect(login.status()).toBe(200) - const loginData = (await login.json()) as { access_token: string } - const headers = { Authorization: `Bearer ${loginData.access_token}` } + // ── Step 1:切到叙事剪辑 → 下一步 ──────────────────────────── + await expect(page.getByText("选择模式", { exact: true })).toBeVisible() + await page.getByText("叙事剪辑").click() + await page.getByRole("button", { name: /下一步/ }).click() - const project = await request.post(`${apiBase}/projects`, { - headers, - data: { name: `E2E API Proj ${suffix}` }, - }) - expect(project.status()).toBe(200) + // ── 文案选择弹窗:选第一条 → 确认 ───────────────────────────── + await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 }) + await page.getByText("测试带货文案").first().click() + await page.getByRole("button", { name: "确认选择" }).click() + await expect(page.getByText("📝 选择文案")).not.toBeVisible() - const tasks = await request.get(`${apiBase}/tasks`, { headers }) - expect(tasks.status()).toBe(200) - const tasksData = await tasks.json() - expect(Array.isArray(tasksData.items)).toBe(true) + // ── TTS 音色弹窗:选系统音色 → 合成 ───────────────────────── + await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 }) + await page.getByText("晓晓(女声)").first().click() + await page.getByRole("button", { name: "🎧 合成配音" }).click() + await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 }) + + // ── Step 2:AI 匹配提示卡可见 + 选素材 ──────────────────────── + await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 }) + await expect(page.getByText(/AI智能匹配/)).toBeVisible() + await page.getByTestId("material-card").first().click() + await page.getByRole("button", { name: /下一步/ }).click() + + // ── 数量弹窗 ───────────────────────────────────────────────── + await expect(page.getByText("要生成几个视频?")).toBeVisible({ timeout: 5000 }) + await page.getByRole("button", { name: "生成 1 个视频" }).click() + + // ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─ + await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 }) + const titleInput2 = page.getByPlaceholder("输入或从标题库选择") + await expect(titleInput2).toBeVisible({ timeout: 5000 }) + await titleInput2.fill(`测试叙事剪辑 ${suffix}`) + await page.getByRole("button", { name: /下一步/ }).click() + + // ── Step 4:确认生成 ────────────────────────────────────────── + await expect(page.getByText("📋 生成配置")).toBeVisible({ timeout: 10000 }) + await expect(page.getByText("叙事剪辑")).toBeVisible() + const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ }) + await expect(confirmBtn2).toBeEnabled({ timeout: 5000 }) + + const createTask2 = page.waitForResponse( + (r) => r.url().includes("/generation/tasks") && r.request().method() === "POST", + { timeout: 30000 }, + ) + await confirmBtn2.click() + const taskResp2 = await createTask2 + expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy() + console.log("[narrative] Generation task created:", (await taskResp2.json()).id) + await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 }) + console.log("[narrative] Wizard flow completed ✓") }) }) diff --git a/apps/web/e2e/core-navigation.spec.ts b/apps/web/e2e/core-navigation.spec.ts new file mode 100755 index 000000000..bdd19d6f9 --- /dev/null +++ b/apps/web/e2e/core-navigation.spec.ts @@ -0,0 +1,105 @@ +import { expect, test, type APIRequestContext, type Page } 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) : "" + +async function routeBrowserApiToTestApi(page: 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 }) + }) +} + +async function loginWithRetry(request: APIRequestContext, email: string, password: string) { + for (let i = 0; i <= 2; i++) { + const r = await request.post(`${apiBase}/auth/login`, { data: { email, password } }) + if (r.status() !== 429) { + expect(r.ok(), `login: ${await r.text()}`).toBeTruthy() + return (await r.json()).access_token as string + } + console.log(`[nav] 429 retry ${i + 1}/2`) + await new Promise((res) => setTimeout(res, 65000)) + } + throw new Error("Login retries exhausted") +} + +/** + * 核心页面导航冒烟:侧边栏主要入口能访问、文案库/配音库页面能正常加载(不出白屏/无致命 js error) + */ +test.describe("Core Navigation", () => { + let authToken: string + + test.beforeAll(async ({ request }) => { + const suffix = Math.random().toString(36).slice(2, 8) + const email = `e2e-nav-${suffix}@example.com` + await request.post(`${apiBase}/auth/register`, { + data: { email, password: PASSWORD, username: `e2e_nav_${suffix}` }, + }) + authToken = await loginWithRetry(request, email, PASSWORD) + const authHeader = { Authorization: `Bearer ${authToken}` } + const proj = await request.post(`${apiBase}/projects`, { + headers: authHeader, + data: { name: `Smoke Nav ${suffix}` }, + }) + if (proj.ok()) { + const projectId = (await proj.json()).id ?? (await proj.json()).project_id + await request.post(`${apiBase}/video-libraries`, { + headers: authHeader, + data: { project_id: projectId, name: "Nav Lib" }, + }) + } + }) + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.addInitScript((t: string) => { + window.localStorage.setItem("access_token", t) + window.localStorage.setItem( + "auth-storage", + JSON.stringify({ state: { token: t, user: null } }), + ) + }, authToken) + await routeBrowserApiToTestApi(page) + }) + + const navCases = [ + { path: "/app/dashboard", marker: /概览|工作台|最近/i, name: "概览" }, + { path: "/app/generate", marker: /智能剪辑|剪辑/, name: "智能剪辑" }, + { path: "/app/assets", marker: /视频库|素材/, name: "视频库" }, + { path: "/app/scripts", marker: /文案/, name: "文案库" }, + { path: "/app/voices", marker: /配音|我的音色|配音库/, name: "配音库" }, + { path: "/app/products", marker: /成品|作品/, name: "成品库" }, + { path: "/app/history", marker: /历史|任务/, name: "任务历史" }, + { path: "/app/tasks", marker: /任务中心|任务列表/, name: "任务中心" }, + { path: "/app/points", marker: /积分|我的积分/, name: "积分中心" }, + ] + + for (const c of navCases) { + test(`visit ${c.name} (${c.path}) loads without fatal pageerror`, async ({ page }) => { + const errors: Error[] = [] + page.on("pageerror", (e) => errors.push(e)) + await page.goto(c.path) + await expect(page.locator("body")).not.toBeEmpty({ timeout: 20000 }) + // 过滤掉常见第三方/非致命错误 + const fatal = errors.filter( + (e) => + !/ResizeObserver|Loading chunk|network error|Failed to fetch|chunkLoadError/i.test( + e.message, + ), + ) + expect(fatal, `${c.name} pageerrors: ${fatal.map((e) => e.message).join("; ")}`).toHaveLength( + 0, + ) + await expect( + page.getByText(c.marker).first(), + `${c.name} should show relevant text`, + ).toBeVisible({ timeout: 15000 }) + console.log(`[nav] ${c.name} loaded ✓`) + }) + } +})