Files
kaidi/frontend/e2e/accessibility.e2e.ts
T
2026-08-18 08:01:57 +08:00

402 lines
14 KiB
TypeScript

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<string, string> = {
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<HTMLElement>('.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<HTMLElement>('.issue-row')].map((row) => {
const count = row.querySelector<HTMLElement>('.issue-count');
const copy = row.querySelector<HTMLElement>('.issue-copy');
const arrow = row.querySelector<HTMLElement>('.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);
}
});