@uniflowed/test 0.0.0-alpha.4 → 0.0.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,7 +82,7 @@ function host(): $FlowFixMe {
82
82
  * formats a date sees a plausible one — and `setSystemTime` is how a test that
83
83
  * cares says which.
84
84
  */
85
- export function useFakeTimers(): void {
85
+ export function installFakeClock(): void {
86
86
  if (installed != null) {
87
87
  return;
88
88
  }
@@ -120,7 +120,7 @@ export function useFakeTimers(): void {
120
120
  }
121
121
 
122
122
  /** Put the real scheduling globals back. */
123
- export function useRealTimers(): void {
123
+ export function restoreRealClock(): void {
124
124
  if (installed == null) {
125
125
  return;
126
126
  }
@@ -0,0 +1,30 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: the error a `uft` binding raises when this
4
+ // host cannot give it.
5
+ //
6
+ // Its own module because two of them need it — the namespace, and the module
7
+ // mocking in `./modules.js` that the namespace imports — and a class that both
8
+ // of a pair of modules reaches for is a third module, not an import cycle.
9
+ //
10
+ // The class is exported from the package, so a test can catch it by name
11
+ // rather than by matching on a message.
12
+
13
+ /**
14
+ * Raised for a `uft` binding this host cannot provide.
15
+ *
16
+ * Names what the binding needs rather than only that it is missing: a reader
17
+ * who is told that module interception needs synchronous module hooks can
18
+ * decide whether to run the suite on another host or to restructure the test,
19
+ * and neither is a decision a bare "not implemented" supports.
20
+ */
21
+ export class UnsupportedError extends Error {
22
+ /** The binding that was called. */
23
+ binding: string;
24
+
25
+ constructor(binding: string, reason: string) {
26
+ super(`uft.${binding} is not available: ${reason}`);
27
+ this.name = "UnsupportedError";
28
+ this.binding = binding;
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.4",
3
+ "version": "0.0.0-alpha.40",
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",
@@ -11,16 +11,41 @@
11
11
  "directory": "packages/test"
12
12
  },
13
13
  "exports": {
14
- ".": "./index.js",
14
+ ".": {
15
+ "uniflowed-bun-test": "./bun/index.js",
16
+ "default": "./index.js"
17
+ },
18
+ "./in-source": "./in-source.js",
15
19
  "./worker": "./worker.js",
20
+ "./browser-worker": "./browser-worker.js",
16
21
  "./package.json": "./package.json"
17
22
  },
18
23
  "files": [
24
+ "browser-worker.js",
25
+ "bun",
26
+ "in-source.js",
19
27
  "index.js",
28
+ "internal",
20
29
  "worker.js",
21
- "internal"
30
+ "!*.test.js"
22
31
  ],
23
32
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.4"
33
+ "@uniflowed/host": "0.0.0-alpha.40"
34
+ },
35
+ "peerDependencies": {
36
+ "axe-core": ">=4"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "axe-core": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "browser": {
44
+ "node:async_hooks": "./internal/browser/node.js",
45
+ "node:fs": "./internal/browser/node.js",
46
+ "node:module": "./internal/browser/node.js",
47
+ "node:path": "./internal/browser/node.js",
48
+ "node:url": "./internal/browser/node.js",
49
+ "node:util": "./internal/browser/node.js"
25
50
  }
26
51
  }
package/worker.js CHANGED
@@ -7,38 +7,100 @@
7
7
  // Flow loader, so the module is transformed by the same `uf transform` the
8
8
  // build uses), runs what it registered, and writes one event per line back.
9
9
  //
10
- // → {"file": "src/math.test.js", "filter": "adds", "timeoutMs": 5000}
11
- // ← {"event": "test", "name": "math > adds", "status": "passed", …}
12
- // ← {"event": "output", "stream": "stdout", "test": "math > adds", "text": "hi\n"}
13
- // ← {"event": "file", "status": "completed", "durationMicros": 1234}
10
+ // → {"file": "src/math.test.js", "filter": "adds", "timeoutMs": 5000, "generation": 7}
11
+ // ← {"event": "test", "name": "math > adds", "status": "passed", "generation": 7, …}
12
+ // ← {"event": "output", "stream": "stdout", "test": "math > adds", "text": "hi\n", "generation": 7}
13
+ // ← {"event": "file", "status": "completed", "durationMicros": 1234, "generation": 7}
14
14
  //
15
- // Three decisions worth stating. Results are streamed as they happen rather
15
+ // Four decisions worth stating. Results are streamed as they happen rather
16
16
  // than batched at the end, so `uf test` can draw progress and `--bail` can stop
17
17
  // a long run early. A file that throws while being *imported* is a file result,
18
18
  // not a test result: there were no tests to fail, and saying "0 tests" for a
19
- // module that could not load would be a lie. And the protocol does not share
20
- // its stream with the tests: a test's own printing becomes an `output` event
19
+ // module that could not load would be a lie. The protocol does not share its
20
+ // stream with the tests: a test's own printing becomes an `output` event
21
21
  // (`internal/output.js`), so a `console.log` cannot land in the middle of a
22
- // line `uf` is parsing.
22
+ // line `uf` is parsing. And every event says which request it belongs to —
23
+ // see "Which file an event belongs to" below.
23
24
  //
24
25
  // This module runs on import by design — it is a process entry point, the way
25
26
  // `@uniflowed/vite`'s loaders are.
27
+ //
28
+ // # Which file an event belongs to
29
+ //
30
+ // One worker's events are one stream, and a file's code outlives the file: a
31
+ // `setTimeout` nobody awaited fires while the *next* file is running, and what
32
+ // it prints used to be reported under a test in a different file. The same held
33
+ // for anything else the abandoned work reached — including the unhandled
34
+ // rejection handler at the bottom of this module, which ends a file.
35
+ //
36
+ // So an event says which request it came from rather than leaving `uf` to
37
+ // assume it came from the one in progress. `uf` numbers the requests it sends;
38
+ // this module runs each file inside an `AsyncLocalStorage` holding that number,
39
+ // and stamps every event with what the storage says *at the moment of writing*.
40
+ // Work a file leaves behind inherits its store however late it runs, so a
41
+ // straggler carries the generation of the file that scheduled it, and `uf`
42
+ // drops it instead of handing it to whatever is running now. See
43
+ // ubugeeei-prod/uf#203 and `crates/uf_test/src/host.rs`.
44
+ //
45
+ // A module-level "the file we are serving now" variable was the obvious answer
46
+ // and it is exactly the bug: at the moment the straggler writes, the file being
47
+ // served *is* the next one. The number has to come from where the work was
48
+ // started, which is what asynchronous storage is.
26
49
 
27
50
  import * as output from "./internal/output.js";
51
+ import { AsyncLocalStorage } from "node:async_hooks";
52
+ import process from "node:process";
28
53
  import { writeChangedSnapshots } from "./internal/snapshot.js";
29
54
  import { createInterface } from "node:readline";
30
55
  import { fileURLToPath, pathToFileURL } from "node:url";
31
56
 
32
- import { reset } from "./internal/registry.js";
57
+ import { installInSourceTests } from "./in-source.js";
58
+ import { restoreSharedState } from "./internal/isolation.js";
33
59
  import { run } from "./internal/run.js";
34
60
 
61
+ // `process` is imported rather than read off the global because this file is
62
+ // the process entry point, and an entry point that names what it depends on is
63
+ // one a reader does not have to trust.
64
+ //
65
+ // It used to be put *on* the global as well. Deno 1.x exposed the object as
66
+ // `node:process` and nothing as `globalThis.process`, and every module this
67
+ // worker reaches that wants the process reads the global: `internal/output.js`
68
+ // replaces `process.stdout.write`, `internal/namespace.js` reads
69
+ // `process.env` for `stubEnv`, and `internal/axe.js` reads `UF_AXE`. The Deno
70
+ // uf starts is 2.8 or newer — `@uniflowed/host/deno-preload` is built on that
71
+ // release's `registerHooks`, and `uf test` refuses anything older — and Deno 2
72
+ // has the global like Node and Bun. A shim for a host uf no longer starts would
73
+ // be a line claiming to be load-bearing while holding nothing up.
74
+
35
75
  /** What `uf` sends for one file. */
36
76
  type Request = {|
37
77
  readonly file: string,
38
78
  readonly filter?: string | null,
39
79
  readonly timeoutMs?: number,
80
+ /**
81
+ * Which request this is, counting from one within this worker.
82
+ *
83
+ * Optional only for a `uf` older than the field; `serve` falls back to its
84
+ * own count of the requests it has served, which is the same number.
85
+ */
86
+ readonly generation?: number,
40
87
  |};
41
88
 
89
+ /**
90
+ * The request whose work the code running right now descends from.
91
+ *
92
+ * Read by `write`, so a callback a finished file left behind stamps its events
93
+ * with that file's number rather than with the number of the file the worker
94
+ * has moved on to.
95
+ *
96
+ * `node:async_hooks` is not guarded for: Node, Deno and Bun all provide it
97
+ * under the `node:` specifier, and a host with no `node:` builtins could not
98
+ * link this module at all — `node:readline` and `node:url` are imported above.
99
+ * A host whose storage does not reach a particular callback is a different
100
+ * matter and is handled by the fallback in `write`.
101
+ */
102
+ const serving: AsyncLocalStorage<number> = new AsyncLocalStorage();
103
+
42
104
  /**
43
105
  * The protocol's own stdout, and the capture that gave it up.
44
106
  *
@@ -60,24 +122,104 @@ const emit: (chunk: string) => void = output.install((chunk) => {
60
122
  });
61
123
  });
62
124
 
125
+ /**
126
+ * Write one event, stamped with the request it belongs to.
127
+ *
128
+ * Stamped here rather than at each of the five places that build an event, so
129
+ * there is no way to write one without a generation — the module-level
130
+ * unhandled rejection handler included, which is the one that most needed it.
131
+ *
132
+ * `0` is what an event carries when the storage has nothing to say: a write
133
+ * from outside any request (a malformed request line, which `uf` answers to
134
+ * immediately), or a host that does not carry a store into the callback that
135
+ * wrote it — Deno 1.31 does not carry one through `setTimeout`, and Bun does
136
+ * not carry one into `unhandledRejection`. `uf` reads `0` as "the file being
137
+ * served", which is what every event meant before this field existed: of the
138
+ * two ways to be less than exact, saying nothing about a straggler is the
139
+ * behaviour that was already there, and refusing an unstamped `file` event
140
+ * would hang a file that had in fact answered.
141
+ */
63
142
  function write(event: { readonly [string]: mixed }): void {
64
- emit(`${JSON.stringify(event)}\n`);
143
+ emit(`${JSON.stringify({ ...event, generation: serving.getStore() ?? 0 })}\n`);
65
144
  }
66
145
 
67
146
  /**
68
147
  * Import and run one file.
69
148
  *
70
- * The module is imported with a cache-busting query so a watch-mode rerun in
71
- * the same worker sees the edited file rather than the one the module registry
72
- * already holds.
149
+ * `generation` is the request's number, and it does two jobs with one value:
150
+ * it busts the module cache so a watch-mode rerun in the same worker sees the
151
+ * edited file rather than the one the registry already holds, and — through
152
+ * the `serving` store this runs inside — it is what every event written from
153
+ * this file, or from anything this file leaves behind, is stamped with.
73
154
  */
155
+ /**
156
+ * Whether this is a run of the benchmarks, `uf test --bench`.
157
+ *
158
+ * Read from the environment, as snapshot updates are: it is a property of the
159
+ * run, set once on the worker by `uf`, and not something each request says.
160
+ */
161
+ function benching(): boolean {
162
+ const value = (globalThis: $FlowFixMe).process?.env?.UF_TEST_BENCH;
163
+ return value != null && value !== "" && value !== "0";
164
+ }
165
+
74
166
  async function runFile(request: Request, generation: number): Promise<void> {
75
167
  const started = performance.now();
76
- reset();
168
+ // Everything the previous file changed and this package shares with it goes
169
+ // back: the registry, the stubbed environment and globals, the clock, the
170
+ // module stand-ins, and the document. What each of those is and why it is on
171
+ // the list is `internal/isolation.js`, which is one place rather than five
172
+ // calls here — a worker serves many files out of one process, `uf test` fans
173
+ // files across workers by size, and a leak therefore makes the *result* of a
174
+ // suite a function of the timings file rather than of the code under test.
175
+ // See ubugeeei-prod/uf#417, #581 and #607, which are that sentence three
176
+ // times over.
177
+ //
178
+ // Before the import rather than after the run, so a file that throws while
179
+ // loading still hands the next one a clean process.
180
+ restoreSharedState();
181
+ // Not a restore, and so not on that list: this is the output budget for the
182
+ // file about to run, rather than something the previous file left behind.
77
183
  output.startFile();
78
184
 
185
+ // In-source blocks in *this* file get uf's test API; the same blocks in
186
+ // every module this file imports get `undefined` and do not register. See
187
+ // `./in-source.js` for why the marker is a call rather than a constant.
188
+ const url = pathToFileURL(request.file).href;
189
+ const uninstallInSourceTests = installInSourceTests(url);
79
190
  try {
80
- await import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
191
+ await runImportedFile(request, generation, url, started);
192
+ } finally {
193
+ // For the whole file rather than only for its import. A block reads the
194
+ // marker at module scope, but a *case body* may read it too — `const
195
+ // { uft } = import.meta.uf.test` inside an `it` is an ordinary thing to
196
+ // write — and a marker that answered during the import and not during the
197
+ // run would be a value that changed under the file that read it.
198
+ //
199
+ // Taking it away earlier would not buy the isolation it looks like it
200
+ // buys: work a finished file left behind can register through a binding it
201
+ // captured just as easily as through this global, so the thing that keeps
202
+ // a straggler out of the next file is the registry reset at the top of
203
+ // this function and the generation stamped on every event, not the
204
+ // lifetime of one accessor.
205
+ uninstallInSourceTests();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The half of [`runFile`] that has a file to run.
211
+ *
212
+ * Split out so the caller's `finally` covers the import *and* the run without
213
+ * either of the two `return`s below escaping it.
214
+ */
215
+ async function runImportedFile(
216
+ request: Request,
217
+ generation: number,
218
+ url: string,
219
+ started: number,
220
+ ): Promise<void> {
221
+ try {
222
+ await import(`${url}?uf-run=${generation}`);
81
223
  } catch (thrown) {
82
224
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
83
225
  write({
@@ -93,7 +235,12 @@ async function runFile(request: Request, generation: number): Promise<void> {
93
235
  try {
94
236
  const absolute = fileURLToPath(pathToFileURL(request.file).href);
95
237
  await run(
96
- { filter: request.filter ?? null, timeoutMs: request.timeoutMs, file: absolute },
238
+ {
239
+ filter: request.filter ?? null,
240
+ timeoutMs: request.timeoutMs,
241
+ file: absolute,
242
+ bench: benching(),
243
+ },
97
244
  (result) => {
98
245
  write({
99
246
  event: "test",
@@ -133,10 +280,15 @@ async function runFile(request: Request, generation: number): Promise<void> {
133
280
  * a time, because two files sharing a process would share globals and module
134
281
  * state, and a test suite that passes alone but fails beside another is the
135
282
  * worst failure a runner can produce.
283
+ *
284
+ * "In order" bounds what the worker *starts*, not what a file leaves running,
285
+ * which is why each file runs inside `serving`. The store is entered here and
286
+ * not in `runFile` so that the whole of a file's work, its module import
287
+ * included, is inside it.
136
288
  */
137
289
  function serve(): void {
138
290
  let queue: Promise<void> = Promise.resolve();
139
- let generation = 0;
291
+ let served = 0;
140
292
 
141
293
  createInterface({ input: process.stdin }).on("line", (line) => {
142
294
  if (line.trim() === "") {
@@ -146,6 +298,9 @@ function serve(): void {
146
298
  try {
147
299
  request = JSON.parse(line);
148
300
  } catch (error) {
301
+ // Outside any `serving.run`, so this is stamped `0` — which is right:
302
+ // there is no request to attribute it to, and `uf` is waiting for an
303
+ // answer to the line it just wrote.
149
304
  write({
150
305
  event: "file",
151
306
  status: "run-failed",
@@ -153,9 +308,14 @@ function serve(): void {
153
308
  });
154
309
  return;
155
310
  }
156
- generation += 1;
157
- const at = generation;
158
- queue = queue.then(() => runFile(request, at));
311
+ served += 1;
312
+ // `uf` chooses the number, because `uf` is the side that checks it. This
313
+ // count of served requests is the same sequence and stands in for a `uf`
314
+ // too old to send one — without something monotonic here the import below
315
+ // would be cache-busted with `undefined` and a watch-mode rerun would see
316
+ // the module it already had.
317
+ const at = request.generation ?? served;
318
+ queue = queue.then(() => serving.run(at, () => runFile(request, at)));
159
319
  });
160
320
 
161
321
  process.stdin.on("close", () => {
@@ -165,6 +325,13 @@ function serve(): void {
165
325
 
166
326
  // Unhandled rejections would otherwise take the worker down mid-file with no
167
327
  // explanation; reporting one as a file failure keeps the run honest.
328
+ //
329
+ // The file it fails is whichever one the rejected promise was created in, not
330
+ // whichever one is running when Node gets round to reporting it: `write` reads
331
+ // the store, and on Node the store follows the promise. That matters because
332
+ // this is a `file` event and a `file` event *ends* a file — a promise the
333
+ // previous file abandoned used to end the next one, with a message from code
334
+ // that file does not contain.
168
335
  process.on("unhandledRejection", (reason: mixed) => {
169
336
  const error = reason instanceof Error ? reason : new Error(String(reason));
170
337
  write({