@uniflowed/test 0.0.0-alpha.40 → 0.0.0-alpha.42
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 +22 -27
- package/browser.js +59 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +55 -3
- package/internal/browser/transport.js +2 -0
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/package.json +15 -4
- package/worker.js +13 -1
package/app-browser.js
ADDED
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
|
-
// #
|
|
25
|
+
// # Browser control
|
|
26
26
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
//
|
|
108
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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,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.
|
|
3
|
+
"version": "0.0.0-alpha.42",
|
|
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.
|
|
41
|
+
"@uniflowed/host": "0.0.0-alpha.42",
|
|
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
|
-
|
|
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",
|