simframe 0.9.0 → 0.11.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.
Files changed (41) hide show
  1. package/README.md +165 -5
  2. package/data/vocabulary/en.json +148 -0
  3. package/native/ocr.swift +13 -1
  4. package/native/rank.swift +87 -0
  5. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +43 -3
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +27 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +4 -0
  8. package/native/simframed/Sources/simframed/main.swift +13 -1
  9. package/native/supervise.swift +181 -0
  10. package/package.json +4 -1
  11. package/scripts/check-package.mjs +22 -2
  12. package/scripts/check-private.mjs +143 -0
  13. package/scripts/ci-memory.mjs +104 -20
  14. package/scripts/eval-perception.mjs +281 -0
  15. package/scripts/phase17-corpus.mjs +176 -0
  16. package/skills/simframe/SKILL.md +237 -5
  17. package/src/actions.js +1825 -38
  18. package/src/analyze.js +70 -0
  19. package/src/cli.js +214 -15
  20. package/src/control.js +1 -0
  21. package/src/fingerprint.js +19 -1
  22. package/src/graph.js +193 -11
  23. package/src/index.js +428 -16
  24. package/src/input.js +115 -8
  25. package/src/localhelper.js +155 -0
  26. package/src/matching.js +119 -3
  27. package/src/mcp.js +319 -27
  28. package/src/metrics.js +134 -8
  29. package/src/navigate.js +10 -7
  30. package/src/ocr.js +18 -1
  31. package/src/planner.js +195 -0
  32. package/src/platform/android.js +3 -2
  33. package/src/platform/ios.js +2 -1
  34. package/src/png.js +26 -0
  35. package/src/refs.js +51 -8
  36. package/src/regions.js +110 -1
  37. package/src/screenmap.js +109 -10
  38. package/src/supervisor.js +117 -0
  39. package/src/view.js +396 -7
  40. package/src/vocabulary.js +134 -0
  41. package/src/wrote.js +136 -0
package/src/input.js CHANGED
@@ -248,6 +248,11 @@ export function elementToNode(e) {
248
248
  type: e.role ?? null,
249
249
  identifier: e.identifier ?? null,
250
250
  enabled: e.state?.enabled ?? null,
251
+ // The daemon batches AXSelected and AXFocused alongside AXEnabled and has
252
+ // since 0.6.0. This converter took one of the three, and it is the one on
253
+ // the path that actually runs — `normalizeNode` below is the idb fallback.
254
+ selected: e.state?.selected ?? null,
255
+ focused: e.state?.focused ?? null,
251
256
  frame: e.frame ?? null,
252
257
  raw: e,
253
258
  };
