import fs from 'node:fs' import path from 'node:path' import { execFileSync } from 'node:child_process' import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData, loginAsAdmin } from './helpers' test.describe.configure({ mode: 'serial' }) test.use({ trace: 'off', video: 'off', // Viewer uses WebGL. Playwright's bundled headless Chromium requires this // explicit opt-in for trusted local/shared test services. launchOptions: { executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', args: ['--enable-unsafe-swiftshader'], }, }) type JsonRecord = Record type Headers = Record type CapabilityType = 'VOICE_CLONE' | 'TTS' | 'SCENE' interface Activity { module: string action: string result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface AvatarPreferences { defaultAvatarId: string builtInVisible: boolean dataVersion: number } interface ResponseLike { status(): number json(): Promise } 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: ResponseLike, expected: number | number[], label: string) { const statuses = Array.isArray(expected) ? expected : [expected] expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) return asRecord(envelopeData(await response.json())) } function addPass( activities: Activity[], module: string, action: string, response?: ResponseLike, resourceId?: string, detail?: string, ) { activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail }) } async function attachLifecycleReport( testInfo: TestInfo, title: string, activities: Activity[], cleanupErrors: string[], ) { await attachJson(testInfo, `${title}-生命周期与清理`, { runId, runPrefix, title, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '报告不记录管理员密码、访问令牌或媒体签名地址。', }) } async function getAvatarPreferences(request: APIRequestContext, headers: Headers): Promise { const response = await request.get('/api/v1/avatar-preferences', { headers }) const value = await responseData(response, 200, '读取数字人平台偏好') return { defaultAvatarId: String(value.defaultAvatarId ?? ''), builtInVisible: value.builtInVisible !== false, dataVersion: Number(value.dataVersion ?? 0), } } async function restoreAvatarPreferences( request: APIRequestContext, headers: Headers, snapshot: AvatarPreferences, ) { const current = await getAvatarPreferences(request, headers) const response = await request.put('/api/v1/avatar-preferences', { headers, data: { defaultAvatarId: snapshot.defaultAvatarId, builtInVisible: snapshot.builtInVisible, dataVersion: current.dataVersion, }, }) await responseData(response, 200, '恢复数字人平台偏好快照') return response } async function listAvatars(request: APIRequestContext, headers: Headers, keyword = '') { const response = await request.get('/api/v1/avatars', { headers, params: { keyword, page: '1', pageSize: '200' }, }) const page = await responseData(response, 200, '读取数字形象列表') return { response, items: asRecords(page.items ?? page.records) } } async function cleanupAvatarsByPrefix( request: APIRequestContext, headers: Headers, activities: Activity[], cleanupErrors: string[], ownedNames: Set, ) { try { const { items } = await listAvatars(request, headers, runPrefix) for (const item of items) { const id = String(item.id ?? '') if (!id || !ownedNames.has(String(item.name ?? ''))) continue const detailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers }) if (detailResponse.status() === 404) continue const detail = await responseData(detailResponse, 200, '数字人清理前刷新版本') const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, timeout: 120_000, }) if (![200, 404].includes(removed.status())) throw new Error(`数字人 ${id} 删除失败:HTTP ${removed.status()}`) activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) } } catch (error) { const message = `数字人兜底清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) } } async function listCapabilities( request: APIRequestContext, headers: Headers, capabilityType: CapabilityType, keyword = '', ) { const response = await request.get('/api/v1/capabilities', { headers, params: { capabilityType, keyword, page: '1', pageSize: '200' }, }) const page = await responseData(response, 200, `读取 ${capabilityType} 能力列表`) return { response, items: asRecords(page.items ?? page.records) } } async function cleanupCapabilitiesByPrefix( request: APIRequestContext, headers: Headers, activities: Activity[], cleanupErrors: string[], knownFileCodes: Set, ) { for (const capabilityType of ['VOICE_CLONE', 'TTS', 'SCENE'] as const) { try { const { items } = await listCapabilities(request, headers, capabilityType, runPrefix) for (const item of items) { const id = String(item.id ?? '') if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers }) if (detailResponse.status() === 404) continue const detail = await responseData(detailResponse, 200, '能力清理前刷新版本') const mediaFileCode = String(detail.mediaFileCode ?? '') if (mediaFileCode) knownFileCodes.add(mediaFileCode) const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, }) if (![200, 404].includes(removed.status())) throw new Error(`能力 ${id} 删除失败:HTTP ${removed.status()}`) activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) } } catch (error) { const message = `${capabilityType} 兜底清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) } } for (const fileCode of knownFileCodes) { try { const removed = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers }) if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) activities.push({ module: '能力媒体', action: '解除引用后清理上传文件', result: 'CLEANED', httpStatus: removed.status(), resourceId: fileCode }) } catch (error) { const message = `能力媒体 ${fileCode} 清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '能力媒体', action: '清理上传文件', result: 'CLEANUP_FAILED', resourceId: fileCode, detail: message }) } } } 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, .voice-clone-table .el-table__row').filter({ hasText: name }).first() } function dialogField(dialog: Locator, label: string) { return dialog.locator('.capability-form > label').filter({ hasText: label }).first() } 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) { const card = capabilityCard(page, name) await card.getByRole('button', { name: '删除' }).click() const box = page.locator('.el-message-box:visible').last() await expect(box).toBeVisible() await box.getByRole('button', { name: /确定|确认/ }).click() await waitForToast(page, '已删除') await expect(capabilityCard(page, name)).toHaveCount(0) } function createVoiceFixture(sourceVideo: string, outputPath: string) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }) execFileSync(process.env.FFMPEG_BINARY?.trim() || 'ffmpeg', [ '-y', '-hide_banner', '-loglevel', 'error', '-i', sourceVideo, '-vn', '-t', '12', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', outputPath, ], { stdio: 'pipe', timeout: 60_000, windowsHide: true }) expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000) } test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request, context }, testInfo) => { test.setTimeout(720_000) const headers = await authHeaders(request) const activities: Activity[] = [] const cleanupErrors: string[] = [] const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') const originalName = `${runPrefix}-UI本人视频形象`.slice(0, 80) const editedName = `${runPrefix}-UI液压维修教员`.slice(0, 80) let preferenceSnapshot: AvatarPreferences | null = null let primaryError: unknown expect(fs.existsSync(sourceVideo), `原型本人视频不存在:${sourceVideo}`).toBeTruthy() try { const upstreamHealth = await request.get('/human/health') expect(upstreamHealth.status(), '数字人生成上游健康检查必须成功').toBe(200) addPass(activities, '数字形象', '真实上游健康检查', upstreamHealth) preferenceSnapshot = await getAvatarPreferences(request, headers) activities.push({ module: '数字形象偏好', action: '保存默认形象与内置显示快照', result: 'PASS' }) await loginAsAdmin(page) await page.goto('/assets/avatars') await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() const builtInToggle = page.locator('.avatar-demo-toggle') const builtInSwitch = builtInToggle.getByRole('switch') const builtInSwitchControl = builtInToggle.locator('.el-switch') if (!(await builtInSwitch.isChecked())) { await builtInSwitchControl.click() await waitForToast(page, '已显示内置示例形象') } const { items: visibleAvatars } = await listAvatars(request, headers) let previewAvatar: Record | undefined let sourceResponse: Awaited> | undefined for (const candidate of visibleAvatars.filter((item) => Boolean(item.bundleReady && item.viewerUrl && item.sourceType === 'built_in'))) { const response = await request.get(String(candidate.viewerUrl)) if (response.status() === 200 && response.headers()['content-type']?.includes('text/html')) { previewAvatar = candidate sourceResponse = response break } } expect(previewAvatar, '形象库应至少包含一个共享数字人服务中真实可用的 Viewer').toBeTruthy() expect(sourceResponse?.status()).toBe(200) const previewName = String(previewAvatar?.name ?? '') const nameSearch = page.getByPlaceholder('搜索形象名称') 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 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: '预览', exact: true }).click() const previewDialog = page.locator('.avatar-preview-dialog:visible').last() 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 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.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 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: 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('可预览') const { response: createdListResponse, items: createdItems } = await listAvatars(request, headers, originalName) const createdAvatar = createdItems.find((item) => String(item.name ?? '') === originalName) 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) await editDialog.getByLabel('角色').fill('装备维修教员') await editDialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select').click() await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click() await editDialog.getByLabel('专业方向').fill('液压与电气检修实训') const visibilityField = editDialog.locator('.avatar-visibility-field') const visibilitySwitch = visibilityField.getByRole('switch') if (await visibilitySwitch.isChecked()) await visibilityField.locator('.el-switch').click() await captureScreenshot(page, testInfo, '05-avatar-profile-and-visibility-edit') await editDialog.getByRole('button', { name: '保存修改' }).click() 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('平台共享') const detailAfterEdit = await responseData( await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }), 200, '刷新验证数字人页面编辑', ) expect(detailAfterEdit).toMatchObject({ name: editedName, role: '装备维修教员', specialty: '液压与电气检修实训', gender: '男', visibility: 'internal', }) addPass(activities, '数字形象', '页面编辑并刷新持久化', undefined, avatarId) await editedCard.getByRole('button', { name: '停用' }).click() const disableBox = page.locator('.el-message-box:visible').last() await expect(disableBox).toContainText('停用后不会出现在业务选择器中') await disableBox.getByRole('button', { name: '确认停用' }).click() await waitForToast(page, '数字人已停用') await expect(avatarCard(page, editedName)).toContainText('已停用') await captureScreenshot(page, testInfo, '06-avatar-disabled-through-ui') await avatarCard(page, editedName).getByRole('button', { name: '启用' }).click() await waitForToast(page, '数字人已启用') await expect(avatarCard(page, editedName)).toContainText('可预览') addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId) 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) expect(defaultPreferences.defaultAvatarId).toBe(avatarId) addPass(activities, '数字形象偏好', '页面设为平台默认数字人', undefined, avatarId) await nameSearch.fill('') const currentSwitchState = await builtInSwitch.isChecked() await builtInSwitchControl.click() await waitForToast(page, currentSwitchState ? '已隐藏内置示例形象' : '已显示内置示例形象') expect(await builtInSwitch.isChecked()).toBe(!currentSwitchState) await captureScreenshot(page, testInfo, '07-builtin-avatar-visibility-toggled') addPass(activities, '数字形象偏好', '页面切换内置示例显示配置') const restoredResponse = await restoreAvatarPreferences(request, headers, preferenceSnapshot) addPass(activities, '数字形象偏好', '按测试前快照恢复默认形象与内置显示', restoredResponse) preferenceSnapshot = null await page.reload() await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() 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() await expect(deleteBox).toContainText(`确定删除“${editedName}”吗`) await deleteBox.getByRole('button', { name: '确认删除' }).click() await waitForToast(page, '数字人已删除') await expect(avatarCard(page, editedName)).toHaveCount(0) const absent = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) expect(absent.status()).toBe(404) await captureScreenshot(page, testInfo, '08-avatar-deleted-and-filter-empty') addPass(activities, '数字形象', '页面删除且正式 API 不可再读取', absent, avatarId) } catch (error) { 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, cleanupHeaders, preferenceSnapshot) activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() }) } catch (error) { const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message }) } } await cleanupAvatarsByPrefix(request, cleanupHeaders, activities, cleanupErrors, new Set([originalName, editedName])) await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '数字形象测试数据与平台偏好必须完整恢复').toEqual([]) }) test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编辑、筛选、预览、停启和删除闭环', async ({ page, request }, testInfo) => { test.setTimeout(360_000) const headers = await authHeaders(request) const activities: Activity[] = [] const cleanupErrors: string[] = [] const knownFileCodes = new Set() const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') const sceneImage = path.resolve('../ai_person/public/brand/DA_bg.jpg') const voiceFixture = testInfo.outputPath(`${runPrefix}-real-voice.wav`) let primaryError: unknown expect(fs.existsSync(sourceVideo)).toBeTruthy() expect(fs.existsSync(sceneImage)).toBeTruthy() createVoiceFixture(sourceVideo, voiceFixture) const voiceName = `${runPrefix}-维修教员克隆音色`.slice(0, 50) const voiceEditedName = `${runPrefix}-维修讲解克隆音色`.slice(0, 50) const ttsName = `${runPrefix}-本地标准中文TTS`.slice(0, 50) const ttsEditedName = `${runPrefix}-本地自然口播TTS`.slice(0, 50) const sceneName = `${runPrefix}-数字车间背景`.slice(0, 50) const sceneEditedName = `${runPrefix}-数字车间实训背景`.slice(0, 50) try { await loginAsAdmin(page) await page.goto('/assets/voice-clones') await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible() await page.getByRole('button', { name: /新建克隆音色/ }).click() let dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(voiceName) await dialogField(dialog, '分类').locator('select').selectOption('男声教员') await dialogField(dialog, '说明').locator('textarea').fill('装备维修实训问答与安全口播音色') await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles(voiceFixture) await expect(dialog.locator('.voice-capture-panel audio')).toBeVisible() await captureScreenshot(page, testInfo, '09-voice-clone-real-audio-before-save') const voiceUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url())) await dialog.getByRole('button', { name: '保存', exact: true }).click() const voiceUpload = await voiceUploadPromise const voiceFile = await responseData(voiceUpload, 201, '上传真实克隆音色样本') knownFileCodes.add(String(voiceFile.id ?? '')) await waitForToast(page, '配置已创建') let search = page.locator('.capability-search input') await search.fill(voiceName) let card = capabilityCard(page, voiceName) await expect(card).toBeVisible() const { response: voiceListResponse, items: voiceItems } = await listCapabilities(request, headers, 'VOICE_CLONE', voiceName) const voiceId = String(voiceItems.find((item) => String(item.name ?? '') === voiceName)?.id ?? '') expect(voiceId).not.toBe('') addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId) await card.getByRole('button', { name: '试听' }).click() const audio = card.locator('.inline-media-preview audio, .voice-table-preview audio') await expect(audio).toBeVisible() await expect(audio).toHaveAttribute('src', /.+/) await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) await captureScreenshot(page, testInfo, '10-voice-clone-signed-audio-preview') addPass(activities, '声音克隆', '短期签名音频真实试听', undefined, voiceId) await card.getByRole('button', { name: '编辑' }).click() dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(voiceEditedName) await dialogField(dialog, '分类').locator('select').selectOption('讲解播报') await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于维修工序讲解与风险提示') await dialog.getByRole('button', { name: '保存', exact: true }).click() await waitForToast(page, '配置已更新') await expect(dialog).toBeHidden() await search.fill(voiceEditedName) card = capabilityCard(page, voiceEditedName) await expect(card).toContainText('讲解播报') await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示') const editedAudio = card.locator('.inline-media-preview audio, .voice-table-preview audio') if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click() await expect(editedAudio).toBeVisible() await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) addPass(activities, '声音克隆', '页面编辑并刷新持久化', undefined, voiceId) await card.getByRole('button', { name: '停用' }).click() await waitForToast(page, '已停用') 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 selectCapabilityStatus(page, '全部状态') addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId) await deleteCapabilityThroughUi(page, voiceEditedName) addPass(activities, '声音克隆', '页面删除', undefined, voiceId) await page.goto('/assets/voice-models') await expect(page.getByRole('heading', { name: '语音模型' })).toBeVisible() await page.getByRole('button', { name: /新增语音模型/ }).click() dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(ttsName) await dialogField(dialog, '分类').locator('select').selectOption('标准中文') await dialogField(dialog, '说明').locator('textarea').fill('本地实时问答标准中文播报') await dialog.locator('details.capability-form-technical summary').click() await dialog.locator('details.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-tts-standard-zh`) await captureScreenshot(page, testInfo, '12-tts-local-reference-before-save') await dialog.getByRole('button', { name: '保存', exact: true }).click() await waitForToast(page, '配置已创建') search = page.locator('.capability-search input') await search.fill(ttsName) card = capabilityCard(page, ttsName) await expect(card).toBeVisible() const { response: ttsListResponse, items: ttsItems } = await listCapabilities(request, headers, 'TTS', ttsName) const ttsId = String(ttsItems.find((item) => String(item.name ?? '') === ttsName)?.id ?? '') expect(ttsId).not.toBe('') addPass(activities, 'TTS', '页面创建正式本地能力引用', ttsListResponse, ttsId) await card.locator('details.capability-technical-info summary').click() await card.getByRole('button', { name: '校验本地配置' }).click() await waitForToast(page, /配置可用|校验通过/) await card.getByRole('button', { name: '预览' }).click() await waitForToast(page, new RegExp(`${ttsName}适合`)) await card.getByRole('button', { name: '编辑' }).click() dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(ttsEditedName) await dialogField(dialog, '分类').locator('select').selectOption('自然口播') await dialogField(dialog, '说明').locator('textarea').fill('已编辑:面向数字教员自然口播') await dialog.getByRole('button', { name: '保存', exact: true }).click() await waitForToast(page, '配置已更新') await expect(dialog).toBeHidden() await search.fill(ttsEditedName) card = capabilityCard(page, ttsEditedName) await expect(card).toContainText('自然口播') await card.getByRole('button', { name: '停用' }).click() await waitForToast(page, '已停用') await card.getByRole('button', { name: '启用' }).click() await waitForToast(page, '已启用') await captureScreenshot(page, testInfo, '13-tts-edited-validated-and-enabled') addPass(activities, 'TTS', '校验、预览、编辑、停用与启用', undefined, ttsId) await deleteCapabilityThroughUi(page, ttsEditedName) addPass(activities, 'TTS', '页面删除', undefined, ttsId) await page.goto('/assets/scene-materials') await expect(page.getByRole('heading', { name: '场景素材' })).toBeVisible() await page.getByRole('tab', { name: '背景图片' }).click() await page.getByRole('button', { name: /新增场景素材/ }).click() dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(sceneName) await dialogField(dialog, '分类').locator('select').selectOption('背景图片') await dialogField(dialog, '说明').locator('textarea').fill('数字车间设备维修实训背景') await dialog.locator('.file-field input[type="file"]').setInputFiles(sceneImage) const formPreview = dialog.locator('.scene-form-preview') await expect(formPreview).toBeVisible() await expect.poll(() => formPreview.locator('.scene-form-foreground').evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) await captureScreenshot(page, testInfo, '14-scene-real-image-upload-preview') const sceneUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url())) await dialog.getByRole('button', { name: '保存', exact: true }).click() const sceneUpload = await sceneUploadPromise const sceneFile = await responseData(sceneUpload, 201, '上传真实场景图片') knownFileCodes.add(String(sceneFile.id ?? '')) await waitForToast(page, '配置已创建') search = page.locator('.capability-search input') await search.fill(sceneName) card = capabilityCard(page, sceneName) await expect(card).toBeVisible() const { response: sceneListResponse, items: sceneItems } = await listCapabilities(request, headers, 'SCENE', sceneName) const sceneId = String(sceneItems.find((item) => String(item.name ?? '') === sceneName)?.id ?? '') expect(sceneId).not.toBe('') addPass(activities, '场景素材', '页面上传真实图片并创建', sceneListResponse, sceneId) await card.getByRole('button', { name: '预览' }).click() const scenePreview = card.locator('.inline-media-preview.scene-preview img') await expect(scenePreview).toBeVisible() await expect.poll(() => scenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) await captureScreenshot(page, testInfo, '15-scene-signed-image-list-preview') await card.getByRole('button', { name: '编辑' }).click() dialog = page.locator('.admin-form-dialog:visible').last() await dialogField(dialog, '名称').locator('input').fill(sceneEditedName) await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于液压与电气联合检修实训') await dialog.getByRole('button', { name: '保存', exact: true }).click() await waitForToast(page, '配置已更新') await expect(dialog).toBeHidden() await search.fill(sceneEditedName) card = capabilityCard(page, sceneEditedName) await expect(card).toContainText('已编辑:用于液压与电气联合检修实训') const editedScenePreview = card.locator('.inline-media-preview.scene-preview img') if (!(await editedScenePreview.count())) await card.getByRole('button', { name: '预览' }).click() await expect(editedScenePreview).toBeVisible() await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) await card.getByRole('button', { name: '停用' }).click() await waitForToast(page, '已停用') 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 selectCapabilityStatus(page, '全部状态') addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId) await deleteCapabilityThroughUi(page, sceneEditedName) addPass(activities, '场景素材', '页面删除', undefined, sceneId) } catch (error) { primaryError = error activities.push({ module: '能力资产 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) } finally { fs.rmSync(voiceFixture, { force: true }) await cleanupCapabilitiesByPrefix(request, headers, activities, cleanupErrors, knownFileCodes) await attachLifecycleReport(testInfo, '能力资产 UI', activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '能力资产及上传媒体必须完整清理').toEqual([]) })