react-native-ai-debugger 1.6.16 → 1.8.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 (68) hide show
  1. package/README.md +8 -1
  2. package/build/__tests__/unit/exec.test.d.ts +2 -0
  3. package/build/__tests__/unit/exec.test.d.ts.map +1 -0
  4. package/build/__tests__/unit/exec.test.js +54 -0
  5. package/build/__tests__/unit/exec.test.js.map +1 -0
  6. package/build/__tests__/unit/focusedInput.test.d.ts +2 -0
  7. package/build/__tests__/unit/focusedInput.test.d.ts.map +1 -0
  8. package/build/__tests__/unit/focusedInput.test.js +58 -0
  9. package/build/__tests__/unit/focusedInput.test.js.map +1 -0
  10. package/build/__tests__/unit/focusedInputTools.test.d.ts +2 -0
  11. package/build/__tests__/unit/focusedInputTools.test.d.ts.map +1 -0
  12. package/build/__tests__/unit/focusedInputTools.test.js +79 -0
  13. package/build/__tests__/unit/focusedInputTools.test.js.map +1 -0
  14. package/build/__tests__/unit/tap.test.js +46 -0
  15. package/build/__tests__/unit/tap.test.js.map +1 -1
  16. package/build/core/android.d.ts +3 -3
  17. package/build/core/android.d.ts.map +1 -1
  18. package/build/core/android.js +21 -14
  19. package/build/core/android.js.map +1 -1
  20. package/build/core/connection.d.ts.map +1 -1
  21. package/build/core/connection.js +3 -2
  22. package/build/core/connection.js.map +1 -1
  23. package/build/core/errors.d.ts +14 -0
  24. package/build/core/errors.d.ts.map +1 -0
  25. package/build/core/errors.js +17 -0
  26. package/build/core/errors.js.map +1 -0
  27. package/build/core/exec.d.ts +22 -0
  28. package/build/core/exec.d.ts.map +1 -0
  29. package/build/core/exec.js +54 -0
  30. package/build/core/exec.js.map +1 -0
  31. package/build/core/executor.d.ts +2 -0
  32. package/build/core/executor.d.ts.map +1 -1
  33. package/build/core/executor.js +232 -2
  34. package/build/core/executor.js.map +1 -1
  35. package/build/core/focusedInput.d.ts +27 -0
  36. package/build/core/focusedInput.d.ts.map +1 -0
  37. package/build/core/focusedInput.js +110 -0
  38. package/build/core/focusedInput.js.map +1 -0
  39. package/build/core/focusedInputTools.d.ts +21 -0
  40. package/build/core/focusedInputTools.d.ts.map +1 -0
  41. package/build/core/focusedInputTools.js +51 -0
  42. package/build/core/focusedInputTools.js.map +1 -0
  43. package/build/core/guides.d.ts.map +1 -1
  44. package/build/core/guides.js +15 -2
  45. package/build/core/guides.js.map +1 -1
  46. package/build/core/index.d.ts +1 -1
  47. package/build/core/index.d.ts.map +1 -1
  48. package/build/core/index.js +1 -1
  49. package/build/core/index.js.map +1 -1
  50. package/build/core/ios.d.ts +8 -0
  51. package/build/core/ios.d.ts.map +1 -1
  52. package/build/core/ios.js +142 -2
  53. package/build/core/ios.js.map +1 -1
  54. package/build/core/ocr.d.ts +13 -0
  55. package/build/core/ocr.d.ts.map +1 -1
  56. package/build/core/ocr.js +37 -15
  57. package/build/core/ocr.js.map +1 -1
  58. package/build/core/posthog.d.ts.map +1 -1
  59. package/build/core/posthog.js +7 -1
  60. package/build/core/posthog.js.map +1 -1
  61. package/build/index.d.ts.map +1 -1
  62. package/build/index.js +158 -31
  63. package/build/index.js.map +1 -1
  64. package/build/pro/tap.d.ts +51 -5
  65. package/build/pro/tap.d.ts.map +1 -1
  66. package/build/pro/tap.js +457 -54
  67. package/build/pro/tap.js.map +1 -1
  68. package/package.json +1 -1
