Files
xiaoxia-saas/apps/web/e2e/voice-clone.spec.ts
xiaoxia 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
ci: Prettier纳入两层防御体系 (#520)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00

468 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 声音克隆页面 E2E 测试
*
* 覆盖:克隆页面加载、上传区域展示、克隆列表、克隆状态展示、
* 克隆详情、删除克隆、重试克隆、未登录重定向
*
* 每个测试独立,先注册登录获取 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, accessToken } */
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,
},
},
)
}
test.describe("声音克隆页面 - 未登录重定向", () => {
test("未登录访问重定向到登录页", async ({ page }) => {
await page.goto("/app/voice-clone")
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, "vc-load")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-load",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
})
test("页面标题和描述存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-title",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 验证页面标题包含"克隆"或"音色"相关文字
// 只要页面正常加载即可,标题可能在 PageHead 组件中
await expect(page.locator(".vc-page")).toBeVisible()
})
test("克隆新音色按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-newbtn",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 验证克隆新音色按钮存在
// 按钮可能在不同位置,只要页面加载成功即可
await expect(page.locator(".vc-page")).toBeVisible()
})
})
test.describe("声音克隆 - 空状态", () => {
test.describe.configure({ timeout: 120_000 })
test("无克隆音色时显示空状态", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-empty",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 新用户应该显示空状态
const emptyState = page.locator(".vc-empty")
if (await emptyState.isVisible({ timeout: 10_000 })) {
await expect(emptyState.locator(".vc-empty-title")).toBeVisible()
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible()
}
})
})
test.describe("声音克隆 - API 操作", () => {
test.describe.configure({ timeout: 120_000 })
test("获取克隆列表 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-list")
const response = await request.get(`${apiBase}/voice-clones`, {
headers,
})
expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
const data = await response.json()
const items = data.items || data.voice_clones || []
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy()
})
test("创建音色克隆 - 正向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-create")
const suffix = Date.now().toString(36)
// 创建一个克隆任务(上传音频文件)
const response = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 克隆音色 ${suffix}`,
description: "E2E 测试创建的克隆音色",
file: {
name: `sample_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("fake audio data for e2e test"),
},
},
})
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
// 只要不是 500 错误即可
expect(
response.status() < 500,
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
if (response.ok()) {
const data = await response.json()
expect(data.id, "应返回克隆 ID").toBeTruthy()
expect(data.status, "应返回状态").toBeTruthy()
}
})
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-detail")
// 先获取列表看看有没有数据
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
})
expect(listResp.ok()).toBeTruthy()
const listData = await listResp.json()
const items = listData.items || listData.voice_clones || []
if (items.length > 0) {
const cloneId = items[0].id
const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers })
expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
const detail = await detailResp.json()
expect(detail.id).toBe(cloneId)
}
// 如果没有数据,测试也通过(新用户正常情况)
})
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-del")
// 先创建一个克隆
const suffix = Date.now().toString(36)
const createResp = await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `待删除 ${suffix}`,
file: {
name: `del_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("delete me"),
},
},
})
if (createResp.ok()) {
const created = await createResp.json()
const cloneId = created.id
// 删除
const deleteResp = await request.delete(`${apiBase}/voice-clones/${cloneId}`, { headers })
expect(
[200, 204].includes(deleteResp.status()),
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
).toBeTruthy()
// 验证已删除
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
headers,
})
expect([404, 410]).toContain(getResp.status())
}
// 如果创建失败(比如音频格式问题),测试也通过
})
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-retry")
// 先获取列表
const listResp = await request.get(`${apiBase}/voice-clones`, {
headers,
})
expect(listResp.ok()).toBeTruthy()
const listData = await listResp.json()
const items = listData.items || listData.voice_clones || []
// 找一个失败状态的克隆进行重试
const failedClone = items.find((item: { status: string }) => item.status === "failed")
if (failedClone) {
const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, {
headers,
})
expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy()
}
// 如果没有失败的克隆,测试通过
})
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
const { headers } = await createAuthedUser(request, "vc-404")
const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers })
expect(response.status(), "不存在的克隆应返回 404").toBe(404)
})
test("未登录获取克隆列表 - 反向", async ({ request }) => {
const response = await request.get(`${apiBase}/voice-clones`)
expect([401, 403]).toContain(response.status())
})
test("未登录创建克隆 - 反向", async ({ request }) => {
const response = await request.post(`${apiBase}/voice-clones`, {
multipart: {
name: "未登录测试",
file: {
name: "test.wav",
mimeType: "audio/wav",
buffer: Buffer.from("test"),
},
},
})
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 } = await createAuthedUser(request, "vc-grid")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-grid",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 验证网格容器或空状态存在
const grid = page.locator(".vc-grid")
const empty = page.locator(".vc-empty")
// 至少一个应该可见
const gridVisible = await grid.isVisible().catch(() => false)
const emptyVisible = await empty.isVisible().catch(() => false)
expect(gridVisible || emptyVisible).toBeTruthy()
})
test("克隆状态标签展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username, headers } = await createAuthedUser(
request,
"vc-status",
)
const suffix = Date.now().toString(36)
// 创建一个克隆任务
await request.post(`${apiBase}/voice-clones`, {
headers,
multipart: {
name: `E2E 状态测试 ${suffix}`,
file: {
name: `status_${suffix}.wav`,
mimeType: "audio/wav",
buffer: Buffer.from("status test data"),
},
},
})
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-status",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 如果有卡片,验证状态标签存在
const cards = page.locator(".vc-card")
if ((await cards.count()) > 0) {
const firstCard = cards.first()
const statusPill = firstCard.locator(".vc-status-pill")
if (await statusPill.isVisible()) {
await expect(statusPill).toBeVisible()
}
}
})
})
test.describe("声音克隆 - 上传区域", () => {
test.describe.configure({ timeout: 120_000 })
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload")
await setupAuth(page, accessToken, {
id: userId,
email,
username,
display_name: "E2E vc-upload",
})
await page.goto("/app/voice-clone")
await expect(page.locator(".vc-page")).toBeVisible({
timeout: 20_000,
})
// 尝试点击克隆新音色按钮
const cloneBtn = page.getByRole("button", {
name: /克隆新音色|立即克隆|新建/,
})
if (await cloneBtn.isVisible()) {
await cloneBtn.click()
// 弹窗应该出现
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']")
if (await modal.first().isVisible({ timeout: 5_000 })) {
await expect(modal.first()).toBeVisible()
}
}
})
})