staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -67,43 +67,73 @@ import { globToRegExp } from '../../freeze/network.js';
67
67
  */
68
68
 
69
69
  /**
70
- * Find Playwright, in the two places it could honestly be.
70
+ * Find the browser library, in every place it could honestly be.
71
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.
72
+ * ## Two packages, not one
76
73
  *
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.
74
+ * `playwright` and `playwright-core` are the same driver. The difference is that the first
75
+ * downloads its own copy of Chromium when it installs about 150MB and the second
76
+ * downloads nothing and expects to be told where a browser already is. This tool depends on
77
+ * `playwright-core` and finds the browser itself, because it already knows how: `browsers.js`
78
+ * locates Chrome for Testing, Chrome, Edge or Chromium on the machine, and deliberately
79
+ * prefers one that is not the browser the person actually uses.
80
+ *
81
+ * Both names are tried, in both places, because a project that already drives its own tests
82
+ * with the full `playwright` should never be asked to install a second copy of the same thing.
83
+ *
84
+ * ## Why it is loaded like this
85
+ *
86
+ * With `import()` rather than named at the top of the file: a tool that cannot start at all
87
+ * because a browser library is missing is a tool that cannot tell you what is missing.
88
+ *
89
+ * ## What went wrong here before, so it does not happen twice
90
+ *
91
+ * `playwright` was removed from this package's dependencies on the grounds that nothing in
92
+ * `src/` imported it. Nothing does — this line does, and a search for a static import cannot
93
+ * see it. The result shipped: 0.7.2 told every agent that asked that web apps and sites could
94
+ * be checked "here and now", and then answered every website check with "Playwright is not
95
+ * installed, so no web page can be opened". A tool that is wrong about its own headline
96
+ * ability is worse than one that lacks it. `test/v2/web-driver.test.js` now holds the
97
+ * dependency in place by name.
80
98
  *
81
99
  * @param {object} [opts]
82
100
  * @param {string} [opts.projectRoot] The project being checked. Looked in second.
83
101
  * @returns {Promise<PlaywrightState>}
84
102
  */
