|
- 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<string, unknown>
- type Headers = Record<string, string>
- 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<unknown>
- }
-
- 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<AvatarPreferences> {
- 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<string>,
- ) {
- 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<string>,
- ) {
- 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').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<string, unknown> | undefined
- let sourceResponse: Awaited<ReturnType<typeof request.get>> | undefined
- for (const candidate of visibleAvatars.filter((item) => Boolean(item.bundleReady && item.viewerUrl && item.sourceType === 'built_in'))) {
- const response = await request.get(String(candidate.viewerUrl))
- if (response.status() === 200 && response.headers()['content-type']?.includes('text/html')) {
- previewAvatar = candidate
- sourceResponse = response
- break
- }
- }
- expect(previewAvatar, '形象库应至少包含一个共享数字人服务中真实可用的 Viewer').toBeTruthy()
- expect(sourceResponse?.status()).toBe(200)
-
- const previewName = String(previewAvatar?.name ?? '')
- const nameSearch = page.getByPlaceholder('搜索形象名称')
- 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<string>()
- 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')
- 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')
- 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([])
- })
|