@uniflowed/test 0.0.0-alpha.1 → 0.0.0-alpha.5

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/worker.js CHANGED
@@ -7,46 +7,139 @@
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": "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}
13
14
  //
14
- // Two decisions worth stating. Results are streamed as they happen rather than
15
- // batched at the end, so `uf test` can draw progress and `--bail` can stop a
16
- // long run early. And a file that throws while being *imported* is a file
17
- // result, not a test result: there were no tests to fail, and saying "0 tests"
18
- // for a module that could not load would be a lie.
15
+ // Four decisions worth stating. Results are streamed as they happen rather
16
+ // than batched at the end, so `uf test` can draw progress and `--bail` can stop
17
+ // a long run early. A file that throws while being *imported* is a file result,
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. The protocol does not share its
20
+ // stream with the tests: a test's own printing becomes an `output` event
21
+ // (`internal/output.js`), so a `console.log` cannot land in the middle of a
22
+ // line `uf` is parsing. And every event says which request it belongs to —
23
+ // see "Which file an event belongs to" below.
19
24
  //
20
25
  // This module runs on import by design — it is a process entry point, the way
21
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.
22
49
 
50
+ import * as output from "./internal/output.js";
51
+ import { AsyncLocalStorage } from "node:async_hooks";
52
+ import { writeChangedSnapshots } from "./internal/snapshot.js";
23
53
  import { createInterface } from "node:readline";
24
- import { pathToFileURL } from "node:url";
54
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
55
 
26
56
  import { reset } from "./internal/registry.js";
27
57
  import { run } from "./internal/run.js";
28
58
 
29
59
  /** What `uf` sends for one file. */
30
60
  type Request = {|
31
- +file: string,
32
- +filter?: string | null,
33
- +timeoutMs?: number,
61
+ readonly file: string,
62
+ readonly filter?: string | null,
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,
34
71
  |};
35
72
 
36
- function write(event: { +[string]: mixed }): void {
37
- process.stdout.write(`${JSON.stringify(event)}\n`);
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
+
88
+ /**
89
+ * The protocol's own stdout, and the capture that gave it up.
90
+ *
91
+ * A test can reach `console.log` and `process.stdout.write`, and both used to
92
+ * land in the middle of a line `uf` was parsing. `internal/output.js` takes
93
+ * those over and hands back the real write it replaced, so the protocol has a
94
+ * stream nothing else can reach and a test's printing is still reported —
95
+ * as an `output` event, escaped, whatever it says.
96
+ *
97
+ * At module scope, before a single test file can be imported, so output
98
+ * written while a file is still loading is reported rather than lost.
99
+ */
100
+ const emit: (chunk: string) => void = output.install((chunk) => {
101
+ write({
102
+ event: "output",
103
+ stream: chunk.stream,
104
+ test: chunk.test,
105
+ text: chunk.text,
106
+ });
107
+ });
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
+ */
126
+ function write(event: { readonly [string]: mixed }): void {
127
+ emit(`${JSON.stringify({ ...event, generation: serving.getStore() ?? 0 })}\n`);
38
128
  }
39
129
 
40
130
  /**
41
131
  * Import and run one file.
42
132
  *
43
- * The module is imported with a cache-busting query so a watch-mode rerun in
44
- * the same worker sees the edited file rather than the one the module registry
45
- * 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.
46
138
  */
47
139
  async function runFile(request: Request, generation: number): Promise<void> {
48
140
  const started = performance.now();
49
141
  reset();
142
+ output.startFile();
50
143
 
51
144
  try {
52
145
  await import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
@@ -63,9 +156,24 @@ async function runFile(request: Request, generation: number): Promise<void> {
63
156
  }
64
157
 
65
158
  try {
66
- await run({ filter: request.filter ?? null, timeoutMs: request.timeoutMs }, (result) => {
67
- write({ event: "test", ...result.outcome, name: result.name, line: result.line, column: result.column, durationMicros: result.durationMicros });
68
- });
159
+ const absolute = fileURLToPath(pathToFileURL(request.file).href);
160
+ await run(
161
+ { filter: request.filter ?? null, timeoutMs: request.timeoutMs, file: absolute },
162
+ (result) => {
163
+ write({
164
+ event: "test",
165
+ ...result.outcome,
166
+ name: result.name,
167
+ line: result.line,
168
+ column: result.column,
169
+ durationMicros: result.durationMicros,
170
+ });
171
+ },
172
+ );
173
+ // Once, at the end of the file, rather than after each snapshot: a file
174
+ // with forty snapshots would otherwise rewrite its snapshot file forty
175
+ // times, and a crash halfway through would leave a partial one.
176
+ writeChangedSnapshots();
69
177
  write({
70
178
  event: "file",
71
179
  status: "completed",
@@ -90,10 +198,15 @@ async function runFile(request: Request, generation: number): Promise<void> {
90
198
  * a time, because two files sharing a process would share globals and module
91
199
  * state, and a test suite that passes alone but fails beside another is the
92
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.
93
206
  */
94
207
  function serve(): void {
95
208
  let queue: Promise<void> = Promise.resolve();
96
- let generation = 0;
209
+ let served = 0;
97
210
 
98
211
  createInterface({ input: process.stdin }).on("line", (line) => {
99
212
  if (line.trim() === "") {
@@ -103,12 +216,24 @@ function serve(): void {
103
216
  try {
104
217
  request = JSON.parse(line);
105
218
  } catch (error) {
106
- write({ event: "file", status: "run-failed", message: `malformed request: ${String(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.
222
+ write({
223
+ event: "file",
224
+ status: "run-failed",
225
+ message: `malformed request: ${String(error)}`,
226
+ });
107
227
  return;
108
228
  }
109
- generation += 1;
110
- const at = generation;
111
- 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)));
112
237
  });
113
238
 
114
239
  process.stdin.on("close", () => {
@@ -118,6 +243,13 @@ function serve(): void {
118
243
 
119
244
  // Unhandled rejections would otherwise take the worker down mid-file with no
120
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.
121
253
  process.on("unhandledRejection", (reason: mixed) => {
122
254
  const error = reason instanceof Error ? reason : new Error(String(reason));
123
255
  write({