/** * 剪辑策划页面 E2E 测试 * * 覆盖:页面加载、模板列表、模式切换、创建/编辑/删除剪辑计划、 * AI推荐片段、详情页、空状态、未登录重定向 * * 每个测试独立,先注册登录获取 auth token。 */ import { expect, test, type APIRequestContext, type Page, } from "@playwright/test"; const PASSWORD = "Test123456!"; 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: 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 }); }); }; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; } function uniqueUsername(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( request: APIRequestContext, email: string, password: string, maxRetries = 2, ) { 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})`); await new Promise((r) => setTimeout(r, 65000)); } return request.post(`${apiBase}/auth/login`, { data: { email, password }, }); } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, }); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); const regData = await reg.json(); const login = await loginWithRetry(request, email, PASSWORD); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); const loginData = await login.json(); return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, userId: regData.user_id, accessToken: loginData.access_token, }; } /** 设置页面认证状态(localStorage) */ async function setupAuth( page: Page, token: string, user: { id: string; email: string; username: string; display_name: string }, ) { await page.addInitScript( ({ token, user }) => { localStorage.setItem("access_token", token); localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), ); }, { token, user: { id: user.id, user_id: user.id, email: user.email, username: user.username, display_name: user.display_name, is_email_verified: true, email_verified: true, }, }, ); } /** 创建一个编辑模板并返回 id */ async function createEditingTemplate( request: APIRequestContext, headers: Record, suffix: string, ): Promise { const resp = await request.post(`${apiBase}/templates`, { headers, data: { name: `E2E 剪辑计划 ${suffix}`, mode: "pip", estimated_duration: 30, description: "E2E 测试创建的剪辑计划", segments: [ { segment_order: 1, duration_min: 5, duration_max: 15, material_type: "video", description: "开场片段", }, { segment_order: 2, duration_min: 10, duration_max: 20, material_type: "video", description: "主体内容", }, ], tags: ["e2e", "test"], category: "default", }, }); expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); return data.id; } test.describe("剪辑策划页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { await page.goto("/app/editing-planner"); await expect(page).toHaveURL(/\/login/); }); }); test.describe("剪辑策划页面 - 页面加载", () => { test.describe.configure({ timeout: 120_000 }); test("剪辑策划页面加载成功", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { accessToken, userId, email, username } = await createAuthedUser( request, "ep-load", ); await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-load", }); await page.goto("/app/editing-planner"); await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, }); // 验证顶栏存在 await expect(page.locator(".ep-top-bar")).toBeVisible(); // 验证模式栏存在 await expect(page.locator(".ep-mode-bar")).toBeVisible(); // 验证主体区域存在 await expect(page.locator(".ep-main-body")).toBeVisible(); }); test("剪辑模式切换正常显示", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { accessToken, userId, email, username } = await createAuthedUser( request, "ep-mode", ); await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-mode", }); await page.goto("/app/editing-planner"); await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, }); // 验证模式按钮存在(画中画、人物口播等) const modeBtns = page.locator(".ep-mode-btn"); await expect(modeBtns.first()).toBeVisible(); const modeCount = await modeBtns.count(); expect(modeCount).toBeGreaterThanOrEqual(2); }); }); test.describe("剪辑计划 - API 操作", () => { test.describe.configure({ timeout: 120_000 }); test("创建剪辑计划 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-create"); const suffix = Date.now().toString(36); const templateName = `E2E 创建测试 ${suffix}`; const response = await request.post(`${apiBase}/templates`, { headers, data: { name: templateName, mode: "pip", estimated_duration: 30, description: "测试创建剪辑计划", segments: [ { segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video", }, ], tags: ["e2e"], }, }); expect( response.ok(), `创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy(); const data = await response.json(); expect(data.id, "应返回模板 ID").toBeTruthy(); expect(data.name).toBe(templateName); expect(data.mode).toBe("pip"); }); test("列出剪辑计划 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-list"); const suffix = Date.now().toString(36); // 创建 2 个模板 await request.post(`${apiBase}/templates`, { headers, data: { name: `E2E 列表测试 A ${suffix}`, mode: "pip", estimated_duration: 30, segments: [ { segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video", }, ], }, }); await request.post(`${apiBase}/templates`, { headers, data: { name: `E2E 列表测试 B ${suffix}`, mode: "voice_over", estimated_duration: 60, segments: [ { segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video", }, ], }, }); const response = await request.get(`${apiBase}/templates`, { headers }); expect( response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`, ).toBeTruthy(); const data = await response.json(); const items = data.items || data.templates || []; expect(Array.isArray(items), "返回应为数组").toBeTruthy(); expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2); }); test("获取剪辑计划详情 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-detail"); const templateId = await createEditingTemplate( request, headers, Date.now().toString(36), ); const response = await request.get(`${apiBase}/templates/${templateId}`, { headers, }); expect( response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`, ).toBeTruthy(); const data = await response.json(); expect(data.id).toBe(templateId); expect(data.name).toBeTruthy(); expect(data.mode).toBeTruthy(); }); test("编辑剪辑计划 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-update"); const templateId = await createEditingTemplate( request, headers, Date.now().toString(36), ); const newName = `更新后的剪辑计划 ${Date.now()}`; const response = await request.patch(`${apiBase}/templates/${templateId}`, { headers, data: { name: newName, description: "更新后的描述", }, }); expect( response.ok(), `更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`, ).toBeTruthy(); const data = await response.json(); expect(data.name).toBe(newName); // 验证更新后的数据 const verify = await request.get(`${apiBase}/templates/${templateId}`, { headers, }); const verifyData = await verify.json(); expect(verifyData.name).toBe(newName); }); test("删除剪辑计划 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-delete"); const templateId = await createEditingTemplate( request, headers, Date.now().toString(36), ); // 删除 const deleteResp = await request.delete( `${apiBase}/templates/${templateId}`, { headers }, ); expect( [200, 204].includes(deleteResp.status()), `删除应返回 200 或 204,实际: ${deleteResp.status()}`, ).toBeTruthy(); // 验证已删除 const getResp = await request.get(`${apiBase}/templates/${templateId}`, { headers, }); expect([404, 410]).toContain(getResp.status()); }); test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-badmode"); const response = await request.post(`${apiBase}/templates`, { headers, data: { name: "无效 mode 测试", mode: "invalid_mode", estimated_duration: 30, segments: [], }, }); expect([400, 422]).toContain(response.status()); }); test("获取不存在的剪辑计划 - 反向", async ({ request }) => { const { headers } = await createAuthedUser(request, "ep-404"); const response = await request.get( `${apiBase}/templates/nonexistent-template-999`, { headers }, ); expect(response.status(), "不存在的模板应返回 404").toBe(404); }); test("未登录创建剪辑计划 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/templates`, { data: { name: "未登录测试", mode: "pip", estimated_duration: 30, segments: [], }, }); expect([401, 403]).toContain(response.status()); }); }); test.describe("剪辑策划页面 - 已模板数据加载", () => { test.describe.configure({ timeout: 120_000 }); test("已创建的模板在页面中显示", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { accessToken, userId, email, username, headers } = await createAuthedUser(request, "ep-data"); const suffix = Date.now().toString(36); await createEditingTemplate(request, headers, suffix); await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-data", }); await page.goto("/app/editing-planner"); await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, }); // 验证状态栏存在 await expect(page.locator(".ep-status-bar")).toBeVisible(); }); test("撤销/重做按钮存在", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { accessToken, userId, email, username } = await createAuthedUser( request, "ep-undo", ); await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-undo", }); await page.goto("/app/editing-planner"); await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, }); // 验证顶栏按钮存在(撤销、重做、保存、生成等) const topBarBtns = page.locator(".ep-top-bar-right .ep-btn"); await expect(topBarBtns.first()).toBeVisible(); const btnCount = await topBarBtns.count(); expect(btnCount).toBeGreaterThanOrEqual(2); }); test("生成按钮存在", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { accessToken, userId, email, username } = await createAuthedUser( request, "ep-gen", ); await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-gen", }); await page.goto("/app/editing-planner"); await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, }); // 验证主操作按钮存在 await expect(page.locator(".ep-btn-primary")).toBeVisible(); }); });