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.
@@ -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
- // Same reason as launchApp: execFile's message is "Command failed: <the
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
- throw new Error(`#${n} cannot be trusted here — simframe does not recognise this screen. Read it again (sim_ui) to renumber.`);
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
- const nodes = daemonScreen?.sources?.includes('ax')
300
- ? daemonScreen.elements.filter((e) => e.source?.includes('ax')).map(input.elementToNode)
301
- : await input.describeAll(udid);
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 || !n.label || isContainer(n)) continue;
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
- detail.kind = answer?.kind
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
- if (regions.offViewport(t, screen)) return false;
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
- trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
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
- return t && !alnum(r.label).includes(t);
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
- text: render({ device, identity, rows, truncated, collapsed, screen, name, exits, exitList, staleExits, cleared, overlay }),
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