Files
goodgo-platform/e2e/a11y/scorecard.spec.ts
2026-05-04 20:11:09 +07:00

188 lines
6.2 KiB
TypeScript

/**
* Axe-core accessibility scorecard for 10 key routes.
*
* Each test page is scanned with @axe-core/playwright and results are:
* 1. Asserted — any Critical or Serious violations fail the test.
* 2. Recorded — full results written to e2e/a11y/reports/<route>.json.
*
* The JSON scorecard is committed so a "before/after" diff is visible in PRs.
* Run with: pnpm exec playwright test --project=a11y
*/
import fs from 'node:fs';
import path from 'node:path';
import { AxeBuilder } from '@axe-core/playwright';
import { test } from '@playwright/test';
const REPORTS_DIR = path.join(__dirname, 'reports');
/** Write JSON report and return the violations. */
function writeReport(routeKey: string, results: Awaited<ReturnType<AxeBuilder['analyze']>>) {
fs.mkdirSync(REPORTS_DIR, { recursive: true });
const outPath = path.join(REPORTS_DIR, `${routeKey}.json`);
const report = {
url: results.url,
timestamp: new Date().toISOString(),
violationCount: results.violations.length,
incompleteCount: results.incomplete.length,
passCount: results.passes.length,
violations: results.violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
helpUrl: v.helpUrl,
})),
incomplete: results.incomplete.map((i) => ({
id: i.id,
impact: i.impact,
description: i.description,
nodes: i.nodes.length,
})),
};
fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
return results.violations;
}
/** Routes to test: [routeKey, urlPath] */
const ROUTES: [string, string][] = [
['home', '/'],
['search', '/search'],
['listing_detail', '/listings/test-listing-id'],
['listing_create', '/listings/create'],
['login', '/login'],
['register', '/register'],
['dashboard', '/dashboard'],
['agent_profile', '/agent/test-agent-id'],
['inquiries', '/inquiries'],
['admin_moderation', '/admin/moderation'],
];
/**
* Mock route handler applied via page.route for pages that need API data
* to render meaningfully. Returns empty-but-valid responses so axe can
* evaluate the rendered DOM rather than blank/error states.
*/
async function applyApiMocks(page: import('@playwright/test').Page) {
// Generic JSON stub for any API calls the page makes during initial load
await page.route('**/api/v1/**', (route) => {
const url = route.request().url();
// Let static resource requests through
if (url.match(/\.(js|css|png|svg|ico|woff2?)$/)) {
return route.continue();
}
// Stub listings
if (url.includes('/listings/')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'test-listing-id',
title: 'Căn hộ mẫu 2PN',
price: 3500000000,
propertyType: 'apartment',
transactionType: 'sale',
area: 75,
bedrooms: 2,
bathrooms: 2,
description: 'Căn hộ mẫu phục vụ kiểm thử.',
address: { street: '123 Lê Lợi', district: 'Quận 1', city: 'Hồ Chí Minh' },
images: [],
agent: { id: 'test-agent-id', name: 'Nguyễn Agent', phone: '0901234567' },
}),
});
}
// Stub agent profile
if (url.includes('/agents/')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'test-agent-id',
name: 'Nguyễn Agent',
bio: 'Chuyên gia bất động sản.',
listings: [],
rating: 4.5,
reviewCount: 10,
}),
});
}
// Stub dashboard
if (url.includes('/dashboard') || url.includes('/users/me')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ stats: {}, listings: [], notifications: [] }),
});
}
// Stub inquiries
if (url.includes('/inquiries')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
});
}
// Stub moderation
if (url.includes('/admin/')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
});
}
// Stub search
if (url.includes('/search') || url.includes('/properties')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
});
}
return route.continue();
});
}
for (const [routeKey, urlPath] of ROUTES) {
test(`a11y: ${routeKey} (${urlPath})`, async ({ page }) => {
await applyApiMocks(page);
await page.goto(urlPath, { waitUntil: 'domcontentloaded' });
// Wait for main content to be in DOM
await page.waitForSelector('body', { timeout: 10_000 });
// Short settle to allow client-side hydration
await page.waitForTimeout(500);
const results = await new AxeBuilder({ page })
// Disable colour-contrast rule — fails in headless due to forced colours
.disableRules(['color-contrast'])
.analyze();
const violations = writeReport(routeKey, results);
// Only critical/serious violations fail the build; moderate/minor are recorded only
const blocking = violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
);
if (blocking.length > 0) {
const summary = blocking
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s)) — ${v.helpUrl}`)
.join('\n');
throw new Error(
`${blocking.length} blocking a11y violation(s) on ${urlPath}:\n${summary}\n\nSee full report: e2e/a11y/reports/${routeKey}.json`,
);
}
// Informational: log moderate/minor counts
const informational = violations.filter(
(v) => v.impact !== 'critical' && v.impact !== 'serious',
);
if (informational.length > 0) {
console.log(
`[a11y] ${routeKey}: ${informational.length} non-blocking violation(s) (moderate/minor) — see e2e/a11y/reports/${routeKey}.json`,
);
}
});
}