staysfixed 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/mcp.md +13 -0
- package/docs/running-it-in-ci.md +123 -1
- package/docs/settings.md +15 -0
- package/package.json +1 -1
- package/src/cli/status.js +45 -1
- package/src/core/config.js +31 -0
- package/src/core/paths.js +15 -0
- package/src/guard/name.js +41 -1
- package/src/guard/run.js +21 -2
- package/src/report/console.js +35 -4
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/http.js +7 -9
- package/src/v2/adapters/process.js +55 -5
- package/src/v2/adapters/web-driver.js +43 -3
- package/src/v2/adapters/web.js +7 -9
- package/src/v2/browsers.js +57 -2
- package/src/v2/check.js +135 -3
- package/src/v2/cli.js +23 -18
- package/src/v2/cluster.js +56 -1
- package/src/v2/doctor.js +68 -9
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +29 -6
- package/src/v2/mcp/tools.js +88 -6
- package/src/v2/reference.js +120 -14
- package/src/v2/sealed.js +7 -1
- package/src/v2/ship.js +58 -0
package/src/report/console.js
CHANGED
|
@@ -124,7 +124,11 @@ function tally(run) {
|
|
|
124
124
|
missing: pictures.filter((p) => p.status === 'missing').length,
|
|
125
125
|
broken: pictures.filter((p) => p.status === 'failed').length,
|
|
126
126
|
wobbled: pictures.filter((p) => p.status === 'flaky').length,
|
|
127
|
-
|
|
127
|
+
// Two different things wear the same status, and calling both of them "a bug is back"
|
|
128
|
+
// sends somebody hunting a regression that never happened. A guard that asked no
|
|
129
|
+
// question at all has not caught anything; it has admitted it cannot.
|
|
130
|
+
guardsFailed: guards.filter((g) => g.status === 'failed' && !(/** @type {any} */ (g).assertedNothing)).length,
|
|
131
|
+
guardsEmpty: guards.filter((g) => /** @type {any} */ (g).assertedNothing === true).length,
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -139,6 +143,20 @@ export function verdictFor(run) {
|
|
|
139
143
|
const parts = [];
|
|
140
144
|
if (t.guardsFailed === 1) parts.push({ n: 1, text: '1 guard failed — a bug that was already fixed is back.' });
|
|
141
145
|
else if (t.guardsFailed > 1) parts.push({ n: t.guardsFailed, text: `${countText(t.guardsFailed)} guards failed — bugs that were already fixed are back.` });
|
|
146
|
+
// Said even on a green run, because that is the run it changes the meaning of.
|
|
147
|
+
const left = /** @type {any} */ (run).leftOut;
|
|
148
|
+
if (left && (left.screens > 0 || left.guards > 0)) {
|
|
149
|
+
const bits = [];
|
|
150
|
+
if (left.screens > 0) bits.push(`${left.screens} ${left.screens === 1 ? 'screen' : 'screens'}`);
|
|
151
|
+
if (left.guards > 0) bits.push(`${left.guards} ${left.guards === 1 ? 'guard' : 'guards'}`);
|
|
152
|
+
const how_many = (left.screens ?? 0) + (left.guards ?? 0);
|
|
153
|
+
parts.push({
|
|
154
|
+
n: 0,
|
|
155
|
+
text: `${bits.join(' and ')} ${how_many === 1 ? 'was' : 'were'} left out by --only, so this covers a slice and not the whole.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (t.guardsEmpty === 1) parts.push({ n: 1, text: '1 guard checks nothing, so it is not protecting anything.' });
|
|
159
|
+
else if (t.guardsEmpty > 1) parts.push({ n: t.guardsEmpty, text: `${countText(t.guardsEmpty)} guards check nothing, so they are not protecting anything.` });
|
|
142
160
|
if (t.changed === 1) parts.push({ n: 1, text: '1 thing changed. Look at it before you ship.' });
|
|
143
161
|
else if (t.changed > 1) parts.push({ n: t.changed, text: `${countText(t.changed)} things changed. Look at them before you ship.` });
|
|
144
162
|
if (t.fresh === 1) parts.push({ n: 1, text: '1 new screen is waiting for a person to approve it.' });
|
|
@@ -162,9 +180,12 @@ export function verdictFor(run) {
|
|
|
162
180
|
* @param {import('../types.js').RunSummary} run
|
|
163
181
|
* @returns {boolean}
|
|
164
182
|
*/
|
|
165
|
-
function allClear(run) {
|
|
183
|
+
export function allClear(run) {
|
|
166
184
|
const t = tally(run);
|
|
167
|
-
|
|
185
|
+
// `guardsEmpty` counts too. Splitting it out of `guardsFailed` was so the SENTENCE could
|
|
186
|
+
// tell a returned bug from a guard that asks nothing — not so that one of them could
|
|
187
|
+
// quietly become a pass.
|
|
188
|
+
return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed + t.guardsEmpty === 0;
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
/**
|
|
@@ -561,6 +582,8 @@ export function printFlakes(history, flakeLimit = 2) {
|
|
|
561
582
|
* @property {number} [markers] Known-good markers saved.
|
|
562
583
|
* @property {{label: string, at?: string}|null} [lastMarker]
|
|
563
584
|
* @property {import('../types.js').RunSummary|null} [lastRun]
|
|
585
|
+
* @property {{at: string, verdict: string, reference: string|null, findings: number}|null} [v2]
|
|
586
|
+
* What version 2 has recorded here. Version 1's counts say nothing about it.
|
|
564
587
|
* @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
|
|
565
588
|
* @property {string} [configFile]
|
|
566
589
|
* @property {string} [root]
|
|
@@ -591,7 +614,15 @@ export function printStatus(status) {
|
|
|
591
614
|
|
|
592
615
|
blank();
|
|
593
616
|
const run = s.lastRun ?? null;
|
|
594
|
-
if (!run) {
|
|
617
|
+
if (!run && s.v2) {
|
|
618
|
+
// Version 2 has run here even though version 1's picture record has not. Saying
|
|
619
|
+
// "nothing has been checked here yet" one command after a real run is the sort of
|
|
620
|
+
// wrongness that costs a person their trust in everything else the tool says.
|
|
621
|
+
say(paint.grey(` last checked ${ago(s.v2.at)} — ${s.v2.verdict}`));
|
|
622
|
+
if (s.v2.findings > 0) say(paint.grey(` ${s.v2.findings} ${plural(s.v2.findings, 'thing', 'things')} nobody had accounted for`));
|
|
623
|
+
if (s.v2.reference) say(paint.grey(` compared against ${s.v2.reference}`));
|
|
624
|
+
else say(` Nothing is on record as working yet — run ${paint.cyan('staysfixed check')}, then ${paint.cyan('staysfixed ship')}.`);
|
|
625
|
+
} else if (!run) {
|
|
595
626
|
say(' Nothing has been checked here yet.');
|
|
596
627
|
say(` Start with: ${paint.cyan('staysfixed check')}`);
|
|
597
628
|
} else {
|
package/src/run.js
CHANGED
|
@@ -226,6 +226,17 @@ export async function runCheck(project, opts = {}) {
|
|
|
226
226
|
tool: TOOL,
|
|
227
227
|
platform: platformTag(),
|
|
228
228
|
condemned: condemnedNames,
|
|
229
|
+
// What `--only` left out. A narrowed run that says "everything that worked still works"
|
|
230
|
+
// is describing a slice and sounding like the whole: measured 2026-08-30 with five of
|
|
231
|
+
// six guards filtered away and one of the five failing, and the run still exited 0
|
|
232
|
+
// saying everything works. A pass has to carry the size of what it looked at.
|
|
233
|
+
leftOut: terms
|
|
234
|
+
? {
|
|
235
|
+
screens: Math.max(0, allScreens.length - screens.length),
|
|
236
|
+
guards: Math.max(0, allGuards.length - guards.length),
|
|
237
|
+
terms,
|
|
238
|
+
}
|
|
239
|
+
: undefined,
|
|
229
240
|
// Read here rather than at the very end: what follows is writing files, and
|
|
230
241
|
// where the run spent its time is a fact about the run, not about the report.
|
|
231
242
|
timings: timings.get(),
|
package/src/types.js
CHANGED
|
@@ -328,6 +328,9 @@
|
|
|
328
328
|
* @property {string} tool
|
|
329
329
|
* @property {string} platform
|
|
330
330
|
* @property {string[]} [condemned] Names of checks that have flaked past the limit.
|
|
331
|
+
* @property {{screens: number, guards: number, terms: string[]}} [leftOut]
|
|
332
|
+
* What `--only` filtered away. A narrowed run that reads as a full pass is describing a
|
|
333
|
+
* slice and sounding like the whole.
|
|
331
334
|
*/
|
|
332
335
|
|
|
333
336
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting and stopping the product's own server.
|
|
3
|
+
*
|
|
4
|
+
* A start command is run through a shell, because that is what people write: `npm run dev`,
|
|
5
|
+
* `sh dev.sh`, `poetry run uvicorn ...`. So the thing that is spawned is the SHELL, and the
|
|
6
|
+
* server is its child — often its grandchild, since `npm run dev` is npm, which runs next,
|
|
7
|
+
* which runs node.
|
|
8
|
+
*
|
|
9
|
+
* Killing the shell therefore does not kill the server. And because the shell's stdout and
|
|
10
|
+
* stderr are pipes, every survivor inherits the writing end of them — so the pipes never
|
|
11
|
+
* close, this process's event loop never empties, and `staysfixed check` prints its whole
|
|
12
|
+
* answer and then hangs for ever at nothing per cent of a CPU. Measured on 2026-08-30 on a
|
|
13
|
+
* start command that spawns its server and waits, which is the shape `npm run dev` has: the
|
|
14
|
+
* verdict appeared in about thirty seconds and the command never returned.
|
|
15
|
+
*
|
|
16
|
+
* So the shell is started as its own process GROUP and the whole group is signalled. And
|
|
17
|
+
* after that, the pipes are torn down here rather than trusted to close, because a survivor
|
|
18
|
+
* this file did not start — a stray `node` somebody's dev server left behind — must not be
|
|
19
|
+
* able to hold a finished check open.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start the product, in a group of its own.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @param {{cwd: string, env: any, stdio?: any}} opts
|
|
29
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
30
|
+
*/
|
|
31
|
+
export function spawnServer(command, opts) {
|
|
32
|
+
return spawn(String(command), {
|
|
33
|
+
shell: true,
|
|
34
|
+
cwd: opts.cwd,
|
|
35
|
+
env: opts.env,
|
|
36
|
+
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',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stop it, and everything it started.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('node:child_process').ChildProcess|null|undefined} child
|
|
47
|
+
* @param {{graceMs?: number}} [opts]
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
export async function stopServer(child, opts = {}) {
|
|
51
|
+
if (!child) return;
|
|
52
|
+
const pid = child.pid;
|
|
53
|
+
const graceMs = opts.graceMs ?? 500;
|
|
54
|
+
|
|
55
|
+
/** @param {NodeJS.Signals} signal */
|
|
56
|
+
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
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
73
|
+
tellTheGroup('SIGTERM');
|
|
74
|
+
await new Promise((done) => {
|
|
75
|
+
let settled = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
done(undefined);
|
|
80
|
+
};
|
|
81
|
+
child.once('exit', finish);
|
|
82
|
+
const timer = setTimeout(finish, graceMs);
|
|
83
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
84
|
+
});
|
|
85
|
+
if (child.exitCode === null && child.signalCode === null) tellTheGroup('SIGKILL');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// And never let what it left behind hold this process open.
|
|
89
|
+
for (const stream of [child.stdout, child.stderr, child.stdin]) {
|
|
90
|
+
try {
|
|
91
|
+
stream?.destroy();
|
|
92
|
+
} catch {
|
|
93
|
+
// Nothing to close.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
child.unref();
|
|
98
|
+
} catch {
|
|
99
|
+
// Not every child can be unreferenced. It has been signalled either way.
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/v2/adapters/http.js
CHANGED
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
import fsp from 'node:fs/promises';
|
|
32
32
|
import net from 'node:net';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
-
import { spawn } from 'node:child_process';
|
|
35
34
|
import {
|
|
36
35
|
defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
|
|
37
36
|
howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
|
|
41
40
|
} from './process.js';
|
|
42
41
|
import { readContract, readFileRoutes } from './source.js';
|
|
42
|
+
import { spawnServer, stopServer } from './child.js';
|
|
43
43
|
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
// Headers
|
|
@@ -456,7 +456,7 @@ export const httpAdapter = defineAdapter({
|
|
|
456
456
|
notes.push(verdict.why);
|
|
457
457
|
} else {
|
|
458
458
|
const result = await new Promise((resolve) => {
|
|
459
|
-
const child =
|
|
459
|
+
const child = spawnServer(String(config.restore), { cwd: work, env });
|
|
460
460
|
/** @type {Buffer[]} */
|
|
461
461
|
const err = [];
|
|
462
462
|
child.stderr?.on('data', (c) => err.push(c));
|
|
@@ -483,7 +483,7 @@ export const httpAdapter = defineAdapter({
|
|
|
483
483
|
/** @type {Buffer[]} */
|
|
484
484
|
const bootOut = [];
|
|
485
485
|
let exited = /** @type {string|null} */ (null);
|
|
486
|
-
const child =
|
|
486
|
+
const child = spawnServer(String(config.start), { cwd: work, env });
|
|
487
487
|
child.stdout?.on('data', (c) => bootOut.push(c));
|
|
488
488
|
child.stderr?.on('data', (c) => bootErr.push(c));
|
|
489
489
|
child.on('close', (code, signal) => {
|
|
@@ -496,11 +496,11 @@ export const httpAdapter = defineAdapter({
|
|
|
496
496
|
});
|
|
497
497
|
|
|
498
498
|
if (!up.up) {
|
|
499
|
-
child
|
|
499
|
+
await stopServer(child);
|
|
500
500
|
return {
|
|
501
501
|
build, root: work, ready: false,
|
|
502
502
|
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
503
|
-
dispose: async () => { child
|
|
503
|
+
dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
|
|
504
504
|
};
|
|
505
505
|
}
|
|
506
506
|
|
|
@@ -522,9 +522,7 @@ export const httpAdapter = defineAdapter({
|
|
|
522
522
|
if (!held) return;
|
|
523
523
|
// Only ever the process we started. Somebody else's server on this machine is
|
|
524
524
|
// somebody else's business.
|
|
525
|
-
held.child
|
|
526
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
527
|
-
if (held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
525
|
+
await stopServer(held.child);
|
|
528
526
|
await fsp.rm(base, { recursive: true, force: true });
|
|
529
527
|
},
|
|
530
528
|
};
|
|
@@ -612,7 +610,7 @@ export const httpAdapter = defineAdapter({
|
|
|
612
610
|
|
|
613
611
|
async teardown() {
|
|
614
612
|
for (const [, held] of running) {
|
|
615
|
-
held.child
|
|
613
|
+
await stopServer(held.child);
|
|
616
614
|
}
|
|
617
615
|
running.clear();
|
|
618
616
|
},
|
|
@@ -180,10 +180,48 @@ export function watcherScript(opts) {
|
|
|
180
180
|
" write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
|
|
181
181
|
" // Refused, not allowed through. Whatever this was going to do out there, it does not",
|
|
182
182
|
" // do it twice, and the run is reported as having a hole rather than as having passed.",
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
183
|
+
" //",
|
|
184
|
+
" // HOW the refusal arrives matters as much as that it happens. Emitting 'error' on the",
|
|
185
|
+
" // socket ourselves reads correctly and kills the product: at that moment nothing is",
|
|
186
|
+
" // listening on the socket yet, and in Node an 'error' event with no listener is a",
|
|
187
|
+
" // thrown exception. `http.get` and `https.get` on Node 22 - the floor this package",
|
|
188
|
+
" // declares in its own engines field - and a bare `net.connect` on EVERY version all",
|
|
189
|
+
" // died that way, exit 1, and the run then reported the product as broken. A tool",
|
|
190
|
+
" // blaming a product for something the tool itself did is the exact failure this whole",
|
|
191
|
+
" // package exists to prevent. Measured 2026-08-30 against the published 0.8.0 watcher;",
|
|
192
|
+
" // its own CI had been red on this for four releases and nobody read it.",
|
|
193
|
+
" //",
|
|
194
|
+
" // So the refusal is made real rather than simulated: the socket is pointed at a port on",
|
|
195
|
+
" // this machine that nothing can be listening on, and the operating system produces the",
|
|
196
|
+
" // refusal through Node's own plumbing - by which time every listener the runtime wires",
|
|
197
|
+
" // up is in place. The product gets an ordinary ECONNREFUSED, which is exactly what it",
|
|
198
|
+
" // would get if the host were unreachable, and cannot tell the difference.",
|
|
199
|
+
" const named = host || 'an unnamed host';",
|
|
200
|
+
" const explain = 'Stays Fixed refused a connection to ' + named + ': nothing irreversible is allowed out during a check.';",
|
|
201
|
+
" const refusal = () => Object.assign(new Error(explain), { code: 'ECONNREFUSED', refusedBy: 'staysfixed' });",
|
|
202
|
+
" // Said in the error the product actually catches, without swallowing it: prepending a",
|
|
203
|
+
" // listener rewrites the message and still leaves every other handler to run as it would.",
|
|
204
|
+
" this.prependListener('error', (e) => {",
|
|
205
|
+
" if (e && e.code === 'ECONNREFUSED') { e.message = explain; e.refusedBy = 'staysfixed'; }",
|
|
206
|
+
" });",
|
|
207
|
+
" // Belt and braces. If something really is listening down there, the connection is cut",
|
|
208
|
+
" // before one byte can cross it: a boundary that fails open is not a boundary.",
|
|
209
|
+
" this.prependOnceListener('connect', () => { this.destroy(refusal()); });",
|
|
210
|
+
" // And a machine where that port is silently dropped rather than refused would hang",
|
|
211
|
+
" // here instead of failing, which is worse than the bug this replaced: a check that",
|
|
212
|
+
" // never finishes tells you nothing at all. A refusal is owed promptly, so if the",
|
|
213
|
+
" // operating system has not produced one shortly, produce it. Safe to do now, and only",
|
|
214
|
+
" // now, because the listener above means this can never be an unhandled error.",
|
|
215
|
+
" const soon = setTimeout(() => { if (!this.destroyed) this.destroy(refusal()); }, 250);",
|
|
216
|
+
" if (typeof soon.unref === 'function') soon.unref();",
|
|
217
|
+
" this.once('close', () => clearTimeout(soon));",
|
|
218
|
+
" try {",
|
|
219
|
+
" return connect.call(this, { port: 1, host: '127.0.0.1' });",
|
|
220
|
+
" } catch {",
|
|
221
|
+
" // Even the refusal failed. Still never throw into the product.",
|
|
222
|
+
" process.nextTick(() => { if (!this.destroyed) this.destroy(refusal()); });",
|
|
223
|
+
" return this;",
|
|
224
|
+
" }",
|
|
187
225
|
" };",
|
|
188
226
|
"} catch { /* no net module, nothing to refuse */ }",
|
|
189
227
|
"",
|
|
@@ -1090,7 +1128,19 @@ export const processAdapter = defineAdapter({
|
|
|
1090
1128
|
*/
|
|
1091
1129
|
export function importProbeCommand(moduleId) {
|
|
1092
1130
|
const probe = [
|
|
1093
|
-
|
|
1131
|
+
// A FILE unless it is really a package. The old rule was "starts with a dot, or has a
|
|
1132
|
+
// slash in it" — and `index.js` has neither, so Node was asked for a PACKAGE called
|
|
1133
|
+
// "index.js" and answered ERR_MODULE_NOT_FOUND. `staysfixed init` writes exactly
|
|
1134
|
+
// `{ module: "index.js" }` for an ordinary package entry, so on those projects this
|
|
1135
|
+
// journey failed on every run, failed the SAME way on both builds, produced no
|
|
1136
|
+
// difference, and the check said "Nothing that worked has changed" for ever. Measured
|
|
1137
|
+
// 2026-08-30. So: if a file of that name is really there, it is a file.
|
|
1138
|
+
"const id = process.argv[1];",
|
|
1139
|
+
"const { existsSync } = await import('node:fs');",
|
|
1140
|
+
"const { fileURLToPath } = await import('node:url');",
|
|
1141
|
+
"const asFile = new URL(id, 'file://' + process.cwd() + '/').href;",
|
|
1142
|
+
"const onDisk = (() => { try { return existsSync(fileURLToPath(asFile)); } catch { return false; } })();",
|
|
1143
|
+
"const m = await import(id.startsWith('.') || id.startsWith('/') || id.includes('/') || onDisk ? asFile : id);",
|
|
1094
1144
|
"const out = {};",
|
|
1095
1145
|
"for (const key of Object.keys(m).sort()) {",
|
|
1096
1146
|
" const v = m[key];",
|
|
@@ -174,6 +174,8 @@ export async function loadPlaywright(opts = {}) {
|
|
|
174
174
|
// So: ask it. Only when there is no browser on the machine at all is this a real "no".
|
|
175
175
|
/** @type {string|undefined} */
|
|
176
176
|
let borrowedFrom;
|
|
177
|
+
/** True when the browser found is the one the PERSON uses, not a separate one. */
|
|
178
|
+
let borrowedTheirOwn = false;
|
|
177
179
|
if (!there) {
|
|
178
180
|
try {
|
|
179
181
|
const { surveyBrowsers } = await import('../browsers.js');
|
|
@@ -181,6 +183,7 @@ export async function loadPlaywright(opts = {}) {
|
|
|
181
183
|
if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
|
|
182
184
|
executable = survey.chosen.binary;
|
|
183
185
|
borrowedFrom = survey.chosen.name;
|
|
186
|
+
borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
|
|
184
187
|
there = true;
|
|
185
188
|
}
|
|
186
189
|
} catch {
|
|
@@ -206,12 +209,35 @@ export async function loadPlaywright(opts = {}) {
|
|
|
206
209
|
chromium: mod.chromium,
|
|
207
210
|
version,
|
|
208
211
|
executable,
|
|
209
|
-
why: borrowedFrom
|
|
210
|
-
? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
|
|
211
|
-
: `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
|
|
212
|
+
why: browserNote(version, borrowedFrom, borrowedTheirOwn),
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
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
|
+
|
|
215
241
|
/**
|
|
216
242
|
* @param {string} file
|
|
217
243
|
* @returns {Promise<boolean>}
|
|
@@ -1489,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
|
|
|
1489
1515
|
break;
|
|
1490
1516
|
}
|
|
1491
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
|
+
}
|
|
1492
1532
|
return did;
|
|
1493
1533
|
}
|
|
1494
1534
|
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -40,7 +40,6 @@ import crypto from 'node:crypto';
|
|
|
40
40
|
import fs from 'node:fs';
|
|
41
41
|
import fsp from 'node:fs/promises';
|
|
42
42
|
import path from 'node:path';
|
|
43
|
-
import { spawn } from 'node:child_process';
|
|
44
43
|
|
|
45
44
|
import {
|
|
46
45
|
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
@@ -50,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
|
|
|
50
49
|
import { freePort, looksDestructive, waitForServer } from './http.js';
|
|
51
50
|
import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
|
|
52
51
|
import { settle } from '../../freeze/settle.js';
|
|
52
|
+
import { spawnServer, stopServer } from './child.js';
|
|
53
53
|
import {
|
|
54
54
|
actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
|
|
55
55
|
watchTheWire, whereItIs, withLimit,
|
|
@@ -537,7 +537,7 @@ export const webAdapter = defineAdapter({
|
|
|
537
537
|
if (!verdict.safe) notes.push(verdict.why);
|
|
538
538
|
else {
|
|
539
539
|
const done = await new Promise((resolve) => {
|
|
540
|
-
const child =
|
|
540
|
+
const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
|
|
541
541
|
child.on('error', () => resolve(false));
|
|
542
542
|
child.on('close', (code) => resolve(code === 0));
|
|
543
543
|
});
|
|
@@ -549,7 +549,7 @@ export const webAdapter = defineAdapter({
|
|
|
549
549
|
const said = [];
|
|
550
550
|
/** @type {string|null} */
|
|
551
551
|
let exited = null;
|
|
552
|
-
const child =
|
|
552
|
+
const child = spawnServer(String(config.start), { cwd: work, env });
|
|
553
553
|
child.stdout?.on('data', (c) => said.push(c));
|
|
554
554
|
child.stderr?.on('data', (c) => said.push(c));
|
|
555
555
|
child.on('close', (code, signal) => {
|
|
@@ -558,14 +558,14 @@ export const webAdapter = defineAdapter({
|
|
|
558
558
|
|
|
559
559
|
const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
|
|
560
560
|
if (!up.up) {
|
|
561
|
-
child
|
|
561
|
+
await stopServer(child);
|
|
562
562
|
return {
|
|
563
563
|
build,
|
|
564
564
|
root: work,
|
|
565
565
|
ready: false,
|
|
566
566
|
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
567
567
|
dispose: async () => {
|
|
568
|
-
child
|
|
568
|
+
await stopServer(child);
|
|
569
569
|
await fsp.rm(base, { recursive: true, force: true });
|
|
570
570
|
},
|
|
571
571
|
};
|
|
@@ -584,9 +584,7 @@ export const webAdapter = defineAdapter({
|
|
|
584
584
|
running.delete(build.id);
|
|
585
585
|
if (!held) return;
|
|
586
586
|
// Only ever the process we started ourselves.
|
|
587
|
-
held.child
|
|
588
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
589
|
-
if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
587
|
+
await stopServer(held.child);
|
|
590
588
|
await fsp.rm(base, { recursive: true, force: true });
|
|
591
589
|
},
|
|
592
590
|
};
|
|
@@ -779,7 +777,7 @@ export const webAdapter = defineAdapter({
|
|
|
779
777
|
},
|
|
780
778
|
|
|
781
779
|
async teardown() {
|
|
782
|
-
for (const [, held] of running) held.child
|
|
780
|
+
for (const [, held] of running) await stopServer(held.child);
|
|
783
781
|
running.clear();
|
|
784
782
|
},
|
|
785
783
|
});
|
package/src/v2/browsers.js
CHANGED
|
@@ -270,6 +270,16 @@ function testingBrowsers() {
|
|
|
270
270
|
take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
|
|
271
271
|
take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
|
|
272
272
|
take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
|
|
273
|
+
// The 64 matters, and leaving it off made this tool blind to full Chrome on every
|
|
274
|
+
// machine that is not a Mac. Playwright unpacks Linux into `chrome-linux64` and
|
|
275
|
+
// Windows into `chrome-win64`; Puppeteer does the same. Only macOS uses the names
|
|
276
|
+
// above, which is why it was never noticed here. On Linux this meant `npx playwright
|
|
277
|
+
// install chromium` - the command THIS FILE tells people to run - left a browser the
|
|
278
|
+
// survey could not see, and checks quietly fell back to the headless shell, or said
|
|
279
|
+
// there was no browser at all when the shell was not there too. Measured on a real
|
|
280
|
+
// Linux box on 2026-08-30: three Chromes present, none of them found.
|
|
281
|
+
take('chrome-for-testing', path.join(inner, 'chrome-linux64', 'chrome'));
|
|
282
|
+
take('chrome-for-testing', path.join(inner, 'chrome-win64', 'chrome.exe'));
|
|
273
283
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
|
|
274
284
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
|
|
275
285
|
take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
|
|
@@ -534,11 +544,56 @@ function killNow(pid, home) {
|
|
|
534
544
|
} catch {
|
|
535
545
|
// Already gone. That is the outcome we wanted.
|
|
536
546
|
}
|
|
547
|
+
// SIGKILL is a request to the kernel, not something that has already happened, and a
|
|
548
|
+
// browser is not one process. While the parent is being reaped its children are still
|
|
549
|
+
// writing into the profile, so deleting the folder in the same breath loses the race:
|
|
550
|
+
// the sweep starts, a file appears behind it, the directory is not empty, and the
|
|
551
|
+
// profile outlives the run - which is the one thing `nothing it opened outlives the
|
|
552
|
+
// run` promises. It passed on macOS and on an idle Linux box and failed on a loaded CI
|
|
553
|
+
// runner for four releases, which is exactly how a race behaves.
|
|
554
|
+
for (let i = 0; i < 40 && stillThere(pid); i++) waitSync(10);
|
|
537
555
|
}
|
|
556
|
+
// Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
|
|
557
|
+
// millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
|
|
558
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
559
|
+
try {
|
|
560
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
561
|
+
return;
|
|
562
|
+
} catch {
|
|
563
|
+
waitSync(20);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// A profile left in the temporary folder is untidy, not harmful — and it is exactly what
|
|
567
|
+
// `staysfixed browsers --clean` is for.
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Is that process still running? Signal 0 asks without sending anything.
|
|
572
|
+
* @param {number} pid
|
|
573
|
+
* @returns {boolean}
|
|
574
|
+
*/
|
|
575
|
+
function stillThere(pid) {
|
|
576
|
+
try {
|
|
577
|
+
process.kill(pid, 0);
|
|
578
|
+
return true;
|
|
579
|
+
} catch {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A real sleep, in the one place nothing may be awaited: this runs inside
|
|
586
|
+
* `process.on('exit')`, where the event loop is already over and a promise never
|
|
587
|
+
* resolves. `Atomics.wait` is the only thing that actually pauses here.
|
|
588
|
+
*
|
|
589
|
+
* @param {number} ms
|
|
590
|
+
*/
|
|
591
|
+
function waitSync(ms) {
|
|
538
592
|
try {
|
|
539
|
-
|
|
593
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
540
594
|
} catch {
|
|
541
|
-
//
|
|
595
|
+
// No SharedArrayBuffer here. Falling straight through is still better than throwing
|
|
596
|
+
// out of a cleanup handler.
|
|
542
597
|
}
|
|
543
598
|
}
|
|
544
599
|
|