staysfixed 0.7.2 → 0.9.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 +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -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/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- 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 +90 -16
- 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 +160 -24
- 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 +55 -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
|
@@ -73,6 +73,100 @@ const CLEAN_SNAPSHOT = 'staysfixed-clean';
|
|
|
73
73
|
/** The journey that needs no device at all. */
|
|
74
74
|
const DECLARED = 'what the app declares';
|
|
75
75
|
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// The virtual device this tool asks for
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The API level to build a virtual device at when nothing says otherwise.
|
|
82
|
+
*
|
|
83
|
+
* Newer is the safe direction and it is the only direction that is safe. A device must be at
|
|
84
|
+
* least as new as the app's `minSdkVersion` or the app cannot be installed on it at all; an app
|
|
85
|
+
* that TARGETS something older installs on a newer device and runs under compatibility rules.
|
|
86
|
+
* So the failure from picking too high is a behaviour difference the tool would report, and the
|
|
87
|
+
* failure from picking too low is "it would not install", which looks like a broken product.
|
|
88
|
+
*
|
|
89
|
+
* When there is an APK in hand its own minSdk is read and this is raised to match — see
|
|
90
|
+
* `deviceFor`. This number is only the answer for somebody who has not built anything yet.
|
|
91
|
+
*/
|
|
92
|
+
export const EMULATOR_API = 35;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The processor the image has to be built for.
|
|
96
|
+
*
|
|
97
|
+
* Both files that recommended an image used to hardcode `arm64-v8a`, so the command handed to
|
|
98
|
+
* anybody on an Intel Mac or an x86 Linux box named an image that does not exist for their
|
|
99
|
+
* machine and failed with a message about the image rather than about the architecture.
|
|
100
|
+
*
|
|
101
|
+
* @returns {string|null} null when this machine's architecture has no emulator image at all.
|
|
102
|
+
*/
|
|
103
|
+
export function emulatorAbi() {
|
|
104
|
+
// Only three answers exist, and anything else has to say so rather than pick one.
|
|
105
|
+
//
|
|
106
|
+
// This used to be arm64 or x86_64, with x86_64 as the fallback for every other
|
|
107
|
+
// architecture. On a 32-bit Intel machine, or an s390x, or anything else Node runs on, that
|
|
108
|
+
// names an emulator image Google does not publish — so the command handed over as "the tool
|
|
109
|
+
// can do this itself" fails with an error about a package that does not exist, and the
|
|
110
|
+
// person is left looking for a typo in a line this tool wrote for them.
|
|
111
|
+
if (process.arch === 'arm64') return 'arm64-v8a';
|
|
112
|
+
if (process.arch === 'x64') return 'x86_64';
|
|
113
|
+
if (process.arch === 'ia32') return 'x86';
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What to install, and the two commands that install it — kept together on purpose.
|
|
119
|
+
*
|
|
120
|
+
* The warning is part of the answer, not a footnote beside it. A Play Store image refuses root
|
|
121
|
+
* permanently, and without root the files an app writes are invisible, so a person who follows
|
|
122
|
+
* a command printed without this sentence ends up with a device that silently checks less than
|
|
123
|
+
* they think. That happened because two files each had their own copy of the command and only
|
|
124
|
+
* one carried the warning. There is one copy now, and the warning cannot be separated from it.
|
|
125
|
+
*
|
|
126
|
+
* `google_apis` and NOT `google_apis_playstore` is the whole of the rule.
|
|
127
|
+
*
|
|
128
|
+
* @param {{api?: number, name?: string}} [opts]
|
|
129
|
+
* @returns {{image: string|null, install: string|null, create: string|null, why: string, both: string}} image, install and create are null where this machine has no emulator image at all; `why` and `both` then say so.
|
|
130
|
+
*/
|
|
131
|
+
export function deviceToMake(opts = {}) {
|
|
132
|
+
const api = Math.max(EMULATOR_API, opts.api ?? 0);
|
|
133
|
+
const name = opts.name ?? 'staysfixed';
|
|
134
|
+
const abi = emulatorAbi();
|
|
135
|
+
const warning = 'Pick a plain Google APIs image, NOT a Play Store one: a Play Store device refuses root forever, and without root the files an app writes cannot be seen.';
|
|
136
|
+
|
|
137
|
+
// No image exists for this machine, and saying so beats naming one that does not exist.
|
|
138
|
+
//
|
|
139
|
+
// Google publishes emulator images for arm64, x86_64 and x86 and nothing else. Handing
|
|
140
|
+
// somebody a command that fails on a package nobody has ever published sends them looking
|
|
141
|
+
// for a typo in a line this tool wrote for them, which is a worse place to be than being
|
|
142
|
+
// told plainly that their machine cannot run one.
|
|
143
|
+
if (!abi) {
|
|
144
|
+
const cannot = `No Android emulator image is published for a ${process.arch} machine, so an emulator cannot be created here. Plug in a real Android device, or run the check from a machine on arm64, x86_64 or x86.`;
|
|
145
|
+
return { image: null, install: null, create: null, why: cannot, both: cannot };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const image = `system-images;android-${api};google_apis;${abi}`;
|
|
149
|
+
const install = `sdkmanager --install "${image}"`;
|
|
150
|
+
const create = `avdmanager create avd -n ${name} -k "${image}"`;
|
|
151
|
+
return {
|
|
152
|
+
image,
|
|
153
|
+
install,
|
|
154
|
+
create,
|
|
155
|
+
why: warning,
|
|
156
|
+
both: `${install} then ${create}. ${warning}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The same answer, raised to whatever the app in front of us actually needs.
|
|
162
|
+
*
|
|
163
|
+
* @param {{minSdk?: number|null}|null} apk
|
|
164
|
+
* @returns {ReturnType<typeof deviceToMake>}
|
|
165
|
+
*/
|
|
166
|
+
export function deviceFor(apk) {
|
|
167
|
+
return deviceToMake({ api: apk?.minSdk ?? 0 });
|
|
168
|
+
}
|
|
169
|
+
|
|
76
170
|
// ---------------------------------------------------------------------------
|
|
77
171
|
// Finding the APK
|
|
78
172
|
// ---------------------------------------------------------------------------
|
|
@@ -522,7 +616,7 @@ export const androidAdapter = defineAdapter({
|
|
|
522
616
|
missing.push({
|
|
523
617
|
what: 'a virtual device for the emulator to run',
|
|
524
618
|
unlocks: 'having somewhere to install the app',
|
|
525
|
-
howToGet:
|
|
619
|
+
howToGet: deviceFor(apk).both,
|
|
526
620
|
});
|
|
527
621
|
}
|
|
528
622
|
|
|
@@ -531,7 +625,8 @@ export const androidAdapter = defineAdapter({
|
|
|
531
625
|
missing.push({
|
|
532
626
|
what: 'a virtual device built from a plain Google APIs image rather than a Play Store one',
|
|
533
627
|
unlocks: 'seeing the files the app writes, and stopping the clock. A Play Store device refuses root permanently, and both of those need it',
|
|
534
|
-
|
|
628
|
+
// Null on a machine with no emulator image at all; `why` then carries the reason.
|
|
629
|
+
howToGet: deviceFor(apk).create ?? deviceFor(apk).why,
|
|
535
630
|
});
|
|
536
631
|
}
|
|
537
632
|
if (usable.some((d) => !d.emulator)) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting and stopping the product's own server.
|
|
3
|
+
*
|
|
4
|
+
* A start command is run through a shell, because that is what people write: `npm run dev`,
|
|
5
|
+
* `sh dev.sh`, `poetry run uvicorn ...`. So the thing that is spawned is the SHELL, and the
|
|
6
|
+
* server is its child — often its grandchild, since `npm run dev` is npm, which runs next,
|
|
7
|
+
* which runs node.
|
|
8
|
+
*
|
|
9
|
+
* Killing the shell therefore does not kill the server. And because the shell's stdout and
|
|
10
|
+
* stderr are pipes, every survivor inherits the writing end of them — so the pipes never
|
|
11
|
+
* close, this process's event loop never empties, and `staysfixed check` prints its whole
|
|
12
|
+
* answer and then hangs for ever at nothing per cent of a CPU. Measured on 2026-08-30 on a
|
|
13
|
+
* start command that spawns its server and waits, which is the shape `npm run dev` has: the
|
|
14
|
+
* verdict appeared in about thirty seconds and the command never returned.
|
|
15
|
+
*
|
|
16
|
+
* So the shell is started as its own process GROUP and the whole group is signalled. And
|
|
17
|
+
* after that, the pipes are torn down here rather than trusted to close, because a survivor
|
|
18
|
+
* this file did not start — a stray `node` somebody's dev server left behind — must not be
|
|
19
|
+
* able to hold a finished check open.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start the product, in a group of its own.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @param {{cwd: string, env: any, stdio?: any}} opts
|
|
29
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
30
|
+
*/
|
|
31
|
+
export function spawnServer(command, opts) {
|
|
32
|
+
return spawn(String(command), {
|
|
33
|
+
shell: true,
|
|
34
|
+
cwd: opts.cwd,
|
|
35
|
+
env: opts.env,
|
|
36
|
+
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
|
|
37
|
+
// The whole point. On Windows there are no process groups of this kind, and killing the
|
|
38
|
+
// child is the best that can be done there.
|
|
39
|
+
detached: process.platform !== 'win32',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stop it, and everything it started.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('node:child_process').ChildProcess|null|undefined} child
|
|
47
|
+
* @param {{graceMs?: number}} [opts]
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
export async function stopServer(child, opts = {}) {
|
|
51
|
+
if (!child) return;
|
|
52
|
+
const pid = child.pid;
|
|
53
|
+
const graceMs = opts.graceMs ?? 500;
|
|
54
|
+
|
|
55
|
+
/** @param {NodeJS.Signals} signal */
|
|
56
|
+
const tellTheGroup = (signal) => {
|
|
57
|
+
if (!pid) return;
|
|
58
|
+
try {
|
|
59
|
+
// A negative pid is the GROUP. This is the line that makes the difference.
|
|
60
|
+
if (process.platform === 'win32') child.kill(signal);
|
|
61
|
+
else process.kill(-pid, signal);
|
|
62
|
+
} catch {
|
|
63
|
+
// No group, or already gone. Ask the one process we definitely know about.
|
|
64
|
+
try {
|
|
65
|
+
child.kill(signal);
|
|
66
|
+
} catch {
|
|
67
|
+
// Already gone, which is the outcome wanted.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
73
|
+
tellTheGroup('SIGTERM');
|
|
74
|
+
await new Promise((done) => {
|
|
75
|
+
let settled = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
done(undefined);
|
|
80
|
+
};
|
|
81
|
+
child.once('exit', finish);
|
|
82
|
+
const timer = setTimeout(finish, graceMs);
|
|
83
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
84
|
+
});
|
|
85
|
+
if (child.exitCode === null && child.signalCode === null) tellTheGroup('SIGKILL');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// And never let what it left behind hold this process open.
|
|
89
|
+
for (const stream of [child.stdout, child.stderr, child.stdin]) {
|
|
90
|
+
try {
|
|
91
|
+
stream?.destroy();
|
|
92
|
+
} catch {
|
|
93
|
+
// Nothing to close.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
child.unref();
|
|
98
|
+
} catch {
|
|
99
|
+
// Not every child can be unreferenced. It has been signalled either way.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -611,11 +611,21 @@ export function undoOurFootprint(text, footprint) {
|
|
|
611
611
|
* @returns {{text: string, truncated: boolean, bytes: number}}
|
|
612
612
|
*/
|
|
613
613
|
export function trimForStorage(text, limit = 64 * 1024) {
|
|
614
|
-
const
|
|
615
|
-
|
|
614
|
+
const whole = Buffer.from(String(text), 'utf8');
|
|
615
|
+
const bytes = whole.length;
|
|
616
|
+
if (bytes <= limit) return { text: String(text), truncated: false, bytes };
|
|
616
617
|
const keep = Math.floor(limit / 2);
|
|
617
|
-
|
|
618
|
-
|
|
618
|
+
// Cut in BYTES, which is what the limit is counted in. This used to cut in characters, and
|
|
619
|
+
// on anything that is not plain ASCII the two are not the same number: a screenful of
|
|
620
|
+
// box-drawing or CJK is three bytes a character, so 90,000 bytes of it is only 30,000
|
|
621
|
+
// characters, both halves took the WHOLE text, and the stored value came out at 180,000
|
|
622
|
+
// bytes — the entire output twice, under a marker claiming 24,464 bytes had been left out
|
|
623
|
+
// of the middle. Three lies at once: the limit was not applied, the count was wrong, and
|
|
624
|
+
// the observation was marked not-fully-covered when in fact nothing had been dropped.
|
|
625
|
+
const headEnd = backToACharacter(whole, keep);
|
|
626
|
+
const tailStart = onToACharacter(whole, bytes - keep);
|
|
627
|
+
const head = whole.subarray(0, headEnd).toString('utf8');
|
|
628
|
+
const tail = whole.subarray(tailStart).toString('utf8');
|
|
619
629
|
// The marker used to carry a COARSE size bucket, and the doc above it claimed a fingerprint
|
|
620
630
|
// of the whole that was never actually computed. Both halves of that were wrong, and the
|
|
621
631
|
// result was the worst thing this tool can produce: a change that happened entirely in the
|
|
@@ -636,8 +646,35 @@ export function trimForStorage(text, limit = 64 * 1024) {
|
|
|
636
646
|
// as not fully covered, the coverage ledger states the hole, and the whole text is written
|
|
637
647
|
// to the evidence folder so anybody can look.
|
|
638
648
|
return {
|
|
639
|
-
text: `${head}\n... exactly ${
|
|
649
|
+
text: `${head}\n... exactly ${tailStart - headEnd} bytes left out of the middle of ${bytes} ...\n${tail}`,
|
|
640
650
|
truncated: true,
|
|
641
651
|
bytes,
|
|
642
652
|
};
|
|
643
653
|
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Cutting a multi-byte character in half turns it into a replacement character, which is a
|
|
657
|
+
* difference nobody made and which would then move about between runs. These two walk the cut
|
|
658
|
+
* to the nearest place a character actually starts — backwards for the head, forwards for the
|
|
659
|
+
* tail, so the two halves can never grow into each other.
|
|
660
|
+
*
|
|
661
|
+
* @param {Buffer} buffer
|
|
662
|
+
* @param {number} at
|
|
663
|
+
* @returns {number}
|
|
664
|
+
*/
|
|
665
|
+
function backToACharacter(buffer, at) {
|
|
666
|
+
let cut = at;
|
|
667
|
+
while (cut > 0 && (buffer[cut] & 0xC0) === 0x80) cut -= 1;
|
|
668
|
+
return cut;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* @param {Buffer} buffer
|
|
673
|
+
* @param {number} at
|
|
674
|
+
* @returns {number}
|
|
675
|
+
*/
|
|
676
|
+
function onToACharacter(buffer, at) {
|
|
677
|
+
let cut = at;
|
|
678
|
+
while (cut < buffer.length && (buffer[cut] & 0xC0) === 0x80) cut += 1;
|
|
679
|
+
return cut;
|
|
680
|
+
}
|
|
@@ -597,6 +597,20 @@ export async function openApp(opts) {
|
|
|
597
597
|
};
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
+
/**
|
|
601
|
+
* A name a picture can be saved under, cut with a fingerprint of the whole on the end so two
|
|
602
|
+
* long names can never land on one file.
|
|
603
|
+
*
|
|
604
|
+
* @param {string} name
|
|
605
|
+
* @returns {string}
|
|
606
|
+
*/
|
|
607
|
+
function pictureName(name) {
|
|
608
|
+
const clean = String(name).replace(/[^a-zA-Z0-9._-]+/g, '-');
|
|
609
|
+
if (clean === '') return 'a-walk';
|
|
610
|
+
if (clean.length <= 60) return clean;
|
|
611
|
+
return `${clean.slice(0, 51)}-${crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8)}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
600
614
|
/**
|
|
601
615
|
* Would this request leave the machine?
|
|
602
616
|
*
|
|
@@ -640,6 +654,17 @@ export function asAddress(text, limit = 110) {
|
|
|
640
654
|
return `${clean.slice(0, limit - 12)}… (${mark})`;
|
|
641
655
|
}
|
|
642
656
|
|
|
657
|
+
/**
|
|
658
|
+
* How much of one control's own text is kept at its address.
|
|
659
|
+
*
|
|
660
|
+
* It protects the store and the diff: a text area holding a whole document would otherwise put
|
|
661
|
+
* that document into every capture and into every sentence written about it. Two hundred bytes
|
|
662
|
+
* is enough to recognise a field by. What breaks if it is wrong is nothing silent — the two
|
|
663
|
+
* ends and the exact byte count are kept either way, so getting this number wrong makes the
|
|
664
|
+
* record bigger or smaller, never quieter.
|
|
665
|
+
*/
|
|
666
|
+
const CONTROL_TEXT_BYTES = 200;
|
|
667
|
+
|
|
643
668
|
/**
|
|
644
669
|
* The states worth writing down.
|
|
645
670
|
*
|
|
@@ -665,21 +690,37 @@ const ROLES_WORTH_NOTHING = new Set(['generic', 'none', 'presentation', 'InlineT
|
|
|
665
690
|
* identical, while a button that lost its label, went missing or went grey all show up as
|
|
666
691
|
* exactly one difference each.
|
|
667
692
|
*
|
|
693
|
+
* NOTHING HERE IS CUT WITHOUT SAYING SO. A control's own text used to be kept as its first
|
|
694
|
+
* two hundred characters and nothing else — no length, no fingerprint — so two builds whose
|
|
695
|
+
* text differed only past character two hundred recorded the same string and compared equal.
|
|
696
|
+
* A total, a message or an error at the end of a long field could change completely and the
|
|
697
|
+
* run would report that nothing had changed. `trimForStorage` had already solved this exactly
|
|
698
|
+
* once, for the output of a command: keep both ends and the EXACT number of bytes discarded,
|
|
699
|
+
* because a length survives normalisation while a digest of the whole text would not. It was
|
|
700
|
+
* never applied here. It is now, and what is still not covered — a change in the middle that
|
|
701
|
+
* leaves the length identical — is counted on `trimmed` and reported as a hole by the caller.
|
|
702
|
+
*
|
|
703
|
+
* The NAME is no longer cut here at all. It is part of an address, and cutting an address
|
|
704
|
+
* merges two things into one: two paragraphs sharing their first hundred and twenty
|
|
705
|
+
* characters became one address, so an edit further along either of them was invisible.
|
|
706
|
+
* `asAddress` already cuts addresses properly, leaving a fingerprint of the whole behind, and
|
|
707
|
+
* the caller passes every address through it.
|
|
708
|
+
*
|
|
668
709
|
* @param {any[]} nodes Straight from Accessibility.getFullAXTree.
|
|
669
710
|
* @param {(text: string) => string} [tidy] Rubs our own footprint out of the names.
|
|
670
|
-
* @returns {{address: string, role: string, name: string, state: Record<string, string|number|boolean
|
|
711
|
+
* @returns {{address: string, role: string, name: string, state: Record<string, string|number|boolean>, trimmed: boolean}[]}
|
|
671
712
|
*/
|
|
672
713
|
export function readMeaning(nodes, tidy = (t) => t) {
|
|
673
714
|
/** @type {Map<string, number>} */
|
|
674
715
|
const seen = new Map();
|
|
675
|
-
/** @type {{address: string, role: string, name: string, state: Record<string, string|number|boolean
|
|
716
|
+
/** @type {{address: string, role: string, name: string, state: Record<string, string|number|boolean>, trimmed: boolean}[]} */
|
|
676
717
|
const rows = [];
|
|
677
718
|
|
|
678
719
|
for (const node of nodes ?? []) {
|
|
679
720
|
if (!node || node.ignored) continue;
|
|
680
721
|
const role = String(node.role?.value ?? '');
|
|
681
722
|
if (!role || ROLES_WORTH_NOTHING.has(role)) continue;
|
|
682
|
-
const name = tidy(String(node.name?.value ?? '')).trim().replace(/\s+/g, ' ')
|
|
723
|
+
const name = tidy(String(node.name?.value ?? '')).trim().replace(/\s+/g, ' ');
|
|
683
724
|
|
|
684
725
|
/** @type {Record<string, string|number|boolean>} */
|
|
685
726
|
const state = {};
|
|
@@ -690,9 +731,18 @@ export function readMeaning(nodes, tidy = (t) => t) {
|
|
|
690
731
|
if (value === undefined || value === false || value === 'false') continue;
|
|
691
732
|
state[key] = typeof value === 'object' ? String(value) : value;
|
|
692
733
|
}
|
|
734
|
+
let trimmed = false;
|
|
693
735
|
const own = node.value?.value;
|
|
694
|
-
if (own !== undefined && own !== null && String(own) !== '')
|
|
695
|
-
|
|
736
|
+
if (own !== undefined && own !== null && String(own) !== '') {
|
|
737
|
+
const kept = trimForStorage(tidy(String(own)), CONTROL_TEXT_BYTES);
|
|
738
|
+
state.value = kept.text;
|
|
739
|
+
trimmed = trimmed || kept.truncated;
|
|
740
|
+
}
|
|
741
|
+
if (node.description?.value) {
|
|
742
|
+
const kept = trimForStorage(tidy(String(node.description.value)), CONTROL_TEXT_BYTES);
|
|
743
|
+
state.described = kept.text;
|
|
744
|
+
trimmed = trimmed || kept.truncated;
|
|
745
|
+
}
|
|
696
746
|
|
|
697
747
|
// A control with no name is only worth an address when it says something else about
|
|
698
748
|
// itself; an anonymous, stateless box is noise in every reading it appears in.
|
|
@@ -706,6 +756,7 @@ export function readMeaning(nodes, tidy = (t) => t) {
|
|
|
706
756
|
role,
|
|
707
757
|
name,
|
|
708
758
|
state,
|
|
759
|
+
trimmed,
|
|
709
760
|
});
|
|
710
761
|
}
|
|
711
762
|
return rows;
|
|
@@ -931,6 +982,18 @@ export function describeApp(input) {
|
|
|
931
982
|
surface: 'electron',
|
|
932
983
|
}));
|
|
933
984
|
}
|
|
985
|
+
const tooLong = meaning.filter((row) => row.trimmed).length;
|
|
986
|
+
if (tooLong > 0) {
|
|
987
|
+
out.push(notCovered({
|
|
988
|
+
channel: 'meaning',
|
|
989
|
+
path: joinPath('count', id, 'controls holding more text than is kept'),
|
|
990
|
+
reason: 'too big',
|
|
991
|
+
says:
|
|
992
|
+
`${tooLong} control${tooLong === 1 ? '' : 's'} on this screen ${tooLong === 1 ? 'holds' : 'hold'} more text than is kept at one address. ` +
|
|
993
|
+
'Both ends of it are compared and so is the exact number of bytes in between, so a change to either end, or one that makes the text longer or shorter, is still caught. ' +
|
|
994
|
+
'A change buried in the middle that leaves the length exactly the same is not, and that is a hole rather than a pass.',
|
|
995
|
+
}));
|
|
996
|
+
}
|
|
934
997
|
out.push(observation({
|
|
935
998
|
channel: 'counters',
|
|
936
999
|
path: joinPath('count', id, 'things on screen'),
|
|
@@ -1572,7 +1635,10 @@ async function takePicture(app, journey, ctx) {
|
|
|
1572
1635
|
const shot = await app.browser.send('Page.captureScreenshot', { format: 'png' }, app.sessionId);
|
|
1573
1636
|
const bytes = Buffer.from(String(shot?.data ?? ''), 'base64');
|
|
1574
1637
|
if (bytes.length === 0) throw new Error('the app sent back an empty picture');
|
|
1575
|
-
|
|
1638
|
+
// Fingerprinted rather than simply cut. Two journeys whose names agreed for sixty
|
|
1639
|
+
// characters saved their pictures over each other, and the evidence offered for one
|
|
1640
|
+
// finding was then a photograph of a different window, with nothing about it looking wrong.
|
|
1641
|
+
const file = path.join(ctx.evidenceDir, `${pictureName(journey.name)}.png`);
|
|
1576
1642
|
await fsp.mkdir(ctx.evidenceDir, { recursive: true });
|
|
1577
1643
|
await fsp.writeFile(file, bytes);
|
|
1578
1644
|
return observation({
|
package/src/v2/adapters/http.js
CHANGED
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
import fsp from 'node:fs/promises';
|
|
32
32
|
import net from 'node:net';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
-
import { spawn } from 'node:child_process';
|
|
35
34
|
import {
|
|
36
35
|
defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
|
|
37
36
|
howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
|
|
41
40
|
} from './process.js';
|
|
42
41
|
import { readContract, readFileRoutes } from './source.js';
|
|
42
|
+
import { spawnServer, stopServer } from './child.js';
|
|
43
43
|
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
// Headers
|
|
@@ -371,7 +371,13 @@ export const httpAdapter = defineAdapter({
|
|
|
371
371
|
surface: 'server',
|
|
372
372
|
from: route.file,
|
|
373
373
|
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
374
|
-
|
|
374
|
+
// `door` and `doorDetail` are how the coverage ledger learns that this journey walked
|
|
375
|
+
// that route. Without them a route counted as opened only if an observation happened to
|
|
376
|
+
// land at its own address, and this adapter writes its observations under `api.<journey
|
|
377
|
+
// name>` — so every route on every server read as never walked, on runs that had just
|
|
378
|
+
// asked the server for all of them. The verb is part of it: GET /basket and POST
|
|
379
|
+
// /basket are two doors.
|
|
380
|
+
steps: [{ act: 'request', method, route: route.name, url, unfilled, door: route.name, kind: 'route', doorDetail: route.detail ?? method }],
|
|
375
381
|
// A route that changes something is walked — against a restored fixture, that is
|
|
376
382
|
// the whole point. Only a route the project itself marks as irreversible is held
|
|
377
383
|
// back, and even then only when nothing is watching to refuse the effect.
|
|
@@ -389,7 +395,10 @@ export const httpAdapter = defineAdapter({
|
|
|
389
395
|
surface: 'server',
|
|
390
396
|
from: 'the project config',
|
|
391
397
|
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
392
|
-
|
|
398
|
+
// A request written by hand in the settings names its own route, so it opens the same
|
|
399
|
+
// door the code reader found — as long as the url is the route's pattern rather than a
|
|
400
|
+
// filled-in one, which is the shape the settings ask for.
|
|
401
|
+
steps: [{ act: 'request', method: String(extra.method ?? 'GET'), route: String(extra.url), url, unfilled, headers: extra.headers, body: extra.body, door: String(extra.route ?? extra.url), kind: 'route', doorDetail: String(extra.method ?? 'GET') }],
|
|
393
402
|
irreversible: extra.irreversible === true,
|
|
394
403
|
});
|
|
395
404
|
}
|
|
@@ -447,7 +456,7 @@ export const httpAdapter = defineAdapter({
|
|
|
447
456
|
notes.push(verdict.why);
|
|
448
457
|
} else {
|
|
449
458
|
const result = await new Promise((resolve) => {
|
|
450
|
-
const child =
|
|
459
|
+
const child = spawnServer(String(config.restore), { cwd: work, env });
|
|
451
460
|
/** @type {Buffer[]} */
|
|
452
461
|
const err = [];
|
|
453
462
|
child.stderr?.on('data', (c) => err.push(c));
|
|
@@ -474,7 +483,7 @@ export const httpAdapter = defineAdapter({
|
|
|
474
483
|
/** @type {Buffer[]} */
|
|
475
484
|
const bootOut = [];
|
|
476
485
|
let exited = /** @type {string|null} */ (null);
|
|
477
|
-
const child =
|
|
486
|
+
const child = spawnServer(String(config.start), { cwd: work, env });
|
|
478
487
|
child.stdout?.on('data', (c) => bootOut.push(c));
|
|
479
488
|
child.stderr?.on('data', (c) => bootErr.push(c));
|
|
480
489
|
child.on('close', (code, signal) => {
|
|
@@ -487,11 +496,11 @@ export const httpAdapter = defineAdapter({
|
|
|
487
496
|
});
|
|
488
497
|
|
|
489
498
|
if (!up.up) {
|
|
490
|
-
child
|
|
499
|
+
await stopServer(child);
|
|
491
500
|
return {
|
|
492
501
|
build, root: work, ready: false,
|
|
493
502
|
why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
|
|
494
|
-
dispose: async () => { child
|
|
503
|
+
dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
|
|
495
504
|
};
|
|
496
505
|
}
|
|
497
506
|
|
|
@@ -513,9 +522,7 @@ export const httpAdapter = defineAdapter({
|
|
|
513
522
|
if (!held) return;
|
|
514
523
|
// Only ever the process we started. Somebody else's server on this machine is
|
|
515
524
|
// somebody else's business.
|
|
516
|
-
held.child
|
|
517
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
518
|
-
if (held.child.exitCode === null) held.child.kill('SIGKILL');
|
|
525
|
+
await stopServer(held.child);
|
|
519
526
|
await fsp.rm(base, { recursive: true, force: true });
|
|
520
527
|
},
|
|
521
528
|
};
|
|
@@ -603,7 +610,7 @@ export const httpAdapter = defineAdapter({
|
|
|
603
610
|
|
|
604
611
|
async teardown() {
|
|
605
612
|
for (const [, held] of running) {
|
|
606
|
-
held.child
|
|
613
|
+
await stopServer(held.child);
|
|
607
614
|
}
|
|
608
615
|
running.clear();
|
|
609
616
|
},
|