/** * 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/.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, expect } from '@playwright/test'; const REPORTS_DIR = path.join(__dirname, 'reports'); /** Write JSON report and return the violations. */ function writeReport(routeKey: string, results: Awaited>) { 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'); expect.fail( `${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`, ); } }); }