react-native-ai-debugger 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/build/__tests__/integration/execute-in-app.test.js +11 -3
  3. package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
  4. package/build/__tests__/integration/tap-strategies.test.js +1 -1
  5. package/build/__tests__/integration/tap-strategies.test.js.map +1 -1
  6. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts +2 -0
  7. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts.map +1 -0
  8. package/build/__tests__/unit/androidEmulatorAvds.test.js +98 -0
  9. package/build/__tests__/unit/androidEmulatorAvds.test.js.map +1 -0
  10. package/build/__tests__/unit/deviceDiscovery.test.d.ts +2 -0
  11. package/build/__tests__/unit/deviceDiscovery.test.d.ts.map +1 -0
  12. package/build/__tests__/unit/deviceDiscovery.test.js +119 -0
  13. package/build/__tests__/unit/deviceDiscovery.test.js.map +1 -0
  14. package/build/__tests__/unit/deviceResolver.test.d.ts +2 -0
  15. package/build/__tests__/unit/deviceResolver.test.d.ts.map +1 -0
  16. package/build/__tests__/unit/deviceResolver.test.js +226 -0
  17. package/build/__tests__/unit/deviceResolver.test.js.map +1 -0
  18. package/build/__tests__/unit/failureArtifact.test.js +3 -3
  19. package/build/__tests__/unit/failureArtifact.test.js.map +1 -1
  20. package/build/__tests__/unit/tap.test.js +13 -30
  21. package/build/__tests__/unit/tap.test.js.map +1 -1
  22. package/build/__tests__/unit/verifyAction.test.d.ts +2 -0
  23. package/build/__tests__/unit/verifyAction.test.d.ts.map +1 -0
  24. package/build/__tests__/unit/verifyAction.test.js +26 -0
  25. package/build/__tests__/unit/verifyAction.test.js.map +1 -0
  26. package/build/core/android.d.ts +20 -0
  27. package/build/core/android.d.ts.map +1 -1
  28. package/build/core/android.js +98 -0
  29. package/build/core/android.js.map +1 -1
  30. package/build/core/connection.d.ts.map +1 -1
  31. package/build/core/connection.js +20 -9
  32. package/build/core/connection.js.map +1 -1
  33. package/build/core/deviceDiscovery.d.ts +52 -0
  34. package/build/core/deviceDiscovery.d.ts.map +1 -0
  35. package/build/core/deviceDiscovery.js +74 -0
  36. package/build/core/deviceDiscovery.js.map +1 -0
  37. package/build/core/deviceResolver.d.ts +44 -0
  38. package/build/core/deviceResolver.d.ts.map +1 -0
  39. package/build/core/deviceResolver.js +191 -0
  40. package/build/core/deviceResolver.js.map +1 -0
  41. package/build/core/failureArtifact.d.ts +0 -2
  42. package/build/core/failureArtifact.d.ts.map +1 -1
  43. package/build/core/failureArtifact.js +0 -4
  44. package/build/core/failureArtifact.js.map +1 -1
  45. package/build/core/guides.d.ts.map +1 -1
  46. package/build/core/guides.js +3 -4
  47. package/build/core/guides.js.map +1 -1
  48. package/build/core/pressables.d.ts.map +1 -1
  49. package/build/core/pressables.js +43 -8
  50. package/build/core/pressables.js.map +1 -1
  51. package/build/core/rnGlobalsBootstrap.d.ts.map +1 -1
  52. package/build/core/rnGlobalsBootstrap.js +16 -1
  53. package/build/core/rnGlobalsBootstrap.js.map +1 -1
  54. package/build/core/types.d.ts +1 -0
  55. package/build/core/types.d.ts.map +1 -1
  56. package/build/pro/screenshot-diff.js +1 -1
  57. package/build/pro/screenshot-diff.js.map +1 -1
  58. package/build/pro/tap.d.ts +8 -10
  59. package/build/pro/tap.d.ts.map +1 -1
  60. package/build/pro/tap.js +178 -413
  61. package/build/pro/tap.js.map +1 -1
  62. package/build/pro/verifyAction.d.ts +49 -0
  63. package/build/pro/verifyAction.d.ts.map +1 -0
  64. package/build/pro/verifyAction.js +255 -0
  65. package/build/pro/verifyAction.js.map +1 -0
  66. package/build/tools/componentTools.d.ts.map +1 -1
  67. package/build/tools/componentTools.js +14 -18
  68. package/build/tools/componentTools.js.map +1 -1
  69. package/build/tools/connectionTools.d.ts.map +1 -1
  70. package/build/tools/connectionTools.js +32 -5
  71. package/build/tools/connectionTools.js.map +1 -1
  72. package/build/tools/deviceTools.d.ts.map +1 -1
  73. package/build/tools/deviceTools.js +104 -2
  74. package/build/tools/deviceTools.js.map +1 -1
  75. package/build/tools/executionTools.d.ts.map +1 -1
  76. package/build/tools/executionTools.js +7 -9
  77. package/build/tools/executionTools.js.map +1 -1
  78. package/build/tools/interactionTools.d.ts.map +1 -1
  79. package/build/tools/interactionTools.js +113 -151
  80. package/build/tools/interactionTools.js.map +1 -1
  81. package/package.json +1 -1
package/build/pro/tap.js CHANGED
@@ -1,29 +1,34 @@
1
- import { connectedApps, imageBuffer } from "../core/state.js";
1
+ import { connectedApps } from "../core/state.js";
2
2
  import { executeInApp } from "../core/executor.js";
3
3
  import { pressElement } from "../core/executor.js";
4
- import { iosTap, iosFindElement, iosScreenshot, getActiveOrBootedSimulatorUdid, findSimulatorByName, isUiDriverAvailable, getUiDriverInstallHint, getIOSSafeAreaTop } from "../core/ios.js";
5
- import { androidTap, androidFindElement, getDefaultAndroidDevice, androidScreenshot } from "../core/android.js";
4
+ import { iosTap, iosFindElement, iosScreenshot, isUiDriverAvailable, getUiDriverInstallHint, getIOSSafeAreaTop } from "../core/ios.js";
5
+ import { androidTap, androidFindElement } from "../core/android.js";
6
6
  import { compareScreenshots } from "./screenshot-diff.js";
