@ultimat3/testing 29.4.0 → 30.1.0

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.
Files changed (93) hide show
  1. package/CLAUDE.md +56 -63
  2. package/README.md +281 -6
  3. package/package.json +27 -16
  4. package/src/ax-node.ts +96 -0
  5. package/src/cassette-errors.ts +91 -0
  6. package/src/cassette-format.ts +175 -0
  7. package/src/cassette-record.ts +120 -0
  8. package/src/cassette-secret-scan.ts +77 -0
  9. package/src/cassette.ts +166 -0
  10. package/src/cdp-browser.ts +5 -3
  11. package/src/cdp-e2e-page.ts +91 -3
  12. package/src/cdp-e2e-session.ts +18 -1
  13. package/src/define-exercise.ts +92 -0
  14. package/src/drill-budgets.ts +54 -0
  15. package/src/drill-errors.ts +45 -0
  16. package/src/drill-fleet.ts +251 -0
  17. package/src/drill-jobs.ts +79 -0
  18. package/src/drill-load.ts +166 -0
  19. package/src/drill-probe.ts +68 -0
  20. package/src/drill-run.ts +144 -0
  21. package/src/drill-services.ts +70 -0
  22. package/src/drill-sync.ts +108 -0
  23. package/src/drill-verdict.ts +256 -0
  24. package/src/drill-writes.ts +123 -0
  25. package/src/drills.ts +109 -0
  26. package/src/e2e-a11y-run.ts +189 -0
  27. package/src/e2e-a11y-tabs.ts +113 -0
  28. package/src/e2e-a11y.ts +153 -0
  29. package/src/e2e-driver.ts +46 -10
  30. package/src/e2e-page.ts +28 -3
  31. package/src/e2e-run.ts +1 -0
  32. package/src/e2e-spawn.ts +3 -2
  33. package/src/errors.ts +43 -0
  34. package/src/exercise-actor.ts +124 -0
  35. package/src/exercise-crash.ts +46 -0
  36. package/src/exercise-input.ts +182 -0
  37. package/src/exercise-override-input.ts +34 -0
  38. package/src/exercise-report.ts +282 -0
  39. package/src/exercise-request.ts +38 -0
  40. package/src/exercise-sweeps.ts +100 -0
  41. package/src/exercise.ts +451 -0
  42. package/src/factories.ts +10 -7
  43. package/src/factory-registry.ts +49 -15
  44. package/src/fake-identity-provider.ts +191 -0
  45. package/src/fault-proxy.ts +342 -0
  46. package/src/file-boundary.ts +4 -1
  47. package/src/fixture-jobs.ts +3 -1
  48. package/src/fixture-statements.ts +4 -1
  49. package/src/free-port.ts +59 -0
  50. package/src/index.ts +148 -3
  51. package/src/island-dom.ts +5 -0
  52. package/src/isolated-plugins.ts +1 -1
  53. package/src/job-crash-proof.ts +249 -0
  54. package/src/job-sim-driver.ts +164 -0
  55. package/src/job-sim-effects.ts +142 -0
  56. package/src/job-sim-errors.ts +72 -0
  57. package/src/job-sim-schedule.ts +154 -0
  58. package/src/job-sim-world.ts +198 -0
  59. package/src/job-simulate.ts +179 -0
  60. package/src/mock-route.ts +81 -0
  61. package/src/module-mock-boundary.ts +85 -0
  62. package/src/module-mock-error.ts +28 -0
  63. package/src/plan-oracle.ts +164 -0
  64. package/src/plan-sweep-fix.ts +123 -0
  65. package/src/plan-sweep-report.ts +84 -0
  66. package/src/plan-sweep.ts +148 -0
  67. package/src/preload.ts +24 -4
  68. package/src/registry-snapshot.ts +1 -1
  69. package/src/repro-stand-in.ts +67 -0
  70. package/src/repro.ts +158 -0
  71. package/src/request-log.ts +64 -0
  72. package/src/reusable-database.ts +1 -1
  73. package/src/scale-sweep-graph.ts +82 -0
  74. package/src/scale-sweep-run.ts +134 -0
  75. package/src/scale-sweep.ts +74 -0
  76. package/src/sealed-network.ts +63 -46
  77. package/src/seed-graph-error.ts +50 -0
  78. package/src/seed-graph-order.ts +98 -0
  79. package/src/seed-graph-pass.ts +85 -0
  80. package/src/seed-graph-plan.ts +81 -0
  81. package/src/seed-graph-row.ts +134 -0
  82. package/src/seed-graph-shape.ts +104 -0
  83. package/src/seed-graph-values.ts +135 -0
  84. package/src/seed-graph.ts +250 -0
  85. package/src/seed-writer.ts +180 -0
  86. package/src/subject-proof-error.ts +23 -0
  87. package/src/subject-proof.ts +180 -0
  88. package/src/template-db-roles.ts +51 -0
  89. package/src/template-db.ts +8 -2
  90. package/src/tenant-canary.ts +120 -0
  91. package/src/tenant-proof-report.ts +62 -0
  92. package/src/tenant-proof.ts +177 -0
  93. package/src/test-types.ts +12 -3
