import type { APIRequestContext, Page, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin } from './helpers' type JsonRecord = Record type Headers = Record interface Activity { module: string action: string result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' resourceId?: string detail?: string } const MATERIAL_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', ) const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] async function apiData(response: Awaited>, expected = 200) { expect(response.status()).toBe(expected) return asRecord(envelopeData(await response.json())) } 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 apiData(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 apiData(response) : {} } } async function findFolder(request: APIRequestContext, headers: Headers, name: string) { const response = await request.get('/api/v1/video-folders', { headers }) const data = await apiData(response) return asRecords(data.items).find((item) => String(item.name ?? '') === name) || null } async function findByKeyword(request: APIRequestContext, headers: Headers, endpoint: string, keyword: string, name: string) { const response = await request.get(endpoint, { headers, params: { keyword, page: '1', pageSize: '100' } }) const data = await apiData(response) return asRecords(data.items).find((item) => String(item.name ?? item.title ?? '') === name) || null } async function cleanupVideo(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { try { let detail = await videoDetail(request, headers, id) if (detail.response.status() === 404) return if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`) if (String(detail.data.shareStatus ?? '') === 'active') { await request.delete(`/api/v1/videos/${encodeURIComponent(id)}/share`, { headers, data: { reason: 'APE2E failure cleanup' }, }) detail = await videoDetail(request, headers, id) } const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, }) if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANED', resourceId: id }) } catch (error) { const message = `成片 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` errors.push(message) activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) } } async function cleanupFolder(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { try { const response = await request.get('/api/v1/video-folders', { headers }) const data = await apiData(response) const folder = asRecords(data.items).find((item) => String(item.id ?? '') === id) if (!folder) return const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(folder.dataVersion ?? 0)) }, }) if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANED', resourceId: id }) } catch (error) { const message = `文件夹 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` errors.push(message) activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) } } async function cleanupProject(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { try { const detail = await projectDetail(request, headers, id) if (detail.response.status() === 404) return if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`) const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, }) if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANED', resourceId: id }) } catch (error) { const message = `视频工程 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` errors.push(message) activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) } } 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.classList.contains('is-disabled') || node.getAttribute('aria-disabled') === 'true', }))) 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) { await page.goto(url) await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible() } test.describe.configure({ mode: 'serial' }) // 成片为 H.264/AAC MP4;Playwright 自带的开源 Chromium 不包含完整专利编解码器。 // 本用例用系统 Chrome 完成真实播放验收,其余 E2E 仍使用项目 Chromium。 test.use({ trace: 'off', video: 'off', channel: 'chrome' }) test('视频制作与成片管理全部通过页面提交并真实生成成片', async ({ page, context, request }, testInfo: TestInfo) => { test.setTimeout(15 * 60_000) const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) const projectName = `${runPrefix}-液压安全口播` const editedVideoName = `${runPrefix}-液压安全成片` const folderName = `${runPrefix}-视频测试文件夹` const renamedFolderName = `${runPrefix}-安全培训成片` const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。操作结束后复核隔离状态,确认现场安全。' const materialName = `${runPrefix}-检修要点.png` const sharePassword = 'Ape2eVideo#2026' const rotatedPassword = 'Ape2eRotate#2026' let projectId = '' let videoId = '' let folderId = '' 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() 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 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() const avatarName = (await avatar.locator('b').textContent() || '').trim() expect(avatarName, '必须从页面选择真实已就绪形象').not.toBe('') await avatar.click() await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() const voiceCard = page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).first() await expect(voiceCard).toBeVisible() await voiceCard.locator('label').click() await expect(voiceCard.locator('input[type="radio"]')).toBeChecked() await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).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() 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({ name: materialName, mimeType: 'image/png', buffer: MATERIAL_PNG, }) 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) 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) 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.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull() projectId = new URL(page.url()).searchParams.get('project') || '' expect(projectId).not.toBe('') let persistedProject = await projectDetail(request, headers, projectId) expect(persistedProject.response.status()).toBe(200) expect(persistedProject.data.name).toBe(projectName) expect(persistedProject.data.scriptText).toBe(scriptText) expect(persistedProject.data.orientation).toBe('landscape') 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) expect(persistedProject.data.subtitlePosition).toBe('center') 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', '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) 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(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? '')) 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.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) 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('.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 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 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') await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 }) await page.getByRole('button', { name: '生成视频', exact: true }).click() await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 }) await captureScreenshot(page, testInfo, '05-real-video-render-running') const succeeded = page.locator('.editor-render-progress.is-succeeded') await expect(succeeded).toContainText('视频生成完成', { timeout: 8 * 60_000 }) await expect(succeeded).toContainText('100%') activities.push({ module: '视频制作', action: '页面发起真实 TTS、浏览器录制、FFmpeg 合成与成片归档', result: 'PASS', resourceId: projectId }) await captureScreenshot(page, testInfo, '06-real-video-render-succeeded') await succeeded.getByRole('button', { name: '查看作品' }).click() await expect(page).toHaveURL(/\/videos\/manage\?video=/) videoId = new URL(page.url()).searchParams.get('video') || '' expect(videoId).not.toBe('') let detail = page.locator('.video-detail-overlay') await expect(detail).toBeVisible({ timeout: 30_000 }) await expect(detail).toContainText(projectName) const video = detail.locator('video') await expect(video).toHaveAttribute('src', /.+/) await detail.getByRole('button', { name: '播放视频', exact: true }).click() await expect(detail.getByRole('button', { name: '暂停视频', exact: true })).toBeVisible() await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true) await detail.getByRole('button', { name: '暂停视频', exact: true }).click() await expect(detail.getByRole('button', { name: '播放视频', exact: true })).toBeVisible() const downloadPromise = page.waitForEvent('download', { timeout: 60_000 }) await detail.getByRole('button', { name: '下载视频', exact: true }).click() const download = await downloadPromise expect(download.suggestedFilename()).toContain(projectName) expect(await download.path()).not.toBeNull() let persistedVideo = await videoDetail(request, headers, videoId) expect(persistedVideo.response.status()).toBe(200) expect(persistedVideo.data.status).toBe('ready') expect(String(persistedVideo.data.videoFileCode ?? '')).not.toBe('') expect(Number(persistedVideo.data.size ?? 0)).toBeGreaterThan(10_000) persistedProject = await projectDetail(request, headers, projectId) expect(persistedProject.data.lastVideoId).toBe(videoId) activities.push({ module: '成片管理', action: '页面打开生成成片详情并实际播放、下载', result: 'PASS', resourceId: videoId }) await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded') await detail.getByRole('button', { name: '关闭' }).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) => ( response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/video-folders' )) const saveFolderButton = dialog.getByRole('button', { name: '保存文件夹', exact: true }) await expect(saveFolderButton).toBeEnabled() await saveFolderButton.click() const createFolderResponse = await createFolderResponsePromise expect( createFolderResponse.status(), `页面新建文件夹请求失败:${await createFolderResponse.text()}`, ).toBe(201) await expect(page.getByText('文件夹已创建').last()).toBeVisible() let folderArticle = page.locator('.video-folder-strip article').filter({ hasText: folderName }) await expect(folderArticle).toBeVisible() let folder = await findFolder(request, headers, folderName) expect(folder).not.toBeNull() folderId = String(folder?.id ?? '') activities.push({ module: '成片管理', action: '页面新建成片文件夹', result: 'PASS', resourceId: folderId }) await captureScreenshot(page, testInfo, '08-video-folder-created') await folderArticle.getByRole('button', { name: '重命名文件夹' }).click() dialog = page.locator('.el-dialog:visible').last() await dialog.getByLabel('文件夹名称').fill(renamedFolderName) await dialog.getByRole('button', { name: '保存文件夹', exact: true }).click() await expect(page.getByText('文件夹已更新').last()).toBeVisible() folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName }) await expect(folderArticle).toBeVisible() await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName) await page.getByRole('button', { name: '搜索', exact: true }).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 }) await captureScreenshot(page, testInfo, '09-video-folder-renamed-and-filtered') await card.getByRole('button', { name: '查看详情', exact: true }).click() detail = page.locator('.video-detail-overlay') await detail.getByRole('button', { name: '编辑', exact: true }).click() await detail.getByLabel('标题').fill(editedVideoName) await detail.getByLabel('简介').fill('液压系统检修前安全确认与上锁挂牌教学成片。') await detail.getByLabel('所在文件夹').selectOption({ label: renamedFolderName }) await detail.getByRole('textbox', { name: '分类', exact: true }).fill('安全培训') await detail.getByLabel('标签').fill('液压系统、上锁挂牌、APE2E') await detail.getByLabel('可见范围').selectOption('public') await detail.getByRole('button', { name: '保存修改', exact: true }).click() await expect(page.getByText('视频资料已更新').last()).toBeVisible() await expect(detail).toContainText(editedVideoName) await expect(detail).toContainText(renamedFolderName) await expect(detail).toContainText('内网分享链接') persistedVideo = await videoDetail(request, headers, videoId) expect(persistedVideo.data.title).toBe(editedVideoName) expect(persistedVideo.data.folderId).toBe(folderId) expect(persistedVideo.data.visibility).toBe('public') expect(persistedVideo.data.tags).toEqual(['液压系统', '上锁挂牌', 'APE2E']) activities.push({ module: '成片管理', action: '页面编辑标题、简介、文件夹、分类、标签和可见范围', result: 'PASS', resourceId: videoId }) await captureScreenshot(page, testInfo, '10-video-metadata-folder-visibility-updated') await detail.getByRole('button', { name: '创建分享链接', exact: true }).click() dialog = page.locator('.el-dialog:visible').last() await dialog.getByLabel('有效时长(小时)').fill('1') await dialog.getByLabel('访问密码(可选)').fill(sharePassword) await dialog.getByLabel('最大访问次数').fill('5') await dialog.getByRole('button', { name: '生成分享链接', exact: true }).click() await expect(dialog.getByText('分享链接已生成')).toBeVisible() await expect(dialog).toContainText('最多 5 次访问') const firstShareInput = dialog.getByLabel('本次视频分享链接') const firstShareUrl = await firstShareInput.inputValue() expect(new URL(firstShareUrl).pathname).toMatch(/^\/share\/videos\/[^/]+$/) await captureScreenshot(page, testInfo, '11-video-share-password-limit-issued', [firstShareInput]) await dialog.getByRole('button', { name: '关闭', exact: true }).click() const sharedPage = await context.newPage() await sharedPage.goto(firstShareUrl) await expect(sharedPage.getByText('视频需要验证或链接暂不可用')).toBeVisible() await sharedPage.getByLabel('访问密码').fill(sharePassword) await sharedPage.getByRole('button', { name: '验证并播放' }).click() await expect(sharedPage.getByRole('heading', { name: editedVideoName })).toBeVisible() await expect(sharedPage.locator('video')).toBeVisible() await captureScreenshot(sharedPage, testInfo, '12-password-protected-share-verified') await sharedPage.close() activities.push({ module: '成片管理', action: '页面签发密码与限次分享并在公开页验证', result: 'PASS', resourceId: videoId }) await detail.getByRole('button', { name: '轮换分享链接', exact: true }).click() dialog = page.locator('.el-dialog:visible').last() await dialog.getByLabel('有效时长(小时)').fill('2') await dialog.getByLabel('访问密码(可选)').fill(rotatedPassword) await dialog.getByLabel('最大访问次数').fill('3') await dialog.getByRole('button', { name: '确认轮换', exact: true }).click() await expect(dialog.getByText('分享链接已生成')).toBeVisible() const rotatedShareInput = dialog.getByLabel('本次视频分享链接') const rotatedShareUrl = await rotatedShareInput.inputValue() expect(rotatedShareUrl).not.toBe(firstShareUrl) await captureScreenshot(page, testInfo, '13-video-share-rotated', [rotatedShareInput]) await dialog.getByRole('button', { name: '关闭', exact: true }).click() const revokedOldSharePage = await context.newPage() await expectPublicShareRejected(revokedOldSharePage, firstShareUrl) await revokedOldSharePage.close() await detail.getByRole('button', { name: '停止分享', exact: true }).click() await confirmMessageBox(page, '停止分享') await expect(page.getByText('分享已停止').last()).toBeVisible() persistedVideo = await videoDetail(request, headers, videoId) expect(persistedVideo.data.shareStatus).not.toBe('active') const revokedRotatedSharePage = await context.newPage() await expectPublicShareRejected(revokedRotatedSharePage, rotatedShareUrl) await revokedRotatedSharePage.close() activities.push({ module: '成片管理', action: '页面轮换与停止分享,新旧链接均按预期失效', result: 'PASS', resourceId: videoId }) await captureScreenshot(page, testInfo, '14-video-share-stopped') await detail.getByRole('button', { name: '关闭' }).click() folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName }) await folderArticle.getByRole('button', { name: '删除文件夹' }).click() await confirmMessageBox(page, '确认删除') await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible() folderId = '' await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName) 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() await card.getByRole('button', { name: '查看详情', exact: true }).click() detail = page.locator('.video-detail-overlay') await expect(detail).toContainText('未分类') persistedVideo = await videoDetail(request, headers, videoId) expect(persistedVideo.data.folderId).toBeNull() activities.push({ module: '成片管理', action: '页面删除含成片文件夹并核验成片移至未分类', result: 'PASS', resourceId: videoId }) await captureScreenshot(page, testInfo, '15-video-folder-deleted-video-unfiled') await detail.getByRole('button', { name: '删除', exact: true }).click() await confirmMessageBox(page, '确认删除') await expect(page.getByText('视频资源已删除').last()).toBeVisible() await expect(page.locator('.video-detail-overlay')).toHaveCount(0) const deletedVideo = await videoDetail(request, headers, videoId) expect(deletedVideo.response.status()).toBe(404) activities.push({ module: '成片管理', action: '页面永久删除成片与关联文件', result: 'PASS', resourceId: videoId }) videoId = '' await captureScreenshot(page, testInfo, '16-generated-video-deleted-baseline-remains') } catch (error) { primaryError = error } finally { if (!videoId) { const leakedVideo = await findByKeyword(request, headers, '/api/v1/videos', runPrefix, editedVideoName).catch(() => null) || await findByKeyword(request, headers, '/api/v1/videos', runPrefix, projectName).catch(() => null) videoId = String(leakedVideo?.id ?? '') } if (!folderId) { const leakedFolder = await findFolder(request, headers, renamedFolderName).catch(() => null) || await findFolder(request, headers, folderName).catch(() => null) folderId = String(leakedFolder?.id ?? '') } if (!projectId) { const urlProjectId = new URL(page.url()).searchParams.get('project') || '' const leakedProject = urlProjectId ? null : await findByKeyword(request, headers, '/api/v1/video-projects', runPrefix, projectName).catch(() => null) projectId = urlProjectId || String(leakedProject?.id ?? '') } if (videoId) await cleanupVideo(request, headers, videoId, activities, cleanupErrors) if (folderId) await cleanupFolder(request, headers, folderId, activities, cleanupErrors) if (projectId) await cleanupProject(request, headers, projectId, activities, cleanupErrors) await attachJson(testInfo, '视频制作与成片管理-UI生命周期', { runPrefix, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '分享密码和一次性分享令牌未写入附件,包含令牌的截图区域已遮罩。', }) } if (primaryError) throw primaryError expect(cleanupErrors, '视频 UI 生命周期测试数据必须清理干净').toEqual([]) })