@uniflowed/test 0.0.0-alpha.14 → 0.0.0-alpha.16
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/in-source.js +169 -0
- package/index.js +1 -0
- package/internal/axe.js +279 -0
- package/internal/browser/node.js +375 -0
- package/internal/expect.js +82 -6
- package/internal/isolation.js +200 -0
- package/package.json +22 -4
- package/worker.js +52 -42
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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// The whole surface is importable from here, so a test file has one import.
|
|
14
14
|
|
|
15
15
|
export type { Expect, Expectation, Matchers } from "./internal/expect.js";
|
|
16
|
+
export type { InSourceTests } from "./in-source.js";
|
|
16
17
|
export type { Body as TestBody, Case, Modifier, Suite, TestOptions } from "./internal/registry.js";
|
|
17
18
|
export type { ModuleFactory, ModuleNamespace } from "./internal/modules.js";
|
|
18
19
|
export type { Uft } from "./internal/namespace.js";
|
package/internal/axe.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
* Audit `node` and report what it found, weakest results already dropped.
|
|
212
|
+
*
|
|
213
|
+
* Rejects when axe-core is not installed or the host has no document to audit.
|
|
214
|
+
* Both are "this assertion cannot be made here" rather than "this assertion
|
|
215
|
+
* failed", and both are ordinary errors rather than an [`AssertionError`]: the
|
|
216
|
+
* runner prints an assertion failure as a comparison, and there is nothing
|
|
217
|
+
* here to compare — what a reader needs is the sentence saying what to
|
|
218
|
+
* install.
|
|
219
|
+
*/
|
|
220
|
+
export async function auditElement(
|
|
221
|
+
node: mixed,
|
|
222
|
+
overrides?: AxeOptions,
|
|
223
|
+
): Promise<$ReadOnlyArray<AxeViolation>> {
|
|
224
|
+
if (typeof globalThis.document === "undefined") {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"`toHaveNoAxeViolations` needs a document, and this host has none. " +
|
|
227
|
+
"Render with `@uniflowed/react-testing`, or run the file with `uf test --browser`.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const axe = await axeEngine();
|
|
231
|
+
const options = resolveOptions(overrides);
|
|
232
|
+
const results = await axe.run(node, axeRunOptions(options));
|
|
233
|
+
const found: Array<AxeViolation> = [];
|
|
234
|
+
for (const raw of arrayAt(results, "violations")) {
|
|
235
|
+
const violation: AxeViolation = {
|
|
236
|
+
id: stringAt(raw, "id"),
|
|
237
|
+
impact: impactOf(raw),
|
|
238
|
+
help: stringAt(raw, "help"),
|
|
239
|
+
helpUrl: stringAt(raw, "helpUrl"),
|
|
240
|
+
nodes: arrayAt(raw, "nodes").map((one) => excerpt(stringAt(one, "html"))),
|
|
241
|
+
};
|
|
242
|
+
if (meetsFloor(violation, options.minImpact)) found.push(violation);
|
|
243
|
+
}
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What a reader is told when the audit found something.
|
|
249
|
+
*
|
|
250
|
+
* The rule id, what it wants, the elements that broke it, and the page Deque
|
|
251
|
+
* publishes about it — which is the part that turns "aria-required-children"
|
|
252
|
+
* into something a person can act on without a search engine.
|
|
253
|
+
*
|
|
254
|
+
* Bounded, because a container rendered from bad markup can violate a hundred
|
|
255
|
+
* rules and a failure that fills a terminal is a failure nobody reads.
|
|
256
|
+
*/
|
|
257
|
+
export function describeViolations(violations: $ReadOnlyArray<AxeViolation>): string {
|
|
258
|
+
const lines: Array<string> = [];
|
|
259
|
+
for (const violation of violations.slice(0, MAX_VIOLATIONS_SHOWN)) {
|
|
260
|
+
const impact = violation.impact == null ? "" : ` (${violation.impact})`;
|
|
261
|
+
lines.push(` ${violation.id}${impact} — ${violation.help}`);
|
|
262
|
+
for (const node of violation.nodes.slice(0, MAX_NODES_SHOWN)) {
|
|
263
|
+
lines.push(` ${node}`);
|
|
264
|
+
}
|
|
265
|
+
if (violation.nodes.length > MAX_NODES_SHOWN) {
|
|
266
|
+
lines.push(` …and ${violation.nodes.length - MAX_NODES_SHOWN} more elements`);
|
|
267
|
+
}
|
|
268
|
+
if (violation.helpUrl !== "") lines.push(` ${violation.helpUrl}`);
|
|
269
|
+
}
|
|
270
|
+
if (violations.length > MAX_VIOLATIONS_SHOWN) {
|
|
271
|
+
lines.push(` …and ${violations.length - MAX_VIOLATIONS_SHOWN} more rules`);
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The rule ids a set of violations covers, for the `expected`/`received` pair. */
|
|
277
|
+
export function violationIds(violations: $ReadOnlyArray<AxeViolation>): string {
|
|
278
|
+
return violations.map((violation) => violation.id).join(", ");
|
|
279
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The `node:` builtins `@uniflowed/test` reaches for, as a browser can have
|
|
4
|
+
// them.
|
|
5
|
+
//
|
|
6
|
+
// # Why a package the browser never runs needs a browser build
|
|
7
|
+
//
|
|
8
|
+
// A module with an in-source test block imports `@uniflowed/test` at its top
|
|
9
|
+
// level, and that block is compiled away by `uf build` — `import.meta.uf.test`
|
|
10
|
+
// becomes `void 0`, the bundler drops the branch, and the import goes with it
|
|
11
|
+
// because the package declares `sideEffects: false`. But a bundler *resolves*
|
|
12
|
+
// a graph before it shakes it, so the six builtins this package's edges import
|
|
13
|
+
// were resolved on the way to being discarded: `node:fs` and `node:path` for
|
|
14
|
+
// snapshot files, `node:module` and `node:url` for module mocking,
|
|
15
|
+
// `node:async_hooks` and `node:util` for output capture. Each one printed
|
|
16
|
+
//
|
|
17
|
+
// Module "node:fs" has been externalized for browser compatibility
|
|
18
|
+
//
|
|
19
|
+
// so a project with one three-line in-source test got six warnings about
|
|
20
|
+
// modules `uf build` was in the middle of removing.
|
|
21
|
+
//
|
|
22
|
+
// `package.json`'s `browser` field maps all six here. Node ignores that field
|
|
23
|
+
// and every bundler honours it, so nothing about running a test on a host
|
|
24
|
+
// changes and the build is quiet.
|
|
25
|
+
//
|
|
26
|
+
// One file rather than six of four lines, because the question a reader has is
|
|
27
|
+
// "what does a browser not have", and that is answered better in one place
|
|
28
|
+
// than in six.
|
|
29
|
+
//
|
|
30
|
+
// # Why the shims work rather than only resolve
|
|
31
|
+
//
|
|
32
|
+
// Nothing here is reached by the build that made it necessary: the module that
|
|
33
|
+
// imports it is removed. What makes the implementations below worth writing is
|
|
34
|
+
// the case where the package *is* evaluated in a browser — a runner that ran a
|
|
35
|
+
// test file in a real page, which `docs/roadmap.md` still lists as not done, or
|
|
36
|
+
// an application that genuinely imports `@uniflowed/test` into client code.
|
|
37
|
+
// A file that exists only to be resolved would be a file that fails the moment
|
|
38
|
+
// somebody's build stops shaking it out, and that failure would arrive as a
|
|
39
|
+
// missing export rather than as a sentence.
|
|
40
|
+
//
|
|
41
|
+
// # Two kinds of shim, and the difference is the point
|
|
42
|
+
//
|
|
43
|
+
// Some of these are **implementations**: `format`, `inspect`, `pathToFileURL`
|
|
44
|
+
// and the path helpers do in a browser what they do in Node, closely enough
|
|
45
|
+
// for the one caller each has. `AsyncLocalStorage` is an implementation with a
|
|
46
|
+
// named limitation.
|
|
47
|
+
//
|
|
48
|
+
// The rest are **refusals**: the filesystem is not something a page has, and
|
|
49
|
+
// `createRequire` cannot invent a synchronous module loader. Each one throws a
|
|
50
|
+
// sentence naming what is not available and what to do instead. A shim that
|
|
51
|
+
// answered plausibly — an `existsSync` that said `false`, say — would be worse
|
|
52
|
+
// than no browser mode at all: `toMatchSnapshot` would read "no snapshot yet",
|
|
53
|
+
// record one it could not write, and pass. That is the runner lying, which is
|
|
54
|
+
// the failure `uf test` exists not to have.
|
|
55
|
+
//
|
|
56
|
+
// Each refusal names what is not available and what to do instead, so a
|
|
57
|
+
// boundary is a sentence rather than a surprise in a stack trace.
|
|
58
|
+
|
|
59
|
+
/** How a refusal is worded, so all of them read the same way. */
|
|
60
|
+
function unavailable(what: string, instead: string): Error {
|
|
61
|
+
return new Error(
|
|
62
|
+
`${what} is not available in \`uf test --browser\`: a page has no ${what}. ${instead}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------- //
|
|
67
|
+
// node:fs — refused
|
|
68
|
+
// ---------------------------------------------------------------------- //
|
|
69
|
+
|
|
70
|
+
/** Snapshot files live on a disk the page cannot see. */
|
|
71
|
+
export function existsSync(_path: mixed): empty {
|
|
72
|
+
throw unavailable(
|
|
73
|
+
"the filesystem",
|
|
74
|
+
"Snapshots (`toMatchSnapshot`) are written by the host that runs the file; assert with `toMatchInlineSnapshot`, which needs no file, or run the file on Node.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @see existsSync */
|
|
79
|
+
export function readFileSync(_path: mixed, _encoding?: mixed): empty {
|
|
80
|
+
return existsSync(_path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @see existsSync */
|
|
84
|
+
export function writeFileSync(_path: mixed, _contents: mixed): empty {
|
|
85
|
+
return existsSync(_path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @see existsSync */
|
|
89
|
+
export function mkdirSync(_path: mixed, _options?: mixed): empty {
|
|
90
|
+
return existsSync(_path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------- //
|
|
94
|
+
// node:module — refused, with one door left open
|
|
95
|
+
// ---------------------------------------------------------------------- //
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Modules the page has already imported, by the specifier they answer to.
|
|
99
|
+
*
|
|
100
|
+
* The one thing `createRequire` is used for that a browser *can* do. It is
|
|
101
|
+
* `@uniflowed/react-testing`'s `render`, which loads `react-dom/client` through
|
|
102
|
+
* `createRequire` rather than an import because `render` is synchronous and an
|
|
103
|
+
* import is not — a decision that is right on Node and impossible here.
|
|
104
|
+
*
|
|
105
|
+
* So the page's entry module imports `react-dom/client` itself, before any
|
|
106
|
+
* test file runs, and puts it here. `render` then finds it synchronously, and
|
|
107
|
+
* the import that made that possible happened at a moment when being
|
|
108
|
+
* asynchronous cost nothing.
|
|
109
|
+
*/
|
|
110
|
+
const provided: Map<string, mixed> = new Map();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a module the page has already imported.
|
|
114
|
+
*
|
|
115
|
+
* Called by whatever brought this package into a page, never by a test.
|
|
116
|
+
*/
|
|
117
|
+
export function provideModule(specifier: string, module: mixed): void {
|
|
118
|
+
provided.set(specifier, module);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A `require` that answers for what the page brought with it and refuses the
|
|
123
|
+
* rest.
|
|
124
|
+
*
|
|
125
|
+
* Refusing loudly rather than returning `undefined`: a caller that gets
|
|
126
|
+
* `undefined` back fails later, somewhere else, with a message about a
|
|
127
|
+
* property of `undefined`.
|
|
128
|
+
*/
|
|
129
|
+
export function createRequire(_from: mixed): (specifier: string) => mixed {
|
|
130
|
+
return (specifier: string) => {
|
|
131
|
+
if (provided.has(specifier)) return provided.get(specifier);
|
|
132
|
+
throw unavailable(
|
|
133
|
+
`a synchronous \`require("${specifier}")\``,
|
|
134
|
+
"Import it from the test file instead; a browser resolves modules asynchronously and cannot be asked to do it in the middle of a call.",
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------- //
|
|
140
|
+
// node:url — implemented
|
|
141
|
+
// ---------------------------------------------------------------------- //
|
|
142
|
+
|
|
143
|
+
/** A path as the `file:` URL Node would produce. */
|
|
144
|
+
export function pathToFileURL(filePath: string): URL {
|
|
145
|
+
const withSlashes = filePath.replace(/\\/g, "/");
|
|
146
|
+
const absolute = withSlashes.startsWith("/") ? withSlashes : `/${withSlashes}`;
|
|
147
|
+
return new URL(`file://${absolute.split("/").map(encodeURIComponent).join("/")}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The path inside a `file:` URL. */
|
|
151
|
+
export function fileURLToPath(url: string | URL): string {
|
|
152
|
+
const href = typeof url === "string" ? url : url.href;
|
|
153
|
+
if (!href.startsWith("file://")) {
|
|
154
|
+
throw new TypeError(`not a file URL: ${href}`);
|
|
155
|
+
}
|
|
156
|
+
return decodeURIComponent(href.slice("file://".length));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------- //
|
|
160
|
+
// node:util — implemented
|
|
161
|
+
// ---------------------------------------------------------------------- //
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `util.inspect`, to the depth a test's `console.log` needs.
|
|
165
|
+
*
|
|
166
|
+
* Not Node's algorithm — no getters, no circular references marked, no colour.
|
|
167
|
+
* What it has to be is *stable* and *readable*, because its output is what a
|
|
168
|
+
* failing test's printing looks like in the terminal, and `JSON.stringify`
|
|
169
|
+
* with a replacer that names functions and symbols is both.
|
|
170
|
+
*/
|
|
171
|
+
export function inspect(value: mixed): string {
|
|
172
|
+
if (typeof value === "string") return `'${value}'`;
|
|
173
|
+
return renderValue(value, new Set());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderValue(value: mixed, seen: Set<mixed>): string {
|
|
177
|
+
if (value === null) return "null";
|
|
178
|
+
if (value === undefined) return "undefined";
|
|
179
|
+
if (typeof value === "string") return `'${value}'`;
|
|
180
|
+
if (typeof value === "bigint") return `${String(value)}n`;
|
|
181
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
182
|
+
if (typeof value === "symbol") return String(value);
|
|
183
|
+
if (typeof value === "function") {
|
|
184
|
+
const name = (value as $FlowFixMe).name;
|
|
185
|
+
return name === "" ? "[Function (anonymous)]" : `[Function: ${String(name)}]`;
|
|
186
|
+
}
|
|
187
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
188
|
+
if (typeof value !== "object") return String(value);
|
|
189
|
+
if (seen.has(value)) return "[Circular]";
|
|
190
|
+
seen.add(value);
|
|
191
|
+
try {
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
return `[ ${value.map((item: mixed) => renderValue(item, seen)).join(", ")} ]`;
|
|
194
|
+
}
|
|
195
|
+
const entries = Object.entries(value).map(
|
|
196
|
+
([key, item]) => `${key}: ${renderValue(item, seen)}`,
|
|
197
|
+
);
|
|
198
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
199
|
+
} finally {
|
|
200
|
+
seen.delete(value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `util.format`, with the `%s`/`%d`/`%i`/`%j`/`%o`/`%O`/`%%` substitutions
|
|
206
|
+
* `console.log` uses.
|
|
207
|
+
*
|
|
208
|
+
* Everything left over is appended, space separated, exactly as Node does —
|
|
209
|
+
* which is the behaviour `console.log("a", 1, { b: 2 })` depends on.
|
|
210
|
+
*/
|
|
211
|
+
export function format(first: mixed, ...rest: $ReadOnlyArray<mixed>): string {
|
|
212
|
+
const args = [...rest];
|
|
213
|
+
let out = "";
|
|
214
|
+
if (typeof first === "string") {
|
|
215
|
+
let at = 0;
|
|
216
|
+
while (at < first.length) {
|
|
217
|
+
const ch = first[at];
|
|
218
|
+
if (ch !== "%" || at + 1 >= first.length) {
|
|
219
|
+
out += ch;
|
|
220
|
+
at += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const kind = first[at + 1];
|
|
224
|
+
if (kind === "%") {
|
|
225
|
+
out += "%";
|
|
226
|
+
at += 2;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!"sdifjoO".includes(kind) || args.length === 0) {
|
|
230
|
+
out += ch;
|
|
231
|
+
at += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const value = args.shift();
|
|
235
|
+
if (kind === "s") out += typeof value === "string" ? value : renderValue(value, new Set());
|
|
236
|
+
else if (kind === "d" || kind === "f") out += String(Number(value));
|
|
237
|
+
else if (kind === "i") out += String(Math.trunc(Number(value)));
|
|
238
|
+
else if (kind === "j") out += safeJson(value);
|
|
239
|
+
else out += renderValue(value, new Set());
|
|
240
|
+
at += 2;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
out = renderValue(first, new Set());
|
|
244
|
+
}
|
|
245
|
+
for (const value of args) {
|
|
246
|
+
out += ` ${typeof value === "string" ? value : renderValue(value, new Set())}`;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function safeJson(value: mixed): string {
|
|
252
|
+
try {
|
|
253
|
+
return JSON.stringify(value) ?? "undefined";
|
|
254
|
+
} catch {
|
|
255
|
+
return "[Circular]";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------- //
|
|
260
|
+
// node:async_hooks — implemented, with a limitation that is written down
|
|
261
|
+
// ---------------------------------------------------------------------- //
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A single-value stand-in for Node's asynchronous storage.
|
|
265
|
+
*
|
|
266
|
+
* The real one follows a value through every continuation of the work that
|
|
267
|
+
* entered it; this one holds whichever `run` is on the stack. A browser has no
|
|
268
|
+
* asynchronous context tracking to build the real thing on — the platform
|
|
269
|
+
* proposal for it is not shipped anywhere — and the alternative to an
|
|
270
|
+
* approximation is that `@uniflowed/test`'s output capture cannot be imported
|
|
271
|
+
* at all.
|
|
272
|
+
*
|
|
273
|
+
* What it costs is bounded by what the store is used for here.
|
|
274
|
+
* `internal/output.js` reads it to attribute a `console.log` to the case that
|
|
275
|
+
* printed it, and cases run one at a time, so the answer is right for
|
|
276
|
+
* everything a case prints while it is running. It is wrong for a `setTimeout`
|
|
277
|
+
* a finished case left behind: the print is still reported, attributed to the
|
|
278
|
+
* file rather than to the case. On Node that same straggler is attributed to
|
|
279
|
+
* its case; here it is attributed one level up.
|
|
280
|
+
*
|
|
281
|
+
* That is the whole of the difference, and it is a difference in a label on a
|
|
282
|
+
* line of output — not in what passes.
|
|
283
|
+
*/
|
|
284
|
+
export class AsyncLocalStorage<T> {
|
|
285
|
+
#store: T | void = undefined;
|
|
286
|
+
|
|
287
|
+
/** Run `body` with `value` readable from `getStore`. */
|
|
288
|
+
run<R>(value: T, body: () => R): R {
|
|
289
|
+
const previous = this.#store;
|
|
290
|
+
this.#store = value;
|
|
291
|
+
try {
|
|
292
|
+
return body();
|
|
293
|
+
} finally {
|
|
294
|
+
this.#store = previous;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The value of the nearest enclosing `run`, or `undefined`. */
|
|
299
|
+
getStore(): T | void {
|
|
300
|
+
return this.#store;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Run `body` with no value readable. */
|
|
304
|
+
exit<R>(body: () => R): R {
|
|
305
|
+
const previous = this.#store;
|
|
306
|
+
this.#store = undefined;
|
|
307
|
+
try {
|
|
308
|
+
return body();
|
|
309
|
+
} finally {
|
|
310
|
+
this.#store = previous;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------- //
|
|
316
|
+
// node:path — implemented, posix only
|
|
317
|
+
// ---------------------------------------------------------------------- //
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The path operations `internal/snapshot.js` performs on a snapshot path.
|
|
321
|
+
*
|
|
322
|
+
* Posix only, which is not a limitation here: every path a page sees came from
|
|
323
|
+
* a URL, and a URL's separator is `/` on every platform.
|
|
324
|
+
*/
|
|
325
|
+
export const path: {
|
|
326
|
+
readonly join: (...parts: $ReadOnlyArray<string>) => string,
|
|
327
|
+
readonly dirname: (of: string) => string,
|
|
328
|
+
readonly basename: (of: string, extension?: string) => string,
|
|
329
|
+
readonly extname: (of: string) => string,
|
|
330
|
+
readonly resolve: (...parts: $ReadOnlyArray<string>) => string,
|
|
331
|
+
readonly sep: string,
|
|
332
|
+
} = {
|
|
333
|
+
sep: "/",
|
|
334
|
+
join: (...parts) => normalize(parts.filter((part) => part !== "").join("/")),
|
|
335
|
+
dirname: (of) => {
|
|
336
|
+
const at = of.lastIndexOf("/");
|
|
337
|
+
if (at === -1) return ".";
|
|
338
|
+
return at === 0 ? "/" : of.slice(0, at);
|
|
339
|
+
},
|
|
340
|
+
basename: (of, extension) => {
|
|
341
|
+
const name = of.slice(of.lastIndexOf("/") + 1);
|
|
342
|
+
return extension != null && name.endsWith(extension)
|
|
343
|
+
? name.slice(0, name.length - extension.length)
|
|
344
|
+
: name;
|
|
345
|
+
},
|
|
346
|
+
extname: (of) => {
|
|
347
|
+
const name = of.slice(of.lastIndexOf("/") + 1);
|
|
348
|
+
const at = name.lastIndexOf(".");
|
|
349
|
+
return at <= 0 ? "" : name.slice(at);
|
|
350
|
+
},
|
|
351
|
+
resolve: (...parts) => {
|
|
352
|
+
let out = "";
|
|
353
|
+
for (const part of parts) {
|
|
354
|
+
if (part.startsWith("/")) out = part;
|
|
355
|
+
else if (out === "") out = part;
|
|
356
|
+
else out = `${out}/${part}`;
|
|
357
|
+
}
|
|
358
|
+
return normalize(out.startsWith("/") ? out : `/${out}`);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** `a//b/./c/../d` as `a/b/d`, keeping a leading slash. */
|
|
363
|
+
function normalize(input: string): string {
|
|
364
|
+
const absolute = input.startsWith("/");
|
|
365
|
+
const out: Array<string> = [];
|
|
366
|
+
for (const part of input.split("/")) {
|
|
367
|
+
if (part === "" || part === ".") continue;
|
|
368
|
+
if (part === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
369
|
+
else out.push(part);
|
|
370
|
+
}
|
|
371
|
+
const joined = out.join("/");
|
|
372
|
+
return absolute ? `/${joined}` : joined === "" ? "." : joined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default path;
|
package/internal/expect.js
CHANGED
|
@@ -66,9 +66,11 @@
|
|
|
66
66
|
// the only part of the issue that does.
|
|
67
67
|
|
|
68
68
|
import type { AsymmetricMatcher } from "./asymmetric.js";
|
|
69
|
+
import type { AxeOptions } from "./axe.js";
|
|
69
70
|
import type { SpyCall } from "./spy.js";
|
|
70
71
|
import * as asymmetric from "./asymmetric.js";
|
|
71
72
|
import * as snapshot from "./snapshot.js";
|
|
73
|
+
import { auditElement, describeViolations, violationIds } from "./axe.js";
|
|
72
74
|
import { isSpy } from "./spy.js";
|
|
73
75
|
import { equals, matchesObject, render } from "./equality.js";
|
|
74
76
|
|
|
@@ -90,7 +92,14 @@ export class AssertionError extends Error {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* What a matcher decided, and how to say it either way.
|
|
97
|
+
*
|
|
98
|
+
* A matcher may answer with a promise of one. Only one does — the accessibility
|
|
99
|
+
* audit, whose engine has no synchronous entry point — and [`bind`] is where
|
|
100
|
+
* the two cases are told apart; see the note there for why the promise is not
|
|
101
|
+
* hidden from the caller.
|
|
102
|
+
*/
|
|
94
103
|
type Verdict = {|
|
|
95
104
|
readonly pass: boolean,
|
|
96
105
|
readonly failure: () => string,
|
|
@@ -177,6 +186,20 @@ export type Matchers<R> = {
|
|
|
177
186
|
readonly toHaveClass: (...names: $ReadOnlyArray<string>) => R,
|
|
178
187
|
readonly toHaveTextContent: (expected: string | RegExp) => R,
|
|
179
188
|
readonly toHaveValue: (expected: mixed) => R,
|
|
189
|
+
/**
|
|
190
|
+
* Run axe-core over this element's subtree and require it to find nothing.
|
|
191
|
+
*
|
|
192
|
+
* `Promise<void>` rather than `R`, and it is the one matcher in this listing
|
|
193
|
+
* that is not generic: axe has no synchronous entry point, so the answer is
|
|
194
|
+
* a promise however the expectation was reached, and `await` is not optional.
|
|
195
|
+
*
|
|
196
|
+
* await expect(container).toHaveNoAxeViolations();
|
|
197
|
+
* await expect(container).toHaveNoAxeViolations({ tags: ["wcag2a"] });
|
|
198
|
+
*
|
|
199
|
+
* The rule set comes from `accessibility.axe` in `uf.config.js`; the argument
|
|
200
|
+
* narrows it for one assertion. See `./axe.js`.
|
|
201
|
+
*/
|
|
202
|
+
readonly toHaveNoAxeViolations: (options?: AxeOptions) => Promise<void>,
|
|
180
203
|
readonly not: Matchers<R>,
|
|
181
204
|
...
|
|
182
205
|
};
|
|
@@ -318,7 +341,7 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
|
|
|
318
341
|
* ubugeeei-prod/uf#402.
|
|
319
342
|
*/
|
|
320
343
|
function verdicts(received: mixed): {
|
|
321
|
-
readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict
|
|
344
|
+
readonly [string]: (...args: $ReadOnlyArray<mixed>) => Verdict | Promise<Verdict>,
|
|
322
345
|
} {
|
|
323
346
|
const shown = () => render(received);
|
|
324
347
|
const simple = (pass: boolean, what: string, expected?: mixed): Verdict => ({
|
|
@@ -667,6 +690,23 @@ function verdicts(received: mixed): {
|
|
|
667
690
|
failure: () => `expected the value ${render(actual)} to be ${render(expected)}`,
|
|
668
691
|
};
|
|
669
692
|
},
|
|
693
|
+
toHaveNoAxeViolations: async (options: mixed) => {
|
|
694
|
+
const node = element("toHaveNoAxeViolations");
|
|
695
|
+
const found = await auditElement(node, (options: $FlowFixMe));
|
|
696
|
+
const named = violationIds(found);
|
|
697
|
+
return {
|
|
698
|
+
pass: found.length === 0,
|
|
699
|
+
expected: "no accessibility violations",
|
|
700
|
+
received: found.length === 0 ? "none" : named,
|
|
701
|
+
failure: () =>
|
|
702
|
+
`expected no accessibility violations, and axe-core reported ` +
|
|
703
|
+
`${String(found.length)}:\n${describeViolations(found)}`,
|
|
704
|
+
// A passing audit is not proof of an accessible component — axe finds
|
|
705
|
+
// what a machine can find — so the negated message says what was
|
|
706
|
+
// actually established rather than implying the opposite verdict.
|
|
707
|
+
negatedFailure: () => "expected axe-core to report a violation, and it reported none",
|
|
708
|
+
};
|
|
709
|
+
},
|
|
670
710
|
};
|
|
671
711
|
|
|
672
712
|
/**
|
|
@@ -679,7 +719,16 @@ function verdicts(received: mixed): {
|
|
|
679
719
|
function element(matcher: string): Element {
|
|
680
720
|
const node: $FlowFixMe = received;
|
|
681
721
|
if (node == null || typeof node.getAttribute !== "function") {
|
|
682
|
-
|
|
722
|
+
// All four arguments, unlike the first version of this: the runner
|
|
723
|
+
// renders `expected` and `received` beside the message, and a one
|
|
724
|
+
// argument call left both of them `undefined` on screen for the one
|
|
725
|
+
// failure whose whole content is what was received instead.
|
|
726
|
+
throw new AssertionError(
|
|
727
|
+
`${matcher} needs an element, and received ${render(received)}`,
|
|
728
|
+
matcher,
|
|
729
|
+
"an element",
|
|
730
|
+
render(received),
|
|
731
|
+
);
|
|
683
732
|
}
|
|
684
733
|
return node;
|
|
685
734
|
}
|
|
@@ -780,8 +829,7 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
|
780
829
|
const table = verdicts(received);
|
|
781
830
|
const bound: $FlowFixMe = {};
|
|
782
831
|
for (const name of Object.keys(table)) {
|
|
783
|
-
|
|
784
|
-
const verdict = table[name](...args);
|
|
832
|
+
const decide = (verdict: Verdict) => {
|
|
785
833
|
if (verdict.pass !== negated) {
|
|
786
834
|
return undefined;
|
|
787
835
|
}
|
|
@@ -793,6 +841,29 @@ function bind(received: mixed, negated: boolean): $FlowFixMe {
|
|
|
793
841
|
verdict.received ?? render(received),
|
|
794
842
|
);
|
|
795
843
|
};
|
|
844
|
+
bound[name] = (...args: $ReadOnlyArray<mixed>) => {
|
|
845
|
+
const verdict = table[name](...args);
|
|
846
|
+
// A matcher whose engine is asynchronous answers with a promise of a
|
|
847
|
+
// verdict, and the promise is handed straight back rather than hidden.
|
|
848
|
+
//
|
|
849
|
+
// Hiding it was the alternative and it cannot be done: the only way to
|
|
850
|
+
// present an asynchronous answer synchronously is to decide before it
|
|
851
|
+
// arrives, which is deciding without it. What the promise costs is a
|
|
852
|
+
// forgotten `await`, and that case is not silent either — the rejection
|
|
853
|
+
// reaches the worker's unhandled-rejection handler, which fails the file
|
|
854
|
+
// the promise was created in and prints this same message. A missing
|
|
855
|
+
// `await` on a passing audit is the one case nothing reports, and it is
|
|
856
|
+
// the case where nothing happened.
|
|
857
|
+
//
|
|
858
|
+
// `instanceof Promise` rather than a `then` test, and it is safe for a
|
|
859
|
+
// reason that would not survive being generalised: every entry in the
|
|
860
|
+
// table is written in this file, so the only promise that can arrive
|
|
861
|
+
// here is one an `async` function in this module made, in this realm. A
|
|
862
|
+
// matcher registered from outside — which `@uniflowed/test` has no API
|
|
863
|
+
// for, deliberately — could hand back a foreign thenable, and this line
|
|
864
|
+
// would be the thing to revisit.
|
|
865
|
+
return verdict instanceof Promise ? verdict.then(decide) : decide(verdict);
|
|
866
|
+
};
|
|
796
867
|
}
|
|
797
868
|
Object.defineProperty(bound, "not", { get: () => bind(received, !negated) });
|
|
798
869
|
return bound;
|
|
@@ -840,7 +911,12 @@ function settled(promise: mixed, wanted: "resolve" | "reject", negated: boolean)
|
|
|
840
911
|
throw value;
|
|
841
912
|
}
|
|
842
913
|
: value;
|
|
843
|
-
|
|
914
|
+
// Returned rather than called and dropped: the wrapper is `async`, so
|
|
915
|
+
// returning an asynchronous matcher's promise is what awaits it. Without
|
|
916
|
+
// this, `await expect(p).resolves.toHaveNoAxeViolations()` awaited the
|
|
917
|
+
// settling and not the audit, and a violation surfaced as an unhandled
|
|
918
|
+
// rejection under whichever file was running by then.
|
|
919
|
+
return bind(subject, negated)[name](...args);
|
|
844
920
|
};
|
|
845
921
|
}
|
|
846
922
|
Object.defineProperty(bound, "not", { get: () => settled(promise, wanted, !negated) });
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/test`: what a file is allowed to leave behind.
|
|
4
|
+
//
|
|
5
|
+
// A worker serves many files out of one process, one at a time (`../worker.js`
|
|
6
|
+
// says why). Everything a file registers lives in this package and is cleared
|
|
7
|
+
// with it; everything a file *reaches around* the package to change belongs to
|
|
8
|
+
// the process, outlives the file, and is handed to whichever file the schedule
|
|
9
|
+
// puts next in that worker.
|
|
10
|
+
//
|
|
11
|
+
// That second list is what this module is. It has been discovered three times,
|
|
12
|
+
// once per entry, and each time the same way — a suite that passed alone and
|
|
13
|
+
// failed beside another, naming the file that read the value rather than the
|
|
14
|
+
// file that wrote it:
|
|
15
|
+
//
|
|
16
|
+
// * ubugeeei-prod/uf#417, `uft.stubEnv("NODE_ENV", …)` still set for the
|
|
17
|
+
// next file;
|
|
18
|
+
// * ubugeeei-prod/uf#581, a fake clock still installed, so the next file's
|
|
19
|
+
// `setTimeout` — including the one each case is raced against — never
|
|
20
|
+
// fired and the file hung with nothing on screen;
|
|
21
|
+
// * ubugeeei-prod/uf#607, `document.body` still holding the markup a
|
|
22
|
+
// hydration test wrote into it, so the next file's "there is one image on
|
|
23
|
+
// the page" found six.
|
|
24
|
+
//
|
|
25
|
+
// Which files share a worker is decided by `.uf/test-timings.json`, so a leak
|
|
26
|
+
// makes the *result* of a suite depend on how the machine was loaded the last
|
|
27
|
+
// time it ran. That is the failure this module exists to end, and the reason
|
|
28
|
+
// it is a list in one place rather than three calls in `../worker.js`: the
|
|
29
|
+
// question "what else does a file share with the next one" now has somewhere
|
|
30
|
+
// to be answered, and a fourth answer is one entry rather than one more thing
|
|
31
|
+
// to remember.
|
|
32
|
+
//
|
|
33
|
+
// # What belongs here
|
|
34
|
+
//
|
|
35
|
+
// State that (a) the process shares, (b) a test can change from inside a file,
|
|
36
|
+
// and (c) nothing else puts back. Anything a file merely *reads* does not
|
|
37
|
+
// belong here, and neither does anything the registry already clears — a spy
|
|
38
|
+
// is not on this list because `reset()` is what a spy lives in.
|
|
39
|
+
//
|
|
40
|
+
// # When it runs
|
|
41
|
+
//
|
|
42
|
+
// Before the next file is imported, not after the previous one is run. A file
|
|
43
|
+
// that throws while loading is still a file that has run code, and it still
|
|
44
|
+
// hands the next one whatever that code changed; putting things back on the
|
|
45
|
+
// way *in* covers the load failure and the crash as well as the ordinary end.
|
|
46
|
+
// It also means the first file in a worker starts from the same state as the
|
|
47
|
+
// tenth.
|
|
48
|
+
|
|
49
|
+
import { reset } from "./registry.js";
|
|
50
|
+
import { resetModuleState } from "./modules.js";
|
|
51
|
+
import { unstubAllEnvs, unstubAllGlobals } from "./namespace.js";
|
|
52
|
+
// Renamed at the door, for two reasons that agree. It reads as the resets
|
|
53
|
+
// beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
|
|
54
|
+
// verb-first, and so is what this does to the clock. And `useRealTimers` is
|
|
55
|
+
// not a React hook: it is uf's own timer control, which happens to be named
|
|
56
|
+
// the way every runner names it, and calling it bare in a plain function is a
|
|
57
|
+
// `react/hooks-rules` error on the name alone. A suppression would assert
|
|
58
|
+
// something about this call; the name is simply accurate.
|
|
59
|
+
import { useRealTimers as restoreRealClock } from "./timers.js";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One piece of process-wide state a file can change, and how to put it back.
|
|
63
|
+
*
|
|
64
|
+
* `what` is written for a person reading this list rather than for any code:
|
|
65
|
+
* nothing branches on it, and it is here because a list of five bare function
|
|
66
|
+
* references is a list nobody can check against the paragraph above it.
|
|
67
|
+
*/
|
|
68
|
+
type Shared = {|
|
|
69
|
+
readonly what: string,
|
|
70
|
+
readonly restore: () => void,
|
|
71
|
+
|};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything a file shares with the file after it, in the order it goes back.
|
|
75
|
+
*
|
|
76
|
+
* The order matters in one place and is harmless everywhere else: the clock
|
|
77
|
+
* goes back before the module stand-ins do, because a stand-in's factory runs
|
|
78
|
+
* on the next import and a factory that schedules anything under a leaked fake
|
|
79
|
+
* clock would schedule it into a clock nobody is going to advance.
|
|
80
|
+
*/
|
|
81
|
+
const SHARED: $ReadOnlyArray<Shared> = [
|
|
82
|
+
{
|
|
83
|
+
what: "the tests, hooks and spies this package registered",
|
|
84
|
+
restore: reset,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// `process.env` belongs to the process. `uft.stubEnv("NODE_ENV",
|
|
88
|
+
// "production")` in one file is still set when the next one imports, and
|
|
89
|
+
// the file that fails is the one that read it.
|
|
90
|
+
what: "environment variables `uft.stubEnv` replaced",
|
|
91
|
+
restore: unstubAllEnvs,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
// `globalThis` likewise, and worse: a stubbed `fetch` makes the next file
|
|
95
|
+
// talk to a stand-in that does not know about it.
|
|
96
|
+
what: "globals `uft.stubGlobal` replaced",
|
|
97
|
+
restore: unstubAllGlobals,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
// Worse than a leaked value, and worse in a way that hides it. A leaked
|
|
101
|
+
// stub makes the next file read something wrong, which arrives as an
|
|
102
|
+
// assertion naming the value. A leaked clock makes the next file's
|
|
103
|
+
// `setTimeout` never fire — including the one `withTimeout` races each
|
|
104
|
+
// case against — so the file hangs with nothing on screen until `uf`'s own
|
|
105
|
+
// deadline kills the worker, and the report names the file that waited
|
|
106
|
+
// rather than the file that stopped time.
|
|
107
|
+
what: "the clock, whatever `uft.useFakeTimers` did to it",
|
|
108
|
+
restore: restoreRealClock,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// A worker serves many files out of one module registry, so this is the
|
|
112
|
+
// difference between "one file at a time" and "one file's mocks at a
|
|
113
|
+
// time".
|
|
114
|
+
what: "modules `uft.mock` stood in for",
|
|
115
|
+
restore: resetModuleState,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
what: "the document, if this process has one",
|
|
119
|
+
restore: restoreDocument,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Hand the next file a document nobody else has written to.
|
|
125
|
+
*
|
|
126
|
+
* The document is process-wide in a way that is easy to miss, because nothing
|
|
127
|
+
* in this package installs it: `@uniflowed/react-testing` puts one on the
|
|
128
|
+
* global object the first time a test renders and deliberately keeps it for
|
|
129
|
+
* the life of the process — replacing it would strand every React root already
|
|
130
|
+
* mounted in the old one. So one document serves every file a worker runs, and
|
|
131
|
+
* what a file leaves in it is what the next file queries.
|
|
132
|
+
*
|
|
133
|
+
* Its *contents* are put back rather than the document itself, and "back"
|
|
134
|
+
* means empty: a file is handed the body it would have had if it had installed
|
|
135
|
+
* the document itself. `cleanup()` already unmounts what `render` mounted, and
|
|
136
|
+
* that is not the leak — the leak is markup a test wrote into the body by
|
|
137
|
+
* hand, which a hydration test must do because hydration is React attaching to
|
|
138
|
+
* markup that is already there. `rsc-split.test.js` and `streaming.test.js`
|
|
139
|
+
* both `replaceChildren` into the body, and the file after them in that worker
|
|
140
|
+
* started with somebody else's page.
|
|
141
|
+
*
|
|
142
|
+
* Read through `globalThis` and guarded by `typeof`, because a worker running
|
|
143
|
+
* a suite that never renders has no `document` at all and must not pay for
|
|
144
|
+
* one — and because on a host that *is* a browser this is the page, whose body
|
|
145
|
+
* a run of `uf test` has no business emptying. It only ever clears a body that
|
|
146
|
+
* a test process is using as scratch space, which is every case this can
|
|
147
|
+
* reach: the shim's document, or a page the project chose to run its own tests
|
|
148
|
+
* in.
|
|
149
|
+
*/
|
|
150
|
+
function restoreDocument(): void {
|
|
151
|
+
if (typeof globalThis.document === "undefined") {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const body = globalThis.document.body;
|
|
155
|
+
if (body == null) {
|
|
156
|
+
// A document a parser produced need not have one, and a document with no
|
|
157
|
+
// body is a document with nothing to put back.
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
body.replaceChildren();
|
|
161
|
+
// The attributes too, and not for tidiness: `<body class="dark">` is how a
|
|
162
|
+
// theme test says what it is testing, and a file that leaves one behind
|
|
163
|
+
// makes the next file's "the page is in light mode" false for a reason it
|
|
164
|
+
// cannot see. Read into an array first — removing an attribute while
|
|
165
|
+
// iterating a live list is the loop that skips every other entry.
|
|
166
|
+
for (const name of [...body.getAttributeNames()]) {
|
|
167
|
+
body.removeAttribute(name);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Put back everything the file that just ran may have changed.
|
|
173
|
+
*
|
|
174
|
+
* Called by `../worker.js` before it imports the next file. Nothing here
|
|
175
|
+
* reports a file for having changed any of this: a file is *allowed* to — that
|
|
176
|
+
* is what the `uft` namespace is for — and the contract is that the change
|
|
177
|
+
* does not outlive the file, not that it never happened.
|
|
178
|
+
*
|
|
179
|
+
* Every entry is attempted even after one has thrown, and the first failure is
|
|
180
|
+
* raised afterwards under the name of what it could not put back. Stopping at
|
|
181
|
+
* the first would leave the four entries behind it un-restored, which is the
|
|
182
|
+
* defect this module exists to prevent arriving by a new route — and a bare
|
|
183
|
+
* throw from one of these used to say only that something in the runner failed
|
|
184
|
+
* between two files.
|
|
185
|
+
*/
|
|
186
|
+
export function restoreSharedState(): void {
|
|
187
|
+
let failure: { readonly what: string, readonly thrown: mixed } | null = null;
|
|
188
|
+
for (const shared of SHARED) {
|
|
189
|
+
try {
|
|
190
|
+
shared.restore();
|
|
191
|
+
} catch (thrown) {
|
|
192
|
+
failure ??= { what: shared.what, thrown };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (failure != null) {
|
|
196
|
+
const cause = failure.thrown;
|
|
197
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
198
|
+
throw new Error(`@uniflowed/test could not put back ${failure.what}: ${message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/test",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.16",
|
|
4
4
|
"description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,15 +12,33 @@
|
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./index.js",
|
|
15
|
+
"./in-source": "./in-source.js",
|
|
15
16
|
"./worker": "./worker.js",
|
|
16
17
|
"./package.json": "./package.json"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
20
|
+
"in-source.js",
|
|
19
21
|
"index.js",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
+
"internal",
|
|
23
|
+
"worker.js"
|
|
22
24
|
],
|
|
23
25
|
"dependencies": {
|
|
24
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
26
|
+
"@uniflowed/host": "0.0.0-alpha.16"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"axe-core": ">=4"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"axe-core": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"browser": {
|
|
37
|
+
"node:async_hooks": "./internal/browser/node.js",
|
|
38
|
+
"node:fs": "./internal/browser/node.js",
|
|
39
|
+
"node:module": "./internal/browser/node.js",
|
|
40
|
+
"node:path": "./internal/browser/node.js",
|
|
41
|
+
"node:url": "./internal/browser/node.js",
|
|
42
|
+
"node:util": "./internal/browser/node.js"
|
|
25
43
|
}
|
|
26
44
|
}
|
package/worker.js
CHANGED
|
@@ -53,18 +53,9 @@ import { writeChangedSnapshots } from "./internal/snapshot.js";
|
|
|
53
53
|
import { createInterface } from "node:readline";
|
|
54
54
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
55
55
|
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
56
|
+
import { installInSourceTests } from "./in-source.js";
|
|
57
|
+
import { restoreSharedState } from "./internal/isolation.js";
|
|
58
58
|
import { run } from "./internal/run.js";
|
|
59
|
-
import { unstubAllEnvs, unstubAllGlobals } from "./internal/namespace.js";
|
|
60
|
-
// Renamed at the door, for two reasons that agree. It reads as the resets
|
|
61
|
-
// beside it do — `reset`, `unstubAllEnvs`, `resetModuleState` are all
|
|
62
|
-
// verb-first, and so is what this does to the clock. And `useRealTimers` is
|
|
63
|
-
// not a React hook: it is uf's own timer control, which happens to be named
|
|
64
|
-
// the way every runner names it, and calling it bare in a plain function is
|
|
65
|
-
// a `react/hooks-rules` error on the name alone. A suppression would assert
|
|
66
|
-
// something about this call; the name is simply accurate.
|
|
67
|
-
import { useRealTimers as restoreRealClock } from "./internal/timers.js";
|
|
68
59
|
|
|
69
60
|
/** What `uf` sends for one file. */
|
|
70
61
|
type Request = {|
|
|
@@ -148,42 +139,61 @@ function write(event: { readonly [string]: mixed }): void {
|
|
|
148
139
|
*/
|
|
149
140
|
async function runFile(request: Request, generation: number): Promise<void> {
|
|
150
141
|
const started = performance.now();
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// across workers by size,
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
unstubAllEnvs();
|
|
161
|
-
unstubAllGlobals();
|
|
162
|
-
// And the clock goes back, whatever the previous file did with it. A spy
|
|
163
|
-
// lives in the registry `reset` clears; a fake clock is a write to the
|
|
164
|
-
// scheduling globals the whole process shares, so `uft.useFakeTimers()` in
|
|
165
|
-
// one file is still installed when the next one imports.
|
|
166
|
-
//
|
|
167
|
-
// Worse than a leaked value, and worse in a way that hides it. A leaked stub
|
|
168
|
-
// makes the next file read something wrong, which arrives as an assertion
|
|
169
|
-
// naming the value. A leaked clock makes the next file's `setTimeout` never
|
|
170
|
-
// fire — including the one `withTimeout` races each case against — so the
|
|
171
|
-
// file hangs with nothing on screen until `uf`'s own deadline kills the
|
|
172
|
-
// worker, and the report names the file that waited rather than the file
|
|
173
|
-
// that stopped time. See ubugeeei-prod/uf#581.
|
|
142
|
+
// Everything the previous file changed and this package shares with it goes
|
|
143
|
+
// back: the registry, the stubbed environment and globals, the clock, the
|
|
144
|
+
// module stand-ins, and the document. What each of those is and why it is on
|
|
145
|
+
// the list is `internal/isolation.js`, which is one place rather than five
|
|
146
|
+
// calls here — a worker serves many files out of one process, `uf test` fans
|
|
147
|
+
// files across workers by size, and a leak therefore makes the *result* of a
|
|
148
|
+
// suite a function of the timings file rather than of the code under test.
|
|
149
|
+
// See ubugeeei-prod/uf#417, #581 and #607, which are that sentence three
|
|
150
|
+
// times over.
|
|
174
151
|
//
|
|
175
152
|
// Before the import rather than after the run, so a file that throws while
|
|
176
|
-
// loading still hands the next one a
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// serves many files out of one module registry, so this is the difference
|
|
181
|
-
// between "one file at a time" and "one file's mocks at a time".
|
|
182
|
-
resetModuleState();
|
|
153
|
+
// loading still hands the next one a clean process.
|
|
154
|
+
restoreSharedState();
|
|
155
|
+
// Not a restore, and so not on that list: this is the output budget for the
|
|
156
|
+
// file about to run, rather than something the previous file left behind.
|
|
183
157
|
output.startFile();
|
|
184
158
|
|
|
159
|
+
// In-source blocks in *this* file get uf's test API; the same blocks in
|
|
160
|
+
// every module this file imports get `undefined` and do not register. See
|
|
161
|
+
// `./in-source.js` for why the marker is a call rather than a constant.
|
|
162
|
+
const url = pathToFileURL(request.file).href;
|
|
163
|
+
const uninstallInSourceTests = installInSourceTests(url);
|
|
164
|
+
try {
|
|
165
|
+
await runImportedFile(request, generation, url, started);
|
|
166
|
+
} finally {
|
|
167
|
+
// For the whole file rather than only for its import. A block reads the
|
|
168
|
+
// marker at module scope, but a *case body* may read it too — `const
|
|
169
|
+
// { uft } = import.meta.uf.test` inside an `it` is an ordinary thing to
|
|
170
|
+
// write — and a marker that answered during the import and not during the
|
|
171
|
+
// run would be a value that changed under the file that read it.
|
|
172
|
+
//
|
|
173
|
+
// Taking it away earlier would not buy the isolation it looks like it
|
|
174
|
+
// buys: work a finished file left behind can register through a binding it
|
|
175
|
+
// captured just as easily as through this global, so the thing that keeps
|
|
176
|
+
// a straggler out of the next file is the registry reset at the top of
|
|
177
|
+
// this function and the generation stamped on every event, not the
|
|
178
|
+
// lifetime of one accessor.
|
|
179
|
+
uninstallInSourceTests();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The half of [`runFile`] that has a file to run.
|
|
185
|
+
*
|
|
186
|
+
* Split out so the caller's `finally` covers the import *and* the run without
|
|
187
|
+
* either of the two `return`s below escaping it.
|
|
188
|
+
*/
|
|
189
|
+
async function runImportedFile(
|
|
190
|
+
request: Request,
|
|
191
|
+
generation: number,
|
|
192
|
+
url: string,
|
|
193
|
+
started: number,
|
|
194
|
+
): Promise<void> {
|
|
185
195
|
try {
|
|
186
|
-
await import(`${
|
|
196
|
+
await import(`${url}?uf-run=${generation}`);
|
|
187
197
|
} catch (thrown) {
|
|
188
198
|
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
189
199
|
write({
|