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

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.
@@ -88,6 +88,24 @@ function matchesThrown(thrown: mixed, expected: mixed): boolean {
88
88
  *
89
89
  * Every entry returns a [`Verdict`] rather than throwing, which is what lets
90
90
  * `.not` reuse all of them.
91
+ *
92
+ * # The `any` in the indexer
93
+ *
94
+ * The entries do not agree about their arguments — `toBe` takes a `mixed`,
95
+ * `toHaveLength` takes a `number`, `toBeCloseTo` takes two — and [`bind`]
96
+ * applies whichever one it was asked for to a `$ReadOnlyArray<mixed>` it
97
+ * collected from a caller. Parameters are contravariant, so one indexer cannot
98
+ * describe both ends: `(...args: $ReadOnlyArray<mixed>)` rejects every entry
99
+ * that wants a `number`, and `(...args: $ReadOnlyArray<empty>)` accepts every
100
+ * entry and rejects the call.
101
+ *
102
+ * `mixed` with a cast at the call would move the same unsoundness one line
103
+ * without checking anything, because the caller is `bind`, whose result is
104
+ * `$FlowFixMe` and whose result's result is `expect`, also `$FlowFixMe`. The
105
+ * type that makes any of this checked is a written-out matcher interface —
106
+ * one signature per matcher, plus `.not`, `.resolves` and `.rejects` — which
107
+ * is what `expect`'s own annotation is waiting for. Until that exists, a
108
+ * narrower type here would be precision nobody can reach.
91
109
  */
92
110
  function verdicts(received: mixed): {
93
111
  readonly [string]: (...args: $ReadOnlyArray<any>) => Verdict,
@@ -21,25 +21,58 @@
21
21
  // # Why this is its own module
22
22
  //
23
23
  // Two callers need it and neither owns it: `worker.js` installs the capture at
24
- // start-up, and `run.js` says which case is running so a chunk can be named.
25
- // "Who printed this" is also state with a lifetime of its own — set around a
26
- // case's body and hooks, cleared between them — exactly like the snapshot key
27
- // in `snapshot.js`, and for the same reason it lives beside the thing it
28
- // describes rather than inside either caller.
24
+ // start-up, and `run.js` runs each case inside the ownership kept here, so a
25
+ // chunk can be named. "Who printed this" is also state with a lifetime of its
26
+ // own — one case's hooks and body — exactly like the snapshot key in
27
+ // `snapshot.js`, and for the same reason it lives beside the thing it describes
28
+ // rather than inside either caller.
29
29
  //
30
30
  // # What "the test that printed it" means
31
31
  //
32
- // The name a chunk carries is the case the *worker* is running when the chunk
33
- // arrives, which is not the same as the case whose code produced it. A
34
- // `setTimeout` a test leaves behind prints while the next case is running and
35
- // is filed under that one; a chunk from no case at all is filed under the
36
- // file.
32
+ // The case whose *asynchronous context* the write happened in, which is the
33
+ // case whose code produced it.
37
34
  //
38
- // Getting this exactly right needs the printing to be tied to the asynchronous
39
- // context the case ran in `AsyncLocalStorage` and everything under it — and
40
- // that is a bigger change than this module, because it has to reach the
41
- // scheduler that runs the cases. It is written down here rather than left to
42
- // be discovered from a confusing report. See ubugeeei-prod/uf#207.
35
+ // The obvious answer was a module-level variable the runner set before a case
36
+ // and cleared after it, and it was wrong in one shape that matters: the name a
37
+ // chunk carried was whatever the worker happened to be running when the chunk
38
+ // arrived. A `setTimeout` a test left behind fires while the *next* case is
39
+ // running, so the line it printed was reported under that next case — a test
40
+ // accused of printing something it never printed, which is worse than not
41
+ // naming it at all, because a reader chasing the message finds it under code
42
+ // that does not contain it. See ubugeeei-prod/uf#207.
43
+ //
44
+ // So the owner is an `AsyncLocalStorage`, and what `run.js` calls is
45
+ // `runInTest` rather than an `enterTest` / `exitTest` pair: the store is only
46
+ // carried by work started *inside* the case, so the case's setup, body and
47
+ // teardown have to run within it. Everything they schedule inherits it,
48
+ // whenever it eventually runs.
49
+ //
50
+ // # When there is no owner
51
+ //
52
+ // `getStore()` answers nothing outside a case, and a chunk with no owner is
53
+ // filed under the file — which is right for an import, a `beforeAll`, or a
54
+ // straggler from a case that is long gone.
55
+ //
56
+ // It is also the answer on a host whose storage does not reach the callback.
57
+ // Deno 1.31 has `AsyncLocalStorage` and propagates it across `await`, but not
58
+ // through `setTimeout`, so a detached callback there is filed under the file
59
+ // rather than under the case that scheduled it. That degradation is the point:
60
+ // of the two ways to be less than exact, naming the file says less, and naming
61
+ // the next case says something false.
62
+ //
63
+ // `node:async_hooks` itself is not guarded for, because a guard could not run.
64
+ // Node, Deno and Bun all provide it under the `node:` specifier, and a host
65
+ // that had no `node:` builtins could not start this worker at all — `node:util`
66
+ // is imported below, `node:readline` and `node:url` by `worker.js`. A `typeof`
67
+ // check around the constructor would only ever execute on a host where this
68
+ // module had already linked.
69
+ //
70
+ // The one thing this does not answer is a straggler that outlives its *file*:
71
+ // the worker runs the next file in the same process, and a chunk still carrying
72
+ // a name from the file before is a name the next file's report has no test for.
73
+ // The host files it under the file it arrived in, which is honest but not
74
+ // exact, and closing it properly needs the file's generation in the protocol.
75
+ // That is ubugeeei-prod/uf#203, and it is not this module's to fix.
43
76
 
44
77
  // # Bounds
45
78
  //
@@ -49,6 +82,7 @@
49
82
  // nothing after it is kept. The budget starts over for each file, so a chatty
50
83
  // file does not silence the next one in the same worker.
51
84
 
85
+ import { AsyncLocalStorage } from "node:async_hooks";
52
86
  import { format, inspect } from "node:util";
53
87
 
54
88
  import { userFrames } from "./frames.js";
@@ -95,9 +129,16 @@ const DECODER = new TextDecoder();
95
129
  /** What a stream's `write` calls when it has taken the chunk. */
96
130
  type WriteCallback = () => mixed;
97
131
 
132
+ /**
133
+ * The case a write belongs to, kept in the asynchronous context it ran in.
134
+ *
135
+ * Full names rather than a record, because that is the whole of what a chunk
136
+ * needs to say and the protocol carries it as a string either way.
137
+ */
138
+ const owner: AsyncLocalStorage<string> = new AsyncLocalStorage();
139
+
98
140
  let sink: OutputSink | null = null;
99
141
  let raw: ((chunk: string) => void) | null = null;
100
- let current: string | null = null;
101
142
  let captured = 0;
102
143
  let stopped = false;
103
144
 
@@ -159,7 +200,7 @@ function capture(stream: OutputStream, text: string): void {
159
200
  stopped = true;
160
201
  }
161
202
  captured += kept.length;
162
- to({ stream, test: current, text: kept });
203
+ to({ stream, test: owner.getStore() ?? null, text: kept });
163
204
  }
164
205
 
165
206
  /** A stand-in for `process.stdout.write` / `process.stderr.write`. */
@@ -236,25 +277,33 @@ export function install(to: OutputSink): (chunk: string) => void {
236
277
  }
237
278
 
238
279
  /**
239
- * Say which case is running, so what it prints can be named.
280
+ * Run `body` as `name`, so what it prints and what it leaves behind to print
281
+ * later — is filed under that case.
240
282
  *
241
- * The runner calls this around a case's body and hooks and clears it between
242
- * them: output written while the module is being imported, from a `beforeAll`,
243
- * or after the last case finished belongs to the file, not to whichever case
244
- * happened to run last.
283
+ * The runner wraps one case's `beforeEach`, body and `afterEach` in a single
284
+ * call, because those are the one case's work. Whatever `body` returns is
285
+ * returned unchanged, so an `await` on this is an `await` on the case.
286
+ *
287
+ * Nothing here needs an "and now nothing is running" counterpart. Output from
288
+ * an import, a `beforeAll` or a case that has already been reported was never
289
+ * inside this call, so it has no owner and is the file's — which is the
290
+ * property the previous module-level variable had to be reset to keep, and
291
+ * kept only for as long as nothing straggled.
245
292
  */
246
- export function enterTest(name: string): void {
247
- current = name;
248
- }
249
-
250
- /** Say that no case is running. */
251
- export function exitTest(): void {
252
- current = null;
293
+ export function runInTest<T>(name: string, body: () => T): T {
294
+ return owner.run(name, body);
253
295
  }
254
296
 
255
- /** Start one file's output budget over, with no case running. */
297
+ /**
298
+ * Start one file's output budget over.
299
+ *
300
+ * Only the budget: there is no current case to clear, because a case's
301
+ * ownership lives in the callbacks it started rather than in this module. A
302
+ * straggler from the file before still carries the name it was written under,
303
+ * which the host cannot match to a test of the new file and files under that
304
+ * file instead. See ubugeeei-prod/uf#203.
305
+ */
256
306
  export function startFile(): void {
257
307
  captured = 0;
258
308
  stopped = false;
259
- current = null;
260
309
  }
package/internal/run.js CHANGED
@@ -193,34 +193,40 @@ async function runCase(
193
193
 
194
194
  const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
195
195
  let outcome: Outcome = { status: "passed" };
196
- // From here to the end of teardown is exactly the window in which this
197
- // case's own code runs, so it is exactly the window whose printing is this
198
- // case's. The cases above never reach it: nothing runs for a `todo`, a
199
- // `skip` or a filtered-out case, so nothing of theirs can print.
200
- output.enterTest(name);
201
- try {
202
- for (const hook of context.beforeEach) {
203
- await withTimeout(hook, timeoutMs);
204
- }
205
- await withTimeout(test.body, timeoutMs);
206
- } catch (thrown) {
207
- outcome = failure(thrown);
208
- }
209
- // Teardown runs whatever happened above, and only reports its own failure
210
- // when the body had not already failed.
211
- for (const hook of context.afterEach) {
196
+ // Setup, body and teardown run *inside* the case's output context, and that
197
+ // nesting is the whole of the fix for #207. What names a printed line is no
198
+ // longer where the runner had got to when the line arrived which named the
199
+ // next case for anything a `setTimeout` left behind but which case's work
200
+ // the write descends from. A callback scheduled here keeps this name however
201
+ // late it fires.
202
+ //
203
+ // The cases above never reach this: nothing runs for a `todo`, a `skip` or a
204
+ // filtered-out case, so nothing of theirs can print.
205
+ await output.runInTest(name, async () => {
212
206
  try {
213
- await withTimeout(hook, timeoutMs);
207
+ for (const hook of context.beforeEach) {
208
+ await withTimeout(hook, timeoutMs);
209
+ }
210
+ await withTimeout(test.body, timeoutMs);
214
211
  } catch (thrown) {
215
- if (outcome.status === "passed") {
216
- outcome = failure(thrown);
212
+ outcome = failure(thrown);
213
+ }
214
+ // Teardown runs whatever happened above, and only reports its own failure
215
+ // when the body had not already failed.
216
+ for (const hook of context.afterEach) {
217
+ try {
218
+ await withTimeout(hook, timeoutMs);
219
+ } catch (thrown) {
220
+ if (outcome.status === "passed") {
221
+ outcome = failure(thrown);
222
+ }
217
223
  }
218
224
  }
219
- }
220
- // No test is running once this one is reported, so a snapshot taken outside
221
- // one fails with something better than a key belonging to whichever test
222
- // happened to run last, and a line printed outside one is the file's.
223
- output.exitTest();
225
+ });
226
+ // The `await` above resumes outside the context it entered, so this line and
227
+ // everything after it belong to no case again — a line printed between cases
228
+ // is the file's, and a snapshot taken outside one fails with something better
229
+ // than a key belonging to whichever test happened to run last.
224
230
  snapshot.exitTest();
225
231
  report(outcome);
226
232
  return outcome.status !== "failed";
@@ -238,6 +244,7 @@ async function runSuite(
238
244
  onlyMode: boolean,
239
245
  emit: (result: Result) => void,
240
246
  state: {| bail: boolean |},
247
+ setUpAncestors: () => Promise<void>,
241
248
  ): Promise<boolean> {
242
249
  const skipped = context.skipped || node.modifier === "skip" || node.modifier === "todo";
243
250
  const onlyPath = !onlyMode || context.onlyPath || node.modifier === "only";
@@ -252,11 +259,18 @@ async function runSuite(
252
259
 
253
260
  // `beforeAll` is deferred until a case in this suite actually runs, so a
254
261
  // fully skipped suite never sets anything up. `afterAll` mirrors it.
262
+ //
263
+ // "In this suite" means anywhere under it. A suite whose children are all
264
+ // suites has no case of its own, and setting up only for a direct child left
265
+ // every `beforeAll` in an ordinary file — one where the tests live inside a
266
+ // `describe` — never running at all, silently. The chain is walked outermost
267
+ // first, so an inner suite's setup sees what the outer one did.
255
268
  let setUp = false;
256
269
  const setUpOnce = async () => {
257
270
  if (setUp) {
258
271
  return;
259
272
  }
273
+ await setUpAncestors();
260
274
  setUp = true;
261
275
  for (const hook of node.beforeAll) {
262
276
  await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -299,7 +313,7 @@ async function runSuite(
299
313
  const ok = await runCase(child, childContext, options, emit);
300
314
  passed = passed && ok;
301
315
  } else {
302
- const ok = await runSuite(child, inner, options, onlyMode, emit, state);
316
+ const ok = await runSuite(child, inner, options, onlyMode, emit, state, setUpOnce);
303
317
  passed = passed && ok;
304
318
  }
305
319
  }
@@ -333,5 +347,5 @@ export async function run(options: RunOptions, emit: (result: Result) => void):
333
347
  skipped: false,
334
348
  onlyPath: !onlyMode,
335
349
  };
336
- await runSuite(root, context, options, onlyMode, emit, { bail: false });
350
+ await runSuite(root, context, options, onlyMode, emit, { bail: false }, async () => {});
337
351
  }
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.6",
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",
@@ -21,6 +21,6 @@
21
21
  "internal"
22
22
  ],
23
23
  "dependencies": {
24
- "@uniflowed/host": "0.0.0-alpha.4"
24
+ "@uniflowed/host": "0.0.0-alpha.6"
25
25
  }
26
26
  }
package/worker.js CHANGED
@@ -7,24 +7,48 @@
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";
28
52
  import { writeChangedSnapshots } from "./internal/snapshot.js";
29
53
  import { createInterface } from "node:readline";
30
54
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -37,8 +61,30 @@ type Request = {|
37
61
  readonly file: string,
38
62
  readonly filter?: string | null,
39
63
  readonly timeoutMs?: number,
64
+ /**
65
+ * Which request this is, counting from one within this worker.
66
+ *
67
+ * Optional only for a `uf` older than the field; `serve` falls back to its
68
+ * own count of the requests it has served, which is the same number.
69
+ */
70
+ readonly generation?: number,
40
71
  |};
41
72
 
73
+ /**
74
+ * The request whose work the code running right now descends from.
75
+ *
76
+ * Read by `write`, so a callback a finished file left behind stamps its events
77
+ * with that file's number rather than with the number of the file the worker
78
+ * has moved on to.
79
+ *
80
+ * `node:async_hooks` is not guarded for: Node, Deno and Bun all provide it
81
+ * under the `node:` specifier, and a host with no `node:` builtins could not
82
+ * link this module at all — `node:readline` and `node:url` are imported above.
83
+ * A host whose storage does not reach a particular callback is a different
84
+ * matter and is handled by the fallback in `write`.
85
+ */
86
+ const serving: AsyncLocalStorage<number> = new AsyncLocalStorage();
87
+
42
88
  /**
43
89
  * The protocol's own stdout, and the capture that gave it up.
44
90
  *
@@ -60,16 +106,35 @@ const emit: (chunk: string) => void = output.install((chunk) => {
60
106
  });
61
107
  });
62
108
 
109
+ /**
110
+ * Write one event, stamped with the request it belongs to.
111
+ *
112
+ * Stamped here rather than at each of the five places that build an event, so
113
+ * there is no way to write one without a generation — the module-level
114
+ * unhandled rejection handler included, which is the one that most needed it.
115
+ *
116
+ * `0` is what an event carries when the storage has nothing to say: a write
117
+ * from outside any request (a malformed request line, which `uf` answers to
118
+ * immediately), or a host that does not carry a store into the callback that
119
+ * wrote it — Deno 1.31 does not carry one through `setTimeout`, and Bun does
120
+ * not carry one into `unhandledRejection`. `uf` reads `0` as "the file being
121
+ * served", which is what every event meant before this field existed: of the
122
+ * two ways to be less than exact, saying nothing about a straggler is the
123
+ * behaviour that was already there, and refusing an unstamped `file` event
124
+ * would hang a file that had in fact answered.
125
+ */
63
126
  function write(event: { readonly [string]: mixed }): void {
64
- emit(`${JSON.stringify(event)}\n`);
127
+ emit(`${JSON.stringify({ ...event, generation: serving.getStore() ?? 0 })}\n`);
65
128
  }
66
129
 
67
130
  /**
68
131
  * Import and run one file.
69
132
  *
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.
133
+ * `generation` is the request's number, and it does two jobs with one value:
134
+ * it busts the module cache so a watch-mode rerun in the same worker sees the
135
+ * edited file rather than the one the registry already holds, and — through
136
+ * the `serving` store this runs inside — it is what every event written from
137
+ * this file, or from anything this file leaves behind, is stamped with.
73
138
  */
74
139
  async function runFile(request: Request, generation: number): Promise<void> {
75
140
  const started = performance.now();
@@ -133,10 +198,15 @@ async function runFile(request: Request, generation: number): Promise<void> {
133
198
  * a time, because two files sharing a process would share globals and module
134
199
  * state, and a test suite that passes alone but fails beside another is the
135
200
  * worst failure a runner can produce.
201
+ *
202
+ * "In order" bounds what the worker *starts*, not what a file leaves running,
203
+ * which is why each file runs inside `serving`. The store is entered here and
204
+ * not in `runFile` so that the whole of a file's work, its module import
205
+ * included, is inside it.
136
206
  */
137
207
  function serve(): void {
138
208
  let queue: Promise<void> = Promise.resolve();
139
- let generation = 0;
209
+ let served = 0;
140
210
 
141
211
  createInterface({ input: process.stdin }).on("line", (line) => {
142
212
  if (line.trim() === "") {
@@ -146,6 +216,9 @@ function serve(): void {
146
216
  try {
147
217
  request = JSON.parse(line);
148
218
  } catch (error) {
219
+ // Outside any `serving.run`, so this is stamped `0` — which is right:
220
+ // there is no request to attribute it to, and `uf` is waiting for an
221
+ // answer to the line it just wrote.
149
222
  write({
150
223
  event: "file",
151
224
  status: "run-failed",
@@ -153,9 +226,14 @@ function serve(): void {
153
226
  });
154
227
  return;
155
228
  }
156
- generation += 1;
157
- const at = generation;
158
- queue = queue.then(() => runFile(request, at));
229
+ served += 1;
230
+ // `uf` chooses the number, because `uf` is the side that checks it. This
231
+ // count of served requests is the same sequence and stands in for a `uf`
232
+ // too old to send one — without something monotonic here the import below
233
+ // would be cache-busted with `undefined` and a watch-mode rerun would see
234
+ // the module it already had.
235
+ const at = request.generation ?? served;
236
+ queue = queue.then(() => serving.run(at, () => runFile(request, at)));
159
237
  });
160
238
 
161
239
  process.stdin.on("close", () => {
@@ -165,6 +243,13 @@ function serve(): void {
165
243
 
166
244
  // Unhandled rejections would otherwise take the worker down mid-file with no
167
245
  // explanation; reporting one as a file failure keeps the run honest.
246
+ //
247
+ // The file it fails is whichever one the rejected promise was created in, not
248
+ // whichever one is running when Node gets round to reporting it: `write` reads
249
+ // the store, and on Node the store follows the promise. That matters because
250
+ // this is a `file` event and a `file` event *ends* a file — a promise the
251
+ // previous file abandoned used to end the next one, with a message from code
252
+ // that file does not contain.
168
253
  process.on("unhandledRejection", (reason: mixed) => {
169
254
  const error = reason instanceof Error ? reason : new Error(String(reason));
170
255
  write({