|
- 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<string, unknown>
- type Headers = Record<string, string>
-
- 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<ReturnType<APIRequestContext['get']>>, 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<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')
-
- 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([])
- })
|