| @@ -21,6 +21,10 @@ E2E_ACCOUNT_FILE=../账号.MD | |||||
| # 可选:用同一隔离全栈只复测指定文件,多个文件用英文逗号分隔。 | # 可选:用同一隔离全栈只复测指定文件,多个文件用英文逗号分隔。 | ||||
| # APE2E_TEST_FILES=tests/operations-ui-lifecycle.spec.ts | # APE2E_TEST_FILES=tests/operations-ui-lifecycle.spec.ts | ||||
| # 可选:真实数字人 Viewer 使用 H.264 素材;本机 Edge/Chrome 支持该编码, | |||||
| # Playwright 自带 Chromium 不含 H.264。验收数字人播放与录制时建议使用 Edge。 | |||||
| # E2E_BROWSER_CHANNEL=msedge | |||||
| # 可选:允许执行依赖外部数字人、FFmpeg 或安全测试地址的条件用例。 | # 可选:允许执行依赖外部数字人、FFmpeg 或安全测试地址的条件用例。 | ||||
| # E2E_SAFE_TOOL_URL=https://example.invalid | # E2E_SAFE_TOOL_URL=https://example.invalid | ||||
| # E2E_ALLOW_EXTERNAL_MEDIA=false | # E2E_ALLOW_EXTERNAL_MEDIA=false | ||||
| @@ -1,5 +1,6 @@ | |||||
| node_modules/ | node_modules/ | ||||
| .env | .env | ||||
| artifacts/runs/ | artifacts/runs/ | ||||
| artifacts/deploy-smoke/ | |||||
| playwright-report/ | playwright-report/ | ||||
| test-results/ | test-results/ | ||||
| @@ -23,6 +23,7 @@ const safeRunId = (process.env.APE2E_RUN_ID || 'manual') | |||||
| .slice(0, 80) || 'manual' | .slice(0, 80) || 'manual' | ||||
| const runDirectory = path.resolve('artifacts', 'runs', safeRunId) | const runDirectory = path.resolve('artifacts', 'runs', safeRunId) | ||||
| const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:8003').replace(/\/$/, '') | const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:8003').replace(/\/$/, '') | ||||
| const browserChannel = process.env.E2E_BROWSER_CHANNEL?.trim() | |||||
| export default defineConfig({ | export default defineConfig({ | ||||
| testDir: './tests', | testDir: './tests', | ||||
| @@ -49,5 +50,11 @@ export default defineConfig({ | |||||
| trace: 'off', | trace: 'off', | ||||
| video: 'off', | video: 'off', | ||||
| }, | }, | ||||
| projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], | |||||
| projects: [{ | |||||
| name: 'chromium', | |||||
| use: { | |||||
| ...devices['Desktop Chrome'], | |||||
| ...(browserChannel ? { channel: browserChannel } : {}), | |||||
| }, | |||||
| }], | |||||
| }) | }) | ||||
| @@ -87,12 +87,15 @@ const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots')) | |||||
| ? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort() | ? 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 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 = [ | const lines = [ | ||||
| '# AI Person 全量 E2E 测试报告', '', | '# AI Person 全量 E2E 测试报告', '', | ||||
| `- 批次:\`${runId}\``, | `- 批次:\`${runId}\``, | ||||
| `- 生成时间:${now}`, | `- 生成时间:${now}`, | ||||
| `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``, | `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``, | ||||
| '- 浏览器:Playwright Chromium(单 worker,真实 API/数据库;不使用业务 Mock)', | |||||
| `- 浏览器:${browserLabel}(单 worker,真实 API/数据库;不使用业务 Mock)`, | |||||
| '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '', | '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '', | ||||
| '## 结果汇总', '', | '## 结果汇总', '', | ||||
| '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |', | '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |', | ||||
| @@ -245,6 +245,8 @@ test('数字形象、七区配置、发布、公开问答、反馈、停用与 | |||||
| headers, | headers, | ||||
| multipart: { | multipart: { | ||||
| name: `${runPrefix}-维修教员形象`, | name: `${runPrefix}-维修教员形象`, | ||||
| specialty: '机械维修教学', | |||||
| gender: '男', | |||||
| resize: 'true', | resize: 'true', | ||||
| matting: 'off', | matting: 'off', | ||||
| file: { | file: { | ||||
| @@ -8,7 +8,16 @@ import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from '. | |||||
| import { authHeaders, envelopeData, loginAsAdmin } from './helpers' | import { authHeaders, envelopeData, loginAsAdmin } from './helpers' | ||||
| test.describe.configure({ mode: 'serial' }) | 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<string, unknown> | type JsonRecord = Record<string, unknown> | ||||
| type Headers = Record<string, string> | type Headers = Record<string, string> | ||||
| @@ -204,6 +213,24 @@ function avatarCard(page: Page, name: string) { | |||||
| return page.locator('.avatar-card').filter({ hasText: name }).first() | 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) { | function capabilityCard(page: Page, name: string) { | ||||
| return page.locator('.capability-card').filter({ hasText: name }).first() | 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() | 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) { | 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) | 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 headers = await authHeaders(request) | ||||
| const activities: Activity[] = [] | const activities: Activity[] = [] | ||||
| const cleanupErrors: string[] = [] | const cleanupErrors: string[] = [] | ||||
| @@ -269,59 +296,150 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| } | } | ||||
| const { items: visibleAvatars } = await listAvatars(request, headers) | 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<string, unknown> | undefined | |||||
| let sourceResponse: Awaited<ReturnType<typeof request.get>> | 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 previewName = String(previewAvatar?.name ?? '') | ||||
| const nameSearch = page.getByPlaceholder('搜索数字人名称') | |||||
| const nameSearch = page.getByPlaceholder('搜索形象名称') | |||||
| await nameSearch.fill(previewName) | 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() | await expect(avatarCard(page, previewName)).toBeVisible() | ||||
| const previewGender = String(previewAvatar?.gender ?? '') | const previewGender = String(previewAvatar?.gender ?? '') | ||||
| if (previewGender === '男' || previewGender === '女') { | 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 expect(avatarCard(page, previewName)).toBeVisible() | ||||
| } | } | ||||
| await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter') | await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter') | ||||
| addPass(activities, '数字形象', '名称与性别组合筛选') | 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 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() | 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 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 expect(createDialog).toBeVisible() | ||||
| await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo) | 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('details.avatar-create-advanced summary').click() | ||||
| await createDialog.locator('select').selectOption('off') | 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 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 nameSearch.fill(originalName) | ||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await waitForAvatarGridReady(page) | |||||
| const createdCard = avatarCard(page, originalName) | const createdCard = avatarCard(page, originalName) | ||||
| await expect(createdCard).toBeVisible() | await expect(createdCard).toBeVisible() | ||||
| await expect(createdCard).toContainText('可预览') | await expect(createdCard).toContainText('可预览') | ||||
| @@ -330,8 +448,24 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| const avatarId = String(createdAvatar?.id ?? '') | const avatarId = String(createdAvatar?.id ?? '') | ||||
| expect(avatarId).not.toBe('') | expect(avatarId).not.toBe('') | ||||
| expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready') | expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready') | ||||
| expect(String(createdAvatar?.previewFileCode ?? '')).not.toBe('') | |||||
| expect(createdAvatar).toMatchObject({ specialty: '维修教学', gender: '男' }) | |||||
| addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId) | 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() | await createdCard.getByRole('button', { name: '编辑' }).click() | ||||
| const editDialog = page.locator('.admin-form-dialog:visible').last() | const editDialog = page.locator('.admin-form-dialog:visible').last() | ||||
| await editDialog.getByLabel('数字人名称').fill(editedName) | await editDialog.getByLabel('数字人名称').fill(editedName) | ||||
| @@ -347,6 +481,8 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| await waitForToast(page, '数字人信息已更新') | await waitForToast(page, '数字人信息已更新') | ||||
| await nameSearch.fill(editedName) | await nameSearch.fill(editedName) | ||||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||||
| await waitForAvatarGridReady(page) | |||||
| const editedCard = avatarCard(page, editedName) | const editedCard = avatarCard(page, editedName) | ||||
| await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训') | await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训') | ||||
| await expect(editedCard).toContainText('平台共享') | await expect(editedCard).toContainText('平台共享') | ||||
| @@ -376,7 +512,7 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| await expect(avatarCard(page, editedName)).toContainText('可预览') | await expect(avatarCard(page, editedName)).toContainText('可预览') | ||||
| addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId) | 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 waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`)) | ||||
| await expect(avatarCard(page, editedName)).toContainText('默认数字人') | await expect(avatarCard(page, editedName)).toContainText('默认数字人') | ||||
| const defaultPreferences = await getAvatarPreferences(request, headers) | const defaultPreferences = await getAvatarPreferences(request, headers) | ||||
| @@ -396,8 +532,10 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| preferenceSnapshot = null | preferenceSnapshot = null | ||||
| await page.reload() | await page.reload() | ||||
| await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() | 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() | await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click() | ||||
| const deleteBox = page.locator('.el-message-box:visible').last() | const deleteBox = page.locator('.el-message-box:visible').last() | ||||
| @@ -413,9 +551,12 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、 | |||||
| primaryError = error | primaryError = error | ||||
| activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) | activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) | ||||
| } finally { | } finally { | ||||
| // 真实 CPU 数字人生成可能超过一次访问令牌的短有效期;清理阶段重新登录, | |||||
| // 避免主流程失败后因旧 token 留下测试形象或未恢复平台偏好。 | |||||
| const cleanupHeaders = await authHeaders(request).catch(() => headers) | |||||
| if (preferenceSnapshot) { | if (preferenceSnapshot) { | ||||
| try { | 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() }) | activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() }) | ||||
| } catch (error) { | } catch (error) { | ||||
| const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(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 }) | 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) | await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors) | ||||
| } | } | ||||
| @@ -473,7 +614,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||||
| knownFileCodes.add(String(voiceFile.id ?? '')) | knownFileCodes.add(String(voiceFile.id ?? '')) | ||||
| await waitForToast(page, '配置已创建') | await waitForToast(page, '配置已创建') | ||||
| let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明') | |||||
| let search = page.locator('.capability-search input') | |||||
| await search.fill(voiceName) | await search.fill(voiceName) | ||||
| let card = capabilityCard(page, voiceName) | let card = capabilityCard(page, voiceName) | ||||
| await expect(card).toBeVisible() | await expect(card).toBeVisible() | ||||
| @@ -510,12 +651,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||||
| await card.getByRole('button', { name: '停用' }).click() | await card.getByRole('button', { name: '停用' }).click() | ||||
| await waitForToast(page, '已停用') | await waitForToast(page, '已停用') | ||||
| await page.getByLabel('状态筛选').selectOption('disabled') | |||||
| await selectCapabilityStatus(page, '已停用') | |||||
| await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用') | await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用') | ||||
| await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter') | await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter') | ||||
| await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click() | await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click() | ||||
| await waitForToast(page, '已启用') | await waitForToast(page, '已启用') | ||||
| await page.getByLabel('状态筛选').selectOption('all') | |||||
| await selectCapabilityStatus(page, '全部状态') | |||||
| addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId) | addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId) | ||||
| await deleteCapabilityThroughUi(page, voiceEditedName) | await deleteCapabilityThroughUi(page, voiceEditedName) | ||||
| addPass(activities, '声音克隆', '页面删除', undefined, voiceId) | addPass(activities, '声音克隆', '页面删除', undefined, voiceId) | ||||
| @@ -533,7 +674,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||||
| await dialog.getByRole('button', { name: '保存', exact: true }).click() | await dialog.getByRole('button', { name: '保存', exact: true }).click() | ||||
| await waitForToast(page, '配置已创建') | await waitForToast(page, '配置已创建') | ||||
| search = page.getByPlaceholder('搜索语音模型名称、分类或说明') | |||||
| search = page.locator('.capability-search input') | |||||
| await search.fill(ttsName) | await search.fill(ttsName) | ||||
| card = capabilityCard(page, ttsName) | card = capabilityCard(page, ttsName) | ||||
| await expect(card).toBeVisible() | await expect(card).toBeVisible() | ||||
| @@ -587,7 +728,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||||
| knownFileCodes.add(String(sceneFile.id ?? '')) | knownFileCodes.add(String(sceneFile.id ?? '')) | ||||
| await waitForToast(page, '配置已创建') | await waitForToast(page, '配置已创建') | ||||
| search = page.getByPlaceholder('搜索场景素材名称、分类或说明') | |||||
| search = page.locator('.capability-search input') | |||||
| await search.fill(sceneName) | await search.fill(sceneName) | ||||
| card = capabilityCard(page, sceneName) | card = capabilityCard(page, sceneName) | ||||
| await expect(card).toBeVisible() | await expect(card).toBeVisible() | ||||
| @@ -617,12 +758,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||||
| await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) | await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) | ||||
| await card.getByRole('button', { name: '停用' }).click() | await card.getByRole('button', { name: '停用' }).click() | ||||
| await waitForToast(page, '已停用') | await waitForToast(page, '已停用') | ||||
| await page.getByLabel('状态筛选').selectOption('disabled') | |||||
| await selectCapabilityStatus(page, '已停用') | |||||
| await expect(capabilityCard(page, sceneEditedName)).toBeVisible() | await expect(capabilityCard(page, sceneEditedName)).toBeVisible() | ||||
| await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered') | await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered') | ||||
| await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click() | await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click() | ||||
| await waitForToast(page, '已启用') | await waitForToast(page, '已启用') | ||||
| await page.getByLabel('状态筛选').selectOption('all') | |||||
| await selectCapabilityStatus(page, '全部状态') | |||||
| addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId) | addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId) | ||||
| await deleteCapabilityThroughUi(page, sceneEditedName) | await deleteCapabilityThroughUi(page, sceneEditedName) | ||||
| addPass(activities, '场景素材', '页面删除', undefined, sceneId) | addPass(activities, '场景素材', '页面删除', undefined, sceneId) | ||||
| @@ -999,6 +999,8 @@ test.describe('正式业务 API 数据生命周期', () => { | |||||
| headers, | headers, | ||||
| multipart: { | multipart: { | ||||
| name: `${runPrefix}-视频工程依赖形象`, | name: `${runPrefix}-视频工程依赖形象`, | ||||
| specialty: '视频口播教学', | |||||
| gender: '男', | |||||
| resize: 'true', | resize: 'true', | ||||
| matting: 'off', | matting: 'off', | ||||
| file: { | file: { | ||||
| @@ -55,7 +55,7 @@ export async function loginAsAdmin(page: Page) { | |||||
| const password = page.locator('input[name="password"]') | const password = page.locator('input[name="password"]') | ||||
| await username.fill(credentials.username) | await username.fill(credentials.username) | ||||
| await password.fill(credentials.password) | 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).not.toHaveURL(/\/login(?:\?|$)/) | ||||
| await expect(page.locator('.app-shell, .main-layout, .platform-shell').first()).toBeVisible() | await expect(page.locator('.app-shell, .main-layout, .platform-shell').first()).toBeVisible() | ||||
| } | } | ||||
| @@ -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<Record<string, string>> = [] | |||||
| 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<void>, 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) | |||||
| }) | |||||
| @@ -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<void>((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<void>((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') | |||||
| }) | |||||
| @@ -0,0 +1,272 @@ | |||||
| import type { Route } from '@playwright/test' | |||||
| import { captureScreenshot, expect, test } from './fixtures' | |||||
| import { loginAsAdmin } from './helpers' | |||||
| type JsonRecord = Record<string, unknown> | |||||
| 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) | |||||
| }) | |||||
| @@ -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<string, unknown> | |||||
| type Headers = Record<string, string> | |||||
| 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<number>() | |||||
| 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<string>() | |||||
| const sourceFileCodes = new Set<string>() | |||||
| const jobIds = new Set<string>() | |||||
| 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(';')}`) | |||||
| } | |||||
| } | |||||
| }) | |||||