@uniflowed/test 0.0.0-alpha.9 → 0.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.
@@ -0,0 +1,362 @@
1
+ // @flow
2
+ //
3
+ // The accessibility audit behind `expect(container).toHaveNoAxeViolations()`.
4
+ //
5
+ // `@uniflowed/react-testing` renders into a real DOM, which is the expensive
6
+ // half of an accessibility assertion — and nothing was reading it. axe-core is
7
+ // the engine every other testing stack uses for that, it is the engine the
8
+ // browsers' own developer tools use, and reimplementing its four hundred-odd
9
+ // checks would be a small lookalike of exactly the kind uf's guide says not to
10
+ // ship. So uf runs axe-core rather than competing with it. See
11
+ // ubugeeei-prod/uf#511.
12
+ //
13
+ // # Why the import is dynamic, and why the specifier is a constant
14
+ //
15
+ // Dynamic, because `@uniflowed/test` is imported by every test file in every
16
+ // project and most of them will never audit anything: a static import would
17
+ // put a megabyte of rule definitions into the start-up of every worker to
18
+ // serve the files that ask for it. It is declared as an *optional* peer
19
+ // dependency for the same reason — a project that never writes the matcher
20
+ // should not be made to install the engine — and a project that writes it
21
+ // without installing it is told so in one sentence rather than by
22
+ // `ERR_MODULE_NOT_FOUND` from inside a matcher.
23
+ //
24
+ // A constant specifier, because the alternative is a module path read from
25
+ // configuration and handed to `import()`. `docs/security.md` is about exactly
26
+ // that shape: `uf.config.js` is a file a cloned repository brings with it, and
27
+ // "which module does the test runner load" is not a question it should get to
28
+ // answer. What a project *may* configure is which rules run, which is data.
29
+ //
30
+ // # What is configured once rather than per test
31
+ //
32
+ // The rule set. A project that has decided `color-contrast` cannot be judged
33
+ // by a DOM with no layout has decided it for the whole suite, and repeating
34
+ // that decision in every assertion is how two tests come to disagree about
35
+ // what "accessible" means. `uf.config.js`'s `accessibility.axe` block is the
36
+ // answer, delivered to workers in the environment (`UF_AXE`) the same way
37
+ // `UF_UPDATE_SNAPSHOTS` delivers the other run-wide decision. A call may still
38
+ // pass overrides, and they are merged over the project's — narrowing one
39
+ // assertion is legitimate, and it is visible on the line that does it.
40
+
41
+ /** How serious a violation is, weakest first. */
42
+ export type AxeImpact = "minor" | "moderate" | "serious" | "critical";
43
+
44
+ /** What a test or a project may say about a run. */
45
+ export type AxeOptions = {
46
+ /** Run only rules carrying one of these axe tags; all rules when empty. */
47
+ readonly tags?: $ReadOnlyArray<string>,
48
+ /** Rule ids to turn off, by id. */
49
+ readonly disabledRules?: $ReadOnlyArray<string>,
50
+ /** Weakest impact that counts as a failure. */
51
+ readonly minImpact?: AxeImpact,
52
+ };
53
+
54
+ /** One thing axe found, reduced to what a failure message needs. */
55
+ export type AxeViolation = {
56
+ readonly id: string,
57
+ readonly impact: AxeImpact | null,
58
+ readonly help: string,
59
+ readonly helpUrl: string,
60
+ readonly nodes: $ReadOnlyArray<string>,
61
+ };
62
+
63
+ /** Impacts in order, so a floor can be compared rather than matched. */
64
+ const IMPACTS: $ReadOnlyArray<AxeImpact> = ["minor", "moderate", "serious", "critical"];
65
+
66
+ /** Most violations one message names; the rest are counted. */
67
+ const MAX_VIOLATIONS_SHOWN = 10;
68
+
69
+ /** Most offending elements one violation names. */
70
+ const MAX_NODES_SHOWN = 3;
71
+
72
+ /** Longest excerpt of one element's markup a message quotes. */
73
+ const MAX_NODE_CHARS = 120;
74
+
75
+ /**
76
+ * The engine, loaded at most once per process.
77
+ *
78
+ * A promise rather than a module, so two assertions in flight at the same time
79
+ * share one load rather than racing to start two.
80
+ */
81
+ let engine: Promise<$FlowFixMe> | null = null;
82
+
83
+ /**
84
+ * axe-core, or a refusal that says what to install.
85
+ *
86
+ * The `$FlowFixMe` is the module's own shape: axe-core ships TypeScript
87
+ * declarations and no Flow ones, so what comes back is untyped whatever this
88
+ * file writes. It is confined to this one binding — everything the rest of the
89
+ * module reads out of a result goes through the readers below, which narrow
90
+ * rather than assert.
91
+ */
92
+ function axeEngine(): Promise<$FlowFixMe> {
93
+ if (engine == null) {
94
+ engine = import("axe-core").then(
95
+ (module) => {
96
+ const loaded: $FlowFixMe = (module as $FlowFixMe).default ?? module;
97
+ if (loaded == null || typeof loaded.run !== "function") {
98
+ throw new Error("`axe-core` is installed but has no `run`, so uf cannot audit with it");
99
+ }
100
+ return loaded;
101
+ },
102
+ () => {
103
+ throw new Error(
104
+ "`toHaveNoAxeViolations` needs axe-core, which this project does not have. " +
105
+ "Add it — `uf add -D axe-core` — and the matcher, and `uf dev`'s audit, " +
106
+ "start working; uf does not install it for you because a project that " +
107
+ "never audits should not carry the engine.",
108
+ );
109
+ },
110
+ );
111
+ }
112
+ return engine;
113
+ }
114
+
115
+ /** A string field, or `""`. */
116
+ function stringAt(value: mixed, key: string): string {
117
+ if (value == null || typeof value !== "object") return "";
118
+ const found = value[key];
119
+ return typeof found === "string" ? found : "";
120
+ }
121
+
122
+ /** An array field, or an empty one. */
123
+ function arrayAt(value: mixed, key: string): $ReadOnlyArray<mixed> {
124
+ if (value == null || typeof value !== "object") return [];
125
+ const found = value[key];
126
+ return Array.isArray(found) ? found : [];
127
+ }
128
+
129
+ /** The impact named at `key`, when it is one of the four. */
130
+ function impactAt(value: mixed, key: string): AxeImpact | null {
131
+ const impact = stringAt(value, key);
132
+ return IMPACTS.find((known) => known === impact) ?? null;
133
+ }
134
+
135
+ /** The impact axe gave a violation, when it gave one it knows. */
136
+ function impactOf(value: mixed): AxeImpact | null {
137
+ return impactAt(value, "impact");
138
+ }
139
+
140
+ /**
141
+ * The project's settings, read once from the environment.
142
+ *
143
+ * Absent, unreadable or the wrong shape all mean the same thing: no project
144
+ * settings, run every rule. A malformed value is not worth failing a suite
145
+ * over — the setting is a narrowing, and the widest answer is the safe one.
146
+ */
147
+ function projectOptions(): AxeOptions {
148
+ const raw =
149
+ typeof process === "undefined" ? undefined : ((process.env as $FlowFixMe)?.UF_AXE ?? undefined);
150
+ if (typeof raw !== "string" || raw === "") return {};
151
+ let parsed: mixed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch {
155
+ return {};
156
+ }
157
+ if (parsed == null || typeof parsed !== "object") return {};
158
+ const tags = arrayAt(parsed, "tags").filter((tag) => typeof tag === "string");
159
+ const disabled = arrayAt(parsed, "disabledRules").filter((rule) => typeof rule === "string");
160
+ const impact = impactAt(parsed, "minImpact") ?? undefined;
161
+ return {
162
+ tags: tags as $FlowFixMe,
163
+ disabledRules: disabled as $FlowFixMe,
164
+ minImpact: impact,
165
+ };
166
+ }
167
+
168
+ /** The project's settings, with a call's own merged over them. */
169
+ function resolveOptions(overrides: AxeOptions | void): AxeOptions {
170
+ const project = projectOptions();
171
+ return {
172
+ tags: overrides?.tags ?? project.tags,
173
+ disabledRules: overrides?.disabledRules ?? project.disabledRules,
174
+ minImpact: overrides?.minImpact ?? project.minImpact,
175
+ };
176
+ }
177
+
178
+ /** Those settings in axe's own vocabulary. */
179
+ function axeRunOptions(options: AxeOptions): { [string]: mixed } {
180
+ const run: { [string]: mixed } = {};
181
+ const tags = options.tags ?? [];
182
+ if (tags.length > 0) run.runOnly = { type: "tag", values: [...tags] };
183
+ const disabled = options.disabledRules ?? [];
184
+ if (disabled.length > 0) {
185
+ const rules: { [string]: mixed } = {};
186
+ for (const rule of disabled) rules[rule] = { enabled: false };
187
+ run.rules = rules;
188
+ }
189
+ return run;
190
+ }
191
+
192
+ /**
193
+ * Whether a violation is at or above the configured floor.
194
+ *
195
+ * A violation axe could not rate is kept: "we do not know how bad this is" is
196
+ * not a reason to drop it, and dropping it is how a floor set to `critical`
197
+ * would quietly hide everything unrated.
198
+ */
199
+ function meetsFloor(violation: AxeViolation, floor: AxeImpact | void): boolean {
200
+ if (floor == null || violation.impact == null) return true;
201
+ return IMPACTS.indexOf(violation.impact) >= IMPACTS.indexOf(floor);
202
+ }
203
+
204
+ /** One element, as short a piece of markup as still identifies it. */
205
+ function excerpt(html: string): string {
206
+ const line = html.split("\n")[0].trim();
207
+ return line.length > MAX_NODE_CHARS ? `${line.slice(0, MAX_NODE_CHARS)}…` : line;
208
+ }
209
+
210
+ /**
211
+ * The audit this process started last, settled or not.
212
+ *
213
+ * axe-core runs one audit at a time per process and refuses a second outright
214
+ * ("Axe is already running") rather than queueing it. One file never starts two
215
+ * on purpose, but a worker serves many files: a case that timed out in the
216
+ * middle of an audit leaves that audit running, and the next file's first audit
217
+ * used to fail on it — reported under a file that had done nothing wrong.
218
+ */
219
+ let running: Promise<mixed> = Promise.resolve();
220
+
221
+ /**
222
+ * Run `audit` once the audit already running has settled, however it settles.
223
+ *
224
+ * Waiting rather than failing is safe because the audit being waited on always
225
+ * ends — axe-core walks a finite tree — and a case that waits too long is still
226
+ * bounded by its own timeout.
227
+ */
228
+ function afterTheAuditRunning<T>(audit: () => Promise<T>): Promise<T> {
229
+ const turn = running.then(audit, audit);
230
+ running = turn.then(
231
+ () => undefined,
232
+ () => undefined,
233
+ );
234
+ return turn;
235
+ }
236
+
237
+ /** How long to wait between looks at an audit this module did not start. */
238
+ const FOREIGN_AUDIT_POLL_MS = 10;
239
+
240
+ /** How long an audit this module did not start may hold axe-core. */
241
+ const FOREIGN_AUDIT_LIMIT_MS = 10000;
242
+
243
+ // The timer and the clock as they were when this module loaded, so a file that
244
+ // fakes timers cannot stall a wait for a real audit to end.
245
+ const pause: typeof setTimeout = setTimeout;
246
+ const now: () => number = Date.now.bind(Date);
247
+
248
+ /**
249
+ * Whether axe-core refused to start because an audit is already running.
250
+ *
251
+ * `running` queues every audit this module starts, so a refusal means an audit
252
+ * started somewhere else. `uf dev`'s overlay runs axe-core itself, and stopping
253
+ * the overlay cannot recall an audit already walking the page: its own test can
254
+ * leave one finishing into the next file in the worker.
255
+ */
256
+ function isAlreadyRunning(error: mixed): boolean {
257
+ return error instanceof Error && error.message.startsWith("Axe is already running");
258
+ }
259
+
260
+ /**
261
+ * Run the audit once axe-core is free of an audit this module did not start.
262
+ *
263
+ * Looks again every `FOREIGN_AUDIT_POLL_MS` until axe-core takes it, and after
264
+ * `FOREIGN_AUDIT_LIMIT_MS` says what held the engine rather than waiting on an
265
+ * audit that is not going to end.
266
+ */
267
+ function runOnceAxeIsFree(
268
+ axe: $FlowFixMe,
269
+ node: mixed,
270
+ options: mixed,
271
+ since: number = now(),
272
+ ): Promise<mixed> {
273
+ return Promise.resolve()
274
+ .then(() => axe.run(node, options))
275
+ .catch((error: mixed) => {
276
+ if (!isAlreadyRunning(error)) {
277
+ throw error;
278
+ }
279
+ if (now() - since >= FOREIGN_AUDIT_LIMIT_MS) {
280
+ throw new Error(
281
+ "axe-core has been running an audit uf did not start for ten seconds, such as " +
282
+ "`uf dev`'s overlay or a direct `axe.run`; this assertion could not start its own.",
283
+ );
284
+ }
285
+ return new Promise((resolve) => {
286
+ pause(resolve, FOREIGN_AUDIT_POLL_MS);
287
+ }).then(() => runOnceAxeIsFree(axe, node, options, since));
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Audit `node` and report what it found, weakest results already dropped.
293
+ *
294
+ * Rejects when axe-core is not installed or the host has no document to audit.
295
+ * Both are "this assertion cannot be made here" rather than "this assertion
296
+ * failed", and both are ordinary errors rather than an [`AssertionError`]: the
297
+ * runner prints an assertion failure as a comparison, and there is nothing
298
+ * here to compare — what a reader needs is the sentence saying what to
299
+ * install.
300
+ */
301
+ export async function auditElement(
302
+ node: mixed,
303
+ overrides?: AxeOptions,
304
+ ): Promise<$ReadOnlyArray<AxeViolation>> {
305
+ if (typeof globalThis.document === "undefined") {
306
+ throw new Error(
307
+ "`toHaveNoAxeViolations` needs a document, and this host has none. " +
308
+ "Render with `@uniflowed/react-testing`, or run the file with `uf test --browser`.",
309
+ );
310
+ }
311
+ const axe = await axeEngine();
312
+ const options = resolveOptions(overrides);
313
+ const results = await afterTheAuditRunning(() =>
314
+ runOnceAxeIsFree(axe, node, axeRunOptions(options)),
315
+ );
316
+ const found: Array<AxeViolation> = [];
317
+ for (const raw of arrayAt(results, "violations")) {
318
+ const violation: AxeViolation = {
319
+ id: stringAt(raw, "id"),
320
+ impact: impactOf(raw),
321
+ help: stringAt(raw, "help"),
322
+ helpUrl: stringAt(raw, "helpUrl"),
323
+ nodes: arrayAt(raw, "nodes").map((one) => excerpt(stringAt(one, "html"))),
324
+ };
325
+ if (meetsFloor(violation, options.minImpact)) found.push(violation);
326
+ }
327
+ return found;
328
+ }
329
+
330
+ /**
331
+ * What a reader is told when the audit found something.
332
+ *
333
+ * The rule id, what it wants, the elements that broke it, and the page Deque
334
+ * publishes about it — which is the part that turns "aria-required-children"
335
+ * into something a person can act on without a search engine.
336
+ *
337
+ * Bounded, because a container rendered from bad markup can violate a hundred
338
+ * rules and a failure that fills a terminal is a failure nobody reads.
339
+ */
340
+ export function describeViolations(violations: $ReadOnlyArray<AxeViolation>): string {
341
+ const lines: Array<string> = [];
342
+ for (const violation of violations.slice(0, MAX_VIOLATIONS_SHOWN)) {
343
+ const impact = violation.impact == null ? "" : ` (${violation.impact})`;
344
+ lines.push(` ${violation.id}${impact} — ${violation.help}`);
345
+ for (const node of violation.nodes.slice(0, MAX_NODES_SHOWN)) {
346
+ lines.push(` ${node}`);
347
+ }
348
+ if (violation.nodes.length > MAX_NODES_SHOWN) {
349
+ lines.push(` …and ${violation.nodes.length - MAX_NODES_SHOWN} more elements`);
350
+ }
351
+ if (violation.helpUrl !== "") lines.push(` ${violation.helpUrl}`);
352
+ }
353
+ if (violations.length > MAX_VIOLATIONS_SHOWN) {
354
+ lines.push(` …and ${violations.length - MAX_VIOLATIONS_SHOWN} more rules`);
355
+ }
356
+ return lines.join("\n");
357
+ }
358
+
359
+ /** The rule ids a set of violations covers, for the `expected`/`received` pair. */
360
+ export function violationIds(violations: $ReadOnlyArray<AxeViolation>): string {
361
+ return violations.map((violation) => violation.id).join(", ");
362
+ }