|
- 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<string, unknown>
- type Headers = Record<string, string>
-
- 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<number>()
- 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<string>()
- const sourceFileCodes = new Set<string>()
- const jobIds = new Set<string>()
- 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(';')}`)
- }
- }
- })
|