import { readFileSync } from 'node:fs'; import AxeBuilder from '@axe-core/playwright'; import type { Page, Route } from '@playwright/test'; import { expect, test } from '@playwright/test'; interface ContractRoute { pageId: string; path: string; pageType: 'system' | 'business'; requiresAuth: boolean; breadcrumb: string[]; roles: string[]; permission: string | string[] | null; } const routes = ( JSON.parse(readFileSync(new URL('../contracts/routes.json', import.meta.url), 'utf8')) as { routes: ContractRoute[]; } ).routes; const projectId = '01KZRXMHB04HR8Y23HTH1F7DXV'; const visibleHeadingPageIds = new Set(['PAGE-01', 'PAGE-02', 'PAGE-08', 'PAGE-10', 'PAGE-23']); const roleNames: Record = { PROJECT_MANAGER: '项目管理人员', FINANCE_MANAGER: '财务管理人员', ARCHIVE_MANAGER: '资料管理人员', SYSTEM_ADMIN: '系统管理员', }; function concretePath(route: ContractRoute) { if (route.pageId === 'PAGE-08') return `/projects/${projectId}`; if (route.pageId === 'PAGE-10') return '/forms/OA-01'; return route.path; } function expectedHeading(route: ContractRoute) { if (route.pageId === 'PAGE-01') return '登录系统'; if (route.pageId === 'PAGE-02') return '选择工作身份'; if (route.pageId === 'PAGE-03') return '个人中心'; if (route.pageId === 'PAGE-08') return '自动化验收项目'; if (route.pageId === 'PAGE-17') return '项目档案包'; return route.breadcrumb.at(-1) || ''; } function sessionFor(route: ContractRoute) { const roleCode = route.roles[0] || 'PROJECT_MANAGER'; const permissions = Array.isArray(route.permission) ? route.permission : route.permission ? [route.permission] : []; return { authenticated: true, user: { publicId: '01M00000000000000000000001', username: 'playwright', displayName: '自动化验收用户', departmentName: '测试组', mustChangePassword: false, }, roles: [ { code: roleCode, name: roleNames[roleCode], workbenchRoute: roleCode === 'SYSTEM_ADMIN' ? '/governance/settings' : `/workbench/${roleCode === 'PROJECT_MANAGER' ? 'project' : roleCode === 'FINANCE_MANAGER' ? 'finance' : 'archive'}`, }, ], activeRole: route.pageId === 'PAGE-02' ? null : roleCode, permissions, }; } function listEnvelope() { return { data: [], meta: { page: 1, size: 20, totalElements: 0, totalPages: 0 }, requestId: '01M00000000000000000000002', }; } function projectDetail() { return { publicId: projectId, businessNo: 'P-E2E-001', name: '自动化验收项目', company: { publicId: '01M00000000000000000000003', name: '自动化验收公司' }, customer: null, owner: { publicId: '01M00000000000000000000004', name: '自动化验收负责人' }, contractAmount: '0.00', receivedAmount: '0.00', paidAmount: '0.00', currency: 'CNY', stage: 'IN_PROGRESS', financeStatus: 'NORMAL', archiveStatus: 'PREPARING', status: 'ACTIVE', startDate: null, endDate: null, version: 0, createdAt: '2026-08-15T00:00:00Z', updatedAt: '2026-08-15T00:00:00Z', overview: { contractAmount: '0.00', receivedAmount: '0.00', paidAmount: '0.00', availableBalance: '0.00', sourceFormCount: 0, contractCount: 0, receiptCount: 0, invoiceCount: 0, paymentCount: 0, progressCount: 0, peopleCount: 0, accountingCount: 0, voucherCount: 0, pendingTaskCount: 0, fileCount: 0, activeRiskCount: 0, documentCompleteness: 0, }, riskFlags: [], timeline: [], allowedActions: [], }; } function projectWorkbench() { return { roleCode: 'PROJECT_MANAGER', title: '项目管理工作台', refreshedAt: '2026-08-17T00:00:00Z', metrics: [ { code: 'PROJECTS', label: '全部进行中项目', value: 2, targetRoute: '/projects' }, { code: 'TASKS', label: '全部待处理', value: 1, targetRoute: '/tasks?view=todo' }, { code: 'RETURNED', label: '全部退回表单', value: 0, targetRoute: '/forms?status=RETURNED' }, { code: 'DUE', label: '全部即将到期待办', value: 1, targetRoute: '/tasks?dueSoon=true' }, ], todos: [], projects: [], issues: [ { code: 'RETURNED_FORM', label: '全部退回表单', severity: 'DANGER', count: 0, detail: '需要补充资料后重新提交', targetRoute: '/forms?status=RETURNED', }, { code: 'MISSING_ARCHIVE_ITEMS', label: '缺资料', severity: 'WARNING', count: 40, detail: '请在档案包中补充缺失资料', targetRoute: '/archives/projects?missing=true', }, { code: 'OVERDUE_TASK', label: '已超期待办', severity: 'DANGER', count: 0, detail: '请优先处理已超过时限的审批任务', targetRoute: '/tasks?overdue=true', }, { code: 'DRAFT_FORM', label: '全部未提交草稿', severity: 'PRIMARY', count: 0, detail: '继续编辑并完成校验后提交', targetRoute: '/forms?status=DRAFT', }, ], funds: [], archiveItems: [], activities: [], }; } function mockData(pathname: string) { if (pathname === `/api/v1/projects/${projectId}`) return projectDetail(); if (pathname === '/api/v1/workbenches/project') return projectWorkbench(); if (pathname === '/api/v1/admin/users') { return [ { resource: 'users', publicId: '01M00000000000000000000007', username: 'playwright', displayName: '自动化验收用户', departmentName: '测试组', roleCodes: ['SYSTEM_ADMIN'], enabled: true, updatedAt: '2026-08-17T00:00:00Z', }, ]; } if (pathname.startsWith('/api/v1/workbenches/')) return {}; if (pathname.includes('/summary') || pathname.includes('/detail')) return {}; return []; } async function fulfillApi(route: Route, contractRoute: ContractRoute) { const request = route.request(); const pathname = new URL(request.url()).pathname; if (pathname === '/api/v1/setup/status') { await route.fulfill({ json: { data: { required: contractRoute.pageId === 'PAGE-23', locked: contractRoute.pageId !== 'PAGE-23', supportedDatabaseTypes: ['MYSQL'], message: contractRoute.pageId === 'PAGE-23' ? '请完成安装' : '系统已完成安装', }, requestId: '01M00000000000000000000000', }, }); return; } if (pathname === '/api/v1/auth/session') { await route.fulfill({ json: { data: sessionFor(contractRoute), requestId: '01M00000000000000000000004' } }); return; } if (pathname === '/api/v1/auth/profile') { await route.fulfill({ json: { data: sessionFor(contractRoute).user, requestId: '01M00000000000000000000005' } }); return; } if (pathname === '/api/v1/auth/roles') { await route.fulfill({ json: { data: sessionFor(contractRoute).roles, requestId: '01M00000000000000000000006' } }); return; } if (request.method() === 'GET') { const envelope = listEnvelope(); await route.fulfill({ json: { ...envelope, data: mockData(pathname) } }); return; } await route.fulfill({ status: 204 }); } async function installRouteFixture(page: Page, route: ContractRoute) { await page.route('**/api/v1/**', (requestRoute) => fulfillApi(requestRoute, route)); } for (const route of routes) { test(`${route.pageId} ${route.path} has no serious accessibility or page-overflow regression`, async ({ page }) => { const consoleErrors: string[] = []; page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); }); page.on('pageerror', (error) => consoleErrors.push(error.message)); await installRouteFixture(page, route); await page.goto(concretePath(route)); const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true }); await expect(heading).toHaveCount(1); if (visibleHeadingPageIds.has(route.pageId)) { await expect(heading).toBeVisible(); } else { await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1); await expect(page.locator('h1:not(.page-title-sr-only)')).toHaveCount(0); } await expect(page.locator('.tdesign-breadcrumb')).toHaveCount(0); await page.waitForLoadState('networkidle'); if (route.pageId === 'PAGE-09') { await expect(page.locator('.t-input-number__decrease, .t-input-number__increase')).toHaveCount(0); } const layout = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, })); expect( layout.scrollWidth, `${route.pageId} document overflowed by ${layout.scrollWidth - layout.clientWidth}px`, ).toBeLessThanOrEqual(layout.clientWidth); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); const blockingViolations = axe.violations.filter( (violation) => violation.impact === 'critical' || violation.impact === 'serious', ); expect(blockingViolations, JSON.stringify(blockingViolations, null, 2)).toEqual([]); expect(consoleErrors, consoleErrors.join('\n')).toEqual([]); }); } test('SYSTEM_ADMIN can open every contracted business page without explicit page permissions', async ({ page }) => { test.setTimeout(90_000); const systemRoute = routes.find((route) => route.pageId === 'PAGE-21'); if (!systemRoute) throw new Error('PAGE-21 contract is missing'); const adminRoute: ContractRoute = { ...systemRoute, roles: ['SYSTEM_ADMIN'], permission: null, }; await installRouteFixture(page, adminRoute); for (const route of routes.filter((item) => item.pageType === 'business')) { await page.goto(concretePath(route)); await expect(page).not.toHaveURL(/\/result\/403$/); const heading = page.getByRole('heading', { name: expectedHeading(route), exact: true }); await expect(heading).toHaveCount(1); if (visibleHeadingPageIds.has(route.pageId)) { await expect(heading).toBeVisible(); } else { await expect(page.locator('h1.page-title-sr-only')).toHaveCount(1); } } }); test('mobile settings table fixed columns do not overlap', async ({ page }) => { const systemRoute = routes.find((route) => route.pageId === 'PAGE-21'); if (!systemRoute) throw new Error('PAGE-21 contract is missing'); await page.setViewportSize({ width: 390, height: 844 }); await installRouteFixture(page, systemRoute); await page.goto(systemRoute.path); await expect(page.locator('.t-table tbody tr')).toHaveCount(1); const layout = await page.evaluate(() => { const cells = [ ...document.querySelectorAll('.t-table__cell--fixed-left, .t-table__cell--fixed-right'), ].filter((cell) => cell.getBoundingClientRect().width > 0); const overlaps = cells.flatMap((left, index) => { const a = left.getBoundingClientRect(); return cells.slice(index + 1).filter((right) => { const b = right.getBoundingClientRect(); const sameRow = Math.abs(a.top - b.top) < 1; return sameRow && a.left < b.right && b.left < a.right; }); }); return { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, overlaps: overlaps.length, fixedPositions: cells.map((cell) => getComputedStyle(cell).position), }; }); expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); expect(layout.overlaps).toBe(0); expect(layout.fixedPositions).not.toContain('sticky'); }); test('project workbench issue rows keep counts, copy and arrows aligned', async ({ page }) => { const projectRoute = routes.find((route) => route.pageId === 'PAGE-04'); if (!projectRoute) throw new Error('PAGE-04 contract is missing'); await installRouteFixture(page, projectRoute); for (const viewport of [ { width: 1100, height: 900 }, { width: 390, height: 844 }, ]) { await page.setViewportSize(viewport); await page.goto(projectRoute.path); await expect(page.locator('.issue-row')).toHaveCount(4); const layout = await page.evaluate(() => { const rows = [...document.querySelectorAll('.issue-row')].map((row) => { const count = row.querySelector('.issue-count'); const copy = row.querySelector('.issue-copy'); const arrow = row.querySelector('.issue-arrow'); if (!count || !copy || !arrow) throw new Error('Issue row layout element is missing'); const rowRect = row.getBoundingClientRect(); const countRect = count.getBoundingClientRect(); const copyRect = copy.getBoundingClientRect(); const arrowRect = arrow.getBoundingClientRect(); return { rowX: Math.round(rowRect.x), rowRight: Math.round(rowRect.right), rowHeight: Math.round(rowRect.height), copyX: Math.round(copyRect.x), copyRight: Math.round(copyRect.right), arrowX: Math.round(arrowRect.x), arrowRight: Math.round(arrowRect.right), countRight: Math.round(countRect.right), countCenterOffset: Math.abs(countRect.y + countRect.height / 2 - (rowRect.y + rowRect.height / 2)), arrowCenterOffset: Math.abs(arrowRect.y + arrowRect.height / 2 - (rowRect.y + rowRect.height / 2)), }; }); return { rows, clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, }; }); expect(new Set(layout.rows.map((row) => row.rowX)).size).toBe(1); expect(new Set(layout.rows.map((row) => row.copyX)).size).toBe(1); expect(new Set(layout.rows.map((row) => row.arrowX)).size).toBe(1); expect(Math.min(...layout.rows.map((row) => row.rowHeight))).toBeGreaterThanOrEqual(64); expect(layout.rows.every((row) => row.countRight <= row.copyX)).toBe(true); expect(layout.rows.every((row) => row.copyRight <= row.arrowX)).toBe(true); expect(layout.rows.every((row) => row.arrowRight <= row.rowRight)).toBe(true); expect(Math.max(...layout.rows.map((row) => row.countCenterOffset))).toBeLessThanOrEqual(1); expect(Math.max(...layout.rows.map((row) => row.arrowCenterOffset))).toBeLessThanOrEqual(1); expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth); } });