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" const __dirname = 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) : "" /** 将浏览器侧 /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()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, }) await route.fulfill({ response }) }) } async function loginWithRetry( request: APIRequestContext, email: string, password: string, maxRetries = 2, ): Promise { for (let i = 0; i <= maxRetries; i++) { 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)) } 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}` } 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 const lib = await request.post(`${apiBase}/asset-libraries`, { headers: auth, data: { project_id: projectId, name: "Smoke", kind: "video" }, }) expect(lib.ok(), `create library: ${await lib.text()}`).toBeTruthy() const libraryId = (await lib.json()).id const samplePath = path.join(__dirname, "fixtures", "sample.mp4") const sampleBuf = fs.readFileSync(samplePath) const up = await request.post(`${apiBase}/upload`, { headers: auth, multipart: { project_id: projectId, library_id: libraryId, file: { name: "sample.mp4", mimeType: "video/mp4", buffer: sampleBuf, }, }, }) expect(up.ok(), `upload sample: ${await up.text()}`).toBeTruthy() const assetId = (await up.json()).asset_id 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 } } /** * #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 } }), ) }, token) await routeBrowserApiToTestApi(page) // ── 提前 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, }), }), ) await page.goto("/app/generate") await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ timeout: 30000, }) // ── Step 1:默认随机混剪选中,点下一步 ────────────────────────── await expect(page.getByText("选择模式", { exact: true })).toBeVisible() await expect(page.getByText("随机混剪")).toBeVisible() 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() // ── 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 3:填写标题 ────────────────────────────────────────── await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 }) const titleInput = page.getByPlaceholder("输入或从标题库选择") await expect(titleInput).toBeVisible({ timeout: 5000 }) await titleInput.fill(`测试随机剪辑 ${suffix}`) 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 }) 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 ✓") }) 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") 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 page.route( (url) => url.pathname.endsWith("/voice-clones"), (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }), ) // 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合成配音" }), }), ) await page.goto("/app/generate") await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({ timeout: 30000, }) // ── Step 1:切到叙事剪辑 → 下一步 ──────────────────────────── await expect(page.getByText("选择模式", { exact: true })).toBeVisible() await page.getByText("叙事剪辑").click() await page.getByRole("button", { name: /下一步/ }).click() // ── 文案选择弹窗:选第一条 → 确认 ───────────────────────────── await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 }) await page.getByText("测试带货文案").first().click() await page.getByRole("button", { name: "确认选择" }).click() await expect(page.getByText("📝 选择文案")).not.toBeVisible() // ── 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 ✓") }) })