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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- 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
|
+
}
|