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,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS — an iPhone app, in the simulator.
|
|
3
|
+
*
|
|
4
|
+
* This file turns one platform into the flat list of `path -> value` facts the engine
|
|
5
|
+
* compares, and knows nothing about how a simulator works; `ios-driver.js` owns all of
|
|
6
|
+
* that. What it owns is the judgement: which doors this app has, which of them a run
|
|
7
|
+
* actually opened, what counts as a difference worth reporting, and — the part that matters
|
|
8
|
+
* most on a phone — what honestly cannot be checked here at all.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT WATCHES.
|
|
11
|
+
*
|
|
12
|
+
* - MEANING. The accessibility tree, read from inside the running app: role, name,
|
|
13
|
+
* identifier, value, state. Not the view hierarchy, which changes when a designer moves a
|
|
14
|
+
* stack view and nothing a person can perceive has changed at all.
|
|
15
|
+
* - EFFECTS OUT. Every network call the app tried to make, by method, host and path, with
|
|
16
|
+
* the ones that would not come back — a charge, a message, a delete — written down and
|
|
17
|
+
* then stopped before the socket opens. Plus every file it wrote inside its own folder.
|
|
18
|
+
* - COMPLAINTS. The app's own log, filtered to its own subsystem, and crashes, read from
|
|
19
|
+
* the folder the Mac puts them in.
|
|
20
|
+
* - THE CONTRACT, read without running anything: the bundle identifier, the version, every
|
|
21
|
+
* URL scheme a stranger can hand the app, every permission it will ask a person for, and
|
|
22
|
+
* every accessibility identifier declared anywhere in the source. That last one is this
|
|
23
|
+
* platform's answer to Terminal Deck's list of IPC channels: a complete list of the
|
|
24
|
+
* controls the app was built to expose, obtained exactly and for free, most of which no
|
|
25
|
+
* walkthrough has ever touched. The coverage ledger counts them, and counts how many were
|
|
26
|
+
* never on screen during a run.
|
|
27
|
+
* - COUNTERS AND PICTURES, coarse and last.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IT CANNOT SEE, and these are not hedges.
|
|
30
|
+
*
|
|
31
|
+
* - A REAL iPHONE. Nothing here touches a device somebody is holding. A paired run means
|
|
32
|
+
* installing two builds one after the other and wiping between them, and no tool may do
|
|
33
|
+
* that to a phone in somebody's pocket.
|
|
34
|
+
* - ANYTHING THAT ONLY BREAKS AFTER THE EFFECT LANDS. The charge is watched at the moment
|
|
35
|
+
* it is asked for. If a bug appears only once the payment settles, this is blind to it,
|
|
36
|
+
* permanently and by design.
|
|
37
|
+
* - AN APP THAT LABELS NOTHING. Controls with no accessibility identifier are addressed by
|
|
38
|
+
* their role and their words, so renaming a button reads as one control vanishing and
|
|
39
|
+
* another appearing. That is reported as a note, not hidden.
|
|
40
|
+
* - THE APP'S OWN TESTS. The project's XCUITest files are found and counted, because they
|
|
41
|
+
* are the best journeys in any repository, but running them needs an Xcode build of the
|
|
42
|
+
* project and this adapter never builds anything. It says so and names what would unlock
|
|
43
|
+
* it rather than quietly walking a thinner path.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fsp from 'node:fs/promises';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
defineAdapter,
|
|
51
|
+
observation,
|
|
52
|
+
notCovered,
|
|
53
|
+
joinPath,
|
|
54
|
+
timeBucket,
|
|
55
|
+
countBucket,
|
|
56
|
+
sizeBucket,
|
|
57
|
+
} from './contract.js';
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
readMachine,
|
|
61
|
+
ensureDevice,
|
|
62
|
+
releaseDevice,
|
|
63
|
+
steadyTheDevice,
|
|
64
|
+
buildProbe,
|
|
65
|
+
openApp,
|
|
66
|
+
readAppBundle,
|
|
67
|
+
flattenMeaning,
|
|
68
|
+
settleTree,
|
|
69
|
+
readAppLog,
|
|
70
|
+
readCrashes,
|
|
71
|
+
resetPermissions,
|
|
72
|
+
resetBetweenBuilds,
|
|
73
|
+
} from './ios-driver.js';
|
|
74
|
+
|
|
75
|
+
/** @typedef {import('./contract.js').AdapterProject} AdapterProject */
|
|
76
|
+
/** @typedef {import('./contract.js').Build} Build */
|
|
77
|
+
/** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
|
|
78
|
+
/** @typedef {import('./contract.js').RunContext} RunContext */
|
|
79
|
+
/** @typedef {import('./contract.js').Journey} Journey */
|
|
80
|
+
/** @typedef {import('./contract.js').Missing} Missing */
|
|
81
|
+
/** @typedef {import('../types.js').Observation} Observation */
|
|
82
|
+
/** @typedef {import('../types.js').ObservedValue} JsonValue */
|
|
83
|
+
/** @typedef {import('./ios-driver.js').OurDevice} OurDevice */
|
|
84
|
+
/** @typedef {import('./ios-driver.js').OpenApp} OpenApp */
|
|
85
|
+
/** @typedef {import('./ios-driver.js').AppFacts} AppFacts */
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One step of a walk, as this adapter understands it.
|
|
89
|
+
*
|
|
90
|
+
* @typedef {object} Step
|
|
91
|
+
* @property {'look'|'tap'|'type'|'wait'|'open'} act
|
|
92
|
+
* @property {string} [target] An accessibility identifier, or the words on the control.
|
|
93
|
+
* @property {string} [text]
|
|
94
|
+
* @property {string} [url]
|
|
95
|
+
* @property {number} [ms]
|
|
96
|
+
* @property {string} [note]
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/** Everything prepared, per build, so `run` can be called many times without re-installing. */
|
|
100
|
+
const ready = new Map();
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Reading the doors out of the source
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every control the app declares by name, read straight out of the code.
|
|
108
|
+
*
|
|
109
|
+
* Free, exact, and it sees doors no walkthrough ever opens. `accessibilityIdentifier("x")`
|
|
110
|
+
* in SwiftUI, and the same property set to a string literal in UIKit, are both a promise that a
|
|
111
|
+
* control called `x` exists and can be pointed at; collecting them gives the complete list
|
|
112
|
+
* of what the app was built to expose, which is the only honest denominator for "how deep
|
|
113
|
+
* is this check really".
|
|
114
|
+
*
|
|
115
|
+
* @param {string} root
|
|
116
|
+
* @param {{limit?: number}} [opts]
|
|
117
|
+
* @returns {Promise<{doors: {id: string, file: string, line: number}[], filesRead: number, tests: string[]}>}
|
|
118
|
+
*/
|
|
119
|
+
export async function readDeclaredDoors(root, opts = {}) {
|
|
120
|
+
const limit = opts.limit ?? 4000;
|
|
121
|
+
/** @type {{id: string, file: string, line: number}[]} */
|
|
122
|
+
const doors = [];
|
|
123
|
+
/** @type {string[]} */
|
|
124
|
+
const tests = [];
|
|
125
|
+
let filesRead = 0;
|
|
126
|
+
const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage', 'DerivedData', 'build', '.build', 'dist', 'vendor']);
|
|
127
|
+
|
|
128
|
+
/** @param {string} dir @param {number} depth */
|
|
129
|
+
const walk = async (dir, depth) => {
|
|
130
|
+
if (depth > 12 || doors.length > limit) return;
|
|
131
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
132
|
+
let entries = [];
|
|
133
|
+
try {
|
|
134
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (doors.length > limit) return;
|
|
140
|
+
const full = path.join(dir, entry.name);
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
if (skip.has(entry.name) || entry.name.startsWith('.') || entry.name.endsWith('.app')) continue;
|
|
143
|
+
await walk(full, depth + 1);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!/\.(swift|m|mm)$/.test(entry.name)) continue;
|
|
147
|
+
/** @type {string} */
|
|
148
|
+
let text = '';
|
|
149
|
+
try {
|
|
150
|
+
text = await fsp.readFile(full, 'utf8');
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
filesRead += 1;
|
|
155
|
+
const relative = path.relative(root, full);
|
|
156
|
+
if (/XCUIApplication|XCTestCase/.test(text) && /\bfunc test/.test(text)) tests.push(relative);
|
|
157
|
+
const lines = text.split('\n');
|
|
158
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
159
|
+
const patterns = [
|
|
160
|
+
/accessibilityIdentifier\(\s*"([^"]{1,120})"\s*\)/g,
|
|
161
|
+
/accessibilityIdentifier\s*=\s*@?"([^"]{1,120})"/g,
|
|
162
|
+
/setAccessibilityIdentifier:\s*@"([^"]{1,120})"/g,
|
|
163
|
+
];
|
|
164
|
+
for (const pattern of patterns) {
|
|
165
|
+
for (const match of lines[i].matchAll(pattern)) {
|
|
166
|
+
doors.push({ id: match[1], file: relative, line: i + 1 });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
await walk(root, 0);
|
|
173
|
+
|
|
174
|
+
/** @type {Map<string, {id: string, file: string, line: number}>} */
|
|
175
|
+
const unique = new Map();
|
|
176
|
+
for (const door of doors) if (!unique.has(door.id)) unique.set(door.id, door);
|
|
177
|
+
return { doors: [...unique.values()].sort((a, b) => a.id.localeCompare(b.id)), filesRead, tests: tests.sort() };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Make one piece of the app's own words safe to use as an address.
|
|
183
|
+
*
|
|
184
|
+
* Anything read out of a running app - a label, a log line, a URL path, the name of a
|
|
185
|
+
* control - can be blank, can be padded with spaces, or can carry a newline. The engine
|
|
186
|
+
* rejects all three, correctly: a path that starts with a space is a path nobody can type
|
|
187
|
+
* and nobody can match a rule against. Rather than let one stray label take a whole run
|
|
188
|
+
* down, the words are trimmed, folded onto one line, and replaced with a plain description
|
|
189
|
+
* when there is nothing left of them.
|
|
190
|
+
*
|
|
191
|
+
* @param {unknown} text
|
|
192
|
+
* @param {string} [whenEmpty]
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function tidySegment(text, whenEmpty = 'unnamed') {
|
|
196
|
+
const out = String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
197
|
+
return out === '' ? whenEmpty : out.slice(0, 160).trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Find the built app.
|
|
202
|
+
*
|
|
203
|
+
* This adapter never builds anything. Building an iPhone app means an `xcodebuild` run of
|
|
204
|
+
* somebody's project with somebody's scheme and somebody's signing settings, it takes
|
|
205
|
+
* minutes, and getting it wrong produces a build that is not the one they meant. So the
|
|
206
|
+
* `.app` is looked for where builds land, and when there isn't one the answer is a clear
|
|
207
|
+
* sentence saying which command would make one — never a silent skip.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} root
|
|
210
|
+
* @param {Record<string, any>} [config]
|
|
211
|
+
* @returns {Promise<{ok: boolean, appPath: string, why: string, candidates: string[]}>}
|
|
212
|
+
*/
|
|
213
|
+
export async function findAppBundle(root, config = {}) {
|
|
214
|
+
if (config.app) {
|
|
215
|
+
const full = path.isAbsolute(config.app) ? config.app : path.join(root, config.app);
|
|
216
|
+
try {
|
|
217
|
+
await fsp.access(path.join(full, 'Info.plist'));
|
|
218
|
+
return { ok: true, appPath: full, why: `Using the app named in the settings: ${full}`, candidates: [full] };
|
|
219
|
+
} catch {
|
|
220
|
+
return { ok: false, appPath: '', why: `The settings point at "${config.app}" but there is no iPhone app bundle there.`, candidates: [] };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @type {string[]} */
|
|
225
|
+
const found = [];
|
|
226
|
+
const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage']);
|
|
227
|
+
/** @param {string} dir @param {number} depth */
|
|
228
|
+
const walk = async (dir, depth) => {
|
|
229
|
+
if (depth > 8 || found.length > 40) return;
|
|
230
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
231
|
+
let entries = [];
|
|
232
|
+
try {
|
|
233
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
234
|
+
} catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
for (const entry of entries) {
|
|
238
|
+
if (!entry.isDirectory()) continue;
|
|
239
|
+
if (skip.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
240
|
+
const full = path.join(dir, entry.name);
|
|
241
|
+
if (entry.name.endsWith('.app')) {
|
|
242
|
+
try {
|
|
243
|
+
await fsp.access(path.join(full, 'Info.plist'));
|
|
244
|
+
found.push(full);
|
|
245
|
+
} catch {
|
|
246
|
+
// A folder called .app with no Info.plist is not an app.
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
await walk(full, depth + 1);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
await walk(root, 0);
|
|
254
|
+
|
|
255
|
+
const simulatorBuilds = found.filter((f) => /iphonesimulator|Debug-iphonesimulator|Release-iphonesimulator|Build\/Products/i.test(f));
|
|
256
|
+
const pick = simulatorBuilds[0] ?? found[0];
|
|
257
|
+
if (!pick) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
appPath: '',
|
|
261
|
+
why: 'No built iPhone app was found under this project. This adapter never builds one itself, because building somebody else\'s Xcode project with the wrong scheme produces a build that is not the one they meant.',
|
|
262
|
+
candidates: [],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
appPath: pick,
|
|
268
|
+
why: found.length === 1
|
|
269
|
+
? `Found one built app: ${path.basename(pick)}.`
|
|
270
|
+
: `Found ${found.length} built apps and picked the simulator one: ${path.basename(pick)}. Name a different one with {"app": "..."} in the settings.`,
|
|
271
|
+
candidates: found,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Journeys
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Work out what there is to walk.
|
|
281
|
+
*
|
|
282
|
+
* The order is the design's order of trust: what the code says first, then what the
|
|
283
|
+
* settings say, and never anything invented by asking a person.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} input
|
|
286
|
+
* @param {Record<string, any>} input.config
|
|
287
|
+
* @param {{id: string, file: string, line: number}[]} input.doors
|
|
288
|
+
* @param {string[]} input.tests
|
|
289
|
+
* @returns {Journey[]}
|
|
290
|
+
*/
|
|
291
|
+
export function journeysFrom(input) {
|
|
292
|
+
/** @type {Journey[]} */
|
|
293
|
+
const out = [];
|
|
294
|
+
|
|
295
|
+
out.push({
|
|
296
|
+
name: 'what-the-app-declares',
|
|
297
|
+
describe: 'Read what the app says about itself and what its code declares, without running it',
|
|
298
|
+
source: 'code',
|
|
299
|
+
surface: 'ios',
|
|
300
|
+
channels: ['contract'],
|
|
301
|
+
steps: [],
|
|
302
|
+
timeoutMs: 60_000,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
out.push({
|
|
306
|
+
name: 'the-first-screen',
|
|
307
|
+
describe: 'Start the app and read what the first screen means',
|
|
308
|
+
source: 'code',
|
|
309
|
+
surface: 'ios',
|
|
310
|
+
channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
|
|
311
|
+
steps: [{ act: 'look', note: 'the screen the app opens on' }],
|
|
312
|
+
timeoutMs: 180_000,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
for (const journey of input.config.journeys ?? []) {
|
|
316
|
+
const steps = Array.isArray(journey.steps) ? journey.steps : [];
|
|
317
|
+
out.push({
|
|
318
|
+
name: safeName(journey.name ?? `walk-${out.length}`),
|
|
319
|
+
describe: String(journey.describe ?? journey.name ?? 'a walk through the app'),
|
|
320
|
+
source: 'recorded',
|
|
321
|
+
surface: 'ios',
|
|
322
|
+
from: 'the project settings',
|
|
323
|
+
channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
|
|
324
|
+
steps,
|
|
325
|
+
irreversible: Boolean(journey.irreversible),
|
|
326
|
+
timeoutMs: Number(journey.timeoutMs ?? 240_000),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const scheme of input.config.openUrls ?? []) {
|
|
331
|
+
out.push({
|
|
332
|
+
name: safeName(`open-${scheme}`),
|
|
333
|
+
describe: `Hand the app the address ${scheme} the way another app would`,
|
|
334
|
+
source: 'code',
|
|
335
|
+
surface: 'ios',
|
|
336
|
+
channels: ['meaning', 'effects', 'complaints', 'counters'],
|
|
337
|
+
steps: [{ act: 'open', url: String(scheme) }, { act: 'look' }],
|
|
338
|
+
timeoutMs: 180_000,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const file of input.tests) {
|
|
343
|
+
out.push({
|
|
344
|
+
name: safeName(`suite-${path.basename(file, path.extname(file))}`),
|
|
345
|
+
describe: `The app's own interface tests in ${path.basename(file)}`,
|
|
346
|
+
source: 'suite',
|
|
347
|
+
surface: 'ios',
|
|
348
|
+
from: file,
|
|
349
|
+
channels: [],
|
|
350
|
+
skip: 'Running the app\'s own interface tests needs an Xcode build of the project, and this adapter never builds anything. The tests were found and counted so the hole is visible, and the check ran without them.',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
void input.doors;
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {string} name
|
|
360
|
+
* @returns {string}
|
|
361
|
+
*/
|
|
362
|
+
function safeName(name) {
|
|
363
|
+
return String(name).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'a-walk';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// The adapter
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
export const iosAdapter = defineAdapter({
|
|
371
|
+
name: 'ios',
|
|
372
|
+
title: 'iPhone apps, in the simulator',
|
|
373
|
+
describe:
|
|
374
|
+
'Installs a built iPhone app into a simulator of its own, pins the clock and the signal bars so two runs can be compared, and walks each journey while reading what the screen MEANS - the roles, names, identifiers, values and states a screen reader would read - straight out of the running app. It also writes down every call the app tried to make, every file it wrote in its own folder, everything it complained about, whether it crashed, and one picture per checkpoint kept only as evidence. It never taps a coordinate and never reads the view hierarchy, so an app that was restyled or rearranged reports nothing. It never builds the app, it never touches a real iPhone, and anything that would spend money, send a message or destroy data is written down at the moment it is asked for and then stopped before it leaves - reported as unchecked, never as done.',
|
|
375
|
+
channels: ['meaning', 'effects', 'complaints', 'contract', 'counters', 'pixels'],
|
|
376
|
+
|
|
377
|
+
/** @param {AdapterProject} project */
|
|
378
|
+
async detect(project) {
|
|
379
|
+
const config = project.config ?? {};
|
|
380
|
+
/** @type {Missing[]} */
|
|
381
|
+
const missing = [];
|
|
382
|
+
/** @type {string[]} */
|
|
383
|
+
const notes = [];
|
|
384
|
+
|
|
385
|
+
const machine = await readMachine();
|
|
386
|
+
if (!machine.isMac) {
|
|
387
|
+
return {
|
|
388
|
+
applies: false,
|
|
389
|
+
confidence: 0,
|
|
390
|
+
why: 'iPhone apps can only be run on a Mac, and this is not one. There is no honest way to check an iPhone app from here.',
|
|
391
|
+
missing: [{
|
|
392
|
+
what: 'a Mac with Xcode on it',
|
|
393
|
+
unlocks: 'checking the iPhone app at all',
|
|
394
|
+
howToGet: 'This one needs a person. An iPhone app can only be built and run on a Mac, and no package or setting changes that. If there is a Mac available, running the check there covers the phone; otherwise the phone is not being checked and the report should say so every time.',
|
|
395
|
+
blocking: true,
|
|
396
|
+
}],
|
|
397
|
+
notes,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (!machine.xcode) {
|
|
401
|
+
missing.push({
|
|
402
|
+
what: 'Xcode',
|
|
403
|
+
unlocks: 'running the iPhone app in a simulator, which is the only way to check it without a real phone',
|
|
404
|
+
howToGet: 'This one needs a person: Xcode is a free download from the Mac App Store, it is about 10 gigabytes, and it has a licence to accept the first time it opens. Once it is installed nothing else here needs doing by hand.',
|
|
405
|
+
blocking: true,
|
|
406
|
+
});
|
|
407
|
+
} else if (machine.runtimes.length === 0) {
|
|
408
|
+
missing.push({
|
|
409
|
+
what: 'an iOS runtime for Xcode',
|
|
410
|
+
unlocks: 'having a version of iOS for the app to run on - Xcode is installed but it has no iPhone to offer',
|
|
411
|
+
howToGet: 'xcodebuild -downloadPlatform iOS',
|
|
412
|
+
blocking: true,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (machine.xcode && !machine.simctlAnswers) {
|
|
416
|
+
missing.push({
|
|
417
|
+
what: 'a simulator tool that answers',
|
|
418
|
+
unlocks: 'everything - Xcode is here, but the tool that drives the simulator did not respond, through xcrun or directly',
|
|
419
|
+
howToGet: 'Open Xcode once and let it finish installing its components, then try again. If it still hangs, restarting the Mac clears it - CoreSimulator can be left wedged by an interrupted update.',
|
|
420
|
+
blocking: true,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (machine.xcode && !machine.clangWorks) {
|
|
424
|
+
missing.push({
|
|
425
|
+
what: 'the command line tools for the simulator',
|
|
426
|
+
unlocks: 'reading what the screen means. Without them the app can still be started, pictured and watched for crashes, but the most important channel is dark',
|
|
427
|
+
howToGet: 'xcode-select --install',
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const found = await findAppBundle(project.root, config);
|
|
432
|
+
if (!found.ok) {
|
|
433
|
+
missing.push({
|
|
434
|
+
what: 'a built iPhone app to check',
|
|
435
|
+
unlocks: 'walking the app. Everything its code declares can still be read without it',
|
|
436
|
+
howToGet: 'Point at one with {"app": "path/to/YourApp.app"} under "ios" in the settings, or build one first: xcodebuild -scheme <YourScheme> -sdk iphonesimulator -derivedDataPath build. This adapter never builds it for you, because guessing a scheme produces a build nobody asked for.',
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const { doors, tests, filesRead } = await readDeclaredDoors(project.root);
|
|
441
|
+
/** @type {AppFacts|null} */
|
|
442
|
+
let facts = null;
|
|
443
|
+
if (found.ok) {
|
|
444
|
+
const read = await readAppBundle(found.appPath);
|
|
445
|
+
if (read.ok) facts = read;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (tests.length > 0) {
|
|
449
|
+
missing.push({
|
|
450
|
+
what: `a way to run the app's own ${tests.length} interface test file${tests.length === 1 ? '' : 's'}`,
|
|
451
|
+
unlocks: 'the best journeys this project has. They are already written, they already know how to sign in and get to the interesting screens, and nothing here is walking them',
|
|
452
|
+
howToGet: `Run them once yourself with: xcodebuild test-without-building -scheme <YourScheme> -destination "platform=iOS Simulator,name=iPhone 16". Once that works, put {"suite": {"scheme": "<YourScheme>"}} under "ios" in the settings and they become journeys.`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const applies = Boolean(machine.isMac) && (found.ok || doors.length > 0 || Boolean(config.app));
|
|
457
|
+
const withoutIdentifiers = doors.length === 0 && found.ok;
|
|
458
|
+
if (withoutIdentifiers) {
|
|
459
|
+
notes.push('No control in this app has an accessibility identifier. Everything on screen will be addressed by its role and its wording instead, which means renaming a button reads as one control disappearing and another arriving. Adding identifiers to the controls that matter makes this check much sharper, and it also makes the app usable with VoiceOver.');
|
|
460
|
+
}
|
|
461
|
+
notes.push('The two builds are installed and walked one after the other on one device, never at the same time. Two copies of one app on one simulator share a bundle identifier, a container and a keychain, and that fight looks exactly like a regression.');
|
|
462
|
+
notes.push('Nothing that spends money, sends a message or destroys data is allowed to leave the phone. It is written down at the moment the app asks for it, stopped before the socket opens, and reported as unchecked.');
|
|
463
|
+
notes.push(...machine.notes);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
applies,
|
|
467
|
+
confidence: machine.ok && found.ok ? 1 : applies ? 0.5 : 0,
|
|
468
|
+
why: applies
|
|
469
|
+
? `${machine.why} ${found.ok ? found.why : found.why} ${doors.length} control${doors.length === 1 ? '' : 's'} named in the code were read out of ${filesRead} source file${filesRead === 1 ? '' : 's'}${facts ? `, and the app itself is ${facts.name} ${facts.version} (${facts.bundleId})` : ''}.`
|
|
470
|
+
: `${machine.why} Nothing here looks like an iPhone app: no built app bundle, no named controls in the source, and nothing in the settings.`,
|
|
471
|
+
missing,
|
|
472
|
+
notes,
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
/** @param {AdapterProject} project */
|
|
477
|
+
async journeys(project) {
|
|
478
|
+
const { doors, tests } = await readDeclaredDoors(project.root);
|
|
479
|
+
return journeysFrom({ config: project.config ?? {}, doors, tests });
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get one build onto a device.
|
|
484
|
+
*
|
|
485
|
+
* The expensive part is the boot — measured at about seventy seconds on this machine — so
|
|
486
|
+
* it happens once here and never again per journey. Installing is a second and a half, so
|
|
487
|
+
* that happens per journey instead, which buys a clean container for every walk.
|
|
488
|
+
*
|
|
489
|
+
* @param {Build} build
|
|
490
|
+
* @param {RunContext} ctx
|
|
491
|
+
* @returns {Promise<PreparedBuild>}
|
|
492
|
+
*/
|
|
493
|
+
async prepare(build, ctx) {
|
|
494
|
+
const config = ctx.config ?? {};
|
|
495
|
+
const scratch = path.join(ctx.scratchDir, `ios-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
496
|
+
await fsp.mkdir(scratch, { recursive: true });
|
|
497
|
+
|
|
498
|
+
/** @param {string} why */
|
|
499
|
+
const notReady = (why) => ({
|
|
500
|
+
build,
|
|
501
|
+
root: scratch,
|
|
502
|
+
ready: false,
|
|
503
|
+
why,
|
|
504
|
+
dispose: async () => {
|
|
505
|
+
await fsp.rm(scratch, { recursive: true, force: true });
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const machine = await readMachine({ signal: ctx.signal });
|
|
510
|
+
if (!machine.ok) return notReady(machine.why);
|
|
511
|
+
|
|
512
|
+
const found = await findAppBundle(build.root, config);
|
|
513
|
+
if (!found.ok) return notReady(found.why);
|
|
514
|
+
|
|
515
|
+
const facts = await readAppBundle(found.appPath);
|
|
516
|
+
if (!facts.ok) return notReady(facts.why);
|
|
517
|
+
|
|
518
|
+
const device = await ensureDevice({
|
|
519
|
+
name: config.device ?? 'staysfixed-ios',
|
|
520
|
+
deviceType: config.deviceType,
|
|
521
|
+
runtime: config.runtime,
|
|
522
|
+
signal: ctx.signal,
|
|
523
|
+
});
|
|
524
|
+
if (!device.ok || !device.device) return notReady(device.why);
|
|
525
|
+
|
|
526
|
+
const steady = await steadyTheDevice(device.device.udid, { appearance: config.appearance, signal: ctx.signal });
|
|
527
|
+
const probe = await buildProbe({ scratchDir: ctx.scratchDir, signal: ctx.signal });
|
|
528
|
+
await resetPermissions({ udid: device.device.udid, bundleId: facts.bundleId, signal: ctx.signal });
|
|
529
|
+
|
|
530
|
+
const doors = await readDeclaredDoors(build.root);
|
|
531
|
+
|
|
532
|
+
ready.set(build.id, {
|
|
533
|
+
device: device.device,
|
|
534
|
+
facts,
|
|
535
|
+
appPath: found.appPath,
|
|
536
|
+
probe: probe.ok ? probe.dylib : null,
|
|
537
|
+
probeWhy: probe.why,
|
|
538
|
+
steady,
|
|
539
|
+
doors: doors.doors,
|
|
540
|
+
scratch,
|
|
541
|
+
config,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
build,
|
|
546
|
+
root: scratch,
|
|
547
|
+
ready: true,
|
|
548
|
+
why: `${facts.name} ${facts.version} (${facts.build}) is on the simulator called ${device.device.name}, running ${device.device.runtimeName}. ${device.why} ${probe.ok ? 'The screen can be read by meaning.' : `The screen CANNOT be read by meaning: ${probe.why} Only pictures, logs, crashes and the files it writes are being checked, which is much less than it sounds.`}`,
|
|
549
|
+
facts: {
|
|
550
|
+
device: device.device.name,
|
|
551
|
+
udid: device.device.udid,
|
|
552
|
+
runtime: device.device.runtimeName,
|
|
553
|
+
bundleId: facts.bundleId,
|
|
554
|
+
version: facts.version,
|
|
555
|
+
readsMeaning: probe.ok,
|
|
556
|
+
weBootedIt: device.device.weBootedIt,
|
|
557
|
+
},
|
|
558
|
+
dispose: async () => {
|
|
559
|
+
const kept = ready.get(build.id);
|
|
560
|
+
ready.delete(build.id);
|
|
561
|
+
if (kept?.device) await releaseDevice(kept.device, { signal: ctx.signal });
|
|
562
|
+
await fsp.rm(scratch, { recursive: true, force: true });
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Walk one journey and write down what was seen.
|
|
569
|
+
*
|
|
570
|
+
* @param {Journey} journey
|
|
571
|
+
* @param {PreparedBuild} build
|
|
572
|
+
* @param {RunContext} ctx
|
|
573
|
+
* @returns {Promise<Observation[]>}
|
|
574
|
+
*/
|
|
575
|
+
async run(journey, build, ctx) {
|
|
576
|
+
const kept = ready.get(build.build.id);
|
|
577
|
+
if (!kept) {
|
|
578
|
+
return [notCovered({
|
|
579
|
+
channel: 'meaning',
|
|
580
|
+
path: joinPath('screen', journey.name, 'walked'),
|
|
581
|
+
reason: 'not supported here',
|
|
582
|
+
says: 'This build was never got ready, so nothing about it could be walked.',
|
|
583
|
+
})];
|
|
584
|
+
}
|
|
585
|
+
if (journey.skip) {
|
|
586
|
+
return [notCovered({
|
|
587
|
+
channel: 'meaning',
|
|
588
|
+
path: joinPath('screen', journey.name, 'walked'),
|
|
589
|
+
reason: 'missing tool',
|
|
590
|
+
says: journey.skip,
|
|
591
|
+
})];
|
|
592
|
+
}
|
|
593
|
+
if (journey.name === 'what-the-app-declares') {
|
|
594
|
+
return declaredObservations(kept.facts, kept.doors, journey.name);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return walkObservations(journey, kept, ctx);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
async teardown() {
|
|
601
|
+
for (const [id, kept] of ready.entries()) {
|
|
602
|
+
if (kept?.device) await releaseDevice(kept.device);
|
|
603
|
+
ready.delete(id);
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// What the app declares
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The contract channel: what the app says about itself, and what its code names.
|
|
614
|
+
*
|
|
615
|
+
* Nothing is run to produce any of this. It is the cheapest and the most complete
|
|
616
|
+
* observation in the lane, and every one of these paths is a door — an address a stranger
|
|
617
|
+
* can hand the app, a permission a person will be asked for, a control the app promised
|
|
618
|
+
* exists. When a walk never reaches one of them, the ledger shows it as a door nobody
|
|
619
|
+
* opened rather than pretending the check went deeper than it did.
|
|
620
|
+
*
|
|
621
|
+
* @param {AppFacts} facts
|
|
622
|
+
* @param {{id: string, file: string, line: number}[]} doors
|
|
623
|
+
* @param {string} journey
|
|
624
|
+
* @returns {Observation[]}
|
|
625
|
+
*/
|
|
626
|
+
export function declaredObservations(facts, doors, journey) {
|
|
627
|
+
/** @type {Observation[]} */
|
|
628
|
+
const out = [];
|
|
629
|
+
const say = /** @param {string} text */ (text) => text;
|
|
630
|
+
|
|
631
|
+
out.push(observation({
|
|
632
|
+
channel: 'contract', journey, surface: 'ios',
|
|
633
|
+
path: joinPath('app', 'identity'),
|
|
634
|
+
value: { bundleId: facts.bundleId, name: facts.name, minimumOS: facts.minimumOS, families: facts.deviceFamilies },
|
|
635
|
+
says: say(`The app calls itself ${facts.name}, its identifier is ${facts.bundleId}, and it needs iOS ${facts.minimumOS || 'an unstated version'} or later.`),
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
out.push(observation({
|
|
639
|
+
channel: 'contract', journey, surface: 'ios',
|
|
640
|
+
path: joinPath('app', 'version'),
|
|
641
|
+
value: { version: facts.version, build: facts.build },
|
|
642
|
+
says: say(`The version people see is ${facts.version} and the build number is ${facts.build}.`),
|
|
643
|
+
}));
|
|
644
|
+
|
|
645
|
+
for (const scheme of facts.urlSchemes) {
|
|
646
|
+
out.push(observation({
|
|
647
|
+
channel: 'contract', journey, surface: 'ios',
|
|
648
|
+
path: joinPath('route', tidySegment(`${scheme}:`, 'an address with no name'), 'declared'),
|
|
649
|
+
value: true,
|
|
650
|
+
says: say(`Any other app on the phone can open this app with an address beginning "${scheme}:". That is a door strangers can knock on, and it should still be there after a change.`),
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
for (const permission of facts.permissions) {
|
|
655
|
+
const plain = permission.key.replace(/^NS/, '').replace(/UsageDescription$/, '').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
|
|
656
|
+
out.push(observation({
|
|
657
|
+
channel: 'contract', journey, surface: 'ios',
|
|
658
|
+
path: joinPath('permission', tidySegment(permission.key), 'reason'),
|
|
659
|
+
value: permission.reason,
|
|
660
|
+
says: say(`Before using the ${plain}, the app asks the person and shows them these words: "${permission.reason}". If those words change, every person who is asked sees something different.`),
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
for (const mode of facts.backgroundModes) {
|
|
665
|
+
out.push(observation({
|
|
666
|
+
channel: 'contract', journey, surface: 'ios',
|
|
667
|
+
path: joinPath('background', tidySegment(mode), 'declared'),
|
|
668
|
+
value: true,
|
|
669
|
+
says: say(`The app claims it needs to keep doing "${mode}" while it is not on screen.`),
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const door of doors) {
|
|
674
|
+
out.push(observation({
|
|
675
|
+
channel: 'contract', journey, surface: 'ios',
|
|
676
|
+
path: joinPath('door', tidySegment(door.id), 'declared'),
|
|
677
|
+
value: true,
|
|
678
|
+
says: say(`The code names a control called "${door.id}". It is a door: something the app was built so a person or a test could point at.`),
|
|
679
|
+
where: { file: door.file, line: door.line },
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
out.push(observation({
|
|
684
|
+
channel: 'counters', journey, surface: 'ios',
|
|
685
|
+
path: joinPath('count', 'doors', 'declared'),
|
|
686
|
+
value: countBucket(doors.length),
|
|
687
|
+
says: say(`${doors.length} named control${doors.length === 1 ? '' : 's'} were read straight out of the code, without running anything.`),
|
|
688
|
+
}));
|
|
689
|
+
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Walking
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Install the build, start it, walk the steps and write down everything.
|
|
699
|
+
*
|
|
700
|
+
* @param {Journey} journey
|
|
701
|
+
* @param {any} kept
|
|
702
|
+
* @param {RunContext} ctx
|
|
703
|
+
* @returns {Promise<Observation[]>}
|
|
704
|
+
*/
|
|
705
|
+
async function walkObservations(journey, kept, ctx) {
|
|
706
|
+
/** @type {Observation[]} */
|
|
707
|
+
const out = [];
|
|
708
|
+
const log = ctx.log ?? (() => {});
|
|
709
|
+
const name = journey.name;
|
|
710
|
+
const startedAt = Date.now();
|
|
711
|
+
|
|
712
|
+
await resetBetweenBuilds({
|
|
713
|
+
udid: kept.device.udid,
|
|
714
|
+
bundleId: kept.facts.bundleId,
|
|
715
|
+
how: kept.config.reset === 'erase' ? 'erase' : 'reinstall',
|
|
716
|
+
signal: ctx.signal,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
log(`Starting ${kept.facts.name} on ${kept.device.name} for "${journey.describe}".`);
|
|
720
|
+
const opened = await openApp({
|
|
721
|
+
udid: kept.device.udid,
|
|
722
|
+
appPath: kept.appPath,
|
|
723
|
+
scratchDir: kept.scratch,
|
|
724
|
+
probeDylib: kept.probe ?? undefined,
|
|
725
|
+
env: { STAYSFIXED_SEED: String(ctx.seed), STAYSFIXED_CLOCK: ctx.clock },
|
|
726
|
+
signal: ctx.signal,
|
|
727
|
+
log,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
if (!opened.ok || !opened.app) {
|
|
731
|
+
out.push(notCovered({
|
|
732
|
+
channel: 'meaning',
|
|
733
|
+
path: joinPath('screen', name, 'started'),
|
|
734
|
+
reason: 'crashed',
|
|
735
|
+
says: `The app could not be started for this walk, so nothing about it was seen. ${opened.why}`,
|
|
736
|
+
}));
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
const app = opened.app;
|
|
740
|
+
const executable = path.basename(app.appPath, '.app');
|
|
741
|
+
|
|
742
|
+
// NO TIMING IS REPORTED HERE, AND THAT IS A DECISION.
|
|
743
|
+
//
|
|
744
|
+
// It was tried first and it was measured: across three runs of the SAME app on the same
|
|
745
|
+
// device, one walk took 16 seconds, one 26 and one 32 - and the coarsest ladder the tool
|
|
746
|
+
// has puts those in three different buckets, so the tool reported a slowdown on a build
|
|
747
|
+
// where nothing about the app had changed at all. The reason is that almost all of that
|
|
748
|
+
// time is OURS: copying the bundle, installing it, waiting for the app to answer, and
|
|
749
|
+
// whatever else the Mac happens to be doing. Rubbing out our own footprint is exactly
|
|
750
|
+
// what `undoOurFootprint` exists for, and on a phone the honest amount of timing left
|
|
751
|
+
// over is none.
|
|
752
|
+
//
|
|
753
|
+
// So the hole is declared instead of filled. An agent reading this knows that a
|
|
754
|
+
// performance regression on iOS will not be caught here, rather than believing a green
|
|
755
|
+
// run covered it.
|
|
756
|
+
out.push(notCovered({
|
|
757
|
+
channel: 'counters',
|
|
758
|
+
path: joinPath('count', name, 'speed'),
|
|
759
|
+
reason: 'not supported here',
|
|
760
|
+
says: `How long this took was not compared. Nearly all of it is the time WE spent installing and starting the app, which changes by ten seconds or more between two runs of the same build, so reporting it would invent a slowdown on every other run. A real performance problem on the phone will not be caught here, and the honest thing is to say so rather than to report a number that means nothing. The walk itself took ${timeBucket(opened.timings.launch ?? 0)} to start.`,
|
|
761
|
+
}));
|
|
762
|
+
|
|
763
|
+
if (!app.probeAnswered) {
|
|
764
|
+
out.push(notCovered({
|
|
765
|
+
channel: 'meaning',
|
|
766
|
+
path: joinPath('screen', name, 'read'),
|
|
767
|
+
reason: 'missing tool',
|
|
768
|
+
says: `What this screen MEANS was not read: ${app.why} A picture was still taken, and crashes and the app's own log were still watched, but a control that stopped working would not be noticed here.`,
|
|
769
|
+
}));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** @type {Step[]} */
|
|
773
|
+
const steps = /** @type {Step[]} */ (journey.steps ?? [{ act: 'look' }]);
|
|
774
|
+
let checkpoint = 0;
|
|
775
|
+
// A door is reached once per walk, not once per checkpoint. Saying it three times because
|
|
776
|
+
// a button stayed on screen through three checkpoints would put three copies of one path
|
|
777
|
+
// into one capture, and the engine is entitled to assume a path appears once.
|
|
778
|
+
/** @type {Set<string>} */
|
|
779
|
+
const doorsReached = new Set();
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
for (const step of steps) {
|
|
783
|
+
if (ctx.signal?.aborted) break;
|
|
784
|
+
if (step.act === 'wait') {
|
|
785
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(Number(step.ms ?? 500), 10_000)));
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (step.act === 'open') {
|
|
789
|
+
const scheme = String(step.url ?? '');
|
|
790
|
+
out.push(observation({
|
|
791
|
+
channel: 'effects', journey: name, surface: 'ios',
|
|
792
|
+
path: joinPath('route', tidySegment(scheme, 'an address with no name'), 'opened'),
|
|
793
|
+
value: true,
|
|
794
|
+
says: `The app was handed the address "${scheme}" the way another app on the phone would hand it over.`,
|
|
795
|
+
}));
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (step.act === 'tap' || step.act === 'type') {
|
|
799
|
+
const target = String(step.target ?? '');
|
|
800
|
+
const done = step.act === 'tap' ? await app.tap(target) : await app.type(target, String(step.text ?? ''));
|
|
801
|
+
out.push(observation({
|
|
802
|
+
channel: 'meaning', journey: name, surface: 'ios',
|
|
803
|
+
path: joinPath('screen', name, 'did', tidySegment(`${step.act} ${target}`, step.act)),
|
|
804
|
+
value: done.ok,
|
|
805
|
+
says: done.ok
|
|
806
|
+
? `"${target}" was ${step.act === 'tap' ? 'activated the way a screen reader activates it' : 'typed into'}.`
|
|
807
|
+
: `"${target}" could not be ${step.act === 'tap' ? 'activated' : 'typed into'}: ${done.why} A control that used to be there and now is not is exactly the kind of break this tool exists to catch.`,
|
|
808
|
+
}));
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
checkpoint += 1;
|
|
813
|
+
const label = step.note ? String(step.note) : `checkpoint ${checkpoint}`;
|
|
814
|
+
const settled = await settleTree(() => app.tree(), { signal: ctx.signal });
|
|
815
|
+
const things = flattenMeaning(settled.tree);
|
|
816
|
+
|
|
817
|
+
if (!settled.settled) {
|
|
818
|
+
out.push(notCovered({
|
|
819
|
+
channel: 'meaning',
|
|
820
|
+
path: joinPath('screen', name, tidySegment(label, 'a checkpoint'), 'settled'),
|
|
821
|
+
reason: 'timed out',
|
|
822
|
+
says: settled.why,
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
for (const thing of things) {
|
|
827
|
+
out.push(observation({
|
|
828
|
+
channel: 'meaning', journey: name, surface: 'ios',
|
|
829
|
+
path: joinPath('screen', name, tidySegment(label, 'a checkpoint'), tidySegment(thing.address, 'something with no name')),
|
|
830
|
+
value: thing.value,
|
|
831
|
+
says: `On ${label}, ${thing.says}.`,
|
|
832
|
+
}));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
out.push(observation({
|
|
836
|
+
channel: 'counters', journey: name, surface: 'ios',
|
|
837
|
+
path: joinPath('count', name, tidySegment(label, 'a checkpoint'), 'things on screen'),
|
|
838
|
+
value: countBucket(things.length),
|
|
839
|
+
says: `${things.length} thing${things.length === 1 ? '' : 's'} a person could perceive were on ${label}.`,
|
|
840
|
+
}));
|
|
841
|
+
|
|
842
|
+
const reached = new Set(things.map((t) => t.address).filter((a) => a.startsWith('#')).map((a) => a.slice(1).replace(/ \(\d+\)$/, '')));
|
|
843
|
+
for (const door of kept.doors) {
|
|
844
|
+
if (reached.has(door.id) && !doorsReached.has(door.id)) {
|
|
845
|
+
doorsReached.add(door.id);
|
|
846
|
+
out.push(observation({
|
|
847
|
+
channel: 'meaning', journey: name, surface: 'ios',
|
|
848
|
+
path: joinPath('door', tidySegment(door.id), 'reached'),
|
|
849
|
+
value: true,
|
|
850
|
+
says: `The control the code calls "${door.id}" was actually on screen during this walk, so it is genuinely covered rather than only declared.`,
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const picture = path.join(ctx.evidenceDir, `ios-${name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
|
|
856
|
+
const shot = await app.screenshot(picture);
|
|
857
|
+
if (shot.ok) {
|
|
858
|
+
const size = await fsp.stat(shot.path).then((s) => s.size).catch(() => 0);
|
|
859
|
+
out.push(observation({
|
|
860
|
+
channel: 'pixels', journey: name, surface: 'ios',
|
|
861
|
+
path: joinPath('shot', name, tidySegment(label, 'a checkpoint'), 'size'),
|
|
862
|
+
value: sizeBucket(size),
|
|
863
|
+
says: `A picture of ${label} was kept as evidence. It is never the reason a difference is reported - it is what you look at once something else has reported one.`,
|
|
864
|
+
evidence: shot.path,
|
|
865
|
+
}));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const traffic = await app.calls();
|
|
870
|
+
for (const call of traffic.calls) {
|
|
871
|
+
out.push(observation({
|
|
872
|
+
channel: 'effects', journey: name, surface: 'ios',
|
|
873
|
+
path: joinPath('net', tidySegment(call.method, 'GET'), tidySegment(`${call.host}${call.path}`, 'an address with no host'), 'asked'),
|
|
874
|
+
value: { method: call.method, host: call.host, path: call.path, body: sizeBucket(call.bodyBytes) },
|
|
875
|
+
says: `The app asked ${call.host} for ${call.method} ${call.path}${call.query ? ` with ${call.query}` : ''}.`,
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
878
|
+
for (const call of traffic.refused) {
|
|
879
|
+
out.push(notCovered({
|
|
880
|
+
channel: 'effects',
|
|
881
|
+
path: joinPath('net', tidySegment(call.method, 'POST'), tidySegment(`${call.host}${call.path}`, 'an address with no host'), 'asked'),
|
|
882
|
+
reason: 'irreversible',
|
|
883
|
+
says: `The app tried to send ${call.method} ${call.path} to ${call.host} with ${sizeBucket(call.bodyBytes)} of body. That reads as something that would not come back - a charge, a message, a deletion - so it was written down and stopped before it left the phone. Whether it would have WORKED is not known, and this is reported as a hole rather than as a pass.`,
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
886
|
+
out.push(observation({
|
|
887
|
+
channel: 'counters', journey: name, surface: 'ios',
|
|
888
|
+
path: joinPath('count', name, 'calls made'),
|
|
889
|
+
value: countBucket(traffic.calls.length),
|
|
890
|
+
says: `${traffic.calls.length} call${traffic.calls.length === 1 ? '' : 's'} went out during this walk, and ${traffic.refused.length} ${traffic.refused.length === 1 ? 'was' : 'were'} stopped.`,
|
|
891
|
+
}));
|
|
892
|
+
|
|
893
|
+
const files = await app.filesWritten();
|
|
894
|
+
for (const file of files) {
|
|
895
|
+
out.push(observation({
|
|
896
|
+
channel: 'effects', journey: name, surface: 'ios',
|
|
897
|
+
path: joinPath('file', tidySegment(file, 'a file with no name'), 'written'),
|
|
898
|
+
value: true,
|
|
899
|
+
says: `The app wrote "${file}" inside its own folder on the phone. Only the name is compared - the contents of a database change every run and would drown everything else.`,
|
|
900
|
+
}));
|
|
901
|
+
}
|
|
902
|
+
out.push(observation({
|
|
903
|
+
channel: 'counters', journey: name, surface: 'ios',
|
|
904
|
+
path: joinPath('count', name, 'files written'),
|
|
905
|
+
value: countBucket(files.length),
|
|
906
|
+
says: `${files.length} file${files.length === 1 ? '' : 's'} were left behind in the app's own folder.`,
|
|
907
|
+
}));
|
|
908
|
+
|
|
909
|
+
const complaints = await readAppLog({
|
|
910
|
+
udid: kept.device.udid,
|
|
911
|
+
bundleId: kept.facts.bundleId,
|
|
912
|
+
processName: executable,
|
|
913
|
+
alsoProcess: kept.config.logProcess === true,
|
|
914
|
+
sinceMs: Date.now() - startedAt,
|
|
915
|
+
signal: ctx.signal,
|
|
916
|
+
});
|
|
917
|
+
/** @type {Map<string, number>} */
|
|
918
|
+
const grouped = new Map();
|
|
919
|
+
for (const line of complaints.lines) {
|
|
920
|
+
const key = `${line.level}|${softenNumbers(line.text)}`;
|
|
921
|
+
grouped.set(key, (grouped.get(key) ?? 0) + 1);
|
|
922
|
+
}
|
|
923
|
+
for (const [key, count] of grouped) {
|
|
924
|
+
const [level, text] = key.split('|');
|
|
925
|
+
out.push(observation({
|
|
926
|
+
channel: 'complaints', journey: name, surface: 'ios',
|
|
927
|
+
path: joinPath('log', name, tidySegment(level, 'said'), tidySegment(text.slice(0, 120), 'an empty line')),
|
|
928
|
+
value: countBucket(count),
|
|
929
|
+
says: `The app itself said "${text}"${count > 1 ? `, ${count} times` : ''}${level === 'error' || level === 'fault' ? ' — and it said it as an error' : ''}.`,
|
|
930
|
+
}));
|
|
931
|
+
}
|
|
932
|
+
if (!complaints.ok) {
|
|
933
|
+
out.push(notCovered({
|
|
934
|
+
channel: 'complaints',
|
|
935
|
+
path: joinPath('log', name, 'read'),
|
|
936
|
+
reason: 'missing tool',
|
|
937
|
+
says: complaints.why,
|
|
938
|
+
}));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const fell = await readCrashes({ processName: executable, since: startedAt });
|
|
942
|
+
for (const crash of fell.crashes) {
|
|
943
|
+
out.push(observation({
|
|
944
|
+
channel: 'complaints', journey: name, surface: 'ios',
|
|
945
|
+
path: joinPath('crash', name, tidySegment(crash.reason, 'no reason given')),
|
|
946
|
+
value: true,
|
|
947
|
+
says: `The app crashed during this walk: ${crash.reason}. A crash is never waved through by an agent - it goes to a person.`,
|
|
948
|
+
}));
|
|
949
|
+
}
|
|
950
|
+
out.push(observation({
|
|
951
|
+
channel: 'complaints', journey: name, surface: 'ios',
|
|
952
|
+
path: joinPath('crash', name, 'count'),
|
|
953
|
+
value: fell.crashes.length,
|
|
954
|
+
says: fell.why,
|
|
955
|
+
}));
|
|
956
|
+
|
|
957
|
+
out.push(observation({
|
|
958
|
+
channel: 'counters', journey: name, surface: 'ios',
|
|
959
|
+
path: joinPath('count', name, 'doors opened'),
|
|
960
|
+
value: countBucket(doorsReached.size),
|
|
961
|
+
says: `Of the ${kept.doors.length} named control${kept.doors.length === 1 ? '' : 's'} this app declares, ${doorsReached.size} ${doorsReached.size === 1 ? 'was' : 'were'} actually on screen during this walk. The rest are doors nobody opened, and they are not being checked by anything.`,
|
|
962
|
+
}));
|
|
963
|
+
|
|
964
|
+
} finally {
|
|
965
|
+
await app.close();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Rub out the numbers inside a log line so the same message twice is the same message.
|
|
973
|
+
*
|
|
974
|
+
* A log line with a counter, an id or a duration in it produces a brand new path every
|
|
975
|
+
* single run, and a hundred of those bury the one line that actually changed. The number is
|
|
976
|
+
* replaced rather than dropped, so a line that gained a number still reads as different.
|
|
977
|
+
*
|
|
978
|
+
* @param {string} text
|
|
979
|
+
* @returns {string}
|
|
980
|
+
*/
|
|
981
|
+
export function softenNumbers(text) {
|
|
982
|
+
return String(text)
|
|
983
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<an id>')
|
|
984
|
+
.replace(/\b0x[0-9a-f]+\b/gi, '<an address>')
|
|
985
|
+
.replace(/#[0-9a-f]{6,}\b/gi, '#<a connection>')
|
|
986
|
+
.replace(/\b\d+(\.\d+)?(ms|s|kb|mb)\b/gi, '<a measurement>')
|
|
987
|
+
.replace(/\b\d{2,}\b/g, '<a number>')
|
|
988
|
+
.trim();
|
|
989
|
+
}
|