simframe 0.4.2 → 0.6.0-rc.1

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 (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
package/src/input.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // capability layered on top, so every entry point here has to answer "is this
3
3
  // even available?" before it answers anything else.
4
4
  import { execFile } from 'node:child_process';
5
+ import * as control from './control.js';
5
6
  import { promisify } from 'node:util';
6
7
 
7
8
  const run = promisify(execFile);
@@ -11,6 +12,77 @@ const IDB_HINT =
11
12
 
12
13
  let driverCache = null;
13
14
 
15
+ /**
16
+ * Which input driver to use for a device.
17
+ *
18
+ * simframed is preferred when its control socket is live: it needs no install,
19
+ * speaks points natively, and is the path that survives idb breaking on a new
20
+ * iOS. idb remains the fallback so a machine without the daemon still works.
21
+ */
22
+ export async function driverFor(udid) {
23
+ if (udid && control.available(udid)) {
24
+ try {
25
+ const status = await control.status(udid);
26
+ if (status.input?.available) {
27
+ return { name: 'simframed', available: true, version: status.input.detail, reason: null, viaSocket: true };
28
+ }
29
+ return { name: 'simframed', available: false, version: null, reason: status.input?.detail ?? 'input unavailable', viaSocket: true };
30
+ } catch {
31
+ /* daemon went away mid-call; fall through to idb */
32
+ }
33
+ }
34
+ return detectDriver();
35
+ }
36
+
37
+ /**
38
+ * An escape hatch back to idb for the tree.
39
+ *
40
+ * Every private-framework path here is version-coupled, and the host-side
41
+ * translator is no exception: an Xcode upgrade could break it on a machine
42
+ * where work still has to happen that day. Reading it per call rather than
43
+ * caching means the switch takes effect without restarting anything.
44
+ */
45
+ function preferIdbTree() {
46
+ return process.env.SIMFRAME_AX_DRIVER === 'idb';
47
+ }
48
+
49
+ /**
50
+ * Which driver reads the accessibility tree for a device.
51
+ *
52
+ * Separate from `driverFor` because these are separate capabilities: a device
53
+ * can be perfectly touchable by the daemon while the translation framework is
54
+ * missing, and reporting one number for both hides which layer is down.
55
+ */
56
+ export async function axDriverFor(udid) {
57
+ if (udid && control.available(udid) && !preferIdbTree()) {
58
+ try {
59
+ const status = await control.status(udid);
60
+ if (status.accessibility?.available) {
61
+ return { name: 'simframed', available: true, chosen: false, version: status.accessibility.detail, reason: null };
62
+ }
63
+ // The daemon is up and says it cannot read the tree. idb might still,
64
+ // so this is a reason to fall through rather than an answer.
65
+ } catch {
66
+ /* daemon went away mid-call; fall through to idb */
67
+ }
68
+ }
69
+ const idbDriver = await detectDriver();
70
+ // Asked for, or fallen back to? A driver someone chose is not a degradation,
71
+ // and grading it as one turns the documented escape hatch into a red CI run
72
+ // on exactly the day an Xcode upgrade makes you reach for it.
73
+ const chosen = preferIdbTree();
74
+ if (idbDriver.available) {
75
+ return {
76
+ name: 'idb',
77
+ available: true,
78
+ chosen,
79
+ version: chosen ? `${idbDriver.version} — selected by SIMFRAME_AX_DRIVER` : idbDriver.version,
80
+ reason: null,
81
+ };
82
+ }
83
+ return { name: null, available: false, chosen, version: null, reason: idbDriver.reason };
84
+ }
85
+
14
86
  /** @returns {Promise<{name: string, available: boolean, version: string|null, reason: string|null}>} */
15
87
  export async function detectDriver({ refresh = false } = {}) {
16
88
  if (driverCache && !refresh) return driverCache;
@@ -68,6 +140,28 @@ export async function screenInfo(udid, { refresh = false } = {}) {
68
140
  }
69
141
 
70
142
  async function readScreenInfo(udid) {
143
+ // Ask the daemon first. It holds the device's own point size and scale, which
144
+ // makes it both authoritative and free — and it means geometry no longer
145
+ // needs idb at all. Going to idb first meant a machine without idb could
146
+ // capture and tap perfectly well but could not run a verified flow, because
147
+ // building a screen map needs the point size.
148
+ if (control.available(udid)) {
149
+ try {
150
+ const { device } = await control.status(udid);
151
+ if (device?.pointWidth && device?.pointHeight) {
152
+ const density = device.scale ?? 1;
153
+ return {
154
+ pixelWidth: Math.round(device.pointWidth * density),
155
+ pixelHeight: Math.round(device.pointHeight * density),
156
+ density,
157
+ pointWidth: device.pointWidth,
158
+ pointHeight: device.pointHeight,
159
+ };
160
+ }
161
+ } catch {
162
+ /* daemon went away mid-call; fall through to idb */
163
+ }
164
+ }
71
165
  const out = await idb(['describe', '--json', '--udid', udid]);
72
166
  const info = JSON.parse(out.trim().split('\n').filter(Boolean).pop());
73
167
  const dims = info.screen_dimensions || {};
@@ -83,6 +177,20 @@ async function readScreenInfo(udid) {
83
177
 
84
178
  /** The accessibility tree, flattened. This is what makes tap-by-label possible. */
85
179
  export async function describeAll(udid) {
180
+ // The daemon reads the tree host-side through AXPTranslator: no install, and
181
+ // measured at 45ms against idb's 203ms on the same screen. idb stays as the
182
+ // fallback, so a machine without the daemon still reads.
183
+ if (control.available(udid) && !preferIdbTree()) {
184
+ try {
185
+ const { screen } = await control.request(udid, { action: 'ui', ocr: false });
186
+ // An app mid-launch genuinely has no tree yet. Falling through to idb
187
+ // here would just ask a second time and report the same emptiness more
188
+ // slowly, so the honest answer is the empty one.
189
+ if (screen?.sources?.includes('ax')) return (screen.elements ?? []).map(elementToNode);
190
+ } catch {
191
+ /* daemon went away mid-call; fall through to idb */
192
+ }
193
+ }
86
194
  // Passing --json here yields empty output; the default already emits JSON.
87
195
  const out = await idb(['ui', 'describe-all', '--udid', udid]);
88
196
  const nodes = [];
@@ -104,6 +212,20 @@ export async function describeAll(udid) {
104
212
  return nodes.filter((n) => n.frame);
105
213
  }
106
214
 
215
+ /** A daemon element back into the node shape every caller here expects. */
216
+ export function elementToNode(e) {
217
+ return {
218
+ label: cleanLabel(e.label),
219
+ rawLabel: e.label ?? null,
220
+ value: e.value ?? null,
221
+ type: e.role ?? null,
222
+ identifier: e.identifier ?? null,
223
+ enabled: e.state?.enabled ?? null,
224
+ frame: e.frame ?? null,
225
+ raw: e,
226
+ };
227
+ }
228
+
107
229
  // Icon fonts put glyphs in the Unicode private use areas, so a label arrives as
108
230
  // "<glyph>, My Tools". Matching has to see through that to the readable text.
109
231
  const PRIVATE_USE = /[\u{E000}-\u{F8FF}\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/gu;
@@ -177,10 +299,15 @@ export function centerOf(node) {
177
299
  }
178
300
 
179
301
  export async function tapPoint(udid, x, y, { durationMs } = {}) {
180
- const args = ['ui', 'tap', '--udid', udid, String(Math.round(x)), String(Math.round(y))];
302
+ const point = { x: Math.round(x), y: Math.round(y) };
303
+ if (control.available(udid)) {
304
+ await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
305
+ return point;
306
+ }
307
+ const args = ['ui', 'tap', '--udid', udid, String(point.x), String(point.y)];
181
308
  if (durationMs) args.push('--duration', String(durationMs / 1000));
182
309
  await idb(args);
183
- return { x: Math.round(x), y: Math.round(y) };
310
+ return point;
184
311
  }
185
312
 
186
313
  export async function tapLabel(udid, query, { index, durationMs } = {}) {
@@ -191,6 +318,21 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
191
318
  }
192
319
 
193
320
  export async function typeText(udid, value) {
321
+ if (control.available(udid)) {
322
+ // The daemon's paste path carries characters rather than key positions, so
323
+ // it is not reinterpreted by the device's keyboard layout.
324
+ await control.paste(udid, String(value));
325
+ return;
326
+ }
327
+ await idb(['ui', 'text', '--udid', udid, String(value)]);
328
+ }
329
+
330
+ /** Key events rather than text: for shortcuts and search-as-you-type. */
331
+ export async function typeKeys(udid, value) {
332
+ if (control.available(udid)) {
333
+ await control.type(udid, String(value));
334
+ return;
335
+ }
194
336
  await idb(['ui', 'text', '--udid', udid, String(value)]);
195
337
  }
196
338
 
@@ -198,11 +340,46 @@ export async function pressKey(udid, keycode) {
198
340
  await idb(['ui', 'key', '--udid', udid, String(keycode)]);
199
341
  }
200
342
 
343
+ /**
344
+ * Rebuild the daemon's HID session.
345
+ *
346
+ * Input is the one path with no feedback: a dispatched Indigo message reports
347
+ * success when the send succeeds, and nothing asks the device whether anything
348
+ * happened. Measured on a long-running daemon, a HOME press returned in 66ms
349
+ * and the screen did not move; the same press on a freshly started daemon
350
+ * worked. Whoever holds the frames is the only one who can notice, which is why
351
+ * this is something callers invoke rather than something input does for itself.
352
+ *
353
+ * @returns {Promise<boolean>} whether a session was actually reset.
354
+ */
355
+ export async function resetSession(udid) {
356
+ if (!control.available(udid)) return false;
357
+ try {
358
+ await control.resetInput(udid);
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
201
365
  export async function pressButton(udid, name) {
366
+ if (control.available(udid)) {
367
+ try {
368
+ await control.press(udid, String(name).toLowerCase());
369
+ return;
370
+ } catch (err) {
371
+ // Only home is verified through Indigo; anything else falls back.
372
+ if (!(await detectDriver()).available) throw err;
373
+ }
374
+ }
202
375
  await idb(['ui', 'button', '--udid', udid, String(name).toUpperCase()]);
203
376
  }
204
377
 
205
378
  export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
379
+ if (control.available(udid)) {
380
+ await control.swipe(udid, from, to, { durationMs });
381
+ return;
382
+ }
206
383
  await idb([
207
384
  'ui', 'swipe', '--udid', udid,
208
385
  String(Math.round(from.x)), String(Math.round(from.y)),
@@ -0,0 +1,265 @@
1
+ // Resolving "what did they mean" against what is on screen.
2
+ //
3
+ // The rule throughout: never guess between two plausible answers. A wrong tap
4
+ // is worse than a question, because a wrong tap can do something and the caller
5
+ // will believe it did the right thing.
6
+
7
+ /** Names for controls that carry an icon and no readable label. */
8
+ const SYNONYMS = {
9
+ back: ['back', 'chevron.left', 'navigate back', 'previous', 'return'],
10
+ close: ['close', 'dismiss', 'xmark', 'cancel', 'done'],
11
+ search: ['search', 'find', 'magnifyingglass'],
12
+ add: ['add', 'new', 'create', 'plus', 'compose'],
13
+ more: ['more', 'options', 'ellipsis', 'overflow', 'menu'],
14
+ settings: ['settings', 'preferences', 'gear', 'configure'],
15
+ share: ['share', 'export', 'send'],
16
+ delete: ['delete', 'remove', 'trash', 'bin'],
17
+ edit: ['edit', 'modify', 'change'],
18
+ save: ['save', 'apply', 'confirm', 'ok', 'submit'],
19
+ };
20
+
21
+ /** Words that say what kind of control the caller means. */
22
+ const ROLE_HINTS = [
23
+ { pattern: /\b(type|enter|fill|input)\b/i, roles: /field|textfield|textview|searchfield/i },
24
+ { pattern: /\b(tap|press|click|hit)\b/i, roles: /button|link|cell|tab/i },
25
+ { pattern: /\b(toggle|switch|enable|disable|turn)\b/i, roles: /switch|toggle|checkbox/i },
26
+ { pattern: /\b(tab)\b/i, roles: /tab/i },
27
+ ];
28
+
29
+ /** Words that say where on screen the caller means. */
30
+ const REGION_HINTS = [
31
+ { pattern: /\btab\b/i, region: 'tab-bar' },
32
+ { pattern: /\b(back|nav|title|toolbar)\b/i, region: 'nav-bar' },
33
+ ];
34
+
35
+ const norm = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
36
+
37
+ /** Levenshtein distance, capped: beyond the cap the exact value is irrelevant. */
38
+ export function editDistance(a, b, cap = 8) {
39
+ if (a === b) return 0;
40
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
41
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
42
+ for (let i = 1; i <= a.length; i++) {
43
+ const row = [i];
44
+ let best = i;
45
+ for (let j = 1; j <= b.length; j++) {
46
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
47
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
48
+ if (row[j] < best) best = row[j];
49
+ }
50
+ if (best > cap) return cap + 1;
51
+ prev = row;
52
+ }
53
+ return prev[b.length];
54
+ }
55
+
56
+ /** How well one name answers to a query, 0 to 1. */
57
+ export function nameScore(name, query) {
58
+ const n = norm(name);
59
+ const q = norm(query);
60
+ if (!n || !q) return 0;
61
+ if (n === q) return 1;
62
+ if (n.startsWith(q) || q.startsWith(n)) return 0.86;
63
+ // A substring match is only as good as the share of the name it covers.
64
+ // Without this, "back" scores 0.78 against a two-hundred-character list row
65
+ // that happens to contain "Back of House", and beats the actual back button.
66
+ if (n.includes(q)) return 0.78 * Math.max(0.15, q.length / n.length);
67
+ if (q.includes(n)) return 0.7 * Math.max(0.15, n.length / q.length);
68
+ // Fuzzy, so a typo or a stray plural still resolves.
69
+ //
70
+ // editDistance returns cap+1 as a sentinel when it gives up early. Treating
71
+ // that as a measurement made every long string score well: 1 - 9/200 is
72
+ // 0.955, so a two-hundred-character list row scored 0.687 against any query
73
+ // at all. A bail-out is "no answer", not "nearly identical".
74
+ const cap = 8;
75
+ const distance = editDistance(n, q, cap);
76
+ if (distance > cap) return 0;
77
+ const longest = Math.max(n.length, q.length);
78
+ const similarity = 1 - distance / longest;
79
+ return similarity >= 0.7 ? similarity * 0.72 : 0;
80
+ }
81
+
82
+ function synonymGroup(query) {
83
+ const q = norm(query);
84
+ for (const [key, words] of Object.entries(SYNONYMS)) {
85
+ if (words.some((w) => w === q || q.includes(w))) return { key, words };
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Rank what is on screen against an intent.
92
+ *
93
+ * Returns candidates sorted best first, each with the reasons behind its score
94
+ * so a caller — or a person reading a failure — can see why.
95
+ */
96
+ export function rank(targets, intent, { screen } = {}) {
97
+ // Never offer something that is not on screen. A scrolled-away row still sits
98
+ // in the map with a negative y, and tapping it lands somewhere else entirely.
99
+ const visible = screen?.width && screen?.height
100
+ ? targets.filter((t) => {
101
+ const f = t.frame;
102
+ if (!f) return t.y >= 0 && t.y <= screen.height && t.x >= 0 && t.x <= screen.width;
103
+ return f.y + (f.height ?? 0) > 0 && f.y < screen.height
104
+ && f.x + (f.width ?? 0) > 0 && f.x < screen.width;
105
+ })
106
+ : targets;
107
+ const group = synonymGroup(intent);
108
+ const roleHint = ROLE_HINTS.find((h) => h.pattern.test(intent));
109
+ const regionHint = REGION_HINTS.find((h) => h.pattern.test(intent));
110
+ // Strip the verb: "tap the Save button" should match a control called "Save".
111
+ const bare = norm(intent)
112
+ .replace(/^(please\s+)?(tap|press|click|hit|type|enter|fill|open|select|choose|toggle|switch)\s+/i, '')
113
+ .replace(/^(the|a|an)\s+/i, '')
114
+ .replace(/\s+(button|tab|field|cell|link|icon)$/i, '');
115
+
116
+ const scored = [];
117
+ for (const t of visible) {
118
+ const names = [t.label, ...(t.aliases ?? [])].filter(Boolean);
119
+ let base = 0;
120
+ let matched = null;
121
+ for (const name of names) {
122
+ const s = Math.max(nameScore(name, intent), nameScore(name, bare));
123
+ if (s > base) {
124
+ base = s;
125
+ matched = name;
126
+ }
127
+ }
128
+ // An icon-only control has no readable name, so a synonym is the only way
129
+ // to reach it — this is how "back" finds a bare chevron.
130
+ if (group && base < 0.5 && !t.label && t.rawLabel) base = 0.55;
131
+ if (group && base < 0.5 && names.some((n) => group.words.includes(norm(n)))) base = 0.9;
132
+ if (base <= 0) continue;
133
+
134
+ const reasons = [matched ? `label "${matched}"` : 'icon-only'];
135
+ let score = base;
136
+ if (roleHint && roleHint.roles.test(t.type ?? '')) {
137
+ score += 0.12;
138
+ reasons.push(`role ${t.type}`);
139
+ }
140
+ if (regionHint && t.region === regionHint.region) {
141
+ score += 0.15;
142
+ reasons.push(`region ${t.region}`);
143
+ }
144
+ // A caption is not a control. Prefer something tappable when the names tie.
145
+ if (/button|link|cell|field|switch|tab/i.test(t.type ?? '')) {
146
+ score += 0.05;
147
+ reasons.push('interactive');
148
+ }
149
+ // Not capped here: clamping to 1 before comparing throws away exactly the
150
+ // signal the bonuses exist to provide. Two elements sharing a label both
151
+ // reach 1.0 on the name alone, and the region bonus that should separate
152
+ // them disappears into the ceiling.
153
+ scored.push({ target: t, score, reasons });
154
+ }
155
+ return scored.sort((a, b) => b.score - a.score);
156
+ }
157
+
158
+ /** How close two candidates may be before the answer counts as ambiguous. */
159
+ export const AMBIGUITY_MARGIN = 0.08;
160
+ /** Below this, no candidate is worth acting on. */
161
+ export const MINIMUM_SCORE = 0.45;
162
+
163
+ /**
164
+ * How close two tap points have to be to mean the same control.
165
+ *
166
+ * Deliberately small. Two genuinely different controls are not twelve points
167
+ * apart centre to centre on any screen iOS lays out; two *readings* of one
168
+ * control are one or two points apart, because the accessibility tree and OCR
169
+ * are describing the same rectangle. Measured on a real filter row: the tree
170
+ * published "Location (All)" at (201,181) and OCR read "Location (AII)" at
171
+ * (200,182), and the caller was asked which of the two it meant.
172
+ */
173
+ export const SAME_CONTROL_POINTS = 12;
174
+
175
+ /**
176
+ * Two candidates in the same place are one control read twice.
177
+ *
178
+ * Asking which one was meant is not caution here, it is a question with no
179
+ * answer — either tap lands on the same pixel. So the readings are collapsed,
180
+ * and the accessibility one wins, because it is the actual hit target and its
181
+ * label has not been through OCR.
182
+ */
183
+ const INTERACTIVE_ROLE = /button|field|cell|row|link|switch|slider|tab|menu|segment|checkbox/i;
184
+
185
+ const contains = (frame, target) =>
186
+ Boolean(frame)
187
+ && target.x >= frame.x && target.x <= frame.x + (frame.width ?? 0)
188
+ && target.y >= frame.y && target.y <= frame.y + (frame.height ?? 0);
189
+
190
+ /**
191
+ * Are these two candidates the same control?
192
+ *
193
+ * Two ways, and the second one cost a measurement. Centres a couple of points
194
+ * apart are one rectangle read twice. But a full-width list cell and the
195
+ * left-aligned text printed inside it have centres a hundred points apart and
196
+ * are still one tap target — measured on a Settings list, where "General" came
197
+ * back as the cell at (201,326) and the OCR text at (102,327) and the caller
198
+ * was asked which of the two it meant. The screen map already folds that pair
199
+ * into one row; this is `locate` catching up with it.
200
+ */
201
+ function sameControl(a, b) {
202
+ if (Math.abs(a.x - b.x) <= SAME_CONTROL_POINTS && Math.abs(a.y - b.y) <= SAME_CONTROL_POINTS) return true;
203
+ // Containment only counts when the container is a hit target. A group that
204
+ // merely encloses things is not the thing inside it, which is what stops a
205
+ // tab bar from absorbing its own tabs.
206
+ if (INTERACTIVE_ROLE.test(a.type ?? '') && contains(a.frame, b)) return true;
207
+ if (INTERACTIVE_ROLE.test(b.type ?? '') && contains(b.frame, a)) return true;
208
+ return false;
209
+ }
210
+
211
+ function collapseSamePlace(ranked) {
212
+ const kept = [];
213
+ for (const c of ranked) {
214
+ const twin = kept.find((k) => sameControl(k.target, c.target));
215
+ if (!twin) {
216
+ kept.push(c);
217
+ continue;
218
+ }
219
+ // Prefer the real hit target: an accessibility element over OCR's reading of
220
+ // it, and an interactive role over a caption sitting inside it.
221
+ const better = (candidate, incumbent) => {
222
+ if (candidate.target.source === 'ax' && incumbent.target.source !== 'ax') return true;
223
+ if (candidate.target.source !== 'ax' && incumbent.target.source === 'ax') return false;
224
+ return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
225
+ && !INTERACTIVE_ROLE.test(incumbent.target.type ?? '');
226
+ };
227
+ if (better(c, twin)) {
228
+ kept[kept.indexOf(twin)] = { ...c, reasons: [...c.reasons, 'the hit target, not the text printed on it'] };
229
+ }
230
+ }
231
+ return kept;
232
+ }
233
+
234
+ /**
235
+ * Resolve an intent to one element, or say why not.
236
+ * @returns {{status: 'ok'|'ambiguous'|'none', target?, score?, reasons?, alternatives?}}
237
+ */
238
+ export function resolve(targets, intent, options = {}) {
239
+ const ranked = collapseSamePlace(rank(targets, intent, options).filter((c) => c.score >= MINIMUM_SCORE));
240
+ if (!ranked.length) return { status: 'none', alternatives: [] };
241
+ const [best, second] = ranked;
242
+ if (second && best.score - second.score < AMBIGUITY_MARGIN) {
243
+ return {
244
+ status: 'ambiguous',
245
+ alternatives: ranked.slice(0, 5).map((c) => ({
246
+ label: c.target.label ?? '(icon-only)',
247
+ x: c.target.x,
248
+ y: c.target.y,
249
+ region: c.target.region,
250
+ score: Math.round(Math.min(1, c.score) * 100) / 100,
251
+ reasons: c.reasons,
252
+ })),
253
+ };
254
+ }
255
+ return {
256
+ status: 'ok',
257
+ target: best.target,
258
+ score: Math.round(Math.min(1, best.score) * 100) / 100,
259
+ reasons: best.reasons,
260
+ alternatives: ranked.slice(1, 4).map((c) => ({
261
+ label: c.target.label ?? '(icon-only)',
262
+ score: Math.round(Math.min(1, c.score) * 100) / 100,
263
+ })),
264
+ };
265
+ }