403 lines
14 KiB
TypeScript
403 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', 'PAGE-24']);
|
|
|
|
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 '项目档案包';
|
|
if (route.pageId === 'PAGE-24') 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);
|
|
}
|
|
});
|