Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d6ce433d0 | |||
| 9b50e0696e | |||
| 9af73dcd86 | |||
| 6002f7a5e4 |
+4
-4
@@ -1,7 +1,7 @@
|
||||
"""add ai_tags to asset_atom_clips for #1970 fragment-level AI tagging
|
||||
|
||||
Revision ID: 081_atom_clip_ai_tags
|
||||
Revises: 080_edit_plan_clips_atom_clip_id
|
||||
Revision ID: 082_atom_clip_ai_tags
|
||||
Revises: 081_add_gpu_lipsync
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,8 @@ import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "081_atom_clip_ai_tags"
|
||||
down_revision = "080_edit_plan_clips_atom_clip_id"
|
||||
revision = "082_atom_clip_ai_tags"
|
||||
down_revision = "081_add_gpu_lipsync"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -184,13 +184,16 @@ async def report_result(
|
||||
pass
|
||||
# 其他情况:success=true 且无文件 → Worker 已自行 PUT 到预签名 URL,直接标记完成
|
||||
|
||||
task = svc.report_result(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
success=success,
|
||||
duration_seconds=duration_seconds,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
try:
|
||||
task = svc.report_result(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
success=success,
|
||||
duration_seconds=duration_seconds,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return GpuLipsyncResultResponse(
|
||||
ok=True,
|
||||
task_id=task.id,
|
||||
|
||||
Executable
+117
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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 ✓")
|
||||
})
|
||||
})
|
||||
|
||||
Executable
+105
@@ -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 ✓`)
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user