staysfixed 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/adapters/ios.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
* it rather than quietly walking a thinner path.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
import crypto from 'node:crypto';
|
|
46
47
|
import fsp from 'node:fs/promises';
|
|
47
48
|
import path from 'node:path';
|
|
48
49
|
|
|
@@ -103,6 +104,44 @@ const ready = new Map();
|
|
|
103
104
|
// Reading the doors out of the source
|
|
104
105
|
// ---------------------------------------------------------------------------
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* How many named controls are collected before the search gives up.
|
|
109
|
+
*
|
|
110
|
+
* It protects memory and the size of a capture: every one of these becomes an address that is
|
|
111
|
+
* stored twice per run. Four thousand is far more than any app this has been pointed at
|
|
112
|
+
* declares. If it is ever wrong the run does not go quiet — hitting it is reported as a hole
|
|
113
|
+
* and the count is described as a floor rather than a total.
|
|
114
|
+
*/
|
|
115
|
+
const MOST_DOORS = 4000;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* How deep into the project's folders the search for named controls goes.
|
|
119
|
+
*
|
|
120
|
+
* Symbolic links are not followed — `entry.isDirectory()` is false for one — so this is not
|
|
121
|
+
* protecting against a loop; it is protecting against spending a long time in a tree where
|
|
122
|
+
* the interesting code is nowhere near the bottom. Twenty-four is deeper than any iPhone
|
|
123
|
+
* project seen so far, and it used to be twelve, which a monorepo passes on its way to the
|
|
124
|
+
* app. Whatever is left out at this depth is named.
|
|
125
|
+
*/
|
|
126
|
+
const DEEPEST_SOURCE_FOLDER = 24;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Why something would not open, in words somebody can act on. A bare `EACCES` sends people
|
|
130
|
+
* looking for a bug in this tool.
|
|
131
|
+
*
|
|
132
|
+
* @param {unknown} error
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function whyNotOpened(error) {
|
|
136
|
+
const code = String(/** @type {any} */ (error)?.code ?? '');
|
|
137
|
+
if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
|
|
138
|
+
if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
|
|
139
|
+
if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
|
|
140
|
+
if (code === 'ELOOP') return 'the links in it point round in a circle';
|
|
141
|
+
const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
|
|
142
|
+
return said === '' ? 'the reason was not given' : said;
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
/**
|
|
107
146
|
* Every control the app declares by name, read straight out of the code.
|
|
108
147
|
*
|
|
@@ -112,31 +151,56 @@ const ready = new Map();
|
|
|
112
151
|
* of what the app was built to expose, which is the only honest denominator for "how deep
|
|
113
152
|
* is this check really".
|
|
114
153
|
*
|
|
154
|
+
* EVERY ONE OF THE LIMITS HERE IS REPORTED. There are three — how deep the folders go, how
|
|
155
|
+
* many controls are collected, and anything that would not open — and until 2026-08-30 all
|
|
156
|
+
* three ended with a bare `return` or `continue`. A run that stopped at four thousand
|
|
157
|
+
* controls, or at a folder it had no permission on, handed back a list that looked exactly
|
|
158
|
+
* like a complete one, and the ledger counted its coverage against a denominator that was
|
|
159
|
+
* quietly wrong. Whatever is left out now comes back on `limits`, in sentences, and the
|
|
160
|
+
* caller turns each one into a hole.
|
|
161
|
+
*
|
|
115
162
|
* @param {string} root
|
|
116
|
-
* @param {{limit?: number}} [opts]
|
|
117
|
-
* @returns {Promise<{doors: {id: string, file: string, line: number}[], filesRead: number, tests: string[]}>}
|
|
163
|
+
* @param {{limit?: number, deepest?: number}} [opts]
|
|
164
|
+
* @returns {Promise<{doors: {id: string, file: string, line: number}[], filesRead: number, tests: string[], limits: string[]}>}
|
|
118
165
|
*/
|
|
119
166
|
export async function readDeclaredDoors(root, opts = {}) {
|
|
120
|
-
const limit = opts.limit ??
|
|
167
|
+
const limit = opts.limit ?? MOST_DOORS;
|
|
168
|
+
const deepest = opts.deepest ?? DEEPEST_SOURCE_FOLDER;
|
|
121
169
|
/** @type {{id: string, file: string, line: number}[]} */
|
|
122
170
|
const doors = [];
|
|
123
171
|
/** @type {string[]} */
|
|
124
172
|
const tests = [];
|
|
173
|
+
/** @type {string[]} */
|
|
174
|
+
const limits = [];
|
|
175
|
+
/** @type {string[]} */
|
|
176
|
+
const tooDeep = [];
|
|
125
177
|
let filesRead = 0;
|
|
178
|
+
let stoppedAtLimit = false;
|
|
126
179
|
const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage', 'DerivedData', 'build', '.build', 'dist', 'vendor']);
|
|
127
180
|
|
|
128
181
|
/** @param {string} dir @param {number} depth */
|
|
129
182
|
const walk = async (dir, depth) => {
|
|
130
|
-
if (
|
|
183
|
+
if (doors.length > limit) {
|
|
184
|
+
stoppedAtLimit = true;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (depth > deepest) {
|
|
188
|
+
tooDeep.push(path.relative(root, dir) || '.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
131
191
|
/** @type {import('node:fs').Dirent[]} */
|
|
132
192
|
let entries = [];
|
|
133
193
|
try {
|
|
134
194
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
135
|
-
} catch {
|
|
195
|
+
} catch (error) {
|
|
196
|
+
limits.push(`"${path.relative(root, dir) || '.'}" could not be opened — ${whyNotOpened(error)} — so any control named anywhere inside it is invisible to this run.`);
|
|
136
197
|
return;
|
|
137
198
|
}
|
|
138
199
|
for (const entry of entries) {
|
|
139
|
-
if (doors.length > limit)
|
|
200
|
+
if (doors.length > limit) {
|
|
201
|
+
stoppedAtLimit = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
140
204
|
const full = path.join(dir, entry.name);
|
|
141
205
|
if (entry.isDirectory()) {
|
|
142
206
|
if (skip.has(entry.name) || entry.name.startsWith('.') || entry.name.endsWith('.app')) continue;
|
|
@@ -148,7 +212,8 @@ export async function readDeclaredDoors(root, opts = {}) {
|
|
|
148
212
|
let text = '';
|
|
149
213
|
try {
|
|
150
214
|
text = await fsp.readFile(full, 'utf8');
|
|
151
|
-
} catch {
|
|
215
|
+
} catch (error) {
|
|
216
|
+
limits.push(`"${path.relative(root, full)}" could not be read — ${whyNotOpened(error)} — so any control it names is invisible to this run.`);
|
|
152
217
|
continue;
|
|
153
218
|
}
|
|
154
219
|
filesRead += 1;
|
|
@@ -171,13 +236,37 @@ export async function readDeclaredDoors(root, opts = {}) {
|
|
|
171
236
|
};
|
|
172
237
|
await walk(root, 0);
|
|
173
238
|
|
|
239
|
+
if (stoppedAtLimit) {
|
|
240
|
+
limits.push(
|
|
241
|
+
`The search stopped after ${limit} named controls, so any beyond that were not read. ` +
|
|
242
|
+
'The count of controls this app declares is therefore a floor, not a total, and the share of them a walk reached is measured against the wrong denominator.'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (tooDeep.length > 0) {
|
|
246
|
+
limits.push(
|
|
247
|
+
`${tooDeep.length} folder${tooDeep.length === 1 ? '' : 's'} sat more than ${deepest} deep and ${tooDeep.length === 1 ? 'was' : 'were'} not looked in: ` +
|
|
248
|
+
`${tooDeep.slice(0, 3).join(', ')}${tooDeep.length > 3 ? `, and ${tooDeep.length - 3} more` : ''}. Any control named inside is invisible to this run.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
174
252
|
/** @type {Map<string, {id: string, file: string, line: number}>} */
|
|
175
253
|
const unique = new Map();
|
|
176
254
|
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() };
|
|
255
|
+
return { doors: [...unique.values()].sort((a, b) => a.id.localeCompare(b.id)), filesRead, tests: tests.sort(), limits };
|
|
178
256
|
}
|
|
179
257
|
|
|
180
258
|
|
|
259
|
+
/**
|
|
260
|
+
* How long one piece of an address may be.
|
|
261
|
+
*
|
|
262
|
+
* A path has a length limit of its own and a label read off a screen can be a paragraph, so
|
|
263
|
+
* something has to give. What this protects against is one runaway label taking a whole run
|
|
264
|
+
* down; what would break if it were wrong is only how much of a name a reader sees, because
|
|
265
|
+
* anything cut off leaves a digest of the whole behind and two different things can never
|
|
266
|
+
* land on one address.
|
|
267
|
+
*/
|
|
268
|
+
const LONGEST_SEGMENT = 160;
|
|
269
|
+
|
|
181
270
|
/**
|
|
182
271
|
* Make one piece of the app's own words safe to use as an address.
|
|
183
272
|
*
|
|
@@ -188,15 +277,40 @@ export async function readDeclaredDoors(root, opts = {}) {
|
|
|
188
277
|
* down, the words are trimmed, folded onto one line, and replaced with a plain description
|
|
189
278
|
* when there is nothing left of them.
|
|
190
279
|
*
|
|
280
|
+
* IT IS CUT WITH A FINGERPRINT, NEVER CUT ALONE. This used to end in `.slice(0, 160)`, and
|
|
281
|
+
* cutting an address merges two things into one: two log lines or two labels that agree for
|
|
282
|
+
* a hundred and sixty characters became one address, so the second answer written there was
|
|
283
|
+
* thrown away and whatever it said was never compared with anything. A short digest of the
|
|
284
|
+
* whole text goes on the end instead, so long-and-different stays different while
|
|
285
|
+
* long-and-identical stays identical. The desktop lane has done this since it was written;
|
|
286
|
+
* this is the same idea, and it should have been here from the start.
|
|
287
|
+
*
|
|
191
288
|
* @param {unknown} text
|
|
192
289
|
* @param {string} [whenEmpty]
|
|
193
290
|
* @returns {string}
|
|
194
291
|
*/
|
|
195
292
|
export function tidySegment(text, whenEmpty = 'unnamed') {
|
|
196
293
|
const out = String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
197
|
-
|
|
294
|
+
if (out === '') return whenEmpty;
|
|
295
|
+
if (out.length <= LONGEST_SEGMENT) return out;
|
|
296
|
+
const mark = crypto.createHash('sha256').update(out).digest('hex').slice(0, 8);
|
|
297
|
+
return `${out.slice(0, LONGEST_SEGMENT - 12).trim()}… (${mark})`;
|
|
198
298
|
}
|
|
199
299
|
|
|
300
|
+
/**
|
|
301
|
+
* How many built app bundles are collected before the search gives up, and how deep it goes.
|
|
302
|
+
*
|
|
303
|
+
* Neither protects against anything unbounded — symbolic links are not followed — so both are
|
|
304
|
+
* about time: a project with a large `build` folder can hold a great many bundles, and reading
|
|
305
|
+
* every one of them to pick the first is wasted work. Twelve deep clears a monorepo whose app
|
|
306
|
+
* sits under `apps/ios/build/Build/Products/Debug-iphonesimulator`; eight, which it used to
|
|
307
|
+
* be, does not. Hitting either is said out loud in the sentence this hands back.
|
|
308
|
+
*/
|
|
309
|
+
const MOST_APP_CANDIDATES = 40;
|
|
310
|
+
|
|
311
|
+
/** @see MOST_APP_CANDIDATES */
|
|
312
|
+
const DEEPEST_APP_FOLDER = 12;
|
|
313
|
+
|
|
200
314
|
/**
|
|
201
315
|
* Find the built app.
|
|
202
316
|
*
|
|
@@ -206,32 +320,51 @@ export function tidySegment(text, whenEmpty = 'unnamed') {
|
|
|
206
320
|
* `.app` is looked for where builds land, and when there isn't one the answer is a clear
|
|
207
321
|
* sentence saying which command would make one — never a silent skip.
|
|
208
322
|
*
|
|
323
|
+
* WHERE IT STOPPED LOOKING IS PART OF THE ANSWER. The search gave up at eight folders deep
|
|
324
|
+
* or forty candidates and said nothing about either, so "no built iPhone app was found under
|
|
325
|
+
* this project" was said with equal confidence about a project with no app in it and about a
|
|
326
|
+
* monorepo whose app sits one folder past where the search turned round. Both of those end
|
|
327
|
+
* the same way — the phone is not checked — and only one of them is the person's fault.
|
|
328
|
+
*
|
|
209
329
|
* @param {string} root
|
|
210
330
|
* @param {Record<string, any>} [config]
|
|
211
|
-
* @returns {Promise<{ok: boolean, appPath: string, why: string, candidates: string[]}>}
|
|
331
|
+
* @returns {Promise<{ok: boolean, appPath: string, why: string, candidates: string[], limits: string[]}>}
|
|
212
332
|
*/
|
|
213
333
|
export async function findAppBundle(root, config = {}) {
|
|
214
334
|
if (config.app) {
|
|
215
335
|
const full = path.isAbsolute(config.app) ? config.app : path.join(root, config.app);
|
|
216
336
|
try {
|
|
217
337
|
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] };
|
|
338
|
+
return { ok: true, appPath: full, why: `Using the app named in the settings: ${full}`, candidates: [full], limits: [] };
|
|
219
339
|
} catch {
|
|
220
|
-
return { ok: false, appPath: '', why: `The settings point at "${config.app}" but there is no iPhone app bundle there.`, candidates: [] };
|
|
340
|
+
return { ok: false, appPath: '', why: `The settings point at "${config.app}" but there is no iPhone app bundle there.`, candidates: [], limits: [] };
|
|
221
341
|
}
|
|
222
342
|
}
|
|
223
343
|
|
|
224
344
|
/** @type {string[]} */
|
|
225
345
|
const found = [];
|
|
346
|
+
/** @type {string[]} */
|
|
347
|
+
const limits = [];
|
|
348
|
+
/** @type {string[]} */
|
|
349
|
+
const tooDeep = [];
|
|
350
|
+
let stoppedAtLimit = false;
|
|
226
351
|
const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage']);
|
|
227
352
|
/** @param {string} dir @param {number} depth */
|
|
228
353
|
const walk = async (dir, depth) => {
|
|
229
|
-
if (
|
|
354
|
+
if (found.length > MOST_APP_CANDIDATES) {
|
|
355
|
+
stoppedAtLimit = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (depth > DEEPEST_APP_FOLDER) {
|
|
359
|
+
tooDeep.push(path.relative(root, dir) || '.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
230
362
|
/** @type {import('node:fs').Dirent[]} */
|
|
231
363
|
let entries = [];
|
|
232
364
|
try {
|
|
233
365
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
234
|
-
} catch {
|
|
366
|
+
} catch (error) {
|
|
367
|
+
limits.push(`"${path.relative(root, dir) || '.'}" could not be opened — ${whyNotOpened(error)} — so a built app inside it was not found.`);
|
|
235
368
|
return;
|
|
236
369
|
}
|
|
237
370
|
for (const entry of entries) {
|
|
@@ -252,23 +385,39 @@ export async function findAppBundle(root, config = {}) {
|
|
|
252
385
|
};
|
|
253
386
|
await walk(root, 0);
|
|
254
387
|
|
|
388
|
+
if (stoppedAtLimit) {
|
|
389
|
+
limits.push(`The search stopped after ${MOST_APP_CANDIDATES} app bundles, so anywhere it had not reached by then was not looked at.`);
|
|
390
|
+
}
|
|
391
|
+
if (tooDeep.length > 0) {
|
|
392
|
+
limits.push(
|
|
393
|
+
`${tooDeep.length} folder${tooDeep.length === 1 ? '' : 's'} sat more than ${DEEPEST_APP_FOLDER} deep and ${tooDeep.length === 1 ? 'was' : 'were'} not looked in: ` +
|
|
394
|
+
`${tooDeep.slice(0, 3).join(', ')}${tooDeep.length > 3 ? `, and ${tooDeep.length - 3} more` : ''}.`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
// Where the search stopped goes into the sentence, always. "Nothing was found" and "nothing
|
|
398
|
+
// was found where I looked" are different answers, and only the second one tells somebody
|
|
399
|
+
// to point at the app by hand.
|
|
400
|
+
const said = limits.length > 0 ? ` Where this search stopped: ${limits.join(' ')}` : '';
|
|
401
|
+
|
|
255
402
|
const simulatorBuilds = found.filter((f) => /iphonesimulator|Debug-iphonesimulator|Release-iphonesimulator|Build\/Products/i.test(f));
|
|
256
403
|
const pick = simulatorBuilds[0] ?? found[0];
|
|
257
404
|
if (!pick) {
|
|
258
405
|
return {
|
|
259
406
|
ok: false,
|
|
260
407
|
appPath: '',
|
|
261
|
-
why:
|
|
408
|
+
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.${said}`,
|
|
262
409
|
candidates: [],
|
|
410
|
+
limits,
|
|
263
411
|
};
|
|
264
412
|
}
|
|
265
413
|
return {
|
|
266
414
|
ok: true,
|
|
267
415
|
appPath: pick,
|
|
268
|
-
why: found.length === 1
|
|
416
|
+
why: (found.length === 1
|
|
269
417
|
? `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
|
|
418
|
+
: `Found ${found.length} built apps and picked the simulator one: ${path.basename(pick)}. Name a different one with {"app": "..."} in the settings.`) + said,
|
|
271
419
|
candidates: found,
|
|
420
|
+
limits,
|
|
272
421
|
};
|
|
273
422
|
}
|
|
274
423
|
|
|
@@ -360,7 +509,15 @@ export function journeysFrom(input) {
|
|
|
360
509
|
* @returns {string}
|
|
361
510
|
*/
|
|
362
511
|
function safeName(name) {
|
|
363
|
-
|
|
512
|
+
const clean = String(name).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
513
|
+
if (clean === '') return 'a-walk';
|
|
514
|
+
if (clean.length <= 60) return clean;
|
|
515
|
+
// Two journeys whose names agree for sixty characters used to end up with one name between
|
|
516
|
+
// them, and a journey's name is the first segment of every address it writes: the second
|
|
517
|
+
// walk's answers landed on the first walk's addresses, and one of the two was thrown away
|
|
518
|
+
// at the door. The digest is of the whole name, so this cannot happen however long they are.
|
|
519
|
+
const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
|
|
520
|
+
return `${clean.slice(0, 51)}-${mark}`;
|
|
364
521
|
}
|
|
365
522
|
|
|
366
523
|
// ---------------------------------------------------------------------------
|
|
@@ -437,7 +594,7 @@ export const iosAdapter = defineAdapter({
|
|
|
437
594
|
});
|
|
438
595
|
}
|
|
439
596
|
|
|
440
|
-
const { doors, tests, filesRead } = await readDeclaredDoors(project.root);
|
|
597
|
+
const { doors, tests, filesRead, limits } = await readDeclaredDoors(project.root);
|
|
441
598
|
/** @type {AppFacts|null} */
|
|
442
599
|
let facts = null;
|
|
443
600
|
if (found.ok) {
|
|
@@ -449,9 +606,21 @@ export const iosAdapter = defineAdapter({
|
|
|
449
606
|
missing.push({
|
|
450
607
|
what: `a way to run the app's own ${tests.length} interface test file${tests.length === 1 ? '' : 's'}`,
|
|
451
608
|
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
|
-
|
|
609
|
+
// NO SETTING IS OFFERED HERE, and that is the honest answer rather than a missing
|
|
610
|
+
// feature. This used to end "put {"suite": {"scheme": "..."}} under "ios" in the
|
|
611
|
+
// settings and they become journeys" — and nothing anywhere read `ios.suite`. Somebody
|
|
612
|
+
// following that sentence would have written the setting, seen the same message on the
|
|
613
|
+
// next run, and had no way at all to tell whether the tool or their spelling was at
|
|
614
|
+
// fault. A door that is painted on is worse than no door: it costs somebody an
|
|
615
|
+
// afternoon and it costs this tool the benefit of the doubt everywhere else.
|
|
616
|
+
howToGet:
|
|
617
|
+
'There is nothing to switch on yet. Running these needs an Xcode build of the project — a scheme, signing settings, minutes of build time — and this adapter never builds anything, because building somebody else\'s project with a guessed scheme produces a build that is not the one they meant. ' +
|
|
618
|
+
'What you can do today is run them yourself: xcodebuild test-without-building -scheme <YourScheme> -destination "platform=iOS Simulator,name=iPhone 16". They are found and counted here so the hole is visible in every run, and each one is reported as a journey that was not walked.',
|
|
453
619
|
});
|
|
454
620
|
}
|
|
621
|
+
for (const said of limits) {
|
|
622
|
+
notes.push(`${said} That makes the count of named controls above a floor rather than a total.`);
|
|
623
|
+
}
|
|
455
624
|
|
|
456
625
|
const applies = Boolean(machine.isMac) && (found.ok || doors.length > 0 || Boolean(config.app));
|
|
457
626
|
const withoutIdentifiers = doors.length === 0 && found.ok;
|
|
@@ -530,6 +699,9 @@ export const iosAdapter = defineAdapter({
|
|
|
530
699
|
const doors = await readDeclaredDoors(build.root);
|
|
531
700
|
|
|
532
701
|
ready.set(build.id, {
|
|
702
|
+
// Everything the two searches could not see, carried through to the walk so it lands in
|
|
703
|
+
// the coverage ledger rather than in a sentence nobody reads twice.
|
|
704
|
+
limits: [...doors.limits, ...found.limits],
|
|
533
705
|
device: device.device,
|
|
534
706
|
facts,
|
|
535
707
|
appPath: found.appPath,
|
|
@@ -591,7 +763,7 @@ export const iosAdapter = defineAdapter({
|
|
|
591
763
|
})];
|
|
592
764
|
}
|
|
593
765
|
if (journey.name === 'what-the-app-declares') {
|
|
594
|
-
return declaredObservations(kept.facts, kept.doors, journey.name);
|
|
766
|
+
return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
|
|
595
767
|
}
|
|
596
768
|
|
|
597
769
|
return walkObservations(journey, kept, ctx);
|
|
@@ -621,9 +793,13 @@ export const iosAdapter = defineAdapter({
|
|
|
621
793
|
* @param {AppFacts} facts
|
|
622
794
|
* @param {{id: string, file: string, line: number}[]} doors
|
|
623
795
|
* @param {string} journey
|
|
796
|
+
* @param {string[]} [limits]
|
|
797
|
+
* Everything the searches that produced `doors` could not see. Each one becomes a hole,
|
|
798
|
+
* because the count below is the denominator the ledger measures a walk's depth against,
|
|
799
|
+
* and a denominator that quietly missed a folder flatters every run made against it.
|
|
624
800
|
* @returns {Observation[]}
|
|
625
801
|
*/
|
|
626
|
-
export function declaredObservations(facts, doors, journey) {
|
|
802
|
+
export function declaredObservations(facts, doors, journey, limits = []) {
|
|
627
803
|
/** @type {Observation[]} */
|
|
628
804
|
const out = [];
|
|
629
805
|
const say = /** @param {string} text */ (text) => text;
|
|
@@ -684,9 +860,24 @@ export function declaredObservations(facts, doors, journey) {
|
|
|
684
860
|
channel: 'counters', journey, surface: 'ios',
|
|
685
861
|
path: joinPath('count', 'doors', 'declared'),
|
|
686
862
|
value: countBucket(doors.length),
|
|
687
|
-
says: say(
|
|
863
|
+
says: say(
|
|
864
|
+
`${doors.length} named control${doors.length === 1 ? '' : 's'} were read straight out of the code, without running anything.` +
|
|
865
|
+
(limits.length > 0 ? ` That is a floor rather than a total: ${limits.length} thing${limits.length === 1 ? '' : 's'} the search could not see ${limits.length === 1 ? 'is' : 'are'} listed beside this.` : '')
|
|
866
|
+
),
|
|
688
867
|
}));
|
|
689
868
|
|
|
869
|
+
for (let i = 0; i < limits.length; i += 1) {
|
|
870
|
+
out.push(notCovered({
|
|
871
|
+
channel: 'contract',
|
|
872
|
+
// Numbered rather than named after the folder: the folder is inside the sentence, and an
|
|
873
|
+
// address built out of a path that differs between two machines would report itself as a
|
|
874
|
+
// difference on every run.
|
|
875
|
+
path: joinPath('contract', 'not read', String(i + 1)),
|
|
876
|
+
reason: 'not supported here',
|
|
877
|
+
says: limits[i],
|
|
878
|
+
}));
|
|
879
|
+
}
|
|
880
|
+
|
|
690
881
|
return out;
|
|
691
882
|
}
|
|
692
883
|
|
|
@@ -823,6 +1014,20 @@ async function walkObservations(journey, kept, ctx) {
|
|
|
823
1014
|
}));
|
|
824
1015
|
}
|
|
825
1016
|
|
|
1017
|
+
// Something on this screen sat deeper than the reader goes. Everything under it is
|
|
1018
|
+
// absent from `things` below, so without this line a control that was never looked at
|
|
1019
|
+
// and a control that is not there read exactly the same.
|
|
1020
|
+
if (settled.deeper > 0) {
|
|
1021
|
+
out.push(notCovered({
|
|
1022
|
+
channel: 'meaning',
|
|
1023
|
+
path: joinPath('screen', name, tidySegment(label, 'a checkpoint'), 'read all the way down'),
|
|
1024
|
+
reason: 'not supported here',
|
|
1025
|
+
says:
|
|
1026
|
+
`${settled.deeper} thing${settled.deeper === 1 ? '' : 's'} on this screen sat deeper than the reader goes, so ${settled.deeper === 1 ? 'it and everything under it' : 'they and everything under them'} were not read. ` +
|
|
1027
|
+
'Nothing down there can be compared, tapped or typed into, and it is a hole rather than an empty part of the screen. A view hierarchy this deep is nearly always something nesting inside itself by mistake.',
|
|
1028
|
+
}));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
826
1031
|
for (const thing of things) {
|
|
827
1032
|
out.push(observation({
|
|
828
1033
|
channel: 'meaning', journey: name, surface: 'ios',
|
|
@@ -890,7 +1095,9 @@ async function walkObservations(journey, kept, ctx) {
|
|
|
890
1095
|
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
1096
|
}));
|
|
892
1097
|
|
|
893
|
-
|
|
1098
|
+
/** @type {{unreadable: string[]}} */
|
|
1099
|
+
const insideTheApp = { unreadable: [] };
|
|
1100
|
+
const files = await app.filesWritten(insideTheApp);
|
|
894
1101
|
for (const file of files) {
|
|
895
1102
|
out.push(observation({
|
|
896
1103
|
channel: 'effects', journey: name, surface: 'ios',
|
|
@@ -905,6 +1112,17 @@ async function walkObservations(journey, kept, ctx) {
|
|
|
905
1112
|
value: countBucket(files.length),
|
|
906
1113
|
says: `${files.length} file${files.length === 1 ? '' : 's'} were left behind in the app's own folder.`,
|
|
907
1114
|
}));
|
|
1115
|
+
if (insideTheApp.unreadable.length > 0) {
|
|
1116
|
+
out.push(notCovered({
|
|
1117
|
+
channel: 'effects',
|
|
1118
|
+
path: joinPath('count', name, 'folders in the app that would not open'),
|
|
1119
|
+
reason: 'not supported here',
|
|
1120
|
+
says:
|
|
1121
|
+
`${insideTheApp.unreadable.length} folder${insideTheApp.unreadable.length === 1 ? '' : 's'} inside the app's own space could not be opened, so nothing written in ${insideTheApp.unreadable.length === 1 ? 'it' : 'them'} was seen: ` +
|
|
1122
|
+
`${insideTheApp.unreadable.slice(0, 5).join(', ')}${insideTheApp.unreadable.length > 5 ? ', and more' : ''}. ` +
|
|
1123
|
+
'The count above is therefore a floor. "The app wrote nothing there" and "nobody could look" are different answers.',
|
|
1124
|
+
}));
|
|
1125
|
+
}
|
|
908
1126
|
|
|
909
1127
|
const complaints = await readAppLog({
|
|
910
1128
|
udid: kept.device.udid,
|
|
@@ -924,7 +1142,11 @@ async function walkObservations(journey, kept, ctx) {
|
|
|
924
1142
|
const [level, text] = key.split('|');
|
|
925
1143
|
out.push(observation({
|
|
926
1144
|
channel: 'complaints', journey: name, surface: 'ios',
|
|
927
|
-
|
|
1145
|
+
// The whole line, not its first hundred and twenty characters. `tidySegment` cuts it
|
|
1146
|
+
// to an address and leaves a digest of the rest behind; cutting it here first threw
|
|
1147
|
+
// that away, so two log lines agreeing for a hundred and twenty characters shared
|
|
1148
|
+
// one address and only one of them was ever compared.
|
|
1149
|
+
path: joinPath('log', name, tidySegment(level, 'said'), tidySegment(text, 'an empty line')),
|
|
928
1150
|
value: countBucket(count),
|
|
929
1151
|
says: `The app itself said "${text}"${count > 1 ? `, ${count} times` : ''}${level === 'error' || level === 'fault' ? ' — and it said it as an error' : ''}.`,
|
|
930
1152
|
}));
|