staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1551 @@
1
+ /**
2
+ * Driving an iPhone app in the simulator, and reading what it means.
3
+ *
4
+ * This file knows about simulators and nothing about Stays Fixed. It finds a device, makes
5
+ * the device boring enough to be compared twice, installs a build into it, walks the steps
6
+ * it is given, and hands back five plain things: the meaning tree, the calls the app made,
7
+ * what it complained about, the files it wrote and a picture. `ios.js` turns those into
8
+ * observations. The split is the same one `web.js` and `web-driver.js` use, for the same
9
+ * reason: the hard problems here are all timing problems, and they are easier to fix when
10
+ * they live in one file.
11
+ *
12
+ * FIVE DECISIONS WORTH KNOWING ABOUT.
13
+ *
14
+ * 1. NO WEBDRIVERAGENT, AND THAT WAS NOT THE PLAN. The usual way to read an iPhone screen
15
+ * is Appium's WebDriverAgent and its `/source?format=json`. It is not on this machine,
16
+ * it needs its own Xcode build every time Xcode moves, and it has historically lagged a
17
+ * new Xcode by months — Xcode 26.6 here is new. So the app is asked directly instead. A
18
+ * small observer library is put beside the app in a SCRATCH COPY of the bundle and loaded
19
+ * into it at launch, exactly the way `src/freeze/` is injected into a page before the
20
+ * browser fetches a byte. From inside the process it can read the real accessibility
21
+ * tree — the same tree VoiceOver reads — with no server, no port, no signing, no Xcode
22
+ * project and no second toolchain. It is about 250 lines of Objective-C and it builds in
23
+ * under two seconds.
24
+ *
25
+ * 2. THE TREE ONLY EXISTS ONCE SOMEBODY ASKS FOR IT. iOS does not build the accessibility
26
+ * tree until an assistive client turns up; a plain walk of the views finds nothing but
27
+ * anonymous wrappers. Measured here: 39 view nodes, every one of them with no name, no
28
+ * identifier and no accessibility children. Calling `_AXSApplicationAccessibilitySetEnabled`
29
+ * and `_AXSSetAutomationEnabled` inside the app is what makes the tree appear — the same
30
+ * 39 nodes then carry names, roles and identifiers. Without that call this whole channel
31
+ * silently reads empty, which is the worst failure this tool could have, so the probe
32
+ * turns it on at load AND again before every read.
33
+ *
34
+ * 3. MEANING, NEVER COORDINATES. What is read is role, name, identifier, value and state.
35
+ * What is TAPPED is an accessibility identifier or a name, activated the way a screen
36
+ * reader activates it — `accessibilityActivate`. Nothing in this file ever touches a
37
+ * pixel position. An app whose buttons moved has not changed, and a tap that depends on
38
+ * where a button was is a test that breaks for a living.
39
+ *
40
+ * 4. THE PAYMENT IS WATCHED, NOT MADE. Every `URLSessionTask.resume` goes through the
41
+ * probe. Anything that would not come back — a POST or DELETE to a path that says charge,
42
+ * pay, refund, transfer, message, delete — is written down with its method, host, path
43
+ * and body size, and then cancelled before the socket opens. It is reported as a hole in
44
+ * the coverage, never as a pass. Proven here: `GET /price` went out, `POST /charge` did
45
+ * not.
46
+ *
47
+ * 5. NEVER THE DEVICE SOMEBODY ELSE IS USING. This file creates its own device, remembers
48
+ * that it created it, and shuts down only what it booted. A machine can easily have a
49
+ * simulator booted for something else — this one did, all through the work that produced
50
+ * this file — and shutting it down would take somebody's session with it.
51
+ */
52
+
53
+ import fsp from 'node:fs/promises';
54
+ import path from 'node:path';
55
+ import os from 'node:os';
56
+ import crypto from 'node:crypto';
57
+ import { execFile } from 'node:child_process';
58
+
59
+ /** @typedef {import('../types.js').ObservedValue} JsonValue */
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Running simctl without letting it take the whole run down with it
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * `xcrun simctl` has hung on a Mac before — not returned, not failed, just stopped — and a
67
+ * lane that waits forever on it is worse than one that cannot drive iOS at all. So every
68
+ * call is given a deadline, and when `xcrun` misses it the same command is tried again
69
+ * against CoreSimulator's own binary, which `xcrun` is only a lookup wrapper around. If both
70
+ * miss the deadline the answer is "this machine cannot be driven", in those words, and the
71
+ * run carries on without iOS instead of stopping.
72
+ *
73
+ * @typedef {object} Ran
74
+ * @property {boolean} ok
75
+ * @property {number} code -1 when it never finished.
76
+ * @property {string} stdout
77
+ * @property {string} stderr
78
+ * @property {boolean} hung True when the deadline was missed by both routes.
79
+ * @property {string} why Plain English, always filled in.
80
+ * @property {number} ms
81
+ */
82
+
83
+ /** Where CoreSimulator's own simctl lives, when `xcrun` will not answer. */
84
+ let directSimctl = /** @type {string|null} */ (null);
85
+
86
+ /**
87
+ * @param {string} file
88
+ * @param {string[]} args
89
+ * @param {{timeoutMs?: number, signal?: AbortSignal, env?: Record<string,string>}} [opts]
90
+ * @returns {Promise<{code: number, stdout: string, stderr: string, timedOut: boolean}>}
91
+ */
92
+ function runOnce(file, args, opts = {}) {
93
+ return new Promise((resolve) => {
94
+ let timedOut = false;
95
+ const child = execFile(
96
+ file,
97
+ args,
98
+ {
99
+ timeout: opts.timeoutMs ?? 60_000,
100
+ killSignal: 'SIGKILL',
101
+ maxBuffer: 64 * 1024 * 1024,
102
+ signal: opts.signal,
103
+ env: { ...process.env, ...opts.env },
104
+ },
105
+ (error, stdout, stderr) => {
106
+ const err = /** @type {any} */ (error);
107
+ if (err && (err.killed || err.signal === 'SIGKILL')) timedOut = true;
108
+ resolve({
109
+ code: err?.code === undefined ? (error ? 1 : 0) : Number(err.code),
110
+ stdout: String(stdout ?? ''),
111
+ stderr: String(stderr ?? (error ? error.message : '')),
112
+ timedOut,
113
+ });
114
+ },
115
+ );
116
+ child.on('error', () => {});
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Run one simctl command, with the hang guard.
122
+ *
123
+ * @param {string[]} args
124
+ * @param {{timeoutMs?: number, signal?: AbortSignal, env?: Record<string,string>}} [opts]
125
+ * @returns {Promise<Ran>}
126
+ */
127
+ export async function simctl(args, opts = {}) {
128
+ const started = Date.now();
129
+ const deadline = opts.timeoutMs ?? 60_000;
130
+ let ran = await runOnce('xcrun', ['simctl', ...args], { ...opts, timeoutMs: deadline });
131
+
132
+ if (ran.timedOut) {
133
+ if (!directSimctl) {
134
+ const found = await runOnce('xcode-select', ['-p'], { timeoutMs: 10_000 });
135
+ const developer = found.stdout.trim();
136
+ if (developer) directSimctl = path.join(developer, 'usr', 'bin', 'simctl');
137
+ }
138
+ if (directSimctl) {
139
+ ran = await runOnce(directSimctl, args, { ...opts, timeoutMs: deadline });
140
+ }
141
+ }
142
+
143
+ const ms = Date.now() - started;
144
+ if (ran.timedOut) {
145
+ return {
146
+ ok: false,
147
+ code: -1,
148
+ stdout: ran.stdout,
149
+ stderr: ran.stderr,
150
+ hung: true,
151
+ ms,
152
+ why: `The simulator tool stopped answering while it was asked to ${args[0]}. It was given ${Math.round(deadline / 1000)} seconds through xcrun and then the same again through CoreSimulator's own copy, and neither came back.`,
153
+ };
154
+ }
155
+ return {
156
+ ok: ran.code === 0,
157
+ code: ran.code,
158
+ stdout: ran.stdout,
159
+ stderr: ran.stderr,
160
+ hung: false,
161
+ ms,
162
+ why: ran.code === 0 ? `simctl ${args[0]} finished` : `simctl ${args[0]} failed: ${firstLine(ran.stderr) || `it exited ${ran.code}`}`,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * @param {string} text
168
+ * @returns {string}
169
+ */
170
+ function firstLine(text) {
171
+ return String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? '';
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // What this machine has
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * What is installed, and what a person would have to do about it.
180
+ *
181
+ * @typedef {object} MachineState
182
+ * @property {boolean} ok An app can be driven here right now.
183
+ * @property {boolean} isMac Simulators exist on macOS and nowhere else.
184
+ * @property {string|null} xcode 'Xcode 26.6' or null.
185
+ * @property {string|null} developerDir
186
+ * @property {string|null} sdk The iPhone simulator SDK version, e.g. '26.5'.
187
+ * @property {Runtime[]} runtimes
188
+ * @property {SimDevice[]} devices
189
+ * @property {boolean} simctlAnswers
190
+ * @property {boolean} clangWorks The probe cannot be built without it.
191
+ * @property {string} why Plain English, always filled in.
192
+ * @property {string[]} notes
193
+ */
194
+
195
+ /**
196
+ * @typedef {object} Runtime
197
+ * @property {string} id 'com.apple.CoreSimulator.SimRuntime.iOS-26-5'
198
+ * @property {string} name 'iOS 26.5'
199
+ * @property {string} version '26.5'
200
+ */
201
+
202
+ /**
203
+ * @typedef {object} SimDevice
204
+ * @property {string} udid
205
+ * @property {string} name
206
+ * @property {string} runtimeId
207
+ * @property {string} runtimeName
208
+ * @property {string} state 'Booted' | 'Shutdown' | ...
209
+ * @property {boolean} available
210
+ */
211
+
212
+ /**
213
+ * Ask the machine what it has. Everything here is read, nothing is started.
214
+ *
215
+ * @param {{signal?: AbortSignal}} [opts]
216
+ * @returns {Promise<MachineState>}
217
+ */
218
+ export async function readMachine(opts = {}) {
219
+ /** @type {string[]} */
220
+ const notes = [];
221
+ const isMac = process.platform === 'darwin';
222
+ if (!isMac) {
223
+ return {
224
+ ok: false, isMac: false, xcode: null, developerDir: null, sdk: null,
225
+ runtimes: [], devices: [], simctlAnswers: false, clangWorks: false,
226
+ why: 'iPhone apps can only be run on a Mac. There is no simulator on this operating system, and there is no honest way to fake one.',
227
+ notes,
228
+ };
229
+ }
230
+
231
+ const version = await runOnce('xcodebuild', ['-version'], { timeoutMs: 20_000, signal: opts.signal });
232
+ const xcode = version.code === 0 ? firstLine(version.stdout) : null;
233
+ const dev = await runOnce('xcode-select', ['-p'], { timeoutMs: 10_000, signal: opts.signal });
234
+ const developerDir = dev.code === 0 ? dev.stdout.trim() : null;
235
+ const sdkVersion = await runOnce('xcrun', ['-sdk', 'iphonesimulator', '--show-sdk-version'], { timeoutMs: 20_000, signal: opts.signal });
236
+ const sdk = sdkVersion.code === 0 ? sdkVersion.stdout.trim() : null;
237
+
238
+ const listed = await simctl(['list', '-j', 'devices', 'available'], { timeoutMs: 45_000, signal: opts.signal });
239
+ const runtimeList = await simctl(['list', '-j', 'runtimes'], { timeoutMs: 45_000, signal: opts.signal });
240
+ if (listed.hung || runtimeList.hung) notes.push(listed.why || runtimeList.why);
241
+
242
+ /** @type {Runtime[]} */
243
+ const runtimes = [];
244
+ try {
245
+ const parsed = JSON.parse(runtimeList.stdout || '{}');
246
+ for (const r of parsed.runtimes ?? []) {
247
+ if (!r.isAvailable) continue;
248
+ if (!String(r.identifier ?? '').includes('SimRuntime.iOS')) continue;
249
+ runtimes.push({ id: String(r.identifier), name: String(r.name ?? r.identifier), version: String(r.version ?? '') });
250
+ }
251
+ } catch {
252
+ // A machine with no runtimes answers with something that is not JSON. That is a 'no',
253
+ // not a crash, and the caller finds out from `ok`.
254
+ }
255
+
256
+ /** @type {SimDevice[]} */
257
+ const devices = [];
258
+ try {
259
+ const parsed = JSON.parse(listed.stdout || '{}');
260
+ for (const [runtimeId, list] of Object.entries(parsed.devices ?? {})) {
261
+ if (!runtimeId.includes('SimRuntime.iOS')) continue;
262
+ const runtime = runtimes.find((r) => r.id === runtimeId);
263
+ for (const d of /** @type {any[]} */ (list)) {
264
+ devices.push({
265
+ udid: String(d.udid),
266
+ name: String(d.name),
267
+ runtimeId,
268
+ runtimeName: runtime?.name ?? runtimeId.split('.').pop() ?? runtimeId,
269
+ state: String(d.state ?? 'Unknown'),
270
+ available: d.isAvailable !== false,
271
+ });
272
+ }
273
+ }
274
+ } catch {
275
+ // Same as above.
276
+ }
277
+
278
+ const clang = await runOnce('xcrun', ['-sdk', 'iphonesimulator', '--find', 'clang'], { timeoutMs: 20_000, signal: opts.signal });
279
+ const clangWorks = clang.code === 0 && clang.stdout.trim().length > 0;
280
+ const simctlAnswers = !listed.hung && !runtimeList.hung;
281
+
282
+ const ok = Boolean(xcode) && runtimes.length > 0 && simctlAnswers && clangWorks;
283
+ const why = !xcode
284
+ ? 'Xcode is not installed, so there is no simulator to run anything in.'
285
+ : !simctlAnswers
286
+ ? 'Xcode is installed but the simulator tool did not answer, through xcrun or directly. Nothing can be driven until it does.'
287
+ : runtimes.length === 0
288
+ ? `${xcode} is installed but it has no iOS runtime, so there is no version of iOS to run an app on.`
289
+ : !clangWorks
290
+ ? `${xcode} is installed with ${runtimes.length} iOS runtime${runtimes.length === 1 ? '' : 's'}, but the compiler for the simulator could not be found, and the small observer that reads the screen has to be built with it.`
291
+ : `${xcode}, ${runtimes.length} iOS runtime${runtimes.length === 1 ? '' : 's'} (${runtimes.map((r) => r.version).join(', ')}), ${devices.length} device${devices.length === 1 ? '' : 's'} already made.`;
292
+
293
+ const booted = devices.filter((d) => d.state === 'Booted');
294
+ if (booted.length > 0) {
295
+ notes.push(`${booted.length} simulator${booted.length === 1 ? ' is' : 's are'} already running (${booted.map((d) => d.name).join(', ')}). They were not started here and they will not be touched — a device of our own is made instead.`);
296
+ }
297
+
298
+ return { ok, isMac, xcode, developerDir, sdk, runtimes, devices, simctlAnswers, clangWorks, why, notes };
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // A device of our own
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * A device this file is allowed to touch.
307
+ *
308
+ * @typedef {object} OurDevice
309
+ * @property {string} udid
310
+ * @property {string} name
311
+ * @property {string} runtimeName
312
+ * @property {boolean} weMadeIt True when this run created it, so this run may delete it.
313
+ * @property {boolean} weBootedIt True when this run booted it, so this run may shut it down.
314
+ * @property {string} why
315
+ */
316
+
317
+ /**
318
+ * Find or make the device this run will use, and never adopt one that is already booted.
319
+ *
320
+ * A simulator somebody else started is somebody else's: it may hold a session, a paired
321
+ * host, a half-finished review. So a device is looked for BY NAME among the shut-down ones,
322
+ * made if it is not there, and its state before we touched it is remembered so teardown can
323
+ * put it back exactly.
324
+ *
325
+ * @param {object} opts
326
+ * @param {string} [opts.name] Defaults to a name nothing else uses.
327
+ * @param {string} [opts.deviceType] A simctl device type id, or a plain name like 'iPhone 16'.
328
+ * @param {string} [opts.runtime] A runtime id, or a version like '26.5'. Newest by default.
329
+ * @param {AbortSignal} [opts.signal]
330
+ * @returns {Promise<{ok: boolean, device: OurDevice|null, why: string}>}
331
+ */
332
+ export async function ensureDevice(opts = {}) {
333
+ const wanted = opts.name ?? 'staysfixed-ios';
334
+ const machine = await readMachine({ signal: opts.signal });
335
+ if (!machine.ok) return { ok: false, device: null, why: machine.why };
336
+
337
+ const existing = machine.devices.find((d) => d.name === wanted && d.available);
338
+ if (existing) {
339
+ if (existing.state === 'Booted') {
340
+ return {
341
+ ok: true,
342
+ device: { udid: existing.udid, name: existing.name, runtimeName: existing.runtimeName, weMadeIt: false, weBootedIt: false, why: 'This device was already running when we arrived, so it will be used as it is and left running afterwards.' },
343
+ why: `Using the device called ${wanted}, which was already booted.`,
344
+ };
345
+ }
346
+ const booted = await bootDevice(existing.udid, { signal: opts.signal });
347
+ if (!booted.ok) return { ok: false, device: null, why: booted.why };
348
+ return {
349
+ ok: true,
350
+ device: { udid: existing.udid, name: existing.name, runtimeName: existing.runtimeName, weMadeIt: false, weBootedIt: true, why: booted.why },
351
+ why: `Booted the device called ${wanted}, which was already made.`,
352
+ };
353
+ }
354
+
355
+ const runtime = pickRuntime(machine.runtimes, opts.runtime);
356
+ if (!runtime) {
357
+ return { ok: false, device: null, why: `No iOS runtime here matches "${opts.runtime}". This machine has ${machine.runtimes.map((r) => r.version).join(', ') || 'none'}.` };
358
+ }
359
+ const typeId = await pickDeviceType(opts.deviceType, { signal: opts.signal });
360
+ if (!typeId.ok) return { ok: false, device: null, why: typeId.why };
361
+
362
+ const made = await simctl(['create', wanted, typeId.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
363
+ if (!made.ok) return { ok: false, device: null, why: `A simulator called ${wanted} could not be made: ${firstLine(made.stderr) || made.why}` };
364
+ const udid = made.stdout.trim();
365
+
366
+ const booted = await bootDevice(udid, { signal: opts.signal });
367
+ if (!booted.ok) return { ok: false, device: null, why: booted.why };
368
+ return {
369
+ ok: true,
370
+ device: { udid, name: wanted, runtimeName: runtime.name, weMadeIt: true, weBootedIt: true, why: booted.why },
371
+ why: `Made a new ${typeId.label} on ${runtime.name} called ${wanted}, and booted it.`,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * @param {Runtime[]} runtimes
377
+ * @param {string} [wanted]
378
+ * @returns {Runtime|null}
379
+ */
380
+ function pickRuntime(runtimes, wanted) {
381
+ if (runtimes.length === 0) return null;
382
+ if (wanted) {
383
+ const exact = runtimes.find((r) => r.id === wanted || r.version === wanted || r.name === wanted);
384
+ if (exact) return exact;
385
+ const prefix = runtimes.find((r) => r.version.startsWith(wanted));
386
+ if (prefix) return prefix;
387
+ return null;
388
+ }
389
+ return [...runtimes].sort((a, b) => compareVersions(b.version, a.version))[0];
390
+ }
391
+
392
+ /**
393
+ * @param {string} a
394
+ * @param {string} b
395
+ * @returns {number}
396
+ */
397
+ function compareVersions(a, b) {
398
+ const left = String(a).split('.').map((n) => Number(n) || 0);
399
+ const right = String(b).split('.').map((n) => Number(n) || 0);
400
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
401
+ const d = (left[i] ?? 0) - (right[i] ?? 0);
402
+ if (d !== 0) return d;
403
+ }
404
+ return 0;
405
+ }
406
+
407
+ /**
408
+ * @param {string|undefined} wanted
409
+ * @param {{signal?: AbortSignal}} opts
410
+ * @returns {Promise<{ok: boolean, id: string, label: string, why: string}>}
411
+ */
412
+ async function pickDeviceType(wanted, opts) {
413
+ const listed = await simctl(['list', '-j', 'devicetypes'], { timeoutMs: 45_000, signal: opts.signal });
414
+ /** @type {{identifier: string, name: string}[]} */
415
+ let types = [];
416
+ try {
417
+ types = (JSON.parse(listed.stdout || '{}').devicetypes ?? []).map((/** @type {any} */ t) => ({
418
+ identifier: String(t.identifier), name: String(t.name),
419
+ }));
420
+ } catch {
421
+ return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.' };
422
+ }
423
+ if (wanted) {
424
+ const found = types.find((t) => t.identifier === wanted || t.name === wanted);
425
+ if (found) return { ok: true, id: found.identifier, label: found.name, why: '' };
426
+ return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".` };
427
+ }
428
+ const phones = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
429
+ const pick = phones[phones.length - 1] ?? types.find((t) => t.identifier.includes('iPhone'));
430
+ if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.' };
431
+ return { ok: true, id: pick.identifier, label: pick.name, why: '' };
432
+ }
433
+
434
+ /**
435
+ * @param {string} udid
436
+ * @param {{signal?: AbortSignal, timeoutMs?: number}} [opts]
437
+ * @returns {Promise<{ok: boolean, why: string, ms: number}>}
438
+ */
439
+ export async function bootDevice(udid, opts = {}) {
440
+ const started = Date.now();
441
+ const boot = await simctl(['boot', udid], { timeoutMs: opts.timeoutMs ?? 180_000, signal: opts.signal });
442
+ if (!boot.ok && !/already booted/i.test(boot.stderr)) {
443
+ return { ok: false, why: `That simulator would not start: ${firstLine(boot.stderr) || boot.why}`, ms: Date.now() - started };
444
+ }
445
+ await simctl(['bootstatus', udid], { timeoutMs: opts.timeoutMs ?? 180_000, signal: opts.signal });
446
+ const ms = Date.now() - started;
447
+ return { ok: true, why: `The simulator was ready after ${Math.round(ms / 1000)} seconds.`, ms };
448
+ }
449
+
450
+ /**
451
+ * Put the device back the way we found it. Shuts down only what this run booted.
452
+ *
453
+ * @param {OurDevice} device
454
+ * @param {{delete?: boolean, signal?: AbortSignal}} [opts]
455
+ * @returns {Promise<string>} plain English, what was and was not done
456
+ */
457
+ export async function releaseDevice(device, opts = {}) {
458
+ if (!device.weBootedIt) return `The simulator "${device.name}" was running before this started, so it was left running.`;
459
+ await simctl(['shutdown', device.udid], { timeoutMs: 90_000, signal: opts.signal });
460
+ if (opts.delete && device.weMadeIt) {
461
+ await simctl(['delete', device.udid], { timeoutMs: 90_000, signal: opts.signal });
462
+ return `The simulator "${device.name}" was made here, so it was shut down and deleted.`;
463
+ }
464
+ return `The simulator "${device.name}" was booted here, so it was shut down. It was not deleted, because booting a fresh one costs about a minute and the next run can reuse this.`;
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Making the device boring
469
+ // ---------------------------------------------------------------------------
470
+
471
+ /**
472
+ * Everything about a phone that changes on its own, pinned.
473
+ *
474
+ * A phone is a clock with a radio in it. The clock in the status bar, the signal bars, the
475
+ * battery percentage and the light/dark setting all move without anybody touching the app,
476
+ * and every one of them lands in a picture. These are pinned to fixed values so that when
477
+ * two runs disagree, the disagreement is about the app.
478
+ *
479
+ * What could NOT be pinned is said out loud in the return value rather than left to be
480
+ * discovered later, because a determinism setting that quietly did nothing is exactly the
481
+ * kind of thing that makes a green run mean less than it looks.
482
+ *
483
+ * @param {string} udid
484
+ * @param {{appearance?: 'light'|'dark', signal?: AbortSignal}} [opts]
485
+ * @returns {Promise<{pinned: string[], couldNot: string[]}>}
486
+ */
487
+ export async function steadyTheDevice(udid, opts = {}) {
488
+ /** @type {string[]} */
489
+ const pinned = [];
490
+ /** @type {string[]} */
491
+ const couldNot = [];
492
+
493
+ // '9:41' and not an ISO timestamp. Measured on iOS 26.5: the documented ISO form is
494
+ // rejected outright ('Invalid, non-ISO date/time string', errno 22) while the short clock
495
+ // form is accepted. Passing the documented one and not checking would have left the clock
496
+ // live in every screenshot.
497
+ const bar = await simctl([
498
+ 'status_bar', udid, 'override',
499
+ '--time', '9:41',
500
+ '--dataNetwork', 'wifi', '--wifiMode', 'active', '--wifiBars', '3',
501
+ '--cellularMode', 'active', '--cellularBars', '4',
502
+ '--batteryState', 'charged', '--batteryLevel', '100',
503
+ ], { timeoutMs: 30_000, signal: opts.signal });
504
+ if (bar.ok) pinned.push('the clock, the signal bars and the battery in the status bar');
505
+ else couldNot.push(`the status bar could not be pinned (${firstLine(bar.stderr) || bar.why}), so the clock in every picture will differ between runs`);
506
+
507
+ const look = await simctl(['ui', udid, 'appearance', opts.appearance ?? 'light'], { timeoutMs: 30_000, signal: opts.signal });
508
+ if (look.ok) pinned.push(`the ${opts.appearance ?? 'light'} appearance`);
509
+ else couldNot.push('light or dark mode could not be set, so a system-wide change would show up as a difference in the app');
510
+
511
+ const motion = await simctl(['spawn', udid, 'defaults', 'write', 'com.apple.Accessibility', 'ReduceMotionEnabled', '-bool', 'true'], { timeoutMs: 30_000, signal: opts.signal });
512
+ if (motion.ok) pinned.push('reduced motion, so animations do not decide what a picture catches');
513
+ else couldNot.push('reduced motion could not be switched on, so a picture may catch an animation halfway');
514
+
515
+ couldNot.push('the app\'s own clock and its own randomness are not touched from out here — they are the product\'s own wobble, and they are measured by running the same build twice rather than suppressed');
516
+
517
+ return { pinned, couldNot };
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // The observer that goes inside the app
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /**
525
+ * The whole probe, in Objective-C.
526
+ *
527
+ * It lives here as text rather than as a file on disk for the same reason the browser lane
528
+ * keeps its page scripts inline: the thing that reads the screen and the thing that
529
+ * interprets what it read have to move together, and a separate file is a separate version.
530
+ *
531
+ * Deliberately no backslashes anywhere in this source: it is carried through a JavaScript
532
+ * template string, and an escape here would arrive at the compiler as something else.
533
+ */
534
+ export const PROBE_SOURCE = `#import <UIKit/UIKit.h>
535
+ #import <Foundation/Foundation.h>
536
+ #import <objc/runtime.h>
537
+ #import <dlfcn.h>
538
+
539
+ static NSString *gDir = nil;
540
+ static NSMutableArray *gCalls = nil;
541
+ static NSMutableArray *gRefused = nil;
542
+
543
+ static NSString *roleOf(UIAccessibilityTraits t) {
544
+ if (t & UIAccessibilityTraitButton) return @"button";
545
+ if (t & UIAccessibilityTraitLink) return @"link";
546
+ if (t & UIAccessibilityTraitSearchField) return @"search field";
547
+ if (t & UIAccessibilityTraitKeyboardKey) return @"key";
548
+ if (t & UIAccessibilityTraitHeader) return @"heading";
549
+ if (t & UIAccessibilityTraitAdjustable) return @"slider";
550
+ if (t & UIAccessibilityTraitImage) return @"image";
551
+ if (t & UIAccessibilityTraitStaticText) return @"text";
552
+ if (t & UIAccessibilityTraitTabBar) return @"tab bar";
553
+ return @"element";
554
+ }
555
+
556
+ static NSArray *statesOf(id node, UIAccessibilityTraits t) {
557
+ NSMutableArray *s = [NSMutableArray array];
558
+ if (t & UIAccessibilityTraitNotEnabled) [s addObject:@"disabled"];
559
+ if (t & UIAccessibilityTraitSelected) [s addObject:@"selected"];
560
+ if (t & UIAccessibilityTraitUpdatesFrequently) [s addObject:@"updates often"];
561
+ if ([node isKindOfClass:[UIControl class]] && ![(UIControl *)node isEnabled]) [s addObject:@"disabled"];
562
+ return s;
563
+ }
564
+
565
+ static NSDictionary *describe(id node, int depth);
566
+
567
+ static NSArray *childrenOf(id node, int depth) {
568
+ NSMutableArray *kids = [NSMutableArray array];
569
+ NSInteger n = 0;
570
+ if ([node respondsToSelector:@selector(accessibilityElementCount)]) n = [node accessibilityElementCount];
571
+ if (n != NSNotFound && n > 0 && n < 5000) {
572
+ for (NSInteger i = 0; i < n; i++) {
573
+ NSDictionary *d = describe([node accessibilityElementAtIndex:i], depth + 1);
574
+ if (d) [kids addObject:d];
575
+ }
576
+ return kids;
577
+ }
578
+ if ([node isKindOfClass:[UIView class]]) {
579
+ for (UIView *sub in [(UIView *)node subviews]) {
580
+ if (sub.isHidden || sub.alpha < 0.01) continue;
581
+ NSDictionary *d = describe(sub, depth + 1);
582
+ if (d) [kids addObject:d];
583
+ }
584
+ }
585
+ return kids;
586
+ }
587
+
588
+ static NSDictionary *describe(id node, int depth) {
589
+ if (!node || depth > 60) return nil;
590
+ if ([node respondsToSelector:@selector(accessibilityElementsHidden)] && [node accessibilityElementsHidden]) return nil;
591
+ NSString *label = [node respondsToSelector:@selector(accessibilityLabel)] ? [node accessibilityLabel] : nil;
592
+ NSString *ident = [node respondsToSelector:@selector(accessibilityIdentifier)] ? [node accessibilityIdentifier] : nil;
593
+ NSString *value = [node respondsToSelector:@selector(accessibilityValue)] ? [node accessibilityValue] : nil;
594
+ UIAccessibilityTraits tr = [node respondsToSelector:@selector(accessibilityTraits)] ? [node accessibilityTraits] : 0;
595
+ BOOL isEl = [node respondsToSelector:@selector(isAccessibilityElement)] ? [node isAccessibilityElement] : NO;
596
+
597
+ NSArray *kids = childrenOf(node, depth);
598
+ BOOL named = isEl || label.length || ident.length || value.length;
599
+ if (!named) {
600
+ if (kids.count == 1) return kids[0];
601
+ if (kids.count == 0) return nil;
602
+ }
603
+ NSMutableDictionary *d = [NSMutableDictionary dictionary];
604
+ d[@"role"] = named ? roleOf(tr) : @"group";
605
+ if (label.length) d[@"name"] = label;
606
+ if (ident.length) d[@"id"] = ident;
607
+ if (value.length) d[@"value"] = value;
608
+ NSArray *st = statesOf(node, tr);
609
+ if (st.count) d[@"states"] = st;
610
+ if (kids.count) d[@"children"] = kids;
611
+ return d;
612
+ }
613
+
614
+ static void turnAccessibilityOn(void) {
615
+ void *h = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW);
616
+ if (!h) return;
617
+ void (*setApp)(BOOL) = dlsym(h, "_AXSApplicationAccessibilitySetEnabled");
618
+ void (*setAuto)(BOOL) = dlsym(h, "_AXSSetAutomationEnabled");
619
+ if (setApp) setApp(YES);
620
+ if (setAuto) setAuto(YES);
621
+ }
622
+
623
+ static NSArray *snapshotTree(void) {
624
+ turnAccessibilityOn();
625
+ NSMutableArray *out = [NSMutableArray array];
626
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
627
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
628
+ for (UIWindow *w in ((UIWindowScene *)scene).windows) {
629
+ if (w.isHidden) continue;
630
+ NSDictionary *d = describe(w, 0);
631
+ if (d) [out addObject:d];
632
+ }
633
+ }
634
+ return out;
635
+ }
636
+
637
+ static id findElement(NSString *wanted) {
638
+ __block id found = nil;
639
+ __block void (^scan)(id, int);
640
+ scan = ^(id node, int depth) {
641
+ if (found || !node || depth > 60) return;
642
+ NSString *ident = [node respondsToSelector:@selector(accessibilityIdentifier)] ? [node accessibilityIdentifier] : nil;
643
+ NSString *label = [node respondsToSelector:@selector(accessibilityLabel)] ? [node accessibilityLabel] : nil;
644
+ if ([ident isEqualToString:wanted] || [label isEqualToString:wanted]) { found = node; return; }
645
+ NSInteger n = [node respondsToSelector:@selector(accessibilityElementCount)] ? [node accessibilityElementCount] : 0;
646
+ if (n != NSNotFound && n > 0 && n < 5000) {
647
+ for (NSInteger i = 0; i < n && !found; i++) scan([node accessibilityElementAtIndex:i], depth + 1);
648
+ return;
649
+ }
650
+ if ([node isKindOfClass:[UIView class]])
651
+ for (UIView *sub in [(UIView *)node subviews]) { if (found) break; scan(sub, depth + 1); }
652
+ };
653
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
654
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
655
+ for (UIWindow *w in ((UIWindowScene *)scene).windows) scan(w, 0);
656
+ }
657
+ return found;
658
+ }
659
+
660
+ static BOOL wouldNotComeBack(NSURLRequest *r) {
661
+ NSString *m = (r.HTTPMethod ?: @"GET").uppercaseString;
662
+ NSString *u = (r.URL.absoluteString ?: @"").lowercaseString;
663
+ if ([m isEqualToString:@"GET"] || [m isEqualToString:@"HEAD"] || [m isEqualToString:@"OPTIONS"]) return NO;
664
+ NSArray *words = @[@"charge", @"payment", @"pay", @"purchase", @"checkout", @"subscribe",
665
+ @"refund", @"payout", @"transfer", @"withdraw", @"sms", @"email",
666
+ @"message", @"invite", @"delete", @"destroy", @"wipe", @"erase"];
667
+ for (NSString *word in words) if ([u containsString:word]) return YES;
668
+ return [m isEqualToString:@"DELETE"];
669
+ }
670
+
671
+ static IMP gOrigResume = NULL;
672
+
673
+ static void sf_resume(id self, SEL _cmd) {
674
+ NSURLRequest *r = [(NSURLSessionTask *)self originalRequest];
675
+ if (r && gCalls) {
676
+ NSMutableDictionary *e = [NSMutableDictionary dictionary];
677
+ e[@"method"] = (r.HTTPMethod ?: @"GET").uppercaseString;
678
+ e[@"host"] = r.URL.host ?: @"";
679
+ e[@"path"] = r.URL.path ?: @"";
680
+ e[@"query"] = r.URL.query ?: @"";
681
+ e[@"bodyBytes"] = @(r.HTTPBody.length);
682
+ if (wouldNotComeBack(r)) {
683
+ [gRefused addObject:e];
684
+ [(NSURLSessionTask *)self cancel];
685
+ return;
686
+ }
687
+ [gCalls addObject:e];
688
+ }
689
+ ((void (*)(id, SEL))gOrigResume)(self, _cmd);
690
+ }
691
+
692
+ static void writeReply(NSString *seq, id payload) {
693
+ NSData *d = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:NULL];
694
+ if (!d) d = [NSJSONSerialization dataWithJSONObject:@{@"error": @"the reply could not be written down"} options:0 error:NULL];
695
+ NSString *tmp = [gDir stringByAppendingPathComponent:[NSString stringWithFormat:@"reply-%@.part", seq]];
696
+ NSString *fin = [gDir stringByAppendingPathComponent:[NSString stringWithFormat:@"reply-%@.json", seq]];
697
+ [d writeToFile:tmp atomically:YES];
698
+ [[NSFileManager defaultManager] moveItemAtPath:tmp toPath:fin error:NULL];
699
+ }
700
+
701
+ static void runCommand(NSDictionary *cmd, NSString *seq) {
702
+ NSString *act = cmd[@"act"] ?: @"tree";
703
+ NSMutableDictionary *res = [NSMutableDictionary dictionary];
704
+ res[@"act"] = act;
705
+ if ([act isEqualToString:@"tree"]) {
706
+ res[@"tree"] = snapshotTree();
707
+ } else if ([act isEqualToString:@"tap"] || [act isEqualToString:@"type"]) {
708
+ id el = findElement(cmd[@"target"]);
709
+ if (!el) {
710
+ res[@"ok"] = @NO;
711
+ res[@"why"] = [NSString stringWithFormat:@"nothing on this screen is called '%@'", cmd[@"target"] ?: @""];
712
+ } else if ([act isEqualToString:@"type"]) {
713
+ BOOL ok = NO;
714
+ if ([el isKindOfClass:[UITextField class]]) { [(UITextField *)el setText:cmd[@"text"]]; ok = YES; }
715
+ else if ([el isKindOfClass:[UITextView class]]) { [(UITextView *)el setText:cmd[@"text"]]; ok = YES; }
716
+ res[@"ok"] = @(ok);
717
+ if (!ok) res[@"why"] = @"that control does not take typing";
718
+ } else {
719
+ BOOL ok = [el respondsToSelector:@selector(accessibilityActivate)] ? [el accessibilityActivate] : NO;
720
+ if (!ok && [el isKindOfClass:[UIControl class]]) {
721
+ [(UIControl *)el sendActionsForControlEvents:UIControlEventTouchUpInside];
722
+ ok = YES;
723
+ }
724
+ res[@"ok"] = @(ok);
725
+ if (!ok) res[@"why"] = @"the control was found but would not activate";
726
+ }
727
+ } else if ([act isEqualToString:@"calls"]) {
728
+ res[@"calls"] = gCalls ?: @[];
729
+ res[@"refused"] = gRefused ?: @[];
730
+ } else if ([act isEqualToString:@"reset"]) {
731
+ [gCalls removeAllObjects];
732
+ [gRefused removeAllObjects];
733
+ res[@"ok"] = @YES;
734
+ } else if ([act isEqualToString:@"ping"]) {
735
+ res[@"ok"] = @YES;
736
+ res[@"bundle"] = [[NSBundle mainBundle] bundleIdentifier] ?: @"";
737
+ res[@"version"] = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"";
738
+ res[@"home"] = NSHomeDirectory() ?: @"";
739
+ } else {
740
+ res[@"ok"] = @NO;
741
+ res[@"why"] = [NSString stringWithFormat:@"the probe was asked to '%@' and does not know how", act];
742
+ }
743
+ writeReply(seq, res);
744
+ }
745
+
746
+ static void loop(void) {
747
+ NSFileManager *fm = [NSFileManager defaultManager];
748
+ while (1) @autoreleasepool {
749
+ NSArray *files = [[fm contentsOfDirectoryAtPath:gDir error:NULL] sortedArrayUsingSelector:@selector(compare:)];
750
+ for (NSString *f in files) {
751
+ if (![f hasPrefix:@"cmd-"] || ![f hasSuffix:@".json"]) continue;
752
+ NSString *seq = [[f stringByDeletingPathExtension] substringFromIndex:4];
753
+ NSString *full = [gDir stringByAppendingPathComponent:f];
754
+ NSData *d = [NSData dataWithContentsOfFile:full];
755
+ [fm removeItemAtPath:full error:NULL];
756
+ if (!d) continue;
757
+ NSDictionary *cmd = [NSJSONSerialization JSONObjectWithData:d options:0 error:NULL];
758
+ if ([cmd isKindOfClass:[NSDictionary class]])
759
+ dispatch_sync(dispatch_get_main_queue(), ^{ runCommand(cmd, seq); });
760
+ }
761
+ usleep(30000);
762
+ }
763
+ }
764
+
765
+ __attribute__((constructor))
766
+ static void sf_start(void) {
767
+ @autoreleasepool {
768
+ const char *dir = getenv("STAYSFIXED_DIR");
769
+ if (!dir) return;
770
+ gDir = [NSString stringWithUTF8String:dir];
771
+ gCalls = [NSMutableArray array];
772
+ gRefused = [NSMutableArray array];
773
+ [[NSFileManager defaultManager] createDirectoryAtPath:gDir withIntermediateDirectories:YES attributes:nil error:NULL];
774
+ turnAccessibilityOn();
775
+ Class cls = NSClassFromString(@"__NSCFURLSessionTask") ?: NSClassFromString(@"NSURLSessionTask");
776
+ Method m = class_getInstanceMethod(cls, @selector(resume));
777
+ if (m) gOrigResume = method_setImplementation(m, (IMP)sf_resume);
778
+ [NSThread detachNewThreadWithBlock:^{ loop(); }];
779
+ }
780
+ }
781
+ `;
782
+
783
+ /** The name the probe is given inside the app bundle. */
784
+ export const PROBE_NAME = 'libstaysfixed-probe.dylib';
785
+
786
+ /**
787
+ * Build the probe, once, and keep it. Measured at about 1.5 seconds cold; after that the
788
+ * cached copy is used, because the source is hashed and the hash is the file name.
789
+ *
790
+ * @param {object} opts
791
+ * @param {string} opts.scratchDir
792
+ * @param {string} [opts.target] Defaults to the oldest iOS the probe is known to work on.
793
+ * @param {AbortSignal} [opts.signal]
794
+ * @returns {Promise<{ok: boolean, dylib: string, why: string, ms: number, cached: boolean}>}
795
+ */
796
+ export async function buildProbe(opts) {
797
+ const started = Date.now();
798
+ const target = opts.target ?? 'arm64-apple-ios15.0-simulator';
799
+ const stamp = crypto.createHash('sha256').update(`${PROBE_SOURCE}|${target}`).digest('hex').slice(0, 16);
800
+ const home = path.join(opts.scratchDir, 'ios-probe');
801
+ const dylib = path.join(home, `probe-${stamp}.dylib`);
802
+ await fsp.mkdir(home, { recursive: true });
803
+ try {
804
+ await fsp.access(dylib);
805
+ return { ok: true, dylib, why: 'The observer was already built for this machine and was reused.', ms: Date.now() - started, cached: true };
806
+ } catch {
807
+ // Not built yet. Fall through and build it.
808
+ }
809
+
810
+ const source = path.join(home, `probe-${stamp}.m`);
811
+ await fsp.writeFile(source, PROBE_SOURCE, 'utf8');
812
+ const built = await runOnce('xcrun', [
813
+ '-sdk', 'iphonesimulator', 'clang',
814
+ '-dynamiclib', '-fobjc-arc', '-O1',
815
+ '-Wno-arc-retain-cycles',
816
+ '-target', target,
817
+ '-framework', 'UIKit', '-framework', 'Foundation', '-framework', 'CoreGraphics',
818
+ '-o', dylib, source,
819
+ ], { timeoutMs: 120_000, signal: opts.signal });
820
+
821
+ if (built.code !== 0) {
822
+ return {
823
+ ok: false,
824
+ dylib: '',
825
+ why: `The small observer that reads the screen would not compile: ${firstLine(built.stderr) || `clang exited ${built.code}`}`,
826
+ ms: Date.now() - started,
827
+ cached: false,
828
+ };
829
+ }
830
+ return { ok: true, dylib, why: 'The observer was built.', ms: Date.now() - started, cached: false };
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Opening one build
835
+ // ---------------------------------------------------------------------------
836
+
837
+ /**
838
+ * One node of the accessibility tree, as the probe reports it.
839
+ *
840
+ * @typedef {object} MeaningNode
841
+ * @property {string} role
842
+ * @property {string} [name]
843
+ * @property {string} [id]
844
+ * @property {string} [value]
845
+ * @property {string[]} [states]
846
+ * @property {MeaningNode[]} [children]
847
+ */
848
+
849
+ /**
850
+ * One call the app tried to make.
851
+ *
852
+ * @typedef {object} Call
853
+ * @property {string} method
854
+ * @property {string} host
855
+ * @property {string} path
856
+ * @property {string} query
857
+ * @property {number} bodyBytes
858
+ */
859
+
860
+ /**
861
+ * A running app, with the probe inside it.
862
+ *
863
+ * @typedef {object} OpenApp
864
+ * @property {string} udid
865
+ * @property {string} bundleId
866
+ * @property {string} appPath The scratch copy that was installed, never the original.
867
+ * @property {string} container The app's data container, on the Mac's own disk.
868
+ * @property {number} launchedAt
869
+ * @property {boolean} probeAnswered False means the screen cannot be read, only pictured.
870
+ * @property {string} why
871
+ * @property {(act: string, args?: Record<string, unknown>, timeoutMs?: number) => Promise<any>} ask
872
+ * @property {() => Promise<MeaningNode[]>} tree
873
+ * @property {(target: string) => Promise<{ok: boolean, why: string}>} tap
874
+ * @property {(target: string, text: string) => Promise<{ok: boolean, why: string}>} type
875
+ * @property {() => Promise<{calls: Call[], refused: Call[]}>} calls
876
+ * @property {(file: string) => Promise<{ok: boolean, path: string, why: string}>} screenshot
877
+ * @property {() => Promise<string[]>} filesWritten
878
+ * @property {() => Promise<void>} close
879
+ */
880
+
881
+ /** How long the probe is given to answer one question before we stop waiting. */
882
+ const ANSWER_TIMEOUT_MS = 20_000;
883
+
884
+ /** Where inside the app's own container the two sides leave notes for each other. */
885
+ const CHANNEL_INSIDE_CONTAINER = path.join('Library', 'Caches', 'staysfixed-channel');
886
+
887
+ /**
888
+ * Folders inside an app's container that iOS writes, not the app.
889
+ *
890
+ * Everything here was seen changing between two runs of the SAME build, which is the
891
+ * definition of somebody else's footprint.
892
+ */
893
+ export const IOS_OWN_BOOKKEEPING = [
894
+ path.join('Library', 'SplashBoard'),
895
+ path.join('Library', 'Caches', 'com.apple.'),
896
+ path.join('Library', 'Saved Application State'),
897
+ path.join('SystemData'),
898
+ path.join('tmp', 'com.apple.'),
899
+ ];
900
+
901
+ /**
902
+ * Install one build into the device and start it with the probe inside.
903
+ *
904
+ * The bundle is COPIED first and the copy is what gets the probe and gets installed. The
905
+ * real build directory is never written to — somebody has it open in Xcode.
906
+ *
907
+ * @param {object} opts
908
+ * @param {string} opts.udid
909
+ * @param {string} opts.appPath Path to a .app bundle.
910
+ * @param {string} opts.scratchDir
911
+ * @param {string} [opts.probeDylib] Skip to run without the probe: pictures only.
912
+ * @param {Record<string,string>} [opts.env] Extra environment for the app.
913
+ * @param {string[]} [opts.args]
914
+ * @param {number} [opts.firstAnswerMs] How long to wait for the app to answer at all.
915
+ * @param {AbortSignal} [opts.signal]
916
+ * @param {(message: string) => void} [opts.log]
917
+ * @returns {Promise<{ok: boolean, app: OpenApp|null, why: string, timings: Record<string, number>}>}
918
+ */
919
+ export async function openApp(opts) {
920
+ /** @type {Record<string, number>} */
921
+ const timings = {};
922
+ const mark = /** @param {string} name @param {number} from */ (name, from) => {
923
+ timings[name] = Date.now() - from;
924
+ };
925
+
926
+ let t = Date.now();
927
+ const facts = await readAppBundle(opts.appPath);
928
+ if (!facts.ok) return { ok: false, app: null, why: facts.why, timings };
929
+ mark('read the bundle', t);
930
+
931
+ t = Date.now();
932
+ const staging = path.join(opts.scratchDir, 'ios-install');
933
+ await fsp.rm(staging, { recursive: true, force: true });
934
+ await fsp.mkdir(staging, { recursive: true });
935
+ const copied = path.join(staging, path.basename(opts.appPath));
936
+ await fsp.cp(opts.appPath, copied, { recursive: true });
937
+ if (opts.probeDylib) await fsp.cp(opts.probeDylib, path.join(copied, PROBE_NAME));
938
+ mark('copy the app', t);
939
+
940
+ t = Date.now();
941
+ const installed = await simctl(['install', opts.udid, copied], { timeoutMs: 180_000, signal: opts.signal });
942
+ if (!installed.ok) {
943
+ return { ok: false, app: null, why: `That build would not install: ${firstLine(installed.stderr) || installed.why}`, timings };
944
+ }
945
+ mark('install', t);
946
+
947
+ const containerRan = await simctl(['get_app_container', opts.udid, facts.bundleId, 'data'], { timeoutMs: 60_000, signal: opts.signal });
948
+ const container = containerRan.stdout.trim();
949
+ if (!container) {
950
+ return { ok: false, app: null, why: `The app installed but its own folder on the device could not be found, so nothing it writes can be seen.`, timings };
951
+ }
952
+ const channel = path.join(container, CHANNEL_INSIDE_CONTAINER);
953
+ await fsp.rm(channel, { recursive: true, force: true });
954
+ await fsp.mkdir(channel, { recursive: true });
955
+
956
+ t = Date.now();
957
+ const launchedAt = Date.now();
958
+ /** @type {Record<string,string>} */
959
+ const env = {};
960
+ for (const [key, value] of Object.entries(opts.env ?? {})) env[`SIMCTL_CHILD_${key}`] = value;
961
+ if (opts.probeDylib) {
962
+ env.SIMCTL_CHILD_DYLD_INSERT_LIBRARIES = `@executable_path/${PROBE_NAME}`;
963
+ env.SIMCTL_CHILD_STAYSFIXED_DIR = channel;
964
+ }
965
+ const launched = await simctl(['launch', opts.udid, facts.bundleId, ...(opts.args ?? [])], {
966
+ timeoutMs: 120_000, signal: opts.signal, env,
967
+ });
968
+ if (!launched.ok) {
969
+ return { ok: false, app: null, why: `That build installed but would not start: ${firstLine(launched.stderr) || launched.why}`, timings };
970
+ }
971
+ mark('launch', t);
972
+
973
+ let sequence = 0;
974
+ /**
975
+ * @param {string} act
976
+ * @param {Record<string, unknown>} [args]
977
+ * @param {number} [timeoutMs]
978
+ * @returns {Promise<any>}
979
+ */
980
+ const ask = async (act, args = {}, timeoutMs = ANSWER_TIMEOUT_MS) => {
981
+ if (!opts.probeDylib) return { ok: false, why: 'The app was started without the observer, so it cannot be asked anything.' };
982
+ sequence += 1;
983
+ const seq = String(sequence).padStart(5, '0');
984
+ const reply = path.join(channel, `reply-${seq}.json`);
985
+ await fsp.writeFile(path.join(channel, `cmd-${seq}.json`), JSON.stringify({ act, ...args }), 'utf8');
986
+ const until = Date.now() + timeoutMs;
987
+ while (Date.now() < until) {
988
+ if (opts.signal?.aborted) throw new Error('The run was cancelled while the app was being asked something.');
989
+ try {
990
+ const text = await fsp.readFile(reply, 'utf8');
991
+ await fsp.rm(reply, { force: true });
992
+ return JSON.parse(text);
993
+ } catch {
994
+ await wait(40);
995
+ }
996
+ }
997
+ return { ok: false, timedOut: true, why: `The app did not answer within ${Math.round(timeoutMs / 1000)} seconds when it was asked to ${act}. It may be busy, or it may have stopped.` };
998
+ };
999
+
1000
+ t = Date.now();
1001
+ let probeAnswered = false;
1002
+ if (opts.probeDylib) {
1003
+ // Sixty seconds and not twenty. Measured: on a device that has just been wiped, the
1004
+ // first app to start waits behind everything iOS does on a fresh boot, and a walk that
1005
+ // took eighteen seconds on a warm device took over seven minutes on a cold one. A
1006
+ // window that is too short does not fail loudly - it reports the screen as unreadable,
1007
+ // which is a hole where there was no problem.
1008
+ const until = Date.now() + (opts.firstAnswerMs ?? 60_000);
1009
+ while (Date.now() < until && !probeAnswered) {
1010
+ const pong = await ask('ping', {}, 4_000);
1011
+ probeAnswered = pong?.ok === true;
1012
+ if (!probeAnswered) await wait(200);
1013
+ }
1014
+ }
1015
+ mark('the app answered', t);
1016
+
1017
+ /** @type {OpenApp} */
1018
+ const app = {
1019
+ udid: opts.udid,
1020
+ bundleId: facts.bundleId,
1021
+ appPath: copied,
1022
+ container,
1023
+ launchedAt,
1024
+ probeAnswered,
1025
+ why: probeAnswered
1026
+ ? 'The app is running with the observer inside it, so its screen can be read by meaning.'
1027
+ : opts.probeDylib
1028
+ ? 'The app is running but the observer never answered, so only pictures, logs, crashes and the files it writes can be seen. What the screen MEANS is not being checked, and that is the most important channel there is.'
1029
+ : 'The app is running without the observer, by request. Only pictures, logs, crashes and the files it writes are being seen.',
1030
+ ask,
1031
+ tree: async () => {
1032
+ const reply = await ask('tree', {}, 30_000);
1033
+ return Array.isArray(reply?.tree) ? reply.tree : [];
1034
+ },
1035
+ tap: async (target) => {
1036
+ const reply = await ask('tap', { target });
1037
+ return { ok: reply?.ok === true, why: reply?.why ?? (reply?.ok ? `Activated "${target}".` : 'The app gave no reason.') };
1038
+ },
1039
+ type: async (target, text) => {
1040
+ const reply = await ask('type', { target, text });
1041
+ return { ok: reply?.ok === true, why: reply?.why ?? (reply?.ok ? `Typed into "${target}".` : 'The app gave no reason.') };
1042
+ },
1043
+ calls: async () => {
1044
+ const reply = await ask('calls');
1045
+ return { calls: Array.isArray(reply?.calls) ? reply.calls : [], refused: Array.isArray(reply?.refused) ? reply.refused : [] };
1046
+ },
1047
+ screenshot: async (file) => takeScreenshot(opts.udid, file, { signal: opts.signal }),
1048
+ filesWritten: async () => listContainerFiles(container),
1049
+ close: async () => {
1050
+ await simctl(['terminate', opts.udid, facts.bundleId], { timeoutMs: 60_000 });
1051
+ },
1052
+ };
1053
+
1054
+ return { ok: true, app, why: app.why, timings };
1055
+ }
1056
+
1057
+ /**
1058
+ * @param {number} ms
1059
+ * @returns {Promise<void>}
1060
+ */
1061
+ function wait(ms) {
1062
+ return new Promise((resolve) => setTimeout(resolve, ms));
1063
+ }
1064
+
1065
+ // ---------------------------------------------------------------------------
1066
+ // Reading a build without running it
1067
+ // ---------------------------------------------------------------------------
1068
+
1069
+ /**
1070
+ * What the app bundle says about itself, before anything is started.
1071
+ *
1072
+ * This is the contract channel for a phone app, and it is the cheapest observation in the
1073
+ * whole lane: no boot, no install, no walking. It names the doors — which URL schemes the
1074
+ * app answers on, which permissions it will ask a person for, which background modes it
1075
+ * claims — and all of that is exact, because it is read out of the thing that ships.
1076
+ *
1077
+ * @typedef {object} AppFacts
1078
+ * @property {boolean} ok
1079
+ * @property {string} why
1080
+ * @property {string} bundleId
1081
+ * @property {string} name
1082
+ * @property {string} version
1083
+ * @property {string} build
1084
+ * @property {string} minimumOS
1085
+ * @property {string[]} urlSchemes Every address a stranger can hand this app.
1086
+ * @property {{key: string, reason: string}[]} permissions Every permission it will ask for.
1087
+ * @property {string[]} backgroundModes
1088
+ * @property {string[]} deviceFamilies
1089
+ * @property {Record<string, JsonValue>} raw
1090
+ */
1091
+
1092
+ /**
1093
+ * @param {string} appPath
1094
+ * @returns {Promise<AppFacts>}
1095
+ */
1096
+ export async function readAppBundle(appPath) {
1097
+ /** @type {AppFacts} */
1098
+ const empty = {
1099
+ ok: false, why: '', bundleId: '', name: '', version: '', build: '', minimumOS: '',
1100
+ urlSchemes: [], permissions: [], backgroundModes: [], deviceFamilies: [], raw: {},
1101
+ };
1102
+ const plist = path.join(appPath, 'Info.plist');
1103
+ const ran = await runOnce('plutil', ['-convert', 'json', '-o', '-', plist], { timeoutMs: 20_000 });
1104
+ if (ran.code !== 0) {
1105
+ return { ...empty, why: `"${path.basename(appPath)}" does not look like an iPhone app: it has no readable Info.plist inside it.` };
1106
+ }
1107
+ /** @type {Record<string, any>} */
1108
+ let info = {};
1109
+ try {
1110
+ info = JSON.parse(ran.stdout);
1111
+ } catch {
1112
+ return { ...empty, why: `The Info.plist inside "${path.basename(appPath)}" could not be read.` };
1113
+ }
1114
+
1115
+ const bundleId = String(info.CFBundleIdentifier ?? '');
1116
+ if (!bundleId) return { ...empty, why: `The app bundle at "${appPath}" has no bundle identifier, so it cannot be installed or launched.` };
1117
+
1118
+ /** @type {string[]} */
1119
+ const urlSchemes = [];
1120
+ for (const entry of info.CFBundleURLTypes ?? []) {
1121
+ for (const scheme of entry?.CFBundleURLSchemes ?? []) urlSchemes.push(String(scheme));
1122
+ }
1123
+ /** @type {{key: string, reason: string}[]} */
1124
+ const permissions = [];
1125
+ for (const [key, value] of Object.entries(info)) {
1126
+ if (/UsageDescription$/.test(key)) permissions.push({ key, reason: String(value) });
1127
+ }
1128
+
1129
+ return {
1130
+ ok: true,
1131
+ why: `${info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId}, version ${info.CFBundleShortVersionString ?? '?'} (${info.CFBundleVersion ?? '?'}).`,
1132
+ bundleId,
1133
+ name: String(info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId),
1134
+ version: String(info.CFBundleShortVersionString ?? ''),
1135
+ build: String(info.CFBundleVersion ?? ''),
1136
+ minimumOS: String(info.MinimumOSVersion ?? ''),
1137
+ urlSchemes: urlSchemes.sort(),
1138
+ permissions: permissions.sort((a, b) => a.key.localeCompare(b.key)),
1139
+ backgroundModes: [...(info.UIBackgroundModes ?? [])].map(String).sort(),
1140
+ deviceFamilies: [...(info.UIDeviceFamily ?? [])].map((n) => (n === 1 ? 'iPhone' : n === 2 ? 'iPad' : `family ${n}`)),
1141
+ raw: info,
1142
+ };
1143
+ }
1144
+
1145
+ // ---------------------------------------------------------------------------
1146
+ // Turning a tree into addresses
1147
+ // ---------------------------------------------------------------------------
1148
+
1149
+ /**
1150
+ * One thing on the screen, at an address that survives a redesign.
1151
+ *
1152
+ * @typedef {object} Meaning
1153
+ * @property {string} address
1154
+ * @property {JsonValue} value
1155
+ * @property {string} says
1156
+ */
1157
+
1158
+ /**
1159
+ * Flatten the accessibility tree into addresses.
1160
+ *
1161
+ * THE ADDRESSING RULE, and it is the load-bearing choice in this file. A control that has
1162
+ * an accessibility identifier is addressed by that identifier alone, and everything a
1163
+ * person can see about it — its role, its label, its value, its states — becomes the VALUE
1164
+ * at that address. An identifier is put there by the people who wrote the app precisely so
1165
+ * it can be pointed at later; it does not move when the screen is rearranged and it does
1166
+ * not change when the wording changes. That means renaming a button reports as one changed
1167
+ * value rather than as a button vanishing and another appearing, which is what a person
1168
+ * actually did.
1169
+ *
1170
+ * A control with NO identifier falls back to what it is and what it says — `button:Pay now`
1171
+ * — inside the chain of named ancestors above it. That address does move when the wording
1172
+ * moves, and that is the honest cost of an app that did not label its controls. The
1173
+ * coverage ledger counts how many controls had to be addressed this way.
1174
+ *
1175
+ * Position is used only to tell apart two controls that are otherwise identical, and only
1176
+ * within the one group they share.
1177
+ *
1178
+ * @param {MeaningNode[]} tree
1179
+ * @returns {Meaning[]}
1180
+ */
1181
+ export function flattenMeaning(tree) {
1182
+ /** @type {Meaning[]} */
1183
+ const out = [];
1184
+ /** @type {Map<string, number>} */
1185
+ const seen = new Map();
1186
+
1187
+ /**
1188
+ * @param {MeaningNode} node
1189
+ * @param {string[]} trail
1190
+ */
1191
+ const walk = (node, trail) => {
1192
+ /** @type {string} */
1193
+ let address = '';
1194
+ if (node.id) {
1195
+ address = `#${node.id}`;
1196
+ } else if (node.name) {
1197
+ address = [...trail, `${node.role}:${node.name}`].join(' > ');
1198
+ }
1199
+
1200
+ if (address) {
1201
+ const count = seen.get(address) ?? 0;
1202
+ seen.set(address, count + 1);
1203
+ const unique = count === 0 ? address : `${address} (${count + 1})`;
1204
+ /** @type {Record<string, JsonValue>} */
1205
+ const value = { role: node.role };
1206
+ if (node.name) value.name = node.name;
1207
+ if (node.value) value.value = node.value;
1208
+ if (node.states?.length) value.states = [...node.states].sort();
1209
+ out.push({
1210
+ address: unique,
1211
+ value,
1212
+ says: `${describeRole(node.role)}${node.name ? ` saying "${node.name}"` : ''}${node.value ? `, set to "${node.value}"` : ''}${node.states?.length ? `, ${node.states.join(' and ')}` : ''}`,
1213
+ });
1214
+ }
1215
+
1216
+ const nextTrail = node.name && isLandmark(node.role) ? [...trail, `${node.role}:${node.name}`] : trail;
1217
+ for (const child of node.children ?? []) walk(child, nextTrail);
1218
+ };
1219
+
1220
+ for (const root of tree) walk(root, []);
1221
+ return out;
1222
+ }
1223
+
1224
+ /**
1225
+ * Roles that anchor everything under them. Kept short: a deep chain of ancestors makes an
1226
+ * address that moves whenever anything above it moves, which is the opposite of the point.
1227
+ * @param {string} role
1228
+ * @returns {boolean}
1229
+ */
1230
+ function isLandmark(role) {
1231
+ return role === 'heading' || role === 'tab bar';
1232
+ }
1233
+
1234
+ /**
1235
+ * @param {string} role
1236
+ * @returns {string}
1237
+ */
1238
+ function describeRole(role) {
1239
+ switch (role) {
1240
+ case 'button': return 'a button';
1241
+ case 'link': return 'a link';
1242
+ case 'heading': return 'a heading';
1243
+ case 'text': return 'a piece of text';
1244
+ case 'image': return 'an image';
1245
+ case 'slider': return 'a control you can slide';
1246
+ case 'search field': return 'a search box';
1247
+ case 'tab bar': return 'a row of tabs';
1248
+ case 'group': return 'a group of things';
1249
+ default: return 'something on the screen';
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * Read the screen until it stops changing.
1255
+ *
1256
+ * The same idea as `src/freeze/settle.js`, which captures until two frames agree, moved
1257
+ * from pixels to meaning: ask for the tree, ask again, and keep the answer only once two
1258
+ * answers in a row are identical. A phone animates almost every transition, and reading
1259
+ * mid-animation gives a tree with half a screen in it — which then reports as a difference
1260
+ * caused by nothing.
1261
+ *
1262
+ * @param {() => Promise<MeaningNode[]>} read
1263
+ * @param {{tries?: number, gapMs?: number, signal?: AbortSignal}} [opts]
1264
+ * @returns {Promise<{tree: MeaningNode[], settled: boolean, tries: number, why: string}>}
1265
+ */
1266
+ export async function settleTree(read, opts = {}) {
1267
+ const tries = opts.tries ?? 6;
1268
+ const gap = opts.gapMs ?? 300;
1269
+ let previous = '';
1270
+ /** @type {MeaningNode[]} */
1271
+ let tree = [];
1272
+ for (let i = 1; i <= tries; i += 1) {
1273
+ if (opts.signal?.aborted) break;
1274
+ tree = await read();
1275
+ const now = JSON.stringify(tree);
1276
+ if (now === previous && now !== '[]') {
1277
+ return { tree, settled: true, tries: i, why: `The screen was the same twice in a row after ${i} looks.` };
1278
+ }
1279
+ previous = now;
1280
+ if (i < tries) await wait(gap);
1281
+ }
1282
+ return {
1283
+ tree,
1284
+ settled: false,
1285
+ tries,
1286
+ why: `The screen was still changing after ${tries} looks, so what was read may have caught it mid-move. Anything that differs here should be treated as the app's own wobble until a second run says otherwise.`,
1287
+ };
1288
+ }
1289
+
1290
+ // ---------------------------------------------------------------------------
1291
+ // Complaints, files, pictures
1292
+ // ---------------------------------------------------------------------------
1293
+
1294
+ /**
1295
+ * The app's own log, filtered to what the app itself said.
1296
+ *
1297
+ * Everything else on a phone is talking at once — a raw log stream from a simulator is tens
1298
+ * of thousands of lines a minute, almost none of it the app's. The filter is the app's OWN
1299
+ * SUBSYSTEM, which is what any team using `Logger` gets for free.
1300
+ *
1301
+ * Widening it to the process name was tried and measured, and it is off by default because
1302
+ * of what it caught: every line the networking stack writes on the app's behalf, each one
1303
+ * carrying a fresh connection id, so a run that made one HTTP call produced six log paths
1304
+ * that had never existed before and would never exist again. Six invented differences for
1305
+ * one real one is how a tool teaches its owner to stop reading it. Ask for `alsoProcess`
1306
+ * when an app prints the old way and means it.
1307
+ *
1308
+ * @param {object} opts
1309
+ * @param {string} opts.udid
1310
+ * @param {string} opts.bundleId
1311
+ * @param {string} [opts.processName]
1312
+ * @param {boolean} [opts.alsoProcess] Widen to everything the process logged. Noisy - see above.
1313
+ * @param {number} opts.sinceMs How far back to look, in milliseconds.
1314
+ * @param {AbortSignal} [opts.signal]
1315
+ * @returns {Promise<{lines: {level: string, text: string}[], ok: boolean, why: string}>}
1316
+ */
1317
+ export async function readAppLog(opts) {
1318
+ const seconds = Math.max(1, Math.ceil(opts.sinceMs / 1000) + 2);
1319
+ const predicate = opts.alsoProcess && opts.processName
1320
+ ? `subsystem BEGINSWITH "${opts.bundleId}" OR process == "${opts.processName}"`
1321
+ : `subsystem BEGINSWITH "${opts.bundleId}"`;
1322
+ const ran = await simctl([
1323
+ 'spawn', opts.udid, 'log', 'show',
1324
+ '--style', 'compact', '--last', `${seconds}s`,
1325
+ '--predicate', predicate,
1326
+ ], { timeoutMs: 90_000, signal: opts.signal });
1327
+
1328
+ if (!ran.ok) {
1329
+ return { lines: [], ok: false, why: `The app's log could not be read: ${firstLine(ran.stderr) || ran.why}` };
1330
+ }
1331
+ /** @type {{level: string, text: string}[]} */
1332
+ const lines = [];
1333
+ for (const raw of ran.stdout.split('\n')) {
1334
+ const match = /^\d{4}-\d{2}-\d{2} [\d:.]+\s+(\S+)\s+\S+\s+(.*)$/.exec(raw.trim());
1335
+ if (!match) continue;
1336
+ const level = ({ Df: 'debug', In: 'info', Dg: 'default', Er: 'error', Fa: 'fault' })[match[1]] ?? match[1];
1337
+ const text = match[2].replace(/^\[[^\]]*\]\s*/, '');
1338
+ if (text) lines.push({ level, text });
1339
+ }
1340
+ return { lines, ok: true, why: `${lines.length} line${lines.length === 1 ? '' : 's'} the app itself wrote.` };
1341
+ }
1342
+
1343
+ /**
1344
+ * Crashes, from the folder the operating system puts them in.
1345
+ *
1346
+ * A simulator writes its crash reports to the Mac's own DiagnosticReports folder, not into
1347
+ * the device. Only reports newer than the moment the app was launched are read, and only
1348
+ * the first few lines of each — the process, the reason and the exception — because the
1349
+ * whole report is a megabyte of stack and none of it belongs in a comparison.
1350
+ *
1351
+ * @param {object} opts
1352
+ * @param {string} opts.processName
1353
+ * @param {number} opts.since Milliseconds since the epoch.
1354
+ * @returns {Promise<{crashes: {process: string, reason: string, at: string}[], why: string}>}
1355
+ */
1356
+ export async function readCrashes(opts) {
1357
+ const folder = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
1358
+ /** @type {{process: string, reason: string, at: string}[]} */
1359
+ const crashes = [];
1360
+ /** @type {string[]} */
1361
+ let names = [];
1362
+ try {
1363
+ names = await fsp.readdir(folder);
1364
+ } catch {
1365
+ return { crashes, why: 'There is no crash report folder on this Mac, so a crash could not have been seen even if there had been one.' };
1366
+ }
1367
+ for (const name of names) {
1368
+ if (!name.startsWith(`${opts.processName}-`) && !name.startsWith(`${opts.processName}_`)) continue;
1369
+ if (!/\.(ips|crash)$/.test(name)) continue;
1370
+ const full = path.join(folder, name);
1371
+ /** @type {import('node:fs').Stats} */
1372
+ let stat;
1373
+ try {
1374
+ stat = await fsp.stat(full);
1375
+ } catch {
1376
+ continue;
1377
+ }
1378
+ if (stat.mtimeMs < opts.since) continue;
1379
+ let reason = 'it stopped without saying why';
1380
+ try {
1381
+ const text = (await fsp.readFile(full, 'utf8')).slice(0, 20_000);
1382
+ const term = /"termination"\s*:\s*\{[^}]*"indicator"\s*:\s*"([^"]+)"/.exec(text);
1383
+ const exception = /"exception"\s*:\s*\{[^}]*"type"\s*:\s*"([^"]+)"/.exec(text);
1384
+ const legacy = /Exception Type:\s*(.+)/.exec(text);
1385
+ // The report is JSON, so a slash in the reason arrives escaped. Left alone it turns
1386
+ // "Trace/BPT trap: 5" into an address with a stray backslash in it.
1387
+ reason = (term?.[1] ?? exception?.[1] ?? legacy?.[1]?.trim() ?? reason).replace(/\\\//g, '/');
1388
+ } catch {
1389
+ // A report we cannot read is still a crash, and saying so is the point.
1390
+ }
1391
+ crashes.push({ process: opts.processName, reason, at: new Date(stat.mtimeMs).toISOString() });
1392
+ }
1393
+ return {
1394
+ crashes,
1395
+ why: crashes.length === 0
1396
+ ? 'The app did not crash while it was being walked.'
1397
+ : `The app crashed ${crashes.length} time${crashes.length === 1 ? '' : 's'} while it was being walked.`,
1398
+ };
1399
+ }
1400
+
1401
+ /**
1402
+ * Everything the app wrote inside its own folder on the device.
1403
+ *
1404
+ * Paths only — the contents are the app's business, they are often large, and a byte-exact
1405
+ * comparison of a database file reports a difference every single run.
1406
+ *
1407
+ * TWO KINDS OF FOOTPRINT ARE RUBBED OUT HERE, and both were measured rather than guessed.
1408
+ * Ours: the folder the probe and the harness leave notes in. And the operating system's:
1409
+ * iOS drops a fresh picture of the app into Library/SplashBoard/Snapshots every time it
1410
+ * goes to the background, named with a brand new random id each time. Left in, that alone
1411
+ * produced four invented differences per paired run — files that appeared and vanished
1412
+ * because iOS took a photograph, which is not something the app did and not something
1413
+ * anybody can fix.
1414
+ *
1415
+ * What is NOT rubbed out is any random id in a name the app chose itself. A database file
1416
+ * that used to have a stable name and now has a random one is a real finding.
1417
+ *
1418
+ * @param {string} container
1419
+ * @returns {Promise<string[]>}
1420
+ */
1421
+ export async function listContainerFiles(container) {
1422
+ /** @type {string[]} */
1423
+ const out = [];
1424
+ /** @param {string} dir */
1425
+ const walk = async (dir) => {
1426
+ /** @type {import('node:fs').Dirent[]} */
1427
+ let entries = [];
1428
+ try {
1429
+ entries = await fsp.readdir(dir, { withFileTypes: true });
1430
+ } catch {
1431
+ return;
1432
+ }
1433
+ for (const entry of entries) {
1434
+ const full = path.join(dir, entry.name);
1435
+ const relative = path.relative(container, full);
1436
+ if (relative.startsWith(CHANNEL_INSIDE_CONTAINER)) continue;
1437
+ if (IOS_OWN_BOOKKEEPING.some((folder) => relative.startsWith(folder))) continue;
1438
+ if (entry.isDirectory()) await walk(full);
1439
+ else if (entry.isFile()) out.push(relative);
1440
+ }
1441
+ };
1442
+ await walk(container);
1443
+ return out.sort();
1444
+ }
1445
+
1446
+ /**
1447
+ * A picture, as evidence and never as the accusation.
1448
+ *
1449
+ * @param {string} udid
1450
+ * @param {string} file
1451
+ * @param {{signal?: AbortSignal}} [opts]
1452
+ * @returns {Promise<{ok: boolean, path: string, why: string}>}
1453
+ */
1454
+ export async function takeScreenshot(udid, file, opts = {}) {
1455
+ await fsp.mkdir(path.dirname(file), { recursive: true });
1456
+ const ran = await simctl(['io', udid, 'screenshot', '--type', 'png', file], { timeoutMs: 60_000, signal: opts.signal });
1457
+ if (!ran.ok) return { ok: false, path: '', why: `A picture of the screen could not be taken: ${firstLine(ran.stderr) || ran.why}` };
1458
+ return { ok: true, path: file, why: 'A picture was kept as evidence.' };
1459
+ }
1460
+
1461
+ // ---------------------------------------------------------------------------
1462
+ // Getting back to a clean start
1463
+ // ---------------------------------------------------------------------------
1464
+
1465
+ /**
1466
+ * Put the device back to a clean state between two builds.
1467
+ *
1468
+ * Two ways, and they are not equivalent, so the choice is named rather than hidden.
1469
+ *
1470
+ * 'reinstall' removes the app and everything it had written, then installs the next build.
1471
+ * Measured on this machine: about a second and a half, and it leaves the rest of the device
1472
+ * exactly as it was — the same first-launch prompts already dismissed, the same keyboard
1473
+ * state, the same warm caches.
1474
+ *
1475
+ * 'erase' wipes the whole device and boots it again. Measured: about seventy seconds, of
1476
+ * which the boot is nearly all of it. It is the only way to clear anything the app left
1477
+ * OUTSIDE its own container — the keychain, granted permissions, the photo library, a
1478
+ * notification it registered.
1479
+ *
1480
+ * The recommendation, which the adapter follows by default, is 'reinstall'. The reason is
1481
+ * arithmetic rather than taste: a paired iOS run costs two of whatever this is, and at
1482
+ * seventy seconds each an erase turns a two-minute check into a four-minute one, which is
1483
+ * the difference between something that runs on every change and something nobody switches
1484
+ * on. What 'reinstall' cannot clear is named in the return value so it appears in the
1485
+ * coverage ledger, and 'erase' is what a pre-release run should use.
1486
+ *
1487
+ * @param {object} opts
1488
+ * @param {string} opts.udid
1489
+ * @param {string} opts.bundleId
1490
+ * @param {'reinstall'|'erase'} [opts.how]
1491
+ * @param {AbortSignal} [opts.signal]
1492
+ * @returns {Promise<{ok: boolean, how: string, ms: number, why: string, leftBehind: string[]}>}
1493
+ */
1494
+ export async function resetBetweenBuilds(opts) {
1495
+ const started = Date.now();
1496
+ const how = opts.how ?? 'reinstall';
1497
+ if (how === 'erase') {
1498
+ await simctl(['shutdown', opts.udid], { timeoutMs: 90_000, signal: opts.signal });
1499
+ const erased = await simctl(['erase', opts.udid], { timeoutMs: 180_000, signal: opts.signal });
1500
+ if (!erased.ok) {
1501
+ return { ok: false, how, ms: Date.now() - started, why: `The device would not erase: ${firstLine(erased.stderr) || erased.why}`, leftBehind: [] };
1502
+ }
1503
+ const booted = await bootDevice(opts.udid, { signal: opts.signal });
1504
+ return {
1505
+ ok: booted.ok,
1506
+ how,
1507
+ ms: Date.now() - started,
1508
+ why: booted.ok
1509
+ ? `The whole device was wiped and booted again, which took about ${Math.round((Date.now() - started) / 1000)} seconds. Nothing at all is left from the previous build. The first walk after this will also be much slower than the ones after it - measured here at over seven minutes against eighteen seconds warm - because everything iOS does on a fresh boot happens while the app is trying to start.`
1510
+ : booted.why,
1511
+ leftBehind: [],
1512
+ };
1513
+ }
1514
+
1515
+ await simctl(['terminate', opts.udid, opts.bundleId], { timeoutMs: 60_000, signal: opts.signal });
1516
+ const removed = await simctl(['uninstall', opts.udid, opts.bundleId], { timeoutMs: 120_000, signal: opts.signal });
1517
+ return {
1518
+ ok: removed.ok || /not.*installed/i.test(removed.stderr),
1519
+ how,
1520
+ ms: Date.now() - started,
1521
+ why: `The app and everything it had written were removed, which took about ${((Date.now() - started) / 1000).toFixed(1)} seconds. The rest of the device was left alone.`,
1522
+ leftBehind: [
1523
+ 'anything the app put in the keychain',
1524
+ 'permissions a person or a previous run had already granted',
1525
+ 'notifications the app had registered for',
1526
+ 'anything it added to the photo library or the contacts',
1527
+ ],
1528
+ };
1529
+ }
1530
+
1531
+ /**
1532
+ * Clear granted permissions for one app without wiping the device.
1533
+ *
1534
+ * The middle option between the two resets above: it costs no boot and it removes the one
1535
+ * thing a reinstall reliably leaves behind and that reliably changes what a screen says.
1536
+ *
1537
+ * @param {object} opts
1538
+ * @param {string} opts.udid
1539
+ * @param {string} opts.bundleId
1540
+ * @param {AbortSignal} [opts.signal]
1541
+ * @returns {Promise<{ok: boolean, why: string}>}
1542
+ */
1543
+ export async function resetPermissions(opts) {
1544
+ const ran = await simctl(['privacy', opts.udid, 'reset', 'all', opts.bundleId], { timeoutMs: 60_000, signal: opts.signal });
1545
+ return {
1546
+ ok: ran.ok,
1547
+ why: ran.ok
1548
+ ? 'Every permission this app had been granted was taken back, so both builds are asked the same questions.'
1549
+ : `Permissions could not be reset (${firstLine(ran.stderr) || ran.why}), so one build may have been trusted with something the other was not.`,
1550
+ };
1551
+ }