staysfixed 0.3.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1532 @@
1
+ /**
2
+ * Driving a browser, and reading what it means.
3
+ *
4
+ * This file knows about browsers and nothing about Stays Fixed. It opens a throwaway
5
+ * Chromium, freezes the world inside it, walks the steps it is given, and hands back four
6
+ * plain things: the meaning tree, the traffic, the complaints and a picture. `web.js` turns
7
+ * those into observations. Keeping the split means the hard browser problems - which are
8
+ * all timing problems - are solved in one place and read in one place.
9
+ *
10
+ * FOUR DECISIONS WORTH KNOWING ABOUT.
11
+ *
12
+ * 1. THE MEANING, NOT THE MARKUP. What is read is the accessibility tree - role, name,
13
+ * state - through Playwright's ARIA snapshot. The DOM is not read at all. A team that
14
+ * swaps a div for a section, renames a class or reorders two wrappers has changed
15
+ * nothing a person can perceive, and a tool that reports it has trained its owner to
16
+ * ignore it.
17
+ *
18
+ * 2. ADDRESSES THAT SURVIVE A REORDERING. A control is addressed by what it is and what it
19
+ * says - `button:Pay now` - inside the chain of landmarks and headings above it. Not by
20
+ * its position. Moving the whole "Randomness" section to the bottom of the page moves
21
+ * nothing, because the address never mentioned where the section was. Position is used
22
+ * only as a last resort, between things that are genuinely indistinguishable, and then
23
+ * only within the one section they share. This is the single most load-bearing choice in
24
+ * the file: everything downstream reads these addresses, and an address that moves when
25
+ * nothing did produces a page of differences nobody will ever read twice.
26
+ *
27
+ * 3. THE FREEZE COMES FIRST, THE WIRE COMES SECOND. `src/freeze/` is applied before a
28
+ * single byte of the app is fetched - frozen clock, killed motion, seeded randomness,
29
+ * pinned fonts, no outbound network. That is proven code and it is used exactly the way
30
+ * `src/picture/capture.js` uses it. On top of it sits one more layer this file owns: a
31
+ * refusal boundary that stops anything that spends money, sends a message or destroys
32
+ * data, records that it was ASKED for, and reports the refusal as a hole in the check.
33
+ * Both layers intercept, and they were measured working together rather than assumed to.
34
+ *
35
+ * 4. OUR OWN WINDOW, ALWAYS. Every walk gets a brand new profile folder under the scratch
36
+ * directory and is closed at the end of the walk. Nothing attaches to a browser somebody
37
+ * else started, nothing uses a fixed debugging port, nothing outlives the walk that
38
+ * opened it. Cookies and local storage cannot leak from one journey into the next, which
39
+ * matters more here than it looks: leaked state between journeys shows up as a
40
+ * difference, and a difference the tool caused itself is the worst kind of noise.
41
+ */
42
+
43
+ import fsp from 'node:fs/promises';
44
+ import path from 'node:path';
45
+ import { createRequire } from 'node:module';
46
+ import { PNG } from 'pngjs';
47
+
48
+ import { globToRegExp } from '../../freeze/network.js';
49
+
50
+ /** @typedef {import('../types.js').ObservedValue} JsonValue */
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Finding Playwright
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * What a browser needs before it can be opened, and what to do when it is not there.
58
+ *
59
+ * @typedef {object} PlaywrightState
60
+ * @property {boolean} ok A browser can be opened right now.
61
+ * @property {any} [chromium] Playwright's chromium launcher, when there is one.
62
+ * @property {string} [version] Which Playwright.
63
+ * @property {string} why Plain English, filled in whether it worked or not.
64
+ * @property {'installed'|'no package'|'no browser'} state
65
+ * @property {string} [howToGet] The exact command that fixes it.
66
+ * @property {string} [executable] Where the browser binary is, when it exists.
67
+ */
68
+
69
+ /**
70
+ * Find Playwright, in the two places it could honestly be.
71
+ *
72
+ * Stays Fixed is installed INTO other people's projects, so "is Playwright here" has two
73
+ * different answers: is it beside us, and is it in the project we were pointed at. Both are
74
+ * tried, because a project that already drives its own tests with Playwright should not be
75
+ * asked to install a second copy.
76
+ *
77
+ * It is loaded with `import()` rather than named at the top of the file on purpose. A tool
78
+ * that cannot start at all because an optional browser library is missing is a tool that
79
+ * cannot tell you what is missing.
80
+ *
81
+ * @param {object} [opts]
82
+ * @param {string} [opts.projectRoot] The project being checked. Looked in second.
83
+ * @returns {Promise<PlaywrightState>}
84
+ */
85
+ export async function loadPlaywright(opts = {}) {
86
+ const install = 'npm install --save-dev playwright';
87
+ /** @type {any} */
88
+ let mod = null;
89
+ /** @type {string|undefined} */
90
+ let version;
91
+
92
+ /** @param {any} loaded */
93
+ const unwrap = (loaded) => (loaded && loaded.chromium ? loaded : (loaded?.default ?? null));
94
+
95
+ try {
96
+ mod = unwrap(await import('playwright'));
97
+ } catch {
98
+ // Not beside us. Try the project we were pointed at.
99
+ }
100
+
101
+ if (!mod && opts.projectRoot) {
102
+ try {
103
+ const require = createRequire(path.join(opts.projectRoot, 'package.json'));
104
+ mod = unwrap(await import(require.resolve('playwright')));
105
+ } catch {
106
+ // Not there either. That is an answer, and it is reported as one.
107
+ }
108
+ }
109
+
110
+ if (!mod?.chromium) {
111
+ return {
112
+ ok: false,
113
+ state: 'no package',
114
+ why: 'Playwright is not installed, so no web page can be opened. Everything read out of the source still works; nothing that needs a browser does.',
115
+ howToGet: install,
116
+ };
117
+ }
118
+
119
+ try {
120
+ const require = createRequire(import.meta.url);
121
+ version = String(require('playwright/package.json').version);
122
+ } catch {
123
+ // A version we cannot read is not a reason to refuse to run.
124
+ }
125
+
126
+ /** @type {string|undefined} */
127
+ let executable;
128
+ try {
129
+ executable = String(mod.chromium.executablePath());
130
+ } catch {
131
+ executable = undefined;
132
+ }
133
+
134
+ const there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
135
+ if (!there) {
136
+ return {
137
+ ok: false,
138
+ state: 'no browser',
139
+ chromium: mod.chromium,
140
+ version,
141
+ why: `Playwright ${version ?? ''} is installed but its browser has not been downloaded, so no page can be opened yet. This is one command and nobody has to be asked.`.trim(),
142
+ howToGet: 'npx playwright install chromium',
143
+ executable,
144
+ };
145
+ }
146
+
147
+ return {
148
+ ok: true,
149
+ state: 'installed',
150
+ chromium: mod.chromium,
151
+ version,
152
+ executable,
153
+ why: `Playwright ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * @param {string} file
159
+ * @returns {Promise<boolean>}
160
+ */
161
+ async function exists(file) {
162
+ try {
163
+ await fsp.access(file);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Opening a window
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * One browser window, and everything hanging off it.
176
+ *
177
+ * @typedef {object} Window
178
+ * @property {any} context Playwright's browser context. Ours, always.
179
+ * @property {any} page
180
+ * @property {any} cdp A debug-protocol session onto that page.
181
+ * @property {any} handle The page, in the shape `src/freeze/` expects.
182
+ * @property {string} profileDir The throwaway profile folder we made.
183
+ * @property {() => Promise<void>} close
184
+ */
185
+
186
+ /**
187
+ * Open a browser window nobody else is using.
188
+ *
189
+ * Its own profile folder, under the scratch directory the engine handed us, thrown away
190
+ * when the walk ends. No fixed debugging port, no attaching to something already running,
191
+ * no reuse between journeys.
192
+ *
193
+ * @param {object} opts
194
+ * @param {any} opts.chromium
195
+ * @param {string} opts.scratchDir
196
+ * @param {{width: number, height: number, deviceScaleFactor?: number}} [opts.viewport]
197
+ * @param {'light'|'dark'} [opts.colorScheme]
198
+ * @param {boolean} [opts.headed] Open a window somebody can watch. Off by default.
199
+ * @param {string} [opts.label] Goes in the folder name, so a leftover folder explains itself.
200
+ * @returns {Promise<Window>}
201
+ */
202
+ export async function openWindow(opts) {
203
+ const viewport = {
204
+ width: opts.viewport?.width ?? 1280,
205
+ height: opts.viewport?.height ?? 800,
206
+ };
207
+ const deviceScaleFactor = opts.viewport?.deviceScaleFactor ?? 1;
208
+ const profileDir = path.join(opts.scratchDir, `browser-${safe(opts.label ?? 'walk')}-${Date.now().toString(36)}`);
209
+ await fsp.mkdir(profileDir, { recursive: true });
210
+
211
+ const context = await opts.chromium.launchPersistentContext(profileDir, {
212
+ headless: opts.headed !== true,
213
+ viewport,
214
+ deviceScaleFactor,
215
+ colorScheme: opts.colorScheme ?? 'light',
216
+ // A window that asks about location, notifications or the camera stops dead waiting for
217
+ // an answer nobody is there to give. Grant nothing, the same way every time.
218
+ permissions: [],
219
+ // A browser that restores a session, offers to save a password or runs an extension is
220
+ // a browser whose screen depends on yesterday.
221
+ args: ['--no-first-run', '--no-default-browser-check', '--disable-extensions', '--hide-scrollbars'],
222
+ ignoreHTTPSErrors: true,
223
+ serviceWorkers: 'block',
224
+ });
225
+
226
+ const page = context.pages()[0] ?? (await context.newPage());
227
+ const cdp = await context.newCDPSession(page);
228
+ await wakeDomains(cdp);
229
+ const handle = pageHandleFor(page, cdp, null);
230
+
231
+ return {
232
+ context,
233
+ page,
234
+ cdp,
235
+ handle,
236
+ profileDir,
237
+ close: async () => {
238
+ // Only ever the window we opened. Somebody else's browser is somebody else's business.
239
+ // Bounded, because closing is also a conversation with the browser and a browser that
240
+ // has stopped answering must not be able to hold a whole check open. Closing it is
241
+ // also what releases anything still waiting on it, so this goes first and always.
242
+ await withLimit(context.close(), 15000, undefined);
243
+ await fsp.rm(profileDir, { recursive: true, force: true }).catch(() => {});
244
+ },
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Switch on the parts of the debug protocol the freeze layer needs, before it is used.
250
+ *
251
+ * READ THIS BEFORE CHANGING ANYTHING HERE. `Page.addScriptToEvaluateOnNewDocument` accepts
252
+ * a script and hands back an identifier whether or not the Page domain is switched on - and
253
+ * if it is not, the script is remembered and never actually run. Nothing fails. Nothing is
254
+ * logged. Every init script the freeze layer registers is accepted and silently ignored, so
255
+ * the clock keeps ticking, randomness stays random, and animations keep animating, while
256
+ * every line of code involved reports success.
257
+ *
258
+ * That was measured, not guessed: with this one call the fixture app's clock reads
259
+ * 2026-08-29T09:00:00.000Z on every run and `Math.random` is the seeded generator; without
260
+ * it the clock is the wall clock and `Math.random` is native, and the only visible symptom
261
+ * is a suspiciously noisy run.
262
+ *
263
+ * @param {any} cdp
264
+ * @returns {Promise<void>}
265
+ */
266
+ export async function wakeDomains(cdp) {
267
+ for (const domain of ['Page', 'Runtime']) {
268
+ try {
269
+ await cdp.send(`${domain}.enable`);
270
+ } catch {
271
+ // An older browser without one of these is still worth driving.
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * @param {string} text
278
+ * @returns {string}
279
+ */
280
+ function safe(text) {
281
+ return String(text).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40) || 'walk';
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // The page, in the shape the freeze layer expects
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Wait for something, but never for ever.
290
+ *
291
+ * Every promise in this file that crosses into the browser needs one of these, and the
292
+ * reason is worth writing down. A browser can leave a promise pending for ever with nothing
293
+ * wrong anywhere: a response whose body never finishes arriving, a request paused by two
294
+ * interceptors where one of them aborted it, a page that is closing while somebody is still
295
+ * reading from it. None of those throw. Nothing times out on its own. The run simply stops,
296
+ * for ever, having already done all its work - which is what happened here before this
297
+ * existed, intermittently, about one walk in four.
298
+ *
299
+ * A check that never finishes is worse than a slow one: nobody can tell it apart from a
300
+ * broken machine, and it takes the answers of every journey that already succeeded with it.
301
+ *
302
+ * @template T
303
+ * @template R
304
+ * @param {Promise<T>} promise
305
+ * @param {number} ms
306
+ * @param {R} whenSlow What to hand back if it never arrives. Give it a value the caller
307
+ * can tell apart from a real answer, and then say so out loud - a
308
+ * timeout quietly returning something that looks like a result is
309
+ * how a hole turns into a pass.
310
+ * @returns {Promise<T|R>}
311
+ */
312
+ export function withLimit(promise, ms, whenSlow) {
313
+ return new Promise((resolve) => {
314
+ const timer = setTimeout(() => resolve(whenSlow), ms);
315
+ promise.then(
316
+ (value) => {
317
+ clearTimeout(timer);
318
+ resolve(value);
319
+ },
320
+ () => {
321
+ clearTimeout(timer);
322
+ resolve(whenSlow);
323
+ },
324
+ );
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Wrap a Playwright page so `src/freeze/` can drive it unchanged.
330
+ *
331
+ * The freeze layer was written against a hand-rolled debug-protocol client and it is the
332
+ * most carefully tested code in the repository - frozen clock, killed motion, seeded
333
+ * randomness, pinned fonts, blocked network, and `settle`, which photographs until two
334
+ * frames agree. Rewriting any of that for Playwright would be re-deriving proven work and
335
+ * getting it subtly wrong. So the page is reshaped instead, and every one of those files
336
+ * runs here exactly as it runs for a picture check.
337
+ *
338
+ * The protocol calls go through a real debug session rather than through Playwright's own
339
+ * wrappers, because that is what the freeze layer asks for: `Emulation.setTimezoneOverride`,
340
+ * `Page.addScriptToEvaluateOnNewDocument`, `Fetch.enable`. Playwright is content to share a
341
+ * target with a second debug client; that was measured, not assumed.
342
+ *
343
+ * The Page domain has to be switched on before this is any use - see {@link wakeDomains},
344
+ * which {@link openWindow} calls for you and which explains what happens when nobody does.
345
+ *
346
+ * @param {any} page
347
+ * @param {any} cdp
348
+ * @param {string|null} baseUrl The app's own origin, so "block everything external"
349
+ * knows what external means. Set before freezing.
350
+ * @returns {any}
351
+ */
352
+ export function pageHandleFor(page, cdp, baseUrl) {
353
+ /** @type {string[]} */
354
+ const complaints = [];
355
+ /** @type {Map<string, {initId: string, token: string}>} */
356
+ const styles = new Map();
357
+ let styleCounter = 0;
358
+
359
+ /**
360
+ * @param {string} method
361
+ * @param {Record<string, unknown>} [params]
362
+ * @returns {Promise<any>}
363
+ */
364
+ const send = (method, params) => cdp.send(method, params ?? {});
365
+
366
+ /**
367
+ * @param {string} event
368
+ * @param {(params: any) => void} handler
369
+ * @returns {() => void}
370
+ */
371
+ const on = (event, handler) => {
372
+ cdp.on(event, handler);
373
+ return () => {
374
+ try {
375
+ cdp.off(event, handler);
376
+ } catch {
377
+ // The session is already closed; nothing left to detach from.
378
+ }
379
+ };
380
+ };
381
+
382
+ /**
383
+ * @param {string} js
384
+ * @returns {Promise<any>}
385
+ */
386
+ const evaluate = async (js) => {
387
+ const res = await send('Runtime.evaluate', {
388
+ expression: js,
389
+ awaitPromise: true,
390
+ returnByValue: true,
391
+ userGesture: true,
392
+ });
393
+ if (res?.exceptionDetails) {
394
+ const text = res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? 'the page refused to run it';
395
+ throw new Error(String(text).split('\n')[0]);
396
+ }
397
+ return res?.result?.value;
398
+ };
399
+
400
+ /**
401
+ * @param {string} source
402
+ * @returns {Promise<string>}
403
+ */
404
+ const addInitScript = async (source) => {
405
+ const res = await send('Page.addScriptToEvaluateOnNewDocument', { source });
406
+ return String(res?.identifier ?? '');
407
+ };
408
+
409
+ /**
410
+ * @param {string} id
411
+ * @returns {Promise<void>}
412
+ */
413
+ const removeInitScript = async (id) => {
414
+ if (!id) return;
415
+ try {
416
+ await send('Page.removeScriptToEvaluateOnNewDocument', { identifier: id });
417
+ } catch {
418
+ // Already gone with the document.
419
+ }
420
+ };
421
+
422
+ /**
423
+ * A stylesheet dies at the next navigation, so the same CSS also goes in as a script that
424
+ * runs before each new document. On this document the tag applies; after a navigation the
425
+ * script puts an identical one back. It refuses to add itself twice, so nothing is ever
426
+ * applied twice over.
427
+ *
428
+ * @param {string} css
429
+ * @returns {Promise<string>}
430
+ */
431
+ const insertCss = async (css) => {
432
+ styleCounter += 1;
433
+ const token = `staysfixed-style-${styleCounter}`;
434
+ const source = `(function () {
435
+ try {
436
+ if (document.getElementById(${JSON.stringify(token)})) return;
437
+ var el = document.createElement('style');
438
+ el.id = ${JSON.stringify(token)};
439
+ el.textContent = ${JSON.stringify(css)};
440
+ (document.head || document.documentElement).appendChild(el);
441
+ } catch (e) {}
442
+ })()`;
443
+ let initId = '';
444
+ try {
445
+ initId = await addInitScript(source);
446
+ } catch {
447
+ // No init scripts on this target. The current document still gets the styles.
448
+ }
449
+ try {
450
+ await evaluate(source);
451
+ } catch {
452
+ // No document yet. The init script covers the one that is coming.
453
+ }
454
+ const id = `style:${styleCounter}`;
455
+ styles.set(id, { initId, token });
456
+ return id;
457
+ };
458
+
459
+ /**
460
+ * @param {string} id
461
+ * @returns {Promise<void>}
462
+ */
463
+ const removeCss = async (id) => {
464
+ const held = styles.get(id);
465
+ if (!held) return;
466
+ styles.delete(id);
467
+ await removeInitScript(held.initId);
468
+ try {
469
+ await evaluate(`(function () {
470
+ var el = document.getElementById(${JSON.stringify(held.token)});
471
+ if (el && el.parentNode) el.parentNode.removeChild(el);
472
+ })()`);
473
+ } catch {
474
+ // The document went away, and it took the tag with it.
475
+ }
476
+ };
477
+
478
+ page.on('console', (/** @type {any} */ message) => {
479
+ const type = message.type();
480
+ if (type !== 'error' && type !== 'assert') return;
481
+ record(message.text());
482
+ });
483
+ page.on('pageerror', (/** @type {any} */ error) => {
484
+ record(`${error?.name ?? 'Error'}: ${error?.message ?? String(error)}`);
485
+ });
486
+
487
+ /** @param {string} text */
488
+ function record(text) {
489
+ const line = String(text ?? '').trim();
490
+ if (!line || complaints.includes(line) || complaints.length >= 50) return;
491
+ complaints.push(line);
492
+ }
493
+
494
+ /**
495
+ * @param {string} selector
496
+ * @param {{timeoutMs?: number, state?: string}} [o]
497
+ * @returns {Promise<void>}
498
+ */
499
+ const locate = (selector, o) =>
500
+ page.locator(selector).first().waitFor({ state: o?.state ?? 'visible', timeout: o?.timeoutMs ?? 10000 });
501
+
502
+ return {
503
+ // --- the extras the freeze layer needs ---------------------------------
504
+ send,
505
+ on,
506
+ sessionId: 'playwright',
507
+ targetId: 'playwright',
508
+ addInitScript,
509
+ removeInitScript,
510
+ insertCss,
511
+ removeCss,
512
+ baseUrl,
513
+ clearConsole: () => {
514
+ complaints.length = 0;
515
+ },
516
+
517
+ // --- the ordinary page a step drives -----------------------------------
518
+ /** @param {string} url */
519
+ goto: async (url) => {
520
+ await page.goto(url, { waitUntil: 'load', timeout: 30000 });
521
+ },
522
+ /**
523
+ * @param {string} selector
524
+ * @param {{timeoutMs?: number}} [o]
525
+ */
526
+ click: async (selector, o) => {
527
+ await page.locator(selector).first().click({ timeout: o?.timeoutMs ?? 10000 });
528
+ },
529
+ /**
530
+ * @param {string} selector
531
+ * @param {string} text
532
+ */
533
+ type: async (selector, text) => {
534
+ await page.locator(selector).first().fill(text, { timeout: 10000 });
535
+ },
536
+ /** @param {string} key */
537
+ press: async (key) => {
538
+ await page.keyboard.press(key);
539
+ },
540
+ /** @param {string} selector */
541
+ hover: async (selector) => {
542
+ await page.locator(selector).first().hover({ timeout: 10000 });
543
+ },
544
+ moveMouseAway: async () => {
545
+ // (1,1) rather than (0,0): some apps read the exact origin as "no pointer at all" and
546
+ // never fire the leave, so whatever is under the cursor stays hovered in the picture.
547
+ await page.mouse.move(1, 1);
548
+ },
549
+ /**
550
+ * @param {string} selector
551
+ * @param {{timeoutMs?: number}} [o]
552
+ */
553
+ waitFor: (selector, o) => locate(selector, o),
554
+ /**
555
+ * @param {string} selector
556
+ * @param {{timeoutMs?: number}} [o]
557
+ */
558
+ waitForGone: (selector, o) => locate(selector, { ...o, state: 'hidden' }),
559
+ /** @param {string} selector */
560
+ scrollTo: async (selector) => {
561
+ await page.locator(selector).first().scrollIntoViewIfNeeded({ timeout: 10000 });
562
+ },
563
+ /** @param {number} ms */
564
+ wait: async (ms) => {
565
+ await page.waitForTimeout(ms);
566
+ },
567
+ evaluate,
568
+ /** @param {string} selector */
569
+ visible: (selector) => page.locator(selector).first().isVisible().catch(() => false),
570
+ /** @param {string} selector */
571
+ exists: async (selector) => (await page.locator(selector).count()) > 0,
572
+ /** @param {string} selector */
573
+ textOf: async (selector) => String((await page.locator(selector).first().textContent()) ?? ''),
574
+ /** @param {string} selector */
575
+ count: (selector) => page.locator(selector).count(),
576
+ /** @param {string} selector */
577
+ boxOf: async (selector) => {
578
+ try {
579
+ return await page.locator(selector).first().boundingBox();
580
+ } catch {
581
+ return null;
582
+ }
583
+ },
584
+ url: async () => String(page.url()),
585
+ title: () => page.title(),
586
+ shoot: async () => Buffer.from(await page.screenshot({ type: 'png', animations: 'disabled', caret: 'hide' })),
587
+ /** @param {{width: number, height: number}} v */
588
+ setViewport: async (v) => {
589
+ await page.setViewportSize({ width: v.width, height: v.height });
590
+ },
591
+ consoleErrors: () => complaints.slice(),
592
+ };
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // The refusal boundary
597
+ // ---------------------------------------------------------------------------
598
+
599
+ /**
600
+ * Things that are not undone by trying again.
601
+ *
602
+ * Blunt on purpose, and blunt in the safe direction. Refusing a call that would have been
603
+ * harmless costs one line in the report saying it was not checked. Making a call that was
604
+ * not harmless costs somebody money, or somebody's inbox, or somebody's data - twice, once
605
+ * for each build. A project that knows better says so in its own settings, and every
606
+ * refusal names itself in the report so nobody has to guess which one to allow.
607
+ */
608
+ export const IRREVERSIBLE = Object.freeze([
609
+ '**/pay', '**/pay/**', '**/payment*', '**/payments/**', '**/charge*', '**/charges/**',
610
+ '**/checkout/**', '**/orders/**', '**/purchase*', '**/subscribe*', '**/subscriptions/**',
611
+ '**/billing/**', '**/refund*', '**/invoices/**', '**/payout*',
612
+ '**/send*', '**/email*', '**/mail*', '**/sms*', '**/notif*', '**/invite*', '**/message*',
613
+ '**/delete*', '**/destroy*', '**/remove*', '**/purge*', '**/wipe*', '**/cancel*',
614
+ '**/unsubscribe*', '**/reset-*',
615
+ ]);
616
+
617
+ // On why these end in a star rather than naming exact paths. The first version of this list
618
+ // had the exact path "send", and a fixture that posted to /api/send-receipt sailed straight
619
+ // through it - a receipt emailed to a customer, twice, once for each build, by the tool that
620
+ // exists to keep that from happening. Every real product spells these differently:
621
+ // send-receipt, sendMail, deleteAccount, cancelSubscription. A prefix catches all of them; an
622
+ // exact path catches only the one somebody happened to think of.
623
+
624
+ /**
625
+ * @typedef {object} WireCall
626
+ * @property {string} method
627
+ * @property {string} pattern The address with the changing parts taken out.
628
+ * @property {string} url The address as it really was. Never compared.
629
+ * @property {string} kind document, script, fetch, image, ...
630
+ * @property {boolean} sameOrigin
631
+ * @property {boolean} refused
632
+ * @property {string} [why] Why it was refused, in plain English.
633
+ * @property {JsonValue} [sends] The shape of what was being sent, when there was a body.
634
+ * @property {number} [status] Filled in when an answer came back.
635
+ * @property {JsonValue} [answered] The answer, for the app's own calls that speak JSON.
636
+ * @property {JsonValue} [shape] The fields the answer carries and what type each one is.
637
+ * @property {string} [failed] Why it never finished.
638
+ * @property {number} times
639
+ */
640
+
641
+ /**
642
+ * Watch - and where it matters, stop - everything the page sends.
643
+ *
644
+ * The freeze layer has already cut the page off from the internet; this is the second,
645
+ * narrower boundary, and it answers a different question. Freezing asks "is this somebody
646
+ * else's server, which would make the picture depend on their weather". Refusing asks "does
647
+ * this spend money, send a message or destroy data", which is true of a call to the app's
648
+ * own back end - exactly the case freezing lets through.
649
+ *
650
+ * Everything is recorded either way. A refused call is recorded as ASKED FOR - same method,
651
+ * same address, same shape of body - and reported as a hole in the check. That is the whole
652
+ * of this tool's safety: watch the ask, never perform the effect, and never let a refusal be
653
+ * mistaken for a pass.
654
+ *
655
+ * @param {any} page
656
+ * @param {object} opts
657
+ * @param {string|null} opts.baseUrl
658
+ * @param {string[]} [opts.refuse] Extra patterns to refuse, from the project.
659
+ * @param {string[]} [opts.allow] Patterns to allow that would otherwise be refused.
660
+ * @param {boolean} [opts.allowWrites] Let ordinary writes through to an address that is
661
+ * not on this machine. Off unless a project says so.
662
+ * @param {boolean} [opts.allowIrreversible] Never set by the engine. It exists so the
663
+ * refusal is a decision in the code, not an accident.
664
+ * @returns {Promise<{calls: () => WireCall[], settled: () => Promise<void>, stop: () => Promise<void>}>}
665
+ */
666
+ export async function watchTheWire(page, opts) {
667
+ const own = originOf(opts.baseUrl);
668
+ const local = isLocal(opts.baseUrl);
669
+ const refuse = (opts.refuse ?? []).map(globToRegExp);
670
+ const allow = (opts.allow ?? []).map(globToRegExp);
671
+
672
+ /** @type {Map<string, WireCall>} */
673
+ const calls = new Map();
674
+ /** @type {Promise<void>[]} */
675
+ const reading = [];
676
+
677
+ /**
678
+ * @param {string} method
679
+ * @param {string} url
680
+ * @returns {WireCall}
681
+ */
682
+ const entryFor = (method, url) => {
683
+ const pattern = wirePattern(url, own);
684
+ const key = `${method} ${pattern}`;
685
+ const found = calls.get(key);
686
+ if (found) return found;
687
+ /** @type {WireCall} */
688
+ const made = { method, pattern, url, kind: 'other', sameOrigin: originOf(url) === own, refused: false, times: 0 };
689
+ calls.set(key, made);
690
+ return made;
691
+ };
692
+
693
+ await page.route('**/*', async (/** @type {any} */ route) => {
694
+ const request = route.request();
695
+ const url = String(request.url());
696
+ const method = String(request.method()).toUpperCase();
697
+ const entry = entryFor(method, url);
698
+ entry.times += 1;
699
+ entry.kind = String(request.resourceType());
700
+
701
+ const body = request.postData();
702
+ if (body) entry.sends = shapeOfBody(String(request.headers()['content-type'] ?? ''), body);
703
+
704
+ const verdict = judge({ method, url, own, local, refuse, allow, allowWrites: opts.allowWrites === true });
705
+ if (verdict.refuse && opts.allowIrreversible !== true) {
706
+ entry.refused = true;
707
+ entry.why = verdict.why;
708
+ try {
709
+ await route.abort('blockedbyclient');
710
+ } catch {
711
+ // The page gave up on it first. Refused either way.
712
+ }
713
+ return;
714
+ }
715
+ try {
716
+ await route.continue();
717
+ } catch {
718
+ // The request was answered already, or the page moved on. Nothing else to do: a
719
+ // request that is paused and never answered stalls the page, which looks like a hang.
720
+ }
721
+ });
722
+
723
+ page.on('response', (/** @type {any} */ response) => {
724
+ const request = response.request();
725
+ const url = String(request.url());
726
+ const method = String(request.method()).toUpperCase();
727
+ const entry = entryFor(method, url);
728
+ entry.status = Number(response.status());
729
+ if (!entry.sameOrigin) return;
730
+ const type = String(response.headers()['content-type'] ?? '');
731
+ if (!/json|text\/plain/i.test(type)) return;
732
+ // Read it now, not later: once the page navigates the body is gone, and a body we
733
+ // failed to read would look exactly like a body that was never sent.
734
+ reading.push(
735
+ withLimit(response.text(), 5000, null).then((/** @type {string|null} */ text) => {
736
+ // null means it never arrived - a redirect, a 204, a stream the page consumed first,
737
+ // or a body that simply never finished. Not worth failing a walk over, and not worth
738
+ // waiting for either.
739
+ if (text === null) return;
740
+ const parsed = readAnswer(type, text);
741
+ entry.answered = parsed.value;
742
+ if (parsed.shape !== undefined) entry.shape = parsed.shape;
743
+ }),
744
+ );
745
+ });
746
+
747
+ page.on('requestfailed', (/** @type {any} */ request) => {
748
+ const entry = entryFor(String(request.method()).toUpperCase(), String(request.url()));
749
+ if (entry.refused) return;
750
+ entry.failed = String(request.failure()?.errorText ?? 'it did not finish');
751
+ });
752
+
753
+ return {
754
+ calls: () => [...calls.values()].sort((a, b) => `${a.method} ${a.pattern}`.localeCompare(`${b.method} ${b.pattern}`)),
755
+ settled: async () => {
756
+ await withLimit(Promise.all(reading.splice(0)), 15000, []);
757
+ },
758
+ stop: async () => {
759
+ try {
760
+ await page.unroute('**/*');
761
+ } catch {
762
+ // The page is closing. The routes go with it.
763
+ }
764
+ },
765
+ };
766
+ }
767
+
768
+ /** {@link IRREVERSIBLE}, compiled once. */
769
+ const ALWAYS_REFUSED = IRREVERSIBLE.map(globToRegExp);
770
+
771
+ /**
772
+ * Should this call be allowed to happen for real?
773
+ *
774
+ * The built-in refusal list is applied HERE rather than by whoever calls this, and that is
775
+ * deliberate: a safety boundary a caller has to remember to switch on is a safety boundary
776
+ * that will one day be called without it.
777
+ *
778
+ * @param {object} input
779
+ * @param {string} input.method
780
+ * @param {string} input.url
781
+ * @param {string|null} input.own
782
+ * @param {boolean} input.local
783
+ * @param {RegExp[]} [input.refuse] Extra patterns from the project, on top of the built-in list.
784
+ * @param {RegExp[]} [input.allow] Patterns the project says are safe after all.
785
+ * @param {boolean} [input.allowWrites]
786
+ * @returns {{refuse: boolean, why: string}}
787
+ */
788
+ export function judge(input) {
789
+ const { url, method } = input;
790
+ const reading = method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
791
+ let where = url;
792
+ try {
793
+ where = new URL(url).pathname;
794
+ } catch {
795
+ // Not a URL we can pick apart. The whole string is matched instead.
796
+ }
797
+ /** @param {RegExp[]} list */
798
+ const matches = (list) => list.some((re) => re.test(url) || re.test(where));
799
+
800
+ if (matches(input.allow ?? [])) return { refuse: false, why: 'the project said this one is safe to let through' };
801
+
802
+ // A project's OWN list is absolute: it applies to reads as well, because only the project
803
+ // knows it has a link that deletes something. The built-in list applies to writes only.
804
+ // Reading is assumed safe, and the assumption buys a lot: without it, every GET of an
805
+ // order, an invoice or a message thread is refused, and a shop loses most of its coverage
806
+ // to protect against a danger that was never there.
807
+ if (matches(input.refuse ?? [])) {
808
+ return {
809
+ refuse: true,
810
+ why: `${method} ${where} is on this project's own list of things that must not really happen. It was asked for and stopped at the wire, so what was asked is compared and the effect never happened.`,
811
+ };
812
+ }
813
+ if (reading) return { refuse: false, why: 'reading something changes nothing' };
814
+ if (matches(ALWAYS_REFUSED)) {
815
+ return {
816
+ refuse: true,
817
+ why: `${method} ${where} looks like it spends money, sends a message or destroys data. It was asked for and stopped at the wire, so what was asked is compared and the effect never happened.`,
818
+ };
819
+ }
820
+ if (input.local || input.allowWrites) {
821
+ return { refuse: false, why: 'a write to a copy of the app running on this machine, against data that is put back between runs' };
822
+ }
823
+ return {
824
+ refuse: true,
825
+ why: `${method} ${where} writes to an address that is not on this machine, so it was stopped. Point the check at a copy of the app running locally, or set "allowWrites" for this project if the address is a safe one.`,
826
+ };
827
+ }
828
+
829
+ /**
830
+ * @param {string|null|undefined} url
831
+ * @returns {string|null}
832
+ */
833
+ function originOf(url) {
834
+ if (!url) return null;
835
+ try {
836
+ return new URL(url).origin;
837
+ } catch {
838
+ return null;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Is this address on the machine we are running on?
844
+ * @param {string|null|undefined} url
845
+ * @returns {boolean}
846
+ */
847
+ export function isLocal(url) {
848
+ if (!url) return false;
849
+ try {
850
+ const host = new URL(url).hostname.toLowerCase();
851
+ return (
852
+ host === 'localhost' ||
853
+ host.endsWith('.localhost') ||
854
+ host === '127.0.0.1' ||
855
+ host === '::1' ||
856
+ host === '[::1]' ||
857
+ host === '0.0.0.0'
858
+ );
859
+ } catch {
860
+ return false;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * An address with the parts that change taken out of it.
866
+ *
867
+ * `/api/users/8412/orders/a9f3-...` and `/api/users/9001/orders/b2c1-...` are the same call
868
+ * made twice, and comparing them as written would report a difference on every run. What is
869
+ * worth comparing is that the page asked for a user's orders at all.
870
+ *
871
+ * @param {string} url
872
+ * @param {string|null} own
873
+ * @returns {string}
874
+ */
875
+ export function wirePattern(url, own) {
876
+ /** @type {URL} */
877
+ let u;
878
+ try {
879
+ u = new URL(url);
880
+ } catch {
881
+ return String(url).slice(0, 200);
882
+ }
883
+ const where = u.pathname
884
+ .split('/')
885
+ .map((part) => {
886
+ if (part === '') return part;
887
+ if (/^[0-9]+$/.test(part)) return '*';
888
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return '*';
889
+ if (/^[0-9a-f]{16,}$/i.test(part)) return '*';
890
+ if (/[0-9]{6,}/.test(part)) return '*';
891
+ return part;
892
+ })
893
+ .join('/');
894
+ const keys = [...u.searchParams.keys()].sort();
895
+ const query = keys.length > 0 ? `?${keys.join('&')}` : '';
896
+ const head = own && u.origin === own ? '' : u.origin;
897
+ return `${head}${where || '/'}${query}`;
898
+ }
899
+
900
+ /**
901
+ * The shape of something being sent, never the thing itself.
902
+ *
903
+ * A request body carries names, addresses and card numbers. What is worth comparing is that
904
+ * the page still sends an order with an id, a quantity and an address - the fields and their
905
+ * types - not what today's happened to say.
906
+ *
907
+ * @param {string} contentType
908
+ * @param {string} body
909
+ * @returns {JsonValue}
910
+ */
911
+ export function shapeOfBody(contentType, body) {
912
+ if (/json/i.test(contentType)) {
913
+ try {
914
+ return typesIn(JSON.parse(body));
915
+ } catch {
916
+ return 'said it was JSON and was not';
917
+ }
918
+ }
919
+ if (/x-www-form-urlencoded/i.test(contentType)) {
920
+ try {
921
+ return [...new URLSearchParams(body).keys()].sort();
922
+ } catch {
923
+ return 'a form we could not read';
924
+ }
925
+ }
926
+ return `${Buffer.byteLength(body, 'utf8')} bytes of ${contentType.split(';')[0] || 'something'}`;
927
+ }
928
+
929
+ /**
930
+ * Turn an answer into something worth comparing.
931
+ * @param {string} contentType
932
+ * @param {string} text
933
+ * @returns {{value: JsonValue, shape?: JsonValue}}
934
+ */
935
+ export function readAnswer(contentType, text) {
936
+ if (text === '') return { value: 'nothing at all' };
937
+ if (/json/i.test(contentType)) {
938
+ try {
939
+ const parsed = JSON.parse(text);
940
+ return { value: sorted(parsed), shape: typesIn(parsed) };
941
+ } catch {
942
+ return { value: `said it was JSON but was not: ${text.slice(0, 500)}` };
943
+ }
944
+ }
945
+ return {
946
+ value: text.length > 4000 ? `${text.slice(0, 2000)}\n... the middle is left out ...\n${text.slice(-2000)}` : text,
947
+ };
948
+ }
949
+
950
+ /**
951
+ * @param {unknown} value
952
+ * @returns {JsonValue}
953
+ */
954
+ function sorted(value) {
955
+ if (value === null || typeof value !== 'object') return /** @type {JsonValue} */ (value ?? null);
956
+ if (Array.isArray(value)) return value.map(sorted);
957
+ /** @type {Record<string, JsonValue>} */
958
+ const out = {};
959
+ const object = /** @type {Record<string, unknown>} */ (value);
960
+ for (const key of Object.keys(object).sort()) out[key] = sorted(object[key]);
961
+ return out;
962
+ }
963
+
964
+ /**
965
+ * The names and types inside a value, with every list collapsed to "N things shaped like
966
+ * this". It holds still while the values churn, so a renamed or dropped field shows up on
967
+ * its own instead of buried inside a diff of the whole body.
968
+ *
969
+ * @param {unknown} value
970
+ * @returns {JsonValue}
971
+ */
972
+ export function typesIn(value) {
973
+ if (value === null || value === undefined) return 'nothing';
974
+ if (Array.isArray(value)) {
975
+ if (value.length === 0) return 'an empty list';
976
+ return { 'a list of': value.length, 'each one': typesIn(value[0]) };
977
+ }
978
+ if (typeof value === 'object') {
979
+ /** @type {Record<string, JsonValue>} */
980
+ const out = {};
981
+ const object = /** @type {Record<string, unknown>} */ (value);
982
+ for (const key of Object.keys(object).sort()) out[key] = typesIn(object[key]);
983
+ return out;
984
+ }
985
+ return typeof value;
986
+ }
987
+
988
+ // ---------------------------------------------------------------------------
989
+ // The meaning tree
990
+ // ---------------------------------------------------------------------------
991
+
992
+ /**
993
+ * One node of the accessibility tree.
994
+ *
995
+ * @typedef {object} AriaNode
996
+ * @property {string} role button, heading, list, textbox, text, ...
997
+ * @property {string} [name] What a screen reader would call it.
998
+ * @property {Record<string, string|boolean>} states disabled, checked, expanded, level, ...
999
+ * @property {string} [text] Its own words, when it has any of its own.
1000
+ * @property {AriaNode[]} children
1001
+ */
1002
+
1003
+ /**
1004
+ * Read Playwright's ARIA snapshot into a tree.
1005
+ *
1006
+ * The snapshot is a small, strict subset of YAML, and it is parsed here rather than with a
1007
+ * YAML library for two reasons: there is no YAML library among this project's dependencies,
1008
+ * and every line has exactly one of five shapes, which is a page of code rather than a
1009
+ * dependency. The five shapes:
1010
+ *
1011
+ * - button "Save" something with a name
1012
+ * - heading "Total" [level=2] with states in brackets
1013
+ * - navigation: something with children under it
1014
+ * - paragraph: nine o'clock something with its own words
1015
+ * - text: nine o'clock words with no element of their own
1016
+ *
1017
+ * A colon inside a quoted name is not a separator, and neither is one inside brackets. Both
1018
+ * happen in real apps, and both would otherwise cut a name silently in half.
1019
+ *
1020
+ * @param {string} snapshot
1021
+ * @returns {AriaNode[]}
1022
+ */
1023
+ export function parseAria(snapshot) {
1024
+ const lines = String(snapshot ?? '').split('\n');
1025
+ /** @type {AriaNode[]} */
1026
+ const roots = [];
1027
+ /** @type {{indent: number, node: AriaNode}[]} */
1028
+ const stack = [];
1029
+
1030
+ for (let i = 0; i < lines.length; i += 1) {
1031
+ const raw = lines[i];
1032
+ if (raw.trim() === '') continue;
1033
+ const indent = raw.length - raw.replace(/^\s+/, '').length;
1034
+ const body = raw.trim();
1035
+ if (!body.startsWith('- ') && body !== '-') continue;
1036
+ const rest = body === '-' ? '' : body.slice(2);
1037
+
1038
+ const cut = splitAtSeparator(rest);
1039
+ const node = readHead(cut.head);
1040
+ if (cut.text !== undefined) {
1041
+ let text = cut.text;
1042
+ if (text === '|' || text === '|-' || text === '>' || text === '>-') {
1043
+ // A block of text, written across several more-indented lines.
1044
+ /** @type {string[]} */
1045
+ const block = [];
1046
+ while (i + 1 < lines.length) {
1047
+ const next = lines[i + 1];
1048
+ if (next.trim() === '') {
1049
+ block.push('');
1050
+ i += 1;
1051
+ continue;
1052
+ }
1053
+ const nextIndent = next.length - next.replace(/^\s+/, '').length;
1054
+ if (nextIndent <= indent) break;
1055
+ block.push(next.trim());
1056
+ i += 1;
1057
+ }
1058
+ text = block.join('\n').trim();
1059
+ }
1060
+ node.text = unquote(text);
1061
+ }
1062
+
1063
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
1064
+ if (stack.length === 0) roots.push(node);
1065
+ else stack[stack.length - 1].node.children.push(node);
1066
+ stack.push({ indent, node });
1067
+ }
1068
+ return roots;
1069
+ }
1070
+
1071
+ /**
1072
+ * Find the colon that separates a node from its words, ignoring the ones inside a quoted
1073
+ * name or inside brackets.
1074
+ *
1075
+ * @param {string} rest
1076
+ * @returns {{head: string, text?: string}}
1077
+ */
1078
+ function splitAtSeparator(rest) {
1079
+ let quoted = false;
1080
+ let depth = 0;
1081
+ for (let i = 0; i < rest.length; i += 1) {
1082
+ const ch = rest[i];
1083
+ if (ch === '\\') {
1084
+ i += 1;
1085
+ continue;
1086
+ }
1087
+ if (ch === '"') quoted = !quoted;
1088
+ else if (!quoted && ch === '[') depth += 1;
1089
+ else if (!quoted && ch === ']') depth = Math.max(0, depth - 1);
1090
+ else if (!quoted && depth === 0 && ch === ':') {
1091
+ const after = rest.slice(i + 1);
1092
+ if (after === '') return { head: rest.slice(0, i) };
1093
+ if (after.startsWith(' ')) return { head: rest.slice(0, i), text: after.trim() };
1094
+ }
1095
+ }
1096
+ return { head: rest };
1097
+ }
1098
+
1099
+ /**
1100
+ * @param {string} head
1101
+ * @returns {AriaNode}
1102
+ */
1103
+ function readHead(head) {
1104
+ const text = head.trim();
1105
+ /** @type {AriaNode} */
1106
+ const node = { role: 'text', states: {}, children: [] };
1107
+
1108
+ const roleMatch = /^([A-Za-z][A-Za-z0-9_-]*)/.exec(text);
1109
+ node.role = roleMatch ? roleMatch[1] : 'text';
1110
+ let at = roleMatch ? roleMatch[1].length : 0;
1111
+
1112
+ const after = text.slice(at).trimStart();
1113
+ at = text.length - after.length;
1114
+ if (after.startsWith('"')) {
1115
+ let i = 1;
1116
+ let name = '';
1117
+ while (i < after.length) {
1118
+ const ch = after[i];
1119
+ if (ch === '\\' && i + 1 < after.length) {
1120
+ name += after[i + 1];
1121
+ i += 2;
1122
+ continue;
1123
+ }
1124
+ if (ch === '"') break;
1125
+ name += ch;
1126
+ i += 1;
1127
+ }
1128
+ node.name = name;
1129
+ at += i + 1;
1130
+ }
1131
+
1132
+ for (const m of text.slice(at).matchAll(/\[([A-Za-z0-9_-]+)(?:=([^\]]*))?\]/g)) {
1133
+ node.states[m[1]] = m[2] === undefined ? true : m[2];
1134
+ }
1135
+ return node;
1136
+ }
1137
+
1138
+ /**
1139
+ * @param {string} text
1140
+ * @returns {string}
1141
+ */
1142
+ function unquote(text) {
1143
+ const t = text.trim();
1144
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
1145
+ return t.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
1146
+ }
1147
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1).replace(/''/g, "'");
1148
+ return t;
1149
+ }
1150
+
1151
+ /**
1152
+ * Roles that are places rather than things. A place goes into the address of everything
1153
+ * underneath it, which is part of what makes an address survive a page being rearranged.
1154
+ */
1155
+ const PLACES = new Set([
1156
+ 'banner', 'navigation', 'main', 'complementary', 'contentinfo', 'region', 'search', 'form',
1157
+ 'dialog', 'alertdialog', 'article', 'list', 'listbox', 'menu', 'menubar', 'table', 'grid',
1158
+ 'treegrid', 'tree', 'tablist', 'tabpanel', 'toolbar', 'radiogroup', 'status', 'alert', 'log',
1159
+ 'feed',
1160
+ ]);
1161
+
1162
+ // What is deliberately NOT on that list: group, figure, blockquote, document, application,
1163
+ // combobox. Every one of them is a container a browser hands out for ordinary markup - a
1164
+ // fieldset, a details element, an address block - and putting them in the address means a
1165
+ // team that wrapped a section in a fieldset for spacing reasons gets told the whole section
1166
+ // moved. A NAMED one still counts, because a name is somebody saying out loud that this is a
1167
+ // place ("Shipping address"), and that is handled by the `node.name` test below rather than
1168
+ // by this list.
1169
+
1170
+ /**
1171
+ * The places that are a place in their own right, and so are never described as being
1172
+ * "under" a heading.
1173
+ *
1174
+ * Without this the navigation bar under a page's title ends up addressed through the title,
1175
+ * and renaming the product moves every link in the site. A landmark is where something is;
1176
+ * a heading is what a run of content is about. Only the second one makes a section.
1177
+ */
1178
+ const LANDMARKS = new Set([
1179
+ 'banner', 'navigation', 'main', 'complementary', 'contentinfo', 'region', 'search', 'form',
1180
+ 'dialog', 'alertdialog',
1181
+ ]);
1182
+
1183
+ /**
1184
+ * One addressable thing on the screen.
1185
+ *
1186
+ * @typedef {object} MeaningEntry
1187
+ * @property {string[]} at The address, part by part, outermost first.
1188
+ * @property {string} role
1189
+ * @property {string} [name]
1190
+ * @property {JsonValue} value What it says, or what it is when it says nothing.
1191
+ * @property {Record<string, string|boolean>} states
1192
+ * @property {string} describe One plain sentence about it.
1193
+ */
1194
+
1195
+ /**
1196
+ * Flatten the meaning tree into addresses that survive a page being rearranged.
1197
+ *
1198
+ * THE PROBLEM THIS SOLVES, because it is most of the reason this file exists. The obvious
1199
+ * way to address a control is by where it is: third thing inside the second section. Do that
1200
+ * and moving one paragraph renames every address below it, so a one-line change reports two
1201
+ * hundred differences. Nobody reads the second report like that.
1202
+ *
1203
+ * So an address is built out of what a person would actually say. Three rules, in order:
1204
+ *
1205
+ * 1. A THING IS NAMED BY WHAT IT IS AND WHAT IT SAYS - `button:Pay now`. Rename the button
1206
+ * and the old address goes while a new one arrives, which is exactly right: the label
1207
+ * IS the promise the control makes.
1208
+ * 2. A THING LIVES SOMEWHERE - inside a landmark, a list, a dialog, and underneath a
1209
+ * heading. Headings are used as section markers because that is what they are for, and
1210
+ * because a section that moves takes its heading with it. `main.under:Your orders`
1211
+ * still means the same place after the section is moved to the top of the page.
1212
+ * 3. ONLY WHEN TWO THINGS IN ONE SECTION ARE GENUINELY INDISTINGUISHABLE does position
1213
+ * come into it, as `#2`, `#3` - and it is counted only within that section, so a change
1214
+ * in one section can never renumber another.
1215
+ *
1216
+ * @param {AriaNode[]} nodes
1217
+ * @returns {MeaningEntry[]}
1218
+ */
1219
+ export function flattenAria(nodes) {
1220
+ /** @type {MeaningEntry[]} */
1221
+ const out = [];
1222
+
1223
+ /**
1224
+ * @param {AriaNode[]} children
1225
+ * @param {string[]} at
1226
+ * @returns {void}
1227
+ */
1228
+ const walk = (children, at) => {
1229
+ /** @type {Map<string, number>} */
1230
+ const seen = new Map();
1231
+ /** @type {{level: number, at: string[]}[]} */
1232
+ const sections = [];
1233
+
1234
+ for (const node of children) {
1235
+ const heading = node.role === 'heading';
1236
+ const level = Number(node.states.level ?? 2) || 2;
1237
+ if (heading) {
1238
+ while (sections.length > 0 && sections[sections.length - 1].level >= level) sections.pop();
1239
+ }
1240
+ const ownPlace = LANDMARKS.has(node.role);
1241
+ const scope = heading || ownPlace || sections.length === 0 ? at : sections[sections.length - 1].at;
1242
+
1243
+ // Two things with the same name in the same place have to be told apart somehow, and
1244
+ // counting is the only honest way left. The count is kept per place, so it cannot
1245
+ // spread: adding a row to one list never renumbers another.
1246
+ const key = `${scope.join(' ')}${node.role}${node.name ?? ''}`;
1247
+ const nth = (seen.get(key) ?? 0) + 1;
1248
+ seen.set(key, nth);
1249
+
1250
+ const base = node.name ? `${node.role}:${short(node.name)}` : node.role;
1251
+ const segment = nth > 1 ? `${base}#${nth}` : base;
1252
+ const here = [...scope, segment];
1253
+
1254
+ out.push({
1255
+ at: here,
1256
+ role: node.role,
1257
+ name: node.name,
1258
+ value: node.text !== undefined && node.text !== '' ? node.text : phrase(node),
1259
+ states: node.states,
1260
+ describe: describeNode(node, here),
1261
+ });
1262
+
1263
+ if (heading) {
1264
+ sections.push({ level, at: [...at, `under:${short(node.name ?? node.text ?? 'a heading')}`] });
1265
+ }
1266
+
1267
+ if (node.children.length > 0) {
1268
+ // A place goes into the address of what is inside it; anything else does not, so a
1269
+ // wrapper somebody added for layout reasons changes no address at all.
1270
+ const inside = PLACES.has(node.role) || node.name ? here : scope;
1271
+ walk(node.children, inside);
1272
+ }
1273
+ }
1274
+ };
1275
+
1276
+ walk(nodes, []);
1277
+ return out;
1278
+ }
1279
+
1280
+ /**
1281
+ * @param {AriaNode} node
1282
+ * @returns {string}
1283
+ */
1284
+ function phrase(node) {
1285
+ if (node.name) return `${withArticle(node.role)} called "${node.name}"`;
1286
+ return withArticle(node.role);
1287
+ }
1288
+
1289
+ /**
1290
+ * @param {AriaNode} node
1291
+ * @param {string[]} at
1292
+ * @returns {string}
1293
+ */
1294
+ function describeNode(node, at) {
1295
+ const where = at.length > 1 ? ` inside ${at.slice(0, -1).join(' / ')}` : '';
1296
+ const states = Object.keys(node.states).filter((k) => k !== 'level');
1297
+ const said = node.text ? ` It says "${short(node.text, 80)}".` : '';
1298
+ const flags = states.length > 0 ? ` It is ${states.join(' and ')}.` : '';
1299
+ return `${capital(phrase(node))}${where}.${said}${flags}`;
1300
+ }
1301
+
1302
+ /**
1303
+ * @param {string} role
1304
+ * @returns {string}
1305
+ */
1306
+ function withArticle(role) {
1307
+ return /^[aeiou]/i.test(role) ? `an ${role}` : `a ${role}`;
1308
+ }
1309
+
1310
+ /**
1311
+ * @param {string} text
1312
+ * @returns {string}
1313
+ */
1314
+ function capital(text) {
1315
+ return text.charAt(0).toUpperCase() + text.slice(1);
1316
+ }
1317
+
1318
+ /**
1319
+ * Keep a name short enough to sit inside an address.
1320
+ *
1321
+ * A whole paragraph used as a button label is rare and real. Cutting it keeps addresses
1322
+ * readable and inside the length an address is allowed to be; the full text is still
1323
+ * compared, because the full text is the value.
1324
+ *
1325
+ * @param {string} text
1326
+ * @param {number} [limit]
1327
+ * @returns {string}
1328
+ */
1329
+ export function short(text, limit = 60) {
1330
+ const one = String(text).replace(/\s+/g, ' ').trim();
1331
+ return one.length <= limit ? one : `${one.slice(0, limit - 1)}...`;
1332
+ }
1333
+
1334
+ /**
1335
+ * How many of each kind of thing is on the screen.
1336
+ *
1337
+ * @param {MeaningEntry[]} entries
1338
+ * @returns {Map<string, number>}
1339
+ */
1340
+ export function countRoles(entries) {
1341
+ /** @type {Map<string, number>} */
1342
+ const counts = new Map();
1343
+ for (const entry of entries) counts.set(entry.role, (counts.get(entry.role) ?? 0) + 1);
1344
+ return new Map([...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])));
1345
+ }
1346
+
1347
+ // ---------------------------------------------------------------------------
1348
+ // Steps
1349
+ // ---------------------------------------------------------------------------
1350
+
1351
+ /**
1352
+ * Everything a step may say, in the order it happens when one step says several things.
1353
+ *
1354
+ * This is v1's vocabulary, unchanged and on purpose: a project that already has screens
1355
+ * written for picture checks can point the difference machine at the same file and have it
1356
+ * work, and nobody has to learn a second set of words for the same actions.
1357
+ */
1358
+ export const ACTION_ORDER = /** @type {const} */ ([
1359
+ 'goto', 'waitFor', 'scrollTo', 'hover', 'click', 'type', 'press', 'evaluate', 'waitForGone', 'wait',
1360
+ ]);
1361
+
1362
+ /**
1363
+ * Do what one step says.
1364
+ *
1365
+ * @param {any} page A page handle from {@link pageHandleFor}.
1366
+ * @param {Record<string, any>} step
1367
+ * @param {{baseUrl?: string|null}} [opts]
1368
+ * @returns {Promise<string[]>} what it did, in plain English, for the report
1369
+ */
1370
+ export async function runStep(page, step, opts = {}) {
1371
+ /** @type {string[]} */
1372
+ const did = [];
1373
+ for (const action of ACTION_ORDER) {
1374
+ const value = step[action];
1375
+ if (value === undefined || value === null) continue;
1376
+ switch (action) {
1377
+ case 'goto': {
1378
+ await page.goto(absolute(String(value), opts.baseUrl ?? null));
1379
+ did.push(`opened ${String(value)}`);
1380
+ break;
1381
+ }
1382
+ case 'waitFor':
1383
+ await page.waitFor(String(value), { timeoutMs: step.timeoutMs });
1384
+ did.push(`waited for ${String(value)} to appear`);
1385
+ break;
1386
+ case 'waitForGone':
1387
+ await page.waitForGone(String(value), { timeoutMs: step.timeoutMs });
1388
+ did.push(`waited for ${String(value)} to go`);
1389
+ break;
1390
+ case 'scrollTo':
1391
+ await page.scrollTo(String(value));
1392
+ did.push(`scrolled to ${String(value)}`);
1393
+ break;
1394
+ case 'hover':
1395
+ await page.hover(String(value));
1396
+ did.push(`hovered ${String(value)}`);
1397
+ break;
1398
+ case 'click':
1399
+ await page.click(String(value), { timeoutMs: step.timeoutMs });
1400
+ did.push(`clicked ${String(value)}`);
1401
+ break;
1402
+ case 'type':
1403
+ await page.type(String(value), String(step.text ?? ''));
1404
+ did.push(`typed into ${String(value)}`);
1405
+ break;
1406
+ case 'press':
1407
+ await page.press(String(value));
1408
+ did.push(`pressed ${String(value)}`);
1409
+ break;
1410
+ case 'evaluate':
1411
+ await page.evaluate(String(value));
1412
+ did.push('ran a piece of script the journey asked for');
1413
+ break;
1414
+ case 'wait':
1415
+ await page.wait(Number(value));
1416
+ did.push(`waited ${Number(value)} milliseconds`);
1417
+ break;
1418
+ default:
1419
+ break;
1420
+ }
1421
+ }
1422
+ return did;
1423
+ }
1424
+
1425
+ /**
1426
+ * The plain-English verb for a step, used to name it in an address.
1427
+ * @param {Record<string, any>} step
1428
+ * @returns {string}
1429
+ */
1430
+ export function actOf(step) {
1431
+ if (step.goto !== undefined) return 'open';
1432
+ if (step.click !== undefined) return 'click';
1433
+ if (step.type !== undefined) return 'type';
1434
+ if (step.press !== undefined) return 'press';
1435
+ if (step.hover !== undefined) return 'hover';
1436
+ if (step.scrollTo !== undefined) return 'scroll';
1437
+ if (step.waitFor !== undefined || step.waitForGone !== undefined || step.wait !== undefined) return 'wait';
1438
+ if (step.evaluate !== undefined) return 'run';
1439
+ return 'step';
1440
+ }
1441
+
1442
+ /**
1443
+ * @param {string} url
1444
+ * @param {string|null} baseUrl
1445
+ * @returns {string}
1446
+ */
1447
+ export function absolute(url, baseUrl) {
1448
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url;
1449
+ if (!baseUrl) return url;
1450
+ try {
1451
+ return new URL(url, baseUrl).toString();
1452
+ } catch {
1453
+ return url;
1454
+ }
1455
+ }
1456
+
1457
+ /**
1458
+ * Where the page is, with the parts that change taken out.
1459
+ *
1460
+ * @param {string} url
1461
+ * @param {string|null} baseUrl
1462
+ * @returns {string}
1463
+ */
1464
+ export function whereItIs(url, baseUrl) {
1465
+ try {
1466
+ const u = new URL(url);
1467
+ const own = baseUrl ? new URL(baseUrl).origin : null;
1468
+ const keys = [...u.searchParams.keys()].sort();
1469
+ return `${u.origin === own ? '' : u.origin}${u.pathname}${keys.length > 0 ? `?${keys.join('&')}` : ''}${u.hash}`;
1470
+ } catch {
1471
+ return url;
1472
+ }
1473
+ }
1474
+
1475
+ // ---------------------------------------------------------------------------
1476
+ // Pictures
1477
+ // ---------------------------------------------------------------------------
1478
+
1479
+ /**
1480
+ * How full the screen is, said roughly.
1481
+ *
1482
+ * The picture itself is evidence and is never compared - comparing pixels is what v1 did and
1483
+ * what this design deliberately stopped doing. But one thing a picture knows and no other
1484
+ * channel does is whether anything was drawn at all. A page whose stylesheet failed to load
1485
+ * still has every button in its accessibility tree; a page that rendered nothing looks
1486
+ * identical to a working one everywhere except here.
1487
+ *
1488
+ * So exactly one number is taken from the pixels, and it is bucketed so coarsely that
1489
+ * nothing short of a real collapse can move it. Sampled every eighth pixel, because this
1490
+ * runs at every checkpoint of every journey and a full decode of a retina screen is not
1491
+ * worth it.
1492
+ *
1493
+ * @param {Buffer} png
1494
+ * @returns {{wide: number, tall: number, ink: string}}
1495
+ */
1496
+ export function inkOf(png) {
1497
+ try {
1498
+ const image = PNG.sync.read(png);
1499
+ const { width, height, data } = image;
1500
+ const back = [data[0], data[1], data[2]];
1501
+ let looked = 0;
1502
+ let different = 0;
1503
+ for (let y = 0; y < height; y += 8) {
1504
+ for (let x = 0; x < width; x += 8) {
1505
+ const at = (y * width + x) * 4;
1506
+ looked += 1;
1507
+ if (
1508
+ Math.abs(data[at] - back[0]) > 24 ||
1509
+ Math.abs(data[at + 1] - back[1]) > 24 ||
1510
+ Math.abs(data[at + 2] - back[2]) > 24
1511
+ ) {
1512
+ different += 1;
1513
+ }
1514
+ }
1515
+ }
1516
+ return { wide: width, tall: height, ink: inkWord(looked === 0 ? 0 : different / looked) };
1517
+ } catch {
1518
+ return { wide: 0, tall: 0, ink: 'unreadable' };
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * @param {number} share
1524
+ * @returns {string}
1525
+ */
1526
+ function inkWord(share) {
1527
+ if (share < 0.005) return 'blank';
1528
+ if (share < 0.05) return 'nearly blank';
1529
+ if (share < 0.2) return 'sparse';
1530
+ if (share < 0.5) return 'busy';
1531
+ return 'very busy';
1532
+ }