staysfixed 0.11.1 → 0.12.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 +76 -0
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/how-v2-works.md +33 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/browsers.js +9 -1
- package/src/v2/check.js +133 -13
- package/src/v2/cli.js +2 -0
- package/src/v2/coverage.js +1 -1
- package/src/v2/detect.js +5 -2
- package/src/v2/doctor.js +122 -15
- package/src/v2/init.js +12 -2
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +8 -15
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
|
@@ -9,6 +9,27 @@
|
|
|
9
9
|
* Everything below is arranged around that: one device, one build at a time, the whole
|
|
10
10
|
* machine put back in between.
|
|
11
11
|
*
|
|
12
|
+
* IS THAT GOOD ENOUGH FOR A PAIRED RUN? YES, AND IT HAS NOW BEEN MEASURED.
|
|
13
|
+
*
|
|
14
|
+
* Until 2026-08-31 this file said a paired run was not offered here, because nobody had proven
|
|
15
|
+
* that two emulator snapshots come back the same. That was a guess. It was measured that day,
|
|
16
|
+
* on an Apple Silicon Mac, against Terminal Deck's own Android app (dev.terminaldeck.apk.debug
|
|
17
|
+
* 0.13.0) on the `sfx_a33` virtual device — Android 13, Google APIs, arm64, no Play Store.
|
|
18
|
+
*
|
|
19
|
+
* One build was walked TEN times, and the device was put back from its own snapshot between
|
|
20
|
+
* every one of them, which is exactly what happens between the two builds of a paired run.
|
|
21
|
+
* Five pairs, 309 addresses in each walk. 301 of those 309 agreed in every single pair. The
|
|
22
|
+
* eight that did not were one thing seen at eight checkpoints: the identity code this app
|
|
23
|
+
* makes fresh for itself every time it is installed. A control run with the snapshot restore
|
|
24
|
+
* switched OFF produced the same eight and no others — so the restore contributed exactly
|
|
25
|
+
* zero disagreements, and the eight belong to the app, not to the emulator.
|
|
26
|
+
*
|
|
27
|
+
* Eight addresses out of 309 that move on their own is ordinary wobble, and the engine
|
|
28
|
+
* already measures and subtracts it: the new build is always walked twice and anything that
|
|
29
|
+
* cannot answer the same way twice is taken out of the comparison before a single difference
|
|
30
|
+
* is reported. So a paired Android run is offered, and what actually limits it is not the
|
|
31
|
+
* device at all — it is getting hold of the OLD build's package. See `prepare`.
|
|
32
|
+
*
|
|
12
33
|
* What is read, and in which channel:
|
|
13
34
|
*
|
|
14
35
|
* CONTRACT Everything the APK declares, read out of the file without installing it and
|
|
@@ -517,6 +538,93 @@ export function screenObservations(input) {
|
|
|
517
538
|
/** Keyed by build id, emptied on teardown. Never shared between two builds. */
|
|
518
539
|
const open = new Map();
|
|
519
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Which file each half of a comparison was walked from, remembered across the whole run.
|
|
543
|
+
*
|
|
544
|
+
* It has to live out here rather than inside a prepared build, because the engine prepares
|
|
545
|
+
* ONE build, walks ONE journey against it and throws it away again before the next: nothing
|
|
546
|
+
* that lives on a prepared build survives long enough to notice that the old build and the
|
|
547
|
+
* new build were the same file.
|
|
548
|
+
*
|
|
549
|
+
* And that is the thing worth noticing. `android.apk` in the settings is very often an
|
|
550
|
+
* absolute path, and an absolute path does not move when the old commit is checked out
|
|
551
|
+
* somewhere else — so both halves of a paired run read the SAME package, find no differences
|
|
552
|
+
* at all, and the run says "nothing that worked has changed" about a comparison that never
|
|
553
|
+
* happened. That is the one failure this tool exists to prevent, arriving through the front
|
|
554
|
+
* door. Anything found here is reported as a hole on every journey the reference walked; see
|
|
555
|
+
* `sameFileAsTheOtherHalf` below.
|
|
556
|
+
*
|
|
557
|
+
* Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
|
|
558
|
+
*
|
|
559
|
+
* @type {Map<string, string>}
|
|
560
|
+
*/
|
|
561
|
+
const walkedFrom = new Map();
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The build ids that turned out to be the other half of the comparison, and the sentence
|
|
565
|
+
* that says so.
|
|
566
|
+
*
|
|
567
|
+
* Separate from `open` above because `open` only holds builds that reached a device, and the
|
|
568
|
+
* dangerous case is the one that does NOT: on a machine with no emulator this adapter still
|
|
569
|
+
* reads everything the package declares, and one package compared against itself declares
|
|
570
|
+
* exactly the same things. That is a clean, confident, meaningless all-clear, and it has to
|
|
571
|
+
* carry the warning like every other journey does.
|
|
572
|
+
*
|
|
573
|
+
* @type {Map<string, string>}
|
|
574
|
+
*/
|
|
575
|
+
const sameFileFor = new Map();
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Which file this really is: the path with the links and the `..`s taken out.
|
|
579
|
+
*
|
|
580
|
+
* A symlink, a `./` and a `..` must not be able to make one file look like two, because one
|
|
581
|
+
* file looking like two is the whole failure being guarded against here.
|
|
582
|
+
*
|
|
583
|
+
* @param {string} file
|
|
584
|
+
* @returns {Promise<string>}
|
|
585
|
+
*/
|
|
586
|
+
async function artifactIdentity(file) {
|
|
587
|
+
try {
|
|
588
|
+
return await fsp.realpath(file);
|
|
589
|
+
} catch {
|
|
590
|
+
// A path that will not resolve is still worth remembering exactly as it was typed. The
|
|
591
|
+
// question below is whether the two halves agree, not whether the file is there.
|
|
592
|
+
return file;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Did this half of the comparison read the same package as the other half?
|
|
598
|
+
*
|
|
599
|
+
* Returns the sentence to put in front of a reader, or null when the two halves really are
|
|
600
|
+
* two different files.
|
|
601
|
+
*
|
|
602
|
+
* WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
|
|
603
|
+
* holding the same build. Somebody whose release script copies today's package to
|
|
604
|
+
* `builds/latest.apk` and points `reference` at it is comparing one build against itself with
|
|
605
|
+
* two names, and nothing here notices. That was left alone on purpose — two different files
|
|
606
|
+
* are usually two builds somebody produced on purpose, and refusing them on a guess would
|
|
607
|
+
* block real comparisons to prevent an unusual one.
|
|
608
|
+
*
|
|
609
|
+
* @param {'reference'|'candidate'} role
|
|
610
|
+
* @param {string} mine
|
|
611
|
+
* @returns {string|null}
|
|
612
|
+
*/
|
|
613
|
+
function sameFileAsTheOtherHalf(role, mine) {
|
|
614
|
+
// Only ever asked about the OLD build's half, and that is not squeamishness — it is the
|
|
615
|
+
// only answer that stays the same from one journey to the next. The engine prepares a
|
|
616
|
+
// build, walks ONE journey against it and throws it away, and it always does the new build
|
|
617
|
+
// first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
|
|
618
|
+
// journey. So from the second journey onwards the new build's half would find the previous
|
|
619
|
+
// journey's old half sitting in the map and flag itself as well — the warning would be
|
|
620
|
+
// absent on the first journey and doubled on every one after it, which reads like a bug in
|
|
621
|
+
// the tool rather than a fact about the run.
|
|
622
|
+
if (role !== 'reference') return null;
|
|
623
|
+
const other = walkedFrom.get('candidate');
|
|
624
|
+
if (!other || other !== mine) return null;
|
|
625
|
+
return `Both halves of this comparison were walked from the same file: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
|
|
626
|
+
}
|
|
627
|
+
|
|
520
628
|
/**
|
|
521
629
|
* Read the size out of a PNG without decoding it.
|
|
522
630
|
*
|
|
@@ -532,6 +640,25 @@ export function pngSize(png) {
|
|
|
532
640
|
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
533
641
|
}
|
|
534
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Where a kept copy of the OLD build's package lives, if the settings name one.
|
|
645
|
+
*
|
|
646
|
+
* Two spellings, because both read naturally and neither is worth an argument:
|
|
647
|
+
* `{"reference": "builds/0.14.0.apk"}` and `{"reference": {"apk": "builds/0.14.0.apk"}}`.
|
|
648
|
+
* A relative path is resolved against whatever `findApk` is given, which for the reference
|
|
649
|
+
* half is the checkout of the old commit — so a project that DOES commit its package can
|
|
650
|
+
* simply leave this out and everything still works.
|
|
651
|
+
*
|
|
652
|
+
* @param {Record<string, any>} config
|
|
653
|
+
* @returns {string|undefined}
|
|
654
|
+
*/
|
|
655
|
+
export function referenceArtifact(config) {
|
|
656
|
+
const said = config?.reference;
|
|
657
|
+
if (typeof said === 'string' && said.trim() !== '') return said;
|
|
658
|
+
if (said && typeof said === 'object' && typeof said.apk === 'string' && said.apk.trim() !== '') return said.apk;
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
|
|
535
662
|
/**
|
|
536
663
|
* Choose how the device gets put back between builds, and say what that costs.
|
|
537
664
|
*
|
|
@@ -657,6 +784,7 @@ export const androidAdapter = defineAdapter({
|
|
|
657
784
|
...notes,
|
|
658
785
|
'What is compared is what the screen MEANS — the roles, names and states a screen reader would read — and every control is found by what it is, never by where it sits. Moving something, restyling it, or wrapping it in another layout reports nothing.',
|
|
659
786
|
'The two builds are never installed at once. Android allows one app of a given name on a device, so each build is installed, walked and removed, and the whole device is put back in between.',
|
|
787
|
+
'Putting the device back really does put it back, and that is measured rather than assumed. On 2026-08-31 one build was walked ten times on an emulator with a snapshot restore between every walk: 301 of 309 addresses agreed in all five pairs, and the eight that did not were the app\'s own freshly-made identity code — a control run with no restore at all produced exactly the same eight. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s package, because an APK is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings.',
|
|
660
788
|
'Nothing is allowed off this machine. Every call the app makes is written down at a proxy and stopped there — which also means what is inside an encrypted request is never seen, and is reported as unchecked rather than as fine.',
|
|
661
789
|
],
|
|
662
790
|
};
|
|
@@ -690,14 +818,51 @@ export const androidAdapter = defineAdapter({
|
|
|
690
818
|
const base = path.join(ctx.scratchDir, `android-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
691
819
|
await fsp.mkdir(base, { recursive: true });
|
|
692
820
|
|
|
821
|
+
// Worked out a few lines below, and captured here so that even a build which could not
|
|
822
|
+
// be got ready still says it. A reference half that failed AND was the same file as the
|
|
823
|
+
// candidate is two separate pieces of bad news, and the second one is the one that would
|
|
824
|
+
// otherwise be lost — the run would report "could not be prepared", somebody would fix
|
|
825
|
+
// that, and the comparison would come back green for the wrong reason.
|
|
826
|
+
/** @type {string|null} */
|
|
827
|
+
let sameFile = null;
|
|
828
|
+
|
|
693
829
|
/** @param {string} why */
|
|
694
830
|
const notReady = (why) => ({
|
|
695
|
-
build,
|
|
696
|
-
|
|
831
|
+
build,
|
|
832
|
+
root: base,
|
|
833
|
+
ready: false,
|
|
834
|
+
why: sameFile ? `${why} ${sameFile}` : why,
|
|
835
|
+
...(build.role === 'reference' ? { facts: { paired: sameFile === null } } : {}),
|
|
836
|
+
dispose: async () => {
|
|
837
|
+
sameFileFor.delete(build.id);
|
|
838
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
839
|
+
},
|
|
697
840
|
});
|
|
698
841
|
|
|
699
|
-
|
|
700
|
-
|
|
842
|
+
// WHICH package this half of the comparison walks.
|
|
843
|
+
//
|
|
844
|
+
// For the build you have, that is whatever the settings point at. For the build you were
|
|
845
|
+
// happy with it is different, and the difference is the whole of paired mode on a phone:
|
|
846
|
+
// the engine hands over a checkout of the old commit, and an APK is a BUILD OUTPUT that
|
|
847
|
+
// nobody commits, so a checkout of the old commit contains no app at all. `android.reference`
|
|
848
|
+
// is where a kept copy of the old build's package goes, and it is looked at first for the
|
|
849
|
+
// reference half and never for the candidate.
|
|
850
|
+
const wanted = build.role === 'reference' ? (referenceArtifact(config) ?? config.apk) : config.apk;
|
|
851
|
+
const found = await findApk(build.artifact ? path.dirname(build.artifact) : build.root, { ...config, apk: build.artifact ?? wanted });
|
|
852
|
+
if (!found.path) {
|
|
853
|
+
return notReady(
|
|
854
|
+
`There is no APK for ${build.label}: ${found.why}.` +
|
|
855
|
+
(build.role === 'reference'
|
|
856
|
+
? ' A paired run walks the OLD build here, and an APK is a build output that a repository does not commit — so a checkout of the old commit has no app in it. Keep a copy of each release\'s package and point at it with {"reference": "path/to/the-old.apk"} under "android" in the settings, and this becomes a real comparison. Without it this journey falls back to the record the old build left the last time it ran, which is weaker and says so.'
|
|
857
|
+
: ''),
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Two halves, one file. Worked out here, said on every journey — see `run`.
|
|
862
|
+
const mine = await artifactIdentity(found.path);
|
|
863
|
+
sameFile = sameFileAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
|
|
864
|
+
walkedFrom.set(build.role, mine);
|
|
865
|
+
if (sameFile) sameFileFor.set(build.id, sameFile);
|
|
701
866
|
const apk = await readApk(found.path);
|
|
702
867
|
if (!apk.ok) return notReady(`The APK for ${build.label} could not be read: ${apk.why}`);
|
|
703
868
|
|
|
@@ -708,8 +873,16 @@ export const androidAdapter = defineAdapter({
|
|
|
708
873
|
return {
|
|
709
874
|
build, root: base, ready: true,
|
|
710
875
|
why: `adb is not on this machine, so ${build.label} cannot be installed or opened. What it declares — its version, its permissions and all ${apk.components.length} of its components — is still read straight out of the file, and every journey that needs a device is reported as unchecked rather than passed.`,
|
|
711
|
-
facts: {
|
|
712
|
-
|
|
876
|
+
facts: {
|
|
877
|
+
apk: found.path,
|
|
878
|
+
pkg: apk.pkg,
|
|
879
|
+
deviceless: true,
|
|
880
|
+
...(build.role === 'reference' ? { paired: sameFile === null } : {}),
|
|
881
|
+
},
|
|
882
|
+
dispose: async () => {
|
|
883
|
+
sameFileFor.delete(build.id);
|
|
884
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
885
|
+
},
|
|
713
886
|
};
|
|
714
887
|
}
|
|
715
888
|
|
|
@@ -791,7 +964,7 @@ export const androidAdapter = defineAdapter({
|
|
|
791
964
|
build,
|
|
792
965
|
root: base,
|
|
793
966
|
ready: true,
|
|
794
|
-
why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
|
|
967
|
+
why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${sameFile ? ` ${sameFile}` : ''}${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
|
|
795
968
|
facts: {
|
|
796
969
|
serial: /** @type {string} */ (serial),
|
|
797
970
|
pkg: apk.pkg,
|
|
@@ -800,9 +973,13 @@ export const androidAdapter = defineAdapter({
|
|
|
800
973
|
rooted: device.rooted === true,
|
|
801
974
|
firstLaunchMs: warmMs,
|
|
802
975
|
ownsDevice,
|
|
976
|
+
// Only ever set on the OLD build's half, because that is the half the question is
|
|
977
|
+
// about: was there really a second build here, or did both halves read one file.
|
|
978
|
+
...(build.role === 'reference' ? { paired: sameFile === null } : {}),
|
|
803
979
|
},
|
|
804
980
|
dispose: async () => {
|
|
805
981
|
open.delete(build.id);
|
|
982
|
+
sameFileFor.delete(build.id);
|
|
806
983
|
try {
|
|
807
984
|
await resetDevice(session);
|
|
808
985
|
} catch {
|
|
@@ -828,18 +1005,44 @@ export const androidAdapter = defineAdapter({
|
|
|
828
1005
|
const out = [];
|
|
829
1006
|
const session = open.get(prepared.build.id);
|
|
830
1007
|
|
|
1008
|
+
// FIRST, BEFORE ANY JOURNEY INCLUDING THE ONE THAT NEEDS NO DEVICE.
|
|
1009
|
+
//
|
|
1010
|
+
// Said on every journey rather than once at the start, which is the same rule the web
|
|
1011
|
+
// adapter follows for an app read at a fixed address. A run that compared one file
|
|
1012
|
+
// against itself finds no differences, and "no differences" is the sentence this whole
|
|
1013
|
+
// tool is believed for.
|
|
1014
|
+
//
|
|
1015
|
+
// It has to come before the contract journey below rather than after it, and that
|
|
1016
|
+
// ordering is the whole point. The contract journey needs no emulator at all — it reads
|
|
1017
|
+
// the package's own manifest — so on a machine with no device it is the ONLY thing that
|
|
1018
|
+
// runs, and it is exactly where one file compared against itself produces a complete,
|
|
1019
|
+
// confident, meaningless all-clear. This check first went in after that early return, on
|
|
1020
|
+
// 2026-08-31, and the deviceless case was still wide open; it was caught by running it
|
|
1021
|
+
// against a real emulator and reading the answer rather than the code.
|
|
1022
|
+
const sameFile = sameFileFor.get(prepared.build.id);
|
|
1023
|
+
if (sameFile) {
|
|
1024
|
+
out.push(notCovered({
|
|
1025
|
+
channel: 'meaning',
|
|
1026
|
+
path: joinPath('screen', journey.name, 'which build this was'),
|
|
1027
|
+
reason: 'not supported here',
|
|
1028
|
+
says:
|
|
1029
|
+
`${sameFile} An APK is a build output, so a checkout of the old commit does not contain one and the settings' own path was used for both halves. ` +
|
|
1030
|
+
'Keep a copy of the package you shipped and name it with {"reference": "path/to/the-old.apk"} under "android" in the settings, and this becomes a real comparison.',
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
831
1034
|
// The one journey that needs nothing but the file.
|
|
832
1035
|
if (journey.name === DECLARED) {
|
|
833
1036
|
const apkPath = String(prepared.facts?.apk ?? session?.apkPath ?? '');
|
|
834
1037
|
const apk = session?.apk ?? (apkPath ? await readApk(apkPath) : null);
|
|
835
1038
|
if (!apk?.ok) {
|
|
836
|
-
return [notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
|
|
1039
|
+
return [...out, notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
|
|
837
1040
|
}
|
|
838
|
-
return declaredObservations(apk, apkPath);
|
|
1041
|
+
return [...out, ...declaredObservations(apk, apkPath)];
|
|
839
1042
|
}
|
|
840
1043
|
|
|
841
1044
|
if (!session) {
|
|
842
|
-
return [notCovered({
|
|
1045
|
+
return [...out, notCovered({
|
|
843
1046
|
channel: 'meaning',
|
|
844
1047
|
path: joinPath('screen', journey.name, 'walked'),
|
|
845
1048
|
reason: 'missing tool',
|
|
@@ -850,8 +1053,9 @@ export const androidAdapter = defineAdapter({
|
|
|
850
1053
|
const { device, apk } = session;
|
|
851
1054
|
const started = Date.now();
|
|
852
1055
|
|
|
1056
|
+
|
|
853
1057
|
if (journey.irreversible && ctx.allowIrreversible !== true) {
|
|
854
|
-
return [notCovered({
|
|
1058
|
+
return [...out, notCovered({
|
|
855
1059
|
channel: 'effects',
|
|
856
1060
|
path: joinPath('screen', journey.name, 'walked'),
|
|
857
1061
|
reason: 'irreversible',
|
|
@@ -1238,6 +1442,11 @@ export const androidAdapter = defineAdapter({
|
|
|
1238
1442
|
}
|
|
1239
1443
|
open.delete(id);
|
|
1240
1444
|
}
|
|
1445
|
+
// One run's memory of which file each half was walked from. It must not survive into the
|
|
1446
|
+
// next run in the same process — the MCP server and the watch panel both call check()
|
|
1447
|
+
// more than once — or a second run would report the first run's packages as its own.
|
|
1448
|
+
walkedFrom.clear();
|
|
1449
|
+
sameFileFor.clear();
|
|
1241
1450
|
},
|
|
1242
1451
|
});
|
|
1243
1452
|
|