staysfixed 0.11.1 → 0.13.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 (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
package/src/cli/trace.js CHANGED
@@ -6,7 +6,7 @@ import { loadProject } from '../core/config.js';
6
6
  import { projectStatus } from '../run.js';
7
7
  import { traceScreens } from '../marker/trace.js';
8
8
  import { printTrace } from '../report/console.js';
9
- import { say, paint } from '../core/log.js';
9
+ import { say, blank, paint } from '../core/log.js';
10
10
  import { resultPicture } from '../core/paths.js';
11
11
  import { sha256File } from '../core/hash.js';
12
12
  import { EXIT } from '../core/errors.js';
@@ -16,12 +16,37 @@ import { EXIT } from '../core/errors.js';
16
16
  * @returns {Promise<number>}
17
17
  */
18
18
  export async function run(ctx) {
19
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
19
+ // `opening: false` tracing compares fingerprints already written down against markers
20
+ // already written down. It opens nothing. Where there is nothing recorded to trace, the
21
+ // report says so in its own words further down; being refused before it could even look
22
+ // was the wrong answer, and it named a settings key that version 2 never writes.
23
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
20
24
 
21
25
  const asked = ctx.args.filter((a) => a.trim() !== '');
22
26
  const changed = asked.length > 0 ? [] : await changedInLastRun(project);
23
27
  const names = asked.length > 0 ? asked : changed;
24
28
 
29
+ // A project with nothing to photograph cannot be traced, and it is owed a sentence saying
30
+ // why rather than a shrug and a suggestion it has already followed.
31
+ //
32
+ // The generic ending is "There is nothing to trace yet. Pin a good version first with
33
+ // `staysfixed mark`." — which, on a project that had just pinned one, told somebody to go
34
+ // and do the thing they had done thirty seconds earlier. Measured 2026-08-31 on a Python
35
+ // command-line tool. Tracing follows a SCREEN backwards, so on a product with no screen
36
+ // the answer is not "not yet", it is "not this kind of project", and saying the second one
37
+ // stops somebody working through a list that was never going to end.
38
+ if (asked.length === 0 && names.length === 0 && project.config.screens.length === 0) {
39
+ blank();
40
+ say('There is no screen in this project to trace.');
41
+ say(paint.grey('Tracing follows one screen back through your markers to the commit where it stopped'));
42
+ say(paint.grey('looking right, so it needs a picture to follow. These settings name none.'));
43
+ blank();
44
+ say(`For what changed in a project without a screen, run ${paint.cyan('staysfixed check')} — it compares every`);
45
+ say('word a command printed, what it exited with and every file it touched.');
46
+ blank();
47
+ return EXIT.ok;
48
+ }
49
+
25
50
  if (asked.length === 0) {
26
51
  if (names.length === 0) {
27
52
  say(paint.grey('Nothing is different right now, so this looks at every screen there is a record of.'));
@@ -83,9 +83,40 @@ export const DEFAULT_MCP = {
83
83
  allowMark: false,
84
84
  };
85
85
 
86
+ /**
87
+ * The `app.kind` of a project that has nothing to open at all.
88
+ *
89
+ * A command-line tool, a library and a plain server are all perfectly ordinary products with
90
+ * no screen anywhere in them, and every command that only READS what is on disk — status,
91
+ * flake, mark, trace, approve — works on one exactly as well as it works on a website. They
92
+ * were all refused anyway, because the only way to load settings was through a check that
93
+ * insisted on something to open. This value is how a command says "I open nothing, so do not
94
+ * ask", and it is a made-up word on purpose: nothing can accidentally match it, and anything
95
+ * that reads it and does not understand it fails loudly rather than photographing a guess.
96
+ */
97
+ export const NOTHING_TO_OPEN = 'nothing-to-open';
98
+
99
+ /**
100
+ * Do these settings name anything this tool could open and photograph?
101
+ *
102
+ * Exported so a command can ask before it tries, and say something true about this project
103
+ * instead of the same refusal for every shape of product.
104
+ *
105
+ * @param {import('../types.js').ResolvedConfig} config
106
+ * @returns {boolean}
107
+ */
108
+ export function hasSomethingToOpen(config) {
109
+ // Read as a plain string on purpose. The declared shape of `app.kind` is version 1's two
110
+ // words, and this third one is deliberately outside it — see NOTHING_TO_OPEN above.
111
+ return /** @type {string} */ (config?.app?.kind) !== NOTHING_TO_OPEN;
112
+ }
113
+
86
114
  /**
87
115
  * Find, import and resolve the config.
88
- * @param {{cwd?: string, configFile?: string}} [opts]
116
+ * @param {{cwd?: string, configFile?: string, opening?: boolean}} [opts]
117
+ * `opening: false` is a command promising it will not open or photograph anything — it
118
+ * only reads what is already on disk. Settings with no screen in them are then a normal,
119
+ * correct shape rather than a reason to refuse.
89
120
  * @returns {Promise<import('../types.js').Project>}
90
121
  */
91
122
  export async function loadProject(opts = {}) {
@@ -98,7 +129,7 @@ export async function loadProject(opts = {}) {
98
129
  }
99
130
  const raw = await importConfig(file);
100
131
  const root = rootForConfig(file);
101
- const config = resolveConfig(raw, file);
132
+ const config = resolveConfig(raw, file, { opening: opts.opening });
102
133
  const paths = pathsFor(root, file, config.dir);
103
134
  // `guards` is the one folder a project is free to move, so the config wins over the
104
135
  // default layout. Without this the setting silently did nothing and the tool reported
@@ -136,9 +167,11 @@ async function importConfig(file) {
136
167
  * Fill in defaults and reject anything that would fail later in a confusing way.
137
168
  * @param {unknown} raw
138
169
  * @param {string} file
170
+ * @param {{opening?: boolean}} [opts]
171
+ * `opening: false` from a command that only reads what is on disk. See {@link loadProject}.
139
172
  * @returns {import('../types.js').ResolvedConfig}
140
173
  */
141
- export function resolveConfig(raw, file = '(inline)') {
174
+ export function resolveConfig(raw, file = '(inline)', opts = {}) {
142
175
  if (!raw || typeof raw !== 'object') {
143
176
  throw new StaysFixedError(`${path.basename(file)} did not export a config object.`, {
144
177
  hint: 'It should `export default { app: { ... }, screens: [ ... ] }`.',
@@ -158,8 +191,8 @@ export function resolveConfig(raw, file = '(inline)') {
158
191
  // Where the address is actually knowable, take it and let the command work. Booting is
159
192
  // version 2's job and these commands cannot do it, so `web.start` alone is not enough —
160
193
  // that case falls through to the message below, which now says so honestly.
194
+ const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
161
195
  if ((!c.app || typeof c.app !== 'object')) {
162
- const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
163
196
  if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
164
197
  c.app = { kind: 'web', url: v2.web.url };
165
198
  } else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
@@ -167,15 +200,49 @@ export function resolveConfig(raw, file = '(inline)') {
167
200
  }
168
201
  }
169
202
 
203
+ // The screens come across with the address, and they did not before.
204
+ //
205
+ // Half a bridge is worse than none: on a version 2 website `walk` and `approve` were
206
+ // handed the address and then found nothing to photograph, because version 2 keeps its
207
+ // screens under `web` and these commands only ever looked at the top level. So the
208
+ // commands ran, opened a browser, and reported an empty walk of a site with six pages in
209
+ // its settings — measured 2026-08-31. Only a screen with a name and a plain address is
210
+ // carried over; anything reached by clicking is version 2's to walk, not this half's, and
211
+ // inventing a journey out of one would put a screen in the report nobody can reach.
212
+ if (c.app && typeof c.app === 'object' && !Array.isArray(c.screens)) {
213
+ const fromV2 = Array.isArray(v2.web?.screens) ? v2.web.screens : [];
214
+ const carried = fromV2.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && typeof s.url === 'string' && s.url !== '');
215
+ if (carried.length > 0) c.screens = carried.map((/** @type {any} */ s) => ({ name: s.name, url: s.url }));
216
+ }
217
+
218
+ // A command that opens nothing must never be refused for having nothing to open.
219
+ //
220
+ // `status`, `flake`, `mark`, `trace` and `approve` all do their whole job by reading files
221
+ // this tool has already written. Every one of them was dead on the settings this tool's own
222
+ // `init` writes for a command-line tool, a library or a server — five commands offered in
223
+ // `--help`, all answering with a paragraph about an `app` key that version 2 never writes
224
+ // and that nobody running them had ever seen. Measured 2026-08-31 on a Python command-line
225
+ // tool and on a plain Node one; both were set up by `staysfixed init` seconds earlier.
226
+ if ((!c.app || typeof c.app !== 'object') && opts.opening === false) {
227
+ c.app = /** @type {any} */ ({ kind: NOTHING_TO_OPEN });
228
+ }
229
+
170
230
  if (!c.app || typeof c.app !== 'object') {
171
- // Every command that lands here status, walk, approve, mark, trace, flake, and
172
- // `check --pictures` works by OPENING something and photographing it. A settings
173
- // file with no `app` in it is the normal, correct shape for a command-line tool, a
174
- // library or a server: there is nothing to open, and telling somebody to go and add a
175
- // web address they do not have sends them off inventing one. So the message says which
176
- // half of the tool needs it, and names the half that does not.
231
+ // Only the commands that really do open something land here now `walk`, and
232
+ // `check --pictures`. They photograph a screen, so a project with no screen anywhere in
233
+ // it genuinely cannot be walked, and the honest thing is to say which product this is
234
+ // and stop offering it.
235
+ //
236
+ // What this message must never do is name a key the person has not got. `app` is version
237
+ // 1's word for the thing to open; the settings `staysfixed init` writes today have no
238
+ // `app` in them and never will, so telling somebody to add one sends them editing a file
239
+ // against a shape nothing else in the tool uses. Where there IS advice worth giving, it
240
+ // is given in the words their own settings file already uses — `url` inside the `web`
241
+ // block — and only for a file that is written that way.
177
242
  const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
178
- const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
243
+ const versionTwo = ['process', 'source', 'http', 'web', 'electron', 'android', 'ios', 'windows']
244
+ .filter((k) => anything[k] && typeof anything[k] === 'object');
245
+ const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => versionTwo.includes(k));
179
246
  // A project that DOES have a screen, described the version 2 way, must never be told it
180
247
  // has none. It is told the true thing instead: this half of the tool photographs an
181
248
  // address you can point it at, and version 2 finds the address by booting the product,
@@ -186,23 +253,36 @@ export function resolveConfig(raw, file = '(inline)') {
186
253
  hint: "`staysfixed check` covers it exactly as it is — it boots `web.start` and finds the address itself. These picture commands need one they can point at, so add `url: 'http://localhost:3000'` beside `start` in the `web` block if you want them too.",
187
254
  });
188
255
  }
256
+ if (versionTwo.length > 0) {
257
+ throw new StaysFixedError(
258
+ `This command photographs a screen, and this project has none — these settings describe ${plainList(versionTwo.map(describeBlock))}.`,
259
+ {
260
+ hint: `Nothing is missing and nothing needs adding. Run \`staysfixed check\`, which covers ${notVisual.length === versionTwo.length ? 'exactly what is here' : 'all of it'} without a picture.`
261
+ + (versionTwo.includes('web') ? " If you want the picture commands on the site too, add `url: 'http://localhost:3000'` inside the `web` block so there is an address to point at." : ''),
262
+ },
263
+ );
264
+ }
189
265
  throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
190
- hint: notVisual.length
191
- ? `That is the right shape for what this project is — ${notVisual.join(', ')} settings need nothing to open. Run \`staysfixed check\`, which covers it without a picture. If there IS a screen here too, add \`app: { kind: 'web', url: 'http://localhost:3000' }\` or \`app: { kind: 'electron', binary: '...' }\`.`
192
- : "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
266
+ hint: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
193
267
  });
194
268
  }
195
- const kind = c.app.kind;
196
- if (kind !== 'web' && kind !== 'electron') {
197
- throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
198
- }
199
- if (kind === 'web' && !c.app.url && !c.app.attach) {
200
- throw new StaysFixedError('A web app needs `app.url` — the address to open.');
201
- }
202
- if (kind === 'electron' && !c.app.binary && !c.app.attach) {
203
- throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
204
- hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
205
- });
269
+ // Read as a plain string, because a project with nothing to open carries a third value
270
+ // that is deliberately outside version 1's two see NOTHING_TO_OPEN at the top.
271
+ const kind = /** @type {string} */ (c.app.kind);
272
+ // A command that told us it opens nothing gets no further questions. Asking a project with
273
+ // no screen for an address or a binary is the refusal this whole branch exists to stop.
274
+ if (kind !== NOTHING_TO_OPEN) {
275
+ if (kind !== 'web' && kind !== 'electron') {
276
+ throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
277
+ }
278
+ if (kind === 'web' && !c.app.url && !c.app.attach) {
279
+ throw new StaysFixedError('A web app needs `app.url` — the address to open.');
280
+ }
281
+ if (kind === 'electron' && !c.app.binary && !c.app.attach) {
282
+ throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
283
+ hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
284
+ });
285
+ }
206
286
  }
207
287
 
208
288
  const screens = (c.screens ?? []).map((s, i) => resolveScreen(s, i));
@@ -285,3 +365,34 @@ export function settingsForScreen(config, screen) {
285
365
  masks: [...config.masks, ...(screen.masks ?? [])],
286
366
  };
287
367
  }
368
+
369
+ /**
370
+ * What one block of version 2 settings actually is, in words somebody who did not write this
371
+ * tool would use. Said out loud in a refusal so the sentence names this project rather than
372
+ * naming a missing key.
373
+ *
374
+ * @param {string} key
375
+ * @returns {string}
376
+ */
377
+ function describeBlock(key) {
378
+ return /** @type {Record<string,string>} */ ({
379
+ process: 'commands to run and libraries to import',
380
+ source: 'code to read without running it',
381
+ http: 'a server to boot and ask for its routes',
382
+ web: 'a website to open',
383
+ electron: 'a desktop app to open',
384
+ android: 'an Android app',
385
+ ios: 'an iPhone app',
386
+ windows: 'a native Windows app',
387
+ })[key] ?? key;
388
+ }
389
+
390
+ /**
391
+ * A list a person reads out loud: "a, b and c". Two commas and an "and" beats three commas.
392
+ * @param {string[]} items
393
+ * @returns {string}
394
+ */
395
+ function plainList(items) {
396
+ if (items.length <= 1) return items[0] ?? '';
397
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
398
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Stopping a command AND everything that command started — on every operating system.
3
+ *
4
+ * Almost every command this tool runs is really a shell: `npm test`, `npm run dev`,
5
+ * `poetry run uvicorn ...`. The shell then starts the real program, which often starts
6
+ * another one. So the process this tool holds a handle to is the shell, and the work is
7
+ * happening in its children and grandchildren.
8
+ *
9
+ * Killing the shell is not killing the work. On Linux the children carry on with a new
10
+ * parent; on a Mac they usually die with the shell, which is exactly why this was invisible
11
+ * for so long. The answer there is to put the shell and everything it starts into one
12
+ * process GROUP and signal the group, which is what a negative process id means.
13
+ *
14
+ * WINDOWS HAS NO PROCESS GROUPS OF THAT KIND, and the code here used to say so and give up —
15
+ * it killed the one process it knew about and hoped. It does not work: measured on a real
16
+ * Windows 11 machine on 2026-08-31, a command the guard had already given up on kept running,
17
+ * finished its work and wrote its file, because only `cmd.exe` had been killed and the `node`
18
+ * underneath it never noticed. Windows does have an answer, it is just spelled differently:
19
+ * `taskkill /T` walks the tree of children and stops all of them. This file is the one place
20
+ * that difference is written down, so no caller has to remember it again.
21
+ */
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ /** Windows spells "stop this and everything under it" as a command, not a signal. */
26
+ const TASKKILL = 'taskkill';
27
+
28
+ /**
29
+ * Stop a process and every process it started.
30
+ *
31
+ * Safe to call more than once, safe to call on something already gone, and safe to call from
32
+ * an exit handler — nothing here is asynchronous, because a process on its way out has no
33
+ * event loop left to wait on.
34
+ *
35
+ * @param {number|null|undefined} pid The process this tool started.
36
+ * @param {'SIGTERM'|'SIGKILL'} signal SIGTERM asks; SIGKILL insists.
37
+ * @param {{child?: import('node:child_process').ChildProcess|null}} [opts]
38
+ * The handle, when the caller has one. It is the fallback if the tree walk fails.
39
+ * @returns {boolean} true when something was asked to stop, false when there was nothing to ask.
40
+ */
41
+ export function stopTree(pid, signal, opts = {}) {
42
+ const child = opts.child ?? null;
43
+ if (!pid) {
44
+ if (!child) return false;
45
+ return tryKill(child, signal);
46
+ }
47
+
48
+ if (process.platform === 'win32') {
49
+ // `/T` takes the children, `/F` insists — and on Windows BOTH are always used, including
50
+ // for the polite SIGTERM. There is no polite stop for a console program there: `taskkill`
51
+ // without `/F` sends a window a close message, which `node` has no window to receive, so
52
+ // it refuses with "this process can only be terminated forcefully" and nothing stops.
53
+ //
54
+ // Nothing is lost by that, because Node's own `child.kill('SIGTERM')` on Windows is
55
+ // already an outright TerminateProcess — the ONLY difference this line makes is that the
56
+ // children go too. Measured on a real Windows 11 machine on 2026-08-31: the first version
57
+ // of this file tried the polite form, watched it refuse, and fell back to killing the
58
+ // shell alone — which left the server running AND made it look like it had stopped,
59
+ // because the handle the caller was watching had gone. The scratch folder could then not
60
+ // be deleted, and the whole of `waiting.test.js` failed on it.
61
+ const ran = spawnSync(TASKKILL, ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
62
+ if (ran.status === 0) return true;
63
+ // taskkill was not there, or the process had already finished — which is the outcome the
64
+ // caller wanted anyway. Stopping the one process this tool definitely knows about is
65
+ // weaker than stopping the tree, and it is a great deal better than leaving it running.
66
+ return child ? tryKill(child, signal) : false;
67
+ }
68
+
69
+ try {
70
+ // A negative process id is the GROUP. This is the line that takes the watchers,
71
+ // bundlers and servers down with the shell that started them.
72
+ process.kill(-pid, signal);
73
+ return true;
74
+ } catch {
75
+ // No group — which happens when the caller did not start it detached — or it is already
76
+ // gone. Either way, ask the one process we know about.
77
+ if (child) return tryKill(child, signal);
78
+ try {
79
+ process.kill(pid, signal);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {import('node:child_process').ChildProcess} child
89
+ * @param {'SIGTERM'|'SIGKILL'} signal
90
+ * @returns {boolean}
91
+ */
92
+ function tryKill(child, signal) {
93
+ try {
94
+ return child.kill(signal);
95
+ } catch {
96
+ // Already gone, which is the outcome wanted.
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Should a command be started in a process group of its own?
103
+ *
104
+ * Only where process groups exist. On Windows `detached: true` does something else entirely —
105
+ * it gives the child its own console WINDOW, which flashes up on the person's screen in the
106
+ * middle of a check and is never what this tool wants. Windows gets its tree walk from
107
+ * `stopTree` instead, which needs nothing at spawn time.
108
+ */
109
+ export const OWN_PROCESS_GROUP = process.platform !== 'win32';
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
 
15
15
  import { StaysFixedError, isExpected } from '../core/errors.js';
16
16
  import { detail } from '../core/log.js';
17
+ import { stopTree, OWN_PROCESS_GROUP } from '../core/stop-tree.js';
17
18
  import { DEFAULT_VIEWPORT } from '../core/config.js';
18
19
  import { waitForEndpoint, listTargets, connect } from './cdp.js';
19
20
  import { requireChrome, freePort } from './find.js';
@@ -116,20 +117,17 @@ export function whenExited(child) {
116
117
  export async function stopProcess(child, graceMs) {
117
118
  if (isGone(child)) return;
118
119
  const exited = whenExited(child);
119
- try {
120
- child.kill('SIGTERM');
121
- } catch {
122
- // Already gone between the check and the signal. Nothing to do.
123
- }
120
+ // The tree, not just this one process. A browser is never one process — it is a parent and
121
+ // a renderer for every page — and on Windows killing only the parent leaves the renderers
122
+ // running, still writing into the throwaway profile, so the profile folder cannot be
123
+ // deleted and outlives the run. That is the one thing "nothing it opened outlives the run"
124
+ // promises. Measured on a real Windows 11 machine on 2026-08-31.
125
+ stopTree(child.pid, 'SIGTERM', { child });
124
126
  const grace = raceTimer(graceMs, false);
125
127
  const stopped = await Promise.race([exited.then(() => true), grace.promise]);
126
128
  grace.cancel();
127
129
  if (stopped) return;
128
- try {
129
- child.kill('SIGKILL');
130
- } catch {
131
- // Same race as above.
132
- }
130
+ stopTree(child.pid, 'SIGKILL', { child });
133
131
  const last = raceTimer(1000, false);
134
132
  await Promise.race([exited, last.promise]);
135
133
  last.cancel();
@@ -458,7 +456,11 @@ export async function startWebApp(app, opts = {}) {
458
456
  // Its own process group. A dev server is really a shell that spawns a
459
457
  // bundler that spawns a watcher; killing only the shell leaves the port held
460
458
  // and the next run fails for a reason nobody can see.
461
- detached: true,
459
+ //
460
+ // Not on Windows, where `detached: true` means something else entirely — a console
461
+ // WINDOW of its own, flashing up on the person's screen in the middle of a check.
462
+ // Windows stops the tree a different way, in `stopTree`, and needs nothing at spawn time.
463
+ detached: OWN_PROCESS_GROUP,
462
464
  stdio: ['ignore', 'pipe', 'pipe'],
463
465
  });
464
466
  const output = keepOutput(child);
@@ -478,30 +480,17 @@ export async function startWebApp(app, opts = {}) {
478
480
  stopping ??= (async () => {
479
481
  if (isGone(child)) return;
480
482
  const exited = whenExited(child);
481
- try {
482
- // Negative pid means the whole group the only way to take the
483
- // watchers down with the server.
484
- if (pid) process.kill(-pid, 'SIGTERM');
485
- } catch {
486
- try {
487
- child.kill('SIGTERM');
488
- } catch {
489
- // Already gone.
490
- }
491
- }
483
+ // The whole tree, not just the shell. On Linux and a Mac that is the process group;
484
+ // on Windows it is `taskkill /T`, which is what `stopTree` reaches for. Before this,
485
+ // Windows killed `cmd.exe` and left the dev server holding the port, so the next run
486
+ // failed for a reason nobody could see — the exact outcome the comment above warns
487
+ // about, on the one operating system where the code did not do it. Found 2026-08-31.
488
+ stopTree(pid, 'SIGTERM', { child });
492
489
  const grace = raceTimer(5000, false);
493
490
  const gone = await Promise.race([exited.then(() => true), grace.promise]);
494
491
  grace.cancel();
495
492
  if (gone) return;
496
- try {
497
- if (pid) process.kill(-pid, 'SIGKILL');
498
- } catch {
499
- try {
500
- child.kill('SIGKILL');
501
- } catch {
502
- // Already gone.
503
- }
504
- }
493
+ stopTree(pid, 'SIGKILL', { child });
505
494
  const last = raceTimer(1000, false);
506
495
  await Promise.race([exited, last.promise]);
507
496
  last.cancel();
package/src/drive/page.js CHANGED
@@ -150,6 +150,73 @@ function removeStyleTagSource(token) {
150
150
  );
151
151
  }
152
152
 
153
+ /**
154
+ * What somebody handed `evaluate`, turned into a piece of JavaScript the app can run.
155
+ *
156
+ * A STRING IS NOT THE OBVIOUS THING TO PASS. Every other tool in this space takes a
157
+ * function — `page.evaluate(() => document.title)` is what anybody who has driven a browser
158
+ * before writes first — and this took only text. Handing it a function put a function object
159
+ * where the debug protocol wanted a string, and what came back was, in full, measured while
160
+ * using the tool on 2026-08-31:
161
+ *
162
+ * The app refused the request "Runtime.evaluate": Invalid parameters
163
+ *
164
+ * That is the machine's own words about its own wire format, said to somebody who has done
165
+ * nothing wrong except write the thing that works everywhere else. So a function is now
166
+ * accepted and turned into the call it obviously means, and text goes through untouched.
167
+ * Only what genuinely cannot be run says so — in a sentence naming what it was given and
168
+ * showing the one line that works.
169
+ *
170
+ * It runs with nothing passed to it, which is why a function that declares a parameter is
171
+ * refused rather than quietly given `undefined`: there is no way to send a value into the
172
+ * page here, and the alternative is a guard failing inside the app for a reason that has
173
+ * nothing to do with the app.
174
+ *
175
+ * @param {unknown} what A piece of JavaScript as text, or a function to call in the page.
176
+ * @returns {string}
177
+ */
178
+ export function asJavaScript(what) {
179
+ if (typeof what === 'string') return what;
180
+
181
+ if (typeof what === 'function') {
182
+ const source = String(what);
183
+ // A built-in — `page.evaluate(Math.max)`, `page.evaluate(document.querySelector)` — has
184
+ // no readable body, so there is nothing to send. Asked FIRST, before anything about the
185
+ // arguments: a built-in usually declares some, and being told to close over them is
186
+ // advice about a function nobody could have sent anyway. Said plainly, too, because
187
+ // "SyntaxError: Unexpected token" out of the page is a worse version of the message this
188
+ // whole function exists to replace.
189
+ if (/\{\s*\[native code\]\s*\}/.test(source)) {
190
+ throw new StaysFixedError('evaluate() was handed a built-in function, and the app cannot be sent one: it has no source to run.', {
191
+ hint: 'Wrap it in a function of your own: page.evaluate(() => document.querySelector(".total").textContent).',
192
+ });
193
+ }
194
+ if (what.length > 0) {
195
+ throw new StaysFixedError(
196
+ `evaluate() runs a function inside the app with nothing passed to it, and this one asks for ${what.length === 1 ? 'an argument' : `${what.length} arguments`}.`,
197
+ {
198
+ hint: 'Nothing can be sent into the page here. Close over what it needs, or write the value into the JavaScript itself: page.evaluate(`document.title === ${JSON.stringify(expected)}`).',
199
+ },
200
+ );
201
+ }
202
+ // Shorthand method syntax — `{ title() { ... } }` — is not an expression on its own, so
203
+ // the ordinary wrapping below would send the app something it cannot parse. Put back in
204
+ // the object it was written in and called by name.
205
+ //
206
+ // A leading `async` is taken off before the name is read, and it has to be: leave it on
207
+ // and the pattern happily reads `async () => 1` as a method called "async", because
208
+ // backtracking gives up the optional keyword and matches the word itself.
209
+ const shorthand = /^(?!function\b)([A-Za-z_$][\w$]*)\s*\(/.exec(source.replace(/^async\s+/, ''));
210
+ if (shorthand) return `({ ${source} }).${shorthand[1]}()`;
211
+ return `(${source})()`;
212
+ }
213
+
214
+ throw new StaysFixedError(
215
+ `evaluate() wants a piece of JavaScript written as text, or a function to run in the app. It was given ${what === null ? 'null' : typeof what}.`,
216
+ { hint: 'Either way round works: page.evaluate(\'document.title\') or page.evaluate(() => document.title).' },
217
+ );
218
+ }
219
+
153
220
  /**
154
221
  * One line describing whatever the page threw.
155
222
  * @param {any} details Runtime.ExceptionDetails
@@ -283,12 +350,17 @@ export async function createPage(cdp, opts) {
283
350
  // ---------------------------------------------------------------------------
284
351
 
285
352
  /**
286
- * @param {string} js
353
+ * Run JavaScript in the page.
354
+ *
355
+ * Takes it as text, or as a function to call — see {@link asJavaScript} for why both, and
356
+ * for the message that used to come back when somebody wrote the function.
357
+ *
358
+ * @param {string|Function} js
287
359
  * @returns {Promise<any>}
288
360
  */
289
361
  async function evaluate(js) {
290
362
  const res = await send('Runtime.evaluate', {
291
- expression: js,
363
+ expression: asJavaScript(js),
292
364
  awaitPromise: true,
293
365
  returnByValue: true,
294
366
  userGesture: true,
package/src/guard/api.js CHANGED
@@ -19,6 +19,7 @@ import { spawn } from 'node:child_process';
19
19
  import fsp from 'node:fs/promises';
20
20
  import path from 'node:path';
21
21
  import { StaysFixedError } from '../core/errors.js';
22
+ import { stopTree, OWN_PROCESS_GROUP } from '../core/stop-tree.js';
22
23
 
23
24
  /** A plain-language expectation that did not hold. */
24
25
  export class ExpectationFailed extends Error {
@@ -313,7 +314,7 @@ export function makeGuardApi(page, project, opts = {}) {
313
314
  const child = spawn(cmd, {
314
315
  cwd,
315
316
  shell: true,
316
- detached: process.platform !== 'win32',
317
+ detached: OWN_PROCESS_GROUP,
317
318
  windowsHide: true,
318
319
  });
319
320
 
@@ -324,15 +325,19 @@ export function makeGuardApi(page, project, opts = {}) {
324
325
  let how = 'ran';
325
326
  let done = false;
326
327
 
327
- /** Stop the shell AND everything it started. */
328
+ /**
329
+ * Stop the shell AND everything it started.
330
+ *
331
+ * This used to kill only the child on Windows, with a comment saying that was the
332
+ * best that could be done there. It is not: measured on a real Windows 11 machine on
333
+ * 2026-08-31, "kills a command that was still running when the run gave up" failed,
334
+ * because killing `cmd.exe` left the `node` underneath it running and it finished its
335
+ * work and wrote its file after the run had given up on it — the same defect Linux
336
+ * showed on 2026-08-31, on a different operating system's spelling of it. `stopTree`
337
+ * holds both spellings.
338
+ */
328
339
  const stopEverything = () => {
329
- if (!child.pid) return;
330
- try {
331
- if (process.platform === 'win32') child.kill('SIGKILL');
332
- else process.kill(-child.pid, 'SIGKILL');
333
- } catch {
334
- // Already gone, which is the good case.
335
- }
340
+ stopTree(child.pid, 'SIGKILL', { child });
336
341
  };
337
342
 
338
343
  child.stdout?.setEncoding('utf8');
package/src/types.js CHANGED
@@ -206,7 +206,7 @@
206
206
  * @property {(selector: string, opts?: {timeoutMs?: number}) => Promise<void>} waitForGone
207
207
  * @property {(selector: string) => Promise<void>} scrollTo
208
208
  * @property {(ms: number) => Promise<void>} wait
209
- * @property {(js: string) => Promise<any>} evaluate
209
+ * @property {(js: string|Function) => Promise<any>} evaluate Text, or a function to run in the page.
210
210
  * @property {(selector: string) => Promise<boolean>} visible
211
211
  * @property {(selector: string) => Promise<boolean>} exists
212
212
  * @property {(selector: string) => Promise<string>} textOf