Bläddra i källkod

test: 补充数字人资源与声音克隆验收

main
leiyun 4 veckor sedan
förälder
incheckning
cd5323463a
12 ändrade filer med 1571 tillägg och 51 borttagningar
  1. +4
    -0
      .env.example
  2. +1
    -0
      .gitignore
  3. +8
    -1
      playwright.config.ts
  4. +4
    -1
      scripts/generate-report.mjs
  5. +2
    -0
      tests/agent-public-lifecycle.spec.ts
  6. +189
    -48
      tests/avatar-capabilities-ui.spec.ts
  7. +2
    -0
      tests/business-api-lifecycle.spec.ts
  8. +1
    -1
      tests/helpers.ts
  9. +241
    -0
      tests/prototype-sync-regression.spec.ts
  10. +161
    -0
      tests/sidebar-bootstrap-regression.spec.ts
  11. +272
    -0
      tests/zipvoice-cloning-ui.spec.ts
  12. +686
    -0
      tests/zipvoice-real-lifecycle.spec.ts

+ 4
- 0
.env.example Visa fil

@@ -21,6 +21,10 @@ E2E_ACCOUNT_FILE=../账号.MD
# 可选:用同一隔离全栈只复测指定文件,多个文件用英文逗号分隔。
# APE2E_TEST_FILES=tests/operations-ui-lifecycle.spec.ts

# 可选:真实数字人 Viewer 使用 H.264 素材;本机 Edge/Chrome 支持该编码,
# Playwright 自带 Chromium 不含 H.264。验收数字人播放与录制时建议使用 Edge。
# E2E_BROWSER_CHANNEL=msedge

# 可选:允许执行依赖外部数字人、FFmpeg 或安全测试地址的条件用例。
# E2E_SAFE_TOOL_URL=https://example.invalid
# E2E_ALLOW_EXTERNAL_MEDIA=false


+ 1
- 0
.gitignore Visa fil

@@ -1,5 +1,6 @@
node_modules/
.env
artifacts/runs/
artifacts/deploy-smoke/
playwright-report/
test-results/

+ 8
- 1
playwright.config.ts Visa fil

@@ -23,6 +23,7 @@ const safeRunId = (process.env.APE2E_RUN_ID || 'manual')
.slice(0, 80) || 'manual'
const runDirectory = path.resolve('artifacts', 'runs', safeRunId)
const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:8003').replace(/\/$/, '')
const browserChannel = process.env.E2E_BROWSER_CHANNEL?.trim()

