0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
/**
|
||
* 剪辑策划页面 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<string, string>,
|
||
suffix: string,
|
||
): Promise<string> {
|
||
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()
|
||
})
|
||
})
|