@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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Route fixtures: audit a signed-in page without a live backend.
3
+ *
4
+ * The gate blocks every request that leaves the app's own origin so the DOM it
5
+ * audits is reproducible. That is exactly why signed-in pages were never
6
+ * audited: with the gateway unreachable the shell bounces to /auth/login and
7
+ * the audit measures the login page a second time.
8
+ *
9
+ * A fixture fixes both halves deterministically:
10
+ *
11
+ * - `localStorage` is seeded before any page script runs, so the shell boots
12
+ * with a session. Values may embed `{ "$jwt": { ...claims } }`, which is
13
+ * encoded here into an UNSIGNED token. Nothing in a repo ever holds a real
14
+ * or real-looking credential, and the claims (exp, workspaceId, ...) stay
15
+ * readable in the fixture file. Expiries use `{ "$fromNow": seconds }` so
16
+ * a session is always valid for a realistic window when the page opens.
17
+ * - `cookies` are set on the app origin, for shells that cross-check the
18
+ * session against a cookie marker.
19
+ * - `graphql.operations` answers GraphQL requests to the configured
20
+ * endpoints by operation name, batched or not. An operation the fixture
21
+ * does not cover gets a GraphQL error response (never a live call) and is
22
+ * recorded in the report, so fixture drift is visible instead of silent.
23
+ *
24
+ * Everything in this file is pure, so it is unit-tested without a browser.
25
+ */
26
+
27
+ import type { FixtureSpec, GraphqlResult } from './types';
28
+
29
+ /** Marker key: `{ "$jwt": { ...claims } }` becomes an unsigned JWT string. */
30
+ export const JWT_MARKER = '$jwt';
31
+
32
+ /**
33
+ * Time markers, resolved when the page is opened:
34
+ * `{ "$fromNow": 3600 }` → unix SECONDS one hour from now (JWT `exp`/`iat`),
35
+ * `{ "$fromNowMs": 3600000 }` → epoch MILLISECONDS one hour from now
36
+ * (`expiresAt` fields). A fixed far-future expiry is not a safe substitute:
37
+ * shells schedule refresh timers off it, and a delay past setTimeout's 2^31-1
38
+ * ms ceiling fires immediately — the session "expires" on load.
39
+ */
40
+ export const FROM_NOW_MARKER = '$fromNow';
41
+ export const FROM_NOW_MS_MARKER = '$fromNowMs';
42
+
43
+ /**
44
+ * Signature segment of every fixture token. It is not a signature; it says so,
45
+ * and it keeps the three-segment shape clients check for.
46
+ */
47
+ export const UNSIGNED_SIGNATURE = 'a11y-fixture-unsigned';
48
+
49
+ /** Error code carried by the response for an operation the fixture lacks. */
50
+ export const UNMOCKED_CODE = 'A11Y_FIXTURE_UNMOCKED';
51
+
52
+ function base64Url(value: string): string {
53
+ return Buffer.from(value, 'utf8')
54
+ .toString('base64')
55
+ .replace(/\+/g, '-')
56
+ .replace(/\//g, '_')
57
+ .replace(/=+$/, '');
58
+ }
59
+
60
+ /** Encode claims as an unsigned JWT (`alg: none`). For fixtures only. */
61
+ export function encodeUnsignedJwt(claims: Record<string, unknown>): string {
62
+ const header = base64Url(JSON.stringify({ alg: 'none', typ: 'JWT' }));
63
+ const payload = base64Url(JSON.stringify(claims));
64
+ return `${header}.${payload}.${UNSIGNED_SIGNATURE}`;
65
+ }
66
+
67
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function soleKey(value: unknown): string | null {
72
+ if (!isPlainObject(value)) return null;
73
+ const keys = Object.keys(value);
74
+ return keys.length === 1 ? keys[0] : null;
75
+ }
76
+
77
+ /**
78
+ * Replace every marker in a JSON value: `{ "$jwt": {...} }` with its encoded
79
+ * token (claims are expanded first, so `exp` may itself be a `$fromNow`), and
80
+ * `{ "$fromNow": s }` / `{ "$fromNowMs": ms }` with a timestamp relative to
81
+ * `nowMs`.
82
+ */
83
+ export function expandMarkers(value: unknown, nowMs: number = Date.now()): unknown {
84
+ const key = soleKey(value);
85
+ if (key !== null && isPlainObject(value)) {
86
+ const inner = value[key];
87
+ if (key === JWT_MARKER && isPlainObject(inner)) {
88
+ return encodeUnsignedJwt(expandMarkers(inner, nowMs) as Record<string, unknown>);
89
+ }
90
+ if (key === FROM_NOW_MARKER && typeof inner === 'number') {
91
+ return Math.floor(nowMs / 1000) + inner;
92
+ }
93
+ if (key === FROM_NOW_MS_MARKER && typeof inner === 'number') {
94
+ return nowMs + inner;
95
+ }
96
+ }
97
+ if (Array.isArray(value)) return value.map((item) => expandMarkers(item, nowMs));
98
+ if (isPlainObject(value)) {
99
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, expandMarkers(v, nowMs)]));
100
+ }
101
+ return value;
102
+ }
103
+
104
+ /**
105
+ * The exact strings written to localStorage. A string value is stored as-is;
106
+ * anything else is stored as JSON, which is how persisted stores read it back.
107
+ */
108
+ export function materializeLocalStorage(
109
+ spec: FixtureSpec,
110
+ nowMs: number = Date.now()
111
+ ): Record<string, string> {
112
+ const entries = Object.entries(spec.localStorage ?? {});
113
+ return Object.fromEntries(
114
+ entries.map(([key, value]) => {
115
+ const expanded = expandMarkers(value, nowMs);
116
+ return [key, typeof expanded === 'string' ? expanded : JSON.stringify(expanded)];
117
+ })
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Cookies set on the app origin before the page opens. A string value is used
123
+ * as-is; anything else is stored as URL-encoded JSON, the encoding shells use
124
+ * for their own JSON cookies (e.g. the `bf-sso` session marker).
125
+ */
126
+ export function materializeCookies(
127
+ spec: FixtureSpec,
128
+ nowMs: number = Date.now()
129
+ ): Array<{ name: string; value: string }> {
130
+ return Object.entries(spec.cookies ?? {}).map(([name, value]) => {
131
+ const expanded = expandMarkers(value, nowMs);
132
+ return {
133
+ name,
134
+ value: typeof expanded === 'string' ? expanded : encodeURIComponent(JSON.stringify(expanded)),
135
+ };
136
+ });
137
+ }
138
+
139
+ /** True when `url` is a request to one of the fixture's GraphQL endpoints. */
140
+ export function matchesGraphqlEndpoint(url: string, spec: FixtureSpec): boolean {
141
+ const endpoints = spec.graphql?.endpoints ?? [];
142
+ if (endpoints.length === 0) return false;
143
+ let pathname: string;
144
+ try {
145
+ pathname = new URL(url).pathname.replace(/\/+$/, '');
146
+ } catch {
147
+ return false;
148
+ }
149
+ return endpoints.some((endpoint) => pathname.endsWith(endpoint.replace(/\/+$/, '')));
150
+ }
151
+
152
+ interface GraphqlRequestLike {
153
+ operationName?: unknown;
154
+ query?: unknown;
155
+ }
156
+
157
+ const OPERATION_NAME = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/;
158
+
159
+ /** Operation name of one GraphQL request, from `operationName` or the document. */
160
+ export function operationNameOf(request: GraphqlRequestLike): string {
161
+ if (typeof request.operationName === 'string' && request.operationName.length > 0) {
162
+ return request.operationName;
163
+ }
164
+ if (typeof request.query === 'string') {
165
+ const match = OPERATION_NAME.exec(request.query);
166
+ if (match) return match[1];
167
+ }
168
+ return '(anonymous)';
169
+ }
170
+
171
+ export interface FixtureResponse {
172
+ status: number;
173
+ body: string;
174
+ /** Operations in this request the fixture had no answer for. */
175
+ unmocked: string[];
176
+ }
177
+
178
+ function unmockedResult(name: string): GraphqlResult {
179
+ return {
180
+ data: null,
181
+ errors: [
182
+ {
183
+ message: `a11y fixture has no response for operation "${name}"`,
184
+ extensions: { code: UNMOCKED_CODE },
185
+ },
186
+ ],
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Parse the request parameters of a GraphQL call: a POST body (single or
192
+ * batched), or a GET query string.
193
+ */
194
+ export function parseGraphqlRequest(
195
+ method: string,
196
+ url: string,
197
+ body: string | null
198
+ ): GraphqlRequestLike | GraphqlRequestLike[] | null {
199
+ if (method.toUpperCase() === 'GET') {
200
+ try {
201
+ const params = new URL(url).searchParams;
202
+ return {
203
+ operationName: params.get('operationName') ?? undefined,
204
+ query: params.get('query'),
205
+ };
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+ if (!body) return null;
211
+ try {
212
+ const parsed: unknown = JSON.parse(body);
213
+ if (Array.isArray(parsed)) return parsed.filter(isPlainObject) as GraphqlRequestLike[];
214
+ return isPlainObject(parsed) ? (parsed as GraphqlRequestLike) : null;
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Answer a GraphQL request from the fixture. Batched requests get a batched
222
+ * response in the same order. A request that cannot be parsed is answered with
223
+ * a 400, never forwarded.
224
+ */
225
+ export function respondToGraphql(
226
+ spec: FixtureSpec,
227
+ method: string,
228
+ url: string,
229
+ body: string | null,
230
+ nowMs: number = Date.now()
231
+ ): FixtureResponse {
232
+ const operations = spec.graphql?.operations ?? {};
233
+ const parsed = parseGraphqlRequest(method, url, body);
234
+ if (parsed === null) {
235
+ return {
236
+ status: 400,
237
+ body: JSON.stringify({
238
+ errors: [{ message: 'a11y fixture could not parse the request' }],
239
+ }),
240
+ unmocked: ['(unparseable request)'],
241
+ };
242
+ }
243
+
244
+ const unmocked: string[] = [];
245
+ const answer = (request: GraphqlRequestLike): GraphqlResult => {
246
+ const name = operationNameOf(request);
247
+ const result = Object.prototype.hasOwnProperty.call(operations, name)
248
+ ? operations[name]
249
+ : undefined;
250
+ if (result === undefined) {
251
+ unmocked.push(name);
252
+ return unmockedResult(name);
253
+ }
254
+ return expandMarkers(result, nowMs) as GraphqlResult;
255
+ };
256
+
257
+ const response = Array.isArray(parsed) ? parsed.map(answer) : answer(parsed);
258
+ return { status: 200, body: JSON.stringify(response), unmocked };
259
+ }
260
+
261
+ export interface SeedArgs {
262
+ /** Only documents on this origin are seeded (not about:blank or iframes). */
263
+ origin: string;
264
+ entries: Record<string, string>;
265
+ }
266
+
267
+ /**
268
+ * Script injected before every page script of a fixture route. Self-contained
269
+ * (no closure over module scope) so Playwright can serialize it into the page.
270
+ * It seeds once per document load, before the shell reads its session.
271
+ */
272
+ export function seedLocalStorageScript(args: SeedArgs): void {
273
+ if (window.location.origin !== args.origin) return;
274
+ for (const [key, value] of Object.entries(args.entries)) {
275
+ window.localStorage.setItem(key, value);
276
+ }
277
+ }
@@ -3,7 +3,23 @@
3
3
  * that needs to embed the audit rather than shell out to the CLI.
4
4
  */
5
5
 
6
- export { resolveConfig, ConfigError, DEFAULT_FAIL_ON, DEFAULT_STANDARD } from './config';
6
+ export {
7
+ resolveConfig,
8
+ inlineFixtureFiles,
9
+ ConfigError,
10
+ DEFAULT_FAIL_ON,
11
+ DEFAULT_STANDARD,
12
+ } from './config';
13
+ export {
14
+ encodeUnsignedJwt,
15
+ expandMarkers,
16
+ materializeCookies,
17
+ materializeLocalStorage,
18
+ matchesGraphqlEndpoint,
19
+ operationNameOf,
20
+ parseGraphqlRequest,
21
+ respondToGraphql,
22
+ } from './fixtures';
7
23
  export {
8
24
  entryCovers,
9
25
  findUnreachableRouteScopes,
@@ -5,12 +5,22 @@
5
5
 
6
6
  import type { AuditReport, FlatNode } from './types';
7
7
 
8
- function ruleSummary(
9
- nodes: FlatNode[]
10
- ): Array<{ rule: string; impact: string; count: number; helpUrl: string; help: string }> {
8
+ function ruleSummary(nodes: FlatNode[]): Array<{
9
+ rule: string;
10
+ impact: string;
11
+ count: number;
12
+ helpUrl: string;
13
+ help: string;
14
+ }> {
11
15
  const byRule = new Map<
12
16
  string,
13
- { rule: string; impact: string; count: number; helpUrl: string; help: string }
17
+ {
18
+ rule: string;
19
+ impact: string;
20
+ count: number;
21
+ helpUrl: string;
22
+ help: string;
23
+ }
14
24
  >();
15
25
  for (const node of nodes) {
16
26
  const existing = byRule.get(node.rule);
@@ -49,7 +59,12 @@ export function renderConsole(report: AuditReport): string {
49
59
  `\n ${route.blocking.length} blocking · ${route.allowed.length} allowlisted · ` +
50
60
  `${route.advisory.length} advisory · ${route.passCount} checks passed · ` +
51
61
  `${route.incompleteCount} needs review · ${route.renderedTextLength} chars rendered` +
52
- `${route.domSettled ? '' : ' (DOM never went quiet)'}`
62
+ `${route.domSettled ? '' : ' (DOM never went quiet)'}` +
63
+ (route.fixture ? `\n fixture: ${route.fixture}` : '') +
64
+ (route.unmockedOperations && route.unmockedOperations.length > 0
65
+ ? ` · unmocked GraphQL operations (answered with an error): ` +
66
+ route.unmockedOperations.join(', ')
67
+ : '')
53
68
  );
54
69
  for (const entry of ruleSummary(route.blocking)) {
55
70
  lines.push(` x ${entry.rule} (${entry.impact}) x${entry.count} — ${entry.help}`);
@@ -114,6 +129,22 @@ export function renderMarkdown(report: AuditReport): string {
114
129
  );
115
130
  }
116
131
 
132
+ const unmocked = report.routes.filter(
133
+ (r) => r.unmockedOperations && r.unmockedOperations.length > 0
134
+ );
135
+ if (unmocked.length > 0) {
136
+ lines.push('');
137
+ lines.push('### Fixture coverage');
138
+ lines.push('');
139
+ lines.push('Operations these fixture routes sent that the fixture has no answer for:');
140
+ for (const route of unmocked) {
141
+ lines.push(
142
+ `- \`${route.routeName}\` @ ${route.viewport} (fixture \`${route.fixture}\`): ` +
143
+ (route.unmockedOperations ?? []).map((name) => `\`${name}\``).join(', ')
144
+ );
145
+ }
146
+ }
147
+
117
148
  const allBlocking = report.routes.flatMap((r) => r.blocking);
118
149
  if (allBlocking.length > 0) {
119
150
  lines.push('');
@@ -66,6 +66,39 @@ export interface RouteSpec {
66
66
  * "clean" result on an unexpected error page cannot be mistaken for a pass.
67
67
  */
68
68
  allowRedirect?: boolean;
69
+ /**
70
+ * Name of an entry in the top-level `fixtures` map to load this route with —
71
+ * typically a signed-in session plus canned GraphQL answers. A fixture route
72
+ * that ends anywhere other than `path` fails the attempt (unless
73
+ * `allowRedirect` is set): landing on /auth/login means the fixture did not
74
+ * sign in, and auditing the login page again would be a false pass.
75
+ */
76
+ fixture?: string;
77
+ }
78
+
79
+ /** A GraphQL response body, as the gateway would send it. */
80
+ export interface GraphqlResult {
81
+ data?: unknown;
82
+ errors?: Array<{ message: string; [key: string]: unknown }>;
83
+ extensions?: Record<string, unknown>;
84
+ }
85
+
86
+ /**
87
+ * Deterministic page state for a route. See fixtures.ts for the semantics.
88
+ * Never put a real credential in one: `{ "$jwt": { ...claims } }` is encoded
89
+ * into an unsigned token at run time.
90
+ */
91
+ export interface FixtureSpec {
92
+ /** Seeded into localStorage before the first page script runs. */
93
+ localStorage?: Record<string, unknown>;
94
+ /** Cookies set on the app origin before the page opens. */
95
+ cookies?: Record<string, unknown>;
96
+ graphql?: {
97
+ /** Path suffixes that identify a GraphQL endpoint, on any origin. */
98
+ endpoints: string[];
99
+ /** Response body per operation name. */
100
+ operations: Record<string, GraphqlResult>;
101
+ };
69
102
  }
70
103
 
71
104
  export interface ViewportSpec {
@@ -152,6 +185,11 @@ export interface A11yAuditConfig {
152
185
  * still a hard failure — this retries a flake, it does not tolerate one.
153
186
  */
154
187
  routeAttempts?: number;
188
+ /**
189
+ * Named route fixtures. A value may be the fixture itself or a path, relative
190
+ * to the config file, of a JSON file holding it.
191
+ */
192
+ fixtures?: Record<string, FixtureSpec | string>;
155
193
  }
156
194
 
157
195
  /** Config with every default resolved. */
@@ -171,6 +209,7 @@ export interface ResolvedConfig {
171
209
  domQuietMs: number;
172
210
  minTextLength: number;
173
211
  routeAttempts: number;
212
+ fixtures: Record<string, FixtureSpec>;
174
213
  }
175
214
 
176
215
  /** A single failing DOM node, flattened out of an axe violation. */
@@ -212,6 +251,10 @@ export interface RouteResult {
212
251
  domSettled: boolean;
213
252
  /** Navigation attempts this route needed. >1 means the runner hit a flake. */
214
253
  attempts: number;
254
+ /** Fixture the route was loaded with, if any. */
255
+ fixture?: string;
256
+ /** GraphQL operations the page sent that the fixture had no answer for. */
257
+ unmockedOperations?: string[];
215
258
  }
216
259
 
217
260
  export interface AuditReport {
@@ -224,7 +267,11 @@ export interface AuditReport {
224
267
  blockExternalRequests: boolean;
225
268
  routes: RouteResult[];
226
269
  staleAllowlistEntries: Array<{ index: number; rule: string; reason: string }>;
227
- expiredAllowlistEntries: Array<{ index: number; rule: string; expires: string }>;
270
+ expiredAllowlistEntries: Array<{
271
+ index: number;
272
+ rule: string;
273
+ expires: string;
274
+ }>;
228
275
  totals: {
229
276
  blocking: number;
230
277
  allowed: number;