From cd5323463abaeb014981dc20fb99a8562651311f Mon Sep 17 00:00:00 2001 From: leiyun Date: Sat, 22 Aug 2026 10:26:44 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E4=BA=BA=E8=B5=84=E6=BA=90=E4=B8=8E=E5=A3=B0=E9=9F=B3=E5=85=8B?= =?UTF-8?q?=E9=9A=86=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + .gitignore | 1 + playwright.config.ts | 9 +- scripts/generate-report.mjs | 5 +- tests/agent-public-lifecycle.spec.ts | 2 + tests/avatar-capabilities-ui.spec.ts | 237 +++++-- tests/business-api-lifecycle.spec.ts | 2 + tests/helpers.ts | 2 +- tests/prototype-sync-regression.spec.ts | 241 ++++++++ tests/sidebar-bootstrap-regression.spec.ts | 161 +++++ tests/zipvoice-cloning-ui.spec.ts | 272 ++++++++ tests/zipvoice-real-lifecycle.spec.ts | 686 +++++++++++++++++++++ 12 files changed, 1571 insertions(+), 51 deletions(-) create mode 100644 tests/prototype-sync-regression.spec.ts create mode 100644 tests/sidebar-bootstrap-regression.spec.ts create mode 100644 tests/zipvoice-cloning-ui.spec.ts create mode 100644 tests/zipvoice-real-lifecycle.spec.ts diff --git a/.env.example b/.env.example index d3ecbcb..80ded79 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ E2E_ACCOUNT_FILE=../账号.MD # 可选:用同一隔离全栈只复测指定文件,多个文件用英文逗号分隔。 # APE2E_TEST_FILES=tests/operations-ui-lifecycle.spec.ts +# 可选:真实数字人 Viewer 使用 H.264 素材;本机 Edge/Chrome 支持该编码, +# Playwright 自带 Chromium 不含 H.264。验收数字人播放与录制时建议使用 Edge。 +# E2E_BROWSER_CHANNEL=msedge + # 可选:允许执行依赖外部数字人、FFmpeg 或安全测试地址的条件用例。 # E2E_SAFE_TOOL_URL=https://example.invalid # E2E_ALLOW_EXTERNAL_MEDIA=false diff --git a/.gitignore b/.gitignore index 53449bf..a145aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .env artifacts/runs/ +artifacts/deploy-smoke/ playwright-report/ test-results/ diff --git a/playwright.config.ts b/playwright.config.ts index 22f9a32..07c1ef2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,6 +23,7 @@ const safeRunId = (process.env.APE2E_RUN_ID || 'manual') .slice(0, 80) || 'manual' const runDirectory = path.resolve('artifacts', 'runs', safeRunId) const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:8003').replace(/\/$/, '') +const browserChannel = process.env.E2E_BROWSER_CHANNEL?.trim() export default defineConfig({ testDir: './tests', @@ -49,5 +50,11 @@ export default defineConfig({ trace: 'off', video: 'off', }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + projects: [{ + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(browserChannel ? { channel: browserChannel } : {}), + }, + }], }) diff --git a/scripts/generate-report.mjs b/scripts/generate-report.mjs index 39743ec..d98847f 100644 --- a/scripts/generate-report.mjs +++ b/scripts/generate-report.mjs @@ -87,12 +87,15 @@ const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots')) ? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort() : [] const now = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'long', timeZone: 'Asia/Shanghai' }).format(new Date()) +const browserLabel = process.env.E2E_BROWSER_CHANNEL?.trim() + ? `Playwright ${process.env.E2E_BROWSER_CHANNEL.trim()}` + : 'Playwright Chromium' const lines = [ '# AI Person 全量 E2E 测试报告', '', `- 批次:\`${runId}\``, `- 生成时间:${now}`, `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``, - '- 浏览器:Playwright Chromium(单 worker,真实 API/数据库;不使用业务 Mock)', + `- 浏览器:${browserLabel}(单 worker,真实 API/数据库;不使用业务 Mock)`, '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '', '## 结果汇总', '', '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |', diff --git a/tests/agent-public-lifecycle.spec.ts b/tests/agent-public-lifecycle.spec.ts index 49613cb..6e8e206 100644 --- a/tests/agent-public-lifecycle.spec.ts +++ b/tests/agent-public-lifecycle.spec.ts @@ -245,6 +245,8 @@ test('数字形象、七区配置、发布、公开问答、反馈、停用与 headers, multipart: { name: `${runPrefix}-维修教员形象`, + specialty: '机械维修教学', + gender: '男', resize: 'true', matting: 'off', file: { diff --git a/tests/avatar-capabilities-ui.spec.ts b/tests/avatar-capabilities-ui.spec.ts index 347402f..a402e06 100644 --- a/tests/avatar-capabilities-ui.spec.ts +++ b/tests/avatar-capabilities-ui.spec.ts @@ -8,7 +8,16 @@ import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from '. import { authHeaders, envelopeData, loginAsAdmin } from './helpers' test.describe.configure({ mode: 'serial' }) -test.use({ trace: 'off', video: 'off' }) +test.use({ + trace: 'off', + video: 'off', + // Viewer uses WebGL. Playwright's bundled headless Chromium requires this + // explicit opt-in for trusted local/shared test services. + launchOptions: { + executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + args: ['--enable-unsafe-swiftshader'], + }, +}) type JsonRecord = Record type Headers = Record @@ -204,6 +213,24 @@ function avatarCard(page: Page, name: string) { return page.locator('.avatar-card').filter({ hasText: name }).first() } +async function waitForAvatarGridReady(page: Page) { + await expect(page.locator('.avatar-grid .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 }) +} + +async function selectAvatarGender(page: Page, label: string) { + await page.locator('.avatar-gender-filter').click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item') + .filter({ hasText: label || '全部性别' }) + .click() +} + +async function selectCapabilityStatus(page: Page, label: string) { + await page.locator('.capability-status-filter').click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item') + .filter({ hasText: label }) + .click() +} + function capabilityCard(page: Page, name: string) { return page.locator('.capability-card').filter({ hasText: name }).first() } @@ -212,8 +239,8 @@ function dialogField(dialog: Locator, label: string) { return dialog.locator('.capability-form > label').filter({ hasText: label }).first() } -async function waitForToast(page: Page, text: string | RegExp) { - await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible() +async function waitForToast(page: Page, text: string | RegExp, timeout = 15_000) { + await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible({ timeout }) } async function deleteCapabilityThroughUi(page: Page, name: string) { @@ -235,8 +262,8 @@ function createVoiceFixture(sourceVideo: string, outputPath: string) { expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000) } -test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request }, testInfo) => { - test.setTimeout(480_000) +test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request, context }, testInfo) => { + test.setTimeout(720_000) const headers = await authHeaders(request) const activities: Activity[] = [] const cleanupErrors: string[] = [] @@ -269,59 +296,150 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 } const { items: visibleAvatars } = await listAvatars(request, headers) - const previewAvatar = visibleAvatars.find((item) => [ - 'a1000000000000000000000000000001', - 'a1000000000000000000000000000002', - 'a1000000000000000000000000000003', - 'a1000000000000000000000000000004', - ].includes(String(item.id ?? ''))) - expect(previewAvatar, '平台应保留至少一个可播放口播样片的内置形象').toBeTruthy() + let previewAvatar: Record | undefined + let sourceResponse: Awaited> | undefined + for (const candidate of visibleAvatars.filter((item) => Boolean(item.bundleReady && item.viewerUrl && item.sourceType === 'built_in'))) { + const response = await request.get(String(candidate.viewerUrl)) + if (response.status() === 200 && response.headers()['content-type']?.includes('text/html')) { + previewAvatar = candidate + sourceResponse = response + break + } + } + expect(previewAvatar, '形象库应至少包含一个共享数字人服务中真实可用的 Viewer').toBeTruthy() + expect(sourceResponse?.status()).toBe(200) const previewName = String(previewAvatar?.name ?? '') - const nameSearch = page.getByPlaceholder('搜索数字人名称') + const nameSearch = page.getByPlaceholder('搜索形象名称') await nameSearch.fill(previewName) + const backendSearch = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname.endsWith('/api/v1/avatars') && url.searchParams.get('keyword') === previewName + }) + await page.getByRole('button', { name: '搜索', exact: true }).click() + expect((await backendSearch).status()).toBe(200) + await waitForAvatarGridReady(page) await expect(avatarCard(page, previewName)).toBeVisible() const previewGender = String(previewAvatar?.gender ?? '') if (previewGender === '男' || previewGender === '女') { - await page.locator('.avatar-gender-filter select').selectOption(previewGender) + await selectAvatarGender(page, previewGender) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) await expect(avatarCard(page, previewName)).toBeVisible() } await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter') addPass(activities, '数字形象', '名称与性别组合筛选') - await avatarCard(page, previewName).getByRole('button', { name: '预览口播' }).click() + await avatarCard(page, previewName).getByRole('button', { name: '预览', exact: true }).click() const previewDialog = page.locator('.avatar-preview-dialog:visible').last() - const previewVideo = previewDialog.locator('video') - await expect(previewVideo).toBeVisible() - await expect(previewVideo).toHaveAttribute('src', /avatar-previews\/.+\.mp4/) - const previewSource = String(await previewVideo.getAttribute('src')) - const previewPoster = String(await previewVideo.getAttribute('poster')) - const sourceResponse = await request.get(previewSource) - expect(sourceResponse.status()).toBe(200) - expect(sourceResponse.headers()['content-type']).toContain('video/mp4') - const posterResponse = await request.get(previewPoster) - expect(posterResponse.status()).toBe(200) - expect(posterResponse.headers()['content-type']).toMatch(/^image\//) - await captureScreenshot(page, testInfo, '02-builtin-avatar-real-video-preview') - addPass(activities, '数字形象', '内置口播真实视频与封面预览', sourceResponse, String(previewAvatar?.id ?? ''), '已校验真实 MP4/封面响应;Playwright Chromium 的 H.264 解码能力不作为服务端文件可用性判据') + const builtInPreviewVideo = previewDialog.locator('video') + await expect(builtInPreviewVideo).toBeVisible() + await expect(builtInPreviewVideo).toHaveAttribute('src', /\/avatar-previews\/.+\.mp4/) + await expect(previewDialog.locator('iframe')).toHaveCount(0) + await expect(previewDialog.getByText('内置形象样片')).toBeVisible() + expect(sourceResponse?.headers()['content-type']).toContain('text/html') + await captureScreenshot(page, testInfo, '02-builtin-avatar-mp4-preview') + addPass(activities, '数字形象', '已有内置 MP4 时直接播放,Viewer 仅作为无视频回退', sourceResponse, String(previewAvatar?.id ?? '')) await previewDialog.locator('.el-dialog__headerbtn').click() + const viewerFallbackAvatar = visibleAvatars.find((item) => Boolean( + item.bundleReady + && item.viewerUrl + && item.sourceType !== 'built_in' + && !item.previewFileCode + && !item.previewUrl, + )) + if (viewerFallbackAvatar) { + const viewerFallbackName = String(viewerFallbackAvatar.name ?? '') + await nameSearch.fill(viewerFallbackName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) + const viewerFallbackCard = avatarCard(page, viewerFallbackName) + await expect(viewerFallbackCard.getByRole('button', { name: '生成预览', exact: true })).toBeVisible() + await viewerFallbackCard.locator('.avatar-cover').click() + const fallbackDialog = page.locator('.avatar-preview-dialog:visible').last() + await expect(fallbackDialog.locator('video')).toHaveCount(0) + await expect(fallbackDialog.locator('iframe')).toHaveAttribute('src', String(viewerFallbackAvatar.viewerUrl)) + await expect(fallbackDialog.getByText('实际数字人服务 Viewer')).toBeVisible() + await captureScreenshot(page, testInfo, '02b-viewer-fallback-without-mp4') + addPass(activities, '数字形象', '无 MP4 素材时回退真实 Viewer', undefined, String(viewerFallbackAvatar.id ?? '')) + await fallbackDialog.locator('.el-dialog__headerbtn').click() + } else { + addPass(activities, '数字形象', '现有真实资源均已有 MP4,无需 Viewer 回退') + } + await nameSearch.fill('') - await page.locator('.avatar-gender-filter select').selectOption('') - await page.getByRole('button', { name: '创建数字人' }).click() - const createDialog = page.locator('.avatar-create-dialog:visible').last() + await selectAvatarGender(page, '全部性别') + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) + const storageSnapshot = await page.evaluate(() => ({ + local: Object.fromEntries(Object.entries(localStorage)), + session: Object.fromEntries(Object.entries(sessionStorage)), + })) + const creationPage = await context.newPage() + await creationPage.goto('/login') + await creationPage.evaluate((snapshot) => { + Object.entries(snapshot.local).forEach(([key, value]) => localStorage.setItem(key, String(value))) + Object.entries(snapshot.session).forEach(([key, value]) => sessionStorage.setItem(key, String(value))) + }, storageSnapshot) + await creationPage.goto('/assets/avatars') + await expect(creationPage.getByRole('heading', { name: '数字形象' })).toBeVisible() + await creationPage.getByRole('button', { name: '创建数字人' }).click() + const createDialog = creationPage.locator('.avatar-create-dialog:visible').last() await expect(createDialog).toBeVisible() await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo) - await createDialog.locator('.avatar-create-fields input').fill(originalName) + await createDialog.getByLabel('数字人名称').fill(originalName) + await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeDisabled() + await createDialog.locator('.avatar-create-gender-field .el-select').click() + await creationPage.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click() + await createDialog.getByLabel('专业方向').fill('维修教学') await createDialog.locator('details.avatar-create-advanced summary').click() await createDialog.locator('select').selectOption('off') - await captureScreenshot(page, testInfo, '03-avatar-real-video-ready-to-submit') + await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeEnabled() + await captureScreenshot(creationPage, testInfo, '03-avatar-real-video-ready-to-submit') + const previewSpeechRequestPromise = creationPage.waitForRequest((candidate) => { + return candidate.method() === 'POST' && /\/api\/v1\/tts(?:\?|$)/.test(candidate.url()) + }, { timeout: 480_000 }) await createDialog.getByRole('button', { name: '开始创建' }).click() - await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 240_000 }) - await captureScreenshot(page, testInfo, '04-avatar-upstream-job-succeeded') - await createDialog.getByRole('button', { name: '关闭', exact: true }).click() + await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 360_000 }) + await captureScreenshot(creationPage, testInfo, '04-avatar-upstream-job-succeeded') + const previewSpeechRequest = await previewSpeechRequestPromise + expect(previewSpeechRequest.postDataJSON()).toMatchObject({ + text: `欢迎使用数字人系统,我是${originalName}。`, + speaker: 1, + }) + const queueCode = createDialog.locator('.avatar-preview-queue-link code') + await expect(queueCode).toBeVisible({ timeout: 180_000 }) + const previewJobId = String(await queueCode.textContent()).trim() + expect(previewJobId).toMatch(/^[a-f0-9]{32}$/) + await expect(createDialog.getByRole('button', { name: '后台运行', exact: true })).toBeVisible() + await captureScreenshot(creationPage, testInfo, '04a-avatar-preview-queue-submitted') + await creationPage.close() + + let previewJob: JsonRecord = {} + const jobDeadline = Date.now() + 180_000 + while (Date.now() < jobDeadline) { + const response = await request.get(`/api/v1/jobs/${previewJobId}`, { headers }) + previewJob = await responseData(response, 200, '浏览器关闭后查询数字人预览制作任务') + if (previewJob.status === 'succeeded' || previewJob.status === 'failed') break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + expect(previewJob).toMatchObject({ kind: 'capture_transcode', status: 'succeeded' }) + + let backgroundAvatar: JsonRecord | undefined + const attachDeadline = Date.now() + 30_000 + while (Date.now() < attachDeadline) { + const listed = await listAvatars(request, headers, originalName) + backgroundAvatar = listed.items.find((item) => String(item.name ?? '') === originalName) + if (String(backgroundAvatar?.previewFileCode ?? '')) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + expect(String(backgroundAvatar?.previewFileCode ?? ''), '关闭浏览器后服务端应自动绑定 MP4 预览').not.toBe('') + addPass(activities, '制作队列', '关闭录制页面后服务端继续封装并自动绑定预览', undefined, previewJobId) await nameSearch.fill(originalName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) const createdCard = avatarCard(page, originalName) await expect(createdCard).toBeVisible() await expect(createdCard).toContainText('可预览') @@ -330,8 +448,24 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 const avatarId = String(createdAvatar?.id ?? '') expect(avatarId).not.toBe('') expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready') + expect(String(createdAvatar?.previewFileCode ?? '')).not.toBe('') + expect(createdAvatar).toMatchObject({ specialty: '维修教学', gender: '男' }) addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId) + await createdCard.getByRole('button', { name: '预览', exact: true }).click() + const servicePreviewDialog = page.locator('.avatar-preview-dialog:visible').last() + const recordedPreview = servicePreviewDialog.locator('video') + await expect(recordedPreview).toBeVisible() + await expect(recordedPreview).toHaveAttribute('src', /\/api\/v1\/files\/.+\/content\?expires=/) + await expect(servicePreviewDialog.getByText('自动录制预览素材')).toBeVisible() + const recordedPreviewUrl = String(await recordedPreview.getAttribute('src')) + const recordedPreviewResponse = await request.get(recordedPreviewUrl) + expect(recordedPreviewResponse.status()).toBe(200) + expect(recordedPreviewResponse.headers()['content-type']).toContain('video/mp4') + await captureScreenshot(page, testInfo, '04b-avatar-recorded-preview-playback') + addPass(activities, '数字形象', '用户自建形象播放自动录制 MP4 预览', recordedPreviewResponse, avatarId) + await servicePreviewDialog.locator('.el-dialog__headerbtn').click() + await createdCard.getByRole('button', { name: '编辑' }).click() const editDialog = page.locator('.admin-form-dialog:visible').last() await editDialog.getByLabel('数字人名称').fill(editedName) @@ -347,6 +481,8 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 await waitForToast(page, '数字人信息已更新') await nameSearch.fill(editedName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) const editedCard = avatarCard(page, editedName) await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训') await expect(editedCard).toContainText('平台共享') @@ -376,7 +512,7 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 await expect(avatarCard(page, editedName)).toContainText('可预览') addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId) - await avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' }).click() + await avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true }).click() await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`)) await expect(avatarCard(page, editedName)).toContainText('默认数字人') const defaultPreferences = await getAvatarPreferences(request, headers) @@ -396,8 +532,10 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 preferenceSnapshot = null await page.reload() await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() - await page.getByPlaceholder('搜索数字人名称').fill(editedName) - await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' })).toBeEnabled() + await page.getByPlaceholder('搜索形象名称').fill(editedName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await waitForAvatarGridReady(page) + await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true })).toBeEnabled() await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click() const deleteBox = page.locator('.el-message-box:visible').last() @@ -413,9 +551,12 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 primaryError = error activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) } finally { + // 真实 CPU 数字人生成可能超过一次访问令牌的短有效期;清理阶段重新登录, + // 避免主流程失败后因旧 token 留下测试形象或未恢复平台偏好。 + const cleanupHeaders = await authHeaders(request).catch(() => headers) if (preferenceSnapshot) { try { - const restored = await restoreAvatarPreferences(request, headers, preferenceSnapshot) + const restored = await restoreAvatarPreferences(request, cleanupHeaders, preferenceSnapshot) activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() }) } catch (error) { const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}` @@ -423,7 +564,7 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message }) } } - await cleanupAvatarsByPrefix(request, headers, activities, cleanupErrors, new Set([originalName, editedName])) + await cleanupAvatarsByPrefix(request, cleanupHeaders, activities, cleanupErrors, new Set([originalName, editedName])) await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors) } @@ -473,7 +614,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 knownFileCodes.add(String(voiceFile.id ?? '')) await waitForToast(page, '配置已创建') - let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明') + let search = page.locator('.capability-search input') await search.fill(voiceName) let card = capabilityCard(page, voiceName) await expect(card).toBeVisible() @@ -510,12 +651,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 await card.getByRole('button', { name: '停用' }).click() await waitForToast(page, '已停用') - await page.getByLabel('状态筛选').selectOption('disabled') + await selectCapabilityStatus(page, '已停用') await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用') await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter') await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click() await waitForToast(page, '已启用') - await page.getByLabel('状态筛选').selectOption('all') + await selectCapabilityStatus(page, '全部状态') addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId) await deleteCapabilityThroughUi(page, voiceEditedName) addPass(activities, '声音克隆', '页面删除', undefined, voiceId) @@ -533,7 +674,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 await dialog.getByRole('button', { name: '保存', exact: true }).click() await waitForToast(page, '配置已创建') - search = page.getByPlaceholder('搜索语音模型名称、分类或说明') + search = page.locator('.capability-search input') await search.fill(ttsName) card = capabilityCard(page, ttsName) await expect(card).toBeVisible() @@ -587,7 +728,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 knownFileCodes.add(String(sceneFile.id ?? '')) await waitForToast(page, '配置已创建') - search = page.getByPlaceholder('搜索场景素材名称、分类或说明') + search = page.locator('.capability-search input') await search.fill(sceneName) card = capabilityCard(page, sceneName) await expect(card).toBeVisible() @@ -617,12 +758,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) await card.getByRole('button', { name: '停用' }).click() await waitForToast(page, '已停用') - await page.getByLabel('状态筛选').selectOption('disabled') + await selectCapabilityStatus(page, '已停用') await expect(capabilityCard(page, sceneEditedName)).toBeVisible() await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered') await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click() await waitForToast(page, '已启用') - await page.getByLabel('状态筛选').selectOption('all') + await selectCapabilityStatus(page, '全部状态') addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId) await deleteCapabilityThroughUi(page, sceneEditedName) addPass(activities, '场景素材', '页面删除', undefined, sceneId) diff --git a/tests/business-api-lifecycle.spec.ts b/tests/business-api-lifecycle.spec.ts index 7ffebf8..6a71113 100644 --- a/tests/business-api-lifecycle.spec.ts +++ b/tests/business-api-lifecycle.spec.ts @@ -999,6 +999,8 @@ test.describe('正式业务 API 数据生命周期', () => { headers, multipart: { name: `${runPrefix}-视频工程依赖形象`, + specialty: '视频口播教学', + gender: '男', resize: 'true', matting: 'off', file: { diff --git a/tests/helpers.ts b/tests/helpers.ts index f931219..1a013e7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -55,7 +55,7 @@ export async function loginAsAdmin(page: Page) { const password = page.locator('input[name="password"]') await username.fill(credentials.username) await password.fill(credentials.password) - await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click() + await page.getByRole('button', { name: /^(?:登录|登录系统|正在验证身份)$/ }).click() await expect(page).not.toHaveURL(/\/login(?:\?|$)/) await expect(page.locator('.app-shell, .main-layout, .platform-shell').first()).toBeVisible() } diff --git a/tests/prototype-sync-regression.spec.ts b/tests/prototype-sync-regression.spec.ts new file mode 100644 index 0000000..233f4e2 --- /dev/null +++ b/tests/prototype-sync-regression.spec.ts @@ -0,0 +1,241 @@ +import type { BrowserContext, Page, Route } from '@playwright/test' + +import { captureScreenshot, expect, test } from './fixtures' + +const envelope = (data: unknown) => ({ + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + requestId: 'prototype-sync-regression', +}) + +const profile = { + user: { + id: 'prototype-admin', username: 'prototype-admin', displayName: '系统管理员', + departmentId: 'system', departmentName: '系统管理', enabled: true, + mustChangePassword: false, version: 1, + }, + roles: [{ + id: 'admin-role', code: 'ADMIN', name: '系统管理员', shortName: '系统', + enabled: true, builtIn: true, isSuperAdmin: true, dataScope: 'ALL', + }], + activeRoleId: 'admin-role', permissions: ['*'], authorizationMode: 'SINGLE_ACTIVE', + loginTime: new Date().toISOString(), +} + +const baseAgent = { + dataVersion: 1, + name: '设备点检数字教员', + description: '用于验证独立页面与悬浮图标入口', + ownerId: 'prototype-admin', ownerName: '系统管理员', + projectId: null, projectName: '', avatarId: '', avatarName: '', viewerUrl: '', avatars: [], + voiceName: '标准教员音色', voiceSpeed: 1, voice: null, voiceNames: [], + knowledgeBaseIds: [], knowledgeDocumentIds: [], interactionModes: ['text'], + llmModel: '', asrModel: '', ttsModel: '', accessMode: 'internal', allowedOrigins: [], + concurrencyLimit: 10, expiresAt: null, background: '', brandVisible: true, + welcomeMessage: '您好,请问需要了解哪项设备点检知识?', fallbackMessage: '服务暂不可用', + sensitiveReply: '请重新描述问题', showInteractionButtons: true, uiPosition: 'fullscreen', + sceneBackgroundType: 'transparent', sceneAssetUrl: '', logoUrl: '', + screensaverEnabled: false, screensaverUrl: '', screensaverIdleSeconds: 120, + apiKey: '', apiKeyPreview: 'dhk_demo••••', callCount: 3, + uiConfig: { + components: [ + { key: 'welcome', enabled: true, order: 1 }, + { key: 'history', enabled: true, order: 2 }, + { key: 'suggestions', enabled: true, order: 3 }, + { key: 'input', enabled: true, order: 4 }, + ], + suggestions: ['设备点检的主要目的是什么?'], + }, +} + +const publishedAgent = { ...baseAgent, id: 'published-agent-id', slug: 'published-agent', status: 'published' } +const draftAgent = { ...baseAgent, id: 'draft-agent-id', slug: 'draft-agent', name: '草稿数字教员', status: 'draft' } + +const video = { + id: 'video-1', dataVersion: 1, title: '液压系统点检口播', description: '设备培训示例', + category: '设备培训', tags: ['点检'], status: 'ready', visibility: 'internal', + ownerId: 'prototype-admin', ownerName: '系统管理员', avatarId: null, duration: 42, + width: 1920, height: 1080, size: 1024, coverUrl: '', fileUrl: null, + coverFileCode: null, videoFileCode: null, folderId: null, shareStatus: 'inactive', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), +} + +async function installMocks(page: Page) { + const videoQueries: Array> = [] + + await page.addInitScript(() => { + window.sessionStorage.setItem('ai-person:web:access-token:v1', 'prototype-sync-token') + + class FakeUtterance { + text: string + lang = '' + rate = 1 + pitch = 1 + onstart: null | (() => void) = null + onend: null | (() => void) = null + onerror: null | (() => void) = null + constructor(text: string) { + this.text = text + const state = window as unknown as { __speechSynthesisUtteranceCalls: number } + state.__speechSynthesisUtteranceCalls += 1 + } + } + ;(window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls = 0 + const fakeSpeech = { + speaking: false, + paused: false, + current: null as FakeUtterance | null, + speak(utterance: FakeUtterance) { + this.current = utterance + this.speaking = true + this.paused = false + utterance.onstart?.() + }, + pause() { this.paused = true; this.speaking = true }, + resume() { this.paused = false; this.speaking = true }, + cancel() { this.speaking = false; this.paused = false; this.current = null }, + } + Object.defineProperty(window, 'SpeechSynthesisUtterance', { configurable: true, value: FakeUtterance }) + Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: fakeSpeech }) + }) + + await page.route('**/api/auth/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + let data: unknown = {} + if (pathname.endsWith('/auth/me')) data = profile + else if (pathname.endsWith('/menus/navigation')) data = [] + else if (pathname.endsWith('/system-config/public')) data = { systemName: '虚拟教员系统', shortName: '数字人平台' } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) + }) + + await page.route('**/api/v1/**', async (route) => { + const url = new URL(route.request().url()) + let data: unknown = {} + if (url.pathname.endsWith('/videos')) { + videoQueries.push(Object.fromEntries(url.searchParams.entries())) + data = { items: [video], total: 1, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] } + } else if (url.pathname.endsWith('/video-folders')) { + data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 } + } else if (url.pathname.endsWith('/realtime-agents')) { + data = { items: [publishedAgent, draftAgent], total: 2, page: 1, pageSize: 10, pages: 1 } + } else if (url.pathname.endsWith('/avatars')) { + data = { items: [], total: 0, page: 1, pageSize: 200, pages: 1 } + } else if (url.pathname.endsWith('/health')) { + data = { status: 'ok' } + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) + }) + + await page.route('**/open/v1/**', async (route) => { + const url = new URL(route.request().url()) + let data: unknown = publishedAgent + let status = 200 + if (url.pathname.endsWith('/sessions')) { + data = { sessionId: 'session-1', sessionToken: 'session-token', expiresAt: new Date(Date.now() + 60_000).toISOString() } + status = 201 + } else if (url.pathname.endsWith('/chat')) { + data = { reply: '设备点检用于及时发现异常并降低故障风险。', citations: [], audioUrl: null, provider: 'local', blocked: false } + } + await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) + }) + + return { videoQueries } +} + +async function expectOpenedPage(context: BrowserContext, action: () => Promise, path: RegExp) { + const openedPromise = context.waitForEvent('page') + await action() + const opened = await openedPromise + await expect(opened).toHaveURL(path) + await opened.close() +} + +test('成片筛选在搜索前且排序方向合并到排序项', async ({ page }, testInfo) => { + const { videoQueries } = await installMocks(page) + await page.goto('/videos/manage') + await expect(page.getByText('液压系统点检口播')).toBeVisible() + + const row = page.locator('.video-filter-row') + const searchButton = row.getByRole('button', { name: '搜索', exact: true }) + const sortSelect = row.locator('label').filter({ hasText: '排序' }).locator('select') + const filterSelects = row.locator('label select') + await expect(filterSelects).toHaveCount(4) + await expect(sortSelect.locator('option')).toContainText(['更新时间降序', '更新时间升序', '创建时间降序', '创建时间升序', '标题升序', '标题降序', '时长升序', '时长降序']) + await expect(row.locator('label:last-of-type + button')).toHaveCount(1) + + await row.locator('label').filter({ hasText: '分类' }).locator('select').selectOption('设备培训') + await row.locator('label').filter({ hasText: '可见范围' }).locator('select').selectOption('internal') + await row.locator('label').filter({ hasText: '归属' }).locator('select').selectOption('mine') + await sortSelect.selectOption('duration_desc') + await searchButton.click() + + await expect.poll(() => videoQueries.length).toBeGreaterThan(1) + expect(videoQueries.at(-1)).toMatchObject({ category: '设备培训', visibility: 'internal', owner: 'mine', sort: 'duration', order: 'desc' }) + await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort') +}) + +test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => { + await installMocks(page) + await page.goto('/agents/manage') + + const publishedRow = page.locator('.agent-table .el-table__row').filter({ hasText: '设备点检数字教员' }) + const draftRow = page.locator('.agent-table .el-table__row').filter({ hasText: '草稿数字教员' }) + await expect(publishedRow.getByRole('button', { name: '独立页面' })).toBeEnabled() + await expect(draftRow.getByRole('button', { name: '独立页面' })).toBeEnabled() + await draftRow.getByRole('button', { name: '独立页面' }).click() + await expect(page.getByText('智能体发布后才能打开体验页')).toBeVisible() + + await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '独立页面' }).click(), /\/live\/published-agent$/) + await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '悬浮图标' }).click(), /\/embed-demo\?agent=published-agent$/) + await captureScreenshot(page, testInfo, 'agent-preview-actions-enabled') +}) + +test('公开回答只播放服务端音频,缺失时保留文字且不调用浏览器 TTS', async ({ page }, testInfo) => { + await installMocks(page) + await page.goto('/live/published-agent') + await expect(page.getByText('设备点检数字教员')).toBeVisible() + + await page.getByRole('textbox', { name: '维修问题' }).fill('设备点检的主要目的是什么?') + await page.getByRole('button', { name: '发送问题' }).click() + const answer = page.locator('.live-message-list article.assistant').filter({ hasText: '设备点检用于及时发现异常并降低故障风险。' }) + await expect(answer).toBeVisible() + await expect(answer.locator('.live-degradation[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。') + const speechButton = answer.locator('.live-speech-toggle') + await expect(speechButton).not.toHaveClass(/is-speaking/) + await speechButton.click() + await expect(answer.locator('.live-degradation[role="status"]')).toBeVisible() + await expect(speechButton).not.toHaveClass(/is-speaking/) + await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0) + await captureScreenshot(page, testInfo, 'public-answer-speech-control') + + await page.goto('/embed/published-agent?open=1') + const widgetAnswer = page.locator('.embed-messages article.assistant').first() + const widgetSpeechButton = widgetAnswer.locator('.embed-speech-toggle') + await expect(widgetSpeechButton).toBeVisible() + await widgetSpeechButton.click() + await expect(widgetAnswer.locator('.embed-audio-unavailable[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。') + await expect(widgetSpeechButton).not.toHaveClass(/is-speaking/) + await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0) +}) + +test('标签达到上限时静默淘汰最久未访问项', async ({ page }) => { + await installMocks(page) + await page.addInitScript(() => { + const paths = [ + '/overview', '/agents/manage', '/agents/projects', '/agents/chat-history', '/agents/wake-words', '/agents/remote-control', + '/videos/manage', '/assets/avatars', '/assets/ai-models', '/organization/users', '/organization/departments', '/organization/roles', + ] + const tabs = paths.map((path, index) => ({ + key: path, fullPath: path, name: `seed-${index}`, title: `种子标签${index + 1}`, + permissions: [], locked: false, + })) + window.localStorage.setItem('ai-person:web:page-tabs:v1', JSON.stringify({ 'prototype-admin': { tabs } })) + }) + + await page.goto('/settings/audit') + await expect(page.locator('.page-tab')).toHaveCount(12) + await expect(page.getByText(/页面标签数量已达上限/)).toHaveCount(0) + await expect(page.locator('.page-tab').filter({ hasText: '审计日志' })).toHaveCount(1) +}) diff --git a/tests/sidebar-bootstrap-regression.spec.ts b/tests/sidebar-bootstrap-regression.spec.ts new file mode 100644 index 0000000..b661f31 --- /dev/null +++ b/tests/sidebar-bootstrap-regression.spec.ts @@ -0,0 +1,161 @@ +import type { Page, Route } from '@playwright/test' + +import { captureScreenshot, expect, test } from './fixtures' + +const envelope = (data: unknown) => ({ + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + requestId: 'sidebar-bootstrap-regression', +}) + +const profile = { + user: { + id: 'sidebar-admin', + username: 'sidebar-admin', + displayName: '系统管理员', + departmentId: 'system', + departmentName: '系统管理', + enabled: true, + mustChangePassword: false, + version: 1, + }, + roles: [{ + id: 'admin-role', + code: 'ADMIN', + name: '系统管理员', + shortName: '系统', + enabled: true, + builtIn: true, + isSuperAdmin: true, + dataScope: 'ALL', + }], + activeRoleId: 'admin-role', + permissions: ['*'], + authorizationMode: 'SINGLE_ACTIVE', + loginTime: new Date().toISOString(), +} + +const navigation = [ + { + id: 'agents', code: 'agents', name: '数字人智能体', type: 'GROUP', sortOrder: 10, enabled: true, + path: '/agents/manage', icon: 'Platform', permissionCodes: [], + children: [ + { id: 'agent-management', code: 'agent-management', name: '虚拟教员智能体', type: 'PAGE', sortOrder: 10, enabled: true, path: '/agents/manage', icon: 'Management', permissionCodes: [], children: [] }, + { id: 'agent-projects', code: 'agent-projects', name: '智能体组别管理', type: 'PAGE', sortOrder: 20, enabled: true, path: '/agents/projects', icon: 'Files', permissionCodes: [], children: [] }, + ], + }, + { + id: 'assets', code: 'assets', name: '资源中心', type: 'GROUP', sortOrder: 20, enabled: true, + path: '/assets/avatars', icon: 'Collection', permissionCodes: [], + children: [ + { id: 'avatars', code: 'avatars', name: '数字形象', type: 'PAGE', sortOrder: 10, enabled: true, path: '/assets/avatars', icon: 'Avatar', permissionCodes: [], children: [] }, + { id: 'ai-models', code: 'ai-models', name: 'AI模型', type: 'PAGE', sortOrder: 20, enabled: true, path: '/assets/ai-models', icon: 'MagicStick', permissionCodes: [], children: [] }, + ], + }, + { + id: 'settings', code: 'settings', name: '系统设置', type: 'GROUP', sortOrder: 30, enabled: true, + path: '/settings/general', icon: 'Setting', permissionCodes: [], + children: [ + { id: 'settings-general', code: 'settings-general', name: '基础设置', type: 'PAGE', sortOrder: 10, enabled: true, path: '/settings/general', icon: 'Setting', permissionCodes: [], children: [] }, + { id: 'settings-menus', code: 'settings-menus', name: '菜单管理', type: 'PAGE', sortOrder: 20, enabled: true, path: '/settings/menus', icon: 'Menu', permissionCodes: [], children: [] }, + ], + }, +] + +async function installShellMocks(page: Page, holdBootstrap = false) { + let releaseBootstrap = () => {} + const bootstrapGate = holdBootstrap + ? new Promise((resolve) => { releaseBootstrap = resolve }) + : Promise.resolve() + + await page.addInitScript(() => { + window.localStorage.setItem('ai-person:web:access-token:v1', 'sidebar-bootstrap-token') + window.localStorage.setItem('ai-person:web:remember:v1', '1') + }) + + const fulfillApi = async (route: Route) => { + const pathname = new URL(route.request().url()).pathname + let data: unknown = {} + + if (pathname.endsWith('/system-config/public') || pathname.endsWith('/auth/me')) { + await bootstrapGate + } + if (pathname.endsWith('/auth/me')) data = profile + else if (pathname.endsWith('/menus/navigation')) data = navigation + else if (pathname.endsWith('/overview')) { + data = { stats: {}, agents: [], videos: [], jobs: [] } + } + + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) + } + + await page.route('**/api/auth/v1/**', fulfillApi) + await page.route('**/api/v1/**', fulfillApi) + + return releaseBootstrap +} + +test('App 模块尚未加载时由 HTML 直接展示启动状态', async ({ page }) => { + let releaseApp = () => {} + const appGate = new Promise((resolve) => { releaseApp = resolve }) + await page.route('**/src/App.vue*', async (route) => { + await appGate + await route.continue() + }) + + try { + await page.goto('/overview', { waitUntil: 'commit' }) + await expect(page.locator('.static-bootstrap-screen')).toBeVisible() + await expect(page.getByText('正在启动系统')).toBeVisible() + await expect(page.getByText('正在加载应用资源,请稍候…')).toBeVisible() + } finally { + releaseApp() + } + + await expect(page.locator('.login-shell')).toBeVisible() + await expect(page.locator('.static-bootstrap-screen')).toHaveCount(0) +}) + +test('认证与公开配置请求未完成时展示品牌启动状态', async ({ page }, testInfo) => { + const releaseBootstrap = await installShellMocks(page, true) + await page.goto('/overview') + + await expect(page.locator('.app-bootstrap-screen')).toBeVisible() + await expect(page.getByText('正在进入系统')).toBeVisible() + await expect(page.getByText('正在验证登录状态并加载工作台,请稍候…')).toBeVisible() + await captureScreenshot(page, testInfo, 'bootstrap-loading-not-blank') + + releaseBootstrap() + await expect(page.locator('.app-shell')).toBeVisible() + await expect(page.locator('.app-bootstrap-screen')).toHaveCount(0) +}) + +test('侧栏使用 Element Plus 菜单并保持手风琴展开', async ({ page }, testInfo) => { + await installShellMocks(page) + await page.goto('/overview') + await expect(page.locator('.app-shell')).toBeVisible() + + const menu = page.locator('#main-sidebar .sidebar-menu') + const groups = menu.locator(':scope > .el-sub-menu') + await expect(menu).toHaveClass(/el-menu/) + await expect(groups).toHaveCount(3) + + const first = groups.nth(0) + const second = groups.nth(1) + const third = groups.nth(2) + + await first.locator(':scope > .el-sub-menu__title').click() + await expect(first).toHaveClass(/is-opened/) + + await second.locator(':scope > .el-sub-menu__title').click() + await expect(second).toHaveClass(/is-opened/) + await expect(first).not.toHaveClass(/is-opened/) + + await third.locator(':scope > .el-sub-menu__title').click() + await expect(third).toHaveClass(/is-opened/) + await expect(second).not.toHaveClass(/is-opened/) + await expect(menu.locator(':scope > .el-sub-menu.is-opened')).toHaveCount(1) + await captureScreenshot(page, testInfo, 'element-menu-accordion') +}) diff --git a/tests/zipvoice-cloning-ui.spec.ts b/tests/zipvoice-cloning-ui.spec.ts new file mode 100644 index 0000000..5be08b1 --- /dev/null +++ b/tests/zipvoice-cloning-ui.spec.ts @@ -0,0 +1,272 @@ +import type { Route } from '@playwright/test' + +import { captureScreenshot, expect, test } from './fixtures' +import { loginAsAdmin } from './helpers' + +type JsonRecord = Record + +const envelope = (data: unknown) => ({ + code: 0, + message: '成功', + data, + timestamp: Date.now(), + requestId: 'ape2e-zipvoice-controlled', +}) + +const fulfillJson = (route: Route, data: unknown, status = 200) => route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(envelope(data)), +}) + +const baseCapability = { + dataVersion: 1, + capabilityType: 'VOICE_CLONE', + type: 'SHERPA_ZIPVOICE', + description: '受控测试音色', + reference: '参考录音.wav', + referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。', + gender: 'MALE', + enabled: true, + builtIn: false, + updatedAt: '2026-08-22T10:00:00+08:00', +} + +test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象默认音色', async ({ page }, testInfo) => { + test.setTimeout(120_000) + await loginAsAdmin(page) + + const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord } = {} + let capabilities = [ + { + ...baseCapability, + id: 'clone-sample-only', + name: '历史参考样音', + voiceRuntimeMode: 'SAMPLE_ONLY', + validationStatus: 'UNTESTED', + status: 'ENABLED', + mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF', + referenceText: null, + gender: 'UNSPECIFIED', + }, + { + ...baseCapability, + id: 'clone-processing', + name: '排队中的男教员', + voiceRuntimeMode: 'ZIPVOICE_PROCESSING', + cloneStatus: 'PROCESSING', + cloneProgress: 42, + currentJobId: 'JOB-ZIPVOICE-42', + validationStatus: 'UNTESTED', + status: 'PROCESSING', + }, + { + ...baseCapability, + id: 'clone-failed', + name: '待重试音色', + voiceRuntimeMode: 'ZIPVOICE_FAILED', + cloneStatus: 'FAILED', + currentJobId: 'JOB-ZIPVOICE-FAILED', + validationStatus: 'UNAVAILABLE', + status: 'FAILED', + errorMessage: '参考音频有效人声时长不足', + }, + { + ...baseCapability, + id: 'clone-ready', + name: '可用克隆男声', + voiceRuntimeMode: 'ZIPVOICE_READY', + cloneStatus: 'READY', + cloneProgress: 100, + validationStatus: 'AVAILABLE', + status: 'ENABLED', + previewUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF', + mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF', + }, + ] + + const ttsVoices = [ + { id: 'male-gentle', capabilityId: 'builtin-voice-male-gentle', name: '温和男声', speaker: 1, gender: 'male', language: 'zh-CN', runtimeMode: 'VITS' }, + { id: 'clone-ready', capabilityId: 'clone-ready', name: '可用克隆男声', speaker: null, gender: 'male', language: 'zh-CN', runtimeMode: 'ZIPVOICE' }, + ] + + const avatar = { + id: 'avatar-controlled', + dataVersion: 3, + name: '受控维修教员', + status: 'ready', + bundleReady: true, + owner: '系统管理员', + ownerId: '1', + ownerName: '系统管理员', + sourceType: 'user', + role: '维修教员', + specialty: '装备检修', + gender: '男', + visibility: 'private', + voiceCapabilityId: 'builtin-voice-male-gentle', + voice: { capabilityId: 'builtin-voice-male-gentle', speaker: 1, name: '温和男声', speed: 0.8 }, + coverUrl: '/avatar-previews/mechanical-male.png', + previewUrl: '/avatar-previews/mechanical-male.mp4', + viewerUrl: '/human/viewer/avatar-controlled', + updatedAt: '2026-08-22T10:00:00+08:00', + } + + await page.route(/\/api\/v1\/tts(?:\/|\?|$)/, async (route) => { + const url = new URL(route.request().url()) + if (route.request().method() === 'GET' && url.pathname.endsWith('/tts/voices')) { + return fulfillJson(route, { ready: true, engine: 'sherpa-onnx', device: 'cpu', items: ttsVoices, error: null, missing: [] }) + } + if (route.request().method() === 'POST' && url.pathname.endsWith('/tts')) { + requests.speech = route.request().postDataJSON() as JsonRecord + return fulfillJson(route, { + status: 'succeeded', id: 'speech-controlled', audioUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF', + mimeType: 'audio/wav', sampleRate: 24000, duration: 1, speaker: null, + voiceCapabilityId: requests.speech.voiceCapabilityId, runtimeMode: 'ZIPVOICE', speed: 0.9, + }) + } + return route.fallback() + }) + + await page.route(/\/api\/v1\/files(?:\/|\?|$)/, async (route) => { + const url = new URL(route.request().url()) + if (route.request().method() === 'POST' && url.pathname.endsWith('/files/signed-urls')) { + return fulfillJson(route, { items: [{ id: 'file-controlled', fileCode: 'file-controlled', url: '/api/v1/files/file-controlled/content?expires=1999999999&signature=controlled', expiresAt: 1999999999, mediaType: 'audio/wav' }] }) + } + if (route.request().method() === 'POST' && url.pathname.endsWith('/files')) { + return fulfillJson(route, { id: 'file-controlled', databaseId: '91', originalName: 'reference.wav', mediaType: 'audio/wav', purposeCode: 'CAPABILITY_SOURCE', size: 1024, sha256: 'controlled', status: 'READY', downloadUrl: '' }, 201) + } + return route.fallback() + }) + + await page.route(/\/api\/v1\/capabilities(?:\/|\?|$)/, async (route) => { + const request = route.request() + const url = new URL(request.url()) + const retryMatch = url.pathname.match(/\/capabilities\/([^/]+)\/voice-clone\/retry$/) + if (retryMatch && request.method() === 'POST') { + requests.retry = request.postDataJSON() as JsonRecord + await new Promise((resolve) => setTimeout(resolve, 350)) + capabilities = capabilities.map((item) => item.id === retryMatch[1] + ? { ...item, dataVersion: 2, voiceRuntimeMode: 'ZIPVOICE_PROCESSING', cloneStatus: 'QUEUED', cloneProgress: 0, status: 'PROCESSING', errorMessage: '' } + : item) + return fulfillJson(route, capabilities.find((item) => item.id === retryMatch[1])) + } + const detailMatch = url.pathname.match(/\/capabilities\/([^/]+)$/) + if (detailMatch && request.method() === 'GET') { + return fulfillJson(route, capabilities.find((item) => item.id === detailMatch[1])) + } + if (url.pathname.endsWith('/capabilities') && request.method() === 'POST') { + requests.create = request.postDataJSON() as JsonRecord + const created = { + ...baseCapability, + ...requests.create, + id: 'clone-new-processing', + dataVersion: 1, + name: String(requests.create.name || ''), + mediaFileCode: 'file-controlled', + mediaUrl: '', + voiceRuntimeMode: 'ZIPVOICE_PROCESSING', + cloneStatus: 'QUEUED', + cloneProgress: 0, + currentJobId: 'JOB-ZIPVOICE-NEW', + validationStatus: 'UNTESTED', + status: 'PROCESSING', + } + capabilities = [created, ...capabilities] + return fulfillJson(route, created, 201) + } + if (url.pathname.endsWith('/capabilities') && request.method() === 'GET') { + return fulfillJson(route, { items: capabilities, total: capabilities.length, page: 1, pageSize: 9, pages: 1 }) + } + return route.fallback() + }) + + await page.route(/\/api\/v1\/avatars(?:\/|\?|$)/, async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (request.method() === 'GET' && url.pathname.endsWith('/avatars')) { + return fulfillJson(route, { items: [avatar], total: 1, page: 1, pageSize: 9, pages: 1 }) + } + if (request.method() === 'PUT' && url.pathname.endsWith(`/avatars/${avatar.id}`)) { + requests.avatarUpdate = request.postDataJSON() as JsonRecord + Object.assign(avatar, { + ...requests.avatarUpdate, + dataVersion: 4, + voiceCapabilityId: requests.avatarUpdate.voiceCapabilityId, + voice: { capabilityId: requests.avatarUpdate.voiceCapabilityId, speaker: null, name: '可用克隆男声', speed: 0.8 }, + }) + return fulfillJson(route, avatar) + } + return route.fallback() + }) + + await page.route(/\/api\/v1\/avatar-preferences(?:\?|$)/, (route) => fulfillJson(route, { + defaultAvatarId: '', builtInVisible: true, dataVersion: 1, + })) + + await page.goto('/assets/voice-clones') + await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible() + await expect(page.locator('.capability-card').filter({ hasText: '历史参考样音' })).toContainText('待开始克隆') + await expect(page.locator('.capability-card').filter({ hasText: '排队中的男教员' })).toContainText('42%') + await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('参考音频有效人声时长不足') + await expect(page.locator('.capability-card').filter({ hasText: '可用克隆男声' })).toContainText('克隆音色可用') + await captureScreenshot(page, testInfo, '01-zipvoice-async-states') + + const failedCard = page.locator('.capability-card').filter({ hasText: '待重试音色' }) + await failedCard.getByRole('button', { name: '重新克隆' }).click() + await expect(failedCard.locator('.el-button.is-loading')).toBeVisible() + await expect.poll(() => requests.retry?.dataVersion).toBe(1) + await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('排队中') + + const readyCard = page.locator('.capability-card').filter({ hasText: '可用克隆男声' }) + await readyCard.getByRole('button', { name: '试听', exact: true }).click() + await expect.poll(() => requests.speech?.voiceCapabilityId).toBe('clone-ready') + await expect(readyCard.locator('.inline-media-preview audio')).toBeVisible() + + await page.getByRole('button', { name: /新建克隆音色/ }).click() + const dialog = page.locator('.admin-form-dialog:visible').last() + await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill('受控 ZipVoice 男教员') + await dialog.locator('.capability-form > label').filter({ hasText: '性别' }).locator('.el-select').click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男声' }).click() + await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill('用于装备维修逐步讲解') + await dialog.locator('.reference-transcript textarea').fill('进入检修区域前,请确认设备已停机、断电、卸压。') + await dialog.locator('.el-dialog__body').evaluate((element) => { element.scrollTop = 0 }) + await captureScreenshot(page, testInfo, '02-zipvoice-required-fields') + await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles({ + name: 'reference.wav', + mimeType: 'audio/wav', + buffer: Buffer.from('RIFF-controlled-wave'), + }) + await dialog.locator('.voice-consent').click() + await captureScreenshot(page, testInfo, '03-zipvoice-source-and-consent') + await dialog.getByRole('button', { name: '提交克隆任务' }).click() + await expect(dialog.getByRole('button', { name: '提交克隆任务' })).toHaveClass(/is-loading/) + await expect(dialog).toBeHidden() + expect(requests.create).toMatchObject({ + capabilityType: 'VOICE_CLONE', + type: 'SHERPA_ZIPVOICE', + name: '受控 ZipVoice 男教员', + gender: 'MALE', + referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。', + mediaFileCode: 'file-controlled', + consentConfirmed: true, + }) + await expect(page.locator('.capability-card').filter({ hasText: '受控 ZipVoice 男教员' })).toContainText('排队中') + + await page.goto('/assets/avatars') + await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() + const avatarCard = page.locator('.avatar-card').filter({ hasText: avatar.name }) + await avatarCard.getByRole('button', { name: '编辑' }).click() + const avatarDialog = page.locator('.admin-form-dialog:visible').last() + const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select') + await voiceSelect.click() + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toContainText('温和男声(内置)') + await expect(dropdown).toContainText('可用克隆男声(克隆)') + await expect(dropdown).not.toContainText('排队中的男教员') + await dropdown.locator('.el-select-dropdown__item').filter({ hasText: '可用克隆男声' }).click() + await captureScreenshot(page, testInfo, '04-avatar-default-ready-voice-only') + await avatarDialog.getByRole('button', { name: '保存修改' }).click() + await expect.poll(() => requests.avatarUpdate?.voiceCapabilityId).toBe('clone-ready') + expect(requests.avatarUpdate?.voiceSpeed).toBe(0.8) +}) diff --git a/tests/zipvoice-real-lifecycle.spec.ts b/tests/zipvoice-real-lifecycle.spec.ts new file mode 100644 index 0000000..092faec --- /dev/null +++ b/tests/zipvoice-real-lifecycle.spec.ts @@ -0,0 +1,686 @@ +import { randomUUID } from 'node:crypto' + +import type { + APIRequestContext, + APIResponse, + Browser, + BrowserContext, + Page, + TestInfo, +} from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, loginAsAdmin, readAdminCredentials } from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +type JsonRecord = Record +type Headers = Record + +interface Activity { + module: string + action: string + result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface WaveInfo { + sampleRate: number + channels: number + duration: number + dataBytes: number +} + +interface CloneReferences { + capabilityId: string + sourceFileCode: string + currentJobId: string +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData( + response: APIResponse, + expected: number | number[], + label: string, +) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + let body: unknown + try { + body = await response.json() + } catch { + throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) + } + return asRecord(envelopeData(body)) +} + +function addActivity( + activities: Activity[], + module: string, + action: string, + result: Activity['result'], + options: { response?: APIResponse; resourceId?: string; detail?: string } = {}, +) { + activities.push({ + module, + action, + result, + httpStatus: options.response?.status(), + resourceId: options.resourceId, + detail: options.detail, + }) +} + +function parseWave(buffer: Buffer): WaveInfo { + if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') { + throw new Error('下载结果不是有效的 RIFF/WAVE 文件') + } + let offset = 12 + let sampleRate = 0 + let channels = 0 + let byteRate = 0 + let dataBytes = 0 + while (offset + 8 <= buffer.length) { + const chunkId = buffer.toString('ascii', offset, offset + 4) + const chunkLength = buffer.readUInt32LE(offset + 4) + const dataOffset = offset + 8 + if (dataOffset + chunkLength > buffer.length) break + if (chunkId === 'fmt ' && chunkLength >= 16) { + channels = buffer.readUInt16LE(dataOffset + 2) + sampleRate = buffer.readUInt32LE(dataOffset + 4) + byteRate = buffer.readUInt32LE(dataOffset + 8) + } else if (chunkId === 'data') { + dataBytes += chunkLength + } + offset = dataOffset + chunkLength + (chunkLength % 2) + } + if (!sampleRate || !channels || !byteRate || !dataBytes) throw new Error('WAV 缺少 fmt 或 data 数据块') + return { sampleRate, channels, duration: dataBytes / byteRate, dataBytes } +} + +function safeAudioUrl(value: unknown, baseURL: string) { + const raw = String(value || '').trim() + if (!raw) throw new Error('TTS 响应缺少 audioUrl') + if (raw.startsWith('data:')) throw new Error('真实生命周期测试不接受 data URL 或前端伪造音频') + return new URL(raw, `${baseURL}/`).toString() +} + +function signedFileCode(value: unknown, baseURL: string) { + const pathname = new URL(safeAudioUrl(value, baseURL)).pathname + return decodeURIComponent(pathname.match(/\/files\/([^/]+)\/(?:content|download)$/)?.[1] || '') +} + +function voiceGender(value: unknown): 'MALE' | 'FEMALE' | null { + const normalized = String(value || '').trim().toLowerCase() + if (normalized === 'male' || normalized === '男') return 'MALE' + if (normalized === 'female' || normalized === '女') return 'FEMALE' + return null +} + +function avatarGender(value: unknown): 'MALE' | 'FEMALE' | null { + const normalized = String(value || '').trim().toLowerCase() + if (normalized === 'male' || normalized === '男') return 'MALE' + if (normalized === 'female' || normalized === '女') return 'FEMALE' + return null +} + +async function downloadWave( + request: APIRequestContext, + url: string, + label: string, +) { + const response = await request.get(url) + expect(response.status(), `${label}:HTTP ${response.status()}`).toBe(200) + const contentType = String(response.headers()['content-type'] || '').toLowerCase() + expect(contentType, `${label}应返回音频内容`).toContain('audio') + const buffer = await response.body() + const wave = parseWave(buffer) + expect(wave.sampleRate).toBeGreaterThan(0) + expect(wave.dataBytes).toBeGreaterThan(0) + return { response, buffer, wave } +} + +async function createAuthenticatedContext(browser: Browser, baseURL: string) { + const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const page = await context.newPage() + await loginAsAdmin(page) + return { context, page } +} + +async function listCapabilities( + request: APIRequestContext, + headers: Headers, + keyword: string, +) { + const response = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'VOICE_CLONE', keyword, page: '1', pageSize: '200' }, + }) + const payload = await responseData(response, 200, '读取声音克隆能力列表') + return { response, items: asRecords(payload.items ?? payload.records) } +} + +async function waitForCloneTerminal( + request: APIRequestContext, + headers: Headers, + capabilityId: string, + jobId: string, + timeoutMs = 12 * 60_000, +) { + const deadline = Date.now() + timeoutMs + let latestCapability: JsonRecord = {} + let latestJob: JsonRecord = {} + const observedProgress = new Set() + while (Date.now() < deadline) { + const [capabilityResponse, jobResponse] = await Promise.all([ + request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }), + request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }), + ]) + latestCapability = await responseData(capabilityResponse, 200, '轮询声音克隆能力') + latestJob = await responseData(jobResponse, 200, '轮询声音克隆任务') + observedProgress.add(Number(latestJob.progress || 0)) + const cloneStatus = String(latestCapability.cloneStatus || '').toUpperCase() + const jobStatus = String(latestJob.status || '').toLowerCase() + if (cloneStatus === 'READY' && jobStatus === 'succeeded') { + return { capability: latestCapability, job: latestJob, observedProgress: [...observedProgress] } + } + if (cloneStatus === 'FAILED' || ['failed', 'cancelled'].includes(jobStatus)) { + const errorCode = String(latestJob.errorCode || '') + const errorMessage = String(latestCapability.errorMessage || latestJob.errorMessage || latestJob.stage || jobStatus) + throw new Error(`ZipVoice 真实克隆失败:${errorCode ? `${errorCode} / ` : ''}${errorMessage}`) + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + throw new Error( + `ZipVoice 在 ${Math.round(timeoutMs / 1000)} 秒内未完成;` + + `能力状态=${String(latestCapability.cloneStatus || 'unknown')},` + + `任务状态=${String(latestJob.status || 'unknown')},阶段=${String(latestJob.stage || 'unknown')}`, + ) +} + +async function deleteCapability( + request: APIRequestContext, + headers: Headers, + capabilityId: string, +) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) + if (detailResponse.status() === 404) return detailResponse + const detail = await responseData(detailResponse, 200, '清理前刷新克隆音色版本') + const response = await request.delete(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion || 0)) }, + }) + if ([200, 404].includes(response.status())) return response + if (response.status() !== 409 || attempt === 2) { + const message = await response.text().catch(() => '') + throw new Error(`删除克隆音色失败:HTTP ${response.status()} ${message.slice(0, 240)}`) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('删除克隆音色失败') +} + +async function settleCloneJob( + request: APIRequestContext, + headers: Headers, + jobId: string, +) { + if (!jobId) return + const firstResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) + if (firstResponse.status() === 404) return + const first = await responseData(firstResponse, 200, '清理前读取声音克隆任务') + let status = String(first.status || '').toLowerCase() + if (['queued', 'running'].includes(status)) { + const cancelResponse = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers }) + if (![200, 409].includes(cancelResponse.status())) { + throw new Error(`取消活动声音克隆任务失败:HTTP ${cancelResponse.status()}`) + } + } + const deadline = Date.now() + 90_000 + while (Date.now() < deadline) { + const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) + if (response.status() === 404) return + const current = await responseData(response, 200, '等待声音克隆任务退出活动状态') + status = String(current.status || '').toLowerCase() + if (!['queued', 'running'].includes(status)) return + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`声音克隆任务 ${jobId} 未在清理时限内退出活动状态`) +} + +async function cleanupOwnedResources( + request: APIRequestContext, + headers: Headers, + capabilityName: string, + references: CloneReferences, + activities: Activity[], + cleanupErrors: string[], +) { + const capabilityIds = new Set() + const sourceFileCodes = new Set() + const jobIds = new Set() + if (references.capabilityId) capabilityIds.add(references.capabilityId) + if (references.sourceFileCode) sourceFileCodes.add(references.sourceFileCode) + if (references.currentJobId) jobIds.add(references.currentJobId) + + try { + const { items } = await listCapabilities(request, headers, capabilityName) + for (const item of items) { + if (String(item.name || '') !== capabilityName) continue + const capabilityId = String(item.id || '') + if (capabilityId) capabilityIds.add(capabilityId) + const sourceFileCode = String(item.mediaFileCode || '') + if (sourceFileCode) sourceFileCodes.add(sourceFileCode) + const currentJobId = String(item.currentJobId || '') + if (currentJobId) jobIds.add(currentJobId) + } + for (const capabilityId of capabilityIds) { + const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) + if (detailResponse.status() === 404) continue + const detail = await responseData(detailResponse, 200, '清理前读取克隆音色详情') + const sourceFileCode = String(detail.mediaFileCode || '') + if (sourceFileCode) sourceFileCodes.add(sourceFileCode) + const currentJobId = String(detail.currentJobId || '') + if (currentJobId) jobIds.add(currentJobId) + } + } catch (error) { + const message = `发现测试资源失败:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + addActivity(activities, '声音克隆', '发现需要清理的测试资源', 'CLEANUP_FAILED', { detail: message }) + } + + for (const jobId of jobIds) { + try { + await settleCloneJob(request, headers, jobId) + addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANED', { resourceId: jobId }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + cleanupErrors.push(message) + addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANUP_FAILED', { + resourceId: jobId, + detail: message, + }) + } + } + + for (const capabilityId of capabilityIds) { + try { + const response = await deleteCapability(request, headers, capabilityId) + addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANED', { + response, + resourceId: capabilityId, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + cleanupErrors.push(message) + addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANUP_FAILED', { + resourceId: capabilityId, + detail: message, + }) + } + } + + for (const sourceFileCode of sourceFileCodes) { + try { + const response = await request.delete(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers }) + if (![200, 404].includes(response.status())) throw new Error(`删除参考源文件失败:HTTP ${response.status()}`) + addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANED', { + response, + resourceId: sourceFileCode, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + cleanupErrors.push(message) + addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANUP_FAILED', { + resourceId: sourceFileCode, + detail: message, + }) + } + } + + try { + const { items } = await listCapabilities(request, headers, capabilityName) + const exactResidual = items.filter((item) => String(item.name || '') === capabilityName) + if (exactResidual.length) throw new Error(`能力列表仍有 ${exactResidual.length} 条同名测试资源`) + for (const capabilityId of capabilityIds) { + const detail = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) + if (detail.status() !== 404) throw new Error(`已删除能力仍可读取:HTTP ${detail.status()}`) + } + const voicesResponse = await request.get('/api/v1/tts/voices', { headers }) + const voices = await responseData(voicesResponse, 200, '清理后刷新 TTS 音色目录') + if (asRecords(voices.items).some((item) => capabilityIds.has(String(item.capabilityId || '')))) { + throw new Error('已删除克隆音色仍残留在 TTS 音色目录') + } + for (const sourceFileCode of sourceFileCodes) { + const source = await request.get(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers }) + if (source.status() !== 404) throw new Error(`已删除参考源文件仍可读取:HTTP ${source.status()}`) + } + if (jobIds.size) { + const activeResponse = await request.get('/api/v1/jobs', { + headers, + params: { status: 'ACTIVE', kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' }, + }) + const active = await responseData(activeResponse, 200, '清理后检查活动声音克隆任务') + if (asRecords(active.items).some((item) => jobIds.has(String(item.id || '')))) { + throw new Error('声音克隆任务仍处于活动状态') + } + } + addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'PASS') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + cleanupErrors.push(message) + addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'CLEANUP_FAILED', { detail: message }) + } +} + +async function attachLifecycleReport( + testInfo: TestInfo, + activities: Activity[], + cleanupErrors: string[], + progress: number[], +) { + const report = { + runId, + runPrefix, + title: 'ZipVoice 真实 CPU 声音克隆生命周期', + activities, + observedProgress: progress, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + browserIndependence: '创建页关闭后由服务端常驻 worker 继续处理,并在新的浏览器上下文读取结果。', + retention: 'TTS 合成记录和已完成任务作为不可变审计历史保留;测试名称不写入 TTS 文本或任务输入。', + security: '附件不记录账号密码、访问令牌、Cookie、上传内容或带签名的音频 URL。', + } + const serialized = JSON.stringify(report) + const credentials = readAdminCredentials() + expect(serialized).not.toContain(credentials.password) + await attachJson(testInfo, 'ZipVoice-真实生命周期与清理', report) +} + +test('ZipVoice 真实生命周期:服务端异步克隆、跨浏览器续作、TTS 合成与页面可选', async ({ browser, request }, testInfo) => { + test.setTimeout(15 * 60_000) + + const baseURL = String(testInfo.project.use.baseURL || process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '') + const capabilityName = `${runPrefix}-ZIPVOICE-REAL`.slice(0, 100) + const referenceText = '请确认设备已经停机断电并完成泄压,随后按照操作规程逐项检查工具和安全防护用品。' + const validationText = '声音克隆真实合成验证已经完成。' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const references: CloneReferences = { capabilityId: '', sourceFileCode: '', currentJobId: '' } + const progress: number[] = [] + let creationContext: BrowserContext | null = null + let jobsContext: BrowserContext | null = null + let verificationContext: BrowserContext | null = null + let bodyFailed = false + + const headers = await authHeaders(request) + + try { + const preCleanupErrors: string[] = [] + await cleanupOwnedResources( + request, + headers, + capabilityName, + { capabilityId: '', sourceFileCode: '', currentJobId: '' }, + activities, + preCleanupErrors, + ) + if (preCleanupErrors.length) { + throw new Error(`无法清理同一运行标识的历史测试资源:${preCleanupErrors.join(';')}`) + } + + const catalogResponse = await request.get('/api/v1/tts/voices', { headers }) + const catalog = await responseData(catalogResponse, 200, '读取真实 TTS 音色目录') + const vitsVoices = asRecords(catalog.items).filter((item) => String(item.runtimeMode || '').toUpperCase() === 'VITS') + expect(vitsVoices.length, '至少需要一个可用的 VITS 内置音色来生成自有参考音频').toBeGreaterThan(0) + + const avatarsResponse = await request.get('/api/v1/avatars', { + headers, + params: { page: '1', pageSize: '200' }, + }) + const avatarsPage = await responseData(avatarsResponse, 200, '读取可编辑数字形象') + const editableAvatars = asRecords(avatarsPage.items ?? avatarsPage.records).filter((item) => ( + item.builtIn !== true + && String(item.sourceType || '').toLowerCase() !== 'built_in' + && avatarGender(item.gender) !== null + )) + const voiceAndAvatar = vitsVoices + .map((voice) => ({ + voice, + gender: voiceGender(voice.gender), + avatar: editableAvatars.find((avatar) => avatarGender(avatar.gender) === voiceGender(voice.gender)), + })) + .find((item) => item.gender && item.avatar) + expect(voiceAndAvatar, '需要至少一个与可用 VITS 音色同性别的可编辑数字形象,用于验证默认音色下拉').toBeTruthy() + const builtInVoice = voiceAndAvatar!.voice + const cloneGender = voiceAndAvatar!.gender! + const targetAvatar = voiceAndAvatar!.avatar! + + const authenticated = await createAuthenticatedContext(browser, baseURL) + creationContext = authenticated.context + const creationPage = authenticated.page + await creationPage.goto('/assets/voice-clones') + await expect(creationPage.getByRole('heading', { name: '声音克隆' })).toBeVisible() + + const referenceResponse = await request.post('/api/v1/tts', { + headers, + data: { + text: referenceText, + voiceCapabilityId: String(builtInVoice.capabilityId), + speed: 0.8, + }, + timeout: 120_000, + }) + const referenceSpeech = await responseData(referenceResponse, 200, '使用 VITS 生成自有参考音频') + expect(String(referenceSpeech.runtimeMode || '').toUpperCase()).toBe('VITS') + expect(Number(referenceSpeech.duration || 0), '参考音频必须至少 5 秒').toBeGreaterThanOrEqual(5) + expect(Number(referenceSpeech.duration || 0), '参考音频必须不超过声音克隆 30 秒上限').toBeLessThanOrEqual(30) + const referenceAudioUrl = safeAudioUrl(referenceSpeech.audioUrl, baseURL) + const referenceAudioFileCode = signedFileCode(referenceSpeech.audioUrl, baseURL) + const referenceWave = await downloadWave(request, referenceAudioUrl, '下载 VITS 参考 WAV') + expect(referenceWave.wave.duration).toBeGreaterThanOrEqual(4.8) + addActivity(activities, '参考音频', 'VITS 真实生成并下载自有 WAV', 'PASS', { + response: referenceResponse, + resourceId: referenceAudioFileCode || undefined, + detail: `${referenceWave.wave.sampleRate}Hz / ${referenceWave.wave.channels}ch / ${referenceWave.wave.duration.toFixed(2)}s`, + }) + + const uploadResponse = await request.post('/api/v1/files', { + headers, + multipart: { + purpose: 'CAPABILITY_SOURCE', + file: { + name: `${runPrefix}-zipvoice-reference.wav`, + mimeType: 'audio/wav', + buffer: referenceWave.buffer, + }, + }, + }) + const uploaded = await responseData(uploadResponse, 201, '上传声音克隆参考 WAV') + references.sourceFileCode = String(uploaded.id || uploaded.fileCode || '') + expect(references.sourceFileCode).not.toBe('') + addActivity(activities, '声音克隆', '以 CAPABILITY_SOURCE 上传参考 WAV', 'PASS', { + response: uploadResponse, + resourceId: references.sourceFileCode, + }) + + const createResponse = await request.post('/api/v1/capabilities', { + headers: { ...headers, 'Idempotency-Key': randomUUID() }, + data: { + capabilityType: 'VOICE_CLONE', + name: capabilityName, + type: 'SHERPA_ZIPVOICE', + description: '真实 CPU 异步声音克隆生命周期验证', + reference: '服务端 VITS 自有合成参考音频', + mediaFileCode: references.sourceFileCode, + referenceText, + gender: cloneGender, + consentConfirmed: true, + enabled: true, + }, + }) + const created = await responseData(createResponse, 201, '创建 ZipVoice 真实克隆任务') + references.capabilityId = String(created.id || '') + references.currentJobId = String(created.currentJobId || '') + expect(references.capabilityId).not.toBe('') + expect(references.currentJobId).not.toBe('') + expect(['PROCESSING', 'QUEUED']).toContain(String(created.cloneStatus || '').toUpperCase()) + addActivity(activities, '声音克隆', '提交带逐字稿、性别和授权确认的异步克隆任务', 'PASS', { + response: createResponse, + resourceId: references.capabilityId, + }) + + const jobsListResponse = await request.get('/api/v1/jobs', { + headers, + params: { kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' }, + }) + const jobsList = await responseData(jobsListResponse, 200, '立即读取制作队列') + expect(asRecords(jobsList.items).some((item) => String(item.id || '') === references.currentJobId)).toBeTruthy() + const immediateJobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}`, { headers }) + const immediateJob = await responseData(immediateJobResponse, 200, '立即读取声音克隆任务详情') + expect(String(immediateJob.kind || '').toLowerCase()).toBe('voice_clone_register') + addActivity(activities, '制作队列', '提交后任务立即可见', 'PASS', { + response: immediateJobResponse, + resourceId: references.currentJobId, + detail: `${String(immediateJob.status || '')} / ${Number(immediateJob.progress || 0)}%`, + }) + + // The page that existed when the task was submitted is deliberately closed. + // From this point onward only the API process and its durable worker remain. + await creationContext.close() + creationContext = null + + const jobsSession = await createAuthenticatedContext(browser, baseURL) + jobsContext = jobsSession.context + const jobsPage = jobsSession.page + await jobsPage.goto(`/jobs?scope=all&jobId=${encodeURIComponent(references.currentJobId)}`) + const jobDialog = jobsPage.locator('.admin-form-dialog:visible').last() + await expect(jobDialog.getByRole('heading', { name: '任务详情' })).toBeVisible({ timeout: 30_000 }) + await expect(jobDialog).toContainText(references.currentJobId) + await expect(jobDialog).toContainText('声音克隆') + await captureScreenshot(jobsPage, testInfo, '01-zipvoice-real-job-after-creator-closed') + await jobsContext.close() + jobsContext = null + + const terminal = await waitForCloneTerminal( + request, + headers, + references.capabilityId, + references.currentJobId, + ) + progress.push(...terminal.observedProgress) + expect(String(terminal.capability.voiceRuntimeMode || '').toUpperCase()).toBe('ZIPVOICE_READY') + expect(String(terminal.capability.validationStatus || '').toUpperCase()).toBe('AVAILABLE') + expect(String(terminal.job.status || '').toLowerCase()).toBe('succeeded') + expect(String(terminal.capability.previewFileCode || '')).not.toBe('') + addActivity(activities, '声音克隆', '创建页关闭后服务端 worker 独立完成真实合成验收', 'PASS', { + resourceId: references.currentJobId, + detail: `READY / progress=${terminal.observedProgress.join('→')}`, + }) + + const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}/events`, { headers }) + const events = await responseData(eventsResponse, 200, '读取声音克隆任务事件') + const eventItems = asRecords(events.items) + expect(eventItems.length).toBeGreaterThan(1) + expect(eventItems.some((item) => String(item.status || '').toLowerCase() === 'succeeded')).toBeTruthy() + addActivity(activities, '制作队列', '持久化事件流包含成功终态', 'PASS', { + response: eventsResponse, + resourceId: references.currentJobId, + detail: `${eventItems.length} events`, + }) + + const readyCatalogResponse = await request.get('/api/v1/tts/voices', { headers }) + const readyCatalog = await responseData(readyCatalogResponse, 200, '刷新 TTS 音色目录') + const clonedVoice = asRecords(readyCatalog.items).find((item) => String(item.capabilityId || '') === references.capabilityId) + expect(clonedVoice, 'READY 克隆音色必须进入统一 /tts/voices 目录').toBeTruthy() + expect(String(clonedVoice!.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE') + expect(clonedVoice!.speaker == null).toBeTruthy() + addActivity(activities, '统一音色目录', 'READY 克隆音色按 capabilityId 可用且不伪造 speaker', 'PASS', { + response: readyCatalogResponse, + resourceId: references.capabilityId, + }) + + const clonedSpeechResponse = await request.post('/api/v1/tts', { + headers, + data: { text: validationText, voiceCapabilityId: references.capabilityId, speed: 0.9 }, + timeout: 120_000, + }) + const clonedSpeech = await responseData(clonedSpeechResponse, 200, '使用克隆 capabilityId 真实合成') + expect(String(clonedSpeech.voiceCapabilityId || '')).toBe(references.capabilityId) + expect(String(clonedSpeech.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE') + expect(clonedSpeech.speaker == null).toBeTruthy() + const clonedAudioUrl = safeAudioUrl(clonedSpeech.audioUrl, baseURL) + const clonedWave = await downloadWave(request, clonedAudioUrl, '下载 ZipVoice 合成 WAV') + expect(clonedWave.wave.duration).toBeGreaterThan(0.5) + addActivity(activities, 'TTS', '克隆 capabilityId 真实合成并下载有效 WAV', 'PASS', { + response: clonedSpeechResponse, + detail: `${clonedWave.wave.sampleRate}Hz / ${clonedWave.wave.channels}ch / ${clonedWave.wave.duration.toFixed(2)}s`, + }) + + const verification = await createAuthenticatedContext(browser, baseURL) + verificationContext = verification.context + const verificationPage = verification.page + await verificationPage.goto('/assets/voice-clones') + const voiceSearch = verificationPage.getByPlaceholder('搜索名称或说明') + await voiceSearch.fill(capabilityName) + await verificationPage.getByRole('button', { name: '搜索', exact: true }).click() + const readyCard = verificationPage.locator('.capability-card').filter({ hasText: capabilityName }).first() + await expect(readyCard).toBeVisible({ timeout: 30_000 }) + await expect(readyCard).toContainText('克隆音色可用') + await captureScreenshot(verificationPage, testInfo, '02-zipvoice-real-ready-card-new-context') + addActivity(activities, '声音克隆页面', '新浏览器上下文展示 READY 克隆音色', 'PASS', { + resourceId: references.capabilityId, + }) + + await verificationPage.goto('/assets/avatars') + const avatarName = String(targetAvatar.name || '') + expect(avatarName).not.toBe('') + await verificationPage.getByPlaceholder('搜索形象名称').fill(avatarName) + await verificationPage.getByRole('button', { name: '搜索', exact: true }).click() + const avatarCard = verificationPage.locator('.avatar-card').filter({ hasText: avatarName }).first() + await expect(avatarCard).toBeVisible({ timeout: 30_000 }) + await avatarCard.getByRole('button', { name: '编辑' }).click() + const avatarDialog = verificationPage.locator('.admin-form-dialog:visible').last() + await expect(avatarDialog.getByRole('heading', { name: '编辑数字人' })).toBeVisible() + const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select') + await expect(voiceSelect).not.toHaveClass(/is-disabled/, { timeout: 30_000 }) + await voiceSelect.click() + const voiceDropdown = verificationPage.locator('.el-select-dropdown:visible').last() + const cloneOption = voiceDropdown.locator('.el-select-dropdown__item').filter({ hasText: capabilityName }) + await expect(cloneOption).toBeVisible({ timeout: 30_000 }) + await expect(cloneOption).toContainText('克隆') + await captureScreenshot(verificationPage, testInfo, '03-avatar-default-voice-selects-real-zipvoice') + addActivity(activities, '数字形象页面', '编辑下拉从统一目录选择 READY 克隆音色', 'PASS', { + resourceId: references.capabilityId, + detail: `${cloneGender} / 未保存,不修改现有数字形象`, + }) + } catch (error) { + bodyFailed = true + throw error + } finally { + await creationContext?.close().catch(() => undefined) + await jobsContext?.close().catch(() => undefined) + await verificationContext?.close().catch(() => undefined) + await cleanupOwnedResources( + request, + headers, + capabilityName, + references, + activities, + cleanupErrors, + ) + await attachLifecycleReport(testInfo, activities, cleanupErrors, progress) + if (!bodyFailed && cleanupErrors.length) { + throw new Error(`ZipVoice 真实生命周期验证通过,但清理失败:${cleanupErrors.join(';')}`) + } + } +})