@burdenoff/fe-libs 2026.1022.3 → 2026.1023.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/fe-libs",
3
- "version": "2026.1022.3",
3
+ "version": "2026.1023.2",
4
4
  "description": "Burdenoff frontend primitives and domain libraries",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,6 +110,69 @@ how you point the same config at alpha or prod.
110
110
 
111
111
  Per route you can also set `waitForSelector` and a route-specific `settleMs`.
112
112
 
113
+ ### Signed-in routes: `fixtures`
114
+
115
+ External requests are blocked, so a signed-in page cannot reach its gateway and
116
+ the shell bounces to `/auth/login`. A **fixture** gives a route a deterministic
117
+ session instead of a live one:
118
+
119
+ ```jsonc
120
+ // a11y-audit.config.json
121
+ "routes": [
122
+ { "path": "/vibecontrols", "name": "signed-in-overview", "fixture": "signed-in",
123
+ "waitForSelector": "main#main-content h1", "minTextLength": 1500 }
124
+ ],
125
+ "fixtures": { "signed-in": "a11y/fixtures/signed-in.json" } // or the object inline
126
+ ```
127
+
128
+ ```jsonc
129
+ // a11y/fixtures/signed-in.json
130
+ {
131
+ "localStorage": {
132
+ "bf-p-<profile>-tokens": {
133
+ "accessToken": { "$jwt": { "sub": "<user>", "exp": { "$fromNow": 3600 } } },
134
+ "expiresAt": { "$fromNowMs": 3600000 }
135
+ }
136
+ },
137
+ "cookies": { "bf-sso": { "id": "<profile>", "origin": null } },
138
+ "graphql": {
139
+ "endpoints": ["/global/graphql", "/workspaces/graphql"],
140
+ "operations": { "ValidateAuthToken": { "data": { "validateTokens": { "valid": true } } } }
141
+ }
142
+ }
143
+ ```
144
+
145
+ | Field | Meaning |
146
+ | --- | --- |
147
+ | `localStorage` | Seeded before any page script runs (same-origin documents only). Strings are stored as-is, anything else as JSON. |
148
+ | `cookies` | Set on the app origin before the page opens. Strings as-is, anything else as URL-encoded JSON. |
149
+ | `graphql.endpoints` | Path suffixes that identify a GraphQL endpoint on **any** origin. Matching requests never leave the browser. |
150
+ | `graphql.operations` | Response body per operation name. Batched requests get a batched answer in order. |
151
+
152
+ Markers, resolved when each page opens:
153
+
154
+ - `{ "$jwt": { ...claims } }` becomes an **unsigned** (`alg: none`) token. Never
155
+ put a real or real-looking credential in a fixture.
156
+ - `{ "$fromNow": seconds }` becomes unix seconds from now (JWT `exp`/`iat`);
157
+ `{ "$fromNowMs": ms }` becomes epoch milliseconds from now (`expiresAt`). Do
158
+ not use a fixed far-future expiry: shells schedule refresh timers off it, and
159
+ a delay past setTimeout's 2^31-1 ms ceiling fires immediately.
160
+
161
+ Guarantees:
162
+
163
+ - Every route runs in its own browser context, so a fixture's session never
164
+ leaks into the signed-out routes (or the other way round).
165
+ - A fixture route that ends anywhere other than its `path` (the query string is
166
+ ignored) is a failed attempt: landing on `/auth/login` means the fixture did
167
+ not sign in, and auditing the login page again would be a false pass. Set
168
+ `allowRedirect` on the route only if the redirect is the page.
169
+ - An operation the fixture has no answer for gets a GraphQL error response
170
+ (code `A11Y_FIXTURE_UNMOCKED`), never a live call, and is listed under the
171
+ route in the console and step-summary report. Keep that list empty: an
172
+ unmocked query usually means part of the page rendered its error state.
173
+ - Fixture data is synthetic. Use obviously fake ids and names, never an export
174
+ of a real account.
175
+
113
176
  ### `failOn`
114
177
 
115
178
  Defaults to `["critical", "serious"]`. It may be **widened** (adding `moderate`),
@@ -9,9 +9,16 @@
9
9
  */
10
10
 
11
11
  import { partitionNodes, flattenViolations } from './allowlist';
