@uniflowed/test 0.0.0-alpha.4 → 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/internal/expect.js +18 -0
- package/internal/output.js +80 -31
- package/internal/run.js +40 -26
- package/package.json +2 -2
- package/worker.js +101 -16
package/internal/expect.js
CHANGED
|
@@ -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,
|
package/internal/output.js
CHANGED
|
@@ -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`
|
|
25
|
-
// "Who printed this" is also state with a lifetime of its
|
|
26
|
-
// case's
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
|
33
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
|
247
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
207
|
+
for (const hook of context.beforeEach) {
|
|
208
|
+
await withTimeout(hook, timeoutMs);
|
|
209
|
+
}
|
|
210
|
+
await withTimeout(test.body, timeoutMs);
|
|
214
211
|
} catch (thrown) {
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.0-alpha.5",
|
|
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.
|
|
24
|
+
"@uniflowed/host": "0.0.0-alpha.5"
|
|
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
|
-
//
|
|
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.
|
|
20
|
-
//
|
|
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
|
-
*
|
|
71
|
-
* the
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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({
|