@uniflowed/test 0.0.0-alpha.8 → 0.1.0

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.
@@ -0,0 +1,375 @@
1
+ // @flow
2
+ //
3
+ // The `node:` builtins `@uniflowed/test` reaches for, as a browser can have
4
+ // them.
5
+ //
6
+ // # Why a package the browser never runs needs a browser build
7
+ //
8
+ // A module with an in-source test block imports `@uniflowed/test` at its top
9
+ // level, and that block is compiled away by `uf build` — `import.meta.uf.test`
10
+ // becomes `void 0`, the bundler drops the branch, and the import goes with it
11
+ // because the package declares `sideEffects: false`. But a bundler *resolves*
12
+ // a graph before it shakes it, so the six builtins this package's edges import
13
+ // were resolved on the way to being discarded: `node:fs` and `node:path` for
14
+ // snapshot files, `node:module` and `node:url` for module mocking,
15
+ // `node:async_hooks` and `node:util` for output capture. Each one printed
16
+ //
17
+ // Module "node:fs" has been externalized for browser compatibility
18
+ //
19
+ // so a project with one three-line in-source test got six warnings about
20
+ // modules `uf build` was in the middle of removing.
21
+ //
22
+ // `package.json`'s `browser` field maps all six here. Node ignores that field
23
+ // and every bundler honours it, so nothing about running a test on a host
24
+ // changes and the build is quiet.
25
+ //
26
+ // One file rather than six of four lines, because the question a reader has is
27
+ // "what does a browser not have", and that is answered better in one place
28
+ // than in six.
29
+ //
30
+ // # Why the shims work rather than only resolve
31
+ //
32
+ // Nothing here is reached by the build that made it necessary: the module that
33
+ // imports it is removed. What makes the implementations below worth writing is
34
+ // the case where the package *is* evaluated in a browser — a runner that ran a
35
+ // test file in a real page, which `docs/roadmap.md` still lists as not done, or
36
+ // an application that genuinely imports `@uniflowed/test` into client code.
37
+ // A file that exists only to be resolved would be a file that fails the moment
38
+ // somebody's build stops shaking it out, and that failure would arrive as a
39
+ // missing export rather than as a sentence.
40
+ //
41
+ // # Two kinds of shim, and the difference is the point
42
+ //
43
+ // Some of these are **implementations**: `format`, `inspect`, `pathToFileURL`
44
+ // and the path helpers do in a browser what they do in Node, closely enough
45
+ // for the one caller each has. `AsyncLocalStorage` is an implementation with a
46
+ // named limitation.
47
+ //
48
+ // The rest are **refusals**: the filesystem is not something a page has, and
49
+ // `createRequire` cannot invent a synchronous module loader. Each one throws a
50
+ // sentence naming what is not available and what to do instead. A shim that
51
+ // answered plausibly — an `existsSync` that said `false`, say — would be worse
52
+ // than no browser mode at all: `toMatchSnapshot` would read "no snapshot yet",
53
+ // record one it could not write, and pass. That is the runner lying, which is
54
+ // the failure `uf test` exists not to have.
55
+ //
56
+ // Each refusal names what is not available and what to do instead, so a
57
+ // boundary is a sentence rather than a surprise in a stack trace.
58
+
59
+ /** How a refusal is worded, so all of them read the same way. */
60
+ function unavailable(what: string, instead: string): Error {
61
+ return new Error(
62
+ `${what} is not available in \`uf test --browser\`: a page has no ${what}. ${instead}`,
63
+ );
64
+ }
65
+
66
+ // ---------------------------------------------------------------------- //
67
+ // node:fs — refused
68
+ // ---------------------------------------------------------------------- //
69
+
70
+ /** Snapshot files live on a disk the page cannot see. */
71
+ export function existsSync(_path: mixed): empty {
72
+ throw unavailable(
73
+ "the filesystem",
74
+ "Snapshots (`toMatchSnapshot`) are written by the host that runs the file; assert with `toMatchInlineSnapshot`, which needs no file, or run the file on Node.",
75
+ );
76
+ }
77
+
78
+ /** @see existsSync */
79
+ export function readFileSync(_path: mixed, _encoding?: mixed): empty {
80
+ return existsSync(_path);
81
+ }
82
+
83
+ /** @see existsSync */
84
+ export function writeFileSync(_path: mixed, _contents: mixed): empty {
85
+ return existsSync(_path);
86
+ }
87
+
88
+ /** @see existsSync */
89
+ export function mkdirSync(_path: mixed, _options?: mixed): empty {
90
+ return existsSync(_path);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------- //
94
+ // node:module — refused, with one door left open
95
+ // ---------------------------------------------------------------------- //
96
+
97
+ /**
98
+ * Modules the page has already imported, by the specifier they answer to.
99
+ *
100
+ * The one thing `createRequire` is used for that a browser *can* do. It is
101
+ * `@uniflowed/react-testing`'s `render`, which loads `react-dom/client` through
102
+ * `createRequire` rather than an import because `render` is synchronous and an
103
+ * import is not — a decision that is right on Node and impossible here.
104
+ *
105
+ * So the page's entry module imports `react-dom/client` itself, before any
106
+ * test file runs, and puts it here. `render` then finds it synchronously, and
107
+ * the import that made that possible happened at a moment when being
108
+ * asynchronous cost nothing.
109
+ */
110
+ const provided: Map<string, mixed> = new Map();
111
+
112
+ /**
113
+ * Register a module the page has already imported.
114
+ *
115
+ * Called by whatever brought this package into a page, never by a test.
116
+ */
117
+ export function provideModule(specifier: string, module: mixed): void {
118
+ provided.set(specifier, module);
119
+ }
120
+
121
+ /**
122
+ * A `require` that answers for what the page brought with it and refuses the
123
+ * rest.
124
+ *
125
+ * Refusing loudly rather than returning `undefined`: a caller that gets
126
+ * `undefined` back fails later, somewhere else, with a message about a
127
+ * property of `undefined`.
128
+ */
129
+ export function createRequire(_from: mixed): (specifier: string) => mixed {
130
+ return (specifier: string) => {
131
+ if (provided.has(specifier)) return provided.get(specifier);
132
+ throw unavailable(
133
+ `a synchronous \`require("${specifier}")\``,
134
+ "Import it from the test file instead; a browser resolves modules asynchronously and cannot be asked to do it in the middle of a call.",
135
+ );
136
+ };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------- //
140
+ // node:url — implemented
141
+ // ---------------------------------------------------------------------- //
142
+
143
+ /** A path as the `file:` URL Node would produce. */
144
+ export function pathToFileURL(filePath: string): URL {
145
+ const withSlashes = filePath.replace(/\\/g, "/");
146
+ const absolute = withSlashes.startsWith("/") ? withSlashes : `/${withSlashes}`;
147
+ return new URL(`file://${absolute.split("/").map(encodeURIComponent).join("/")}`);
148
+ }
149
+
150
+ /** The path inside a `file:` URL. */
151
+ export function fileURLToPath(url: string | URL): string {
152
+ const href = typeof url === "string" ? url : url.href;
153
+ if (!href.startsWith("file://")) {
154
+ throw new TypeError(`not a file URL: ${href}`);
155
+ }
156
+ return decodeURIComponent(href.slice("file://".length));
157
+ }
158
+
159
+ // ---------------------------------------------------------------------- //
160
+ // node:util — implemented
161
+ // ---------------------------------------------------------------------- //
162
+
163
+ /**
164
+ * `util.inspect`, to the depth a test's `console.log` needs.
165
+ *
166
+ * Not Node's algorithm — no getters, no circular references marked, no colour.
167
+ * What it has to be is *stable* and *readable*, because its output is what a
168
+ * failing test's printing looks like in the terminal, and `JSON.stringify`
169
+ * with a replacer that names functions and symbols is both.
170
+ */
171
+ export function inspect(value: mixed): string {
172
+ if (typeof value === "string") return `'${value}'`;
173
+ return renderValue(value, new Set());
174
+ }
175
+
176
+ function renderValue(value: mixed, seen: Set<mixed>): string {
177
+ if (value === null) return "null";
178
+ if (value === undefined) return "undefined";
179
+ if (typeof value === "string") return `'${value}'`;
180
+ if (typeof value === "bigint") return `${String(value)}n`;
181
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
182
+ if (typeof value === "symbol") return String(value);
183
+ if (typeof value === "function") {
184
+ const name = (value as $FlowFixMe).name;
185
+ return name === "" ? "[Function (anonymous)]" : `[Function: ${String(name)}]`;
186
+ }
187
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
188
+ if (typeof value !== "object") return String(value);
189
+ if (seen.has(value)) return "[Circular]";
190
+ seen.add(value);
191
+ try {
192
+ if (Array.isArray(value)) {
193
+ return `[ ${value.map((item: mixed) => renderValue(item, seen)).join(", ")} ]`;
194
+ }
195
+ const entries = Object.entries(value).map(
196
+ ([key, item]) => `${key}: ${renderValue(item, seen)}`,
197
+ );
198
+ return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
199
+ } finally {
200
+ seen.delete(value);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * `util.format`, with the `%s`/`%d`/`%i`/`%j`/`%o`/`%O`/`%%` substitutions
206
+ * `console.log` uses.
207
+ *
208
+ * Everything left over is appended, space separated, exactly as Node does —
209
+ * which is the behaviour `console.log("a", 1, { b: 2 })` depends on.
210
+ */
211
+ export function format(first: mixed, ...rest: $ReadOnlyArray<mixed>): string {
212
+ const args = [...rest];
213
+ let out = "";
214
+ if (typeof first === "string") {
215
+ let at = 0;
216
+ while (at < first.length) {
217
+ const ch = first[at];
218
+ if (ch !== "%" || at + 1 >= first.length) {
219
+ out += ch;
220
+ at += 1;
221
+ continue;
222
+ }
223
+ const kind = first[at + 1];
224
+ if (kind === "%") {
225
+ out += "%";
226
+ at += 2;
227
+ continue;
228
+ }
229
+ if (!"sdifjoO".includes(kind) || args.length === 0) {
230
+ out += ch;
231
+ at += 1;
232
+ continue;
233
+ }
234
+ const value = args.shift();
235
+ if (kind === "s") out += typeof value === "string" ? value : renderValue(value, new Set());
236
+ else if (kind === "d" || kind === "f") out += String(Number(value));
237
+ else if (kind === "i") out += String(Math.trunc(Number(value)));
238
+ else if (kind === "j") out += safeJson(value);
239
+ else out += renderValue(value, new Set());
240
+ at += 2;
241
+ }
242
+ } else {
243
+ out = renderValue(first, new Set());
244
+ }
245
+ for (const value of args) {
246
+ out += ` ${typeof value === "string" ? value : renderValue(value, new Set())}`;
247
+ }
248
+ return out;
249
+ }
250
+
251
+ function safeJson(value: mixed): string {
252
+ try {
253
+ return JSON.stringify(value) ?? "undefined";
254
+ } catch {
255
+ return "[Circular]";
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------- //
260
+ // node:async_hooks — implemented, with a limitation that is written down
261
+ // ---------------------------------------------------------------------- //
262
+
263
+ /**
264
+ * A single-value stand-in for Node's asynchronous storage.
265
+ *
266
+ * The real one follows a value through every continuation of the work that
267
+ * entered it; this one holds whichever `run` is on the stack. A browser has no
268
+ * asynchronous context tracking to build the real thing on — the platform
269
+ * proposal for it is not shipped anywhere — and the alternative to an
270
+ * approximation is that `@uniflowed/test`'s output capture cannot be imported
271
+ * at all.
272
+ *
273
+ * What it costs is bounded by what the store is used for here.
274
+ * `internal/output.js` reads it to attribute a `console.log` to the case that
275
+ * printed it, and cases run one at a time, so the answer is right for
276
+ * everything a case prints while it is running. It is wrong for a `setTimeout`
277
+ * a finished case left behind: the print is still reported, attributed to the
278
+ * file rather than to the case. On Node that same straggler is attributed to
279
+ * its case; here it is attributed one level up.
280
+ *
281
+ * That is the whole of the difference, and it is a difference in a label on a
282
+ * line of output — not in what passes.
283
+ */
284
+ export class AsyncLocalStorage<T> {
285
+ #store: T | void = undefined;
286
+
287
+ /** Run `body` with `value` readable from `getStore`. */
288
+ run<R>(value: T, body: () => R): R {
289
+ const previous = this.#store;
290
+ this.#store = value;
291
+ try {
292
+ return body();
293
+ } finally {
294
+ this.#store = previous;
295
+ }
296
+ }
297
+
298
+ /** The value of the nearest enclosing `run`, or `undefined`. */
299
+ getStore(): T | void {
300
+ return this.#store;
301
+ }
302
+
303
+ /** Run `body` with no value readable. */
304
+ exit<R>(body: () => R): R {
305
+ const previous = this.#store;
306
+ this.#store = undefined;
307
+ try {
308
+ return body();
309
+ } finally {
310
+ this.#store = previous;
311
+ }
312
+ }
313
+ }
314
+
315
+ // ---------------------------------------------------------------------- //
316
+ // node:path — implemented, posix only
317
+ // ---------------------------------------------------------------------- //
318
+
319
+ /**
320
+ * The path operations `internal/snapshot.js` performs on a snapshot path.
321
+ *
322
+ * Posix only, which is not a limitation here: every path a page sees came from
323
+ * a URL, and a URL's separator is `/` on every platform.
324
+ */
325
+ export const path: {
326
+ readonly join: (...parts: $ReadOnlyArray<string>) => string,
327
+ readonly dirname: (of: string) => string,
328
+ readonly basename: (of: string, extension?: string) => string,
329
+ readonly extname: (of: string) => string,
330
+ readonly resolve: (...parts: $ReadOnlyArray<string>) => string,
331
+ readonly sep: string,
332
+ } = {
333
+ sep: "/",
334
+ join: (...parts) => normalize(parts.filter((part) => part !== "").join("/")),
335
+ dirname: (of) => {
336
+ const at = of.lastIndexOf("/");
337
+ if (at === -1) return ".";
338
+ return at === 0 ? "/" : of.slice(0, at);
339
+ },
340
+ basename: (of, extension) => {
341
+ const name = of.slice(of.lastIndexOf("/") + 1);
342
+ return extension != null && name.endsWith(extension)
343
+ ? name.slice(0, name.length - extension.length)
344
+ : name;
345
+ },
346
+ extname: (of) => {
347
+ const name = of.slice(of.lastIndexOf("/") + 1);
348
+ const at = name.lastIndexOf(".");
349
+ return at <= 0 ? "" : name.slice(at);
350
+ },
351
+ resolve: (...parts) => {
352
+ let out = "";
353
+ for (const part of parts) {
354
+ if (part.startsWith("/")) out = part;
355
+ else if (out === "") out = part;
356
+ else out = `${out}/${part}`;
357
+ }
358
+ return normalize(out.startsWith("/") ? out : `/${out}`);
359
+ },
360
+ };
361
+
362
+ /** `a//b/./c/../d` as `a/b/d`, keeping a leading slash. */
363
+ function normalize(input: string): string {
364
+ const absolute = input.startsWith("/");
365
+ const out: Array<string> = [];
366
+ for (const part of input.split("/")) {
367
+ if (part === "" || part === ".") continue;
368
+ if (part === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
369
+ else out.push(part);
370
+ }
371
+ const joined = out.join("/");
372
+ return absolute ? `/${joined}` : joined === "" ? "." : joined;
373
+ }
374
+
375
+ export default path;
@@ -0,0 +1,36 @@
1
+ // @flow
2
+ import type { BrowserTransport, ControlOptions } from "./cdp.js";
3
+
4
+ export async function createTransport(options: ControlOptions): Promise<BrowserTransport> {
5
+ if (options.executable != null)
6
+ throw new Error("uf test --browser selects its own UF_BROWSER executable");
7
+ const token = (globalThis as $FlowFixMe)[Symbol.for("uf.test.browser.token")];
8
+ if (typeof token !== "string")
9
+ throw new Error("createBrowser needs uf test --browser or a Node test worker");
10
+ async function command(
11
+ id: string | null,
12
+ method: string,
13
+ args: $ReadOnlyArray<mixed> = [],
14
+ ): Promise<$FlowFixMe> {
15
+ const response = await fetch("/uf-test/browser", {
16
+ method: "POST",
17
+ headers: { "content-type": "application/json", "uf-test-browser": token },
18
+ body: JSON.stringify({ id, method, args }),
19
+ });
20
+ const result = await response.json();
21
+ if (!response.ok || result.error)
22
+ throw new Error(result.error ?? `browser command failed (${response.status})`);
23
+ return result.value;
24
+ }
25
+ const id = await command(null, "create");
26
+ let closed = false;
27
+ return {
28
+ id,
29
+ command,
30
+ close: async () => {
31
+ if (closed) return;
32
+ closed = true;
33
+ await command(id, "close");
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,258 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/test`: the worker, as it exists inside a page.
4
+ //
5
+ // `../../worker.js` and this module are the same program written for two
6
+ // different hosts, and the half that matters is the same code in both:
7
+ // `internal/run.js` walks the tree, `internal/registry.js` holds it,
8
+ // `internal/expect.js` decides what passed. What differs is only how a file
9
+ // arrives and how a result leaves.
10
+ //
11
+ // worker.js page.js
12
+ // ───────────────────────────── ──────────────────────────────────
13
+ // a request per line on stdin a long-polled GET /uf-test/next
14
+ // an event per line on stdout a POST /uf-test/events
15
+ // one process, many files one page load, one file
16
+ // `restoreSharedState()` a reload
17
+ //
18
+ // The last row is the one worth reading twice. A Node worker serves many files
19
+ // out of one process, so everything two files could share has to be put back
20
+ // between them by hand — the registry, the clock, the module stand-ins, the
21
+ // document — and `internal/isolation.js` is that list, three issues long. A
22
+ // page reloads instead. The realm is new, the document is new, the module
23
+ // registry is new, and a `setInterval` the previous file abandoned is not
24
+ // merely unstamped, it is gone. Browser mode gets stronger isolation than
25
+ // Node mode for free, and it is the reload that buys it.
26
+ //
27
+ // # What this file does not do
28
+ //
29
+ // Snapshots. `toMatchSnapshot` writes a file, a page has no filesystem, and
30
+ // `./node.js` refuses it in a sentence rather than answering "no snapshot yet"
31
+ // and passing. `toMatchInlineSnapshot` needs no file and works here.
32
+ //
33
+ // Module mocking. `uft.mock` intercepts resolution, which on Node is
34
+ // `node:module`'s synchronous hooks and in a page is nothing at all. Same
35
+ // refusal, same reason.
36
+
37
+ import * as output from "../output.js";
38
+ import { run } from "../run.js";
39
+ import { installInSourceTests } from "../../in-source.js";
40
+
41
+ /** One file, as the server hands it over. */
42
+ type PageRequest = {
43
+ readonly file?: string,
44
+ readonly filter?: string | null,
45
+ readonly timeoutMs?: number,
46
+ readonly generation?: number,
47
+ readonly done?: boolean,
48
+ ...
49
+ };
50
+
51
+ /** The generation every event written from this page load carries. */
52
+ let serving = 0;
53
+
54
+ /**
55
+ * Events waiting to be posted, and the post that will carry them.
56
+ *
57
+ * Batched rather than one request per event, and still streamed rather than
58
+ * held to the end: a `POST` is in flight or it is not, and everything that
59
+ * accumulates while one is in flight goes in the next. So `uf test` draws
60
+ * progress and `--bail` stops a long file early, without a request per
61
+ * assertion — a file with four hundred cases would otherwise spend more time
62
+ * in the loopback than in the tests.
63
+ */
64
+ const queue: Array<{ readonly [string]: mixed }> = [];
65
+ let flushing: Promise<void> | null = null;
66
+
67
+ function write(event: { readonly [string]: mixed }): void {
68
+ queue.push({ ...event, generation: serving });
69
+ flush();
70
+ }
71
+
72
+ function flush(): Promise<void> {
73
+ const already = flushing;
74
+ if (already != null) return already;
75
+ if (queue.length === 0) return Promise.resolve();
76
+ const batch = queue.splice(0, queue.length);
77
+ const sent = fetch("/uf-test/events", {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json" },
80
+ body: JSON.stringify(batch),
81
+ })
82
+ .catch(() => {
83
+ // The run is over, or `uf` killed the driver on a deadline. Either way
84
+ // there is nobody to tell, and throwing here would replace a report with
85
+ // an unhandled rejection.
86
+ })
87
+ .then(() => {
88
+ flushing = null;
89
+ if (queue.length > 0) return flush();
90
+ return undefined;
91
+ });
92
+ flushing = sent;
93
+ return sent;
94
+ }
95
+
96
+ /**
97
+ * Take over `console`, so a test's printing is an event rather than a line in
98
+ * a console nobody is reading.
99
+ *
100
+ * The same `internal/output.js` the Node worker installs. It has no
101
+ * `process.stdout` to take here and does not need one — the protocol's channel
102
+ * is a separate HTTP request, not a shared stream — which is the one thing
103
+ * that module had to learn about a page.
104
+ */
105
+ output.install((chunk) => {
106
+ write({ event: "output", stream: chunk.stream, test: chunk.test, text: chunk.text });
107
+ });
108
+
109
+ /** Run one file and report it, whatever happens to it. */
110
+ async function runFile(request: PageRequest): Promise<void> {
111
+ const file = request.file;
112
+ if (typeof file !== "string") {
113
+ write({ event: "file", status: "run-failed", message: "a request with no file" });
114
+ return;
115
+ }
116
+ const started = performance.now();
117
+ const url = new URL(`/@fs${file.split("/").map(encodeURIComponent).join("/")}`, location.href)
118
+ .href;
119
+ output.startFile();
120
+ const uninstall = installInSourceTests(url);
121
+ try {
122
+ try {
123
+ await import(url);
124
+ } catch (thrown) {
125
+ const error = asError(thrown);
126
+ write({
127
+ event: "file",
128
+ status: "load-failed",
129
+ message: `${error.name}: ${error.message}`,
130
+ stack: error.stack ?? null,
131
+ durationMicros: micros(started),
132
+ });
133
+ return;
134
+ }
135
+ await run(
136
+ {
137
+ filter: request.filter ?? null,
138
+ timeoutMs: request.timeoutMs,
139
+ file,
140
+ },
141
+ (result) => {
142
+ write({
143
+ event: "test",
144
+ ...result.outcome,
145
+ name: result.name,
146
+ line: result.line,
147
+ column: result.column,
148
+ durationMicros: result.durationMicros,
149
+ });
150
+ },
151
+ );
152
+ write({ event: "file", status: "completed", durationMicros: micros(started) });
153
+ } catch (thrown) {
154
+ const error = asError(thrown);
155
+ write({
156
+ event: "file",
157
+ status: "run-failed",
158
+ message: `${error.name}: ${error.message}`,
159
+ stack: error.stack ?? null,
160
+ durationMicros: micros(started),
161
+ });
162
+ } finally {
163
+ uninstall();
164
+ }
165
+ }
166
+
167
+ function asError(thrown: mixed): Error {
168
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
169
+ }
170
+
171
+ function micros(started: number): number {
172
+ return Math.round((performance.now() - started) * 1000);
173
+ }
174
+
175
+ /**
176
+ * The page's global object, as somewhere to hang a listener.
177
+ *
178
+ * `globalThis` is a namespace to the checker rather than an object, so
179
+ * `globalThis.addEventListener` is not an expression that can be written. The
180
+ * same one-line trust boundary `internal/output.js` draws around `console`,
181
+ * for the same reason and with the same amount of type in it.
182
+ */
183
+ function page(): $FlowFixMe {
184
+ return globalThis;
185
+ }
186
+
187
+ /**
188
+ * Ask for a file, run it, and hand the page back for the next one.
189
+ *
190
+ * The reload is the isolation, so it is unconditional: a page that ran a file
191
+ * does not run a second one, whatever the first one left behind. `location`
192
+ * rather than a fresh `import` with a busted cache, because the cache is the
193
+ * smallest of the things two files would otherwise share.
194
+ */
195
+ async function serve(): Promise<void> {
196
+ let request: PageRequest;
197
+ try {
198
+ request = await (await fetch("/uf-test/next")).json();
199
+ } catch {
200
+ // The driver has gone. Nothing to report it to.
201
+ return;
202
+ }
203
+ if (request.done === true) return;
204
+ serving = typeof request.generation === "number" ? request.generation : 0;
205
+ await runFile(request);
206
+ await flush();
207
+ location.reload();
208
+ }
209
+
210
+ /**
211
+ * An unhandled rejection ends the file, exactly as it does on a Node worker.
212
+ *
213
+ * A promise nobody awaited is the one failure a runner cannot see from inside
214
+ * `run.js`: the case has already been reported as passed by the time the
215
+ * rejection surfaces. Reporting it as a file failure is what keeps the run
216
+ * honest, and it is the same decision `worker.js` makes at the bottom of the
217
+ * file for the same reason.
218
+ *
219
+ * Then the page is *not* reloaded, so the run's own deadline is what ends this
220
+ * file. `uf` is holding a wall clock the page cannot argue with; a page that
221
+ * reloaded itself here would race that clock and could report the next file's
222
+ * events against this one's generation.
223
+ */
224
+ page().addEventListener("unhandledrejection", (event: mixed) => {
225
+ const reason = event != null && typeof event === "object" ? event.reason : undefined;
226
+ const error = asError(reason);
227
+ write({
228
+ event: "file",
229
+ status: "run-failed",
230
+ message: `unhandled rejection: ${error.message}`,
231
+ stack: error.stack ?? null,
232
+ });
233
+ void flush();
234
+ });
235
+
236
+ /**
237
+ * A window error ends the file too.
238
+ *
239
+ * A page has one more way to fail than a process does: a script that throws
240
+ * during evaluation of something the test did not `await` — an image handler,
241
+ * a `requestAnimationFrame` callback, a listener a component attached — is
242
+ * reported to `window` and to nothing else. On Node the equivalent takes the
243
+ * process down and `uf` reports a worker that died; here nothing at all would
244
+ * happen, and the file would sit until its deadline.
245
+ */
246
+ page().addEventListener("error", (event: mixed) => {
247
+ const thrown = event != null && typeof event === "object" ? event.error : undefined;
248
+ const error = asError(thrown ?? "an error with no value");
249
+ write({
250
+ event: "file",
251
+ status: "run-failed",
252
+ message: `${error.name}: ${error.message}`,
253
+ stack: error.stack ?? null,
254
+ });
255
+ void flush();
256
+ });
257
+
258
+ void serve();