@uniflowed/test 0.0.0-alpha.9 → 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.
- 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 +11 -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 +432 -80
- package/internal/isolation.js +231 -0
- package/internal/namespace.js +22 -10
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +25 -7
- package/internal/registry.js +54 -3
- package/internal/run.js +130 -11
- package/internal/timers.js +2 -2
- package/package.json +41 -5
- package/worker.js +98 -10
|
@@ -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();
|