Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

782 linhas
42 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { execFileSync } from 'node:child_process'
  4. import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test'
  5. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  6. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  7. test.describe.configure({ mode: 'serial' })
  8. test.use({
  9. trace: 'off',
  10. video: 'off',
  11. // Viewer uses WebGL. Playwright's bundled headless Chromium requires this
  12. // explicit opt-in for trusted local/shared test services.
  13. launchOptions: {
  14. executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
  15. args: ['--enable-unsafe-swiftshader'],
  16. },
  17. })
  18. type JsonRecord = Record<string, unknown>
  19. type Headers = Record<string, string>
  20. type CapabilityType = 'VOICE_CLONE' | 'TTS' | 'SCENE'
  21. interface Activity {
  22. module: string
  23. action: string
  24. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  25. httpStatus?: number
  26. resourceId?: string
  27. detail?: string
  28. }
  29. interface AvatarPreferences {
  30. defaultAvatarId: string
  31. builtInVisible: boolean
  32. dataVersion: number
  33. }
  34. interface ResponseLike {
  35. status(): number
  36. json(): Promise<unknown>
  37. }
  38. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  39. ? value as JsonRecord
  40. : {}
  41. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  42. async function responseData(response: ResponseLike, expected: number | number[], label: string) {
  43. const statuses = Array.isArray(expected) ? expected : [expected]
  44. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  45. return asRecord(envelopeData(await response.json()))
  46. }
  47. function addPass(
  48. activities: Activity[],
  49. module: string,
  50. action: string,
  51. response?: ResponseLike,
  52. resourceId?: string,
  53. detail?: string,
  54. ) {
  55. activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail })
  56. }
  57. async function attachLifecycleReport(
  58. testInfo: TestInfo,
  59. title: string,
  60. activities: Activity[],
  61. cleanupErrors: string[],
  62. ) {
  63. await attachJson(testInfo, `${title}-生命周期与清理`, {
  64. runId,
  65. runPrefix,
  66. title,
  67. activities,
  68. cleanupComplete: cleanupErrors.length === 0,
  69. cleanupErrors,
  70. security: '报告不记录管理员密码、访问令牌或媒体签名地址。',
  71. })
  72. }
  73. async function getAvatarPreferences(request: APIRequestContext, headers: Headers): Promise<AvatarPreferences> {
  74. const response = await request.get('/api/v1/avatar-preferences', { headers })
  75. const value = await responseData(response, 200, '读取数字人平台偏好')
  76. return {
  77. defaultAvatarId: String(value.defaultAvatarId ?? ''),
  78. builtInVisible: value.builtInVisible !== false,
  79. dataVersion: Number(value.dataVersion ?? 0),
  80. }
  81. }
  82. async function restoreAvatarPreferences(
  83. request: APIRequestContext,
  84. headers: Headers,
  85. snapshot: AvatarPreferences,
  86. ) {
  87. const current = await getAvatarPreferences(request, headers)
  88. const response = await request.put('/api/v1/avatar-preferences', {
  89. headers,
  90. data: {
  91. defaultAvatarId: snapshot.defaultAvatarId,
  92. builtInVisible: snapshot.builtInVisible,
  93. dataVersion: current.dataVersion,
  94. },
  95. })
  96. await responseData(response, 200, '恢复数字人平台偏好快照')
  97. return response
  98. }
  99. async function listAvatars(request: APIRequestContext, headers: Headers, keyword = '') {
  100. const response = await request.get('/api/v1/avatars', {
  101. headers,
  102. params: { keyword, page: '1', pageSize: '200' },
  103. })
  104. const page = await responseData(response, 200, '读取数字形象列表')
  105. return { response, items: asRecords(page.items ?? page.records) }
  106. }
  107. async function cleanupAvatarsByPrefix(
  108. request: APIRequestContext,
  109. headers: Headers,
  110. activities: Activity[],
  111. cleanupErrors: string[],
  112. ownedNames: Set<string>,
  113. ) {
  114. try {
  115. const { items } = await listAvatars(request, headers, runPrefix)
  116. for (const item of items) {
  117. const id = String(item.id ?? '')
  118. if (!id || !ownedNames.has(String(item.name ?? ''))) continue
  119. const detailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers })
  120. if (detailResponse.status() === 404) continue
  121. const detail = await responseData(detailResponse, 200, '数字人清理前刷新版本')
  122. const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(id)}`, {
  123. headers,
  124. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  125. timeout: 120_000,
  126. })
  127. if (![200, 404].includes(removed.status())) throw new Error(`数字人 ${id} 删除失败:HTTP ${removed.status()}`)
  128. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  129. }
  130. } catch (error) {
  131. const message = `数字人兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  132. cleanupErrors.push(message)
  133. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  134. }
  135. }
  136. async function listCapabilities(
  137. request: APIRequestContext,
  138. headers: Headers,
  139. capabilityType: CapabilityType,
  140. keyword = '',
  141. ) {
  142. const response = await request.get('/api/v1/capabilities', {
  143. headers,
  144. params: { capabilityType, keyword, page: '1', pageSize: '200' },
  145. })
  146. const page = await responseData(response, 200, `读取 ${capabilityType} 能力列表`)
  147. return { response, items: asRecords(page.items ?? page.records) }
  148. }
  149. async function cleanupCapabilitiesByPrefix(
  150. request: APIRequestContext,
  151. headers: Headers,
  152. activities: Activity[],
  153. cleanupErrors: string[],
  154. knownFileCodes: Set<string>,
  155. ) {
  156. for (const capabilityType of ['VOICE_CLONE', 'TTS', 'SCENE'] as const) {
  157. try {
  158. const { items } = await listCapabilities(request, headers, capabilityType, runPrefix)
  159. for (const item of items) {
  160. const id = String(item.id ?? '')
  161. if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue
  162. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers })
  163. if (detailResponse.status() === 404) continue
  164. const detail = await responseData(detailResponse, 200, '能力清理前刷新版本')
  165. const mediaFileCode = String(detail.mediaFileCode ?? '')
  166. if (mediaFileCode) knownFileCodes.add(mediaFileCode)
  167. const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, {
  168. headers,
  169. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  170. })
  171. if (![200, 404].includes(removed.status())) throw new Error(`能力 ${id} 删除失败:HTTP ${removed.status()}`)
  172. activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  173. }
  174. } catch (error) {
  175. const message = `${capabilityType} 兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  176. cleanupErrors.push(message)
  177. activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  178. }
  179. }
  180. for (const fileCode of knownFileCodes) {
  181. try {
  182. const removed = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
  183. if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
  184. activities.push({ module: '能力媒体', action: '解除引用后清理上传文件', result: 'CLEANED', httpStatus: removed.status(), resourceId: fileCode })
  185. } catch (error) {
  186. const message = `能力媒体 ${fileCode} 清理异常:${error instanceof Error ? error.message : String(error)}`
  187. cleanupErrors.push(message)
  188. activities.push({ module: '能力媒体', action: '清理上传文件', result: 'CLEANUP_FAILED', resourceId: fileCode, detail: message })
  189. }
  190. }
  191. }
  192. function avatarCard(page: Page, name: string) {
  193. return page.locator('.avatar-card').filter({ hasText: name }).first()
  194. }
  195. async function waitForAvatarGridReady(page: Page) {
  196. await expect(page.locator('.avatar-grid .el-loading-mask:visible')).toHaveCount(0, { timeout: 20_000 })
  197. }
  198. async function selectAvatarGender(page: Page, label: string) {
  199. await page.locator('.avatar-gender-filter').click()
  200. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
  201. .filter({ hasText: label || '全部性别' })
  202. .click()
  203. }
  204. async function selectCapabilityStatus(page: Page, label: string) {
  205. await page.locator('.capability-status-filter').click()
  206. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item')
  207. .filter({ hasText: label })
  208. .click()
  209. }
  210. function capabilityCard(page: Page, name: string) {
  211. return page.locator('.capability-card').filter({ hasText: name }).first()
  212. }
  213. function dialogField(dialog: Locator, label: string) {
  214. return dialog.locator('.capability-form > label').filter({ hasText: label }).first()
  215. }
  216. async function waitForToast(page: Page, text: string | RegExp, timeout = 15_000) {
  217. await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible({ timeout })
  218. }
  219. async function deleteCapabilityThroughUi(page: Page, name: string) {
  220. const card = capabilityCard(page, name)
  221. await card.getByRole('button', { name: '删除' }).click()
  222. const box = page.locator('.el-message-box:visible').last()
  223. await expect(box).toBeVisible()
  224. await box.getByRole('button', { name: /确定|确认/ }).click()
  225. await waitForToast(page, '已删除')
  226. await expect(capabilityCard(page, name)).toHaveCount(0)
  227. }
  228. function createVoiceFixture(sourceVideo: string, outputPath: string) {
  229. fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  230. execFileSync(process.env.FFMPEG_BINARY?.trim() || 'ffmpeg', [
  231. '-y', '-hide_banner', '-loglevel', 'error', '-i', sourceVideo,
  232. '-vn', '-t', '12', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', outputPath,
  233. ], { stdio: 'pipe', timeout: 60_000, windowsHide: true })
  234. expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000)
  235. }
  236. test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request, context }, testInfo) => {
  237. test.setTimeout(720_000)
  238. const headers = await authHeaders(request)
  239. const activities: Activity[] = []
  240. const cleanupErrors: string[] = []
  241. const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  242. const originalName = `${runPrefix}-UI本人视频形象`.slice(0, 80)
  243. const editedName = `${runPrefix}-UI液压维修教员`.slice(0, 80)
  244. let preferenceSnapshot: AvatarPreferences | null = null
  245. let primaryError: unknown
  246. expect(fs.existsSync(sourceVideo), `原型本人视频不存在:${sourceVideo}`).toBeTruthy()
  247. try {
  248. const upstreamHealth = await request.get('/human/health')
  249. expect(upstreamHealth.status(), '数字人生成上游健康检查必须成功').toBe(200)
  250. addPass(activities, '数字形象', '真实上游健康检查', upstreamHealth)
  251. preferenceSnapshot = await getAvatarPreferences(request, headers)
  252. activities.push({ module: '数字形象偏好', action: '保存默认形象与内置显示快照', result: 'PASS' })
  253. await loginAsAdmin(page)
  254. await page.goto('/assets/avatars')
  255. await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
  256. const builtInToggle = page.locator('.avatar-demo-toggle')
  257. const builtInSwitch = builtInToggle.getByRole('switch')
  258. const builtInSwitchControl = builtInToggle.locator('.el-switch')
  259. if (!(await builtInSwitch.isChecked())) {
  260. await builtInSwitchControl.click()
  261. await waitForToast(page, '已显示内置示例形象')
  262. }
  263. const { items: visibleAvatars } = await listAvatars(request, headers)
  264. let previewAvatar: Record<string, unknown> | undefined
  265. let sourceResponse: Awaited<ReturnType<typeof request.get>> | undefined
  266. for (const candidate of visibleAvatars.filter((item) => Boolean(item.bundleReady && item.viewerUrl && item.sourceType === 'built_in'))) {
  267. const response = await request.get(String(candidate.viewerUrl))
  268. if (response.status() === 200 && response.headers()['content-type']?.includes('text/html')) {
  269. previewAvatar = candidate
  270. sourceResponse = response
  271. break
  272. }
  273. }
  274. expect(previewAvatar, '形象库应至少包含一个共享数字人服务中真实可用的 Viewer').toBeTruthy()
  275. expect(sourceResponse?.status()).toBe(200)
  276. const previewName = String(previewAvatar?.name ?? '')
  277. const nameSearch = page.getByPlaceholder('搜索形象名称')
  278. await nameSearch.fill(previewName)
  279. const backendSearch = page.waitForResponse((response) => {
  280. const url = new URL(response.url())
  281. return url.pathname.endsWith('/api/v1/avatars') && url.searchParams.get('keyword') === previewName
  282. })
  283. await page.getByRole('button', { name: '搜索', exact: true }).click()
  284. expect((await backendSearch).status()).toBe(200)
  285. await waitForAvatarGridReady(page)
  286. await expect(avatarCard(page, previewName)).toBeVisible()
  287. const previewGender = String(previewAvatar?.gender ?? '')
  288. if (previewGender === '男' || previewGender === '女') {
  289. await selectAvatarGender(page, previewGender)
  290. await page.getByRole('button', { name: '搜索', exact: true }).click()
  291. await waitForAvatarGridReady(page)
  292. await expect(avatarCard(page, previewName)).toBeVisible()
  293. }
  294. await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter')
  295. addPass(activities, '数字形象', '名称与性别组合筛选')
  296. await avatarCard(page, previewName).getByRole('button', { name: '预览', exact: true }).click()
  297. const previewDialog = page.locator('.avatar-preview-dialog:visible').last()
  298. const builtInPreviewVideo = previewDialog.locator('video')
  299. await expect(builtInPreviewVideo).toBeVisible()
  300. await expect(builtInPreviewVideo).toHaveAttribute('src', /\/avatar-previews\/.+\.mp4/)
  301. await expect(previewDialog.locator('iframe')).toHaveCount(0)
  302. await expect(previewDialog.getByText('内置形象样片')).toBeVisible()
  303. expect(sourceResponse?.headers()['content-type']).toContain('text/html')
  304. await captureScreenshot(page, testInfo, '02-builtin-avatar-mp4-preview')
  305. addPass(activities, '数字形象', '已有内置 MP4 时直接播放,Viewer 仅作为无视频回退', sourceResponse, String(previewAvatar?.id ?? ''))
  306. await previewDialog.locator('.el-dialog__headerbtn').click()
  307. const viewerFallbackAvatar = visibleAvatars.find((item) => Boolean(
  308. item.bundleReady
  309. && item.viewerUrl
  310. && item.sourceType !== 'built_in'
  311. && !item.previewFileCode
  312. && !item.previewUrl,
  313. ))
  314. if (viewerFallbackAvatar) {
  315. const viewerFallbackName = String(viewerFallbackAvatar.name ?? '')
  316. await nameSearch.fill(viewerFallbackName)
  317. await page.getByRole('button', { name: '搜索', exact: true }).click()
  318. await waitForAvatarGridReady(page)
  319. const viewerFallbackCard = avatarCard(page, viewerFallbackName)
  320. await expect(viewerFallbackCard.getByRole('button', { name: '生成预览', exact: true })).toBeVisible()
  321. await viewerFallbackCard.locator('.avatar-cover').click()
  322. const fallbackDialog = page.locator('.avatar-preview-dialog:visible').last()
  323. await expect(fallbackDialog.locator('video')).toHaveCount(0)
  324. await expect(fallbackDialog.locator('iframe')).toHaveAttribute('src', String(viewerFallbackAvatar.viewerUrl))
  325. await expect(fallbackDialog.getByText('实际数字人服务 Viewer')).toBeVisible()
  326. await captureScreenshot(page, testInfo, '02b-viewer-fallback-without-mp4')
  327. addPass(activities, '数字形象', '无 MP4 素材时回退真实 Viewer', undefined, String(viewerFallbackAvatar.id ?? ''))
  328. await fallbackDialog.locator('.el-dialog__headerbtn').click()
  329. } else {
  330. addPass(activities, '数字形象', '现有真实资源均已有 MP4,无需 Viewer 回退')
  331. }
  332. await nameSearch.fill('')
  333. await selectAvatarGender(page, '全部性别')
  334. await page.getByRole('button', { name: '搜索', exact: true }).click()
  335. await waitForAvatarGridReady(page)
  336. const storageSnapshot = await page.evaluate(() => ({
  337. local: Object.fromEntries(Object.entries(localStorage)),
  338. session: Object.fromEntries(Object.entries(sessionStorage)),
  339. }))
  340. const creationPage = await context.newPage()
  341. await creationPage.goto('/login')
  342. await creationPage.evaluate((snapshot) => {
  343. Object.entries(snapshot.local).forEach(([key, value]) => localStorage.setItem(key, String(value)))
  344. Object.entries(snapshot.session).forEach(([key, value]) => sessionStorage.setItem(key, String(value)))
  345. }, storageSnapshot)
  346. await creationPage.goto('/assets/avatars')
  347. await expect(creationPage.getByRole('heading', { name: '数字形象' })).toBeVisible()
  348. await creationPage.getByRole('button', { name: '创建数字人' }).click()
  349. const createDialog = creationPage.locator('.avatar-create-dialog:visible').last()
  350. await expect(createDialog).toBeVisible()
  351. await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo)
  352. await createDialog.getByLabel('数字人名称').fill(originalName)
  353. await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeDisabled()
  354. await createDialog.locator('.avatar-create-gender-field .el-select').click()
  355. await creationPage.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click()
  356. await createDialog.getByLabel('专业方向').fill('维修教学')
  357. await createDialog.locator('details.avatar-create-advanced summary').click()
  358. await createDialog.locator('select').selectOption('off')
  359. await expect(createDialog.getByRole('button', { name: '开始创建' })).toBeEnabled()
  360. await captureScreenshot(creationPage, testInfo, '03-avatar-real-video-ready-to-submit')
  361. const previewSpeechRequestPromise = creationPage.waitForRequest((candidate) => {
  362. return candidate.method() === 'POST' && /\/api\/v1\/tts(?:\?|$)/.test(candidate.url())
  363. }, { timeout: 480_000 })
  364. await createDialog.getByRole('button', { name: '开始创建' }).click()
  365. await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 360_000 })
  366. await captureScreenshot(creationPage, testInfo, '04-avatar-upstream-job-succeeded')
  367. const previewSpeechRequest = await previewSpeechRequestPromise
  368. expect(previewSpeechRequest.postDataJSON()).toMatchObject({
  369. text: `欢迎使用数字人系统,我是${originalName}。`,
  370. speaker: 1,
  371. })
  372. const queueCode = createDialog.locator('.avatar-preview-queue-link code')
  373. await expect(queueCode).toBeVisible({ timeout: 180_000 })
  374. const previewJobId = String(await queueCode.textContent()).trim()
  375. expect(previewJobId).toMatch(/^[a-f0-9]{32}$/)
  376. await expect(createDialog.getByRole('button', { name: '后台运行', exact: true })).toBeVisible()
  377. await captureScreenshot(creationPage, testInfo, '04a-avatar-preview-queue-submitted')
  378. await creationPage.close()
  379. let previewJob: JsonRecord = {}
  380. const jobDeadline = Date.now() + 180_000
  381. while (Date.now() < jobDeadline) {
  382. const response = await request.get(`/api/v1/jobs/${previewJobId}`, { headers })
  383. previewJob = await responseData(response, 200, '浏览器关闭后查询数字人预览制作任务')
  384. if (previewJob.status === 'succeeded' || previewJob.status === 'failed') break
  385. await new Promise((resolve) => setTimeout(resolve, 1000))
  386. }
  387. expect(previewJob).toMatchObject({ kind: 'capture_transcode', status: 'succeeded' })
  388. let backgroundAvatar: JsonRecord | undefined
  389. const attachDeadline = Date.now() + 30_000
  390. while (Date.now() < attachDeadline) {
  391. const listed = await listAvatars(request, headers, originalName)
  392. backgroundAvatar = listed.items.find((item) => String(item.name ?? '') === originalName)
  393. if (String(backgroundAvatar?.previewFileCode ?? '')) break
  394. await new Promise((resolve) => setTimeout(resolve, 500))
  395. }
  396. expect(String(backgroundAvatar?.previewFileCode ?? ''), '关闭浏览器后服务端应自动绑定 MP4 预览').not.toBe('')
  397. addPass(activities, '制作队列', '关闭录制页面后服务端继续封装并自动绑定预览', undefined, previewJobId)
  398. await nameSearch.fill(originalName)
  399. await page.getByRole('button', { name: '搜索', exact: true }).click()
  400. await waitForAvatarGridReady(page)
  401. const createdCard = avatarCard(page, originalName)
  402. await expect(createdCard).toBeVisible()
  403. await expect(createdCard).toContainText('可预览')
  404. const { response: createdListResponse, items: createdItems } = await listAvatars(request, headers, originalName)
  405. const createdAvatar = createdItems.find((item) => String(item.name ?? '') === originalName)
  406. const avatarId = String(createdAvatar?.id ?? '')
  407. expect(avatarId).not.toBe('')
  408. expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready')
  409. expect(String(createdAvatar?.previewFileCode ?? '')).not.toBe('')
  410. expect(createdAvatar).toMatchObject({ specialty: '维修教学', gender: '男' })
  411. addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId)
  412. await createdCard.getByRole('button', { name: '预览', exact: true }).click()
  413. const servicePreviewDialog = page.locator('.avatar-preview-dialog:visible').last()
  414. const recordedPreview = servicePreviewDialog.locator('video')
  415. await expect(recordedPreview).toBeVisible()
  416. await expect(recordedPreview).toHaveAttribute('src', /\/api\/v1\/files\/.+\/content\?expires=/)
  417. await expect(servicePreviewDialog.getByText('自动录制预览素材')).toBeVisible()
  418. const recordedPreviewUrl = String(await recordedPreview.getAttribute('src'))
  419. const recordedPreviewResponse = await request.get(recordedPreviewUrl)
  420. expect(recordedPreviewResponse.status()).toBe(200)
  421. expect(recordedPreviewResponse.headers()['content-type']).toContain('video/mp4')
  422. await captureScreenshot(page, testInfo, '04b-avatar-recorded-preview-playback')
  423. addPass(activities, '数字形象', '用户自建形象播放自动录制 MP4 预览', recordedPreviewResponse, avatarId)
  424. await servicePreviewDialog.locator('.el-dialog__headerbtn').click()
  425. await createdCard.getByRole('button', { name: '编辑' }).click()
  426. const editDialog = page.locator('.admin-form-dialog:visible').last()
  427. await editDialog.getByLabel('数字人名称').fill(editedName)
  428. await editDialog.getByLabel('角色').fill('装备维修教员')
  429. await editDialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select').click()
  430. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click()
  431. await editDialog.getByLabel('专业方向').fill('液压与电气检修实训')
  432. const visibilityField = editDialog.locator('.avatar-visibility-field')
  433. const visibilitySwitch = visibilityField.getByRole('switch')
  434. if (await visibilitySwitch.isChecked()) await visibilityField.locator('.el-switch').click()
  435. await captureScreenshot(page, testInfo, '05-avatar-profile-and-visibility-edit')
  436. await editDialog.getByRole('button', { name: '保存修改' }).click()
  437. await waitForToast(page, '数字人信息已更新')
  438. await nameSearch.fill(editedName)
  439. await page.getByRole('button', { name: '搜索', exact: true }).click()
  440. await waitForAvatarGridReady(page)
  441. const editedCard = avatarCard(page, editedName)
  442. await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训')
  443. await expect(editedCard).toContainText('平台共享')
  444. const detailAfterEdit = await responseData(
  445. await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }),
  446. 200,
  447. '刷新验证数字人页面编辑',
  448. )
  449. expect(detailAfterEdit).toMatchObject({
  450. name: editedName,
  451. role: '装备维修教员',
  452. specialty: '液压与电气检修实训',
  453. gender: '男',
  454. visibility: 'internal',
  455. })
  456. addPass(activities, '数字形象', '页面编辑并刷新持久化', undefined, avatarId)
  457. await editedCard.getByRole('button', { name: '停用' }).click()
  458. const disableBox = page.locator('.el-message-box:visible').last()
  459. await expect(disableBox).toContainText('停用后不会出现在业务选择器中')
  460. await disableBox.getByRole('button', { name: '确认停用' }).click()
  461. await waitForToast(page, '数字人已停用')
  462. await expect(avatarCard(page, editedName)).toContainText('已停用')
  463. await captureScreenshot(page, testInfo, '06-avatar-disabled-through-ui')
  464. await avatarCard(page, editedName).getByRole('button', { name: '启用' }).click()
  465. await waitForToast(page, '数字人已启用')
  466. await expect(avatarCard(page, editedName)).toContainText('可预览')
  467. addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId)
  468. await avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true }).click()
  469. await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`))
  470. await expect(avatarCard(page, editedName)).toContainText('默认数字人')
  471. const defaultPreferences = await getAvatarPreferences(request, headers)
  472. expect(defaultPreferences.defaultAvatarId).toBe(avatarId)
  473. addPass(activities, '数字形象偏好', '页面设为平台默认数字人', undefined, avatarId)
  474. await nameSearch.fill('')
  475. const currentSwitchState = await builtInSwitch.isChecked()
  476. await builtInSwitchControl.click()
  477. await waitForToast(page, currentSwitchState ? '已隐藏内置示例形象' : '已显示内置示例形象')
  478. expect(await builtInSwitch.isChecked()).toBe(!currentSwitchState)
  479. await captureScreenshot(page, testInfo, '07-builtin-avatar-visibility-toggled')
  480. addPass(activities, '数字形象偏好', '页面切换内置示例显示配置')
  481. const restoredResponse = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
  482. addPass(activities, '数字形象偏好', '按测试前快照恢复默认形象与内置显示', restoredResponse)
  483. preferenceSnapshot = null
  484. await page.reload()
  485. await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
  486. await page.getByPlaceholder('搜索形象名称').fill(editedName)
  487. await page.getByRole('button', { name: '搜索', exact: true }).click()
  488. await waitForAvatarGridReady(page)
  489. await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认', exact: true })).toBeEnabled()
  490. await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click()
  491. const deleteBox = page.locator('.el-message-box:visible').last()
  492. await expect(deleteBox).toContainText(`确定删除“${editedName}”吗`)
  493. await deleteBox.getByRole('button', { name: '确认删除' }).click()
  494. await waitForToast(page, '数字人已删除')
  495. await expect(avatarCard(page, editedName)).toHaveCount(0)
  496. const absent = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  497. expect(absent.status()).toBe(404)
  498. await captureScreenshot(page, testInfo, '08-avatar-deleted-and-filter-empty')
  499. addPass(activities, '数字形象', '页面删除且正式 API 不可再读取', absent, avatarId)
  500. } catch (error) {
  501. primaryError = error
  502. activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  503. } finally {
  504. // 真实 CPU 数字人生成可能超过一次访问令牌的短有效期;清理阶段重新登录,
  505. // 避免主流程失败后因旧 token 留下测试形象或未恢复平台偏好。
  506. const cleanupHeaders = await authHeaders(request).catch(() => headers)
  507. if (preferenceSnapshot) {
  508. try {
  509. const restored = await restoreAvatarPreferences(request, cleanupHeaders, preferenceSnapshot)
  510. activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() })
  511. } catch (error) {
  512. const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}`
  513. cleanupErrors.push(message)
  514. activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message })
  515. }
  516. }
  517. await cleanupAvatarsByPrefix(request, cleanupHeaders, activities, cleanupErrors, new Set([originalName, editedName]))
  518. await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors)
  519. }
  520. if (primaryError) throw primaryError
  521. expect(cleanupErrors, '数字形象测试数据与平台偏好必须完整恢复').toEqual([])
  522. })
  523. test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编辑、筛选、预览、停启和删除闭环', async ({ page, request }, testInfo) => {
  524. test.setTimeout(360_000)
  525. const headers = await authHeaders(request)
  526. const activities: Activity[] = []
  527. const cleanupErrors: string[] = []
  528. const knownFileCodes = new Set<string>()
  529. const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  530. const sceneImage = path.resolve('../ai_person/public/brand/DA_bg.jpg')
  531. const voiceFixture = testInfo.outputPath(`${runPrefix}-real-voice.wav`)
  532. let primaryError: unknown
  533. expect(fs.existsSync(sourceVideo)).toBeTruthy()
  534. expect(fs.existsSync(sceneImage)).toBeTruthy()
  535. createVoiceFixture(sourceVideo, voiceFixture)
  536. const voiceName = `${runPrefix}-维修教员克隆音色`.slice(0, 50)
  537. const voiceEditedName = `${runPrefix}-维修讲解克隆音色`.slice(0, 50)
  538. const ttsName = `${runPrefix}-本地标准中文TTS`.slice(0, 50)
  539. const ttsEditedName = `${runPrefix}-本地自然口播TTS`.slice(0, 50)
  540. const sceneName = `${runPrefix}-数字车间背景`.slice(0, 50)
  541. const sceneEditedName = `${runPrefix}-数字车间实训背景`.slice(0, 50)
  542. try {
  543. await loginAsAdmin(page)
  544. await page.goto('/assets/voice-clones')
  545. await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
  546. await page.getByRole('button', { name: /新建克隆音色/ }).click()
  547. let dialog = page.locator('.admin-form-dialog:visible').last()
  548. await dialogField(dialog, '名称').locator('input').fill(voiceName)
  549. await dialogField(dialog, '分类').locator('select').selectOption('男声教员')
  550. await dialogField(dialog, '说明').locator('textarea').fill('装备维修实训问答与安全口播音色')
  551. await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles(voiceFixture)
  552. await expect(dialog.locator('.voice-capture-panel audio')).toBeVisible()
  553. await captureScreenshot(page, testInfo, '09-voice-clone-real-audio-before-save')
  554. const voiceUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
  555. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  556. const voiceUpload = await voiceUploadPromise
  557. const voiceFile = await responseData(voiceUpload, 201, '上传真实克隆音色样本')
  558. knownFileCodes.add(String(voiceFile.id ?? ''))
  559. await waitForToast(page, '配置已创建')
  560. let search = page.locator('.capability-search input')
  561. await search.fill(voiceName)
  562. let card = capabilityCard(page, voiceName)
  563. await expect(card).toBeVisible()
  564. const { response: voiceListResponse, items: voiceItems } = await listCapabilities(request, headers, 'VOICE_CLONE', voiceName)
  565. const voiceId = String(voiceItems.find((item) => String(item.name ?? '') === voiceName)?.id ?? '')
  566. expect(voiceId).not.toBe('')
  567. addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId)
  568. await card.getByRole('button', { name: '试听' }).click()
  569. const audio = card.locator('.inline-media-preview audio')
  570. await expect(audio).toBeVisible()
  571. await expect(audio).toHaveAttribute('src', /.+/)
  572. await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
  573. await captureScreenshot(page, testInfo, '10-voice-clone-signed-audio-preview')
  574. addPass(activities, '声音克隆', '短期签名音频真实试听', undefined, voiceId)
  575. await card.getByRole('button', { name: '编辑' }).click()
  576. dialog = page.locator('.admin-form-dialog:visible').last()
  577. await dialogField(dialog, '名称').locator('input').fill(voiceEditedName)
  578. await dialogField(dialog, '分类').locator('select').selectOption('讲解播报')
  579. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于维修工序讲解与风险提示')
  580. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  581. await waitForToast(page, '配置已更新')
  582. await expect(dialog).toBeHidden()
  583. await search.fill(voiceEditedName)
  584. card = capabilityCard(page, voiceEditedName)
  585. await expect(card).toContainText('讲解播报')
  586. await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示')
  587. const editedAudio = card.locator('.inline-media-preview audio')
  588. if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click()
  589. await expect(editedAudio).toBeVisible()
  590. await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
  591. addPass(activities, '声音克隆', '页面编辑并刷新持久化', undefined, voiceId)
  592. await card.getByRole('button', { name: '停用' }).click()
  593. await waitForToast(page, '已停用')
  594. await selectCapabilityStatus(page, '已停用')
  595. await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用')
  596. await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter')
  597. await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click()
  598. await waitForToast(page, '已启用')
  599. await selectCapabilityStatus(page, '全部状态')
  600. addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId)
  601. await deleteCapabilityThroughUi(page, voiceEditedName)
  602. addPass(activities, '声音克隆', '页面删除', undefined, voiceId)
  603. await page.goto('/assets/voice-models')
  604. await expect(page.getByRole('heading', { name: '语音模型' })).toBeVisible()
  605. await page.getByRole('button', { name: /新增语音模型/ }).click()
  606. dialog = page.locator('.admin-form-dialog:visible').last()
  607. await dialogField(dialog, '名称').locator('input').fill(ttsName)
  608. await dialogField(dialog, '分类').locator('select').selectOption('标准中文')
  609. await dialogField(dialog, '说明').locator('textarea').fill('本地实时问答标准中文播报')
  610. await dialog.locator('details.capability-form-technical summary').click()
  611. await dialog.locator('details.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-tts-standard-zh`)
  612. await captureScreenshot(page, testInfo, '12-tts-local-reference-before-save')
  613. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  614. await waitForToast(page, '配置已创建')
  615. search = page.locator('.capability-search input')
  616. await search.fill(ttsName)
  617. card = capabilityCard(page, ttsName)
  618. await expect(card).toBeVisible()
  619. const { response: ttsListResponse, items: ttsItems } = await listCapabilities(request, headers, 'TTS', ttsName)
  620. const ttsId = String(ttsItems.find((item) => String(item.name ?? '') === ttsName)?.id ?? '')
  621. expect(ttsId).not.toBe('')
  622. addPass(activities, 'TTS', '页面创建正式本地能力引用', ttsListResponse, ttsId)
  623. await card.locator('details.capability-technical-info summary').click()
  624. await card.getByRole('button', { name: '校验本地配置' }).click()
  625. await waitForToast(page, /配置可用|校验通过/)
  626. await card.getByRole('button', { name: '预览' }).click()
  627. await waitForToast(page, new RegExp(`${ttsName}适合`))
  628. await card.getByRole('button', { name: '编辑' }).click()
  629. dialog = page.locator('.admin-form-dialog:visible').last()
  630. await dialogField(dialog, '名称').locator('input').fill(ttsEditedName)
  631. await dialogField(dialog, '分类').locator('select').selectOption('自然口播')
  632. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:面向数字教员自然口播')
  633. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  634. await waitForToast(page, '配置已更新')
  635. await expect(dialog).toBeHidden()
  636. await search.fill(ttsEditedName)
  637. card = capabilityCard(page, ttsEditedName)
  638. await expect(card).toContainText('自然口播')
  639. await card.getByRole('button', { name: '停用' }).click()
  640. await waitForToast(page, '已停用')
  641. await card.getByRole('button', { name: '启用' }).click()
  642. await waitForToast(page, '已启用')
  643. await captureScreenshot(page, testInfo, '13-tts-edited-validated-and-enabled')
  644. addPass(activities, 'TTS', '校验、预览、编辑、停用与启用', undefined, ttsId)
  645. await deleteCapabilityThroughUi(page, ttsEditedName)
  646. addPass(activities, 'TTS', '页面删除', undefined, ttsId)
  647. await page.goto('/assets/scene-materials')
  648. await expect(page.getByRole('heading', { name: '场景素材' })).toBeVisible()
  649. await page.getByRole('tab', { name: '背景图片' }).click()
  650. await page.getByRole('button', { name: /新增场景素材/ }).click()
  651. dialog = page.locator('.admin-form-dialog:visible').last()
  652. await dialogField(dialog, '名称').locator('input').fill(sceneName)
  653. await dialogField(dialog, '分类').locator('select').selectOption('背景图片')
  654. await dialogField(dialog, '说明').locator('textarea').fill('数字车间设备维修实训背景')
  655. await dialog.locator('.file-field input[type="file"]').setInputFiles(sceneImage)
  656. const formPreview = dialog.locator('.scene-form-preview')
  657. await expect(formPreview).toBeVisible()
  658. await expect.poll(() => formPreview.locator('.scene-form-foreground').evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  659. await captureScreenshot(page, testInfo, '14-scene-real-image-upload-preview')
  660. const sceneUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
  661. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  662. const sceneUpload = await sceneUploadPromise
  663. const sceneFile = await responseData(sceneUpload, 201, '上传真实场景图片')
  664. knownFileCodes.add(String(sceneFile.id ?? ''))
  665. await waitForToast(page, '配置已创建')
  666. search = page.locator('.capability-search input')
  667. await search.fill(sceneName)
  668. card = capabilityCard(page, sceneName)
  669. await expect(card).toBeVisible()
  670. const { response: sceneListResponse, items: sceneItems } = await listCapabilities(request, headers, 'SCENE', sceneName)
  671. const sceneId = String(sceneItems.find((item) => String(item.name ?? '') === sceneName)?.id ?? '')
  672. expect(sceneId).not.toBe('')
  673. addPass(activities, '场景素材', '页面上传真实图片并创建', sceneListResponse, sceneId)
  674. await card.getByRole('button', { name: '预览' }).click()
  675. const scenePreview = card.locator('.inline-media-preview.scene-preview img')
  676. await expect(scenePreview).toBeVisible()
  677. await expect.poll(() => scenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  678. await captureScreenshot(page, testInfo, '15-scene-signed-image-list-preview')
  679. await card.getByRole('button', { name: '编辑' }).click()
  680. dialog = page.locator('.admin-form-dialog:visible').last()
  681. await dialogField(dialog, '名称').locator('input').fill(sceneEditedName)
  682. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于液压与电气联合检修实训')
  683. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  684. await waitForToast(page, '配置已更新')
  685. await expect(dialog).toBeHidden()
  686. await search.fill(sceneEditedName)
  687. card = capabilityCard(page, sceneEditedName)
  688. await expect(card).toContainText('已编辑:用于液压与电气联合检修实训')
  689. const editedScenePreview = card.locator('.inline-media-preview.scene-preview img')
  690. if (!(await editedScenePreview.count())) await card.getByRole('button', { name: '预览' }).click()
  691. await expect(editedScenePreview).toBeVisible()
  692. await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  693. await card.getByRole('button', { name: '停用' }).click()
  694. await waitForToast(page, '已停用')
  695. await selectCapabilityStatus(page, '已停用')
  696. await expect(capabilityCard(page, sceneEditedName)).toBeVisible()
  697. await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered')
  698. await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click()
  699. await waitForToast(page, '已启用')
  700. await selectCapabilityStatus(page, '全部状态')
  701. addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId)
  702. await deleteCapabilityThroughUi(page, sceneEditedName)
  703. addPass(activities, '场景素材', '页面删除', undefined, sceneId)
  704. } catch (error) {
  705. primaryError = error
  706. activities.push({ module: '能力资产 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  707. } finally {
  708. fs.rmSync(voiceFixture, { force: true })
  709. await cleanupCapabilitiesByPrefix(request, headers, activities, cleanupErrors, knownFileCodes)
  710. await attachLifecycleReport(testInfo, '能力资产 UI', activities, cleanupErrors)
  711. }
  712. if (primaryError) throw primaryError
  713. expect(cleanupErrors, '能力资产及上传媒体必须完整清理').toEqual([])
  714. })