@uniflowed/test 0.0.0-alpha.40 → 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 ADDED
@@ -0,0 +1,6 @@
1
+ // @flow
2
+ export function createTestApp(_options?: mixed): empty {
3
+ throw new Error(
4
+ "createTestApp runs in a Node test worker. Run this test with uf test, and use createBrowser() to check its RSC hydration in Chromium.",
5
+ );
6
+ }
package/app.js ADDED
@@ -0,0 +1,175 @@
1
+ // @flow
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ export type AppTestOptions = {|
8
+ readonly root: string | URL,
9
+ readonly binary?: string,
10
+ readonly env?: { readonly [string]: string },
11
+ readonly timeoutMs?: number,
12
+ |};
13
+
14
+ export type AppRequest = {|
15
+ readonly method?: string,
16
+ readonly headers?: { readonly [string]: string },
17
+ readonly body?: string,
18
+ readonly signal?: AbortSignal,
19
+ |};
20
+
21
+ export type TestApp = {|
22
+ /** The live URL, including after an environment-triggered restart. */
23
+ readonly origin: () => string,
24
+ readonly fetch: (pathname: string, options?: AppRequest) => Promise<Response>,
25
+ readonly render: (pathname: string, options?: AppRequest) => Promise<Response>,
26
+ /** The actual React Flight stream, with request headers and Suspense intact. */
27
+ readonly flight: (pathname: string, options?: AppRequest) => Promise<Response>,
28
+ readonly events: () => $ReadOnlyArray<{ readonly [string]: mixed }>,
29
+ readonly close: () => Promise<void>,
30
+ |};
31
+
32
+ /** Start the application's real web pipeline in an isolated process for `uf test`. */
33
+ export async function createTestApp(options: AppTestOptions): Promise<TestApp> {
34
+ const root = path.resolve(
35
+ options.root instanceof URL ? fileURLToPath(options.root.href) : options.root,
36
+ );
37
+ const timeout = options.timeoutMs ?? 30000;
38
+ if (!Number.isFinite(timeout) || timeout <= 0)
39
+ throw new TypeError("test app timeout must be positive");
40
+ const child = spawn(
41
+ options.binary ?? process.env.UF_BINARY ?? "uf",
42
+ [
43
+ "--cwd",
44
+ root,
45
+ "dev",
46
+ "--json",
47
+ "--parent-pipe",
48
+ "--target",
49
+ "web",
50
+ "--host",
51
+ "127.0.0.1",
52
+ "--port",
53
+ "0",
54
+ ],
55
+ { env: { ...process.env, ...options.env }, stdio: ["pipe", "pipe", "pipe"] },
56
+ );
57
+ const events: Array<{ readonly [string]: mixed }> = [];
58
+ let transcript = "";
59
+ let origin = "";
60
+ let ended = false;
61
+ const exited = new Promise<void>((resolve) =>
62
+ // The CLI exits before its Vite driver's inherited streams close. Wait for
63
+ // those streams too, so close() includes server and diagnostic cleanup.
64
+ child.once("close", () => {
65
+ ended = true;
66
+ resolve();
67
+ }),
68
+ );
69
+ let closing = null;
70
+ const close = (): Promise<void> => {
71
+ if (closing != null) return closing;
72
+ closing = (async () => {
73
+ if (ended) return;
74
+ child.stdin?.end();
75
+ const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
76
+ try {
77
+ await exited;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ })();
82
+ return closing;
83
+ };
84
+ child.stderr?.on("data", (chunk) => {
85
+ transcript = (transcript + String(chunk)).slice(-16384);
86
+ });
87
+ const lines = createInterface({ input: child.stdout });
88
+ try {
89
+ await new Promise<void>((resolve, reject) => {
90
+ const timer = setTimeout(
91
+ () => reject(new Error(`test app did not start within ${timeout}ms\n${transcript}`)),
92
+ timeout,
93
+ );
94
+ const fail = (error: mixed) => {
95
+ clearTimeout(timer);
96
+ reject(error);
97
+ };
98
+ child.once("error", (error) => {
99
+ ended = true;
100
+ fail(error);
101
+ });
102
+ child.once("exit", (code) =>
103
+ fail(new Error(`test app exited (${String(code)})\n${transcript}`)),
104
+ );
105
+ lines.on("line", (line) => {
106
+ let event;
107
+ try {
108
+ event = JSON.parse(line);
109
+ } catch {
110
+ transcript = (transcript + line + "\n").slice(-16384);
111
+ return;
112
+ }
113
+ events.push(event);
114
+ if (events.length > 200) events.shift();
115
+ if (event.event === "listening" && typeof event.local?.[0] === "string") {
116
+ origin = new URL(event.local[0]).origin;
117
+ clearTimeout(timer);
118
+ resolve();
119
+ } else if (event.event === "error") {
120
+ fail(new Error(`test app: ${String(event.message)}`));
121
+ }
122
+ });
123
+ });
124
+ } catch (error) {
125
+ await close();
126
+ throw error;
127
+ }
128
+ function address(pathname: string): URL {
129
+ if (ended || closing != null) throw new Error("test app is closed");
130
+ if (!pathname.startsWith("/") || pathname.startsWith("//") || pathname.includes("\\")) {
131
+ throw new TypeError("test app needs an absolute path on its own origin");
132
+ }
133
+ const url = new URL(pathname, origin);
134
+ if (url.origin !== origin) throw new TypeError("test app cannot fetch another origin");
135
+ return url;
136
+ }
137
+ const request = async (url: URL, init?: AppRequest): Promise<Response> => {
138
+ const controller = new AbortController();
139
+ const timer = setTimeout(() => controller.abort(), timeout);
140
+ try {
141
+ return await fetch(url.href, {
142
+ ...init,
143
+ headers: { ...init?.headers },
144
+ signal:
145
+ init?.signal == null
146
+ ? controller.signal
147
+ : AbortSignal.any([init.signal, controller.signal]),
148
+ });
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ };
153
+ return {
154
+ origin: () => origin,
155
+ fetch: async (pathname, init) => request(address(pathname), init),
156
+ render: async (pathname, init) => {
157
+ const headers = new Headers({ ...init?.headers });
158
+ headers.set("accept", "text/html");
159
+ return request(address(pathname), { ...init, headers: Object.fromEntries(headers) });
160
+ },
161
+ flight: async (pathname, init) => {
162
+ const url = address(pathname);
163
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/__uf.flight`;
164
+ const response = await request(url, init);
165
+ if (!(response.headers.get("content-type") ?? "").includes("text/x-component")) {
166
+ throw new Error(
167
+ `test app returned ${response.status} without a Flight stream; enable RSC for this route`,
168
+ );
169
+ }
170
+ return response;
171
+ },
172
+ events: () => [...events],
173
+ close,
174
+ };
175
+ }
package/browser-worker.js CHANGED
@@ -22,22 +22,13 @@
22
22
  // browser is the host under test, and the split is named in `docs/hosts.md`
23
23
  // rather than left for a reader to infer from a stack trace.
24
24
  //
25
- // # What this depends on, exactly
25
+ // # Browser control
26
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.
27
+ // Chromium is selected by `UF_BROWSER` or the CLI's browser discovery. Tests
28
+ // use `@uniflowed/test/browser` to create isolated pages, send trusted input,
29
+ // navigate and compare screenshots. The controller speaks CDP over a private
30
+ // pipe; the harness page relays bounded commands through its authenticated
31
+ // loopback endpoint. Closing the worker's pipe also terminates Chromium.
41
32
  //
42
33
  // # How this fails
43
34
  //
@@ -57,6 +48,7 @@ import path from "node:path";
57
48
  import { inSourceTests, sharedService } from "@uniflowed/host/transform";
58
49
 
59
50
  import { create } from "./internal/browser/server.js";
51
+ import { connectPipe } from "./internal/browser/cdp.js";
60
52
 
61
53
  /** What `uf` sends for one file. */
62
54
  type Request = {|
@@ -104,11 +96,8 @@ function browserArguments(profile: string, url: string): Array<string> {
104
96
  // timeout eventually cannot run. Chrome treats the pipe as its lifeline:
105
97
  // when the file descriptors close, for any reason, it shuts itself down.
106
98
  //
107
- // Not a protocol. uf opens the two descriptors and never writes a byte to
108
- // them; the page reports over HTTP, as everything else here does. The pipe
109
- // is also why this is not `--remote-debugging-port`, which would open a
110
- // port on the machine that anything could speak to — a pipe is held by this
111
- // process alone.
99
+ // The same private pipe carries CDP commands for pages created by tests.
100
+ // It exposes no debugging port to other local browser origins.
112
101
  //
113
102
  // The tab closing is not enough on its own, which is what this replaces: a
114
103
  // page that calls `window.close()` frees the renderers and leaves the
@@ -173,8 +162,13 @@ async function main(): Promise<void> {
173
162
  const browser = process.env.UF_BROWSER;
174
163
  const service = sharedService(root);
175
164
 
165
+ let controller = null;
176
166
  const server = await create({
177
167
  root,
168
+ browserCommand: async (id, method, args) => {
169
+ if (controller == null) throw new Error("browser driver is not ready");
170
+ return controller.command(id, method, args);
171
+ },
178
172
  transform: async (id, code) => {
179
173
  // The same three the Node loader passes (`packages/host/internal/
180
174
  // node-hooks.js`), so a module means the same thing on both hosts.
@@ -207,7 +201,12 @@ async function main(): Promise<void> {
207
201
  });
208
202
 
209
203
  server.onEvent((event) => {
210
- write(event);
204
+ if (event.event === "file" && controller != null) {
205
+ void controller.closePages().then(
206
+ () => write(event),
207
+ (error) => refuse(String(error), Number(event.generation ?? 0)),
208
+ );
209
+ } else write(event);
211
210
  });
212
211
 
213
212
  if (browser == null || browser === "") {
@@ -223,15 +222,11 @@ async function main(): Promise<void> {
223
222
 
224
223
  const profile = mkdtempSync(path.join(tmpdir(), "uf-browser-"));
225
224
  // Five descriptors, because `--remote-debugging-pipe` reads 3 and writes 4.
226
- // They are opened and never used; see the flag's note for what that buys.
225
+ // The controller below exchanges null-delimited CDP messages on them.
227
226
  const child = spawn(browser, browserArguments(profile, server.url), {
228
227
  stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
229
228
  });
230
- // The DevTools pipe is a lifeline, not a protocol this driver speaks. Chrome
231
- // can still write target bookkeeping to fd 4 before the page asks for work;
232
- // drain it so a full pipe cannot block startup before the first HTTP request.
233
- const devtoolsOutput = child.stdio[4];
234
- devtoolsOutput?.on("data", () => {});
229
+ controller = connectPipe(child, { root });
235
230
  // Kept, not printed. A Chromium writes a dozen lines about GPU probing and
236
231
  // Vulkan on a healthy start, and forwarding those to a passing run's report
237
232
  // would be noise; they are the whole of the evidence when it does not start,
package/browser.js ADDED
@@ -0,0 +1,59 @@
1
+ // @flow
2
+ import { createTransport } from "./internal/browser/transport.js";
3
+
4
+ export type Viewport = {|
5
+ readonly width: number,
6
+ readonly height: number,
7
+ readonly deviceScaleFactor?: number,
8
+ |};
9
+ export type ScreenshotResult = {|
10
+ readonly baseline: string,
11
+ readonly updated: boolean,
12
+ readonly differentPixels: number,
13
+ |};
14
+ export type BrowserOptions = {|
15
+ readonly executable?: string,
16
+ readonly timeoutMs?: number,
17
+ |};
18
+ export type TestPage = {|
19
+ readonly visit: (url: string) => Promise<void>,
20
+ readonly setContent: (html: string) => Promise<void>,
21
+ readonly waitFor: (selector: string) => Promise<void>,
22
+ readonly text: (selector: string) => Promise<string | null>,
23
+ readonly value: (selector: string) => Promise<string | null>,
24
+ readonly url: () => Promise<string>,
25
+ readonly click: (selector: string) => Promise<void>,
26
+ readonly fill: (selector: string, text: string) => Promise<void>,
27
+ readonly press: (key: string) => Promise<void>,
28
+ readonly tap: (selector: string) => Promise<void>,
29
+ readonly viewport: (viewport: Viewport) => Promise<void>,
30
+ readonly screenshot: (
31
+ name: string,
32
+ options?: {| readonly threshold?: number, readonly baselines?: string |},
33
+ ) => Promise<ScreenshotResult>,
34
+ readonly events: () => Promise<$ReadOnlyArray<{ readonly [string]: mixed }>>,
35
+ readonly close: () => Promise<void>,
36
+ |};
37
+
38
+ /** An isolated Chromium page; the test harness stays alive across navigations. */
39
+ export async function createBrowser(options?: BrowserOptions): Promise<TestPage> {
40
+ const transport = await createTransport(options ?? {});
41
+ const call = (method: string, args: $ReadOnlyArray<mixed> = []) =>
42
+ transport.command(transport.id, method, args);
43
+ return {
44
+ visit: (url) => call("visit", [url]),
45
+ setContent: (html) => call("setContent", [html]),
46
+ waitFor: (selector) => call("waitFor", [selector]),
47
+ text: (selector) => call("text", [selector]),
48
+ value: (selector) => call("value", [selector]),
49
+ url: () => call("url"),
50
+ click: (selector) => call("click", [selector]),
51
+ fill: (selector, text) => call("fill", [selector, text]),
52
+ press: (key) => call("press", [key]),
53
+ tap: (selector) => call("tap", [selector]),
54
+ viewport: (viewport) => call("viewport", [viewport]),
55
+ screenshot: (name, options) => call("screenshot", [name, options]),
56
+ events: () => call("events"),
57
+ close: transport.close,
58
+ };
59
+ }
@@ -0,0 +1,364 @@
1
+ // @flow
2
+ import { spawn } from "node:child_process";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { compareScreenshot } from "./screenshots.js";
7
+
8
+ // CDP replies are JSON selected by the command name. Keep that untyped wire
9
+ // boundary here; the application-facing page API has concrete Flow types.
10
+ type WireValue = $FlowFixMe;
11
+ export type ControlOptions = {|
12
+ readonly root?: string,
13
+ readonly baselines?: string,
14
+ readonly threshold?: number,
15
+ readonly executable?: string,
16
+ readonly timeoutMs?: number,
17
+ |};
18
+ export type BrowserTransport = {|
19
+ readonly id: string,
20
+ readonly command: (
21
+ id: string | null,
22
+ method: string,
23
+ args?: $ReadOnlyArray<WireValue>,
24
+ ) => Promise<WireValue>,
25
+ readonly close: () => Promise<void>,
26
+ |};
27
+ type Pending = {|
28
+ timer: TimeoutID,
29
+ resolve: (value: WireValue) => void,
30
+ reject: (error: Error) => void,
31
+ |};
32
+
33
+ /** CDP over the private pipe: closing the test worker also closes Chromium. */
34
+ export function connectPipe(child: $FlowFixMe, options: ControlOptions = {}) {
35
+ let sequence = 0;
36
+ let buffered = "";
37
+ let closed = false;
38
+ const pending: Map<number, Pending> = new Map();
39
+ const pages: Map<string, string> = new Map();
40
+ const events: Map<string, Array<WireValue>> = new Map();
41
+ const loaded: Map<string, Set<string>> = new Map();
42
+ const output = child.stdio[4];
43
+ output.setEncoding("utf8");
44
+ output.on("data", (chunk) => {
45
+ buffered += chunk;
46
+ for (let end; (end = buffered.indexOf("\0")) !== -1; ) {
47
+ const raw = buffered.slice(0, end);
48
+ buffered = buffered.slice(end + 1);
49
+ if (!raw) continue;
50
+ const message = JSON.parse(raw);
51
+ const task = pending.get(message.id);
52
+ if (task) {
53
+ pending.delete(message.id);
54
+ clearTimeout(task.timer);
55
+ if (message.error) task.reject(new Error(message.error.message));
56
+ else task.resolve(message.result);
57
+ } else if (message.sessionId && events.has(message.sessionId)) {
58
+ if (message.method === "Page.lifecycleEvent" && message.params.name === "load")
59
+ loaded.get(message.sessionId)?.add(message.params.loaderId);
60
+ if (["Runtime.exceptionThrown", "Runtime.consoleAPICalled"].includes(message.method)) {
61
+ const history = events.get(message.sessionId);
62
+ if (history == null) continue;
63
+ history.push({ method: message.method, ...message.params });
64
+ if (history.length > 100) history.shift();
65
+ }
66
+ }
67
+ }
68
+ });
69
+ const disconnect = () => {
70
+ closed = true;
71
+ for (const task of pending.values()) {
72
+ clearTimeout(task.timer);
73
+ task.reject(new Error("test browser closed"));
74
+ }
75
+ pending.clear();
76
+ };
77
+ child.once("exit", disconnect);
78
+ child.once("error", disconnect);
79
+ child.stdio[3].on("error", disconnect);
80
+ function send(
81
+ method: string,
82
+ params: { readonly [string]: mixed } = {},
83
+ sessionId?: string,
84
+ ): Promise<WireValue> {
85
+ if (closed) return Promise.reject(new Error("test browser closed"));
86
+ const id = ++sequence;
87
+ return new Promise((resolve, reject) => {
88
+ const timer = setTimeout(() => {
89
+ pending.delete(id);
90
+ reject(new Error(`CDP timed out: ${method}`));
91
+ }, options.timeoutMs ?? 15000);
92
+ pending.set(id, { resolve, reject, timer });
93
+ child.stdio[3].write(JSON.stringify({ id, method, params, sessionId }) + "\0");
94
+ });
95
+ }
96
+ async function evaluate(sessionId: string, expression: string): Promise<WireValue> {
97
+ const result = await send(
98
+ "Runtime.evaluate",
99
+ { expression, returnByValue: true, awaitPromise: true },
100
+ sessionId,
101
+ );
102
+ if (result.exceptionDetails)
103
+ throw new Error(
104
+ result.exceptionDetails.exception?.description ?? result.exceptionDetails.text,
105
+ );
106
+ return result.result.value;
107
+ }
108
+ async function until(
109
+ session: string,
110
+ expression: string,
111
+ description: string,
112
+ ): Promise<WireValue> {
113
+ const end = Date.now() + (options.timeoutMs ?? 15000);
114
+ while (Date.now() < end) {
115
+ try {
116
+ const result = await evaluate(session, expression);
117
+ if (result) return result;
118
+ } catch (error) {
119
+ if (!/context|navigat/i.test(error.message)) throw error;
120
+ }
121
+ await new Promise((resolve) => setTimeout(resolve, 25));
122
+ }
123
+ throw new Error(`test browser timed out waiting for ${description}`);
124
+ }
125
+ async function point(session: string, selector: string): Promise<{| x: number, y: number |}> {
126
+ return until(
127
+ session,
128
+ `(() => {
129
+ const element = document.querySelector(${JSON.stringify(selector)});
130
+ if (!element || element.disabled) return null;
131
+ element.scrollIntoView({block:"center", inline:"center", behavior:"instant"});
132
+ const box = element.getBoundingClientRect();
133
+ if (!box.width || !box.height || getComputedStyle(element).visibility !== "visible") return null;
134
+ const x = box.x + box.width / 2, y = box.y + box.height / 2;
135
+ const top = document.elementFromPoint(x, y);
136
+ return top && (top === element || element.contains(top)) ? {x,y} : null;
137
+ })()`,
138
+ `actionable ${selector}`,
139
+ );
140
+ }
141
+ async function createPage(): Promise<string> {
142
+ const { targetId } = await send("Target.createTarget", { url: "about:blank" });
143
+ const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
144
+ pages.set(targetId, sessionId);
145
+ events.set(sessionId, []);
146
+ loaded.set(sessionId, new Set());
147
+ await send("Page.enable", {}, sessionId);
148
+ await send("Page.setLifecycleEventsEnabled", { enabled: true }, sessionId);
149
+ await send("Runtime.enable", {}, sessionId);
150
+ return targetId;
151
+ }
152
+ async function command(
153
+ id: string | null,
154
+ method: string,
155
+ args: $ReadOnlyArray<WireValue> = [],
156
+ ): Promise<WireValue> {
157
+ if (method === "create") return createPage();
158
+ if (id == null) throw new Error("unknown test page");
159
+ const session = pages.get(id);
160
+ if (!session) throw new Error("unknown or closed test page");
161
+ const [first, second] = args;
162
+ switch (method) {
163
+ case "visit": {
164
+ const url = new URL(first);
165
+ if (!["http:", "https:", "about:"].includes(url.protocol))
166
+ throw new Error("test page visit supports HTTP(S) URLs and about:blank");
167
+ const result = await send("Page.navigate", { url: url.href }, session);
168
+ if (result.errorText) throw new Error(result.errorText);
169
+ if (result.loaderId) {
170
+ const deadline = Date.now() + (options.timeoutMs ?? 15000);
171
+ while (loaded.get(session)?.has(result.loaderId) !== true) {
172
+ if (closed || Date.now() >= deadline)
173
+ throw new Error("test browser timed out waiting for document load");
174
+ await new Promise((resolve) => setTimeout(resolve, 25));
175
+ }
176
+ }
177
+ await until(session, "document.readyState === 'complete'", "document load");
178
+ return;
179
+ }
180
+ case "setContent": {
181
+ const { frameTree } = await send("Page.getFrameTree", {}, session);
182
+ await send(
183
+ "Page.setDocumentContent",
184
+ { frameId: frameTree.frame.id, html: first },
185
+ session,
186
+ );
187
+ return;
188
+ }
189
+ case "waitFor":
190
+ await until(session, `!!document.querySelector(${JSON.stringify(first)})`, first);
191
+ return;
192
+ case "text":
193
+ return evaluate(
194
+ session,
195
+ `document.querySelector(${JSON.stringify(first)})?.textContent ?? null`,
196
+ );
197
+ case "value":
198
+ return evaluate(session, `document.querySelector(${JSON.stringify(first)})?.value ?? null`);
199
+ case "url":
200
+ return evaluate(session, "location.href");
201
+ case "events":
202
+ return [...(events.get(session) ?? [])];
203
+ case "viewport": {
204
+ const { width, height, deviceScaleFactor = 1 } = first;
205
+ if (
206
+ ![width, height, deviceScaleFactor].every(
207
+ (value) => Number.isFinite(value) && value > 0,
208
+ ) ||
209
+ width > 8192 ||
210
+ height > 8192 ||
211
+ deviceScaleFactor > 4
212
+ )
213
+ throw new Error("invalid test viewport");
214
+ await send(
215
+ "Emulation.setDeviceMetricsOverride",
216
+ { width, height, deviceScaleFactor, mobile: false },
217
+ session,
218
+ );
219
+ return;
220
+ }
221
+ case "click": {
222
+ const position = await point(session, first);
223
+ await send(
224
+ "Input.dispatchMouseEvent",
225
+ { type: "mousePressed", ...position, button: "left", clickCount: 1 },
226
+ session,
227
+ );
228
+ await send(
229
+ "Input.dispatchMouseEvent",
230
+ { type: "mouseReleased", ...position, button: "left", clickCount: 1 },
231
+ session,
232
+ );
233
+ return;
234
+ }
235
+ case "fill": {
236
+ await command(id, "click", [first]);
237
+ await evaluate(
238
+ session,
239
+ `(() => {const input = document.querySelector(${JSON.stringify(first)}); input.focus(); if (!input.matches('input,textarea')) throw new Error('fill needs an input or textarea'); input.select();})()`,
240
+ );
241
+ await send("Input.insertText", { text: second }, session);
242
+ return;
243
+ }
244
+ case "press": {
245
+ const keys = {
246
+ Enter: 13,
247
+ Tab: 9,
248
+ Escape: 27,
249
+ Backspace: 8,
250
+ ArrowLeft: 37,
251
+ ArrowUp: 38,
252
+ ArrowRight: 39,
253
+ ArrowDown: 40,
254
+ " ": 32,
255
+ };
256
+ if (!(first in keys)) throw new Error(`unsupported test key ${first}`);
257
+ const params = {
258
+ key: first,
259
+ windowsVirtualKeyCode: keys[first],
260
+ ...(first === "Enter" ? { text: "\r" } : first === " " ? { text: " " } : {}),
261
+ };
262
+ await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }, session);
263
+ await send(
264
+ "Input.dispatchKeyEvent",
265
+ { type: "keyUp", key: first, windowsVirtualKeyCode: keys[first] },
266
+ session,
267
+ );
268
+ return;
269
+ }
270
+ case "tap": {
271
+ const position = await point(session, first);
272
+ await send("Emulation.setTouchEmulationEnabled", { enabled: true }, session);
273
+ try {
274
+ await send(
275
+ "Input.dispatchTouchEvent",
276
+ { type: "touchStart", touchPoints: [{ ...position, id: 1 }] },
277
+ session,
278
+ );
279
+ await send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }, session);
280
+ } finally {
281
+ await send("Emulation.setTouchEmulationEnabled", { enabled: false }, session);
282
+ }
283
+ return;
284
+ }
285
+ case "screenshot": {
286
+ await evaluate(
287
+ session,
288
+ "document.fonts.ready.then(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))))",
289
+ );
290
+ const { data } = await send(
291
+ "Page.captureScreenshot",
292
+ { format: "png", captureBeyondViewport: false },
293
+ session,
294
+ );
295
+ return compareScreenshot(data, first, {
296
+ root: options.root,
297
+ threshold: second?.threshold ?? options.threshold,
298
+ baselines: second?.baselines ?? options.baselines,
299
+ });
300
+ }
301
+ case "close":
302
+ await send("Target.closeTarget", { targetId: id });
303
+ pages.delete(id);
304
+ events.delete(session);
305
+ loaded.delete(session);
306
+ return;
307
+ default:
308
+ throw new Error(`unknown test browser command ${method}`);
309
+ }
310
+ }
311
+ return {
312
+ command,
313
+ closePages: async () => {
314
+ for (const id of [...pages.keys()]) await command(id, "close");
315
+ },
316
+ };
317
+ }
318
+
319
+ export async function launchBrowser(options: ControlOptions = {}): Promise<BrowserTransport> {
320
+ const executable = options.executable ?? process.env.UF_BROWSER;
321
+ if (!executable) throw new Error("Set UF_BROWSER to a Chromium executable for createBrowser()");
322
+ const profile = mkdtempSync(path.join(tmpdir(), "uf-test-cdp-"));
323
+ const child = spawn(
324
+ executable,
325
+ [
326
+ "--headless=new",
327
+ "--remote-debugging-pipe",
328
+ `--user-data-dir=${profile}`,
329
+ "--no-first-run",
330
+ "--no-default-browser-check",
331
+ "--disable-dev-shm-usage",
332
+ "--disable-background-networking",
333
+ "--password-store=basic",
334
+ "about:blank",
335
+ ],
336
+ { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] },
337
+ );
338
+ let stderr = "";
339
+ child.stderr.on("data", (chunk) => {
340
+ stderr = (stderr + chunk).slice(-8192);
341
+ });
342
+ const controller = connectPipe(child, options);
343
+ const exited = new Promise((resolve) => {
344
+ child.once("exit", resolve);
345
+ child.once("error", resolve);
346
+ });
347
+ let closing: Promise<void> | void;
348
+ const close = (): Promise<void> => {
349
+ if (closing != null) return closing;
350
+ const done = (async (): Promise<void> => {
351
+ child.kill("SIGKILL");
352
+ await exited;
353
+ rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
354
+ })();
355
+ closing = done;
356
+ return done;
357
+ };
358
+ try {
359
+ return { id: await controller.command(null, "create"), command: controller.command, close };
360
+ } catch (error) {
361
+ await close();
362
+ throw new Error(`${error.message}\n${stderr}`);
363
+ }
364
+ }
@@ -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,102 @@
1
+ // @flow
2
+ import { readFile, writeFile, mkdir, realpath, lstat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { PNG } from "pngjs";
5
+ import pixelmatch from "pixelmatch";
6
+
7
+ export type ScreenshotOptions = {|
8
+ readonly root?: string,
9
+ readonly baselines?: string,
10
+ readonly threshold?: number,
11
+ |};
12
+
13
+ /** Baselines stay under the project's configured directory, including through symlinks. */
14
+ export async function compareScreenshot(
15
+ data: string,
16
+ name: string,
17
+ options: ScreenshotOptions = {},
18
+ ) {
19
+ if (
20
+ typeof name !== "string" ||
21
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$/.test(name) ||
22
+ name.includes("..") ||
23
+ /\.(actual|diff)$/.test(name)
24
+ )
25
+ throw new Error("screenshot name must be a simple file name without traversal");
26
+ const config = JSON.parse(process.env.UF_VRT_CONFIG ?? "{}");
27
+ const root = await realpath(options.root ?? process.env.UF_PROJECT_ROOT ?? process.cwd());
28
+ const directory = path.resolve(root, options.baselines ?? config.baselines ?? "__uf_vrt__");
29
+ if (!directory.startsWith(root + path.sep))
30
+ throw new Error("vrt.baselines must stay inside the test project");
31
+ // Check each existing ancestor before creating anything through it.
32
+ let ancestor = root;
33
+ for (const segment of path.relative(root, directory).split(path.sep)) {
34
+ ancestor = path.join(ancestor, segment);
35
+ try {
36
+ if ((await lstat(ancestor)).isSymbolicLink())
37
+ throw new Error("vrt.baselines cannot traverse a symlink");
38
+ } catch (error) {
39
+ if (error.code !== "ENOENT") throw error;
40
+ }
41
+ }
42
+ await mkdir(directory, { recursive: true });
43
+ const baseline = path.join(directory, `${name}.png`);
44
+ try {
45
+ if ((await lstat(baseline)).isSymbolicLink())
46
+ throw new Error("screenshot baseline cannot be a symlink");
47
+ } catch (error) {
48
+ if (error.code !== "ENOENT") throw error;
49
+ }
50
+ const bytes = Buffer.from(data, "base64");
51
+ const actual = PNG.sync.read(bytes);
52
+ const threshold = options.threshold ?? config.threshold ?? 0;
53
+ if (!Number.isInteger(threshold) || threshold < 0)
54
+ throw new Error("vrt.threshold is the maximum number of different pixels");
55
+ if (process.env.UF_UPDATE_SNAPSHOTS === "1") {
56
+ await writeFile(baseline, bytes);
57
+ return { baseline, updated: true, differentPixels: 0 };
58
+ }
59
+ let expected;
60
+ try {
61
+ expected = PNG.sync.read(await readFile(baseline));
62
+ } catch (error) {
63
+ if (error.code === "ENOENT")
64
+ throw new Error(`missing screenshot baseline ${baseline}; record it with uf test -u`);
65
+ throw error;
66
+ }
67
+ const width = Math.max(actual.width, expected.width),
68
+ height = Math.max(actual.height, expected.height);
69
+ const diff = new PNG({ width, height });
70
+ const sameSize = actual.width === expected.width && actual.height === expected.height;
71
+ let differentPixels;
72
+ if (sameSize)
73
+ differentPixels = pixelmatch(actual.data, expected.data, diff.data, width, height, {
74
+ threshold: 0.1,
75
+ includeAA: true,
76
+ });
77
+ else {
78
+ differentPixels = width * height;
79
+ for (let i = 0; i < diff.data.length; i += 4) {
80
+ diff.data[i] = 255;
81
+ diff.data[i + 3] = 255;
82
+ }
83
+ }
84
+ if (!sameSize || differentPixels > threshold) {
85
+ const actualFile = path.join(directory, `${name}.actual.png`);
86
+ const diffFile = path.join(directory, `${name}.diff.png`);
87
+ for (const file of [actualFile, diffFile]) {
88
+ try {
89
+ if ((await lstat(file)).isSymbolicLink())
90
+ throw new Error("screenshot output cannot be a symlink");
91
+ } catch (error) {
92
+ if (error.code !== "ENOENT") throw error;
93
+ }
94
+ }
95
+ await writeFile(actualFile, bytes);
96
+ await writeFile(diffFile, PNG.sync.write(diff));
97
+ throw new Error(
98
+ `screenshot ${name}: ${differentPixels} pixels differ (allowed ${threshold})${sameSize ? "" : "; dimensions changed"}; diff: ${diffFile}`,
99
+ );
100
+ }
101
+ return { baseline, updated: false, differentPixels };
102
+ }
@@ -51,6 +51,7 @@
51
51
  // worth not having at all.
52
52
 
53
53
  import { createServer } from "node:http";
54
+ import { randomUUID } from "node:crypto";
54
55
  import { createRequire } from "node:module";
55
56
  import { readFileSync, readdirSync, realpathSync } from "node:fs";
56
57
  import path from "node:path";
@@ -122,6 +123,11 @@ export type PageRequest = {|
122
123
 
123
124
  /** How `create` is configured. */
124
125
  export type ServerOptions = {|
126
+ readonly browserCommand?: (
127
+ id: mixed,
128
+ method: string,
129
+ args: $ReadOnlyArray<mixed>,
130
+ ) => Promise<mixed>,
125
131
  /** The project root; every served file must be under this or a package. */
126
132
  readonly root: string,
127
133
  /** Transforms a Flow module, or answers `null` when it is not uf's. */
@@ -139,6 +145,7 @@ export type ServerOptions = {|
139
145
  */
140
146
  export async function create(options: ServerOptions): Promise<ModuleServer> {
141
147
  const root = realpathSync(options.root);
148
+ const browserToken = randomUUID();
142
149
  const packages = crawlPackages(root);
143
150
  const map = importMap(packages);
144
151
  const roots = [root, ...packages.map((entry) => entry.directory)];
@@ -180,7 +187,7 @@ export async function create(options: ServerOptions): Promise<ModuleServer> {
180
187
  const at = url.indexOf("?");
181
188
  const route = at === -1 ? url : url.slice(0, at);
182
189
  if (route === "/" || route === PAGE_PATH) {
183
- reply(outgoing, 200, "text/html; charset=utf-8", harnessHtml(map));
190
+ reply(outgoing, 200, "text/html; charset=utf-8", harnessHtml(map, browserToken));
184
191
  return;
185
192
  }
186
193
  if (route === "/uf-test/next") {
@@ -198,6 +205,46 @@ export async function create(options: ServerOptions): Promise<ModuleServer> {
198
205
  });
199
206
  return;
200
207
  }
208
+ if (route === "/uf-test/browser") {
209
+ const origin = `http://127.0.0.1:${String(port)}`;
210
+ if (
211
+ incoming.method !== "POST" ||
212
+ incoming.headers.origin !== origin ||
213
+ incoming.headers.host !== `127.0.0.1:${String(port)}` ||
214
+ incoming.headers["uf-test-browser"] !== browserToken
215
+ ) {
216
+ reply(
217
+ outgoing,
218
+ 403,
219
+ "application/json",
220
+ JSON.stringify({ error: "invalid test browser request" }),
221
+ );
222
+ return;
223
+ }
224
+ readBody(incoming, (body) => {
225
+ void (async () => {
226
+ try {
227
+ const { id, method, args } = JSON.parse(body);
228
+ if (
229
+ typeof method !== "string" ||
230
+ !Array.isArray(args) ||
231
+ options.browserCommand == null
232
+ )
233
+ throw new Error("invalid browser command");
234
+ const value = await options.browserCommand(id, method, args);
235
+ reply(outgoing, 200, "application/json", JSON.stringify({ value }));
236
+ } catch (error) {
237
+ reply(
238
+ outgoing,
239
+ 400,
240
+ "application/json",
241
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
242
+ );
243
+ }
244
+ })();
245
+ });
246
+ return;
247
+ }
201
248
  if (route === "/uf-test/events") {
202
249
  readBody(incoming, (body) => {
203
250
  for (const event of parseEvents(body, options.warn)) {
@@ -324,6 +371,8 @@ async function serveFile(
324
371
  `${absolute} is outside this project and the packages it depends on, so the browser is not being handed it`,
325
372
  );
326
373
  }
374
+ if (/\.html$/.test(real))
375
+ return { body: readFileSync(real, "utf8"), type: "text/html; charset=utf-8" };
327
376
  if (!/\.(?:js|jsx|mjs|cjs)$/.test(real)) {
328
377
  // A JSON import, a stylesheet, an asset. Not this change: a page can have
329
378
  // them and `uf test --browser` has no opinion about what they should mean
@@ -582,13 +631,14 @@ export function moduleUrl(file: string): string {
582
631
  * differently — a layout answer from a quirks-mode page would be a wrong
583
632
  * answer wearing a real browser's authority.
584
633
  */
585
- function harnessHtml(map: string): string {
634
+ function harnessHtml(map: string, browserToken: string): string {
586
635
  const page = moduleUrl(fileURLToPath(new URL("./page.js", import.meta.url).href));
587
636
  return [
588
637
  "<!doctype html>",
589
638
  '<html lang="en">',
590
639
  '<meta charset="utf-8">',
591
640
  "<title>uf test</title>",
641
+ `<script>globalThis[Symbol.for("uf.test.browser.token")]=${JSON.stringify(browserToken)}</script>`,
592
642
  `<script type="importmap">${map}</script>`,
593
643
  `<script type="module" src="${page}"></script>`,
594
644
  "",
@@ -835,7 +885,9 @@ function browserField(entry: Package): { [string]: string } {
835
885
  // an empty module is a silence, and silence is what the whole `browser`
836
886
  // shim in this package exists not to be. Nothing uf ships uses it.
837
887
  if (typeof target !== "string") continue;
838
- substitutions[key] = target.startsWith(".") ? join(entry, target) : target;
888
+ substitutions[key.startsWith(".") ? join(entry, key) : key] = target.startsWith(".")
889
+ ? join(entry, target)
890
+ : target;
839
891
  }
840
892
  return substitutions;
841
893
  }
@@ -0,0 +1,2 @@
1
+ // @flow
2
+ export { launchBrowser as createTransport } from "./cdp.js";
@@ -0,0 +1,14 @@
1
+ // @flow
2
+ // The facade returns the outer uf worker's registry, not another test runner.
3
+ const environment: $FlowFixMe = globalThis;
4
+ const api = environment.__UF_NATIVE_TEST_API__;
5
+ export const test = api.test;
6
+ export const it = api.it;
7
+ export const describe = api.describe;
8
+ export const beforeAll = api.beforeAll;
9
+ export const afterAll = api.afterAll;
10
+ export const beforeEach = api.beforeEach;
11
+ export const afterEach = api.afterEach;
12
+ export const expect = api.expect;
13
+ export const fn = api.fn;
14
+ export const spyOn = api.spyOn;
@@ -0,0 +1,97 @@
1
+ // @flow
2
+ // Jest's module environment executes RN's own mocks. uf still owns discovery,
3
+ // scheduling, hooks, timeouts, isolation and the result protocol.
4
+
5
+ import { createRequire } from "node:module";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import * as tests from "../index.js";
9
+
10
+ // Optional, application-owned environment modules cross one dynamic boundary.
11
+ type EnvironmentModule = $FlowFixMe;
12
+
13
+ export async function loadNativeFile(file: string): Promise<() => Promise<void>> {
14
+ const rootDir = process.cwd();
15
+ const require = createRequire(path.join(rootDir, "package.json"));
16
+ const load = (name: string): EnvironmentModule => {
17
+ try {
18
+ return require(name);
19
+ } catch (cause) {
20
+ throw new Error(
21
+ `uf test (react-native): install ${name} in the application; the native environment could not load it`,
22
+ { cause },
23
+ );
24
+ }
25
+ };
26
+ const Runtime = load("jest-runtime").default;
27
+ const { readConfig } = load("jest-config");
28
+ const { createScriptTransformer } = load("@jest/transform");
29
+ const { jestExpect } = load("@jest/expect");
30
+ const { projectConfig: config, globalConfig } = await readConfig(
31
+ {
32
+ config: JSON.stringify({
33
+ rootDir,
34
+ preset: "@react-native/jest-preset",
35
+ cacheDirectory: path.join(rootDir, ".uf", "native-test-cache"),
36
+ moduleNameMapper: {
37
+ "^@uniflowed/test$": fileURLToPath(new URL("./native-globals.js", import.meta.url).href),
38
+ },
39
+ transform: {
40
+ "^.+\\.[jt]sx?$": require.resolve("@uniflowed/react-native/test-transformer.cjs"),
41
+ },
42
+ transformIgnorePatterns: [
43
+ "node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|@uniflowed|react-native-screens|react-native-safe-area-context)/)",
44
+ ],
45
+ }),
46
+ },
47
+ rootDir,
48
+ );
49
+ const context = await Runtime.createContext(config, {
50
+ console,
51
+ maxWorkers: 1,
52
+ watch: false,
53
+ watchman: false,
54
+ });
55
+ const Environment = load(config.testEnvironment);
56
+ const environment = new (Environment.TestEnvironment ?? Environment)(
57
+ { projectConfig: config, globalConfig },
58
+ { console, testPath: file, docblockPragmas: {} },
59
+ );
60
+ await environment.setup();
61
+ let runtime: EnvironmentModule = null;
62
+ let closed = false;
63
+ const close = async () => {
64
+ if (closed) return;
65
+ closed = true;
66
+ try {
67
+ runtime?.leaveTestCode();
68
+ runtime?.teardown();
69
+ } finally {
70
+ await environment.teardown();
71
+ }
72
+ };
73
+ try {
74
+ const transformer = await createScriptTransformer(config, new Map());
75
+ runtime = new Runtime(
76
+ config,
77
+ environment,
78
+ context.resolver,
79
+ transformer,
80
+ new Map(),
81
+ { collectCoverage: false, collectCoverageFrom: [], coverageProvider: "v8" },
82
+ file,
83
+ globalConfig,
84
+ );
85
+ const api = { ...tests, expect: jestExpect };
86
+ for (const [key, value] of Object.entries(api)) environment.global[key] = value;
87
+ environment.global.__UF_NATIVE_TEST_API__ = api;
88
+ runtime.setGlobalsForRuntime(api);
89
+ runtime.enterTestCode();
90
+ for (const setup of config.setupFiles) runtime.requireModule(setup);
91
+ runtime.requireModule(file);
92
+ return close;
93
+ } catch (error) {
94
+ await close();
95
+ throw error;
96
+ }
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/test",
3
- "version": "0.0.0-alpha.40",
3
+ "version": "0.0.0-alpha.41",
4
4
  "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,12 @@
18
18
  "./in-source": "./in-source.js",
19
19
  "./worker": "./worker.js",
20
20
  "./browser-worker": "./browser-worker.js",
21
- "./package.json": "./package.json"
21
+ "./package.json": "./package.json",
22
+ "./app": {
23
+ "browser": "./app-browser.js",
24
+ "default": "./app.js"
25
+ },
26
+ "./browser": "./browser.js"
22
27
  },
23
28
  "files": [
24
29
  "browser-worker.js",
@@ -27,10 +32,15 @@
27
32
  "index.js",
28
33
  "internal",
29
34
  "worker.js",
35
+ "app.js",
36
+ "browser.js",
37
+ "app-browser.js",
30
38
  "!*.test.js"
31
39
  ],
32
40
  "dependencies": {
33
- "@uniflowed/host": "0.0.0-alpha.40"
41
+ "@uniflowed/host": "0.0.0-alpha.41",
42
+ "pixelmatch": "^7.1.0",
43
+ "pngjs": "^7.0.0"
34
44
  },
35
45
  "peerDependencies": {
36
46
  "axe-core": ">=4"
@@ -46,6 +56,7 @@
46
56
  "node:module": "./internal/browser/node.js",
47
57
  "node:path": "./internal/browser/node.js",
48
58
  "node:url": "./internal/browser/node.js",
49
- "node:util": "./internal/browser/node.js"
59
+ "node:util": "./internal/browser/node.js",
60
+ "./internal/browser/transport.js": "./internal/browser/page-transport.js"
50
61
  }
51
62
  }
package/worker.js CHANGED
@@ -218,8 +218,14 @@ async function runImportedFile(
218
218
  url: string,
219
219
  started: number,
220
220
  ): Promise<void> {
221
+ let closeNative: (() => Promise<void>) | null = null;
221
222
  try {
222
- await import(`${url}?uf-run=${generation}`);
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
+ }
223
229
  } catch (thrown) {
224
230
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
225
231
  write({
@@ -256,12 +262,18 @@ async function runImportedFile(
256
262
  // with forty snapshots would otherwise rewrite its snapshot file forty
257
263
  // times, and a crash halfway through would leave a partial one.
258
264
  writeChangedSnapshots();
265
+ await closeNative?.();
259
266
  write({
260
267
  event: "file",
261
268
  status: "completed",
262
269
  durationMicros: Math.round((performance.now() - started) * 1000),
263
270
  });
264
271
  } catch (thrown) {
272
+ try {
273
+ await closeNative?.();
274
+ } catch {
275
+ // Preserve the original run or teardown failure.
276
+ }
265
277
  const error = thrown instanceof Error ? thrown : new Error(String(thrown));
266
278
  write({
267
279
  event: "file",