staysfixed 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fsp from 'node:fs/promises';
11
11
  import path from 'node:path';
12
- import { verdictFor, plainTime, countText } from './console.js';
12
+ import { verdictFor, plainTime, countText, guardVerdict } from './console.js';
13
13
 
14
14
  /**
15
15
  * @param {unknown} s
@@ -210,27 +210,51 @@ function troubleCard(p, actual) {
210
210
  */
211
211
  function guardsSection(guards) {
212
212
  if (guards.length === 0) return '';
213
- const failed = guards.filter((g) => g.status === 'failed');
213
+ // Counted by what each result actually says, not by its status. "3 of 3 bugs that were
214
+ // fixed are back" was printed on 2026-08-31 over one real failure, one guard whose clock
215
+ // ran out and one that was never even asked — and "All 3 bugs that were fixed are still
216
+ // fixed" was printed over three guards marked skip. Both sentences were about guards
217
+ // nobody had an answer from.
218
+ const back = guards.filter((g) => guardVerdict(g) === 'back');
219
+ const held = guards.filter((g) => guardVerdict(g) === 'held');
220
+ const unanswered = guards.length - back.length - held.length;
214
221
  const out = [];
215
222
  out.push('<h2>Guards</h2>');
216
- out.push(
217
- `<p class="lead">${
218
- failed.length === 0
219
- ? `All ${countText(guards.length)} ${plural(guards.length, 'bug', 'bugs')} that were fixed are still fixed.`
220
- : `${countText(failed.length)} of ${countText(guards.length)} bugs that were fixed ${plural(failed.length, 'is', 'are')} back.`
221
- }</p>`,
222
- );
223
+ const lead = [];
224
+ if (back.length > 0) {
225
+ lead.push(`${countText(back.length)} of ${countText(guards.length)} bugs that were fixed ${plural(back.length, 'is', 'are')} back.`);
226
+ } else if (held.length === guards.length) {
227
+ lead.push(`All ${countText(guards.length)} ${plural(guards.length, 'bug', 'bugs')} that were fixed are still fixed.`);
228
+ } else if (held.length > 0) {
229
+ lead.push(`${countText(held.length)} of ${countText(guards.length)} bugs that were fixed ${plural(held.length, 'is', 'are')} still fixed.`);
230
+ } else {
231
+ lead.push(`Not one of these ${countText(guards.length)} guards gave an answer.`);
232
+ }
233
+ if (unanswered > 0) {
234
+ lead.push(
235
+ `${countText(unanswered)} ${plural(unanswered, 'guard', 'guards')} ${plural(unanswered, 'was', 'were')} never answered — left out, out of time, or asking nothing at all.`,
236
+ );
237
+ }
238
+ out.push(`<p class="lead">${lead.join(' ')}</p>`);
223
239
  out.push('<section class="card guards"><ul class="guardlist">');
224
240
  for (const g of guards) {
225
- const state = g.status === 'passed' ? 'good' : g.status === 'skipped' ? 'muted' : 'bad';
241
+ const verdict = guardVerdict(g);
242
+ const state = verdict === 'held' ? 'good' : verdict === 'back' ? 'bad' : 'muted';
226
243
  out.push(`<li class="${state}">`);
227
244
  out.push(`<span class="dot"></span><span class="gname">${escapeHtml(g.name)}</span>`);
228
- if (g.status === 'failed') {
245
+ if (verdict === 'left out') {
246
+ out.push('<div class="note">left out on purpose</div>');
247
+ } else if (verdict !== 'held') {
229
248
  if (g.failedAt) out.push(`<div class="claim">expected: ${escapeHtml(g.failedAt)}</div>`);
230
249
  if (g.message && g.message !== g.failedAt) out.push(`<div class="claim">${escapeHtml(g.message)}</div>`);
231
- if (g.because) out.push(`<div class="because">Why this guard exists: ${escapeHtml(g.because)}</div>`);
232
- } else if (g.status === 'skipped') {
233
- out.push('<div class="note">left out on purpose</div>');
250
+ // The story is what says whether a failure matters, so it belongs under a returned bug.
251
+ // Under a guard that ran out of time it reads as that bug being back, which is exactly
252
+ // what nobody knows. An empty guard gets it in the words that fit an empty guard.
253
+ if (g.because && verdict === 'back') {
254
+ out.push(`<div class="because">Why this guard exists: ${escapeHtml(g.because)}</div>`);
255
+ } else if (g.because && /** @type {any} */ (g).assertedNothing === true) {
256
+ out.push(`<div class="because">What it was meant to protect: ${escapeHtml(g.because)}</div>`);
257
+ }
234
258
  }
235
259
  out.push('</li>');
236
260
  }
@@ -260,10 +284,30 @@ function condemnedSection(names) {
260
284
  */
261
285
  function passedSection(passed) {
262
286
  if (passed.length === 0) return '';
263
- const items = passed.map((p) => `<li><code>${escapeHtml(p.name)}</code></li>`).join('');
287
+ // "Exactly as approved" has to mean exactly, or it is the most expensive sentence on this
288
+ // page. A picture that differs and is waved through by `tolerance.pixels` was listed here in
289
+ // the same words as one that matched byte for byte — which is how a missing letter in a
290
+ // heading, 593 plainly visible pixels, sat under "still looks exactly as approved" while an
291
+ // allowance of 2,592 quietly absorbed it. The terminal was taught to say this in full and
292
+ // the report was not, so the same run said two different things depending where you read it.
293
+ const allowed = passed.filter((p) => (p.diffPixels ?? 0) > 0);
294
+ const items = passed
295
+ .map((p) => {
296
+ const moved = p.diffPixels ?? 0;
297
+ const note =
298
+ moved > 0
299
+ ? ` <span class="muted">the same, apart from ${countText(moved)} ${plural(moved, 'pixel', 'pixels')} your tolerance allowed</span>`
300
+ : '';
301
+ return `<li><code>${escapeHtml(p.name)}</code>${note}</li>`;
302
+ })
303
+ .join('');
304
+ const summary =
305
+ allowed.length === 0
306
+ ? `${countText(passed.length)} ${plural(passed.length, 'screen', 'screens')} still ${plural(passed.length, 'looks', 'look')} exactly as approved`
307
+ : `${countText(passed.length)} ${plural(passed.length, 'screen', 'screens')} passed — ${countText(allowed.length)} of ${plural(allowed.length, 'them', 'them')} only because your tolerance allowed what changed`;
264
308
  return [
265
309
  '<details class="card quiet">',
266
- `<summary>${countText(passed.length)} ${plural(passed.length, 'screen', 'screens')} still ${plural(passed.length, 'looks', 'look')} exactly as approved</summary>`,
310
+ `<summary>${summary}</summary>`,
267
311
  `<ul class="plain columns">${items}</ul>`,
268
312
  '</details>',
269
313
  ].join('\n');
@@ -298,13 +342,25 @@ async function buildHtml(project, run) {
298
342
  if (run.platform) meta.push(`on ${escapeHtml(run.platform)}`);
299
343
  body.push(`<p class="meta">${meta.join(' &middot; ')}</p>`);
300
344
  const chips = [];
301
- if (passed.length) chips.push(`<span class="chip good">${countText(passed.length)} unchanged</span>`);
345
+ // Not "unchanged" when a tolerance allowed the change through. Same sentence, same reason.
346
+ if (passed.length) {
347
+ const untouched = passed.filter((p) => (p.diffPixels ?? 0) === 0).length;
348
+ chips.push(
349
+ `<span class="chip good">${countText(passed.length)} ${untouched === passed.length ? 'unchanged' : 'passed'}</span>`,
350
+ );
351
+ }
302
352
  if (changed.length) chips.push(`<span class="chip bad">${countText(changed.length)} changed</span>`);
303
353
  if (fresh.length) chips.push(`<span class="chip warn">${countText(fresh.length)} new</span>`);
304
354
  if (trouble.length) chips.push(`<span class="chip bad">${countText(trouble.length)} could not be checked</span>`);
305
355
  if (guards.length) {
306
- const bad = guards.filter((g) => g.status === 'failed').length;
307
- chips.push(`<span class="chip ${bad ? 'bad' : 'good'}">${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}${bad ? `, ${countText(bad)} failed` : ' holding'}</span>`);
356
+ // "1 failed" over a guard that only ran out of time is the headline bug of this report in
357
+ // miniature. Counted the same way every other sentence about guards is counted now.
358
+ const bad = guards.filter((g) => guardVerdict(g) === 'back').length;
359
+ const unheard = guards.filter((g) => guardVerdict(g) === 'unanswered').length;
360
+ const note = bad ? `, ${countText(bad)} failed` : unheard ? `, ${countText(unheard)} unanswered` : ' holding';
361
+ chips.push(
362
+ `<span class="chip ${bad ? 'bad' : unheard ? 'warn' : 'good'}">${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}${note}</span>`,
363
+ );
308
364
  }
309
365
  if (chips.length) body.push(`<p class="chips">${chips.join('')}</p>`);
310
366
  body.push('</header>');
package/src/types.js CHANGED
@@ -501,6 +501,11 @@ export {};
501
501
  * @property {string} [message]
502
502
  * @property {string} [failedAt] The plain-language expectation that failed.
503
503
  * @property {string} [because] Why a guard exists.
504
+ * @property {boolean} [timedOut] A guard whose clock ran out. It is reported with the status
505
+ * 'failed' because a question nobody answered must never count
506
+ * as a pass — but it is not a bug coming back, and anything
507
+ * drawing this stream has to be able to tell the two apart.
508
+ * @property {boolean} [assertedNothing] A guard that finished without asking a single question.
504
509
  * @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
505
510
  * while the real file is still being written.
506
511
  * @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
@@ -40,6 +40,11 @@ import http from 'node:http';
40
40
  import net from 'node:net';
41
41
  import { execFile, spawn } from 'node:child_process';
42
42
 
43
+ // Every wait in this file has a limit and every limit says what it was waiting for. The pieces
44
+ // that do that live in process.js, next to the one function in this codebase whose whole job is
45
+ // running a program and waiting for it.
46
+ import { boundedMs, endOfChild, letGoOf } from './process.js';
47
+
43
48
  // ---------------------------------------------------------------------------
44
49
  // Finding the tools
45
50
  // ---------------------------------------------------------------------------
@@ -190,7 +195,11 @@ export function run(file, args, opts = {}) {
190
195
  file,
191
196
  args,
192
197
  {
193
- timeout: opts.timeoutMs ?? 120000,
198
+ // `boundedMs` and not the number as handed over. `execFile` only arms its timeout when
199
+ // the value is greater than zero, so a limit of NaN — which is what a limit read out of
200
+ // a settings file as text becomes — switches the limit OFF rather than shortening it,
201
+ // and adb hanging on a wedged device then hangs the whole check with no output at all.
202
+ timeout: boundedMs(opts.timeoutMs, 120000),
194
203
  signal: opts.signal,
195
204
  cwd: opts.cwd,
196
205
  maxBuffer: opts.maxBuffer ?? 32 * 1024 * 1024,
@@ -259,18 +268,41 @@ export class Device {
259
268
 
260
269
  /**
261
270
  * Run something and get raw bytes back — a screenshot, a file.
271
+ *
272
+ * This had neither a limit nor a size cap, and it settled on `close`. All three were the same
273
+ * bug: `close` does not mean the program ended, it means nobody anywhere is holding its pipes
274
+ * any more, and `adb` keeps a server daemon of its own that inherits them. So a device that
275
+ * stopped answering held this open for ever, with no output and nothing to act on — measured
276
+ * as a shape on 2026-08-31 against a fake program that leaves an orphan behind. It now ends,
277
+ * says why, and hands back what it did get.
278
+ *
262
279
  * @param {string} command
280
+ * @param {{timeoutMs?: number, mostBytes?: number}} [opts]
263
281
  * @returns {Promise<Buffer>}
264
282
  */
265
- bytes(command) {
266
- return new Promise((resolve, reject) => {
267
- const child = spawn(this.adb, ['-s', this.serial, 'exec-out', command], { signal: this.signal });
268
- /** @type {Buffer[]} */
269
- const chunks = [];
270
- child.stdout.on('data', (b) => chunks.push(b));
271
- child.on('error', reject);
272
- child.on('close', () => resolve(Buffer.concat(chunks)));
283
+ async bytes(command, opts = {}) {
284
+ const mostBytes = Math.max(1, Number(opts.mostBytes) || 64 * 1024 * 1024);
285
+ const child = spawn(this.adb, ['-s', this.serial, 'exec-out', command], { signal: this.signal });
286
+ /** @type {Buffer[]} */
287
+ const chunks = [];
288
+ let held = 0;
289
+ let tooMuch = false;
290
+ child.stdout.on('data', (/** @type {Buffer} */ b) => {
291
+ // Kept reading even once it is too much, because a reader that stops reading is a pipe
292
+ // that fills up, and a full pipe blocks the writer for ever — which is the same hang
293
+ // wearing a different hat.
294
+ if (held >= mostBytes) { tooMuch = true; return; }
295
+ held += b.length;
296
+ chunks.push(b);
273
297
  });
298
+ child.stderr?.resume();
299
+ const ended = await endOfChild(child, {
300
+ limitMs: boundedMs(opts.timeoutMs, 60000),
301
+ what: `the device to finish "${command.slice(0, 60)}"`,
302
+ });
303
+ if (ended.gaveUp) throw new Error(ended.why);
304
+ if (tooMuch) throw new Error(`"${command.slice(0, 60)}" sent back more than ${Math.round(mostBytes / (1024 * 1024))}MB, which is more than anything this asks for should ever be. It was stopped rather than held in memory.`);
305
+ return Buffer.concat(chunks);
274
306
  }
275
307
 
276
308
  /**
@@ -1566,13 +1598,25 @@ export async function pidOf(device, pkg) {
1566
1598
 
1567
1599
  /**
1568
1600
  * Take a picture. Evidence only — never the accusation.
1601
+ *
1602
+ * A device that will not hand one over is a hole in the evidence and nothing more, so it is
1603
+ * caught here and said in a sentence. Letting it throw would end the whole walk over a
1604
+ * screenshot, and the six channels that had already read the app properly would be thrown away
1605
+ * with it.
1606
+ *
1569
1607
  * @param {Device} device
1570
1608
  * @param {string} to
1571
- * @returns {Promise<{ok: boolean, bytes: number, path: string}>}
1609
+ * @returns {Promise<{ok: boolean, bytes: number, path: string, why?: string}>}
1572
1610
  */
1573
1611
  export async function screenshot(device, to) {
1574
- const png = await device.bytes('screencap -p');
1575
- if (png.length < 8 || png[0] !== 0x89) return { ok: false, bytes: png.length, path: to };
1612
+ /** @type {Buffer} */
1613
+ let png;
1614
+ try {
1615
+ png = await device.bytes('screencap -p');
1616
+ } catch (e) {
1617
+ return { ok: false, bytes: 0, path: to, why: e instanceof Error ? e.message : String(e) };
1618
+ }
1619
+ if (png.length < 8 || png[0] !== 0x89) return { ok: false, bytes: png.length, path: to, why: 'the device sent back something that is not a picture' };
1576
1620
  await fsp.mkdir(path.dirname(to), { recursive: true });
1577
1621
  await fsp.writeFile(to, png);
1578
1622
  return { ok: true, bytes: png.length, path: to };
@@ -1701,6 +1745,12 @@ export async function startEmulator(spec) {
1701
1745
  // Falling through to the process is fine; the console may already be gone.
1702
1746
  }
1703
1747
  if (!child.killed) child.kill('SIGTERM');
1748
+ // And let go of its pipes rather than trusting them to close. An emulator is a family of
1749
+ // processes and the helpers inherit the writing end of these; a survivor holding one keeps
1750
+ // Node's event loop awake, so the check finishes its work, prints its verdict and then
1751
+ // never returns. Measured in that exact shape on 2026-08-31 for the desktop adapter, and
1752
+ // the emulator has the same shape.
1753
+ letGoOf(child);
1704
1754
  };
1705
1755
 
1706
1756
  if (!ready.ready) {
@@ -400,9 +400,19 @@ export function observation(spec) {
400
400
  if (spec.surface) meta.surface = spec.surface;
401
401
  if (spec.covered === false) {
402
402
  // The engine reads `refused` when it builds the coverage ledger. Everything an adapter
403
- // could not look at lands here, whichever of the reasons it was — a payment it would not
404
- // make, a runtime this machine does not have, a parameter nobody supplied. They are all
405
- // the same thing to a reader: a hole, with the reason attached, and never a pass.
403
+ // could not look at IN FULL lands here, whichever of the reasons it was — a payment it
404
+ // would not make, a runtime this machine does not have, a parameter nobody supplied.
405
+ // They are all the same thing to the ledger: a hole, with the reason attached, and never
406
+ // a pass.
407
+ //
408
+ // IT DOES NOT MEAN "NOTHING ANSWERED", and reading it that way is wrong in one specific
409
+ // case that really happens: `too big` sets it on a REAL value that was only partly kept.
410
+ // A large log is a genuine observation of the product and has to go on being compared;
411
+ // treating it as a refusal would drop a real address out of the comparison and, worse,
412
+ // could block a healthy release. So anything deciding whether an address ANSWERED reads
413
+ // the value — see `src/v2/refusal.js`, which is the one place that question is answered
414
+ // — and never this flag. Written down on 2026-08-31 after the refusal lane found the two
415
+ // meanings sharing one field.
406
416
  meta.refused = true;
407
417
  meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})`;
408
418
  }
@@ -512,7 +522,11 @@ export function howLongItTook(spec) {
512
522
  // never become a difference. The measurement lives in the sentence, which is never compared.
513
523
  value: `not compared — ${NOT_COVERED_MEANING['measures the machine']}`,
514
524
  says:
515
- `${spec.what} took ${timeBucket(spec.ms)}. That is recorded and NOT compared: a stopwatch on a shared machine ` +
525
+ // "took" reads wrong in front of two of the rungs "took quick", "took instant"
526
+ // and this sentence goes in front of a person. The rungs are values, kept as they are
527
+ // because they are recorded; the sentence bends around them instead. Measured on a
528
+ // real run 2026-08-31, which printed "Walking the steps of "home" took quick."
529
+ `${spec.what}: ${timeBucket(spec.ms)}. That is recorded and NOT compared: a stopwatch on a shared machine ` +
516
530
  `measures the machine as much as the product, so a busy laptop would otherwise invent a slowdown that nobody caused. ` +
517
531
  `A build that hangs is still caught — it gets stopped for taking too long, and how it finished is compared.` +
518
532
  (spec.andAlso ? ` ${spec.andAlso}` : ''),
@@ -54,7 +54,7 @@ import {
54
54
  countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
55
55
  timeBucket, trimForStorage, undoOurFootprint,
56
56
  } from './contract.js';
57
- import { compareTrees, snapshotTree } from './process.js';
57
+ import { boundedCount, boundedMs, compareTrees, snapshotTree, withLimit } from './process.js';
58
58
  import {
59
59
  describeIsolation, releaseEverything, releaseIsolation, reserveIsolation, startIsolated,
60
60
  verifyAlone,
@@ -377,11 +377,17 @@ async function askFor(url) {
377
377
  * @param {object} opts
378
378
  * @param {number} opts.timeoutMs
379
379
  * @param {() => string|null} opts.died A sentence when the app has already quit, else null.
380
+ * @param {AbortSignal} [opts.signal]
380
381
  * @returns {Promise<{webSocketDebuggerUrl: string, title: string}>}
381
382
  */
382
383
  async function waitForMainProcess(port, opts) {
383
- const until = Date.now() + opts.timeoutMs;
384
+ // `boundedMs` and not the number as given, because this limit comes from a project's own
385
+ // settings file. A limit of NaN — which is what `startTimeoutMs: "60s"` becomes — makes
386
+ // `Date.now() > until` false for ever, and this loop would then poll a dead port until
387
+ // somebody killed the tool, with no output and no explanation.
388
+ const until = Date.now() + boundedMs(opts.timeoutMs, 60_000);
384
389
  for (;;) {
390
+ if (opts.signal?.aborted) throw new Error('The run was stopped while waiting for the app to open its main-process debugging connection.');
385
391
  const gone = opts.died();
386
392
  if (gone) throw new Error(gone);
387
393
  try {
@@ -406,13 +412,17 @@ async function waitForMainProcess(port, opts) {
406
412
  * @param {object} opts
407
413
  * @param {number} opts.timeoutMs
408
414
  * @param {() => string|null} opts.died
415
+ * @param {AbortSignal} [opts.signal]
409
416
  * @returns {Promise<{id: string, title: string, url: string}>}
410
417
  */
411
418
  async function waitForWindow(port, match, opts) {
412
- const until = Date.now() + opts.timeoutMs;
419
+ // Guarded for the same reason as the wait above it: a limit that is not a number turns this
420
+ // endless-looking loop into a genuinely endless one.
421
+ const until = Date.now() + boundedMs(opts.timeoutMs, 60_000);
413
422
  /** @type {any[]} */
414
423
  let pages = [];
415
424
  for (;;) {
425
+ if (opts.signal?.aborted) throw new Error('The run was stopped while waiting for the app to open a window.');
416
426
  const gone = opts.died();
417
427
  if (gone) throw new Error(gone);
418
428
  try {
@@ -465,10 +475,35 @@ async function waitForWindow(port, match, opts) {
465
475
  * @returns {Promise<OpenApp>}
466
476
  */
467
477
  export async function openApp(opts) {
468
- const timeoutMs = opts.timeoutMs ?? 60_000;
478
+ // The whole open, on ONE clock, on top of the clock each individual step already has.
479
+ //
480
+ // Every step below is bounded on its own, and that was still not enough: a run that gives up
481
+ // on eight things in a row for sixty seconds each has waited eight minutes, and the recorded
482
+ // symptom this file is being fixed for — an Electron check on 2026-08-30 that produced no
483
+ // output at all and never came back — is indistinguishable from a very long wait. So there
484
+ // is an outer limit as well, and because a bare "it timed out" sends somebody looking in
485
+ // every wrong place first, it names the step it was actually stuck in.
486
+ const timeoutMs = boundedMs(opts.timeoutMs, 60_000);
487
+ const stage = { at: 'the app to be started' };
488
+ return withLimit(openTheApp(opts, timeoutMs, stage), {
489
+ limitMs: timeoutMs * 3,
490
+ what: () => `${stage.at}. Nothing about this build was checked.`,
491
+ });
492
+ }
493
+
494
+ /**
495
+ * The steps of opening one app. Wrapped by `openApp`, which owns the outer limit.
496
+ *
497
+ * @param {Parameters<typeof openApp>[0]} opts
498
+ * @param {number} timeoutMs
499
+ * @param {{at: string}} stage Updated as it goes, so a give-up can name where it stopped.
500
+ * @returns {Promise<OpenApp>}
501
+ */
502
+ async function openTheApp(opts, timeoutMs, stage) {
469
503
  const isolation = opts.isolation;
470
504
  const startedAt = Date.now();
471
505
 
506
+ stage.at = 'the previous copy of this app to be proved gone';
472
507
  const alone = await verifyAlone(isolation);
473
508
  if (!alone.alone) throw new Error(`${alone.why} Nothing was started, because two copies of one app fight over the same lock and the same settings.`);
474
509
 
@@ -480,7 +515,9 @@ export async function openApp(opts) {
480
515
  };
481
516
 
482
517
  // ---- the main process, paused at its first statement
483
- const mainTarget = await waitForMainProcess(isolation.inspectPort, { timeoutMs, died });
518
+ stage.at = `the app to open its main-process debugging connection on port ${isolation.inspectPort}`;
519
+ const mainTarget = await waitForMainProcess(isolation.inspectPort, { timeoutMs, died, signal: opts.signal });
520
+ stage.at = 'the app to finish accepting a debugging connection to its main process';
484
521
  const main = await connect(mainTarget.webSocketDebuggerUrl, { timeoutMs: 20_000 });
485
522
  isolation.closeFirst(() => main.close());
486
523
 
@@ -499,6 +536,7 @@ export async function openApp(opts) {
499
536
  complain(`the app printed an error: ${text}`.split('\n')[0]);
500
537
  });
501
538
 
539
+ stage.at = 'the app to answer the first question about its main process';
502
540
  await main.send('Runtime.enable');
503
541
  await main.send('Debugger.enable');
504
542
 
@@ -507,13 +545,17 @@ export async function openApp(opts) {
507
545
  const stopListening = main.on('Debugger.paused', (params) => {
508
546
  if (frameId === null) frameId = String(params?.callFrames?.[0]?.callFrameId ?? '') || null;
509
547
  });
548
+ stage.at = 'the app to stop at its first line so the safety boundary can be put in place';
510
549
  await main.send('Runtime.runIfWaitingForDebugger');
550
+ // Five seconds, and it gives up rather than waits: an app that never stops at its first line
551
+ // is still worth checking, just with a hole in the report where the boundary would have been.
511
552
  for (let i = 0; i < 100 && frameId === null; i += 1) await rest(50);
512
553
 
513
554
  let watching = '';
514
555
  /** @type {string[]} */
515
556
  let couldNotWatch = [];
516
557
  if (frameId) {
558
+ stage.at = 'the safety boundary to be put in place inside the app';
517
559
  const result = await main.send('Debugger.evaluateOnCallFrame', {
518
560
  callFrameId: frameId,
519
561
  expression: mainProbeScript(),
@@ -531,8 +573,13 @@ export async function openApp(opts) {
531
573
  opts.log?.(watching ? `Watching ${watching}.` : 'Nothing is watching the main process from the inside.');
532
574
 
533
575
  // ---- the window
534
- const window = await waitForWindow(isolation.debugPort, opts.windowMatch, { timeoutMs, died });
576
+ stage.at = opts.windowMatch
577
+ ? `the app to open a window matching "${opts.windowMatch}"`
578
+ : 'the app to open a window to look at';
579
+ const window = await waitForWindow(isolation.debugPort, opts.windowMatch, { timeoutMs, died, signal: opts.signal });
580
+ stage.at = 'the app to say which window connection to use';
535
581
  const version = await askFor(`http://127.0.0.1:${isolation.debugPort}/json/version`);
582
+ stage.at = 'the app to accept a debugging connection to its window';
536
583
  const browser = await connect(String(version.webSocketDebuggerUrl), { timeoutMs: 20_000 });
537
584
  isolation.closeFirst(() => browser.close());
538
585
  const attached = await browser.send('Target.attachToTarget', { targetId: window.id, flatten: true });
@@ -578,6 +625,7 @@ export async function openApp(opts) {
578
625
  browser.send('Fetch.continueRequest', { requestId: params.requestId }, sessionId).catch(() => {});
579
626
  });
580
627
  });
628
+ stage.at = "the window to accept the boundary in front of its own network requests";
581
629
  await browser.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId).catch(() => {
582
630
  couldNotWatch.push("the window's own network requests");
583
631
  });
@@ -775,8 +823,12 @@ export function readMeaning(nodes, tidy = (t) => t) {
775
823
  * @returns {Promise<{nodes: any[], settled: boolean, reads: number}>}
776
824
  */
777
825
  export async function settleTree(read, opts = {}) {
778
- const tries = opts.tries ?? 8;
779
- const gapMs = opts.gapMs ?? 350;
826
+ // Both of these are settable from a project's own settings file, so both are guarded. A
827
+ // gap of NaN is a `setTimeout` of one millisecond, which turns "read until it stops moving"
828
+ // into a spin; a count that is not a number stops the loop running at all and silently
829
+ // reports an empty screen. Neither says anything out loud, which is why they are pinned.
830
+ const tries = boundedCount(opts.tries, 8, 200);
831
+ const gapMs = boundedMs(opts.gapMs, 350, 60_000);
780
832
  let previous = '';
781
833
  /** @type {any[]} */
782
834
  let nodes = [];
@@ -824,16 +876,26 @@ export async function takeStep(app, step) {
824
876
 
825
877
  if (act === 'wait') {
826
878
  if (step.control) {
827
- const until = Date.now() + Number(step.timeoutMs ?? 10_000);
879
+ // `boundedMs` and not `Number(...)`, and this is the one loop in this file that could
880
+ // genuinely run for ever. A journey is written by hand, and `timeoutMs: "10s"` is
881
+ // `Number("10s")`, which is NaN — at which point `Date.now() + NaN` is NaN, `Date.now()
882
+ // > NaN` is false every single time round, and this loop asks the app for its whole
883
+ // accessibility tree five times a second until somebody kills the tool. No output, no
884
+ // reason, and it looks exactly like the app hanging rather than the journey being
885
+ // mistyped. Capped at five minutes as well: a step that waits longer than that for a
886
+ // control to appear is a mistake in the journey, not patience.
887
+ const limitMs = boundedMs(step.timeoutMs, 10_000, 5 * 60_000);
888
+ const until = Date.now() + limitMs;
828
889
  for (;;) {
829
890
  const tree = await app.browser.send('Accessibility.getFullAXTree', {}, app.sessionId).catch(() => ({ nodes: [] }));
830
891
  if (readMeaning(tree.nodes ?? []).some((row) => row.name === String(step.control))) return say(`waited until "${step.control}" appeared`);
831
- if (Date.now() > until) return say(`waited for "${step.control}", and it never appeared`, false);
892
+ if (Date.now() > until) return say(`waited ${timeBucket(limitMs)} for "${step.control}" to appear, and it never did`, false);
832
893
  await rest(200);
833
894
  }
834
895
  }
835
- await rest(Number(step.ms ?? 500));
836
- return say(`waited ${timeBucket(Number(step.ms ?? 500))}`);
896
+ const restMs = boundedMs(step.ms, 500, 5 * 60_000);
897
+ await rest(restMs);
898
+ return say(`waited ${timeBucket(restMs)}`);
837
899
  }
838
900
 
839
901
  if (act === 'click' || act === 'focus') {
@@ -924,7 +986,21 @@ export async function exerciseChannel(app, channel, args) {
924
986
  if (thrown) return { answered: false, why: 'it threw: ' + String(thrown && thrown.message || thrown) };
925
987
  return { answered: true, value: reply === undefined ? null : reply, why: 'it answered' };
926
988
  })()`;
927
- const result = await app.main.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
989
+ // On a clock of its own, and it says which door it was knocking on. This is the only place
990
+ // in this adapter that makes the app RUN something, so it is the only place where the app's
991
+ // own code can decide never to answer — a handler that awaits a promise nobody resolves
992
+ // holds this open, and without a name in the sentence the report would say "the app did not
993
+ // answer" about an app with four hundred doors.
994
+ /** @type {any} */
995
+ let result;
996
+ try {
997
+ result = await withLimit(
998
+ app.main.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }),
999
+ { limitMs: 30_000, what: `the private channel "${channel}" to answer` },
1000
+ );
1001
+ } catch (e) {
1002
+ return { answered: false, value: null, why: e instanceof Error ? e.message : String(e) };
1003
+ }
928
1004
  if (result?.exceptionDetails) {
929
1005
  return { answered: false, value: null, why: `asking it threw: ${String(result.exceptionDetails.text ?? '')}` };
930
1006
  }
@@ -1563,7 +1639,13 @@ export const electronAdapter = defineAdapter({
1563
1639
  return tree?.nodes ?? [];
1564
1640
  }, { tries: config.settleTries ?? 8, gapMs: config.settleGapMs ?? 350 });
1565
1641
 
1566
- const read = await app.main.send('Runtime.evaluate', { expression: mainReadScript(), returnByValue: true, awaitPromise: true });
1642
+ // Bounded, and it degrades rather than throws. Everything the window said has already
1643
+ // been collected by this point, and losing all of it because the main process went quiet
1644
+ // would turn one hole into a whole unchecked build.
1645
+ const read = await withLimit(
1646
+ app.main.send('Runtime.evaluate', { expression: mainReadScript(), returnByValue: true, awaitPromise: true }),
1647
+ { limitMs: 30_000, what: 'the main process to say what it is, what doors it has open and what it did' },
1648
+ ).catch((e) => ({ result: { value: { problems: [e instanceof Error ? e.message : String(e)] } } }));
1567
1649
  const reading = read?.result?.value ?? { problems: ['the main process would not say anything about itself'] };
1568
1650
  if (Array.isArray(app.couldNotWatch) && app.couldNotWatch.length > 0) {
1569
1651
  reading.effects = reading.effects ?? {};