@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/index.js +19 -2
- package/internal/asymmetric.js +184 -0
- package/internal/equality.js +79 -19
- package/internal/expect.js +312 -81
- package/internal/frames.js +9 -3
- package/internal/namespace.js +251 -0
- package/internal/output.js +309 -0
- package/internal/registry.js +30 -27
- package/internal/run.js +75 -38
- package/internal/snapshot.js +306 -0
- package/internal/spy.js +236 -0
- package/internal/timers.js +362 -0
- package/package.json +5 -2
- package/worker.js +157 -25
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": "
|
|
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
|
-
//
|
|
15
|
-
// batched at the end, so `uf test` can draw progress and `--bail` can stop
|
|
16
|
-
// long run early.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
44
|
-
* the
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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({
|