| @@ -232,7 +232,7 @@ async function selectCapabilityStatus(page: Page, label: string) { | |||
| } | |||
| function capabilityCard(page: Page, name: string) { | |||
| return page.locator('.capability-card').filter({ hasText: name }).first() | |||
| return page.locator('.capability-card, .voice-clone-table .el-table__row').filter({ hasText: name }).first() | |||
| } | |||
| function dialogField(dialog: Locator, label: string) { | |||
| @@ -624,7 +624,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||
| addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId) | |||
| await card.getByRole('button', { name: '试听' }).click() | |||
| const audio = card.locator('.inline-media-preview audio') | |||
| const audio = card.locator('.inline-media-preview audio, .voice-table-preview audio') | |||
| await expect(audio).toBeVisible() | |||
| await expect(audio).toHaveAttribute('src', /.+/) | |||
| await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) | |||
| @@ -643,7 +643,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编 | |||
| card = capabilityCard(page, voiceEditedName) | |||
| await expect(card).toContainText('讲解播报') | |||
| await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示') | |||
| const editedAudio = card.locator('.inline-media-preview audio') | |||
| const editedAudio = card.locator('.inline-media-preview audio, .voice-table-preview audio') | |||
| if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click() | |||
| await expect(editedAudio).toBeVisible() | |||
| await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) | |||
| @@ -62,7 +62,25 @@ const video = { | |||
| createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), | |||
| } | |||
| async function installMocks(page: Page) { | |||
| const longVideoDescription = Array.from({ length: 24 }, (_, index) => ( | |||
| `第 ${index + 1} 段:检修前需要逐项确认停机、断电、验电、卸压与上锁挂牌,并完整记录复核结果。` | |||
| )).join('\n') | |||
| const longDetailVideo = { | |||
| ...video, | |||
| id: 'video-long-description', | |||
| title: '长简介成片详情回归', | |||
| description: longVideoDescription, | |||
| } | |||
| const shortDetailVideo = { | |||
| ...video, | |||
| id: 'video-short-description', | |||
| title: '短简介成片详情回归', | |||
| description: '简短的设备培训说明。', | |||
| } | |||
| async function installMocks(page: Page, videos: Array<typeof video> = [video]) { | |||
| const videoQueries: Array<Record<string, string>> = [] | |||
| await page.addInitScript(() => { | |||
| @@ -115,7 +133,7 @@ async function installMocks(page: Page) { | |||
| let data: unknown = {} | |||
| if (url.pathname.endsWith('/videos')) { | |||
| videoQueries.push(Object.fromEntries(url.searchParams.entries())) | |||
| data = { items: [video], total: 1, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] } | |||
| data = { items: videos, total: videos.length, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] } | |||
| } else if (url.pathname.endsWith('/video-folders')) { | |||
| data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 } | |||
| } else if (url.pathname.endsWith('/realtime-agents')) { | |||
| @@ -176,6 +194,127 @@ test('成片筛选在搜索前且排序方向合并到排序项', async ({ page | |||
| await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort') | |||
| }) | |||
| test('成片详情固定首尾、中部滚动且简介仅在溢出时展开收起', async ({ page }, testInfo) => { | |||
| const consoleErrors: string[] = [] | |||
| const pageErrors: string[] = [] | |||
| page.on('console', (message) => { | |||
| if (message.type() === 'error') consoleErrors.push(message.text()) | |||
| }) | |||
| page.on('pageerror', (error) => pageErrors.push(error.message)) | |||
| await installMocks(page, [longDetailVideo, shortDetailVideo]) | |||
| await page.setViewportSize({ width: 1280, height: 640 }) | |||
| await page.goto('/videos/manage') | |||
| const longCard = page.locator('.video-library-card').filter({ hasText: longDetailVideo.title }) | |||
| await longCard.getByRole('button', { name: '查看详情', exact: true }).click() | |||
| const dialog = page.locator('.video-detail-dialog:visible') | |||
| const header = dialog.locator(':scope > .el-dialog__header') | |||
| const body = dialog.locator(':scope > .el-dialog__body') | |||
| const footer = dialog.locator(':scope > .el-dialog__footer') | |||
| const player = dialog.locator('.video-detail-player') | |||
| const description = dialog.locator('.video-detail-description') | |||
| const descriptionText = description.locator('p') | |||
| const expandButton = dialog.getByRole('button', { name: '展开简介', exact: true }) | |||
| await expect(dialog).toBeVisible() | |||
| await expect(page.locator('.video-detail-overlay:visible')).toBeVisible() | |||
| await expect(header).toBeVisible() | |||
| await expect(body.locator('.video-detail-layout')).toBeVisible() | |||
| await expect(footer).toBeVisible() | |||
| await expect(descriptionText).toHaveText(longVideoDescription) | |||
| await expect(expandButton).toBeVisible() | |||
| await expect(expandButton).toHaveAttribute('aria-expanded', 'false') | |||
| const collapsedDescription = await descriptionText.evaluate((element) => ({ | |||
| clientHeight: element.clientHeight, | |||
| scrollHeight: element.scrollHeight, | |||
| })) | |||
| expect(collapsedDescription.scrollHeight).toBeGreaterThan(collapsedDescription.clientHeight) | |||
| const scrollState = await body.evaluate((element) => ({ | |||
| overflowY: getComputedStyle(element).overflowY, | |||
| clientHeight: element.clientHeight, | |||
| scrollHeight: element.scrollHeight, | |||
| })) | |||
| expect(['auto', 'scroll']).toContain(scrollState.overflowY) | |||
| expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight) | |||
| await captureScreenshot(page, testInfo, 'video-detail-long-collapsed-fixed-chrome') | |||
| const playerBeforeExpand = await player.boundingBox() | |||
| expect(playerBeforeExpand).not.toBeNull() | |||
| await expandButton.click() | |||
| const collapseButton = dialog.getByRole('button', { name: '收起简介', exact: true }) | |||
| await expect(collapseButton).toBeVisible() | |||
| await expect(collapseButton).toHaveAttribute('aria-expanded', 'true') | |||
| const expandedDescription = await descriptionText.evaluate((element) => ({ | |||
| clientHeight: element.clientHeight, | |||
| scrollHeight: element.scrollHeight, | |||
| })) | |||
| expect(expandedDescription.clientHeight).toBeGreaterThan(collapsedDescription.clientHeight) | |||
| expect(expandedDescription.clientHeight).toBe(expandedDescription.scrollHeight) | |||
| const playerAfterExpand = await player.boundingBox() | |||
| expect(playerAfterExpand).not.toBeNull() | |||
| expect(Math.abs(playerAfterExpand!.y - playerBeforeExpand!.y)).toBeLessThanOrEqual(1) | |||
| const [dialogBox, headerBefore, footerBefore] = await Promise.all([ | |||
| dialog.boundingBox(), | |||
| header.boundingBox(), | |||
| footer.boundingBox(), | |||
| ]) | |||
| expect(dialogBox).not.toBeNull() | |||
| expect(headerBefore).not.toBeNull() | |||
| expect(footerBefore).not.toBeNull() | |||
| await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) | |||
| await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) | |||
| const [headerAfter, bodyBox, footerAfter] = await Promise.all([ | |||
| header.boundingBox(), | |||
| body.boundingBox(), | |||
| footer.boundingBox(), | |||
| ]) | |||
| expect(headerAfter).not.toBeNull() | |||
| expect(bodyBox).not.toBeNull() | |||
| expect(footerAfter).not.toBeNull() | |||
| expect(Math.abs(headerAfter!.y - headerBefore!.y)).toBeLessThanOrEqual(1) | |||
| expect(Math.abs(footerAfter!.y - footerBefore!.y)).toBeLessThanOrEqual(1) | |||
| expect(headerAfter!.y).toBeGreaterThanOrEqual(dialogBox!.y) | |||
| expect(bodyBox!.y).toBeGreaterThanOrEqual(headerAfter!.y + headerAfter!.height - 2) | |||
| expect(bodyBox!.y + bodyBox!.height).toBeLessThanOrEqual(footerAfter!.y + 2) | |||
| expect(footerAfter!.y + footerAfter!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height) | |||
| await collapseButton.scrollIntoViewIfNeeded() | |||
| await expect(collapseButton).toBeInViewport() | |||
| await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) | |||
| await captureScreenshot(page, testInfo, 'video-detail-long-expanded-middle-scrolled') | |||
| await collapseButton.click() | |||
| await expect(dialog.getByRole('button', { name: '展开简介', exact: true })).toHaveAttribute('aria-expanded', 'false') | |||
| const recollapsedHeight = await descriptionText.evaluate((element) => element.clientHeight) | |||
| expect(recollapsedHeight).toBeLessThanOrEqual(collapsedDescription.clientHeight + 1) | |||
| await page.keyboard.press('Escape') | |||
| await expect(dialog).toBeHidden() | |||
| const shortCard = page.locator('.video-library-card').filter({ hasText: shortDetailVideo.title }) | |||
| await shortCard.getByRole('button', { name: '查看详情', exact: true }).click() | |||
| const shortDialog = page.locator('.video-detail-dialog:visible') | |||
| const shortDescription = shortDialog.locator('.video-detail-description') | |||
| const shortDescriptionText = shortDescription.locator('p') | |||
| await expect(shortDialog).toBeVisible() | |||
| await expect(shortDescriptionText).toHaveText(shortDetailVideo.description) | |||
| await shortDescriptionText.evaluate(() => new Promise<void>((resolve) => { | |||
| requestAnimationFrame(() => requestAnimationFrame(() => resolve())) | |||
| })) | |||
| const shortDescriptionMetrics = await shortDescriptionText.evaluate((element) => ({ | |||
| clientHeight: element.clientHeight, | |||
| scrollHeight: element.scrollHeight, | |||
| })) | |||
| expect(shortDescriptionMetrics.scrollHeight).toBeLessThanOrEqual(shortDescriptionMetrics.clientHeight + 1) | |||
| await expect(shortDialog.getByRole('button', { name: /^(展开|收起)简介$/ })).toHaveCount(0) | |||
| await captureScreenshot(page, testInfo, 'video-detail-short-no-toggle') | |||
| expect(consoleErrors, '定向回归不应产生 console.error').toEqual([]) | |||
| expect(pageErrors, '定向回归不应产生 pageerror').toEqual([]) | |||
| }) | |||
| test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => { | |||
| await installMocks(page) | |||
| await page.goto('/agents/manage') | |||
| @@ -0,0 +1,697 @@ | |||
| import { execFileSync } from 'node:child_process' | |||
| import { createHash } from 'node:crypto' | |||
| import fs from 'node:fs' | |||
| import path from 'node:path' | |||
| import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test' | |||
| import { attachJson, captureScreenshot, expect, test } from './fixtures' | |||
| import { authHeaders, envelopeData, loginAsAdmin } from './helpers' | |||
| type JsonRecord = Record<string, unknown> | |||
| type Headers = Record<string, string> | |||
| interface AvatarSnapshot extends JsonRecord { | |||
| id: string | |||
| name: string | |||
| status: string | |||
| bundleReady: boolean | |||
| engineAuthorized?: boolean | null | |||
| sourceType: string | |||
| gender: string | |||
| viewerUrl: string | |||
| voiceCapabilityId: string | |||
| } | |||
| interface VoiceSnapshot extends JsonRecord { | |||
| capabilityId: string | |||
| name: string | |||
| gender: string | |||
| runtimeMode: string | |||
| } | |||
| interface PersistentResource { | |||
| avatarId: string | |||
| avatarName: string | |||
| title: string | |||
| projectId: string | |||
| materialFileCode: string | |||
| captureJobId: string | |||
| videoId: string | |||
| videoFileCode: string | |||
| } | |||
| interface AvatarOutcome { | |||
| avatarId: string | |||
| avatarName: string | |||
| title: string | |||
| result: 'RETAINED' | 'FAILED_CLEANED' | 'FAILED_CLEANUP_INCOMPLETE' | |||
| projectId?: string | |||
| materialFileCode?: string | |||
| captureJobId?: string | |||
| videoId?: string | |||
| videoFileCode?: string | |||
| ttsCapabilityId?: string | |||
| ttsRuntimeMode?: string | |||
| ttsDurationSeconds?: number | |||
| ttsRequestVerified?: boolean | |||
| captureUploadBytes?: number | null | |||
| captureUploadEvidence?: string | |||
| ffprobe?: JsonRecord | |||
| error?: string | |||
| cleanup?: string[] | |||
| } | |||
| const SCRIPT_PATH = path.resolve('../滕王阁序.MD') | |||
| const UPLOAD_MATERIAL_PATH = path.resolve('../ai_person_web/public/brand/dashboard-instructor.webp') | |||
| const SCRIPT_TEXT = fs.readFileSync(SCRIPT_PATH, 'utf8').trim() | |||
| const SCRIPT_SHA256 = createHash('sha256').update(SCRIPT_TEXT, 'utf8').digest('hex') | |||
| const UPLOAD_MATERIAL = fs.readFileSync(UPLOAD_MATERIAL_PATH) | |||
| const TERMINAL_JOB_STATUSES = new Set(['succeeded', 'failed', 'cancelled']) | |||
| const TARGET_AVATAR_IDS = new Set( | |||
| (process.env.E2E_AVATAR_IDS || '') | |||
| .split(',') | |||
| .map((item) => item.trim()) | |||
| .filter(Boolean), | |||
| ) | |||
| const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} | |||
| const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] | |||
| const textValue = (value: unknown) => String(value ?? '').trim() | |||
| const lower = (value: unknown) => textValue(value).toLowerCase() | |||
| function safeError(value: unknown) { | |||
| return (value instanceof Error ? value.message : String(value)) | |||
| .replace(/([?&](?:access_token|api_key|signature|token|password)=)[^&\s]+/gi, '$1<redacted>') | |||
| .replace(/Bearer\s+\S+/gi, 'Bearer <redacted>') | |||
| .slice(0, 2_000) | |||
| } | |||
| async function apiRecord(response: { status(): number; json(): Promise<unknown> }, expected: number | number[] = 200) { | |||
| const accepted = Array.isArray(expected) ? expected : [expected] | |||
| expect(accepted, `API 返回了非预期 HTTP ${response.status()}`).toContain(response.status()) | |||
| if (response.status() === 204) return {} | |||
| return asRecord(envelopeData(await response.json())) | |||
| } | |||
| function normalizeAvatar(item: JsonRecord): AvatarSnapshot { | |||
| return { | |||
| ...item, | |||
| id: textValue(item.id), | |||
| name: textValue(item.name), | |||
| status: lower(item.status), | |||
| bundleReady: item.bundleReady === true, | |||
| engineAuthorized: item.engineAuthorized == null ? null : item.engineAuthorized === true, | |||
| sourceType: lower(item.sourceType), | |||
| gender: textValue(item.gender), | |||
| viewerUrl: textValue(item.viewerUrl), | |||
| voiceCapabilityId: textValue(item.voiceCapabilityId || asRecord(item.voice).capabilityId), | |||
| } | |||
| } | |||
| async function listAllAvatars(request: APIRequestContext, headers: Headers) { | |||
| const first = await apiRecord(await request.get('/api/v1/avatars', { | |||
| headers, | |||
| params: { page: '1', pageSize: '200' }, | |||
| })) | |||
| const items = asRecords(first.items ?? first.records) | |||
| const pages = Math.max(1, Number(first.pages || Math.ceil(Number(first.total || items.length) / 200))) | |||
| for (let page = 2; page <= pages; page += 1) { | |||
| const payload = await apiRecord(await request.get('/api/v1/avatars', { | |||
| headers, | |||
| params: { page: String(page), pageSize: '200' }, | |||
| })) | |||
| items.push(...asRecords(payload.items ?? payload.records)) | |||
| } | |||
| return items.map(normalizeAvatar) | |||
| } | |||
| async function listVoices(request: APIRequestContext, headers: Headers) { | |||
| const payload = await apiRecord(await request.get('/api/v1/tts/voices', { headers })) | |||
| expect(payload.ready, '真实 TTS 目录必须处于 ready').toBe(true) | |||
| return asRecords(payload.items).map((item): VoiceSnapshot => ({ | |||
| ...item, | |||
| capabilityId: textValue(item.capabilityId || item.id), | |||
| name: textValue(item.name), | |||
| gender: textValue(item.gender), | |||
| runtimeMode: textValue(item.runtimeMode), | |||
| })).filter((item) => item.capabilityId && item.name) | |||
| } | |||
| function normalizedGender(value: string): 'female' | 'male' | 'unknown' { | |||
| const normalized = value.trim().toLowerCase() | |||
| if (normalized === 'female' || normalized.includes('女')) return 'female' | |||
| if (normalized === 'male' || normalized.includes('男')) return 'male' | |||
| return 'unknown' | |||
| } | |||
| function matchingGender(avatarGender: string, voiceGender: string) { | |||
| const avatar = normalizedGender(avatarGender) | |||
| return avatar !== 'unknown' && avatar === normalizedGender(voiceGender) | |||
| } | |||
| function voiceForAvatar(avatar: AvatarSnapshot, voices: VoiceSnapshot[]) { | |||
| const avatarGender = normalizedGender(avatar.gender) | |||
| const preferredCapabilityId = avatarGender === 'female' | |||
| ? 'builtin-voice-female-clear' | |||
| : avatarGender === 'male' | |||
| ? 'builtin-voice-male-gentle' | |||
| : '' | |||
| return voices.find((voice) => voice.capabilityId === preferredCapabilityId) | |||
| || voices.find((voice) => matchingGender(avatar.gender, voice.gender) && voice.runtimeMode.toUpperCase().includes('VITS')) | |||
| || voices.find((voice) => voice.runtimeMode.toUpperCase().includes('VITS')) | |||
| || voices[0] | |||
| } | |||
| function compactAvatarName(avatar: AvatarSnapshot) { | |||
| return avatar.name | |||
| .replace('资深教员', '') | |||
| .replace('教员', '') | |||
| .replace(/·/g, ' · ') | |||
| .replace(/\s+/g, ' ') | |||
| .trim() | |||
| } | |||
| function resourceShortName(avatar: AvatarSnapshot, occurrence: number) { | |||
| const stem = avatar.name.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 12) | |||
| || avatar.id.slice(-8) | |||
| return occurrence > 1 ? `${stem}${occurrence}` : stem | |||
| } | |||
| async function chooseElSelect(page: Page, label: string, option: string) { | |||
| const combobox = page.getByRole('combobox', { name: label, exact: true }) | |||
| await combobox | |||
| .locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]') | |||
| .locator('.el-select__wrapper') | |||
| .click() | |||
| const dropdown = page.locator('.el-select-dropdown:visible').last() | |||
| await expect(dropdown).toBeVisible() | |||
| await dropdown.getByRole('option', { name: option, exact: true }).click() | |||
| await expect(dropdown).toBeHidden() | |||
| } | |||
| async function selectAvatar( | |||
| page: Page, | |||
| avatar: AvatarSnapshot, | |||
| initialAvatars: AvatarSnapshot[], | |||
| ) { | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click() | |||
| const builtIn = avatar.sourceType === 'built_in' | |||
| await page.getByRole('tab', { name: builtIn ? /本地形象/ : /我的形象/ }).click() | |||
| const group = initialAvatars.filter((item) => (item.sourceType === 'built_in') === builtIn) | |||
| const index = group.findIndex((item) => item.id === avatar.id) | |||
| expect(index, `UI 资源列表中找不到数字人 ${avatar.id}`).toBeGreaterThanOrEqual(0) | |||
| const cards = page.locator('.studio-avatar-list button') | |||
| await expect.poll(() => cards.count(), { timeout: 30_000 }).toBeGreaterThan(index) | |||
| const card = cards.nth(index) | |||
| await expect(card).toBeVisible() | |||
| await expect(card.locator('b')).toHaveText(compactAvatarName(avatar)) | |||
| await card.click() | |||
| await expect(card).toHaveClass(/active/) | |||
| expect(await card.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中数字人必须有可见边框').not.toBe('0px') | |||
| await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 }) | |||
| return card | |||
| } | |||
| async function projectDetail(request: APIRequestContext, headers: Headers, id: string) { | |||
| const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers }) | |||
| return { response, data: response.status() === 200 ? await apiRecord(response) : {} } | |||
| } | |||
| async function videoDetail(request: APIRequestContext, headers: Headers, id: string) { | |||
| const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers }) | |||
| return { response, data: response.status() === 200 ? await apiRecord(response) : {} } | |||
| } | |||
| async function exactListItem( | |||
| request: APIRequestContext, | |||
| headers: Headers, | |||
| endpoint: string, | |||
| title: string, | |||
| ) { | |||
| const payload = await apiRecord(await request.get(endpoint, { | |||
| headers, | |||
| params: { keyword: title, page: '1', pageSize: '100' }, | |||
| })) | |||
| return asRecords(payload.items).find((item) => textValue(item.name ?? item.title) === title) || null | |||
| } | |||
| async function settleCaptureJob(request: APIRequestContext, headers: Headers, jobId: string, cleanup: string[]) { | |||
| const deadline = Date.now() + 20 * 60_000 | |||
| while (Date.now() < deadline) { | |||
| const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) | |||
| if (response.status() === 404) return | |||
| const job = await apiRecord(response) | |||
| const status = lower(job.status) | |||
| if (TERMINAL_JOB_STATUSES.has(status)) { | |||
| cleanup.push(`任务 ${jobId} 已进入不可变终态 ${status},保留审计历史`) | |||
| return | |||
| } | |||
| if (job.canCancel === true) { | |||
| const cancelled = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers }) | |||
| await apiRecord(cancelled, [200, 409]) | |||
| cleanup.push(`已请求取消任务 ${jobId}`) | |||
| } | |||
| await new Promise((resolve) => setTimeout(resolve, 5_000)) | |||
| } | |||
| throw new Error(`任务 ${jobId} 在失败清理等待期内仍未进入终态`) | |||
| } | |||
| async function deleteVideo(request: APIRequestContext, headers: Headers, id: string, cleanup: string[]) { | |||
| const detail = await videoDetail(request, headers, id) | |||
| if (detail.response.status() === 404) return | |||
| expect(detail.response.status()).toBe(200) | |||
| const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, { | |||
| headers, | |||
| params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, | |||
| }) | |||
| await apiRecord(removed, [200, 404]) | |||
| cleanup.push(`已删除失败迭代成片 ${id}`) | |||
| } | |||
| async function deleteProjectAndMaterial( | |||
| request: APIRequestContext, | |||
| headers: Headers, | |||
| id: string, | |||
| knownMaterialFileCode: string, | |||
| cleanup: string[], | |||
| ) { | |||
| const detail = await projectDetail(request, headers, id) | |||
| if (detail.response.status() === 404) return | |||
| expect(detail.response.status()).toBe(200) | |||
| const tracks = asRecords(asRecord(detail.data.timeline).tracks) | |||
| const materialFileCodes = new Set([ | |||
| knownMaterialFileCode, | |||
| ...tracks.flatMap((track) => asRecords(track.clips)).map((clip) => textValue(clip.fileId)), | |||
| ].filter(Boolean)) | |||
| const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, { | |||
| headers, | |||
| params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, | |||
| }) | |||
| await apiRecord(removed, [200, 404]) | |||
| cleanup.push(`已删除失败迭代工程 ${id}`) | |||
| for (const fileCode of materialFileCodes) { | |||
| const fileRemoved = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers }) | |||
| await apiRecord(fileRemoved, [200, 404]) | |||
| cleanup.push(`已删除失败迭代上传素材 ${fileCode}`) | |||
| } | |||
| } | |||
| async function cleanupFailedIteration( | |||
| request: APIRequestContext, | |||
| headers: Headers, | |||
| title: string, | |||
| ids: { projectId: string; videoId: string; jobId: string; materialFileCode: string }, | |||
| ) { | |||
| const cleanup: string[] = [] | |||
| const foundJob = ids.jobId | |||
| ? { id: ids.jobId } | |||
| : await exactListItem(request, headers, '/api/v1/jobs', title) | |||
| if (foundJob) await settleCaptureJob(request, headers, textValue(foundJob.id), cleanup) | |||
| const foundVideo = ids.videoId | |||
| ? { id: ids.videoId } | |||
| : await exactListItem(request, headers, '/api/v1/videos', title) | |||
| if (foundVideo) await deleteVideo(request, headers, textValue(foundVideo.id), cleanup) | |||
| const foundProject = ids.projectId | |||
| ? { id: ids.projectId } | |||
| : await exactListItem(request, headers, '/api/v1/video-projects', title) | |||
| if (foundProject) { | |||
| await deleteProjectAndMaterial( | |||
| request, | |||
| headers, | |||
| textValue(foundProject.id), | |||
| ids.materialFileCode, | |||
| cleanup, | |||
| ) | |||
| } | |||
| return cleanup | |||
| } | |||
| function probeMp4(filename: string) { | |||
| const output = execFileSync('ffprobe', [ | |||
| '-v', 'error', | |||
| '-show_entries', 'format=duration,size,format_name:stream=codec_name,codec_type,width,height', | |||
| '-of', 'json', | |||
| filename, | |||
| ], { encoding: 'utf8', windowsHide: true, maxBuffer: 10 * 1024 * 1024 }) | |||
| const probe = asRecord(JSON.parse(output)) | |||
| const format = asRecord(probe.format) | |||
| const streams = asRecords(probe.streams) | |||
| const video = streams.find((stream) => stream.codec_type === 'video') || {} | |||
| const audio = streams.find((stream) => stream.codec_type === 'audio') || {} | |||
| expect(textValue(format.format_name)).toMatch(/mp4|mov/) | |||
| expect(Number(format.duration), '滕王阁序成片时长必须证明完整长文本已录制').toBeGreaterThan(60) | |||
| expect(Number(format.size), '归档 MP4 不能是占位文件').toBeGreaterThan(100_000) | |||
| expect(textValue(video.codec_name)).toBe('h264') | |||
| expect(Number(video.width)).toBe(1920) | |||
| expect(Number(video.height)).toBe(1080) | |||
| expect(textValue(audio.codec_name)).toBe('aac') | |||
| return { | |||
| durationSeconds: Number(format.duration), | |||
| sizeBytes: Number(format.size), | |||
| format: textValue(format.format_name), | |||
| videoCodec: textValue(video.codec_name), | |||
| audioCodec: textValue(audio.codec_name), | |||
| width: Number(video.width), | |||
| height: Number(video.height), | |||
| } | |||
| } | |||
| test.describe.configure({ mode: 'serial' }) | |||
| // 浏览器端 Viewer 录制和最终 H.264/AAC 播放均依赖系统 Chrome 的完整媒体能力。 | |||
| test.use({ trace: 'off', video: 'off', channel: 'chrome' }) | |||
| test('每个可用数字人串行生成并保留一条《滕王阁序》真实成片', async ({ page, request }, testInfo: TestInfo) => { | |||
| test.setTimeout(4 * 60 * 60_000) | |||
| expect(SCRIPT_TEXT.length, '《滕王阁序》测试文本不能为空').toBeGreaterThan(500) | |||
| expect(SCRIPT_TEXT.length, '口播文案必须满足页面 3000 字上限').toBeLessThanOrEqual(3_000) | |||
| expect(Math.ceil(SCRIPT_TEXT.length / 6), '口播文案必须满足单条约 5 分钟上限').toBeLessThanOrEqual(300) | |||
| expect(UPLOAD_MATERIAL.length, '上传素材必须是真实图片而非空占位').toBeGreaterThan(50_000) | |||
| const headers = await authHeaders(request) | |||
| const initialAvatars = await listAllAvatars(request, headers) | |||
| const allAvailableAvatars = initialAvatars.filter((avatar) => ( | |||
| avatar.status === 'ready' | |||
| && avatar.bundleReady | |||
| && avatar.engineAuthorized !== false | |||
| && Boolean(avatar.viewerUrl) | |||
| )) | |||
| const availableAvatars = TARGET_AVATAR_IDS.size | |||
| ? allAvailableAvatars.filter((avatar) => TARGET_AVATAR_IDS.has(avatar.id)) | |||
| : allAvailableAvatars | |||
| const voices = await listVoices(request, headers) | |||
| if (TARGET_AVATAR_IDS.size) { | |||
| expect( | |||
| new Set(availableAvatars.map((avatar) => avatar.id)), | |||
| '目标数字人过滤必须精确命中所有 ID', | |||
| ).toEqual(TARGET_AVATAR_IDS) | |||
| } | |||
| expect(availableAvatars.length, '至少需要一个 READY、已制包且有 Viewer 的可用数字人').toBeGreaterThan(0) | |||
| expect(voices.length, '至少需要一个真实可用 TTS 音色').toBeGreaterThan(0) | |||
| const runStamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14) | |||
| const nameOccurrences = new Map<string, number>() | |||
| const persistent: PersistentResource[] = [] | |||
| const outcomes: AvatarOutcome[] = [] | |||
| const failures: string[] = [] | |||
| await attachJson(testInfo, 'available-avatar-snapshot', { | |||
| capturedAt: new Date().toISOString(), | |||
| script: { sha256: SCRIPT_SHA256, characters: SCRIPT_TEXT.length, estimatedSeconds: Math.ceil(SCRIPT_TEXT.length / 6) }, | |||
| totalAvatarRecords: initialAvatars.length, | |||
| allAvailableAvatarIds: allAvailableAvatars.map((avatar) => avatar.id), | |||
| allAvailableAvatarNames: allAvailableAvatars.map((avatar) => avatar.name), | |||
| selectedAvatarIds: availableAvatars.map((avatar) => avatar.id), | |||
| selectedAvatarNames: availableAvatars.map((avatar) => avatar.name), | |||
| retentionPolicy: '成功工程、成片、任务及工程引用素材均作为预期持久数据保留;仅失败迭代清理可删除临时资源。', | |||
| }) | |||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||
| await loginAsAdmin(page) | |||
| for (const [index, avatar] of availableAvatars.entries()) { | |||
| const baseShortName = resourceShortName(avatar, 1) | |||
| const occurrence = (nameOccurrences.get(baseShortName) || 0) + 1 | |||
| nameOccurrences.set(baseShortName, occurrence) | |||
| const shortName = resourceShortName(avatar, occurrence) | |||
| const title = `滕王阁序验证-${shortName}-${runStamp}` | |||
| const materialName = `滕王阁序素材-${shortName}-${runStamp}.webp` | |||
| const selectedVoice = voiceForAvatar(avatar, voices) | |||
| expect(selectedVoice, `数字人 ${avatar.id} 必须可匹配真实 TTS 音色`).toBeTruthy() | |||
| const avatarGender = normalizedGender(avatar.gender) | |||
| if (avatarGender === 'female') { | |||
| expect(selectedVoice!.capabilityId, '女性数字人必须优选清晰女声').toBe('builtin-voice-female-clear') | |||
| } else if (avatarGender === 'male') { | |||
| expect(selectedVoice!.capabilityId, '男性数字人必须优选温和男声').toBe('builtin-voice-male-gentle') | |||
| } | |||
| expect(title).not.toMatch(/APE2E/i) | |||
| const ids = { projectId: '', videoId: '', jobId: '', materialFileCode: '' } | |||
| let videoFileCode = '' | |||
| let ttsResponse: Response | null = null | |||
| let ttsDurationSeconds = 0 | |||
| let captureResponse: Response | null = null | |||
| let captureResponseHandler: ((response: Response) => void) | null = null | |||
| let iterationError: unknown | |||
| try { | |||
| await page.goto('/videos/create?mode=offline') | |||
| await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible() | |||
| await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 }) | |||
| await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click() | |||
| await selectAvatar(page, avatar, initialAvatars) | |||
| const railRadii = await page.locator('.editor-tool-rail').evaluate((element) => { | |||
| const style = getComputedStyle(element) | |||
| return { topLeft: style.borderTopLeftRadius, bottomLeft: style.borderBottomLeftRadius } | |||
| }) | |||
| expect(railRadii, '工具 rail 左上、左下必须保持直角').toEqual({ topLeft: '0px', bottomLeft: '0px' }) | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-avatar-selected-${shortName}`) | |||
| await page.getByLabel('项目名称').fill(title) | |||
| await page.locator('.script-field textarea').fill(SCRIPT_TEXT) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click() | |||
| const backgroundCard = page.locator('.scene-background-card').first() | |||
| await expect(backgroundCard).toBeVisible({ timeout: 30_000 }) | |||
| const backgroundCapabilityId = textValue(await backgroundCard.getAttribute('data-capability-id')) | |||
| expect(backgroundCapabilityId, '必须选择真实 SCENE 能力资产作为背景').not.toBe('') | |||
| await expect(backgroundCard.locator('img, video').first()).toHaveAttribute('src', /.+/) | |||
| await backgroundCard.click() | |||
| await expect(backgroundCard).toHaveClass(/active/) | |||
| await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click() | |||
| await page.getByRole('button', { name: /添加正文/ }).click() | |||
| await page.getByLabel('显示文字').fill('滕王阁序') | |||
| await expect(page.locator('.stage-overlay[data-overlay-kind="body"]')).toContainText('滕王阁序') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click() | |||
| await page.getByRole('button', { name: /检查标记/ }).click() | |||
| await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible() | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() | |||
| await page.locator('.media-library input[type="file"]').setInputFiles({ | |||
| name: materialName, | |||
| mimeType: 'image/webp', | |||
| buffer: UPLOAD_MATERIAL, | |||
| }) | |||
| await expect(page.locator('.uploaded-media-card')).toContainText(materialName) | |||
| await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible() | |||
| await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0) | |||
| await chooseElSelect( | |||
| page, | |||
| '语音能力资产', | |||
| `${selectedVoice!.name}(${selectedVoice!.runtimeMode.toUpperCase().includes('ZIPVOICE') ? '克隆' : '内置'})`, | |||
| ) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() | |||
| const selectedVoiceCard = page.locator('.editor-resource-list article.active') | |||
| await expect(selectedVoiceCard).toBeVisible() | |||
| expect(await selectedVoiceCard.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中音色必须有可见边框').not.toBe('0px') | |||
| const voicePreviewIcon = selectedVoiceCard.locator('.offline-voice-preview') | |||
| await expect(voicePreviewIcon).toBeVisible() | |||
| await expect(voicePreviewIcon.locator('svg')).toHaveCount(1) | |||
| expect((await voicePreviewIcon.innerText()).trim(), '音频试听操作只能显示图标').toBe('') | |||
| const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true }) | |||
| if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') { | |||
| await subtitleSwitch | |||
| .locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]') | |||
| .click() | |||
| } | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-configured-${shortName}`) | |||
| await page.getByRole('button', { name: '保存工程', exact: true }).click() | |||
| await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 90_000 }) | |||
| await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull() | |||
| ids.projectId = new URL(page.url()).searchParams.get('project') || '' | |||
| expect(ids.projectId).not.toBe('') | |||
| let persistedProject = await projectDetail(request, headers, ids.projectId) | |||
| expect(persistedProject.response.status()).toBe(200) | |||
| expect(persistedProject.data.name).toBe(title) | |||
| expect(persistedProject.data.scriptText).toBe(SCRIPT_TEXT) | |||
| expect(persistedProject.data.avatarId).toBe(avatar.id) | |||
| expect(persistedProject.data.backgroundType).toBe('asset') | |||
| expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId) | |||
| expect(persistedProject.data.voiceCapabilityId).toBe(selectedVoice!.capabilityId) | |||
| expect(persistedProject.data.subtitleEnabled).toBe(true) | |||
| const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks) | |||
| expect(tracks.map((track) => textValue(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE']) | |||
| const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips) | |||
| ids.materialFileCode = textValue(mediaClips.find((clip) => clip.type === 'MEDIA')?.fileId) | |||
| expect(ids.materialFileCode, '上传素材必须进入 MEDIA 轨道').not.toBe('') | |||
| const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips) | |||
| expect(elementClips.some((clip) => clip.type === 'BODY' && clip.text === '滕王阁序')).toBe(true) | |||
| expect(elementClips.some((clip) => clip.type === 'CHECK')).toBe(true) | |||
| const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {} | |||
| expect(audioClip.capabilityId).toBe(selectedVoice!.capabilityId) | |||
| expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips).map((clip) => textValue(clip.text)).join('')).toBe(SCRIPT_TEXT) | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-saved-${shortName}`) | |||
| captureResponseHandler = (response: Response) => { | |||
| const requestUrl = new URL(response.url()) | |||
| if (response.request().method() !== 'POST') return | |||
| if (requestUrl.pathname === '/api/v1/tts') ttsResponse = response | |||
| if (requestUrl.pathname === '/api/v1/captures') captureResponse = response | |||
| } | |||
| page.on('response', captureResponseHandler) | |||
| await page.getByRole('button', { name: '生成视频', exact: true }).click() | |||
| await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 }) | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-recording-${shortName}`) | |||
| await expect.poll(() => ttsResponse?.status() || 0, { | |||
| message: '页面必须调用真实 /tts', | |||
| timeout: 180_000, | |||
| }).toBe(200) | |||
| const ttsRequestPayload = ttsResponse!.request().postDataJSON() as JsonRecord | |||
| expect(ttsRequestPayload.text).toBe(SCRIPT_TEXT) | |||
| expect(ttsRequestPayload.voiceCapabilityId).toBe(selectedVoice!.capabilityId) | |||
| const speech = asRecord(envelopeData(await ttsResponse!.json())) | |||
| expect(textValue(speech.audioUrl), '真实 TTS 必须返回受保护 WAV 地址').not.toBe('') | |||
| expect(textValue(speech.mimeType)).toBe('audio/wav') | |||
| ttsDurationSeconds = Number(speech.duration) | |||
| expect(ttsDurationSeconds, '真实 TTS 音频必须覆盖长文本').toBeGreaterThan(60) | |||
| await expect.poll(() => captureResponse?.status() || 0, { | |||
| message: 'Viewer 浏览器录制必须上传 /captures', | |||
| timeout: 35 * 60_000, | |||
| }).toBe(202) | |||
| const succeeded = page.locator('.editor-render-progress.is-succeeded') | |||
| await expect(succeeded).toContainText('视频生成完成', { timeout: 45 * 60_000 }) | |||
| await expect(succeeded).toContainText('100%') | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-rendered-${shortName}`) | |||
| persistedProject = await projectDetail(request, headers, ids.projectId) | |||
| expect(persistedProject.response.status()).toBe(200) | |||
| ids.jobId = textValue(persistedProject.data.lastJobId) | |||
| const projectVideoId = textValue(persistedProject.data.lastVideoId) | |||
| expect(ids.jobId, 'UI 成功后工程必须公开 lastJobId').not.toBe('') | |||
| expect(projectVideoId, 'UI 成功后工程必须公开 lastVideoId').not.toBe('') | |||
| const job = await apiRecord(await request.get(`/api/v1/jobs/${encodeURIComponent(ids.jobId)}`, { headers })) | |||
| expect(lower(job.status)).toBe('succeeded') | |||
| expect(lower(job.kind)).toBe('capture_transcode') | |||
| expect(job.title).toBe(title) | |||
| expect(Number(job.progress)).toBe(100) | |||
| expect(textValue(asRecord(job.output).mimeType)).toBe('video/mp4') | |||
| await succeeded.getByRole('button', { name: '查看作品' }).click() | |||
| await expect(page).toHaveURL(/\/videos\/manage\?video=/) | |||
| ids.videoId = new URL(page.url()).searchParams.get('video') || '' | |||
| expect(ids.videoId).toBe(projectVideoId) | |||
| let detail = page.locator('.video-detail-overlay') | |||
| await expect(detail).toBeVisible({ timeout: 45_000 }) | |||
| await expect(detail).toContainText(title) | |||
| await detail.getByRole('button', { name: '关闭' }).click() | |||
| await page.getByPlaceholder('搜索标题、简介或标签').fill(title) | |||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||
| const card = page.locator('.video-library-card').filter({ hasText: title }) | |||
| await expect(card).toBeVisible({ timeout: 30_000 }) | |||
| await card.getByRole('button', { name: '查看详情', exact: true }).click() | |||
| detail = page.locator('.video-detail-overlay') | |||
| await expect(detail).toContainText(title) | |||
| const video = detail.locator('video') | |||
| await expect(video).toHaveAttribute('src', /.+/) | |||
| await detail.getByRole('button', { name: '播放视频', exact: true }).click() | |||
| await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true) | |||
| await detail.getByRole('button', { name: '暂停视频', exact: true }).click() | |||
| const downloadPromise = page.waitForEvent('download', { timeout: 120_000 }) | |||
| await detail.getByRole('button', { name: '下载视频', exact: true }).click() | |||
| const download = await downloadPromise | |||
| const downloadedPath = await download.path() | |||
| expect(downloadedPath, '必须能从成片管理下载正式 MP4').not.toBeNull() | |||
| const ffprobe = probeMp4(downloadedPath!) | |||
| const persistedVideo = await videoDetail(request, headers, ids.videoId) | |||
| expect(persistedVideo.response.status()).toBe(200) | |||
| expect(persistedVideo.data.title).toBe(title) | |||
| expect(persistedVideo.data.avatarId).toBe(avatar.id) | |||
| expect(lower(persistedVideo.data.status)).toBe('ready') | |||
| videoFileCode = textValue(persistedVideo.data.videoFileCode) | |||
| expect(videoFileCode).not.toBe('') | |||
| expect(Number(persistedVideo.data.size)).toBeGreaterThan(100_000) | |||
| persistedProject = await projectDetail(request, headers, ids.projectId) | |||
| expect(persistedProject.data.lastJobId).toBe(ids.jobId) | |||
| expect(persistedProject.data.lastVideoId).toBe(ids.videoId) | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-manage-${shortName}`) | |||
| await page.goto(`/jobs?jobId=${encodeURIComponent(ids.jobId)}`) | |||
| const jobDialog = page.locator('.el-dialog:visible').last() | |||
| await expect(jobDialog).toBeVisible({ timeout: 30_000 }) | |||
| await expect(jobDialog).toContainText(ids.jobId) | |||
| await expect(jobDialog).toContainText('100%') | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-job-${shortName}`) | |||
| const retained: PersistentResource = { | |||
| avatarId: avatar.id, | |||
| avatarName: avatar.name, | |||
| title, | |||
| projectId: ids.projectId, | |||
| materialFileCode: ids.materialFileCode, | |||
| captureJobId: ids.jobId, | |||
| videoId: ids.videoId, | |||
| videoFileCode, | |||
| } | |||
| persistent.push(retained) | |||
| outcomes.push({ | |||
| ...retained, | |||
| result: 'RETAINED', | |||
| ttsCapabilityId: selectedVoice!.capabilityId, | |||
| ttsRuntimeMode: selectedVoice!.runtimeMode, | |||
| ttsDurationSeconds, | |||
| ttsRequestVerified: true, | |||
| captureUploadBytes: null, | |||
| captureUploadEvidence: 'Browser multipart internals intentionally not inspected; HTTP 202 durable capture job + succeeded FFmpeg job + FFprobe MP4', | |||
| ffprobe, | |||
| }) | |||
| await attachJson(testInfo, `expected-persistent-ids-avatar-${String(index + 1).padStart(2, '0')}`, retained) | |||
| console.log(`[TENGWANGGE_RETAINED] avatar=${avatar.name} stage=成片管理已核验 videoId=${ids.videoId}`) | |||
| } catch (error) { | |||
| iterationError = error | |||
| const message = safeError(error) | |||
| failures.push(`${avatar.name}:${message}`) | |||
| await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-failed-${shortName}`).catch(() => undefined) | |||
| const failedOutcome: AvatarOutcome = { | |||
| avatarId: avatar.id, | |||
| avatarName: avatar.name, | |||
| title, | |||
| result: 'FAILED_CLEANED', | |||
| projectId: ids.projectId || undefined, | |||
| materialFileCode: ids.materialFileCode || undefined, | |||
| captureJobId: ids.jobId || undefined, | |||
| videoId: ids.videoId || undefined, | |||
| error: message, | |||
| } | |||
| try { | |||
| failedOutcome.cleanup = await cleanupFailedIteration(request, headers, title, ids) | |||
| } catch (cleanupError) { | |||
| failedOutcome.result = 'FAILED_CLEANUP_INCOMPLETE' | |||
| failedOutcome.cleanup = [safeError(cleanupError)] | |||
| failures.push(`${avatar.name} 清理:${safeError(cleanupError)}`) | |||
| } | |||
| outcomes.push(failedOutcome) | |||
| } finally { | |||
| if (captureResponseHandler) page.off('response', captureResponseHandler) | |||
| // 成功资源是用户要求的正式可见结果;这里刻意不执行任何成功清理。 | |||
| if (iterationError) await page.goto('/overview').catch(() => undefined) | |||
| } | |||
| } | |||
| await attachJson(testInfo, 'expected-persistent-ids', { | |||
| policy: 'intentional-retention', | |||
| count: persistent.length, | |||
| resources: persistent, | |||
| }) | |||
| await attachJson(testInfo, 'all-avatar-video-outcomes', outcomes) | |||
| expect(persistent.length, '每个初始可用数字人都必须留下独立工程、任务、素材和成片').toBe(availableAvatars.length) | |||
| expect(new Set(persistent.map((item) => item.projectId)).size).toBe(persistent.length) | |||
| expect(new Set(persistent.map((item) => item.captureJobId)).size).toBe(persistent.length) | |||
| expect(new Set(persistent.map((item) => item.videoId)).size).toBe(persistent.length) | |||
| expect(failures, failures.join('\n')).toEqual([]) | |||
| }) | |||
| @@ -1,4 +1,4 @@ | |||
| import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' | |||
| import type { APIRequestContext, Page, TestInfo } from '@playwright/test' | |||
| import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' | |||
| import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin } from './helpers' | |||
| @@ -111,17 +111,30 @@ async function cleanupProject(request: APIRequestContext, headers: Headers, id: | |||
| } | |||
| } | |||
| async function selectFirstEnabledOption(select: Locator, preferredLabel: RegExp) { | |||
| const options = await select.locator('option').evaluateAll((nodes) => nodes.map((node) => ({ | |||
| value: (node as HTMLOptionElement).value, | |||
| async function chooseElSelect(page: Page, label: string, option: string) { | |||
| const combobox = page.getByRole('combobox', { name: label, exact: true }) | |||
| await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click() | |||
| const dropdown = page.locator('.el-select-dropdown:visible').last() | |||
| await expect(dropdown).toBeVisible() | |||
| await dropdown.getByRole('option', { name: option, exact: true }).click() | |||
| await expect(dropdown).toBeHidden() | |||
| } | |||
| async function chooseFirstElSelectOption(page: Page, label: string, preferred: RegExp) { | |||
| const combobox = page.getByRole('combobox', { name: label, exact: true }) | |||
| await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click() | |||
| const dropdown = page.locator('.el-select-dropdown:visible').last() | |||
| await expect(dropdown).toBeVisible() | |||
| const options = await dropdown.getByRole('option').evaluateAll((nodes) => nodes.map((node) => ({ | |||
| label: (node.textContent || '').trim(), | |||
| disabled: (node as HTMLOptionElement).disabled, | |||
| disabled: node.classList.contains('is-disabled') || node.getAttribute('aria-disabled') === 'true', | |||
| }))) | |||
| const selected = options.find((item) => !item.disabled && item.value && preferredLabel.test(item.label)) | |||
| || options.find((item) => !item.disabled && item.value) | |||
| expect(selected, '页面必须加载至少一个可选的真实语音能力').toBeTruthy() | |||
| await select.selectOption(selected!.value) | |||
| return selected! | |||
| const selected = options.find((item) => !item.disabled && preferred.test(item.label)) | |||
| || options.find((item) => !item.disabled) | |||
| expect(selected, `“${label}”必须存在至少一个可选项`).toBeTruthy() | |||
| await dropdown.getByRole('option', { name: selected!.label, exact: true }).click() | |||
| await expect(dropdown).toBeHidden() | |||
| return selected!.label | |||
| } | |||
| async function expectPublicShareRejected(page: Page, url: string) { | |||
| @@ -143,7 +156,7 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| const editedVideoName = `${runPrefix}-液压安全成片` | |||
| const folderName = `${runPrefix}-视频测试文件夹` | |||
| const renamedFolderName = `${runPrefix}-安全培训成片` | |||
| const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。' | |||
| const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。操作结束后复核隔离状态,确认现场安全。' | |||
| const materialName = `${runPrefix}-检修要点.png` | |||
| const sharePassword = 'Ape2eVideo#2026' | |||
| const rotatedPassword = 'Ape2eRotate#2026' | |||
| @@ -153,12 +166,76 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| let primaryError: unknown | |||
| try { | |||
| await page.setViewportSize({ width: 1440, height: 1000 }) | |||
| await loginAsAdmin(page) | |||
| await page.goto('/videos/create?mode=offline') | |||
| await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible() | |||
| await expect(page.locator('.studio-avatar-list button:not([disabled])').first()).toBeVisible() | |||
| const firstAvatarCard = page.locator('.studio-avatar-list button:not([disabled])').first() | |||
| await expect(firstAvatarCard).toBeVisible() | |||
| await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 }) | |||
| await captureScreenshot(page, testInfo, '01-video-create-nonempty-assets') | |||
| await expect(page.getByRole('tab', { name: /本地形象/ })).toHaveAttribute('aria-selected', 'true') | |||
| await expect(page.locator('.library-tabs .el-button')).toHaveCount(0) | |||
| await expect(page.getByRole('tab', { name: '文本', exact: true })).toHaveAttribute('aria-selected', 'true') | |||
| await expect(page.locator('.inspector-tabs .el-button')).toHaveCount(0) | |||
| expect(await page.getByLabel('项目名称').evaluate((element) => { | |||
| const style = getComputedStyle(element) | |||
| return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth] | |||
| }), 'Element Plus 输入框内部不能再绘制第二层边框').toEqual(['0px', '0px', '0px', '0px']) | |||
| const avatarCardBox = await firstAvatarCard.boundingBox() | |||
| const avatarImageBox = await firstAvatarCard.locator('img').boundingBox() | |||
| expect(avatarCardBox, '数字人卡片不能折叠').not.toBeNull() | |||
| expect(avatarCardBox!.height, '数字人卡片必须完整展示图片和名称').toBeGreaterThan(150) | |||
| expect(avatarImageBox, '数字人卡片必须显示完整封面区').not.toBeNull() | |||
| expect(avatarImageBox!.height).toBeGreaterThanOrEqual(120) | |||
| await captureScreenshot(page, testInfo, '01-video-create-avatar-library-layout') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click() | |||
| const layoutBackgroundCard = page.locator('.scene-background-card').first() | |||
| await expect(layoutBackgroundCard).toBeVisible() | |||
| const layoutBackgroundBox = await layoutBackgroundCard.boundingBox() | |||
| const layoutBackgroundMediaBox = await layoutBackgroundCard.locator('img, video').first().boundingBox() | |||
| expect(layoutBackgroundBox, '背景资源卡片不能折叠').not.toBeNull() | |||
| expect(layoutBackgroundBox!.height).toBeGreaterThan(105) | |||
| expect(layoutBackgroundMediaBox, '背景资源必须完整显示缩略图').not.toBeNull() | |||
| expect(layoutBackgroundMediaBox!.height).toBeGreaterThanOrEqual(80) | |||
| await captureScreenshot(page, testInfo, '01-background-library-layout') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() | |||
| const layoutVoiceRow = page.locator('.editor-resource-list article').first() | |||
| await expect(layoutVoiceRow).toBeVisible() | |||
| expect((await layoutVoiceRow.boundingBox())!.height).toBeGreaterThanOrEqual(60) | |||
| const selectedVoiceRow = page.locator('.editor-resource-list article.active') | |||
| await expect(selectedVoiceRow).toHaveCount(1) | |||
| expect(await selectedVoiceRow.evaluate((element) => getComputedStyle(element).borderTopColor)) | |||
| .not.toBe('rgba(0, 0, 0, 0)') | |||
| const voicePreviewIcon = layoutVoiceRow.getByRole('button', { name: /试听声音|停止试听/ }) | |||
| await expect(voicePreviewIcon).toHaveText('') | |||
| await expect(voicePreviewIcon.locator('svg')).toHaveCount(1) | |||
| expect(await voicePreviewIcon.evaluate((element) => ({ | |||
| border: getComputedStyle(element).borderTopWidth, | |||
| background: getComputedStyle(element).backgroundColor, | |||
| }))).toEqual({ border: '0px', background: 'rgba(0, 0, 0, 0)' }) | |||
| expect(await page.locator('.editor-tool-rail button').first().evaluate((element) => getComputedStyle(element).borderTopLeftRadius)) | |||
| .toBe('0px') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click() | |||
| const layoutTextButton = page.locator('.text-assets button').first() | |||
| await expect(layoutTextButton).toBeVisible() | |||
| expect((await layoutTextButton.boundingBox())!.height).toBeGreaterThanOrEqual(60) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click() | |||
| const layoutElementButton = page.locator('.element-library button').first() | |||
| await expect(layoutElementButton).toBeVisible() | |||
| expect((await layoutElementButton.boundingBox())!.height).toBeGreaterThanOrEqual(68) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() | |||
| const layoutUploadButton = page.locator('.media-library .upload-zone') | |||
| await expect(layoutUploadButton).toBeVisible() | |||
| expect((await layoutUploadButton.boundingBox())!.height).toBeGreaterThanOrEqual(100) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click() | |||
| await expect(firstAvatarCard).toBeVisible() | |||
| await page.setViewportSize({ width: 1920, height: 1080 }) | |||
| await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click() | |||
| const avatar = page.locator('.studio-avatar-list button:not([disabled])').first() | |||
| @@ -173,10 +250,75 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| await expect(voiceCard.locator('input[type="radio"]')).toBeChecked() | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click() | |||
| await page.getByRole('button', { name: '实训教室', exact: true }).click() | |||
| const backgroundCard = page.locator('.scene-background-card').first() | |||
| await expect(backgroundCard).toBeVisible() | |||
| const backgroundCapabilityId = await backgroundCard.getAttribute('data-capability-id') | |||
| expect(backgroundCapabilityId).toBeTruthy() | |||
| await backgroundCard.click() | |||
| await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:') | |||
| await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click() | |||
| await page.getByRole('button', { name: /检查标记/ }).click() | |||
| await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible() | |||
| const checkOverlay = page.locator('.stage-overlay[data-overlay-kind="check"]') | |||
| await expect(checkOverlay).toBeVisible() | |||
| await expect(checkOverlay.locator('.overlay-copy')).toHaveCount(0) | |||
| expect(await checkOverlay.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') | |||
| const initialCheckBox = await checkOverlay.boundingBox() | |||
| expect(initialCheckBox).not.toBeNull() | |||
| await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2, initialCheckBox!.y + initialCheckBox!.height / 2) | |||
| await page.mouse.down() | |||
| await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2 - 34, initialCheckBox!.y + initialCheckBox!.height / 2 - 22, { steps: 5 }) | |||
| await page.mouse.up() | |||
| const movedCheckBox = await checkOverlay.boundingBox() | |||
| expect(movedCheckBox!.x).toBeLessThan(initialCheckBox!.x - 20) | |||
| expect(movedCheckBox!.y).toBeLessThan(initialCheckBox!.y - 10) | |||
| const resizeHandle = checkOverlay.getByRole('button', { name: '缩放已完成检查' }) | |||
| const resizeBox = await resizeHandle.boundingBox() | |||
| expect(resizeBox).not.toBeNull() | |||
| await page.mouse.move(resizeBox!.x + resizeBox!.width / 2, resizeBox!.y + resizeBox!.height / 2) | |||
| await page.mouse.down() | |||
| await page.mouse.move(resizeBox!.x + resizeBox!.width / 2 + 30, resizeBox!.y + resizeBox!.height / 2 + 26, { steps: 5 }) | |||
| await page.mouse.up() | |||
| const resizedCheckBox = await checkOverlay.boundingBox() | |||
| expect(resizedCheckBox!.width).toBeGreaterThan(movedCheckBox!.width + 18) | |||
| expect(resizedCheckBox!.height).toBeGreaterThan(movedCheckBox!.height + 14) | |||
| const positionedResizeBox = await resizeHandle.boundingBox() | |||
| expect(Math.abs(positionedResizeBox!.x + positionedResizeBox!.width / 2 - (resizedCheckBox!.x + resizedCheckBox!.width))).toBeLessThanOrEqual(16) | |||
| expect(Math.abs(positionedResizeBox!.y + positionedResizeBox!.height / 2 - (resizedCheckBox!.y + resizedCheckBox!.height))).toBeLessThanOrEqual(16) | |||
| const numberControl = page.locator('.overlay-size-fields .el-input-number').first() | |||
| const numberBox = await numberControl.boundingBox() | |||
| const increaseBox = await numberControl.locator('.el-input-number__increase').boundingBox() | |||
| const decreaseBox = await numberControl.locator('.el-input-number__decrease').boundingBox() | |||
| expect(numberBox).not.toBeNull() | |||
| expect(increaseBox).not.toBeNull() | |||
| expect(decreaseBox).not.toBeNull() | |||
| expect(Math.abs(numberBox!.x + numberBox!.width - (increaseBox!.x + increaseBox!.width))).toBeLessThanOrEqual(2) | |||
| expect(Math.abs(numberBox!.x + numberBox!.width - (decreaseBox!.x + decreaseBox!.width))).toBeLessThanOrEqual(2) | |||
| expect(decreaseBox!.y - (increaseBox!.y + increaseBox!.height)).toBeLessThanOrEqual(2) | |||
| const inspectorBox = await page.locator('.editor-inspector').boundingBox() | |||
| const stageBox = await page.locator('.editor-canvas-row .studio-stage').boundingBox() | |||
| expect(inspectorBox).not.toBeNull() | |||
| expect(stageBox).not.toBeNull() | |||
| expect(Math.abs(inspectorBox!.height - stageBox!.height), '右侧编辑区不能撑高中间预览区').toBeLessThanOrEqual(2) | |||
| const inspectorScrollState = await page.locator('.editor-inspector-scroll').evaluate((element) => ({ | |||
| overflowY: getComputedStyle(element).overflowY, | |||
| clientHeight: element.clientHeight, | |||
| scrollHeight: element.scrollHeight, | |||
| })) | |||
| expect(inspectorScrollState).toMatchObject({ overflowY: 'auto' }) | |||
| expect(inspectorScrollState.scrollHeight).toBeGreaterThan(inspectorScrollState.clientHeight) | |||
| await captureScreenshot(page, testInfo, '02-stage-element-moved-resized-without-label-or-background') | |||
| await page.getByLabel('开始(秒)').fill('0.5') | |||
| await page.getByLabel('结束(秒)').fill('4.5') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click() | |||
| await page.getByRole('button', { name: /添加正文/ }).click() | |||
| const bodyOverlay = page.locator('.stage-overlay[data-overlay-kind="body"]') | |||
| await expect(bodyOverlay).toBeVisible() | |||
| await chooseElSelect(page, '元素文字字体', '楷体') | |||
| await chooseElSelect(page, '元素文字字号', '40') | |||
| await expect(bodyOverlay).toHaveCSS('font-family', /KaiTi/i) | |||
| await expect(bodyOverlay).toHaveCSS('font-size', '18px') | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() | |||
| await page.locator('.media-library input[type="file"]').setInputFiles({ | |||
| @@ -186,21 +328,78 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| }) | |||
| await expect(page.locator('.uploaded-media-card')).toContainText(materialName) | |||
| await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible() | |||
| await page.getByRole('button', { name: '文本', exact: true }).last().click() | |||
| await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0) | |||
| const stageMedia = page.locator('.stage-media-picture-in-picture') | |||
| const initialMediaBox = await stageMedia.boundingBox() | |||
| expect(initialMediaBox).not.toBeNull() | |||
| await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2, initialMediaBox!.y + initialMediaBox!.height / 2) | |||
| await page.mouse.down() | |||
| await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2 - 32, initialMediaBox!.y + initialMediaBox!.height / 2 + 24, { steps: 5 }) | |||
| await page.mouse.up() | |||
| const movedMediaBox = await stageMedia.boundingBox() | |||
| expect(movedMediaBox!.x).toBeLessThan(initialMediaBox!.x - 18) | |||
| expect(movedMediaBox!.y).toBeGreaterThan(initialMediaBox!.y + 12) | |||
| const mediaResize = stageMedia.getByRole('button', { name: `缩放${materialName}` }) | |||
| const mediaRemove = stageMedia.getByRole('button', { name: `移除${materialName}` }) | |||
| const mediaRemoveBox = await mediaRemove.boundingBox() | |||
| const initialMediaResizeBox = await mediaResize.boundingBox() | |||
| expect(mediaRemoveBox).not.toBeNull() | |||
| expect(initialMediaResizeBox).not.toBeNull() | |||
| expect(Math.abs(mediaRemoveBox!.width - mediaRemoveBox!.height), '素材移除按钮必须保持正方形').toBeLessThanOrEqual(1) | |||
| expect(Math.abs(initialMediaResizeBox!.width - initialMediaResizeBox!.height), '素材缩放控制器必须保持正方形').toBeLessThanOrEqual(1) | |||
| await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2) | |||
| await page.mouse.down() | |||
| await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2 + 28, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2 + 20, { steps: 5 }) | |||
| await page.mouse.up() | |||
| const resizedMediaBox = await stageMedia.boundingBox() | |||
| expect(resizedMediaBox!.width).toBeGreaterThan(movedMediaBox!.width + 14) | |||
| expect(resizedMediaBox!.height).toBeGreaterThan(movedMediaBox!.height + 10) | |||
| await page.getByRole('tab', { name: '文本', exact: true }).click() | |||
| await page.getByLabel('项目名称').fill(projectName) | |||
| await page.locator('.script-field textarea').fill(scriptText) | |||
| const capabilitySelect = page.getByLabel('语音能力资产') | |||
| const selectedCapability = await selectFirstEnabledOption(capabilitySelect, /演示实训播报语音/) | |||
| await page.getByLabel('字幕', { exact: true }).check() | |||
| await page.getByRole('button', { name: '字幕样式', exact: true }).click() | |||
| await page.getByLabel('字体').selectOption('Source Han Sans SC') | |||
| await page.getByLabel('字号').selectOption('48') | |||
| await page.getByLabel('字幕位置').selectOption('center') | |||
| const scriptInput = page.locator('.script-field textarea') | |||
| await scriptInput.fill('时间轴长内容验证。'.repeat(250)) | |||
| const timelineScroll = page.getByRole('region', { name: '可横向滚动的时间轴轨道' }) | |||
| await expect(timelineScroll).toBeVisible() | |||
| const initialTimelineSize = await timelineScroll.evaluate((element) => ({ | |||
| clientWidth: element.clientWidth, | |||
| scrollWidth: element.scrollWidth, | |||
| })) | |||
| expect(initialTimelineSize.scrollWidth).toBeGreaterThan(initialTimelineSize.clientWidth * 1.25) | |||
| await page.getByRole('button', { name: '放大时间轴' }).click() | |||
| await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth)).toBeGreaterThan(initialTimelineSize.scrollWidth) | |||
| await timelineScroll.hover() | |||
| await page.keyboard.down('Shift') | |||
| await page.mouse.wheel(0, 700) | |||
| await page.keyboard.up('Shift') | |||
| await expect.poll(() => timelineScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0) | |||
| const timelineZoomSlider = page.getByRole('slider', { name: '调整时间轴比例尺' }) | |||
| await expect(timelineZoomSlider).toBeVisible() | |||
| await expect(timelineZoomSlider).toHaveAttribute('aria-valuemin', '5') | |||
| await expect(timelineZoomSlider).toHaveAttribute('aria-valuemax', '1000') | |||
| await captureScreenshot(page, testInfo, '02a-timeline-zoom-and-horizontal-scroll') | |||
| await page.getByRole('button', { name: '适配', exact: true }).click() | |||
| await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(2) | |||
| expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2) | |||
| await scriptInput.fill(scriptText) | |||
| const voiceCatalogBeforeSave = await apiData(await request.get('/api/v1/tts/voices', { headers })) | |||
| const availableVoices = asRecords(voiceCatalogBeforeSave.items).filter((voice) => String(voice.capabilityId ?? '') && String(voice.name ?? '')) | |||
| const selectedVoiceLabel = await chooseFirstElSelectOption(page, '语音能力资产', /演示实训播报语音/) | |||
| const selectedVoiceName = selectedVoiceLabel.replace(/((?:克隆|内置))$/, '') | |||
| const selectedCapability = availableVoices.find((voice) => String(voice.name ?? '') === selectedVoiceName) | |||
| expect(selectedCapability, '页面必须加载至少一个可选的真实语音能力').toBeTruthy() | |||
| const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true }) | |||
| if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') { | |||
| await subtitleSwitch.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]').click() | |||
| } | |||
| await page.getByRole('tab', { name: '字幕样式', exact: true }).click() | |||
| await chooseElSelect(page, '字体', '思源黑体') | |||
| await chooseElSelect(page, '字号', '48') | |||
| await chooseElSelect(page, '字幕位置', '画面中部') | |||
| await captureScreenshot(page, testInfo, '02-video-project-ui-configured') | |||
| await page.getByRole('button', { name: '保存工程', exact: true }).click() | |||
| await expect(page.getByText('工程与四轨时间线已保存').last()).toBeVisible({ timeout: 60_000 }) | |||
| await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 60_000 }) | |||
| await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull() | |||
| projectId = new URL(page.url()).searchParams.get('project') || '' | |||
| expect(projectId).not.toBe('') | |||
| @@ -210,7 +409,8 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| expect(persistedProject.data.name).toBe(projectName) | |||
| expect(persistedProject.data.scriptText).toBe(scriptText) | |||
| expect(persistedProject.data.orientation).toBe('landscape') | |||
| expect(persistedProject.data.backgroundType).toBe('training') | |||
| expect(persistedProject.data.backgroundType).toBe('asset') | |||
| expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId) | |||
| expect(persistedProject.data.subtitleEnabled).toBe(true) | |||
| expect(persistedProject.data.subtitleFont).toBe('Source Han Sans SC') | |||
| expect(persistedProject.data.subtitleFontSize).toBe(48) | |||
| @@ -218,30 +418,72 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| expect(String(persistedProject.data.avatarId ?? '')).not.toBe('') | |||
| expect(String(persistedProject.data.voiceCapabilityId ?? '')).not.toBe('') | |||
| const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks) | |||
| expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) | |||
| expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE']) | |||
| const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips) | |||
| expect(mediaClips.some((clip) => clip.type === 'MEDIA' && String(clip.fileId ?? ''))).toBe(true) | |||
| expect(mediaClips.some((clip) => clip.type === 'CHECK')).toBe(true) | |||
| const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips) | |||
| const checkClip = elementClips.find((clip) => clip.type === 'CHECK') || {} | |||
| expect(checkClip.startMs).toBe(500) | |||
| expect(checkClip.endMs).toBe(4500) | |||
| expect(Number(checkClip.x)).toBeLessThan(0.75) | |||
| expect(Number(checkClip.y)).toBeLessThan(0.18) | |||
| expect(Number(checkClip.width)).toBeGreaterThan(0.15) | |||
| expect(Number(checkClip.height)).toBeGreaterThan(0.15) | |||
| const bodyClip = elementClips.find((clip) => clip.type === 'BODY') || {} | |||
| expect(asRecord(bodyClip.style).fontFamily).toBe('KaiTi') | |||
| expect(asRecord(bodyClip.style).fontSize).toBe(40) | |||
| const mediaClip = mediaClips.find((clip) => clip.type === 'MEDIA') || {} | |||
| expect(Number(mediaClip.x)).toBeLessThan(0.64) | |||
| expect(Number(mediaClip.y)).toBeGreaterThan(0.05) | |||
| expect(Number(mediaClip.width)).toBeGreaterThan(0.32) | |||
| expect(Number(mediaClip.height)).toBeGreaterThan(0.18) | |||
| const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {} | |||
| expect(asRecord(audioClip.style).speaker).toBe(2) | |||
| expect(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? '')) | |||
| expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)[0]?.text).toBe(scriptText) | |||
| activities.push({ module: '视频制作', action: '页面保存横屏工程、真实形象音色、素材、元素、字幕和四轨时间线', result: 'PASS', resourceId: projectId }) | |||
| await captureScreenshot(page, testInfo, '03-video-project-saved-four-tracks') | |||
| const voiceCatalog = await apiData(await request.get('/api/v1/tts/voices', { headers })) | |||
| const persistedVoice = asRecords(voiceCatalog.items).find((voice) => ( | |||
| String(voice.capabilityId ?? '') === String(persistedProject.data.voiceCapabilityId ?? '') | |||
| )) | |||
| expect(persistedVoice, '工程音轨引用的音色必须仍在统一 TTS 目录中').toBeTruthy() | |||
| expect(asRecord(audioClip.style).speaker ?? null).toBe(persistedVoice!.speaker ?? null) | |||
| const subtitleClips = asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips) | |||
| expect(subtitleClips.length).toBeGreaterThan(1) | |||
| expect(subtitleClips.map((clip) => String(clip.text || '')).join('')).toBe(scriptText) | |||
| activities.push({ module: '视频制作', action: '页面保存资源背景、真实形象音色、素材、可定时元素、分段字幕和五轨时间线', result: 'PASS', resourceId: projectId }) | |||
| await captureScreenshot(page, testInfo, '03-video-project-saved-five-tracks') | |||
| await page.reload() | |||
| await expect(page.getByText('工程已保存', { exact: true })).toBeVisible({ timeout: 30_000 }) | |||
| await expect(page.getByText('已保存', { exact: true })).toBeVisible({ timeout: 30_000 }) | |||
| await expect(page.getByLabel('项目名称')).toHaveValue(projectName) | |||
| await expect(page.locator('.script-field textarea')).toHaveValue(scriptText) | |||
| await expect(page.getByRole('button', { name: /横屏(1920 × 1080)/ })).toHaveClass(/active/) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() | |||
| await expect(page.locator('.uploaded-media-card')).toContainText(materialName) | |||
| await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible() | |||
| await expect(page.getByLabel('语音能力资产')).toHaveValue(selectedCapability.value) | |||
| const timelineSlider = page.getByRole('slider', { name: '拖动时间轴播放头' }) | |||
| await timelineSlider.focus() | |||
| await timelineSlider.press('Home') | |||
| for (let step = 0; step < 20; step += 1) await timelineSlider.press('ArrowRight') | |||
| await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible() | |||
| const reloadedVoiceSelect = page.getByRole('combobox', { name: '语音能力资产', exact: true }) | |||
| .locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]') | |||
| await expect(reloadedVoiceSelect.locator('.el-select__selected-item:not(.is-hidden)')).toContainText(String(selectedCapability!.name)) | |||
| await expect(page.locator('.timeline-clip.media')).toContainText(materialName) | |||
| await expect(page.locator('.timeline-clip.subtitle')).toContainText(scriptText) | |||
| await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0) | |||
| const timelinePlayButton = page.getByRole('button', { name: '播放', exact: true }) | |||
| await expect(timelinePlayButton.locator('svg')).toHaveCount(1) | |||
| await expect.poll(async () => ( | |||
| (await page.locator('.timeline-clip.subtitle').allTextContents()).join('') | |||
| )).toBe(scriptText) | |||
| await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() | |||
| await expect(page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).locator('input[type="radio"]')).toBeChecked() | |||
| await expect(page.locator(`.editor-resource-list input[type="radio"][value="${String(selectedCapability!.capabilityId)}"]`)).toBeChecked() | |||
| const previewSpeechRequest = page.waitForRequest((request) => ( | |||
| request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/tts' | |||
| )) | |||
| await page.getByRole('button', { name: '预览播放', exact: true }).click() | |||
| const previewSpeechPayload = (await previewSpeechRequest).postDataJSON() as Record<string, unknown> | |||
| expect(previewSpeechPayload.text).toBe(scriptText) | |||
| await expect(page.getByRole('button', { name: '停止预览', exact: true })).toBeVisible({ timeout: 120_000 }) | |||
| await page.getByRole('button', { name: '停止预览', exact: true }).click() | |||
| await expect(page.getByRole('button', { name: '预览播放', exact: true })).toBeVisible() | |||
| activities.push({ module: '视频制作', action: '刷新 project 深链并核验全部配置持久化', result: 'PASS', resourceId: projectId }) | |||
| await captureScreenshot(page, testInfo, '04-video-project-deeplink-persisted') | |||
| @@ -285,7 +527,7 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded') | |||
| await detail.getByRole('button', { name: '关闭' }).click() | |||
| await page.locator('.folder-create-button').click() | |||
| await page.getByRole('button', { name: '新建文件夹', exact: true }).click() | |||
| let dialog = page.locator('.el-dialog:visible').last() | |||
| await dialog.getByLabel('文件夹名称').fill(folderName) | |||
| const createFolderResponsePromise = page.waitForResponse((response) => ( | |||
| @@ -318,11 +560,10 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName) | |||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||
| await page.getByLabel('分类').selectOption({ label: '口播视频' }) | |||
| await page.getByLabel('可见范围').selectOption('private') | |||
| await page.getByLabel('归属').selectOption('mine') | |||
| await page.getByLabel('排序').selectOption('title') | |||
| await page.locator('.sort-direction').click() | |||
| await chooseElSelect(page, '视频分类', '口播视频') | |||
| await chooseElSelect(page, '可见范围', '仅自己') | |||
| await chooseElSelect(page, '视频归属', '我创建的') | |||
| await chooseElSelect(page, '排序方式', '标题升序') | |||
| let card = page.locator('.video-library-card').filter({ hasText: projectName }) | |||
| await expect(card).toBeVisible() | |||
| activities.push({ module: '成片管理', action: '页面重命名文件夹并组合搜索、分类、可见范围、归属与排序', result: 'PASS', resourceId: folderId }) | |||
| @@ -409,8 +650,8 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片', | |||
| await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible() | |||
| folderId = '' | |||
| await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName) | |||
| await page.getByLabel('分类').selectOption('') | |||
| await page.getByLabel('可见范围').selectOption('public') | |||
| await chooseElSelect(page, '视频分类', '全部分类') | |||
| await chooseElSelect(page, '可见范围', '内网分享链接') | |||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||
| card = page.locator('.video-library-card').filter({ hasText: editedVideoName }) | |||
| await expect(card).toBeVisible() | |||
| @@ -1,4 +1,4 @@ | |||
| import type { Route } from '@playwright/test' | |||
| import type { Page, Route } from '@playwright/test' | |||
| import { captureScreenshot, expect, test } from './fixtures' | |||
| import { loginAsAdmin } from './helpers' | |||
| @@ -19,6 +19,11 @@ const fulfillJson = (route: Route, data: unknown, status = 200) => route.fulfill | |||
| body: JSON.stringify(envelope(data)), | |||
| }) | |||
| const voiceRow = (page: Page, name: string) => page | |||
| .locator('.voice-clone-table .el-table__row') | |||
| .filter({ hasText: name }) | |||
| .first() | |||
| const baseCapability = { | |||
| dataVersion: 1, | |||
| capabilityType: 'VOICE_CLONE', | |||
| @@ -36,7 +41,7 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象 | |||
| test.setTimeout(120_000) | |||
| await loginAsAdmin(page) | |||
| const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord } = {} | |||
| const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord; builtIn?: string | null } = {} | |||
| let capabilities = [ | |||
| { | |||
| ...baseCapability, | |||
| @@ -188,7 +193,11 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象 | |||
| return fulfillJson(route, created, 201) | |||
| } | |||
| if (url.pathname.endsWith('/capabilities') && request.method() === 'GET') { | |||
| return fulfillJson(route, { items: capabilities, total: capabilities.length, page: 1, pageSize: 9, pages: 1 }) | |||
| requests.builtIn = url.searchParams.get('builtIn') | |||
| const visible = requests.builtIn == null | |||
| ? capabilities | |||
| : capabilities.filter((item) => item.builtIn === (requests.builtIn === 'true')) | |||
| return fulfillJson(route, { items: visible, total: visible.length, page: 1, pageSize: 10, pages: 1 }) | |||
| } | |||
| return route.fallback() | |||
| }) | |||
| @@ -218,29 +227,47 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象 | |||
| await page.goto('/assets/voice-clones') | |||
| await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible() | |||
| await expect(page.locator('.capability-card').filter({ hasText: '历史参考样音' })).toContainText('待开始克隆') | |||
| await expect(page.locator('.capability-card').filter({ hasText: '排队中的男教员' })).toContainText('42%') | |||
| await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('参考音频有效人声时长不足') | |||
| await expect(page.locator('.capability-card').filter({ hasText: '可用克隆男声' })).toContainText('克隆音色可用') | |||
| const builtinCard = page.locator('.capability-card').filter({ hasText: '温和男声' }) | |||
| await expect(builtinCard).toContainText('平台内置') | |||
| await expect(builtinCard).toContainText('VITS') | |||
| await expect(builtinCard.getByRole('button', { name: '试听', exact: true })).toBeVisible() | |||
| await expect(builtinCard.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0) | |||
| await expect(builtinCard.getByRole('button', { name: '停用', exact: true })).toHaveCount(0) | |||
| await expect(builtinCard.getByRole('button', { name: '删除', exact: true })).toHaveCount(0) | |||
| await expect(voiceRow(page, '历史参考样音')).toContainText('待开始克隆') | |||
| await expect(voiceRow(page, '排队中的男教员')).toContainText('42%') | |||
| const failedInitialRow = voiceRow(page, '待重试音色') | |||
| await expect(failedInitialRow).not.toContainText('参考音频有效人声时长不足') | |||
| await expect(voiceRow(page, '可用克隆男声')).toContainText('克隆音色可用') | |||
| const builtinRow = voiceRow(page, '温和男声') | |||
| await expect(builtinRow).toContainText('平台内置') | |||
| await expect(builtinRow).toContainText('VITS') | |||
| await expect(builtinRow.getByRole('button', { name: '试听', exact: true })).toBeVisible() | |||
| await expect(builtinRow.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0) | |||
| await expect(builtinRow.getByRole('button', { name: '停用', exact: true })).toHaveCount(0) | |||
| await expect(builtinRow.getByRole('button', { name: '删除', exact: true })).toHaveCount(0) | |||
| await failedInitialRow.getByRole('button', { name: '详情', exact: true }).click() | |||
| const failedDetailDialog = page.locator('.model-detail-dialog:visible').last() | |||
| await expect(failedDetailDialog).toContainText('参考音频有效人声时长不足') | |||
| await failedDetailDialog.getByRole('button', { name: '关闭', exact: true }).click() | |||
| const readyDetailRow = voiceRow(page, '可用克隆男声') | |||
| await readyDetailRow.getByRole('button', { name: '详情', exact: true }).click() | |||
| const detailDialog = page.locator('.model-detail-dialog:visible').last() | |||
| await expect(detailDialog.locator('.el-dialog__title')).toHaveText('音色详情') | |||
| await expect(detailDialog).toContainText('可用克隆男声') | |||
| await expect(detailDialog).toContainText('ZipVoice') | |||
| await detailDialog.getByRole('button', { name: '关闭', exact: true }).click() | |||
| await captureScreenshot(page, testInfo, '01-zipvoice-async-states') | |||
| const failedCard = page.locator('.capability-card').filter({ hasText: '待重试音色' }) | |||
| await failedCard.getByRole('button', { name: '重新克隆' }).click() | |||
| await expect(failedCard.locator('.el-button.is-loading')).toBeVisible() | |||
| await page.locator('.voice-ownership-filter').click() | |||
| await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '自定义音色' }).click() | |||
| await page.getByRole('button', { name: '搜索', exact: true }).click() | |||
| await expect.poll(() => requests.builtIn).toBe('false') | |||
| await expect(voiceRow(page, '温和男声')).toHaveCount(0) | |||
| const failedRow = voiceRow(page, '待重试音色') | |||
| await failedRow.getByRole('button', { name: '重新克隆' }).click() | |||
| await expect(failedRow.locator('.el-button.is-loading')).toBeVisible() | |||
| await expect.poll(() => requests.retry?.dataVersion).toBe(1) | |||
| await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('排队中') | |||
| await expect(voiceRow(page, '待重试音色')).toContainText('排队中') | |||
| const readyCard = page.locator('.capability-card').filter({ hasText: '可用克隆男声' }) | |||
| await readyCard.getByRole('button', { name: '试听', exact: true }).click() | |||
| const readyRow = voiceRow(page, '可用克隆男声') | |||
| await readyRow.getByRole('button', { name: '试听', exact: true }).click() | |||
| await expect.poll(() => requests.speech?.voiceCapabilityId).toBe('clone-ready') | |||
| await expect(readyCard.locator('.inline-media-preview audio')).toBeVisible() | |||
| await expect(readyRow.locator('.voice-table-preview audio')).toBeVisible() | |||
| await page.getByRole('button', { name: /新建克隆音色/ }).click() | |||
| const dialog = page.locator('.admin-form-dialog:visible').last() | |||
| @@ -280,7 +307,7 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象 | |||
| mediaFileCode: 'file-controlled', | |||
| consentConfirmed: true, | |||
| }) | |||
| await expect(page.locator('.capability-card').filter({ hasText: '受控 ZipVoice 男教员' })).toContainText('排队中') | |||
| await expect(voiceRow(page, '受控 ZipVoice 男教员')).toContainText('排队中') | |||
| await page.goto('/assets/avatars') | |||
| await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() | |||
| @@ -633,9 +633,9 @@ test('ZipVoice 真实生命周期:服务端异步克隆、跨浏览器续作 | |||
| const voiceSearch = verificationPage.getByPlaceholder('搜索名称或说明') | |||
| await voiceSearch.fill(capabilityName) | |||
| await verificationPage.getByRole('button', { name: '搜索', exact: true }).click() | |||
| const readyCard = verificationPage.locator('.capability-card').filter({ hasText: capabilityName }).first() | |||
| await expect(readyCard).toBeVisible({ timeout: 30_000 }) | |||
| await expect(readyCard).toContainText('克隆音色可用') | |||
| 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, | |||