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(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[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') })