Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

271 строка
13 KiB

  1. import type { Page } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, test } from './fixtures'
  3. import { loginAsAdmin } from './helpers'
  4. import { expectRouteHasNonEmptyData, hasNonEmptyAcceptance } from './nonempty'
  5. interface RouteCase {
  6. path: string
  7. heading: string
  8. screenshot: string
  9. placeholder?: boolean
  10. }
  11. const adminRoutes: RouteCase[] = [
  12. { path: '/overview', heading: '实时数字人智能体', screenshot: 'overview' },
  13. { path: '/agents/manage', heading: '虚拟教员智能体', screenshot: 'agents' },
  14. { path: '/agents/projects', heading: '智能体组别管理', screenshot: 'agent-projects' },
  15. { path: '/agents/chat-history', heading: '会话记录', screenshot: 'chat-history' },
  16. { path: '/agents/wake-words', heading: '唤醒词库', screenshot: 'wake-words' },
  17. { path: '/agents/remote-control', heading: '远程控制', screenshot: 'remote-control' },
  18. { path: '/videos/create?mode=offline', heading: '视频制作', screenshot: 'video-create' },
  19. { path: '/videos/manage', heading: '成片管理', screenshot: 'videos' },
  20. { path: '/assets/avatars', heading: '数字形象', screenshot: 'avatars' },
  21. { path: '/assets/voice-clones', heading: '声音克隆', screenshot: 'voice-clones' },
  22. { path: '/assets/ai-models', heading: 'AI模型', screenshot: 'ai-models' },
  23. { path: '/assets/voice-models', heading: '语音模型', screenshot: 'voice-models' },
  24. { path: '/assets/asr-models', heading: 'ASR模型', screenshot: 'asr-models' },
  25. { path: '/assets/scene-materials', heading: '场景素材', screenshot: 'scene-materials' },
  26. { path: '/knowledge/library', heading: '维修知识', screenshot: 'knowledge' },
  27. { path: '/knowledge/sensitive-words', heading: '敏感词库', screenshot: 'sensitive-words' },
  28. { path: '/knowledge/hot-words', heading: '热词管理', screenshot: 'hot-words' },
  29. { path: '/jobs', heading: '制作队列', screenshot: 'jobs' },
  30. { path: '/tools', heading: '工具管理', screenshot: 'tools' },
  31. { path: '/organization/users', heading: '用户管理', screenshot: 'users' },
  32. { path: '/organization/departments', heading: '部门管理', screenshot: 'departments' },
  33. { path: '/organization/roles', heading: '角色管理', screenshot: 'roles' },
  34. { path: '/organization/permissions', heading: '权限管理', screenshot: 'permissions' },
  35. { path: '/settings/general', heading: '基础设置', screenshot: 'settings-general' },
  36. { path: '/settings/integrations', heading: '三方配置', screenshot: 'settings-integrations' },
  37. { path: '/settings/menus', heading: '菜单管理', screenshot: 'settings-menus' },
  38. { path: '/profile', heading: '个人中心', screenshot: 'profile' },
  39. { path: '/courses', heading: '课程管理', screenshot: 'placeholder-courses', placeholder: true },
  40. { path: '/equipment', heading: '装备空间', screenshot: 'placeholder-equipment', placeholder: true },
  41. { path: '/maintenance', heading: '维修任务', screenshot: 'placeholder-maintenance', placeholder: true },
  42. ]
  43. interface BrowserIssueState {
  44. consoleErrors: string[]
  45. pageErrors: string[]
  46. serverErrors: string[]
  47. }
  48. function observeBrowserIssues(page: Page): BrowserIssueState {
  49. const state: BrowserIssueState = { consoleErrors: [], pageErrors: [], serverErrors: [] }
  50. page.on('console', (message) => {
  51. if (message.type() === 'error') state.consoleErrors.push(message.text())
  52. })
  53. page.on('pageerror', (error) => state.pageErrors.push(error.message))
  54. page.on('response', (response) => {
  55. const url = new URL(response.url())
  56. if (url.origin === new URL(page.url() || 'http://127.0.0.1').origin
  57. && url.pathname.startsWith('/api/') && response.status() >= 500) {
  58. state.serverErrors.push(`${response.status()} ${url.pathname}`)
  59. }
  60. })
  61. return state
  62. }
  63. function takeIssues(state: BrowserIssueState) {
  64. const result = {
  65. consoleErrors: state.consoleErrors.splice(0),
  66. pageErrors: state.pageErrors.splice(0),
  67. serverErrors: state.serverErrors.splice(0),
  68. }
  69. return result
  70. }
  71. async function expectNoDocumentOverflow(page: Page) {
  72. const dimensions = await page.evaluate(() => ({
  73. viewport: document.documentElement.clientWidth,
  74. document: document.documentElement.scrollWidth,
  75. }))
  76. expect.soft(dimensions.document, `页面产生横向溢出:${dimensions.document}px > ${dimensions.viewport}px`).toBeLessThanOrEqual(dimensions.viewport + 1)
  77. }
  78. async function expectControlsWithinContainer(page: Page, containerSelector: string, controlsSelector: string, label: string) {
  79. const clippedControls = await page.locator(containerSelector).evaluate((container, selector) => {
  80. const boundary = container.getBoundingClientRect()
  81. return [...container.querySelectorAll<HTMLElement>(selector)]
  82. .filter((element) => {
  83. const style = getComputedStyle(element)
  84. return style.display !== 'none' && style.visibility !== 'hidden'
  85. })
  86. .flatMap((element) => {
  87. const rect = element.getBoundingClientRect()
  88. const clipped = rect.left < boundary.left - 1
  89. || rect.right > boundary.right + 1
  90. || rect.top < boundary.top - 1
  91. || rect.bottom > boundary.bottom + 1
  92. return clipped ? [{
  93. control: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 40) || element.tagName,
  94. boundary: [Math.round(boundary.left), Math.round(boundary.top), Math.round(boundary.right), Math.round(boundary.bottom)],
  95. rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.right), Math.round(rect.bottom)],
  96. }] : []
  97. })
  98. }, controlsSelector)
  99. expect.soft(clippedControls, `${label}存在被容器裁切的关键控件`).toEqual([])
  100. }
  101. async function openAndCaptureRoute(
  102. page: Page,
  103. route: RouteCase,
  104. viewportLabel: string,
  105. testInfo: Parameters<typeof captureScreenshot>[1],
  106. issues: BrowserIssueState,
  107. ) {
  108. takeIssues(issues)
  109. await page.goto(route.path)
  110. await expect(page).toHaveURL(new RegExp(`${route.path.split('?')[0]!.replaceAll('/', '\\/')}(?:\\?|$)`))
  111. await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
  112. const loadingOverlays = page.locator('.el-loading-mask:visible, .management-loading:visible, .overview-loading:visible, .library-loading:visible')
  113. const textLoadingStates = page
  114. .locator(':is(.empty-state, .agent-placeholder, .agent-detail-placeholder):visible')
  115. .filter({ hasText: /正在(?:读取|加载|汇总)/ })
  116. await expect(loadingOverlays).toHaveCount(0, { timeout: 20_000 })
  117. await expect(textLoadingStates).toHaveCount(0, { timeout: 20_000 })
  118. // 菜单树由页面挂载后的接口异步加载;必须等真实行出现,不能把初始 0 节点空态当成页面完成。
  119. if (route.screenshot === 'settings-menus') {
  120. await expect(page.locator('.menu-name-cell').first()).toBeVisible({ timeout: 20_000 })
  121. }
  122. if (route.screenshot === 'knowledge') {
  123. // 持久演示库包含部门共享与个人库;切到“用户自建”后再验收、截图,
  124. // 避免只拍平台内置库而漏掉 DEMO manifest 对应的真实业务数据。
  125. const userBuiltTab = page.getByRole('tab', { name: '用户自建' })
  126. await userBuiltTab.focus()
  127. // 移动端固定顶栏会覆盖页签的指针命中区域;使用原生按钮的键盘激活路径,
  128. // 仍然触发页面真实交互与数据切换,并同时验证该页签具备可访问性。
  129. await userBuiltTab.press('Enter')
  130. await expect(userBuiltTab).toHaveAttribute('aria-selected', 'true')
  131. }
  132. if (route.placeholder) {
  133. await expect(page.getByText('业务说明', { exact: true })).toBeVisible()
  134. await expect(page.getByText('本页面用于展示相关业务范围,具体数据、操作内容和可见范围以实际业务配置及账号权限为准。')).toBeVisible()
  135. await expect(page.locator('.domain-card')).toHaveCount(3)
  136. await expect(page.locator('form, .primary-button')).toHaveCount(0)
  137. } else {
  138. expect(hasNonEmptyAcceptance(route.screenshot), `${route.path} 必须配置非空页面验收规则`).toBeTruthy()
  139. const evidence = await expectRouteHasNonEmptyData(page, route.screenshot)
  140. await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-nonempty-evidence`, evidence)
  141. }
  142. // 数据出现后再次确认所有页面自定义 loading 均已结束,保证截图不会记录
  143. // “正在读取/加载”与真实列表短暂共存的过渡帧。
  144. await expect(loadingOverlays).toHaveCount(0)
  145. await expect(textLoadingStates).toHaveCount(0)
  146. await expectNoDocumentOverflow(page)
  147. await page.waitForTimeout(150)
  148. await captureScreenshot(page, testInfo, `${viewportLabel}-${route.screenshot}`)
  149. const routeIssues = takeIssues(issues)
  150. await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-browser-issues`, routeIssues)
  151. expect.soft(routeIssues.pageErrors, `${route.path} 发生 pageerror`).toEqual([])
  152. expect.soft(routeIssues.consoleErrors, `${route.path} 发生 console.error`).toEqual([])
  153. expect.soft(routeIssues.serverErrors, `${route.path} 出现 API 5xx`).toEqual([])
  154. }
  155. test('管理员桌面端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => {
  156. test.setTimeout(480_000)
  157. await page.setViewportSize({ width: 1920, height: 1080 })
  158. const issues = observeBrowserIssues(page)
  159. await loginAsAdmin(page)
  160. for (const route of adminRoutes) {
  161. await test.step(route.path, async () => {
  162. await openAndCaptureRoute(page, route, 'desktop', testInfo, issues)
  163. })
  164. }
  165. })
  166. test('管理员移动端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => {
  167. test.setTimeout(480_000)
  168. await page.setViewportSize({ width: 390, height: 844 })
  169. const issues = observeBrowserIssues(page)
  170. await loginAsAdmin(page)
  171. for (const route of adminRoutes) {
  172. await test.step(route.path, async () => {
  173. await openAndCaptureRoute(page, route, 'mobile', testInfo, issues)
  174. if (route.screenshot === 'video-create') {
  175. await expectControlsWithinContainer(
  176. page,
  177. '.editor-commandbar',
  178. '.editor-format-switch button, .editor-command-actions button',
  179. '移动端视频制作命令栏',
  180. )
  181. }
  182. if (route.screenshot === 'settings-menus') {
  183. await expectControlsWithinContainer(
  184. page,
  185. '.system-page-header',
  186. '.system-page-actions > *',
  187. '移动端菜单管理页头',
  188. )
  189. }
  190. })
  191. }
  192. })
  193. test('明暗主题、桌面侧栏和移动主导航可以实际切换', async ({ page }, testInfo) => {
  194. await page.setViewportSize({ width: 1440, height: 900 })
  195. await loginAsAdmin(page)
  196. await expect(page.locator('#main-sidebar')).toBeVisible()
  197. await expect(page.locator('.mobile-menu-button')).toBeHidden()
  198. await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', /light|dark/)
  199. const themeButton = page.locator('.theme-toggle')
  200. if (await themeButton.count()) {
  201. const initialTheme = await page.locator('.app-shell').getAttribute('data-theme')
  202. await themeButton.click()
  203. await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '')
  204. await captureScreenshot(page, testInfo, 'desktop-theme-toggled')
  205. await page.reload()
  206. await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '')
  207. await themeButton.click()
  208. await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', initialTheme || 'light')
  209. }
  210. await expectNoDocumentOverflow(page)
  211. await page.setViewportSize({ width: 390, height: 844 })
  212. await page.reload()
  213. const mobileMenuButton = page.getByRole('button', { name: '打开主导航' })
  214. await expect(mobileMenuButton).toBeVisible()
  215. await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true')
  216. await mobileMenuButton.click()
  217. await expect(page.locator('#main-sidebar')).toHaveClass(/mobile-open/)
  218. await expect(page.locator('#main-sidebar')).not.toHaveAttribute('aria-hidden', 'true')
  219. await expect(page.locator('.main-panel')).toHaveAttribute('inert', '')
  220. await captureScreenshot(page, testInfo, 'mobile-navigation-open')
  221. await page.keyboard.press('Escape')
  222. await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true')
  223. await expect(mobileMenuButton).toBeFocused()
  224. await expectNoDocumentOverflow(page)
  225. })
  226. test('公开登录页和悬浮接入演示在桌面及移动视口可读', async ({ page }, testInfo) => {
  227. await page.setViewportSize({ width: 1920, height: 1080 })
  228. await page.goto('/login')
  229. await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible()
  230. await expectNoDocumentOverflow(page)
  231. await captureScreenshot(page, testInfo, 'desktop-public-login')
  232. await page.goto('/embed-demo')
  233. await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible()
  234. await expect(page.getByPlaceholder('请输入已发布智能体的 slug')).toBeVisible()
  235. await expectNoDocumentOverflow(page)
  236. await captureScreenshot(page, testInfo, 'desktop-embed-demo')
  237. await page.setViewportSize({ width: 390, height: 844 })
  238. await page.goto('/login')
  239. await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible()
  240. await expectNoDocumentOverflow(page)
  241. await captureScreenshot(page, testInfo, 'mobile-public-login')
  242. await page.goto('/embed-demo')
  243. await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible()
  244. await expectNoDocumentOverflow(page)
  245. await captureScreenshot(page, testInfo, 'mobile-embed-demo')
  246. })