staysfixed 0.12.0 → 0.14.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.
@@ -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
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { spawn } from 'node:child_process';
23
23
 
24
+ import { stopTree, OWN_PROCESS_GROUP } from '../../core/stop-tree.js';
25
+
24
26
  /**
25
27
  * Start the product, in a group of its own.
26
28
  *
@@ -34,9 +36,10 @@ export function spawnServer(command, opts) {
34
36
  cwd: opts.cwd,
35
37
  env: opts.env,
36
38
  stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
37
- // The whole point. On Windows there are no process groups of this kind, and killing the
38
- // child is the best that can be done there.
39
- detached: process.platform !== 'win32',
39
+ // The whole point. Windows has no process groups of this kind, and `detached` there means
40
+ // a console window of its own instead — so Windows is left alone at spawn time and gets
41
+ // its whole tree stopped by `stopTree` below, which walks the children itself.
42
+ detached: OWN_PROCESS_GROUP,
40
43
  });
41
44
  }
42
45
 
@@ -52,21 +55,16 @@ export async function stopServer(child, opts = {}) {
52
55
  const pid = child.pid;
53
56
  const graceMs = opts.graceMs ?? 500;
54
57
 
55
- /** @param {NodeJS.Signals} signal */
58
+ /**
59
+ * @param {'SIGTERM'|'SIGKILL'} signal
60
+ */
56
61
  const tellTheGroup = (signal) => {
57
- if (!pid) return;
58
- try {
59
- // A negative pid is the GROUP. This is the line that makes the difference.
60
- if (process.platform === 'win32') child.kill(signal);
61
- else process.kill(-pid, signal);
62
- } catch {
63
- // No group, or already gone. Ask the one process we definitely know about.
64
- try {
65
- child.kill(signal);
66
- } catch {
67
- // Already gone, which is the outcome wanted.
68
- }
69
- }
62
+ // The group on Linux and a Mac, the tree of children on Windows. Killing only the shell
63
+ // on Windows left the server running and holding the folder it was started in: measured
64
+ // on a real Windows 11 machine on 2026-08-31, where the whole of `waiting.test.js` failed
65
+ // on being unable to delete its own scratch folder afterwards, because the servers it had
66
+ // asked to stop were all still there.
67
+ stopTree(pid, signal, { child });
70
68
  };
71
69
 
72
70
  if (child.exitCode === null && child.signalCode === null) {
@@ -378,6 +378,10 @@ export function compareJson(a, b) {
378
378
  * @param {string} spec.says
379
379
  * @param {boolean} [spec.covered] False means we did not really look. See `reason`.
380
380
  * @param {NotCoveredReason} [spec.reason]
381
+ * @param {string} [spec.detail] What the thing itself said, in its own words, when it
382
+ * said anything. Goes onto the refusal's reason, which
383
+ * is where a person reads why a hole is a hole. Never
384
+ * compared — see `whatItSaid` for why that matters.
381
385
  * @param {{file?: string, line?: number, url?: string}} [spec.where]
382
386
  * @param {string} [spec.evidence]
383
387
  * @param {string} [spec.journey]
@@ -414,7 +418,16 @@ export function observation(spec) {
414
418
  // — and never this flag. Written down on 2026-08-31 after the refusal lane found the two
415
419
  // meanings sharing one field.
416
420
  meta.refused = true;
417
- meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})`;
421
+ // The reason word stays first and stays in the fixed vocabulary, because the ledger counts
422
+ // these and a free-text reason cannot be counted. What the thing ITSELF said goes on the
423
+ // end. That position is deliberate: `staysfixed coverage` prints this line with a 400
424
+ // character budget and the sentence above it with 160, so this is the place where the real
425
+ // error — the `ModuleNotFoundError`, the `SyntaxError` — reliably survives being trimmed
426
+ // and reaches the person who has to go and fix it. Added 2026-08-31, after three broken
427
+ // products were checked and none of their owners was ever told what was wrong.
428
+ meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})${
429
+ spec.detail ? ` — and this is what it said for itself: ${spec.detail}` : ''
430
+ }`;
418
431
  }