export default defineConfig({
testDir: './tests',
@@ -49,5 +50,11 @@ export default defineConfig({
trace: 'off',
video: 'off',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
projects: [{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
...(browserChannel ? { channel: browserChannel } : {}),
},
}],
})

+ 4
- 1
scripts/generate-report.mjs Visa fil

@@ -87,12 +87,15 @@ const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots'))
? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort()
: []
const now = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'long', timeZone: 'Asia/Shanghai' }).format(new Date())
const browserLabel = process.env.E2E_BROWSER_CHANNEL?.trim()
? `Playwright ${process.env.E2E_BROWSER_CHANNEL.trim()}`
: 'Playwright Chromium'
const lines = [
'# AI Person 全量 E2E 测试报告', '',
`- 批次:\`${runId}\``,
`- 生成时间:${now}`,
`- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``,
'- 浏览器:Playwright Chromium(单 worker,真实 API/数据库;不使用业务 Mock)',
`- 浏览器:${browserLabel}(单 worker,真实 API/数据库;不使用业务 Mock)`,
'- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '',
'## 结果汇总', '',
'| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |',


+ 2
- 0
tests/agent-public-lifecycle.spec.ts Visa fil

@@ -245,6 +245,8 @@ test('数字形象、七区配置、发布、公开问答、反馈、停用与
headers,
multipart: {
name: `${runPrefix}-维修教员形象`,
specialty: '机械维修教学',
gender: '男',
resize: 'true',
matting: 'off',
file: {


+ 189
- 48
tests/avatar-capabilities-ui.spec.ts Visa fil

@@ -8,7 +8,16 @@ import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from '.
import { authHeaders, envelopeData, loginAsAdmin } from './helpers'

test.describe.configure({ mode: 'serial' })
test.use({ trace: 'off', video: 'off' })
test.use({
trace: 'off',
video: 'off',
// Viewer uses WebGL. Playwright's bundled headless Chromium requires this
// explicit opt-in for trusted local/shared test services.
launchOptions: {
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
args: ['--enable-unsafe-swiftshader'],
},
})

type JsonRecord = Record<string, unknown>
type Headers = Record<string, string>
@@ -204,6 +213,24 @@ function avatarCard(page: Page, name: string) {
return page.locator('.avatar-card').filter({ hasText: name }).first()
}

async function waitForAvatarGridReady(page: Page) {
await expect(page.locator('.avatar-grid .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 })
}

async function selectAvatarGender(page: Page, label: string) {
await page.locator('.avatar-gender-filter').click()
await page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
.filter({ hasText: label || '全部性别' })
.click()
}

async function selectCapabilityStatus(page: Page, label: string) {
await page.locator('.capability-status-filter').click()
await page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
.filter({ hasText: label })
.click()
}

function capabilityCard(page: Page, name: string) {
return page.locator('.capability-card').filter({ hasText: name }).first()
}
@@ -212,8 +239,8 @@ function dialogField(dialog: Locator, label: string) {
return dialog.locator('.capability-form > label').filter({ hasText: label }).first()
}

async function waitForToast(page: Page, text: string | RegExp) {
await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible()
async function waitForToast(page: Page, text: string | RegExp, timeout = 15_000) {
await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible({ timeout })
}

async function deleteCapabilityThroughUi(page: Page, name: string) {
@@ -235,8 +262,8 @@ function createVoiceFixture(sourceVideo: string, outputPath: string) {
expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000)
}

test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request }, testInfo) => {
test.setTimeout(480_000)
test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request, context }, testInfo) => {
test.setTimeout(720_000)
const headers = await authHeaders(request)
const activities: Activity[] = []
const cleanupErrors: string[] = []
@@ -269,59 +296,150 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
}

const { items: visibleAvatars } = await listAvatars(request, headers)
const previewAvatar = visibleAvatars.find((item) => [
'a1000000000000000000000000000001',
'a1000000000000000000000000000002',
'a1000000000000000000000000000003',
'a1000000000000000000000000000004',
].includes(String(item.id ?? '')))
expect(previewAvatar, '平台应保留至少一个可播放口播样片的内置形象').toBeTruthy()
let previewAvatar: Record<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 nameSearch = page.getByPlaceholder('搜索数字人名称')
const nameSearch = page.getByPlaceholder('搜索形象名称')
await nameSearch.fill(previewName)
const backendSearch = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname.endsWith('/api/v1/avatars') && url.searchParams.get('keyword') === previewName
})
await page.getByRole('button', { name: '搜索', exact: true }).click()
expect((await backendSearch).status()).toBe(200)
await waitForAvatarGridReady(page)
await expect(avatarCard(page, previewName)).toBeVisible()
const previewGender = String(previewAvatar?.gender ?? '')
if (previewGender === '男' || previewGender === '女') {
await page.locator('.avatar-gender-filter select').selectOption(previewGender)
await selectAvatarGender(page, previewGender)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
await expect(avatarCard(page, previewName)).toBeVisible()
}
await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter')
addPass(activities, '数字形象', '名称与性别组合筛选')

await avatarCard(page, previewName).getByRole('button', { name: '预览口播' }).click()
await avatarCard(page, previewName).getByRole('button', { name: '预览', exact: true }).click()
const previewDialog = page.locator('.avatar-preview-dialog:visible').last()
const previewVideo = previewDialog.locator('video')
await expect(previewVideo).toBeVisible()
await expect(previewVideo).toHaveAttribute('src', /avatar-previews\/.+\.mp4/)
const previewSource = String(await previewVideo.getAttribute('src'))
const previewPoster = String(await previewVideo.getAttribute('poster'))
const sourceResponse = await request.get(previewSource)
expect(sourceResponse.status()).toBe(200)
expect(sourceResponse.headers()['content-type']).toContain('video/mp4')
const posterResponse = await request.get(previewPoster)
expect(posterResponse.status()).toBe(200)
expect(posterResponse.headers()['content-type']).toMatch(/^image\//)
await captureScreenshot(page, testInfo, '02-builtin-avatar-real-video-preview')
addPass(activities, '数字形象', '内置口播真实视频与封面预览', sourceResponse, String(previewAvatar?.id ?? ''), '已校验真实 MP4/封面响应;Playwright Chromium 的 H.264 解码能力不作为服务端文件可用性判据')
const builtInPreviewVideo = previewDialog.locator('video')
await expect(builtInPreviewVideo).toBeVisible()
await expect(builtInPreviewVideo).toHaveAttribute('src', /\/avatar-previews\/.+\.mp4/)
await expect(previewDialog.locator('iframe')).toHaveCount(0)
await expect(previewDialog.getByText('内置形象样片')).toBeVisible()
expect(sourceResponse?.headers()['content-type']).toContain('text/html')
await captureScreenshot(page, testInfo, '02-builtin-avatar-mp4-preview')
addPass(activities, '数字形象', '已有内置 MP4 时直接播放,Viewer 仅作为无视频回退', sourceResponse, String(previewAvatar?.id ?? ''))
await previewDialog.locator('.el-dialog__headerbtn').click()

const viewerFallbackAvatar = visibleAvatars.find((item) => Boolean(
item.bundleReady
&& item.viewerUrl
&& item.sourceType !== 'built_in'
&& !item.previewFileCode
&& !item.previewUrl,
))
if (viewerFallbackAvatar) {
const viewerFallbackName = String(viewerFallbackAvatar.name ?? '')
await nameSearch.fill(viewerFallbackName)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
const viewerFallbackCard = avatarCard(page, viewerFallbackName)
await expect(viewerFallbackCard.getByRole('button', { name: '生成预览', exact: true })).toBeVisible()
await viewerFallbackCard.locator('.avatar-cover').click()
const fallbackDialog = page.locator('.avatar-preview-dialog:visible').last()
await expect(fallbackDialog.locator('video')).toHaveCount(0)
await expect(fallbackDialog.locator('iframe')).toHaveAttribute('src', String(viewerFallbackAvatar.viewerUrl))
await expect(fallbackDialog.getByText('实际数字人服务 Viewer')).toBeVisible()
await captureScreenshot(page, testInfo, '02b-viewer-fallback-without-mp4')
addPass(activities, '数字形象', '无 MP4 素材时回退真实 Viewer', undefined, String(viewerFallbackAvatar.id ?? ''))
await fallbackDialog.locator('.el-dialog__headerbtn').click()
} else {
addPass(activities, '数字形象', '现有真实资源均已有 MP4,无需 Viewer 回退')
}

await nameSearch.fill('')
await page.locator('.avatar-gender-filter select').selectOption('')
await page.getByRole('button', { name: '创建数字人' }).click()
const createDialog = page.locator('.avatar-create-dialog:visible').last()
await selectAvatarGender(page, '全部性别')
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
const storageSnapshot = await page.evaluate(() => ({
local: Object.fromEntries(Object.entries(localStorage)),
session: Object.fromEntries(Object.entries(sessionStorage)),
}))
const creationPage = await context.newPage()
await creationPage.goto('/login')
await creationPage.evaluate((snapshot) => {
Object.entries(snapshot.local).forEach(([key, value]) => localStorage.setItem(key, String(value)))
Object.entries(snapshot.session).forEach(([key, value]) => sessionStorage.setItem(key, String(value)))
}, storageSnapshot)
await creationPage.goto('/assets/avatars')
await expect(creationPage.getByRole('heading', { name: '数字形象' })).toBeVisible()
await creationPage.getByRole('button', { name: '创建数字人' }).click()
const createDialog = creationPage.locator('.avatar-create-dialog:visible').last()
await expect(createDialog).toBeVisible()
await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo)
await createDialog.locator('.avatar-create-fields input').fill(originalName)
await createDialog.getByLabel('数字人名称').fill(originalName)
await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeDisabled()
await createDialog.locator('.avatar-create-gender-field .el-select').click()
await creationPage.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click()
await createDialog.getByLabel('专业方向').fill('维修教学')
await createDialog.locator('details.avatar-create-advanced summary').click()
await createDialog.locator('select').selectOption('off')
await captureScreenshot(page, testInfo, '03-avatar-real-video-ready-to-submit')
await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeEnabled()
await captureScreenshot(creationPage, testInfo, '03-avatar-real-video-ready-to-submit')
const previewSpeechRequestPromise = creationPage.waitForRequest((candidate) => {
return candidate.method() === 'POST' && /\/api\/v1\/tts(?:\?|$)/.test(candidate.url())
}, { timeout: 480_000 })
await createDialog.getByRole('button', { name: '开始创建' }).click()
await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 240_000 })
await captureScreenshot(page, testInfo, '04-avatar-upstream-job-succeeded')
await createDialog.getByRole('button', { name: '关闭', exact: true }).click()
await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 360_000 })
await captureScreenshot(creationPage, testInfo, '04-avatar-upstream-job-succeeded')
const previewSpeechRequest = await previewSpeechRequestPromise
expect(previewSpeechRequest.postDataJSON()).toMatchObject({
text: `欢迎使用数字人系统,我是${originalName}。`,
speaker: 1,
})
const queueCode = createDialog.locator('.avatar-preview-queue-link code')
await expect(queueCode).toBeVisible({ timeout: 180_000 })
const previewJobId = String(await queueCode.textContent()).trim()
expect(previewJobId).toMatch(/^[a-f0-9]{32}$/)
await expect(createDialog.getByRole('button', { name: '后台运行', exact: true })).toBeVisible()
await captureScreenshot(creationPage, testInfo, '04a-avatar-preview-queue-submitted')
await creationPage.close()

let previewJob: JsonRecord = {}
const jobDeadline = Date.now() + 180_000
while (Date.now() < jobDeadline) {
const response = await request.get(`/api/v1/jobs/${previewJobId}`, { headers })
previewJob = await responseData(response, 200, '浏览器关闭后查询数字人预览制作任务')
if (previewJob.status === 'succeeded' || previewJob.status === 'failed') break
await new Promise((resolve) => setTimeout(resolve, 1000))
}
expect(previewJob).toMatchObject({ kind: 'capture_transcode', status: 'succeeded' })

let backgroundAvatar: JsonRecord | undefined
const attachDeadline = Date.now() + 30_000
while (Date.now() < attachDeadline) {
const listed = await listAvatars(request, headers, originalName)
backgroundAvatar = listed.items.find((item) => String(item.name ?? '') === originalName)
if (String(backgroundAvatar?.previewFileCode ?? '')) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
expect(String(backgroundAvatar?.previewFileCode ?? ''), '关闭浏览器后服务端应自动绑定 MP4 预览').not.toBe('')
addPass(activities, '制作队列', '关闭录制页面后服务端继续封装并自动绑定预览', undefined, previewJobId)

await nameSearch.fill(originalName)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
const createdCard = avatarCard(page, originalName)
await expect(createdCard).toBeVisible()
await expect(createdCard).toContainText('可预览')
@@ -330,8 +448,24 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
const avatarId = String(createdAvatar?.id ?? '')
expect(avatarId).not.toBe('')
expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready')
expect(String(createdAvatar?.previewFileCode ?? '')).not.toBe('')
expect(createdAvatar).toMatchObject({ specialty: '维修教学', gender: '男' })
addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId)

await createdCard.getByRole('button', { name: '预览', exact: true }).click()
const servicePreviewDialog = page.locator('.avatar-preview-dialog:visible').last()
const recordedPreview = servicePreviewDialog.locator('video')
await expect(recordedPreview).toBeVisible()
await expect(recordedPreview).toHaveAttribute('src', /\/api\/v1\/files\/.+\/content\?expires=/)
await expect(servicePreviewDialog.getByText('自动录制预览素材')).toBeVisible()
const recordedPreviewUrl = String(await recordedPreview.getAttribute('src'))
const recordedPreviewResponse = await request.get(recordedPreviewUrl)
expect(recordedPreviewResponse.status()).toBe(200)
expect(recordedPreviewResponse.headers()['content-type']).toContain('video/mp4')
await captureScreenshot(page, testInfo, '04b-avatar-recorded-preview-playback')
addPass(activities, '数字形象', '用户自建形象播放自动录制 MP4 预览', recordedPreviewResponse, avatarId)
await servicePreviewDialog.locator('.el-dialog__headerbtn').click()

await createdCard.getByRole('button', { name: '编辑' }).click()
const editDialog = page.locator('.admin-form-dialog:visible').last()
await editDialog.getByLabel('数字人名称').fill(editedName)
@@ -347,6 +481,8 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
await waitForToast(page, '数字人信息已更新')

await nameSearch.fill(editedName)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
const editedCard = avatarCard(page, editedName)
await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训')
await expect(editedCard).toContainText('平台共享')
@@ -376,7 +512,7 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
await expect(avatarCard(page, editedName)).toContainText('可预览')
addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId)

await avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' }).click()
await avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true }).click()
await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`))
await expect(avatarCard(page, editedName)).toContainText('默认数字人')
const defaultPreferences = await getAvatarPreferences(request, headers)
@@ -396,8 +532,10 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
preferenceSnapshot = null
await page.reload()
await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
await page.getByPlaceholder('搜索数字人名称').fill(editedName)
await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' })).toBeEnabled()
await page.getByPlaceholder('搜索形象名称').fill(editedName)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await waitForAvatarGridReady(page)
await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true })).toBeEnabled()

