@datamoc/mw_games 0.25.0 → 0.27.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/CHANGELOG.md +192 -0
- package/README.md +15 -0
- package/api-surface.json +14942 -0
- package/dist/actors/StatBlock.d.ts +2 -4
- package/dist/actors/StatBlock.d.ts.map +1 -1
- package/dist/actors/StatBlock.js +2 -4
- package/dist/actors/StatBlock.js.map +1 -1
- package/dist/ai/Heuristics.d.ts +1 -1
- package/dist/ai/Heuristics.js +1 -1
- package/dist/ai/Suggest.d.ts +54 -0
- package/dist/ai/Suggest.d.ts.map +1 -0
- package/dist/ai/Suggest.js +48 -0
- package/dist/ai/Suggest.js.map +1 -0
- package/dist/ai/index.d.ts +2 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +1 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/assets/index.d.ts +1 -0
- package/dist/assets/index.d.ts.map +1 -1
- package/dist/assets/index.js.map +1 -1
- package/dist/core/Blob.d.ts.map +1 -1
- package/dist/core/Blob.js +2 -8
- package/dist/core/Blob.js.map +1 -1
- package/dist/core/Grid.d.ts +115 -0
- package/dist/core/Grid.d.ts.map +1 -1
- package/dist/core/Grid.js +125 -0
- package/dist/core/Grid.js.map +1 -1
- package/dist/core/HttpTransport.d.ts +14 -0
- package/dist/core/HttpTransport.d.ts.map +1 -1
- package/dist/core/HttpTransport.js +14 -0
- package/dist/core/HttpTransport.js.map +1 -1
- package/dist/core/Random.d.ts +63 -13
- package/dist/core/Random.d.ts.map +1 -1
- package/dist/core/Random.js +28 -25
- package/dist/core/Random.js.map +1 -1
- package/dist/core/Replay.d.ts +28 -4
- package/dist/core/Replay.d.ts.map +1 -1
- package/dist/core/Replay.js +38 -9
- package/dist/core/Replay.js.map +1 -1
- package/dist/core/ReplayFile.d.ts +197 -0
- package/dist/core/ReplayFile.d.ts.map +1 -0
- package/dist/core/ReplayFile.js +257 -0
- package/dist/core/ReplayFile.js.map +1 -0
- package/dist/core/index.d.ts +9 -5
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +4 -2
- package/dist/core/index.js.map +1 -1
- package/dist/headless/index.d.ts +87 -0
- package/dist/headless/index.d.ts.map +1 -0
- package/dist/headless/index.js +87 -0
- package/dist/headless/index.js.map +1 -0
- package/dist/mw_games.global.js +113 -113
- package/dist/mwl/index.d.ts +1 -1
- package/dist/mwl/index.d.ts.map +1 -1
- package/dist/mwl/index.js.map +1 -1
- package/dist/roguelike/Level.d.ts +1 -1
- package/dist/roguelike/Level.d.ts.map +1 -1
- package/dist/roguelike/Level.js +3 -21
- package/dist/roguelike/Level.js.map +1 -1
- package/dist/roguelike/Targeting.d.ts.map +1 -1
- package/dist/roguelike/Targeting.js +2 -1
- package/dist/roguelike/Targeting.js.map +1 -1
- package/dist/rpg/index.d.ts +1 -1
- package/dist/rpg/index.d.ts.map +1 -1
- package/dist/simulation/Training.d.ts +77 -0
- package/dist/simulation/Training.d.ts.map +1 -1
- package/dist/simulation/Training.js +87 -0
- package/dist/simulation/Training.js.map +1 -1
- package/dist/simulation/index.d.ts +2 -2
- package/dist/simulation/index.d.ts.map +1 -1
- package/dist/simulation/index.js +1 -1
- package/dist/simulation/index.js.map +1 -1
- package/dist/two-d/pixi-interop.d.ts +24 -1
- package/dist/two-d/pixi-interop.d.ts.map +1 -1
- package/dist/two-d/pixi-interop.js +23 -1
- package/dist/two-d/pixi-interop.js.map +1 -1
- package/dist/two-d/render/Beam.d.ts +93 -0
- package/dist/two-d/render/Beam.d.ts.map +1 -0
- package/dist/two-d/render/Beam.js +154 -0
- package/dist/two-d/render/Beam.js.map +1 -0
- package/dist/two-d/render/Flights.d.ts +79 -0
- package/dist/two-d/render/Flights.d.ts.map +1 -0
- package/dist/two-d/render/Flights.js +120 -0
- package/dist/two-d/render/Flights.js.map +1 -0
- package/dist/two-d/render/Particles.d.ts +48 -0
- package/dist/two-d/render/Particles.d.ts.map +1 -1
- package/dist/two-d/render/Particles.js +59 -1
- package/dist/two-d/render/Particles.js.map +1 -1
- package/dist/two-d/render/index.d.ts +6 -2
- package/dist/two-d/render/index.d.ts.map +1 -1
- package/dist/two-d/render/index.js +2 -0
- package/dist/two-d/render/index.js.map +1 -1
- package/dist/two-d/ui/MessageLog.d.ts +28 -0
- package/dist/two-d/ui/MessageLog.d.ts.map +1 -1
- package/dist/two-d/ui/MessageLog.js +23 -6
- package/dist/two-d/ui/MessageLog.js.map +1 -1
- package/dist/two-d/ui/TextPrompt.d.ts +115 -0
- package/dist/two-d/ui/TextPrompt.d.ts.map +1 -0
- package/dist/two-d/ui/TextPrompt.js +227 -0
- package/dist/two-d/ui/TextPrompt.js.map +1 -0
- package/dist/two-d/ui/index.d.ts +4 -2
- package/dist/two-d/ui/index.d.ts.map +1 -1
- package/dist/two-d/ui/index.js +2 -1
- package/dist/two-d/ui/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +16 -3
- package/sbom.cdx.json +6 -6
- package/tools/audit-adoptions.d.mts +65 -0
- package/tools/audit-adoptions.mjs +252 -0
- package/tools/browser-driver.d.mts +78 -0
- 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
|
+
}
|