react-native-ai-debugger 1.6.15 → 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.
- package/README.md +24 -14
- package/build/__tests__/unit/exec.test.d.ts +2 -0
- package/build/__tests__/unit/exec.test.d.ts.map +1 -0
- package/build/__tests__/unit/exec.test.js +54 -0
- package/build/__tests__/unit/exec.test.js.map +1 -0
- package/build/__tests__/unit/focusedInput.test.d.ts +2 -0
- package/build/__tests__/unit/focusedInput.test.d.ts.map +1 -0
- package/build/__tests__/unit/focusedInput.test.js +58 -0
- package/build/__tests__/unit/focusedInput.test.js.map +1 -0
- package/build/__tests__/unit/focusedInputTools.test.d.ts +2 -0
- package/build/__tests__/unit/focusedInputTools.test.d.ts.map +1 -0
- package/build/__tests__/unit/focusedInputTools.test.js +79 -0
- package/build/__tests__/unit/focusedInputTools.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.js +46 -0
- package/build/__tests__/unit/tap.test.js.map +1 -1
- package/build/core/android.d.ts +3 -3
- package/build/core/android.d.ts.map +1 -1
- package/build/core/android.js +21 -14
- package/build/core/android.js.map +1 -1
- package/build/core/connection.d.ts.map +1 -1
- package/build/core/connection.js +3 -2
- package/build/core/connection.js.map +1 -1
- package/build/core/errors.d.ts +14 -0
- package/build/core/errors.d.ts.map +1 -0
- package/build/core/errors.js +17 -0
- package/build/core/errors.js.map +1 -0
- package/build/core/exec.d.ts +22 -0
- package/build/core/exec.d.ts.map +1 -0
- package/build/core/exec.js +54 -0
- package/build/core/exec.js.map +1 -0
- package/build/core/executor.d.ts +2 -0
- package/build/core/executor.d.ts.map +1 -1
- package/build/core/executor.js +232 -2
- package/build/core/executor.js.map +1 -1
- package/build/core/focusedInput.d.ts +27 -0
- package/build/core/focusedInput.d.ts.map +1 -0
- package/build/core/focusedInput.js +110 -0
- package/build/core/focusedInput.js.map +1 -0
- package/build/core/focusedInputTools.d.ts +21 -0
- package/build/core/focusedInputTools.d.ts.map +1 -0
- package/build/core/focusedInputTools.js +51 -0
- package/build/core/focusedInputTools.js.map +1 -0
- package/build/core/guides.d.ts.map +1 -1
- package/build/core/guides.js +15 -2
- package/build/core/guides.js.map +1 -1
- package/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/ios.d.ts +8 -0
- package/build/core/ios.d.ts.map +1 -1
- package/build/core/ios.js +142 -2
- package/build/core/ios.js.map +1 -1
- package/build/core/ocr.d.ts +13 -0
- package/build/core/ocr.d.ts.map +1 -1
- package/build/core/ocr.js +37 -15
- package/build/core/ocr.js.map +1 -1
- package/build/core/posthog.d.ts.map +1 -1
- package/build/core/posthog.js +7 -1
- package/build/core/posthog.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +158 -31
- package/build/index.js.map +1 -1
- package/build/pro/tap.d.ts +51 -5
- package/build/pro/tap.d.ts.map +1 -1
- package/build/pro/tap.js +457 -54
- package/build/pro/tap.js.map +1 -1
- 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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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: ${
|
|
542
|
-
matches:
|
|
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 =
|
|
696
|
+
const match = iosEffectiveMatches[index ?? 0];
|
|
554
697
|
if (!match) {
|
|
555
698
|
return {
|
|
556
699
|
success: false,
|
|
557
|
-
reason: `Index ${index} out of bounds (${
|
|
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
|
-
|
|
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: ${
|
|
608
|
-
matches:
|
|
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 =
|
|
766
|
+
const match = androidEffectiveMatches[index ?? 0];
|
|
620
767
|
if (!match) {
|
|
621
768
|
return {
|
|
622
769
|
success: false,
|
|
623
|
-
reason: `Index ${index} out of bounds (${
|
|
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
|
-
|
|
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
|
-
|
|
920
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1895
|
+
result = await withCancelableTimeout((signal) => tryAccessibilityStrategy(query, index, platform, targetUdid, evidence, signal), budget, `accessibility`);
|
|
1544
1896
|
break;
|
|
1545
1897
|
case "ocr":
|
|
1546
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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:
|
|
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:
|
|
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",
|