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.
Files changed (144) hide show
  1. package/LICENSE +12 -1
  2. package/README.md +198 -1063
  3. package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
  4. package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
  5. package/build/__tests__/helpers/tap-test-helpers.js +130 -0
  6. package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
  7. package/build/__tests__/integration/connection-health.test.js +12 -6
  8. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  9. package/build/__tests__/integration/execute-in-app.test.js +2 -1
  10. package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
  11. package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
  12. package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/tap-strategies.test.js +329 -0
  14. package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
  15. package/build/__tests__/integration/tools.test.js +9 -10
  16. package/build/__tests__/integration/tools.test.js.map +1 -1
  17. package/build/__tests__/unit/burst.test.d.ts +2 -0
  18. package/build/__tests__/unit/burst.test.d.ts.map +1 -0
  19. package/build/__tests__/unit/burst.test.js +50 -0
  20. package/build/__tests__/unit/burst.test.js.map +1 -0
  21. package/build/__tests__/unit/connection-health.test.js +26 -16
  22. package/build/__tests__/unit/connection-health.test.js.map +1 -1
  23. package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
  24. package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
  25. package/build/__tests__/unit/fingerprint.test.js +89 -0
  26. package/build/__tests__/unit/fingerprint.test.js.map +1 -0
  27. package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
  28. package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
  29. package/build/__tests__/unit/imageBuffer.test.js +128 -0
  30. package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
  31. package/build/__tests__/unit/logs.test.js +9 -0
  32. package/build/__tests__/unit/logs.test.js.map +1 -1
  33. package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
  34. package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
  35. package/build/__tests__/unit/networkInterceptor.test.js +104 -0
  36. package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
  37. package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
  38. package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
  39. package/build/__tests__/unit/screenshot-diff.test.js +97 -0
  40. package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
  41. package/build/__tests__/unit/tap.test.js +433 -28
  42. package/build/__tests__/unit/tap.test.js.map +1 -1
  43. package/build/core/android.d.ts.map +1 -1
  44. package/build/core/android.js +64 -29
  45. package/build/core/android.js.map +1 -1
  46. package/build/core/appDetection.d.ts +8 -0
  47. package/build/core/appDetection.d.ts.map +1 -0
  48. package/build/core/appDetection.js +91 -0
  49. package/build/core/appDetection.js.map +1 -0
  50. package/build/core/bundle.d.ts.map +1 -1
  51. package/build/core/bundle.js +15 -23
  52. package/build/core/bundle.js.map +1 -1
  53. package/build/core/config.d.ts +8 -0
  54. package/build/core/config.d.ts.map +1 -0
  55. package/build/core/config.js +27 -0
  56. package/build/core/config.js.map +1 -0
  57. package/build/core/connection.d.ts +47 -3
  58. package/build/core/connection.d.ts.map +1 -1
  59. package/build/core/connection.js +519 -124
  60. package/build/core/connection.js.map +1 -1
  61. package/build/core/executor.d.ts +57 -14
  62. package/build/core/executor.d.ts.map +1 -1
  63. package/build/core/executor.js +936 -208
  64. package/build/core/executor.js.map +1 -1
  65. package/build/core/fingerprint.d.ts +4 -0
  66. package/build/core/fingerprint.d.ts.map +1 -0
  67. package/build/core/fingerprint.js +47 -0
  68. package/build/core/fingerprint.js.map +1 -0
  69. package/build/core/guides.d.ts.map +1 -1
  70. package/build/core/guides.js +48 -9
  71. package/build/core/guides.js.map +1 -1
  72. package/build/core/httpServer.js.map +1 -1
  73. package/build/core/imageBuffer.d.ts +37 -0
  74. package/build/core/imageBuffer.d.ts.map +1 -0
  75. package/build/core/imageBuffer.js +58 -0
  76. package/build/core/imageBuffer.js.map +1 -0
  77. package/build/core/index.d.ts +21 -17
  78. package/build/core/index.d.ts.map +1 -1
  79. package/build/core/index.js +21 -19
  80. package/build/core/index.js.map +1 -1
  81. package/build/core/ios.d.ts +36 -7
  82. package/build/core/ios.d.ts.map +1 -1
  83. package/build/core/ios.js +553 -323
  84. package/build/core/ios.js.map +1 -1
  85. package/build/core/license.d.ts +38 -0
  86. package/build/core/license.d.ts.map +1 -0
  87. package/build/core/license.js +265 -0
  88. package/build/core/license.js.map +1 -0
  89. package/build/core/logbox.d.ts +51 -0
  90. package/build/core/logbox.d.ts.map +1 -0
  91. package/build/core/logbox.js +258 -0
  92. package/build/core/logbox.js.map +1 -0
  93. package/build/core/logs.d.ts +1 -0
  94. package/build/core/logs.d.ts.map +1 -1
  95. package/build/core/logs.js +5 -0
  96. package/build/core/logs.js.map +1 -1
  97. package/build/core/metro.d.ts +8 -0
  98. package/build/core/metro.d.ts.map +1 -1
  99. package/build/core/metro.js +28 -0
  100. package/build/core/metro.js.map +1 -1
  101. package/build/core/networkInterceptor.d.ts +30 -0
  102. package/build/core/networkInterceptor.d.ts.map +1 -0
  103. package/build/core/networkInterceptor.js +207 -0
  104. package/build/core/networkInterceptor.js.map +1 -0
  105. package/build/core/ocr.d.ts +8 -3
  106. package/build/core/ocr.d.ts.map +1 -1
  107. package/build/core/ocr.js +116 -15
  108. package/build/core/ocr.js.map +1 -1
  109. package/build/core/posthog.d.ts +5 -0
  110. package/build/core/posthog.d.ts.map +1 -0
  111. package/build/core/posthog.js +46 -0
  112. package/build/core/posthog.js.map +1 -0
  113. package/build/core/sdkBridge.d.ts +67 -0
  114. package/build/core/sdkBridge.d.ts.map +1 -0
  115. package/build/core/sdkBridge.js +148 -0
  116. package/build/core/sdkBridge.js.map +1 -0
  117. package/build/core/state.d.ts +26 -5
  118. package/build/core/state.d.ts.map +1 -1
  119. package/build/core/state.js +83 -10
  120. package/build/core/state.js.map +1 -1
  121. package/build/core/telemetry.d.ts +19 -1
  122. package/build/core/telemetry.d.ts.map +1 -1
  123. package/build/core/telemetry.js +165 -8
  124. package/build/core/telemetry.js.map +1 -1
  125. package/build/core/types.d.ts +11 -0
  126. package/build/core/types.d.ts.map +1 -1
  127. package/build/index.js +1304 -304
  128. package/build/index.js.map +1 -1
  129. package/build/pro/screenshot-diff.d.ts +10 -0
  130. package/build/pro/screenshot-diff.d.ts.map +1 -0
  131. package/build/pro/screenshot-diff.js +38 -0
  132. package/build/pro/screenshot-diff.js.map +1 -0
  133. package/build/pro/tap.d.ts +160 -0
  134. package/build/pro/tap.d.ts.map +1 -0
  135. package/build/pro/tap.js +1215 -0
  136. package/build/pro/tap.js.map +1 -0
  137. package/build/tools/accountTools.d.ts +44 -0
  138. package/build/tools/accountTools.d.ts.map +1 -0
  139. package/build/tools/accountTools.js +217 -0
  140. package/build/tools/accountTools.js.map +1 -0
  141. package/package.json +11 -8
  142. package/build/core/httpServer.js +0 -2459
  143. package/build/core/httpServerProcess.js +0 -153
  144. package/build/httpServerStandalone.js +0 -31
@@ -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