@@ -0,0 +1,123 @@
1
+ // A drill's write lane: the app's actions an anonymous caller may POST, each request carrying a
2
+ // fresh input drawn as the exercise pass draws one (the seeded draw, then the app's `exercise.ts`
3
+ // override). It is how a drill's load reaches the queue — a job enqueued over HTTP is what
4
+ // `worker-sigkill` and `worker-sigterm` need on the table to have anything to reclaim.
5
+
6
+ import { bindDrawn } from '@ultimat3/core';
7
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
8
+ import type { ExerciseOverrides } from './define-exercise';
9
+ import type { DrillTarget } from './drill-load';
10
+ import type { Sampler } from './exercise-input';
11
+ import { loadSampler } from './exercise-input';
12
+ import { isAsyncInput } from './exercise-override-input';
13
+
14
+ /** The seed every drill draws under: one run's inputs are the same on every machine. */
15
+ export const DRILL_DRAW_SEED = 40;
16
+
17
+ /** An action as the served route table states it. */
18
+ export interface DrillWriteCandidate {
19
+ readonly name: string;
20
+ readonly method: string;
21
+ readonly path: string;
22
+ /** The policy admits a caller with no session (`auth: 'public'` on its route). */
23
+ readonly anonymous: boolean;
24
+ readonly input: StandardSchemaV1;
25
+ }
26
+
27
+ export interface DrillWritesOptions {
28
+ readonly actions: readonly DrillWriteCandidate[];
29
+ readonly overrides?: ExerciseOverrides | undefined;
30
+ /** The exercise pass's own sampler, loaded on demand when not handed in. */
31
+ readonly sampler?: Pick<Sampler, 'arbitrary'> | undefined;
32
+ }
33
+
34
+ export interface DrillWrites {
35
+ readonly targets: readonly DrillTarget[];
36
+ /** Every action left out, and why: printed, so an empty lane is never a silent one. */
37
+ readonly skipped: readonly { readonly name: string; readonly reason: string }[];
38
+ }
39
+
40
+ const ASYNC_INPUT =
41
+ 'exercise.ts computes its input from the pass (an async input); a load has none';
42
+
43
+ type Verdict =
44
+ | { readonly reason: string }
45
+ | { readonly bind: ((drawn: unknown) => unknown) | undefined };
46
+
47
+ /** Why `candidate` cannot ride an anonymous load, or the override that binds its input. */
48
+ function verdictOf(
49
+ candidate: DrillWriteCandidate,
50
+ overrides: ExerciseOverrides | undefined,
51
+ ): Verdict {
52
+ if (candidate.method !== 'POST') return { reason: `answers ${candidate.method}; the load posts` };
53
+ if (/[:*]/.test(candidate.path))
54
+ return { reason: 'its path takes a parameter the load cannot bind' };
55
+ if (!candidate.anonymous) return { reason: 'its policy refuses an anonymous caller' };
56
+ const override = overrides?.entries.get(candidate.name);
57
+ if (override === undefined) return { bind: undefined };
58
+ if ('off' in override) return { reason: `exercise.ts turns it off: ${override.off.trim()}` };
59
+ if (override.actor !== null) return { reason: 'exercise.ts runs it as a signed-in actor' };
60
+ if (isAsyncInput(override)) {
61
+ return {
62
+ reason: 'exercise.ts computes its input from the pass (an async input); a load has none',
63
+ };
64
+ }
65
+ return { bind: bindDrawn(override.input) };
66
+ }
67
+
68
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
69
+ (typeof value === 'object' || typeof value === 'function') &&
70
+ value !== null &&
71
+ typeof (value as { then?: unknown }).then === 'function';
72
+
73
+ /**
74
+ * The first request's input, drawn ahead of the load. A thenable's rejection is handled: its
75
+ * outcome is discarded — the action is skipped either way — and unhandled it would end the drill.
76
+ * A throw answers `undefined`: the load's own draw throws it again on every request, as before.
77
+ */
78
+ function probe(
79
+ bind: (drawn: unknown) => unknown,
80
+ sampler: Pick<Sampler, 'arbitrary'>,
81
+ candidate: DrillWriteCandidate,
82
+ ): unknown {
83
+ try {
84
+ const first = bind(sampler.arbitrary(candidate.input, { seed: DRILL_DRAW_SEED, run: 1 }));
85
+ if (isThenable(first)) Promise.resolve(first).catch(() => undefined);
86
+ return first;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /** The write targets of an anonymous load, and every action it leaves out. */
93
+ export async function drillWriteTargets(options: DrillWritesOptions): Promise<DrillWrites> {
94
+ const sampler = options.sampler ?? (await loadSampler());
95
+ const targets: DrillTarget[] = [];
96
+ const skipped: { name: string; reason: string }[] = [];
97
+ for (const candidate of options.actions) {
98
+ const verdict = verdictOf(candidate, options.overrides);
99
+ if ('reason' in verdict) {
100
+ skipped.push({ name: candidate.name, reason: verdict.reason });
101
+ continue;
102
+ }
103
+ const { bind } = verdict;
104
+ // `isAsyncInput` reads the function's tag, and a plain function returning a promise has none:
105
+ // drawn, its body was `JSON.stringify(promise)` — `{}`. So the first request's input is drawn
106
+ // here, and one that is a thenable is the same skip.
107
+ if (bind !== undefined && isThenable(probe(bind, sampler, candidate))) {
108
+ skipped.push({ name: candidate.name, reason: ASYNC_INPUT });
109
+ continue;
110
+ }
111
+ targets.push({
112
+ method: 'POST',
113
+ path: candidate.path,
114
+ // Run n of the seeded draw: a distinct input per request, so a job keyed by its input is a
115
+ // distinct job — the same body every time is one job deduped, and nothing to reclaim.
116
+ draw: (run) => {
117
+ const drawn = sampler.arbitrary(candidate.input, { seed: DRILL_DRAW_SEED, run });
118
+ return bind === undefined ? drawn : bind(drawn);
119
+ },
120
+ });
121
+ }
122
+ return { targets, skipped };
123
+ }
package/src/drills.ts ADDED
@@ -0,0 +1,109 @@
1
+ // The drill table: each drill's fault and the framework contracts that must hold through it. Data
2
+ // only — `drill-run.ts` plays a fault, `drill-verdict.ts` judges what was measured. Never an app's
3
+ // business rule: every property is one the framework declares for every app the same.
4
+
5
+ import type { DrillBudgets, DrillProperty } from './drill-verdict';
6
+ import type { FaultMode } from './fault-proxy';
7
+
8
+ /** A service a drill puts a fault proxy in front of. */
9
+ export type DrillService = 'postgres' | 'redis' | 'nats';
10
+
11
+ /**
12
+ * A role the fleet runs as its own process (`ROLE`, `apps/web/server.ts`); `sync` only for a drill
13
+ * whose sync client needs a node to dial.
14
+ */
15
+ export type DrillRole = 'web' | 'worker' | 'sync';
16
+
17
+ export type DrillName =
18
+ | 'pg-blackhole'
19
+ | 'pg-slow'
20
+ | 'pg-restart'
21
+ | 'redis-down'
22
+ | 'nats-down'
23
+ | 'worker-sigkill'
24
+ | 'worker-sigterm'
25
+ | 'web-sigterm';
26
+
27
+ /** The fault itself: a proxy mode per phase, or a signal to one role's process. */
28
+ export type DrillFault =
29
+ | {
30
+ readonly kind: 'service';
31
+ readonly service: DrillService;
32
+ /** Played in order, each for one phase; `pass` follows, and recovery is timed from it. */
33
+ readonly phases: (budgets: DrillBudgets) => readonly FaultMode[];
34
+ }
35
+ | { readonly kind: 'signal'; readonly role: DrillRole; readonly signal: 'SIGKILL' | 'SIGTERM' };
36
+
37
+ export interface Drill {
38
+ readonly name: DrillName;
39
+ readonly fault: DrillFault;
40
+ readonly holds: readonly DrillProperty[];
41
+ }
42
+
43
+ /** Past the statement ceiling and short of the request deadline: slow, never dead. */
44
+ const slowStatementMs = (budgets: DrillBudgets): number => Math.floor(budgets.deadlineMs / 2);
45
+
46
+ export const DRILLS: readonly Drill[] = [
47
+ {
48
+ name: 'pg-blackhole',
49
+ fault: { kind: 'service', service: 'postgres', phases: () => [{ kind: 'blackhole' }] },
50
+ holds: ['requests-coded', 'requests-bounded', 'recovers', 'jobs-once'],
51
+ },
52
+ {
53
+ name: 'pg-slow',
54
+ fault: {
55
+ kind: 'service',
56
+ service: 'postgres',
57
+ phases: (budgets) => [{ kind: 'delay', ms: slowStatementMs(budgets) }],
58
+ },
59
+ holds: ['requests-coded', 'requests-bounded', 'recovers'],
60
+ },
61
+ {
62
+ name: 'pg-restart',
63
+ fault: {
64
+ kind: 'service',
65
+ service: 'postgres',
66
+ phases: () => [{ kind: 'reset' }, { kind: 'refuse' }],
67
+ },
68
+ holds: ['requests-coded', 'readyz-flips', 'recovers', 'jobs-once'],
69
+ },
70
+ {
71
+ name: 'redis-down',
72
+ fault: { kind: 'service', service: 'redis', phases: () => [{ kind: 'blackhole' }] },
73
+ holds: ['requests-coded', 'requests-prompt', 'requests-ok', 'recovers'],
74
+ },
75
+ {
76
+ name: 'nats-down',
77
+ fault: { kind: 'service', service: 'nats', phases: () => [{ kind: 'refuse' }] },
78
+ holds: ['requests-coded', 'recovers', 'sync-resumes'],
79
+ },
80
+ {
81
+ name: 'worker-sigkill',
82
+ fault: { kind: 'signal', role: 'worker', signal: 'SIGKILL' },
83
+ holds: ['jobs-terminal', 'jobs-once', 'requests-coded'],
84
+ },
85
+ {
86
+ name: 'worker-sigterm',
87
+ fault: { kind: 'signal', role: 'worker', signal: 'SIGTERM' },
88
+ holds: ['role-exits', 'jobs-terminal', 'jobs-once'],
89
+ },
90
+ {
91
+ name: 'web-sigterm',
92
+ fault: { kind: 'signal', role: 'web', signal: 'SIGTERM' },
93
+ holds: ['requests-ok', 'role-exits'],
94
+ },
95
+ ];
96
+
97
+ export const DRILL_NAMES: readonly DrillName[] = DRILLS.map((drill) => drill.name);
98
+
99
+ /** The drill named `name`, or `undefined`: the caller refuses with the list. */
100
+ export const drillNamed = (name: string): Drill | undefined =>
101
+ DRILLS.find((drill) => drill.name === name);
102
+
103
+ /** The roles a drill's fleet runs: a sync client needs the sync node it dials. */
104
+ export const drillRoles = (drill: Drill): readonly DrillRole[] =>
105
+ drill.holds.includes('sync-resumes') ? ['web', 'worker', 'sync'] : ['web', 'worker'];
106
+
107
+ /** The service a drill needs running, when it needs one beyond Postgres. */
108
+ export const drillService = (drill: Drill): DrillService | undefined =>
109
+ drill.fault.kind === 'service' ? drill.fault.service : undefined;
@@ -0,0 +1,189 @@
1
+ // One test's accessibility audit, for `e2eTest` and a raw tab (`e2e-a11y-tabs.ts`) alike: every
2
+ // settled navigation and the test's end read the tree and the probe, findings no reasoned `allow`
3
+ // covers are kept, and the test fails once with every element named. Rules: `e2e-a11y.ts`.
4
+ // `X_E2E_A11Y_VIOLATION` is registered in `errors.ts`; its constructors live here.
5
+
6
+ import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
7
+ import { navigatedAway } from './cdp-e2e-page';
8
+ import type { A11yFinding } from './e2e-a11y';
9
+ import { findingKey, probeExpression, probeFindings, ruleFor, treeFinding } from './e2e-a11y';
10
+ import type { E2eBrowserPage } from './e2e-page';
11
+ // Bare: the title `X_E2E_A11Y_VIOLATION` renders with is registered there.
12
+ import './errors';
13
+
14
+ /** One deliberate exception, and why. `selector` is CSS, matched by the page against the element. */
15
+ export interface A11yAllow {
16
+ readonly role: string;
17
+ readonly selector: string;
18
+ readonly because: string;
19
+ }
20
+
21
+ /** What an `e2eTest` may say about its audit. There is no switch: only reasoned exceptions. */
22
+ export interface A11yOptions {
23
+ readonly allow?: readonly A11yAllow[] | undefined;
24
+ }
25
+
26
+ const ALLOW_FIX =
27
+ "e2eTest(name, body, { a11y: { allow: [{ role: 'button', selector: '#close', because: 'why this element is exempt' }] } })";
28
+
29
+ /** Who audited the page — so the fix names the place THAT test can say `allow`. */
30
+ export type A11yAuditor = 'e2eTest' | 'tab';
31
+
32
+ /** A page in this test failed the audit. The cause lists every element, deduplicated. */
33
+ export class E2eA11yViolationError extends UltimateError {
34
+ constructor(findings: readonly A11yFinding[], auditor: A11yAuditor = 'e2eTest') {
35
+ const listed = findings
36
+ .map((f) => `${f.rule} ${f.role} ${renderCauseValue(f.html)} at ${renderCauseValue(f.url)}`)
37
+ .join('; ');
38
+ const role = renderFixLiteral(findings[0]?.role, "'<role>'");
39
+ const entry = `[{ role: ${role}, selector: '<css>', because: '<why>' }]`;
40
+ super({
41
+ code: 'X_E2E_A11Y_VIOLATION',
42
+ cause: `${String(findings.length)} element(s) a screen reader cannot use — name each one (visible text, aria-label, <label for>, alt; alt="" for a decorative image; a <title>, <html lang>, nothing focusable under aria-hidden), or allow it with a reason: ${listed}`,
43
+ fix:
44
+ auditor === 'tab'
45
+ ? `allowA11y(${entry}) — in the test, before the navigation that lands on it`
46
+ : `e2eTest(name, body, { a11y: { allow: ${entry} } })`,
47
+ meta: { findings: findings.map(({ matches: _, ...told }) => told) },
48
+ });
49
+ }
50
+ }
51
+
52
+ /** An `allow` entry with no reason: an exception nobody can review is the global switch again. */
53
+ export class E2eA11yAllowUnreasonedError extends UltimateError {
54
+ constructor(entry: unknown) {
55
+ super({
56
+ code: 'X_E2E_A11Y_VIOLATION',
57
+ cause: `an a11y.allow entry has no role, selector or non-blank because: ${renderCauseValue(entry)}`,
58
+ fix: ALLOW_FIX,
59
+ });
60
+ }
61
+ }
62
+
63
+ /** The page port cannot read the tree. Never a silent skip: that would pass every page. */
64
+ export class E2eA11yUnavailableError extends UltimateError {
65
+ constructor() {
66
+ super({
67
+ code: 'X_TEST_FIXTURE_UNAVAILABLE',
68
+ cause:
69
+ 'the page handed to installE2eDriver({ page }) has no accessibilityTree(), so no e2eTest can be audited — implement it on that E2eBrowserPage, or forward it from the wrapper; cdpE2eTab (packages/testing/src/cdp-e2e-page.ts) has one',
70
+ fix: 'accessibilityTree: (request) => tab.accessibilityTree(request)',
71
+ });
72
+ }
73
+ }
74
+
75
+ const filled = (text: unknown): boolean => typeof text === 'string' && text.trim() !== '';
76
+
77
+ /** Every entry reasoned, or the test is refused before its body runs. */
78
+ export const checkAllow = (options: A11yOptions | undefined): readonly A11yAllow[] => {
79
+ const allow = options?.allow ?? [];
80
+ for (const entry of allow) {
81
+ if (!filled(entry.role) || !filled(entry.selector) || !filled(entry.because)) {
82
+ throw new E2eA11yAllowUnreasonedError(entry);
83
+ }
84
+ }
85
+ return allow;
86
+ };
87
+
88
+ const allowed = (finding: A11yFinding, allow: readonly A11yAllow[]): boolean =>
89
+ allow.some((entry) => entry.role === finding.role && finding.matches.includes(entry.selector));
90
+
91
+ /** The tree and the probe of the page as it is now: one round trip each, plus one per finding. */
92
+ const readPage = async (
93
+ page: E2eBrowserPage,
94
+ selectors: readonly string[],
95
+ ): Promise<readonly A11yFinding[]> => {
96
+ if (page.accessibilityTree === undefined) throw new E2eA11yUnavailableError();
97
+ const url = page.url();
98
+ // A tab nothing navigated (`about:blank`) is no app's page: it has no title and no lang to give.
99
+ if (!/^https?:/.test(url)) return [];
100
+ const probed = probeFindings(await page.evaluate(probeExpression(selectors)), url);
101
+ if (probed === undefined) return [];
102
+ const nodes = await page.accessibilityTree({
103
+ describe: (node) => ruleFor(node) !== undefined,
104
+ selectors,
105
+ });
106
+ const fromTree = nodes.filter((node) => ruleFor(node) !== undefined);
107
+ return [...fromTree.map((node) => treeFinding(node, url)), ...probed];
108
+ };
109
+
110
+ export interface A11yRun {
111
+ /** Audit the page now; findings are kept, never thrown here. */
112
+ check(): Promise<void>;
113
+ /** Throw `X_E2E_A11Y_VIOLATION` if anything was kept. */
114
+ verdict(): void;
115
+ }
116
+
117
+ /** One test's findings over any number of pages: each `check(page)` reads one, `verdict` tells all. */
118
+ export interface A11yPagesRun {
119
+ check(page: E2eBrowserPage): Promise<void>;
120
+ /** More reasoned exceptions for THIS run: they cover the reads after this call. */
121
+ allow(entries: readonly A11yAllow[]): void;
122
+ verdict(): void;
123
+ }
124
+
125
+ /**
126
+ * The pages an `e2eTest` runner audits itself, with its test's `allow`, until the test ends. The
127
+ * raw-tab audit (`e2e-a11y-tabs.ts`) stands down for these, so one navigation is never judged twice
128
+ * — once with the test's reasoned exceptions and once without them.
129
+ */
130
+ const claimed = new Set<E2eBrowserPage>();
131
+
132
+ export const claimedThisTest = (page: E2eBrowserPage): boolean => claimed.has(page);
133
+
134
+ /** At a test's end: every page is the raw-tab audit's again. */
135
+ export const releaseClaims = (): void => claimed.clear();
136
+
137
+ /**
138
+ * Findings across pages, for one test. `reported` is the SUITE's set: a finding another test
139
+ * already failed on is not told twice, so a shared header with no name fails one test rather than
140
+ * every one.
141
+ */
142
+ export function a11yPagesRun(
143
+ given: readonly A11yAllow[],
144
+ reported: Set<string>,
145
+ auditor: A11yAuditor = 'e2eTest',
146
+ ): A11yPagesRun {
147
+ const allow = [...given];
148
+ let selectors = [...new Set(allow.map((entry) => entry.selector))];
149
+ const kept = new Map<string, A11yFinding>();
150
+ return {
151
+ async check(page) {
152
+ let findings: readonly A11yFinding[];
153
+ try {
154
+ findings = await readPage(page, selectors);
155
+ } catch (error) {
156
+ // A document that committed under the read is "not this document": read the new one once.
157
+ if (!navigatedAway(error)) throw error;
158
+ findings = await readPage(page, selectors);
159
+ }
160
+ for (const finding of findings) {
161
+ const key = findingKey(finding);
162
+ if (allowed(finding, allow) || reported.has(key)) continue;
163
+ kept.set(key, finding);
164
+ }
165
+ },
166
+ allow(entries) {
167
+ allow.push(...entries);
168
+ selectors = [...new Set(allow.map((entry) => entry.selector))];
169
+ },
170
+ verdict() {
171
+ if (kept.size === 0) return;
172
+ for (const key of kept.keys()) reported.add(key);
173
+ const told = [...kept.values()];
174
+ kept.clear();
175
+ throw new E2eA11yViolationError(told, auditor);
176
+ },
177
+ };
178
+ }
179
+
180
+ /** One `e2eTest`'s audit of the one page it drives — claimed from the raw-tab audit until it ends. */
181
+ export function startA11yRun(
182
+ page: E2eBrowserPage,
183
+ allow: readonly A11yAllow[],
184
+ reported: Set<string>,
185
+ ): A11yRun {
186
+ claimed.add(page);
187
+ const run = a11yPagesRun(allow, reported);
188
+ return { check: () => run.check(page), verdict: () => run.verdict() };
189
+ }
@@ -0,0 +1,113 @@
1
+ // The audit of every RAW tab — a `session.newTab()` a suite drives itself, outside `e2eTest`.
2
+ // Every settled `goto`/`reload` reads the page, every tab the test drove is read once more at its
3
+ // end, and the findings fail that test from the preload's one `afterEach`. No suite opts in: the
4
+ // tab reports here because `cdp-e2e-session.ts` built it. The rules and the read are `e2e-a11y-run`'s.
5
+
6
+ import { afterEach } from 'bun:test';
7
+ import type { A11yAllow, A11yPagesRun } from './e2e-a11y-run';
8
+ import { a11yPagesRun, checkAllow, claimedThisTest, releaseClaims } from './e2e-a11y-run';
9
+ import type { E2eBrowserPage } from './e2e-page';
10
+
11
+ /** What the session tells this file about one tab it built. */
12
+ export interface AuditedTab {
13
+ /** A settled navigation: read the document now, before the next one replaces it. */
14
+ navigated(): Promise<void>;
15
+ /** The test called the tab — it is read again at the test's end, so a dialog it opened counts. */
16
+ used(): void;
17
+ /** Closed by the test: nothing left to read at its end, and Chrome may still list the target. */
18
+ closed(): void;
19
+ }
20
+
21
+ interface Watched {
22
+ readonly tab: E2eBrowserPage;
23
+ closed: boolean;
24
+ /** False once the tab or its whole browser is gone: there is no page left to read. */
25
+ readonly alive: () => Promise<boolean>;
26
+ }
27
+
28
+ /** The tabs the running test (or the `beforeAll` before it) called. Cleared at each test's end. */
29
+ const used = new Set<Watched>();
30
+ /** The process' set: a finding one test failed on is not told again by the next. */
31
+ const reported = new Set<string>();
32
+ let current: A11yPagesRun | undefined;
33
+ let installed = false;
34
+
35
+ const run = (): A11yPagesRun => {
36
+ current ??= a11yPagesRun([], reported, 'tab');
37
+ return current;
38
+ };
39
+
40
+ /** Register a tab the session just built. Its `goto` calls `navigated`, its every call `used`. */
41
+ export function auditTab(tab: E2eBrowserPage, alive: () => Promise<boolean>): AuditedTab {
42
+ const entry: Watched = { tab, alive, closed: false };
43
+ return {
44
+ async navigated() {
45
+ if (claimedThisTest(tab)) return;
46
+ await run().check(tab);
47
+ // No preload to fail the test at its end: fail it HERE, at the navigation, rather than
48
+ // keeping findings nothing will ever tell — a silent skip would pass every page.
49
+ if (!installed) endRun();
50
+ },
51
+ used() {
52
+ used.add(entry);
53
+ },
54
+ closed() {
55
+ entry.closed = true;
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The raw-tab test's reasoned exceptions — `e2eTest`'s `{ a11y: { allow } }`, for a test with no
62
+ * options slot. Each entry needs a non-blank `because`, it covers the reads after the call, and it
63
+ * ends with the test. Call it first in the test body, before the navigation that lands on it.
64
+ */
65
+ export function allowA11y(entries: readonly A11yAllow[]): void {
66
+ run().allow(checkAllow({ allow: entries }));
67
+ }
68
+
69
+ const endRun = (): void => {
70
+ const ending = current;
71
+ current = undefined;
72
+ ending?.verdict();
73
+ };
74
+
75
+ /**
76
+ * The end of one test: every tab it called is read as the test left it, and everything kept since
77
+ * the last test fails THIS one. A tab whose browser closed has no page left to read; what its
78
+ * navigations found is still told.
79
+ */
80
+ export async function endTabA11y(): Promise<void> {
81
+ const touched = [...used];
82
+ try {
83
+ for (const entry of touched) {
84
+ if (claimedThisTest(entry.tab)) continue;
85
+ if (entry.closed || !(await entry.alive())) continue;
86
+ await run().check(entry.tab);
87
+ }
88
+ endRun();
89
+ } finally {
90
+ // After the reads, which call the tab too: the next test starts with nothing it touched.
91
+ used.clear();
92
+ current = undefined;
93
+ releaseClaims();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The longest one test's end may take: three reads per tab, a handful of tabs. A literal, not
99
+ * `DEFAULT_CDP_TIMEOUT_MS` times a count — `cdp-browser.ts` reaches this file through the session,
100
+ * and the import back would be a cycle. Measured ends are tens of milliseconds.
101
+ */
102
+ export const TAB_A11Y_END_MS = 120_000;
103
+
104
+ /**
105
+ * Called by the preloads, once per process. In a preload, `afterEach` runs after every test of
106
+ * every file — the only place a hook reaches a suite that wrote none (measured on Bun 1.4.4: one
107
+ * registered from a library call inside `beforeAll` ran once, not per test).
108
+ */
109
+ export function installTabA11yAudit(): void {
110
+ if (installed) return;
111
+ installed = true;
112
+ afterEach(endTabA11y, TAB_A11Y_END_MS);
113
+ }
@@ -0,0 +1,153 @@
1
+ // The accessibility audit's RULES: which of the browser's own tree nodes a screen reader cannot
2
+ // name, the one in-page probe for what the tree prunes (`<html lang>`, focus under `aria-hidden`),
3
+ // and how a finding is told. No engine and no heuristics: every rule is a fact the browser already
4
+ // computed. When it runs and how it fails a test is `e2e-a11y-run.ts`.
5
+
6
+ import type { AxTreeNode } from './ax-node';
7
+
8
+ /**
9
+ * Roles whose node needs a non-blank name, keyed on the browser's spelling AND its `role.type`
10
+ * (probed on Chrome, 2026-11-02): an ARIA `role` and Chrome's `internalRole` never share a key,
11
+ * so an author's `role="Iframe"` is not the browser's `Iframe`. An image is `image`, never `img`.
12
+ */
13
+ export const NAMED_ROLES: Readonly<Record<'role' | 'internalRole', readonly string[]>> = {
14
+ role: [
15
+ 'button',
16
+ 'link',
17
+ 'textbox',
18
+ 'searchbox',
19
+ 'combobox',
20
+ 'checkbox',
21
+ 'radio',
22
+ 'switch',
23
+ 'slider',
24
+ 'spinbutton',
25
+ 'tab',
26
+ 'menuitem',
27
+ 'image',
28
+ 'heading',
29
+ ],
30
+ internalRole: ['Iframe', 'DisclosureTriangle'],
31
+ };
32
+
33
+ /** The document itself: an empty name is a page with no `<title>`. */
34
+ export const DOCUMENT_ROLE = 'RootWebArea';
35
+
36
+ /** What a probe finding under `aria-hidden` is called: the tree pruned it, so it has no role. */
37
+ export const FOCUSABLE_ROLE = 'focusable';
38
+
39
+ export type A11yRule = 'name' | 'title' | 'lang' | 'aria-hidden-focus';
40
+
41
+ /** One element that fails, as the error tells it and as an `allow` entry matches it. */
42
+ export interface A11yFinding {
43
+ readonly rule: A11yRule;
44
+ readonly role: string;
45
+ /** `outerHTML`, at most `HTML_BOUND` characters. */
46
+ readonly html: string;
47
+ readonly url: string;
48
+ /** The `allow` selectors this element matches — read in the page, never re-derived here. */
49
+ readonly matches: readonly string[];
50
+ }
51
+
52
+ /** A finding is read by a person in a terminal: the opening tag is what names the element. */
53
+ export const HTML_BOUND = 120;
54
+
55
+ const blank = (text: string): boolean => text.trim() === '';
56
+
57
+ /** The rule a tree node breaks, or undefined. Only an UNIGNORED node is in the reader's tree. */
58
+ export const ruleFor = (node: AxTreeNode): A11yRule | undefined => {
59
+ if (node.ignored || !blank(node.name)) return undefined;
60
+ if (node.roleType === 'internalRole' && node.role === DOCUMENT_ROLE) return 'title';
61
+ const named =
62
+ node.roleType === 'role'
63
+ ? NAMED_ROLES.role
64
+ : node.roleType === 'internalRole'
65
+ ? NAMED_ROLES.internalRole
66
+ : [];
67
+ return named.includes(node.role) ? 'name' : undefined;
68
+ };
69
+
70
+ /** Every node of a tree that breaks a rule, in the browser's order. */
71
+ export const auditTree = (nodes: readonly AxTreeNode[]): readonly AxTreeNode[] =>
72
+ nodes.filter((node) => ruleFor(node) !== undefined);
73
+
74
+ /**
75
+ * What the tree cannot say, asked in ONE evaluate. Answers JSON text. Focusable means reachable by
76
+ * Tab: rendered, not disabled, not `inert`, not `tabindex="-1"`.
77
+ */
78
+ export const probeExpression = (selectors: readonly string[]): string => `(() => {
79
+ const selectors = ${JSON.stringify(selectors)};
80
+ const describe = (el) => ({
81
+ html: (el === document.documentElement
82
+ ? el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1)
83
+ : el.outerHTML).slice(0, ${String(HTML_BOUND)}),
84
+ matches: selectors.filter((s) => { try { return el.matches(s); } catch { return false; } }),
85
+ });
86
+ const root = document.documentElement;
87
+ const focusable = 'a[href], button, input:not([type="hidden"]), select, textarea, iframe, summary, [tabindex], [contenteditable=""], [contenteditable="true"], audio[controls], video[controls]';
88
+ const hidden = [...document.querySelectorAll('[aria-hidden="true"]')]
89
+ .flatMap((host) => [host, ...host.querySelectorAll(focusable)])
90
+ .filter((el, at, all) => all.indexOf(el) === at && el.matches(focusable))
91
+ .filter((el) => !el.matches(':disabled') && el.getAttribute('tabindex') !== '-1')
92
+ .filter((el) => el.closest('[inert]') === null && el.getClientRects().length > 0)
93
+ .map(describe);
94
+ return JSON.stringify({
95
+ html: document.contentType === 'text/html' || document.contentType === 'application/xhtml+xml',
96
+ lang: (root?.getAttribute('lang') ?? '').trim() !== '',
97
+ root: root === null ? { html: '', matches: [] } : describe(root),
98
+ hidden,
99
+ });
100
+ })()`;
101
+
102
+ interface Described {
103
+ readonly html: string;
104
+ readonly matches: readonly string[];
105
+ }
106
+
107
+ const described = (raw: unknown): Described => {
108
+ const value = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {};
109
+ const html = value['html'];
110
+ const matches = value['matches'];
111
+ return {
112
+ html: typeof html === 'string' ? html.slice(0, HTML_BOUND) : '',
113
+ matches: Array.isArray(matches)
114
+ ? matches.filter((m): m is string => typeof m === 'string')
115
+ : [],
116
+ };
117
+ };
118
+
119
+ /**
120
+ * The probe's answer as findings. `undefined` is a document that is not markup — JSON, an image,
121
+ * a PDF the browser wrapped in its own viewer — which no rule here is about. An answer that is not
122
+ * the probe's shape finds nothing.
123
+ */
124
+ export const probeFindings = (raw: unknown, url: string): readonly A11yFinding[] | undefined => {
125
+ const decoded: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
126
+ if (typeof decoded !== 'object' || decoded === null) return [];
127
+ const answer = decoded as Record<string, unknown>;
128
+ if (answer['html'] === false) return undefined;
129
+ const hidden = Array.isArray(answer['hidden']) ? answer['hidden'] : [];
130
+ const out: A11yFinding[] = hidden.map((el) => ({
131
+ rule: 'aria-hidden-focus',
132
+ role: FOCUSABLE_ROLE,
133
+ url,
134
+ ...described(el),
135
+ }));
136
+ if (answer['lang'] === false) {
137
+ out.unshift({ rule: 'lang', role: DOCUMENT_ROLE, url, ...described(answer['root']) });
138
+ }
139
+ return out;
140
+ };
141
+
142
+ /** A tree node that broke a rule, as a finding. */
143
+ export const treeFinding = (node: AxTreeNode, url: string): A11yFinding => ({
144
+ rule: ruleFor(node) ?? 'name',
145
+ role: node.role,
146
+ html: (node.html ?? '').slice(0, HTML_BOUND),
147
+ url,
148
+ matches: node.matches ?? [],
149
+ });
150
+
151
+ /** The four facts a finding is deduplicated on: a shared header reports once per URL. */
152
+ export const findingKey = (finding: A11yFinding): string =>
153
+ JSON.stringify([finding.rule, finding.role, finding.html, finding.url]);