419
432
  return makeObservation(path, spec.channel, stableValue(spec.value), meta);
420
433
  }
@@ -431,6 +444,7 @@ export function observation(spec) {
431
444
  * @param {string|(string|number)[]} spec.path
432
445
  * @param {NotCoveredReason} spec.reason
433
446
  * @param {string} spec.says What we would have looked at, and why we did not.
447
+ * @param {string} [spec.detail] What the thing itself said about it, in its own words.
434
448
  * @param {{file?: string, line?: number, url?: string}} [spec.where]
435
449
  * @returns {Observation}
436
450
  */
@@ -438,10 +452,15 @@ export function notCovered(spec) {
438
452
  return observation({
439
453
  channel: spec.channel,
440
454
  path: spec.path,
455
+ // The VALUE stays the fixed sentence and never carries what the thing said. Two builds
456
+ // that fall over with two different messages would otherwise differ at this address, and
457
+ // the report would call a crash a change in the product. The words go in `says` and in the
458
+ // refusal's reason, neither of which is ever compared.
441
459
  value: `not checked — ${NOT_COVERED_MEANING[spec.reason]}`,
442
460
  says: spec.says,
443
461
  covered: false,
444
462
  reason: spec.reason,
463
+ detail: spec.detail,
445
464
  where: spec.where,
446
465
  });
447
466
  }
@@ -610,6 +629,108 @@ export function undoOurFootprint(text, footprint) {
610
629
  return out;
611
630
  }
612
631
 
632
+ /**
633
+ * What a program said about itself, cut down to the part a person can act on.
634
+ *
635
+ * WHY THIS EXISTS. Measured on 2026-08-31, against three deliberately broken products: a Node
636
+ * server whose source has a syntax error, a Python command importing a module that is not
637
+ * installed, and a Node command importing a package that is not installed. All three were
638
+ * correctly refused — no false all-clear, and that half worked. But a grep of the whole reply,
639
+ * `--verbose` included, found no mention of `SyntaxError`, `ModuleNotFoundError` or
640
+ * `ERR_MODULE_NOT_FOUND` anywhere in it. Every owner was told "the thing being observed fell
641
+ * over before it could be read" and then had to go and find the reason themselves. Each
642
+ * product had printed the reason, in one line, on its own standard error, and this tool threw
643
+ * it away. Handing somebody a sentence they can act on is the whole design; this is the
644
+ * function that keeps the product's own sentence instead of a paraphrase of it.
645
+ *
646
+ * NOTHING IS INVENTED HERE. What comes out is the program's own words. What is dropped is only
647
+ * what carries nothing for the person reading: blank lines, the `at ...` frames of a stack
648
+ * trace, the caret lines that underline a column of a terminal that is not this one, "... 4
649
+ * more" frame counts, and Node's own version footer. What is left over the budget is dropped
650
+ * from the FRONT, because every runtime in use here — Node, Python, a shell — puts the
651
+ * sentence that says what went wrong at the END of what it printed.
652
+ *
653
+ * This is evidence, not noise, and it belongs in the reply. It is never compared: it goes into
654
+ * the sentence and into the refusal's reason, both of which live in `meta`, so a crash that
655
+ * words itself differently on two machines can never register as a difference in the product.
656
+ *
657
+ * @param {string} text Whatever the thing printed. Run our own footprint out of it
658
+ * first, or a scratch path ends up quoted at a person.
659
+ * @param {object} [opts]
660
+ * @param {number} [opts.mostLines] How many lines of it to keep. Default 6.
661
+ * @param {number} [opts.mostChars] How many characters of it to keep. Default 300.
662
+ * @returns {string} One line, or '' when it said nothing worth repeating.
663
+ */
664
+ export function whatItSaid(text, opts = {}) {
665
+ const mostLines = opts.mostLines ?? 6;
666
+ const mostChars = opts.mostChars ?? 300;
667
+ const lines = String(text ?? '')
668
+ // Colour codes are how a program makes an error red in a terminal. Printed into a
669
+ // sentence they are unreadable rubbish, and they are not part of what it said.
670
+ .replace(/\u001b\[[0-9;]*m/g, '')
671
+ .split(/\r?\n/)
672
+ .map((line) => line.trim())
673
+ .filter((line) => line !== '' && !NOT_WORTH_REPEATING.some((noise) => noise.test(line)));
674
+ if (lines.length === 0) return '';
675
+
676
+ // ONE LINE MEANS THE ONE THAT NAMES THE TROUBLE, not simply the last one.
677
+ //
678
+ // "The last line" was the first thing tried and it is wrong often enough to matter: measured
679
+ // 2026-08-31 on a Node command importing a package that is not installed, Node printed the
680
+ // whole error object after the message, so the last line was `}` and the headline read "It
681
+ // said: }." — which is worse than saying nothing. So the line that looks like a runtime
682
+ // naming a fault is preferred, and the last line is what happens when none does. Preferred,
683
+ // never required: nothing is dropped by this, it only decides which single line gets quoted
684
+ // where there is room for one, and the fuller quote sits directly underneath it.
685
+ if (mostLines === 1) {
686
+ const named = [...lines].reverse().find((line) => NAMES_THE_TROUBLE.test(line)) ?? lines[lines.length - 1];
687
+ return named.length > mostChars ? `${named.slice(0, mostChars - 3)}...` : named;
688
+ }
689
+
690
+ /** @type {string[]} */
691
+ const kept = [];
692
+ let used = 0;
693
+ for (let i = lines.length - 1; i >= 0 && kept.length < mostLines; i--) {
694
+ const line = lines[i];
695
+ if (kept.length > 0 && used + line.length + 3 > mostChars) break;
696
+ kept.unshift(line);
697
+ used += line.length + 3;
698
+ }
699
+ // One line longer than the whole budget is still the line that says what went wrong, so it
700
+ // is kept and cut rather than dropped for being too long.
701
+ if (kept.length === 1 && kept[0].length > mostChars) kept[0] = `${kept[0].slice(0, mostChars - 3)}...`;
702
+ const left = lines.length - kept.length;
703
+ // Said out loud rather than trimmed silently: a reader who is told six lines were left out
704
+ // knows to open the evidence file, and a reader who is not told assumes they have all of it.
705
+ return `${left > 0 ? `(${left} earlier ${left === 1 ? 'line' : 'lines'} left out) ` : ''}${kept.join(' / ')}`;
706
+ }
707
+
708
+ /**
709
+ * A line that reads like a runtime saying what went wrong.
710
+ *
711
+ * The three shapes measured on 2026-08-31, one from each broken product: `SyntaxError:
712
+ * Unexpected end of input`, `ModuleNotFoundError: No module named 'tabulate'`, and `Error
713
+ * [ERR_MODULE_NOT_FOUND]: Cannot find package 'chalk' imported from ...`. The second pattern is
714
+ * for the tools that write their trouble in lower case instead — a compiler, git, a shell.
715
+ */
716
+ const NAMES_THE_TROUBLE =
717
+ /^[A-Za-z_$][A-Za-z0-9_$.]*(?:Error|Exception|Warning)(?:\s*\[[^\]]*\])?\s*:|^(?:fatal|error|panic|Traceback)\b/i;
718
+
719
+ /**
720
+ * Lines that are in the way of the sentence rather than part of it.
721
+ *
722
+ * Deliberately short. Every pattern here has to be something that cannot possibly BE the
723
+ * reason a product fell over — a stack frame under the message, a caret underlining a column,
724
+ * a version footer. Anything else stays, because a rule that guesses which of a program's own
725
+ * lines matter is a rule that will one day drop the only line that did.
726
+ */
727
+ const NOT_WORTH_REPEATING = [
728
+ /^at\s+\S/, // a stack frame under the message that already said it
729
+ /^[\^~]+$/, // the caret line underlining a column in somebody else's terminal
730
+ /^\.\.\.\s*\d+\s+more$/, // "... 4 more", the frames a runtime left out itself
731
+ /^Node\.js v[\d.]+$/, // Node's version footer, printed under every uncaught throw
732
+ ];
733
+
613
734
  /**
614
735
  * Keep a piece of text at a size worth storing.
615
736
  *