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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|