@uniflowed/test 0.0.0-alpha.4 → 0.0.0-alpha.40
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/browser-worker.js +339 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +13 -1
- package/internal/axe.js +362 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/server.js +841 -0
- package/internal/expect.js +435 -54
- package/internal/frames.js +87 -1
- package/internal/isolation.js +231 -0
- package/internal/modules.js +430 -0
- package/internal/namespace.js +98 -50
- package/internal/output.js +105 -38
- package/internal/registry.js +54 -3
- package/internal/run.js +188 -40
- package/internal/timers.js +2 -2
- package/internal/unsupported.js +30 -0
- package/package.json +29 -4
- package/worker.js +187 -20
package/in-source.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// In-source tests: `if (import.meta.uf.test) { … }` in the file being tested.
|
|
4
|
+
//
|
|
5
|
+
// A three-line test for a three-line pure function does not want a file of its
|
|
6
|
+
// own, an import of the thing it tests, and a path that has to be kept in step
|
|
7
|
+
// with it. `import.meta.uf.test` is the block that holds it, and it is the
|
|
8
|
+
// same idea as Vitest's `import.meta.vitest` for the same reason.
|
|
9
|
+
//
|
|
10
|
+
// # What a block looks like
|
|
11
|
+
//
|
|
12
|
+
// import { expect, it } from "@uniflowed/test";
|
|
13
|
+
//
|
|
14
|
+
// export function add(a: number, b: number): number {
|
|
15
|
+
// return a + b;
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// if (import.meta.uf.test) {
|
|
19
|
+
// it("adds", () => {
|
|
20
|
+
// expect(add(1, 2)).toBe(3);
|
|
21
|
+
// });
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// An ordinary import for the bindings, because that is what makes them typed:
|
|
25
|
+
// Flow has no `typeof import("…")` for a library definition to use, so a
|
|
26
|
+
// marker that carried the API could not be described to the checker. The
|
|
27
|
+
// marker's *value* is the API anyway — `const { it } = import.meta.uf.test` is
|
|
28
|
+
// what somebody arriving from Vitest writes and it works — but it is untyped,
|
|
29
|
+
// and the import is the form uf recommends.
|
|
30
|
+
//
|
|
31
|
+
// The import costs nothing in a build. The branch folds to `if (void 0)` and
|
|
32
|
+
// goes, `@uniflowed/test` declares `sideEffects: false` so the now-unused
|
|
33
|
+
// import goes with it, and the package carries a `browser` field mapping the
|
|
34
|
+
// six `node:` builtins its edges import at `./internal/browser/node.js` — so
|
|
35
|
+
// the bundler does not even print a warning about modules it is in the middle
|
|
36
|
+
// of removing.
|
|
37
|
+
//
|
|
38
|
+
// # What makes the block disappear
|
|
39
|
+
//
|
|
40
|
+
// `uf` compiles the marker away rather than leaving it to be falsy at runtime:
|
|
41
|
+
// `crates/uf_transform`'s printer substitutes `void 0` for
|
|
42
|
+
// `import.meta.uf.test` in every transform except the ones `uf test` asks for,
|
|
43
|
+
// so `uf build` sees `if (void 0)` and the bundler removes the block. There is
|
|
44
|
+
// no `import.meta.uf` at runtime, in any host, which is also why the block
|
|
45
|
+
// cannot throw in a browser that never heard of uf.
|
|
46
|
+
//
|
|
47
|
+
// `tests/library/in-source.test.js` and `tests/library/in-source-subject.js`
|
|
48
|
+
// are the block running. The other half is in `crates/uf_cli/tests/vite.rs`,
|
|
49
|
+
// which builds a fixture whose block holds a string that appears nowhere else
|
|
50
|
+
// and reads the emitted client bundle back looking for it — in the same file
|
|
51
|
+
// the module's other marker has to be present in, because "a string is
|
|
52
|
+
// missing" is only evidence when something establishes the module is not.
|
|
53
|
+
//
|
|
54
|
+
// # Why the marker is a call, and what it is called with
|
|
55
|
+
//
|
|
56
|
+
// Under `uf test` the printer substitutes
|
|
57
|
+
// `globalThis.__ufInSourceTests?.(import.meta.url)`, and this module is what
|
|
58
|
+
// answers it. The argument is the module asking, because a source file with an
|
|
59
|
+
// in-source block is an ordinary module and is imported more than once in a
|
|
60
|
+
// normal run: `uf test` imports `add.js` as the file it is running, and
|
|
61
|
+
// `add.test.js` imports it again for the function. Both evaluations reach the
|
|
62
|
+
// marker. Only the first is the file the run is reporting on, so only the
|
|
63
|
+
// first is given the API — otherwise every in-source case would register twice
|
|
64
|
+
// and the second copy would be filed under whichever test file imported it.
|
|
65
|
+
|
|
66
|
+
import {
|
|
67
|
+
afterAll,
|
|
68
|
+
afterEach,
|
|
69
|
+
beforeAll,
|
|
70
|
+
beforeEach,
|
|
71
|
+
describe,
|
|
72
|
+
it,
|
|
73
|
+
test,
|
|
74
|
+
} from "./internal/registry.js";
|
|
75
|
+
import { fn, spyOn } from "./internal/spy.js";
|
|
76
|
+
|
|
77
|
+
import type { Expect } from "./internal/expect.js";
|
|
78
|
+
import type { Uft } from "./internal/namespace.js";
|
|
79
|
+
import { expect } from "./internal/expect.js";
|
|
80
|
+
import { uft } from "./internal/namespace.js";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The name `uf` compiles `import.meta.uf.test` into a call on.
|
|
84
|
+
*
|
|
85
|
+
* Exported so the worker and the tests name it once; it is spelled out in `crates/uf_transform/src/print.rs` as well, and
|
|
86
|
+
* `tests/library/in-source.test.js` is what keeps the two spellings equal.
|
|
87
|
+
*/
|
|
88
|
+
export const IN_SOURCE_GLOBAL: "__ufInSourceTests" = "__ufInSourceTests";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What an in-source block is handed.
|
|
92
|
+
*
|
|
93
|
+
* The subset of `@uniflowed/test` a test body needs, and deliberately not all
|
|
94
|
+
* of it: a block that wants module mocking or a snapshot is a block that has
|
|
95
|
+
* outgrown living inside the file it tests, and `import { uft } from
|
|
96
|
+
* "@uniflowed/test"` in a file of its own is the answer to that. `uft` is here
|
|
97
|
+
* anyway because spies and the fake clock are ordinary for a unit test.
|
|
98
|
+
*/
|
|
99
|
+
export type InSourceTests = {
|
|
100
|
+
readonly describe: typeof describe,
|
|
101
|
+
readonly it: typeof it,
|
|
102
|
+
readonly test: typeof test,
|
|
103
|
+
readonly expect: Expect,
|
|
104
|
+
readonly beforeAll: typeof beforeAll,
|
|
105
|
+
readonly beforeEach: typeof beforeEach,
|
|
106
|
+
readonly afterAll: typeof afterAll,
|
|
107
|
+
readonly afterEach: typeof afterEach,
|
|
108
|
+
readonly fn: typeof fn,
|
|
109
|
+
readonly spyOn: typeof spyOn,
|
|
110
|
+
readonly uft: Uft,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The API every in-source block in a run shares.
|
|
115
|
+
*
|
|
116
|
+
* One frozen object rather than one per file: the bindings are the module's
|
|
117
|
+
* own singletons — `it` registers into the one registry the worker resets
|
|
118
|
+
* between files — so a copy per file would only be a copy of the same
|
|
119
|
+
* references.
|
|
120
|
+
*/
|
|
121
|
+
const API: InSourceTests = Object.freeze({
|
|
122
|
+
describe,
|
|
123
|
+
it,
|
|
124
|
+
test,
|
|
125
|
+
expect,
|
|
126
|
+
beforeAll,
|
|
127
|
+
beforeEach,
|
|
128
|
+
afterAll,
|
|
129
|
+
afterEach,
|
|
130
|
+
fn,
|
|
131
|
+
spyOn,
|
|
132
|
+
uft,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The module URL, without whatever a host appended to it.
|
|
137
|
+
*
|
|
138
|
+
* The worker busts its import cache with `?uf-run=<generation>` so a watch
|
|
139
|
+
* rerun sees the edited file, which means the file being run reports an
|
|
140
|
+
* `import.meta.url` that its own path does not match. Comparing the part
|
|
141
|
+
* before the query is comparing the module.
|
|
142
|
+
*
|
|
143
|
+
* A hash is stripped for the same reason and never appears in practice; it
|
|
144
|
+
* costs one `indexOf` to not have to think about it again.
|
|
145
|
+
*/
|
|
146
|
+
function moduleUrl(url: string): string {
|
|
147
|
+
const end = url.search(/[?#]/);
|
|
148
|
+
return end === -1 ? url : url.slice(0, end);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hand in-source blocks their API for the file `url`, and nothing to any other
|
|
153
|
+
* module.
|
|
154
|
+
*
|
|
155
|
+
* Returns the function that puts the global back as it was, which the worker
|
|
156
|
+
* calls when the file is over. Restoring rather than leaving it set is the
|
|
157
|
+
* same rule the worker applies to environment stubs and module mocks: a file
|
|
158
|
+
* may not change what the next file on this worker sees.
|
|
159
|
+
*/
|
|
160
|
+
export function installInSourceTests(url: string): () => void {
|
|
161
|
+
const wanted = moduleUrl(url);
|
|
162
|
+
const globals: { [string]: mixed } = globalThis as $FlowFixMe;
|
|
163
|
+
const previous = globals[IN_SOURCE_GLOBAL];
|
|
164
|
+
globals[IN_SOURCE_GLOBAL] = (asking: string): InSourceTests | void =>
|
|
165
|
+
moduleUrl(asking) === wanted ? API : undefined;
|
|
166
|
+
return () => {
|
|
167
|
+
globals[IN_SOURCE_GLOBAL] = previous;
|
|
168
|
+
};
|
|
169
|
+
}
|
package/index.js
CHANGED
|
@@ -12,7 +12,18 @@
|
|
|
12
12
|
//
|
|
13
13
|
// The whole surface is importable from here, so a test file has one import.
|
|
14
14
|
|
|
15
|
-
export type {
|
|
15
|
+
export type { Expect, Expectation, Matchers } from "./internal/expect.js";
|
|
16
|
+
export type { InSourceTests } from "./in-source.js";
|
|
17
|
+
export type {
|
|
18
|
+
BenchOptions,
|
|
19
|
+
Body as TestBody,
|
|
20
|
+
Case,
|
|
21
|
+
Modifier,
|
|
22
|
+
Suite,
|
|
23
|
+
TestOptions,
|
|
24
|
+
} from "./internal/registry.js";
|
|
25
|
+
export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
|
|
26
|
+
export type { Uft } from "./internal/namespace.js";
|
|
16
27
|
export type { Outcome, Result, RunOptions } from "./internal/run.js";
|
|
17
28
|
export type { Site } from "./internal/frames.js";
|
|
18
29
|
export type { SpyCall, SpyResult } from "./internal/spy.js";
|
|
@@ -23,6 +34,7 @@ export {
|
|
|
23
34
|
afterEach,
|
|
24
35
|
beforeAll,
|
|
25
36
|
beforeEach,
|
|
37
|
+
bench,
|
|
26
38
|
describe,
|
|
27
39
|
it,
|
|
28
40
|
test,
|
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
|
+
}
|