Просмотр исходного кода

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

main
leiyun 4 недель назад
Родитель
Сommit
cd5323463a
12 измененных файлов: 1571 добавлений и 51 удалений
  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 Просмотреть файл

@@ -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
- 0
.gitignore Просмотреть файл

@@ -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/

+ 8
- 1
playwright.config.ts Просмотреть файл

@@ -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 } : {}),
},
}],
}) })

+ 4
- 1
scripts/generate-report.mjs Просмотреть файл

@@ -87,12 +87,15 @@ const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots'))
? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort() ? 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》运行时读取,未写入报告、截图或版本库', '',
'## 结果汇总', '', '## 结果汇总', '',
'| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |', '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |',


+ 2
- 0
tests/agent-public-lifecycle.spec.ts Просмотреть файл

@@ -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: {


+ 189
- 48
tests/avatar-capabilities-ui.spec.ts Просмотреть файл

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


+ 2
- 0
tests/business-api-lifecycle.spec.ts Просмотреть файл

@@ -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: {


+ 1
- 1
tests/helpers.ts Просмотреть файл

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


+ 241
- 0
tests/prototype-sync-regression.spec.ts Просмотреть файл

@@ -0,0 +1,241 @@
import type { BrowserContext, Page, Route } from '@playwright/test'

import { captureScreenshot, expect, test } from './fixtures'

const envelope = (data: unknown) => ({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
requestId: 'prototype-sync-regression',
})

const profile = {
user: {
id: 'prototype-admin', username: 'prototype-admin', displayName: '系统管理员',
departmentId: 'system', departmentName: '系统管理', enabled: true,
mustChangePassword: false, version: 1,
},
roles: [{
id: 'admin-role', code: 'ADMIN', name: '系统管理员', shortName: '系统',
enabled: true, builtIn: true, isSuperAdmin: true, dataScope: 'ALL',
}],
activeRoleId: 'admin-role', permissions: ['*'], authorizationMode: 'SINGLE_ACTIVE',
loginTime: new Date().toISOString(),
}

const baseAgent = {
dataVersion: 1,
name: '设备点检数字教员',
description: '用于验证独立页面与悬浮图标入口',
ownerId: 'prototype-admin', ownerName: '系统管理员',
projectId: null, projectName: '', avatarId: '', avatarName: '', viewerUrl: '', avatars: [],
voiceName: '标准教员音色', voiceSpeed: 1, voice: null, voiceNames: [],
knowledgeBaseIds: [], knowledgeDocumentIds: [], interactionModes: ['text'],
llmModel: '', asrModel: '', ttsModel: '', accessMode: 'internal', allowedOrigins: [],
concurrencyLimit: 10, expiresAt: null, background: '', brandVisible: true,
welcomeMessage: '您好,请问需要了解哪项设备点检知识?', fallbackMessage: '服务暂不可用',
sensitiveReply: '请重新描述问题', showInteractionButtons: true, uiPosition: 'fullscreen',
sceneBackgroundType: 'transparent', sceneAssetUrl: '', logoUrl: '',
screensaverEnabled: false, screensaverUrl: '', screensaverIdleSeconds: 120,
apiKey: '', apiKeyPreview: 'dhk_demo••••', callCount: 3,
uiConfig: {
components: [
{ key: 'welcome', enabled: true, order: 1 },
{ key: 'history', enabled: true, order: 2 },
{ key: 'suggestions', enabled: true, order: 3 },
{ key: 'input', enabled: true, order: 4 },
],
suggestions: ['设备点检的主要目的是什么?'],
},
}

const publishedAgent = { ...baseAgent, id: 'published-agent-id', slug: 'published-agent', status: 'published' }
const draftAgent = { ...baseAgent, id: 'draft-agent-id', slug: 'draft-agent', name: '草稿数字教员', status: 'draft' }

const video = {
id: 'video-1', dataVersion: 1, title: '液压系统点检口播', description: '设备培训示例',
category: '设备培训', tags: ['点检'], status: 'ready', visibility: 'internal',
ownerId: 'prototype-admin', ownerName: '系统管理员', avatarId: null, duration: 42,
width: 1920, height: 1080, size: 1024, coverUrl: '', fileUrl: null,
coverFileCode: null, videoFileCode: null, folderId: null, shareStatus: 'inactive',
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}

async function installMocks(page: Page) {
const videoQueries: Array<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 Просмотреть файл

@@ -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 Просмотреть файл

@@ -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 Просмотреть файл

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

Загрузка…
Отмена
Сохранить