@datamoc/mw_games 0.25.0 → 0.26.1

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +15 -0
  3. package/api-surface.json +14468 -0
  4. package/dist/actors/StatBlock.d.ts +2 -4
  5. package/dist/actors/StatBlock.d.ts.map +1 -1
  6. package/dist/actors/StatBlock.js +2 -4
  7. package/dist/actors/StatBlock.js.map +1 -1
  8. package/dist/ai/Heuristics.d.ts +1 -1
  9. package/dist/ai/Heuristics.js +1 -1
  10. package/dist/assets/index.d.ts +1 -0
  11. package/dist/assets/index.d.ts.map +1 -1
  12. package/dist/assets/index.js.map +1 -1
  13. package/dist/core/Blob.d.ts.map +1 -1
  14. package/dist/core/Blob.js +2 -8
  15. package/dist/core/Blob.js.map +1 -1
  16. package/dist/core/Grid.d.ts +115 -0
  17. package/dist/core/Grid.d.ts.map +1 -1
  18. package/dist/core/Grid.js +125 -0
  19. package/dist/core/Grid.js.map +1 -1
  20. package/dist/core/HttpTransport.d.ts +14 -0
  21. package/dist/core/HttpTransport.d.ts.map +1 -1
  22. package/dist/core/HttpTransport.js +14 -0
  23. package/dist/core/HttpTransport.js.map +1 -1
  24. package/dist/core/Random.d.ts +36 -13
  25. package/dist/core/Random.d.ts.map +1 -1
  26. package/dist/core/Random.js +28 -25
  27. package/dist/core/Random.js.map +1 -1
  28. package/dist/core/index.d.ts +6 -4
  29. package/dist/core/index.d.ts.map +1 -1
  30. package/dist/core/index.js +2 -1
  31. package/dist/core/index.js.map +1 -1
  32. package/dist/headless/index.d.ts +87 -0
  33. package/dist/headless/index.d.ts.map +1 -0
  34. package/dist/headless/index.js +87 -0
  35. package/dist/headless/index.js.map +1 -0
  36. package/dist/mw_games.global.js +113 -113
  37. package/dist/mwl/index.d.ts +1 -1
  38. package/dist/mwl/index.d.ts.map +1 -1
  39. package/dist/mwl/index.js.map +1 -1
  40. package/dist/roguelike/Level.d.ts +1 -1
  41. package/dist/roguelike/Level.d.ts.map +1 -1
  42. package/dist/roguelike/Level.js +3 -21
  43. package/dist/roguelike/Level.js.map +1 -1
  44. package/dist/roguelike/Targeting.d.ts.map +1 -1
  45. package/dist/roguelike/Targeting.js +2 -1
  46. package/dist/roguelike/Targeting.js.map +1 -1
  47. package/dist/rpg/index.d.ts +1 -1
  48. package/dist/rpg/index.d.ts.map +1 -1
  49. package/dist/two-d/pixi-interop.d.ts +24 -1
  50. package/dist/two-d/pixi-interop.d.ts.map +1 -1
  51. package/dist/two-d/pixi-interop.js +23 -1
  52. package/dist/two-d/pixi-interop.js.map +1 -1
  53. package/dist/two-d/render/index.d.ts +2 -2
  54. package/dist/two-d/render/index.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +16 -3
  58. package/sbom.cdx.json +6 -6
  59. package/tools/audit-adoptions.d.mts +65 -0
  60. package/tools/audit-adoptions.mjs +252 -0
  61. package/tools/browser-driver.d.mts +78 -0
  62. package/tools/browser-driver.mjs +642 -0