7
7
  import { scanMetroPorts, fetchDevices, selectMainDevice } from "../core/metro.js";
8
8
  import { connectToDevice, clearReconnectionSuppression, getConnectedAppByDevice } from "../core/connection.js";
9
+ import { resolveDeviceTarget, formatResolverError } from "../core/deviceResolver.js";
9
10
  import { notifyDriverMissing } from "../core/logbox.js";
10
11
  import { captureFailureArtifact } from "../core/failureArtifact.js";
12
+ import { captureScreenshot, verifyAndCapture, burstCaptureAndVerify, } from "./verifyAction.js";
11
13
  export function buildVerificationExplanation(v) {
12
14
  const pct = (rate) => (rate * 100).toFixed(1) + "%";
15
+ const action = v.action ?? "tap";
16
+ const Action = action[0].toUpperCase() + action.slice(1);
17
+ const target = action === "swipe" ? "scroll surface" : "element";
13
18
  if (v.meaningful && !v.transientChangeDetected) {
14
- return `Tap caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
19
+ return `${Action} caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
15
20
  }
16
21
  if (v.meaningful && v.transientChangeDetected) {
17
22
  return (`No persistent change, but transient visual feedback detected ` +
18
23
  `(frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff). ` +
19
- `Tap triggered a press animation that settled back to original state.`);
24
+ `${Action} triggered a momentary animation that settled back to original state.`);
20
25
  }
21
26
  if (v.transientChangeDetected === false) {
22
27
  return (`No visual change detected — neither persistent nor transient across burst frames. ` +
23
- `The element may not respond visually or the tap may have missed.`);
28
+ `The ${target} may not respond visually or the ${action} may have missed.`);
24
29
  }
25
30
  return (`No visual change detected between before and after screenshots. ` +
26
- `The element may not respond visually or the tap may have missed.`);
31
+ `The ${target} may not respond visually or the ${action} may have missed.`);
27
32
  }
28
33
  const BURST_CHANGE_THRESHOLD = 0.005;
29
34
  export async function analyzeBurstFrames(frames, options) {
@@ -1008,21 +1013,24 @@ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, u
1008
1013
  };
1009
1014
  }
1010
1015
  }
1011
- const SETTLE_DELAY_MS = 800;
1012
- const TAP_TIMEOUT_MS = 20000;
1016
+ const TAP_TIMEOUT_MS = 25000;
1013
1017
  const MIN_STRATEGY_BUDGET_MS = 500;
1014
1018
  // Per-strategy budget. OCR cap on Android is bumped via maxStrategyMs() because
1015
1019
  // the ADB screencap+pull leg has ~2s variance on real devices; iOS stays at 5s
1016
1020
  // where xcrun simctl screenshot is consistent.
1021
+ // Heavy strategies (fiber on deep trees with multi-depth retries, axe accessibility
1022
+ // dumps on dense iOS screens) need more headroom — previous caps produced spurious
1023
+ // timeouts that the agent read as "element missing" when the strategy simply didn't
1024
+ // get to finish. The overall TAP_TIMEOUT_MS budget still bounds the worst case.
1017
1025
  const MAX_STRATEGY_MS = {
1018
- fiber: 5000,
1019
- accessibility: 3000,
1020
- ocr: 5000,
1026
+ fiber: 8000,
1027
+ accessibility: 6000,
1028
+ ocr: 6000,
1021
1029
  coordinate: 3000
1022
1030
  };
1023
1031
  function maxStrategyMs(strategy, platform) {
1024
1032
  if (strategy === "ocr" && platform === "android")
1025
- return 7000;
1033
+ return 9000;
1026
1034
  return MAX_STRATEGY_MS[strategy] ?? 5000;
1027
1035
  }
1028
1036
  // Matches only the outer withTimeout wrapper message for a tap strategy.
