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.
- package/README.md +1 -1
- package/build/__tests__/integration/execute-in-app.test.js +11 -3
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
- package/build/__tests__/integration/tap-strategies.test.js +1 -1
- package/build/__tests__/integration/tap-strategies.test.js.map +1 -1
- package/build/__tests__/unit/androidEmulatorAvds.test.d.ts +2 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.d.ts.map +1 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.js +98 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.js.map +1 -0
- package/build/__tests__/unit/deviceDiscovery.test.d.ts +2 -0
- package/build/__tests__/unit/deviceDiscovery.test.d.ts.map +1 -0
- package/build/__tests__/unit/deviceDiscovery.test.js +119 -0
- package/build/__tests__/unit/deviceDiscovery.test.js.map +1 -0
- package/build/__tests__/unit/deviceResolver.test.d.ts +2 -0
- package/build/__tests__/unit/deviceResolver.test.d.ts.map +1 -0
- package/build/__tests__/unit/deviceResolver.test.js +226 -0
- package/build/__tests__/unit/deviceResolver.test.js.map +1 -0
- package/build/__tests__/unit/failureArtifact.test.js +3 -3
- package/build/__tests__/unit/failureArtifact.test.js.map +1 -1
- package/build/__tests__/unit/tap.test.js +13 -30
- package/build/__tests__/unit/tap.test.js.map +1 -1
- package/build/__tests__/unit/verifyAction.test.d.ts +2 -0
- package/build/__tests__/unit/verifyAction.test.d.ts.map +1 -0
- package/build/__tests__/unit/verifyAction.test.js +26 -0
- package/build/__tests__/unit/verifyAction.test.js.map +1 -0
- package/build/core/android.d.ts +20 -0
- package/build/core/android.d.ts.map +1 -1
- package/build/core/android.js +98 -0
- package/build/core/android.js.map +1 -1
- package/build/core/connection.d.ts.map +1 -1
- package/build/core/connection.js +20 -9
- package/build/core/connection.js.map +1 -1
- package/build/core/deviceDiscovery.d.ts +52 -0
- package/build/core/deviceDiscovery.d.ts.map +1 -0
- package/build/core/deviceDiscovery.js +74 -0
- package/build/core/deviceDiscovery.js.map +1 -0
- package/build/core/deviceResolver.d.ts +44 -0
- package/build/core/deviceResolver.d.ts.map +1 -0
- package/build/core/deviceResolver.js +191 -0
- package/build/core/deviceResolver.js.map +1 -0
- package/build/core/failureArtifact.d.ts +0 -2
- package/build/core/failureArtifact.d.ts.map +1 -1
- package/build/core/failureArtifact.js +0 -4
- package/build/core/failureArtifact.js.map +1 -1
- package/build/core/guides.d.ts.map +1 -1
- package/build/core/guides.js +3 -4
- package/build/core/guides.js.map +1 -1
- package/build/core/pressables.d.ts.map +1 -1
- package/build/core/pressables.js +43 -8
- package/build/core/pressables.js.map +1 -1
- package/build/core/rnGlobalsBootstrap.d.ts.map +1 -1
- package/build/core/rnGlobalsBootstrap.js +16 -1
- package/build/core/rnGlobalsBootstrap.js.map +1 -1
- package/build/core/types.d.ts +1 -0
- package/build/core/types.d.ts.map +1 -1
- package/build/pro/screenshot-diff.js +1 -1
- package/build/pro/screenshot-diff.js.map +1 -1
- package/build/pro/tap.d.ts +8 -10
- package/build/pro/tap.d.ts.map +1 -1
- package/build/pro/tap.js +178 -413
- package/build/pro/tap.js.map +1 -1
- package/build/pro/verifyAction.d.ts +49 -0
- package/build/pro/verifyAction.d.ts.map +1 -0
- package/build/pro/verifyAction.js +255 -0
- package/build/pro/verifyAction.js.map +1 -0
- package/build/tools/componentTools.d.ts.map +1 -1
- package/build/tools/componentTools.js +14 -18
- package/build/tools/componentTools.js.map +1 -1
- package/build/tools/connectionTools.d.ts.map +1 -1
- package/build/tools/connectionTools.js +32 -5
- package/build/tools/connectionTools.js.map +1 -1
- package/build/tools/deviceTools.d.ts.map +1 -1
- package/build/tools/deviceTools.js +104 -2
- package/build/tools/deviceTools.js.map +1 -1
- package/build/tools/executionTools.d.ts.map +1 -1
- package/build/tools/executionTools.js +7 -9
- package/build/tools/executionTools.js.map +1 -1
- package/build/tools/interactionTools.d.ts.map +1 -1
- package/build/tools/interactionTools.js +113 -151
- package/build/tools/interactionTools.js.map +1 -1
- package/package.json +1 -1
package/build/pro/tap.js
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
import { connectedApps
|
|
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,
|
|
5
|
-
import { androidTap, androidFindElement
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
1019
|
-
accessibility:
|
|
1020
|
-
ocr:
|
|
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
|
|
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
|
-
|
|
1554
|
-
|
|
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:
|
|
1315
|
+
error: formatResolverError(nativeResolved.error)
|
|
1561
1316
|
};
|
|
1562
1317
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
1695
|
-
|
|
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:
|
|
1423
|
+
error: formatResolverError(resolved.error)
|
|
1700
1424
|
};
|
|
1701
1425
|
}
|
|
1702
|
-
|
|
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 (
|
|
1713
|
-
app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid ===
|
|
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
|
|
1720
|
-
// getConnectedAppByDevice throws on ambiguous matches
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1987
|
-
//
|
|
1988
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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");
|