/** * Shared type definitions for the fleet accessibility gate. * * See README.md in this directory for the config shape and worked examples. */ /** axe-core impact levels, ordered least to most severe. */ export const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; export type ImpactLevel = (typeof IMPACT_LEVELS)[number]; /** * One explicitly-documented exception. Every entry MUST carry a `reason`; * there is deliberately no way to disable a rule globally, no `enabled: false` * escape hatch and no wildcard rule id. Narrowing an entry to specific routes * and/or specific DOM subtrees is optional but strongly encouraged — an entry * with neither is a fleet-wide exemption for that one rule and reads that way * in the report. */ export interface AllowlistEntry { /** axe rule id, e.g. `color-contrast`. Wildcards are rejected. */ rule: string; /** Why this violation is tolerated. Free prose, minimum 20 characters. */ reason: string; /** * Routes this entry applies to. Each is either an exact configured route * path or a `/prefix/*` subtree. Omit to apply to every audited route. */ routes?: string[]; /** * CSS selectors this entry applies to. A node matches when the selector is * the node's own axe target or an ancestor prefix of it. Omit to apply to * every node the rule flags. */ selectors?: string[]; /** * ISO `YYYY-MM-DD` date after which the entry stops suppressing anything and * the violations it covered start failing the build again. Optional, but the * only thing that keeps an allowlist from becoming permanent. */ expires?: string; /** Linear ticket tracking the fix, e.g. `BOFF-1234`. */ ticket?: string; } /** One page to audit. */ export interface RouteSpec { /** Path appended to the base URL, e.g. `/settings/profile`. */ path: string; /** Human label used in reports. Defaults to `path`. */ name?: string; /** Selector that must be present before axe runs. */ waitForSelector?: string; /** Settle time in ms for this route, replacing the global `settleMs`. */ settleMs?: number; /** * Minimum rendered text length for THIS route, overriding the global * `minTextLength`. Raise it on a heavy page whose load has a visible plateau * — a splash or skeleton can hold steady long enough to look like a settled * DOM, and auditing that frame measures the skeleton, not the page. */ minTextLength?: number; /** * Treat a redirect away from `path` as expected. When false (the default) a * redirect is still audited, but the report records the final URL so a * "clean" result on an unexpected error page cannot be mistaken for a pass. */ allowRedirect?: boolean; } export interface ViewportSpec { name: string; width: number; height: number; } /** * How the audit gets a running app. Exactly one of `staticDir`, `command` or * `baseUrl` must be set. */ export interface ServeConfig { /** Directory of built static assets to serve, relative to the repo root. */ staticDir?: string; /** Serve `index.html` for unknown extension-less paths. Client-routed apps need this. */ spa?: boolean; /** Command that starts a server, e.g. `bun run preview --port 4180`. */ command?: string; /** Port the command listens on. Required with `command`. */ port?: number; /** Audit an already-running server instead of starting one. */ baseUrl?: string; /** How long to wait for `command` to answer, ms. Default 60000. */ readyTimeoutMs?: number; } export interface A11yAuditConfig { /** Repo/app name, used in report headings. */ name: string; serve: ServeConfig; routes: RouteSpec[]; /** Defaults to a single 1280x800 `desktop` viewport. */ viewports?: ViewportSpec[]; /** axe tag set. Defaults to the WCAG 2.1 AA tags. */ standard?: string[]; /** Impacts that fail the build. Defaults to `['critical', 'serious']`. */ failOn?: ImpactLevel[]; allowlist?: AllowlistEntry[]; /** Where the JSON report is written. Defaults to `a11y-report.json`. */ reportPath?: string; /** Fail when an allowlist entry matched nothing. Defaults to false (warn only). */ failOnStaleAllowlist?: boolean; /** Per-navigation timeout in ms. Defaults to 45000. */ navigationTimeoutMs?: number; /** Settle time in ms applied to every route after load. Defaults to 1500. */ settleMs?: number; /** * Abort every request that leaves the app's own origin. Defaults to true. * * A build-time gate must produce the same DOM on a laptop, a CI runner and a * re-run three weeks later. Letting the built bundle talk to a live gateway * makes axe race backend latency: the same route audits a splash screen on * one run and a rendered page on the next. Blocking egress pins the app to * one deterministic state. Set false only when auditing a live deployment * via `--base-url`, where the backend is part of what you are auditing. */ blockExternalRequests?: boolean; /** * How long the DOM must stop changing before axe runs, in ms. Defaults to * 1500. * * Single-page shells paint a splash, then hydrate, then swap in the real * page. A fixed sleep audits whichever frame it happens to land on, which is * how a gate ends up reporting a clean splash screen as a pass. */ domQuietMs?: number; /** * Minimum rendered `body.innerText` length for a route to count as audited. * Defaults to 100 characters. * * Below this the page never rendered, and axe finding nothing means nothing. * The run fails as an incomplete audit rather than reporting a false pass. */ minTextLength?: number; /** * How many times a route may be re-navigated on a fresh page before the run * is declared an incomplete audit. Defaults to 3. * * Chromium in a VM/container periodically fires a spurious network-change * notification that cancels every in-flight request; on a code-split shell * that lands as a blank page. The content floor catches it, and a retry on a * clean page recovers it. A route that never renders across all attempts is * still a hard failure — this retries a flake, it does not tolerate one. */ routeAttempts?: number; } /** Config with every default resolved. */ export interface ResolvedConfig { name: string; serve: Required> & ServeConfig; routes: Array> & RouteSpec>; viewports: ViewportSpec[]; standard: string[]; failOn: ImpactLevel[]; allowlist: AllowlistEntry[]; reportPath: string; failOnStaleAllowlist: boolean; navigationTimeoutMs: number; settleMs: number; blockExternalRequests: boolean; domQuietMs: number; minTextLength: number; routeAttempts: number; } /** A single failing DOM node, flattened out of an axe violation. */ export interface FlatNode { rule: string; impact: ImpactLevel; help: string; helpUrl: string; tags: string[]; route: string; routeName: string; viewport: string; target: string[]; html: string; failureSummary: string; } export interface AllowedNode extends FlatNode { allowlistIndex: number; allowlistReason: string; allowlistTicket?: string; } export interface RouteResult { route: string; routeName: string; viewport: string; /** URL the browser actually ended on — records redirects. */ finalUrl: string; blocking: FlatNode[]; allowed: AllowedNode[]; advisory: FlatNode[]; /** Rules that ran clean, for the "we actually looked" evidence trail. */ passCount: number; incompleteCount: number; /** Rendered text length at the moment axe ran — evidence the page was real. */ renderedTextLength: number; /** Whether the DOM went quiet before the navigation timeout. */ domSettled: boolean; /** Navigation attempts this route needed. >1 means the runner hit a flake. */ attempts: number; } export interface AuditReport { name: string; generatedAt: string; baseUrl: string; standard: string[]; failOn: ImpactLevel[]; axeVersion: string; blockExternalRequests: boolean; routes: RouteResult[]; staleAllowlistEntries: Array<{ index: number; rule: string; reason: string }>; expiredAllowlistEntries: Array<{ index: number; rule: string; expires: string }>; totals: { blocking: number; allowed: number; advisory: number; routesAudited: number; }; }