@burdenoff/fe-libs 2026.825.1 → 2026.825.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/scripts/a11y-audit/README.md +172 -0
- package/scripts/a11y-audit/allowlist.ts +178 -0
- package/scripts/a11y-audit/audit.ts +306 -0
- package/scripts/a11y-audit/cli.ts +161 -0
- package/scripts/a11y-audit/config.ts +319 -0
- package/scripts/a11y-audit/index.ts +19 -0
- package/scripts/a11y-audit/report.ts +144 -0
- package/scripts/a11y-audit/server.ts +237 -0
- package/scripts/a11y-audit/types.ts +234 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/fe-libs",
|
|
3
|
-
"version": "2026.825.
|
|
3
|
+
"version": "2026.825.2",
|
|
4
4
|
"description": "Burdenoff frontend primitives and domain libraries",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts"
|
|
7
|
+
"graphql-schema-drift": "./scripts/graphql-schema-drift/cli.ts",
|
|
8
|
+
"a11y-audit": "./scripts/a11y-audit/cli.ts"
|
|
8
9
|
},
|
|
9
10
|
"imports": {
|
|
10
11
|
"#lib/*": "./src/lib/*",
|
|
@@ -283,6 +284,8 @@
|
|
|
283
284
|
"src/vite/**/*",
|
|
284
285
|
"scripts/graphql-schema-drift/*.ts",
|
|
285
286
|
"scripts/graphql-schema-drift/README.md",
|
|
287
|
+
"scripts/a11y-audit/*.ts",
|
|
288
|
+
"scripts/a11y-audit/README.md",
|
|
286
289
|
"README.md",
|
|
287
290
|
"LICENSE"
|
|
288
291
|
],
|
|
@@ -293,7 +296,7 @@
|
|
|
293
296
|
"lint:sanity": "if [ \"${CI:-}\" = \"true\" ]; then bun run lint; else bun run lint -- --cache --cache-location .eslintcache; fi",
|
|
294
297
|
"format": "prettier --config shared/config/prettier.config.mjs --write \"src/**/*.{ts,tsx}\" \"!src/config/*.config.ts\"",
|
|
295
298
|
"type:check": "bash scripts/build-limits.sh tsc --noEmit",
|
|
296
|
-
"test": "bun test scripts/graphql-schema-drift/__tests__ src/credentials src/storage src/shared/assistant src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/chrome/__tests__ src/ui/__tests__",
|
|
299
|
+
"test": "bun test scripts/graphql-schema-drift/__tests__ scripts/a11y-audit/__tests__ src/credentials src/storage src/shared/assistant src/shared/providers/AppShellProvider.test.ts src/shared/events/sinks/RybbitSink.test.ts src/shell-native src/shared/native src/chrome/__tests__ src/ui/__tests__",
|
|
297
300
|
"sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run lint:sanity && bun run format && bun run type:check && bun run test && bun run build'",
|
|
298
301
|
"switch:status": "bun scripts/deps-mode.ts status",
|
|
299
302
|
"switch:remote": "bun scripts/deps-mode.ts remote",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# a11y-audit — the fleet WCAG 2.1 AA gate
|
|
2
|
+
|
|
3
|
+
A real axe-core run against a built app shell, microfrontend or website, wired to
|
|
4
|
+
fail a build on `serious` **and** `critical` violations, with an explicit
|
|
5
|
+
allowlist for the exceptions you have decided to live with.
|
|
6
|
+
|
|
7
|
+
It exists because the thing it replaces did not work. Several repos had an
|
|
8
|
+
`@axe-core/playwright` spec checked in, but:
|
|
9
|
+
|
|
10
|
+
- no CI workflow ever executed it, and
|
|
11
|
+
- where it did assert, it asserted `impact === 'critical'` only, through
|
|
12
|
+
`expect.soft(...)`, which records a failure and then lets the run pass.
|
|
13
|
+
|
|
14
|
+
So the fleet's accessibility posture was "not assessed" while looking assessed.
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
For every configured route × viewport:
|
|
19
|
+
|
|
20
|
+
1. serves the built app (or points at a running one),
|
|
21
|
+
2. navigates, waits for the DOM to stop changing, and refuses to audit a page
|
|
22
|
+
that never painted,
|
|
23
|
+
3. runs axe-core with the WCAG 2.1 A + AA tag set,
|
|
24
|
+
4. splits every failing DOM node into **blocking** / **allowlisted** /
|
|
25
|
+
**advisory**,
|
|
26
|
+
5. writes a JSON report and a GitHub step summary,
|
|
27
|
+
6. exits non-zero when anything blocking survives.
|
|
28
|
+
|
|
29
|
+
Exit codes:
|
|
30
|
+
|
|
31
|
+
| Code | Meaning |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `0` | no blocking violations |
|
|
34
|
+
| `1` | blocking violations (or stale allowlist entries with `failOnStaleAllowlist`) |
|
|
35
|
+
| `2` | the audit could not run — invalid config, app never came up, a route failed to load or never rendered |
|
|
36
|
+
|
|
37
|
+
Exit `2` matters as much as exit `1`. An audit that could not look at a page is
|
|
38
|
+
reported as a failure to audit, never as a pass.
|
|
39
|
+
|
|
40
|
+
## Install in a repo
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun add -d @playwright/test @axe-core/playwright
|
|
44
|
+
bunx playwright install --with-deps chromium
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`@burdenoff/fe-libs` ships the runner as a package `bin`, so either of these works
|
|
48
|
+
from the repo root:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
a11y-audit
|
|
52
|
+
bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add the script to `package.json`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
"a11y": "bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Playwright and axe are imported dynamically, so fe-libs itself does not drag a
|
|
62
|
+
browser automation stack into every consumer of the component library.
|
|
63
|
+
|
|
64
|
+
## Config
|
|
65
|
+
|
|
66
|
+
`a11y-audit.config.json` at the repo root (or pass a path as the first argument).
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"name": "vibecontrols-app",
|
|
71
|
+
"serve": { "staticDir": "dist", "spa": true },
|
|
72
|
+
"routes": [
|
|
73
|
+
{ "path": "/", "name": "landing" },
|
|
74
|
+
{ "path": "/auth/login", "name": "auth-login" }
|
|
75
|
+
],
|
|
76
|
+
"viewports": [
|
|
77
|
+
{ "name": "desktop", "width": 1280, "height": 800 },
|
|
78
|
+
{ "name": "mobile", "width": 390, "height": 844 }
|
|
79
|
+
],
|
|
80
|
+
"settleMs": 1000,
|
|
81
|
+
"domQuietMs": 2000,
|
|
82
|
+
"allowlist": []
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `serve` — exactly one mode
|
|
87
|
+
|
|
88
|
+
| Mode | Use for | Example |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `staticDir` | app shells and websites, which build to a servable `dist/` | `{ "staticDir": "dist", "spa": true }` |
|
|
91
|
+
| `command` + `port` | microfrontends, which build to a library and need their Vite harness to render | `{ "command": "bun run dev --port 4180", "port": 4180 }` |
|
|
92
|
+
| `baseUrl` | an already-running target (preview deploy, alpha, prod) | `{ "baseUrl": "https://alphaapp.vibecontrols.com" }` |
|
|
93
|
+
|
|
94
|
+
`spa: true` (the default) serves `index.html` for extension-less paths so
|
|
95
|
+
client-routed URLs resolve. Missing *assets* still 404 — a broken bundle must not
|
|
96
|
+
be laundered into a page load.
|
|
97
|
+
|
|
98
|
+
`--base-url=URL` on the command line overrides whatever the config says, which is
|
|
99
|
+
how you point the same config at alpha or prod.
|
|
100
|
+
|
|
101
|
+
### Determinism knobs
|
|
102
|
+
|
|
103
|
+
| Key | Default | Why |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `blockExternalRequests` | `true` | Aborts every request leaving the app's own origin. Without it, axe races backend latency: the same route audits a splash screen on one run and a rendered page on the next. Set `false` when auditing a live deployment where the backend is part of the subject. |
|
|
106
|
+
| `settleMs` | `1500` | Fixed delay after `load` before quiescence polling starts. |
|
|
107
|
+
| `domQuietMs` | `1500` | The DOM must stop changing (text length and element count) for this long before axe runs. |
|
|
108
|
+
| `minTextLength` | `100` | A route that renders less than this much text is reported as **not audited** (exit 2), never as a pass. |
|
|
109
|
+
| `navigationTimeoutMs` | `45000` | Per-navigation and per-quiescence ceiling. |
|
|
110
|
+
|
|
111
|
+
Per route you can also set `waitForSelector` and a route-specific `settleMs`.
|
|
112
|
+
|
|
113
|
+
### `failOn`
|
|
114
|
+
|
|
115
|
+
Defaults to `["critical", "serious"]`. It may be **widened** (adding `moderate`),
|
|
116
|
+
never narrowed — a config that drops `critical` or `serious` is rejected. That is
|
|
117
|
+
the fleet floor and the gate refuses to run below it.
|
|
118
|
+
|
|
119
|
+
## The allowlist
|
|
120
|
+
|
|
121
|
+
There is deliberately **no** way to disable a rule. The only suppression
|
|
122
|
+
mechanism is an allowlist entry, and every entry must say why.
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
{
|
|
126
|
+
"rule": "color-contrast",
|
|
127
|
+
"reason": "Stripe's hosted card iframe; contrast is fixed upstream and we cannot restyle it.",
|
|
128
|
+
"routes": ["/billing/*"],
|
|
129
|
+
"selectors": ["#stripe-card-frame"],
|
|
130
|
+
"expires": "2026-12-31",
|
|
131
|
+
"ticket": "BOFF-1234"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Field | Required | Rules |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `rule` | yes | one axe rule id. Wildcards (`*`, `**`, `.*`) are rejected. |
|
|
138
|
+
| `reason` | yes | ≥ 20 characters of actual prose. `TODO`, `TBD`, `known issue`, `legacy`, `n/a` and "placeholder + nothing substantive" (`TODO: fix later`) are rejected. |
|
|
139
|
+
| `routes` | no | exact path or `/prefix/*` subtree. A scope naming a route that is not audited fails the run as a typo. |
|
|
140
|
+
| `selectors` | no | matches the node's own axe target, or an ancestor prefix of it on a combinator boundary. `#billing` does **not** match `#billing-panel`. |
|
|
141
|
+
| `expires` | no | ISO `YYYY-MM-DD`. Past its end-of-day the entry suppresses nothing and the violations under it start failing again. |
|
|
142
|
+
| `ticket` | no | Linear id, surfaced in the report. |
|
|
143
|
+
|
|
144
|
+
Omitting both `routes` and `selectors` is allowed but it is a fleet-wide
|
|
145
|
+
exemption for that rule, and the report labels it as unscoped so it is visible in
|
|
146
|
+
review.
|
|
147
|
+
|
|
148
|
+
Entries that matched nothing in a run are reported as **stale**. Set
|
|
149
|
+
`failOnStaleAllowlist: true` once a repo's list is clean to keep it that way.
|
|
150
|
+
|
|
151
|
+
## Output
|
|
152
|
+
|
|
153
|
+
- console report (route by route, rule by rule, with the failing selectors),
|
|
154
|
+
- `a11y-report.json` (path configurable via `reportPath` or `--json=`), which is
|
|
155
|
+
what you attach to a CI artifact,
|
|
156
|
+
- a markdown table appended to `$GITHUB_STEP_SUMMARY` when running in Actions.
|
|
157
|
+
|
|
158
|
+
## CLI flags
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
a11y-audit [configPath]
|
|
162
|
+
[--base-url=URL] audit a running deployment instead of serving dist
|
|
163
|
+
[--json=PATH] where to write the JSON report
|
|
164
|
+
[--route=/path] repeatable; audit only these routes
|
|
165
|
+
[--quiet] suppress the console report, keep the verdict
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Wiring it into CI
|
|
169
|
+
|
|
170
|
+
Copy `a11y-check.yml.tmpl` from
|
|
171
|
+
`~/products/dev/platform/context/cicd/templates/` into `.github/workflows/`.
|
|
172
|
+
Rollout guide: `~/products/dev/platform/context/ui-simplification/HOW_TO_ADD_A11Y_CI.md`.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist matching and violation partitioning.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only — this is the part of the gate that decides what is
|
|
5
|
+
* allowed to not fail a build, so it is unit-tested directly rather than
|
|
6
|
+
* inferred from a green CI run.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AllowedNode, AllowlistEntry, FlatNode, ImpactLevel, RouteSpec } from './types';
|
|
10
|
+
|
|
11
|
+
/** axe targets nest one level for iframes; flatten to plain selector strings. */
|
|
12
|
+
export function flattenTarget(target: unknown): string[] {
|
|
13
|
+
if (typeof target === 'string') return [target];
|
|
14
|
+
if (!Array.isArray(target)) return [];
|
|
15
|
+
return target.flatMap((t) => flattenTarget(t));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeSelector(selector: string): string {
|
|
19
|
+
return selector.trim().replace(/\s+/g, ' ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A configured selector matches an axe target when it is the target itself or
|
|
24
|
+
* an ancestor prefix of it. `#billing-panel` therefore covers
|
|
25
|
+
* `#billing-panel > table > tr:nth-child(2) > td`, but `#billing` does not —
|
|
26
|
+
* the boundary must fall on a combinator, so prefixes never match by accident.
|
|
27
|
+
*/
|
|
28
|
+
export function selectorMatches(allowSelector: string, target: string): boolean {
|
|
29
|
+
const allow = normalizeSelector(allowSelector);
|
|
30
|
+
const actual = normalizeSelector(target);
|
|
31
|
+
if (allow.length === 0) return false;
|
|
32
|
+
if (allow === actual) return true;
|
|
33
|
+
if (!actual.startsWith(allow)) return false;
|
|
34
|
+
const boundary = actual.charAt(allow.length);
|
|
35
|
+
return boundary === ' ' || boundary === '>' || boundary === '+' || boundary === '~';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A configured route matches either exactly, or as a `/prefix/*` subtree.
|
|
40
|
+
* `/settings/*` covers `/settings/profile` and `/settings` itself.
|
|
41
|
+
*/
|
|
42
|
+
export function routeMatches(allowRoute: string, routePath: string): boolean {
|
|
43
|
+
const allow = allowRoute.trim();
|
|
44
|
+
const actual = routePath.trim();
|
|
45
|
+
if (allow === actual) return true;
|
|
46
|
+
if (!allow.endsWith('/*')) return false;
|
|
47
|
+
const prefix = allow.slice(0, -2);
|
|
48
|
+
if (prefix.length === 0) return false;
|
|
49
|
+
return actual === prefix || actual.startsWith(`${prefix}/`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An entry with an `expires` date in the past suppresses nothing. */
|
|
53
|
+
export function isExpired(entry: AllowlistEntry, now: Date): boolean {
|
|
54
|
+
if (!entry.expires) return false;
|
|
55
|
+
const expiryEnd = new Date(`${entry.expires}T23:59:59.999Z`).getTime();
|
|
56
|
+
return now.getTime() > expiryEnd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Does this entry cover this specific failing node? */
|
|
60
|
+
export function entryCovers(entry: AllowlistEntry, node: FlatNode, now: Date): boolean {
|
|
61
|
+
if (entry.rule !== node.rule) return false;
|
|
62
|
+
if (isExpired(entry, now)) return false;
|
|
63
|
+
if (entry.routes && !entry.routes.some((r) => routeMatches(r, node.route))) return false;
|
|
64
|
+
if (
|
|
65
|
+
entry.selectors &&
|
|
66
|
+
!entry.selectors.some((s) => node.target.some((t) => selectorMatches(s, t)))
|
|
67
|
+
) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Shape of the subset of an axe violation this module reads. */
|
|
74
|
+
export interface AxeViolationLike {
|
|
75
|
+
id: string;
|
|
76
|
+
impact?: string | null;
|
|
77
|
+
help?: string;
|
|
78
|
+
helpUrl?: string;
|
|
79
|
+
tags?: string[];
|
|
80
|
+
nodes: Array<{
|
|
81
|
+
target: unknown;
|
|
82
|
+
html?: string;
|
|
83
|
+
impact?: string | null;
|
|
84
|
+
failureSummary?: string;
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Flatten axe violations into one record per failing DOM node. */
|
|
89
|
+
export function flattenViolations(
|
|
90
|
+
violations: AxeViolationLike[],
|
|
91
|
+
context: { route: string; routeName: string; viewport: string }
|
|
92
|
+
): FlatNode[] {
|
|
93
|
+
const flat: FlatNode[] = [];
|
|
94
|
+
for (const violation of violations) {
|
|
95
|
+
for (const node of violation.nodes) {
|
|
96
|
+
flat.push({
|
|
97
|
+
rule: violation.id,
|
|
98
|
+
impact: (node.impact ?? violation.impact ?? 'minor') as ImpactLevel,
|
|
99
|
+
help: violation.help ?? '',
|
|
100
|
+
helpUrl: violation.helpUrl ?? '',
|
|
101
|
+
tags: violation.tags ?? [],
|
|
102
|
+
route: context.route,
|
|
103
|
+
routeName: context.routeName,
|
|
104
|
+
viewport: context.viewport,
|
|
105
|
+
target: flattenTarget(node.target),
|
|
106
|
+
html: (node.html ?? '').slice(0, 400),
|
|
107
|
+
failureSummary: node.failureSummary ?? '',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return flat;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PartitionResult {
|
|
115
|
+
blocking: FlatNode[];
|
|
116
|
+
allowed: AllowedNode[];
|
|
117
|
+
advisory: FlatNode[];
|
|
118
|
+
/** Indexes into the allowlist that actually suppressed something. */
|
|
119
|
+
usedEntries: Set<number>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Split flattened nodes into build-failing, explicitly-allowed and advisory
|
|
124
|
+
* (below the failure threshold, reported but not blocking).
|
|
125
|
+
*
|
|
126
|
+
* When several entries cover the same node the first one wins, so the report
|
|
127
|
+
* attributes it to a single, nameable reason.
|
|
128
|
+
*/
|
|
129
|
+
export function partitionNodes(
|
|
130
|
+
nodes: FlatNode[],
|
|
131
|
+
allowlist: AllowlistEntry[],
|
|
132
|
+
failOn: ImpactLevel[],
|
|
133
|
+
now: Date
|
|
134
|
+
): PartitionResult {
|
|
135
|
+
const blocking: FlatNode[] = [];
|
|
136
|
+
const allowed: AllowedNode[] = [];
|
|
137
|
+
const advisory: FlatNode[] = [];
|
|
138
|
+
const usedEntries = new Set<number>();
|
|
139
|
+
|
|
140
|
+
for (const node of nodes) {
|
|
141
|
+
const index = allowlist.findIndex((entry) => entryCovers(entry, node, now));
|
|
142
|
+
if (index >= 0) {
|
|
143
|
+
usedEntries.add(index);
|
|
144
|
+
const entry = allowlist[index];
|
|
145
|
+
allowed.push({
|
|
146
|
+
...node,
|
|
147
|
+
allowlistIndex: index,
|
|
148
|
+
allowlistReason: entry.reason,
|
|
149
|
+
allowlistTicket: entry.ticket,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (failOn.includes(node.impact)) blocking.push(node);
|
|
154
|
+
else advisory.push(node);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { blocking, allowed, advisory, usedEntries };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Entries whose `routes` name a path that is not in the audited route list.
|
|
162
|
+
* Almost always a typo, and a typo here silently widens or voids the scope.
|
|
163
|
+
*/
|
|
164
|
+
export function findUnreachableRouteScopes(
|
|
165
|
+
allowlist: AllowlistEntry[],
|
|
166
|
+
routes: RouteSpec[]
|
|
167
|
+
): Array<{ index: number; route: string }> {
|
|
168
|
+
const unreachable: Array<{ index: number; route: string }> = [];
|
|
169
|
+
allowlist.forEach((entry, index) => {
|
|
170
|
+
if (!entry.routes) return;
|
|
171
|
+
for (const scope of entry.routes) {
|
|
172
|
+
if (!routes.some((route) => routeMatches(scope, route.path))) {
|
|
173
|
+
unreachable.push({ index, route: scope });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return unreachable;
|
|
178
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser driving: navigate each configured route at each configured viewport
|
|
3
|
+
* and run a real axe-core pass against the rendered DOM.
|
|
4
|
+
*
|
|
5
|
+
* `playwright-core` and `@axe-core/playwright` are imported dynamically so the
|
|
6
|
+
* shared runner can live in fe-libs without dragging a browser automation
|
|
7
|
+
* dependency into every consumer of the component library. Consuming repos
|
|
8
|
+
* declare them as devDependencies; a missing one is a loud, actionable error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { partitionNodes, flattenViolations } from './allowlist';
|
|
12
|
+
import type { AxeViolationLike } from './allowlist';
|
|
13
|
+
import type { RunningApp } from './server';
|
|
14
|
+
import type { AuditReport, ResolvedConfig, RouteResult } from './types';
|
|
15
|
+
|
|
16
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- dynamically imported
|
|
17
|
+
third-party modules have no static types available inside this script. */
|
|
18
|
+
type AnyRecord = Record<string, any>;
|
|
19
|
+
|
|
20
|
+
const MISSING_DEP_HINT =
|
|
21
|
+
'Add them to the repo devDependencies:\n' +
|
|
22
|
+
' bun add -d @playwright/test @axe-core/playwright\n' +
|
|
23
|
+
' bunx playwright install --with-deps chromium';
|
|
24
|
+
|
|
25
|
+
async function loadPlaywright(): Promise<AnyRecord> {
|
|
26
|
+
for (const specifier of ['playwright', 'playwright-core', '@playwright/test']) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = (await import(specifier)) as AnyRecord;
|
|
29
|
+
if (mod?.chromium) return mod.chromium as AnyRecord;
|
|
30
|
+
} catch {
|
|
31
|
+
/* try the next specifier */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`[a11y-audit] Cannot resolve Playwright.\n ${MISSING_DEP_HINT}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type AxeBuilderCtor = new (options: { page: AnyRecord }) => {
|
|
38
|
+
withTags: (tags: string[]) => { analyze: () => Promise<AnyRecord> };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function loadAxeBuilder(): Promise<AxeBuilderCtor> {
|
|
42
|
+
try {
|
|
43
|
+
const mod = (await import('@axe-core/playwright')) as AnyRecord;
|
|
44
|
+
return (mod.default ?? mod) as AxeBuilderCtor;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[a11y-audit] Cannot resolve @axe-core/playwright (${(error as Error).message}).\n ` +
|
|
48
|
+
MISSING_DEP_HINT,
|
|
49
|
+
{ cause: error }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadAxeVersion(): Promise<string> {
|
|
55
|
+
try {
|
|
56
|
+
const mod = (await import('axe-core')) as AnyRecord;
|
|
57
|
+
return (mod.default?.version ?? mod.version ?? 'unknown') as string;
|
|
58
|
+
} catch {
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DomSnapshot {
|
|
64
|
+
textLength: number;
|
|
65
|
+
elementCount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function snapshotDom(page: AnyRecord): Promise<DomSnapshot> {
|
|
69
|
+
return (await page.evaluate(() => ({
|
|
70
|
+
textLength: (document.body?.innerText ?? '').trim().length,
|
|
71
|
+
elementCount: document.querySelectorAll('*').length,
|
|
72
|
+
}))) as DomSnapshot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Poll until the rendered DOM stops changing for `quietMs`, or `timeoutMs`
|
|
77
|
+
* elapses. Returns the final snapshot and whether it actually went quiet.
|
|
78
|
+
*
|
|
79
|
+
* This is what makes the gate reproducible on a shell that paints a splash
|
|
80
|
+
* before it paints the page.
|
|
81
|
+
*/
|
|
82
|
+
async function waitForStableDom(
|
|
83
|
+
page: AnyRecord,
|
|
84
|
+
quietMs: number,
|
|
85
|
+
timeoutMs: number
|
|
86
|
+
): Promise<{ snapshot: DomSnapshot; settled: boolean }> {
|
|
87
|
+
const pollMs = 250;
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
let previous = await snapshotDom(page);
|
|
90
|
+
let quietSince = Date.now();
|
|
91
|
+
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
await page.waitForTimeout(pollMs);
|
|
94
|
+
const current = await snapshotDom(page);
|
|
95
|
+
if (
|
|
96
|
+
current.textLength !== previous.textLength ||
|
|
97
|
+
current.elementCount !== previous.elementCount
|
|
98
|
+
) {
|
|
99
|
+
previous = current;
|
|
100
|
+
quietSince = Date.now();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
previous = current;
|
|
104
|
+
if (Date.now() - quietSince >= quietMs) return { snapshot: current, settled: true };
|
|
105
|
+
}
|
|
106
|
+
return { snapshot: previous, settled: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Run the full audit. Throws on navigation failure — never silently skips a route. */
|
|
110
|
+
export async function runAudit(
|
|
111
|
+
config: ResolvedConfig,
|
|
112
|
+
app: Pick<RunningApp, 'baseUrl' | 'resolveAsset'>,
|
|
113
|
+
now: Date = new Date()
|
|
114
|
+
): Promise<AuditReport> {
|
|
115
|
+
const { baseUrl, resolveAsset } = app;
|
|
116
|
+
const chromium = await loadPlaywright();
|
|
117
|
+
const AxeBuilder = await loadAxeBuilder();
|
|
118
|
+
const axeVersion = await loadAxeVersion();
|
|
119
|
+
|
|
120
|
+
const browser = await chromium.launch({
|
|
121
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
122
|
+
});
|
|
123
|
+
const results: RouteResult[] = [];
|
|
124
|
+
const usedEntries = new Set<number>();
|
|
125
|
+
const navigationErrors: string[] = [];
|
|
126
|
+
|
|
127
|
+
/** Fresh page with request routing applied — used for the first try and every retry. */
|
|
128
|
+
const openPage = async (context: AnyRecord): Promise<AnyRecord> => {
|
|
129
|
+
const page = await context.newPage();
|
|
130
|
+
if (config.blockExternalRequests || resolveAsset) {
|
|
131
|
+
await page.route('**/*', (route: AnyRecord) => {
|
|
132
|
+
const requestUrl: string = route.request().url();
|
|
133
|
+
const isOwnOrigin = requestUrl.startsWith(baseUrl);
|
|
134
|
+
|
|
135
|
+
// Serve the build's own files straight from disk. Chromium never opens
|
|
136
|
+
// a socket for them, so a spurious network-change notification cannot
|
|
137
|
+
// cancel a hundred in-flight chunk loads and leave the shell blank.
|
|
138
|
+
if (isOwnOrigin && resolveAsset) {
|
|
139
|
+
const asset = resolveAsset(new URL(requestUrl).pathname);
|
|
140
|
+
if (asset) {
|
|
141
|
+
void route.fulfill({
|
|
142
|
+
status: 200,
|
|
143
|
+
contentType: asset.contentType,
|
|
144
|
+
headers: { 'cache-control': 'no-store' },
|
|
145
|
+
body: asset.body,
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
void route.fulfill({ status: 404, contentType: 'text/plain', body: 'Not found' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Deterministic DOM: nothing the audit sees depends on a live backend,
|
|
154
|
+
// a CDN or the runner's network latency.
|
|
155
|
+
if (isOwnOrigin || requestUrl.startsWith('data:') || !config.blockExternalRequests) {
|
|
156
|
+
void route.continue();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
void route.abort();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return page;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
for (const viewport of config.viewports) {
|
|
167
|
+
const context = await browser.newContext({
|
|
168
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
169
|
+
// Deterministic rendering: no OS locale/timezone drift between the dev
|
|
170
|
+
// machine and the CI runner changing what axe sees.
|
|
171
|
+
locale: 'en-US',
|
|
172
|
+
timezoneId: 'UTC',
|
|
173
|
+
reducedMotion: 'reduce',
|
|
174
|
+
// A service worker caching the shell across routes would make the audit
|
|
175
|
+
// depend on which route ran first.
|
|
176
|
+
serviceWorkers: 'block',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const route of config.routes) {
|
|
180
|
+
const url = `${baseUrl}${route.path}`;
|
|
181
|
+
const attemptFailures: string[] = [];
|
|
182
|
+
let result: RouteResult | null = null;
|
|
183
|
+
|
|
184
|
+
for (let attempt = 1; attempt <= config.routeAttempts && result === null; attempt += 1) {
|
|
185
|
+
const page = await openPage(context);
|
|
186
|
+
try {
|
|
187
|
+
const response = await page.goto(url, {
|
|
188
|
+
waitUntil: 'load',
|
|
189
|
+
timeout: config.navigationTimeoutMs,
|
|
190
|
+
});
|
|
191
|
+
if (response && response.status() >= 400) {
|
|
192
|
+
throw new Error(`HTTP ${response.status()}`);
|
|
193
|
+
}
|
|
194
|
+
if (route.waitForSelector) {
|
|
195
|
+
await page.waitForSelector(route.waitForSelector, {
|
|
196
|
+
timeout: config.navigationTimeoutMs,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
await page.waitForTimeout(route.settleMs ?? config.settleMs);
|
|
200
|
+
|
|
201
|
+
const stability = await waitForStableDom(
|
|
202
|
+
page,
|
|
203
|
+
config.domQuietMs,
|
|
204
|
+
config.navigationTimeoutMs
|
|
205
|
+
);
|
|
206
|
+
const floor = route.minTextLength ?? config.minTextLength;
|
|
207
|
+
if (stability.snapshot.textLength < floor) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`rendered only ${stability.snapshot.textLength} characters of text (floor is ` +
|
|
210
|
+
`${floor}) — the page never finished painting, so a clean axe result here ` +
|
|
211
|
+
'would be meaningless'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const axeResults = (await new AxeBuilder({ page })
|
|
216
|
+
.withTags(config.standard)
|
|
217
|
+
.analyze()) as AnyRecord;
|
|
218
|
+
|
|
219
|
+
const nodes = flattenViolations(axeResults.violations as AxeViolationLike[], {
|
|
220
|
+
route: route.path,
|
|
221
|
+
routeName: route.name,
|
|
222
|
+
viewport: viewport.name,
|
|
223
|
+
});
|
|
224
|
+
const partition = partitionNodes(nodes, config.allowlist, config.failOn, now);
|
|
225
|
+
partition.usedEntries.forEach((index) => usedEntries.add(index));
|
|
226
|
+
|
|
227
|
+
result = {
|
|
228
|
+
route: route.path,
|
|
229
|
+
routeName: route.name,
|
|
230
|
+
viewport: viewport.name,
|
|
231
|
+
finalUrl: page.url(),
|
|
232
|
+
blocking: partition.blocking,
|
|
233
|
+
allowed: partition.allowed,
|
|
234
|
+
advisory: partition.advisory,
|
|
235
|
+
passCount: (axeResults.passes as unknown[]).length,
|
|
236
|
+
incompleteCount: (axeResults.incomplete as unknown[]).length,
|
|
237
|
+
renderedTextLength: stability.snapshot.textLength,
|
|
238
|
+
domSettled: stability.settled,
|
|
239
|
+
attempts: attempt,
|
|
240
|
+
};
|
|
241
|
+
} catch (error) {
|
|
242
|
+
attemptFailures.push(`attempt ${attempt}: ${(error as Error).message}`);
|
|
243
|
+
} finally {
|
|
244
|
+
await page.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (result === null) {
|
|
249
|
+
navigationErrors.push(
|
|
250
|
+
`${route.name} @ ${viewport.name} (${url}):\n ${attemptFailures.join('\n ')}`
|
|
251
|
+
);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (result.attempts > 1) {
|
|
255
|
+
console.warn(
|
|
256
|
+
`[a11y-audit] ${route.name} @ ${viewport.name} needed ${result.attempts} attempts ` +
|
|
257
|
+
`(${attemptFailures.join('; ')})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
results.push(result);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await context.close();
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
await browser.close();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (navigationErrors.length > 0) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`[a11y-audit] ${navigationErrors.length} route(s) could not be loaded, so the audit is ` +
|
|
272
|
+
'incomplete and cannot be treated as a pass:\n - ' +
|
|
273
|
+
navigationErrors.join('\n - ')
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const staleAllowlistEntries = config.allowlist
|
|
278
|
+
.map((entry, index) => ({ index, rule: entry.rule, reason: entry.reason }))
|
|
279
|
+
.filter((entry) => !usedEntries.has(entry.index));
|
|
280
|
+
|
|
281
|
+
const expiredAllowlistEntries = config.allowlist
|
|
282
|
+
.map((entry, index) => ({ index, rule: entry.rule, expires: entry.expires ?? '' }))
|
|
283
|
+
.filter((entry) => {
|
|
284
|
+
if (!entry.expires) return false;
|
|
285
|
+
return now.getTime() > new Date(`${entry.expires}T23:59:59.999Z`).getTime();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
name: config.name,
|
|
290
|
+
generatedAt: now.toISOString(),
|
|
291
|
+
baseUrl,
|
|
292
|
+
standard: config.standard,
|
|
293
|
+
failOn: config.failOn,
|
|
294
|
+
axeVersion,
|
|
295
|
+
blockExternalRequests: config.blockExternalRequests,
|
|
296
|
+
routes: results,
|
|
297
|
+
staleAllowlistEntries,
|
|
298
|
+
expiredAllowlistEntries,
|
|
299
|
+
totals: {
|
|
300
|
+
blocking: results.reduce((sum, r) => sum + r.blocking.length, 0),
|
|
301
|
+
allowed: results.reduce((sum, r) => sum + r.allowed.length, 0),
|
|
302
|
+
advisory: results.reduce((sum, r) => sum + r.advisory.length, 0),
|
|
303
|
+
routesAudited: results.length,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|