|
- import type { Page } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, test } from './fixtures'
- import { loginAsAdmin } from './helpers'
- import { expectRouteHasNonEmptyData, hasNonEmptyAcceptance } from './nonempty'
-
- interface RouteCase {
- path: string
- heading: string
- screenshot: string
- placeholder?: boolean
- }
-
- const adminRoutes: RouteCase[] = [
- { path: '/overview', heading: '实时数字人智能体', screenshot: 'overview' },
- { path: '/agents/manage', heading: '虚拟教员智能体', screenshot: 'agents' },
- { path: '/agents/projects', heading: '智能体组别管理', screenshot: 'agent-projects' },
- { path: '/agents/chat-history', heading: '会话记录', screenshot: 'chat-history' },
- { path: '/agents/wake-words', heading: '唤醒词库', screenshot: 'wake-words' },
- { path: '/agents/remote-control', heading: '远程控制', screenshot: 'remote-control' },
- { path: '/videos/create?mode=offline', heading: '视频制作', screenshot: 'video-create' },
- { path: '/videos/manage', heading: '成片管理', screenshot: 'videos' },
- { path: '/assets/avatars', heading: '数字形象', screenshot: 'avatars' },
- { path: '/assets/voice-clones', heading: '声音克隆', screenshot: 'voice-clones' },
- { path: '/assets/ai-models', heading: 'AI模型', screenshot: 'ai-models' },
- { path: '/assets/voice-models', heading: '语音模型', screenshot: 'voice-models' },
- { path: '/assets/asr-models', heading: 'ASR模型', screenshot: 'asr-models' },
- { path: '/assets/scene-materials', heading: '场景素材', screenshot: 'scene-materials' },
- { path: '/knowledge/library', heading: '维修知识', screenshot: 'knowledge' },
- { path: '/knowledge/sensitive-words', heading: '敏感词库', screenshot: 'sensitive-words' },
- { path: '/knowledge/hot-words', heading: '热词管理', screenshot: 'hot-words' },
- { path: '/jobs', heading: '制作队列', screenshot: 'jobs' },
- { path: '/tools', heading: '工具管理', screenshot: 'tools' },
- { path: '/organization/users', heading: '用户管理', screenshot: 'users' },
- { path: '/organization/departments', heading: '部门管理', screenshot: 'departments' },
- { path: '/organization/roles', heading: '角色管理', screenshot: 'roles' },
- { path: '/organization/permissions', heading: '权限管理', screenshot: 'permissions' },
- { path: '/settings/general', heading: '基础设置', screenshot: 'settings-general' },
- { path: '/settings/integrations', heading: '三方配置', screenshot: 'settings-integrations' },
- { path: '/settings/menus', heading: '菜单管理', screenshot: 'settings-menus' },
- { path: '/profile', heading: '个人中心', screenshot: 'profile' },
- { path: '/courses', heading: '课程管理', screenshot: 'placeholder-courses', placeholder: true },
- { path: '/equipment', heading: '装备空间', screenshot: 'placeholder-equipment', placeholder: true },
- { path: '/maintenance', heading: '维修任务', screenshot: 'placeholder-maintenance', placeholder: true },
- ]
-
- interface BrowserIssueState {
- consoleErrors: string[]
- pageErrors: string[]
- serverErrors: string[]
- }
-
- function observeBrowserIssues(page: Page): BrowserIssueState {
- const state: BrowserIssueState = { consoleErrors: [], pageErrors: [], serverErrors: [] }
- page.on('console', (message) => {
- if (message.type() === 'error') state.consoleErrors.push(message.text())
- })
- page.on('pageerror', (error) => state.pageErrors.push(error.message))
- page.on('response', (response) => {
- const url = new URL(response.url())
- if (url.origin === new URL(page.url() || 'http://127.0.0.1').origin
- && url.pathname.startsWith('/api/') && response.status() >= 500) {
- state.serverErrors.push(`${response.status()} ${url.pathname}`)
- }
- })
- return state
- }
-
- function takeIssues(state: BrowserIssueState) {
- const result = {
- consoleErrors: state.consoleErrors.splice(0),
- pageErrors: state.pageErrors.splice(0),
- serverErrors: state.serverErrors.splice(0),
- }
- return result
- }
-
- async function expectNoDocumentOverflow(page: Page) {
- const dimensions = await page.evaluate(() => ({
- viewport: document.documentElement.clientWidth,
- document: document.documentElement.scrollWidth,
- }))
- expect.soft(dimensions.document, `页面产生横向溢出:${dimensions.document}px > ${dimensions.viewport}px`).toBeLessThanOrEqual(dimensions.viewport + 1)
- }
-
- async function expectControlsWithinContainer(page: Page, containerSelector: string, controlsSelector: string, label: string) {
- const clippedControls = await page.locator(containerSelector).evaluate((container, selector) => {
- const boundary = container.getBoundingClientRect()
- return [...container.querySelectorAll<HTMLElement>(selector)]
- .filter((element) => {
- const style = getComputedStyle(element)
- return style.display !== 'none' && style.visibility !== 'hidden'
- })
- .flatMap((element) => {
- const rect = element.getBoundingClientRect()
- const clipped = rect.left < boundary.left - 1
- || rect.right > boundary.right + 1
- || rect.top < boundary.top - 1
- || rect.bottom > boundary.bottom + 1
- return clipped ? [{
- control: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 40) || element.tagName,
- boundary: [Math.round(boundary.left), Math.round(boundary.top), Math.round(boundary.right), Math.round(boundary.bottom)],
- rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.right), Math.round(rect.bottom)],
- }] : []
- })
- }, controlsSelector)
- expect.soft(clippedControls, `${label}存在被容器裁切的关键控件`).toEqual([])
- }
-
- async function openAndCaptureRoute(
- page: Page,
- route: RouteCase,
- viewportLabel: string,
- testInfo: Parameters<typeof captureScreenshot>[1],
- issues: BrowserIssueState,
- ) {
- takeIssues(issues)
- await page.goto(route.path)
- await expect(page).toHaveURL(new RegExp(`${route.path.split('?')[0]!.replaceAll('/', '\\/')}(?:\\?|$)`))
- await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible()
- const loadingOverlays = page.locator('.el-loading-mask:visible, .management-loading:visible, .overview-loading:visible, .library-loading:visible')
- const textLoadingStates = page
- .locator(':is(.empty-state, .agent-placeholder, .agent-detail-placeholder):visible')
- .filter({ hasText: /正在(?:读取|加载|汇总)/ })
- await expect(loadingOverlays).toHaveCount(0, { timeout: 20_000 })
- await expect(textLoadingStates).toHaveCount(0, { timeout: 20_000 })
-
- // 菜单树由页面挂载后的接口异步加载;必须等真实行出现,不能把初始 0 节点空态当成页面完成。
- if (route.screenshot === 'settings-menus') {
- await expect(page.locator('.menu-name-cell').first()).toBeVisible({ timeout: 20_000 })
- }
- if (route.screenshot === 'knowledge') {
- // 持久演示库包含部门共享与个人库;切到“用户自建”后再验收、截图,
- // 避免只拍平台内置库而漏掉 DEMO manifest 对应的真实业务数据。
- const userBuiltTab = page.getByRole('tab', { name: '用户自建' })
- await userBuiltTab.focus()
- // 移动端固定顶栏会覆盖页签的指针命中区域;使用原生按钮的键盘激活路径,
- // 仍然触发页面真实交互与数据切换,并同时验证该页签具备可访问性。
- await userBuiltTab.press('Enter')
- await expect(userBuiltTab).toHaveAttribute('aria-selected', 'true')
- }
-
- if (route.placeholder) {
- await expect(page.getByText('业务说明', { exact: true })).toBeVisible()
- await expect(page.getByText('本页面用于展示相关业务范围,具体数据、操作内容和可见范围以实际业务配置及账号权限为准。')).toBeVisible()
- await expect(page.locator('.domain-card')).toHaveCount(3)
- await expect(page.locator('form, .primary-button')).toHaveCount(0)
- } else {
- expect(hasNonEmptyAcceptance(route.screenshot), `${route.path} 必须配置非空页面验收规则`).toBeTruthy()
- const evidence = await expectRouteHasNonEmptyData(page, route.screenshot)
- await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-nonempty-evidence`, evidence)
- }
-
- // 数据出现后再次确认所有页面自定义 loading 均已结束,保证截图不会记录
- // “正在读取/加载”与真实列表短暂共存的过渡帧。
- await expect(loadingOverlays).toHaveCount(0)
- await expect(textLoadingStates).toHaveCount(0)
- await expectNoDocumentOverflow(page)
- await page.waitForTimeout(150)
- await captureScreenshot(page, testInfo, `${viewportLabel}-${route.screenshot}`)
-
- const routeIssues = takeIssues(issues)
- await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-browser-issues`, routeIssues)
- expect.soft(routeIssues.pageErrors, `${route.path} 发生 pageerror`).toEqual([])
- expect.soft(routeIssues.consoleErrors, `${route.path} 发生 console.error`).toEqual([])
- expect.soft(routeIssues.serverErrors, `${route.path} 出现 API 5xx`).toEqual([])
- }
-
- test('管理员桌面端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => {
- test.setTimeout(480_000)
- await page.setViewportSize({ width: 1920, height: 1080 })
- const issues = observeBrowserIssues(page)
- await loginAsAdmin(page)
-
- for (const route of adminRoutes) {
- await test.step(route.path, async () => {
- await openAndCaptureRoute(page, route, 'desktop', testInfo, issues)
- })
- }
- })
-
- test('管理员移动端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => {
- test.setTimeout(480_000)
- await page.setViewportSize({ width: 390, height: 844 })
- const issues = observeBrowserIssues(page)
- await loginAsAdmin(page)
-
- for (const route of adminRoutes) {
- await test.step(route.path, async () => {
- await openAndCaptureRoute(page, route, 'mobile', testInfo, issues)
- if (route.screenshot === 'video-create') {
- await expectControlsWithinContainer(
- page,
- '.editor-commandbar',
- '.editor-format-switch button, .editor-command-actions button',
- '移动端视频制作命令栏',
- )
- }
- if (route.screenshot === 'settings-menus') {
- await expectControlsWithinContainer(
- page,
- '.system-page-header',
- '.system-page-actions > *',
- '移动端菜单管理页头',
- )
- }
- })
- }
- })
-
- test('明暗主题、桌面侧栏和移动主导航可以实际切换', async ({ page }, testInfo) => {
- await page.setViewportSize({ width: 1440, height: 900 })
- await loginAsAdmin(page)
- await expect(page.locator('#main-sidebar')).toBeVisible()
- await expect(page.locator('.mobile-menu-button')).toBeHidden()
- await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', /light|dark/)
-
- const themeButton = page.locator('.theme-toggle')
- if (await themeButton.count()) {
- const initialTheme = await page.locator('.app-shell').getAttribute('data-theme')
- await themeButton.click()
- await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '')
- await captureScreenshot(page, testInfo, 'desktop-theme-toggled')
- await page.reload()
- await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '')
- await themeButton.click()
- await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', initialTheme || 'light')
- }
- await expectNoDocumentOverflow(page)
-
- await page.setViewportSize({ width: 390, height: 844 })
- await page.reload()
- const mobileMenuButton = page.getByRole('button', { name: '打开主导航' })
- await expect(mobileMenuButton).toBeVisible()
- await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true')
- await mobileMenuButton.click()
- await expect(page.locator('#main-sidebar')).toHaveClass(/mobile-open/)
- await expect(page.locator('#main-sidebar')).not.toHaveAttribute('aria-hidden', 'true')
- await expect(page.locator('.main-panel')).toHaveAttribute('inert', '')
- await captureScreenshot(page, testInfo, 'mobile-navigation-open')
- await page.keyboard.press('Escape')
- await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true')
- await expect(mobileMenuButton).toBeFocused()
- await expectNoDocumentOverflow(page)
- })
-
- test('公开登录页和悬浮接入演示在桌面及移动视口可读', async ({ page }, testInfo) => {
- await page.setViewportSize({ width: 1920, height: 1080 })
- await page.goto('/login')
- await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible()
- await expectNoDocumentOverflow(page)
- await captureScreenshot(page, testInfo, 'desktop-public-login')
-
- await page.goto('/embed-demo')
- await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible()
- await expect(page.getByPlaceholder('请输入已发布智能体的 slug')).toBeVisible()
- await expectNoDocumentOverflow(page)
- await captureScreenshot(page, testInfo, 'desktop-embed-demo')
-
- await page.setViewportSize({ width: 390, height: 844 })
- await page.goto('/login')
- await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible()
- await expectNoDocumentOverflow(page)
- await captureScreenshot(page, testInfo, 'mobile-public-login')
-
- await page.goto('/embed-demo')
- await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible()
- await expectNoDocumentOverflow(page)
- await captureScreenshot(page, testInfo, 'mobile-embed-demo')
- })
|