react-native-ai-debugger 1.1.6 → 1.4.21

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