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.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. 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: 'sdkmanager --install "system-images;android-33;google_apis;arm64-v8a" then avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a". 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.',
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
- howToGet: 'avdmanager create avd -n staysfixed -k "system-images;android-33;google_apis;arm64-v8a"',
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 bytes = Buffer.byteLength(text, 'utf8');
615
- if (bytes <= limit) return { text, truncated: false, bytes };
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
- const head = text.slice(0, keep);
618
- const tail = text.slice(-keep);
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 ${bytes - keep * 2} bytes left out of the middle of ${bytes} ...\n${tail}`,
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, ' ').slice(0, 120);
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) !== '') state.value = tidy(String(own)).slice(0, 200);
695
- if (node.description?.value) state.described = tidy(String(node.description.value)).slice(0, 200);
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
- const file = path.join(ctx.evidenceDir, `${journey.name.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 60)}.png`);
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({
@@ -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
- steps: [{ act: 'request', method, route: route.name, url, unfilled }],
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
- steps: [{ act: 'request', method: String(extra.method ?? 'GET'), route: String(extra.url), url, unfilled, headers: extra.headers, body: extra.body }],
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 = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
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 = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
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.kill('SIGTERM');
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.kill('SIGKILL'); await fsp.rm(base, { recursive: true, force: true }); },
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.kill('SIGTERM');
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.kill('SIGTERM');
613
+ await stopServer(held.child);
607
614
  }
608
615
  running.clear();
609
616
  },