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.
- package/CHANGELOG.md +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/types.js +1 -1
- 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
|
-
|
|
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.'));
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
//
|
|
172
|
-
// `check --pictures
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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';
|
package/src/drive/browser.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|