85
103
  export async function loadPlaywright(opts = {}) {
86
- const install = 'npm install --save-dev playwright';
104
+ const install = 'npm install playwright-core';
87
105
  /** @type {any} */
88
106
  let mod = null;
89
107
  /** @type {string|undefined} */
90
108
  let version;
109
+ /** @type {string|null} */
110
+ let loadedName = null;
91
111
 
92
112
  /** @param {any} loaded */
93
113
  const unwrap = (loaded) => (loaded && loaded.chromium ? loaded : (loaded?.default ?? null));
94
114
 
95
- try {
96
- mod = unwrap(await import('playwright'));
97
- } catch {
98
- // Not beside us. Try the project we were pointed at.
115
+ // `playwright` first, because a project that has the full package has a browser downloaded
116
+ // with it, and using that is one less thing to go looking for.
117
+ for (const name of ['playwright', 'playwright-core']) {
118
+ if (mod) break;
119
+ try {
120
+ mod = unwrap(await import(name));
121
+ if (mod) loadedName = name;
122
+ } catch {
123
+ // Not beside us under this name. Try the next, then the project we were pointed at.
124
+ }
99
125
  }
100
126
 
101
127
  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.
128
+ for (const name of ['playwright', 'playwright-core']) {
129
+ if (mod) break;
130
+ try {
131
+ const require = createRequire(path.join(opts.projectRoot, 'package.json'));
132
+ mod = unwrap(await import(require.resolve(name)));
133
+ if (mod) loadedName = name;
134
+ } catch {
135
+ // Not there either. That is an answer, and it is reported as one.
136
+ }
107
137
  }
108
138
  }
109
139
 
@@ -111,14 +141,14 @@ export async function loadPlaywright(opts = {}) {
111
141
  return {
112
142
  ok: false,
113
143
  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.',
144
+ why: 'The browser driver 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
145
  howToGet: install,
116
146
  };
117
147
  }
118
148
 
119
149
  try {
120
150
  const require = createRequire(import.meta.url);
121
- version = String(require('playwright/package.json').version);
151
+ version = String(require(`${loadedName ?? 'playwright-core'}/package.json`).version);
122
152
  } catch {
123
153
  // A version we cannot read is not a reason to refuse to run.
124
154
  }
@@ -131,14 +161,43 @@ export async function loadPlaywright(opts = {}) {
131
161
  executable = undefined;
132
162
  }
133
163
 
134
- const there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
164
+ let there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
165
+
166
+ // The driver's own browser is not the only browser.
167
+ //
168
+ // `playwright-core` downloads nothing, so it always names a Chromium that is not there.
169
+ // That is not a failure — this tool already knows how to find a browser, and has a
170
+ // considered opinion about which one: `browsers.js` prefers Chrome for Testing over the
171
+ // browser the person actually uses, precisely so a check can never take over their
172
+ // windows, their profile or their sign-ins.
173
+ //
174
+ // So: ask it. Only when there is no browser on the machine at all is this a real "no".
175
+ /** @type {string|undefined} */
176
+ let borrowedFrom;
177
+ /** True when the browser found is the one the PERSON uses, not a separate one. */
178
+ let borrowedTheirOwn = false;
179
+ if (!there) {
180
+ try {
181
+ const { surveyBrowsers } = await import('../browsers.js');
182
+ const survey = await surveyBrowsers({ headless: true });
183
+ if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
184
+ executable = survey.chosen.binary;
185
+ borrowedFrom = survey.chosen.name;
186
+ borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
187
+ there = true;
188
+ }
189
+ } catch {
190
+ // Nothing found, or the survey itself would not run. Reported as "no browser" below.
191
+ }
192
+ }
193
+
135
194
  if (!there) {
136
195
  return {
137
196
  ok: false,
138
197
  state: 'no browser',
139
198
  chromium: mod.chromium,
140
199
  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(),
200
+ why: `The browser driver ${version ?? ''} is installed, and there is no browser on this machine for it to open. This is one command and nobody has to be asked.`.trim(),
142
201
  howToGet: 'npx playwright install chromium',
143
202
  executable,
144
203
  };
@@ -150,10 +209,35 @@ export async function loadPlaywright(opts = {}) {
150
209
  chromium: mod.chromium,
151
210
  version,
152
211
  executable,
153
- why: `Playwright ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
212
+ why: browserNote(version, borrowedFrom, borrowedTheirOwn),
154
213
  };
155
214
  }
156
215
 
216
+ /**
217
+ * What to say about the browser a check will open.
218
+ *
219
+ * "a separate application from the browser you use" used to be said whatever was found — and
220
+ * the one case where that sentence matters is the case where it is false. With no downloaded
221
+ * test browser anywhere, the survey falls back to the person's OWN browser, and this then
222
+ * told them the opposite of what was about to happen. A reassurance is only worth anything
223
+ * if it is withheld when it is not true.
224
+ *
225
+ * Exported so the wording is a test rather than a thing somebody has to notice.
226
+ *
227
+ * @param {string|undefined} version The driver version, if it said one.
228
+ * @param {string|undefined} borrowedFrom The browser found, if one had to be borrowed.
229
+ * @param {boolean} borrowedTheirOwn True when that browser is the person's own.
230
+ * @returns {string}
231
+ */
232
+ export function browserNote(version, borrowedFrom, borrowedTheirOwn) {
233
+ const v = version ?? '';
234
+ if (!borrowedFrom) return `The browser driver ${v} is here and its Chromium is downloaded, so pages can be opened.`.trim();
235
+ if (borrowedTheirOwn) {
236
+ return `The browser driver ${v} is here and the only browser on this machine is the one you use yourself (${borrowedFrom}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but it is your browser, not a separate one. \`npx playwright install chromium\` gives checks one of their own.`.trim();
237
+ }
238
+ return `The browser driver ${v} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim();
239
+ }
240
+
157
241
  /**
158
242
  * @param {string} file
159
243
  * @returns {Promise<boolean>}
@@ -192,6 +276,7 @@ async function exists(file) {
192
276
  *
193
277
  * @param {object} opts
194
278
  * @param {any} opts.chromium
279
+ * @param {string} [opts.executable] Which browser to open. From `loadPlaywright`.
195
280
  * @param {string} opts.scratchDir
196
281
  * @param {{width: number, height: number, deviceScaleFactor?: number}} [opts.viewport]
197
282
  * @param {'light'|'dark'} [opts.colorScheme]
@@ -209,6 +294,15 @@ export async function openWindow(opts) {
209
294
  await fsp.mkdir(profileDir, { recursive: true });
210
295
 
211
296
  const context = await opts.chromium.launchPersistentContext(profileDir, {
297
+ // Which browser, said out loud rather than left to the driver's default.
298
+ //
299
+ // `playwright-core` has no browser of its own, so without this it looks for one that was
300
+ // never downloaded and the launch fails with a path nobody recognises. `loadPlaywright`
301
+ // has already decided which browser this machine should open — usually Chrome for
302
+ // Testing, deliberately not the browser the person uses — and this is where that decision
303
+ // is honoured. Left out when there is nothing to say, so the full `playwright` keeps
304
+ // using the Chromium it downloaded for itself.
305
+ ...(opts.executable ? { executablePath: opts.executable } : {}),
212
306
  headless: opts.headed !== true,
213
307
  viewport,
214
308
  deviceScaleFactor,
@@ -1243,7 +1337,9 @@ export function flattenAria(nodes) {
1243
1337
  // Two things with the same name in the same place have to be told apart somehow, and
1244
1338
  // counting is the only honest way left. The count is kept per place, so it cannot
1245
1339
  // spread: adding a row to one list never renumbers another.
1246
- const key = `${scope.join(' ')}${node.role}${node.name ?? ''}`;
1340
+ // The separator is written as the escape, never as the byte. A raw NUL makes grep and
1341
+ // file(1) treat this whole module as binary and skip it without saying so.
1342
+ const key = `${scope.join(' ')}\u0000${node.role}\u0000${node.name ?? ''}`;
1247
1343
  const nth = (seen.get(key) ?? 0) + 1;
1248
1344
  seen.set(key, nth);
1249
1345
 
@@ -1419,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
1419
1515
  break;
1420
1516
  }
1421
1517
  }
1518
+ // A step that did nothing at all is almost always a word this tool does not know, and
1519
+ // saying nothing about it is the worst outcome available: the journey walks on, the sign-in
1520
+ // never happens, every page behind the login wall photographs the login page, and the run
1521
+ // comes back clean. `staysfixed init` itself shipped `{ fill: '#email', with: 'a@b.c' }`
1522
+ // as its sign-in example, and neither word is in the vocabulary.
1523
+ if (did.length === 0) {
1524
+ const known = new Set([...ACTION_ORDER, 'text', 'timeoutMs', 'name', 'note', 'act', 'checkpoint', 'describe']);
1525
+ const unknown = Object.keys(step).filter((k) => !known.has(k));
1526
+ if (unknown.length > 0) {
1527
+ throw new Error(
1528
+ `This step does nothing: ${unknown.map((k) => `\`${k}\``).join(', ')} ${unknown.length === 1 ? 'is not a word' : 'are not words'} this tool knows, so the step was skipped and whatever it was meant to do did not happen. The steps it understands are: ${ACTION_ORDER.join(', ')} — with \`text\` beside \`type\`. To type into a field: { type: '#email', text: 'a@b.c' }.`,
1529
+ );
1530
+ }
1531
+ }
1422
1532
  return did;