await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click()
const deleteBox = page.locator('.el-message-box:visible').last()
@@ -413,9 +551,12 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
primaryError = error
activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
} finally {
// 真实 CPU 数字人生成可能超过一次访问令牌的短有效期;清理阶段重新登录,
// 避免主流程失败后因旧 token 留下测试形象或未恢复平台偏好。
const cleanupHeaders = await authHeaders(request).catch(() => headers)
if (preferenceSnapshot) {
try {
const restored = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
const restored = await restoreAvatarPreferences(request, cleanupHeaders, preferenceSnapshot)
activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() })
} catch (error) {
const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}`
@@ -423,7 +564,7 @@ test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、
activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message })
}
}
await cleanupAvatarsByPrefix(request, headers, activities, cleanupErrors, new Set([originalName, editedName]))
await cleanupAvatarsByPrefix(request, cleanupHeaders, activities, cleanupErrors, new Set([originalName, editedName]))
await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors)
}

@@ -473,7 +614,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
knownFileCodes.add(String(voiceFile.id ?? ''))
await waitForToast(page, '配置已创建')

let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明')
let search = page.locator('.capability-search input')
await search.fill(voiceName)
let card = capabilityCard(page, voiceName)
await expect(card).toBeVisible()
@@ -510,12 +651,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编

await card.getByRole('button', { name: '停用' }).click()
await waitForToast(page, '已停用')
await page.getByLabel('状态筛选').selectOption('disabled')
await selectCapabilityStatus(page, '已停用')
await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用')
await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter')
await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click()
await waitForToast(page, '已启用')
await page.getByLabel('状态筛选').selectOption('all')
await selectCapabilityStatus(page, '全部状态')
addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId)
await deleteCapabilityThroughUi(page, voiceEditedName)
addPass(activities, '声音克隆', '页面删除', undefined, voiceId)
@@ -533,7 +674,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await waitForToast(page, '配置已创建')

search = page.getByPlaceholder('搜索语音模型名称、分类或说明')
search = page.locator('.capability-search input')
await search.fill(ttsName)
card = capabilityCard(page, ttsName)
await expect(card).toBeVisible()
@@ -587,7 +728,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
knownFileCodes.add(String(sceneFile.id ?? ''))
await waitForToast(page, '配置已创建')

search = page.getByPlaceholder('搜索场景素材名称、分类或说明')
search = page.locator('.capability-search input')
await search.fill(sceneName)
card = capabilityCard(page, sceneName)
await expect(card).toBeVisible()
@@ -617,12 +758,12 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
await card.getByRole('button', { name: '停用' }).click()
await waitForToast(page, '已停用')
await page.getByLabel('状态筛选').selectOption('disabled')
await selectCapabilityStatus(page, '已停用')
await expect(capabilityCard(page, sceneEditedName)).toBeVisible()
await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered')
await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click()
await waitForToast(page, '已启用')
await page.getByLabel('状态筛选').selectOption('all')
await selectCapabilityStatus(page, '全部状态')
addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId)
await deleteCapabilityThroughUi(page, sceneEditedName)
addPass(activities, '场景素材', '页面删除', undefined, sceneId)


+ 2
- 0
tests/business-api-lifecycle.spec.ts Visa fil

@@ -999,6 +999,8 @@ test.describe('正式业务 API 数据生命周期', () => {
headers,
multipart: {
name: `${runPrefix}-视频工程依赖形象`,
specialty: '视频口播教学',
gender: '男',
resize: 'true',
matting: 'off',
file: {


+ 1
- 1
tests/helpers.ts Visa fil

@@ -55,7 +55,7 @@ export async function loginAsAdmin(page: Page) {
const password = page.locator('input[name="password"]')
await username.fill(credentials.username)
await password.fill(credentials.password)
await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click()
await page.getByRole('button', { name: /^(?:登录|登录系统|正在验证身份)$/ }).click()
await expect(page).not.toHaveURL(/\/login(?:\?|$)/)
await expect(page.locator('.app-shell, .main-layout, .platform-shell').first()).toBeVisible()
}


+ 241
- 0
tests/prototype-sync-regression.spec.ts Visa fil

@@ -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)
})

+ 161
- 0
tests/sidebar-bootstrap-regression.spec.ts Visa fil

@@ -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')
})

+ 272
- 0
tests/zipvoice-cloning-ui.spec.ts Visa fil

@@ -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)
})

+ 686
- 0
tests/zipvoice-real-lifecycle.spec.ts Visa fil

@@ -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(';')}`)
}
}
})

Laddar…
Avbryt
Spara