@@ -0,0 +1,642 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { extname, join, normalize, resolve, sep } from 'node:path';
7
+ import { createServer as createNetServer } from 'node:net';
8
+
9
+ /**
10
+ * The dependency-free browser driver: open a page, tap it, evaluate against it,
11
+ * screenshot it, and collect its console errors - with no Playwright install, over
12
+ * `node:http`, `node:net`, `node:child_process` and the global `WebSocket` only.
13
+ *
14
+ * `tools/browser-smoke` answers "did it render" through `playwright-core` and only
15
+ * from a `file://` page, with one probe expression and an `after` hook for anything
16
+ * more. Anything interactive - tapping through a title screen, evaluating scene state
17
+ * mid-run, running a consumer-authored check script against the live game, across two
18
+ * engines - gets this driver instead. The game-facing surface stays generic
19
+ * (`goto`/`waitReady`/`tap`/`evaluate`/`script`/`screenshot`/`consoleErrors`/`close`);
20
+ * anything game-specific is a consumer script run through `evaluate`/`script`.
21
+ *
22
+ * Readiness reuses `browser-smoke`'s contract (`window.__MWG__`/`window.__MWG_3D__`
23
+ * plus a sized canvas, 5 s), and deliberately not its pixels: font hinting and
24
+ * anti-aliasing differ between machines, so a pixel-diff baseline fails for the
25
+ * renderer rather than the change.
26
+ *
27
+ * Engines: Chrome over CDP (`driveChrome`) and Firefox over WebDriver BiDi
28
+ * (`driveFirefox`). Both are verified live where the browsers exist; where neither
29
+ * does, the server/port/protocol-framing units below still run - see
30
+ * `tests/browser-driver.test.ts`.
31
+ */
32
+
33
+ const READY_TIMEOUT_MS = 5000;
34
+
35
+ /** the readiness predicate `browser-smoke`'s `waitForGame` waits on, as page JS */
36
+ export function readinessExpression() {
37
+ return '(() => { const canvas = document.querySelector("canvas"); return Boolean((window.__MWG__ || window.__MWG_3D__) && canvas && canvas.width > 0 && canvas.height > 0); })()';
38
+ }
39
+
40
+ /** an OS-chosen free port, so Windows' excluded-port ranges cannot bite */
41
+ export function pickFreePort() {
42
+ return new Promise((resolvePort, reject) => {
43
+ const server = createNetServer();
44
+ server.once('error', reject);
45
+ server.listen(0, '127.0.0.1', () => {
46
+ const port = server.address().port;
47
+ server.close((error) => (error ? reject(error) : resolvePort(port)));
48
+ });
49
+ });
50
+ }
51
+
52
+ const CONTENT_TYPES = new Map([
53
+ ['.html', 'text/html; charset=utf-8'],
54
+ ['.js', 'text/javascript; charset=utf-8'],
55
+ ['.mjs', 'text/javascript; charset=utf-8'],
56
+ ['.css', 'text/css; charset=utf-8'],
57
+ ['.json', 'application/json; charset=utf-8'],
58
+ ['.png', 'image/png'],
59
+ ['.jpg', 'image/jpeg'],
60
+ ['.webp', 'image/webp'],
61
+ ['.svg', 'image/svg+xml'],
62
+ ['.wasm', 'application/wasm'],
63
+ ['.mp3', 'audio/mpeg'],
64
+ ['.ogg', 'audio/ogg'],
65
+ ['.wav', 'audio/wav'],
66
+ ]);
67
+
68
+ /**
69
+ * Serves `root` over HTTP on an OS-chosen free port: `url` is the base to open pages
70
+ * from, `close()` stops the server. Path traversal (`..`) is refused, directories
71
+ * resolve to their `index.html`, and unknown suffixes are served as bytes.
72
+ */
73
+ export async function serveDir(root) {
74
+ const base = realpathSync(root);
75
+ const server = createServer((request, response) => {
76
+ try {
77
+ const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
78
+ let file = normalize(join(base, pathname));
79
+ if (file !== base && !file.startsWith(base + sep)) {
80
+ response.writeHead(403, { 'content-type': 'text/plain' });
81
+ response.end('forbidden');
82
+ return;
83
+ }
84
+ if (statSync(file, { throwIfNoEntry: false })?.isDirectory()) file = join(file, 'index.html');
85
+ const bytes = readFileSync(file);
86
+ response.writeHead(200, {
87
+ 'content-type': CONTENT_TYPES.get(extname(file).toLowerCase()) ?? 'application/octet-stream',
88
+ });
89
+ response.end(bytes);
90
+ } catch {
91
+ response.writeHead(404, { 'content-type': 'text/plain' });
92
+ response.end('not found');
93
+ }
94
+ });
95
+
96
+ const port = await pickFreePort();
97
+ await new Promise((resolveReady, reject) => {
98
+ server.once('error', reject);
99
+ server.listen(port, '127.0.0.1', () => resolveReady());
100
+ });
101
+
102
+ const sockets = new Set();
103
+ server.on('connection', (socket) => {
104
+ sockets.add(socket);
105
+ socket.once('close', () => sockets.delete(socket));
106
+ });
107
+
108
+ return {
109
+ url: `http://127.0.0.1:${port}/`,
110
+ port,
111
+ close: () =>
112
+ new Promise((resolveClosed, rejectClose) => {
113
+ //idle keep-alive connections otherwise hold `close()` open indefinitely
114
+ for (const socket of sockets) socket.destroy();
115
+ server.close((error) => (error ? rejectClose(error) : resolveClosed()));
116
+ }),
117
+ };
118
+ }
119
+
120
+ /** the `ws://` endpoint Chrome prints as `DevTools listening on ...`, or null */
121
+ export function parseDevToolsEndpoint(stderr) {
122
+ const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
123
+ return match ? match[1] : null;
124
+ }
125
+
126
+ /**
127
+ * The `ws://` endpoint Firefox prints as `WebDriver BiDi listening on ...`, or null.
128
+ * Commands go to its `/session` path: the bare printed URL opens no session.
129
+ */
130
+ export function parseBidiEndpoint(stderr) {
131
+ const match = stderr.match(/WebDriver BiDi listening on (ws:\/\/[^\s]+)/);
132
+ if (!match) return null;
133
+ return match[1].endsWith('/session') ? match[1] : `${match[1].replace(/\/$/, '')}/session`;
134
+ }
135
+
136
+ /**
137
+ * The CDP `Input.dispatchMouseEvent` sequence a tap is: move, press, release, at CSS
138
+ * pixels, with the button state each step carries. Pure so the suite can pin the
139
+ * coordinates without a browser.
140
+ */
141
+ export function buildTapSequence(x, y) {
142
+ return [
143
+ { type: 'mouseMoved', x, y, button: 'none', buttons: 0 },
144
+ { type: 'mousePressed', x, y, button: 'left', buttonValue: 0, clickCount: 1, buttons: 1 },
145
+ { type: 'mouseReleased', x, y, button: 'left', buttonValue: 0, clickCount: 1, buttons: 0 },
146
+ ];
147
+ }
148
+
149
+ /**
150
+ * Unwraps a WebDriver BiDi remote value into plain JSON: primitives read off
151
+ * `.value`, arrays unwrap element-wise, objects/maps unwrap their key/value pairs,
152
+ * sets unwrap to arrays. Handles (nodes, windows) carry no value and unwrap to
153
+ * `undefined` - evaluate for a primitive or a plain-data shape instead.
154
+ */
155
+ export function unwrapRemoteValue(node) {
156
+ if (node === null || typeof node !== 'object') return node;
157
+ if (Array.isArray(node)) return node.map(unwrapRemoteValue);
158
+ const { type, value } = node;
159
+ switch (type) {
160
+ case 'array':
161
+ return (value ?? []).map(unwrapRemoteValue);
162
+ case 'object':
163
+ case 'map':
164
+ return Object.fromEntries((value ?? []).map(([key, item]) => [key, unwrapRemoteValue(item)]));
165
+ case 'set':
166
+ return (value ?? []).map(unwrapRemoteValue);
167
+ case 'undefined':
168
+ return undefined;
169
+ case 'null':
170
+ return null;
171
+ case 'nan':
172
+ return NaN;
173
+ case 'infinity':
174
+ return Infinity;
175
+ case '-infinity':
176
+ return -Infinity;
177
+ case '-0':
178
+ return -0;
179
+ case 'bigint':
180
+ return typeof value === 'string' ? BigInt(value) : value;
181
+ default:
182
+ return value;
183
+ }
184
+ }
185
+
186
+ /** the BiDi `input.performActions` pointer half of a tap, at CSS pixels */
187
+ export function buildPointerActions(x, y) {
188
+ return [
189
+ {
190
+ type: 'pointer',
191
+ id: 'mwg-mouse',
192
+ parameters: { pointerType: 'mouse' },
193
+ actions: [
194
+ { type: 'pointerMove', x, y },
195
+ { type: 'pointerDown', button: 0 },
196
+ { type: 'pointerUp', button: 0 },
197
+ ],
198
+ },
199
+ ];
200
+ }
201
+
202
+ function findOnPath(names) {
203
+ const locator = process.platform === 'win32' ? 'where' : 'which';
204
+ for (const name of names) {
205
+ try {
206
+ const found = spawnSync(locator, [name], { encoding: 'utf8', shell: process.platform === 'win32' });
207
+ const candidate = (found.stdout ?? '').split('\n')[0].trim();
208
+ if (candidate) return candidate;
209
+ } catch {
210
+ continue;
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * Locates a browser executable without a driver install: the `CHROME_PATH` /
218
+ * `FIREFOX_PATH` override first, then well-known install locations. Returns null
219
+ * when neither yields anything, so callers skip rather than fail.
220
+ */
221
+ export function findBrowser(kind) {
222
+ const override = process.env[kind === 'chrome' ? 'CHROME_PATH' : 'FIREFOX_PATH'];
223
+ if (override) return override;
224
+ const onPath = findOnPath(kind === 'chrome' ? ['chrome', 'chromium', 'google-chrome'] : ['firefox']);
225
+ if (onPath) return onPath;
226
+
227
+ const candidates =
228
+ kind === 'chrome'
229
+ ? [
230
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
231
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
232
+ '/usr/bin/google-chrome',
233
+ '/usr/bin/chromium',
234
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
235
+ ]
236
+ : [
237
+ 'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
238
+ 'C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe',
239
+ '/usr/bin/firefox',
240
+ '/Applications/Firefox.app/Contents/MacOS/firefox',
241
+ ];
242
+ try {
243
+ for (const candidate of candidates) {
244
+ if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
245
+ }
246
+ } catch {
247
+ return null;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Waits for `match` in the child's output, watching both stdout and stderr: which of the
254
+ * two a given browser build prints its debugging endpoint on is not something to bet on.
255
+ * The timeout message carries what was seen, because "no endpoint" alone cannot tell a
256
+ * slow cold start from a browser that started and refused to open one.
257
+ */
258
+ function waitForEndpoint(child, match, timeoutMs = 30000) {
259
+ return new Promise((resolveEndpoint, reject) => {
260
+ let seen = '';
261
+ const timer = setTimeout(() => {
262
+ child.kill();
263
+ reject(
264
+ new Error(
265
+ `browser printed no debugging endpoint within ${timeoutMs} ms; output so far:\n${seen || '(none)'}`,
266
+ ),
267
+ );
268
+ }, timeoutMs);
269
+ const onChunk = (chunk) => {
270
+ seen += chunk.toString();
271
+ const endpoint = match(seen);
272
+ if (endpoint) {
273
+ clearTimeout(timer);
274
+ resolveEndpoint({ endpoint, seen });
275
+ }
276
+ };
277
+ child.stdout?.on('data', onChunk);
278
+ child.stderr?.on('data', onChunk);
279
+ child.once('error', (error) => {
280
+ clearTimeout(timer);
281
+ reject(error);
282
+ });
283
+ child.once('exit', () => {
284
+ clearTimeout(timer);
285
+ reject(new Error(`browser exited before printing a debugging endpoint:\n${seen}`));
286
+ });
287
+ });
288
+ }
289
+
290
+ /** a JSON-RPC channel over one WebSocket: `send(method, params)` awaits its reply, `onEvent` receives the rest */
291
+ function cdpChannel(socket) {
292
+ let nextId = 1;
293
+ const pending = new Map();
294
+ const listeners = new Map();
295
+
296
+ socket.addEventListener('message', (event) => {
297
+ const frame = JSON.parse(event.data.toString());
298
+ if (frame.id !== undefined && pending.has(frame.id)) {
299
+ const { resolveFrame, rejectFrame } = pending.get(frame.id);
300
+ pending.delete(frame.id);
301
+ if (frame.error) rejectFrame(new Error(`CDP ${frame.error.message ?? JSON.stringify(frame.error)}`));
302
+ else resolveFrame(frame.result ?? {});
303
+ } else if (frame.method) {
304
+ for (const listener of listeners.get(frame.method) ?? []) listener(frame.params ?? {});
305
+ }
306
+ });
307
+
308
+ return {
309
+ send: (method, params = {}) =>
310
+ withTimeout(
311
+ new Promise((resolveFrame, rejectFrame) => {
312
+ const id = nextId++;
313
+ pending.set(id, { resolveFrame, rejectFrame });
314
+ socket.send(JSON.stringify({ id, method, params }));
315
+ }),
316
+ 20000,
317
+ `CDP ${method} timed out`,
318
+ ),
319
+ onEvent: (method, listener) => {
320
+ if (!listeners.has(method)) listeners.set(method, []);
321
+ listeners.get(method).push(listener);
322
+ },
323
+ };
324
+ }
325
+
326
+ function openSocket(url, timeoutMs = 10000) {
327
+ return new Promise((resolveSocket, reject) => {
328
+ const socket = new WebSocket(url);
329
+ const timer = setTimeout(() => {
330
+ try {
331
+ socket.close();
332
+ } catch {
333
+ // already gone; the rejection below is what matters
334
+ }
335
+ reject(new Error(`cannot open ${url} within ${timeoutMs} ms`));
336
+ }, timeoutMs);
337
+ socket.addEventListener('error', () => reject(new Error(`cannot open ${url}`)), { once: true });
338
+ socket.addEventListener(
339
+ 'open',
340
+ () => {
341
+ clearTimeout(timer);
342
+ socket.addEventListener('error', () => {});
343
+ resolveSocket(socket);
344
+ },
345
+ { once: true },
346
+ );
347
+ });
348
+ }
349
+
350
+ /** rejects when `work` takes longer than `ms`: no protocol send may hang a suite forever */
351
+ function withTimeout(work, ms, message) {
352
+ let timer;
353
+ const timeout = new Promise((_, reject) => {
354
+ timer = setTimeout(() => reject(new Error(message)), ms);
355
+ });
356
+ //the loser must not hold the event loop: a suite that finished its work exits
357
+ timer.unref?.();
358
+ return Promise.race([work.finally(() => clearTimeout(timer)), timeout]);
359
+ }
360
+
361
+ function cdpTextOf(args) {
362
+ return args
363
+ .map((arg) => {
364
+ if (typeof arg?.value === 'string') return arg.value;
365
+ if (typeof arg?.description === 'string') return arg.description;
366
+ return JSON.stringify(arg?.value ?? null);
367
+ })
368
+ .join(' ');
369
+ }
370
+
371
+ /**
372
+ * Drives headless Chrome over CDP. The game-facing half of P32: `goto` a served page,
373
+ * `waitReady` for the `browser-smoke` contract, `tap` at canvas fractions with the
374
+ * full pointer sequence, `evaluate`/`script` against the live page, `screenshot` to
375
+ * a PNG buffer, `consoleErrors` across console/page-error channels, `close`. The
376
+ * browser runs against its own profile (`options.profileDir`, or a temp dir this
377
+ * driver creates and removes on `close`), never the machine's default one.
378
+ */
379
+ /**
380
+ * Polls the shared readiness predicate through either engine's `evaluate` until it
381
+ * reads true: the `browser-smoke` contract (`window.__MWG__`/`window.__MWG_3D__` plus
382
+ * a sized canvas), with the same timeout and failure shape on both.
383
+ */
384
+ async function waitForReadyPage(evaluate, consoleErrors, url, timeoutMs = READY_TIMEOUT_MS) {
385
+ const started = Date.now();
386
+ for (;;) {
387
+ if ((await evaluate(readinessExpression())) === true) return;
388
+ if (Date.now() - started > timeoutMs) {
389
+ const errors = consoleErrors();
390
+ const suffix = errors.length ? `; page errors: ${errors.join('; ')}` : '';
391
+ throw new Error(
392
+ `no mwg game became ready within ${Math.round(timeoutMs / 1000)} s on ${url} (window.__MWG__ and a sized canvas)${suffix}`,
393
+ );
394
+ }
395
+ await new Promise((tick) => setTimeout(tick, 100));
396
+ }
397
+ }
398
+
399
+ /** runs a consumer function against the live page, awaiting it when it is async */
400
+ function runPageScript(evaluate, fn, args) {
401
+ return evaluate(`(${fn.toString()})(...${JSON.stringify(args)})`, true);
402
+ }
403
+
404
+ export async function driveChrome(options = {}) {
405
+ const executable = options.executable ?? findBrowser('chrome');
406
+ const debuggingPort = await pickFreePort();
407
+ if (!executable) throw new Error('no Chrome executable found (set CHROME_PATH)');
408
+ const extraArgs = (process.env.MWG_DRIVER_CHROME_ARGS ?? '').split(' ').filter(Boolean);
409
+
410
+ //a dedicated profile, never the machine's own default one: a desktop Chrome already
411
+ //running on it (a dev machine) or a first-ever start against it (a cold CI runner) can
412
+ //hold the endpoint past any reasonable startup budget, and Chrome has said the default
413
+ //profile is the one configuration whose debugging port it may refuse. Fresh temp dir
414
+ //unless the caller owns the profile, and then it is the caller's to remove.
415
+ const ownsProfile = !options.profileDir;
416
+ const profileDir = resolve(options.profileDir ?? mkdtempSync(join(tmpdir(), 'mwg-chrome-')));
417
+ const child = spawn(
418
+ executable,
419
+ [
420
+ '--headless',
421
+ '--no-first-run',
422
+ '--no-default-browser-check',
423
+ '--disable-extensions',
424
+ '--allow-file-access-from-files',
425
+ `--user-data-dir=${profileDir}`,
426
+ `--remote-debugging-port=${debuggingPort}`,
427
+ '--remote-allow-origins=*',
428
+ ...(options.chromeArgs ?? extraArgs),
429
+ 'about:blank',
430
+ ],
431
+ { stdio: ['ignore', 'pipe', 'pipe'] },
432
+ );
433
+ await waitForEndpoint(child, parseDevToolsEndpoint);
434
+
435
+ const target = await (
436
+ await fetch(`http://127.0.0.1:${debuggingPort}/json/new?about:blank`, { method: 'PUT' })
437
+ ).json();
438
+ const socket = await openSocket(target.webSocketDebuggerUrl);
439
+ const cdp = cdpChannel(socket);
440
+ const errors = [];
441
+ cdp.onEvent('Runtime.consoleAPICalled', (params) => {
442
+ if (params.type === 'error') errors.push(cdpTextOf(params.args ?? []));
443
+ });
444
+ cdp.onEvent('Log.entryAdded', (params) => {
445
+ if (params.entry?.level === 'error') errors.push(params.entry.text ?? JSON.stringify(params.entry));
446
+ });
447
+ cdp.onEvent('Runtime.exceptionThrown', (params) => {
448
+ errors.push(params.exceptionDetails?.text ?? params.exceptionDetails?.exception?.description ?? 'exception');
449
+ });
450
+ await cdp.send('Page.enable');
451
+ await cdp.send('Runtime.enable');
452
+ await cdp.send('Log.enable');
453
+
454
+ let closed = false;
455
+ const evaluate = async (expression, awaitPromise = false) => {
456
+ const { result } = await cdp.send('Runtime.evaluate', {
457
+ expression: `(${expression})`,
458
+ returnByValue: true,
459
+ awaitPromise,
460
+ });
461
+ if (result?.subtype === 'error' || result?.type === 'error') {
462
+ throw new Error(`page evaluation threw: ${result.description ?? expression}`);
463
+ }
464
+ return result?.value;
465
+ };
466
+
467
+ return {
468
+ kind: 'chrome-cdp',
469
+ goto: (url) => cdp.send('Page.navigate', { url }).then(() => undefined),
470
+ waitReady: (url, timeoutMs) => waitForReadyPage(evaluate, () => errors, url, timeoutMs),
471
+ evaluate,
472
+ script: (fn, ...args) => runPageScript(evaluate, fn, args),
473
+ tap: async (xFraction, yFraction) => {
474
+ const point = await evaluate(
475
+ '(() => { const box = document.querySelector("canvas").getBoundingClientRect(); return { x: box.x, y: box.y, w: box.width, h: box.height }; })()',
476
+ );
477
+ const x = Math.round(point.x + point.w * xFraction);
478
+ const y = Math.round(point.y + point.h * yFraction);
479
+ for (const event of buildTapSequence(x, y)) {
480
+ await cdp.send('Input.dispatchMouseEvent', event);
481
+ }
482
+ },
483
+ screenshot: async () => {
484
+ const { data } = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true });
485
+ return Buffer.from(data, 'base64');
486
+ },
487
+ consoleErrors: () => [...errors],
488
+ close: async () => {
489
+ if (closed) return;
490
+ closed = true;
491
+ try {
492
+ await withTimeout(cdp.send('Browser.close'), 5000, 'Browser.close timed out');
493
+ } catch {
494
+ // already gone; the kill below is the backstop
495
+ }
496
+ try {
497
+ socket.close();
498
+ } catch {
499
+ // already closed
500
+ }
501
+ child.kill();
502
+ //the pipes must go: a browser's surviving helper processes can hold them (and so
503
+ //the whole suite) open past the main process's death
504
+ child.stdout?.destroy();
505
+ child.stderr?.destroy();
506
+ if (ownsProfile) {
507
+ //a browser releases its profile files briefly after the kill, so wait for
508
+ //the exit and retry: a profile left behind is clutter, not a failure
509
+ await new Promise((done) => {
510
+ if (child.exitCode !== null) return done();
511
+ const giveUp = setTimeout(done, 2000);
512
+ giveUp.unref?.();
513
+ child.once('exit', () => {
514
+ clearTimeout(giveUp);
515
+ done();
516
+ });
517
+ });
518
+ for (let attempt = 0; attempt < 5; attempt++) {
519
+ try {
520
+ rmSync(profileDir, { recursive: true, force: true });
521
+ return;
522
+ } catch {
523
+ await new Promise((tick) => setTimeout(tick, 100));
524
+ }
525
+ }
526
+ }
527
+ },
528
+ };
529
+ }
530
+
531
+ /**
532
+ * Drives headless Firefox over WebDriver BiDi: the same game-facing surface as
533
+ * `driveChrome` (`goto`/`waitReady`/`tap`/`evaluate`/`script`/`screenshot`/
534
+ * `consoleErrors`/`close`), so a consumer script runs unchanged on either engine.
535
+ */
536
+ export async function driveFirefox(options = {}) {
537
+ const executable = options.executable ?? findBrowser('firefox');
538
+ if (!executable) throw new Error('no Firefox executable found (set FIREFOX_PATH)');
539
+
540
+ const profile = resolve(options.profileDir ?? join(process.cwd(), '.example-check', 'firefox-profile'));
541
+ const bidiPort = await pickFreePort();
542
+ const child = spawn(
543
+ executable,
544
+ [
545
+ '--headless',
546
+ '--no-remote',
547
+ '--profile',
548
+ profile,
549
+ '--remote-debugging-port',
550
+ String(bidiPort),
551
+ ...(options.firefoxArgs ?? []),
552
+ 'about:blank',
553
+ ],
554
+ { stdio: ['ignore', 'ignore', 'pipe'] },
555
+ );
556
+ const { endpoint } = await waitForEndpoint(child, parseBidiEndpoint);
557
+ const socket = await openSocket(endpoint);
558
+ let nextId = 1;
559
+ const pending = new Map();
560
+ const listeners = [];
561
+ socket.addEventListener('message', (event) => {
562
+ const frame = JSON.parse(event.data.toString());
563
+ if (frame.id !== undefined && pending.has(frame.id)) {
564
+ const { resolveFrame, rejectFrame } = pending.get(frame.id);
565
+ pending.delete(frame.id);
566
+ if (frame.type === 'error') rejectFrame(new Error(`BiDi ${frame.message ?? JSON.stringify(frame)}`));
567
+ else resolveFrame(frame.result ?? {});
568
+ } else if (frame.type === 'event') {
569
+ for (const listener of listeners) listener(frame);
570
+ }
571
+ });
572
+ const send = (method, params = {}, session) =>
573
+ withTimeout(
574
+ new Promise((resolveFrame, rejectFrame) => {
575
+ const id = nextId++;
576
+ pending.set(id, { resolveFrame, rejectFrame });
577
+ socket.send(JSON.stringify({ id, type: 'command', method, params, ...(session ? { session } : {}) }));
578
+ }),
579
+ 20000,
580
+ `BiDi ${method} timed out`,
581
+ );
582
+
583
+ const { sessionId } = await send('session.new', { capabilities: {} });
584
+ const errors = [];
585
+ listeners.push((frame) => {
586
+ if (frame.method === 'log.entryAdded' && frame.params?.level === 'error') {
587
+ errors.push(frame.params.text ?? JSON.stringify(frame.params));
588
+ }
589
+ });
590
+ await send('session.subscribe', { events: ['log.entryAdded'] }, sessionId);
591
+ const { context } = await send('browsingContext.create', { type: 'tab' }, sessionId);
592
+
593
+ const evaluate = async (expression, awaitPromise = false) => {
594
+ const outer = await send(
595
+ 'script.evaluate',
596
+ { expression: `(${expression})`, target: { context }, awaitPromise, resultOwnership: 'root' },
597
+ sessionId,
598
+ );
599
+ if (outer?.type === 'exception') {
600
+ throw new Error(`page evaluation threw: ${outer.exceptionDetails?.text ?? expression}`);
601
+ }
602
+ return unwrapRemoteValue(outer?.result);
603
+ };
604
+
605
+ let closed = false;
606
+ return {
607
+ kind: 'firefox-bidi',
608
+ goto: (url) =>
609
+ send('browsingContext.navigate', { context, url, wait: 'complete' }, sessionId).then(() => undefined),
610
+ waitReady: (url, timeoutMs) => waitForReadyPage(evaluate, () => errors, url, timeoutMs),
611
+ evaluate,
612
+ script: (fn, ...args) => runPageScript(evaluate, fn, args),
613
+ tap: async (xFraction, yFraction) => {
614
+ const point = await evaluate(
615
+ '(() => { const box = document.querySelector("canvas").getBoundingClientRect(); return { x: box.x, y: box.y, w: box.width, h: box.height }; })()',
616
+ );
617
+ const x = Math.round(point.x + point.w * xFraction);
618
+ const y = Math.round(point.y + point.h * yFraction);
619
+ await send('input.performActions', { context, actions: buildPointerActions(x, y) }, sessionId);
620
+ },
621
+ screenshot: async () => {
622
+ const { data } = await send('browsingContext.captureScreenshot', { context }, sessionId);
623
+ return Buffer.from(data, 'base64');
624
+ },
625
+ consoleErrors: () => [...errors],
626
+ close: async () => {
627
+ if (closed) return;
628
+ closed = true;
629
+ try {
630
+ await withTimeout(send('session.end', {}, sessionId), 5000, 'session.end timed out');
631
+ } catch {
632
+ // already gone; the kill below is the backstop
633
+ }
634
+ try {
635
+ socket.close();
636
+ } catch {
637
+ // already closed
638
+ }
639
+ child.kill();
640
+ },
641
+ };
642
+ }