@@ -1030,7 +1038,7 @@ function maxStrategyMs(strategy, platform) {
1030
1038
  // must NOT be classified as a tap-level timeout.
1031
1039
  const STRATEGY_TIMEOUT_RE = /^(fiber|accessibility|ocr|coordinate) timed out after \d+ms$/;
1032
1040
  export function isTapTimeout(attempted) {
1033
- return attempted.some((a) => STRATEGY_TIMEOUT_RE.test(a.reason) || a.reason.startsWith("Skipped —"));
1041
+ return attempted.some((a) => a.outcome === "timeout" || a.outcome === "skipped" || STRATEGY_TIMEOUT_RE.test(a.reason) || a.reason.startsWith("Skipped —"));
1034
1042
  }
1035
1043
  function withTimeout(promise, ms, label) {
1036
1044
  return new Promise((resolve, reject) => {
@@ -1060,54 +1068,6 @@ function withCancelableTimeout(make, ms, label) {
1060
1068
  throw err;
1061
1069
  });
1062
1070
  }
1063
- async function captureScreenshot(platform, udid) {
1064
- try {
1065
- const result = platform === "ios" ? await iosScreenshot(undefined, udid) : await androidScreenshot();
1066
- if (!result.success || !result.data)
1067
- return null;
1068
- return {
1069
- buffer: result.data,
1070
- width: result.originalWidth || 0,
1071
- height: result.originalHeight || 0,
1072
- scaleFactor: result.scaleFactor || 1
1073
- };
1074
- }
1075
- catch {
1076
- return null;
1077
- }
1078
- }
1079
- function screenshotToBase64(buffer) {
1080
- return buffer.toString("base64");
1081
- }
1082
- /**
1083
- * Composite a red crosshair marker onto a screenshot at the given pixel coordinates.
1084
- * Uses sharp + SVG so we don't need to inject into the app. Coordinates are in the
1085
- * screenshot's own pixel space (i.e. the space the returned image uses).
1086
- */
1087
- async function drawTapMarker(input, x, y) {
1088
- try {
1089
- const sharp = (await import("sharp")).default;
1090
- const size = 72;
1091
- const half = size / 2;
1092
- const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
1093
- <circle cx="${half}" cy="${half}" r="${half - 8}" fill="none" stroke="white" stroke-width="6" opacity="0.85"/>
1094
- <circle cx="${half}" cy="${half}" r="${half - 8}" fill="none" stroke="#FF2D55" stroke-width="3" opacity="1"/>
1095
- <line x1="${half}" y1="8" x2="${half}" y2="${size - 8}" stroke="white" stroke-width="6" opacity="0.85"/>
1096
- <line x1="8" y1="${half}" x2="${size - 8}" y2="${half}" stroke="white" stroke-width="6" opacity="0.85"/>
1097
- <line x1="${half}" y1="8" x2="${half}" y2="${size - 8}" stroke="#FF2D55" stroke-width="2.5"/>
1098
- <line x1="8" y1="${half}" x2="${size - 8}" y2="${half}" stroke="#FF2D55" stroke-width="2.5"/>
1099
- <circle cx="${half}" cy="${half}" r="3" fill="#FF2D55"/>
1100
- </svg>`;
1101
- const left = Math.round(x - half);
1102
- const top = Math.round(y - half);
1103
- return await sharp(input)
1104
- .composite([{ input: Buffer.from(svg), left, top }])
1105
- .toBuffer();
1106
- }
1107
- catch {
1108
- return input;
1109
- }
1110
- }
1111
1071
  /**
1112
1072
  * Compute marker coordinates in screenshot-pixel space (what the returned PNG uses).
1113
1073
  * Unit rules by strategy, assuming screenshotScale = downscale factor:
@@ -1150,208 +1110,6 @@ function computeMarkerPx(args) {
1150
1110
  y: Math.round(convertedTo.y / scale)
1151
1111
  };
1152
1112
  }
1153
- async function verifyAndCapture(platform, shouldVerify, shouldScreenshot, beforeBuffer, udid, beforeScaleFactor, markerPx) {
1154
- // Decoupled 2026-05-16 (I5): screenshot and verify are independent knobs.
1155
- // - shouldVerify=true: capture after-screenshot internally, diff against beforeBuffer, return verification.
1156
- // - shouldScreenshot=true: also return the (marker-overlaid) image bytes in the response.
1157
- // - both false: fastest path, return only a `skipped` verification stub so the agent can tell apart "ran clean" from "skipped intentionally".
1158
- if (!shouldVerify && !shouldScreenshot) {
1159
- return {
1160
- verification: {
1161
- skipped: true,
1162
- skippedReason: "screenshot=false, verify=false",
1163
- explanation: "Verification skipped (screenshot=false and verify=false)."
1164
- }
1165
- };
1166
- }
1167
- await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS));
1168
- const after = await captureScreenshot(platform, udid);
1169
- if (!after) {
1170
- if (shouldVerify) {
1171
- return {
1172
- verification: {
1173
- skipped: true,
1174
- skippedReason: "after-screenshot capture failed",
1175
- explanation: "Verification skipped — could not capture post-tap screenshot."
1176
- }
1177
- };
1178
- }
1179
- return {};
1180
- }
1181
- const afterWithMarker = markerPx
1182
- ? await drawTapMarker(after.buffer, markerPx.x, markerPx.y)
1183
- : after.buffer;
1184
- const screenshot = shouldScreenshot
1185
- ? {
1186
- image: screenshotToBase64(afterWithMarker),
1187
- width: after.width,
1188
- height: after.height,
1189
- scaleFactor: after.scaleFactor
1190
- }
1191
- : undefined;
1192
- let verification;
1193
- if (!shouldVerify) {
1194
- verification = {
1195
- skipped: true,
1196
- skippedReason: "verify=false",
1197
- explanation: "Verification skipped (verify=false)."
1198
- };
1199
- }
1200
- else if (!beforeBuffer) {
1201
- verification = {
1202
- skipped: true,
1203
- skippedReason: "before-screenshot unavailable",
1204
- explanation: "Verification skipped — could not capture pre-tap screenshot."
1205
- };
1206
- }
1207
- else {
1208
- try {
1209
- const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
1210
- const scale = beforeScaleFactor || after.scaleFactor || 1;
1211
- const statusBarHeight = Math.round(rawStatusBar / scale);
1212
- const diff = await compareScreenshots(beforeBuffer, after.buffer, {
1213
- statusBarHeight
1214
- });
1215
- verification = {
1216
- meaningful: diff.changed,
1217
- changeRate: diff.changeRate,
1218
- changedPixels: diff.changedPixels,
1219
- totalPixels: diff.totalPixels,
1220
- explanation: buildVerificationExplanation({
1221
- meaningful: diff.changed,
1222
- changeRate: diff.changeRate,
1223
- changedPixels: diff.changedPixels,
1224
- totalPixels: diff.totalPixels
1225
- })
1226
- };
1227
- }
1228
- catch (err) {
1229
- verification = {
1230
- skipped: true,
1231
- skippedReason: `diff failed: ${err instanceof Error ? err.message : String(err)}`,
1232
- explanation: "Verification skipped — pixel diff threw."
1233
- };
1234
- }
1235
- }
1236
- const verifyGroupId = `verify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1237
- if (beforeBuffer) {
1238
- imageBuffer.add({
1239
- id: `${verifyGroupId}-before`,
1240
- image: beforeBuffer,
1241
- timestamp: Date.now(),
1242
- source: "tap-verify",
1243
- groupId: verifyGroupId,
1244
- metadata: { phase: "before" }
1245
- });
1246
- }
1247
- imageBuffer.add({
1248
- id: `${verifyGroupId}-after`,
1249
- image: afterWithMarker,
1250
- timestamp: Date.now(),
1251
- source: "tap-verify",
1252
- groupId: verifyGroupId,
1253
- metadata: { phase: "after", changeRate: verification?.changeRate }
1254
- });
1255
- return { screenshot, verification, afterBuffer: after.buffer, afterWithMarkerBuffer: afterWithMarker };
1256
- }
1257
- // --- Burst Capture ---
1258
- const BURST_FRAME_COUNT = 4;
1259
- const BURST_FRAME_INTERVAL_MS = 150;
1260
- async function burstCaptureAndVerify(platform, beforeBuffer, udid, beforeScaleFactor, markerPx) {
1261
- if (!beforeBuffer)
1262
- return {};
1263
- const frames = [beforeBuffer];
1264
- let capturedScaleFactor = beforeScaleFactor || 1;
1265
- for (let i = 0; i < BURST_FRAME_COUNT; i++) {
1266
- await new Promise((resolve) => setTimeout(resolve, BURST_FRAME_INTERVAL_MS));
1267
- const capture = await captureScreenshot(platform, udid);
1268
- if (capture) {
1269
- frames.push(capture.buffer);
1270
- if (i === 0)
1271
- capturedScaleFactor = capture.scaleFactor || capturedScaleFactor;
1272
- }
1273
- }
1274
- if (frames.length < 2)
1275
- return {};
1276
- const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
1277
- const statusBarHeight = Math.round(rawStatusBar / capturedScaleFactor);
1278
- // Run the diff on clean frames (no marker) so the cross doesn't register as change.
1279
- const analysis = await analyzeBurstFrames(frames, { statusBarHeight });
1280
- // Apply marker to post-tap frames only (frames[0] is the before state).
1281
- const markedFrames = [];
1282
- for (let i = 0; i < frames.length; i++) {
1283
- if (markerPx && i > 0) {
1284
- markedFrames.push(await drawTapMarker(frames[i], markerPx.x, markerPx.y));
1285
- }
1286
- else {
1287
- markedFrames.push(frames[i]);
1288
- }
1289
- }
1290
- const groupId = `burst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1291
- for (let i = 0; i < markedFrames.length; i++) {
1292
- imageBuffer.add({
1293
- id: `${groupId}-f${i}`,
1294
- image: markedFrames[i],
1295
- timestamp: Date.now(),
1296
- source: "tap-burst",
1297
- groupId,
1298
- metadata: {
1299
- frameIndex: i,
1300
- isBefore: i === 0,
1301
- changeRate: i === 0 ? 0 : analysis.framesWithChange.includes(i) ? analysis.peakChangeRate : 0
1302
- }
1303
- });
1304
- }
1305
- imageBuffer.addGroup({
1306
- groupId,
1307
- intent: "tap-verification",
1308
- source: "tap-burst",
1309
- timestamp: Date.now(),
1310
- frameCount: frames.length,
1311
- summary: {
1312
- peakChangeRate: analysis.peakChangeRate,
1313
- peakFrame: analysis.peakFrame,
1314
- framesWithChange: analysis.framesWithChange,
1315
- transientChangeDetected: analysis.transientChangeDetected,
1316
- persistentChangeRate: analysis.persistentChangeRate
1317
- }
1318
- });
1319
- // Get dimensions from the last frame using sharp
1320
- const sharp = (await import("sharp")).default;
1321
- const meta = await sharp(markedFrames[markedFrames.length - 1]).metadata();
1322
- const screenshot = {
1323
- image: screenshotToBase64(markedFrames[markedFrames.length - 1]),
1324
- width: meta.width || 0,
1325
- height: meta.height || 0,
1326
- scaleFactor: 1
1327
- };
1328
- const verification = {
1329
- meaningful: analysis.meaningful,
1330
- changeRate: analysis.persistentChangeRate,
1331
- changedPixels: 0,
1332
- totalPixels: 0,
1333
- transientChangeDetected: analysis.transientChangeDetected,
1334
- peakChangeRate: analysis.peakChangeRate,
1335
- peakFrame: analysis.peakFrame,
1336
- burstGroupId: groupId,
1337
- explanation: buildVerificationExplanation({
1338
- meaningful: analysis.meaningful,
1339
- changeRate: analysis.persistentChangeRate,
1340
- changedPixels: 0,
1341
- totalPixels: 0,
1342
- transientChangeDetected: analysis.transientChangeDetected,
1343
- peakChangeRate: analysis.peakChangeRate,
1344
- peakFrame: analysis.peakFrame
1345
- })
1346
- };
1347
- const lastFrameIdx = frames.length - 1;
1348
- return {
1349
- screenshot,
1350
- verification,
1351
- afterBuffer: frames[lastFrameIdx],
1352
- afterWithMarkerBuffer: markedFrames[lastFrameIdx]
1353
- };
1354
- }
1355
1113
  // D2 (Step 6): in-memory ring buffer of recent coord-strategy artifact keys
1356
1114
  // `${round(x/20)}:${round(y/20)}`. Entries expire after 60s. The first
1357
1115
  // failed coord tap at a grid cell uploads its artifact; retries within the
@@ -1478,7 +1236,6 @@ async function captureTapArtifact(ctx) {
1478
1236
  chosenElement: null,
1479
1237
  screenshots: {
1480
1238
  before: ctx.screenshotBuffer ?? null,
1481
- after: ctx.afterBuffer ?? null,
1482
1239
  afterWithMarker: ctx.afterWithMarker ?? null
1483
1240
  },
1484
1241
  deviceMeta: {
@@ -1550,67 +1307,16 @@ export async function tap(options) {
1550
1307
  }
1551
1308
  // Native mode: bypass React Native connection, tap directly via ADB/simctl
1552
1309
  if (options.native && hasCoordinates) {
1553
- let platform = options.platform;
1554
- // Explicit udid forces iOS targeting. If platform is also explicit and conflicts,
1555
- // surface a clear error rather than silently overriding.
1556
- if (options.udid && platform === "android") {
1310
+ const nativeResolved = await resolveDeviceTarget(options.device);
1311
+ if (!nativeResolved.ok) {
1557
1312
  return {
1558
1313
  success: false,
1559
1314
  query,
1560
- error: 'udid is only valid for iOS. Got platform: "android".'
1315
+ error: formatResolverError(nativeResolved.error)
1561
1316
  };
1562
1317
  }
1563
- // Auto-detect platform if not specified
1564
- let nativeUdid;
1565
- if (options.udid) {
1566
- platform = "ios";
1567
- nativeUdid = options.udid;
1568
- }
1569
- else if (!platform) {
1570
- // If `device` is provided, try resolving it as an iOS simulator name first —
1571
- // otherwise the auto-detect would fall through to getActiveOrBootedSimulatorUdid()
1572
- // and ignore the caller's targeting hint when multiple simulators are booted.
1573
- const deviceMatchedUdid = options.device
1574
- ? await findSimulatorByName(options.device).catch(() => null)
1575
- : null;
1576
- if (deviceMatchedUdid) {
1577
- platform = "ios";
1578
- nativeUdid = deviceMatchedUdid;
1579
- }
1580
- else {
1581
- const [androidDevice, iosSimulator] = await Promise.all([
1582
- getDefaultAndroidDevice().catch(() => null),
1583
- getActiveOrBootedSimulatorUdid().catch(() => null)
1584
- ]);
1585
- if (androidDevice && iosSimulator) {
1586
- return {
1587
- success: false,
1588
- query,
1589
- error: 'Multiple platforms detected (both Android and iOS). Specify platform: "android" or platform: "ios" to target the correct device.'
1590
- };
1591
- }
1592
- if (androidDevice) {
1593
- platform = "android";
1594
- }
1595
- else if (iosSimulator) {
1596
- platform = "ios";
1597
- nativeUdid = iosSimulator;
1598
- }
1599
- else {
1600
- return {
1601
- success: false,
1602
- query,
1603
- error: "No Android device or iOS simulator found. Connect a device or start a simulator."
1604
- };
1605
- }
1606
- }
1607
- }
1608
- else if (platform === "ios") {
1609
- nativeUdid =
1610
- (options.device ? await findSimulatorByName(options.device) : null) ??
1611
- (await getActiveOrBootedSimulatorUdid()) ??
1612
- undefined;
1613
- }
1318
+ const platform = nativeResolved.target.platform;
1319
+ const nativeUdid = nativeResolved.target.iosUdid;
1614
1320
  const nativeShouldScreenshot = options.screenshot !== false;
1615
1321
  // Decoupled (I5, 2026-05-16): verify runs even when image bytes aren't returned.
1616
1322
  const nativeShouldVerify = options.verify !== false;
@@ -1668,12 +1374,26 @@ export async function tap(options) {
1668
1374
  screenshotScale: nativeScreenshotMeta?.scaleFactor || 1
1669
1375
  });
1670
1376
  if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
1671
- ({ screenshot, verification } = await burstCaptureAndVerify(platform, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
1377
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1378
+ platform,
1379
+ beforeBuffer: nativeBeforeBuffer,
1380
+ udid: nativeUdid,
1381
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1382
+ markerPx: nativeMarker
1383
+ }));
1672
1384
  if (!nativeShouldScreenshot)
1673
1385
  screenshot = undefined;
1674
1386
  }
1675
1387
  else {
1676
- ({ screenshot, verification } = await verifyAndCapture(platform, nativeShouldVerify, nativeShouldScreenshot, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
1388
+ ({ screenshot, verification } = await verifyAndCapture({
1389
+ platform,
1390
+ shouldVerify: nativeShouldVerify,
1391
+ shouldScreenshot: nativeShouldScreenshot,
1392
+ beforeBuffer: nativeBeforeBuffer,
1393
+ udid: nativeUdid,
1394
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1395
+ markerPx: nativeMarker
1396
+ }));
1677
1397
  }
1678
1398
  return formatTapSuccess({
1679
1399
  method: "native-coordinate",
@@ -1691,46 +1411,48 @@ export async function tap(options) {
1691
1411
  suggestion: `Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) to verify coordinates.`
1692
1412
  });
1693
1413
  }
1694
- // udid is iOS-only. Reject early if combined with android.
1695
- if (options.udid && options.platform === "android") {
1414
+ // Resolve device target via the unified resolver. Replaces the prior chain
1415
+ // of (udid → connectedApp → findSimulatorByName) lookups and the standalone
1416
+ // "no platform → probe OS for devices" branch. Returns a structured error
1417
+ // when ambiguous; surface that to the caller so they can disambiguate.
1418
+ const resolved = await resolveDeviceTarget(options.device);
1419
+ if (!resolved.ok) {
1696
1420
  return {
1697
1421
  success: false,
1698
1422
  query,
1699
- error: 'udid is only valid for iOS. Got platform: "android".'
1423
+ error: formatResolverError(resolved.error)
1700
1424
  };
1701
1425
  }
1702
- // Detect available capabilities
1426
+ const platform = resolved.target.platform;
1427
+ let targetUdid = resolved.target.iosUdid;
1428
+ // Pick the connected app to bias strategy selection. Prefer the registry
1429
+ // entry whose identifier matches the resolved target; fall back to a
1430
+ // device-name match (handy when registry entries lack identifiers yet);
1431
+ // last resort is the first connected app on the matching platform.
1703
1432
  const allApps = Array.from(connectedApps.values());
1704
1433
  let hasMetro = allApps.length > 0;
1705
- // Pick the connected app to bias platform/strategy selection.
1706
- // Precedence:
1707
- // 1. udid → match the iOS app whose simulatorUdid equals udid (if any)
1708
- // 2. device → substring match against connected app's deviceName/title
1709
- // 3. platform → first app on that platform
1710
- // 4. first connected app
1711
1434
  let app;
1712
- if (options.udid) {
1713
- app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === options.udid);
1435
+ if (resolved.target.iosUdid) {
1436
+ app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === resolved.target.iosUdid);
1437
+ }
1438
+ else if (resolved.target.androidSerial) {
1439
+ app = allApps.find((a) => a.platform === "android" && a.adbSerial === resolved.target.androidSerial);
1714
1440
  }
1715
1441
  if (!app && options.device) {
1716
1442
  try {
1717
1443
  app = getConnectedAppByDevice(options.device) ?? undefined;
1718
1444
  }
1719
- catch (err) {
1720
- // getConnectedAppByDevice throws on ambiguous matches — surface that to the caller
1721
- return {
1722
- success: false,
1723
- query,
1724
- error: err instanceof Error ? err.message : String(err)
1725
- };
1445
+ catch {
1446
+ // getConnectedAppByDevice throws on ambiguous matches; the resolver
1447
+ // would have already returned that error, so reaching here means
1448
+ // the resolved target's identifier just doesn't appear in the
1449
+ // registry yet — fall through to the platform-first pick below.
1726
1450
  }
1727
1451
  }
1728
1452
  if (!app) {
1729
- app = options.platform
1730
- ? (allApps.find((a) => a.platform === options.platform) ?? allApps[0])
1731
- : allApps[0];
1453
+ app = allApps.find((a) => a.platform === platform) ?? allApps[0];
1732
1454
  }
1733
- // Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work
1455
+ // Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work.
1734
1456
  if (!hasMetro) {
1735
1457
  try {
1736
1458
  await withTimeout((async () => {
@@ -1747,56 +1469,15 @@ export async function tap(options) {
1747
1469
  })(), Math.min(remainingMs(), 3000), "auto-connect");
1748
1470
  const apps = Array.from(connectedApps.values());
1749
1471
  hasMetro = apps.length > 0;
1750
- app = apps[0];
1472
+ app = apps.find((a) => a.platform === platform) ?? apps[0];
1473
+ if (platform === "ios" && !targetUdid) {
1474
+ targetUdid = app?.simulatorUdid ?? targetUdid;
1475
+ }
1751
1476
  }
1752
1477
  catch {
1753
1478
  // Auto-connect failed — Metro-dependent strategies will be skipped
1754
1479
  }
1755
1480
  }
1756
- // Detect platform — from Metro connection or from available devices
1757
- let platform = options.platform;
1758
- if (!platform) {
1759
- if (app) {
1760
- platform = app.platform;
1761
- }
1762
- else {
1763
- // No Metro — detect from available devices
1764
- const [androidDevice, iosSimulator] = await Promise.all([
1765
- getDefaultAndroidDevice().catch(() => null),
1766
- getActiveOrBootedSimulatorUdid().catch(() => null)
1767
- ]);
1768
- if (androidDevice && iosSimulator) {
1769
- return {
1770
- success: false,
1771
- query,
1772
- error: 'Both iOS and Android devices detected but no Metro connection. Specify platform="ios" or platform="android".'
1773
- };
1774
- }
1775
- if (androidDevice)
1776
- platform = "android";
1777
- else if (iosSimulator)
1778
- platform = "ios";
1779
- }
1780
- }
1781
- if (!platform) {
1782
- return {
1783
- success: false,
1784
- query,
1785
- error: "No devices found. Boot a simulator or connect an Android device."
1786
- };
1787
- }
1788
- // Resolve target iOS simulator UDID.
1789
- // Precedence: explicit udid → connected app's simulatorUdid → findSimulatorByName(device or app deviceName)
1790
- // This ensures taps go to the correct device when multiple simulators are booted.
1791
- let targetUdid;
1792
- if (platform === "ios") {
1793
- targetUdid =
1794
- options.udid ??
1795
- app?.simulatorUdid ??
1796
- (options.device ? await findSimulatorByName(options.device) : null) ??
1797
- (app?.deviceInfo?.deviceName ? await findSimulatorByName(app.deviceInfo.deviceName) : null) ??
1798
- undefined;
1799
- }
1800
1481
  const deviceName = app?.deviceInfo?.deviceName;
1801
1482
  // Determine strategies
1802
1483
  const strategies = getAvailableStrategies(query, strategy);
@@ -1813,14 +1494,16 @@ export async function tap(options) {
1813
1494
  if (strat === "fiber" && !hasMetro) {
1814
1495
  attempted.push({
1815
1496
  strategy: "fiber",
1816
- reason: "Skipped — no Metro connection (required for fiber)"
1497
+ reason: "Skipped — no Metro connection (required for fiber)",
1498
+ outcome: "skipped"
1817
1499
  });
1818
1500
  return false;
1819
1501
  }
1820
1502
  if (uiDriverMissing && UI_DRIVER_REQUIRED_STRATEGIES.includes(strat)) {
1821
1503
  attempted.push({
1822
1504
  strategy: strat,
1823
- reason: "Skipped — iOS UI driver is not installed (required for iOS tap/accessibility/OCR)"
1505
+ reason: "Skipped — iOS UI driver is not installed (required for iOS tap/accessibility/OCR)",
1506
+ outcome: "skipped"
1824
1507
  });
1825
1508
  return false;
1826
1509
  }
@@ -1845,10 +1528,12 @@ export async function tap(options) {
1845
1528
  // capture cost is paid either way; bandwidth cost is what `screenshot` toggles.
1846
1529
  const shouldScreenshot = options.screenshot !== false;
1847
1530
  const canVerify = options.verify !== false;
1848
- // Capture "before" screenshot for verification
1531
+ // Capture "before" screenshot. Always attempted (independent of canVerify)
1532
+ // so failure artifacts always carry a before.png — the diagnostic value of
1533
+ // the before frame doesn't depend on whether we run the post-tap diff.
1849
1534
  let beforeBuffer = null;
1850
1535
  let beforeScaleFactor;
1851
- if (canVerify) {
1536
+ {
1852
1537
  const before = await captureScreenshot(platform, targetUdid);
1853
1538
  beforeBuffer = before?.buffer || null;
1854
1539
  beforeScaleFactor = before?.scaleFactor;
@@ -1876,7 +1561,8 @@ export async function tap(options) {
1876
1561
  if (remaining < MIN_STRATEGY_BUDGET_MS) {
1877
1562
  attempted.push({
1878
1563
  strategy: strat,
1879
- reason: `Skipped — only ${remaining}ms remaining (budget ${TAP_TIMEOUT_MS}ms)`
1564
+ reason: `Skipped — only ${remaining}ms remaining (budget ${TAP_TIMEOUT_MS}ms)`,
1565
+ outcome: "skipped"
1880
1566
  });
1881
1567
  continue;
1882
1568
  }
@@ -1949,7 +1635,13 @@ export async function tap(options) {
1949
1635
  // Fall through past the catch block to the success handler.
1950
1636
  }
1951
1637
  else {
1952
- attempted.push({ strategy: strat, reason });
1638
+ // Classify the throw: a strategy-level timeout means "result UNKNOWN"
1639
+ // (the strategy didn't finish), distinct from "ran clean and found
1640
+ // nothing". Agents must not infer absence from timeouts. The
1641
+ // `outcome` field carries that distinction; `reason` stays the raw
1642
+ // message so existing log-matchers / classifiers keep working.
1643
+ const outcome = STRATEGY_TIMEOUT_RE.test(reason) ? "timeout" : "error";
1644
+ attempted.push({ strategy: strat, reason, outcome });
1953
1645
  continue;
1954
1646
  }
1955
1647
  }
@@ -1960,7 +1652,6 @@ export async function tap(options) {
1960
1652
  ocrPrewarmCtrl?.abort();
1961
1653
  let screenshot;
1962
1654
  let verification;
1963
- let afterBuffer;
1964
1655
  let afterWithMarkerBuffer;
1965
1656
  let dprForMarker;
1966
1657
  if (platform === "ios") {
@@ -1981,15 +1672,35 @@ export async function tap(options) {
1981
1672
  devicePixelRatio: dprForMarker
1982
1673
  });
1983
1674
  if (options.burst && canVerify && beforeBuffer) {
1984
- ({ screenshot, verification, afterBuffer, afterWithMarkerBuffer } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
1675
+ ({ screenshot, verification, afterWithMarkerBuffer } = await burstCaptureAndVerify({
1676
+ platform,
1677
+ beforeBuffer,
1678
+ udid: targetUdid,
1679
+ beforeScaleFactor,
1680
+ markerPx: strategyMarker
1681
+ }));
1985
1682
  // Burst always produces a screenshot for diff visualization. Drop the
1986
- // bytes when the caller didn't ask for them — frames are still in
1987
- // imageBuffer (accessible via get_images(groupId=verification.burstGroupId)).
1988
- if (!shouldScreenshot)
1683
+ // bytes when the caller didn't ask for them AND the tap was meaningful
1684
+ // — keeping the screenshot on unmeaningful taps saves the agent a
1685
+ // round-trip when it needs to diagnose. Frames remain in imageBuffer
1686
+ // either way (accessible via get_images(groupId=verification.burstGroupId)).
1687
+ if (!shouldScreenshot && verification?.meaningful !== false)
1989
1688
  screenshot = undefined;
1990
1689
  }
1991
1690
  else {
1992
- ({ screenshot, verification, afterBuffer, afterWithMarkerBuffer } = await verifyAndCapture(platform, canVerify, shouldScreenshot, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
1691
+ ({ screenshot, verification, afterWithMarkerBuffer } = await verifyAndCapture({
1692
+ platform,
1693
+ shouldVerify: canVerify,
1694
+ shouldScreenshot: true,
1695
+ beforeBuffer,
1696
+ udid: targetUdid,
1697
+ beforeScaleFactor,
1698
+ markerPx: strategyMarker
1699
+ }));
1700
+ // Mirror the burst-path gate: drop the screenshot only when the
1701
+ // caller didn't ask for it AND the tap was meaningful.
1702
+ if (!shouldScreenshot && verification?.meaningful !== false)
1703
+ screenshot = undefined;
1993
1704
  }
1994
1705
  if (screenshot && app) {
1995
1706
  app.lastScreenshot = {
@@ -2014,7 +1725,9 @@ export async function tap(options) {
2014
1725
  });
2015
1726
  // Capture an artifact for "successful but unmeaningful" taps so we can
2016
1727
  // diagnose taps that landed wrong or hit non-responsive elements.
2017
- if (verification && !verification.skipped && (verification.changeRate ?? 1) < 0.001) {
1728
+ // Uses the same `meaningful` flag the agent sees so dashboard outcomes
1729
+ // stay consistent with what the caller observed.
1730
+ if (verification && !verification.skipped && verification.meaningful === false) {
2018
1731
  const unmeaningfulSignals = await captureTapArtifact({
2019
1732
  query,
2020
1733
  outcome: "unmeaningful",
@@ -2024,7 +1737,6 @@ export async function tap(options) {
2024
1737
  deviceName,
2025
1738
  screenshotMeta: screenshot ? { width: screenshot.width, height: screenshot.height } : undefined,
2026
1739
  screenshotBuffer: beforeBuffer,
2027
- afterBuffer: afterBuffer ?? null,
2028
1740
  afterWithMarker: afterWithMarkerBuffer ?? null,
2029
1741
  chosenTapPoint: strategyMarker ? { x: strategyMarker.x, y: strategyMarker.y } : null,
2030
1742
  verification,
@@ -2034,7 +1746,19 @@ export async function tap(options) {
2034
1746
  }
2035
1747
  return successResult;
2036
1748
  }
2037
- attempted.push({ strategy: strat, reason: result.reason });
1749
+ // Classify the strategy's non-success result. The reason string carries
1750
+ // the diagnostic from the strategy; outcome lets agents branch on category
1751
+ // without parsing prose. "invisible" / "ambiguous" are pinned by substring
1752
+ // matches the fiber strategy emits via pressables.ts.
1753
+ const reasonStr = result.reason || "";
1754
+ let attemptOutcome = "not-found";
1755
+ if (result.ambiguous)
1756
+ attemptOutcome = "ambiguous";
1757
+ else if (reasonStr.indexOf("but none are visible") !== -1)
1758
+ attemptOutcome = "invisible";
1759
+ else if (STRATEGY_TIMEOUT_RE.test(reasonStr))
1760
+ attemptOutcome = "timeout";
1761
+ attempted.push({ strategy: strat, reason: reasonStr, outcome: attemptOutcome });
2038
1762
  // If fiber found an element with measured coordinates, do a native tap directly
2039
1763
  if (strat === "fiber" && result.convertedTo && result.pressed) {
2040
1764
  try {
@@ -2089,11 +1813,27 @@ export async function tap(options) {
2089
1813
  androidDensityScale: fnDensity
2090
1814
  });
2091
1815
  if (options.burst && canVerify && beforeBuffer) {
2092
- ({ screenshot, verification } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor, fiberMarker));
1816
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1817
+ platform,
1818
+ beforeBuffer,
1819
+ udid: targetUdid,
1820
+ beforeScaleFactor,
1821
+ markerPx: fiberMarker
1822
+ }));
2093
1823
  }
2094
1824
  else {
2095
- ({ screenshot, verification } = await verifyAndCapture(platform, canVerify, shouldScreenshot, beforeBuffer, targetUdid, beforeScaleFactor, fiberMarker));
1825
+ ({ screenshot, verification } = await verifyAndCapture({
1826
+ platform,
1827
+ shouldVerify: canVerify,
1828
+ shouldScreenshot: true,
1829
+ beforeBuffer,
1830
+ udid: targetUdid,
1831
+ beforeScaleFactor,
1832
+ markerPx: fiberMarker
1833
+ }));
2096
1834
  }
1835
+ if (!shouldScreenshot && verification?.meaningful !== false)
1836
+ screenshot = undefined;
2097
1837
  if (screenshot && app) {
2098
1838
  app.lastScreenshot = {
2099
1839
  originalWidth: screenshot.width,
@@ -2124,7 +1864,7 @@ export async function tap(options) {
2124
1864
  // Do NOT fall through to other strategies (they can't resolve ambiguity).
2125
1865
  if (result.matches && result.ambiguous) {
2126
1866
  const { screenshot: matchScreenshot } = shouldScreenshot
2127
- ? await verifyAndCapture(platform, false, true, null, targetUdid)
1867
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
2128
1868
  : { screenshot: undefined };
2129
1869
  if (matchScreenshot && app) {
2130
1870
  app.lastScreenshot = {
@@ -2151,9 +1891,22 @@ export async function tap(options) {
2151
1891
  // itself ran out of time.
2152
1892
  const hitTimeout = isTapTimeout(attempted);
2153
1893
  const elapsed = TAP_TIMEOUT_MS - remainingMs();
2154
- const suggestion = buildSuggestion(query, strategies, platform);
1894
+ // If fiber located the element but visibility filtered it out, prepend a
1895
+ // scroll-or-dismiss hint so the agent doesn't waste a turn assuming the
1896
+ // testID/component is missing.
1897
+ const fiberSawInvisible = attempted.some(a => a.strategy === "fiber" && a.outcome === "invisible");
1898
+ const allTimedOut = attempted.length > 0 && attempted.every(a => a.outcome === "timeout" || a.outcome === "skipped");
1899
+ let suggestion = buildSuggestion(query, strategies, platform);
1900
+ if (fiberSawInvisible) {
1901
+ suggestion = `Element exists in the React tree but is not on screen. Try scrolling it into view with swipe(), dismiss any overlay covering it, or wait for layout to settle. ` + suggestion;
1902
+ }
1903
+ else if (allTimedOut) {
1904
+ // No strategy got to a definitive answer — don't let the agent conclude
1905
+ // the element is missing. Steer toward retry or a different strategy.
1906
+ suggestion = `All strategies timed out — the element's presence is UNKNOWN. Retry the tap (transient slowness is common on dense screens), or try a different strategy explicitly (e.g. strategy='fiber' if accessibility timed out). ` + suggestion;
1907
+ }
2155
1908
  const { screenshot: failScreenshot } = shouldScreenshot
2156
- ? await verifyAndCapture(platform, false, true, null, targetUdid)
1909
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
2157
1910
  : { screenshot: undefined };
2158
1911
  if (failScreenshot && app) {
2159
1912
  app.lastScreenshot = {
@@ -2162,10 +1915,22 @@ export async function tap(options) {
2162
1915
  scaleFactor: failScreenshot.scaleFactor
2163
1916
  };
2164
1917
  }
1918
+ // Pick the right error framing: a definitive "not found" is different from
1919
+ // "all strategies timed out — we don't know whether it exists".
1920
+ let errorOverride;
1921
+ if (hitTimeout) {
1922
+ errorOverride = `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)`;
1923
+ }
1924
+ else if (allTimedOut) {
1925
+ errorOverride = `All tap strategies timed out before completing — element presence is UNKNOWN`;
1926
+ }
1927
+ else if (fiberSawInvisible) {
1928
+ errorOverride = `Element matches the query but is not visible on screen`;
1929
+ }
2165
1930
  const failureResult = formatTapFailure({
2166
1931
  query,
2167
1932
  attempted,
2168
- error: hitTimeout ? `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)` : undefined,
1933
+ error: errorOverride,
2169
1934
  suggestion,
2170
1935
  device: deviceName,
2171
1936
  screenshot: failScreenshot
@@ -2217,7 +1982,7 @@ function buildSuggestion(query, triedStrategies, platform) {
2217
1982
  // the error) keeps the existing message stable for tooling that parses it.
2218
1983
  if (query.testID && looksLikeVirtualizedListItem(query.testID)) {
2219
1984
  suggestions.push("testID looks like a virtualized list item — scroll it on-screen first " +
2220
- `(${platform === "ios" ? "ios_button arrow keys / swipe" : "android_swipe"}) before tapping`);
1985
+ `(swipe()${platform === "ios" ? " or ios_button arrow keys" : ""}) before tapping`);
2221
1986
  }
2222
1987
  suggestions.push(`Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) ` +
2223
1988
  "to verify the element is visible, then use x/y coordinates");