@uniflowed/test 0.0.0-alpha.15 → 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 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";
@@ -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;
@@ -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
- /** What a matcher decided, and how to say it either way. */
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
- throw new AssertionError(`${matcher} needs an element, and received ${render(received)}`);
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
- bound[name] = (...args: $ReadOnlyArray<mixed>) => {
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
- bind(subject, negated)[name](...args);
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.15",
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
- "worker.js",
21
- "internal"
22
+ "internal",
23
+ "worker.js"
22
24
  ],
23
25
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.15"
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,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 import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
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({