staysfixed 0.7.1 → 0.8.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 +364 -0
- package/README.md +193 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +345 -47
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
|
@@ -374,6 +374,13 @@ export async function runOneFile(opts) {
|
|
|
374
374
|
|
|
375
375
|
/** @type {NodeJS.ProcessEnv} */
|
|
376
376
|
const env = { ...process.env, STAYSFIXED_HARVEST: '1' };
|
|
377
|
+
// Node's own test runner marks the processes it starts with NODE_TEST_CONTEXT, and a test
|
|
378
|
+
// file that finds it switches from TAP to a private binary stream meant for its parent. Let
|
|
379
|
+
// that through and every file we run comes back having reported nothing at all, and the
|
|
380
|
+
// harvest says - calmly, one line per file - that this project's suite cannot be walked.
|
|
381
|
+
// It bites whenever the harvest is itself started from inside a test run, which is exactly
|
|
382
|
+
// where anybody would first try it.
|
|
383
|
+
delete env.NODE_TEST_CONTEXT;
|
|
377
384
|
// Node's own runner writes coverage for the process and every child it spawns, which is
|
|
378
385
|
// exactly how a test file's own run gets measured. Vitest is asked for its own instead.
|
|
379
386
|
if (wantCoverage && opts.runner === 'node:test') env.NODE_V8_COVERAGE = coverageDir;
|
|
@@ -505,30 +512,149 @@ function runToEnd(command, argv, opts) {
|
|
|
505
512
|
// ---------------------------------------------------------------------------
|
|
506
513
|
|
|
507
514
|
/**
|
|
508
|
-
*
|
|
515
|
+
* One check a test file reported.
|
|
516
|
+
*
|
|
517
|
+
* `detail` is only filled in for a check that failed, and it is the whole of what the runner
|
|
518
|
+
* said about it. It matters because "this test is still failing" and "this test is failing
|
|
519
|
+
* for a completely different reason now" are two different facts, and a pass-or-fail flag
|
|
520
|
+
* alone cannot tell them apart - which is exactly the half an exit code misses.
|
|
521
|
+
*
|
|
522
|
+
* @typedef {object} Check
|
|
523
|
+
* @property {string} name
|
|
524
|
+
* @property {boolean} ok
|
|
525
|
+
* @property {string} [detail]
|
|
526
|
+
*/
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Pull the checks out of TAP, each with whether it passed and what it said when it did not.
|
|
509
530
|
*
|
|
510
531
|
* Names rather than counts, because two runs producing thirteen checks each is not the same
|
|
511
532
|
* evidence as two runs producing the same thirteen checks.
|
|
512
533
|
*
|
|
513
534
|
* @param {string} output
|
|
514
|
-
* @returns {
|
|
535
|
+
* @returns {Check[]} in the order the runner reported them
|
|
515
536
|
*/
|
|
516
|
-
export function
|
|
517
|
-
/** @type {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
let
|
|
521
|
-
|
|
522
|
-
const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(line);
|
|
537
|
+
export function parseTapChecks(output) {
|
|
538
|
+
/** @type {Check[]} */
|
|
539
|
+
const checks = [];
|
|
540
|
+
const lines = String(output).split('\n');
|
|
541
|
+
for (let i = 0; i < lines.length; i++) {
|
|
542
|
+
const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(lines[i]);
|
|
523
543
|
if (!match) continue;
|
|
524
544
|
const name = match[2].replace(/\s*#\s*(SKIP|TODO).*$/i, '').trim();
|
|
525
545
|
if (name === '') continue;
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
|
|
546
|
+
const ok = !match[1];
|
|
547
|
+
if (ok) {
|
|
548
|
+
checks.push({ name, ok });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
// A failing check is followed by an indented block holding what went wrong. It opens on
|
|
552
|
+
// `---` and closes on `...`, and everything between is the reason this check is worth
|
|
553
|
+
// reading rather than counting.
|
|
554
|
+
/** @type {string[]} */
|
|
555
|
+
const said = [];
|
|
556
|
+
let j = i + 1;
|
|
557
|
+
if (/^\s*---\s*$/.test(lines[j] ?? '')) {
|
|
558
|
+
j++;
|
|
559
|
+
while (j < lines.length && !/^\s*\.\.\.\s*$/.test(lines[j])) {
|
|
560
|
+
said.push(lines[j]);
|
|
561
|
+
j++;
|
|
562
|
+
}
|
|
563
|
+
i = j;
|
|
564
|
+
}
|
|
565
|
+
const detail = withoutRunnerTiming(said.join('\n')).trim();
|
|
566
|
+
checks.push(detail === '' ? { name, ok } : { name, ok, detail });
|
|
529
567
|
}
|
|
530
|
-
|
|
531
|
-
|
|
568
|
+
return checks;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Pull the check names out of TAP.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} output
|
|
575
|
+
* @returns {{tests: string[], passed: number, failed: number}}
|
|
576
|
+
*/
|
|
577
|
+
export function parseTap(output) {
|
|
578
|
+
const checks = parseTapChecks(output);
|
|
579
|
+
return {
|
|
580
|
+
tests: checks.map((c) => c.name).sort(),
|
|
581
|
+
passed: checks.filter((c) => c.ok).length,
|
|
582
|
+
failed: checks.filter((c) => !c.ok).length,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The runner's own stopwatch, taken back out of what it printed.
|
|
588
|
+
*
|
|
589
|
+
* Measured rather than assumed: two runs of the same three-check file, on the same bytes,
|
|
590
|
+
* minutes apart, differ in exactly four lines and every one of them is a `duration_ms`.
|
|
591
|
+
* Nothing else moves. So this one small rule turns the whole of what a test runner printed
|
|
592
|
+
* from noise into something worth comparing word for word - and comparing it is the point,
|
|
593
|
+
* because a program that starts printing a deprecation warning under a test that still
|
|
594
|
+
* passes is invisible to an exit code.
|
|
595
|
+
*
|
|
596
|
+
* WHAT THIS COULD HIDE, said plainly: a test that got slower. That is already the one thing
|
|
597
|
+
* this tool refuses to report on principle - see `howLongItTook` in adapters/contract.js -
|
|
598
|
+
* so nothing is lost here that was ever going to be claimed.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} text
|
|
601
|
+
* @returns {string}
|
|
602
|
+
*/
|
|
603
|
+
export function withoutRunnerTiming(text) {
|
|
604
|
+
return String(text)
|
|
605
|
+
.split('\n')
|
|
606
|
+
.filter((line) => !/^\s*#?\s*duration_ms[:\s]/.test(line))
|
|
607
|
+
.join('\n');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The keys vitest fills in from a clock. A named list, so nothing else is touched. */
|
|
611
|
+
const VITEST_CLOCK_KEYS = new Set([
|
|
612
|
+
'duration', 'startTime', 'endTime', 'start', 'end', 'setupDuration', 'collectDuration',
|
|
613
|
+
'prepareDuration', 'environmentSetupDuration', 'runtime', 'heap',
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Everything a test runner printed, with its own stopwatch out of it and nothing else.
|
|
618
|
+
*
|
|
619
|
+
* Node's runner narrates its timings as `duration_ms` lines, which come straight out. Vitest
|
|
620
|
+
* hands back a JSON report with the times scattered through it under named keys, so it is
|
|
621
|
+
* parsed and those exact keys - a named list, never a pattern over the text - are replaced.
|
|
622
|
+
* Replaced rather than deleted, because a key vanishing and a key holding a different number
|
|
623
|
+
* are two different facts and only one of them is the clock.
|
|
624
|
+
*
|
|
625
|
+
* Anything that will not parse comes back with the line rule applied and no more. Guessing
|
|
626
|
+
* at the shape of text nobody recognises is how a real change gets rubbed out.
|
|
627
|
+
*
|
|
628
|
+
* @param {Runner} runner
|
|
629
|
+
* @param {string} text
|
|
630
|
+
* @returns {string}
|
|
631
|
+
*/
|
|
632
|
+
export function quietenRunnerOutput(runner, text) {
|
|
633
|
+
const plain = withoutRunnerTiming(text);
|
|
634
|
+
if (runner !== 'vitest') return plain;
|
|
635
|
+
const start = plain.indexOf('{');
|
|
636
|
+
if (start < 0) return plain;
|
|
637
|
+
try {
|
|
638
|
+
const report = JSON.parse(plain.slice(start));
|
|
639
|
+
return `${plain.slice(0, start)}${JSON.stringify(hushClock(report), null, 2)}`;
|
|
640
|
+
} catch {
|
|
641
|
+
return plain;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* @param {unknown} value
|
|
647
|
+
* @returns {unknown}
|
|
648
|
+
*/
|
|
649
|
+
function hushClock(value) {
|
|
650
|
+
if (Array.isArray(value)) return value.map(hushClock);
|
|
651
|
+
if (value === null || typeof value !== 'object') return value;
|
|
652
|
+
/** @type {Record<string, unknown>} */
|
|
653
|
+
const out = {};
|
|
654
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
655
|
+
out[key] = VITEST_CLOCK_KEYS.has(key) && typeof inner === 'number' ? 'a time, not compared' : hushClock(inner);
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
532
658
|
}
|
|
533
659
|
|
|
534
660
|
/**
|
|
@@ -537,26 +663,77 @@ export function parseTap(output) {
|
|
|
537
663
|
*/
|
|
538
664
|
async function readVitestResults(file) {
|
|
539
665
|
try {
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
for (const assertion of suite.assertionResults ?? []) {
|
|
547
|
-
const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
|
|
548
|
-
tests.push(name);
|
|
549
|
-
if (assertion.status === 'passed') passed++;
|
|
550
|
-
else if (assertion.status === 'failed') failed++;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
tests.sort();
|
|
554
|
-
return { tests, passed, failed };
|
|
666
|
+
const checks = checksFromVitestReport(JSON.parse(await fsp.readFile(file, 'utf8')));
|
|
667
|
+
return {
|
|
668
|
+
tests: checks.map((c) => c.name).sort(),
|
|
669
|
+
passed: checks.filter((c) => c.ok).length,
|
|
670
|
+
failed: checks.filter((c) => !c.ok).length,
|
|
671
|
+
};
|
|
555
672
|
} catch {
|
|
556
673
|
return { tests: [], passed: 0, failed: 0 };
|
|
557
674
|
}
|
|
558
675
|
}
|
|
559
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Vitest's own JSON report, as checks.
|
|
679
|
+
*
|
|
680
|
+
* @param {any} report
|
|
681
|
+
* @returns {Check[]}
|
|
682
|
+
*/
|
|
683
|
+
export function checksFromVitestReport(report) {
|
|
684
|
+
/** @type {Check[]} */
|
|
685
|
+
const checks = [];
|
|
686
|
+
for (const suite of report?.testResults ?? []) {
|
|
687
|
+
for (const assertion of suite?.assertionResults ?? []) {
|
|
688
|
+
const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
|
|
689
|
+
if (name === '') continue;
|
|
690
|
+
const ok = assertion.status === 'passed';
|
|
691
|
+
const detail = withoutRunnerTiming(
|
|
692
|
+
Array.isArray(assertion.failureMessages) ? assertion.failureMessages.join('\n') : '',
|
|
693
|
+
).trim();
|
|
694
|
+
checks.push(ok || detail === '' ? { name, ok } : { name, ok, detail });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return checks;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The checks a walked test file reported, read out of whatever the runner printed.
|
|
702
|
+
*
|
|
703
|
+
* This is the walking half of the harvest. `runOneFile` reads a runner it started itself and
|
|
704
|
+
* knows exactly where the report went; a journey being walked months later has only the text
|
|
705
|
+
* the command printed. The two live next to each other on purpose - a journey read
|
|
706
|
+
* differently from the way it was harvested is not the same journey.
|
|
707
|
+
*
|
|
708
|
+
* `read` false is not "no checks". It means nothing here could tell, and the caller has to
|
|
709
|
+
* report that as a hole rather than as a file with nothing in it.
|
|
710
|
+
*
|
|
711
|
+
* @param {Runner} runner
|
|
712
|
+
* @param {string} stdout
|
|
713
|
+
* @returns {{checks: Check[], read: boolean, why: string}}
|
|
714
|
+
*/
|
|
715
|
+
export function readChecks(runner, stdout) {
|
|
716
|
+
const text = String(stdout ?? '');
|
|
717
|
+
if (runner === 'vitest') {
|
|
718
|
+
// Asked for `--reporter=json` with nowhere to put it, vitest prints the report to the
|
|
719
|
+
// screen, sometimes after a banner. The first `{` that parses is the report.
|
|
720
|
+
const start = text.indexOf('{');
|
|
721
|
+
if (start >= 0) {
|
|
722
|
+
try {
|
|
723
|
+
const checks = checksFromVitestReport(JSON.parse(text.slice(start)));
|
|
724
|
+
if (checks.length > 0) return { checks, read: true, why: `Vitest reported ${checks.length} checks.` };
|
|
725
|
+
} catch { /* fall through to TAP, which some vitest setups print instead */ }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const checks = parseTapChecks(text);
|
|
729
|
+
if (checks.length > 0) return { checks, read: true, why: `${checks.length} checks reported.` };
|
|
730
|
+
return {
|
|
731
|
+
checks: [],
|
|
732
|
+
read: false,
|
|
733
|
+
why: 'The test runner printed nothing this could read as a list of checks, so which checks passed is not known.',
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
560
737
|
/**
|
|
561
738
|
* How many functions from one file are worth writing down. A journey that lists nine
|
|
562
739
|
* hundred function names is not evidence, it is a wall, and nobody reads a wall.
|
|
@@ -719,6 +896,11 @@ export function relativeIfInside(url, root) {
|
|
|
719
896
|
* @property {string[]} [files] Exact files, relative to the root. Overrides listing.
|
|
720
897
|
* @property {string[]} [only] Substrings a test file's path must contain.
|
|
721
898
|
* @property {number} [limit] Stop after this many files. Coverage says so out loud.
|
|
899
|
+
* @property {number} [budgetMs] Stop harvesting once this much time has gone, and name
|
|
900
|
+
* every file that was not reached. Default
|
|
901
|
+
* `DEFAULT_HARVEST_BUDGET_MS`. Zero means no budget,
|
|
902
|
+
* which is a thing to ask for on purpose and never a
|
|
903
|
+
* default.
|
|
722
904
|
* @property {1|2} [repeat] Runs per file. Two is the default and it is the point:
|
|
723
905
|
* a journey that does not reproduce twice on the same
|
|
724
906
|
* build is rejected at birth rather than admitted and
|
|
@@ -748,11 +930,36 @@ export function relativeIfInside(url, root) {
|
|
|
748
930
|
* @property {{file: string, failed: number}[]} failing
|
|
749
931
|
* Files whose checks did not all pass. Kept anyway —
|
|
750
932
|
* what a test exercises is useful even when it is red.
|
|
933
|
+
* @property {string[]} notReached
|
|
934
|
+
* Test files the budget ran out before. Named one by
|
|
935
|
+
* one, never counted: "some of your tests were
|
|
936
|
+
* skipped" tells a reader nothing they can act on, and
|
|
937
|
+
* a reader who cannot tell which half of their suite is
|
|
938
|
+
* being watched will assume it is all of it.
|
|
939
|
+
* @property {number} [budgetMs] The budget this harvest was held to, when it had one.
|
|
751
940
|
* @property {Missing[]} missing
|
|
752
941
|
* @property {string[]} notes
|
|
753
942
|
* @property {number} durationMs
|
|
754
943
|
*/
|
|
755
944
|
|
|
945
|
+
/**
|
|
946
|
+
* How long a harvest gets before it stops and says what it did not reach.
|
|
947
|
+
*
|
|
948
|
+
* WHY THERE IS A BUDGET AT ALL. This whole tool is worth using because it is cheap enough to
|
|
949
|
+
* run on every change. A stranger's test suite is not cheap: twenty minutes is an ordinary
|
|
950
|
+
* number for a real one, and the harvest runs every file TWICE to prove it repeats. A check
|
|
951
|
+
* that costs three quarters of an hour is a check nobody runs, and a check nobody runs
|
|
952
|
+
* catches nothing - which is a worse failure than any bug it could have found.
|
|
953
|
+
*
|
|
954
|
+
* Ninety seconds is not a guess about how long suites take. It is a statement about how long
|
|
955
|
+
* somebody will wait inside an edit-and-check loop before deciding to switch the thing off.
|
|
956
|
+
*
|
|
957
|
+
* The budget stops the harvest. It never stops it QUIETLY: every file it did not reach is
|
|
958
|
+
* named in the report and lands in the coverage ledger by name, because a partial harvest
|
|
959
|
+
* reported as a whole one is the tool lying about how much of the product it is watching.
|
|
960
|
+
*/
|
|
961
|
+
export const DEFAULT_HARVEST_BUDGET_MS = 90_000;
|
|
962
|
+
|
|
756
963
|
/**
|
|
757
964
|
* Harvest journeys out of a project's own test suite.
|
|
758
965
|
*
|
|
@@ -761,7 +968,14 @@ export function relativeIfInside(url, root) {
|
|
|
761
968
|
*/
|
|
762
969
|
export async function harvestJourneys(opts) {
|
|
763
970
|
const started = Date.now();
|
|
764
|
-
|
|
971
|
+
// The real path, never the one we were handed. On a Mac /tmp is a symlink to /private/tmp,
|
|
972
|
+
// so a project reached through /tmp runs its tests and gets coverage back full of
|
|
973
|
+
// /private/tmp paths - every one of which looks like a file outside the project, and every
|
|
974
|
+
// one of which is thrown away. The harvest then says, perfectly calmly, that the tests
|
|
975
|
+
// touched nothing at all. That is a silence, and a silence is the one failure this tool
|
|
976
|
+
// exists to prevent, so the path is resolved once, here, before anything is measured
|
|
977
|
+
// against it.
|
|
978
|
+
const root = await realRoot(opts.root);
|
|
765
979
|
const log = opts.log ?? (() => {});
|
|
766
980
|
const detection = opts.runner
|
|
767
981
|
? { runner: opts.runner, why: 'The runner was named by the caller.', binary: opts.binary, missing: [], notes: [] }
|
|
@@ -780,10 +994,13 @@ export async function harvestJourneys(opts) {
|
|
|
780
994
|
touchedMeasured: false,
|
|
781
995
|
rejected: [],
|
|
782
996
|
failing: [],
|
|
997
|
+
notReached: [],
|
|
783
998
|
missing: [...(detection.missing ?? [])],
|
|
784
999
|
notes: [...(detection.notes ?? [])],
|
|
785
1000
|
durationMs: 0,
|
|
786
1001
|
};
|
|
1002
|
+
const budgetMs = opts.budgetMs ?? DEFAULT_HARVEST_BUDGET_MS;
|
|
1003
|
+
if (budgetMs > 0) report.budgetMs = budgetMs;
|
|
787
1004
|
|
|
788
1005
|
if (runner === 'none') {
|
|
789
1006
|
report.durationMs = Date.now() - started;
|
|
@@ -820,6 +1037,14 @@ export async function harvestJourneys(opts) {
|
|
|
820
1037
|
report.rejected.push({ file, why: 'The harvest was stopped before this file was reached.' });
|
|
821
1038
|
continue;
|
|
822
1039
|
}
|
|
1040
|
+
// Checked before starting a file rather than after finishing one, so the budget is a
|
|
1041
|
+
// ceiling on when the harvest STARTS work and not a suggestion that one slow file can
|
|
1042
|
+
// blow through by ten minutes. What it costs: a file that would just have fitted is left
|
|
1043
|
+
// out. What it buys: the number in the budget is the truth.
|
|
1044
|
+
if (budgetMs > 0 && Date.now() - started >= budgetMs) {
|
|
1045
|
+
report.notReached.push(file);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
823
1048
|
log(`Running ${file}${repeat > 1 ? ' (twice, to see whether it repeats)' : ''}.`);
|
|
824
1049
|
|
|
825
1050
|
/** @type {OneRun[]} */
|
|
@@ -873,6 +1098,15 @@ export async function harvestJourneys(opts) {
|
|
|
873
1098
|
|
|
874
1099
|
if (!opts.scratchDir) await fsp.rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
|
875
1100
|
|
|
1101
|
+
if (report.notReached.length > 0) {
|
|
1102
|
+
report.notes.push(
|
|
1103
|
+
`The harvest stopped after ${Math.round((Date.now() - started) / 1000)} seconds, which is its budget, with ` +
|
|
1104
|
+
`${report.notReached.length} test ${report.notReached.length === 1 ? 'file' : 'files'} still to go. Whatever ` +
|
|
1105
|
+
`those walk is not being watched. They are named one by one, and a bigger budget or a narrower list of files ` +
|
|
1106
|
+
`is how they get in.`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
876
1110
|
report.journeys = journeys.length;
|
|
877
1111
|
report.touchedFiles = touchedEverything.size;
|
|
878
1112
|
report.touchedMeasured = journeys.some((j) => j.touched?.measured === true);
|
|
@@ -986,3 +1220,75 @@ export function slugPath(file) {
|
|
|
986
1220
|
.replace(/[^a-z0-9]+/g, '-')
|
|
987
1221
|
.replace(/^-+|-+$/g, '');
|
|
988
1222
|
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* The path a project really lives at, with every symlink on the way resolved.
|
|
1226
|
+
*
|
|
1227
|
+
* @param {string} root
|
|
1228
|
+
* @returns {Promise<string>}
|
|
1229
|
+
*/
|
|
1230
|
+
async function realRoot(root) {
|
|
1231
|
+
const absolute = path.resolve(root);
|
|
1232
|
+
try {
|
|
1233
|
+
return await fsp.realpath(absolute);
|
|
1234
|
+
} catch {
|
|
1235
|
+
return absolute;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
// Only the tests near the change
|
|
1241
|
+
// ---------------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Split harvested journeys into the ones worth walking for this change and the ones that
|
|
1245
|
+
* cannot have been touched by it.
|
|
1246
|
+
*
|
|
1247
|
+
* WHY THIS IS ALLOWED TO BE A FILTER AT ALL. Skipping tests to go faster is normally how a
|
|
1248
|
+
* safety net gets holes cut in it, and the usual version of this - guess from the name, walk
|
|
1249
|
+
* `total.test.js` because `total.js` changed - earns that reputation, because a name is not
|
|
1250
|
+
* evidence.
|
|
1251
|
+
*
|
|
1252
|
+
* This is not that. Harvesting a file MEASURES what it executed: Node writes the coverage
|
|
1253
|
+
* itself, and every harvested journey arrives carrying the list of project files that
|
|
1254
|
+
* actually ran. A test file whose measured list holds not one file you touched cannot have
|
|
1255
|
+
* run different code, so walking it proves nothing and costs a process launch.
|
|
1256
|
+
*
|
|
1257
|
+
* THE TWO WAYS THIS COULD BE WRONG, and what is done about each. A test that reaches code by
|
|
1258
|
+
* a path the coverage never saw - a child process, a native module - would be dropped on
|
|
1259
|
+
* false evidence, so a journey that does not KNOW what it touched is always walked and never
|
|
1260
|
+
* filtered. And code reached only on some runs would make the measurement itself unsteady,
|
|
1261
|
+
* which is exactly what the harvest's two runs reject a file for. What is left is filtered on
|
|
1262
|
+
* a measurement, not on a hunch.
|
|
1263
|
+
*
|
|
1264
|
+
* Nothing here decides quietly: the ones left out come back named, for the ledger.
|
|
1265
|
+
*
|
|
1266
|
+
* @param {SuiteJourney[]} journeys
|
|
1267
|
+
* @param {string[]} changed Project files that changed, relative to the root.
|
|
1268
|
+
* @returns {{walk: SuiteJourney[], skipped: {journey: string, why: string}[]}}
|
|
1269
|
+
*/
|
|
1270
|
+
export function testsNear(journeys, changed) {
|
|
1271
|
+
const changedFiles = new Set(changed.map((file) => String(file).split(path.sep).join('/')));
|
|
1272
|
+
/** @type {SuiteJourney[]} */
|
|
1273
|
+
const walk = [];
|
|
1274
|
+
/** @type {{journey: string, why: string}[]} */
|
|
1275
|
+
const skipped = [];
|
|
1276
|
+
|
|
1277
|
+
for (const journey of journeys) {
|
|
1278
|
+
const touched = journey.touched;
|
|
1279
|
+
if (!touched?.measured || touched.files.length === 0) {
|
|
1280
|
+
walk.push(journey);
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
// Its own file counts. A test you edited is the most interesting one there is.
|
|
1284
|
+
const reaches = [...touched.files, journey.from ?? ''].some((file) => changedFiles.has(file));
|
|
1285
|
+
if (reaches) walk.push(journey);
|
|
1286
|
+
else {
|
|
1287
|
+
skipped.push({
|
|
1288
|
+
journey: journey.name,
|
|
1289
|
+
why: 'Nothing it was measured going through was changed, so it would run exactly the same code twice.',
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return { walk, skipped };
|
|
1294
|
+
}
|
package/src/v2/journeys/index.js
CHANGED
|
@@ -32,7 +32,7 @@ import path from 'node:path';
|
|
|
32
32
|
|
|
33
33
|
import { measureWobble } from '../observation.js';
|
|
34
34
|
import { journeysFromCode } from './from-routes.js';
|
|
35
|
-
import { harvestJourneys } from './from-suite.js';
|
|
35
|
+
import { DEFAULT_HARVEST_BUDGET_MS, harvestJourneys, testsNear } from './from-suite.js';
|
|
36
36
|
import { loadJourneyFolder, whatWillNotReplay } from './record.js';
|
|
37
37
|
|
|
38
38
|
/** @typedef {import('../types.js').Journey} Journey */
|
|
@@ -46,7 +46,7 @@ import { loadJourneyFolder, whatWillNotReplay } from './record.js';
|
|
|
46
46
|
/** @typedef {import('../adapters/source.js').Door} Door */
|
|
47
47
|
|
|
48
48
|
export { journeysFromCode, journeysFromDoors, irreversibility } from './from-routes.js';
|
|
49
|
-
export { detectRunner, harvestJourneys, listTestFiles } from './from-suite.js';
|
|
49
|
+
export { detectRunner, harvestJourneys, listTestFiles, testsNear, DEFAULT_HARVEST_BUDGET_MS } from './from-suite.js';
|
|
50
50
|
export { startRecording, recordSession, saveJourneys, loadJourneys, loadJourneyFolder, redact } from './record.js';
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -369,7 +369,13 @@ export async function checkReproducible(journeys, opts = {}) {
|
|
|
369
369
|
* Journeys harvested from the project's own tests. OFF by default, and deliberately:
|
|
370
370
|
* harvesting RUNS the suite, which starts processes and takes minutes. Nothing that
|
|
371
371
|
* expensive should happen because somebody called a function called `gather`. When it is
|
|
372
|
-
* off, the report says what it would have unlocked.
|
|
372
|
+
* off, the report says what it would have unlocked. When it is on it is held to a time
|
|
373
|
+
* budget - `DEFAULT_HARVEST_BUDGET_MS` unless the caller says otherwise - and every test
|
|
374
|
+
* file the budget did not reach is named, one by one, in the gaps.
|
|
375
|
+
* @property {string[]} [changed]
|
|
376
|
+
* Project files this change touched, relative to the root. Given, the harvested journeys
|
|
377
|
+
* are narrowed to the ones MEASURED going through one of them, and the rest are named in
|
|
378
|
+
* the gaps rather than quietly dropped. Left out, every harvested journey is walked.
|
|
373
379
|
* @property {false|{dir?: string, files?: string[]}} [recorded]
|
|
374
380
|
* Recorded sessions. On by default: it only reads files. Defaults to `.staysfixed/journeys`.
|
|
375
381
|
* @property {Journey[]} [explored] Journeys an agent produced, handed straight in.
|
|
@@ -471,15 +477,32 @@ export async function gather(opts) {
|
|
|
471
477
|
root,
|
|
472
478
|
};
|
|
473
479
|
const harvest = await harvestJourneys(suiteOptions);
|
|
474
|
-
|
|
480
|
+
// Narrowed on a measurement, never on a hunch: the harvest recorded which project files
|
|
481
|
+
// each test file actually executed, and one that went through nothing you changed would
|
|
482
|
+
// run identical code twice. Anything left out is named, because a test quietly not run
|
|
483
|
+
// is the difference between a safety net and a story about one.
|
|
484
|
+
const near = opts.changed ? testsNear(harvest.journeys, opts.changed) : { walk: harvest.journeys, skipped: [] };
|
|
485
|
+
collected.push(...near.walk);
|
|
486
|
+
for (const left of near.skipped) {
|
|
487
|
+
gaps.push({
|
|
488
|
+
what: `The tests in "${left.journey}" were not walked for this change.`,
|
|
489
|
+
why: left.why,
|
|
490
|
+
unlockedBy: 'Nothing to install. Leave `changed` out of gather() and every harvested test file is walked, whatever it goes through.',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
475
493
|
gaps.push(...suiteGaps(harvest.report));
|
|
476
494
|
missing.push(...harvest.report.missing);
|
|
477
495
|
notes.push(...harvest.report.notes);
|
|
478
496
|
} else {
|
|
479
497
|
gaps.push({
|
|
480
498
|
what: "The project's own test suite was not harvested, so every path its tests walk is invisible to this check.",
|
|
481
|
-
why:
|
|
482
|
-
|
|
499
|
+
why:
|
|
500
|
+
'Harvesting runs the suite one file at a time and twice each, which starts processes and takes minutes. This ' +
|
|
501
|
+
'tool is worth having because it is cheap enough to run on every change, so nothing that expensive is ever ' +
|
|
502
|
+
'switched on for somebody without being asked for.',
|
|
503
|
+
unlockedBy:
|
|
504
|
+
`Ask for it: gather({suite: true}). Every test file that repeats twice becomes a journey nobody had to write, ` +
|
|
505
|
+
`and the harvest stops after ${Math.round(DEFAULT_HARVEST_BUDGET_MS / 1000)} seconds and names whatever it did not reach.`,
|
|
483
506
|
});
|
|
484
507
|
}
|
|
485
508
|
|
|
@@ -579,6 +602,63 @@ export async function gather(opts) {
|
|
|
579
602
|
return { journeys: verified.kept, report, doors };
|
|
580
603
|
}
|
|
581
604
|
|
|
605
|
+
/**
|
|
606
|
+
* A number of milliseconds as a person would say it. "0 seconds" reads as no budget at all.
|
|
607
|
+
* @param {number} ms
|
|
608
|
+
* @returns {string}
|
|
609
|
+
*/
|
|
610
|
+
function inSeconds(ms) {
|
|
611
|
+
return ms < 1000 ? 'under a second' : `${Math.round(ms / 1000)} seconds`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The project's own test suite, as journeys ready to walk, in the shape a check wants back.
|
|
616
|
+
*
|
|
617
|
+
* `gather` does everything and is the right door for anything exploring what a project has.
|
|
618
|
+
* This is the narrow one: somebody asked for `--journeys suite`, and what they need back is
|
|
619
|
+
* a list of journeys and a list of holes - nothing else, and no second reading of the source
|
|
620
|
+
* that the caller has already done.
|
|
621
|
+
*
|
|
622
|
+
* WHAT THE CALLER IS SIGNING UP FOR, in one place so it cannot be missed. This RUNS the
|
|
623
|
+
* project's tests, one file at a time and twice each, inside the harvest's own temp folder.
|
|
624
|
+
* It is held to a time budget, and every file the budget did not reach comes back as a
|
|
625
|
+
* named hole. Hand it `changed` and only the test files measured going through one of those
|
|
626
|
+
* are kept, with the rest named too. Nothing is ever skipped quietly.
|
|
627
|
+
*
|
|
628
|
+
* @param {object} opts
|
|
629
|
+
* @param {string} opts.root
|
|
630
|
+
* @param {Surface} [opts.surface]
|
|
631
|
+
* @param {true|Partial<import('./from-suite.js').HarvestOptions>} [opts.suite]
|
|
632
|
+
* @param {string[]} [opts.changed]
|
|
633
|
+
* @param {(message: string) => void} [opts.log]
|
|
634
|
+
* @param {AbortSignal} [opts.signal]
|
|
635
|
+
* @returns {Promise<{journeys: GatheredJourney[], gaps: CoverageGap[], report: GatherReport}>}
|
|
636
|
+
*/
|
|
637
|
+
export async function journeysFromSuite(opts) {
|
|
638
|
+
const gathered = await gather({
|
|
639
|
+
root: opts.root,
|
|
640
|
+
surface: opts.surface,
|
|
641
|
+
suite: opts.suite ?? true,
|
|
642
|
+
changed: opts.changed,
|
|
643
|
+
// The caller asked for the suite. Reading the source and loading recordings are other
|
|
644
|
+
// sources with their own costs, and doing them here would charge for work nobody asked
|
|
645
|
+
// for and hand back journeys nobody expected.
|
|
646
|
+
code: false,
|
|
647
|
+
recorded: false,
|
|
648
|
+
log: opts.log,
|
|
649
|
+
signal: opts.signal,
|
|
650
|
+
});
|
|
651
|
+
return {
|
|
652
|
+
journeys: gathered.journeys.filter((journey) => journey.source === 'suite'),
|
|
653
|
+
// Everything except "the doors were never counted". That hole is real when nobody read
|
|
654
|
+
// the source at all, and it is a lie here: the caller asking for the suite reads the
|
|
655
|
+
// source through the contract adapter on the same run, and a ledger carrying a hole
|
|
656
|
+
// somebody has already filled sends a reader looking for work that is done.
|
|
657
|
+
gaps: gathered.report.gaps.filter((gap) => gap.channel !== 'contract'),
|
|
658
|
+
report: gathered.report,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
582
662
|
/**
|
|
583
663
|
* @param {import('./from-suite.js').HarvestReport} report
|
|
584
664
|
* @returns {CoverageGap[]}
|
|
@@ -593,6 +673,19 @@ function suiteGaps(report) {
|
|
|
593
673
|
unlockedBy: 'Nothing to install. Either that file is not repeatable, or it needs something the harvest did not give it.',
|
|
594
674
|
});
|
|
595
675
|
}
|
|
676
|
+
if (report.notReached.length > 0) {
|
|
677
|
+
gaps.push({
|
|
678
|
+
// Every one of them, by name, however many there are. This used to stop at twenty and
|
|
679
|
+
// add "and 14 more", which is the same failure as "some tests were skipped" wearing a
|
|
680
|
+
// number: a reader cannot act on a name they were not given, and the reader who most
|
|
681
|
+
// needs the full list is precisely the one whose harvest reached almost nothing. The
|
|
682
|
+
// sentence gets long. A ledger that is honest and long beats one that is short and
|
|
683
|
+
// leaves half of somebody's suite unaccounted for.
|
|
684
|
+
what: `${report.notReached.length} test ${report.notReached.length === 1 ? 'file was' : 'files were'} never run, so whatever they walk is not being watched: ${report.notReached.join(', ')}.`,
|
|
685
|
+
why: `The harvest was held to ${inSeconds(report.budgetMs ?? DEFAULT_HARVEST_BUDGET_MS)} so that running it on every change stays affordable, and it ran out before these.`,
|
|
686
|
+
unlockedBy: 'Give it longer — suite: {budgetMs} in your settings file, or gather({suite: {budgetMs}}) in code, and 0 means no budget at all. Or narrow it to the files that matter with gather({suite: {only}}).',
|
|
687
|
+
});
|
|
688
|
+
}
|
|
596
689
|
if (report.runner === 'none') {
|
|
597
690
|
gaps.push({
|
|
598
691
|
what: 'No test suite could be harvested.',
|
package/src/v2/mcp/tools.js
CHANGED
|
@@ -388,7 +388,7 @@ export function toolDefinitions() {
|
|
|
388
388
|
journeys: {
|
|
389
389
|
type: 'string',
|
|
390
390
|
description:
|
|
391
|
-
"Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels.
|
|
391
|
+
"Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. 'suite' walks the project's own test suite as well: each test file runs twice inside the scratch copy, every check is reported by name, and it stops after 90 seconds naming each file it did not reach. It catches breaks nothing else can - a rounding change the product's own output never shows. It is opt-in because running a stranger's whole suite twice on every check is not something to do by default. You can also pass a path to a journeys file naming steps by hand. 'recorded' (replay a recorded session) is written and not yet wired into a run: ask for it and it says so rather than checking something else.",
|
|
392
392
|
},
|
|
393
393
|
surface: {
|
|
394
394
|
type: 'string',
|
|
@@ -756,18 +756,17 @@ async function toolCheck(ctx, input) {
|
|
|
756
756
|
const limit = positive(input.limit) ?? DEFAULT_LIMIT;
|
|
757
757
|
const offset = positive(input.offset) ?? 0;
|
|
758
758
|
|
|
759
|
-
// A value the engine does not understand must be refused BY NAME
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
// the engine as the name of a
|
|
763
|
-
// .../
|
|
764
|
-
//
|
|
759
|
+
// A value the engine does not understand must be refused BY NAME, never passed down.
|
|
760
|
+
//
|
|
761
|
+
// `suite` is now wired and reaches the harvest. `recorded` is still written and called by
|
|
762
|
+
// nothing, so passing it down would reach the engine as the name of a FILE and come back as
|
|
763
|
+
// "there is no journeys file at .../recorded" — an error that sends an agent looking for a
|
|
764
|
+
// file it never asked for. Refusing it by name and saying why is the honest answer, and a
|
|
765
|
+
// clean result about the wrong steps would be worse than no result.
|
|
765
766
|
const wantedJourneys = text(input.journeys);
|
|
766
|
-
if (wantedJourneys === '
|
|
767
|
+
if (wantedJourneys === 'recorded') {
|
|
767
768
|
return problem(
|
|
768
|
-
|
|
769
|
-
? 'Harvesting your own test suite as journeys is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file. Saying this rather than quietly checking something else is deliberate: a clean result about the wrong steps is worse than no result.'
|
|
770
|
-
: 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file.'
|
|
769
|
+
'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, pass "suite" to walk your own test suite, or pass the path to a journeys file.'
|
|
771
770
|
);
|
|
772
771
|
}
|
|
773
772
|
|