import { randomUUID } from 'node:crypto' import type { APIRequestContext, APIResponse, Browser, BrowserContext, Page, TestInfo, } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData, loginAsAdmin, readAdminCredentials } from './helpers' test.describe.configure({ mode: 'serial' }) test.use({ trace: 'off', video: 'off' }) type JsonRecord = Record type Headers = Record interface Activity { module: string action: string result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface WaveInfo { sampleRate: number channels: number duration: number dataBytes: number } interface CloneReferences { capabilityId: string sourceFileCode: string currentJobId: string } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] async function responseData( response: APIResponse, expected: number | number[], label: string, ) { const statuses = Array.isArray(expected) ? expected : [expected] expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) let body: unknown try { body = await response.json() } catch { throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) } return asRecord(envelopeData(body)) } function addActivity( activities: Activity[], module: string, action: string, result: Activity['result'], options: { response?: APIResponse; resourceId?: string; detail?: string } = {}, ) { activities.push({ module, action, result, httpStatus: options.response?.status(), resourceId: options.resourceId, detail: options.detail, }) } function parseWave(buffer: Buffer): WaveInfo { if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') { throw new Error('下载结果不是有效的 RIFF/WAVE 文件') } let offset = 12 let sampleRate = 0 let channels = 0 let byteRate = 0 let dataBytes = 0 while (offset + 8 <= buffer.length) { const chunkId = buffer.toString('ascii', offset, offset + 4) const chunkLength = buffer.readUInt32LE(offset + 4) const dataOffset = offset + 8 if (dataOffset + chunkLength > buffer.length) break if (chunkId === 'fmt ' && chunkLength >= 16) { channels = buffer.readUInt16LE(dataOffset + 2) sampleRate = buffer.readUInt32LE(dataOffset + 4) byteRate = buffer.readUInt32LE(dataOffset + 8) } else if (chunkId === 'data') { dataBytes += chunkLength } offset = dataOffset + chunkLength + (chunkLength % 2) } if (!sampleRate || !channels || !byteRate || !dataBytes) throw new Error('WAV 缺少 fmt 或 data 数据块') return { sampleRate, channels, duration: dataBytes / byteRate, dataBytes } } function safeAudioUrl(value: unknown, baseURL: string) { const raw = String(value || '').trim() if (!raw) throw new Error('TTS 响应缺少 audioUrl') if (raw.startsWith('data:')) throw new Error('真实生命周期测试不接受 data URL 或前端伪造音频') return new URL(raw, `${baseURL}/`).toString() } function signedFileCode(value: unknown, baseURL: string) { const pathname = new URL(safeAudioUrl(value, baseURL)).pathname return decodeURIComponent(pathname.match(/\/files\/([^/]+)\/(?:content|download)$/)?.[1] || '') } function voiceGender(value: unknown): 'MALE' | 'FEMALE' | null { const normalized = String(value || '').trim().toLowerCase() if (normalized === 'male' || normalized === '男') return 'MALE' if (normalized === 'female' || normalized === '女') return 'FEMALE' return null } function avatarGender(value: unknown): 'MALE' | 'FEMALE' | null { const normalized = String(value || '').trim().toLowerCase() if (normalized === 'male' || normalized === '男') return 'MALE' if (normalized === 'female' || normalized === '女') return 'FEMALE' return null } async function downloadWave( request: APIRequestContext, url: string, label: string, ) { const response = await request.get(url) expect(response.status(), `${label}:HTTP ${response.status()}`).toBe(200) const contentType = String(response.headers()['content-type'] || '').toLowerCase() expect(contentType, `${label}应返回音频内容`).toContain('audio') const buffer = await response.body() const wave = parseWave(buffer) expect(wave.sampleRate).toBeGreaterThan(0) expect(wave.dataBytes).toBeGreaterThan(0) return { response, buffer, wave } } async function createAuthenticatedContext(browser: Browser, baseURL: string) { const context = await browser.newContext({ baseURL, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) const page = await context.newPage() await loginAsAdmin(page) return { context, page } } async function listCapabilities( request: APIRequestContext, headers: Headers, keyword: string, ) { const response = await request.get('/api/v1/capabilities', { headers, params: { capabilityType: 'VOICE_CLONE', keyword, page: '1', pageSize: '200' }, }) const payload = await responseData(response, 200, '读取声音克隆能力列表') return { response, items: asRecords(payload.items ?? payload.records) } } async function waitForCloneTerminal( request: APIRequestContext, headers: Headers, capabilityId: string, jobId: string, timeoutMs = 12 * 60_000, ) { const deadline = Date.now() + timeoutMs let latestCapability: JsonRecord = {} let latestJob: JsonRecord = {} const observedProgress = new Set() while (Date.now() < deadline) { const [capabilityResponse, jobResponse] = await Promise.all([ request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }), request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }), ]) latestCapability = await responseData(capabilityResponse, 200, '轮询声音克隆能力') latestJob = await responseData(jobResponse, 200, '轮询声音克隆任务') observedProgress.add(Number(latestJob.progress || 0)) const cloneStatus = String(latestCapability.cloneStatus || '').toUpperCase() const jobStatus = String(latestJob.status || '').toLowerCase() if (cloneStatus === 'READY' && jobStatus === 'succeeded') { return { capability: latestCapability, job: latestJob, observedProgress: [...observedProgress] } } if (cloneStatus === 'FAILED' || ['failed', 'cancelled'].includes(jobStatus)) { const errorCode = String(latestJob.errorCode || '') const errorMessage = String(latestCapability.errorMessage || latestJob.errorMessage || latestJob.stage || jobStatus) throw new Error(`ZipVoice 真实克隆失败:${errorCode ? `${errorCode} / ` : ''}${errorMessage}`) } await new Promise((resolve) => setTimeout(resolve, 1_000)) } throw new Error( `ZipVoice 在 ${Math.round(timeoutMs / 1000)} 秒内未完成;` + `能力状态=${String(latestCapability.cloneStatus || 'unknown')},` + `任务状态=${String(latestJob.status || 'unknown')},阶段=${String(latestJob.stage || 'unknown')}`, ) } async function deleteCapability( request: APIRequestContext, headers: Headers, capabilityId: string, ) { for (let attempt = 0; attempt < 3; attempt += 1) { const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) if (detailResponse.status() === 404) return detailResponse const detail = await responseData(detailResponse, 200, '清理前刷新克隆音色版本') const response = await request.delete(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers, params: { dataVersion: String(Number(detail.dataVersion || 0)) }, }) if ([200, 404].includes(response.status())) return response if (response.status() !== 409 || attempt === 2) { const message = await response.text().catch(() => '') throw new Error(`删除克隆音色失败:HTTP ${response.status()} ${message.slice(0, 240)}`) } await new Promise((resolve) => setTimeout(resolve, 500)) } throw new Error('删除克隆音色失败') } async function settleCloneJob( request: APIRequestContext, headers: Headers, jobId: string, ) { if (!jobId) return const firstResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) if (firstResponse.status() === 404) return const first = await responseData(firstResponse, 200, '清理前读取声音克隆任务') let status = String(first.status || '').toLowerCase() if (['queued', 'running'].includes(status)) { const cancelResponse = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers }) if (![200, 409].includes(cancelResponse.status())) { throw new Error(`取消活动声音克隆任务失败:HTTP ${cancelResponse.status()}`) } } const deadline = Date.now() + 90_000 while (Date.now() < deadline) { const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) if (response.status() === 404) return const current = await responseData(response, 200, '等待声音克隆任务退出活动状态') status = String(current.status || '').toLowerCase() if (!['queued', 'running'].includes(status)) return await new Promise((resolve) => setTimeout(resolve, 500)) } throw new Error(`声音克隆任务 ${jobId} 未在清理时限内退出活动状态`) } async function cleanupOwnedResources( request: APIRequestContext, headers: Headers, capabilityName: string, references: CloneReferences, activities: Activity[], cleanupErrors: string[], ) { const capabilityIds = new Set() const sourceFileCodes = new Set() const jobIds = new Set() if (references.capabilityId) capabilityIds.add(references.capabilityId) if (references.sourceFileCode) sourceFileCodes.add(references.sourceFileCode) if (references.currentJobId) jobIds.add(references.currentJobId) try { const { items } = await listCapabilities(request, headers, capabilityName) for (const item of items) { if (String(item.name || '') !== capabilityName) continue const capabilityId = String(item.id || '') if (capabilityId) capabilityIds.add(capabilityId) const sourceFileCode = String(item.mediaFileCode || '') if (sourceFileCode) sourceFileCodes.add(sourceFileCode) const currentJobId = String(item.currentJobId || '') if (currentJobId) jobIds.add(currentJobId) } for (const capabilityId of capabilityIds) { const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) if (detailResponse.status() === 404) continue const detail = await responseData(detailResponse, 200, '清理前读取克隆音色详情') const sourceFileCode = String(detail.mediaFileCode || '') if (sourceFileCode) sourceFileCodes.add(sourceFileCode) const currentJobId = String(detail.currentJobId || '') if (currentJobId) jobIds.add(currentJobId) } } catch (error) { const message = `发现测试资源失败:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) addActivity(activities, '声音克隆', '发现需要清理的测试资源', 'CLEANUP_FAILED', { detail: message }) } for (const jobId of jobIds) { try { await settleCloneJob(request, headers, jobId) addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANED', { resourceId: jobId }) } catch (error) { const message = error instanceof Error ? error.message : String(error) cleanupErrors.push(message) addActivity(activities, '制作队列', '确认测试任务已退出活动状态', 'CLEANUP_FAILED', { resourceId: jobId, detail: message, }) } } for (const capabilityId of capabilityIds) { try { const response = await deleteCapability(request, headers, capabilityId) addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANED', { response, resourceId: capabilityId, }) } catch (error) { const message = error instanceof Error ? error.message : String(error) cleanupErrors.push(message) addActivity(activities, '声音克隆', '软删除克隆能力与运行档案', 'CLEANUP_FAILED', { resourceId: capabilityId, detail: message, }) } } for (const sourceFileCode of sourceFileCodes) { try { const response = await request.delete(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers }) if (![200, 404].includes(response.status())) throw new Error(`删除参考源文件失败:HTTP ${response.status()}`) addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANED', { response, resourceId: sourceFileCode, }) } catch (error) { const message = error instanceof Error ? error.message : String(error) cleanupErrors.push(message) addActivity(activities, '声音克隆', '删除上传的参考源文件', 'CLEANUP_FAILED', { resourceId: sourceFileCode, detail: message, }) } } try { const { items } = await listCapabilities(request, headers, capabilityName) const exactResidual = items.filter((item) => String(item.name || '') === capabilityName) if (exactResidual.length) throw new Error(`能力列表仍有 ${exactResidual.length} 条同名测试资源`) for (const capabilityId of capabilityIds) { const detail = await request.get(`/api/v1/capabilities/${encodeURIComponent(capabilityId)}`, { headers }) if (detail.status() !== 404) throw new Error(`已删除能力仍可读取:HTTP ${detail.status()}`) } const voicesResponse = await request.get('/api/v1/tts/voices', { headers }) const voices = await responseData(voicesResponse, 200, '清理后刷新 TTS 音色目录') if (asRecords(voices.items).some((item) => capabilityIds.has(String(item.capabilityId || '')))) { throw new Error('已删除克隆音色仍残留在 TTS 音色目录') } for (const sourceFileCode of sourceFileCodes) { const source = await request.get(`/api/v1/files/${encodeURIComponent(sourceFileCode)}`, { headers }) if (source.status() !== 404) throw new Error(`已删除参考源文件仍可读取:HTTP ${source.status()}`) } if (jobIds.size) { const activeResponse = await request.get('/api/v1/jobs', { headers, params: { status: 'ACTIVE', kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' }, }) const active = await responseData(activeResponse, 200, '清理后检查活动声音克隆任务') if (asRecords(active.items).some((item) => jobIds.has(String(item.id || '')))) { throw new Error('声音克隆任务仍处于活动状态') } } addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'PASS') } catch (error) { const message = error instanceof Error ? error.message : String(error) cleanupErrors.push(message) addActivity(activities, '声音克隆', '验证能力、音色目录、源文件与活动任务零残留', 'CLEANUP_FAILED', { detail: message }) } } async function attachLifecycleReport( testInfo: TestInfo, activities: Activity[], cleanupErrors: string[], progress: number[], ) { const report = { runId, runPrefix, title: 'ZipVoice 真实 CPU 声音克隆生命周期', activities, observedProgress: progress, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, browserIndependence: '创建页关闭后由服务端常驻 worker 继续处理,并在新的浏览器上下文读取结果。', retention: 'TTS 合成记录和已完成任务作为不可变审计历史保留;测试名称不写入 TTS 文本或任务输入。', security: '附件不记录账号密码、访问令牌、Cookie、上传内容或带签名的音频 URL。', } const serialized = JSON.stringify(report) const credentials = readAdminCredentials() expect(serialized).not.toContain(credentials.password) await attachJson(testInfo, 'ZipVoice-真实生命周期与清理', report) } test('ZipVoice 真实生命周期:服务端异步克隆、跨浏览器续作、TTS 合成与页面可选', async ({ browser, request }, testInfo) => { test.setTimeout(15 * 60_000) const baseURL = String(testInfo.project.use.baseURL || process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '') const capabilityName = `${runPrefix}-ZIPVOICE-REAL`.slice(0, 100) const referenceText = '请确认设备已经停机断电并完成泄压,随后按照操作规程逐项检查工具和安全防护用品。' const validationText = '声音克隆真实合成验证已经完成。' const activities: Activity[] = [] const cleanupErrors: string[] = [] const references: CloneReferences = { capabilityId: '', sourceFileCode: '', currentJobId: '' } const progress: number[] = [] let creationContext: BrowserContext | null = null let jobsContext: BrowserContext | null = null let verificationContext: BrowserContext | null = null let bodyFailed = false const headers = await authHeaders(request) try { const preCleanupErrors: string[] = [] await cleanupOwnedResources( request, headers, capabilityName, { capabilityId: '', sourceFileCode: '', currentJobId: '' }, activities, preCleanupErrors, ) if (preCleanupErrors.length) { throw new Error(`无法清理同一运行标识的历史测试资源:${preCleanupErrors.join(';')}`) } const catalogResponse = await request.get('/api/v1/tts/voices', { headers }) const catalog = await responseData(catalogResponse, 200, '读取真实 TTS 音色目录') const vitsVoices = asRecords(catalog.items).filter((item) => String(item.runtimeMode || '').toUpperCase() === 'VITS') expect(vitsVoices.length, '至少需要一个可用的 VITS 内置音色来生成自有参考音频').toBeGreaterThan(0) const avatarsResponse = await request.get('/api/v1/avatars', { headers, params: { page: '1', pageSize: '200' }, }) const avatarsPage = await responseData(avatarsResponse, 200, '读取可编辑数字形象') const editableAvatars = asRecords(avatarsPage.items ?? avatarsPage.records).filter((item) => ( item.builtIn !== true && String(item.sourceType || '').toLowerCase() !== 'built_in' && avatarGender(item.gender) !== null )) const voiceAndAvatar = vitsVoices .map((voice) => ({ voice, gender: voiceGender(voice.gender), avatar: editableAvatars.find((avatar) => avatarGender(avatar.gender) === voiceGender(voice.gender)), })) .find((item) => item.gender && item.avatar) expect(voiceAndAvatar, '需要至少一个与可用 VITS 音色同性别的可编辑数字形象,用于验证默认音色下拉').toBeTruthy() const builtInVoice = voiceAndAvatar!.voice const cloneGender = voiceAndAvatar!.gender! const targetAvatar = voiceAndAvatar!.avatar! const authenticated = await createAuthenticatedContext(browser, baseURL) creationContext = authenticated.context const creationPage = authenticated.page await creationPage.goto('/assets/voice-clones') await expect(creationPage.getByRole('heading', { name: '声音克隆' })).toBeVisible() const referenceResponse = await request.post('/api/v1/tts', { headers, data: { text: referenceText, voiceCapabilityId: String(builtInVoice.capabilityId), speed: 0.8, }, timeout: 120_000, }) const referenceSpeech = await responseData(referenceResponse, 200, '使用 VITS 生成自有参考音频') expect(String(referenceSpeech.runtimeMode || '').toUpperCase()).toBe('VITS') expect(Number(referenceSpeech.duration || 0), '参考音频必须至少 5 秒').toBeGreaterThanOrEqual(5) expect(Number(referenceSpeech.duration || 0), '参考音频必须不超过声音克隆 30 秒上限').toBeLessThanOrEqual(30) const referenceAudioUrl = safeAudioUrl(referenceSpeech.audioUrl, baseURL) const referenceAudioFileCode = signedFileCode(referenceSpeech.audioUrl, baseURL) const referenceWave = await downloadWave(request, referenceAudioUrl, '下载 VITS 参考 WAV') expect(referenceWave.wave.duration).toBeGreaterThanOrEqual(4.8) addActivity(activities, '参考音频', 'VITS 真实生成并下载自有 WAV', 'PASS', { response: referenceResponse, resourceId: referenceAudioFileCode || undefined, detail: `${referenceWave.wave.sampleRate}Hz / ${referenceWave.wave.channels}ch / ${referenceWave.wave.duration.toFixed(2)}s`, }) const uploadResponse = await request.post('/api/v1/files', { headers, multipart: { purpose: 'CAPABILITY_SOURCE', file: { name: `${runPrefix}-zipvoice-reference.wav`, mimeType: 'audio/wav', buffer: referenceWave.buffer, }, }, }) const uploaded = await responseData(uploadResponse, 201, '上传声音克隆参考 WAV') references.sourceFileCode = String(uploaded.id || uploaded.fileCode || '') expect(references.sourceFileCode).not.toBe('') addActivity(activities, '声音克隆', '以 CAPABILITY_SOURCE 上传参考 WAV', 'PASS', { response: uploadResponse, resourceId: references.sourceFileCode, }) const createResponse = await request.post('/api/v1/capabilities', { headers: { ...headers, 'Idempotency-Key': randomUUID() }, data: { capabilityType: 'VOICE_CLONE', name: capabilityName, type: 'SHERPA_ZIPVOICE', description: '真实 CPU 异步声音克隆生命周期验证', reference: '服务端 VITS 自有合成参考音频', mediaFileCode: references.sourceFileCode, referenceText, gender: cloneGender, consentConfirmed: true, enabled: true, }, }) const created = await responseData(createResponse, 201, '创建 ZipVoice 真实克隆任务') references.capabilityId = String(created.id || '') references.currentJobId = String(created.currentJobId || '') expect(references.capabilityId).not.toBe('') expect(references.currentJobId).not.toBe('') expect(['PROCESSING', 'QUEUED']).toContain(String(created.cloneStatus || '').toUpperCase()) addActivity(activities, '声音克隆', '提交带逐字稿、性别和授权确认的异步克隆任务', 'PASS', { response: createResponse, resourceId: references.capabilityId, }) const jobsListResponse = await request.get('/api/v1/jobs', { headers, params: { kind: 'VOICE_CLONE_REGISTER', page: '1', pageSize: '200' }, }) const jobsList = await responseData(jobsListResponse, 200, '立即读取制作队列') expect(asRecords(jobsList.items).some((item) => String(item.id || '') === references.currentJobId)).toBeTruthy() const immediateJobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}`, { headers }) const immediateJob = await responseData(immediateJobResponse, 200, '立即读取声音克隆任务详情') expect(String(immediateJob.kind || '').toLowerCase()).toBe('voice_clone_register') addActivity(activities, '制作队列', '提交后任务立即可见', 'PASS', { response: immediateJobResponse, resourceId: references.currentJobId, detail: `${String(immediateJob.status || '')} / ${Number(immediateJob.progress || 0)}%`, }) // The page that existed when the task was submitted is deliberately closed. // From this point onward only the API process and its durable worker remain. await creationContext.close() creationContext = null const jobsSession = await createAuthenticatedContext(browser, baseURL) jobsContext = jobsSession.context const jobsPage = jobsSession.page await jobsPage.goto(`/jobs?scope=all&jobId=${encodeURIComponent(references.currentJobId)}`) const jobDialog = jobsPage.locator('.admin-form-dialog:visible').last() await expect(jobDialog.getByRole('heading', { name: '任务详情' })).toBeVisible({ timeout: 30_000 }) await expect(jobDialog).toContainText(references.currentJobId) await expect(jobDialog).toContainText('声音克隆') await captureScreenshot(jobsPage, testInfo, '01-zipvoice-real-job-after-creator-closed') await jobsContext.close() jobsContext = null const terminal = await waitForCloneTerminal( request, headers, references.capabilityId, references.currentJobId, ) progress.push(...terminal.observedProgress) expect(String(terminal.capability.voiceRuntimeMode || '').toUpperCase()).toBe('ZIPVOICE_READY') expect(String(terminal.capability.validationStatus || '').toUpperCase()).toBe('AVAILABLE') expect(String(terminal.job.status || '').toLowerCase()).toBe('succeeded') expect(String(terminal.capability.previewFileCode || '')).not.toBe('') addActivity(activities, '声音克隆', '创建页关闭后服务端 worker 独立完成真实合成验收', 'PASS', { resourceId: references.currentJobId, detail: `READY / progress=${terminal.observedProgress.join('→')}`, }) const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(references.currentJobId)}/events`, { headers }) const events = await responseData(eventsResponse, 200, '读取声音克隆任务事件') const eventItems = asRecords(events.items) expect(eventItems.length).toBeGreaterThan(1) expect(eventItems.some((item) => String(item.status || '').toLowerCase() === 'succeeded')).toBeTruthy() addActivity(activities, '制作队列', '持久化事件流包含成功终态', 'PASS', { response: eventsResponse, resourceId: references.currentJobId, detail: `${eventItems.length} events`, }) const readyCatalogResponse = await request.get('/api/v1/tts/voices', { headers }) const readyCatalog = await responseData(readyCatalogResponse, 200, '刷新 TTS 音色目录') const clonedVoice = asRecords(readyCatalog.items).find((item) => String(item.capabilityId || '') === references.capabilityId) expect(clonedVoice, 'READY 克隆音色必须进入统一 /tts/voices 目录').toBeTruthy() expect(String(clonedVoice!.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE') expect(clonedVoice!.speaker == null).toBeTruthy() addActivity(activities, '统一音色目录', 'READY 克隆音色按 capabilityId 可用且不伪造 speaker', 'PASS', { response: readyCatalogResponse, resourceId: references.capabilityId, }) const clonedSpeechResponse = await request.post('/api/v1/tts', { headers, data: { text: validationText, voiceCapabilityId: references.capabilityId, speed: 0.9 }, timeout: 120_000, }) const clonedSpeech = await responseData(clonedSpeechResponse, 200, '使用克隆 capabilityId 真实合成') expect(String(clonedSpeech.voiceCapabilityId || '')).toBe(references.capabilityId) expect(String(clonedSpeech.runtimeMode || '').toUpperCase()).toBe('ZIPVOICE') expect(clonedSpeech.speaker == null).toBeTruthy() const clonedAudioUrl = safeAudioUrl(clonedSpeech.audioUrl, baseURL) const clonedWave = await downloadWave(request, clonedAudioUrl, '下载 ZipVoice 合成 WAV') expect(clonedWave.wave.duration).toBeGreaterThan(0.5) addActivity(activities, 'TTS', '克隆 capabilityId 真实合成并下载有效 WAV', 'PASS', { response: clonedSpeechResponse, detail: `${clonedWave.wave.sampleRate}Hz / ${clonedWave.wave.channels}ch / ${clonedWave.wave.duration.toFixed(2)}s`, }) const verification = await createAuthenticatedContext(browser, baseURL) verificationContext = verification.context const verificationPage = verification.page await verificationPage.goto('/assets/voice-clones') const voiceSearch = verificationPage.getByPlaceholder('搜索名称或说明') await voiceSearch.fill(capabilityName) await verificationPage.getByRole('button', { name: '搜索', exact: true }).click() const readyRow = verificationPage.locator('.voice-clone-table .el-table__row').filter({ hasText: capabilityName }).first() await expect(readyRow).toBeVisible({ timeout: 30_000 }) await expect(readyRow).toContainText('克隆音色可用') await captureScreenshot(verificationPage, testInfo, '02-zipvoice-real-ready-card-new-context') addActivity(activities, '声音克隆页面', '新浏览器上下文展示 READY 克隆音色', 'PASS', { resourceId: references.capabilityId, }) await verificationPage.goto('/assets/avatars') const avatarName = String(targetAvatar.name || '') expect(avatarName).not.toBe('') await verificationPage.getByPlaceholder('搜索形象名称').fill(avatarName) await verificationPage.getByRole('button', { name: '搜索', exact: true }).click() const avatarCard = verificationPage.locator('.avatar-card').filter({ hasText: avatarName }).first() await expect(avatarCard).toBeVisible({ timeout: 30_000 }) await avatarCard.getByRole('button', { name: '编辑' }).click() const avatarDialog = verificationPage.locator('.admin-form-dialog:visible').last() await expect(avatarDialog.getByRole('heading', { name: '编辑数字人' })).toBeVisible() const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select') await expect(voiceSelect).not.toHaveClass(/is-disabled/, { timeout: 30_000 }) await voiceSelect.click() const voiceDropdown = verificationPage.locator('.el-select-dropdown:visible').last() const cloneOption = voiceDropdown.locator('.el-select-dropdown__item').filter({ hasText: capabilityName }) await expect(cloneOption).toBeVisible({ timeout: 30_000 }) await expect(cloneOption).toContainText('克隆') await captureScreenshot(verificationPage, testInfo, '03-avatar-default-voice-selects-real-zipvoice') addActivity(activities, '数字形象页面', '编辑下拉从统一目录选择 READY 克隆音色', 'PASS', { resourceId: references.capabilityId, detail: `${cloneGender} / 未保存,不修改现有数字形象`, }) } catch (error) { bodyFailed = true throw error } finally { await creationContext?.close().catch(() => undefined) await jobsContext?.close().catch(() => undefined) await verificationContext?.close().catch(() => undefined) await cleanupOwnedResources( request, headers, capabilityName, references, activities, cleanupErrors, ) await attachLifecycleReport(testInfo, activities, cleanupErrors, progress) if (!bodyFailed && cleanupErrors.length) { throw new Error(`ZipVoice 真实生命周期验证通过,但清理失败:${cleanupErrors.join(';')}`) } } })