@burdenoff/fe-libs 2026.825.1 → 2026.825.3
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/dist/shared/native/push-registration.d.ts +6 -1
- package/dist/shared/native/push-registration.d.ts.map +1 -1
- package/dist/shared/native/push-registration.js +7 -4
- package/dist/shell-native/web-push.d.ts.map +1 -1
- package/dist/shell-native/web-push.js +41 -22
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* a11y-audit — the fleet's WCAG 2.1 AA gate.
|
|
4
|
+
*
|
|
5
|
+
* Runs a real axe-core pass against a built app shell, microfrontend harness or
|
|
6
|
+
* website and fails the build on `serious` and `critical` violations. Known
|
|
7
|
+
* exceptions live in an explicit allowlist where every entry must carry a
|
|
8
|
+
* written reason; there is no way to switch a rule off wholesale.
|
|
9
|
+
*
|
|
10
|
+
* This replaces the per-repo Playwright specs that asserted `critical` only via
|
|
11
|
+
* `expect.soft`, which could never fail a build and were never wired into CI.
|
|
12
|
+
*
|
|
13
|
+
* Usage (from a consuming repo root, after `bun run build`):
|
|
14
|
+
* bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts
|
|
15
|
+
* or, since fe-libs declares this as a package `bin`:
|
|
16
|
+
* a11y-audit [configPath] [--base-url=URL] [--json=PATH] [--route=/path ...] [--quiet]
|
|
17
|
+
*
|
|
18
|
+
* `configPath` defaults to `./a11y-audit.config.json`.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes:
|
|
21
|
+
* 0 no blocking violations
|
|
22
|
+
* 1 blocking violations (or a stale allowlist when failOnStaleAllowlist is set)
|
|
23
|
+
* 2 the audit could not run — bad config, app never came up, route failed to load
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { runAudit } from './audit';
|
|
29
|
+
import { ConfigError, resolveConfig } from './config';
|
|
30
|
+
import { renderConsole, renderMarkdown } from './report';
|
|
31
|
+
import { findUnreachableRouteScopes } from './allowlist';
|
|
32
|
+
import { startApp } from './server';
|
|
33
|
+
|
|
34
|
+
interface CliArgs {
|
|
35
|
+
configPath: string;
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
jsonPath?: string;
|
|
38
|
+
routes: string[];
|
|
39
|
+
quiet: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
43
|
+
const args: CliArgs = { configPath: 'a11y-audit.config.json', routes: [], quiet: false };
|
|
44
|
+
let positionalSeen = false;
|
|
45
|
+
for (const arg of argv) {
|
|
46
|
+
if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length);
|
|
47
|
+
else if (arg.startsWith('--json=')) args.jsonPath = arg.slice('--json='.length);
|
|
48
|
+
else if (arg.startsWith('--route=')) args.routes.push(arg.slice('--route='.length));
|
|
49
|
+
else if (arg === '--quiet') args.quiet = true;
|
|
50
|
+
else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`);
|
|
51
|
+
else if (!positionalSeen) {
|
|
52
|
+
args.configPath = arg;
|
|
53
|
+
positionalSeen = true;
|
|
54
|
+
} else throw new Error(`Unexpected argument: ${arg}`);
|
|
55
|
+
}
|
|
56
|
+
return args;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main(): Promise<number> {
|
|
60
|
+
let args: CliArgs;
|
|
61
|
+
try {
|
|
62
|
+
args = parseArgs(process.argv.slice(2));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`[a11y-audit] ${(error as Error).message}`);
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const configPath = resolve(process.cwd(), args.configPath);
|
|
69
|
+
if (!existsSync(configPath)) {
|
|
70
|
+
console.error(`[a11y-audit] Config not found: ${configPath}`);
|
|
71
|
+
console.error(
|
|
72
|
+
'Create an a11y-audit.config.json at your repo root. See ' +
|
|
73
|
+
'@burdenoff/fe-libs/scripts/a11y-audit/README.md for the config shape.'
|
|
74
|
+
);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let config;
|
|
79
|
+
try {
|
|
80
|
+
config = resolveConfig(JSON.parse(readFileSync(configPath, 'utf8')));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ConfigError) console.error(`[a11y-audit] ${error.message}`);
|
|
83
|
+
else console.error(`[a11y-audit] Failed to read ${configPath}: ${(error as Error).message}`);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (args.routes.length > 0) {
|
|
88
|
+
const filtered = config.routes.filter((route) => args.routes.includes(route.path));
|
|
89
|
+
if (filtered.length === 0) {
|
|
90
|
+
console.error(`[a11y-audit] --route filter matched none of the configured routes.`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
config.routes = filtered;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unreachable = findUnreachableRouteScopes(config.allowlist, config.routes);
|
|
97
|
+
if (unreachable.length > 0 && args.routes.length === 0) {
|
|
98
|
+
for (const item of unreachable) {
|
|
99
|
+
console.error(
|
|
100
|
+
`[a11y-audit] allowlist[${item.index}] is scoped to route "${item.route}", which is not ` +
|
|
101
|
+
'in the audited route list — fix the typo or the scope is silently doing nothing'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let app;
|
|
108
|
+
try {
|
|
109
|
+
app = await startApp(config, args.baseUrl);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`[a11y-audit] Could not reach the app under test: ${(error as Error).message}`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const report = await runAudit(config, app);
|
|
117
|
+
|
|
118
|
+
const jsonPath = resolve(process.cwd(), args.jsonPath ?? config.reportPath);
|
|
119
|
+
mkdirSync(dirname(jsonPath), { recursive: true });
|
|
120
|
+
writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
121
|
+
|
|
122
|
+
if (!args.quiet) console.log(renderConsole(report));
|
|
123
|
+
console.log(`[a11y-audit] JSON report written to ${jsonPath}`);
|
|
124
|
+
|
|
125
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
126
|
+
if (summaryPath) {
|
|
127
|
+
writeFileSync(summaryPath, renderMarkdown(report), { flag: 'a' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (report.totals.blocking > 0) {
|
|
131
|
+
console.error(
|
|
132
|
+
`[a11y-audit] FAILED: ${report.totals.blocking} blocking violation node(s) at ` +
|
|
133
|
+
`${config.failOn.join('/')} impact. Fix them, or add a narrowly-scoped allowlist entry ` +
|
|
134
|
+
'with a written reason and an expiry.'
|
|
135
|
+
);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
if (config.failOnStaleAllowlist && report.staleAllowlistEntries.length > 0) {
|
|
139
|
+
console.error(
|
|
140
|
+
`[a11y-audit] FAILED: ${report.staleAllowlistEntries.length} allowlist entr(ies) matched ` +
|
|
141
|
+
'nothing and failOnStaleAllowlist is set — delete them.'
|
|
142
|
+
);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
console.log('[a11y-audit] PASSED: no blocking accessibility violations.');
|
|
146
|
+
return 0;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error(`[a11y-audit] ${(error as Error).message}`);
|
|
149
|
+
return 2;
|
|
150
|
+
} finally {
|
|
151
|
+
await app.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().then(
|
|
156
|
+
(code) => process.exit(code),
|
|
157
|
+
(error) => {
|
|
158
|
+
console.error(`[a11y-audit] Unexpected failure: ${(error as Error).stack ?? error}`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
);
|