staysfixed 0.11.0 → 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 +102 -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/guard/api.js +115 -36
- 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 +41 -2
- 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 +164 -20
- package/src/v2/init.js +21 -3
- 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
|
@@ -373,16 +373,54 @@ export async function ensureDevice(opts = {}) {
|
|
|
373
373
|
const typeId = await pickDeviceType(opts.deviceType, { signal: opts.signal });
|
|
374
374
|
if (!typeId.ok) return { ok: false, device: null, why: typeId.why };
|
|
375
375
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
376
|
+
// EVERY kind of iPhone is tried, newest first, not just the first one.
|
|
377
|
+
//
|
|
378
|
+
// Not every phone runs on every version of iOS, and Apple says so with a number and no
|
|
379
|
+
// words: measured on this Mac on 2026-08-31, asking for an iPhone 6s on iOS 27.0 came back
|
|
380
|
+
// as `SimError 403` with an empty message. One attempt meant one number, and the sentence
|
|
381
|
+
// handed to a person was "a simulator could not be made" on a Mac that could perfectly well
|
|
382
|
+
// make several. So the loop walks down the list, and if every phone this Mac has is refused
|
|
383
|
+
// by every runtime it has, the refusal that comes back names what was tried and how many.
|
|
384
|
+
//
|
|
385
|
+
// SIX, not all of them. A refusal comes back instantly, but a simulator tool that has
|
|
386
|
+
// wedged does not — it takes the full two minutes before this gives up on it — and this Mac
|
|
387
|
+
// lists forty kinds of iPhone. Forty of those in a row is eighty minutes of a check that
|
|
388
|
+
// looks like it has hung, which is worse than a clear failure. Six covers every real case:
|
|
389
|
+
// if the six newest phones a Mac has all refuse a runtime, the seventh will too.
|
|
390
|
+
const MOST_TRIED = 6;
|
|
391
|
+
/** @type {string[]} */
|
|
392
|
+
const refused = [];
|
|
393
|
+
let udid = '';
|
|
394
|
+
let label = '';
|
|
395
|
+
for (const candidate of typeId.candidates.slice(0, MOST_TRIED)) {
|
|
396
|
+
const made = await simctl(['create', wanted, candidate.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
|
|
397
|
+
if (made.ok && made.stdout.trim() !== '') {
|
|
398
|
+
udid = made.stdout.trim();
|
|
399
|
+
label = candidate.label;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
refused.push(`${candidate.label} (${firstLine(made.stderr) || made.why})`);
|
|
403
|
+
}
|
|
404
|
+
if (udid === '') {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
device: null,
|
|
408
|
+
why:
|
|
409
|
+
`A simulator called ${wanted} could not be made. ${refused.length} kind${refused.length === 1 ? '' : 's'} of iPhone ` +
|
|
410
|
+
`${refused.length === 1 ? 'was' : 'were'} tried on ${runtime.name}${typeId.candidates.length > MOST_TRIED ? ` (the newest ${MOST_TRIED} of the ${typeId.candidates.length} this Mac has)` : ''} and every one was refused. ` +
|
|
411
|
+
`The first was: ${refused[0] ?? 'nothing at all was tried'}. ` +
|
|
412
|
+
'This usually means the iOS version installed here is newer than every phone this Mac knows about, or older than all of them — opening Xcode once and letting it finish installing its simulator components fixes it.',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
379
415
|
|
|
380
416
|
const booted = await bootDevice(udid, { signal: opts.signal });
|
|
381
417
|
if (!booted.ok) return { ok: false, device: null, why: booted.why };
|
|
382
418
|
return {
|
|
383
419
|
ok: true,
|
|
384
420
|
device: { udid, name: wanted, runtimeName: runtime.name, weMadeIt: true, weBootedIt: true, why: booted.why },
|
|
385
|
-
why:
|
|
421
|
+
why:
|
|
422
|
+
`Made a new ${label} on ${runtime.name} called ${wanted}, and booted it.` +
|
|
423
|
+
(refused.length > 0 ? ` ${refused.length} newer kind${refused.length === 1 ? '' : 's'} of iPhone would not run on ${runtime.name}, so ${label} was used instead.` : ''),
|
|
386
424
|
};
|
|
387
425
|
}
|
|
388
426
|
|
|
@@ -419,9 +457,25 @@ function compareVersions(a, b) {
|
|
|
419
457
|
}
|
|
420
458
|
|
|
421
459
|
/**
|
|
460
|
+
* Which kinds of iPhone this Mac could make, best first.
|
|
461
|
+
*
|
|
462
|
+
* A LIST rather than one answer, and the reason was measured on this Mac on 2026-08-31.
|
|
463
|
+
* `simctl list devicetypes` prints the newest iPhone first — iPhone 17 Pro at the top,
|
|
464
|
+
* iPhone 6s at the bottom — and this function used to take the LAST phone in that list.
|
|
465
|
+
* So on a Mac whose only iOS runtime was 27.0, it asked for an iPhone 6s on iOS 27, which
|
|
466
|
+
* Apple refuses outright: `simctl create` came back with SimError 403 and no explanation,
|
|
467
|
+
* `prepare` reported "a simulator called staysfixed-ios could not be made", and the whole
|
|
468
|
+
* iPhone surface was dark on a machine with Xcode, a runtime and a built app all sitting
|
|
469
|
+
* there ready. Nothing said the pairing was the problem, so the message read like the Mac
|
|
470
|
+
* was broken.
|
|
471
|
+
*
|
|
472
|
+
* Two things changed. The newest phone is picked first, because a runtime always supports
|
|
473
|
+
* the hardware of its own year. And every other phone is handed back behind it in order, so
|
|
474
|
+
* a caller that gets refused can try the next one instead of giving up on the platform.
|
|
475
|
+
*
|
|
422
476
|
* @param {string|undefined} wanted
|
|
423
477
|
* @param {{signal?: AbortSignal}} opts
|
|
424
|
-
* @returns {Promise<{ok: boolean, id: string, label: string, why: string}>}
|
|
478
|
+
* @returns {Promise<{ok: boolean, id: string, label: string, why: string, candidates: {id: string, label: string}[]}>}
|
|
425
479
|
*/
|
|
426
480
|
async function pickDeviceType(wanted, opts) {
|
|
427
481
|
const listed = await simctl(['list', '-j', 'devicetypes'], { timeoutMs: 45_000, signal: opts.signal });
|
|
@@ -432,17 +486,46 @@ async function pickDeviceType(wanted, opts) {
|
|
|
432
486
|
identifier: String(t.identifier), name: String(t.name),
|
|
433
487
|
}));
|
|
434
488
|
} catch {
|
|
435
|
-
return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.' };
|
|
489
|
+
return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.', candidates: [] };
|
|
436
490
|
}
|
|
437
491
|
if (wanted) {
|
|
438
492
|
const found = types.find((t) => t.identifier === wanted || t.name === wanted);
|
|
439
|
-
if (found) return { ok: true, id: found.identifier, label: found.name, why: '' };
|
|
440
|
-
return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}"
|
|
493
|
+
if (found) return { ok: true, id: found.identifier, label: found.name, why: '', candidates: [{ id: found.identifier, label: found.name }] };
|
|
494
|
+
return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".`, candidates: [] };
|
|
495
|
+
}
|
|
496
|
+
const candidates = phoneKindsToTry(types);
|
|
497
|
+
const pick = candidates[0];
|
|
498
|
+
if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.', candidates: [] };
|
|
499
|
+
return { ok: true, id: pick.id, label: pick.label, why: '', candidates };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Every kind of iPhone worth trying, best first.
|
|
504
|
+
*
|
|
505
|
+
* Pulled out of `pickDeviceType` so the ORDER can be tested on any machine, including one
|
|
506
|
+
* with no Xcode on it. The order is the whole bug: `simctl list devicetypes` prints the
|
|
507
|
+
* newest iPhone first and the oldest last, and taking the last one asked for an iPhone 6s on
|
|
508
|
+
* iOS 27.0, which Apple refuses with `SimError 403` and no words. Measured on this Mac on
|
|
509
|
+
* 2026-08-31, where it left the entire iPhone surface dark on a machine that had Xcode, a
|
|
510
|
+
* runtime and a built app all sitting there ready.
|
|
511
|
+
*
|
|
512
|
+
* A plain iPhone comes before a Plus, a Max, a mini or an `e`, because those are the same
|
|
513
|
+
* year's hardware in an awkward shape and a plain one is the least surprising thing to
|
|
514
|
+
* compare on; within each group the list's own order is kept, which is newest first.
|
|
515
|
+
*
|
|
516
|
+
* @param {{identifier: string, name: string}[]} types
|
|
517
|
+
* @returns {{id: string, label: string}[]}
|
|
518
|
+
*/
|
|
519
|
+
export function phoneKindsToTry(types) {
|
|
520
|
+
const plain = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
|
|
521
|
+
const anyPhone = types.filter((t) => t.identifier.includes('iPhone'));
|
|
522
|
+
/** @type {{id: string, label: string}[]} */
|
|
523
|
+
const candidates = [];
|
|
524
|
+
for (const t of [...plain, ...anyPhone]) {
|
|
525
|
+
if (candidates.some((c) => c.id === t.identifier)) continue;
|
|
526
|
+
candidates.push({ id: t.identifier, label: t.name });
|
|
441
527
|
}
|
|
442
|
-
|
|
443
|
-
const pick = phones[phones.length - 1] ?? types.find((t) => t.identifier.includes('iPhone'));
|
|
444
|
-
if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.' };
|
|
445
|
-
return { ok: true, id: pick.identifier, label: pick.name, why: '' };
|
|
528
|
+
return candidates;
|
|
446
529
|
}
|
|
447
530
|
|
|
448
531
|
/**
|
package/src/v2/adapters/ios.js
CHANGED
|
@@ -26,6 +26,30 @@
|
|
|
26
26
|
* never on screen during a run.
|
|
27
27
|
* - COUNTERS AND PICTURES, coarse and last.
|
|
28
28
|
*
|
|
29
|
+
* IS A PAIRED RUN POSSIBLE HERE? YES, AND IT HAS NOW BEEN MEASURED.
|
|
30
|
+
*
|
|
31
|
+
* A paired run means the old build is put back on this machine and walked minutes before the
|
|
32
|
+
* new one, so nothing that drifted in between — the weather, a dependency, the clock — can be
|
|
33
|
+
* mistaken for somebody's change. On a phone that means one device, two builds one after the
|
|
34
|
+
* other, and the device put back in between. Until 2026-08-31 this was not offered, because
|
|
35
|
+
* nobody had ever checked whether the device really does come back to the same place.
|
|
36
|
+
*
|
|
37
|
+
* It has been checked. On an Apple Silicon Mac, on 2026-08-31, against Terminal Deck's own
|
|
38
|
+
* iPhone app (0.15.0, build 2608221311) on a simulator this adapter made for itself — an
|
|
39
|
+
* iPhone 17 Pro on iOS 27.0 — ONE build was walked ten times, with the device put back
|
|
40
|
+
* between every walk exactly the way it is put back between two builds: the app and
|
|
41
|
+
* everything it had written removed, and every permission it had been granted taken back.
|
|
42
|
+
*
|
|
43
|
+
* Five pairs. 725 addresses in each walk, 215 of them read out of the RUNNING app and the
|
|
44
|
+
* rest out of the bundle and the source. 725 of 725 agreed, in all five pairs — 3,625
|
|
45
|
+
* comparisons and not one disagreement. The Mac was carrying a load average of about 500 at
|
|
46
|
+
* the time, which makes that the harsher version of the result rather than the flattering
|
|
47
|
+
* one.
|
|
48
|
+
*
|
|
49
|
+
* So a paired iOS run is offered. What actually limits it is not the simulator — it is
|
|
50
|
+
* getting hold of the OLD build's app bundle, because a `.app` is a build output and a
|
|
51
|
+
* checkout of the old commit does not contain one. See `prepare` and `ios.reference`.
|
|
52
|
+
*
|
|
29
53
|
* WHAT IT CANNOT SEE, and these are not hedges.
|
|
30
54
|
*
|
|
31
55
|
* - A REAL iPHONE. Nothing here touches a device somebody is holding. A paired run means
|
|
@@ -101,6 +125,111 @@ import {
|
|
|
101
125
|
/** Everything prepared, per build, so `run` can be called many times without re-installing. */
|
|
102
126
|
const ready = new Map();
|
|
103
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Which app bundle each half of a comparison was walked from, remembered across the run.
|
|
130
|
+
*
|
|
131
|
+
* It lives out here rather than on a prepared build because the engine prepares one build,
|
|
132
|
+
* walks one journey against it and throws it away before the next: nothing kept on a
|
|
133
|
+
* prepared build survives long enough to notice that the old build and the new build were
|
|
134
|
+
* the same folder on disk.
|
|
135
|
+
*
|
|
136
|
+
* And that is the thing worth noticing. `ios.app` in the settings is usually an absolute
|
|
137
|
+
* path — Xcode writes into DerivedData, which is nowhere near the project — and an absolute
|
|
138
|
+
* path does not move when the old commit is checked out somewhere else. So both halves of a
|
|
139
|
+
* paired run would read the SAME bundle, find nothing different, and the run would say
|
|
140
|
+
* "nothing that worked has changed" about a comparison that never took place. That is the
|
|
141
|
+
* one failure this tool exists to prevent, arriving through the front door. Anything caught
|
|
142
|
+
* here is reported as a hole on every journey; see `run`.
|
|
143
|
+
*
|
|
144
|
+
* Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
|
|
145
|
+
*
|
|
146
|
+
* @type {Map<string, string>}
|
|
147
|
+
*/
|
|
148
|
+
const walkedFrom = new Map();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The build ids that turned out to be the other half of the comparison, and the sentence
|
|
152
|
+
* that says so.
|
|
153
|
+
*
|
|
154
|
+
* Separate from `ready` above because `ready` only holds builds that reached a simulator,
|
|
155
|
+
* and the warning has to survive a build that did not: a reference half that failed to
|
|
156
|
+
* prepare AND was the same bundle as the candidate is two pieces of bad news, and losing the
|
|
157
|
+
* second one is how a comparison comes back green for the wrong reason.
|
|
158
|
+
*
|
|
159
|
+
* @type {Map<string, string>}
|
|
160
|
+
*/
|
|
161
|
+
const sameBundleFor = new Map();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which bundle this really is: the path with the links and the `..`s taken out.
|
|
165
|
+
*
|
|
166
|
+
* A symlink, a `./` or a `..` must not be able to make one bundle look like two, because one
|
|
167
|
+
* bundle looking like two is the whole failure being guarded against here.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} appPath
|
|
170
|
+
* @returns {Promise<string>}
|
|
171
|
+
*/
|
|
172
|
+
async function bundleIdentity(appPath) {
|
|
173
|
+
try {
|
|
174
|
+
return await fsp.realpath(appPath);
|
|
175
|
+
} catch {
|
|
176
|
+
// A path that will not resolve is still worth remembering exactly as it was typed. The
|
|
177
|
+
// question below is whether the two halves agree, not whether the bundle is there.
|
|
178
|
+
return appPath;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Did this half of the comparison read the same app bundle as the other half?
|
|
184
|
+
*
|
|
185
|
+
* Returns the sentence to put in front of a reader, or null when the two halves really are
|
|
186
|
+
* two different bundles.
|
|
187
|
+
*
|
|
188
|
+
* WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
|
|
189
|
+
* holding the same build. Somebody whose release script copies today's build to
|
|
190
|
+
* `builds/latest/App.app` and points `reference` at it is comparing one build against itself
|
|
191
|
+
* with two names, and nothing here notices. That was left alone on purpose — two different
|
|
192
|
+
* bundles are usually two builds somebody produced on purpose, and refusing them on a guess
|
|
193
|
+
* would block real comparisons to prevent an unusual one.
|
|
194
|
+
*
|
|
195
|
+
* @param {'reference'|'candidate'} role
|
|
196
|
+
* @param {string} mine
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
function sameBundleAsTheOtherHalf(role, mine) {
|
|
200
|
+
// Only ever asked about the OLD build's half, and that is not squeamishness — it is the
|
|
201
|
+
// only answer that stays the same from one journey to the next. The engine prepares a
|
|
202
|
+
// build, walks ONE journey against it and throws it away, and it always does the new build
|
|
203
|
+
// first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
|
|
204
|
+
// journey. So from the second journey onwards the new build's half would find the previous
|
|
205
|
+
// journey's old half sitting in the map and flag itself as well — the warning would be
|
|
206
|
+
// absent on the first journey and doubled on every one after it, which reads like a bug in
|
|
207
|
+
// the tool rather than a fact about the run.
|
|
208
|
+
if (role !== 'reference') return null;
|
|
209
|
+
const other = walkedFrom.get('candidate');
|
|
210
|
+
if (!other || other !== mine) return null;
|
|
211
|
+
return `Both halves of this comparison were walked from the same app bundle: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Where a kept copy of the OLD build's app bundle lives, if the settings name one.
|
|
216
|
+
*
|
|
217
|
+
* Two spellings, because both read naturally and neither is worth an argument:
|
|
218
|
+
* `{"reference": "builds/0.14.0/YourApp.app"}` and
|
|
219
|
+
* `{"reference": {"app": "builds/0.14.0/YourApp.app"}}`. A relative path is resolved against
|
|
220
|
+
* whatever `findAppBundle` is given, which for the reference half is the checkout of the old
|
|
221
|
+
* commit — so a project that DOES commit a simulator build can leave this out entirely.
|
|
222
|
+
*
|
|
223
|
+
* @param {Record<string, any>} config
|
|
224
|
+
* @returns {string|undefined}
|
|
225
|
+
*/
|
|
226
|
+
export function referenceBundle(config) {
|
|
227
|
+
const said = config?.reference;
|
|
228
|
+
if (typeof said === 'string' && said.trim() !== '') return said;
|
|
229
|
+
if (said && typeof said === 'object' && typeof said.app === 'string' && said.app.trim() !== '') return said.app;
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
104
233
|
// ---------------------------------------------------------------------------
|
|
105
234
|
// Reading the doors out of the source
|
|
106
235
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +671,13 @@ export const iosAdapter = defineAdapter({
|
|
|
542
671
|
/** @type {string[]} */
|
|
543
672
|
const notes = [];
|
|
544
673
|
|
|
674
|
+
// What was MEASURED goes on before the machine is asked, so it survives every early
|
|
675
|
+
// return below. These notes are what `doctor` prints and what an agent reads, and on a
|
|
676
|
+
// machine that is not a Mac they were dropped entirely — so somebody planning where to
|
|
677
|
+
// run their checks was told only "not here", never what a Mac would actually do or what
|
|
678
|
+
// it would need from them. A fact that exists only in a comment is a fact nobody sees.
|
|
679
|
+
notes.push('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 iOS 27.0 simulator with the app removed and every permission taken back between walks: 725 of 725 addresses agreed in all five pairs — 3,625 comparisons, no disagreements. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s app bundle, because a .app is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings.');
|
|
680
|
+
|
|
545
681
|
const machine = await readMachine();
|
|
546
682
|
if (!machine.isMac) {
|
|
547
683
|
return {
|
|
@@ -667,23 +803,61 @@ export const iosAdapter = defineAdapter({
|
|
|
667
803
|
const scratch = path.join(ctx.scratchDir, `ios-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
668
804
|
await fsp.mkdir(scratch, { recursive: true });
|
|
669
805
|
|
|
806
|
+
// Worked out a few lines below, and captured here so that even a build which could not
|
|
807
|
+
// be got ready still says it. A reference half that failed AND was the same bundle as the
|
|
808
|
+
// candidate is two separate pieces of bad news, and the second one is the one that would
|
|
809
|
+
// otherwise be lost — the run would report "could not be prepared", somebody would fix
|
|
810
|
+
// that, and the comparison would come back green for the wrong reason.
|
|
811
|
+
/** @type {string|null} */
|
|
812
|
+
let sameBundle = null;
|
|
813
|
+
|
|
670
814
|
/** @param {string} why */
|
|
671
815
|
const notReady = (why) => ({
|
|
672
816
|
build,
|
|
673
817
|
root: scratch,
|
|
674
818
|
ready: false,
|
|
675
|
-
why,
|
|
819
|
+
why: sameBundle ? `${why} ${sameBundle}` : why,
|
|
820
|
+
...(build.role === 'reference' ? { facts: { paired: sameBundle === null } } : {}),
|
|
676
821
|
dispose: async () => {
|
|
822
|
+
sameBundleFor.delete(build.id);
|
|
677
823
|
await fsp.rm(scratch, { recursive: true, force: true });
|
|
678
824
|
},
|
|
679
825
|
});
|
|
680
826
|
|
|
827
|
+
// WHICH bundle this half of the comparison walks.
|
|
828
|
+
//
|
|
829
|
+
// For the build you have, that is whatever the settings point at. For the build you were
|
|
830
|
+
// happy with it is different, and the difference is the whole of paired mode on a phone:
|
|
831
|
+
// the engine hands over a checkout of the old commit, and a `.app` is a BUILD OUTPUT that
|
|
832
|
+
// nobody commits, so a checkout of the old commit contains no app at all. `ios.reference`
|
|
833
|
+
// is where a kept copy of the old build's bundle goes, and it is looked at first for the
|
|
834
|
+
// reference half and never for the candidate.
|
|
835
|
+
const forThisHalf = build.role === 'reference' ? { ...config, app: referenceBundle(config) ?? config.app } : config;
|
|
836
|
+
const found = await findAppBundle(build.root, forThisHalf);
|
|
837
|
+
if (!found.ok) {
|
|
838
|
+
return notReady(
|
|
839
|
+
found.why +
|
|
840
|
+
(build.role === 'reference'
|
|
841
|
+
? ' A paired run walks the OLD build here, and a .app 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 simulator build and point at it with {"reference": "path/to/TheOld.app"} under "ios" 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.'
|
|
842
|
+
: ''),
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Two halves, one bundle. Worked out here, said on every journey — see `run`.
|
|
847
|
+
const mine = await bundleIdentity(found.appPath);
|
|
848
|
+
sameBundle = sameBundleAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
|
|
849
|
+
walkedFrom.set(build.role, mine);
|
|
850
|
+
if (sameBundle) sameBundleFor.set(build.id, sameBundle);
|
|
851
|
+
|
|
852
|
+
// The machine is asked AFTER all of that, and the order is the point. Working out which
|
|
853
|
+
// bundle each half walks needs nothing but the filesystem, and it is true on every
|
|
854
|
+
// machine. Asking the machine first meant that anywhere an iPhone app cannot run — any
|
|
855
|
+
// Linux box, any Windows box, a Mac without Xcode — the reference half returned before
|
|
856
|
+
// the check and reported `paired: true`, which is a claim about a comparison it had not
|
|
857
|
+
// made. Caught by CI on Linux against a green Mac suite, 2026-08-31.
|
|
681
858
|
const machine = await readMachine({ signal: ctx.signal });
|
|
682
859
|
if (!machine.ok) return notReady(machine.why);
|
|
683
860
|
|
|
684
|
-
const found = await findAppBundle(build.root, config);
|
|
685
|
-
if (!found.ok) return notReady(found.why);
|
|
686
|
-
|
|
687
861
|
const facts = await readAppBundle(found.appPath);
|
|
688
862
|
if (!facts.ok) return notReady(facts.why);
|
|
689
863
|
|
|
@@ -720,7 +894,7 @@ export const iosAdapter = defineAdapter({
|
|
|
720
894
|
build,
|
|
721
895
|
root: scratch,
|
|
722
896
|
ready: true,
|
|
723
|
-
why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}`,
|
|
897
|
+
why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}${sameBundle ? ` ${sameBundle}` : ''}`,
|
|
724
898
|
facts: {
|
|
725
899
|
device: device.device.name,
|
|
726
900
|
udid: device.device.udid,
|
|
@@ -729,10 +903,15 @@ export const iosAdapter = defineAdapter({
|
|
|
729
903
|
version: facts.version,
|
|
730
904
|
readsMeaning: probe.ok,
|
|
731
905
|
weBootedIt: device.device.weBootedIt,
|
|
906
|
+
app: found.appPath,
|
|
907
|
+
// Only ever set on the OLD build's half, because that is the half the question is
|
|
908
|
+
// about: was there really a second build here, or did both halves read one bundle.
|
|
909
|
+
...(build.role === 'reference' ? { paired: sameBundle === null } : {}),
|
|
732
910
|
},
|
|
733
911
|
dispose: async () => {
|
|
734
912
|
const kept = ready.get(build.id);
|
|
735
913
|
ready.delete(build.id);
|
|
914
|
+
sameBundleFor.delete(build.id);
|
|
736
915
|
if (kept?.device) await releaseDevice(kept.device, { signal: ctx.signal });
|
|
737
916
|
await fsp.rm(scratch, { recursive: true, force: true });
|
|
738
917
|
},
|
|
@@ -748,28 +927,53 @@ export const iosAdapter = defineAdapter({
|
|
|
748
927
|
* @returns {Promise<Observation[]>}
|
|
749
928
|
*/
|
|
750
929
|
async run(journey, build, ctx) {
|
|
930
|
+
// FIRST, IN FRONT OF EVERY OTHER ANSWER THIS FUNCTION CAN GIVE.
|
|
931
|
+
//
|
|
932
|
+
// Said on every journey rather than once at the start, which is the same rule the web
|
|
933
|
+
// adapter follows for an app read at a fixed address. A run that compared one bundle
|
|
934
|
+
// against itself finds no differences, and "no differences" is the sentence this whole
|
|
935
|
+
// tool is believed for.
|
|
936
|
+
//
|
|
937
|
+
// It has to come before the "was this build ever got ready" answer below and not after
|
|
938
|
+
// it, because a reference half can fail to prepare AND have been the same bundle as the
|
|
939
|
+
// candidate. Say only the first and somebody fixes the preparation, runs it again, and
|
|
940
|
+
// gets a clean comparison of one bundle against itself with nothing anywhere to say so.
|
|
941
|
+
const sameBundle = sameBundleFor.get(build.build.id);
|
|
942
|
+
/** @type {Observation[]} */
|
|
943
|
+
const sameBundleSaid = sameBundle
|
|
944
|
+
? [notCovered({
|
|
945
|
+
channel: 'meaning',
|
|
946
|
+
path: joinPath('screen', journey.name, 'which build this was'),
|
|
947
|
+
reason: 'not supported here',
|
|
948
|
+
says:
|
|
949
|
+
`${sameBundle} A .app 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. ` +
|
|
950
|
+
'Keep a copy of the simulator build you shipped and name it with {"reference": "path/to/TheOld.app"} under "ios" in the settings, and this becomes a real comparison.',
|
|
951
|
+
})]
|
|
952
|
+
: [];
|
|
953
|
+
|
|
751
954
|
const kept = ready.get(build.build.id);
|
|
752
955
|
if (!kept) {
|
|
753
|
-
return [notCovered({
|
|
956
|
+
return [...sameBundleSaid, notCovered({
|
|
754
957
|
channel: 'meaning',
|
|
755
958
|
path: joinPath('screen', journey.name, 'walked'),
|
|
756
959
|
reason: 'not supported here',
|
|
757
|
-
says:
|
|
960
|
+
says: `This build was never got ready, so nothing about it could be walked. ${build.why}`,
|
|
758
961
|
})];
|
|
759
962
|
}
|
|
760
963
|
if (journey.skip) {
|
|
761
|
-
return [notCovered({
|
|
964
|
+
return [...sameBundleSaid, notCovered({
|
|
762
965
|
channel: 'meaning',
|
|
763
966
|
path: joinPath('screen', journey.name, 'walked'),
|
|
764
967
|
reason: 'missing tool',
|
|
765
968
|
says: journey.skip,
|
|
766
969
|
})];
|
|
767
970
|
}
|
|
971
|
+
|
|
768
972
|
if (journey.name === 'what-the-app-declares') {
|
|
769
|
-
return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
|
|
973
|
+
return [...sameBundleSaid, ...declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? [])];
|
|
770
974
|
}
|
|
771
975
|
|
|
772
|
-
return walkObservations(journey, kept, ctx);
|
|
976
|
+
return [...sameBundleSaid, ...(await walkObservations(journey, kept, ctx))];
|
|
773
977
|
},
|
|
774
978
|
|
|
775
979
|
async teardown() {
|
|
@@ -777,6 +981,12 @@ export const iosAdapter = defineAdapter({
|
|
|
777
981
|
if (kept?.device) await releaseDevice(kept.device);
|
|
778
982
|
ready.delete(id);
|
|
779
983
|
}
|
|
984
|
+
// One run's memory of which bundle each half was walked from. It must not survive into
|
|
985
|
+
// the next run in the same process — the MCP server and the watch panel both call
|
|
986
|
+
// check() more than once — or a second run would report the first run's bundles as its
|
|
987
|
+
// own.
|
|
988
|
+
walkedFrom.clear();
|
|
989
|
+
sameBundleFor.clear();
|
|
780
990
|
},
|
|
781
991
|
});
|
|
782
992
|
|