/** * Browser driving: navigate each configured route at each configured viewport * and run a real axe-core pass against the rendered DOM. * * `playwright-core` and `@axe-core/playwright` are imported dynamically so the * shared runner can live in fe-libs without dragging a browser automation * dependency into every consumer of the component library. Consuming repos * declare them as devDependencies; a missing one is a loud, actionable error. */ import { partitionNodes, flattenViolations } from './allowlist'; import type { AxeViolationLike } from './allowlist'; import type { RunningApp } from './server'; import type { AuditReport, ResolvedConfig, RouteResult } from './types'; /* eslint-disable @typescript-eslint/no-explicit-any -- dynamically imported third-party modules have no static types available inside this script. */ type AnyRecord = Record; const MISSING_DEP_HINT = 'Add them to the repo devDependencies:\n' + ' bun add -d @playwright/test @axe-core/playwright\n' + ' bunx playwright install --with-deps chromium'; async function loadPlaywright(): Promise { for (const specifier of ['playwright', 'playwright-core', '@playwright/test']) { try { const mod = (await import(specifier)) as AnyRecord; if (mod?.chromium) return mod.chromium as AnyRecord; } catch { /* try the next specifier */ } } throw new Error(`[a11y-audit] Cannot resolve Playwright.\n ${MISSING_DEP_HINT}`); } type AxeBuilderCtor = new (options: { page: AnyRecord }) => { withTags: (tags: string[]) => { analyze: () => Promise }; }; async function loadAxeBuilder(): Promise { try { const mod = (await import('@axe-core/playwright')) as AnyRecord; return (mod.default ?? mod) as AxeBuilderCtor; } catch (error) { throw new Error( `[a11y-audit] Cannot resolve @axe-core/playwright (${(error as Error).message}).\n ` + MISSING_DEP_HINT, { cause: error } ); } } async function loadAxeVersion(): Promise { try { const mod = (await import('axe-core')) as AnyRecord; return (mod.default?.version ?? mod.version ?? 'unknown') as string; } catch { return 'unknown'; } } interface DomSnapshot { textLength: number; elementCount: number; } async function snapshotDom(page: AnyRecord): Promise { return (await page.evaluate(() => ({ textLength: (document.body?.innerText ?? '').trim().length, elementCount: document.querySelectorAll('*').length, }))) as DomSnapshot; } /** * Poll until the rendered DOM stops changing for `quietMs`, or `timeoutMs` * elapses. Returns the final snapshot and whether it actually went quiet. * * This is what makes the gate reproducible on a shell that paints a splash * before it paints the page. */ async function waitForStableDom( page: AnyRecord, quietMs: number, timeoutMs: number ): Promise<{ snapshot: DomSnapshot; settled: boolean }> { const pollMs = 250; const deadline = Date.now() + timeoutMs; let previous = await snapshotDom(page); let quietSince = Date.now(); while (Date.now() < deadline) { await page.waitForTimeout(pollMs); const current = await snapshotDom(page); if ( current.textLength !== previous.textLength || current.elementCount !== previous.elementCount ) { previous = current; quietSince = Date.now(); continue; } previous = current; if (Date.now() - quietSince >= quietMs) return { snapshot: current, settled: true }; } return { snapshot: previous, settled: false }; } /** Run the full audit. Throws on navigation failure — never silently skips a route. */ export async function runAudit( config: ResolvedConfig, app: Pick, now: Date = new Date() ): Promise { const { baseUrl, resolveAsset } = app; const chromium = await loadPlaywright(); const AxeBuilder = await loadAxeBuilder(); const axeVersion = await loadAxeVersion(); const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], }); const results: RouteResult[] = []; const usedEntries = new Set(); const navigationErrors: string[] = []; /** Fresh page with request routing applied — used for the first try and every retry. */ const openPage = async (context: AnyRecord): Promise => { const page = await context.newPage(); if (config.blockExternalRequests || resolveAsset) { await page.route('**/*', (route: AnyRecord) => { const requestUrl: string = route.request().url(); const isOwnOrigin = requestUrl.startsWith(baseUrl); // Serve the build's own files straight from disk. Chromium never opens // a socket for them, so a spurious network-change notification cannot // cancel a hundred in-flight chunk loads and leave the shell blank. if (isOwnOrigin && resolveAsset) { const asset = resolveAsset(new URL(requestUrl).pathname); if (asset) { void route.fulfill({ status: 200, contentType: asset.contentType, headers: { 'cache-control': 'no-store' }, body: asset.body, }); return; } void route.fulfill({ status: 404, contentType: 'text/plain', body: 'Not found' }); return; } // Deterministic DOM: nothing the audit sees depends on a live backend, // a CDN or the runner's network latency. if (isOwnOrigin || requestUrl.startsWith('data:') || !config.blockExternalRequests) { void route.continue(); return; } void route.abort(); }); } return page; }; try { for (const viewport of config.viewports) { const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, // Deterministic rendering: no OS locale/timezone drift between the dev // machine and the CI runner changing what axe sees. locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce', // A service worker caching the shell across routes would make the audit // depend on which route ran first. serviceWorkers: 'block', }); for (const route of config.routes) { const url = `${baseUrl}${route.path}`; const attemptFailures: string[] = []; let result: RouteResult | null = null; for (let attempt = 1; attempt <= config.routeAttempts && result === null; attempt += 1) { const page = await openPage(context); try { const response = await page.goto(url, { waitUntil: 'load', timeout: config.navigationTimeoutMs, }); if (response && response.status() >= 400) { throw new Error(`HTTP ${response.status()}`); } if (route.waitForSelector) { await page.waitForSelector(route.waitForSelector, { timeout: config.navigationTimeoutMs, }); } await page.waitForTimeout(route.settleMs ?? config.settleMs); const stability = await waitForStableDom( page, config.domQuietMs, config.navigationTimeoutMs ); const floor = route.minTextLength ?? config.minTextLength; if (stability.snapshot.textLength < floor) { throw new Error( `rendered only ${stability.snapshot.textLength} characters of text (floor is ` + `${floor}) — the page never finished painting, so a clean axe result here ` + 'would be meaningless' ); } const axeResults = (await new AxeBuilder({ page }) .withTags(config.standard) .analyze()) as AnyRecord; const nodes = flattenViolations(axeResults.violations as AxeViolationLike[], { route: route.path, routeName: route.name, viewport: viewport.name, }); const partition = partitionNodes(nodes, config.allowlist, config.failOn, now); partition.usedEntries.forEach((index) => usedEntries.add(index)); result = { route: route.path, routeName: route.name, viewport: viewport.name, finalUrl: page.url(), blocking: partition.blocking, allowed: partition.allowed, advisory: partition.advisory, passCount: (axeResults.passes as unknown[]).length, incompleteCount: (axeResults.incomplete as unknown[]).length, renderedTextLength: stability.snapshot.textLength, domSettled: stability.settled, attempts: attempt, }; } catch (error) { attemptFailures.push(`attempt ${attempt}: ${(error as Error).message}`); } finally { await page.close(); } } if (result === null) { navigationErrors.push( `${route.name} @ ${viewport.name} (${url}):\n ${attemptFailures.join('\n ')}` ); continue; } if (result.attempts > 1) { console.warn( `[a11y-audit] ${route.name} @ ${viewport.name} needed ${result.attempts} attempts ` + `(${attemptFailures.join('; ')})` ); } results.push(result); } await context.close(); } } finally { await browser.close(); } if (navigationErrors.length > 0) { throw new Error( `[a11y-audit] ${navigationErrors.length} route(s) could not be loaded, so the audit is ` + 'incomplete and cannot be treated as a pass:\n - ' + navigationErrors.join('\n - ') ); } const staleAllowlistEntries = config.allowlist .map((entry, index) => ({ index, rule: entry.rule, reason: entry.reason })) .filter((entry) => !usedEntries.has(entry.index)); const expiredAllowlistEntries = config.allowlist .map((entry, index) => ({ index, rule: entry.rule, expires: entry.expires ?? '' })) .filter((entry) => { if (!entry.expires) return false; return now.getTime() > new Date(`${entry.expires}T23:59:59.999Z`).getTime(); }); return { name: config.name, generatedAt: now.toISOString(), baseUrl, standard: config.standard, failOn: config.failOn, axeVersion, blockExternalRequests: config.blockExternalRequests, routes: results, staleAllowlistEntries, expiredAllowlistEntries, totals: { blocking: results.reduce((sum, r) => sum + r.blocking.length, 0), allowed: results.reduce((sum, r) => sum + r.allowed.length, 0), advisory: results.reduce((sum, r) => sum + r.advisory.length, 0), routesAudited: results.length, }, }; }