simframe 0.12.2 → 0.13.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 +123 -9
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-integration-local.sh +22 -1
- package/scripts/ci-memory.mjs +92 -14
- package/scripts/eval-fingerprint.mjs +20 -1
- package/scripts/replay-rulings.mjs +60 -1
- package/src/actions.js +96 -9
- package/src/analyze.js +56 -0
- package/src/cli.js +182 -13
- package/src/fingerprint.js +10 -1
- package/src/index.js +302 -16
- package/src/input.js +4 -0
- package/src/mcp.js +7 -1
- package/src/metrics.js +49 -6
- package/src/ollama.js +37 -9
- package/src/platform/ios.js +30 -5
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +85 -6
- package/src/store.js +53 -0
- package/src/supervisor.js +20 -2
- package/src/view.js +84 -9
package/src/platform/ios.js
CHANGED
|
@@ -178,17 +178,42 @@ function isBootedSync(udid) {
|
|
|
178
178
|
return false;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* What went wrong with a `simctl io screenshot`, in one sentence.
|
|
183
|
+
*
|
|
184
|
+
* Extracted so it can be *tested* rather than reasoned about, for the same
|
|
185
|
+
* reason `pickDevice` and `decisionOf` were: this is the line where a wrong
|
|
186
|
+
* answer was expensive, and it was wrong for a day.
|
|
187
|
+
*/
|
|
188
|
+
export function screenshotFailure(err) {
|
|
189
|
+
const killed = err.killed || err.signal === 'SIGTERM';
|
|
190
|
+
// `simctl` opens with `Note: No display specified …` on every run, success or
|
|
191
|
+
// failure. When the display surface is dead the command does not fail, it
|
|
192
|
+
// *hangs* — so at kill time that Note is the only thing on stderr, and the
|
|
193
|
+
// tool reported a benign informational line as the reason a capture failed.
|
|
194
|
+
// That is how this wedge stayed nameless through five CI failures.
|
|
195
|
+
const lines = String(err.stderr || '').trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
196
|
+
const real = lines.filter((l) => !/^Note:/i.test(l)).pop();
|
|
197
|
+
if (killed && !real) {
|
|
198
|
+
// Run to completion the device names it exactly:
|
|
199
|
+
// NSPOSIXErrorDomain code 60 — Timeout waiting for screen surfaces
|
|
200
|
+
// which is CoreSimulator saying the surface is gone, and the closest thing
|
|
201
|
+
// to a positive test for the wedge that exists.
|
|
202
|
+
return 'simctl screenshot did not return within 10s. The display surface is not answering'
|
|
203
|
+
+ ' — run to completion it reports "Timeout waiting for screen surfaces" (NSPOSIXErrorDomain 60).'
|
|
204
|
+
+ ' This is the device, not the capture loop: `simframe revive` restarts it.';
|
|
205
|
+
}
|
|
206
|
+
const detail = real ?? lines.pop();
|
|
207
|
+
return detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
181
210
|
async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
|
|
182
211
|
try {
|
|
183
212
|
await run('xcrun', ['simctl', 'io', udid, 'screenshot', '--type=png', `--mask=${mask}`, outFile], {
|
|
184
213
|
timeout: 10_000,
|
|
185
214
|
});
|
|
186
215
|
} catch (err) {
|
|
187
|
-
|
|
188
|
-
// whole command>" and simctl's actual complaint is in stderr. A CI failure
|
|
189
|
-
// here reported the command and nothing about why it did not work.
|
|
190
|
-
const detail = (err.stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
191
|
-
throw new Error(detail ? `simctl screenshot failed: ${detail}` : `simctl screenshot failed: ${err.message}`);
|
|
216
|
+
throw new Error(screenshotFailure(err));
|
|
192
217
|
}
|
|
193
218
|
}
|
|
194
219
|
|
package/src/refs.js
CHANGED
|
@@ -149,7 +149,18 @@ export function resolveRef(udid, n, { structuralHash, layoutHash, screenKnown, s
|
|
|
149
149
|
// numbers. Refusing costs a re-read; guessing taps whatever is at those
|
|
150
150
|
// coordinates now.
|
|
151
151
|
if (screenKnown === false) {
|
|
152
|
-
|
|
152
|
+
// Flagged, like every other refusal in this function, and it was the one
|
|
153
|
+
// that was not.
|
|
154
|
+
//
|
|
155
|
+
// We tell callers to read `staleRef` rather than the sentence — the CI check
|
|
156
|
+
// for this very guard carries a comment saying it matched on prose twice and
|
|
157
|
+
// went red twice, so it reads the contract now. Then it went red a third
|
|
158
|
+
// time, on a refusal that was correct, well worded, and carried no field at
|
|
159
|
+
// all: `#1 cannot be trusted here — simframe does not recognise this
|
|
160
|
+
// screen`, reported by the harness as `no reason`. A refusal a human can
|
|
161
|
+
// read and a program cannot is the same defect as a failure that reads like
|
|
162
|
+
// a success, one level down.
|
|
163
|
+
throw staleError('simframe does not recognise this screen', 'unknown-screen');
|
|
153
164
|
}
|
|
154
165
|
// The pixel check stays, but only as a backstop, and only where it means
|
|
155
166
|
// something. A dark or near-uniform screen produces a layout hash of almost
|
package/src/regions.js
CHANGED
|
@@ -470,6 +470,60 @@ export function offViewport(t, screen) {
|
|
|
470
470
|
return false;
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* The smallest sliver of an element that is worth offering as a tap target.
|
|
475
|
+
*
|
|
476
|
+
* Apple's own minimum touch target is 44pt; this is deliberately smaller,
|
|
477
|
+
* because the question here is not "is this comfortable to tap" but "is this a
|
|
478
|
+
* real control a person can see and reach". A filter chip showing 29pt of
|
|
479
|
+
* itself at the edge of a horizontal strip is both. Below this, what is on
|
|
480
|
+
* screen is bleed rather than a control.
|
|
481
|
+
*/
|
|
482
|
+
export const MIN_VISIBLE_PT = 24;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* How much of an element is actually on screen, and where to tap what is.
|
|
486
|
+
*
|
|
487
|
+
* `offViewport` answers a yes/no question about the element's *centre*, which
|
|
488
|
+
* is right for "should I scroll to reach this" and wrong for "is this here at
|
|
489
|
+
* all". A chip at the end of a horizontal strip showed **29pt of itself** on a
|
|
490
|
+
* 402pt screen — real, visible, tappable — while its centre sat at x=416, so it
|
|
491
|
+
* was dropped from the map entirely. What the caller got in its place was OCR's
|
|
492
|
+
* reading of the visible sliver: a `text` element labelled **"Flc"** at x=392,
|
|
493
|
+
* which passes every filter because its own box is inside the viewport.
|
|
494
|
+
*
|
|
495
|
+
* So the map did not merely omit a control. It offered a different, meaningless
|
|
496
|
+
* name for it, at a coordinate that looks perfectly ordinary. That is the shape
|
|
497
|
+
* of item 121 — a caller who cannot trust what the map says about the edge of
|
|
498
|
+
* the screen — and the item's own words are "say so or clamp". This does both.
|
|
499
|
+
*/
|
|
500
|
+
export function clipping(t, screen) {
|
|
501
|
+
const f = t?.frame;
|
|
502
|
+
const w = screen?.width;
|
|
503
|
+
const h = screen?.height;
|
|
504
|
+
if (!f || !Number.isFinite(w) || !Number.isFinite(h)) return null;
|
|
505
|
+
const left = Math.max(0, f.x);
|
|
506
|
+
const right = Math.min(w, f.x + f.width);
|
|
507
|
+
const top = Math.max(0, f.y);
|
|
508
|
+
const bottom = Math.min(h, f.y + f.height);
|
|
509
|
+
const visibleWidth = right - left;
|
|
510
|
+
const visibleHeight = bottom - top;
|
|
511
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) {
|
|
512
|
+
return { visibleWidth: 0, visibleHeight: 0, clipped: true, usable: false, point: null };
|
|
513
|
+
}
|
|
514
|
+
const clipped = f.x < 0 || f.y < 0 || f.x + f.width > w || f.y + f.height > h;
|
|
515
|
+
return {
|
|
516
|
+
visibleWidth,
|
|
517
|
+
visibleHeight,
|
|
518
|
+
clipped,
|
|
519
|
+
usable: visibleWidth >= MIN_VISIBLE_PT && visibleHeight >= MIN_VISIBLE_PT,
|
|
520
|
+
// The centre of what can be seen, not the centre of the element. Tapping
|
|
521
|
+
// the latter would aim off the screen, which is the clamp the item asked
|
|
522
|
+
// for and the reason a caller could not simply be handed the real centre.
|
|
523
|
+
point: { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) },
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
473
527
|
/** Annotate a target list with region and nav slot. Mutates and returns it. */
|
|
474
528
|
export function annotate(targets, screen) {
|
|
475
529
|
const band = bands(targets, screen);
|
package/src/screenmap.js
CHANGED
|
@@ -296,15 +296,64 @@ export async function build(udid, {
|
|
|
296
296
|
try {
|
|
297
297
|
// The tree is already in hand when the daemon answered; describeAll would
|
|
298
298
|
// only ask for it a second time.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
//
|
|
300
|
+
// And when the daemon answered *without* a tree, asking again is asking
|
|
301
|
+
// the thing that just failed. That path cost a red CI run and a wrong
|
|
302
|
+
// diagnosis: the daemon's ax read timed out, `describeAll` re-asked it
|
|
303
|
+
// (another 20s), got the same nothing, fell through to idb, and the map
|
|
304
|
+
// reported **"idb is not installed, so simframe can observe the screen but
|
|
305
|
+
// cannot touch it"**. A sentence about a tool this project deliberately
|
|
306
|
+
// does not use on CI, printed because a different tool had a bad read —
|
|
307
|
+
// and it points whoever reads it at installing idb, which would change
|
|
308
|
+
// nothing. The same read worked a minute later.
|
|
309
|
+
//
|
|
310
|
+
// The daemon has sent `axError` since it learned to time its own reads,
|
|
311
|
+
// and nothing here had ever read it. The OCR branch below reads
|
|
312
|
+
// `ocrError`, which is what makes the asymmetry visible: one sensor could
|
|
313
|
+
// say why it failed and the other borrowed a different tool's excuse.
|
|
314
|
+
let nodes;
|
|
315
|
+
if (daemonScreen?.sources?.includes('ax')) {
|
|
316
|
+
nodes = daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode);
|
|
317
|
+
} else if (daemonScreen) {
|
|
318
|
+
throw new Error(daemonScreen.axError ?? 'the daemon read the screen and the accessibility tree did not answer');
|
|
319
|
+
} else {
|
|
320
|
+
nodes = await input.describeAll(udid);
|
|
321
|
+
}
|
|
302
322
|
|
|
303
323
|
sources.push('ax');
|
|
324
|
+
// A control the tree gives no name to is still a control — item 122.
|
|
325
|
+
//
|
|
326
|
+
// This line used to read `!n.label`, on the reasoning that a row nobody
|
|
327
|
+
// can name is a row nobody can tap. The whole of item 122 is that the
|
|
328
|
+
// opposite is true, and three field reports in a row said so
|
|
329
|
+
// independently: icon-only overflow menus on every card, a bottom-sheet
|
|
330
|
+
// drag handle, a back chevron. Real, tappable, on screen, and absent from
|
|
331
|
+
// the map — so every one of them needed a raw `@x,y` read off a
|
|
332
|
+
// screenshot, which is the exact round trip the text map exists to
|
|
333
|
+
// remove. Knowing that *a hit target is there* is most of the value even
|
|
334
|
+
// with no semantics attached to it.
|
|
335
|
+
//
|
|
336
|
+
// Measured on the testbed before it was written, because the premise
|
|
337
|
+
// could have been false: on the list screen, of 39 accessibility nodes
|
|
338
|
+
// exactly **one** is nameless — and it is the one control on that screen
|
|
339
|
+
// no caller could reach. This does not flood the map.
|
|
340
|
+
//
|
|
341
|
+
// It also settles a thing the reports could not: those controls were
|
|
342
|
+
// called "absent from the tree", and AXPTranslator had them all along.
|
|
343
|
+
// What is genuinely absent is the drag handle, a plain view holding a
|
|
344
|
+
// responder that UIKit is never told is accessible. No filter here can
|
|
345
|
+
// recover that one; see 122's second half.
|
|
304
346
|
for (const n of nodes) {
|
|
305
|
-
if (!n.frame ||
|
|
347
|
+
if (!n.frame || isContainer(n)) continue;
|
|
348
|
+
if (nameless(n) && !namelessHitTarget(n, nodes)) continue;
|
|
306
349
|
targets.push({
|
|
307
|
-
label: n.label,
|
|
350
|
+
label: n.label ?? undefined,
|
|
351
|
+
// A `testID` arrives as AXIdentifier, and `tap` has matched on it
|
|
352
|
+
// since long before this — but only down the tree path, because the
|
|
353
|
+
// map never carried it. So the one name an unlabelled React Native
|
|
354
|
+
// control usually does have was invisible in the map and worked if
|
|
355
|
+
// you guessed it.
|
|
356
|
+
identifier: n.identifier ?? undefined,
|
|
308
357
|
// What the control *contains*, whether it is on, and whether it has
|
|
309
358
|
// focus. All three come off the accessibility tree, the daemon has
|
|
310
359
|
// asked for all three since 0.6.0, and all three were dropped before
|
|
@@ -506,6 +555,36 @@ export function isInteractive(target) {
|
|
|
506
555
|
return INTERACTIVE.test(target.type || '');
|
|
507
556
|
}
|
|
508
557
|
|
|
558
|
+
/** No name from any source: not a label, not an identifier. */
|
|
559
|
+
export const nameless = (n) => !n?.label && !n?.identifier;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Whether a nameless accessibility node is worth printing as a hit target.
|
|
563
|
+
*
|
|
564
|
+
* Two guards, because "has no name" is not the same as "is a control".
|
|
565
|
+
*
|
|
566
|
+
* The role has to be one the tree calls interactive. A nameless `Other` is a
|
|
567
|
+
* layout view and a real screen has hundreds of them; emitting those would bury
|
|
568
|
+
* the one node that matters under the scenery it sits in.
|
|
569
|
+
*
|
|
570
|
+
* And it must not enclose two or more other elements. That is the same test the
|
|
571
|
+
* fingerprint uses for scenery, reused deliberately rather than invented here:
|
|
572
|
+
* a table cell holding its own labels is not the target, its labels are.
|
|
573
|
+
*/
|
|
574
|
+
export function namelessHitTarget(node, nodes) {
|
|
575
|
+
const f = node?.frame;
|
|
576
|
+
if (!f || !(f.width > 0) || !(f.height > 0)) return false;
|
|
577
|
+
if (!INTERACTIVE.test(node.type || '')) return false;
|
|
578
|
+
const encloses = nodes.filter((o) => {
|
|
579
|
+
const g = o.frame;
|
|
580
|
+
if (!g || o === node) return false;
|
|
581
|
+
const cx = g.x + (g.width ?? 0) / 2;
|
|
582
|
+
const cy = g.y + (g.height ?? 0) / 2;
|
|
583
|
+
return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
|
|
584
|
+
}).length;
|
|
585
|
+
return encloses < 2;
|
|
586
|
+
}
|
|
587
|
+
|
|
509
588
|
/**
|
|
510
589
|
* Rank candidates for a label. Exact beats substring, and a real control beats
|
|
511
590
|
* a caption that happens to read the same — a screen title and a tab are often
|
|
@@ -514,7 +593,7 @@ export function isInteractive(target) {
|
|
|
514
593
|
export function rank(entry, query) {
|
|
515
594
|
if (!entry) return [];
|
|
516
595
|
const q = norm(query);
|
|
517
|
-
const names = (t) => [t.label, ...(t.aliases || [])].map(norm);
|
|
596
|
+
const names = (t) => [t.label, t.identifier, ...(t.aliases || [])].map(norm);
|
|
518
597
|
const exact = entry.targets.filter((t) => names(t).includes(q));
|
|
519
598
|
const pool = exact.length
|
|
520
599
|
? exact
|
package/src/store.js
CHANGED
|
@@ -34,10 +34,63 @@ export function paths(udid) {
|
|
|
34
34
|
// wedged. It cannot ride in state.json: that is written when a frame is
|
|
35
35
|
// recorded, and a stall is the absence of frames.
|
|
36
36
|
captureHealth: path.join(dir, 'capture-health.json'),
|
|
37
|
+
lastInput: path.join(dir, 'last-input'),
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** What the capture loop last said about its own health, or null if it has no complaint. */
|
|
42
|
+
/**
|
|
43
|
+
* When input was last delivered to this device.
|
|
44
|
+
*
|
|
45
|
+
* Written by every input path and read by `liveness`, which needs it to catch
|
|
46
|
+
* the one wedge shape nothing else can see: a capture loop that is alive,
|
|
47
|
+
* incrementing its frame counter, and re-reading a **dead surface**. Field
|
|
48
|
+
* report, 0.12.2: `age=268ms` next to `stable=159753ms` while three screen
|
|
49
|
+
* transitions had just happened, and no warning fired because every signal we
|
|
50
|
+
* had was green. A screen that has not moved since before we last touched it,
|
|
51
|
+
* over and over, is a contradiction the daemon can notice locally.
|
|
52
|
+
*/
|
|
53
|
+
const INPUT_MEMORY = 8;
|
|
54
|
+
|
|
55
|
+
export function noteInput(udid, at = Date.now()) {
|
|
56
|
+
try {
|
|
57
|
+
writeAtomic(paths(udid).lastInput, [...inputTimes(udid), at].slice(-INPUT_MEMORY).join(','));
|
|
58
|
+
} catch {
|
|
59
|
+
/* a timestamp nothing depends on for correctness must not fail an action */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The last few input timestamps, oldest first.
|
|
65
|
+
*
|
|
66
|
+
* A list rather than a single timestamp, and that is the whole correction.
|
|
67
|
+
* The first version kept only the latest, so the only question it could ask was
|
|
68
|
+
* "has the screen been still for a long time?" — which needed a duration
|
|
69
|
+
* threshold, and the threshold is what defeated it. A field report caught a
|
|
70
|
+
* three-hour-stale frame on a screen that had been still for **8.2 seconds**,
|
|
71
|
+
* under a 20-second gate, so the check could not fire on the case it was
|
|
72
|
+
* written for.
|
|
73
|
+
*
|
|
74
|
+
* What actually says "dead surface" is not duration. It is **several gestures
|
|
75
|
+
* delivered with no pixel moving at all** — one tap that changes nothing is
|
|
76
|
+
* ordinary, and three in a row are not.
|
|
77
|
+
*/
|
|
78
|
+
export function inputTimes(udid) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(paths(udid).lastInput, 'utf8')
|
|
81
|
+
.split(',')
|
|
82
|
+
.map(Number)
|
|
83
|
+
.filter(Number.isFinite);
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function lastInputAt(udid) {
|
|
90
|
+
const times = inputTimes(udid);
|
|
91
|
+
return times.length ? times[times.length - 1] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
41
94
|
export function captureHealth(udid) {
|
|
42
95
|
return readJson(paths(udid).captureHealth);
|
|
43
96
|
}
|
package/src/supervisor.js
CHANGED
|
@@ -84,6 +84,18 @@ function backendFor(want) {
|
|
|
84
84
|
* text — so it broke when the branch grew an else, with nothing actually wrong.
|
|
85
85
|
* A property this important deserves an assertion that runs it.
|
|
86
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* The fourth word, and why it is not in `DECISIONS`.
|
|
89
|
+
*
|
|
90
|
+
* `abstain` means "I cannot tell from what I was given", and the only correct
|
|
91
|
+
* thing to do with it is exactly what this module already does with every
|
|
92
|
+
* failure: return `null`, and behave as if there is no supervisor. So it is a
|
|
93
|
+
* recognised *answer* and not a recognised *decision*, and keeping those apart
|
|
94
|
+
* is the point — a caller must never be able to act on it, and a log must be
|
|
95
|
+
* able to tell it from a timeout, a refusal and a model that was never there.
|
|
96
|
+
*/
|
|
97
|
+
export const ABSTAIN = 'abstain';
|
|
98
|
+
|
|
87
99
|
export function decisionOf(answer) {
|
|
88
100
|
// A string, checked rather than coerced. `String(["wait"])` is `"wait"`, so a
|
|
89
101
|
// `String(...)` coercion here let `{decision: ["wait"]}` through the one gate
|
|
@@ -113,7 +125,7 @@ export function requested(options) {
|
|
|
113
125
|
*/
|
|
114
126
|
export async function judge({
|
|
115
127
|
goal, step, expected, failure, screen, stillMs, note, options, timeoutMs = 2500,
|
|
116
|
-
detail,
|
|
128
|
+
detail, mayAbstain = false,
|
|
117
129
|
} = {}) {
|
|
118
130
|
const want = requested(options);
|
|
119
131
|
if (!want) return null;
|
|
@@ -131,6 +143,7 @@ export async function judge({
|
|
|
131
143
|
screen: (screen ?? []).filter(Boolean).map((s) => String(s).slice(0, 40)).slice(0, 25),
|
|
132
144
|
stillMs: Number.isFinite(stillMs) ? Math.round(stillMs) : null,
|
|
133
145
|
note: note ? String(note).slice(0, 200) : null,
|
|
146
|
+
mayAbstain: Boolean(mayAbstain),
|
|
134
147
|
}, timeoutMs);
|
|
135
148
|
const decision = decisionOf(answer);
|
|
136
149
|
if (decision == null) {
|
|
@@ -144,7 +157,12 @@ export async function judge({
|
|
|
144
157
|
// supervisor did not answer": a timeout, a guardrail refusal and a model
|
|
145
158
|
// that was never installed were one indistinguishable line.
|
|
146
159
|
if (detail && typeof detail === 'object') {
|
|
147
|
-
|
|
160
|
+
// An abstention is an answer, and the least interesting thing a log can
|
|
161
|
+
// say about it is that the supervisor "did not answer". It is the model
|
|
162
|
+
// declining on purpose, which is the one failure mode worth encouraging.
|
|
163
|
+
detail.kind = answer?.decision === ABSTAIN
|
|
164
|
+
? 'abstained'
|
|
165
|
+
: answer?.kind
|
|
148
166
|
?? (answer == null ? 'no answer' : answer.decision ? 'outside the vocabulary' : 'unparseable');
|
|
149
167
|
if (answer?.error) detail.error = String(answer.error).slice(0, 200);
|
|
150
168
|
}
|
package/src/view.js
CHANGED
|
@@ -247,8 +247,26 @@ export function actsInteractive(t) {
|
|
|
247
247
|
export function rowsFor(entry, { screen, filter, interactive, all = false, limit = DEFAULT_LIMIT } = {}) {
|
|
248
248
|
let kept = (entry?.targets ?? []).map((t) => ({ ...t })).filter((t) => {
|
|
249
249
|
if (!isNum(t.x) || !isNum(t.y)) return false;
|
|
250
|
-
// Off-screen elements are real in the tree and untappable in fact
|
|
251
|
-
|
|
250
|
+
// Off-screen elements are real in the tree and untappable in fact —
|
|
251
|
+
// *unless* enough of one is on screen to tap, which the centre test cannot
|
|
252
|
+
// see. A filter chip showing 29pt of itself at the right edge was dropped
|
|
253
|
+
// while OCR's reading of that same sliver, labelled "Flc", was kept and
|
|
254
|
+
// printed in its place. Item 121: say so or clamp. Both, here — the row is
|
|
255
|
+
// kept, its tap point is moved to the centre of the visible part, and it is
|
|
256
|
+
// marked so nobody reads a clamped coordinate as a whole element.
|
|
257
|
+
//
|
|
258
|
+
// Deliberately NOT also relaxing `locate`. A clipped element still answers
|
|
259
|
+
// "in the tree but not in view", so `scrollTo` keeps scrolling to it rather
|
|
260
|
+
// than declaring a sliver good enough — that conservatism was bought with a
|
|
261
|
+
// reported session lost to `"'Assigned to Me' is in view at 422,277
|
|
262
|
+
// already"`, and widening it here would buy the same bug back.
|
|
263
|
+
if (regions.offViewport(t, screen)) {
|
|
264
|
+
const clip = regions.clipping(t, screen);
|
|
265
|
+
if (!clip?.usable) return false;
|
|
266
|
+
t.clipped = true;
|
|
267
|
+
t.x = clip.point.x;
|
|
268
|
+
t.y = clip.point.y;
|
|
269
|
+
}
|
|
252
270
|
if (!all && !regions.offerable(t.region)) return false;
|
|
253
271
|
if (!all && isNoise(t)) return false;
|
|
254
272
|
return true;
|
|
@@ -380,11 +398,42 @@ function valueNote(r) {
|
|
|
380
398
|
return `= ${v}`;
|
|
381
399
|
}
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* What to call a row.
|
|
403
|
+
*
|
|
404
|
+
* A control with no accessibility label is not necessarily anonymous, and the
|
|
405
|
+
* order here is by how much the name can be trusted. Its own label first. Then
|
|
406
|
+
* its `testID`, which is a name a developer chose and which `tap` has always
|
|
407
|
+
* matched on. Then OCR's reading of it, which is how an icon-only control gets
|
|
408
|
+
* a name at all and which cannot contradict a label it does not have. Only when
|
|
409
|
+
* all three are missing is it really unaddressable, and that is the case item
|
|
410
|
+
* 122 is about: three field reports in a row spent their time on controls that
|
|
411
|
+
* existed, were tappable, and could be reached by no selector at all.
|
|
412
|
+
*/
|
|
413
|
+
export function displayName(r) {
|
|
414
|
+
return trim(r.label)
|
|
415
|
+
|| trim(r.identifier)
|
|
416
|
+
// Only an alias with a word in it. OCR reads the three dots of an overflow
|
|
417
|
+
// menu as `...`, and on the first live run of this the button was named
|
|
418
|
+
// `#1 button 364,84 ...` — which looks like a name, cannot be typed into a
|
|
419
|
+
// selector, and made the count below say there was nothing unnamed here.
|
|
420
|
+
// `isNoise` already refuses such text as a row of its own; it must not get
|
|
421
|
+
// in through the alias door either.
|
|
422
|
+
|| (r.aliases ?? []).find((a) => alnum(a))
|
|
423
|
+
|| (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A row the map can print and no caller can name. */
|
|
427
|
+
export const unaddressable = (r) =>
|
|
428
|
+
matching.isAxTarget(r) && !trim(r.label) && !trim(r.identifier)
|
|
429
|
+
&& !(r.aliases ?? []).some((a) => alnum(a));
|
|
430
|
+
|
|
383
431
|
function renderRow(r) {
|
|
432
|
+
const shown = displayName(r);
|
|
384
433
|
const name = [
|
|
385
|
-
|
|
434
|
+
shown,
|
|
386
435
|
valueNote(r),
|
|
387
|
-
aliasNote(r),
|
|
436
|
+
aliasNote(r, shown),
|
|
388
437
|
].filter(Boolean).join(' ');
|
|
389
438
|
const state = [
|
|
390
439
|
r.enabled === false ? 'disabled' : null,
|
|
@@ -395,7 +444,9 @@ function renderRow(r) {
|
|
|
395
444
|
shortType(r.type).padEnd(9),
|
|
396
445
|
`${r.x},${r.y}`.padEnd(9),
|
|
397
446
|
state ? `${state} ` : '',
|
|
398
|
-
name,
|
|
447
|
+
// After the name, and appended rather than added as a column: an extra
|
|
448
|
+
// element in this join puts an extra space on *every* row, clipped or not.
|
|
449
|
+
r.clipped ? `${name} (partly off-screen — the coordinate is the middle of the visible part)` : name,
|
|
399
450
|
].join(' ');
|
|
400
451
|
}
|
|
401
452
|
|
|
@@ -406,11 +457,14 @@ function renderRow(r) {
|
|
|
406
457
|
* for, which is useful when they disagree and pure cost when they agree —
|
|
407
458
|
* "WELCOME ~ WELCOME" was a third of some rows.
|
|
408
459
|
*/
|
|
409
|
-
function aliasNote(r) {
|
|
460
|
+
function aliasNote(r, shown = r.label) {
|
|
410
461
|
const extra = (r.aliases ?? [])
|
|
411
462
|
.filter((a) => {
|
|
412
463
|
const t = alnum(a);
|
|
413
|
-
|
|
464
|
+
// Compared against the name actually printed, not against the label. An
|
|
465
|
+
// unlabelled control is named by its own alias now, and comparing against
|
|
466
|
+
// an absent label printed every one of them twice: `Sort ~ Sort`.
|
|
467
|
+
return t && !alnum(shown).includes(t);
|
|
414
468
|
})
|
|
415
469
|
.slice(0, 2);
|
|
416
470
|
return extra.length ? `~ ${trim(extra.join(' '))}` : null;
|
|
@@ -511,6 +565,25 @@ export async function screenMap(deviceQuery, {
|
|
|
511
565
|
+ ' you did not expect to see as belonging to the layer underneath'
|
|
512
566
|
: null;
|
|
513
567
|
|
|
568
|
+
// Controls that are on the screen and answer to no name — item 122.
|
|
569
|
+
//
|
|
570
|
+
// Counted from the rows about to be printed rather than from the map, so it
|
|
571
|
+
// is a statement about what the caller can see. The wording is the ask,
|
|
572
|
+
// near-verbatim from the report that made it: *"a line like 'N on-screen
|
|
573
|
+
// tappable views have no accessibility label — they cannot be addressed by
|
|
574
|
+
// selector' would push people toward instrumenting, which is the outcome
|
|
575
|
+
// everyone wants"*. It is also the honest answer to the second half of that
|
|
576
|
+
// item, which we cannot fix from here: a view UIKit was never told is
|
|
577
|
+
// accessible is invisible to the tree, so a screen whose controls are all
|
|
578
|
+
// undeclared shows this count as 0 and still needs a screenshot.
|
|
579
|
+
const anonymous = rows.filter(unaddressable).length;
|
|
580
|
+
const unnamed = anonymous
|
|
581
|
+
? `${anonymous} on-screen control(s) have no accessibility label — they are listed with their`
|
|
582
|
+
+ ' coordinates and can be tapped by point or by #ref, but not by name. If what you are'
|
|
583
|
+
+ ' looking for is not in the list either, the app has views that were never declared'
|
|
584
|
+
+ ' accessible and only a screenshot will find those.'
|
|
585
|
+
: null;
|
|
586
|
+
|
|
514
587
|
return {
|
|
515
588
|
device,
|
|
516
589
|
identity,
|
|
@@ -529,7 +602,8 @@ export async function screenMap(deviceQuery, {
|
|
|
529
602
|
staleExits,
|
|
530
603
|
cleared,
|
|
531
604
|
overlay,
|
|
532
|
-
|
|
605
|
+
unnamed,
|
|
606
|
+
text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay, unnamed }),
|
|
533
607
|
};
|
|
534
608
|
}
|
|
535
609
|
|
|
@@ -712,7 +786,7 @@ export function ambiguousLabels(rows) {
|
|
|
712
786
|
return [...seen.values()].filter((n) => n > 1).length;
|
|
713
787
|
}
|
|
714
788
|
|
|
715
|
-
export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay }) {
|
|
789
|
+
export function render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, verdictLine, ambiguities, cleared, overlay, unnamed }) {
|
|
716
790
|
const head = [
|
|
717
791
|
device?.name,
|
|
718
792
|
screen?.width ? `${screen.width}x${screen.height}pt` : null,
|
|
@@ -747,6 +821,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
|
|
|
747
821
|
// already believes, which is the one kind of news that must not be scrolled to.
|
|
748
822
|
if (cleared) lines.push(cleared);
|
|
749
823
|
if (overlay) lines.push(overlay);
|
|
824
|
+
if (unnamed) lines.push(unnamed);
|
|
750
825
|
const worked = exitsLine(exitList, { stale: staleExits });
|
|
751
826
|
if (worked) lines.push(worked);
|
|
752
827
|
|