react-native-ai-debugger 1.1.4 → 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 +12 -1
- package/README.md +198 -1063
- 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 +433 -28
- 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 +48 -9
- 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 +30 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +207 -0
- package/build/core/networkInterceptor.js.map +1 -0
- 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 +26 -5
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +83 -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 +165 -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 +1304 -304
- 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 +160 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +1215 -0
- package/build/pro/tap.js.map +1 -0
- 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 +11 -8
- 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
ADDED
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
import { connectedApps, imageBuffer } from "../core/state.js";
|
|
2
|
+
import { executeInApp } from "../core/executor.js";
|
|
3
|
+
import { pressElement } from "../core/executor.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
|
+
import { scanMetroPorts, fetchDevices, selectMainDevice } from "../core/metro.js";
|
|
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
|
+
}
|
|
64
|
+
// --- Helpers ---
|
|
65
|
+
export function buildQuery(options) {
|
|
66
|
+
const query = {};
|
|
67
|
+
if (options.text !== undefined)
|
|
68
|
+
query.text = options.text;
|
|
69
|
+
if (options.testID !== undefined)
|
|
70
|
+
query.testID = options.testID;
|
|
71
|
+
if (options.component !== undefined)
|
|
72
|
+
query.component = options.component;
|
|
73
|
+
if (options.x !== undefined)
|
|
74
|
+
query.x = options.x;
|
|
75
|
+
if (options.y !== undefined)
|
|
76
|
+
query.y = options.y;
|
|
77
|
+
return query;
|
|
78
|
+
}
|
|
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);
|
|
87
|
+
}
|
|
88
|
+
export function getAvailableStrategies(query, strategy) {
|
|
89
|
+
if (query.x !== undefined && query.y !== undefined) {
|
|
90
|
+
return ["coordinate"];
|
|
91
|
+
}
|
|
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
|
+
}
|
|
97
|
+
return [strategy];
|
|
98
|
+
}
|
|
99
|
+
if (query.component && !query.text && !query.testID) {
|
|
100
|
+
return ["fiber"];
|
|
101
|
+
}
|
|
102
|
+
if (query.testID && !query.text) {
|
|
103
|
+
return ["accessibility", "fiber"];
|
|
104
|
+
}
|
|
105
|
+
if (query.text) {
|
|
106
|
+
const strategies = [];
|
|
107
|
+
strategies.push("accessibility");
|
|
108
|
+
if (!hasProblematicUnicode(query.text)) {
|
|
109
|
+
strategies.push("fiber");
|
|
110
|
+
}
|
|
111
|
+
strategies.push("ocr");
|
|
112
|
+
return strategies;
|
|
113
|
+
}
|
|
114
|
+
return ["fiber", "accessibility", "ocr"];
|
|
115
|
+
}
|
|
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;
|
|
129
|
+
if (platform === "android") {
|
|
130
|
+
return { x: Math.round(deviceX), y: Math.round(deviceY) };
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
x: Math.round(deviceX / devicePixelRatio),
|
|
134
|
+
y: Math.round(deviceY / devicePixelRatio)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** @deprecated Use convertScreenshotToTapCoords instead */
|
|
138
|
+
export const convertPixelsToPoints = convertScreenshotToTapCoords;
|
|
139
|
+
export async function getCurrentScreen() {
|
|
140
|
+
try {
|
|
141
|
+
// Note: Uses var instead of let/const because Hermes Runtime.evaluate
|
|
142
|
+
// sometimes has issues with block-scoped declarations
|
|
143
|
+
const expression = `(function() {
|
|
144
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
145
|
+
if (!hook) return null;
|
|
146
|
+
var roots = [];
|
|
147
|
+
if (hook.getFiberRoots) {
|
|
148
|
+
hook.renderers.forEach(function(r, id) {
|
|
149
|
+
var fiberRoots = hook.getFiberRoots(id);
|
|
150
|
+
if (fiberRoots) fiberRoots.forEach(function(root) { roots.push(root); });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (roots.length === 0) return null;
|
|
154
|
+
|
|
155
|
+
function findScreen(fiber, depth) {
|
|
156
|
+
if (!fiber || depth > 5000) return null;
|
|
157
|
+
var name = fiber.type && (fiber.type.displayName || fiber.type.name || (typeof fiber.type === 'string' ? fiber.type : null));
|
|
158
|
+
|
|
159
|
+
if (name === 'RNSScreen') {
|
|
160
|
+
var props = fiber.memoizedProps || {};
|
|
161
|
+
if (props['aria-hidden'] === true) return null;
|
|
162
|
+
var child = fiber.child;
|
|
163
|
+
while (child) {
|
|
164
|
+
var childName = child.type && (child.type.displayName || child.type.name);
|
|
165
|
+
if (childName && typeof child.type !== 'string' && childName !== 'RNSScreenContentWrapper') {
|
|
166
|
+
return childName;
|
|
167
|
+
}
|
|
168
|
+
child = child.child;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
var child = fiber.child;
|
|
173
|
+
while (child) {
|
|
174
|
+
var found = findScreen(child, depth + 1);
|
|
175
|
+
if (found) return found;
|
|
176
|
+
child = child.sibling;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (var i = 0; i < roots.length; i++) {
|
|
182
|
+
var root = roots[i].current;
|
|
183
|
+
var screen = findScreen(root, 0);
|
|
184
|
+
if (screen) return screen;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findFirstUserComponent(fiber, depth) {
|
|
188
|
+
if (!fiber || depth > 5000) return null;
|
|
189
|
+
var name = fiber.type && (fiber.type.displayName || fiber.type.name);
|
|
190
|
+
if (name && typeof fiber.type !== 'string') return name;
|
|
191
|
+
var child = fiber.child;
|
|
192
|
+
while (child) {
|
|
193
|
+
var found = findFirstUserComponent(child, depth + 1);
|
|
194
|
+
if (found) return found;
|
|
195
|
+
child = child.sibling;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (var i = 0; i < roots.length; i++) {
|
|
201
|
+
var fallback = findFirstUserComponent(roots[i].current, 0);
|
|
202
|
+
if (fallback) return fallback;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
})()`;
|
|
206
|
+
const result = await executeInApp(expression, false);
|
|
207
|
+
if (result.success && result.result && result.result !== "null" && result.result !== "undefined") {
|
|
208
|
+
return result.result.replace(/^"|"$/g, "");
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export function formatTapSuccess(data) {
|
|
217
|
+
const { screenshot, verification, ...rest } = data;
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
...rest,
|
|
221
|
+
...(verification && { verification }),
|
|
222
|
+
...(screenshot && { screenshot })
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export function formatTapFailure(data) {
|
|
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;
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
method: lastStrategy,
|
|
234
|
+
query: data.query,
|
|
235
|
+
screen: data.screen,
|
|
236
|
+
error: errorMsg,
|
|
237
|
+
attempted: data.attempted,
|
|
238
|
+
suggestion: data.suggestion,
|
|
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 })
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function buildErrorMessage(query) {
|
|
247
|
+
const parts = [];
|
|
248
|
+
if (query.text)
|
|
249
|
+
parts.push(`text="${query.text}"`);
|
|
250
|
+
if (query.testID)
|
|
251
|
+
parts.push(`testID="${query.testID}"`);
|
|
252
|
+
if (query.component)
|
|
253
|
+
parts.push(`component="${query.component}"`);
|
|
254
|
+
return `No element found matching ${parts.join(", ")}`;
|
|
255
|
+
}
|
|
256
|
+
// --- Strategy Functions ---
|
|
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) {
|
|
279
|
+
try {
|
|
280
|
+
const result = await pressElement({
|
|
281
|
+
text: query.text,
|
|
282
|
+
testID: query.testID,
|
|
283
|
+
component: query.component,
|
|
284
|
+
index,
|
|
285
|
+
maxTraversalDepth
|
|
286
|
+
});
|
|
287
|
+
if (!result.success) {
|
|
288
|
+
return { success: false, reason: result.error || "pressElement failed" };
|
|
289
|
+
}
|
|
290
|
+
if (!result.result) {
|
|
291
|
+
return { success: false, reason: "No result from pressElement" };
|
|
292
|
+
}
|
|
293
|
+
const parsed = JSON.parse(result.result);
|
|
294
|
+
if (parsed.error) {
|
|
295
|
+
const strategyResult = {
|
|
296
|
+
success: false,
|
|
297
|
+
reason: parsed.error
|
|
298
|
+
};
|
|
299
|
+
if (parsed.matches) {
|
|
300
|
+
strategyResult.matches = parsed.matches;
|
|
301
|
+
}
|
|
302
|
+
return strategyResult;
|
|
303
|
+
}
|
|
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.
|
|
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
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
reason: `Found ${parsed.pressed} (${elementType}) but could not measure coordinates — falling through to next strategy`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// All elements now use needsNativeTap — this shouldn't be reached
|
|
331
|
+
return {
|
|
332
|
+
success: false,
|
|
333
|
+
reason: "Unexpected: element did not request native tap"
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
return {
|
|
338
|
+
success: false,
|
|
339
|
+
reason: `Fiber strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function tryAccessibilityStrategy(query, index, platform, udid) {
|
|
344
|
+
try {
|
|
345
|
+
const hasTestID = !!query.testID;
|
|
346
|
+
const hasText = !!query.text;
|
|
347
|
+
if (!hasTestID && !hasText) {
|
|
348
|
+
return {
|
|
349
|
+
success: false,
|
|
350
|
+
reason: "No text or testID for accessibility search"
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
if (platform === "ios") {
|
|
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
|
+
}
|
|
385
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
386
|
+
return {
|
|
387
|
+
success: false,
|
|
388
|
+
reason: result.error ?? "No iOS accessibility match"
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const match = result.allMatches[index ?? 0];
|
|
392
|
+
if (!match) {
|
|
393
|
+
return {
|
|
394
|
+
success: false,
|
|
395
|
+
reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
await iosTap(match.center.x, match.center.y, { udid });
|
|
399
|
+
return {
|
|
400
|
+
success: true,
|
|
401
|
+
reason: "Tapped via iOS accessibility",
|
|
402
|
+
pressed: match.label || match.type,
|
|
403
|
+
text: match.label || undefined,
|
|
404
|
+
component: match.type || null,
|
|
405
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "points" }
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
// Android: testID maps to resource-id, text maps to text content
|
|
410
|
+
const searchOptions = { index };
|
|
411
|
+
if (hasTestID && !hasText) {
|
|
412
|
+
searchOptions.resourceId = query.testID;
|
|
413
|
+
}
|
|
414
|
+
else if (hasText) {
|
|
415
|
+
searchOptions.textContains = query.text;
|
|
416
|
+
}
|
|
417
|
+
let result = await androidFindElement(searchOptions);
|
|
418
|
+
// If testID search via resourceId failed, try contentDescContains
|
|
419
|
+
// (older RN versions map testID to content-description)
|
|
420
|
+
if (hasTestID && !hasText && (!result.success || !result.allMatches || result.allMatches.length === 0)) {
|
|
421
|
+
result = await androidFindElement({
|
|
422
|
+
contentDescContains: query.testID,
|
|
423
|
+
index
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
427
|
+
return {
|
|
428
|
+
success: false,
|
|
429
|
+
reason: result.error ?? "No Android accessibility match"
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const match = result.allMatches[index ?? 0];
|
|
433
|
+
if (!match) {
|
|
434
|
+
return {
|
|
435
|
+
success: false,
|
|
436
|
+
reason: `Index ${index} out of bounds (${result.allMatches.length} matches)`
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
await androidTap(match.center.x, match.center.y);
|
|
440
|
+
return {
|
|
441
|
+
success: true,
|
|
442
|
+
reason: "Tapped via Android accessibility",
|
|
443
|
+
pressed: match.text || match.className || undefined,
|
|
444
|
+
text: match.text || undefined,
|
|
445
|
+
component: match.className || undefined,
|
|
446
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "pixels" }
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
reason: `Accessibility strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function tryOcrStrategy(query, platform, udid) {
|
|
458
|
+
try {
|
|
459
|
+
const searchText = query.text;
|
|
460
|
+
if (!searchText) {
|
|
461
|
+
return { success: false, reason: "OCR strategy requires text query" };
|
|
462
|
+
}
|
|
463
|
+
let imageBuffer;
|
|
464
|
+
let scaleFactor = 1;
|
|
465
|
+
if (platform === "ios") {
|
|
466
|
+
const screenshot = await iosScreenshot(undefined, udid);
|
|
467
|
+
if (!screenshot.success || !screenshot.data) {
|
|
468
|
+
return {
|
|
469
|
+
success: false,
|
|
470
|
+
reason: "Failed to capture iOS screenshot for OCR"
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
imageBuffer = screenshot.data;
|
|
474
|
+
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const { androidScreenshot } = await import("../core/android.js");
|
|
478
|
+
const screenshot = await androidScreenshot();
|
|
479
|
+
if (!screenshot.success || !screenshot.data) {
|
|
480
|
+
return {
|
|
481
|
+
success: false,
|
|
482
|
+
reason: "Failed to capture Android screenshot for OCR"
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
imageBuffer = screenshot.data;
|
|
486
|
+
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
487
|
+
}
|
|
488
|
+
const { recognizeText } = await import("../core/ocr.js");
|
|
489
|
+
const ocrResult = await recognizeText(imageBuffer, {
|
|
490
|
+
scaleFactor,
|
|
491
|
+
platform
|
|
492
|
+
});
|
|
493
|
+
const lowerSearch = searchText.toLowerCase();
|
|
494
|
+
const matchingWord = ocrResult.words.find((w) => w.text.toLowerCase() === lowerSearch) ||
|
|
495
|
+
ocrResult.words.find((w) => w.text.toLowerCase().includes(lowerSearch));
|
|
496
|
+
if (!matchingWord) {
|
|
497
|
+
return {
|
|
498
|
+
success: false,
|
|
499
|
+
reason: `OCR did not find text "${searchText}" on screen`
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (platform === "ios") {
|
|
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
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
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));
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
reason: "Tapped via OCR text recognition",
|
|
521
|
+
text: matchingWord.text,
|
|
522
|
+
convertedTo: {
|
|
523
|
+
x: matchingWord.tapCenter.x,
|
|
524
|
+
y: matchingWord.tapCenter.y,
|
|
525
|
+
unit: "pixels"
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
return {
|
|
531
|
+
success: false,
|
|
532
|
+
reason: `OCR strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, udid) {
|
|
537
|
+
try {
|
|
538
|
+
if (platform === "ios") {
|
|
539
|
+
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
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
|
+
}
|
|
550
|
+
// Best-effort: identify what was tapped via fiber tree
|
|
551
|
+
let screen = null;
|
|
552
|
+
try {
|
|
553
|
+
screen = await getCurrentScreen();
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// Inspection failure is non-fatal
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
success: true,
|
|
560
|
+
reason: "Tapped at coordinates (iOS)",
|
|
561
|
+
screen,
|
|
562
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "points" }
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
567
|
+
const converted = convertScreenshotToTapCoords(pixelX, pixelY, "android", 1, scaleFactor);
|
|
568
|
+
await androidTap(converted.x, converted.y);
|
|
569
|
+
// Best-effort: identify what was tapped via fiber tree
|
|
570
|
+
let screen = null;
|
|
571
|
+
try {
|
|
572
|
+
screen = await getCurrentScreen();
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Inspection failure is non-fatal
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
success: true,
|
|
579
|
+
reason: "Tapped at coordinates (Android)",
|
|
580
|
+
screen,
|
|
581
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "pixels" }
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
return {
|
|
587
|
+
success: false,
|
|
588
|
+
reason: `Coordinate strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
589
|
+
};
|
|
590
|
+
}
|
|
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
|
+
}
|
|
773
|
+
// --- Orchestrator ---
|
|
774
|
+
export async function tap(options) {
|
|
775
|
+
const query = buildQuery(options);
|
|
776
|
+
const strategy = options.strategy || "auto";
|
|
777
|
+
const index = options.index;
|
|
778
|
+
const maxTraversalDepth = options.maxTraversalDepth;
|
|
779
|
+
const deadline = Date.now() + TAP_TIMEOUT_MS;
|
|
780
|
+
const remainingMs = () => Math.max(0, deadline - Date.now());
|
|
781
|
+
// Validate inputs
|
|
782
|
+
const hasSearchParam = query.text || query.testID || query.component;
|
|
783
|
+
const hasCoordinates = query.x !== undefined || query.y !== undefined;
|
|
784
|
+
if (!hasSearchParam && !hasCoordinates) {
|
|
785
|
+
return {
|
|
786
|
+
success: false,
|
|
787
|
+
query,
|
|
788
|
+
error: "Must provide at least one of: text, testID, component, or x/y coordinates"
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
if (hasCoordinates && (query.x === undefined || query.y === undefined)) {
|
|
792
|
+
return {
|
|
793
|
+
success: false,
|
|
794
|
+
query,
|
|
795
|
+
error: "Both x and y coordinates must be provided"
|
|
796
|
+
};
|
|
797
|
+
}
|
|
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;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
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];
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// Auto-connect failed — Metro-dependent strategies will be skipped
|
|
932
|
+
}
|
|
933
|
+
}
|
|
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) {
|
|
960
|
+
return {
|
|
961
|
+
success: false,
|
|
962
|
+
query,
|
|
963
|
+
error: "No devices found. Boot a simulator or connect an Android device."
|
|
964
|
+
};
|
|
965
|
+
}
|
|
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;
|
|
976
|
+
// Determine strategies
|
|
977
|
+
const strategies = getAvailableStrategies(query, strategy);
|
|
978
|
+
const attempted = [];
|
|
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);
|
|
1039
|
+
let result;
|
|
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;
|
|
1064
|
+
}
|
|
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
|
+
}
|
|
1081
|
+
return formatTapSuccess({
|
|
1082
|
+
method: strat,
|
|
1083
|
+
query,
|
|
1084
|
+
pressed: result.pressed,
|
|
1085
|
+
text: result.text,
|
|
1086
|
+
screen: result.screen,
|
|
1087
|
+
path: result.path,
|
|
1088
|
+
component: result.component,
|
|
1089
|
+
convertedTo: result.convertedTo,
|
|
1090
|
+
platform,
|
|
1091
|
+
device: deviceName,
|
|
1092
|
+
screenshot,
|
|
1093
|
+
verification
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
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
|
+
}
|
|
1147
|
+
// If we got match suggestions from fiber, carry them forward
|
|
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
|
+
}
|
|
1159
|
+
return formatTapFailure({
|
|
1160
|
+
query,
|
|
1161
|
+
attempted,
|
|
1162
|
+
suggestion: `Found ${result.matches.length} match(es) — specify index to select one`,
|
|
1163
|
+
device: deviceName,
|
|
1164
|
+
matches: result.matches,
|
|
1165
|
+
screenshot: matchScreenshot
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
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();
|
|
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
|
+
}
|
|
1185
|
+
return formatTapFailure({
|
|
1186
|
+
query,
|
|
1187
|
+
attempted,
|
|
1188
|
+
error: hitTimeout ? `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)` : undefined,
|
|
1189
|
+
suggestion,
|
|
1190
|
+
device: deviceName,
|
|
1191
|
+
screenshot: failScreenshot
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
function buildSuggestion(query, triedStrategies, platform) {
|
|
1195
|
+
const suggestions = [];
|
|
1196
|
+
if (!triedStrategies.includes("ocr") && query.text) {
|
|
1197
|
+
suggestions.push("Try strategy='ocr' to find text visually on screen");
|
|
1198
|
+
}
|
|
1199
|
+
if (query.text && query.text.length <= 2) {
|
|
1200
|
+
suggestions.push("Very short text is unreliable for OCR — use testID or coordinates instead");
|
|
1201
|
+
}
|
|
1202
|
+
if (query.text && hasProblematicUnicode(query.text)) {
|
|
1203
|
+
suggestions.push("Emoji text cannot use fiber strategy — use testID or coordinates instead");
|
|
1204
|
+
}
|
|
1205
|
+
if (query.component && triedStrategies.includes("fiber")) {
|
|
1206
|
+
suggestions.push("Component not found or has no onPress handler — use find_components to discover exact component names, or use text/coordinates instead");
|
|
1207
|
+
}
|
|
1208
|
+
if (query.testID && !triedStrategies.includes("ocr")) {
|
|
1209
|
+
suggestions.push("testID not found in fiber/accessibility tree — verify the element is on the current screen with a screenshot");
|
|
1210
|
+
}
|
|
1211
|
+
suggestions.push(`Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) ` +
|
|
1212
|
+
"to verify the element is visible, then use x/y coordinates");
|
|
1213
|
+
return suggestions.join(". ");
|
|
1214
|
+
}
|
|
1215
|
+
//# sourceMappingURL=tap.js.map
|