staysfixed 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -3
- package/README.md +92 -6
- package/package.json +1 -1
- package/src/v2/adapters/android-driver.js +8 -1
- package/src/v2/adapters/android.js +36 -4
- package/src/v2/adapters/contract.js +84 -6
- package/src/v2/adapters/electron.js +5 -5
- package/src/v2/adapters/http.js +7 -6
- package/src/v2/adapters/process.js +17 -6
- package/src/v2/adapters/source.js +62 -11
- package/src/v2/adapters/web.js +6 -6
- package/src/v2/cause.js +15 -8
- package/src/v2/check.js +121 -23
- package/src/v2/cli.js +16 -3
- package/src/v2/coverage.js +9 -1
- package/src/v2/detect.js +1 -1
- package/src/v2/doctor.js +15 -3
- package/src/v2/journeys/from-routes.js +3 -1
- package/src/v2/observation.js +62 -1
- package/src/v2/remote.js +4 -5
- package/src/v2/run.js +125 -9
- package/src/v2/sealed.js +10 -6
- package/src/v2/selfcheck.js +197 -32
- package/src/v2/types.js +6 -0
- package/src/v2/watch/events.js +1087 -0
- package/src/v2/watch/focus.js +215 -0
- package/src/v2/watch/index.js +382 -0
- package/src/v2/watch/panel.js +1660 -0
- package/src/v2/watch/window.js +1671 -0
package/src/v2/selfcheck.js
CHANGED
|
@@ -56,7 +56,7 @@ const run = promisify(execFile);
|
|
|
56
56
|
* @typedef {object} Case
|
|
57
57
|
* @property {string} name A sentence, because it is read back as one.
|
|
58
58
|
* @property {string} breaks What is wrong, in plain English.
|
|
59
|
-
* @property {'a finding'|'nothing'} expect
|
|
59
|
+
* @property {'a finding'|'nothing'|'no answer'} expect
|
|
60
60
|
* @property {RegExp[]} [mustSay]
|
|
61
61
|
* @property {boolean} [mustBeUnstable] It has to land in `newlyUnstable`, not in the findings.
|
|
62
62
|
* @property {(broken: boolean) => Record<string, string>} build
|
|
@@ -210,6 +210,46 @@ export const CASES = [
|
|
|
210
210
|
}),
|
|
211
211
|
},
|
|
212
212
|
|
|
213
|
+
{
|
|
214
|
+
name: 'a break buried in the middle of a huge output',
|
|
215
|
+
breaks:
|
|
216
|
+
'A program prints more than the tool will store, and the thing that broke is in the middle — past the head it keeps and before the tail it keeps. This is the case the tool used to be blind to: it kept the two ends and a COARSE size, so a change in the discarded middle left a byte-identical record and the run reported that nothing had changed. Exactly the shape of failure this whole thing exists to prevent, and it survived until 2026-08-30.',
|
|
217
|
+
expect: 'a finding',
|
|
218
|
+
// The marker in the middle is what has to have caught it. If some other part of the value
|
|
219
|
+
// reported instead, this case has stopped testing what it was written to test.
|
|
220
|
+
mustSay: [/bytes left out of the middle/],
|
|
221
|
+
build: (broken) => ({
|
|
222
|
+
'package.json': PKG,
|
|
223
|
+
'cli.js': [
|
|
224
|
+
"console.log('report begins');",
|
|
225
|
+
'for (let i = 0; i < 6000; i += 1) {',
|
|
226
|
+
broken
|
|
227
|
+
? " console.log(i === 3000 ? `row ${i}: could not be loaded at all` : `row ${i}: ok`);"
|
|
228
|
+
: ' console.log(`row ${i}: ok`);',
|
|
229
|
+
'}',
|
|
230
|
+
"console.log('report ends');",
|
|
231
|
+
'',
|
|
232
|
+
].join('\n'),
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
name: 'a build that takes ten times longer stays silent',
|
|
238
|
+
breaks:
|
|
239
|
+
'Nothing, and the product is markedly slower. How long something took is recorded and never compared, because a stopwatch on a shared machine measures the machine as much as the product — and comparing it is what made this corpus fail one case out of nine on a busy laptop while passing five times in a row on a quiet one. This case exists so that decision cannot be quietly undone: put timing back into the comparison and this goes red.',
|
|
240
|
+
expect: 'nothing',
|
|
241
|
+
build: (broken) => ({
|
|
242
|
+
'package.json': PKG,
|
|
243
|
+
'cli.js': [
|
|
244
|
+
// A sleep, deliberately, and never a busy loop. Loading the machine to test timing
|
|
245
|
+
// is how you take four other things down with you.
|
|
246
|
+
`await new Promise((done) => setTimeout(done, ${broken ? 900 : 40}));`,
|
|
247
|
+
"console.log('total 10.005');",
|
|
248
|
+
'',
|
|
249
|
+
].join('\n'),
|
|
250
|
+
}),
|
|
251
|
+
},
|
|
252
|
+
|
|
213
253
|
{
|
|
214
254
|
name: 'a value that used to be steady is now random',
|
|
215
255
|
breaks:
|
|
@@ -226,6 +266,28 @@ export const CASES = [
|
|
|
226
266
|
'cli.js': [broken ? 'console.log(`batch id ${Math.floor(Math.random() * 1e9)}`);' : 'console.log(`batch id 4242`);', "console.log('two orders');", ''].join('\n'),
|
|
227
267
|
}),
|
|
228
268
|
},
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
name: 'a run that could not answer says so instead of passing',
|
|
272
|
+
breaks:
|
|
273
|
+
'The break is real and it is hidden by the product itself: this build writes a fresh set of randomly named files on every run and stamps a random id on what it prints, so the same build disagrees with itself about nearly every address it has. Everything that wobbles is subtracted before anything is compared — which is right, and which here removes the comparison altogether. The only honest answer is that this run says nothing, and until 2026-08-30 the engine said "nothing that already worked has changed", which is the same sentence it uses when a product is genuinely fine.',
|
|
274
|
+
expect: 'no answer',
|
|
275
|
+
build: (broken) => ({
|
|
276
|
+
'package.json': PKG,
|
|
277
|
+
'cli.js': [
|
|
278
|
+
"import fs from 'node:fs';",
|
|
279
|
+
"fs.mkdirSync('out', { recursive: true });",
|
|
280
|
+
'// A build tool writing hash-named artefacts. Nothing unusual, and every one of them',
|
|
281
|
+
'// is a new address that was not there on the last run.',
|
|
282
|
+
'for (let i = 0; i < 30; i += 1) {',
|
|
283
|
+
" fs.writeFileSync(`out/chunk-${Math.random().toString(36).slice(2, 10)}.txt`, 'x');",
|
|
284
|
+
'}',
|
|
285
|
+
'console.log(`request ${Math.random().toString(36).slice(2, 10)}`);',
|
|
286
|
+
broken ? "console.log('orders: could not be loaded');" : "console.log('orders: 2');",
|
|
287
|
+
'',
|
|
288
|
+
].join('\n'),
|
|
289
|
+
}),
|
|
290
|
+
},
|
|
229
291
|
];
|
|
230
292
|
|
|
231
293
|
// ---------------------------------------------------------------------------
|
|
@@ -237,7 +299,7 @@ export const CASES = [
|
|
|
237
299
|
* @property {string} name
|
|
238
300
|
* @property {boolean} caught True when the case behaved: the break was found, or the clean pair stayed silent.
|
|
239
301
|
* @property {string} [why] Why it did not, in one plain sentence.
|
|
240
|
-
* @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'} verdict
|
|
302
|
+
* @property {'caught'|'quiet'|'escaped'|'false alarm'|'could not run'|'could not tell'|'said it could not tell'} verdict
|
|
241
303
|
*/
|
|
242
304
|
|
|
243
305
|
/**
|
|
@@ -245,6 +307,8 @@ export const CASES = [
|
|
|
245
307
|
* @property {boolean} passed
|
|
246
308
|
* @property {CaseResult[]} cases
|
|
247
309
|
* @property {boolean} ran False when the engine could not be driven at all.
|
|
310
|
+
* @property {boolean} [certain] False when at least one case could not be told either way.
|
|
311
|
+
* A run that is not certain is NOT a pass and NOT a failure.
|
|
248
312
|
* @property {string} [why] Why it could not run.
|
|
249
313
|
* @property {string} [workDir]
|
|
250
314
|
*/
|
|
@@ -287,48 +351,108 @@ export async function selfcheck(opts = {}) {
|
|
|
287
351
|
const cases = [];
|
|
288
352
|
|
|
289
353
|
for (const c of wanted) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
working = await plant(dir, c);
|
|
295
|
-
} catch (e) {
|
|
296
|
-
cases.push({ name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` });
|
|
354
|
+
const first = await runOne(check, workDir, c, 1);
|
|
355
|
+
if (first.caught) {
|
|
356
|
+
cases.push(first);
|
|
297
357
|
continue;
|
|
298
358
|
}
|
|
299
359
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
360
|
+
// IT FAILED. Before that becomes an accusation, it has to reproduce.
|
|
361
|
+
//
|
|
362
|
+
// This is the same rule the engine itself lives by, turned on the corpus: a difference
|
|
363
|
+
// that will not happen twice is not a difference. On the night of 2026-08-29 this corpus
|
|
364
|
+
// came back "1 of 9 wrong" while the test suite was running beside it and then passed
|
|
365
|
+
// five times in a row on a quiet machine — and a corpus that can be perturbed by a busy
|
|
366
|
+
// laptop is worth nothing on a busy laptop, because nobody can tell its noise from its
|
|
367
|
+
// signal. The cause was found and removed (see howLongItTook in adapters/contract.js),
|
|
368
|
+
// and this stays anyway, because the next machine-shaped thing to creep in should land
|
|
369
|
+
// as "I could not tell" rather than as a false accusation somebody learns to ignore.
|
|
370
|
+
//
|
|
371
|
+
// A second run that agrees is a real failure and is reported as one. A second run that
|
|
372
|
+
// disagrees is filed as UNTELLABLE, which is not a pass: the exit code is 2, the same
|
|
373
|
+
// one used for "the corpus could not be run at all", because both mean no answer.
|
|
374
|
+
const second = await runOne(check, workDir, c, 2);
|
|
375
|
+
if (!second.caught) {
|
|
376
|
+
cases.push({ ...second, why: `${second.why ?? 'it did not behave'} (it did this twice in a row, so it is real)` });
|
|
316
377
|
continue;
|
|
317
378
|
}
|
|
318
|
-
|
|
319
|
-
|
|
379
|
+
cases.push({
|
|
380
|
+
name: c.name,
|
|
381
|
+
caught: false,
|
|
382
|
+
verdict: 'could not tell',
|
|
383
|
+
why:
|
|
384
|
+
`it behaved on the second run and not on the first, so this says nothing either way. ` +
|
|
385
|
+
`The first time: ${first.why ?? 'it did not behave'}. ` +
|
|
386
|
+
`This machine's load was ${loadNow()} — something that comes and goes with how busy the machine is is not evidence about the engine. ` +
|
|
387
|
+
`Run it again on a quiet machine before believing either answer.`,
|
|
388
|
+
});
|
|
320
389
|
}
|
|
321
390
|
|
|
322
391
|
if (!opts.keep) await fsp.rm(workDir, { recursive: true, force: true });
|
|
323
392
|
|
|
393
|
+
const untellable = cases.some((r) => r.verdict === 'could not tell');
|
|
324
394
|
return {
|
|
325
395
|
passed: cases.length > 0 && cases.every((r) => r.caught),
|
|
326
396
|
ran: true,
|
|
397
|
+
certain: !untellable,
|
|
327
398
|
cases,
|
|
328
399
|
...(opts.keep ? { workDir } : {}),
|
|
329
400
|
};
|
|
330
401
|
}
|
|
331
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Build one case fresh and put the engine through it once.
|
|
405
|
+
*
|
|
406
|
+
* A fresh folder every attempt, deliberately. Re-running inside the same folder would leave
|
|
407
|
+
* the first attempt's stored captures sitting there, and the second attempt would be
|
|
408
|
+
* comparing against those rather than against the build that works.
|
|
409
|
+
*
|
|
410
|
+
* @param {any} check
|
|
411
|
+
* @param {string} workDir
|
|
412
|
+
* @param {Case} c
|
|
413
|
+
* @param {number} attempt
|
|
414
|
+
* @returns {Promise<CaseResult>}
|
|
415
|
+
*/
|
|
416
|
+
async function runOne(check, workDir, c, attempt) {
|
|
417
|
+
const dir = path.join(workDir, `${safe(c.name)}${attempt > 1 ? `-again-${attempt}` : ''}`);
|
|
418
|
+
/** @type {string} */
|
|
419
|
+
let working;
|
|
420
|
+
try {
|
|
421
|
+
working = await plant(dir, c);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return { name: c.name, caught: false, verdict: 'could not run', why: `the product could not be built: ${why(e)}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @type {any} */
|
|
427
|
+
let result;
|
|
428
|
+
try {
|
|
429
|
+
// Exactly the call an agent makes, with exactly the arguments an agent
|
|
430
|
+
// sends. A corpus that reached past the front door would prove the engine
|
|
431
|
+
// works when driven in a way nobody drives it.
|
|
432
|
+
result = await check({
|
|
433
|
+
cwd: dir,
|
|
434
|
+
configFile: undefined,
|
|
435
|
+
against: working,
|
|
436
|
+
paired: true,
|
|
437
|
+
journeys: path.join(dir, 'journeys.json'),
|
|
438
|
+
only: [],
|
|
439
|
+
});
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return { name: c.name, caught: false, verdict: 'could not run', why: `the engine threw: ${why(e)}` };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return judge(c, result);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** How busy this machine is, in words, so an untellable result can name the likely reason. */
|
|
448
|
+
function loadNow() {
|
|
449
|
+
const [one] = os.loadavg();
|
|
450
|
+
const cores = os.cpus().length || 1;
|
|
451
|
+
const per = one / cores;
|
|
452
|
+
const how = per < 0.4 ? 'quiet' : per < 0.9 ? 'busy' : 'very busy';
|
|
453
|
+
return `${one.toFixed(1)} across ${cores} cores, which is ${how}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
332
456
|
/**
|
|
333
457
|
* Did the engine do what this case demands?
|
|
334
458
|
*
|
|
@@ -349,9 +473,35 @@ function judge(c, result) {
|
|
|
349
473
|
const findings = Array.isArray(result?.findings) ? result.findings : [];
|
|
350
474
|
const unstable = Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : [];
|
|
351
475
|
|
|
476
|
+
// The third expectation, and the one the other two cannot express: a run that is entitled
|
|
477
|
+
// to no verdict at all. What is demanded here is narrow on purpose — not that it found the
|
|
478
|
+
// break, which it cannot, but that it refused to call the run clean and said why in words a
|
|
479
|
+
// person can read.
|
|
480
|
+
if (c.expect === 'no answer') {
|
|
481
|
+
const said = String(result?.summary ?? '');
|
|
482
|
+
if (result?.ok === false && /no answer|not a pass/i.test(said)) {
|
|
483
|
+
return { name: c.name, caught: true, verdict: 'said it could not tell' };
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
name: c.name,
|
|
487
|
+
caught: false,
|
|
488
|
+
verdict: 'escaped',
|
|
489
|
+
why:
|
|
490
|
+
result?.ok === false
|
|
491
|
+
? `it did not pass, but it never said why in a way anybody could read: ${said.slice(0, 200)}`
|
|
492
|
+
: `it reported a clean run over a comparison that had been thrown away: ${said.slice(0, 200)}`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
352
496
|
if (c.expect === 'nothing') {
|
|
353
497
|
if (findings.length === 0 && unstable.length === 0) return { name: c.name, caught: true, verdict: 'quiet' };
|
|
354
|
-
|
|
498
|
+
// `unstable` holds WobbleEntry objects, not strings. Interpolating one printed
|
|
499
|
+
// "[object Object]" and turned the most important line in a failure report — the one
|
|
500
|
+
// saying WHAT went wrong — into nothing at all.
|
|
501
|
+
const named = unstable.map((/** @type {any} */ u) => (typeof u === 'string' ? u : `${u?.path ?? 'an address'} (was ${JSON.stringify(u?.a)}, then ${JSON.stringify(u?.b)})`));
|
|
502
|
+
const what = findings.length
|
|
503
|
+
? `${findings.length} finding${findings.length === 1 ? '' : 's'}: ${describe(findings[0])}`
|
|
504
|
+
: `${unstable.length} newly unpredictable address${unstable.length === 1 ? '' : 'es'}: ${named.slice(0, 3).join('; ')}`;
|
|
355
505
|
return { name: c.name, caught: false, verdict: 'false alarm', why: `two builds that should have looked the same produced ${what}` };
|
|
356
506
|
}
|
|
357
507
|
|
|
@@ -532,13 +682,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
532
682
|
|
|
533
683
|
if (json) {
|
|
534
684
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
535
|
-
|
|
685
|
+
if (result.passed) return 0;
|
|
686
|
+
if (!result.ran) return 2;
|
|
687
|
+
return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
|
|
536
688
|
}
|
|
537
689
|
|
|
538
690
|
if (!result.ran) {
|
|
539
691
|
process.stderr.write(`Could not run the self-check.\n${result.why ?? ''}\n`);
|
|
540
692
|
return 2;
|
|
541
693
|
}
|
|
694
|
+
const untellable = result.cases.filter((r) => r.verdict === 'could not tell');
|
|
542
695
|
|
|
543
696
|
/** @type {string[]} */
|
|
544
697
|
const out = ['Stays Fixed - checking that it can still catch things', ''];
|
|
@@ -549,14 +702,26 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
549
702
|
out.push('');
|
|
550
703
|
if (result.passed) {
|
|
551
704
|
out.push(`All ${result.cases.length} behaved: every break was caught, and every pair that should have been silent was silent.`);
|
|
705
|
+
} else if (untellable.length > 0 && untellable.length === result.cases.filter((r) => !r.caught).length) {
|
|
706
|
+
// Nothing failed twice. Saying "wrong" here would be an accusation the evidence does not
|
|
707
|
+
// support, and saying "fine" would be worse.
|
|
708
|
+
out.push(
|
|
709
|
+
`${untellable.length} of ${result.cases.length} could not be told either way — ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
|
|
710
|
+
'That is not a pass and not a failure. Run it again on a quiet machine.',
|
|
711
|
+
);
|
|
552
712
|
} else {
|
|
553
|
-
const bad = result.cases.filter((r) => !r.caught);
|
|
554
|
-
out.push(`${bad.length} of ${result.cases.length} did not behave. Until that is fixed, a clean check from this tool does not mean what it says.`);
|
|
713
|
+
const bad = result.cases.filter((r) => !r.caught && r.verdict !== 'could not tell');
|
|
714
|
+
out.push(`${bad.length} of ${result.cases.length} did not behave, twice in a row each. Until that is fixed, a clean check from this tool does not mean what it says.`);
|
|
715
|
+
if (untellable.length > 0) out.push(`${untellable.length} more could not be told either way.`);
|
|
555
716
|
}
|
|
556
717
|
if (result.workDir) out.push(`The products were left in ${result.workDir}.`);
|
|
557
718
|
|
|
558
719
|
process.stdout.write(out.join('\n') + '\n');
|
|
559
|
-
|
|
720
|
+
if (result.passed) return 0;
|
|
721
|
+
// "I could not test this" is exit 2 and never exit 0, and it is not exit 1 either: one of
|
|
722
|
+
// those says the engine is broken and the other says nobody knows, and they need different
|
|
723
|
+
// reactions from whoever is reading.
|
|
724
|
+
return result.certain === false && result.cases.every((r) => r.caught || r.verdict === 'could not tell') ? 2 : 1;
|
|
560
725
|
}
|
|
561
726
|
|
|
562
727
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
package/src/v2/types.js
CHANGED
|
@@ -235,6 +235,12 @@
|
|
|
235
235
|
* False when we had no stability record for the reference,
|
|
236
236
|
* so `newlyUnstable` is empty for lack of evidence rather
|
|
237
237
|
* than because nothing became unstable.
|
|
238
|
+
* @property {boolean} [couldNotTell] True when the wobble measurement was too big to be a
|
|
239
|
+
* measurement — the same build answered differently at
|
|
240
|
+
* most of its own addresses, so subtracting it subtracts
|
|
241
|
+
* the answer. A run in this state has no verdict, and it
|
|
242
|
+
* must never be reported as a clean one.
|
|
243
|
+
* @property {string} [couldNotTellWhy] Said plainly, with the numbers in it.
|
|
238
244
|
* @property {string} note One plain sentence stating exactly that.
|
|
239
245
|
*/
|
|
240
246
|
|