@uniflowed/test 0.0.0-alpha.4 → 0.0.0-alpha.41
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/app-browser.js +6 -0
- package/app.js +175 -0
- package/browser-worker.js +334 -0
- package/browser.js +59 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +13 -1
- package/internal/axe.js +362 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +893 -0
- package/internal/browser/transport.js +2 -0
- package/internal/expect.js +435 -54
- package/internal/frames.js +87 -1
- package/internal/isolation.js +231 -0
- package/internal/modules.js +430 -0
- package/internal/namespace.js +98 -50
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +105 -38
- package/internal/registry.js +54 -3
- package/internal/run.js +188 -40
- package/internal/timers.js +2 -2
- package/internal/unsupported.js +30 -0
- package/package.json +41 -5
- package/worker.js +199 -20
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
|
-
//
|
|
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";
|
|
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 {
|
|
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,110 @@ 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
|
-
*
|
|
71
|
-
* the
|
|
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
|
-
|
|
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);
|
|
190
|
+
try {
|
|
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
|
+
let closeNative: (() => Promise<void>) | null = null;
|
|
79
222
|
try {
|
|
80
|
-
|
|
223
|
+
if (process.env.UF_TEST_TARGET === "react-native") {
|
|
224
|
+
const { loadNativeFile } = await import("./internal/native-host.js");
|
|
225
|
+
closeNative = await loadNativeFile(request.file);
|
|
226
|
+
} else {
|
|
227
|
+
await import(`${url}?uf-run=${generation}`);
|
|
228
|
+
}
|
|
81
229
|
} catch (thrown) {
|
|
82
230
|
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
83
231
|
write({
|
|
@@ -93,7 +241,12 @@ async function runFile(request: Request, generation: number): Promise<void> {
|
|
|
93
241
|
try {
|
|
94
242
|
const absolute = fileURLToPath(pathToFileURL(request.file).href);
|
|
95
243
|
await run(
|
|
96
|
-
{
|
|
244
|
+
{
|
|
245
|
+
filter: request.filter ?? null,
|
|
246
|
+
timeoutMs: request.timeoutMs,
|
|
247
|
+
file: absolute,
|
|
248
|
+
bench: benching(),
|
|
249
|
+
},
|
|
97
250
|
(result) => {
|
|
98
251
|
write({
|
|
99
252
|
event: "test",
|
|
@@ -109,12 +262,18 @@ async function runFile(request: Request, generation: number): Promise<void> {
|
|
|
109
262
|
// with forty snapshots would otherwise rewrite its snapshot file forty
|
|
110
263
|
// times, and a crash halfway through would leave a partial one.
|
|
111
264
|
writeChangedSnapshots();
|
|
265
|
+
await closeNative?.();
|
|
112
266
|
write({
|
|
113
267
|
event: "file",
|
|
114
268
|
status: "completed",
|
|
115
269
|
durationMicros: Math.round((performance.now() - started) * 1000),
|
|
116
270
|
});
|
|
117
271
|
} catch (thrown) {
|
|
272
|
+
try {
|
|
273
|
+
await closeNative?.();
|
|
274
|
+
} catch {
|
|
275
|
+
// Preserve the original run or teardown failure.
|
|
276
|
+
}
|
|
118
277
|
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
119
278
|
write({
|
|
120
279
|
event: "file",
|
|
@@ -133,10 +292,15 @@ async function runFile(request: Request, generation: number): Promise<void> {
|
|
|
133
292
|
* a time, because two files sharing a process would share globals and module
|
|
134
293
|
* state, and a test suite that passes alone but fails beside another is the
|
|
135
294
|
* worst failure a runner can produce.
|
|
295
|
+
*
|
|
296
|
+
* "In order" bounds what the worker *starts*, not what a file leaves running,
|
|
297
|
+
* which is why each file runs inside `serving`. The store is entered here and
|
|
298
|
+
* not in `runFile` so that the whole of a file's work, its module import
|
|
299
|
+
* included, is inside it.
|
|
136
300
|
*/
|
|
137
301
|
function serve(): void {
|
|
138
302
|
let queue: Promise<void> = Promise.resolve();
|
|
139
|
-
let
|
|
303
|
+
let served = 0;
|
|
140
304
|
|
|
141
305
|
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
142
306
|
if (line.trim() === "") {
|
|
@@ -146,6 +310,9 @@ function serve(): void {
|
|
|
146
310
|
try {
|
|
147
311
|
request = JSON.parse(line);
|
|
148
312
|
} catch (error) {
|
|
313
|
+
// Outside any `serving.run`, so this is stamped `0` — which is right:
|
|
314
|
+
// there is no request to attribute it to, and `uf` is waiting for an
|
|
315
|
+
// answer to the line it just wrote.
|
|
149
316
|
write({
|
|
150
317
|
event: "file",
|
|
151
318
|
status: "run-failed",
|
|
@@ -153,9 +320,14 @@ function serve(): void {
|
|
|
153
320
|
});
|
|
154
321
|
return;
|
|
155
322
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
323
|
+
served += 1;
|
|
324
|
+
// `uf` chooses the number, because `uf` is the side that checks it. This
|
|
325
|
+
// count of served requests is the same sequence and stands in for a `uf`
|
|
326
|
+
// too old to send one — without something monotonic here the import below
|
|
327
|
+
// would be cache-busted with `undefined` and a watch-mode rerun would see
|
|
328
|
+
// the module it already had.
|
|
329
|
+
const at = request.generation ?? served;
|
|
330
|
+
queue = queue.then(() => serving.run(at, () => runFile(request, at)));
|
|
159
331
|
});
|
|
160
332
|
|
|
161
333
|
process.stdin.on("close", () => {
|
|
@@ -165,6 +337,13 @@ function serve(): void {
|
|
|
165
337
|
|
|
166
338
|
// Unhandled rejections would otherwise take the worker down mid-file with no
|
|
167
339
|
// explanation; reporting one as a file failure keeps the run honest.
|
|
340
|
+
//
|
|
341
|
+
// The file it fails is whichever one the rejected promise was created in, not
|
|
342
|
+
// whichever one is running when Node gets round to reporting it: `write` reads
|
|
343
|
+
// the store, and on Node the store follows the promise. That matters because
|
|
344
|
+
// this is a `file` event and a `file` event *ends* a file — a promise the
|
|
345
|
+
// previous file abandoned used to end the next one, with a message from code
|
|
346
|
+
// that file does not contain.
|
|
168
347
|
process.on("unhandledRejection", (reason: mixed) => {
|
|
169
348
|
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
170
349
|
write({
|