@uniflowed/test 0.0.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app-browser.js +6 -0
- package/app.js +175 -0
- package/browser-worker.js +334 -0
- package/browser.js +59 -0
- package/bun/index.js +305 -0
- package/in-source.js +169 -0
- package/index.js +11 -1
- package/internal/axe.js +362 -0
- package/internal/browser/cdp.js +364 -0
- package/internal/browser/node.js +375 -0
- package/internal/browser/page-transport.js +36 -0
- package/internal/browser/page.js +258 -0
- package/internal/browser/screenshots.js +102 -0
- package/internal/browser/server.js +893 -0
- package/internal/browser/transport.js +2 -0
- package/internal/expect.js +506 -100
- package/internal/frames.js +178 -0
- package/internal/isolation.js +231 -0
- package/internal/namespace.js +22 -10
- package/internal/native-globals.js +14 -0
- package/internal/native-host.js +97 -0
- package/internal/output.js +25 -7
- package/internal/registry.js +102 -17
- package/internal/run.js +159 -24
- package/internal/timers.js +2 -2
- package/package.json +41 -5
- package/worker.js +115 -10
|
@@ -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
|
+
}
|