react-native-ai-debugger 1.9.1 → 1.10.1

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 (73) hide show
  1. package/README.md +1 -1
  2. package/build/__tests__/integration/tap-strategies.test.js +1 -1
  3. package/build/__tests__/integration/tap-strategies.test.js.map +1 -1
  4. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts +2 -0
  5. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts.map +1 -0
  6. package/build/__tests__/unit/androidEmulatorAvds.test.js +98 -0
  7. package/build/__tests__/unit/androidEmulatorAvds.test.js.map +1 -0
  8. package/build/__tests__/unit/deviceDiscovery.test.d.ts +2 -0
  9. package/build/__tests__/unit/deviceDiscovery.test.d.ts.map +1 -0
  10. package/build/__tests__/unit/deviceDiscovery.test.js +119 -0
  11. package/build/__tests__/unit/deviceDiscovery.test.js.map +1 -0
  12. package/build/__tests__/unit/deviceResolver.test.d.ts +2 -0
  13. package/build/__tests__/unit/deviceResolver.test.d.ts.map +1 -0
  14. package/build/__tests__/unit/deviceResolver.test.js +226 -0
  15. package/build/__tests__/unit/deviceResolver.test.js.map +1 -0
  16. package/build/__tests__/unit/tap.test.js +23 -34
  17. package/build/__tests__/unit/tap.test.js.map +1 -1
  18. package/build/__tests__/unit/verifyAction.test.d.ts +2 -0
  19. package/build/__tests__/unit/verifyAction.test.d.ts.map +1 -0
  20. package/build/__tests__/unit/verifyAction.test.js +26 -0
  21. package/build/__tests__/unit/verifyAction.test.js.map +1 -0
  22. package/build/core/android.d.ts +20 -0
  23. package/build/core/android.d.ts.map +1 -1
  24. package/build/core/android.js +98 -0
  25. package/build/core/android.js.map +1 -1
  26. package/build/core/connection.d.ts.map +1 -1
  27. package/build/core/connection.js +116 -55
  28. package/build/core/connection.js.map +1 -1
  29. package/build/core/connectionState.d.ts +14 -0
  30. package/build/core/connectionState.d.ts.map +1 -1
  31. package/build/core/connectionState.js +29 -0
  32. package/build/core/connectionState.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 +215 -0
  40. package/build/core/deviceResolver.js.map +1 -0
  41. package/build/core/guides.d.ts.map +1 -1
  42. package/build/core/guides.js +3 -4
  43. package/build/core/guides.js.map +1 -1
  44. package/build/core/index.d.ts +1 -1
  45. package/build/core/index.d.ts.map +1 -1
  46. package/build/core/index.js +3 -1
  47. package/build/core/index.js.map +1 -1
  48. package/build/core/types.d.ts +1 -0
  49. package/build/core/types.d.ts.map +1 -1
  50. package/build/pro/tap.d.ts +5 -10
  51. package/build/pro/tap.d.ts.map +1 -1
  52. package/build/pro/tap.js +125 -403
  53. package/build/pro/tap.js.map +1 -1
  54. package/build/pro/verifyAction.d.ts +50 -0
  55. package/build/pro/verifyAction.d.ts.map +1 -0
  56. package/build/pro/verifyAction.js +283 -0
  57. package/build/pro/verifyAction.js.map +1 -0
  58. package/build/tools/componentTools.d.ts.map +1 -1
  59. package/build/tools/componentTools.js +8 -6
  60. package/build/tools/componentTools.js.map +1 -1
  61. package/build/tools/connectionTools.d.ts.map +1 -1
  62. package/build/tools/connectionTools.js +64 -5
  63. package/build/tools/connectionTools.js.map +1 -1
  64. package/build/tools/deviceTools.d.ts.map +1 -1
  65. package/build/tools/deviceTools.js +104 -2
  66. package/build/tools/deviceTools.js.map +1 -1
  67. package/build/tools/interactionTools.d.ts.map +1 -1
  68. package/build/tools/interactionTools.js +113 -151
  69. package/build/tools/interactionTools.js.map +1 -1
  70. package/build/tools/screenshotTools.d.ts.map +1 -1
  71. package/build/tools/screenshotTools.js +22 -6
  72. package/build/tools/screenshotTools.js.map +1 -1
  73. 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) {
@@ -476,7 +481,7 @@ export function findClosestOcrText(ocrResult, query) {
476
481
  return best;
477
482
  }
478
483
  // --- Strategy Functions ---
479
- async function tryFiberStrategy(query, index, maxTraversalDepth, sink) {
484
+ async function tryFiberStrategy(query, index, maxTraversalDepth, sink, device) {
480
485
  if (sink) {
481
486
  sink.fiber.ran = true;
482
487
  sink.fiber.metroConnected = connectedApps.size > 0;
@@ -495,7 +500,7 @@ async function tryFiberStrategy(query, index, maxTraversalDepth, sink) {
495
500
  }
496
501
  let lastResult = null;
497
502
  for (const depth of depthAttempts) {
498
- const result = await tryFiberAtDepth(query, index, depth);
503
+ const result = await tryFiberAtDepth(query, index, depth, device);
499
504
  if (sink && result.matches?.length) {
500
505
  sink.fiber.pressables = result.matches.slice(0, 50).map(m => ({
501
506
  label: m.text || undefined,
@@ -518,14 +523,15 @@ async function tryFiberStrategy(query, index, maxTraversalDepth, sink) {
518
523
  sink.fiber.durationMs = Date.now() - startedAt;
519
524
  }
520
525
  }
521
- async function tryFiberAtDepth(query, index, maxTraversalDepth) {
526
+ async function tryFiberAtDepth(query, index, maxTraversalDepth, device) {
522
527
  try {
523
528
  const result = await pressElement({
524
529
  text: query.text,
525
530
  testID: query.testID,
526
531
  component: query.component,
527
532
  index,
528
- maxTraversalDepth
533
+ maxTraversalDepth,
534
+ device
529
535
  });
530
536
  if (!result.success) {
531
537
  return { success: false, reason: result.error || "pressElement failed" };
@@ -1008,7 +1014,6 @@ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, u
1008
1014
  };
1009
1015
  }
1010
1016
  }
1011
- const SETTLE_DELAY_MS = 800;
1012
1017
  const TAP_TIMEOUT_MS = 25000;
1013
1018
  const MIN_STRATEGY_BUDGET_MS = 500;
1014
1019
  // Per-strategy budget. OCR cap on Android is bumped via maxStrategyMs() because
@@ -1064,54 +1069,6 @@ function withCancelableTimeout(make, ms, label) {
1064
1069
  throw err;
1065
1070
  });
1066
1071
  }
1067
- async function captureScreenshot(platform, udid) {
1068
- try {
1069
- const result = platform === "ios" ? await iosScreenshot(undefined, udid) : await androidScreenshot();
1070
- if (!result.success || !result.data)
1071
- return null;
1072
- return {
1073
- buffer: result.data,
1074
- width: result.originalWidth || 0,
1075
- height: result.originalHeight || 0,
1076
- scaleFactor: result.scaleFactor || 1
1077
- };
1078
- }
1079
- catch {
1080
- return null;
1081
- }
1082
- }
1083
- function screenshotToBase64(buffer) {
1084
- return buffer.toString("base64");
1085
- }
1086
- /**
1087
- * Composite a red crosshair marker onto a screenshot at the given pixel coordinates.
1088
- * Uses sharp + SVG so we don't need to inject into the app. Coordinates are in the
1089
- * screenshot's own pixel space (i.e. the space the returned image uses).
1090
- */
1091
- async function drawTapMarker(input, x, y) {
1092
- try {
1093
- const sharp = (await import("sharp")).default;
1094
- const size = 72;
1095
- const half = size / 2;
1096
- const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
1097
- <circle cx="${half}" cy="${half}" r="${half - 8}" fill="none" stroke="white" stroke-width="6" opacity="0.85"/>
1098
- <circle cx="${half}" cy="${half}" r="${half - 8}" fill="none" stroke="#FF2D55" stroke-width="3" opacity="1"/>
1099
- <line x1="${half}" y1="8" x2="${half}" y2="${size - 8}" stroke="white" stroke-width="6" opacity="0.85"/>
1100
- <line x1="8" y1="${half}" x2="${size - 8}" y2="${half}" stroke="white" stroke-width="6" opacity="0.85"/>
1101
- <line x1="${half}" y1="8" x2="${half}" y2="${size - 8}" stroke="#FF2D55" stroke-width="2.5"/>
1102
- <line x1="8" y1="${half}" x2="${size - 8}" y2="${half}" stroke="#FF2D55" stroke-width="2.5"/>
1103
- <circle cx="${half}" cy="${half}" r="3" fill="#FF2D55"/>
1104
- </svg>`;
1105
- const left = Math.round(x - half);
1106
- const top = Math.round(y - half);
1107
- return await sharp(input)
1108
- .composite([{ input: Buffer.from(svg), left, top }])
1109
- .toBuffer();
1110
- }
1111
- catch {
1112
- return input;
1113
- }
1114
- }
1115
1072
  /**
1116
1073
  * Compute marker coordinates in screenshot-pixel space (what the returned PNG uses).
1117
1074
  * Unit rules by strategy, assuming screenshotScale = downscale factor:
@@ -1154,209 +1111,6 @@ function computeMarkerPx(args) {
1154
1111
  y: Math.round(convertedTo.y / scale)
1155
1112
  };
1156
1113
  }
1157
- async function verifyAndCapture(platform, shouldVerify, shouldScreenshot, beforeBuffer, udid, beforeScaleFactor, markerPx) {
1158
- // Decoupled 2026-05-16 (I5): screenshot and verify are independent knobs.
1159
- // - shouldVerify=true: capture after-screenshot internally, diff against beforeBuffer, return verification.
1160
- // - shouldScreenshot=true: also return the (marker-overlaid) image bytes in the response.
1161
- // - both false: fastest path, return only a `skipped` verification stub so the agent can tell apart "ran clean" from "skipped intentionally".
1162
- if (!shouldVerify && !shouldScreenshot) {
1163
- return {
1164
- verification: {
1165
- skipped: true,
1166
- skippedReason: "screenshot=false, verify=false",
1167
- explanation: "Verification skipped (screenshot=false and verify=false)."
1168
- }
1169
- };
1170
- }
1171
- await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS));
1172
- const after = await captureScreenshot(platform, udid);
1173
- if (!after) {
1174
- if (shouldVerify) {
1175
- return {
1176
- verification: {
1177
- skipped: true,
1178
- skippedReason: "after-screenshot capture failed",
1179
- explanation: "Verification skipped — could not capture post-tap screenshot."
1180
- }
1181
- };
1182
- }
1183
- return {};
1184
- }
1185
- const afterWithMarker = markerPx
1186
- ? await drawTapMarker(after.buffer, markerPx.x, markerPx.y)
1187
- : after.buffer;
1188
- // Construct the screenshot eagerly. Caller decides whether to drop it
1189
- // based on `shouldScreenshot` AND the verification outcome — when a tap
1190
- // is unmeaningful we surface the image regardless so the agent can
1191
- // diagnose without an extra round-trip.
1192
- const screenshot = {
1193
- image: screenshotToBase64(afterWithMarker),
1194
- width: after.width,
1195
- height: after.height,
1196
- scaleFactor: after.scaleFactor
1197
- };
1198
- let verification;
1199
- if (!shouldVerify) {
1200
- verification = {
1201
- skipped: true,
1202
- skippedReason: "verify=false",
1203
- explanation: "Verification skipped (verify=false)."
1204
- };
1205
- }
1206
- else if (!beforeBuffer) {
1207
- verification = {
1208
- skipped: true,
1209
- skippedReason: "before-screenshot unavailable",
1210
- explanation: "Verification skipped — could not capture pre-tap screenshot."
1211
- };
1212
- }
1213
- else {
1214
- try {
1215
- const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
1216
- const scale = beforeScaleFactor || after.scaleFactor || 1;
1217
- const statusBarHeight = Math.round(rawStatusBar / scale);
1218
- const diff = await compareScreenshots(beforeBuffer, after.buffer, {
1219
- statusBarHeight
1220
- });
1221
- verification = {
1222
- meaningful: diff.changed,
1223
- changeRate: diff.changeRate,
1224
- changedPixels: diff.changedPixels,
1225
- totalPixels: diff.totalPixels,
1226
- explanation: buildVerificationExplanation({
1227
- meaningful: diff.changed,
1228
- changeRate: diff.changeRate,
1229
- changedPixels: diff.changedPixels,
1230
- totalPixels: diff.totalPixels
1231
- })
1232
- };
1233
- }
1234
- catch (err) {
1235
- verification = {
1236
- skipped: true,
1237
- skippedReason: `diff failed: ${err instanceof Error ? err.message : String(err)}`,
1238
- explanation: "Verification skipped — pixel diff threw."
1239
- };
1240
- }
1241
- }
1242
- const verifyGroupId = `verify-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1243
- if (beforeBuffer) {
1244
- imageBuffer.add({
1245
- id: `${verifyGroupId}-before`,
1246
- image: beforeBuffer,
1247
- timestamp: Date.now(),
1248
- source: "tap-verify",
1249
- groupId: verifyGroupId,
1250
- metadata: { phase: "before" }
1251
- });
1252
- }
1253
- imageBuffer.add({
1254
- id: `${verifyGroupId}-after`,
1255
- image: afterWithMarker,
1256
- timestamp: Date.now(),
1257
- source: "tap-verify",
1258
- groupId: verifyGroupId,
1259
- metadata: { phase: "after", changeRate: verification?.changeRate }
1260
- });
1261
- return { screenshot, verification, afterWithMarkerBuffer: afterWithMarker };
1262
- }
1263
- // --- Burst Capture ---
1264
- const BURST_FRAME_COUNT = 4;
1265
- const BURST_FRAME_INTERVAL_MS = 150;
1266
- async function burstCaptureAndVerify(platform, beforeBuffer, udid, beforeScaleFactor, markerPx) {
1267
- if (!beforeBuffer)
1268
- return {};
1269
- const frames = [beforeBuffer];
1270
- let capturedScaleFactor = beforeScaleFactor || 1;
1271
- for (let i = 0; i < BURST_FRAME_COUNT; i++) {
1272
- await new Promise((resolve) => setTimeout(resolve, BURST_FRAME_INTERVAL_MS));
1273
- const capture = await captureScreenshot(platform, udid);
1274
- if (capture) {
1275
- frames.push(capture.buffer);
1276
- if (i === 0)
1277
- capturedScaleFactor = capture.scaleFactor || capturedScaleFactor;
1278
- }
1279
- }
1280
- if (frames.length < 2)
1281
- return {};
1282
- const rawStatusBar = platform === "ios" ? 177 : 142; // pixels in original screenshot space
1283
- const statusBarHeight = Math.round(rawStatusBar / capturedScaleFactor);
1284
- // Run the diff on clean frames (no marker) so the cross doesn't register as change.
1285
- const analysis = await analyzeBurstFrames(frames, { statusBarHeight });
1286
- // Apply marker to post-tap frames only (frames[0] is the before state).
1287
- const markedFrames = [];
1288
- for (let i = 0; i < frames.length; i++) {
1289
- if (markerPx && i > 0) {
1290
- markedFrames.push(await drawTapMarker(frames[i], markerPx.x, markerPx.y));
1291
- }
1292
- else {
1293
- markedFrames.push(frames[i]);
1294
- }
1295
- }
1296
- const groupId = `burst-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1297
- for (let i = 0; i < markedFrames.length; i++) {
1298
- imageBuffer.add({
1299
- id: `${groupId}-f${i}`,
1300
- image: markedFrames[i],
1301
- timestamp: Date.now(),
1302
- source: "tap-burst",
1303
- groupId,
1304
- metadata: {
1305
- frameIndex: i,
1306
- isBefore: i === 0,
1307
- changeRate: i === 0 ? 0 : analysis.framesWithChange.includes(i) ? analysis.peakChangeRate : 0
1308
- }
1309
- });
1310
- }
1311
- imageBuffer.addGroup({
1312
- groupId,
1313
- intent: "tap-verification",
1314
- source: "tap-burst",
1315
- timestamp: Date.now(),
1316
- frameCount: frames.length,
1317
- summary: {
1318
- peakChangeRate: analysis.peakChangeRate,
1319
- peakFrame: analysis.peakFrame,
1320
- framesWithChange: analysis.framesWithChange,
1321
- transientChangeDetected: analysis.transientChangeDetected,
1322
- persistentChangeRate: analysis.persistentChangeRate
1323
- }
1324
- });
1325
- // Get dimensions from the last frame using sharp
1326
- const sharp = (await import("sharp")).default;
1327
- const meta = await sharp(markedFrames[markedFrames.length - 1]).metadata();
1328
- const screenshot = {
1329
- image: screenshotToBase64(markedFrames[markedFrames.length - 1]),
1330
- width: meta.width || 0,
1331
- height: meta.height || 0,
1332
- scaleFactor: 1
1333
- };
1334
- const verification = {
1335
- meaningful: analysis.meaningful,
1336
- changeRate: analysis.persistentChangeRate,
1337
- changedPixels: 0,
1338
- totalPixels: 0,
1339
- transientChangeDetected: analysis.transientChangeDetected,
1340
- peakChangeRate: analysis.peakChangeRate,
1341
- peakFrame: analysis.peakFrame,
1342
- burstGroupId: groupId,
1343
- explanation: buildVerificationExplanation({
1344
- meaningful: analysis.meaningful,
1345
- changeRate: analysis.persistentChangeRate,
1346
- changedPixels: 0,
1347
- totalPixels: 0,
1348
- transientChangeDetected: analysis.transientChangeDetected,
1349
- peakChangeRate: analysis.peakChangeRate,
1350
- peakFrame: analysis.peakFrame
1351
- })
1352
- };
1353
- const lastFrameIdx = frames.length - 1;
1354
- return {
1355
- screenshot,
1356
- verification,
1357
- afterWithMarkerBuffer: markedFrames[lastFrameIdx]
1358
- };
1359
- }
1360
1114
  // D2 (Step 6): in-memory ring buffer of recent coord-strategy artifact keys
1361
1115
  // `${round(x/20)}:${round(y/20)}`. Entries expire after 60s. The first
1362
1116
  // failed coord tap at a grid cell uploads its artifact; retries within the
@@ -1554,67 +1308,16 @@ export async function tap(options) {
1554
1308
  }
1555
1309
  // Native mode: bypass React Native connection, tap directly via ADB/simctl
1556
1310
  if (options.native && hasCoordinates) {
1557
- let platform = options.platform;
1558
- // Explicit udid forces iOS targeting. If platform is also explicit and conflicts,
1559
- // surface a clear error rather than silently overriding.
1560
- if (options.udid && platform === "android") {
1311
+ const nativeResolved = await resolveDeviceTarget(options.device);
1312
+ if (!nativeResolved.ok) {
1561
1313
  return {
1562
1314
  success: false,
1563
1315
  query,
1564
- error: 'udid is only valid for iOS. Got platform: "android".'
1316
+ error: formatResolverError(nativeResolved.error)
1565
1317
  };
1566
1318
  }
1567
- // Auto-detect platform if not specified
1568
- let nativeUdid;
1569
- if (options.udid) {
1570
- platform = "ios";
1571
- nativeUdid = options.udid;
1572
- }
1573
- else if (!platform) {
1574
- // If `device` is provided, try resolving it as an iOS simulator name first —
1575
- // otherwise the auto-detect would fall through to getActiveOrBootedSimulatorUdid()
1576
- // and ignore the caller's targeting hint when multiple simulators are booted.
1577
- const deviceMatchedUdid = options.device
1578
- ? await findSimulatorByName(options.device).catch(() => null)
1579
- : null;
1580
- if (deviceMatchedUdid) {
1581
- platform = "ios";
1582
- nativeUdid = deviceMatchedUdid;
1583
- }
1584
- else {
1585
- const [androidDevice, iosSimulator] = await Promise.all([
1586
- getDefaultAndroidDevice().catch(() => null),
1587
- getActiveOrBootedSimulatorUdid().catch(() => null)
1588
- ]);
1589
- if (androidDevice && iosSimulator) {
1590
- return {
1591
- success: false,
1592
- query,
1593
- error: 'Multiple platforms detected (both Android and iOS). Specify platform: "android" or platform: "ios" to target the correct device.'
1594
- };
1595
- }
1596
- if (androidDevice) {
1597
- platform = "android";
1598
- }
1599
- else if (iosSimulator) {
1600
- platform = "ios";
1601
- nativeUdid = iosSimulator;
1602
- }
1603
- else {
1604
- return {
1605
- success: false,
1606
- query,
1607
- error: "No Android device or iOS simulator found. Connect a device or start a simulator."
1608
- };
1609
- }
1610
- }
1611
- }
1612
- else if (platform === "ios") {
1613
- nativeUdid =
1614
- (options.device ? await findSimulatorByName(options.device) : null) ??
1615
- (await getActiveOrBootedSimulatorUdid()) ??
1616
- undefined;
1617
- }
1319
+ const platform = nativeResolved.target.platform;
1320
+ const nativeUdid = nativeResolved.target.iosUdid;
1618
1321
  const nativeShouldScreenshot = options.screenshot !== false;
1619
1322
  // Decoupled (I5, 2026-05-16): verify runs even when image bytes aren't returned.
1620
1323
  const nativeShouldVerify = options.verify !== false;
@@ -1672,12 +1375,26 @@ export async function tap(options) {
1672
1375
  screenshotScale: nativeScreenshotMeta?.scaleFactor || 1
1673
1376
  });
1674
1377
  if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
1675
- ({ screenshot, verification } = await burstCaptureAndVerify(platform, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
1378
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1379
+ platform,
1380
+ beforeBuffer: nativeBeforeBuffer,
1381
+ udid: nativeUdid,
1382
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1383
+ markerPx: nativeMarker
1384
+ }));
1676
1385
  if (!nativeShouldScreenshot)
1677
1386
  screenshot = undefined;
1678
1387
  }
1679
1388
  else {
1680
- ({ screenshot, verification } = await verifyAndCapture(platform, nativeShouldVerify, nativeShouldScreenshot, nativeBeforeBuffer, nativeUdid, nativeScreenshotMeta?.scaleFactor, nativeMarker));
1389
+ ({ screenshot, verification } = await verifyAndCapture({
1390
+ platform,
1391
+ shouldVerify: nativeShouldVerify,
1392
+ shouldScreenshot: nativeShouldScreenshot,
1393
+ beforeBuffer: nativeBeforeBuffer,
1394
+ udid: nativeUdid,
1395
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1396
+ markerPx: nativeMarker
1397
+ }));
1681
1398
  }
1682
1399
  return formatTapSuccess({
1683
1400
  method: "native-coordinate",
@@ -1695,46 +1412,48 @@ export async function tap(options) {
1695
1412
  suggestion: `Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) to verify coordinates.`
1696
1413
  });
1697
1414
  }
1698
- // udid is iOS-only. Reject early if combined with android.
1699
- if (options.udid && options.platform === "android") {
1415
+ // Resolve device target via the unified resolver. Replaces the prior chain
1416
+ // of (udid → connectedApp → findSimulatorByName) lookups and the standalone
1417
+ // "no platform → probe OS for devices" branch. Returns a structured error
1418
+ // when ambiguous; surface that to the caller so they can disambiguate.
1419
+ const resolved = await resolveDeviceTarget(options.device);
1420
+ if (!resolved.ok) {
1700
1421
  return {
1701
1422
  success: false,
1702
1423
  query,
1703
- error: 'udid is only valid for iOS. Got platform: "android".'
1424
+ error: formatResolverError(resolved.error)
1704
1425
  };
1705
1426
  }
1706
- // Detect available capabilities
1427
+ const platform = resolved.target.platform;
1428
+ let targetUdid = resolved.target.iosUdid;
1429
+ // Pick the connected app to bias strategy selection. Prefer the registry
1430
+ // entry whose identifier matches the resolved target; fall back to a
1431
+ // device-name match (handy when registry entries lack identifiers yet);
1432
+ // last resort is the first connected app on the matching platform.
1707
1433
  const allApps = Array.from(connectedApps.values());
1708
1434
  let hasMetro = allApps.length > 0;
1709
- // Pick the connected app to bias platform/strategy selection.
1710
- // Precedence:
1711
- // 1. udid → match the iOS app whose simulatorUdid equals udid (if any)
1712
- // 2. device → substring match against connected app's deviceName/title
1713
- // 3. platform → first app on that platform
1714
- // 4. first connected app
1715
1435
  let app;
1716
- if (options.udid) {
1717
- app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === options.udid);
1436
+ if (resolved.target.iosUdid) {
1437
+ app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === resolved.target.iosUdid);
1438
+ }
1439
+ else if (resolved.target.androidSerial) {
1440
+ app = allApps.find((a) => a.platform === "android" && a.adbSerial === resolved.target.androidSerial);
1718
1441
  }
1719
1442
  if (!app && options.device) {
1720
1443
  try {
1721
1444
  app = getConnectedAppByDevice(options.device) ?? undefined;
1722
1445
  }
1723
- catch (err) {
1724
- // getConnectedAppByDevice throws on ambiguous matches — surface that to the caller
1725
- return {
1726
- success: false,
1727
- query,
1728
- error: err instanceof Error ? err.message : String(err)
1729
- };
1446
+ catch {
1447
+ // getConnectedAppByDevice throws on ambiguous matches; the resolver
1448
+ // would have already returned that error, so reaching here means
1449
+ // the resolved target's identifier just doesn't appear in the
1450
+ // registry yet — fall through to the platform-first pick below.
1730
1451
  }
1731
1452
  }
1732
1453
  if (!app) {
1733
- app = options.platform
1734
- ? (allApps.find((a) => a.platform === options.platform) ?? allApps[0])
1735
- : allApps[0];
1454
+ app = allApps.find((a) => a.platform === platform) ?? allApps[0];
1736
1455
  }
1737
- // Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work
1456
+ // Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work.
1738
1457
  if (!hasMetro) {
1739
1458
  try {
1740
1459
  await withTimeout((async () => {
@@ -1751,57 +1470,21 @@ export async function tap(options) {
1751
1470
  })(), Math.min(remainingMs(), 3000), "auto-connect");
1752
1471
  const apps = Array.from(connectedApps.values());
1753
1472
  hasMetro = apps.length > 0;
1754
- app = apps[0];
1473
+ app = apps.find((a) => a.platform === platform) ?? apps[0];
1474
+ if (platform === "ios" && !targetUdid) {
1475
+ targetUdid = app?.simulatorUdid ?? targetUdid;
1476
+ }
1755
1477
  }
1756
1478
  catch {
1757
1479
  // Auto-connect failed — Metro-dependent strategies will be skipped
1758
1480
  }
1759
1481
  }
1760
- // Detect platform — from Metro connection or from available devices
1761
- let platform = options.platform;
1762
- if (!platform) {
1763
- if (app) {
1764
- platform = app.platform;
1765
- }
1766
- else {
1767
- // No Metro — detect from available devices
1768
- const [androidDevice, iosSimulator] = await Promise.all([
1769
- getDefaultAndroidDevice().catch(() => null),
1770
- getActiveOrBootedSimulatorUdid().catch(() => null)
1771
- ]);
1772
- if (androidDevice && iosSimulator) {
1773
- return {
1774
- success: false,
1775
- query,
1776
- error: 'Both iOS and Android devices detected but no Metro connection. Specify platform="ios" or platform="android".'
1777
- };
1778
- }
1779
- if (androidDevice)
1780
- platform = "android";
1781
- else if (iosSimulator)
1782
- platform = "ios";
1783
- }
1784
- }
1785
- if (!platform) {
1786
- return {
1787
- success: false,
1788
- query,
1789
- error: "No devices found. Boot a simulator or connect an Android device."
1790
- };
1791
- }
1792
- // Resolve target iOS simulator UDID.
1793
- // Precedence: explicit udid → connected app's simulatorUdid → findSimulatorByName(device or app deviceName)
1794
- // This ensures taps go to the correct device when multiple simulators are booted.
1795
- let targetUdid;
1796
- if (platform === "ios") {
1797
- targetUdid =
1798
- options.udid ??
1799
- app?.simulatorUdid ??
1800
- (options.device ? await findSimulatorByName(options.device) : null) ??
1801
- (app?.deviceInfo?.deviceName ? await findSimulatorByName(app.deviceInfo.deviceName) : null) ??
1802
- undefined;
1803
- }
1804
- const deviceName = app?.deviceInfo?.deviceName;
1482
+ // The response's `device` label must reflect the RESOLVED target, not the
1483
+ // registry-app fallback. When the registry lookup couldn't find a matching
1484
+ // app (e.g. simulatorUdid hadn't been backfilled yet), `app` lands on the
1485
+ // first iOS entry — which on a multi-sim setup is the WRONG device. The
1486
+ // resolver already knows the right deviceName; trust it. Bug #5 (2026-05-20).
1487
+ const deviceName = resolved.target.deviceName || app?.deviceInfo?.deviceName;
1805
1488
  // Determine strategies
1806
1489
  const strategies = getAvailableStrategies(query, strategy);
1807
1490
  const attempted = [];
@@ -1898,7 +1581,7 @@ export async function tap(options) {
1898
1581
  case "fiber":
1899
1582
  // Fiber is JS-only against a CDP target — no subprocess to cancel,
1900
1583
  // so the cheaper non-cancellable wrapper is fine.
1901
- result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth, evidence), budget, `fiber`);
1584
+ result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth, evidence, deviceName), budget, `fiber`);
1902
1585
  break;
1903
1586
  case "accessibility":
1904
1587
  result = await withCancelableTimeout((signal) => tryAccessibilityStrategy(query, index, platform, targetUdid, evidence, signal), budget, `accessibility`);
@@ -1935,7 +1618,12 @@ export async function tap(options) {
1935
1618
  }
1936
1619
  break;
1937
1620
  case "coordinate":
1938
- result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, app?.lastScreenshot, targetUdid), budget, `coordinate`);
1621
+ // Prefer `beforeScaleFactor` (captured against `targetUdid` this turn)
1622
+ // over `app.lastScreenshot.scaleFactor` (stale and may belong to a
1623
+ // different device on a multi-sim setup). Bug #5 (2026-05-20).
1624
+ result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, beforeScaleFactor != null
1625
+ ? { originalWidth: 0, originalHeight: 0, scaleFactor: beforeScaleFactor }
1626
+ : app?.lastScreenshot, targetUdid), budget, `coordinate`);
1939
1627
  break;
1940
1628
  default:
1941
1629
  result = { success: false, reason: `Unknown strategy: ${strat}` };
@@ -1995,7 +1683,13 @@ export async function tap(options) {
1995
1683
  devicePixelRatio: dprForMarker
1996
1684
  });
1997
1685
  if (options.burst && canVerify && beforeBuffer) {
1998
- ({ screenshot, verification, afterWithMarkerBuffer } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
1686
+ ({ screenshot, verification, afterWithMarkerBuffer } = await burstCaptureAndVerify({
1687
+ platform,
1688
+ beforeBuffer,
1689
+ udid: targetUdid,
1690
+ beforeScaleFactor,
1691
+ markerPx: strategyMarker
1692
+ }));
1999
1693
  // Burst always produces a screenshot for diff visualization. Drop the
2000
1694
  // bytes when the caller didn't ask for them AND the tap was meaningful
2001
1695
  // — keeping the screenshot on unmeaningful taps saves the agent a
@@ -2005,8 +1699,15 @@ export async function tap(options) {
2005
1699
  screenshot = undefined;
2006
1700
  }
2007
1701
  else {
2008
- ({ screenshot, verification, afterWithMarkerBuffer } = await verifyAndCapture(platform, canVerify,
2009
- /* shouldScreenshot */ true, beforeBuffer, targetUdid, beforeScaleFactor, strategyMarker));
1702
+ ({ screenshot, verification, afterWithMarkerBuffer } = await verifyAndCapture({
1703
+ platform,
1704
+ shouldVerify: canVerify,
1705
+ shouldScreenshot: true,
1706
+ beforeBuffer,
1707
+ udid: targetUdid,
1708
+ beforeScaleFactor,
1709
+ markerPx: strategyMarker
1710
+ }));
2010
1711
  // Mirror the burst-path gate: drop the screenshot only when the
2011
1712
  // caller didn't ask for it AND the tap was meaningful.
2012
1713
  if (!shouldScreenshot && verification?.meaningful !== false)
@@ -2088,7 +1789,15 @@ export async function tap(options) {
2088
1789
  const { androidGetDensity } = await import("../core/android.js");
2089
1790
  const densityResult = await androidGetDensity();
2090
1791
  const densityScale = (densityResult.density || 420) / 160;
2091
- await androidTap(Math.round(coords.x * densityScale), Math.round(coords.y * densityScale));
1792
+ const pxX = Math.round(coords.x * densityScale);
1793
+ const pxY = Math.round(coords.y * densityScale);
1794
+ await androidTap(pxX, pxY);
1795
+ // Report the actual tap coords (pixels) the caller can pass straight
1796
+ // to coordinate tools / verification — not the raw dp from fiber.
1797
+ // Matches every other Android coord report in the tool (OB1, 2026-05-20).
1798
+ coords.x = pxX;
1799
+ coords.y = pxY;
1800
+ coords.unit = "pixels";
2092
1801
  }
2093
1802
  // fiber+native uses native tap — always verify
2094
1803
  let screenshot;
@@ -2123,11 +1832,24 @@ export async function tap(options) {
2123
1832
  androidDensityScale: fnDensity
2124
1833
  });
2125
1834
  if (options.burst && canVerify && beforeBuffer) {
2126
- ({ screenshot, verification } = await burstCaptureAndVerify(platform, beforeBuffer, targetUdid, beforeScaleFactor, fiberMarker));
1835
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1836
+ platform,
1837
+ beforeBuffer,
1838
+ udid: targetUdid,
1839
+ beforeScaleFactor,
1840
+ markerPx: fiberMarker
1841
+ }));
2127
1842
  }
2128
1843
  else {
2129
- ({ screenshot, verification } = await verifyAndCapture(platform, canVerify,
2130
- /* shouldScreenshot */ true, beforeBuffer, targetUdid, beforeScaleFactor, fiberMarker));
1844
+ ({ screenshot, verification } = await verifyAndCapture({
1845
+ platform,
1846
+ shouldVerify: canVerify,
1847
+ shouldScreenshot: true,
1848
+ beforeBuffer,
1849
+ udid: targetUdid,
1850
+ beforeScaleFactor,
1851
+ markerPx: fiberMarker
1852
+ }));
2131
1853
  }
2132
1854
  if (!shouldScreenshot && verification?.meaningful !== false)
2133
1855
  screenshot = undefined;
@@ -2161,7 +1883,7 @@ export async function tap(options) {
2161
1883
  // Do NOT fall through to other strategies (they can't resolve ambiguity).
2162
1884
  if (result.matches && result.ambiguous) {
2163
1885
  const { screenshot: matchScreenshot } = shouldScreenshot
2164
- ? await verifyAndCapture(platform, false, true, null, targetUdid)
1886
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
2165
1887
  : { screenshot: undefined };
2166
1888
  if (matchScreenshot && app) {
2167
1889
  app.lastScreenshot = {
@@ -2195,7 +1917,7 @@ export async function tap(options) {
2195
1917
  const allTimedOut = attempted.length > 0 && attempted.every(a => a.outcome === "timeout" || a.outcome === "skipped");
2196
1918
  let suggestion = buildSuggestion(query, strategies, platform);
2197
1919
  if (fiberSawInvisible) {
2198
- suggestion = `Element exists in the React tree but is not on screen. Try scrolling it into view (${platform === "ios" ? "ios_swipe / arrow keys" : "android_swipe"}), dismiss any overlay covering it, or wait for layout to settle. ` + suggestion;
1920
+ 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;
2199
1921
  }
2200
1922
  else if (allTimedOut) {
2201
1923
  // No strategy got to a definitive answer — don't let the agent conclude
@@ -2203,7 +1925,7 @@ export async function tap(options) {
2203
1925
  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;
2204
1926
  }
2205
1927
  const { screenshot: failScreenshot } = shouldScreenshot
2206
- ? await verifyAndCapture(platform, false, true, null, targetUdid)
1928
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
2207
1929
  : { screenshot: undefined };
2208
1930
  if (failScreenshot && app) {
2209
1931
  app.lastScreenshot = {
@@ -2279,7 +2001,7 @@ function buildSuggestion(query, triedStrategies, platform) {
2279
2001
  // the error) keeps the existing message stable for tooling that parses it.
2280
2002
  if (query.testID && looksLikeVirtualizedListItem(query.testID)) {
2281
2003
  suggestions.push("testID looks like a virtualized list item — scroll it on-screen first " +
2282
- `(${platform === "ios" ? "ios_button arrow keys / swipe" : "android_swipe"}) before tapping`);
2004
+ `(swipe()${platform === "ios" ? " or ios_button arrow keys" : ""}) before tapping`);
2283
2005
  }
2284
2006
  suggestions.push(`Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) ` +
2285
2007
  "to verify the element is visible, then use x/y coordinates");