1423
1533
  }
1424
1534
 
@@ -36,10 +36,10 @@
36
36
  * Every refusal is reported as a hole in the check. None of them is ever reported as a pass.
37
37
  */
38
38
 
39
+ import crypto from 'node:crypto';
39
40
  import fs from 'node:fs';
40
41
  import fsp from 'node:fs/promises';
41
42
  import path from 'node:path';
42
- import { spawn } from 'node:child_process';
43
43
 
44
44
  import {
45
45
  countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
@@ -49,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
49
49
  import { freePort, looksDestructive, waitForServer } from './http.js';
50
50
  import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
51
51
  import { settle } from '../../freeze/settle.js';
52
+ import { spawnServer, stopServer } from './child.js';
52
53
  import {
53
54
  actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
54
55
  watchTheWire, whereItIs, withLimit,
@@ -70,6 +71,26 @@ const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 };
70
71
  /** Folders that never hold a page worth walking. */
71
72
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.staysfixed']);
72
73
 
74
+ /**
75
+ * Why a folder would not open, in words somebody can act on.
76
+ *
77
+ * The code alone is no use to the person who has to fix it: `EACCES` on its own has sent
78
+ * more than one person looking for a bug in the tool.
79
+ *
80
+ * @param {unknown} error
81
+ * @returns {string}
82
+ */
83
+ function whyNotOpened(error) {
84
+ const code = String(/** @type {any} */ (error)?.code ?? '');
85
+ if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
86
+ if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
87
+ if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
88
+ if (code === 'ELOOP') return 'the links in it point round in a circle';
89
+ if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files while reading it';
90
+ const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
91
+ return said === '' ? 'the reason was not given' : said;
92
+ }
93
+
73
94
  /**
74
95
  * The pages a project has, read out of its folder names.
75
96
  *
@@ -83,12 +104,29 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.nex
83
104
  * reported as needing a sample value rather than guessed at, because "we did not check
84
105
  * this" and "this is fine" must never be allowed to look alike.
85
106
  *
107
+ * A FOLDER THIS CANNOT OPEN IS NAMED, not skipped. It used to `continue` on the read that
108
+ * failed, which threw away that folder and every page underneath it without one word — and
109
+ * a page that was never listed is never walked, never counted, and never missed. The run
110
+ * then reported that nothing had changed about a section of the site it had not looked at.
111
+ * This is the same shape of bug as three others that were found and closed elsewhere in the
112
+ * tool; this was the fourth place it was living.
113
+ *
86
114
  * @param {string} root
115
+ * @param {{unreadable?: {folder: string, why: string}[]}} [collect]
116
+ * Hand in an object and every folder that could not be opened is pushed onto
117
+ * `collect.unreadable`, with the reason in plain English. Callers that pass nothing get
118
+ * the old shape back and lose the holes, so the two callers inside this file both pass
119
+ * one; `detect` turns them into something a person can act on and `journeys` turns them
120
+ * into missing coverage, which is what stops a folder nobody could read reading as a
121
+ * folder with nothing in it.
87
122
  * @returns {Promise<{url: string, file: string, needs: string[]}[]>}
88
123
  */
89
- export async function readPageRoutes(root) {
124
+ export async function readPageRoutes(root, collect = {}) {
90
125
  /** @type {Map<string, {url: string, file: string, needs: string[]}>} */
91
126
  const found = new Map();
127
+ const unreadable = collect.unreadable;
128
+ /** @type {Set<string>} */
129
+ const alreadySaid = new Set();
92
130
 
93
131
  /**
94
132
  * @param {string} base
@@ -104,7 +142,12 @@ export async function readPageRoutes(root) {
104
142
  let entries;
105
143
  try {
106
144
  entries = fs.readdirSync(dir, { withFileTypes: true });
107
- } catch {
145
+ } catch (error) {
146
+ const folder = path.relative(root, dir) || '.';
147
+ if (unreadable && !alreadySaid.has(folder)) {
148
+ alreadySaid.add(folder);
149
+ unreadable.push({ folder, why: whyNotOpened(error) });
150
+ }
108
151
  continue;
109
152
  }
110
153
  for (const entry of entries) {
@@ -163,9 +206,20 @@ export async function readPageRoutes(root) {
163
206
  * already has them), a journeys file if one was named, and the pages read out of the folder
164
207
  * names for everything neither of those covers. Never invented, never crawled.
165
208
  *
209
+ * TWO SCREENS WITH ONE NAME ARE TWO SCREENS. They used to be one: the list is built in a
210
+ * Map keyed by name, so the second `set` overwrote the first, and the screen that lost was
211
+ * never walked, never compared and never counted as a door nobody opened — missing from the
212
+ * very ledger that exists to catch exactly this. Now the second one is told apart by a
213
+ * number and both are walked. Two entries that are the same in every respect really are one
214
+ * screen written down twice, and only those are folded together.
215
+ *
166
216
  * @param {object} input
167
217
  * @param {Record<string, any>} input.config
168
218
  * @param {{url: string, file: string, needs: string[]}[]} input.pages
219
+ * @param {{folder: string, why: string}[]} [input.unreadable]
220
+ * Folders `readPageRoutes` could not open. Each becomes a journey that says it was not
221
+ * walked and why, which the engine records as missing coverage. A page behind one of them
222
+ * was never listed, so nothing else in the run would ever mention it.
169
223
  * @returns {Journey[]}
170
224
  */
171
225
  export function journeysFrom(input) {
@@ -173,19 +227,50 @@ export function journeysFrom(input) {
173
227
  const samples = config.samples ?? {};
174
228
  /** @type {Map<string, Journey>} */
175
229
  const journeys = new Map();
230
+ /** @type {Map<string, string>} name -> what that journey is, so a true duplicate is spotted */
231
+ const shapes = new Map();
232
+
233
+ /**
234
+ * A name nothing else is using. Numbering rather than overwriting: a screen that is
235
+ * dropped here is dropped everywhere, silently, for the rest of the run.
236
+ *
237
+ * @param {string} wanted
238
+ * @returns {string}
239
+ */
240
+ const freeName = (wanted) => {
241
+ if (!journeys.has(wanted)) return wanted;
242
+ for (let n = 2; ; n += 1) {
243
+ const tried = `${wanted} (${n})`;
244
+ if (!journeys.has(tried)) return tried;
245
+ }
246
+ };
176
247
 
177
248
  // v1 called these "screens" and this reads them unchanged, on purpose: nobody should have
178
249
  // to write their steps twice to get a second kind of check out of them.
179
250
  for (const screen of [...(config.screens ?? []), ...(config.journeys ?? [])]) {
180
251
  if (!screen || typeof screen !== 'object') continue;
181
- const name = String(screen.name ?? screen.url ?? 'a screen');
252
+ const wanted = String(screen.name ?? screen.url ?? 'a screen');
182
253
  /** @type {Record<string, any>[]} */
183
254
  const steps = [];
184
255
  if (screen.url !== undefined) steps.push({ act: 'open', goto: String(screen.url), note: `open ${screen.url}` });
185
256
  for (const step of screen.steps ?? []) steps.push({ act: actOf(step), ...step });
257
+
258
+ // The same name AND the same steps is one screen listed twice — the settings and the
259
+ // journeys file both naming it, most often — and folding those together loses nothing.
260
+ // A different set of steps under the same name is a different screen.
261
+ const shape = JSON.stringify(steps);
262
+ if (shapes.get(wanted) === shape) continue;
263
+ const name = freeName(wanted);
264
+ shapes.set(name, shape);
186
265
  journeys.set(name, {
187
266
  name,
188
- describe: String(screen.describe ?? screen.why ?? `walk ${name}`),
267
+ describe: String(
268
+ screen.describe ??
269
+ screen.why ??
270
+ (name === wanted
271
+ ? `walk ${name}`
272
+ : `walk ${name} — the settings name two different screens "${wanted}", so this is the second of them`)
273
+ ),
189
274
  source: 'code',
190
275
  surface: 'web',
191
276
  from: 'the project settings',
@@ -205,8 +290,7 @@ export function journeysFrom(input) {
205
290
  if (sample === undefined) unfilled.push(need);
206
291
  else url = url.replace(new RegExp(`\\[\\.{0,3}${need}\\]|:${need}`), encodeURIComponent(String(sample)));
207
292
  }
208
- const name = `page ${page.url}`;
209
- if (journeys.has(name)) continue;
293
+ const name = freeName(`page ${page.url}`);
210
294
  journeys.set(name, {
211
295
  name,
212
296
  describe: `open ${page.url} and read what the screen says`,
@@ -230,6 +314,28 @@ export function journeysFrom(input) {
230
314
  });
231
315
  }
232
316
 
317
+ // Last, and after the front-page fallback so a project made entirely of holes still gets
318
+ // one real journey. A folder nobody could open is a journey that says out loud it was not
319
+ // walked: the engine turns a journey carrying `skip` into missing coverage, which is the
320
+ // only route from here to the ledger. Without it, a page behind that folder was never
321
+ // listed, so nothing anywhere in the run would ever have mentioned it — and a page nobody
322
+ // listed reads exactly like a page that is fine.
323
+ for (const hole of input.unreadable ?? []) {
324
+ const name = freeName(`the pages under ${hole.folder}`);
325
+ journeys.set(name, {
326
+ name,
327
+ describe: `the pages under ${hole.folder}`,
328
+ source: 'code',
329
+ surface: 'web',
330
+ from: hole.folder,
331
+ channels: [],
332
+ steps: /** @type {any} */ ([]),
333
+ skip:
334
+ `"${hole.folder}" could not be opened while looking for this project's pages — ${hole.why} — so nothing under it was listed. ` +
335
+ 'Any page in there was not walked and is not in the count of pages that were. This is a hole, not a pass.',
336
+ });
337
+ }
338
+
233
339
  return [...journeys.values()].sort((a, b) => a.name.localeCompare(b.name));
234
340
  }
235
341
 
@@ -290,7 +396,16 @@ export const webAdapter = defineAdapter({
290
396
  const framework = ['next', 'react', 'vue', 'svelte', 'astro', '@remix-run/react', 'nuxt', 'solid-js', 'preact', 'vite']
291
397
  .find((name) => name in dependencies);
292
398
 
293
- const pages = await readPageRoutes(project.root);
399
+ /** @type {{unreadable: {folder: string, why: string}[]}} */
400
+ const collect = { unreadable: [] };
401
+ const pages = await readPageRoutes(project.root, collect);
402
+ if (collect.unreadable.length > 0) {
403
+ missing.push({
404
+ what: `permission to read ${collect.unreadable.length === 1 ? 'a folder' : `${collect.unreadable.length} folders`} this project's pages live in: ${collect.unreadable.map((u) => `${u.folder} (${u.why})`).join(', ')}`,
405
+ unlocks: 'listing the pages under them at all. Nothing under a folder that will not open is walked, counted, or missed, so the run is quiet about that part of the site',
406
+ howToGet: `Give this account permission to read ${collect.unreadable.length === 1 ? 'it' : 'them'} — on a Mac or Linux that is: chmod +rx ${collect.unreadable[0].folder}`,
407
+ });
408
+ }
294
409
  const address = config.url ?? config.baseUrl ?? null;
295
410
 
296
411
  if (!config.start && !address) {
@@ -338,7 +453,10 @@ export const webAdapter = defineAdapter({
338
453
 
339
454
  /** @param {import('./contract.js').AdapterProject} project */
340
455
  async journeys(project) {
341
- return journeysFrom({ config: project.config ?? {}, pages: await readPageRoutes(project.root) });
456
+ /** @type {{unreadable: {folder: string, why: string}[]}} */
457
+ const collect = { unreadable: [] };
458
+ const pages = await readPageRoutes(project.root, collect);
459
+ return journeysFrom({ config: project.config ?? {}, pages, unreadable: collect.unreadable });
342
460
  },
343
461
 
344
462
  /**
@@ -419,7 +537,7 @@ export const webAdapter = defineAdapter({
419
537
  if (!verdict.safe) notes.push(verdict.why);
420
538
  else {
421
539
  const done = await new Promise((resolve) => {
422
- const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: 'ignore' });
540
+ const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
423
541
  child.on('error', () => resolve(false));
424
542
  child.on('close', (code) => resolve(code === 0));
425
543
  });
@@ -431,7 +549,7 @@ export const webAdapter = defineAdapter({
431
549
  const said = [];
432
550
  /** @type {string|null} */
433
551
  let exited = null;
434
- const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
552
+ const child = spawnServer(String(config.start), { cwd: work, env });
435
553
  child.stdout?.on('data', (c) => said.push(c));
436
554
  child.stderr?.on('data', (c) => said.push(c));
437
555
  child.on('close', (code, signal) => {
@@ -440,14 +558,14 @@ export const webAdapter = defineAdapter({
440
558
 
441
559
  const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
442
560
  if (!up.up) {
443
- child.kill('SIGTERM');
561
+ await stopServer(child);
444
562
  return {
445
563
  build,
446
564
  root: work,
447
565
  ready: false,
448
566
  why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
449
567
  dispose: async () => {
450
- child.kill('SIGKILL');
568
+ await stopServer(child);
451
569
  await fsp.rm(base, { recursive: true, force: true });
452
570
  },
453
571
  };
@@ -466,9 +584,7 @@ export const webAdapter = defineAdapter({
466
584
  running.delete(build.id);
467
585
  if (!held) return;
468
586
  // 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');
587
+ await stopServer(held.child);
472
588
  await fsp.rm(base, { recursive: true, force: true });
473
589
  },
474
590
  };
@@ -525,6 +641,9 @@ export const webAdapter = defineAdapter({
525
641
  const viewport = viewportFrom(config);
526
642
  const window = await openWindow({
527
643
  chromium: held.playwright.chromium,
644
+ // Which browser was chosen for this machine. Without it a driver that downloads no
645
+ // browser of its own looks for one that is not there.
646
+ executable: held.playwright.executable,
528
647
  scratchDir: ctx.scratchDir,
529
648
  viewport,
530
649
  colorScheme: config.colorScheme ?? 'light',
@@ -658,7 +777,7 @@ export const webAdapter = defineAdapter({
658
777
  },
659
778
 
660
779
  async teardown() {
661
- for (const [, held] of running) held.child?.kill('SIGTERM');
780
+ for (const [, held] of running) await stopServer(held.child);
662
781
  running.clear();
663
782
  },
664
783
  });
@@ -1001,9 +1120,21 @@ export function complaintKey(message) {
1001
1120
  }
1002
1121
 
1003
1122
  /**
1123
+ * A name a picture can be saved under.
1124
+ *
1125
+ * Cut with a fingerprint on the end, never cut alone. This is a FILE name: two checkpoints
1126
+ * whose names agreed for eighty characters were saved over each other, so the picture
1127
+ * offered as evidence for one finding was a photograph of a different screen — and nothing
1128
+ * about it looked wrong. Long file names are the normal case here, because the name is the
1129
+ * build, the journey and the checkpoint run together.
1130
+ *
1004
1131
  * @param {string} name
1005
1132
  * @returns {string}
1006
1133
  */
1007
1134
  function fileSafe(name) {
1008
- return String(name).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'checkpoint';
1135
+ const clean = String(name).replace(/[^A-Za-z0-9._-]+/g, '-');
1136
+ if (clean === '') return 'checkpoint';
1137
+ if (clean.length <= 80) return clean;
1138
+ const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
1139
+ return `${clean.slice(0, 71)}-${mark}`;
1009
1140
  }
@@ -1201,7 +1201,24 @@ export const windowsAdapter = defineAdapter({
1201
1201
  // Pixels last, and only as evidence. A picture is written to the evidence folder and
1202
1202
  // pointed at; it is never the thing compared.
1203
1203
  const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
1204
- if (shot.ok && shot.png && Number(shot.bytes) <= MAX_SHOT_BYTES) {
1204
+ // THREE WAYS OUT OF HERE AND TWO OF THEM USED TO BE SILENT. A picture that failed,
1205
+ // came back empty, or came back bigger than the cap simply produced no observation
1206
+ // at all — so the pixels channel dropped out of the run without a word, and the
1207
+ // ledger reported the same coverage as a run where every window was photographed.
1208
+ // A cap is a decision and a decision has to be visible; a failure is a hole and a
1209
+ // hole has to be named. Neither is a reason to lose the rest of the walk.
1210
+ const tooBig = shot.ok === true && Boolean(shot.png) && Number(shot.bytes) > MAX_SHOT_BYTES;
1211
+ if (!shot.ok || !shot.png || tooBig) {
1212
+ seen.push(notCovered({
1213
+ channel: 'pixels',
1214
+ path: joinPath('screen', label, 'picture'),
1215
+ reason: tooBig ? 'too big' : 'crashed',
1216
+ says: tooBig
1217
+ ? `The picture of "${label}" came back at ${sizeBucket(Number(shot.bytes))}, over the ${sizeBucket(MAX_SHOT_BYTES)} this keeps, so it was not stored. Every other channel still looked at that window; only the picture is missing.`
1218
+ : `No picture of "${label}" could be taken${shot.error ? `: ${String(shot.error)}` : '.'} Every other channel still looked at that window; only the picture is missing.`,
1219
+ }));
1220
+ }
1221
+ if (shot.ok && shot.png && !tooBig) {
1205
1222
  const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
1206
1223
  await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
1207
1224
  seen.push(observation({