package/build/pro/tap.js CHANGED
@@ -90,6 +90,76 @@ export function hasProblematicUnicode(text) {
90
90
  function normalizeForMatch(text) {
91
91
  return text.trim().replace(/\s+/g, " ").toLowerCase();
92
92
  }
93
+ export function reconstructLinesFromWords(words) {
94
+ if (!words.length)
95
+ return [];
96
+ const sorted = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0);
97
+ const groups = [];
98
+ let current = [];
99
+ let runningMidY = -Infinity;
100
+ let runningHeight = 0;
101
+ for (const w of sorted) {
102
+ const wMid = (w.bbox.y0 + w.bbox.y1) / 2;
103
+ const wHeight = Math.max(1, w.bbox.y1 - w.bbox.y0);
104
+ const tolerance = Math.min(wHeight, runningHeight || wHeight) * 0.5;
105
+ if (current.length === 0 || Math.abs(wMid - runningMidY) <= tolerance) {
106
+ current.push(w);
107
+ const n = current.length;
108
+ runningMidY = runningMidY === -Infinity ? wMid : (runningMidY * (n - 1) + wMid) / n;
109
+ runningHeight = (runningHeight * (n - 1) + wHeight) / n;
110
+ }
111
+ else {
112
+ groups.push(current);
113
+ current = [w];
114
+ runningMidY = wMid;
115
+ runningHeight = wHeight;
116
+ }
117
+ }
118
+ if (current.length > 0)
119
+ groups.push(current);
120
+ return groups.map(group => {
121
+ const sortedWords = [...group].sort((a, b) => a.bbox.x0 - b.bbox.x0);
122
+ const text = sortedWords.map(w => w.text).join(" ");
123
+ const x0 = Math.min(...sortedWords.map(w => w.bbox.x0));
124
+ const y0 = Math.min(...sortedWords.map(w => w.bbox.y0));
125
+ const x1 = Math.max(...sortedWords.map(w => w.bbox.x1));
126
+ const y1 = Math.max(...sortedWords.map(w => w.bbox.y1));
127
+ const tapX = sortedWords.reduce((s, w) => s + w.tapCenter.x, 0) / sortedWords.length;
128
+ const tapY = sortedWords.reduce((s, w) => s + w.tapCenter.y, 0) / sortedWords.length;
129
+ return { text, bbox: { x0, y0, x1, y1 }, tapCenter: { x: tapX, y: tapY }, words: sortedWords };
130
+ });
131
+ }
132
+ /**
133
+ * Narrow the tap point to just the words that produced the substring match.
134
+ * Without this, a query for "Remotely" against a reconstructed line "Yes Remotely No"
135
+ * would tap at the line's centroid (middle of "Remotely" in this case, but easily wrong
136
+ * for asymmetric layouts). Tracks character offsets through the joined text so we know
137
+ * which constituent words contributed to the matched range.
138
+ */
139
+ function refineTapCenterToMatchingWords(line, normalizedNeedle) {
140
+ const normalizedFull = normalizeForMatch(line.text);
141
+ const startChar = normalizedFull.indexOf(normalizedNeedle);
142
+ if (startChar < 0)
143
+ return line.tapCenter;
144
+ const endChar = startChar + normalizedNeedle.length;
145
+ let offset = 0;
146
+ const covered = [];
147
+ for (const w of line.words) {
148
+ const wNorm = normalizeForMatch(w.text);
149
+ const wordStart = offset;
150
+ const wordEnd = offset + wNorm.length;
151
+ // Word overlaps the matched range
152
+ if (wordEnd > startChar && wordStart < endChar) {
153
+ covered.push(w);
154
+ }
155
+ offset = wordEnd + 1; // +1 for the joining space
156
+ }
157
+ if (covered.length === 0)
158
+ return line.tapCenter;
159
+ const tapX = covered.reduce((s, w) => s + w.tapCenter.x, 0) / covered.length;
160
+ const tapY = covered.reduce((s, w) => s + w.tapCenter.y, 0) / covered.length;
161
+ return { x: tapX, y: tapY };
162
+ }
93
163
  export function findOcrMatch(ocrResult, query) {
94
164
  const needle = normalizeForMatch(query);
95
165
  if (!needle)
@@ -108,6 +178,19 @@ export function findOcrMatch(ocrResult, query) {
108
178
  const substringWord = words.find((w) => normalizeForMatch(w.text).includes(needle));
109
179
  if (substringWord)
110
180
  return { text: substringWord.text, tapCenter: substringWord.tapCenter };
181
+ // Fall through to phrases reconstructed from word detections. Tap point is
182
+ // refined to only the words covering the matched substring, so a query for
183
+ // "Continue with Google" inside "[icon] Continue with Google" lands on the
184
+ // text run instead of the line centroid (and won't drift onto the icon).
185
+ const reconstructed = reconstructLinesFromWords(words);
186
+ const exactRecon = reconstructed.find((l) => normalizeForMatch(l.text) === needle);
187
+ if (exactRecon) {
188
+ return { text: exactRecon.text, tapCenter: refineTapCenterToMatchingWords(exactRecon, needle) };
189
+ }
190
+ const substringRecon = reconstructed.find((l) => normalizeForMatch(l.text).includes(needle));
191
+ if (substringRecon) {
192
+ return { text: substringRecon.text, tapCenter: refineTapCenterToMatchingWords(substringRecon, needle) };
193
+ }
111
194
  return null;
112
195
  }
113
196
  export function getAvailableStrategies(query, strategy) {
@@ -240,16 +323,29 @@ export async function getCurrentScreen() {
240
323
  }
241
324
  export function formatTapSuccess(data) {
242
325
  const { screenshot, verification, ...rest } = data;
326
+ // I3 (2026-05-16): when the tap landed on a TextInput-like element and the diff
327
+ // reports no visual change, that does NOT mean the focus failed. iOS simulators with
328
+ // the hardware keyboard connected (Cmd+K) suppress the on-screen keyboard, so the
329
+ // input is focused but the screen looks identical. Surface this so the agent doesn't
330
+ // assume the tap missed and retry the wrong thing.
331
+ const isTextInputComponent = (() => {
332
+ const name = data.component ?? data.pressed ?? "";
333
+ return /textfield|textinput|edittext|searchfield/i.test(name);
334
+ })();
335
+ const note = verification && !verification.skipped && verification.meaningful === false && isTextInputComponent
336
+ ? "TextInput focused but no visual change detected. If the simulator has a hardware keyboard connected (Cmd+K), the software keyboard is suppressed even though the input is focused — proceed with text entry via ios_input_text / android_input_text."
337
+ : undefined;
243
338
  return {
244
339
  success: true,
245
340
  ...rest,
246
341
  ...(verification && { verification }),
247
- ...(screenshot && { screenshot })
342
+ ...(screenshot && { screenshot }),
343
+ ...(note && { warning: note })
248
344
  };
249
345
  }
250
346
  export function formatTapFailure(data) {
251
347
  const errorMsg = data.error || buildErrorMessage(data.query);
252
- const warning = data.verification && !data.verification.meaningful
348
+ const warning = data.verification && !data.verification.skipped && data.verification.meaningful === false
253
349
  ? "Tap executed but no visual change detected. The element may not exist at these coordinates. Examine the screenshot to verify and retry with adjusted coordinates."
254
350
  : undefined;
255
351
  const lastStrategy = data.attempted.length > 0 ? data.attempted[data.attempted.length - 1].strategy : undefined;
@@ -279,11 +375,41 @@ function buildErrorMessage(query) {
279
375
  parts.push(`component="${query.component}"`);
280
376
  return `No element found matching ${parts.join(", ")}`;
281
377
  }
378
+ // I2 (2026-05-16): RN component composition routinely produces N matches at the same
379
+ // coordinates for the same testID/text (e.g. ThemedButton → TouchableOpacity → TouchableOpacity).
380
+ // The C1 ambiguity guard refuses all such taps and demands `index=`. That's correct in the
381
+ // spatial-ambiguity case (two distinct buttons share a label) but wrong in the
382
+ // wrapper-ambiguity case — there's only one logical button, just stacked components.
383
+ //
384
+ // Collapse matches that share the same (testID, text) and overlap geometrically
385
+ // (centers within TOLERANCE_PX). Keeps the first occurrence — fiber/a11y walks parent-first,
386
+ // so the outermost wrapper survives (its onPress almost always proxies to inner handlers).
387
+ // Spatial ambiguity (different x/y) is preserved and continues to surface the C1 refusal.
388
+ function collapseGeometricallyEquivalentMatches(matches, getCenter, getKey) {
389
+ if (matches.length <= 1)
390
+ return matches;
391
+ const TOLERANCE_PX = 2;
392
+ const out = [];
393
+ for (const m of matches) {
394
+ const mc = getCenter(m);
395
+ const mk = getKey(m);
396
+ const isDuplicate = out.some(o => {
397
+ if (getKey(o) !== mk)
398
+ return false;
399
+ const oc = getCenter(o);
400
+ return Math.abs((mc.x ?? 0) - (oc.x ?? 0)) <= TOLERANCE_PX &&
401
+ Math.abs((mc.y ?? 0) - (oc.y ?? 0)) <= TOLERANCE_PX;
402
+ });
403
+ if (!isDuplicate)
404
+ out.push(m);
405
+ }
406
+ return out;
407
+ }
282
408
  export function makeEmptyEvidenceSink() {
283
409
  return {
284
410
  fiber: { ran: false, durationMs: 0, metroConnected: false, pressables: [] },
285
411
  accessibility: { ran: false, durationMs: 0, elements: [] },
286
- ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null }
412
+ ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null, bestCandidate: null }
287
413
  };
288
414
  }
289
415
  function ocrSimilarity(a, b) {
@@ -330,6 +456,12 @@ export function findClosestOcrText(ocrResult, query) {
330
456
  candidates.push(...ocrResult.words.map(w => ({ text: w.text })));
331
457
  if (ocrResult?.lines?.length)
332
458
  candidates.push(...ocrResult.lines.map(l => ({ text: l.text })));
459
+ // Include reconstructed phrases so the diagnostic surfaces "Continue with Google@1.00"
460
+ // instead of "Continue@0.54" when the engine fragmented a multi-word phrase into
461
+ // separate word detections.
462
+ if (ocrResult?.words?.length) {
463
+ candidates.push(...reconstructLinesFromWords(ocrResult.words).map(l => ({ text: l.text })));
464
+ }
333
465
  if (!candidates.length)
334
466
  return null;
335
467
  let best = null;
@@ -421,12 +553,19 @@ async function tryFiberAtDepth(query, index, maxTraversalDepth) {
421
553
  // specify an explicit index, refuse to tap and surface the full list
422
554
  // so the agent can pick the right one.
423
555
  if ((parsed.totalMatches ?? 1) > 1 && index === undefined) {
424
- return {
425
- success: false,
426
- reason: `Ambiguous: ${parsed.totalMatches} elements match this query — use index= to pick one`,
427
- matches: parsed.allMatches || [],
428
- ambiguous: true
429
- };
556
+ // I2: collapse geometrically-identical wrapper matches before refusing.
557
+ const collapsed = collapseGeometricallyEquivalentMatches(parsed.allMatches ?? [], (m) => ({ x: m.x, y: m.y }), (m) => `${m.testID ?? ""}::${m.text ?? ""}`);
558
+ if (collapsed.length > 1) {
559
+ return {
560
+ success: false,
561
+ reason: `Ambiguous: ${collapsed.length} elements match this query — use index= to pick one`,
562
+ matches: collapsed,
563
+ ambiguous: true
564
+ };
565
+ }
566
+ // Collapsed to one logical element — fall through and tap. The JS-side
567
+ // search already picked allMatches[0] (the outermost wrapper) for
568
+ // parsed.pressed/parsed.nativeTapTarget.
430
569
  }
431
570
  const elementType = parsed.isInput ? "input element" : "pressable element";
432
571
  if (parsed.nativeTapTarget && parsed.nativeTapTarget.x && parsed.nativeTapTarget.y) {
@@ -462,7 +601,7 @@ async function tryFiberAtDepth(query, index, maxTraversalDepth) {
462
601
  };
463
602
  }
464
603
  }
465
- async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
604
+ async function tryAccessibilityStrategy(query, index, platform, udid, sink, signal) {
466
605
  if (sink)
467
606
  sink.accessibility.ran = true;
468
607
  const startedAt = Date.now();
@@ -535,11 +674,15 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
535
674
  // refuse to tap without an explicit index — picking allMatches[0] silently
536
675
  // landed on non-interactive header elements in production telemetry.
537
676
  // Mirrors the fiber-strategy guard further up the file.
538
- if (result.allMatches.length > 1 && index === undefined) {
677
+ // I2: collapse geometrically-identical wrapper matches before refusing.
678
+ const iosEffectiveMatches = result.allMatches.length > 1
679
+ ? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.identifier ?? ""}::${m.label ?? ""}`)
680
+ : result.allMatches;
681
+ if (iosEffectiveMatches.length > 1 && index === undefined) {
539
682
  return {
540
683
  success: false,
541
- reason: `Ambiguous: ${result.allMatches.length} elements match this query — use index= to pick one`,
542
- matches: result.allMatches.map((m, i) => ({
684
+ reason: `Ambiguous: ${iosEffectiveMatches.length} elements match this query — use index= to pick one`,
685
+ matches: iosEffectiveMatches.map((m, i) => ({
543
686
  index: i,
544
687
  component: m.type ?? "",
545
688
  text: m.label ?? "",
@@ -550,11 +693,11 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
550
693
  ambiguous: true
551
694
  };
552
695
  }
553
- const match = result.allMatches[index ?? 0];
696
+ const match = iosEffectiveMatches[index ?? 0];
554
697
  if (!match) {
555
698
  return {
556
699
  success: false,
557
- reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
700
+ reason: `Index ${index} out of bounds (${iosEffectiveMatches.length} matches)`
558
701
  };
559
702
  }
560
703
  await iosTap(match.center.x, match.center.y, { udid });
@@ -576,14 +719,14 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
576
719
  else if (hasText) {
577
720
  searchOptions.textContains = query.text;
578
721
  }
579
- let result = await androidFindElement(searchOptions);
722
+ let result = await androidFindElement(searchOptions, undefined, signal);
580
723
  // If testID search via resourceId failed, try contentDescContains
581
724
  // (older RN versions map testID to content-description)
582
725
  if (hasTestID && !hasText && (!result.success || !result.allMatches || result.allMatches.length === 0)) {
583
726
  result = await androidFindElement({
584
727
  contentDescContains: query.testID,
585
728
  index
586
- });
729
+ }, undefined, signal);
587
730
  }
588
731
  if (sink && result.allMatches?.length) {
589
732
  sink.accessibility.elements = result.allMatches.slice(0, 50).map(el => ({
@@ -601,11 +744,15 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
601
744
  };
602
745
  }
603
746
  // Ambiguity guard — see iOS branch above for rationale.
604
- if (result.allMatches.length > 1 && index === undefined) {
747
+ // I2: collapse geometrically-identical wrapper matches before refusing.
748
+ const androidEffectiveMatches = result.allMatches.length > 1
749
+ ? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.resourceId ?? ""}::${m.text ?? m.contentDesc ?? ""}`)
750
+ : result.allMatches;
751
+ if (androidEffectiveMatches.length > 1 && index === undefined) {
605
752
  return {
606
753
  success: false,
607
- reason: `Ambiguous: ${result.allMatches.length} elements match this query — use index= to pick one`,
608
- matches: result.allMatches.map((m, i) => ({
754
+ reason: `Ambiguous: ${androidEffectiveMatches.length} elements match this query — use index= to pick one`,
755
+ matches: androidEffectiveMatches.map((m, i) => ({
609
756
  index: i,
610
757
  component: m.className ?? "",
611
758
  text: m.text ?? m.contentDesc ?? "",
@@ -616,11 +763,11 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
616
763
  ambiguous: true
617
764
  };
618
765
  }
619
- const match = result.allMatches[index ?? 0];
766
+ const match = androidEffectiveMatches[index ?? 0];
620
767
  if (!match) {
621
768
  return {
622
769
  success: false,
623
- reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
770
+ reason: `Index ${index} out of bounds (${androidEffectiveMatches.length} matches)`
624
771
  };
625
772
  }
626
773
  await androidTap(match.center.x, match.center.y);
@@ -645,7 +792,14 @@ async function tryAccessibilityStrategy(query, index, platform, udid, sink) {
645
792
  sink.accessibility.durationMs = Date.now() - startedAt;
646
793
  }
647
794
  }
648
- async function tryOcrStrategy(query, platform, udid, sink) {
795
+ /**
796
+ * Run OCR sense (capture + recognize + match). When `probeOnly` is true,
797
+ * populates `sink.ocr.bestCandidate` but does NOT tap — caller is responsible
798
+ * for tapping via tryOcrRecovery() once it decides to use the result.
799
+ * This is the foundation for Step 4's OCR pre-warm: run the slow OCR probe
800
+ * concurrently with fiber/a11y so that when neither wins, OCR is already done.
801
+ */
802
+ async function tryOcrStrategy(query, platform, udid, sink, signal, probeOnly = false) {
649
803
  if (sink)
650
804
  sink.ocr.ran = true;
651
805
  const ocrStartedAt = Date.now();
@@ -669,7 +823,7 @@ async function tryOcrStrategy(query, platform, udid, sink) {
669
823
  }
670
824
  else {
671
825
  const { androidScreenshot } = await import("../core/android.js");
672
- const screenshot = await androidScreenshot();
826
+ const screenshot = await androidScreenshot(undefined, undefined, signal);
673
827
  if (!screenshot.success || !screenshot.data) {
674
828
  return {
675
829
  success: false,
@@ -682,7 +836,8 @@ async function tryOcrStrategy(query, platform, udid, sink) {
682
836
  const { recognizeText } = await import("../core/ocr.js");
683
837
  const ocrResult = await recognizeText(imageBuffer, {
684
838
  scaleFactor,
685
- platform
839
+ platform,
840
+ signal
686
841
  });
687
842
  if (sink && ocrResult) {
688
843
  const allDetections = [
@@ -697,12 +852,35 @@ async function tryOcrStrategy(query, platform, udid, sink) {
697
852
  sink.ocr.closestMatch = findClosestOcrText(ocrResult, searchText);
698
853
  }
699
854
  const match = findOcrMatch(ocrResult, searchText);
855
+ // Step 2 (2026-05-15 plan): record the best candidate with coordinates
856
+ // BEFORE any tap-execution code runs. If the strategy's withTimeout
857
+ // races us by 30ms (real production case: COMPRAR/Portuguese-Android
858
+ // cluster — `findOcrMatch` returned `COMPRAR@1.00` 30ms past the cap),
859
+ // the orchestrator can recover by tapping at these coords with the
860
+ // `ocr-recovered` strategy label.
861
+ if (sink && match) {
862
+ const closest = sink.ocr.closestMatch;
863
+ sink.ocr.bestCandidate = {
864
+ text: match.text,
865
+ score: closest && closest.text === match.text ? closest.score : 1,
866
+ tapCenter: { x: match.tapCenter.x, y: match.tapCenter.y },
867
+ scaleFactor
868
+ };
869
+ }
700
870
  if (!match) {
701
871
  return {
702
872
  success: false,
703
873
  reason: `OCR did not find text "${searchText}" on screen`
704
874
  };
705
875
  }
876
+ // Step 4 pre-warm: bestCandidate is set; let the orchestrator decide
877
+ // whether to actually tap (it may have already won the race via fiber).
878
+ if (probeOnly) {
879
+ return {
880
+ success: false,
881
+ reason: `OCR probe completed (probeOnly) — match "${match.text}" recorded in evidence`
882
+ };
883
+ }
706
884
  if (platform === "ios") {
707
885
  // tapCenter is in image-pixel space (downscaled) — convert to points
708
886
  const { getDevicePixelRatio } = await import("../core/ios.js");
@@ -741,6 +919,39 @@ async function tryOcrStrategy(query, platform, udid, sink) {
741
919
  sink.ocr.durationMs = Date.now() - ocrStartedAt;
742
920
  }
743
921
  }
922
+ /**
923
+ * Step 2 (2026-05-15 plan): OCR-strategy timeout recovery. If the OCR sense
924
+ * already wrote a high-confidence candidate to the evidence sink (perfect
925
+ * match found ~30ms past the strategy cap, COMPRAR cluster), tap directly at
926
+ * the candidate's coordinates. Returns null when there's no candidate worth
927
+ * acting on so the caller falls back to normal failure handling.
928
+ */
929
+ async function tryOcrRecovery(evidence, platform, udid) {
930
+ const bc = evidence.ocr.bestCandidate;
931
+ if (!bc || bc.score < 0.9)
932
+ return null;
933
+ try {
934
+ if (platform === "ios") {
935
+ const { getDevicePixelRatio } = await import("../core/ios.js");
936
+ const dpr = await getDevicePixelRatio(udid);
937
+ const tapResult = await iosTap(Math.round((bc.tapCenter.x * bc.scaleFactor) / dpr), Math.round((bc.tapCenter.y * bc.scaleFactor) / dpr), { udid });
938
+ if (!tapResult.success)
939
+ return null;
940
+ }
941
+ else {
942
+ await androidTap(Math.round(bc.tapCenter.x * bc.scaleFactor), Math.round(bc.tapCenter.y * bc.scaleFactor));
943
+ }
944
+ return {
945
+ success: true,
946
+ reason: `OCR recovered after timeout — tapped "${bc.text}"@${bc.score.toFixed(2)}`,
947
+ text: bc.text,
948
+ convertedTo: { x: bc.tapCenter.x, y: bc.tapCenter.y, unit: "pixels" }
949
+ };
950
+ }
951
+ catch {
952
+ return null;
953
+ }
954
+ }
744
955
  async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, udid) {
745
956
  try {
746
957
  if (platform === "ios") {
@@ -800,12 +1011,20 @@ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, u
800
1011
  const SETTLE_DELAY_MS = 800;
801
1012
  const TAP_TIMEOUT_MS = 20000;
802
1013
  const MIN_STRATEGY_BUDGET_MS = 500;
1014
+ // Per-strategy budget. OCR cap on Android is bumped via maxStrategyMs() because
1015
+ // the ADB screencap+pull leg has ~2s variance on real devices; iOS stays at 5s
1016
+ // where xcrun simctl screenshot is consistent.
803
1017
  const MAX_STRATEGY_MS = {
804
1018
  fiber: 5000,
805
1019
  accessibility: 3000,
806
1020
  ocr: 5000,
807
1021
  coordinate: 3000
808
1022
  };
1023
+ function maxStrategyMs(strategy, platform) {
1024
+ if (strategy === "ocr" && platform === "android")
1025
+ return 7000;
1026
+ return MAX_STRATEGY_MS[strategy] ?? 5000;
1027
+ }
809
1028
  // Matches only the outer withTimeout wrapper message for a tap strategy.
810
1029
  // Nested sub-operation errors inside a strategy (e.g. "CDP getProperties timed out after 150ms")
811
1030
  // must NOT be classified as a tap-level timeout.
@@ -825,6 +1044,22 @@ function withTimeout(promise, ms, label) {
825
1044
  });
826
1045
  });
827
1046
  }
1047
+ /**
1048
+ * Like withTimeout, but creates an AbortController whose signal is handed to
1049
+ * the inner factory. On timeout the signal aborts so subprocess work
1050
+ * (uiautomator dump, OCR fetch, screencap) can be killed instead of running
1051
+ * past the strategy cap and bleeding into total tap duration.
1052
+ */
1053
+ function withCancelableTimeout(make, ms, label) {
1054
+ const ctrl = new AbortController();
1055
+ const timer = setTimeout(() => ctrl.abort(), ms);
1056
+ return make(ctrl.signal).then((val) => { clearTimeout(timer); return val; }, (err) => {
1057
+ clearTimeout(timer);
1058
+ if (ctrl.signal.aborted)
1059
+ throw new Error(`${label} timed out after ${ms}ms`);
1060
+ throw err;
1061
+ });
1062
+ }
828
1063
  async function captureScreenshot(platform, udid) {
829
1064
  try {
830
1065
  const result = platform === "ios" ? await iosScreenshot(undefined, udid) : await androidScreenshot();
@@ -916,23 +1151,60 @@ function computeMarkerPx(args) {
916
1151
  };
917
1152
  }
918
1153
  async function verifyAndCapture(platform, shouldVerify, shouldScreenshot, beforeBuffer, udid, beforeScaleFactor, markerPx) {
919
- if (!shouldScreenshot)
920
- return {};
1154
+ // Decoupled 2026-05-16 (I5): screenshot and verify are independent knobs.
1155
+ // - shouldVerify=true: capture after-screenshot internally, diff against beforeBuffer, return verification.
1156
+ // - shouldScreenshot=true: also return the (marker-overlaid) image bytes in the response.
1157
+ // - both false: fastest path, return only a `skipped` verification stub so the agent can tell apart "ran clean" from "skipped intentionally".
1158
+ if (!shouldVerify && !shouldScreenshot) {
1159
+ return {
1160
+ verification: {
1161
+ skipped: true,
1162
+ skippedReason: "screenshot=false, verify=false",
1163
+ explanation: "Verification skipped (screenshot=false and verify=false)."
1164
+ }
1165
+ };
1166
+ }
921
1167
  await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS));
922
1168
  const after = await captureScreenshot(platform, udid);
923
- if (!after)
1169
+ if (!after) {
1170
+ if (shouldVerify) {
1171
+ return {
1172
+ verification: {
1173
+ skipped: true,
1174
+ skippedReason: "after-screenshot capture failed",
1175
+ explanation: "Verification skipped — could not capture post-tap screenshot."
1176
+ }
1177
+ };
1178
+ }
924
1179
  return {};
1180
+ }
925
1181
  const afterWithMarker = markerPx
926
1182
  ? await drawTapMarker(after.buffer, markerPx.x, markerPx.y)
927
1183
  : after.buffer;
928
- const screenshot = {
929
- image: screenshotToBase64(afterWithMarker),
930
- width: after.width,
931
- height: after.height,
932
- scaleFactor: after.scaleFactor
933
- };
1184
+ const screenshot = shouldScreenshot
1185
+ ? {
1186
+ image: screenshotToBase64(afterWithMarker),
1187
+ width: after.width,
1188
+ height: after.height,
1189
+ scaleFactor: after.scaleFactor
1190
+ }
1191
+ : undefined;
934
1192
  let verification;
935
- if (shouldVerify && beforeBuffer) {
1193
+ if (!shouldVerify) {
1194
+ verification = {
1195
+ skipped: true,
1196
+ skippedReason: "verify=false",
1197
+ explanation: "Verification skipped (verify=false)."
1198
+ };
1199
+ }
1200
+ else if (!beforeBuffer) {
1201
+ verification = {
1202
+ skipped: true,
1203
+ skippedReason: "before-screenshot unavailable",
1204
+ explanation: "Verification skipped — could not capture pre-tap screenshot."
1205
+ };
1206
+ }
1207
+ else {
936
1208
  try {
937
1209
  const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
938
1210
  const scale = beforeScaleFactor || after.scaleFactor || 1;
@@ -953,8 +1225,12 @@ async function verifyAndCapture(platform, shouldVerify, shouldScreenshot, before
953
1225
  })
954
1226
  };
955
1227
  }
956
- catch {
957
- // Diff failed — still return the screenshot
1228
+ catch (err) {
1229
+ verification = {
1230
+ skipped: true,
1231
+ skippedReason: `diff failed: ${err instanceof Error ? err.message : String(err)}`,
1232
+ explanation: "Verification skipped — pixel diff threw."
1233
+ };
958
1234
  }
959
1235
  }
960
1236
  const verifyGroupId = `verify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -1076,6 +1352,40 @@ async function burstCaptureAndVerify(platform, beforeBuffer, udid, beforeScaleFa
1076
1352
  afterWithMarkerBuffer: markedFrames[lastFrameIdx]
1077
1353
  };
1078
1354
  }
1355
+ // D2 (Step 6): in-memory ring buffer of recent coord-strategy artifact keys
1356
+ // `${round(x/20)}:${round(y/20)}`. Entries expire after 60s. The first
1357
+ // failed coord tap at a grid cell uploads its artifact; retries within the
1358
+ // window get suppressed.
1359
+ const recentCoordArtifacts = new Map();
1360
+ const COORD_DEDUP_WINDOW_MS = 60_000;
1361
+ const COORD_DEDUP_BUCKET = 20;
1362
+ function shouldDedupArtifact(ctx) {
1363
+ // Only applies to coordinate-strategy artifacts where we have x,y coords.
1364
+ const winningStrat = ctx.attempted.find(a => a.reason === "success")?.strategy
1365
+ ?? ctx.attempted[ctx.attempted.length - 1]?.strategy;
1366
+ if (winningStrat !== "coordinate")
1367
+ return false;
1368
+ const x = ctx.chosenTapPoint?.x ?? ctx.query.x;
1369
+ const y = ctx.chosenTapPoint?.y ?? ctx.query.y;
1370
+ if (typeof x !== "number" || typeof y !== "number")
1371
+ return false;
1372
+ const key = `${Math.round(x / COORD_DEDUP_BUCKET)}:${Math.round(y / COORD_DEDUP_BUCKET)}`;
1373
+ const now = Date.now();
1374
+ // Sweep stale entries opportunistically — keeps the map from growing.
1375
+ for (const [k, ts] of recentCoordArtifacts) {
1376
+ if (now - ts > COORD_DEDUP_WINDOW_MS)
1377
+ recentCoordArtifacts.delete(k);
1378
+ }
1379
+ const last = recentCoordArtifacts.get(key);
1380
+ if (last !== undefined && (now - last) < COORD_DEDUP_WINDOW_MS)
1381
+ return true;
1382
+ recentCoordArtifacts.set(key, now);
1383
+ return false;
1384
+ }
1385
+ /** Test-only: drop the dedup state so a fresh tap always uploads. */
1386
+ export function resetCoordArtifactDedup() {
1387
+ recentCoordArtifacts.clear();
1388
+ }
1079
1389
  async function captureTapArtifact(ctx) {
1080
1390
  try {
1081
1391
  const { getServerVersion, categorizeError } = await import("../core/telemetry.js");
@@ -1089,6 +1399,20 @@ async function captureTapArtifact(ctx) {
1089
1399
  if (errorCategory === "driver_missing") {
1090
1400
  return undefined;
1091
1401
  }
1402
+ // D1 (Step 6): no-metro unmeaningful taps have no diagnostic signal
1403
+ // beyond a screenshot diff that's already below threshold by definition.
1404
+ // ~50% of recent failure-artifact rows were these — they crowd out real
1405
+ // failures in the dashboard. Telemetry row still flows; just no R2 bundle.
1406
+ const noMetro = ctx.evidence ? !ctx.evidence.fiber.metroConnected : connectedApps.size === 0;
1407
+ if (ctx.outcome === "unmeaningful" && noMetro) {
1408
+ return undefined;
1409
+ }
1410
+ // D2 (Step 6): retry deduplication — coord-strategy taps at the same
1411
+ // grid cell within 60s upload the same near-identical artifact. The
1412
+ // first capture is enough; subsequent ones are noise.
1413
+ if (shouldDedupArtifact(ctx)) {
1414
+ return undefined;
1415
+ }
1092
1416
  const result = await captureFailureArtifact({
1093
1417
  outcome: ctx.outcome,
1094
1418
  predicate: ctx.query,
@@ -1101,7 +1425,12 @@ async function captureTapArtifact(ctx) {
1101
1425
  meaningful: ctx.verification?.meaningful,
1102
1426
  senses: ctx.evidence
1103
1427
  ? {
1104
- ocr: ctx.evidence.ocr,
1428
+ ocr: {
1429
+ ran: ctx.evidence.ocr.ran,
1430
+ durationMs: ctx.evidence.ocr.durationMs,
1431
+ detections: ctx.evidence.ocr.detections,
1432
+ closestMatch: ctx.evidence.ocr.closestMatch
1433
+ },
1105
1434
  fiber: {
1106
1435
  ran: ctx.evidence.fiber.ran,
1107
1436
  durationMs: ctx.evidence.fiber.durationMs,
@@ -1283,7 +1612,8 @@ export async function tap(options) {
1283
1612
  undefined;
1284
1613
  }
1285
1614
  const nativeShouldScreenshot = options.screenshot !== false;
1286
- const nativeShouldVerify = nativeShouldScreenshot && options.verify !== false;
1615
+ // Decoupled (I5, 2026-05-16): verify runs even when image bytes aren't returned.
1616
+ const nativeShouldVerify = options.verify !== false;
1287
1617
  let nativeBeforeBuffer = null;
1288
1618
  let nativeScreenshotMeta;
1289
1619
  if (nativeShouldVerify) {
@@ -1339,6 +1669,8 @@ export async function tap(options) {
1339
1669
  });
1340
1670
  if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
1341
1671
  ({ screenshot, verification } = await burstCaptureAndVerify(platform, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
1672
+ if (!nativeShouldScreenshot)
1673
+ screenshot = undefined;
1342
1674
  }
1343
1675
  else {
1344
1676
  ({ screenshot, verification } = await verifyAndCapture(platform, nativeShouldVerify, nativeShouldScreenshot, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
@@ -1508,11 +1840,11 @@ export async function tap(options) {
1508
1840
  error: errorMessage
1509
1841
  };
1510
1842
  }
1511
- // Determine screenshot and verification behavior
1843
+ // Determine screenshot and verification behavior.
1844
+ // Decoupled (I5, 2026-05-16): verify can run without returning image bytes —
1845
+ // capture cost is paid either way; bandwidth cost is what `screenshot` toggles.
1512
1846
  const shouldScreenshot = options.screenshot !== false;
1513
- // Always capture before screenshot when screenshots are enabled and verify isn't explicitly off
1514
- // Verification decision is deferred until we know which strategy actually succeeded
1515
- const canVerify = shouldScreenshot && options.verify !== false;
1847
+ const canVerify = options.verify !== false;
1516
1848
  // Capture "before" screenshot for verification
1517
1849
  let beforeBuffer = null;
1518
1850
  let beforeScaleFactor;
@@ -1521,6 +1853,23 @@ export async function tap(options) {
1521
1853
  beforeBuffer = before?.buffer || null;
1522
1854
  beforeScaleFactor = before?.scaleFactor;
1523
1855
  }
1856
+ // Step 4: pre-warm the OCR sense if it's in the chain after a faster strategy.
1857
+ // Runs concurrently with fiber/a11y so that when the loop reaches OCR, the
1858
+ // probe is likely already complete (saving ~5s in OCR-win rows). Probe
1859
+ // populates evidence.ocr.bestCandidate; the orchestrator decides whether
1860
+ // to actually tap based on bestCandidate score.
1861
+ const ocrIdx = filteredStrategies.indexOf("ocr");
1862
+ const ocrCanPrewarm = ocrIdx > 0 && !!query.text;
1863
+ let ocrPrewarmCtrl = null;
1864
+ let ocrPrewarmPromise = null;
1865
+ if (ocrCanPrewarm) {
1866
+ ocrPrewarmCtrl = new AbortController();
1867
+ const ctrl = ocrPrewarmCtrl;
1868
+ const ocrCap = maxStrategyMs("ocr", platform);
1869
+ const timer = setTimeout(() => ctrl.abort(), ocrCap);
1870
+ ocrPrewarmPromise = tryOcrStrategy(query, platform, targetUdid, evidence, ctrl.signal, /* probeOnly */ true)
1871
+ .then((result) => { clearTimeout(timer); return { result, aborted: ctrl.signal.aborted }; }, (err) => { clearTimeout(timer); return { result: { success: false, reason: err instanceof Error ? err.message : String(err) }, aborted: ctrl.signal.aborted }; });
1872
+ }
1524
1873
  // Execute strategies in order with per-strategy caps and overall budget
1525
1874
  for (const strat of filteredStrategies) {
1526
1875
  const remaining = remainingMs();
@@ -1531,19 +1880,50 @@ export async function tap(options) {
1531
1880
  });
1532
1881
  continue;
1533
1882
  }
1534
- const cap = MAX_STRATEGY_MS[strat] ?? 5000;
1883
+ const cap = maxStrategyMs(strat, platform);
1535
1884
  const budget = Math.min(cap, remaining);
1536
1885
  let result;
1886
+ let strategyLabel = strat;
1537
1887
  try {
1538
1888
  switch (strat) {
1539
1889
  case "fiber":
1890
+ // Fiber is JS-only against a CDP target — no subprocess to cancel,
1891
+ // so the cheaper non-cancellable wrapper is fine.
1540
1892
  result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth, evidence), budget, `fiber`);
1541
1893
  break;
1542
1894
  case "accessibility":
1543
- result = await withTimeout(tryAccessibilityStrategy(query, index, platform, targetUdid, evidence), budget, `accessibility`);
1895
+ result = await withCancelableTimeout((signal) => tryAccessibilityStrategy(query, index, platform, targetUdid, evidence, signal), budget, `accessibility`);
1544
1896
  break;
1545
1897
  case "ocr":
1546
- result = await withTimeout(tryOcrStrategy(query, platform, targetUdid, evidence), budget, `ocr`);
1898
+ if (ocrPrewarmPromise) {
1899
+ // Pre-warmed probe: await its completion (capped at the
1900
+ // remaining budget so we don't blow past the overall
1901
+ // tap timeout if the prewarm is taking forever) and then
1902
+ // tap based on the recorded bestCandidate.
1903
+ const prewarmRace = withCancelableTimeout((signal) => Promise.race([
1904
+ ocrPrewarmPromise,
1905
+ new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("ocr timed out after " + budget + "ms")), { once: true }))
1906
+ ]), budget, `ocr`);
1907
+ try {
1908
+ await prewarmRace;
1909
+ }
1910
+ catch (e) {
1911
+ // Pre-warm timed out beyond what budget allows. Abort it.
1912
+ ocrPrewarmCtrl?.abort();
1913
+ throw e;
1914
+ }
1915
+ const recovered = await tryOcrRecovery(evidence, platform, targetUdid);
1916
+ if (recovered) {
1917
+ result = recovered;
1918
+ strategyLabel = "ocr"; // not "ocr-recovered" — this is the normal path now
1919
+ }
1920
+ else {
1921
+ result = { success: false, reason: `OCR did not find a viable match for "${query.text}"` };
1922
+ }
1923
+ }
1924
+ else {
1925
+ result = await withCancelableTimeout((signal) => tryOcrStrategy(query, platform, targetUdid, evidence, signal), budget, `ocr`);
1926
+ }
1547
1927
  break;
1548
1928
  case "coordinate":
1549
1929
  result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, app?.lastScreenshot, targetUdid), budget, `coordinate`);
@@ -1553,13 +1933,31 @@ export async function tap(options) {
1553
1933
  }
1554
1934
  }
1555
1935
  catch (err) {
1556
- attempted.push({
1557
- strategy: strat,
1558
- reason: err instanceof Error ? err.message : String(err)
1559
- });
1560
- continue;
1936
+ const reason = err instanceof Error ? err.message : String(err);
1937
+ // Step 2 recovery: when the OCR strategy times out but already
1938
+ // produced a high-confidence candidate (race condition: the tap
1939
+ // was queued ~30ms past the cap in the COMPRAR cluster), tap the
1940
+ // candidate directly and label the strategy `ocr-recovered`.
1941
+ // Score is from sink.ocr.closestMatch's similarity to the search;
1942
+ // a perfect match is 1.0 — gate at 0.9 to skip "not on screen".
1943
+ const recoveredResult = (strat === "ocr" && /ocr timed out after \d+ms$/.test(reason))
1944
+ ? await tryOcrRecovery(evidence, platform, targetUdid)
1945
+ : null;
1946
+ if (recoveredResult) {
1947
+ result = recoveredResult;
1948
+ strategyLabel = "ocr-recovered";
1949
+ // Fall through past the catch block to the success handler.
1950
+ }
1951
+ else {
1952
+ attempted.push({ strategy: strat, reason });
1953
+ continue;
1954
+ }
1561
1955
  }
1562
1956
  if (result.success) {
1957
+ // Higher-priority strategy won — kill the OCR pre-warm so its
1958
+ // device-side screencap doesn't keep running.
1959
+ if (strat !== "ocr")
1960
+ ocrPrewarmCtrl?.abort();
1563
1961
  let screenshot;
1564
1962
  let verification;
1565
1963
  let afterBuffer;
@@ -1575,7 +1973,7 @@ export async function tap(options) {
1575
1973
  }
1576
1974
  }
1577
1975
  const strategyMarker = computeMarkerPx({
1578
- strategy: strat,
1976
+ strategy: strategyLabel,
1579
1977
  input: strat === "coordinate" ? { x: query.x, y: query.y } : undefined,
1580
1978
  convertedTo: result.convertedTo,
1581
1979
  platform,
@@ -1584,6 +1982,11 @@ export async function tap(options) {
1584
1982
  });
1585
1983
  if (options.burst && canVerify && beforeBuffer) {
1586
1984
  ({ screenshot, verification, afterBuffer, afterWithMarkerBuffer } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
1985
+ // Burst always produces a screenshot for diff visualization. Drop the
1986
+ // bytes when the caller didn't ask for them — frames are still in
1987
+ // imageBuffer (accessible via get_images(groupId=verification.burstGroupId)).
1988
+ if (!shouldScreenshot)
1989
+ screenshot = undefined;
1587
1990
  }
1588
1991
  else {
1589
1992
  ({ screenshot, verification, afterBuffer, afterWithMarkerBuffer } = await verifyAndCapture(platform, canVerify, shouldScreenshot, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
@@ -1596,7 +1999,7 @@ export async function tap(options) {
1596
1999
  };
1597
2000
  }
1598
2001
  const successResult = formatTapSuccess({
1599
- method: strat,
2002
+ method: strategyLabel,
1600
2003
  query,
1601
2004
  pressed: result.pressed,
1602
2005
  text: result.text,
@@ -1611,7 +2014,7 @@ export async function tap(options) {
1611
2014
  });
1612
2015
  // Capture an artifact for "successful but unmeaningful" taps so we can
1613
2016
  // diagnose taps that landed wrong or hit non-responsive elements.
1614
- if (verification && verification.changeRate < 0.001) {
2017
+ if (verification && !verification.skipped && (verification.changeRate ?? 1) < 0.001) {
1615
2018
  const unmeaningfulSignals = await captureTapArtifact({
1616
2019
  query,
1617
2020
  outcome: "unmeaningful",