#!/usr/bin/env bun /** * a11y-audit — the fleet's WCAG 2.1 AA gate. * * Runs a real axe-core pass against a built app shell, microfrontend harness or * website and fails the build on `serious` and `critical` violations. Known * exceptions live in an explicit allowlist where every entry must carry a * written reason; there is no way to switch a rule off wholesale. * * This replaces the per-repo Playwright specs that asserted `critical` only via * `expect.soft`, which could never fail a build and were never wired into CI. * * Usage (from a consuming repo root, after `bun run build`): * bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts * or, since fe-libs declares this as a package `bin`: * a11y-audit [configPath] [--base-url=URL] [--json=PATH] [--route=/path ...] [--quiet] * * `configPath` defaults to `./a11y-audit.config.json`. * * Exit codes: * 0 no blocking violations * 1 blocking violations (or a stale allowlist when failOnStaleAllowlist is set) * 2 the audit could not run — bad config, app never came up, route failed to load */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { runAudit } from './audit'; import { ConfigError, resolveConfig } from './config'; import { renderConsole, renderMarkdown } from './report'; import { findUnreachableRouteScopes } from './allowlist'; import { startApp } from './server'; interface CliArgs { configPath: string; baseUrl?: string; jsonPath?: string; routes: string[]; quiet: boolean; } export function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { configPath: 'a11y-audit.config.json', routes: [], quiet: false }; let positionalSeen = false; for (const arg of argv) { if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length); else if (arg.startsWith('--json=')) args.jsonPath = arg.slice('--json='.length); else if (arg.startsWith('--route=')) args.routes.push(arg.slice('--route='.length)); else if (arg === '--quiet') args.quiet = true; else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); else if (!positionalSeen) { args.configPath = arg; positionalSeen = true; } else throw new Error(`Unexpected argument: ${arg}`); } return args; } async function main(): Promise { let args: CliArgs; try { args = parseArgs(process.argv.slice(2)); } catch (error) { console.error(`[a11y-audit] ${(error as Error).message}`); return 2; } const configPath = resolve(process.cwd(), args.configPath); if (!existsSync(configPath)) { console.error(`[a11y-audit] Config not found: ${configPath}`); console.error( 'Create an a11y-audit.config.json at your repo root. See ' + '@burdenoff/fe-libs/scripts/a11y-audit/README.md for the config shape.' ); return 2; } let config; try { config = resolveConfig(JSON.parse(readFileSync(configPath, 'utf8'))); } catch (error) { if (error instanceof ConfigError) console.error(`[a11y-audit] ${error.message}`); else console.error(`[a11y-audit] Failed to read ${configPath}: ${(error as Error).message}`); return 2; } if (args.routes.length > 0) { const filtered = config.routes.filter((route) => args.routes.includes(route.path)); if (filtered.length === 0) { console.error(`[a11y-audit] --route filter matched none of the configured routes.`); return 2; } config.routes = filtered; } const unreachable = findUnreachableRouteScopes(config.allowlist, config.routes); if (unreachable.length > 0 && args.routes.length === 0) { for (const item of unreachable) { console.error( `[a11y-audit] allowlist[${item.index}] is scoped to route "${item.route}", which is not ` + 'in the audited route list — fix the typo or the scope is silently doing nothing' ); } return 2; } let app; try { app = await startApp(config, args.baseUrl); } catch (error) { console.error(`[a11y-audit] Could not reach the app under test: ${(error as Error).message}`); return 2; } try { const report = await runAudit(config, app); const jsonPath = resolve(process.cwd(), args.jsonPath ?? config.reportPath); mkdirSync(dirname(jsonPath), { recursive: true }); writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); if (!args.quiet) console.log(renderConsole(report)); console.log(`[a11y-audit] JSON report written to ${jsonPath}`); const summaryPath = process.env.GITHUB_STEP_SUMMARY; if (summaryPath) { writeFileSync(summaryPath, renderMarkdown(report), { flag: 'a' }); } if (report.totals.blocking > 0) { console.error( `[a11y-audit] FAILED: ${report.totals.blocking} blocking violation node(s) at ` + `${config.failOn.join('/')} impact. Fix them, or add a narrowly-scoped allowlist entry ` + 'with a written reason and an expiry.' ); return 1; } if (config.failOnStaleAllowlist && report.staleAllowlistEntries.length > 0) { console.error( `[a11y-audit] FAILED: ${report.staleAllowlistEntries.length} allowlist entr(ies) matched ` + 'nothing and failOnStaleAllowlist is set — delete them.' ); return 1; } console.log('[a11y-audit] PASSED: no blocking accessibility violations.'); return 0; } catch (error) { console.error(`[a11y-audit] ${(error as Error).message}`); return 2; } finally { await app.close(); } } main().then( (code) => process.exit(code), (error) => { console.error(`[a11y-audit] Unexpected failure: ${(error as Error).stack ?? error}`); process.exit(2); } );