Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

469 linhas
28 KiB

  1. import type { APIRequestContext, Locator, 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 selectFirstEnabledOption(select: Locator, preferredLabel: RegExp) {
  101. const options = await select.locator('option').evaluateAll((nodes) => nodes.map((node) => ({
  102. value: (node as HTMLOptionElement).value,
  103. label: (node.textContent || '').trim(),
  104. disabled: (node as HTMLOptionElement).disabled,
  105. })))
  106. const selected = options.find((item) => !item.disabled && item.value && preferredLabel.test(item.label))
  107. || options.find((item) => !item.disabled && item.value)
  108. expect(selected, '页面必须加载至少一个可选的真实语音能力').toBeTruthy()
  109. await select.selectOption(selected!.value)
  110. return selected!
  111. }
  112. async function expectPublicShareRejected(page: Page, url: string) {
  113. await page.goto(url)
  114. await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible()
  115. }
  116. test.describe.configure({ mode: 'serial' })
  117. // 成片为 H.264/AAC MP4;Playwright 自带的开源 Chromium 不包含完整专利编解码器。
  118. // 本用例用系统 Chrome 完成真实播放验收,其余 E2E 仍使用项目 Chromium。
  119. test.use({ trace: 'off', video: 'off', channel: 'chrome' })
  120. test('视频制作与成片管理全部通过页面提交并真实生成成片', async ({ page, context, request }, testInfo: TestInfo) => {
  121. test.setTimeout(15 * 60_000)
  122. const activities: Activity[] = []
  123. const cleanupErrors: string[] = []
  124. const headers = await authHeaders(request)
  125. const projectName = `${runPrefix}-液压安全口播`
  126. const editedVideoName = `${runPrefix}-液压安全成片`
  127. const folderName = `${runPrefix}-视频测试文件夹`
  128. const renamedFolderName = `${runPrefix}-安全培训成片`
  129. const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。'
  130. const materialName = `${runPrefix}-检修要点.png`
  131. const sharePassword = 'Ape2eVideo#2026'
  132. const rotatedPassword = 'Ape2eRotate#2026'
  133. let projectId = ''
  134. let videoId = ''
  135. let folderId = ''
  136. let primaryError: unknown
  137. try {
  138. await loginAsAdmin(page)
  139. await page.goto('/videos/create?mode=offline')
  140. await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
  141. await expect(page.locator('.studio-avatar-list button:not([disabled])').first()).toBeVisible()
  142. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 })
  143. await captureScreenshot(page, testInfo, '01-video-create-nonempty-assets')
  144. await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()
  145. const avatar = page.locator('.studio-avatar-list button:not([disabled])').first()
  146. const avatarName = (await avatar.locator('b').textContent() || '').trim()
  147. expect(avatarName, '必须从页面选择真实已就绪形象').not.toBe('')
  148. await avatar.click()
  149. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  150. const voiceCard = page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).first()
  151. await expect(voiceCard).toBeVisible()
  152. await voiceCard.locator('label').click()
  153. await expect(voiceCard.locator('input[type="radio"]')).toBeChecked()
  154. await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
  155. await page.getByRole('button', { name: '实训教室', exact: true }).click()
  156. await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
  157. await page.getByRole('button', { name: /检查标记/ }).click()
  158. await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible()
  159. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  160. await page.locator('.media-library input[type="file"]').setInputFiles({
  161. name: materialName,
  162. mimeType: 'image/png',
  163. buffer: MATERIAL_PNG,
  164. })
  165. await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
  166. await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()
  167. await page.getByRole('button', { name: '文本', exact: true }).last().click()
  168. await page.getByLabel('项目名称').fill(projectName)
  169. await page.locator('.script-field textarea').fill(scriptText)
  170. const capabilitySelect = page.getByLabel('语音能力资产')
  171. const selectedCapability = await selectFirstEnabledOption(capabilitySelect, /演示实训播报语音/)
  172. await page.getByLabel('字幕', { exact: true }).check()
  173. await page.getByRole('button', { name: '字幕样式', exact: true }).click()
  174. await page.getByLabel('字体').selectOption('Source Han Sans SC')
  175. await page.getByLabel('字号').selectOption('48')
  176. await page.getByLabel('字幕位置').selectOption('center')
  177. await captureScreenshot(page, testInfo, '02-video-project-ui-configured')
  178. await page.getByRole('button', { name: '保存工程', exact: true }).click()
  179. await expect(page.getByText('工程与四轨时间线已保存').last()).toBeVisible({ timeout: 60_000 })
  180. await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
  181. projectId = new URL(page.url()).searchParams.get('project') || ''
  182. expect(projectId).not.toBe('')
  183. let persistedProject = await projectDetail(request, headers, projectId)
  184. expect(persistedProject.response.status()).toBe(200)
  185. expect(persistedProject.data.name).toBe(projectName)
  186. expect(persistedProject.data.scriptText).toBe(scriptText)
  187. expect(persistedProject.data.orientation).toBe('landscape')
  188. expect(persistedProject.data.backgroundType).toBe('training')
  189. expect(persistedProject.data.subtitleEnabled).toBe(true)
  190. expect(persistedProject.data.subtitleFont).toBe('Source Han Sans SC')
  191. expect(persistedProject.data.subtitleFontSize).toBe(48)
  192. expect(persistedProject.data.subtitlePosition).toBe('center')
  193. expect(String(persistedProject.data.avatarId ?? '')).not.toBe('')
  194. expect(String(persistedProject.data.voiceCapabilityId ?? '')).not.toBe('')
  195. const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
  196. expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
  197. const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
  198. expect(mediaClips.some((clip) => clip.type === 'MEDIA' && String(clip.fileId ?? ''))).toBe(true)
  199. expect(mediaClips.some((clip) => clip.type === 'CHECK')).toBe(true)
  200. const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
  201. expect(asRecord(audioClip.style).speaker).toBe(2)
  202. expect(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? ''))
  203. expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)[0]?.text).toBe(scriptText)
  204. activities.push({ module: '视频制作', action: '页面保存横屏工程、真实形象音色、素材、元素、字幕和四轨时间线', result: 'PASS', resourceId: projectId })
  205. await captureScreenshot(page, testInfo, '03-video-project-saved-four-tracks')
  206. await page.reload()
  207. await expect(page.getByText('工程已保存', { exact: true })).toBeVisible({ timeout: 30_000 })
  208. await expect(page.getByLabel('项目名称')).toHaveValue(projectName)
  209. await expect(page.locator('.script-field textarea')).toHaveValue(scriptText)
  210. await expect(page.getByRole('button', { name: /横屏(1920 × 1080)/ })).toHaveClass(/active/)
  211. await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
  212. await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
  213. await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible()
  214. await expect(page.getByLabel('语音能力资产')).toHaveValue(selectedCapability.value)
  215. await expect(page.locator('.timeline-clip.media')).toContainText(materialName)
  216. await expect(page.locator('.timeline-clip.subtitle')).toContainText(scriptText)
  217. await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
  218. await expect(page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).locator('input[type="radio"]')).toBeChecked()
  219. activities.push({ module: '视频制作', action: '刷新 project 深链并核验全部配置持久化', result: 'PASS', resourceId: projectId })
  220. await captureScreenshot(page, testInfo, '04-video-project-deeplink-persisted')
  221. await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 })
  222. await page.getByRole('button', { name: '生成视频', exact: true }).click()
  223. await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 })
  224. await captureScreenshot(page, testInfo, '05-real-video-render-running')
  225. const succeeded = page.locator('.editor-render-progress.is-succeeded')
  226. await expect(succeeded).toContainText('视频生成完成', { timeout: 8 * 60_000 })
  227. await expect(succeeded).toContainText('100%')
  228. activities.push({ module: '视频制作', action: '页面发起真实 TTS、浏览器录制、FFmpeg 合成与成片归档', result: 'PASS', resourceId: projectId })
  229. await captureScreenshot(page, testInfo, '06-real-video-render-succeeded')
  230. await succeeded.getByRole('button', { name: '查看作品' }).click()
  231. await expect(page).toHaveURL(/\/videos\/manage\?video=/)
  232. videoId = new URL(page.url()).searchParams.get('video') || ''
  233. expect(videoId).not.toBe('')
  234. let detail = page.locator('.video-detail-overlay')
  235. await expect(detail).toBeVisible({ timeout: 30_000 })
  236. await expect(detail).toContainText(projectName)
  237. const video = detail.locator('video')
  238. await expect(video).toHaveAttribute('src', /.+/)
  239. await detail.getByRole('button', { name: '播放视频', exact: true }).click()
  240. await expect(detail.getByRole('button', { name: '暂停视频', exact: true })).toBeVisible()
  241. await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true)
  242. await detail.getByRole('button', { name: '暂停视频', exact: true }).click()
  243. await expect(detail.getByRole('button', { name: '播放视频', exact: true })).toBeVisible()
  244. const downloadPromise = page.waitForEvent('download', { timeout: 60_000 })
  245. await detail.getByRole('button', { name: '下载视频', exact: true }).click()
  246. const download = await downloadPromise
  247. expect(download.suggestedFilename()).toContain(projectName)
  248. expect(await download.path()).not.toBeNull()
  249. let persistedVideo = await videoDetail(request, headers, videoId)
  250. expect(persistedVideo.response.status()).toBe(200)
  251. expect(persistedVideo.data.status).toBe('ready')
  252. expect(String(persistedVideo.data.videoFileCode ?? '')).not.toBe('')
  253. expect(Number(persistedVideo.data.size ?? 0)).toBeGreaterThan(10_000)
  254. persistedProject = await projectDetail(request, headers, projectId)
  255. expect(persistedProject.data.lastVideoId).toBe(videoId)
  256. activities.push({ module: '成片管理', action: '页面打开生成成片详情并实际播放、下载', result: 'PASS', resourceId: videoId })
  257. await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded')
  258. await detail.getByRole('button', { name: '关闭' }).click()
  259. await page.locator('.folder-create-button').click()
  260. let dialog = page.locator('.el-dialog:visible').last()
  261. await dialog.getByLabel('文件夹名称').fill(folderName)
  262. const createFolderResponsePromise = page.waitForResponse((response) => (
  263. response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/video-folders'
  264. ))
  265. const saveFolderButton = dialog.getByRole('button', { name: '保存文件夹', exact: true })
  266. await expect(saveFolderButton).toBeEnabled()
  267. await saveFolderButton.click()
  268. const createFolderResponse = await createFolderResponsePromise
  269. expect(
  270. createFolderResponse.status(),
  271. `页面新建文件夹请求失败:${await createFolderResponse.text()}`,
  272. ).toBe(201)
  273. await expect(page.getByText('文件夹已创建').last()).toBeVisible()
  274. let folderArticle = page.locator('.video-folder-strip article').filter({ hasText: folderName })
  275. await expect(folderArticle).toBeVisible()
  276. let folder = await findFolder(request, headers, folderName)
  277. expect(folder).not.toBeNull()
  278. folderId = String(folder?.id ?? '')
  279. activities.push({ module: '成片管理', action: '页面新建成片文件夹', result: 'PASS', resourceId: folderId })
  280. await captureScreenshot(page, testInfo, '08-video-folder-created')
  281. await folderArticle.getByRole('button', { name: '重命名文件夹' }).click()
  282. dialog = page.locator('.el-dialog:visible').last()
  283. await dialog.getByLabel('文件夹名称').fill(renamedFolderName)
  284. await dialog.getByRole('button', { name: '保存文件夹', exact: true }).click()
  285. await expect(page.getByText('文件夹已更新').last()).toBeVisible()
  286. folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName })
  287. await expect(folderArticle).toBeVisible()
  288. await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName)
  289. await page.getByRole('button', { name: '搜索', exact: true }).click()
  290. await page.getByLabel('分类').selectOption({ label: '口播视频' })
  291. await page.getByLabel('可见范围').selectOption('private')
  292. await page.getByLabel('归属').selectOption('mine')
  293. await page.getByLabel('排序').selectOption('title')
  294. await page.locator('.sort-direction').click()
  295. let card = page.locator('.video-library-card').filter({ hasText: projectName })
  296. await expect(card).toBeVisible()
  297. activities.push({ module: '成片管理', action: '页面重命名文件夹并组合搜索、分类、可见范围、归属与排序', result: 'PASS', resourceId: folderId })
  298. await captureScreenshot(page, testInfo, '09-video-folder-renamed-and-filtered')
  299. await card.getByRole('button', { name: '查看详情', exact: true }).click()
  300. detail = page.locator('.video-detail-overlay')
  301. await detail.getByRole('button', { name: '编辑', exact: true }).click()
  302. await detail.getByLabel('标题').fill(editedVideoName)
  303. await detail.getByLabel('简介').fill('液压系统检修前安全确认与上锁挂牌教学成片。')
  304. await detail.getByLabel('所在文件夹').selectOption({ label: renamedFolderName })
  305. await detail.getByRole('textbox', { name: '分类', exact: true }).fill('安全培训')
  306. await detail.getByLabel('标签').fill('液压系统、上锁挂牌、APE2E')
  307. await detail.getByLabel('可见范围').selectOption('public')
  308. await detail.getByRole('button', { name: '保存修改', exact: true }).click()
  309. await expect(page.getByText('视频资料已更新').last()).toBeVisible()
  310. await expect(detail).toContainText(editedVideoName)
  311. await expect(detail).toContainText(renamedFolderName)
  312. await expect(detail).toContainText('内网分享链接')
  313. persistedVideo = await videoDetail(request, headers, videoId)
  314. expect(persistedVideo.data.title).toBe(editedVideoName)
  315. expect(persistedVideo.data.folderId).toBe(folderId)
  316. expect(persistedVideo.data.visibility).toBe('public')
  317. expect(persistedVideo.data.tags).toEqual(['液压系统', '上锁挂牌', 'APE2E'])
  318. activities.push({ module: '成片管理', action: '页面编辑标题、简介、文件夹、分类、标签和可见范围', result: 'PASS', resourceId: videoId })
  319. await captureScreenshot(page, testInfo, '10-video-metadata-folder-visibility-updated')
  320. await detail.getByRole('button', { name: '创建分享链接', exact: true }).click()
  321. dialog = page.locator('.el-dialog:visible').last()
  322. await dialog.getByLabel('有效时长(小时)').fill('1')
  323. await dialog.getByLabel('访问密码(可选)').fill(sharePassword)
  324. await dialog.getByLabel('最大访问次数').fill('5')
  325. await dialog.getByRole('button', { name: '生成分享链接', exact: true }).click()
  326. await expect(dialog.getByText('分享链接已生成')).toBeVisible()
  327. await expect(dialog).toContainText('最多 5 次访问')
  328. const firstShareInput = dialog.getByLabel('本次视频分享链接')
  329. const firstShareUrl = await firstShareInput.inputValue()
  330. expect(new URL(firstShareUrl).pathname).toMatch(/^\/share\/videos\/[^/]+$/)
  331. await captureScreenshot(page, testInfo, '11-video-share-password-limit-issued', [firstShareInput])
  332. await dialog.getByRole('button', { name: '关闭', exact: true }).click()
  333. const sharedPage = await context.newPage()
  334. await sharedPage.goto(firstShareUrl)
  335. await expect(sharedPage.getByText('视频需要验证或链接暂不可用')).toBeVisible()
  336. await sharedPage.getByLabel('访问密码').fill(sharePassword)
  337. await sharedPage.getByRole('button', { name: '验证并播放' }).click()
  338. await expect(sharedPage.getByRole('heading', { name: editedVideoName })).toBeVisible()
  339. await expect(sharedPage.locator('video')).toBeVisible()
  340. await captureScreenshot(sharedPage, testInfo, '12-password-protected-share-verified')
  341. await sharedPage.close()
  342. activities.push({ module: '成片管理', action: '页面签发密码与限次分享并在公开页验证', result: 'PASS', resourceId: videoId })
  343. await detail.getByRole('button', { name: '轮换分享链接', exact: true }).click()
  344. dialog = page.locator('.el-dialog:visible').last()
  345. await dialog.getByLabel('有效时长(小时)').fill('2')
  346. await dialog.getByLabel('访问密码(可选)').fill(rotatedPassword)
  347. await dialog.getByLabel('最大访问次数').fill('3')
  348. await dialog.getByRole('button', { name: '确认轮换', exact: true }).click()
  349. await expect(dialog.getByText('分享链接已生成')).toBeVisible()
  350. const rotatedShareInput = dialog.getByLabel('本次视频分享链接')
  351. const rotatedShareUrl = await rotatedShareInput.inputValue()
  352. expect(rotatedShareUrl).not.toBe(firstShareUrl)
  353. await captureScreenshot(page, testInfo, '13-video-share-rotated', [rotatedShareInput])
  354. await dialog.getByRole('button', { name: '关闭', exact: true }).click()
  355. const revokedOldSharePage = await context.newPage()
  356. await expectPublicShareRejected(revokedOldSharePage, firstShareUrl)
  357. await revokedOldSharePage.close()
  358. await detail.getByRole('button', { name: '停止分享', exact: true }).click()
  359. await confirmMessageBox(page, '停止分享')
  360. await expect(page.getByText('分享已停止').last()).toBeVisible()
  361. persistedVideo = await videoDetail(request, headers, videoId)
  362. expect(persistedVideo.data.shareStatus).not.toBe('active')
  363. const revokedRotatedSharePage = await context.newPage()
  364. await expectPublicShareRejected(revokedRotatedSharePage, rotatedShareUrl)
  365. await revokedRotatedSharePage.close()
  366. activities.push({ module: '成片管理', action: '页面轮换与停止分享,新旧链接均按预期失效', result: 'PASS', resourceId: videoId })
  367. await captureScreenshot(page, testInfo, '14-video-share-stopped')
  368. await detail.getByRole('button', { name: '关闭' }).click()
  369. folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName })
  370. await folderArticle.getByRole('button', { name: '删除文件夹' }).click()
  371. await confirmMessageBox(page, '确认删除')
  372. await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible()
  373. folderId = ''
  374. await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName)
  375. await page.getByLabel('分类').selectOption('')
  376. await page.getByLabel('可见范围').selectOption('public')
  377. await page.getByRole('button', { name: '搜索', exact: true }).click()
  378. card = page.locator('.video-library-card').filter({ hasText: editedVideoName })
  379. await expect(card).toBeVisible()
  380. await card.getByRole('button', { name: '查看详情', exact: true }).click()
  381. detail = page.locator('.video-detail-overlay')
  382. await expect(detail).toContainText('未分类')
  383. persistedVideo = await videoDetail(request, headers, videoId)
  384. expect(persistedVideo.data.folderId).toBeNull()
  385. activities.push({ module: '成片管理', action: '页面删除含成片文件夹并核验成片移至未分类', result: 'PASS', resourceId: videoId })
  386. await captureScreenshot(page, testInfo, '15-video-folder-deleted-video-unfiled')
  387. await detail.getByRole('button', { name: '删除', exact: true }).click()
  388. await confirmMessageBox(page, '确认删除')
  389. await expect(page.getByText('视频资源已删除').last()).toBeVisible()
  390. await expect(page.locator('.video-detail-overlay')).toHaveCount(0)
  391. const deletedVideo = await videoDetail(request, headers, videoId)
  392. expect(deletedVideo.response.status()).toBe(404)
  393. activities.push({ module: '成片管理', action: '页面永久删除成片与关联文件', result: 'PASS', resourceId: videoId })
  394. videoId = ''
  395. await captureScreenshot(page, testInfo, '16-generated-video-deleted-baseline-remains')
  396. } catch (error) {
  397. primaryError = error
  398. } finally {
  399. if (!videoId) {
  400. const leakedVideo = await findByKeyword(request, headers, '/api/v1/videos', runPrefix, editedVideoName).catch(() => null)
  401. || await findByKeyword(request, headers, '/api/v1/videos', runPrefix, projectName).catch(() => null)
  402. videoId = String(leakedVideo?.id ?? '')
  403. }
  404. if (!folderId) {
  405. const leakedFolder = await findFolder(request, headers, renamedFolderName).catch(() => null)
  406. || await findFolder(request, headers, folderName).catch(() => null)
  407. folderId = String(leakedFolder?.id ?? '')
  408. }
  409. if (!projectId) {
  410. const urlProjectId = new URL(page.url()).searchParams.get('project') || ''
  411. const leakedProject = urlProjectId
  412. ? null
  413. : await findByKeyword(request, headers, '/api/v1/video-projects', runPrefix, projectName).catch(() => null)
  414. projectId = urlProjectId || String(leakedProject?.id ?? '')
  415. }
  416. if (videoId) await cleanupVideo(request, headers, videoId, activities, cleanupErrors)
  417. if (folderId) await cleanupFolder(request, headers, folderId, activities, cleanupErrors)
  418. if (projectId) await cleanupProject(request, headers, projectId, activities, cleanupErrors)
  419. await attachJson(testInfo, '视频制作与成片管理-UI生命周期', {
  420. runPrefix,
  421. activities,
  422. cleanupComplete: cleanupErrors.length === 0,
  423. cleanupErrors,
  424. security: '分享密码和一次性分享令牌未写入附件,包含令牌的截图区域已遮罩。',
  425. })
  426. }
  427. if (primaryError) throw primaryError
  428. expect(cleanupErrors, '视频 UI 生命周期测试数据必须清理干净').toEqual([])
  429. })