react-native-ai-debugger 1.1.6 → 1.4.21
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/LICENSE +1 -1
- package/README.md +371 -42
- package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
- package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
- package/build/__tests__/helpers/tap-test-helpers.js +130 -0
- package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +12 -6
- package/build/__tests__/integration/connection-health.test.js.map +1 -1
- package/build/__tests__/integration/execute-in-app.test.js +2 -1
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
- package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
- package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
- package/build/__tests__/integration/tap-strategies.test.js +329 -0
- package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.js +9 -10
- package/build/__tests__/integration/tools.test.js.map +1 -1
- package/build/__tests__/unit/burst.test.d.ts +2 -0
- package/build/__tests__/unit/burst.test.d.ts.map +1 -0
- package/build/__tests__/unit/burst.test.js +50 -0
- package/build/__tests__/unit/burst.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +26 -16
- package/build/__tests__/unit/connection-health.test.js.map +1 -1
- package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
- package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
- package/build/__tests__/unit/fingerprint.test.js +89 -0
- package/build/__tests__/unit/fingerprint.test.js.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.js +128 -0
- package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.js +9 -0
- package/build/__tests__/unit/logs.test.js.map +1 -1
- package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
- package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
- package/build/__tests__/unit/networkInterceptor.test.js +104 -0
- package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.js +97 -0
- package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.js +430 -25
- package/build/__tests__/unit/tap.test.js.map +1 -1
- package/build/core/android.d.ts.map +1 -1
- package/build/core/android.js +64 -29
- package/build/core/android.js.map +1 -1
- package/build/core/appDetection.d.ts +8 -0
- package/build/core/appDetection.d.ts.map +1 -0
- package/build/core/appDetection.js +91 -0
- package/build/core/appDetection.js.map +1 -0
- package/build/core/bundle.d.ts.map +1 -1
- package/build/core/bundle.js +15 -23
- package/build/core/bundle.js.map +1 -1
- package/build/core/config.d.ts +8 -0
- package/build/core/config.d.ts.map +1 -0
- package/build/core/config.js +27 -0
- package/build/core/config.js.map +1 -0
- package/build/core/connection.d.ts +47 -3
- package/build/core/connection.d.ts.map +1 -1
- package/build/core/connection.js +519 -124
- package/build/core/connection.js.map +1 -1
- package/build/core/executor.d.ts +57 -14
- package/build/core/executor.d.ts.map +1 -1
- package/build/core/executor.js +936 -208
- package/build/core/executor.js.map +1 -1
- package/build/core/fingerprint.d.ts +4 -0
- package/build/core/fingerprint.d.ts.map +1 -0
- package/build/core/fingerprint.js +47 -0
- package/build/core/fingerprint.js.map +1 -0
- package/build/core/guides.d.ts.map +1 -1
- package/build/core/guides.js +27 -3
- package/build/core/guides.js.map +1 -1
- package/build/core/httpServer.js.map +1 -1
- package/build/core/imageBuffer.d.ts +37 -0
- package/build/core/imageBuffer.d.ts.map +1 -0
- package/build/core/imageBuffer.js +58 -0
- package/build/core/imageBuffer.js.map +1 -0
- package/build/core/index.d.ts +21 -17
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js +21 -19
- package/build/core/index.js.map +1 -1
- package/build/core/ios.d.ts +36 -7
- package/build/core/ios.d.ts.map +1 -1
- package/build/core/ios.js +553 -323
- package/build/core/ios.js.map +1 -1
- package/build/core/license.d.ts +38 -0
- package/build/core/license.d.ts.map +1 -0
- package/build/core/license.js +265 -0
- package/build/core/license.js.map +1 -0
- package/build/core/logbox.d.ts +51 -0
- package/build/core/logbox.d.ts.map +1 -0
- package/build/core/logbox.js +258 -0
- package/build/core/logbox.js.map +1 -0
- package/build/core/logs.d.ts +1 -0
- package/build/core/logs.d.ts.map +1 -1
- package/build/core/logs.js +5 -0
- package/build/core/logs.js.map +1 -1
- package/build/core/metro.d.ts +8 -0
- package/build/core/metro.d.ts.map +1 -1
- package/build/core/metro.js +28 -0
- package/build/core/metro.js.map +1 -1
- package/build/core/networkInterceptor.d.ts +29 -2
- package/build/core/networkInterceptor.d.ts.map +1 -1
- package/build/core/networkInterceptor.js +195 -191
- package/build/core/networkInterceptor.js.map +1 -1
- package/build/core/ocr.d.ts +8 -3
- package/build/core/ocr.d.ts.map +1 -1
- package/build/core/ocr.js +116 -15
- package/build/core/ocr.js.map +1 -1
- package/build/core/posthog.d.ts +5 -0
- package/build/core/posthog.d.ts.map +1 -0
- package/build/core/posthog.js +46 -0
- package/build/core/posthog.js.map +1 -0
- package/build/core/sdkBridge.d.ts +67 -0
- package/build/core/sdkBridge.d.ts.map +1 -0
- package/build/core/sdkBridge.js +148 -0
- package/build/core/sdkBridge.js.map +1 -0
- package/build/core/state.d.ts +25 -5
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +79 -10
- package/build/core/state.js.map +1 -1
- package/build/core/telemetry.d.ts +19 -1
- package/build/core/telemetry.d.ts.map +1 -1
- package/build/core/telemetry.js +163 -8
- package/build/core/telemetry.js.map +1 -1
- package/build/core/types.d.ts +11 -0
- package/build/core/types.d.ts.map +1 -1
- package/build/index.js +1302 -302
- package/build/index.js.map +1 -1
- package/build/pro/screenshot-diff.d.ts +10 -0
- package/build/pro/screenshot-diff.d.ts.map +1 -0
- package/build/pro/screenshot-diff.js +38 -0
- package/build/pro/screenshot-diff.js.map +1 -0
- package/build/pro/tap.d.ts +71 -2
- package/build/pro/tap.d.ts.map +1 -1
- package/build/pro/tap.js +797 -124
- package/build/pro/tap.js.map +1 -1
- package/build/tools/accountTools.d.ts +44 -0
- package/build/tools/accountTools.d.ts.map +1 -0
- package/build/tools/accountTools.js +217 -0
- package/build/tools/accountTools.js.map +1 -0
- package/package.json +9 -7
- package/build/core/httpServer.js +0 -2459
- package/build/core/httpServerProcess.js +0 -153
- package/build/httpServerStandalone.js +0 -31
package/build/pro/tap.js
CHANGED
|
@@ -1,11 +1,66 @@
|
|
|
1
|
-
import { connectedApps } from "../core/state.js";
|
|
2
|
-
import { inferIOSDevicePixelRatio } from "../core/ocr.js";
|
|
1
|
+
import { connectedApps, imageBuffer } from "../core/state.js";
|
|
3
2
|
import { executeInApp } from "../core/executor.js";
|
|
4
3
|
import { pressElement } from "../core/executor.js";
|
|
5
|
-
import { iosTap, iosFindElement, iosScreenshot } from "../core/ios.js";
|
|
6
|
-
import { androidTap, androidFindElement } from "../core/android.js";
|
|
4
|
+
import { iosTap, iosFindElement, iosScreenshot, getActiveOrBootedSimulatorUdid, findSimulatorByName, isUiDriverAvailable, getUiDriverInstallHint } from "../core/ios.js";
|
|
5
|
+
import { androidTap, androidFindElement, getDefaultAndroidDevice, androidScreenshot } from "../core/android.js";
|
|
6
|
+
import { compareScreenshots } from "./screenshot-diff.js";
|
|
7
7
|
import { scanMetroPorts, fetchDevices, selectMainDevice } from "../core/metro.js";
|
|
8
8
|
import { connectToDevice, clearReconnectionSuppression } from "../core/connection.js";
|
|
9
|
+
export function buildVerificationExplanation(v) {
|
|
10
|
+
const pct = (rate) => (rate * 100).toFixed(1) + "%";
|
|
11
|
+
if (v.meaningful && !v.transientChangeDetected) {
|
|
12
|
+
return `Tap caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
|
|
13
|
+
}
|
|
14
|
+
if (v.meaningful && v.transientChangeDetected) {
|
|
15
|
+
return (`No persistent change, but transient visual feedback detected ` +
|
|
16
|
+
`(frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff). ` +
|
|
17
|
+
`Tap triggered a press animation that settled back to original state.`);
|
|
18
|
+
}
|
|
19
|
+
if (v.transientChangeDetected === false) {
|
|
20
|
+
return (`No visual change detected — neither persistent nor transient across burst frames. ` +
|
|
21
|
+
`The element may not respond visually or the tap may have missed.`);
|
|
22
|
+
}
|
|
23
|
+
return (`No visual change detected between before and after screenshots. ` +
|
|
24
|
+
`The element may not respond visually or the tap may have missed.`);
|
|
25
|
+
}
|
|
26
|
+
const BURST_CHANGE_THRESHOLD = 0.005;
|
|
27
|
+
export async function analyzeBurstFrames(frames, options) {
|
|
28
|
+
if (frames.length < 2) {
|
|
29
|
+
return {
|
|
30
|
+
meaningful: false,
|
|
31
|
+
persistentChangeRate: 0,
|
|
32
|
+
transientChangeDetected: false,
|
|
33
|
+
peakChangeRate: 0,
|
|
34
|
+
peakFrame: 0,
|
|
35
|
+
framesWithChange: []
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
let peakChangeRate = 0;
|
|
39
|
+
let peakFrame = 0;
|
|
40
|
+
const framesWithChange = [];
|
|
41
|
+
for (let i = 1; i < frames.length; i++) {
|
|
42
|
+
const diff = await compareScreenshots(frames[i - 1], frames[i], options);
|
|
43
|
+
if (diff.changeRate > BURST_CHANGE_THRESHOLD) {
|
|
44
|
+
framesWithChange.push(i);
|
|
45
|
+
}
|
|
46
|
+
if (diff.changeRate > peakChangeRate) {
|
|
47
|
+
peakChangeRate = diff.changeRate;
|
|
48
|
+
peakFrame = i;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const persistentDiff = await compareScreenshots(frames[0], frames[frames.length - 1], options);
|
|
52
|
+
const persistentChangeRate = persistentDiff.changeRate;
|
|
53
|
+
const transientChangeDetected = !persistentDiff.changed && framesWithChange.length > 0;
|
|
54
|
+
const meaningful = persistentDiff.changed || transientChangeDetected;
|
|
55
|
+
return {
|
|
56
|
+
meaningful,
|
|
57
|
+
persistentChangeRate,
|
|
58
|
+
transientChangeDetected,
|
|
59
|
+
peakChangeRate,
|
|
60
|
+
peakFrame,
|
|
61
|
+
framesWithChange
|
|
62
|
+
};
|
|
63
|
+
}
|
|
9
64
|
// --- Helpers ---
|
|
10
65
|
export function buildQuery(options) {
|
|
11
66
|
const query = {};
|
|
@@ -21,43 +76,66 @@ export function buildQuery(options) {
|
|
|
21
76
|
query.y = options.y;
|
|
22
77
|
return query;
|
|
23
78
|
}
|
|
24
|
-
|
|
25
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Check if text contains characters that break Hermes Runtime.evaluate.
|
|
81
|
+
* Standard accented Latin characters (Polish, Vietnamese, French, German, etc.)
|
|
82
|
+
* and Cyrillic work fine in Hermes. Only emoji and special Unicode ranges cause issues.
|
|
83
|
+
*/
|
|
84
|
+
export function hasProblematicUnicode(text) {
|
|
85
|
+
const emojiPattern = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{200D}]|[\u{20E3}]|[\u{E0020}-\u{E007F}]/u;
|
|
86
|
+
return emojiPattern.test(text);
|
|
26
87
|
}
|
|
27
88
|
export function getAvailableStrategies(query, strategy) {
|
|
28
89
|
if (query.x !== undefined && query.y !== undefined) {
|
|
29
90
|
return ["coordinate"];
|
|
30
91
|
}
|
|
31
92
|
if (strategy !== "auto") {
|
|
93
|
+
// Always fallback to OCR for text queries — explicit strategy may miss visible text
|
|
94
|
+
if (query.text && strategy !== "ocr" && strategy !== "coordinate") {
|
|
95
|
+
return [strategy, "ocr"];
|
|
96
|
+
}
|
|
32
97
|
return [strategy];
|
|
33
98
|
}
|
|
34
99
|
if (query.component && !query.text && !query.testID) {
|
|
35
100
|
return ["fiber"];
|
|
36
101
|
}
|
|
37
102
|
if (query.testID && !query.text) {
|
|
38
|
-
return ["
|
|
103
|
+
return ["accessibility", "fiber"];
|
|
39
104
|
}
|
|
40
105
|
if (query.text) {
|
|
41
106
|
const strategies = [];
|
|
42
|
-
|
|
107
|
+
strategies.push("accessibility");
|
|
108
|
+
if (!hasProblematicUnicode(query.text)) {
|
|
43
109
|
strategies.push("fiber");
|
|
44
110
|
}
|
|
45
|
-
strategies.push("
|
|
111
|
+
strategies.push("ocr");
|
|
46
112
|
return strategies;
|
|
47
113
|
}
|
|
48
114
|
return ["fiber", "accessibility", "ocr"];
|
|
49
115
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Convert screenshot image coordinates to platform-native tap coordinates.
|
|
118
|
+
*
|
|
119
|
+
* For iOS: screenshot pixels → device pixels (undo downscale) → points (÷ DPR)
|
|
120
|
+
* For Android: screenshot pixels → device pixels (undo downscale)
|
|
121
|
+
*
|
|
122
|
+
* IMPORTANT: Only use this for EXTERNAL coordinates from screenshots.
|
|
123
|
+
* Internal strategies (OCR, accessibility, fiber) produce tap-ready coordinates
|
|
124
|
+
* and call iosTap/androidTap directly — they must NOT go through this function.
|
|
125
|
+
*/
|
|
126
|
+
export function convertScreenshotToTapCoords(pixelX, pixelY, platform, devicePixelRatio, scaleFactor = 1) {
|
|
127
|
+
const deviceX = pixelX * scaleFactor;
|
|
128
|
+
const deviceY = pixelY * scaleFactor;
|
|
53
129
|
if (platform === "android") {
|
|
54
|
-
return { x: Math.round(
|
|
130
|
+
return { x: Math.round(deviceX), y: Math.round(deviceY) };
|
|
55
131
|
}
|
|
56
132
|
return {
|
|
57
|
-
x: Math.round(
|
|
58
|
-
y: Math.round(
|
|
133
|
+
x: Math.round(deviceX / devicePixelRatio),
|
|
134
|
+
y: Math.round(deviceY / devicePixelRatio)
|
|
59
135
|
};
|
|
60
136
|
}
|
|
137
|
+
/** @deprecated Use convertScreenshotToTapCoords instead */
|
|
138
|
+
export const convertPixelsToPoints = convertScreenshotToTapCoords;
|
|
61
139
|
export async function getCurrentScreen() {
|
|
62
140
|
try {
|
|
63
141
|
// Note: Uses var instead of let/const because Hermes Runtime.evaluate
|
|
@@ -75,7 +153,7 @@ export async function getCurrentScreen() {
|
|
|
75
153
|
if (roots.length === 0) return null;
|
|
76
154
|
|
|
77
155
|
function findScreen(fiber, depth) {
|
|
78
|
-
if (!fiber || depth >
|
|
156
|
+
if (!fiber || depth > 5000) return null;
|
|
79
157
|
var name = fiber.type && (fiber.type.displayName || fiber.type.name || (typeof fiber.type === 'string' ? fiber.type : null));
|
|
80
158
|
|
|
81
159
|
if (name === 'RNSScreen') {
|
|
@@ -107,7 +185,7 @@ export async function getCurrentScreen() {
|
|
|
107
185
|
}
|
|
108
186
|
|
|
109
187
|
function findFirstUserComponent(fiber, depth) {
|
|
110
|
-
if (!fiber || depth >
|
|
188
|
+
if (!fiber || depth > 5000) return null;
|
|
111
189
|
var name = fiber.type && (fiber.type.displayName || fiber.type.name);
|
|
112
190
|
if (name && typeof fiber.type !== 'string') return name;
|
|
113
191
|
var child = fiber.child;
|
|
@@ -136,21 +214,33 @@ export async function getCurrentScreen() {
|
|
|
136
214
|
}
|
|
137
215
|
}
|
|
138
216
|
export function formatTapSuccess(data) {
|
|
217
|
+
const { screenshot, verification, ...rest } = data;
|
|
139
218
|
return {
|
|
140
219
|
success: true,
|
|
141
|
-
...
|
|
220
|
+
...rest,
|
|
221
|
+
...(verification && { verification }),
|
|
222
|
+
...(screenshot && { screenshot })
|
|
142
223
|
};
|
|
143
224
|
}
|
|
144
225
|
export function formatTapFailure(data) {
|
|
145
226
|
const errorMsg = data.error || buildErrorMessage(data.query);
|
|
227
|
+
const warning = data.verification && !data.verification.meaningful
|
|
228
|
+
? "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."
|
|
229
|
+
: undefined;
|
|
230
|
+
const lastStrategy = data.attempted.length > 0 ? data.attempted[data.attempted.length - 1].strategy : undefined;
|
|
146
231
|
return {
|
|
147
232
|
success: false,
|
|
233
|
+
method: lastStrategy,
|
|
148
234
|
query: data.query,
|
|
149
235
|
screen: data.screen,
|
|
150
236
|
error: errorMsg,
|
|
151
237
|
attempted: data.attempted,
|
|
152
238
|
suggestion: data.suggestion,
|
|
153
239
|
matches: data.matches,
|
|
240
|
+
...(data.device && { device: data.device }),
|
|
241
|
+
...(data.verification && { verification: data.verification }),
|
|
242
|
+
...(data.screenshot && { screenshot: data.screenshot }),
|
|
243
|
+
...(warning && { warning })
|
|
154
244
|
};
|
|
155
245
|
}
|
|
156
246
|
function buildErrorMessage(query) {
|
|
@@ -165,13 +255,34 @@ function buildErrorMessage(query) {
|
|
|
165
255
|
}
|
|
166
256
|
// --- Strategy Functions ---
|
|
167
257
|
async function tryFiberStrategy(query, index, maxTraversalDepth) {
|
|
258
|
+
// Retry with increasing depth if the initial traversal finds nothing
|
|
259
|
+
const baseDepth = maxTraversalDepth ?? 15;
|
|
260
|
+
const depthAttempts = [baseDepth];
|
|
261
|
+
// Only add deeper retries if user didn't explicitly set a high depth
|
|
262
|
+
if (baseDepth <= 15) {
|
|
263
|
+
depthAttempts.push(30, 45);
|
|
264
|
+
}
|
|
265
|
+
else if (baseDepth <= 30) {
|
|
266
|
+
depthAttempts.push(baseDepth * 2);
|
|
267
|
+
}
|
|
268
|
+
let lastResult = null;
|
|
269
|
+
for (const depth of depthAttempts) {
|
|
270
|
+
const result = await tryFiberAtDepth(query, index, depth);
|
|
271
|
+
if (result.success || result.matches) {
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
lastResult = result;
|
|
275
|
+
}
|
|
276
|
+
return lastResult;
|
|
277
|
+
}
|
|
278
|
+
async function tryFiberAtDepth(query, index, maxTraversalDepth) {
|
|
168
279
|
try {
|
|
169
280
|
const result = await pressElement({
|
|
170
281
|
text: query.text,
|
|
171
282
|
testID: query.testID,
|
|
172
283
|
component: query.component,
|
|
173
284
|
index,
|
|
174
|
-
maxTraversalDepth
|
|
285
|
+
maxTraversalDepth
|
|
175
286
|
});
|
|
176
287
|
if (!result.success) {
|
|
177
288
|
return { success: false, reason: result.error || "pressElement failed" };
|
|
@@ -183,67 +294,115 @@ async function tryFiberStrategy(query, index, maxTraversalDepth) {
|
|
|
183
294
|
if (parsed.error) {
|
|
184
295
|
const strategyResult = {
|
|
185
296
|
success: false,
|
|
186
|
-
reason: parsed.error
|
|
297
|
+
reason: parsed.error
|
|
187
298
|
};
|
|
188
299
|
if (parsed.matches) {
|
|
189
300
|
strategyResult.matches = parsed.matches;
|
|
190
301
|
}
|
|
191
302
|
return strategyResult;
|
|
192
303
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
304
|
+
// Fiber finds the element by text/testID/component, then measures its
|
|
305
|
+
// host component's screen position for a native tap. This ensures the tap
|
|
306
|
+
// goes through React's event pipeline, executing any onPress wrappers
|
|
307
|
+
// (analytics, debouncing, state tracking) inside the component.
|
|
195
308
|
if (parsed.needsNativeTap) {
|
|
309
|
+
const elementType = parsed.isInput ? "input element" : "pressable element";
|
|
310
|
+
if (parsed.nativeTapTarget && parsed.nativeTapTarget.x && parsed.nativeTapTarget.y) {
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
reason: `Found ${parsed.pressed} (${elementType}) — measured coordinates for native tap`,
|
|
314
|
+
pressed: parsed.pressed,
|
|
315
|
+
text: parsed.text,
|
|
316
|
+
path: parsed.path || null,
|
|
317
|
+
component: parsed.pressed || null,
|
|
318
|
+
convertedTo: {
|
|
319
|
+
x: parsed.nativeTapTarget.x,
|
|
320
|
+
y: parsed.nativeTapTarget.y,
|
|
321
|
+
unit: parsed.nativeTapTarget.unit || "points"
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
196
325
|
return {
|
|
197
326
|
success: false,
|
|
198
|
-
reason: `Found ${parsed.pressed} (
|
|
327
|
+
reason: `Found ${parsed.pressed} (${elementType}) but could not measure coordinates — falling through to next strategy`
|
|
199
328
|
};
|
|
200
329
|
}
|
|
330
|
+
// All elements now use needsNativeTap — this shouldn't be reached
|
|
201
331
|
return {
|
|
202
|
-
success:
|
|
203
|
-
reason: "
|
|
204
|
-
pressed: parsed.pressed,
|
|
205
|
-
text: parsed.text,
|
|
206
|
-
path: parsed.path || null,
|
|
207
|
-
component: parsed.pressed || null,
|
|
332
|
+
success: false,
|
|
333
|
+
reason: "Unexpected: element did not request native tap"
|
|
208
334
|
};
|
|
209
335
|
}
|
|
210
336
|
catch (err) {
|
|
211
337
|
return {
|
|
212
338
|
success: false,
|
|
213
|
-
reason: `Fiber strategy error: ${err instanceof Error ? err.message : String(err)}
|
|
339
|
+
reason: `Fiber strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
214
340
|
};
|
|
215
341
|
}
|
|
216
342
|
}
|
|
217
|
-
async function tryAccessibilityStrategy(query, index, platform) {
|
|
343
|
+
async function tryAccessibilityStrategy(query, index, platform, udid) {
|
|
218
344
|
try {
|
|
219
345
|
const hasTestID = !!query.testID;
|
|
220
346
|
const hasText = !!query.text;
|
|
221
347
|
if (!hasTestID && !hasText) {
|
|
222
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
success: false,
|
|
350
|
+
reason: "No text or testID for accessibility search"
|
|
351
|
+
};
|
|
223
352
|
}
|
|
224
353
|
if (platform === "ios") {
|
|
225
|
-
// iOS:
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
354
|
+
// iOS: testID maps to accessibilityIdentifier — search by identifier first,
|
|
355
|
+
// then fall back to labelContains for text-based searches
|
|
356
|
+
let result;
|
|
357
|
+
if (hasTestID && !hasText) {
|
|
358
|
+
// Try exact identifier match first (testID → accessibilityIdentifier)
|
|
359
|
+
result = await iosFindElement({
|
|
360
|
+
identifier: query.testID,
|
|
361
|
+
index
|
|
362
|
+
}, udid);
|
|
363
|
+
// Fall back to identifierContains if exact match fails
|
|
364
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
365
|
+
result = await iosFindElement({
|
|
366
|
+
identifierContains: query.testID,
|
|
367
|
+
index
|
|
368
|
+
}, udid);
|
|
369
|
+
}
|
|
370
|
+
// Last resort: try labelContains in case testID is reflected in label
|
|
371
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
372
|
+
result = await iosFindElement({
|
|
373
|
+
labelContains: query.testID,
|
|
374
|
+
index
|
|
375
|
+
}, udid);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const searchText = query.text;
|
|
380
|
+
result = await iosFindElement({
|
|
381
|
+
labelContains: searchText,
|
|
382
|
+
index
|
|
383
|
+
}, udid);
|
|
384
|
+
}
|
|
232
385
|
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
233
|
-
return {
|
|
386
|
+
return {
|
|
387
|
+
success: false,
|
|
388
|
+
reason: result.error ?? "No iOS accessibility match"
|
|
389
|
+
};
|
|
234
390
|
}
|
|
235
391
|
const match = result.allMatches[index ?? 0];
|
|
236
392
|
if (!match) {
|
|
237
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
success: false,
|
|
395
|
+
reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
|
|
396
|
+
};
|
|
238
397
|
}
|
|
239
|
-
await iosTap(match.center.x, match.center.y);
|
|
398
|
+
await iosTap(match.center.x, match.center.y, { udid });
|
|
240
399
|
return {
|
|
241
400
|
success: true,
|
|
242
401
|
reason: "Tapped via iOS accessibility",
|
|
243
402
|
pressed: match.label || match.type,
|
|
244
403
|
text: match.label || undefined,
|
|
245
404
|
component: match.type || null,
|
|
246
|
-
convertedTo: { x: match.center.x, y: match.center.y, unit: "points" }
|
|
405
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "points" }
|
|
247
406
|
};
|
|
248
407
|
}
|
|
249
408
|
else {
|
|
@@ -258,19 +417,24 @@ async function tryAccessibilityStrategy(query, index, platform) {
|
|
|
258
417
|
let result = await androidFindElement(searchOptions);
|
|
259
418
|
// If testID search via resourceId failed, try contentDescContains
|
|
260
419
|
// (older RN versions map testID to content-description)
|
|
261
|
-
if (hasTestID && !hasText &&
|
|
262
|
-
(!result.success || !result.allMatches || result.allMatches.length === 0)) {
|
|
420
|
+
if (hasTestID && !hasText && (!result.success || !result.allMatches || result.allMatches.length === 0)) {
|
|
263
421
|
result = await androidFindElement({
|
|
264
422
|
contentDescContains: query.testID,
|
|
265
|
-
index
|
|
423
|
+
index
|
|
266
424
|
});
|
|
267
425
|
}
|
|
268
426
|
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
269
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
success: false,
|
|
429
|
+
reason: result.error ?? "No Android accessibility match"
|
|
430
|
+
};
|
|
270
431
|
}
|
|
271
432
|
const match = result.allMatches[index ?? 0];
|
|
272
433
|
if (!match) {
|
|
273
|
-
return {
|
|
434
|
+
return {
|
|
435
|
+
success: false,
|
|
436
|
+
reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
|
|
437
|
+
};
|
|
274
438
|
}
|
|
275
439
|
await androidTap(match.center.x, match.center.y);
|
|
276
440
|
return {
|
|
@@ -279,18 +443,18 @@ async function tryAccessibilityStrategy(query, index, platform) {
|
|
|
279
443
|
pressed: match.text || match.className || undefined,
|
|
280
444
|
text: match.text || undefined,
|
|
281
445
|
component: match.className || undefined,
|
|
282
|
-
convertedTo: { x: match.center.x, y: match.center.y, unit: "pixels" }
|
|
446
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "pixels" }
|
|
283
447
|
};
|
|
284
448
|
}
|
|
285
449
|
}
|
|
286
450
|
catch (err) {
|
|
287
451
|
return {
|
|
288
452
|
success: false,
|
|
289
|
-
reason: `Accessibility strategy error: ${err instanceof Error ? err.message : String(err)}
|
|
453
|
+
reason: `Accessibility strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
290
454
|
};
|
|
291
455
|
}
|
|
292
456
|
}
|
|
293
|
-
async function tryOcrStrategy(query, platform) {
|
|
457
|
+
async function tryOcrStrategy(query, platform, udid) {
|
|
294
458
|
try {
|
|
295
459
|
const searchText = query.text;
|
|
296
460
|
if (!searchText) {
|
|
@@ -298,48 +462,58 @@ async function tryOcrStrategy(query, platform) {
|
|
|
298
462
|
}
|
|
299
463
|
let imageBuffer;
|
|
300
464
|
let scaleFactor = 1;
|
|
301
|
-
let originalWidth;
|
|
302
|
-
let originalHeight;
|
|
303
465
|
if (platform === "ios") {
|
|
304
|
-
const screenshot = await iosScreenshot();
|
|
466
|
+
const screenshot = await iosScreenshot(undefined, udid);
|
|
305
467
|
if (!screenshot.success || !screenshot.data) {
|
|
306
|
-
return {
|
|
468
|
+
return {
|
|
469
|
+
success: false,
|
|
470
|
+
reason: "Failed to capture iOS screenshot for OCR"
|
|
471
|
+
};
|
|
307
472
|
}
|
|
308
473
|
imageBuffer = screenshot.data;
|
|
309
474
|
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
310
|
-
originalWidth = screenshot.originalWidth;
|
|
311
|
-
originalHeight = screenshot.originalHeight;
|
|
312
475
|
}
|
|
313
476
|
else {
|
|
314
477
|
const { androidScreenshot } = await import("../core/android.js");
|
|
315
478
|
const screenshot = await androidScreenshot();
|
|
316
479
|
if (!screenshot.success || !screenshot.data) {
|
|
317
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
reason: "Failed to capture Android screenshot for OCR"
|
|
483
|
+
};
|
|
318
484
|
}
|
|
319
485
|
imageBuffer = screenshot.data;
|
|
320
486
|
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
321
|
-
originalWidth = screenshot.originalWidth;
|
|
322
|
-
originalHeight = screenshot.originalHeight;
|
|
323
487
|
}
|
|
324
|
-
const devicePixelRatio = (platform === "ios" && originalWidth && originalHeight)
|
|
325
|
-
? inferIOSDevicePixelRatio(originalWidth, originalHeight)
|
|
326
|
-
: 3;
|
|
327
488
|
const { recognizeText } = await import("../core/ocr.js");
|
|
328
489
|
const ocrResult = await recognizeText(imageBuffer, {
|
|
329
490
|
scaleFactor,
|
|
330
|
-
platform
|
|
331
|
-
devicePixelRatio,
|
|
491
|
+
platform
|
|
332
492
|
});
|
|
333
493
|
const lowerSearch = searchText.toLowerCase();
|
|
334
|
-
const matchingWord = ocrResult.words.find((w) => w.text.toLowerCase() === lowerSearch) ||
|
|
494
|
+
const matchingWord = ocrResult.words.find((w) => w.text.toLowerCase() === lowerSearch) ||
|
|
495
|
+
ocrResult.words.find((w) => w.text.toLowerCase().includes(lowerSearch));
|
|
335
496
|
if (!matchingWord) {
|
|
336
|
-
return {
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
reason: `OCR did not find text "${searchText}" on screen`
|
|
500
|
+
};
|
|
337
501
|
}
|
|
338
502
|
if (platform === "ios") {
|
|
339
|
-
|
|
503
|
+
// tapCenter is in image-pixel space (downscaled) — convert to points
|
|
504
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
505
|
+
const dpr = await getDevicePixelRatio(udid);
|
|
506
|
+
const tapResult = await iosTap(Math.round((matchingWord.tapCenter.x * scaleFactor) / dpr), Math.round((matchingWord.tapCenter.y * scaleFactor) / dpr), { udid });
|
|
507
|
+
if (!tapResult.success) {
|
|
508
|
+
return {
|
|
509
|
+
success: false,
|
|
510
|
+
reason: `OCR found "${matchingWord.text}" but tap failed: ${tapResult.error}`
|
|
511
|
+
};
|
|
512
|
+
}
|
|
340
513
|
}
|
|
341
514
|
else {
|
|
342
|
-
|
|
515
|
+
// Android: image-pixel → device-pixel (undo downscale), ADB accepts pixels
|
|
516
|
+
await androidTap(Math.round(matchingWord.tapCenter.x * scaleFactor), Math.round(matchingWord.tapCenter.y * scaleFactor));
|
|
343
517
|
}
|
|
344
518
|
return {
|
|
345
519
|
success: true,
|
|
@@ -348,28 +522,31 @@ async function tryOcrStrategy(query, platform) {
|
|
|
348
522
|
convertedTo: {
|
|
349
523
|
x: matchingWord.tapCenter.x,
|
|
350
524
|
y: matchingWord.tapCenter.y,
|
|
351
|
-
unit:
|
|
352
|
-
}
|
|
525
|
+
unit: "pixels"
|
|
526
|
+
}
|
|
353
527
|
};
|
|
354
528
|
}
|
|
355
529
|
catch (err) {
|
|
356
530
|
return {
|
|
357
531
|
success: false,
|
|
358
|
-
reason: `OCR strategy error: ${err instanceof Error ? err.message : String(err)}
|
|
532
|
+
reason: `OCR strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
359
533
|
};
|
|
360
534
|
}
|
|
361
535
|
}
|
|
362
|
-
async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot) {
|
|
536
|
+
async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, udid) {
|
|
363
537
|
try {
|
|
364
538
|
if (platform === "ios") {
|
|
365
539
|
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
540
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
541
|
+
const devicePixelRatio = await getDevicePixelRatio(udid);
|
|
542
|
+
const converted = convertScreenshotToTapCoords(pixelX, pixelY, "ios", devicePixelRatio, scaleFactor);
|
|
543
|
+
const tapResult = await iosTap(converted.x, converted.y, { udid });
|
|
544
|
+
if (!tapResult.success) {
|
|
545
|
+
return {
|
|
546
|
+
success: false,
|
|
547
|
+
reason: `Coordinate tap failed: ${tapResult.error}`
|
|
548
|
+
};
|
|
549
|
+
}
|
|
373
550
|
// Best-effort: identify what was tapped via fiber tree
|
|
374
551
|
let screen = null;
|
|
375
552
|
try {
|
|
@@ -382,12 +559,12 @@ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot) {
|
|
|
382
559
|
success: true,
|
|
383
560
|
reason: "Tapped at coordinates (iOS)",
|
|
384
561
|
screen,
|
|
385
|
-
convertedTo: { x: converted.x, y: converted.y, unit: "points" }
|
|
562
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "points" }
|
|
386
563
|
};
|
|
387
564
|
}
|
|
388
565
|
else {
|
|
389
566
|
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
390
|
-
const converted =
|
|
567
|
+
const converted = convertScreenshotToTapCoords(pixelX, pixelY, "android", 1, scaleFactor);
|
|
391
568
|
await androidTap(converted.x, converted.y);
|
|
392
569
|
// Best-effort: identify what was tapped via fiber tree
|
|
393
570
|
let screen = null;
|
|
@@ -401,23 +578,206 @@ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot) {
|
|
|
401
578
|
success: true,
|
|
402
579
|
reason: "Tapped at coordinates (Android)",
|
|
403
580
|
screen,
|
|
404
|
-
convertedTo: { x: converted.x, y: converted.y, unit: "pixels" }
|
|
581
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "pixels" }
|
|
405
582
|
};
|
|
406
583
|
}
|
|
407
584
|
}
|
|
408
585
|
catch (err) {
|
|
409
586
|
return {
|
|
410
587
|
success: false,
|
|
411
|
-
reason: `Coordinate strategy error: ${err instanceof Error ? err.message : String(err)}
|
|
588
|
+
reason: `Coordinate strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
412
589
|
};
|
|
413
590
|
}
|
|
414
591
|
}
|
|
592
|
+
const SETTLE_DELAY_MS = 800;
|
|
593
|
+
const TAP_TIMEOUT_MS = 20000;
|
|
594
|
+
const MIN_STRATEGY_BUDGET_MS = 500;
|
|
595
|
+
const MAX_STRATEGY_MS = {
|
|
596
|
+
fiber: 5000,
|
|
597
|
+
accessibility: 3000,
|
|
598
|
+
ocr: 5000,
|
|
599
|
+
coordinate: 3000
|
|
600
|
+
};
|
|
601
|
+
function withTimeout(promise, ms, label) {
|
|
602
|
+
return new Promise((resolve, reject) => {
|
|
603
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
604
|
+
promise.then((val) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
resolve(val);
|
|
607
|
+
}, (err) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
reject(err);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
async function captureScreenshot(platform, udid) {
|
|
614
|
+
try {
|
|
615
|
+
const result = platform === "ios" ? await iosScreenshot(undefined, udid) : await androidScreenshot();
|
|
616
|
+
if (!result.success || !result.data)
|
|
617
|
+
return null;
|
|
618
|
+
return {
|
|
619
|
+
buffer: result.data,
|
|
620
|
+
width: result.originalWidth || 0,
|
|
621
|
+
height: result.originalHeight || 0,
|
|
622
|
+
scaleFactor: result.scaleFactor || 1
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function screenshotToBase64(buffer) {
|
|
630
|
+
return buffer.toString("base64");
|
|
631
|
+
}
|
|
632
|
+
async function verifyAndCapture(platform, shouldVerify, shouldScreenshot, beforeBuffer, udid, beforeScaleFactor) {
|
|
633
|
+
if (!shouldScreenshot)
|
|
634
|
+
return {};
|
|
635
|
+
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS));
|
|
636
|
+
const after = await captureScreenshot(platform, udid);
|
|
637
|
+
if (!after)
|
|
638
|
+
return {};
|
|
639
|
+
const screenshot = {
|
|
640
|
+
image: screenshotToBase64(after.buffer),
|
|
641
|
+
width: after.width,
|
|
642
|
+
height: after.height,
|
|
643
|
+
scaleFactor: after.scaleFactor
|
|
644
|
+
};
|
|
645
|
+
let verification;
|
|
646
|
+
if (shouldVerify && beforeBuffer) {
|
|
647
|
+
try {
|
|
648
|
+
const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
|
|
649
|
+
const scale = beforeScaleFactor || after.scaleFactor || 1;
|
|
650
|
+
const statusBarHeight = Math.round(rawStatusBar / scale);
|
|
651
|
+
const diff = await compareScreenshots(beforeBuffer, after.buffer, {
|
|
652
|
+
statusBarHeight
|
|
653
|
+
});
|
|
654
|
+
verification = {
|
|
655
|
+
meaningful: diff.changed,
|
|
656
|
+
changeRate: diff.changeRate,
|
|
657
|
+
changedPixels: diff.changedPixels,
|
|
658
|
+
totalPixels: diff.totalPixels,
|
|
659
|
+
explanation: buildVerificationExplanation({
|
|
660
|
+
meaningful: diff.changed,
|
|
661
|
+
changeRate: diff.changeRate,
|
|
662
|
+
changedPixels: diff.changedPixels,
|
|
663
|
+
totalPixels: diff.totalPixels
|
|
664
|
+
})
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// Diff failed — still return the screenshot
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const verifyGroupId = `verify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
672
|
+
if (beforeBuffer) {
|
|
673
|
+
imageBuffer.add({
|
|
674
|
+
id: `${verifyGroupId}-before`,
|
|
675
|
+
image: beforeBuffer,
|
|
676
|
+
timestamp: Date.now(),
|
|
677
|
+
source: "tap-verify",
|
|
678
|
+
groupId: verifyGroupId,
|
|
679
|
+
metadata: { phase: "before" }
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
imageBuffer.add({
|
|
683
|
+
id: `${verifyGroupId}-after`,
|
|
684
|
+
image: after.buffer,
|
|
685
|
+
timestamp: Date.now(),
|
|
686
|
+
source: "tap-verify",
|
|
687
|
+
groupId: verifyGroupId,
|
|
688
|
+
metadata: { phase: "after", changeRate: verification?.changeRate }
|
|
689
|
+
});
|
|
690
|
+
return { screenshot, verification };
|
|
691
|
+
}
|
|
692
|
+
// --- Burst Capture ---
|
|
693
|
+
const BURST_FRAME_COUNT = 4;
|
|
694
|
+
const BURST_FRAME_INTERVAL_MS = 150;
|
|
695
|
+
async function burstCaptureAndVerify(platform, beforeBuffer, udid, beforeScaleFactor) {
|
|
696
|
+
if (!beforeBuffer)
|
|
697
|
+
return {};
|
|
698
|
+
const frames = [beforeBuffer];
|
|
699
|
+
let capturedScaleFactor = beforeScaleFactor || 1;
|
|
700
|
+
for (let i = 0; i < BURST_FRAME_COUNT; i++) {
|
|
701
|
+
await new Promise((resolve) => setTimeout(resolve, BURST_FRAME_INTERVAL_MS));
|
|
702
|
+
const capture = await captureScreenshot(platform, udid);
|
|
703
|
+
if (capture) {
|
|
704
|
+
frames.push(capture.buffer);
|
|
705
|
+
if (i === 0)
|
|
706
|
+
capturedScaleFactor = capture.scaleFactor || capturedScaleFactor;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (frames.length < 2)
|
|
710
|
+
return {};
|
|
711
|
+
const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
|
|
712
|
+
const statusBarHeight = Math.round(rawStatusBar / capturedScaleFactor);
|
|
713
|
+
const analysis = await analyzeBurstFrames(frames, { statusBarHeight });
|
|
714
|
+
const groupId = `burst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
715
|
+
for (let i = 0; i < frames.length; i++) {
|
|
716
|
+
imageBuffer.add({
|
|
717
|
+
id: `${groupId}-f${i}`,
|
|
718
|
+
image: frames[i],
|
|
719
|
+
timestamp: Date.now(),
|
|
720
|
+
source: "tap-burst",
|
|
721
|
+
groupId,
|
|
722
|
+
metadata: {
|
|
723
|
+
frameIndex: i,
|
|
724
|
+
isBefore: i === 0,
|
|
725
|
+
changeRate: i === 0 ? 0 : analysis.framesWithChange.includes(i) ? analysis.peakChangeRate : 0
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
imageBuffer.addGroup({
|
|
730
|
+
groupId,
|
|
731
|
+
intent: "tap-verification",
|
|
732
|
+
source: "tap-burst",
|
|
733
|
+
timestamp: Date.now(),
|
|
734
|
+
frameCount: frames.length,
|
|
735
|
+
summary: {
|
|
736
|
+
peakChangeRate: analysis.peakChangeRate,
|
|
737
|
+
peakFrame: analysis.peakFrame,
|
|
738
|
+
framesWithChange: analysis.framesWithChange,
|
|
739
|
+
transientChangeDetected: analysis.transientChangeDetected,
|
|
740
|
+
persistentChangeRate: analysis.persistentChangeRate
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// Get dimensions from the last frame using sharp
|
|
744
|
+
const sharp = (await import("sharp")).default;
|
|
745
|
+
const meta = await sharp(frames[frames.length - 1]).metadata();
|
|
746
|
+
const screenshot = {
|
|
747
|
+
image: screenshotToBase64(frames[frames.length - 1]),
|
|
748
|
+
width: meta.width || 0,
|
|
749
|
+
height: meta.height || 0,
|
|
750
|
+
scaleFactor: 1
|
|
751
|
+
};
|
|
752
|
+
const verification = {
|
|
753
|
+
meaningful: analysis.meaningful,
|
|
754
|
+
changeRate: analysis.persistentChangeRate,
|
|
755
|
+
changedPixels: 0,
|
|
756
|
+
totalPixels: 0,
|
|
757
|
+
transientChangeDetected: analysis.transientChangeDetected,
|
|
758
|
+
peakChangeRate: analysis.peakChangeRate,
|
|
759
|
+
peakFrame: analysis.peakFrame,
|
|
760
|
+
burstGroupId: groupId,
|
|
761
|
+
explanation: buildVerificationExplanation({
|
|
762
|
+
meaningful: analysis.meaningful,
|
|
763
|
+
changeRate: analysis.persistentChangeRate,
|
|
764
|
+
changedPixels: 0,
|
|
765
|
+
totalPixels: 0,
|
|
766
|
+
transientChangeDetected: analysis.transientChangeDetected,
|
|
767
|
+
peakChangeRate: analysis.peakChangeRate,
|
|
768
|
+
peakFrame: analysis.peakFrame
|
|
769
|
+
})
|
|
770
|
+
};
|
|
771
|
+
return { screenshot, verification };
|
|
772
|
+
}
|
|
415
773
|
// --- Orchestrator ---
|
|
416
774
|
export async function tap(options) {
|
|
417
775
|
const query = buildQuery(options);
|
|
418
776
|
const strategy = options.strategy || "auto";
|
|
419
777
|
const index = options.index;
|
|
420
778
|
const maxTraversalDepth = options.maxTraversalDepth;
|
|
779
|
+
const deadline = Date.now() + TAP_TIMEOUT_MS;
|
|
780
|
+
const remainingMs = () => Math.max(0, deadline - Date.now());
|
|
421
781
|
// Validate inputs
|
|
422
782
|
const hasSearchParam = query.text || query.testID || query.component;
|
|
423
783
|
const hasCoordinates = query.x !== undefined || query.y !== undefined;
|
|
@@ -425,68 +785,299 @@ export async function tap(options) {
|
|
|
425
785
|
return {
|
|
426
786
|
success: false,
|
|
427
787
|
query,
|
|
428
|
-
error: "Must provide at least one of: text, testID, component, or x/y coordinates"
|
|
788
|
+
error: "Must provide at least one of: text, testID, component, or x/y coordinates"
|
|
429
789
|
};
|
|
430
790
|
}
|
|
431
791
|
if (hasCoordinates && (query.x === undefined || query.y === undefined)) {
|
|
432
792
|
return {
|
|
433
793
|
success: false,
|
|
434
794
|
query,
|
|
435
|
-
error: "Both x and y coordinates must be provided"
|
|
795
|
+
error: "Both x and y coordinates must be provided"
|
|
436
796
|
};
|
|
437
797
|
}
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
798
|
+
// Native mode: bypass React Native connection, tap directly via ADB/simctl
|
|
799
|
+
if (options.native && hasCoordinates) {
|
|
800
|
+
let platform = options.platform;
|
|
801
|
+
// Auto-detect platform if not specified
|
|
802
|
+
let nativeUdid;
|
|
803
|
+
if (!platform) {
|
|
804
|
+
const [androidDevice, iosSimulator] = await Promise.all([
|
|
805
|
+
getDefaultAndroidDevice().catch(() => null),
|
|
806
|
+
getActiveOrBootedSimulatorUdid().catch(() => null)
|
|
807
|
+
]);
|
|
808
|
+
if (androidDevice && iosSimulator) {
|
|
809
|
+
return {
|
|
810
|
+
success: false,
|
|
811
|
+
query,
|
|
812
|
+
error: 'Multiple platforms detected (both Android and iOS). Specify platform: "android" or platform: "ios" to target the correct device.'
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (androidDevice) {
|
|
816
|
+
platform = "android";
|
|
817
|
+
}
|
|
818
|
+
else if (iosSimulator) {
|
|
819
|
+
platform = "ios";
|
|
820
|
+
nativeUdid = iosSimulator;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
return {
|
|
824
|
+
success: false,
|
|
825
|
+
query,
|
|
826
|
+
error: "No Android device or iOS simulator found. Connect a device or start a simulator."
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else if (platform === "ios") {
|
|
831
|
+
nativeUdid = (await getActiveOrBootedSimulatorUdid()) ?? undefined;
|
|
832
|
+
}
|
|
833
|
+
const nativeShouldScreenshot = options.screenshot !== false;
|
|
834
|
+
const nativeShouldVerify = nativeShouldScreenshot && options.verify !== false;
|
|
835
|
+
let nativeBeforeBuffer = null;
|
|
836
|
+
let nativeScreenshotMeta;
|
|
837
|
+
if (nativeShouldVerify) {
|
|
838
|
+
const before = await captureScreenshot(platform, nativeUdid);
|
|
839
|
+
nativeBeforeBuffer = before?.buffer || null;
|
|
840
|
+
if (before) {
|
|
841
|
+
nativeScreenshotMeta = {
|
|
842
|
+
originalWidth: before.width,
|
|
843
|
+
originalHeight: before.height,
|
|
844
|
+
scaleFactor: before.scaleFactor
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// If no screenshot was taken for verification, take one just for scaleFactor
|
|
849
|
+
if (!nativeScreenshotMeta) {
|
|
850
|
+
const ref = await captureScreenshot(platform, nativeUdid);
|
|
851
|
+
if (ref) {
|
|
852
|
+
nativeScreenshotMeta = {
|
|
853
|
+
originalWidth: ref.width,
|
|
854
|
+
originalHeight: ref.height,
|
|
855
|
+
scaleFactor: ref.scaleFactor
|
|
856
|
+
};
|
|
857
|
+
// Also use it for verification if buffer is needed
|
|
858
|
+
if (!nativeBeforeBuffer) {
|
|
859
|
+
nativeBeforeBuffer = ref.buffer;
|
|
450
860
|
}
|
|
451
861
|
}
|
|
452
|
-
|
|
862
|
+
}
|
|
863
|
+
let result;
|
|
864
|
+
try {
|
|
865
|
+
result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, nativeScreenshotMeta, nativeUdid), remainingMs(), "native-coordinate");
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
return formatTapFailure({
|
|
869
|
+
query,
|
|
870
|
+
attempted: [
|
|
871
|
+
{
|
|
872
|
+
strategy: "native-coordinate",
|
|
873
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
874
|
+
}
|
|
875
|
+
],
|
|
876
|
+
suggestion: `Tap timed out. Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) and retry with coordinates.`
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
if (result.success) {
|
|
880
|
+
let screenshot;
|
|
881
|
+
let verification;
|
|
882
|
+
if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
|
|
883
|
+
({ screenshot, verification } = await burstCaptureAndVerify(platform, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor));
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
({ screenshot, verification } = await verifyAndCapture(platform, nativeShouldVerify, nativeShouldScreenshot, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor));
|
|
887
|
+
}
|
|
888
|
+
return formatTapSuccess({
|
|
889
|
+
method: "native-coordinate",
|
|
890
|
+
query,
|
|
891
|
+
pressed: result.pressed,
|
|
892
|
+
convertedTo: result.convertedTo,
|
|
893
|
+
platform,
|
|
894
|
+
screenshot,
|
|
895
|
+
verification
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return formatTapFailure({
|
|
899
|
+
query,
|
|
900
|
+
attempted: [{ strategy: "native-coordinate", reason: result.reason }],
|
|
901
|
+
suggestion: `Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) to verify coordinates.`
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
// Detect available capabilities
|
|
905
|
+
const allApps = Array.from(connectedApps.values());
|
|
906
|
+
let hasMetro = allApps.length > 0;
|
|
907
|
+
// If platform is specified, prefer an app matching that platform
|
|
908
|
+
let app = options.platform
|
|
909
|
+
? (allApps.find((a) => a.platform === options.platform) ?? allApps[0])
|
|
910
|
+
: allApps[0];
|
|
911
|
+
// Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work
|
|
912
|
+
if (!hasMetro) {
|
|
913
|
+
try {
|
|
914
|
+
await withTimeout((async () => {
|
|
915
|
+
clearReconnectionSuppression();
|
|
916
|
+
const openPorts = await scanMetroPorts();
|
|
917
|
+
for (const port of openPorts) {
|
|
918
|
+
const devices = await fetchDevices(port);
|
|
919
|
+
const mainDevice = selectMainDevice(devices);
|
|
920
|
+
if (mainDevice) {
|
|
921
|
+
await connectToDevice(mainDevice, port);
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
})(), Math.min(remainingMs(), 3000), "auto-connect");
|
|
926
|
+
const apps = Array.from(connectedApps.values());
|
|
927
|
+
hasMetro = apps.length > 0;
|
|
928
|
+
app = apps[0];
|
|
453
929
|
}
|
|
454
930
|
catch {
|
|
455
|
-
// Auto-connect failed
|
|
931
|
+
// Auto-connect failed — Metro-dependent strategies will be skipped
|
|
456
932
|
}
|
|
457
933
|
}
|
|
458
|
-
|
|
934
|
+
// Detect platform — from Metro connection or from available devices
|
|
935
|
+
let platform = options.platform;
|
|
936
|
+
if (!platform) {
|
|
937
|
+
if (app) {
|
|
938
|
+
platform = app.platform;
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
// No Metro — detect from available devices
|
|
942
|
+
const [androidDevice, iosSimulator] = await Promise.all([
|
|
943
|
+
getDefaultAndroidDevice().catch(() => null),
|
|
944
|
+
getActiveOrBootedSimulatorUdid().catch(() => null)
|
|
945
|
+
]);
|
|
946
|
+
if (androidDevice && iosSimulator) {
|
|
947
|
+
return {
|
|
948
|
+
success: false,
|
|
949
|
+
query,
|
|
950
|
+
error: 'Both iOS and Android devices detected but no Metro connection. Specify platform="ios" or platform="android".'
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
if (androidDevice)
|
|
954
|
+
platform = "android";
|
|
955
|
+
else if (iosSimulator)
|
|
956
|
+
platform = "ios";
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (!platform) {
|
|
459
960
|
return {
|
|
460
961
|
success: false,
|
|
461
962
|
query,
|
|
462
|
-
error: "No
|
|
963
|
+
error: "No devices found. Boot a simulator or connect an Android device."
|
|
463
964
|
};
|
|
464
965
|
}
|
|
465
|
-
|
|
466
|
-
|
|
966
|
+
// Resolve target iOS simulator UDID from the connected app
|
|
967
|
+
// This ensures taps go to the correct device when multiple simulators are booted
|
|
968
|
+
let targetUdid;
|
|
969
|
+
if (platform === "ios") {
|
|
970
|
+
targetUdid =
|
|
971
|
+
app?.simulatorUdid ??
|
|
972
|
+
(app?.deviceInfo?.deviceName ? await findSimulatorByName(app.deviceInfo.deviceName) : null) ??
|
|
973
|
+
undefined;
|
|
974
|
+
}
|
|
975
|
+
const deviceName = app?.deviceInfo?.deviceName;
|
|
467
976
|
// Determine strategies
|
|
468
977
|
const strategies = getAvailableStrategies(query, strategy);
|
|
469
978
|
const attempted = [];
|
|
470
|
-
//
|
|
471
|
-
|
|
979
|
+
// Early UI driver check for iOS — fail fast instead of falling through every strategy
|
|
980
|
+
const UI_DRIVER_REQUIRED_STRATEGIES = ["accessibility", "ocr", "coordinate"];
|
|
981
|
+
let uiDriverMissing = false;
|
|
982
|
+
if (platform === "ios") {
|
|
983
|
+
uiDriverMissing = !(await isUiDriverAvailable());
|
|
984
|
+
}
|
|
985
|
+
// Filter strategies by available capabilities
|
|
986
|
+
const filteredStrategies = strategies.filter((strat) => {
|
|
987
|
+
if (strat === "fiber" && !hasMetro) {
|
|
988
|
+
attempted.push({
|
|
989
|
+
strategy: "fiber",
|
|
990
|
+
reason: "Skipped — no Metro connection (required for fiber)"
|
|
991
|
+
});
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
if (uiDriverMissing && UI_DRIVER_REQUIRED_STRATEGIES.includes(strat)) {
|
|
995
|
+
attempted.push({
|
|
996
|
+
strategy: strat,
|
|
997
|
+
reason: "Skipped — iOS UI driver is not installed (required for iOS tap/accessibility/OCR)"
|
|
998
|
+
});
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
return true;
|
|
1002
|
+
});
|
|
1003
|
+
if (filteredStrategies.length === 0) {
|
|
1004
|
+
const errorMessage = uiDriverMissing
|
|
1005
|
+
? `Cannot tap on iOS Simulator — ${getUiDriverInstallHint()}\n\nThe iOS UI driver is required for tapping, swiping, text input, and accessibility queries on iOS Simulators.\n\nAfter installing, retry the tap.`
|
|
1006
|
+
: "All strategies require Metro connection, which is unavailable.\n\nTo fix:\n1. Make sure your React Native app is running\n2. Run scan_metro to connect\n3. Or use tap(x, y, native=true) for coordinate-based taps";
|
|
1007
|
+
return {
|
|
1008
|
+
success: false,
|
|
1009
|
+
query,
|
|
1010
|
+
attempted,
|
|
1011
|
+
error: errorMessage
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
// Determine screenshot and verification behavior
|
|
1015
|
+
const shouldScreenshot = options.screenshot !== false;
|
|
1016
|
+
// Always capture before screenshot when screenshots are enabled and verify isn't explicitly off
|
|
1017
|
+
// Verification decision is deferred until we know which strategy actually succeeded
|
|
1018
|
+
const canVerify = shouldScreenshot && options.verify !== false;
|
|
1019
|
+
// Capture "before" screenshot for verification
|
|
1020
|
+
let beforeBuffer = null;
|
|
1021
|
+
let beforeScaleFactor;
|
|
1022
|
+
if (canVerify) {
|
|
1023
|
+
const before = await captureScreenshot(platform, targetUdid);
|
|
1024
|
+
beforeBuffer = before?.buffer || null;
|
|
1025
|
+
beforeScaleFactor = before?.scaleFactor;
|
|
1026
|
+
}
|
|
1027
|
+
// Execute strategies in order with per-strategy caps and overall budget
|
|
1028
|
+
for (const strat of filteredStrategies) {
|
|
1029
|
+
const remaining = remainingMs();
|
|
1030
|
+
if (remaining < MIN_STRATEGY_BUDGET_MS) {
|
|
1031
|
+
attempted.push({
|
|
1032
|
+
strategy: strat,
|
|
1033
|
+
reason: `Skipped — only ${remaining}ms remaining (budget ${TAP_TIMEOUT_MS}ms)`
|
|
1034
|
+
});
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const cap = MAX_STRATEGY_MS[strat] ?? 5000;
|
|
1038
|
+
const budget = Math.min(cap, remaining);
|
|
472
1039
|
let result;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1040
|
+
try {
|
|
1041
|
+
switch (strat) {
|
|
1042
|
+
case "fiber":
|
|
1043
|
+
result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth), budget, `fiber`);
|
|
1044
|
+
break;
|
|
1045
|
+
case "accessibility":
|
|
1046
|
+
result = await withTimeout(tryAccessibilityStrategy(query, index, platform, targetUdid), budget, `accessibility`);
|
|
1047
|
+
break;
|
|
1048
|
+
case "ocr":
|
|
1049
|
+
result = await withTimeout(tryOcrStrategy(query, platform, targetUdid), budget, `ocr`);
|
|
1050
|
+
break;
|
|
1051
|
+
case "coordinate":
|
|
1052
|
+
result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, app?.lastScreenshot, targetUdid), budget, `coordinate`);
|
|
1053
|
+
break;
|
|
1054
|
+
default:
|
|
1055
|
+
result = { success: false, reason: `Unknown strategy: ${strat}` };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
attempted.push({
|
|
1060
|
+
strategy: strat,
|
|
1061
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
1062
|
+
});
|
|
1063
|
+
continue;
|
|
488
1064
|
}
|
|
489
1065
|
if (result.success) {
|
|
1066
|
+
let screenshot;
|
|
1067
|
+
let verification;
|
|
1068
|
+
if (options.burst && canVerify && beforeBuffer) {
|
|
1069
|
+
({ screenshot, verification } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor));
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
({ screenshot, verification } = await verifyAndCapture(platform, canVerify, shouldScreenshot, beforeBuffer, targetUdid, beforeScaleFactor));
|
|
1073
|
+
}
|
|
1074
|
+
if (screenshot && app) {
|
|
1075
|
+
app.lastScreenshot = {
|
|
1076
|
+
originalWidth: screenshot.width,
|
|
1077
|
+
originalHeight: screenshot.height,
|
|
1078
|
+
scaleFactor: screenshot.scaleFactor
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
490
1081
|
return formatTapSuccess({
|
|
491
1082
|
method: strat,
|
|
492
1083
|
query,
|
|
@@ -497,25 +1088,107 @@ export async function tap(options) {
|
|
|
497
1088
|
component: result.component,
|
|
498
1089
|
convertedTo: result.convertedTo,
|
|
499
1090
|
platform,
|
|
1091
|
+
device: deviceName,
|
|
1092
|
+
screenshot,
|
|
1093
|
+
verification
|
|
500
1094
|
});
|
|
501
1095
|
}
|
|
502
1096
|
attempted.push({ strategy: strat, reason: result.reason });
|
|
1097
|
+
// If fiber found an element with measured coordinates, do a native tap directly
|
|
1098
|
+
if (strat === "fiber" && result.convertedTo && result.pressed) {
|
|
1099
|
+
try {
|
|
1100
|
+
const coords = result.convertedTo;
|
|
1101
|
+
if (platform === "ios") {
|
|
1102
|
+
// Fabric returns points — iosTap expects points
|
|
1103
|
+
await iosTap(coords.x, coords.y, { udid: targetUdid });
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
// Fabric returns dp — androidTap expects pixels
|
|
1107
|
+
// Convert dp to pixels using device density
|
|
1108
|
+
const { androidGetDensity } = await import("../core/android.js");
|
|
1109
|
+
const densityResult = await androidGetDensity();
|
|
1110
|
+
const densityScale = (densityResult.density || 420) / 160;
|
|
1111
|
+
await androidTap(Math.round(coords.x * densityScale), Math.round(coords.y * densityScale));
|
|
1112
|
+
}
|
|
1113
|
+
// fiber+native uses native tap — always verify
|
|
1114
|
+
let screenshot;
|
|
1115
|
+
let verification;
|
|
1116
|
+
if (options.burst && canVerify && beforeBuffer) {
|
|
1117
|
+
({ screenshot, verification } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor));
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
({ screenshot, verification } = await verifyAndCapture(platform, canVerify, shouldScreenshot, beforeBuffer, targetUdid, beforeScaleFactor));
|
|
1121
|
+
}
|
|
1122
|
+
if (screenshot && app) {
|
|
1123
|
+
app.lastScreenshot = {
|
|
1124
|
+
originalWidth: screenshot.width,
|
|
1125
|
+
originalHeight: screenshot.height,
|
|
1126
|
+
scaleFactor: screenshot.scaleFactor
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
return formatTapSuccess({
|
|
1130
|
+
method: "fiber+native",
|
|
1131
|
+
query,
|
|
1132
|
+
pressed: result.pressed,
|
|
1133
|
+
text: result.text,
|
|
1134
|
+
path: result.path,
|
|
1135
|
+
component: result.component,
|
|
1136
|
+
convertedTo: coords,
|
|
1137
|
+
platform,
|
|
1138
|
+
device: deviceName,
|
|
1139
|
+
screenshot,
|
|
1140
|
+
verification
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
// Native tap at fiber coordinates failed — continue to next strategy
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
503
1147
|
// If we got match suggestions from fiber, carry them forward
|
|
504
1148
|
if (result.matches) {
|
|
1149
|
+
const { screenshot: matchScreenshot } = shouldScreenshot
|
|
1150
|
+
? await verifyAndCapture(platform, false, true, null, targetUdid)
|
|
1151
|
+
: { screenshot: undefined };
|
|
1152
|
+
if (matchScreenshot && app) {
|
|
1153
|
+
app.lastScreenshot = {
|
|
1154
|
+
originalWidth: matchScreenshot.width,
|
|
1155
|
+
originalHeight: matchScreenshot.height,
|
|
1156
|
+
scaleFactor: matchScreenshot.scaleFactor
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
505
1159
|
return formatTapFailure({
|
|
506
1160
|
query,
|
|
507
1161
|
attempted,
|
|
508
1162
|
suggestion: `Found ${result.matches.length} match(es) — specify index to select one`,
|
|
1163
|
+
device: deviceName,
|
|
509
1164
|
matches: result.matches,
|
|
1165
|
+
screenshot: matchScreenshot
|
|
510
1166
|
});
|
|
511
1167
|
}
|
|
512
1168
|
}
|
|
513
|
-
// All strategies failed
|
|
1169
|
+
// All strategies failed — check if timeout was the cause
|
|
1170
|
+
const timedOut = attempted.some((a) => a.reason.includes("timed out"));
|
|
1171
|
+
const skipped = attempted.some((a) => a.reason.includes("Skipped"));
|
|
1172
|
+
const hitTimeout = timedOut || skipped;
|
|
1173
|
+
const elapsed = TAP_TIMEOUT_MS - remainingMs();
|
|
514
1174
|
const suggestion = buildSuggestion(query, strategies, platform);
|
|
1175
|
+
const { screenshot: failScreenshot } = shouldScreenshot
|
|
1176
|
+
? await verifyAndCapture(platform, false, true, null, targetUdid)
|
|
1177
|
+
: { screenshot: undefined };
|
|
1178
|
+
if (failScreenshot && app) {
|
|
1179
|
+
app.lastScreenshot = {
|
|
1180
|
+
originalWidth: failScreenshot.width,
|
|
1181
|
+
originalHeight: failScreenshot.height,
|
|
1182
|
+
scaleFactor: failScreenshot.scaleFactor
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
515
1185
|
return formatTapFailure({
|
|
516
1186
|
query,
|
|
517
1187
|
attempted,
|
|
1188
|
+
error: hitTimeout ? `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)` : undefined,
|
|
518
1189
|
suggestion,
|
|
1190
|
+
device: deviceName,
|
|
1191
|
+
screenshot: failScreenshot
|
|
519
1192
|
});
|
|
520
1193
|
}
|
|
521
1194
|
function buildSuggestion(query, triedStrategies, platform) {
|
|
@@ -526,8 +1199,8 @@ function buildSuggestion(query, triedStrategies, platform) {
|
|
|
526
1199
|
if (query.text && query.text.length <= 2) {
|
|
527
1200
|
suggestions.push("Very short text is unreliable for OCR — use testID or coordinates instead");
|
|
528
1201
|
}
|
|
529
|
-
if (query.text &&
|
|
530
|
-
suggestions.push("
|
|
1202
|
+
if (query.text && hasProblematicUnicode(query.text)) {
|
|
1203
|
+
suggestions.push("Emoji text cannot use fiber strategy — use testID or coordinates instead");
|
|
531
1204
|
}
|
|
532
1205
|
if (query.component && triedStrategies.includes("fiber")) {
|
|
533
1206
|
suggestions.push("Component not found or has no onPress handler — use find_components to discover exact component names, or use text/coordinates instead");
|