@uniflowed/test 0.0.0-alpha.17 → 0.0.0-alpha.20

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,316 @@
1
+ // @flow
2
+ //
3
+ // The process `uf test --browser` fans work out to.
4
+ //
5
+ // `./worker.js` runs a test file. This runs a *browser* that runs a test file,
6
+ // and from `uf`'s side the two are the same thing: a process started the same
7
+ // way, given a request per line on stdin, answering with an event per line on
8
+ // stdout, bounded by the same wall clock and replaced the same way when it
9
+ // stops answering. `crates/uf_test/src/host.rs` does not branch on which of
10
+ // the two it is talking to, and that is the whole design — the browser is
11
+ // another host, not a second runner.
12
+ //
13
+ // uf ──stdin──▶ this process ──HTTP──▶ a page in a real browser
14
+ // ◀─stdout── ◀─HTTP──
15
+ //
16
+ // # Why there is a process in the middle at all
17
+ //
18
+ // A page cannot read a pipe. Somebody has to hold the browser's process
19
+ // handle, serve it modules, and turn what it says into what `uf` reads, and
20
+ // that somebody has to be a JavaScript host because resolving a bare specifier
21
+ // and transforming Flow are already solved there. So the driver is Node, the
22
+ // browser is the host under test, and the split is named in `docs/hosts.md`
23
+ // rather than left for a reader to infer from a stack trace.
24
+ //
25
+ // # What this depends on, exactly
26
+ //
27
+ // One browser binary, already installed, found or named by
28
+ // `crates/uf_test/src/browser.rs` and handed over in `UF_BROWSER`. Not
29
+ // downloaded, not vendored, not version-pinned, and not a driver library:
30
+ // there is no Playwright, no Puppeteer and no DevTools protocol here. The
31
+ // browser is started with a URL and a scratch profile and is otherwise left
32
+ // alone; everything uf needs to know comes back over HTTP from the page's own
33
+ // code.
34
+ //
35
+ // That is a deliberate ceiling as much as a deliberate floor. **uf cannot
36
+ // drive this browser** — it cannot click, navigate, screenshot, throttle a
37
+ // network or read a console it was not handed. Those are the other half of
38
+ // `docs/roadmap.md`'s "Playwright-compatible browser automation" and they want
39
+ // a protocol; this half only wants a page, and buying the protocol to get the
40
+ // page would have made a test runner own a browser automation library.
41
+ //
42
+ // # How this fails
43
+ //
44
+ // Loudly, by name, in three places. The browser is not there — refused in
45
+ // Rust, before a process is started. The browser starts and never asks for
46
+ // work — refused here, with whatever it wrote to stderr, because a browser
47
+ // that will not open a page is not a suite that failed. The browser goes away
48
+ // mid-run — the stream ends, and `uf` reports a host that died, which is the
49
+ // path Node workers already take.
50
+
51
+ import { spawn } from "node:child_process";
52
+ import { createInterface } from "node:readline";
53
+ import { mkdtempSync, rmSync } from "node:fs";
54
+ import { tmpdir } from "node:os";
55
+ import path from "node:path";
56
+
57
+ import { inSourceTests, sharedService } from "@uniflowed/host/transform";
58
+
59
+ import { create } from "./internal/browser/server.js";
60
+
61
+ /** What `uf` sends for one file. */
62
+ type Request = {|
63
+ readonly file: string,
64
+ readonly filter?: string | null,
65
+ readonly timeoutMs?: number,
66
+ readonly generation?: number,
67
+ |};
68
+
69
+ /**
70
+ * How long a browser is given to open the page before the run is refused.
71
+ *
72
+ * Generous against a cold Chromium with a cold profile, which is about seven
73
+ * seconds on a laptop and worse in a container, and deliberately *shorter than
74
+ * `uf_test`'s browser file budget* (`BROWSER_FILE_TIMEOUT`, thirty seconds).
75
+ * That order is the whole point: `uf` is already holding a stopwatch on the
76
+ * first file while this is happening, and whichever of the two fires first is
77
+ * what the report says. A browser that will not start should be reported as a
78
+ * browser that will not start, with its own output attached — not as a file
79
+ * that timed out, which is a sentence about tests that were never reached.
80
+ */
81
+ const START_TIMEOUT_MS = 20_000;
82
+
83
+ /**
84
+ * The switches a headless run needs, and what each is for.
85
+ *
86
+ * Kept short on purpose. Every flag here is a decision about the browser the
87
+ * tests are measured in, and a long list is a long list of ways this page
88
+ * differs from the application's — which is the difference browser mode exists
89
+ * to remove.
90
+ */
91
+ function browserArguments(profile: string, url: string): Array<string> {
92
+ return [
93
+ // The modern headless, which is the same renderer as a windowed browser.
94
+ // The old one was a separate binary with its own layout quirks, and a
95
+ // layout answer from it would not have been an answer about Chrome.
96
+ "--headless=new",
97
+ // What makes the browser die when this process does, and the only reason
98
+ // this mode names a debugging switch at all.
99
+ //
100
+ // `uf` kills a worker that misses its deadline, and a killed process runs
101
+ // no exit handler — so without this, a timed-out file would leave a browser
102
+ // running with nobody to stop it, and a suite that leaks one browser per
103
+ // timeout eventually cannot run. Chrome treats the pipe as its lifeline:
104
+ // when the file descriptors close, for any reason, it shuts itself down.
105
+ //
106
+ // Not a protocol. uf opens the two descriptors and never writes a byte to
107
+ // them; the page reports over HTTP, as everything else here does. The pipe
108
+ // is also why this is not `--remote-debugging-port`, which would open a
109
+ // port on the machine that anything could speak to — a pipe is held by this
110
+ // process alone.
111
+ //
112
+ // The tab closing is not enough on its own, which is what this replaces: a
113
+ // page that calls `window.close()` frees the renderers and leaves the
114
+ // browser process running (measured on Chrome 148).
115
+ "--remote-debugging-pipe",
116
+ // A scratch profile per run: no extensions, no saved state, no policy from
117
+ // the developer's own browser leaking into what the suite measures.
118
+ `--user-data-dir=${profile}`,
119
+ "--no-first-run",
120
+ "--no-default-browser-check",
121
+ // Nothing here paints, and a GPU process is one more thing to fail in a
122
+ // container.
123
+ "--disable-gpu",
124
+ // `/dev/shm` is 64 MB in most containers, which is where a renderer dies
125
+ // in CI and nowhere else.
126
+ "--disable-dev-shm-usage",
127
+ "--disable-background-timer-throttling",
128
+ "--disable-renderer-backgrounding",
129
+ url,
130
+ ];
131
+ }
132
+
133
+ /**
134
+ * Write one event, exactly as `./worker.js` does.
135
+ *
136
+ * The raw stream, taken before anything in this process could have replaced
137
+ * it. Nothing here captures `console`: the driver runs no test code, and the
138
+ * printing that has to be captured happens in the page.
139
+ */
140
+ const emit = (line: string) => {
141
+ process.stdout.write(line);
142
+ };
143
+
144
+ function write(event: { readonly [string]: mixed }): void {
145
+ emit(`${JSON.stringify(event)}\n`);
146
+ }
147
+
148
+ /** Report that the run cannot happen, against the file that asked for it. */
149
+ function refuse(message: string, generation: number): void {
150
+ write({ event: "file", status: "run-failed", message, generation });
151
+ }
152
+
153
+ async function main(): Promise<void> {
154
+ const root = process.env.UF_PROJECT_ROOT ?? process.cwd();
155
+ const browser = process.env.UF_BROWSER;
156
+ const service = sharedService(root);
157
+
158
+ const server = await create({
159
+ root,
160
+ transform: async (id, code) => {
161
+ // The same three the Node loader passes (`packages/host/internal/
162
+ // node-hooks.js`), so a module means the same thing on both hosts.
163
+ // `inSourceTests` is the one that would be easy to forget and expensive
164
+ // to: without it `import.meta.uf.test` compiles to `void 0`, every
165
+ // in-source block is shaken out, and a browser run of a project that
166
+ // writes them reports fewer tests than the Node run of the same files —
167
+ // silently, and with a green summary.
168
+ const result = await service.transform(id, code, {
169
+ development: true,
170
+ sourceMap: true,
171
+ inSourceTests: inSourceTests(),
172
+ });
173
+ if (result == null) return null;
174
+ // The map is appended rather than served beside the module: a browser
175
+ // reads `sourceMappingURL` from the end of the file, and a data URL is
176
+ // one fewer route and one fewer round trip. What it buys is a stack
177
+ // frame in a failure that names the line the author wrote — the same
178
+ // thing `--enable-source-maps` buys a Node worker.
179
+ const map = result.map;
180
+ if (map == null) return result.code;
181
+ const encoded = Buffer.from(typeof map === "string" ? map : JSON.stringify(map)).toString(
182
+ "base64",
183
+ );
184
+ return `${result.code}\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${encoded}\n`;
185
+ },
186
+ warn: (message) => {
187
+ process.stderr.write(`[uf] ${message}\n`);
188
+ },
189
+ });
190
+
191
+ server.onEvent((event) => {
192
+ write(event);
193
+ });
194
+
195
+ if (browser == null || browser === "") {
196
+ // Belt and braces: `uf` refuses before starting this process, so reaching
197
+ // here means somebody ran the driver by hand.
198
+ refuse(
199
+ "`@uniflowed/test/browser-worker.js` was started with no UF_BROWSER. It is not a command to run directly; `uf test --browser` finds a browser and names it here.",
200
+ 0,
201
+ );
202
+ await server.close();
203
+ return;
204
+ }
205
+
206
+ const profile = mkdtempSync(path.join(tmpdir(), "uf-browser-"));
207
+ // Five descriptors, because `--remote-debugging-pipe` reads 3 and writes 4.
208
+ // They are opened and never used; see the flag's note for what that buys.
209
+ const child = spawn(browser, browserArguments(profile, server.url), {
210
+ stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
211
+ });
212
+ // Kept, not printed. A Chromium writes a dozen lines about GPU probing and
213
+ // Vulkan on a healthy start, and forwarding those to a passing run's report
214
+ // would be noise; they are the whole of the evidence when it does not start,
215
+ // and that is when they are shown.
216
+ let noise = "";
217
+ const keep = (chunk: mixed) => {
218
+ if (noise.length < 8 * 1024) noise += String(chunk);
219
+ };
220
+ child.stdout?.on("data", keep);
221
+ child.stderr?.on("data", keep);
222
+
223
+ let started = false;
224
+ const startFailure = await new Promise<string | null>((resolve) => {
225
+ const deadline = setTimeout(() => {
226
+ resolve(`the browser did not open the page within ${String(START_TIMEOUT_MS / 1000)}s`);
227
+ }, START_TIMEOUT_MS);
228
+ // Polled rather than pushed: the page's first request *is* the signal, and
229
+ // the server already knows when it arrives. A `Page.loadEventFired` would
230
+ // be the protocol this mode does not speak.
231
+ const poll: IntervalID = setInterval(() => {
232
+ if (!server.connected()) return;
233
+ clearInterval(poll);
234
+ clearTimeout(deadline);
235
+ started = true;
236
+ resolve(null);
237
+ }, 25);
238
+ child.on("exit", (code: mixed) => {
239
+ if (started) return;
240
+ clearInterval(poll);
241
+ clearTimeout(deadline);
242
+ resolve(`the browser exited (${String(code)}) before opening the page`);
243
+ });
244
+ child.on("error", (error: Error) => {
245
+ clearInterval(poll);
246
+ clearTimeout(deadline);
247
+ resolve(`the browser could not be started: ${error.message}`);
248
+ });
249
+ });
250
+
251
+ const stop = () => {
252
+ child.kill("SIGKILL");
253
+ try {
254
+ rmSync(profile, { recursive: true, force: true });
255
+ } catch {
256
+ // A scratch profile that outlives the run is litter in the system
257
+ // temporary directory, not a reason to fail a suite.
258
+ }
259
+ };
260
+
261
+ if (startFailure != null) {
262
+ stop();
263
+ await server.close();
264
+ // The first file's request is what `uf` is waiting on, so the refusal is
265
+ // reported against it — every subsequent file gets the same message from a
266
+ // fresh driver, which is the same shape as a worker that cannot spawn.
267
+ const trailer = noise.trim() === "" ? "" : `\n${noise.trim()}`;
268
+ refuse(`\`${browser}\`: ${startFailure}.${trailer}`, 0);
269
+ // The stream ends here, which `uf` reads as a host that died. That is
270
+ // exactly what happened, and it is what makes `uf` start a fresh driver
271
+ // for the next file rather than send it to a browser that is not there.
272
+ return;
273
+ }
274
+
275
+ createInterface({ input: process.stdin }).on("line", (line) => {
276
+ if (line.trim() === "") return;
277
+ let request: Request;
278
+ try {
279
+ request = JSON.parse(line);
280
+ } catch (error) {
281
+ write({
282
+ event: "file",
283
+ status: "run-failed",
284
+ message: `malformed request: ${String(error)}`,
285
+ generation: 0,
286
+ });
287
+ return;
288
+ }
289
+ // Handed straight over. `uf` sends one file at a time and waits for its
290
+ // `file` event, and the page runs one file per load, so there is no queue
291
+ // to keep here — the two ends already agree on the shape.
292
+ server.offer({
293
+ file: request.file,
294
+ filter: request.filter ?? null,
295
+ timeoutMs: request.timeoutMs ?? 5000,
296
+ generation: request.generation ?? 0,
297
+ });
298
+ });
299
+
300
+ process.stdin.on("close", () => {
301
+ void server.close().then(() => {
302
+ stop();
303
+ service.close();
304
+ process.exit(0);
305
+ });
306
+ });
307
+ }
308
+
309
+ // A driver that throws has no file to blame, so it says so against the request
310
+ // `uf` is waiting on and lets the stream end. Silence here would be a run that
311
+ // waits for a deadline.
312
+ main().catch((error: mixed) => {
313
+ const reason = error instanceof Error ? error.message : String(error);
314
+ refuse(`\`uf test --browser\` could not start: ${reason}`, 0);
315
+ process.exit(1);
316
+ });
@@ -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();