Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

710 rader
45 KiB

  1. import type { APIRequestContext, Page, TestInfo } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures'
  3. import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin } from './helpers'
  4. type JsonRecord = Record<string, unknown>
  5. type Headers = Record<string, string>
  6. interface Activity {
  7. module: string
  8. action: string
  9. result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED'
  10. resourceId?: string
  11. detail?: string
  12. }
  13. const MATERIAL_PNG = Buffer.from(
  14. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  15. 'base64',
  16. )
  17. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
  18. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  19. async function apiData(response: Awaited<ReturnType<APIRequestContext['get']>>, expected = 200) {
  20. expect(response.status()).toBe(expected)
  21. return asRecord(envelopeData(await response.json()))
  22. }
  23. async function projectDetail(request: APIRequestContext, headers: Headers, id: string) {
  24. const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers })
  25. return { response, data: response.status() === 200 ? await apiData(response) : {} }
  26. }
  27. async function videoDetail(request: APIRequestContext, headers: Headers, id: string) {
  28. const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers })
  29. return { response, data: response.status() === 200 ? await apiData(response) : {} }
  30. }
  31. async function findFolder(request: APIRequestContext, headers: Headers, name: string) {
  32. const response = await request.get('/api/v1/video-folders', { headers })
  33. const data = await apiData(response)
  34. return asRecords(data.items).find((item) => String(item.name ?? '') === name) || null
  35. }
  36. async function findByKeyword(request: APIRequestContext, headers: Headers, endpoint: string, keyword: string, name: string) {
  37. const response = await request.get(endpoint, { headers, params: { keyword, page: '1', pageSize: '100' } })
  38. const data = await apiData(response)
  39. return asRecords(data.items).find((item) => String(item.name ?? item.title ?? '') === name) || null
  40. }
  41. async function cleanupVideo(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) {
  42. try {
  43. let detail = await videoDetail(request, headers, id)
  44. if (detail.response.status() === 404) return
  45. if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`)
  46. if (String(detail.data.shareStatus ?? '') === 'active') {
  47. await request.delete(`/api/v1/videos/${encodeURIComponent(id)}/share`, {
  48. headers,
  49. data: { reason: 'APE2E failure cleanup' },
  50. })
  51. detail = await videoDetail(request, headers, id)
  52. }
  53. const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, {
  54. headers,
  55. params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
  56. })
  57. if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
  58. activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANED', resourceId: id })
  59. } catch (error) {
  60. const message = `成片 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}`
  61. errors.push(message)
  62. activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANUP_FAILED', resourceId: id, detail: message })
  63. }
  64. }
  65. async function cleanupFolder(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) {
  66. try {
  67. const response = await request.get('/api/v1/video-folders', { headers })
  68. const data = await apiData(response)
  69. const folder = asRecords(data.items).find((item) => String(item.id ?? '') === id)
  70. if (!folder) return
  71. const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(id)}`, {
  72. headers,
  73. params: { dataVersion: String(Number(folder.dataVersion ?? 0)) },
  74. })
  75. if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
  76. activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANED', resourceId: id })
  77. } catch (error) {
  78. const message = `文件夹 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}`
  79. errors.push(message)
  80. activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANUP_FAILED', resourceId: id, detail: message })
  81. }
  82. }
  83. async function cleanupProject(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) {
  84. try {
  85. const detail = await projectDetail(request, headers, id)
  86. if (detail.response.status() === 404) return
  87. if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`)
  88. const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, {
  89. headers,
  90. params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
  91. })
  92. if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
  93. activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANED', resourceId: id })
  94. } catch (error) {
  95. const message = `视频工程 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}`
  96. errors.push(message)
  97. activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANUP_FAILED', resourceId: id, detail: message })
  98. }
  99. }
  100. async function chooseElSelect(page: Page, label: string, option: string) {
  101. const combobox = page.getByRole('combobox', { name: label, exact: true })
  102. await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click()
  103. const dropdown = page.locator('.el-select-dropdown:visible').last()
  104. await expect(dropdown).toBeVisible()
  105. await dropdown.getByRole('option', { name: option, exact: true }).click()
  106. await expect(dropdown).toBeHidden()
  107. }
  108. async function chooseFirstElSelectOption(page: Page, label: string, preferred: RegExp) {
  109. const combobox = page.getByRole('combobox', { name: label, exact: true })
  110. await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click()
  111. const dropdown = page.locator('.el-select-dropdown:visible').last()
  112. await expect(dropdown).toBeVisible()
  113. const options = await dropdown.getByRole('option').evaluateAll((nodes) => nodes.map((node) => ({
  114. label: (node.textContent || '').trim(),
  115. disabled: node.classList.contains('is-disabled') || node.getAttribute('aria-disabled') === 'true',
  116. })))
  117. const selected = options.find((item) => !item.disabled && preferred.test(item.label))
  118. || options.find((item) => !item.disabled)
  119. expect(selected, `“${label}”必须存在至少一个可选项`).toBeTruthy()
  120. await dropdown.getByRole('option', { name: selected!.label, exact: true }).click()
  121. await expect(dropdown).toBeHidden()
  122. return selected!.label
  123. }
  124. async function expectPublicShareRejected(page: Page, url: string) {
  125. await page.goto(url)
  126. await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible()
  127. }
  128. test.describe.configure({ mode: 'serial' })
  129. // 成片为 H.264/AAC MP4;Playwright 自带的开源 Chromium 不包含完整专利编解码器。
  130. // 本用例用系统 Chrome 完成真实播放验收,其余 E2E 仍使用项目 Chromium。
  131. test.use({ trace: 'off', video: 'off', channel: 'chrome' })
  132. test('视频制作与成片管理全部通过页面提交并真实生成成片', async ({ page, context, request }, testInfo: TestInfo) => {
  133. test.setTimeout(15 * 60_000)
  134. const activities: Activity[] = []
  135. const cleanupErrors: string[] = []
  136. const headers = await authHeaders(request)
  137. const projectName = `${runPrefix}-液压安全口播`
  138. const editedVideoName = `${runPrefix}-液压安全成片`
  139. const folderName = `${runPrefix}-视频测试文件夹`
  140. const renamedFolderName = `${runPrefix}-安全培训成片`
  141. const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。操作结束后复核隔离状态,确认现场安全。'
  142. const materialName = `${runPrefix}-检修要点.png`
  143. const sharePassword = 'Ape2eVideo#2026'
  144. const rotatedPassword = 'Ape2eRotate#2026'
  145. let projectId = ''
  146. let videoId = ''
  147. let folderId = ''
  148. let primaryError: unknown
  149. try {
  150. await page.setViewportSize({ width: 1440, height: 1000 })
  151. await loginAsAdmin(page)
  152. await page.goto('/videos/create?mode=offline')
  153. await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
  154. const firstAvatarCard = page.locator('.studio-avatar-list button:not([disabled])').first()
  155. await expect(firstAvatarCard).toBeVisible()
  156. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 })
  157. await expect(page.getByRole('tab', { name: /本地形象/ })).toHaveAttribute('aria-selected', 'true')
  158. await expect(page.locator('.library-tabs .el-button')).toHaveCount(0)
  159. await expect(page.getByRole('tab', { name: '文本', exact: true })).toHaveAttribute('aria-selected', 'true')
  160. await expect(page.locator('.inspector-tabs .el-button')).toHaveCount(0)
  161. expect(await page.getByLabel('项目名称').evaluate((element) => {
  162. const style = getComputedStyle(element)
  163. return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth]
  164. }), 'Element Plus 输入框内部不能再绘制第二层边框').toEqual(['0px', '0px', '0px', '0px'])
  165. const avatarCardBox = await firstAvatarCard.boundingBox()
  166. const avatarImageBox = await firstAvatarCard.locator('img').boundingBox()
  167. expect(avatarCardBox, '数字人卡片不能折叠').not.toBeNull()
  168. expect(avatarCardBox!.height, '数字人卡片必须完整展示图片和名称').toBeGreaterThan(150)
  169. expect(avatarImageBox, '数字人卡片必须显示完整封面区').not.toBeNull()
  170. expect(avatarImageBox!.height).toBeGreaterThanOrEqual(120)
  171. await captureScreenshot(page, testInfo, '01-video-create-avatar-library-layout')
  172. await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
  173. const layoutBackgroundCard = page.locator('.scene-background-card').first()
  174. await expect(layoutBackgroundCard).toBeVisible()
  175. const layoutBackgroundBox = await layoutBackgroundCard.boundingBox()
  176. const layoutBackgroundMediaBox = await layoutBackgroundCard.locator('img, video').first().boundingBox()
  177. expect(layoutBackgroundBox, '背景资源卡片不能折叠').not.toBeNull()
  178. expect(layoutBackgroundBox!.height).toBeGreaterThan(105)
  179. expect(layoutBackgroundMediaBox, '背景资源必须完整显示缩略图').not.toBeNull()
  180. expect(layoutBackgroundMediaBox!.height).toBeGreaterThanOrEqual(80)
  181. await captureScreenshot(page, testInfo, '01-background-library-layout')
  182. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  183. const layoutVoiceRow = page.locator('.editor-resource-list article').first()
  184. await expect(layoutVoiceRow).toBeVisible()
  185. expect((await layoutVoiceRow.boundingBox())!.height).toBeGreaterThanOrEqual(60)
  186. const selectedVoiceRow = page.locator('.editor-resource-list article.active')
  187. await expect(selectedVoiceRow).toHaveCount(1)
  188. expect(await selectedVoiceRow.evaluate((element) => getComputedStyle(element).borderTopColor))
  189. .not.toBe('rgba(0, 0, 0, 0)')
  190. const voicePreviewIcon = layoutVoiceRow.getByRole('button', { name: /试听声音|停止试听/ })
  191. await expect(voicePreviewIcon).toHaveText('')
  192. await expect(voicePreviewIcon.locator('svg')).toHaveCount(1)
  193. expect(await voicePreviewIcon.evaluate((element) => ({
  194. border: getComputedStyle(element).borderTopWidth,
  195. background: getComputedStyle(element).backgroundColor,
  196. }))).toEqual({ border: '0px', background: 'rgba(0, 0, 0, 0)' })
  197. expect(await page.locator('.editor-tool-rail button').first().evaluate((element) => getComputedStyle(element).borderTopLeftRadius))
  198. .toBe('0px')
  199. await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
  200. const layoutTextButton = page.locator('.text-assets button').first()
  201. await expect(layoutTextButton).toBeVisible()
  202. expect((await layoutTextButton.boundingBox())!.height).toBeGreaterThanOrEqual(60)
  203. await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
  204. const layoutElementButton = page.locator('.element-library button').first()
  205. await expect(layoutElementButton).toBeVisible()
  206. expect((await layoutElementButton.boundingBox())!.height).toBeGreaterThanOrEqual(68)
  207. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  208. const layoutUploadButton = page.locator('.media-library .upload-zone')
  209. await expect(layoutUploadButton).toBeVisible()
  210. expect((await layoutUploadButton.boundingBox())!.height).toBeGreaterThanOrEqual(100)
  211. await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click()
  212. await expect(firstAvatarCard).toBeVisible()
  213. await page.setViewportSize({ width: 1920, height: 1080 })
  214. await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()
  215. const avatar = page.locator('.studio-avatar-list button:not([disabled])').first()
  216. const avatarName = (await avatar.locator('b').textContent() || '').trim()
  217. expect(avatarName, '必须从页面选择真实已就绪形象').not.toBe('')
  218. await avatar.click()
  219. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  220. const voiceCard = page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).first()
  221. await expect(voiceCard).toBeVisible()
  222. await voiceCard.locator('label').click()
  223. await expect(voiceCard.locator('input[type="radio"]')).toBeChecked()
  224. await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
  225. const backgroundCard = page.locator('.scene-background-card').first()
  226. await expect(backgroundCard).toBeVisible()
  227. const backgroundCapabilityId = await backgroundCard.getAttribute('data-capability-id')
  228. expect(backgroundCapabilityId).toBeTruthy()
  229. await backgroundCard.click()
  230. await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:')
  231. await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0)
  232. await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
  233. await page.getByRole('button', { name: /检查标记/ }).click()
  234. const checkOverlay = page.locator('.stage-overlay[data-overlay-kind="check"]')
  235. await expect(checkOverlay).toBeVisible()
  236. await expect(checkOverlay.locator('.overlay-copy')).toHaveCount(0)
  237. expect(await checkOverlay.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
  238. const initialCheckBox = await checkOverlay.boundingBox()
  239. expect(initialCheckBox).not.toBeNull()
  240. await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2, initialCheckBox!.y + initialCheckBox!.height / 2)
  241. await page.mouse.down()
  242. await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2 - 34, initialCheckBox!.y + initialCheckBox!.height / 2 - 22, { steps: 5 })
  243. await page.mouse.up()
  244. const movedCheckBox = await checkOverlay.boundingBox()
  245. expect(movedCheckBox!.x).toBeLessThan(initialCheckBox!.x - 20)
  246. expect(movedCheckBox!.y).toBeLessThan(initialCheckBox!.y - 10)
  247. const resizeHandle = checkOverlay.getByRole('button', { name: '缩放已完成检查' })
  248. const resizeBox = await resizeHandle.boundingBox()
  249. expect(resizeBox).not.toBeNull()
  250. await page.mouse.move(resizeBox!.x + resizeBox!.width / 2, resizeBox!.y + resizeBox!.height / 2)
  251. await page.mouse.down()
  252. await page.mouse.move(resizeBox!.x + resizeBox!.width / 2 + 30, resizeBox!.y + resizeBox!.height / 2 + 26, { steps: 5 })
  253. await page.mouse.up()
  254. const resizedCheckBox = await checkOverlay.boundingBox()
  255. expect(resizedCheckBox!.width).toBeGreaterThan(movedCheckBox!.width + 18)
  256. expect(resizedCheckBox!.height).toBeGreaterThan(movedCheckBox!.height + 14)
  257. const positionedResizeBox = await resizeHandle.boundingBox()
  258. expect(Math.abs(positionedResizeBox!.x + positionedResizeBox!.width / 2 - (resizedCheckBox!.x + resizedCheckBox!.width))).toBeLessThanOrEqual(16)
  259. expect(Math.abs(positionedResizeBox!.y + positionedResizeBox!.height / 2 - (resizedCheckBox!.y + resizedCheckBox!.height))).toBeLessThanOrEqual(16)
  260. const numberControl = page.locator('.overlay-size-fields .el-input-number').first()
  261. const numberBox = await numberControl.boundingBox()
  262. const increaseBox = await numberControl.locator('.el-input-number__increase').boundingBox()
  263. const decreaseBox = await numberControl.locator('.el-input-number__decrease').boundingBox()
  264. expect(numberBox).not.toBeNull()
  265. expect(increaseBox).not.toBeNull()
  266. expect(decreaseBox).not.toBeNull()
  267. expect(Math.abs(numberBox!.x + numberBox!.width - (increaseBox!.x + increaseBox!.width))).toBeLessThanOrEqual(2)
  268. expect(Math.abs(numberBox!.x + numberBox!.width - (decreaseBox!.x + decreaseBox!.width))).toBeLessThanOrEqual(2)
  269. expect(decreaseBox!.y - (increaseBox!.y + increaseBox!.height)).toBeLessThanOrEqual(2)
  270. const inspectorBox = await page.locator('.editor-inspector').boundingBox()
  271. const stageBox = await page.locator('.editor-canvas-row .studio-stage').boundingBox()
  272. expect(inspectorBox).not.toBeNull()
  273. expect(stageBox).not.toBeNull()
  274. expect(Math.abs(inspectorBox!.height - stageBox!.height), '右侧编辑区不能撑高中间预览区').toBeLessThanOrEqual(2)
  275. const inspectorScrollState = await page.locator('.editor-inspector-scroll').evaluate((element) => ({
  276. overflowY: getComputedStyle(element).overflowY,
  277. clientHeight: element.clientHeight,
  278. scrollHeight: element.scrollHeight,
  279. }))
  280. expect(inspectorScrollState).toMatchObject({ overflowY: 'auto' })
  281. expect(inspectorScrollState.scrollHeight).toBeGreaterThan(inspectorScrollState.clientHeight)
  282. await captureScreenshot(page, testInfo, '02-stage-element-moved-resized-without-label-or-background')
  283. await page.getByLabel('开始(秒)').fill('0.5')
  284. await page.getByLabel('结束(秒)').fill('4.5')
  285. await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
  286. await page.getByRole('button', { name: /添加正文/ }).click()
  287. const bodyOverlay = page.locator('.stage-overlay[data-overlay-kind="body"]')
  288. await expect(bodyOverlay).toBeVisible()
  289. await chooseElSelect(page, '元素文字字体', '楷体')
  290. await chooseElSelect(page, '元素文字字号', '40')
  291. await expect(bodyOverlay).toHaveCSS('font-family', /KaiTi/i)
  292. await expect(bodyOverlay).toHaveCSS('font-size', '18px')
  293. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  294. await page.locator('.media-library input[type="file"]').setInputFiles({
  295. name: materialName,
  296. mimeType: 'image/png',
  297. buffer: MATERIAL_PNG,
  298. })
  299. await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
  300. await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()
  301. await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0)
  302. const stageMedia = page.locator('.stage-media-picture-in-picture')
  303. const initialMediaBox = await stageMedia.boundingBox()
  304. expect(initialMediaBox).not.toBeNull()
  305. await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2, initialMediaBox!.y + initialMediaBox!.height / 2)
  306. await page.mouse.down()
  307. await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2 - 32, initialMediaBox!.y + initialMediaBox!.height / 2 + 24, { steps: 5 })
  308. await page.mouse.up()
  309. const movedMediaBox = await stageMedia.boundingBox()
  310. expect(movedMediaBox!.x).toBeLessThan(initialMediaBox!.x - 18)
  311. expect(movedMediaBox!.y).toBeGreaterThan(initialMediaBox!.y + 12)
  312. const mediaResize = stageMedia.getByRole('button', { name: `缩放${materialName}` })
  313. const mediaRemove = stageMedia.getByRole('button', { name: `移除${materialName}` })
  314. const mediaRemoveBox = await mediaRemove.boundingBox()
  315. const initialMediaResizeBox = await mediaResize.boundingBox()
  316. expect(mediaRemoveBox).not.toBeNull()
  317. expect(initialMediaResizeBox).not.toBeNull()
  318. expect(Math.abs(mediaRemoveBox!.width - mediaRemoveBox!.height), '素材移除按钮必须保持正方形').toBeLessThanOrEqual(1)
  319. expect(Math.abs(initialMediaResizeBox!.width - initialMediaResizeBox!.height), '素材缩放控制器必须保持正方形').toBeLessThanOrEqual(1)
  320. await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2)
  321. await page.mouse.down()
  322. await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2 + 28, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2 + 20, { steps: 5 })
  323. await page.mouse.up()
  324. const resizedMediaBox = await stageMedia.boundingBox()
  325. expect(resizedMediaBox!.width).toBeGreaterThan(movedMediaBox!.width + 14)
  326. expect(resizedMediaBox!.height).toBeGreaterThan(movedMediaBox!.height + 10)
  327. await page.getByRole('tab', { name: '文本', exact: true }).click()
  328. await page.getByLabel('项目名称').fill(projectName)
  329. const scriptInput = page.locator('.script-field textarea')
  330. await scriptInput.fill('时间轴长内容验证。'.repeat(250))
  331. const timelineScroll = page.getByRole('region', { name: '可横向滚动的时间轴轨道' })
  332. await expect(timelineScroll).toBeVisible()
  333. const initialTimelineSize = await timelineScroll.evaluate((element) => ({
  334. clientWidth: element.clientWidth,
  335. scrollWidth: element.scrollWidth,
  336. }))
  337. expect(initialTimelineSize.scrollWidth).toBeGreaterThan(initialTimelineSize.clientWidth * 1.25)
  338. await page.getByRole('button', { name: '放大时间轴' }).click()
  339. await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth)).toBeGreaterThan(initialTimelineSize.scrollWidth)
  340. await timelineScroll.hover()
  341. await page.keyboard.down('Shift')
  342. await page.mouse.wheel(0, 700)
  343. await page.keyboard.up('Shift')
  344. await expect.poll(() => timelineScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0)
  345. const timelineZoomSlider = page.getByRole('slider', { name: '调整时间轴比例尺' })
  346. await expect(timelineZoomSlider).toBeVisible()
  347. await expect(timelineZoomSlider).toHaveAttribute('aria-valuemin', '5')
  348. await expect(timelineZoomSlider).toHaveAttribute('aria-valuemax', '1000')
  349. await captureScreenshot(page, testInfo, '02a-timeline-zoom-and-horizontal-scroll')
  350. await page.getByRole('button', { name: '适配', exact: true }).click()
  351. await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(2)
  352. expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2)
  353. await scriptInput.fill(scriptText)
  354. const voiceCatalogBeforeSave = await apiData(await request.get('/api/v1/tts/voices', { headers }))
  355. const availableVoices = asRecords(voiceCatalogBeforeSave.items).filter((voice) => String(voice.capabilityId ?? '') && String(voice.name ?? ''))
  356. const selectedVoiceLabel = await chooseFirstElSelectOption(page, '语音能力资产', /演示实训播报语音/)
  357. const selectedVoiceName = selectedVoiceLabel.replace(/((?:克隆|内置))$/, '')
  358. const selectedCapability = availableVoices.find((voice) => String(voice.name ?? '') === selectedVoiceName)
  359. expect(selectedCapability, '页面必须加载至少一个可选的真实语音能力').toBeTruthy()
  360. const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true })
  361. if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') {
  362. await subtitleSwitch.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]').click()
  363. }
  364. await page.getByRole('tab', { name: '字幕样式', exact: true }).click()
  365. await chooseElSelect(page, '字体', '思源黑体')
  366. await chooseElSelect(page, '字号', '48')
  367. await chooseElSelect(page, '字幕位置', '画面中部')
  368. await captureScreenshot(page, testInfo, '02-video-project-ui-configured')
  369. await page.getByRole('button', { name: '保存工程', exact: true }).click()
  370. await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 60_000 })
  371. await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
  372. projectId = new URL(page.url()).searchParams.get('project') || ''
  373. expect(projectId).not.toBe('')
  374. let persistedProject = await projectDetail(request, headers, projectId)
  375. expect(persistedProject.response.status()).toBe(200)
  376. expect(persistedProject.data.name).toBe(projectName)
  377. expect(persistedProject.data.scriptText).toBe(scriptText)
  378. expect(persistedProject.data.orientation).toBe('landscape')
  379. expect(persistedProject.data.backgroundType).toBe('asset')
  380. expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId)
  381. expect(persistedProject.data.subtitleEnabled).toBe(true)
  382. expect(persistedProject.data.subtitleFont).toBe('Source Han Sans SC')
  383. expect(persistedProject.data.subtitleFontSize).toBe(48)
  384. expect(persistedProject.data.subtitlePosition).toBe('center')
  385. expect(String(persistedProject.data.avatarId ?? '')).not.toBe('')
  386. expect(String(persistedProject.data.voiceCapabilityId ?? '')).not.toBe('')
  387. const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
  388. expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE'])
  389. const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
  390. expect(mediaClips.some((clip) => clip.type === 'MEDIA' && String(clip.fileId ?? ''))).toBe(true)
  391. const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips)
  392. const checkClip = elementClips.find((clip) => clip.type === 'CHECK') || {}
  393. expect(checkClip.startMs).toBe(500)
  394. expect(checkClip.endMs).toBe(4500)
  395. expect(Number(checkClip.x)).toBeLessThan(0.75)
  396. expect(Number(checkClip.y)).toBeLessThan(0.18)
  397. expect(Number(checkClip.width)).toBeGreaterThan(0.15)
  398. expect(Number(checkClip.height)).toBeGreaterThan(0.15)
  399. const bodyClip = elementClips.find((clip) => clip.type === 'BODY') || {}
  400. expect(asRecord(bodyClip.style).fontFamily).toBe('KaiTi')
  401. expect(asRecord(bodyClip.style).fontSize).toBe(40)
  402. const mediaClip = mediaClips.find((clip) => clip.type === 'MEDIA') || {}
  403. expect(Number(mediaClip.x)).toBeLessThan(0.64)
  404. expect(Number(mediaClip.y)).toBeGreaterThan(0.05)
  405. expect(Number(mediaClip.width)).toBeGreaterThan(0.32)
  406. expect(Number(mediaClip.height)).toBeGreaterThan(0.18)
  407. const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
  408. expect(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? ''))
  409. const voiceCatalog = await apiData(await request.get('/api/v1/tts/voices', { headers }))
  410. const persistedVoice = asRecords(voiceCatalog.items).find((voice) => (
  411. String(voice.capabilityId ?? '') === String(persistedProject.data.voiceCapabilityId ?? '')
  412. ))
  413. expect(persistedVoice, '工程音轨引用的音色必须仍在统一 TTS 目录中').toBeTruthy()
  414. expect(asRecord(audioClip.style).speaker ?? null).toBe(persistedVoice!.speaker ?? null)
  415. const subtitleClips = asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)
  416. expect(subtitleClips.length).toBeGreaterThan(1)
  417. expect(subtitleClips.map((clip) => String(clip.text || '')).join('')).toBe(scriptText)
  418. activities.push({ module: '视频制作', action: '页面保存资源背景、真实形象音色、素材、可定时元素、分段字幕和五轨时间线', result: 'PASS', resourceId: projectId })
  419. await captureScreenshot(page, testInfo, '03-video-project-saved-five-tracks')
  420. await page.reload()
  421. await expect(page.getByText('已保存', { exact: true })).toBeVisible({ timeout: 30_000 })
  422. await expect(page.getByLabel('项目名称')).toHaveValue(projectName)
  423. await expect(page.locator('.script-field textarea')).toHaveValue(scriptText)
  424. await expect(page.getByRole('button', { name: /横屏(1920 × 1080)/ })).toHaveClass(/active/)
  425. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  426. await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
  427. const timelineSlider = page.getByRole('slider', { name: '拖动时间轴播放头' })
  428. await timelineSlider.focus()
  429. await timelineSlider.press('Home')
  430. for (let step = 0; step < 20; step += 1) await timelineSlider.press('ArrowRight')
  431. await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible()
  432. const reloadedVoiceSelect = page.getByRole('combobox', { name: '语音能力资产', exact: true })
  433. .locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]')
  434. await expect(reloadedVoiceSelect.locator('.el-select__selected-item:not(.is-hidden)')).toContainText(String(selectedCapability!.name))
  435. await expect(page.locator('.timeline-clip.media')).toContainText(materialName)
  436. await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0)
  437. const timelinePlayButton = page.getByRole('button', { name: '播放', exact: true })
  438. await expect(timelinePlayButton.locator('svg')).toHaveCount(1)
  439. await expect.poll(async () => (
  440. (await page.locator('.timeline-clip.subtitle').allTextContents()).join('')
  441. )).toBe(scriptText)
  442. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  443. await expect(page.locator(`.editor-resource-list input[type="radio"][value="${String(selectedCapability!.capabilityId)}"]`)).toBeChecked()
  444. const previewSpeechRequest = page.waitForRequest((request) => (
  445. request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/tts'
  446. ))
  447. await page.getByRole('button', { name: '预览播放', exact: true }).click()
  448. const previewSpeechPayload = (await previewSpeechRequest).postDataJSON() as Record<string, unknown>
  449. expect(previewSpeechPayload.text).toBe(scriptText)
  450. await expect(page.getByRole('button', { name: '停止预览', exact: true })).toBeVisible({ timeout: 120_000 })
  451. await page.getByRole('button', { name: '停止预览', exact: true }).click()
  452. await expect(page.getByRole('button', { name: '预览播放', exact: true })).toBeVisible()
  453. activities.push({ module: '视频制作', action: '刷新 project 深链并核验全部配置持久化', result: 'PASS', resourceId: projectId })
  454. await captureScreenshot(page, testInfo, '04-video-project-deeplink-persisted')
  455. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 })
  456. await page.getByRole('button', { name: '生成视频', exact: true }).click()
  457. await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 })
  458. await captureScreenshot(page, testInfo, '05-real-video-render-running')
  459. const succeeded = page.locator('.editor-render-progress.is-succeeded')
  460. await expect(succeeded).toContainText('视频生成完成', { timeout: 8 * 60_000 })
  461. await expect(succeeded).toContainText('100%')
  462. activities.push({ module: '视频制作', action: '页面发起真实 TTS、浏览器录制、FFmpeg 合成与成片归档', result: 'PASS', resourceId: projectId })
  463. await captureScreenshot(page, testInfo, '06-real-video-render-succeeded')
  464. await succeeded.getByRole('button', { name: '查看作品' }).click()
  465. await expect(page).toHaveURL(/\/videos\/manage\?video=/)
  466. videoId = new URL(page.url()).searchParams.get('video') || ''
  467. expect(videoId).not.toBe('')
  468. let detail = page.locator('.video-detail-overlay')
  469. await expect(detail).toBeVisible({ timeout: 30_000 })
  470. await expect(detail).toContainText(projectName)
  471. const video = detail.locator('video')
  472. await expect(video).toHaveAttribute('src', /.+/)
  473. await detail.getByRole('button', { name: '播放视频', exact: true }).click()
  474. await expect(detail.getByRole('button', { name: '暂停视频', exact: true })).toBeVisible()
  475. await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true)
  476. await detail.getByRole('button', { name: '暂停视频', exact: true }).click()
  477. await expect(detail.getByRole('button', { name: '播放视频', exact: true })).toBeVisible()
  478. const downloadPromise = page.waitForEvent('download', { timeout: 60_000 })
  479. await detail.getByRole('button', { name: '下载视频', exact: true }).click()
  480. const download = await downloadPromise
  481. expect(download.suggestedFilename()).toContain(projectName)
  482. expect(await download.path()).not.toBeNull()
  483. let persistedVideo = await videoDetail(request, headers, videoId)
  484. expect(persistedVideo.response.status()).toBe(200)
  485. expect(persistedVideo.data.status).toBe('ready')
  486. expect(String(persistedVideo.data.videoFileCode ?? '')).not.toBe('')
  487. expect(Number(persistedVideo.data.size ?? 0)).toBeGreaterThan(10_000)
  488. persistedProject = await projectDetail(request, headers, projectId)
  489. expect(persistedProject.data.lastVideoId).toBe(videoId)
  490. activities.push({ module: '成片管理', action: '页面打开生成成片详情并实际播放、下载', result: 'PASS', resourceId: videoId })
  491. await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded')
  492. await detail.getByRole('button', { name: '关闭' }).click()
  493. await page.getByRole('button', { name: '新建文件夹', exact: true }).click()
  494. let dialog = page.locator('.el-dialog:visible').last()
  495. await dialog.getByLabel('文件夹名称').fill(folderName)
  496. const createFolderResponsePromise = page.waitForResponse((response) => (
  497. response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/video-folders'
  498. ))
  499. const saveFolderButton = dialog.getByRole('button', { name: '保存文件夹', exact: true })
  500. await expect(saveFolderButton).toBeEnabled()
  501. await saveFolderButton.click()
  502. const createFolderResponse = await createFolderResponsePromise
  503. expect(
  504. createFolderResponse.status(),
  505. `页面新建文件夹请求失败:${await createFolderResponse.text()}`,
  506. ).toBe(201)
  507. await expect(page.getByText('文件夹已创建').last()).toBeVisible()
  508. let folderArticle = page.locator('.video-folder-strip article').filter({ hasText: folderName })
  509. await expect(folderArticle).toBeVisible()
  510. let folder = await findFolder(request, headers, folderName)
  511. expect(folder).not.toBeNull()
  512. folderId = String(folder?.id ?? '')
  513. activities.push({ module: '成片管理', action: '页面新建成片文件夹', result: 'PASS', resourceId: folderId })
  514. await captureScreenshot(page, testInfo, '08-video-folder-created')
  515. await folderArticle.getByRole('button', { name: '重命名文件夹' }).click()
  516. dialog = page.locator('.el-dialog:visible').last()
  517. await dialog.getByLabel('文件夹名称').fill(renamedFolderName)
  518. await dialog.getByRole('button', { name: '保存文件夹', exact: true }).click()
  519. await expect(page.getByText('文件夹已更新').last()).toBeVisible()
  520. folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName })
  521. await expect(folderArticle).toBeVisible()
  522. await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName)
  523. await page.getByRole('button', { name: '搜索', exact: true }).click()
  524. await chooseElSelect(page, '视频分类', '口播视频')
  525. await chooseElSelect(page, '可见范围', '仅自己')
  526. await chooseElSelect(page, '视频归属', '我创建的')
  527. await chooseElSelect(page, '排序方式', '标题升序')
  528. let card = page.locator('.video-library-card').filter({ hasText: projectName })
  529. await expect(card).toBeVisible()
  530. activities.push({ module: '成片管理', action: '页面重命名文件夹并组合搜索、分类、可见范围、归属与排序', result: 'PASS', resourceId: folderId })
  531. await captureScreenshot(page, testInfo, '09-video-folder-renamed-and-filtered')
  532. await card.getByRole('button', { name: '查看详情', exact: true }).click()
  533. detail = page.locator('.video-detail-overlay')
  534. await detail.getByRole('button', { name: '编辑', exact: true }).click()
  535. await detail.getByLabel('标题').fill(editedVideoName)
  536. await detail.getByLabel('简介').fill('液压系统检修前安全确认与上锁挂牌教学成片。')
  537. await detail.getByLabel('所在文件夹').selectOption({ label: renamedFolderName })
  538. await detail.getByRole('textbox', { name: '分类', exact: true }).fill('安全培训')
  539. await detail.getByLabel('标签').fill('液压系统、上锁挂牌、APE2E')
  540. await detail.getByLabel('可见范围').selectOption('public')
  541. await detail.getByRole('button', { name: '保存修改', exact: true }).click()
  542. await expect(page.getByText('视频资料已更新').last()).toBeVisible()
  543. await expect(detail).toContainText(editedVideoName)
  544. await expect(detail).toContainText(renamedFolderName)
  545. await expect(detail).toContainText('内网分享链接')
  546. persistedVideo = await videoDetail(request, headers, videoId)
  547. expect(persistedVideo.data.title).toBe(editedVideoName)
  548. expect(persistedVideo.data.folderId).toBe(folderId)
  549. expect(persistedVideo.data.visibility).toBe('public')
  550. expect(persistedVideo.data.tags).toEqual(['液压系统', '上锁挂牌', 'APE2E'])
  551. activities.push({ module: '成片管理', action: '页面编辑标题、简介、文件夹、分类、标签和可见范围', result: 'PASS', resourceId: videoId })
  552. await captureScreenshot(page, testInfo, '10-video-metadata-folder-visibility-updated')
  553. await detail.getByRole('button', { name: '创建分享链接', exact: true }).click()
  554. dialog = page.locator('.el-dialog:visible').last()
  555. await dialog.getByLabel('有效时长(小时)').fill('1')
  556. await dialog.getByLabel('访问密码(可选)').fill(sharePassword)
  557. await dialog.getByLabel('最大访问次数').fill('5')
  558. await dialog.getByRole('button', { name: '生成分享链接', exact: true }).click()
  559. await expect(dialog.getByText('分享链接已生成')).toBeVisible()
  560. await expect(dialog).toContainText('最多 5 次访问')
  561. const firstShareInput = dialog.getByLabel('本次视频分享链接')
  562. const firstShareUrl = await firstShareInput.inputValue()
  563. expect(new URL(firstShareUrl).pathname).toMatch(/^\/share\/videos\/[^/]+$/)
  564. await captureScreenshot(page, testInfo, '11-video-share-password-limit-issued', [firstShareInput])
  565. await dialog.getByRole('button', { name: '关闭', exact: true }).click()
  566. const sharedPage = await context.newPage()
  567. await sharedPage.goto(firstShareUrl)
  568. await expect(sharedPage.getByText('视频需要验证或链接暂不可用')).toBeVisible()
  569. await sharedPage.getByLabel('访问密码').fill(sharePassword)
  570. await sharedPage.getByRole('button', { name: '验证并播放' }).click()
  571. await expect(sharedPage.getByRole('heading', { name: editedVideoName })).toBeVisible()
  572. await expect(sharedPage.locator('video')).toBeVisible()
  573. await captureScreenshot(sharedPage, testInfo, '12-password-protected-share-verified')
  574. await sharedPage.close()
  575. activities.push({ module: '成片管理', action: '页面签发密码与限次分享并在公开页验证', result: 'PASS', resourceId: videoId })
  576. await detail.getByRole('button', { name: '轮换分享链接', exact: true }).click()
  577. dialog = page.locator('.el-dialog:visible').last()
  578. await dialog.getByLabel('有效时长(小时)').fill('2')
  579. await dialog.getByLabel('访问密码(可选)').fill(rotatedPassword)
  580. await dialog.getByLabel('最大访问次数').fill('3')
  581. await dialog.getByRole('button', { name: '确认轮换', exact: true }).click()
  582. await expect(dialog.getByText('分享链接已生成')).toBeVisible()
  583. const rotatedShareInput = dialog.getByLabel('本次视频分享链接')
  584. const rotatedShareUrl = await rotatedShareInput.inputValue()
  585. expect(rotatedShareUrl).not.toBe(firstShareUrl)
  586. await captureScreenshot(page, testInfo, '13-video-share-rotated', [rotatedShareInput])
  587. await dialog.getByRole('button', { name: '关闭', exact: true }).click()
  588. const revokedOldSharePage = await context.newPage()
  589. await expectPublicShareRejected(revokedOldSharePage, firstShareUrl)
  590. await revokedOldSharePage.close()
  591. await detail.getByRole('button', { name: '停止分享', exact: true }).click()
  592. await confirmMessageBox(page, '停止分享')
  593. await expect(page.getByText('分享已停止').last()).toBeVisible()
  594. persistedVideo = await videoDetail(request, headers, videoId)
  595. expect(persistedVideo.data.shareStatus).not.toBe('active')
  596. const revokedRotatedSharePage = await context.newPage()
  597. await expectPublicShareRejected(revokedRotatedSharePage, rotatedShareUrl)
  598. await revokedRotatedSharePage.close()
  599. activities.push({ module: '成片管理', action: '页面轮换与停止分享,新旧链接均按预期失效', result: 'PASS', resourceId: videoId })
  600. await captureScreenshot(page, testInfo, '14-video-share-stopped')
  601. await detail.getByRole('button', { name: '关闭' }).click()
  602. folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName })
  603. await folderArticle.getByRole('button', { name: '删除文件夹' }).click()
  604. await confirmMessageBox(page, '确认删除')
  605. await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible()
  606. folderId = ''
  607. await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName)
  608. await chooseElSelect(page, '视频分类', '全部分类')
  609. await chooseElSelect(page, '可见范围', '内网分享链接')
  610. await page.getByRole('button', { name: '搜索', exact: true }).click()
  611. card = page.locator('.video-library-card').filter({ hasText: editedVideoName })
  612. await expect(card).toBeVisible()
  613. await card.getByRole('button', { name: '查看详情', exact: true }).click()
  614. detail = page.locator('.video-detail-overlay')
  615. await expect(detail).toContainText('未分类')
  616. persistedVideo = await videoDetail(request, headers, videoId)
  617. expect(persistedVideo.data.folderId).toBeNull()
  618. activities.push({ module: '成片管理', action: '页面删除含成片文件夹并核验成片移至未分类', result: 'PASS', resourceId: videoId })
  619. await captureScreenshot(page, testInfo, '15-video-folder-deleted-video-unfiled')
  620. await detail.getByRole('button', { name: '删除', exact: true }).click()
  621. await confirmMessageBox(page, '确认删除')
  622. await expect(page.getByText('视频资源已删除').last()).toBeVisible()
  623. await expect(page.locator('.video-detail-overlay')).toHaveCount(0)
  624. const deletedVideo = await videoDetail(request, headers, videoId)
  625. expect(deletedVideo.response.status()).toBe(404)
  626. activities.push({ module: '成片管理', action: '页面永久删除成片与关联文件', result: 'PASS', resourceId: videoId })
  627. videoId = ''
  628. await captureScreenshot(page, testInfo, '16-generated-video-deleted-baseline-remains')
  629. } catch (error) {
  630. primaryError = error
  631. } finally {
  632. if (!videoId) {
  633. const leakedVideo = await findByKeyword(request, headers, '/api/v1/videos', runPrefix, editedVideoName).catch(() => null)
  634. || await findByKeyword(request, headers, '/api/v1/videos', runPrefix, projectName).catch(() => null)
  635. videoId = String(leakedVideo?.id ?? '')
  636. }
  637. if (!folderId) {
  638. const leakedFolder = await findFolder(request, headers, renamedFolderName).catch(() => null)
  639. || await findFolder(request, headers, folderName).catch(() => null)
  640. folderId = String(leakedFolder?.id ?? '')
  641. }
  642. if (!projectId) {
  643. const urlProjectId = new URL(page.url()).searchParams.get('project') || ''
  644. const leakedProject = urlProjectId
  645. ? null
  646. : await findByKeyword(request, headers, '/api/v1/video-projects', runPrefix, projectName).catch(() => null)
  647. projectId = urlProjectId || String(leakedProject?.id ?? '')
  648. }
  649. if (videoId) await cleanupVideo(request, headers, videoId, activities, cleanupErrors)
  650. if (folderId) await cleanupFolder(request, headers, folderId, activities, cleanupErrors)
  651. if (projectId) await cleanupProject(request, headers, projectId, activities, cleanupErrors)
  652. await attachJson(testInfo, '视频制作与成片管理-UI生命周期', {
  653. runPrefix,
  654. activities,
  655. cleanupComplete: cleanupErrors.length === 0,
  656. cleanupErrors,
  657. security: '分享密码和一次性分享令牌未写入附件,包含令牌的截图区域已遮罩。',
  658. })
  659. }
  660. if (primaryError) throw primaryError
  661. expect(cleanupErrors, '视频 UI 生命周期测试数据必须清理干净').toEqual([])
  662. })