Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

687 строки
31 KiB

  1. import { randomUUID } from 'node:crypto'
  2. import type {
  3. APIRequestContext,
  4. APIResponse,
  5. Browser,
  6. BrowserContext,
  7. Page,
  8. TestInfo,
  9. } from '@playwright/test'
  10. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  11. import { authHeaders, envelopeData, loginAsAdmin, readAdminCredentials } from './helpers'
  12. test.describe.configure({ mode: 'serial' })
  13. test.use({ trace: 'off', video: 'off' })
  14. type JsonRecord = Record<string, unknown>
  15. type Headers = Record<string, string>
  16. interface Activity {
  17. module: string
  18. action: string
  19. result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED'
  20. httpStatus?: number
  21. resourceId?: string
  22. detail?: string
  23. }
  24. interface WaveInfo {
  25. sampleRate: number
  26. channels: number
  27. duration: number
  28. dataBytes: number
  29. }
  30. interface CloneReferences {
  31. capabilityId: string
  32. sourceFileCode: string
  33. currentJobId: string
  34. }
  35. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  36. ? value as JsonRecord
  37. : {}
  38. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  39. async function responseData(
  40. response: APIResponse,
  41. expected: number | number[],
  42. label: string,
  43. ) {
  44. const statuses = Array.isArray(expected) ? expected : [expected]
  45. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  46. let body: unknown
  47. try {
  48. body = await response.json()
  49. } catch {
  50. throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
  51. }
  52. return asRecord(envelopeData(body))
  53. }
  54. function addActivity(
  55. activities: Activity[],
  56. module: string,
  57. action: string,
  58. result: Activity['result'],
  59. options: { response?: APIResponse; resourceId?: string; detail?: string } = {},
  60. ) {
  61. activities.push({
  62. module,
  63. action,
  64. result,
  65. httpStatus: options.response?.status(),
  66. resourceId: options.resourceId,
  67. detail: options.detail,
  68. })
  69. }
  70. function parseWave(buffer: Buffer): WaveInfo {
  71. if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') {
  72. throw new Error('下载结果不是有效的 RIFF/WAVE 文件')
  73. }
  74. let offset = 12
  75. let sampleRate = 0
  76. let channels = 0
  77. let byteRate = 0
  78. let dataBytes = 0
  79. while (offset + 8 <= buffer.length) {
  80. const chunkId = buffer.toString('ascii', offset, offset + 4)
  81. const chunkLength = buffer.readUInt32LE(offset + 4)
  82. const dataOffset = offset + 8
  83. if (dataOffset + chunkLength > buffer.length) break
  84. if (chunkId === 'fmt ' && chunkLength >= 16) {
  85. channels = buffer.readUInt16LE(dataOffset + 2)
  86. sampleRate = buffer.readUInt32LE(dataOffset + 4)
  87. byteRate = buffer.readUInt32LE(dataOffset + 8)
  88. } else if (chunkId === 'data') {
  89. dataBytes += chunkLength
  90. }
  91. offset = dataOffset + chunkLength + (chunkLength % 2)
  92. }
  93. if (!sampleRate || !channels || !byteRate || !dataBytes) throw new Error('WAV 缺少 fmt 或 data 数据块')
  94. return { sampleRate, channels, duration: dataBytes / byteRate, dataBytes }
  95. }
  96. function safeAudioUrl(value: unknown, baseURL: string) {
  97. const raw = String(value || '').trim()
  98. if (!raw) throw new Error('TTS 响应缺少 audioUrl')
  99. if (raw.startsWith('data:')) throw new Error('真实生命周期测试不接受 data URL 或前端伪造音频')
  100. return new URL(raw, `${baseURL}/`).toString()
  101. }
  102. function signedFileCode(value: unknown, baseURL: string) {
  103. const pathname = new URL(safeAudioUrl(value, baseURL)).pathname
  104. return decodeURIComponent(pathname.match(/\/files\/([^/]+)\/(?:content|download)$/)?.[1] || '')
  105. }
  106. function voiceGender(value: unknown): 'MALE' | 'FEMALE' | null {
  107. const normalized = String(value || '').trim().toLowerCase()
  108. if (normalized === 'male' || normalized === '男') return 'MALE'
  109. if (normalized === 'female' || normalized === '女') return 'FEMALE'
  110. return null
  111. }
  112. function avatarGender(value: unknown): 'MALE' | 'FEMALE' | null {
  113. const normalized = String(value || '').trim().toLowerCase()
  114. if (normalized === 'male' || normalized === '男') return 'MALE'
  115. if (normalized === 'female' || normalized === '女') return 'FEMALE'
  116. return null
  117. }
  118. async function downloadWave(
  119. request: APIRequestContext,
  120. url: string,
  121. label: string,
  122. ) {
  123. const response = await request.get(url)
  124. expect(response.status(), `${label}:HTTP ${response.status()}`).toBe(200)
  125. const contentType = String(response.headers()['content-type'] || '').toLowerCase()
  126. expect(contentType, `${label}应返回音频内容`).toContain('audio')
  127. const buffer = await response.body()
  128. const wave = parseWave(buffer)
  129. expect(wave.sampleRate).toBeGreaterThan(0)
  130. expect(wave.dataBytes).toBeGreaterThan(0)
  131. return { response, buffer, wave }
  132. }
  133. async function createAuthenticatedContext(browser: Browser, baseURL: string) {
  134. const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
  135. const page = await context.newPage()
  136. await loginAsAdmin(page)
  137. return { context, page }
  138. }
  139. async function listCapabilities(
  140. request: APIRequestContext,
  141. headers: Headers,
  142. keyword: string,
  143. ) {
  144. const response = await request.get('/api/v1/capabilities', {
  145. headers,
  146. params: { capabilityType: 'VOICE_CLONE', keyword, page: '1', pageSize: '200' },
  147. })
  148. const payload = await responseData(response, 200, '读取声音克隆能力列表')
  149. return { response, items: asRecords(payload.items ?? payload.records) }
  150. }
  151. async function waitForCloneTerminal(
  152. request: APIRequestContext,
  153. headers: Headers,
  154. capabilityId: string,
  155. jobId: string,
  156. timeoutMs = 12 * 60_000,
  157. ) {
  158. const deadline = Date.now() + timeoutMs
  159. let latestCapability: JsonRecord = {}
  160. let latestJob: JsonRecord = {}
  161. const observedProgress = new Set<number>()
  162. while (Date.now() < deadline) {
  163. const [capabilityResponse, jobResponse] = await Promise.all([
  164. request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }),
  165. request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }),
  166. ])
  167. latestCapability = await responseData(capabilityResponse, 200, '轮询声音克隆能力')
  168. latestJob = await responseData(jobResponse, 200, '轮询声音克隆任务')
  169. observedProgress.add(Number(latestJob.progress || 0))
  170. const cloneStatus = String(latestCapability.cloneStatus || '').toUpperCase()
  171. const jobStatus = String(latestJob.status || '').toLowerCase()
  172. if (cloneStatus === 'READY' && jobStatus === 'succeeded') {
  173. return { capability: latestCapability, job: latestJob, observedProgress: [...observedProgress] }
  174. }
  175. if (cloneStatus === 'FAILED' || ['failed', 'cancelled'].includes(jobStatus)) {
  176. const errorCode = String(latestJob.errorCode || '')
  177. const errorMessage = String(latestCapability.errorMessage || latestJob.errorMessage || latestJob.stage || jobStatus)
  178. throw new Error(`ZipVoice 真实克隆失败:${errorCode ? `${errorCode} / ` : ''}${errorMessage}`)
  179. }
  180. await new Promise((resolve) => setTimeout(resolve, 1_000))
  181. }
  182. throw new Error(
  183. `ZipVoice 在 ${Math.round(timeoutMs / 1000)} 秒内未完成;`
  184. + `能力状态=${String(latestCapability.cloneStatus || 'unknown')},`
  185. + `任务状态=${String(latestJob.status || 'unknown')},阶段=${String(latestJob.stage || 'unknown')}`,
  186. )
  187. }
  188. async function deleteCapability(
  189. request: APIRequestContext,
  190. headers: Headers,
  191. capabilityId: string,
  192. ) {
  193. for (let attempt = 0; attempt < 3; attempt += 1) {
  194. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers })
  195. if (detailResponse.status() === 404) return detailResponse
  196. const detail = await responseData(detailResponse, 200, '清理前刷新克隆音色版本')
  197. const response = await request.delete(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, {
  198. headers,
  199. params: { dataVersion: String(Number(detail.dataVersion || 0)) },
  200. })
  201. if ([200, 404].includes(response.status())) return response
  202. if (response.status() !== 409 || attempt === 2) {
  203. const message = await response.text().catch(() => '')
  204. throw new Error(`删除克隆音色失败:HTTP ${response.status()} ${message.slice(0, 240)}`)
  205. }
  206. await new Promise((resolve) => setTimeout(resolve, 500))
  207. }
  208. throw new Error('删除克隆音色失败')
  209. }
  210. async function settleCloneJob(
  211. request: APIRequestContext,
  212. headers: Headers,
  213. jobId: string,
  214. ) {
  215. if (!jobId) return
  216. const firstResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  217. if (firstResponse.status() === 404) return
  218. const first = await responseData(firstResponse, 200, '清理前读取声音克隆任务')
  219. let status = String(first.status || '').toLowerCase()
  220. if (['queued', 'running'].includes(status)) {
  221. const cancelResponse = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers })
  222. if (![200, 409].includes(cancelResponse.status())) {
  223. throw new Error(`取消活动声音克隆任务失败:HTTP ${cancelResponse.status()}`)
  224. }
  225. }
  226. const deadline = Date.now() + 90_000
  227. while (Date.now() < deadline) {
  228. const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  229. if (response.status() === 404) return
  230. const current = await responseData(response, 200, '等待声音克隆任务退出活动状态')
  231. status = String(current.status || '').toLowerCase()
  232. if (!['queued', 'running'].includes(status)) return
  233. await new Promise((resolve) => setTimeout(resolve, 500))
  234. }
  235. throw new Error(`声音克隆任务 ${jobId} 未在清理时限内退出活动状态`)
  236. }
  237. async function cleanupOwnedResources(
  238. request: APIRequestContext,
  239. headers: Headers,
  240. capabilityName: string,
  241. references: CloneReferences,
  242. activities: Activity[],
  243. cleanupErrors: string[],
  244. ) {
  245. const capabilityIds = new Set<string>()
  246. const sourceFileCodes = new Set<string>()
  247. const jobIds = new Set<string>()
  248. if (references.capabilityId) capabilityIds.add(references.capabilityId)
  249. if (references.sourceFileCode) sourceFileCodes.add(references.sourceFileCode)
  250. if (references.currentJobId) jobIds.add(references.currentJobId)
  251. try {
  252. const { items } = await listCapabilities(request, headers, capabilityName)
  253. for (const item of items) {
  254. if (String(item.name || '') !== capabilityName) continue
  255. const capabilityId = String(item.id || '')
  256. if (capabilityId) capabilityIds.add(capabilityId)
  257. const sourceFileCode = String(item.mediaFileCode || '')
  258. if (sourceFileCode) sourceFileCodes.add(sourceFileCode)
  259. const currentJobId = String(item.currentJobId || '')
  260. if (currentJobId) jobIds.add(currentJobId)
  261. }
  262. for (const capabilityId of capabilityIds) {
  263. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers })
  264. if (detailResponse.status() === 404) continue
  265. const detail = await responseData(detailResponse, 200, '清理前读取克隆音色详情')
  266. const sourceFileCode = String(detail.mediaFileCode || '')
  267. if (sourceFileCode) sourceFileCodes.add(sourceFileCode)
  268. const currentJobId = String(detail.currentJobId || '')
  269. if (currentJobId) jobIds.add(currentJobId)
  270. }
  271. } catch (error) {
  272. const message = `发现测试资源失败:${error instanceof Error ? error.message : String(error)}`
  273. cleanupErrors.push(message)
  274. addActivity(activities, '声音克隆', '发现需要清理的测试资源', 'CLEANUP_FAILED', { detail: message })
  275. }
  276. for (const jobId of jobIds) {
  277. try {
  278. await settleCloneJob(request, headers, jobId)
  279. addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANED', { resourceId: jobId })
  280. } catch (error) {
  281. const message = error instanceof Error ? error.message : String(error)
  282. cleanupErrors.push(message)
  283. addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANUP_FAILED', {
  284. resourceId: jobId,
  285. detail: message,
  286. })
  287. }
  288. }
  289. for (const capabilityId of capabilityIds) {
  290. try {
  291. const response = await deleteCapability(request, headers, capabilityId)
  292. addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANED', {
  293. response,
  294. resourceId: capabilityId,
  295. })
  296. } catch (error) {
  297. const message = error instanceof Error ? error.message : String(error)
  298. cleanupErrors.push(message)
  299. addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANUP_FAILED', {
  300. resourceId: capabilityId,
  301. detail: message,
  302. })
  303. }
  304. }
  305. for (const sourceFileCode of sourceFileCodes) {
  306. try {
  307. const response = await request.delete(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers })
  308. if (![200, 404].includes(response.status())) throw new Error(`删除参考源文件失败:HTTP ${response.status()}`)
  309. addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANED', {
  310. response,
  311. resourceId: sourceFileCode,
  312. })
  313. } catch (error) {
  314. const message = error instanceof Error ? error.message : String(error)
  315. cleanupErrors.push(message)
  316. addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANUP_FAILED', {
  317. resourceId: sourceFileCode,
  318. detail: message,
  319. })
  320. }
  321. }
  322. try {
  323. const { items } = await listCapabilities(request, headers, capabilityName)
  324. const exactResidual = items.filter((item) => String(item.name || '') === capabilityName)
  325. if (exactResidual.length) throw new Error(`能力列表仍有 ${exactResidual.length} 条同名测试资源`)
  326. for (const capabilityId of capabilityIds) {
  327. const detail = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers })
  328. if (detail.status() !== 404) throw new Error(`已删除能力仍可读取:HTTP ${detail.status()}`)
  329. }
  330. const voicesResponse = await request.get('/api/v1/tts/voices', { headers })
  331. const voices = await responseData(voicesResponse, 200, '清理后刷新 TTS 音色目录')
  332. if (asRecords(voices.items).some((item) => capabilityIds.has(String(item.capabilityId || '')))) {
  333. throw new Error('已删除克隆音色仍残留在 TTS 音色目录')
  334. }
  335. for (const sourceFileCode of sourceFileCodes) {
  336. const source = await request.get(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers })
  337. if (source.status() !== 404) throw new Error(`已删除参考源文件仍可读取:HTTP ${source.status()}`)
  338. }
  339. if (jobIds.size) {
  340. const activeResponse = await request.get('/api/v1/jobs', {
  341. headers,
  342. params: { status: 'ACTIVE', kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' },
  343. })
  344. const active = await responseData(activeResponse, 200, '清理后检查活动声音克隆任务')
  345. if (asRecords(active.items).some((item) => jobIds.has(String(item.id || '')))) {
  346. throw new Error('声音克隆任务仍处于活动状态')
  347. }
  348. }
  349. addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'PASS')
  350. } catch (error) {
  351. const message = error instanceof Error ? error.message : String(error)
  352. cleanupErrors.push(message)
  353. addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'CLEANUP_FAILED', { detail: message })
  354. }
  355. }
  356. async function attachLifecycleReport(
  357. testInfo: TestInfo,
  358. activities: Activity[],
  359. cleanupErrors: string[],
  360. progress: number[],
  361. ) {
  362. const report = {
  363. runId,
  364. runPrefix,
  365. title: 'ZipVoice 真实 CPU 声音克隆生命周期',
  366. activities,
  367. observedProgress: progress,
  368. cleanupComplete: cleanupErrors.length === 0,
  369. cleanupErrors,
  370. browserIndependence: '创建页关闭后由服务端常驻 worker 继续处理,并在新的浏览器上下文读取结果。',
  371. retention: 'TTS 合成记录和已完成任务作为不可变审计历史保留;测试名称不写入 TTS 文本或任务输入。',
  372. security: '附件不记录账号密码、访问令牌、Cookie、上传内容或带签名的音频 URL。',
  373. }
  374. const serialized = JSON.stringify(report)
  375. const credentials = readAdminCredentials()
  376. expect(serialized).not.toContain(credentials.password)
  377. await attachJson(testInfo, 'ZipVoice-真实生命周期与清理', report)
  378. }
  379. test('ZipVoice 真实生命周期:服务端异步克隆、跨浏览器续作、TTS 合成与页面可选', async ({ browser, request }, testInfo) => {
  380. test.setTimeout(15 * 60_000)
  381. const baseURL = String(testInfo.project.use.baseURL || process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '')
  382. const capabilityName = `${runPrefix}-ZIPVOICE-REAL`.slice(0, 100)
  383. const referenceText = '请确认设备已经停机断电并完成泄压,随后按照操作规程逐项检查工具和安全防护用品。'
  384. const validationText = '声音克隆真实合成验证已经完成。'
  385. const activities: Activity[] = []
  386. const cleanupErrors: string[] = []
  387. const references: CloneReferences = { capabilityId: '', sourceFileCode: '', currentJobId: '' }
  388. const progress: number[] = []
  389. let creationContext: BrowserContext | null = null
  390. let jobsContext: BrowserContext | null = null
  391. let verificationContext: BrowserContext | null = null
  392. let bodyFailed = false
  393. const headers = await authHeaders(request)
  394. try {
  395. const preCleanupErrors: string[] = []
  396. await cleanupOwnedResources(
  397. request,
  398. headers,
  399. capabilityName,
  400. { capabilityId: '', sourceFileCode: '', currentJobId: '' },
  401. activities,
  402. preCleanupErrors,
  403. )
  404. if (preCleanupErrors.length) {
  405. throw new Error(`无法清理同一运行标识的历史测试资源:${preCleanupErrors.join(';')}`)
  406. }
  407. const catalogResponse = await request.get('/api/v1/tts/voices', { headers })
  408. const catalog = await responseData(catalogResponse, 200, '读取真实 TTS 音色目录')
  409. const vitsVoices = asRecords(catalog.items).filter((item) => String(item.runtimeMode || '').toUpperCase() === 'VITS')
  410. expect(vitsVoices.length, '至少需要一个可用的 VITS 内置音色来生成自有参考音频').toBeGreaterThan(0)
  411. const avatarsResponse = await request.get('/api/v1/avatars', {
  412. headers,
  413. params: { page: '1', pageSize: '200' },
  414. })
  415. const avatarsPage = await responseData(avatarsResponse, 200, '读取可编辑数字形象')
  416. const editableAvatars = asRecords(avatarsPage.items ?? avatarsPage.records).filter((item) => (
  417. item.builtIn !== true
  418. && String(item.sourceType || '').toLowerCase() !== 'built_in'
  419. && avatarGender(item.gender) !== null
  420. ))
  421. const voiceAndAvatar = vitsVoices
  422. .map((voice) => ({
  423. voice,
  424. gender: voiceGender(voice.gender),
  425. avatar: editableAvatars.find((avatar) => avatarGender(avatar.gender) === voiceGender(voice.gender)),
  426. }))
  427. .find((item) => item.gender && item.avatar)
  428. expect(voiceAndAvatar, '需要至少一个与可用 VITS 音色同性别的可编辑数字形象,用于验证默认音色下拉').toBeTruthy()
  429. const builtInVoice = voiceAndAvatar!.voice
  430. const cloneGender = voiceAndAvatar!.gender!
  431. const targetAvatar = voiceAndAvatar!.avatar!
  432. const authenticated = await createAuthenticatedContext(browser, baseURL)
  433. creationContext = authenticated.context
  434. const creationPage = authenticated.page
  435. await creationPage.goto('/assets/voice-clones')
  436. await expect(creationPage.getByRole('heading', { name: '声音克隆' })).toBeVisible()
  437. const referenceResponse = await request.post('/api/v1/tts', {
  438. headers,
  439. data: {
  440. text: referenceText,
  441. voiceCapabilityId: String(builtInVoice.capabilityId),
  442. speed: 0.8,
  443. },
  444. timeout: 120_000,
  445. })
  446. const referenceSpeech = await responseData(referenceResponse, 200, '使用 VITS 生成自有参考音频')
  447. expect(String(referenceSpeech.runtimeMode || '').toUpperCase()).toBe('VITS')
  448. expect(Number(referenceSpeech.duration || 0), '参考音频必须至少 5 秒').toBeGreaterThanOrEqual(5)
  449. expect(Number(referenceSpeech.duration || 0), '参考音频必须不超过声音克隆 30 秒上限').toBeLessThanOrEqual(30)
  450. const referenceAudioUrl = safeAudioUrl(referenceSpeech.audioUrl, baseURL)
  451. const referenceAudioFileCode = signedFileCode(referenceSpeech.audioUrl, baseURL)
  452. const referenceWave = await downloadWave(request, referenceAudioUrl, '下载 VITS 参考 WAV')
  453. expect(referenceWave.wave.duration).toBeGreaterThanOrEqual(4.8)
  454. addActivity(activities, '参考音频', 'VITS 真实生成并下载自有 WAV', 'PASS', {
  455. response: referenceResponse,
  456. resourceId: referenceAudioFileCode || undefined,
  457. detail: `${referenceWave.wave.sampleRate}Hz / ${referenceWave.wave.channels}ch / ${referenceWave.wave.duration.toFixed(2)}s`,
  458. })
  459. const uploadResponse = await request.post('/api/v1/files', {
  460. headers,
  461. multipart: {
  462. purpose: 'CAPABILITY_SOURCE',
  463. file: {
  464. name: `${runPrefix}-zipvoice-reference.wav`,
  465. mimeType: 'audio/wav',
  466. buffer: referenceWave.buffer,
  467. },
  468. },
  469. })
  470. const uploaded = await responseData(uploadResponse, 201, '上传声音克隆参考 WAV')
  471. references.sourceFileCode = String(uploaded.id || uploaded.fileCode || '')
  472. expect(references.sourceFileCode).not.toBe('')
  473. addActivity(activities, '声音克隆', '以 CAPABILITY_SOURCE 上传参考 WAV', 'PASS', {
  474. response: uploadResponse,
  475. resourceId: references.sourceFileCode,
  476. })
  477. const createResponse = await request.post('/api/v1/capabilities', {
  478. headers: { ...headers, 'Idempotency-Key': randomUUID() },
  479. data: {
  480. capabilityType: 'VOICE_CLONE',
  481. name: capabilityName,
  482. type: 'SHERPA_ZIPVOICE',
  483. description: '真实 CPU 异步声音克隆生命周期验证',
  484. reference: '服务端 VITS 自有合成参考音频',
  485. mediaFileCode: references.sourceFileCode,
  486. referenceText,
  487. gender: cloneGender,
  488. consentConfirmed: true,
  489. enabled: true,
  490. },
  491. })
  492. const created = await responseData(createResponse, 201, '创建 ZipVoice 真实克隆任务')
  493. references.capabilityId = String(created.id || '')
  494. references.currentJobId = String(created.currentJobId || '')
  495. expect(references.capabilityId).not.toBe('')
  496. expect(references.currentJobId).not.toBe('')
  497. expect(['PROCESSING', 'QUEUED']).toContain(String(created.cloneStatus || '').toUpperCase())
  498. addActivity(activities, '声音克隆', '提交带逐字稿、性别和授权确认的异步克隆任务', 'PASS', {
  499. response: createResponse,
  500. resourceId: references.capabilityId,
  501. })
  502. const jobsListResponse = await request.get('/api/v1/jobs', {
  503. headers,
  504. params: { kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' },
  505. })
  506. const jobsList = await responseData(jobsListResponse, 200, '立即读取制作队列')
  507. expect(asRecords(jobsList.items).some((item) => String(item.id || '') === references.currentJobId)).toBeTruthy()
  508. const immediateJobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}`, { headers })
  509. const immediateJob = await responseData(immediateJobResponse, 200, '立即读取声音克隆任务详情')
  510. expect(String(immediateJob.kind || '').toLowerCase()).toBe('voice_clone_register')
  511. addActivity(activities, '制作队列', '提交后任务立即可见', 'PASS', {
  512. response: immediateJobResponse,
  513. resourceId: references.currentJobId,
  514. detail: `${String(immediateJob.status || '')} / ${Number(immediateJob.progress || 0)}%`,
  515. })
  516. // The page that existed when the task was submitted is deliberately closed.
  517. // From this point onward only the API process and its durable worker remain.
  518. await creationContext.close()
  519. creationContext = null
  520. const jobsSession = await createAuthenticatedContext(browser, baseURL)
  521. jobsContext = jobsSession.context
  522. const jobsPage = jobsSession.page
  523. await jobsPage.goto(`/jobs?scope=all&jobId=${encodeURIComponent(references.currentJobId)}`)
  524. const jobDialog = jobsPage.locator('.admin-form-dialog:visible').last()
  525. await expect(jobDialog.getByRole('heading', { name: '任务详情' })).toBeVisible({ timeout: 30_000 })
  526. await expect(jobDialog).toContainText(references.currentJobId)
  527. await expect(jobDialog).toContainText('声音克隆')
  528. await captureScreenshot(jobsPage, testInfo, '01-zipvoice-real-job-after-creator-closed')
  529. await jobsContext.close()
  530. jobsContext = null
  531. const terminal = await waitForCloneTerminal(
  532. request,
  533. headers,
  534. references.capabilityId,
  535. references.currentJobId,
  536. )
  537. progress.push(...terminal.observedProgress)
  538. expect(String(terminal.capability.voiceRuntimeMode || '').toUpperCase()).toBe('ZIPVOICE_READY')
  539. expect(String(terminal.capability.validationStatus || '').toUpperCase()).toBe('AVAILABLE')
  540. expect(String(terminal.job.status || '').toLowerCase()).toBe('succeeded')
  541. expect(String(terminal.capability.previewFileCode || '')).not.toBe('')
  542. addActivity(activities, '声音克隆', '创建页关闭后服务端 worker 独立完成真实合成验收', 'PASS', {
  543. resourceId: references.currentJobId,
  544. detail: `READY / progress=${terminal.observedProgress.join('→')}`,
  545. })
  546. const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}/events`, { headers })
  547. const events = await responseData(eventsResponse, 200, '读取声音克隆任务事件')
  548. const eventItems = asRecords(events.items)
  549. expect(eventItems.length).toBeGreaterThan(1)
  550. expect(eventItems.some((item) => String(item.status || '').toLowerCase() === 'succeeded')).toBeTruthy()
  551. addActivity(activities, '制作队列', '持久化事件流包含成功终态', 'PASS', {
  552. response: eventsResponse,
  553. resourceId: references.currentJobId,
  554. detail: `${eventItems.length} events`,
  555. })
  556. const readyCatalogResponse = await request.get('/api/v1/tts/voices', { headers })
  557. const readyCatalog = await responseData(readyCatalogResponse, 200, '刷新 TTS 音色目录')
  558. const clonedVoice = asRecords(readyCatalog.items).find((item) => String(item.capabilityId || '') === references.capabilityId)
  559. expect(clonedVoice, 'READY 克隆音色必须进入统一 /tts/voices 目录').toBeTruthy()
  560. expect(String(clonedVoice!.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE')
  561. expect(clonedVoice!.speaker == null).toBeTruthy()
  562. addActivity(activities, '统一音色目录', 'READY 克隆音色按 capabilityId 可用且不伪造 speaker', 'PASS', {
  563. response: readyCatalogResponse,
  564. resourceId: references.capabilityId,
  565. })
  566. const clonedSpeechResponse = await request.post('/api/v1/tts', {
  567. headers,
  568. data: { text: validationText, voiceCapabilityId: references.capabilityId, speed: 0.9 },
  569. timeout: 120_000,
  570. })
  571. const clonedSpeech = await responseData(clonedSpeechResponse, 200, '使用克隆 capabilityId 真实合成')
  572. expect(String(clonedSpeech.voiceCapabilityId || '')).toBe(references.capabilityId)
  573. expect(String(clonedSpeech.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE')
  574. expect(clonedSpeech.speaker == null).toBeTruthy()
  575. const clonedAudioUrl = safeAudioUrl(clonedSpeech.audioUrl, baseURL)
  576. const clonedWave = await downloadWave(request, clonedAudioUrl, '下载 ZipVoice 合成 WAV')
  577. expect(clonedWave.wave.duration).toBeGreaterThan(0.5)
  578. addActivity(activities, 'TTS', '克隆 capabilityId 真实合成并下载有效 WAV', 'PASS', {
  579. response: clonedSpeechResponse,
  580. detail: `${clonedWave.wave.sampleRate}Hz / ${clonedWave.wave.channels}ch / ${clonedWave.wave.duration.toFixed(2)}s`,
  581. })
  582. const verification = await createAuthenticatedContext(browser, baseURL)
  583. verificationContext = verification.context
  584. const verificationPage = verification.page
  585. await verificationPage.goto('/assets/voice-clones')
  586. const voiceSearch = verificationPage.getByPlaceholder('搜索名称或说明')
  587. await voiceSearch.fill(capabilityName)
  588. await verificationPage.getByRole('button', { name: '搜索', exact: true }).click()
  589. const readyCard = verificationPage.locator('.capability-card').filter({ hasText: capabilityName }).first()
  590. await expect(readyCard).toBeVisible({ timeout: 30_000 })
  591. await expect(readyCard).toContainText('克隆音色可用')
  592. await captureScreenshot(verificationPage, testInfo, '02-zipvoice-real-ready-card-new-context')
  593. addActivity(activities, '声音克隆页面', '新浏览器上下文展示 READY 克隆音色', 'PASS', {
  594. resourceId: references.capabilityId,
  595. })
  596. await verificationPage.goto('/assets/avatars')
  597. const avatarName = String(targetAvatar.name || '')
  598. expect(avatarName).not.toBe('')
  599. await verificationPage.getByPlaceholder('搜索形象名称').fill(avatarName)
  600. await verificationPage.getByRole('button', { name: '搜索', exact: true }).click()
  601. const avatarCard = verificationPage.locator('.avatar-card').filter({ hasText: avatarName }).first()
  602. await expect(avatarCard).toBeVisible({ timeout: 30_000 })
  603. await avatarCard.getByRole('button', { name: '编辑' }).click()
  604. const avatarDialog = verificationPage.locator('.admin-form-dialog:visible').last()
  605. await expect(avatarDialog.getByRole('heading', { name: '编辑数字人' })).toBeVisible()
  606. const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select')
  607. await expect(voiceSelect).not.toHaveClass(/is-disabled/, { timeout: 30_000 })
  608. await voiceSelect.click()
  609. const voiceDropdown = verificationPage.locator('.el-select-dropdown:visible').last()
  610. const cloneOption = voiceDropdown.locator('.el-select-dropdown__item').filter({ hasText: capabilityName })
  611. await expect(cloneOption).toBeVisible({ timeout: 30_000 })
  612. await expect(cloneOption).toContainText('克隆')
  613. await captureScreenshot(verificationPage, testInfo, '03-avatar-default-voice-selects-real-zipvoice')
  614. addActivity(activities, '数字形象页面', '编辑下拉从统一目录选择 READY 克隆音色', 'PASS', {
  615. resourceId: references.capabilityId,
  616. detail: `${cloneGender} / 未保存,不修改现有数字形象`,
  617. })
  618. } catch (error) {
  619. bodyFailed = true
  620. throw error
  621. } finally {
  622. await creationContext?.close().catch(() => undefined)
  623. await jobsContext?.close().catch(() => undefined)
  624. await verificationContext?.close().catch(() => undefined)
  625. await cleanupOwnedResources(
  626. request,
  627. headers,
  628. capabilityName,
  629. references,
  630. activities,
  631. cleanupErrors,
  632. )
  633. await attachLifecycleReport(testInfo, activities, cleanupErrors, progress)
  634. if (!bodyFailed && cleanupErrors.length) {
  635. throw new Error(`ZipVoice 真实生命周期验证通过,但清理失败:${cleanupErrors.join(';')}`)
  636. }
  637. }
  638. })