12
+ import {
13
+ materializeCookies,
14
+ materializeLocalStorage,
15
+ matchesGraphqlEndpoint,
16
+ respondToGraphql,
17
+ seedLocalStorageScript,
18
+ } from './fixtures';
12
19
  import type { AxeViolationLike } from './allowlist';
13
20
  import type { RunningApp } from './server';
14
- import type { AuditReport, ResolvedConfig, RouteResult } from './types';
21
+ import type { AuditReport, FixtureSpec, ResolvedConfig, RouteResult } from './types';
15
22
 
16
23
  /* eslint-disable @typescript-eslint/no-explicit-any -- dynamically imported
17
24
  third-party modules have no static types available inside this script. */
@@ -124,14 +131,73 @@ export async function runAudit(
124
131
  const usedEntries = new Set<number>();
125
132
  const navigationErrors: string[] = [];
126
133
 
127
- /** Fresh page with request routing applied — used for the first try and every retry. */
128
- const openPage = async (context: AnyRecord): Promise<AnyRecord> => {
134
+ /**
135
+ * Fresh page with request routing applied — used for the first try and every
136
+ * retry. With a fixture, its session is seeded before any page script runs
137
+ * and its GraphQL endpoints are answered from the fixture (recording any
138
+ * operation it has no answer for in `unmocked`).
139
+ */
140
+ const openPage = async (
141
+ context: AnyRecord,
142
+ fixture: FixtureSpec | undefined,
143
+ unmocked: Set<string>
144
+ ): Promise<AnyRecord> => {
145
+ if (fixture?.cookies) {
146
+ await context.addCookies(
147
+ materializeCookies(fixture).map((cookie) => ({
148
+ ...cookie,
149
+ url: baseUrl,
150
+ }))
151
+ );
152
+ }
129
153
  const page = await context.newPage();
130
- if (config.blockExternalRequests || resolveAsset) {
154
+ if (fixture?.localStorage) {
155
+ await page.addInitScript(seedLocalStorageScript, {
156
+ origin: new URL(baseUrl).origin,
157
+ entries: materializeLocalStorage(fixture),
158
+ });
159
+ }
160
+ if (config.blockExternalRequests || resolveAsset || fixture?.graphql) {
131
161
  await page.route('**/*', (route: AnyRecord) => {
132
- const requestUrl: string = route.request().url();
162
+ const request = route.request();
163
+ const requestUrl: string = request.url();
133
164
  const isOwnOrigin = requestUrl.startsWith(baseUrl);
134
165
 
166
+ // Fixture GraphQL first: on any origin, a matching endpoint is answered
167
+ // from the fixture and never reaches a live gateway.
168
+ if (fixture?.graphql && matchesGraphqlEndpoint(requestUrl, fixture)) {
169
+ if (request.method() === 'OPTIONS') {
170
+ void route.fulfill({
171
+ status: 204,
172
+ headers: {
173
+ 'access-control-allow-origin': new URL(baseUrl).origin,
174
+ 'access-control-allow-credentials': 'true',
175
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
176
+ 'access-control-allow-headers': '*',
177
+ },
178
+ body: '',
179
+ });
180
+ return;
181
+ }
182
+ const answer = respondToGraphql(
183
+ fixture,
184
+ request.method() as string,
185
+ requestUrl,
186
+ (request.postData() as string | null) ?? null
187
+ );
188
+ answer.unmocked.forEach((name) => unmocked.add(name));
189
+ void route.fulfill({
190
+ status: answer.status,
191
+ contentType: 'application/json',
192
+ headers: {
193
+ 'access-control-allow-origin': new URL(baseUrl).origin,
194
+ 'access-control-allow-credentials': 'true',
195
+ },
196
+ body: answer.body,
197
+ });
198
+ return;
199
+ }
200
+
135
201
  // Serve the build's own files straight from disk. Chromium never opens
136
202
  // a socket for them, so a spurious network-change notification cannot
137
203
  // cancel a hundred in-flight chunk loads and leave the shell blank.
@@ -146,7 +212,11 @@ export async function runAudit(
146
212
  });
147
213
  return;
148
214
  }
149
- void route.fulfill({ status: 404, contentType: 'text/plain', body: 'Not found' });
215
+ void route.fulfill({
216
+ status: 404,
217
+ contentType: 'text/plain',
218
+ body: 'Not found',
219
+ });
150
220
  return;
151
221
  }
152
222
 
@@ -162,27 +232,34 @@ export async function runAudit(
162
232
  return page;
163
233
  };
164
234
 
235
+ // Every route gets its own browser context, so a fixture's seeded session
236
+ // can never leak into the signed-out routes audited after it (or the other
237
+ // way round), and route order cannot change what axe sees.
238
+ const newContext = async (viewport: { width: number; height: number }): Promise<AnyRecord> =>
239
+ browser.newContext({
240
+ viewport: { width: viewport.width, height: viewport.height },
241
+ // Deterministic rendering: no OS locale/timezone drift between the dev
242
+ // machine and the CI runner changing what axe sees.
243
+ locale: 'en-US',
244
+ timezoneId: 'UTC',
245
+ reducedMotion: 'reduce',
246
+ // A service worker caching the shell across routes would make the audit
247
+ // depend on which route ran first.
248
+ serviceWorkers: 'block',
249
+ });
250
+
165
251
  try {
166
252
  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
253
  for (const route of config.routes) {
180
254
  const url = `${baseUrl}${route.path}`;
181
255
  const attemptFailures: string[] = [];
182
256
  let result: RouteResult | null = null;
257
+ const fixture = route.fixture ? config.fixtures[route.fixture] : undefined;
183
258
 
184
259
  for (let attempt = 1; attempt <= config.routeAttempts && result === null; attempt += 1) {
185
- const page = await openPage(context);
260
+ const context = await newContext(viewport);
261
+ const unmocked = new Set<string>();
262
+ const page = await openPage(context, fixture, unmocked);
186
263
  try {
187
264
  const response = await page.goto(url, {
188
265
  waitUntil: 'load',
@@ -203,6 +280,18 @@ export async function runAudit(
203
280
  config.domQuietMs,
204
281
  config.navigationTimeoutMs
205
282
  );
283
+ if (fixture && !route.allowRedirect) {
284
+ const landed = new URL(page.url() as string).pathname.replace(/\/+$/, '') || '/';
285
+ const expected = new URL(url).pathname.replace(/\/+$/, '') || '/';
286
+ if (landed !== expected) {
287
+ throw new Error(
288
+ `fixture "${route.fixture}" route ended on ${landed} instead of ${expected} — ` +
289
+ 'the fixture did not produce the page, so auditing where it landed would be ' +
290
+ 'a false pass' +
291
+ (unmocked.size > 0 ? ` (unmocked operations: ${[...unmocked].join(', ')})` : '')
292
+ );
293
+ }
294
+ }
206
295
  const floor = route.minTextLength ?? config.minTextLength;
207
296
  if (stability.snapshot.textLength < floor) {
208
297
  throw new Error(
@@ -237,11 +326,18 @@ export async function runAudit(
237
326
  renderedTextLength: stability.snapshot.textLength,
238
327
  domSettled: stability.settled,
239
328
  attempts: attempt,
329
+ ...(route.fixture
330
+ ? {
331
+ fixture: route.fixture,
332
+ unmockedOperations: [...unmocked].sort(),
333
+ }
334
+ : {}),
240
335
  };
241
336
  } catch (error) {
242
337
  attemptFailures.push(`attempt ${attempt}: ${(error as Error).message}`);
243
338
  } finally {
244
339
  await page.close();
340
+ await context.close();
245
341
  }
246
342
  }
247
343
 
@@ -259,8 +355,6 @@ export async function runAudit(
259
355
  }
260
356
  results.push(result);
261
357
  }
262
-
263
- await context.close();
264
358
  }
265
359
  } finally {
266
360
  await browser.close();
@@ -279,7 +373,11 @@ export async function runAudit(
279
373
  .filter((entry) => !usedEntries.has(entry.index));
280
374
 
281
375
  const expiredAllowlistEntries = config.allowlist
282
- .map((entry, index) => ({ index, rule: entry.rule, expires: entry.expires ?? '' }))
376
+ .map((entry, index) => ({
377
+ index,
378
+ rule: entry.rule,
379
+ expires: entry.expires ?? '',
380
+ }))
283
381
  .filter((entry) => {
284
382
  if (!entry.expires) return false;
285
383
  return now.getTime() > new Date(`${entry.expires}T23:59:59.999Z`).getTime();
@@ -26,7 +26,7 @@
26
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, resolve } from 'node:path';
28
28
  import { runAudit } from './audit';
29
- import { ConfigError, resolveConfig } from './config';
29
+ import { ConfigError, inlineFixtureFiles, resolveConfig } from './config';
30
30
  import { renderConsole, renderMarkdown } from './report';
31
31
  import { findUnreachableRouteScopes } from './allowlist';
32
32
  import { startApp } from './server';
@@ -40,7 +40,11 @@ interface CliArgs {
40
40
  }
41
41
 
42
42
  export function parseArgs(argv: string[]): CliArgs {
43
- const args: CliArgs = { configPath: 'a11y-audit.config.json', routes: [], quiet: false };
43
+ const args: CliArgs = {
44
+ configPath: 'a11y-audit.config.json',
45
+ routes: [],
46
+ quiet: false,
47
+ };
44
48
  let positionalSeen = false;
45
49
  for (const arg of argv) {
46
50
  if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length);
@@ -77,7 +81,10 @@ async function main(): Promise<number> {
77
81
 
78
82
  let config;
79
83
  try {
80
- config = resolveConfig(JSON.parse(readFileSync(configPath, 'utf8')));
84
+ const raw: unknown = JSON.parse(readFileSync(configPath, 'utf8'));
85
+ config = resolveConfig(
86
+ inlineFixtureFiles(raw, dirname(configPath), (path) => readFileSync(path, 'utf8'))
87
+ );
81
88
  } catch (error) {
82
89
  if (error instanceof ConfigError) console.error(`[a11y-audit] ${error.message}`);
83
90
  else console.error(`[a11y-audit] Failed to read ${configPath}: ${(error as Error).message}`);
@@ -11,6 +11,7 @@ import { IMPACT_LEVELS } from './types';
11
11
  import type {
12
12
  A11yAuditConfig,
13
13
  AllowlistEntry,
14
+ FixtureSpec,
14
15
  ImpactLevel,
15
16
  ResolvedConfig,
16
17
  RouteSpec,
@@ -187,6 +188,73 @@ function validateRoute(route: unknown, index: number, issues: string[]): void {
187
188
  }
188
189
  }
189
190
 
191
+ function validateFixture(name: string, fixture: unknown, issues: string[]): void {
192
+ const where = `fixtures.${name}`;
193
+ if (typeof fixture === 'string') {
194
+ issues.push(
195
+ `${where} is still a file reference ("${fixture}") — load the config with ` +
196
+ 'inlineFixtureFiles() before resolving it'
197
+ );
198
+ return;
199
+ }
200
+ if (!isPlainObject(fixture)) {
201
+ issues.push(`${where} must be an object`);
202
+ return;
203
+ }
204
+ for (const key of Object.keys(fixture)) {
205
+ if (key !== 'localStorage' && key !== 'cookies' && key !== 'graphql') {
206
+ issues.push(
207
+ `${where}.${key} is not a fixture field (allowed: localStorage, cookies, graphql)`
208
+ );
209
+ }
210
+ }
211
+ if (
212
+ fixture.localStorage === undefined &&
213
+ fixture.cookies === undefined &&
214
+ fixture.graphql === undefined
215
+ ) {
216
+ issues.push(
217
+ `${where} must set localStorage, cookies and/or graphql — an empty fixture changes nothing`
218
+ );
219
+ }
220
+ for (const key of ['localStorage', 'cookies'] as const) {
221
+ if (fixture[key] !== undefined && !isPlainObject(fixture[key])) {
222
+ issues.push(`${where}.${key} must be an object of name → value`);
223
+ }
224
+ }
225
+ if (fixture.graphql !== undefined) {
226
+ const graphql = fixture.graphql;
227
+ if (!isPlainObject(graphql)) {
228
+ issues.push(`${where}.graphql must be { endpoints, operations }`);
229
+ return;
230
+ }
231
+ if (
232
+ !Array.isArray(graphql.endpoints) ||
233
+ graphql.endpoints.length === 0 ||
234
+ !graphql.endpoints.every(isNonEmptyString)
235
+ ) {
236
+ issues.push(`${where}.graphql.endpoints must be a non-empty array of path suffixes`);
237
+ } else {
238
+ graphql.endpoints.forEach((endpoint, i) => {
239
+ if (!endpoint.startsWith('/')) {
240
+ issues.push(`${where}.graphql.endpoints[${i}] "${endpoint}" must start with "/"`);
241
+ }
242
+ });
243
+ }
244
+ if (!isPlainObject(graphql.operations) || Object.keys(graphql.operations).length === 0) {
245
+ issues.push(`${where}.graphql.operations must be a non-empty object of operation → response`);
246
+ } else {
247
+ for (const [operation, result] of Object.entries(graphql.operations)) {
248
+ if (!isPlainObject(result) || (!('data' in result) && !('errors' in result))) {
249
+ issues.push(
250
+ `${where}.graphql.operations.${operation} must be a GraphQL response ({ data } or { errors })`
251
+ );
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+
190
258
  function validateServe(serve: unknown, issues: string[]): void {
191
259
  if (!isPlainObject(serve)) {
192
260
  issues.push('serve is required and must be an object');
@@ -286,6 +354,28 @@ export function resolveConfig(raw: unknown): ResolvedConfig {
286
354
  }
287
355
  }
288
356
 
357
+ const fixtureNames = new Set<string>();
358
+ if (raw.fixtures !== undefined) {
359
+ if (!isPlainObject(raw.fixtures)) {
360
+ issues.push('fixtures must be an object of name → fixture when present');
361
+ } else {
362
+ for (const [name, fixture] of Object.entries(raw.fixtures)) {
363
+ fixtureNames.add(name);
364
+ validateFixture(name, fixture, issues);
365
+ }
366
+ }
367
+ }
368
+ if (Array.isArray(raw.routes)) {
369
+ raw.routes.forEach((route, i) => {
370
+ if (!isPlainObject(route) || route.fixture === undefined) return;
371
+ if (!isNonEmptyString(route.fixture)) {
372
+ issues.push(`routes[${i}].fixture must be a non-empty fixture name when present`);
373
+ } else if (!fixtureNames.has(route.fixture)) {
374
+ issues.push(`routes[${i}].fixture "${route.fixture}" is not defined in fixtures`);
375
+ }
376
+ });
377
+ }
378
+
289
379
  if (issues.length > 0) throw new ConfigError(issues);
290
380
 
291
381
  const config = raw as unknown as A11yAuditConfig;
@@ -315,5 +405,38 @@ export function resolveConfig(raw: unknown): ResolvedConfig {
315
405
  domQuietMs: config.domQuietMs ?? 1500,
316
406
  minTextLength: config.minTextLength ?? 100,
317
407
  routeAttempts: config.routeAttempts ?? 3,
408
+ // Validated above: no string (file) entries survive to this point.
409
+ fixtures: (config.fixtures ?? {}) as Record<string, FixtureSpec>,
318
410
  };
319
411
  }
412
+
413
+ /**
414
+ * Replace every fixture given as a file path with the parsed file. Paths
415
+ * resolve against `baseDir` (the config file's directory). `readFile` is
416
+ * injected so this stays pure and testable.
417
+ *
418
+ * @throws ConfigError when a referenced file is missing or is not JSON.
419
+ */
420
+ export function inlineFixtureFiles(
421
+ raw: unknown,
422
+ baseDir: string,
423
+ readFile: (absolutePath: string) => string
424
+ ): unknown {
425
+ if (!isPlainObject(raw) || !isPlainObject(raw.fixtures)) return raw;
426
+ const issues: string[] = [];
427
+ const fixtures: Record<string, unknown> = {};
428
+ for (const [name, fixture] of Object.entries(raw.fixtures)) {
429
+ if (typeof fixture !== 'string') {
430
+ fixtures[name] = fixture;
431
+ continue;
432
+ }
433
+ const path = fixture.startsWith('/') ? fixture : `${baseDir.replace(/\/+$/, '')}/${fixture}`;
434
+ try {
435
+ fixtures[name] = JSON.parse(readFile(path));
436
+ } catch (error) {
437
+ issues.push(`fixtures.${name}: cannot load "${fixture}" (${(error as Error).message})`);
438
+ }
439
+ }
440
+ if (issues.length > 0) throw new ConfigError(issues);
441
+ return { ...raw, fixtures };
442
+ }