ac5353b335
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m12s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m26s
CI/CD Pipeline / Integration Tests (push) Successful in 2m27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m49s
CI/CD Pipeline / Build Staging API Image (push) Successful in 2m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m20s
CI/CD Pipeline / Validate - Style (push) Successful in 4m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m16s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m3s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m20s
CI/CD Pipeline / Validate - Security (push) Successful in 6m28s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m56s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Unit Tests (push) Successful in 11m4s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 28m1s
Co-authored-by: frontend-dev <frontend-dev@xiaoxiajianji.com> Co-committed-by: frontend-dev <frontend-dev@xiaoxiajianji.com>
379 lines
16 KiB
TypeScript
Executable File
379 lines
16 KiB
TypeScript
Executable File
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<string> {
|
||
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")
|
||
// ── 页面标题 ─────────────────────────────────────────────────
|
||
// GenerateHeader: <h2><ThunderboltOutlined />智能剪辑</h2>
|
||
// SVG icon 可能干扰 role=heading 的 accessible name,用文本包含兜底
|
||
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
|
||
|
||
// ── Step 1:默认随机混剪选中,点下一步 ──────────────────────────
|
||
// h3 实际文案: "🎬 选择剪辑模式"(非 "选择模式"),用正则包含匹配
|
||
await expect(page.getByText(/选择剪辑模式/)).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()
|
||
|
||
// ── Step 3:填写标题 ──────────────────────────────────────────
|
||
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
|
||
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:确认生成 ──────────────────────────────────────────
|
||
// (#2024: Step4 不再显示"📋 生成配置"卡片,内容区仅显示进度/错误)
|
||
// 等待底部操作栏的「✨ 确认生成视频」按钮可见即可
|
||
const confirmBtn = page.getByRole("button", { name: /确认生成视频/ })
|
||
await expect(confirmBtn).toBeVisible({ timeout: 10000 })
|
||
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.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
|
||
|
||
// ── Step 1:切到叙事剪辑 → 下一步 ────────────────────────────
|
||
await expect(page.getByText(/选择剪辑模式/)).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()
|
||
|
||
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
|
||
// (#2048: PreviewCountModal 已移除)
|
||
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:确认生成 ──────────────────────────────────────────
|
||
// (#2024: Step4 不再显示"📋 生成配置"卡片)
|
||
const confirmBtn2 = page.getByRole("button", { name: /确认生成视频/ })
|
||
await expect(confirmBtn2).toBeVisible({ timeout: 10000 })
|
||
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 ✓")
|
||
})
|
||
})
|