@uniflowed/test 0.0.0-alpha.15 → 0.0.0-alpha.17
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/package.json +22 -4
- package/worker.js +38 -1
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) });
|
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.17",
|
|
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.17"
|
|
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,6 +53,7 @@ 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 { installInSourceTests } from "./in-source.js";
|
|
56
57
|
import { restoreSharedState } from "./internal/isolation.js";
|
|
57
58
|
import { run } from "./internal/run.js";
|
|
58
59
|
|
|
@@ -155,8 +156,44 @@ async function runFile(request: Request, generation: number): Promise<void> {
|
|
|
155
156
|
// file about to run, rather than something the previous file left behind.
|
|
156
157
|
output.startFile();
|
|
157
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);
|
|
158
164
|
try {
|
|
159
|
-
await
|
|
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> {
|
|
195
|
+
try {
|
|
196
|
+
await import(`${url}?uf-run=${generation}`);
|
|
160
197
|
} catch (thrown) {
|
|
161
198
|
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
162
199
|
write({
|