@uniflowed/test 0.0.0-alpha.9 → 0.2.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.
- package/app-browser.js +6 -0
- package/app.js +175 -0
- package/browser-worker.js +334 -0
- package/browser.js +59 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +11 -1
- package/internal/axe.js +362 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +893 -0
- package/internal/browser/transport.js +2 -0
- package/internal/expect.js +506 -100
- package/internal/frames.js +178 -0
- package/internal/isolation.js +231 -0
- package/internal/namespace.js +22 -10
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +25 -7
- package/internal/registry.js +102 -17
- package/internal/run.js +159 -24
- package/internal/timers.js +2 -2
- package/package.json +41 -5
- package/worker.js +115 -10
package/internal/axe.js
ADDED
|
@@ -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
|
+
}
|