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.
- package/CHANGELOG.md +182 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +526 -19
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +293 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +253 -30
- package/src/v2/init.js +102 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/normalise.js +11 -0
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/report/html.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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>${
|
|
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(' · ')}</p>`);
|
|
300
344
|
const chips = [];
|
|
301
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
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
|
|
404
|
-
// make, a runtime this machine does not have, a parameter nobody supplied.
|
|
405
|
-
// the same thing to
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? {};
|