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,1009 @@
1
+ /**
2
+ * Web apps.
3
+ *
4
+ * The engine wants a flat list of `path -> value` facts. A web page is the hardest thing in
5
+ * this repository to turn into one, because almost everything a browser will tell you about
6
+ * a page is different the second time you ask: the markup, the class names, the ids, the
7
+ * pixels, the timings, the request order. Ask any of those and the tool cries wolf, and a
8
+ * tool that cries wolf gets switched off inside a week.
9
+ *
10
+ * So this adapter asks for none of them. What it reads is:
11
+ *
12
+ * MEANING the accessibility tree - what the screen says each control IS and DOES.
13
+ * Roles, names and states, addressed through the landmarks and headings above
14
+ * them, so a page that was rearranged reports nothing at all. This is the
15
+ * channel that catches a button that stopped being a button, a form that lost
16
+ * a field, a control that went disabled, a dialog that never opened.
17
+ * EFFECTS every call the page made, with the changing parts of the address taken out
18
+ * and the body reduced to its shape. A page that quietly stopped saving still
19
+ * looks perfect in every other channel.
20
+ * COMPLAINTS console errors, uncaught errors, requests that failed.
21
+ * RESULTS what the app's own back end answered.
22
+ * COUNTERS how many of each kind of thing, and a rough time bucket - never milliseconds.
23
+ * PIXELS one picture per checkpoint, kept as EVIDENCE for a finding another channel
24
+ * already made. Never the accusation.
25
+ *
26
+ * THE TWO BUILDS ARE NEVER OPEN AT ONCE. Each is prepared, walked and shut down before the
27
+ * other starts: its own port, its own profile folder, its own scratch copy. Two copies of one
28
+ * app running side by side fight over ports, single-instance locks and stored sessions, and
29
+ * that fight looks exactly like a regression - which is the two-hosts-over-one-relay-slot
30
+ * bug that cost a real evening on 2026-08-28, recreated by the very tool meant to catch it.
31
+ *
32
+ * WHAT IT REFUSES. Everything the freeze layer refuses - all outbound traffic to anywhere
33
+ * that is not the app itself - plus a second boundary this adapter owns: any call that looks
34
+ * like it spends money, sends a message or destroys data, and any write at all to an address
35
+ * that is not on this machine. The call is recorded as ASKED FOR and stopped at the wire.
36
+ * Every refusal is reported as a hole in the check. None of them is ever reported as a pass.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import fsp from 'node:fs/promises';
41
+ import path from 'node:path';
42
+ import { spawn } from 'node:child_process';
43
+
44
+ import {
45
+ countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
46
+ trimForStorage, undoOurFootprint,
47
+ } from './contract.js';
48
+ import { copyForScratch, frozenEnvironment } from './process.js';
49
+ import { freePort, looksDestructive, waitForServer } from './http.js';
50
+ import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
51
+ import { settle } from '../../freeze/settle.js';
52
+ import {
53
+ actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
54
+ watchTheWire, whereItIs, withLimit,
55
+ } from './web-driver.js';
56
+
57
+ /** @typedef {import('./contract.js').Journey} Journey */
58
+ /** @typedef {import('./contract.js').Observation} Observation */
59
+ /** @typedef {import('./contract.js').Missing} Missing */
60
+ /** @typedef {import('./web-driver.js').MeaningEntry} MeaningEntry */
61
+ /** @typedef {import('./web-driver.js').WireCall} WireCall */
62
+
63
+ /** How big the window is, unless a project says otherwise. */
64
+ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Where the journeys come from
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Folders that never hold a page worth walking. */
71
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.staysfixed']);
72
+
73
+ /**
74
+ * The pages a project has, read out of its folder names.
75
+ *
76
+ * Free, exact, and it finds the four pages nobody links to. A crawl finds only what
77
+ * somebody remembered to link, which is the set of pages least likely to be broken.
78
+ *
79
+ * Both of the layouts in the wild are handled: an `app` folder, where a `page` file makes
80
+ * the folder it sits in a route, and a `pages` folder, where the file itself is the route.
81
+ * A folder in brackets is a grouping and is not part of the address; one starting with an
82
+ * underscore is private and is not routed at all. A folder with a parameter in it is
83
+ * reported as needing a sample value rather than guessed at, because "we did not check
84
+ * this" and "this is fine" must never be allowed to look alike.
85
+ *
86
+ * @param {string} root
87
+ * @returns {Promise<{url: string, file: string, needs: string[]}[]>}
88
+ */
89
+ export async function readPageRoutes(root) {
90
+ /** @type {Map<string, {url: string, file: string, needs: string[]}>} */
91
+ const found = new Map();
92
+
93
+ /**
94
+ * @param {string} base
95
+ * @param {(rel: string, full: string) => void} visit
96
+ */
97
+ const walk = (base, visit) => {
98
+ if (!fs.existsSync(base)) return;
99
+ /** @type {string[]} */
100
+ const stack = [base];
101
+ while (stack.length > 0) {
102
+ const dir = /** @type {string} */ (stack.pop());
103
+ /** @type {import('node:fs').Dirent[]} */
104
+ let entries;
105
+ try {
106
+ entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ } catch {
108
+ continue;
109
+ }
110
+ for (const entry of entries) {
111
+ const full = path.join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) stack.push(full);
114
+ } else if (entry.isFile()) {
115
+ visit(path.relative(base, full).split(path.sep).join('/'), full);
116
+ }
117
+ }
118
+ }
119
+ };
120
+
121
+ /**
122
+ * @param {string} url
123
+ * @param {string} file
124
+ */
125
+ const add = (url, file) => {
126
+ const clean = url === '' ? '/' : url;
127
+ if (found.has(clean)) return;
128
+ const needs = [...clean.matchAll(/\[\.{0,3}([A-Za-z0-9_]+)\]|:([A-Za-z0-9_]+)/g)].map((m) => m[1] ?? m[2]);
129
+ found.set(clean, { url: clean, file: path.relative(root, file), needs });
130
+ };
131
+
132
+ for (const appDir of ['app', 'src/app']) {
133
+ walk(path.join(root, appDir), (rel, full) => {
134
+ if (!/(^|\/)page\.[cm]?[jt]sx?$/.test(rel)) return;
135
+ const url =
136
+ '/' +
137
+ rel
138
+ .split('/')
139
+ .slice(0, -1)
140
+ .filter((s) => s !== '' && !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_') && s !== '@')
141
+ .join('/');
142
+ add(url.replace(/\/+$/, ''), full);
143
+ });
144
+ }
145
+
146
+ for (const pagesDir of ['pages', 'src/pages']) {
147
+ walk(path.join(root, pagesDir), (rel, full) => {
148
+ if (!/\.[cm]?[jt]sx?$/.test(rel)) return;
149
+ if (rel.startsWith('api/') || rel.startsWith('_')) return;
150
+ const stem = rel.replace(/\.[cm]?[jt]sx?$/, '');
151
+ add(`/${stem.replace(/(^|\/)index$/, '')}`.replace(/\/+$/, ''), full);
152
+ });
153
+ }
154
+
155
+ return [...found.values()].sort((a, b) => a.url.localeCompare(b.url));
156
+ }
157
+
158
+ /**
159
+ * Turn a project's settings and its pages into journeys.
160
+ *
161
+ * Three sources, in the order the design ranks them: the project's own screen list if it has
162
+ * one (a person wrote those on purpose, and a project that already does picture checks
163
+ * already has them), a journeys file if one was named, and the pages read out of the folder
164
+ * names for everything neither of those covers. Never invented, never crawled.
165
+ *
166
+ * @param {object} input
167
+ * @param {Record<string, any>} input.config
168
+ * @param {{url: string, file: string, needs: string[]}[]} input.pages
169
+ * @returns {Journey[]}
170
+ */
171
+ export function journeysFrom(input) {
172
+ const config = input.config ?? {};
173
+ const samples = config.samples ?? {};
174
+ /** @type {Map<string, Journey>} */
175
+ const journeys = new Map();
176
+
177
+ // v1 called these "screens" and this reads them unchanged, on purpose: nobody should have
178
+ // to write their steps twice to get a second kind of check out of them.
179
+ for (const screen of [...(config.screens ?? []), ...(config.journeys ?? [])]) {
180
+ if (!screen || typeof screen !== 'object') continue;
181
+ const name = String(screen.name ?? screen.url ?? 'a screen');
182
+ /** @type {Record<string, any>[]} */
183
+ const steps = [];
184
+ if (screen.url !== undefined) steps.push({ act: 'open', goto: String(screen.url), note: `open ${screen.url}` });
185
+ for (const step of screen.steps ?? []) steps.push({ act: actOf(step), ...step });
186
+ journeys.set(name, {
187
+ name,
188
+ describe: String(screen.describe ?? screen.why ?? `walk ${name}`),
189
+ source: 'code',
190
+ surface: 'web',
191
+ from: 'the project settings',
192
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
193
+ steps: /** @type {any} */ (steps),
194
+ irreversible: screen.irreversible === true,
195
+ timeoutMs: screen.timeoutMs,
196
+ });
197
+ }
198
+
199
+ for (const page of input.pages) {
200
+ let url = page.url;
201
+ /** @type {string[]} */
202
+ const unfilled = [];
203
+ for (const need of page.needs) {
204
+ const sample = samples[need];
205
+ if (sample === undefined) unfilled.push(need);
206
+ else url = url.replace(new RegExp(`\\[\\.{0,3}${need}\\]|:${need}`), encodeURIComponent(String(sample)));
207
+ }
208
+ const name = `page ${page.url}`;
209
+ if (journeys.has(name)) continue;
210
+ journeys.set(name, {
211
+ name,
212
+ describe: `open ${page.url} and read what the screen says`,
213
+ source: 'code',
214
+ surface: 'web',
215
+ from: page.file,
216
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
217
+ steps: /** @type {any} */ ([{ act: 'open', goto: url, note: `open ${page.url}`, unfilled }]),
218
+ });
219
+ }
220
+
221
+ if (journeys.size === 0 && (config.url || config.baseUrl || config.start)) {
222
+ journeys.set('the front page', {
223
+ name: 'the front page',
224
+ describe: 'open the front page and read what the screen says',
225
+ source: 'code',
226
+ surface: 'web',
227
+ from: 'the address in the project settings',
228
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
229
+ steps: /** @type {any} */ ([{ act: 'open', goto: '/', note: 'open the front page' }]),
230
+ });
231
+ }
232
+
233
+ return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Booting one build
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /** What each prepared build is holding open. Keyed by build id, emptied on teardown. */
241
+ const running = new Map();
242
+
243
+ /**
244
+ * @param {Record<string, any>} config
245
+ * @returns {{width: number, height: number, deviceScaleFactor: number}}
246
+ */
247
+ function viewportFrom(config) {
248
+ return {
249
+ width: Number(config.viewport?.width ?? VIEWPORT.width),
250
+ height: Number(config.viewport?.height ?? VIEWPORT.height),
251
+ deviceScaleFactor: Number(config.viewport?.deviceScaleFactor ?? VIEWPORT.deviceScaleFactor),
252
+ };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // The adapter
257
+ // ---------------------------------------------------------------------------
258
+
259
+ export const webAdapter = defineAdapter({
260
+ name: 'web',
261
+ title: 'Web apps, in a real browser',
262
+ describe:
263
+ 'Opens the app in a throwaway Chromium with the clock stopped, motion killed, randomness seeded and the internet cut off, then walks each journey and writes down what the screen MEANS - the roles, names and states a screen reader would read - along with every call the page made, everything it complained about, what its own back end answered, and one picture per checkpoint kept only as evidence. It never reads the markup, so a page that was restyled or rearranged reports nothing. It cannot see anything a journey never opened, and anything that would spend money, send a message or destroy data is stopped at the wire and reported as unchecked rather than done.',
264
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
265
+
266
+ /** @param {import('./contract.js').AdapterProject} project */
267
+ async detect(project) {
268
+ const config = project.config ?? {};
269
+ /** @type {Missing[]} */
270
+ const missing = [];
271
+
272
+ const playwright = await loadPlaywright({ projectRoot: project.root });
273
+ if (!playwright.ok) {
274
+ missing.push({
275
+ what: playwright.state === 'no package' ? 'Playwright, the thing that drives the browser' : "Playwright's Chromium",
276
+ unlocks: 'opening the app at all - every other kind of check still works without it',
277
+ howToGet: playwright.howToGet,
278
+ blocking: true,
279
+ });
280
+ }
281
+
282
+ /** @type {any} */
283
+ let pkg = null;
284
+ try {
285
+ pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8'));
286
+ } catch {
287
+ // A project with no package.json can still be a web app somebody points an address at.
288
+ }
289
+ const dependencies = { ...pkg?.dependencies, ...pkg?.devDependencies };
290
+ const framework = ['next', 'react', 'vue', 'svelte', 'astro', '@remix-run/react', 'nuxt', 'solid-js', 'preact', 'vite']
291
+ .find((name) => name in dependencies);
292
+
293
+ const pages = await readPageRoutes(project.root);
294
+ const address = config.url ?? config.baseUrl ?? null;
295
+
296
+ if (!config.start && !address) {
297
+ missing.push({
298
+ what: 'either a command that starts the app, or the address it is already running at',
299
+ unlocks: 'walking any of it - the pages can be listed from the folder names without this, but none of them can be opened',
300
+ howToGet: pkg?.scripts?.dev
301
+ ? 'Put {"start": "npm run dev"} under "web" in the settings, and have it read the PORT it is given. That is the better of the two: a command means each build is booted and walked on its own, which is what makes a comparison mean anything.'
302
+ : 'Put {"start": "..."} under "web" in the settings (it should listen on the PORT it is given), or {"url": "http://localhost:3000"} if it is already running.',
303
+ blocking: true,
304
+ });
305
+ } else if (!config.start && address) {
306
+ missing.push({
307
+ what: 'a command that starts the app',
308
+ unlocks: 'comparing two builds properly. One address can only ever serve one build, so with an address alone both halves of the comparison read the SAME running app, and a paired run proves nothing',
309
+ howToGet: 'Put {"start": "..."} under "web" in the settings - a command that boots this build and listens on the PORT it is given.',
310
+ });
311
+ }
312
+
313
+ const needing = pages.filter((p) => p.needs.length > 0);
314
+ if (needing.length > 0 && !config.samples) {
315
+ missing.push({
316
+ what: `a real value for the parts that vary in ${needing.length} page address${needing.length === 1 ? '' : 'es'}`,
317
+ unlocks: `opening ${needing.length === 1 ? 'that page' : 'those pages'} at all instead of reporting ${needing.length === 1 ? 'it' : 'them'} as unchecked`,
318
+ howToGet: `Put {"samples": {${[...new Set(needing.flatMap((p) => p.needs))].slice(0, 3).map((n) => `"${n}": "..."`).join(', ')}}} under "web" in the settings - one real value per name.`,
319
+ });
320
+ }
321
+
322
+ const screens = (config.screens ?? []).length + (config.journeys ?? []).length;
323
+ const applies = Boolean(address) || Boolean(config.start) || pages.length > 0 || screens > 0 || Boolean(framework);
324
+ return {
325
+ applies,
326
+ confidence: (config.start || address) && (pages.length > 0 || screens > 0) ? 1 : applies ? 0.5 : 0,
327
+ why: applies
328
+ ? `${pages.length > 0 ? `${pages.length} page${pages.length === 1 ? '' : 's'} were read out of the folder names` : 'No pages were found in the folder names'}${screens > 0 ? `, and the settings name ${screens} screen${screens === 1 ? '' : 's'}` : ''}${framework ? `. This project uses ${framework}` : ''}. ${playwright.why}`
329
+ : 'Nothing here looks like a web app: no pages in the folder names, no address in the settings and no browser framework installed.',
330
+ missing,
331
+ notes: [
332
+ 'What is compared is what the screen MEANS - the roles, names and states a screen reader would read - never the markup. A page that was restyled or rearranged reports nothing.',
333
+ 'The two builds are opened one after the other, never at the same time. Two copies of one app on one machine fight over the port, the profile and the stored session, and that fight looks exactly like a regression.',
334
+ 'Nothing that spends money, sends a message or destroys data is allowed to happen. It is watched at the moment it is asked for, stopped at the wire, and reported as unchecked.',
335
+ ],
336
+ };
337
+ },
338
+
339
+ /** @param {import('./contract.js').AdapterProject} project */
340
+ async journeys(project) {
341
+ return journeysFrom({ config: project.config ?? {}, pages: await readPageRoutes(project.root) });
342
+ },
343
+
344
+ /**
345
+ * Get one build ready to be walked.
346
+ *
347
+ * Two shapes, and the difference between them matters enough that the run says which one
348
+ * it got. With a `start` command each build is booted from its own scratch copy on a port
349
+ * nobody else is on, which is a real paired comparison. With only an address, both builds
350
+ * are read from the same running app - which cannot tell them apart, and is reported that
351
+ * way on every single journey rather than quietly passing.
352
+ *
353
+ * @param {import('./contract.js').Build} build
354
+ * @param {import('./contract.js').RunContext} ctx
355
+ */
356
+ async prepare(build, ctx) {
357
+ const config = ctx.config ?? {};
358
+ const base = path.join(ctx.scratchDir, `web-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
359
+ await fsp.mkdir(base, { recursive: true });
360
+
361
+ const playwright = await loadPlaywright({ projectRoot: build.root });
362
+ /** @param {string} why */
363
+ const notReady = (why) => ({
364
+ build,
365
+ root: base,
366
+ ready: false,
367
+ why,
368
+ dispose: async () => {
369
+ await fsp.rm(base, { recursive: true, force: true });
370
+ },
371
+ });
372
+
373
+ if (!playwright.ok) return notReady(`${playwright.why}${playwright.howToGet ? ` Run: ${playwright.howToGet}` : ''}`);
374
+
375
+ const address = config.url ?? config.baseUrl ?? null;
376
+ if (!config.start) {
377
+ if (!address) return notReady('There is no command to start this app and no address it is already running at, so no page can be opened.');
378
+ running.set(build.id, { base, baseUrl: String(address), port: 0, child: null, config, playwright, paired: false });
379
+ return {
380
+ build,
381
+ root: base,
382
+ ready: true,
383
+ why: `Reading the app at ${address}. There is no command to start it, so BOTH builds are read from that one running app and it cannot tell them apart - a comparison made this way proves nothing about a change. Every journey says so in its own results.`,
384
+ facts: { baseUrl: String(address), paired: false },
385
+ dispose: async () => {
386
+ running.delete(build.id);
387
+ await fsp.rm(base, { recursive: true, force: true });
388
+ },
389
+ };
390
+ }
391
+
392
+ const work = path.join(base, 'work');
393
+ const home = path.join(base, 'home');
394
+ const tmp = path.join(base, 'tmp');
395
+ await fsp.mkdir(home, { recursive: true });
396
+ await fsp.mkdir(tmp, { recursive: true });
397
+
398
+ const copy = await copyForScratch(build.root, work);
399
+ if (!copy.copied) return notReady(copy.why);
400
+
401
+ const port = await freePort();
402
+ const env = frozenEnvironment({
403
+ clock: ctx.clock,
404
+ seed: ctx.seed,
405
+ home,
406
+ tmp,
407
+ extra: {
408
+ PORT: String(port),
409
+ HOST: '127.0.0.1',
410
+ NODE_ENV: config.nodeEnv ?? 'production',
411
+ ...config.env,
412
+ },
413
+ });
414
+
415
+ /** @type {string[]} */
416
+ const notes = [];
417
+ if (config.restore) {
418
+ const verdict = looksDestructive(String(config.restore));
419
+ if (!verdict.safe) notes.push(verdict.why);
420
+ else {
421
+ const done = await new Promise((resolve) => {
422
+ const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: 'ignore' });
423
+ child.on('error', () => resolve(false));
424
+ child.on('close', (code) => resolve(code === 0));
425
+ });
426
+ notes.push(done ? 'The data was put back to a known state before booting.' : 'The command that puts the data back failed, so the data is not in a known state and every difference after the first write is suspect.');
427
+ }
428
+ }
429
+
430
+ /** @type {Buffer[]} */
431
+ const said = [];
432
+ /** @type {string|null} */
433
+ let exited = null;
434
+ const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
435
+ child.stdout?.on('data', (c) => said.push(c));
436
+ child.stderr?.on('data', (c) => said.push(c));
437
+ child.on('close', (code, signal) => {
438
+ exited = `The app stopped before it answered - exit code ${code}${signal ? `, killed by ${signal}` : ''}.`;
439
+ });
440
+
441
+ const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
442
+ if (!up.up) {
443
+ child.kill('SIGTERM');
444
+ return {
445
+ build,
446
+ root: work,
447
+ ready: false,
448
+ why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
449
+ dispose: async () => {
450
+ child.kill('SIGKILL');
451
+ await fsp.rm(base, { recursive: true, force: true });
452
+ },
453
+ };
454
+ }
455
+
456
+ const baseUrl = `http://127.0.0.1:${port}`;
457
+ running.set(build.id, { base, baseUrl, port, child, config, playwright, paired: true, work, home, tmp });
458
+ return {
459
+ build,
460
+ root: work,
461
+ ready: true,
462
+ why: `${copy.why} It came up on port ${port} in ${timeBucket(up.ms)}, in a browser profile nobody else is using.${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`,
463
+ facts: { baseUrl, port, paired: true },
464
+ dispose: async () => {
465
+ const held = running.get(build.id);
466
+ running.delete(build.id);
467
+ if (!held) return;
468
+ // Only ever the process we started ourselves.
469
+ held.child?.kill('SIGTERM');
470
+ await new Promise((r) => setTimeout(r, 400));
471
+ if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
472
+ await fsp.rm(base, { recursive: true, force: true });
473
+ },
474
+ };
475
+ },
476
+
477
+ /**
478
+ * Walk one journey against one prepared build.
479
+ *
480
+ * @param {Journey} journey
481
+ * @param {import('./contract.js').PreparedBuild} build
482
+ * @param {import('./contract.js').RunContext} ctx
483
+ * @returns {Promise<Observation[]>}
484
+ */
485
+ async run(journey, build, ctx) {
486
+ const held = running.get(build.build.id);
487
+ if (!build.ready || !held) {
488
+ return [
489
+ notCovered({
490
+ channel: 'meaning',
491
+ path: joinPath('screen', journey.name, 'opened at all'),
492
+ reason: /playwright|chromium|browser/i.test(build.why) ? 'missing tool' : 'crashed',
493
+ says: `"${journey.describe}" was not walked: ${build.why}`,
494
+ }),
495
+ ];
496
+ }
497
+
498
+ const config = held.config ?? {};
499
+ const steps = /** @type {Record<string, any>[]} */ (journey.steps ?? []);
500
+ const unfilled = steps.flatMap((s) => /** @type {string[]} */ (s.unfilled ?? []));
501
+ if (unfilled.length > 0) {
502
+ return [
503
+ notCovered({
504
+ channel: 'meaning',
505
+ path: joinPath('screen', journey.name, 'opened at all'),
506
+ reason: 'needs a sample',
507
+ says: `${journey.name} was not opened, because nobody has said what ${unfilled.map((p) => `"${p}"`).join(' and ')} should be. Put a real value under "web.samples" in the settings and this page starts being checked.`,
508
+ }),
509
+ ];
510
+ }
511
+
512
+ /** @type {Observation[]} */
513
+ const out = [];
514
+ if (held.paired === false) {
515
+ out.push(
516
+ notCovered({
517
+ channel: 'meaning',
518
+ path: joinPath('screen', journey.name, 'which build this was'),
519
+ reason: 'not supported here',
520
+ says: 'Both builds were read from the same running app, because the settings give an address but no command to start it. Everything below is a true description of whatever is running at that address - it just is not evidence about the change, because the same app answered both times. Add a "start" command under "web" and this becomes a real comparison.',
521
+ }),
522
+ );
523
+ }
524
+
525
+ const viewport = viewportFrom(config);
526
+ const window = await openWindow({
527
+ chromium: held.playwright.chromium,
528
+ scratchDir: ctx.scratchDir,
529
+ viewport,
530
+ colorScheme: config.colorScheme ?? 'light',
531
+ label: journey.name,
532
+ });
533
+
534
+ /** @type {{dirs: string[], ports: number[], projectRoot?: string}} */
535
+ const footprint = { dirs: [held.base, held.tmp, held.home].filter(Boolean), ports: [held.port].filter(Boolean), projectRoot: build.build.root };
536
+
537
+ // Nothing below may run for ever. A browser can leave a promise pending with nothing
538
+ // visibly wrong, and a journey that never finishes takes the whole check with it -
539
+ // including the answers of every journey that already worked. So the walk gets a
540
+ // deadline, and running out of it is reported the way every other hole is: as something
541
+ // that was not checked, with the reason attached, never as a pass.
542
+ const deadline = journey.timeoutMs ?? config.timeoutMs ?? 180000;
543
+ /** @type {string|null} */
544
+ let ranOut = null;
545
+
546
+ try {
547
+ const handle = window.handle;
548
+ handle.baseUrl = held.baseUrl;
549
+
550
+ // The freeze goes on BEFORE a single byte of the app is fetched. A frozen clock
551
+ // applied after the app has booted is a clock the app already read.
552
+ const frozen = await applyFreeze(
553
+ handle,
554
+ {
555
+ clock: ctx.clock,
556
+ seed: ctx.seed,
557
+ timezone: config.timezone ?? 'UTC',
558
+ locale: config.locale ?? 'en-US',
559
+ network: config.network ?? 'block-external',
560
+ networkAllow: config.allowHosts ?? [],
561
+ hideScrollbars: true,
562
+ hideCaret: true,
563
+ settle: { frames: 2, intervalMs: 120, timeoutMs: config.settleTimeoutMs ?? 8000 },
564
+ },
565
+ { fixturesDir: ctx.evidenceDir, screenName: journey.name, deviceScaleFactor: viewport.deviceScaleFactor },
566
+ );
567
+
568
+ // The stopwatch starts HERE, not when this method was called. Everything before this
569
+ // line - downloading nothing, launching a browser, freezing it - is our own time, and
570
+ // it is cold on the first walk of a run and warm afterwards. Timing that would report
571
+ // "this got slower" about the first journey of every single run, which is a lie the
572
+ // wobble measurement should not have to keep cleaning up after.
573
+ const started = Date.now();
574
+
575
+ const wire = await watchTheWire(window.page, {
576
+ baseUrl: held.baseUrl,
577
+ refuse: config.refuse ?? [],
578
+ allow: config.allowed ?? [],
579
+ allowWrites: config.allowWrites === true,
580
+ allowIrreversible: ctx.allowIrreversible === true,
581
+ });
582
+
583
+ /** @type {string[]} */
584
+ const did = [];
585
+ /** @type {Set<string>} */
586
+ const taken = new Set();
587
+
588
+ /**
589
+ * @param {string} name
590
+ * @returns {Promise<void>}
591
+ */
592
+ const checkpoint = async (name) => {
593
+ let id = name;
594
+ for (let n = 2; taken.has(id); n += 1) id = `${name} ${n}`;
595
+ taken.add(id);
596
+ out.push(...(await lookAt({ page: window.page, handle, journey, checkpoint: id, ctx, config, held, footprint, buildId: build.build.id })));
597
+ };
598
+
599
+ const walked = withLimit(
600
+ (async () => {
601
+ for (let i = 0; i < steps.length; i += 1) {
602
+ const step = steps[i];
603
+ did.push(...(await runStep(handle, step, { baseUrl: held.baseUrl })));
604
+ const named = step.name ?? step.checkpoint;
605
+ if (named) await checkpoint(String(named));
606
+ else if (config.everyStep === true && i < steps.length - 1) await checkpoint(`after ${i + 1} ${actOf(step)}`);
607
+ }
608
+ // Always one at the end, and by default only one: a checkpoint per step reads well
609
+ // until somebody inserts a step, at which point every address after it is renamed
610
+ // and one small change reports as hundreds. Name a step, or switch "everyStep" on,
611
+ // to look in the middle as well.
612
+ await checkpoint('end');
613
+ return 'finished';
614
+ })(),
615
+ deadline,
616
+ 'ran out of time',
617
+ );
618
+ if ((await walked) === 'ran out of time') {
619
+ ranOut = `"${journey.describe}" did not finish within ${timeBucket(deadline)}. It got as far as: ${did.join(', ') || 'opening the browser'}. Everything it did manage to look at is below; the rest was not checked.`;
620
+ }
621
+
622
+ await wire.settled();
623
+ out.push(...describeTraffic(journey, wire.calls(), footprint));
624
+ out.push(...describeComplaints(journey, handle.consoleErrors()));
625
+
626
+ out.push(
627
+ observation({
628
+ channel: 'counters',
629
+ path: joinPath('count', journey.name, 'how long the steps took'),
630
+ value: timeBucket(Date.now() - started),
631
+ says: `Walking the steps of "${journey.name}" took ${timeBucket(Date.now() - started)}, not counting opening the browser, which is our time and not the app's. Deliberately rough: exact timings differ on every run and would drown everything else.`,
632
+ journey: journey.name,
633
+ surface: 'web',
634
+ }),
635
+ );
636
+
637
+ if (ranOut) {
638
+ out.push(
639
+ notCovered({
640
+ channel: 'meaning',
641
+ path: joinPath('screen', journey.name, 'finished'),
642
+ reason: 'timed out',
643
+ says: ranOut,
644
+ }),
645
+ );
646
+ }
647
+
648
+ await wire.stop();
649
+ // Undoing the freeze also talks to the browser, and a browser that has stopped
650
+ // answering must not be able to hold the check open. It is being thrown away anyway.
651
+ await withLimit(frozen.release(), 10000, undefined);
652
+ } finally {
653
+ // Only the window we opened ourselves, and always, even when a step threw.
654
+ await window.close();
655
+ }
656
+
657
+ return out;
658
+ },
659
+
660
+ async teardown() {
661
+ for (const [, held] of running) held.child?.kill('SIGTERM');
662
+ running.clear();
663
+ },
664
+ });
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // One checkpoint
668
+ // ---------------------------------------------------------------------------
669
+
670
+ /**
671
+ * Hold still, then write down everything the screen means.
672
+ *
673
+ * `settle` is the one that makes this trustworthy: it photographs, photographs again, and
674
+ * only carries on once two pictures in a row agree. Everything else in the freeze layer
675
+ * removes a REASON for the page to move; this waits for the ones nobody thought of - a late
676
+ * render, a font swapping in, a chart drawing itself, an image that turns up on a timer.
677
+ * Reading the meaning tree before the page has stopped moving is how a tool ends up
678
+ * reporting a control that was simply not painted yet.
679
+ *
680
+ * @param {object} input
681
+ * @param {any} input.page
682
+ * @param {any} input.handle
683
+ * @param {Journey} input.journey
684
+ * @param {string} input.checkpoint
685
+ * @param {import('./contract.js').RunContext} input.ctx
686
+ * @param {Record<string, any>} input.config
687
+ * @param {any} input.held
688
+ * @param {{dirs: string[], ports: number[], projectRoot?: string}} input.footprint
689
+ * @param {string} input.buildId
690
+ * @returns {Promise<Observation[]>}
691
+ */
692
+ async function lookAt(input) {
693
+ const { handle, journey, checkpoint, footprint } = input;
694
+ /** @type {Observation[]} */
695
+ const out = [];
696
+ const head = ['screen', journey.name, checkpoint];
697
+
698
+ await prepareForShutter(handle, { fonts: true, timeoutMs: input.config.settleTimeoutMs ?? 10000 });
699
+
700
+ /** @type {Buffer|null} */
701
+ let png = null;
702
+ try {
703
+ const held = await settle(handle, {
704
+ frames: 2,
705
+ intervalMs: 120,
706
+ timeoutMs: input.config.settleTimeoutMs ?? 8000,
707
+ capture: () => handle.shoot(),
708
+ });
709
+ png = held.png;
710
+ } catch {
711
+ // A page that will not hold still is still worth reading. The picture is evidence; the
712
+ // meaning is the check, and refusing to look because the picture wobbled would throw
713
+ // away the whole finding to protect the least important channel.
714
+ }
715
+
716
+ const at = whereItIs(String(input.page.url()), input.held.baseUrl);
717
+ out.push(
718
+ observation({
719
+ channel: 'meaning',
720
+ path: joinPath(...head, 'where the browser ended up'),
721
+ value: at,
722
+ says: `After "${journey.describe}" the browser was at ${at}.`,
723
+ journey: journey.name,
724
+ surface: 'web',
725
+ }),
726
+ );
727
+
728
+ const title = await input.page.title().catch(() => '');
729
+ out.push(
730
+ observation({
731
+ channel: 'meaning',
732
+ path: joinPath(...head, 'what the tab is called'),
733
+ value: String(title),
734
+ says: `The browser tab was called "${title}".`,
735
+ journey: journey.name,
736
+ surface: 'web',
737
+ }),
738
+ );
739
+
740
+ /** @type {MeaningEntry[]} */
741
+ let entries = [];
742
+ try {
743
+ entries = flattenAria(parseAria(await input.page.locator('body').ariaSnapshot()));
744
+ } catch (error) {
745
+ out.push(
746
+ notCovered({
747
+ channel: 'meaning',
748
+ path: joinPath(...head, 'what the screen says'),
749
+ reason: 'crashed',
750
+ says: `The screen could not be read at "${checkpoint}": ${error instanceof Error ? error.message : String(error)}. Nothing about this checkpoint is being claimed either way.`,
751
+ }),
752
+ );
753
+ }
754
+
755
+ for (const entry of entries) {
756
+ const where = joinPath(...head, 'tree', ...entry.at);
757
+ out.push(
758
+ observation({
759
+ channel: 'meaning',
760
+ path: where,
761
+ value: typeof entry.value === 'string' ? undoOurFootprint(entry.value, footprint) : entry.value,
762
+ says: entry.describe,
763
+ journey: journey.name,
764
+ surface: 'web',
765
+ }),
766
+ );
767
+ for (const [state, value] of Object.entries(entry.states)) {
768
+ out.push(
769
+ observation({
770
+ channel: 'meaning',
771
+ path: `${where}.${state}`,
772
+ value,
773
+ says: `${entry.name ? `"${short(entry.name)}"` : `The ${entry.role}`} is ${state}${value === true ? '' : ` ${value}`}.`,
774
+ journey: journey.name,
775
+ surface: 'web',
776
+ }),
777
+ );
778
+ }
779
+ }
780
+
781
+ for (const [role, howMany] of countRoles(entries)) {
782
+ out.push(
783
+ observation({
784
+ channel: 'counters',
785
+ path: joinPath('count', journey.name, checkpoint, role),
786
+ value: countBucket(howMany),
787
+ says: `There ${howMany === 1 ? 'was 1' : `were ${howMany}`} ${role}${howMany === 1 ? '' : 's'} on the screen. Small counts are exact, because three going to four IS the finding; big ones are rounded, because they are not.`,
788
+ journey: journey.name,
789
+ surface: 'web',
790
+ }),
791
+ );
792
+ }
793
+ out.push(
794
+ observation({
795
+ channel: 'counters',
796
+ path: joinPath('count', journey.name, checkpoint, 'everything on the screen'),
797
+ value: countBucket(entries.length),
798
+ says: `${entries.length} things on the screen had a role and a name a person could act on.`,
799
+ journey: journey.name,
800
+ surface: 'web',
801
+ }),
802
+ );
803
+
804
+ if (png) {
805
+ // The build id goes in the name because both builds write into one evidence folder,
806
+ // and a picture of the change overwriting the picture of what it changed FROM is the one
807
+ // way this folder could be worse than useless.
808
+ const file = path.join(input.ctx.evidenceDir, `${fileSafe(`${input.buildId}-${journey.name}-${checkpoint}`)}.png`);
809
+ await fsp.writeFile(file, png).catch(() => {});
810
+ const ink = inkOf(png);
811
+ out.push(
812
+ observation({
813
+ channel: 'pixels',
814
+ path: joinPath('picture', journey.name, checkpoint),
815
+ value: { wide: ink.wide, tall: ink.tall, 'how full the screen is': ink.ink },
816
+ says: `The screen was ${ink.wide} by ${ink.tall} and ${ink.ink}. The picture itself is kept as evidence and is never compared - only whether anything was drawn at all, which is the one thing no other channel can see. It is ${sizeBucket(png.length)}.`,
817
+ evidence: file,
818
+ journey: journey.name,
819
+ surface: 'web',
820
+ }),
821
+ );
822
+ }
823
+
824
+ return out;
825
+ }
826
+
827
+ // ---------------------------------------------------------------------------
828
+ // Traffic and complaints
829
+ // ---------------------------------------------------------------------------
830
+
831
+ /**
832
+ * Everything the page sent, and what came back.
833
+ *
834
+ * The half a screen comparison misses entirely: a page that still shows the right thing and
835
+ * has quietly stopped saving it looks perfect in every other channel.
836
+ *
837
+ * @param {Journey} journey
838
+ * @param {WireCall[]} calls
839
+ * @param {{dirs: string[], ports: number[], projectRoot?: string}} footprint
840
+ * @returns {Observation[]}
841
+ */
842
+ export function describeTraffic(journey, calls, footprint) {
843
+ /** @type {Observation[]} */
844
+ const out = [];
845
+ for (const call of calls) {
846
+ const asked = `${call.method} ${call.pattern}`;
847
+ const where = joinPath('net', journey.name, asked);
848
+
849
+ if (call.refused) {
850
+ out.push(
851
+ notCovered({
852
+ channel: 'effects',
853
+ path: where,
854
+ reason: 'irreversible',
855
+ says: `The page asked for ${asked} and was stopped at the wire. ${call.why} That it asked, and the shape of what it was sending, are compared; what would have come back is not, because it was never allowed to happen.`,
856
+ }),
857
+ );
858
+ if (call.sends !== undefined) {
859
+ out.push(
860
+ observation({
861
+ channel: 'effects',
862
+ path: `${where}.what it was sending`,
863
+ value: call.sends,
864
+ says: `The fields the page was sending to ${asked}, and what type each one is. The values are not kept - they are somebody's name and address; the shape is the promise, and a field that disappeared from it is a real break.`,
865
+ journey: journey.name,
866
+ surface: 'web',
867
+ }),
868
+ );
869
+ }
870
+ continue;
871
+ }
872
+
873
+ out.push(
874
+ observation({
875
+ channel: 'effects',
876
+ path: where,
877
+ value: { 'asked for': countBucket(call.times), what: call.kind },
878
+ says: `The page asked for ${asked}${call.times > 1 ? ` ${call.times} times` : ''}. A call that used to go out and no longer does is one of the most common ways a page keeps looking right while having stopped working.`,
879
+ journey: journey.name,
880
+ surface: 'web',
881
+ }),
882
+ );
883
+ if (call.sends !== undefined) {
884
+ out.push(
885
+ observation({
886
+ channel: 'effects',
887
+ path: `${where}.what it sends`,
888
+ value: call.sends,
889
+ says: `The fields the page sends to ${asked}, and what type each one is. The values themselves are not kept.`,
890
+ journey: journey.name,
891
+ surface: 'web',
892
+ }),
893
+ );
894
+ }
895
+ if (call.failed) {
896
+ out.push(
897
+ observation({
898
+ channel: 'complaints',
899
+ path: `${where}.never finished`,
900
+ value: call.failed,
901
+ says: `${asked} never finished: ${call.failed}.`,
902
+ journey: journey.name,
903
+ surface: 'web',
904
+ }),
905
+ );
906
+ }
907
+ if (call.status !== undefined) {
908
+ out.push(
909
+ observation({
910
+ channel: 'results',
911
+ path: joinPath('api', journey.name, asked, 'answered'),
912
+ value: call.status,
913
+ says: `${asked} answered ${call.status}${call.status >= 400 ? ', which is a refusal' : ''}.`,
914
+ journey: journey.name,
915
+ surface: 'web',
916
+ }),
917
+ );
918
+ }
919
+ if (call.shape !== undefined) {
920
+ out.push(
921
+ observation({
922
+ channel: 'results',
923
+ path: joinPath('api', journey.name, asked, 'the fields it sends back'),
924
+ value: call.shape,
925
+ says: `The fields ${asked} sends back and what type each one is. This holds still while the values churn, so a renamed or dropped field shows up on its own instead of buried inside a diff of the whole answer.`,
926
+ journey: journey.name,
927
+ surface: 'web',
928
+ }),
929
+ );
930
+ }
931
+ if (call.answered !== undefined) {
932
+ const value = typeof call.answered === 'string' ? undoOurFootprint(call.answered, footprint) : call.answered;
933
+ out.push(
934
+ observation({
935
+ channel: 'results',
936
+ path: joinPath('api', journey.name, asked, 'what it sent back'),
937
+ value,
938
+ says: `What ${asked} sent back.`,
939
+ journey: journey.name,
940
+ surface: 'web',
941
+ }),
942
+ );
943
+ }
944
+ }
945
+ return out;
946
+ }
947
+
948
+ /**
949
+ * What the page complained about.
950
+ *
951
+ * Addressed by a stripped-down version of the message rather than by the order the messages
952
+ * arrived in, so a new error is a new address and an existing one stays where it is however
953
+ * many others turn up around it.
954
+ *
955
+ * @param {Journey} journey
956
+ * @param {string[]} messages
957
+ * @returns {Observation[]}
958
+ */
959
+ export function describeComplaints(journey, messages) {
960
+ /** @type {Map<string, {text: string, times: number}>} */
961
+ const grouped = new Map();
962
+ for (const message of messages) {
963
+ const key = complaintKey(message);
964
+ const found = grouped.get(key);
965
+ if (found) found.times += 1;
966
+ else grouped.set(key, { text: message, times: 1 });
967
+ }
968
+ return [...grouped.entries()]
969
+ .sort((a, b) => a[0].localeCompare(b[0]))
970
+ .map(([key, held]) =>
971
+ observation({
972
+ channel: 'complaints',
973
+ path: joinPath('log', journey.name, key),
974
+ value: held.text,
975
+ says: `The page complained: ${short(held.text, 160)}`,
976
+ journey: journey.name,
977
+ surface: 'web',
978
+ }),
979
+ );
980
+ }
981
+
982
+ /**
983
+ * The shape of a complaint, with everything that changes between runs taken out of it.
984
+ * Two runs of the same broken code produce the same key; the message itself is the value.
985
+ *
986
+ * @param {string} message
987
+ * @returns {string}
988
+ */
989
+ export function complaintKey(message) {
990
+ return (
991
+ short(
992
+ String(message)
993
+ .replace(/https?:\/\/[^\s)'"]+/g, 'an address')
994
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, 'an id')
995
+ .replace(/\b\d+\b/g, 'a number')
996
+ .replace(/\s+/g, ' ')
997
+ .trim(),
998
+ 70,
999
+ ) || 'something it would not say'
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * @param {string} name
1005
+ * @returns {string}
1006
+ */
1007
+ function fileSafe(name) {
1008
+ return String(name).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'checkpoint';
1009
+ }