@@ -273,6 +278,12 @@ function normalizeNode(node) {
273
278
  type: node.type ?? node.AXType ?? null,
274
279
  identifier: node.AXUniqueId ?? node.identifier ?? null,
275
280
  enabled: node.AXEnabled ?? node.enabled ?? null,
281
+ // The daemon has asked the tree for AXSelected and AXFocused since 0.6.0 —
282
+ // they are two of the eight attributes in its batched round trip — and this
283
+ // function dropped both. `view.renderRow` has printed `selected` for as
284
+ // long as it has existed, against a field nobody set.
285
+ selected: node.AXSelected ?? node.selected ?? null,
286
+ focused: node.AXFocused ?? node.focused ?? null,
276
287
  frame: frame
277
288
  ? {
278
289
  x: frame.x ?? frame.X ?? 0,
@@ -418,14 +429,77 @@ export async function typeKeys(udid, value) {
418
429
  await idb(['ui', 'text', '--udid', udid, String(value)]);
419
430
  }
420
431
 
421
- export async function pressKey(udid, keycode) {
432
+ /**
433
+ * Keyboard keys, by name.
434
+ *
435
+ * A peer was blocked outright for want of Return: half of mobile search fields
436
+ * submit on the keyboard return key, `button` covers only the hardware buttons,
437
+ * and `key` wanted a raw HID usage code that nobody should have to know. Typing
438
+ * "\n" as text is not a substitute — text goes through whatever keyboard layout
439
+ * iOS has active, and measured, it turned "Coke Display" into "Coke In Display".
440
+ *
441
+ * These are HID keyboard usage codes, which name a key *position* and are never
442
+ * translated by a layout. That property is the whole reason this path exists on
443
+ * a device whose own doctor warns that two extra layouts are installed.
444
+ */
445
+ export const KEYS = {
446
+ return: 40, enter: 40, escape: 41, esc: 41, backspace: 42, delete: 42,
447
+ tab: 43, space: 44, up: 82, down: 81, left: 80, right: 79,
448
+ a: 4,
449
+ };
450
+
451
+ /** Modifier usage codes, held while another key is pressed. */
452
+ export const MODIFIERS = { control: 224, shift: 225, alt: 226, option: 226, command: 227, cmd: 227, gui: 227 };
453
+
454
+ /** The usage code for a name, a number, or null when it is neither. */
455
+ export function keyUsage(key) {
456
+ if (Number.isFinite(Number(key))) return Number(key);
457
+ const name = String(key ?? '').trim().toLowerCase();
458
+ return Object.hasOwn(KEYS, name) ? KEYS[name] : null;
459
+ }
460
+
461
+ export async function pressKey(udid, keycode, { modifiers = [] } = {}) {
422
462
  await ensureFreshSession(udid);
463
+ const usage = keyUsage(keycode);
464
+ const held = modifiers
465
+ .map((m) => (Number.isFinite(Number(m)) ? Number(m) : MODIFIERS[String(m).trim().toLowerCase()]))
466
+ .filter((m) => Number.isFinite(m));
467
+ if (usage == null) {
468
+ throw new Error(`unknown key ${JSON.stringify(String(keycode))} — known names: ${Object.keys(KEYS).join(', ')}`
469
+ + ', or a HID usage code');
470
+ }
423
471
  const own = inputDriverFor(udid);
424
472
  if (own) {
425
- await own.key(udid, keycode);
473
+ await own.key(udid, usage, held);
474
+ return;
475
+ }
476
+ // The daemon owns the keyboard usage path on iOS. It was implemented in the
477
+ // HID layer and never exposed as a verb, so this fell through to idb — which
478
+ // is absent on a machine using the daemon, and so there was no way to press a
479
+ // keyboard key at all.
480
+ if (control.available(udid)) {
481
+ await control.key(udid, usage, held);
426
482
  return;
427
483
  }
428
- await idb(['ui', 'key', '--udid', udid, String(keycode)]);
484
+ if (held.length) throw new Error('modifier keys need the daemon; idb cannot hold one');
485
+ await idb(['ui', 'key', '--udid', udid, String(usage)]);
486
+ }
487
+
488
+ /**
489
+ * Empty the focused field.
490
+ *
491
+ * Command-A then Delete, over HID. There is no clear primitive anywhere —
492
+ * XCUITest, Appium and idb all lack one, and re-typing appends — so this is the
493
+ * standard answer rather than a trick of ours. It is layout-independent for the
494
+ * reason that matters on a device with Farsi and Armenian keyboards installed:
495
+ * a modifier and Delete are key *positions*, and so is the `a` in Command-A, so
496
+ * none of the three is translated by the active layout.
497
+ *
498
+ * It clears whatever has focus, which is why every caller focuses first.
499
+ */
500
+ export async function clearField(udid) {
501
+ await pressKey(udid, 'a', { modifiers: ['command'] });
502
+ await pressKey(udid, 'delete');
429
503
  }
430
504
 
431
505
  /**
@@ -506,19 +580,52 @@ export async function sessionHealth(udid) {
506
580
  }
507
581
 
508
582
  /**
509
- * Rebuild the session if the device outlived it. Once per process, per device.
583
+ * Should this staleness be acted on, given what has already been rebuilt?
584
+ *
585
+ * Pure, and separate from the check because the *key* is the whole bug. This
586
+ * gate used to be a set of udids — "once per process, per device" — and the
587
+ * reasoning was to avoid statting on every action. What it actually bought was
588
+ * that the feature could not fire in the one process that matters. A CLI
589
+ * command is a new process every time, so per-process is per-call there and
590
+ * the gate never showed; the MCP server is a single process that lives for a
591
+ * whole session, so it checked once, at the first action, and then never
592
+ * again — and a device that reboots *mid-session* is precisely the case this
593
+ * exists to catch. Reported from a real session: capture kept working, input
594
+ * died, every tap returned `ok`, and about ten calls went into two wrong
595
+ * conclusions about the app.
596
+ *
597
+ * The right key is the boot the rebuild was for. One attempt per device boot:
598
+ * enough that a failed rebuild does not retry on every tap forever, and not so
599
+ * much that the next boot is invisible.
600
+ */
601
+ export function shouldRebuildSession({ stale, bootedAt }, rebuiltFor) {
602
+ if (!stale) return false;
603
+ // A boot we cannot date cannot be memoised against, and re-attempting on
604
+ // every action would be worse than not detecting it. sessionStaleness only
605
+ // reports stale with a finite bootedAt, so this is a belt, not a case.
606
+ if (!Number.isFinite(bootedAt)) return false;
607
+ return rebuiltFor !== bootedAt;
608
+ }
609
+
610
+ /**
611
+ * Rebuild the session if the device outlived it. Once per device boot.
510
612
  *
511
613
  * Rebuild, and retry nothing: this runs *before* the action, so the action is
512
614
  * delivered on a session known to be current. Retrying afterwards is how an
513
615
  * action fires twice, which is the hazard the verify barrier exists to
514
616
  * prevent — and it is why the existing recovery covers hardware buttons only.
617
+ *
618
+ * The check now runs on every dispatch rather than once. It costs two small
619
+ * `readJson`s and, at most every three seconds, one stat — `bootedAtCached`
620
+ * already caps the part that was expensive, which is what made the
621
+ * once-per-process gate unnecessary as well as wrong.
515
622
  */
516
- const freshened = new Set();
623
+ const rebuiltForBoot = new Map();
517
624
  export async function ensureFreshSession(udid) {
518
- if (!udid || freshened.has(udid)) return null;
519
- freshened.add(udid);
625
+ if (!udid) return null;
520
626
  const health = await sessionHealth(udid);
521
- if (!health.stale) return null;
627
+ if (!shouldRebuildSession(health, rebuiltForBoot.get(udid))) return null;
628
+ rebuiltForBoot.set(udid, health.bootedAt);
522
629
  const rebuilt = await resetSession(udid);
523
630
  return { ...health, rebuilt };
524
631
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * A warm, line-oriented local helper process.
3
+ *
4
+ * Extracted rather than duplicated, because writing this twice would mean
5
+ * risking the same two bugs twice — and both were subtle enough to look like
6
+ * something else entirely.
7
+ *
8
+ * A timed-out request left its waiter in the queue, so every later answer went
9
+ * to the wrong asker and the run simply never finished; it read as the model
10
+ * being slow. And unreferencing the child's stdout unreferenced the pipe every
11
+ * request waits on, so the process exited silently in the middle of an await
12
+ * and printed nothing at all, returning 0.
13
+ *
14
+ * The helper is kept warm because the first answer in a process pays model load
15
+ * — measured at ~880ms against ~560ms for every answer after it.
16
+ */
17
+ import { spawn, execFile } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { promisify } from 'node:util';
21
+
22
+ const run = promisify(execFile);
23
+
24
+ /**
25
+ * Compile a Swift source once and reuse the binary.
26
+ *
27
+ * `swiftc`, not `xcrun swiftc`: nothing above the platform boundary may name a
28
+ * platform tool, and the boundary test catches it. A compiler is not a device
29
+ * tool, which is the precedent `src/ocr.js` set.
30
+ */
31
+ export function compiler({ source, binary, what }) {
32
+ let building = null;
33
+ return async function ensureBinary() {
34
+ if (building) return building;
35
+ building = (async () => {
36
+ try {
37
+ const src = fs.statSync(source).mtimeMs;
38
+ const bin = fs.existsSync(binary) ? fs.statSync(binary).mtimeMs : 0;
39
+ if (bin > src) return { available: true, binary };
40
+ } catch {
41
+ return { available: false, reason: `the ${what} source is missing from this install` };
42
+ }
43
+ try {
44
+ fs.mkdirSync(path.dirname(binary), { recursive: true });
45
+ await run('swiftc', ['-O', source, '-o', binary], { timeout: 180_000 });
46
+ return { available: true, binary };
47
+ } catch (err) {
48
+ building = null; // let a later call retry once a toolchain is present
49
+ return {
50
+ available: false,
51
+ reason: err.code === 'ENOENT'
52
+ ? `swiftc is not installed, so the ${what} cannot be built (install Xcode command line tools)`
53
+ : `could not build the ${what}: ${String(err.message).split('\n')[0]}`,
54
+ };
55
+ }
56
+ })();
57
+ return building;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Open a helper and speak JSON lines to it.
63
+ *
64
+ * @returns {{ask: (o: object, ms: number) => Promise<object|null>, close: () => void, ok: boolean, reason?: string}}
65
+ */
66
+ export function lineServer({ ensureBinary, what }) {
67
+ let session = null;
68
+
69
+ async function open() {
70
+ if (session) return session;
71
+ const built = await ensureBinary();
72
+ if (!built.available) return { ok: false, reason: built.reason };
73
+ session = await new Promise((resolve) => {
74
+ const child = spawn(built.binary, [], { stdio: ['pipe', 'pipe', 'ignore'] });
75
+ // Deliberately NOT unref'd — see the note at the top of this file.
76
+ let buffer = '';
77
+ const waiters = [];
78
+ let settled = false;
79
+ const fail = (reason) => {
80
+ if (!settled) { settled = true; resolve({ ok: false, reason }); }
81
+ while (waiters.length) waiters.shift()(null);
82
+ };
83
+ child.on('error', (err) => fail(`the ${what} would not start: ${err.message}`));
84
+ child.on('exit', () => { session = null; fail(`the ${what} exited`); });
85
+ child.stdout.on('data', (chunk) => {
86
+ buffer += chunk;
87
+ let i = buffer.indexOf('\n');
88
+ while (i >= 0) {
89
+ const line = buffer.slice(0, i).trim();
90
+ buffer = buffer.slice(i + 1);
91
+ i = buffer.indexOf('\n');
92
+ if (!line) continue;
93
+ let msg;
94
+ try { msg = JSON.parse(line); } catch { continue; }
95
+ if (!settled) {
96
+ settled = true;
97
+ if (msg.ready) resolve({ ok: true, child, waiters });
98
+ else resolve({ ok: false, reason: msg.unavailable ?? `the ${what} did not become ready` });
99
+ continue;
100
+ }
101
+ const next = waiters.shift();
102
+ if (next) next(msg);
103
+ }
104
+ });
105
+ });
106
+ return session;
107
+ }
108
+
109
+ return {
110
+ async ask(question, timeoutMs = 3000) {
111
+ let live;
112
+ try {
113
+ live = await open();
114
+ } catch {
115
+ return null;
116
+ }
117
+ if (!live?.ok) return null;
118
+ return new Promise((resolve) => {
119
+ // A timed-out waiter is retired, not merely resolved. Leaving it queued
120
+ // sent the next answer to it instead of to the next asker, and every
121
+ // call after that was off by one.
122
+ let done = false;
123
+ const waiter = (msg) => {
124
+ if (done) return;
125
+ done = true;
126
+ clearTimeout(timer);
127
+ resolve(msg);
128
+ };
129
+ const timer = setTimeout(() => {
130
+ if (done) return;
131
+ done = true;
132
+ const i = live.waiters.indexOf(waiter);
133
+ if (i >= 0) live.waiters.splice(i, 1);
134
+ resolve(null);
135
+ }, timeoutMs);
136
+ live.waiters.push(waiter);
137
+ try {
138
+ live.child.stdin.write(`${JSON.stringify(question)}\n`);
139
+ } catch {
140
+ clearTimeout(timer);
141
+ done = true;
142
+ resolve(null);
143
+ }
144
+ });
145
+ },
146
+ async status() {
147
+ const live = await open();
148
+ return live?.ok ? { ok: true } : { ok: false, reason: live?.reason ?? 'unavailable' };
149
+ },
150
+ close() {
151
+ try { session?.child?.kill(); } catch { /* already gone */ }
152
+ session = null;
153
+ },
154
+ };
155
+ }
package/src/matching.js CHANGED
@@ -59,7 +59,25 @@ export function nameScore(name, query) {
59
59
  const q = norm(query);
60
60
  if (!n || !q) return 0;
61
61
  if (n === q) return 1;
62
- if (n.startsWith(q) || q.startsWith(n)) return 0.86;
62
+ // A prefix match is only as good as the share it covers, and this branch had
63
+ // to be taught that twice.
64
+ //
65
+ // `n.startsWith(q)` — the name begins with the query, "Acce" for
66
+ // "Accessibility" — is the ordinary case and keeps most of its score: a
67
+ // prefix of a name is how people abbreviate. `q.startsWith(n)` is the
68
+ // opposite direction, where the *name* is a fragment of the query, and it
69
+ // returned the same flat 0.86 no matter how little of the query it was. So
70
+ // the section-index letter "S" scored 0.86 against the query "Search" and
71
+ // beat the search field's own label "Q Search" at 0.585 — and simframe
72
+ // tapped a scrubber and typed into it.
73
+ //
74
+ // The sibling branch below already carries this lesson in a comment about
75
+ // "back" matching a list row. Only one of the two had learned it. Both scale
76
+ // now, and the floor differs by direction on purpose: a query that is a
77
+ // prefix of a name is usually deliberate, while a name that is a fragment of
78
+ // the query is usually a coincidence, and one character is always one.
79
+ if (n.startsWith(q)) return 0.86 * Math.max(PREFIX_FLOOR, Math.min(1, q.length / n.length + 0.35));
80
+ if (q.startsWith(n)) return 0.8 * Math.max(0.15, n.length / q.length);
63
81
  // A substring match is only as good as the share of the name it covers.
64
82
  // Without this, "back" scores 0.78 against a two-hundred-character list row
65
83
  // that happens to contain "Back of House", and beats the actual back button.
@@ -76,7 +94,31 @@ export function nameScore(name, query) {
76
94
  if (distance > cap) return 0;
77
95
  const longest = Math.max(n.length, q.length);
78
96
  const similarity = 1 - distance / longest;
79
- return similarity >= 0.7 ? similarity * 0.72 : 0;
97
+ if (similarity >= 0.7) return similarity * 0.72;
98
+ // Last tier: OCR read a confusable character.
99
+ //
100
+ // Language correction is deliberately off, which is right for labels and
101
+ // wrong for exactly this. Measured on a real app, `(All)` reads back as
102
+ // `(AII)` and would fail an assert against the string it is; capital-I,
103
+ // lowercase-l, the digit one and a pipe are one shape in most UI fonts, as
104
+ // are capital-O and zero.
105
+ //
106
+ // Deliberately the *last* tier and discounted, not part of `norm`. Folding
107
+ // in `norm` would make it change what an exact match means — "Log in" and
108
+ // "1og in" would become the same string everywhere — and identity is not
109
+ // something to be fuzzy about. Here it only ever rescues a comparison that
110
+ // had already scored zero.
111
+ const foldedScore = confusableFold(n) === confusableFold(q) ? 0.62 : 0;
112
+ return foldedScore;
113
+ }
114
+
115
+ /** One shape per glyph family, for comparison only. Never for identity. */
116
+ export function confusableFold(s) {
117
+ return String(s ?? '')
118
+ .replace(/[il1|!]/gi, '1')
119
+ .replace(/[o0]/gi, '0')
120
+ .replace(/[s5]/gi, '5')
121
+ .replace(/[b8]/gi, '8');
80
122
  }
81
123
 
82
124
  function synonymGroup(query) {
@@ -159,6 +201,17 @@ export function rank(targets, intent, { screen } = {}) {
159
201
  export const AMBIGUITY_MARGIN = 0.08;
160
202
  /** Below this, no candidate is worth acting on. */
161
203
  export const MINIMUM_SCORE = 0.45;
204
+ /**
205
+ * How much of a name's score survives scaling a prefix by its coverage.
206
+ *
207
+ * Tied to `MINIMUM_SCORE` rather than chosen: at 0.5 the shortest useful
208
+ * abbreviation — "Ac" for "Accessibility" — scored 0.433 and fell *below* the
209
+ * threshold to resolve at all, which would have turned a ranking fix into a
210
+ * feature removal. 0.6 puts the worst case at 0.516, comfortably resolvable and
211
+ * still well under a fuller match. The unit test asserts the relationship so
212
+ * the cliff cannot come back by someone tuning one of the two numbers.
213
+ */
214
+ export const PREFIX_FLOOR = 0.6;
162
215
 
163
216
  /**
164
217
  * How close two tap points have to be to mean the same control.
@@ -284,9 +337,67 @@ function sameControl(a, b) {
284
337
  // and a map built before that still resolves.
285
338
  if (isAxTarget(a) && !isAxTarget(b) && sameElementSeenTwice(a, b)) return true;
286
339
  if (isAxTarget(b) && !isAxTarget(a) && sameElementSeenTwice(b, a)) return true;
340
+ // And a caption sitting on the control it names. Containment above requires
341
+ // the container to be a recognised hit target, and a React Native composite
342
+ // is a generic element — so a form row published its caption and its select
343
+ // with the same label, 152 points apart, and nothing merged them. They are
344
+ // one control seen twice, not two candidates: answering `ambiguous` here
345
+ // costs a round trip to choose between a thing and its own name.
346
+ if (isCaptionFor(a, b) || isCaptionFor(b, a)) return true;
287
347
  return false;
288
348
  }
289
349
 
350
+ /** Does `caption` merely name `control`, overlapping it, with the same label? */
351
+ function isCaptionFor(caption, control) {
352
+ if (!namesOnly(caption) || namesOnly(control)) return false;
353
+ if (norm(caption.label) !== norm(control.label)) return false;
354
+ return overlaps(caption.frame, control.frame);
355
+ }
356
+
357
+ /** Any shared area at all — a caption's box often clears its control's by a point or two. */
358
+ function overlaps(a, b) {
359
+ if (!a || !b) return false;
360
+ return a.x < b.x + b.width && b.x < a.x + a.width
361
+ && a.y < b.y + b.height && b.y < a.y + a.height;
362
+ }
363
+
364
+ /**
365
+ * Roles that only ever *name* a control, never are one.
366
+ *
367
+ * The tree publishes a caption and the control it captions with the same label,
368
+ * and a React Native composite (a `native-base` Select, say) surfaces as a
369
+ * generic element that `INTERACTIVE_ROLE` does not recognise. So `tap "Problem"`
370
+ * resolved to the caption at (49,486) and did nothing, while the select sat at
371
+ * (201,496) — reported as half of the single largest cost in a real-app run,
372
+ * because it means neither selector can be trusted.
373
+ *
374
+ * `collapseSamePlace` already prefers a hit target over the text printed on it,
375
+ * but only once `sameControl` has decided they are the same place. These two
376
+ * were 152 points apart with a generic role, so nothing merged them.
377
+ */
378
+ const NAMING_ONLY_ROLE = /^(statictext|text|label|heading|image)$/i;
379
+
380
+ const namesOnly = (t) => NAMING_ONLY_ROLE.test(t?.type ?? '');
381
+
382
+ /**
383
+ * A caption never wins over a control that answers the same name.
384
+ *
385
+ * Applied only when the two are close enough in score to be answering the same
386
+ * question — a heading is still reachable when nothing else matches, which is
387
+ * why this promotes rather than filters. Anything further apart than
388
+ * `CAPTION_MARGIN` is a different match, not the same match seen twice.
389
+ */
390
+ export const CAPTION_MARGIN = 0.2;
391
+
392
+ export function preferTheControl(ranked) {
393
+ if (!ranked.length || !namesOnly(ranked[0].target)) return ranked;
394
+ const lead = ranked[0].score;
395
+ const i = ranked.findIndex((c, idx) => idx > 0 && !namesOnly(c.target) && lead - c.score <= CAPTION_MARGIN);
396
+ if (i < 0) return ranked;
397
+ const promoted = { ...ranked[i], reasons: [...ranked[i].reasons, 'the control, not the caption naming it'] };
398
+ return [promoted, ...ranked.filter((_, idx) => idx !== i)];
399
+ }
400
+
290
401
  function collapseSamePlace(ranked) {
291
402
  const kept = [];
292
403
  for (const c of ranked) {
@@ -298,6 +409,11 @@ function collapseSamePlace(ranked) {
298
409
  // Prefer the real hit target: an accessibility element over OCR's reading of
299
410
  // it, and an interactive role over a caption sitting inside it.
300
411
  const better = (candidate, incumbent) => {
412
+ // A caption loses to what it names before anything else is considered:
413
+ // a `StaticText` is never the tap target when the control it labels is
414
+ // right there, whatever either one's source.
415
+ if (namesOnly(incumbent.target) && !namesOnly(candidate.target)) return true;
416
+ if (namesOnly(candidate.target) && !namesOnly(incumbent.target)) return false;
301
417
  if (isAxTarget(candidate.target) && !isAxTarget(incumbent.target)) return true;
302
418
  if (!isAxTarget(candidate.target) && isAxTarget(incumbent.target)) return false;
303
419
  return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
@@ -315,7 +431,7 @@ function collapseSamePlace(ranked) {
315
431
  * @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
316
432
  */
317
433
  export function resolve(targets, intent, options = {}) {
318
- const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
434
+ const ranked = preferTheControl(collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE)));
319
435
  if (!ranked.length) return { status: 'none', alternatives: [] };
320
436
  const [best, second] = ranked;
321
437
  if (second && best.score - second.score < AMBIGUITY_MARGIN) {