mobile-debug-mcp 0.26.4 → 0.27.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/dist/interact/index.js +392 -192
- package/dist/observe/ios.js +47 -3
- package/dist/server/common.js +39 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +35 -3
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +114 -16
- package/docs/rfcs/009-semantic-control-modeling-for-custom-and-composite-controls.md +238 -0
- package/docs/rfcs/010-verification-stabilization-and-temporal-convergence.md +265 -0
- package/docs/rfcs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +321 -0
- package/docs/rfcs/011.1-recovery-contract-types-and-runtime-wiring-spec.md +253 -0
- package/docs/rfcs/012.md +203 -0
- package/docs/specs/mcp-tooling-spec-v1.md +34 -0
- package/docs/tools/interact.md +10 -0
- package/package.json +1 -1
- package/src/interact/index.ts +433 -194
- package/src/observe/ios.ts +42 -3
- package/src/server/common.ts +44 -1
- package/src/server-core.ts +1 -1
- package/src/types.ts +41 -1
- package/src/utils/android/utils.ts +30 -3
- package/test/unit/interact/adjust_control.test.ts +77 -1
- package/test/unit/interact/verification_stabilization.test.ts +94 -0
- package/test/unit/observe/find_element.test.ts +46 -0
- package/test/unit/observe/state_extraction.test.ts +65 -2
- package/test/unit/server/common.test.ts +36 -1
package/src/interact/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ExpectStateResponse,
|
|
17
17
|
ExpectScreenResponse,
|
|
18
18
|
WaitForUIChangeResponse,
|
|
19
|
+
UIElementSemanticMetadata,
|
|
19
20
|
UIElementState,
|
|
20
21
|
TapElementResponse
|
|
21
22
|
} from '../types.js'
|
|
@@ -48,7 +49,7 @@ interface UiElement {
|
|
|
48
49
|
role?: string | null
|
|
49
50
|
test_tag?: string | null
|
|
50
51
|
selector?: { value: string | null, confidence: { score: number, reason: string } | null } | null
|
|
51
|
-
semantic?:
|
|
52
|
+
semantic?: UIElementSemanticMetadata | null
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
interface ResolvedUiElementContext {
|
|
@@ -342,6 +343,12 @@ export class ToolsInteract {
|
|
|
342
343
|
return !!el.state?.value_range || /slider|seekbar|stepper|adjustable|range/.test(type) || /slider|seekbar|stepper|adjustable|range/.test(role)
|
|
343
344
|
}
|
|
344
345
|
|
|
346
|
+
private static _isSemanticActionable(el: UiElement | null): boolean {
|
|
347
|
+
if (!el?.semantic) return false
|
|
348
|
+
if (el.semantic.adjustable) return true
|
|
349
|
+
return Array.isArray(el.semantic.supported_actions) && el.semantic.supported_actions.length > 0
|
|
350
|
+
}
|
|
351
|
+
|
|
345
352
|
private static _readNumericControlValue(el: UiElement | null, property: string): number | null {
|
|
346
353
|
if (!el?.state) return null
|
|
347
354
|
const stateValue = el.state[property as keyof UIElementState]
|
|
@@ -410,6 +417,59 @@ export class ToolsInteract {
|
|
|
410
417
|
return ToolsInteract._buildControlPoint(bounds, safeRatio, axis)
|
|
411
418
|
}
|
|
412
419
|
|
|
420
|
+
private static _buildAdjustmentProbePoints(
|
|
421
|
+
bounds: [number, number, number, number],
|
|
422
|
+
targetValue: number,
|
|
423
|
+
currentValue: number | null,
|
|
424
|
+
min: number,
|
|
425
|
+
max: number,
|
|
426
|
+
axis: 'horizontal' | 'vertical'
|
|
427
|
+
) {
|
|
428
|
+
const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis)
|
|
429
|
+
const currentPoint = currentValue !== null
|
|
430
|
+
? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
|
|
431
|
+
: ToolsInteract._buildControlPoint(bounds, 0.5, axis)
|
|
432
|
+
|
|
433
|
+
const [left, top, right, bottom] = bounds
|
|
434
|
+
const width = Math.max(1, right - left)
|
|
435
|
+
const height = Math.max(1, bottom - top)
|
|
436
|
+
const crossAxisBumps = axis === 'horizontal'
|
|
437
|
+
? [Math.max(24, Math.floor(height * 0.75)), Math.max(40, Math.floor(height * 1.5))]
|
|
438
|
+
: [Math.max(24, Math.floor(width * 0.75)), Math.max(40, Math.floor(width * 1.5))]
|
|
439
|
+
|
|
440
|
+
const clampPoint = (point: { x: number, y: number }) => ({
|
|
441
|
+
x: axis === 'horizontal'
|
|
442
|
+
? Math.max(left, Math.min(right, point.x))
|
|
443
|
+
: Math.max(left, Math.min(right + Math.max(width, height), point.x)),
|
|
444
|
+
y: axis === 'vertical'
|
|
445
|
+
? Math.max(top, Math.min(bottom, point.y))
|
|
446
|
+
: Math.max(top, Math.min(bottom + Math.max(height, width), point.y))
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
const probes = [targetPoint, currentPoint]
|
|
450
|
+
for (const bump of crossAxisBumps) {
|
|
451
|
+
if (axis === 'horizontal') {
|
|
452
|
+
probes.push(
|
|
453
|
+
{ x: targetPoint.x, y: bottom + bump },
|
|
454
|
+
{ x: currentPoint.x, y: bottom + bump }
|
|
455
|
+
)
|
|
456
|
+
} else {
|
|
457
|
+
probes.push(
|
|
458
|
+
{ x: right + bump, y: targetPoint.y },
|
|
459
|
+
{ x: right + bump, y: currentPoint.y }
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return Array.from(
|
|
465
|
+
new Map(
|
|
466
|
+
probes
|
|
467
|
+
.map(clampPoint)
|
|
468
|
+
.map((point) => [`${point.x}:${point.y}`, point] as const)
|
|
469
|
+
).values()
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
413
473
|
private static _controlAxis(el: UiElement, bounds: [number, number, number, number]): 'horizontal' | 'vertical' {
|
|
414
474
|
const type = ToolsInteract._normalize(el.type ?? el.class ?? '')
|
|
415
475
|
const role = ToolsInteract._normalize(el.role ?? '')
|
|
@@ -460,12 +520,12 @@ export class ToolsInteract {
|
|
|
460
520
|
|
|
461
521
|
private static _resolveActionableAncestor(elements: UiElement[], chosen: { el: UiElement, idx: number } | null): { el: UiElement, idx: number } | null {
|
|
462
522
|
if (!chosen) return null
|
|
463
|
-
if (chosen.el.clickable || chosen.el.focusable) return chosen
|
|
523
|
+
if (chosen.el.clickable || chosen.el.focusable || ToolsInteract._isSemanticActionable(chosen.el)) return chosen
|
|
464
524
|
|
|
465
525
|
let current = chosen
|
|
466
526
|
let safety = 0
|
|
467
527
|
|
|
468
|
-
while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
|
|
528
|
+
while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) && current.el.parentId !== undefined && current.el.parentId !== null) {
|
|
469
529
|
const parentId = current.el.parentId
|
|
470
530
|
let parentIndex: number | null = null
|
|
471
531
|
|
|
@@ -474,12 +534,12 @@ export class ToolsInteract {
|
|
|
474
534
|
|
|
475
535
|
if (parentIndex !== null && elements[parentIndex]) {
|
|
476
536
|
current = { el: elements[parentIndex], idx: parentIndex }
|
|
477
|
-
if (current.el.clickable || current.el.focusable) return current
|
|
537
|
+
if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) return current
|
|
478
538
|
} else if (typeof parentId === 'string') {
|
|
479
539
|
const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId)
|
|
480
540
|
if (foundIndex === -1) break
|
|
481
541
|
current = { el: elements[foundIndex], idx: foundIndex }
|
|
482
|
-
if (current.el.clickable || current.el.focusable) return current
|
|
542
|
+
if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) return current
|
|
483
543
|
} else {
|
|
484
544
|
break
|
|
485
545
|
}
|
|
@@ -496,7 +556,7 @@ export class ToolsInteract {
|
|
|
496
556
|
|
|
497
557
|
for (let i = 0; i < elements.length; i++) {
|
|
498
558
|
const el = elements[i]
|
|
499
|
-
if (!el || !(el.clickable || el.focusable)) continue
|
|
559
|
+
if (!el || !(el.clickable || el.focusable || ToolsInteract._isSemanticActionable(el))) continue
|
|
500
560
|
const bounds = ToolsInteract._normalizeBounds(el.bounds)
|
|
501
561
|
if (!bounds) continue
|
|
502
562
|
const [pl, pt, pr, pb] = bounds
|
|
@@ -1005,6 +1065,7 @@ export class ToolsInteract {
|
|
|
1005
1065
|
const currentPoint = currentValue !== null
|
|
1006
1066
|
? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
|
|
1007
1067
|
: ToolsInteract._buildControlPoint(bounds, 0.5, axis)
|
|
1068
|
+
const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis)
|
|
1008
1069
|
|
|
1009
1070
|
const runVerification = async (): Promise<{
|
|
1010
1071
|
verification: any
|
|
@@ -1040,41 +1101,72 @@ export class ToolsInteract {
|
|
|
1040
1101
|
}
|
|
1041
1102
|
}
|
|
1042
1103
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
y: targetPoint.y,
|
|
1048
|
-
deviceId: resolvedDeviceId
|
|
1049
|
-
})
|
|
1050
|
-
let actionDevice = primaryActionResult.device ?? currentDevice
|
|
1051
|
-
attemptCount++
|
|
1104
|
+
let actionDevice: any = currentDevice
|
|
1105
|
+
let observedState: { property: string; value: number | null; raw_value?: number | null } | null = actualState
|
|
1106
|
+
let verification: any = null
|
|
1107
|
+
let verificationResult: any = { verification: null, observedState: actualState, withinTolerance: false }
|
|
1052
1108
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1109
|
+
for (let i = 0; i < probePoints.length; i++) {
|
|
1110
|
+
const probePoint = probePoints[i]
|
|
1111
|
+
lastAdjustmentMode = 'coordinate'
|
|
1112
|
+
const actionResult = await ToolsInteract.tapHandler({
|
|
1056
1113
|
platform: resolvedPlatform,
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
x2: targetPoint.x,
|
|
1060
|
-
y2: targetPoint.y,
|
|
1061
|
-
duration: 220,
|
|
1114
|
+
x: probePoint.x,
|
|
1115
|
+
y: probePoint.y,
|
|
1062
1116
|
deviceId: resolvedDeviceId
|
|
1063
1117
|
})
|
|
1064
1118
|
attemptCount++
|
|
1119
|
+
actionDevice = actionResult.device ?? actionDevice
|
|
1065
1120
|
|
|
1066
|
-
if (!
|
|
1067
|
-
|
|
1121
|
+
if (!actionResult.success) {
|
|
1122
|
+
continue
|
|
1068
1123
|
}
|
|
1069
1124
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1125
|
+
verificationResult = await runVerification()
|
|
1126
|
+
observedState = verificationResult.observedState
|
|
1127
|
+
lastObservedState = observedState
|
|
1128
|
+
|
|
1129
|
+
if (verificationResult.withinTolerance) {
|
|
1130
|
+
const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
|
|
1131
|
+
const base = buildActionExecutionResult({
|
|
1132
|
+
actionType,
|
|
1133
|
+
sourceModule: 'interact',
|
|
1134
|
+
device: actionDevice ?? currentDevice,
|
|
1135
|
+
selector: targetSelector,
|
|
1136
|
+
resolved: resolvedTarget,
|
|
1137
|
+
success: true,
|
|
1138
|
+
uiFingerprintBefore: fingerprintBefore,
|
|
1139
|
+
uiFingerprintAfter,
|
|
1140
|
+
details: {
|
|
1141
|
+
target_value: targetValue,
|
|
1142
|
+
tolerance: normalizedTolerance,
|
|
1143
|
+
property,
|
|
1144
|
+
attempts: attemptCount,
|
|
1145
|
+
adjustment_mode: lastAdjustmentMode,
|
|
1146
|
+
actual_state: observedState,
|
|
1147
|
+
converged: true,
|
|
1148
|
+
within_tolerance: true,
|
|
1149
|
+
reason: verificationResult.verification?.reason ?? 'control converged to target value'
|
|
1150
|
+
}
|
|
1151
|
+
}) as AdjustControlResponse
|
|
1072
1152
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1153
|
+
return {
|
|
1154
|
+
...base,
|
|
1155
|
+
target_state: {
|
|
1156
|
+
property,
|
|
1157
|
+
target_value: targetValue,
|
|
1158
|
+
tolerance: normalizedTolerance
|
|
1159
|
+
},
|
|
1160
|
+
actual_state: observedState,
|
|
1161
|
+
within_tolerance: true,
|
|
1162
|
+
converged: true,
|
|
1163
|
+
attempts: attemptCount,
|
|
1164
|
+
adjustment_mode: lastAdjustmentMode
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1076
1168
|
|
|
1077
|
-
if (
|
|
1169
|
+
if (currentValue !== null) {
|
|
1078
1170
|
lastAdjustmentMode = 'gesture'
|
|
1079
1171
|
const fallbackActionResult = await ToolsInteract.swipeHandler({
|
|
1080
1172
|
platform: resolvedPlatform,
|
|
@@ -1087,14 +1179,55 @@ export class ToolsInteract {
|
|
|
1087
1179
|
})
|
|
1088
1180
|
attemptCount++
|
|
1089
1181
|
if (!fallbackActionResult.success) {
|
|
1090
|
-
return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false)
|
|
1182
|
+
return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? actionDevice, observedState ?? actualState, attemptCount, lastAdjustmentMode, false)
|
|
1091
1183
|
}
|
|
1092
1184
|
|
|
1185
|
+
actionDevice = fallbackActionResult.device ?? actionDevice
|
|
1093
1186
|
verificationResult = await runVerification()
|
|
1094
1187
|
observedState = verificationResult.observedState
|
|
1188
|
+
lastObservedState = observedState
|
|
1189
|
+
|
|
1190
|
+
if (verificationResult.withinTolerance) {
|
|
1191
|
+
const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
|
|
1192
|
+
const base = buildActionExecutionResult({
|
|
1193
|
+
actionType,
|
|
1194
|
+
sourceModule: 'interact',
|
|
1195
|
+
device: actionDevice ?? currentDevice,
|
|
1196
|
+
selector: targetSelector,
|
|
1197
|
+
resolved: resolvedTarget,
|
|
1198
|
+
success: true,
|
|
1199
|
+
uiFingerprintBefore: fingerprintBefore,
|
|
1200
|
+
uiFingerprintAfter,
|
|
1201
|
+
details: {
|
|
1202
|
+
target_value: targetValue,
|
|
1203
|
+
tolerance: normalizedTolerance,
|
|
1204
|
+
property,
|
|
1205
|
+
attempts: attemptCount,
|
|
1206
|
+
adjustment_mode: lastAdjustmentMode,
|
|
1207
|
+
actual_state: observedState,
|
|
1208
|
+
converged: true,
|
|
1209
|
+
within_tolerance: true,
|
|
1210
|
+
reason: verificationResult.verification?.reason ?? 'control converged to target value'
|
|
1211
|
+
}
|
|
1212
|
+
}) as AdjustControlResponse
|
|
1213
|
+
|
|
1214
|
+
return {
|
|
1215
|
+
...base,
|
|
1216
|
+
target_state: {
|
|
1217
|
+
property,
|
|
1218
|
+
target_value: targetValue,
|
|
1219
|
+
tolerance: normalizedTolerance
|
|
1220
|
+
},
|
|
1221
|
+
actual_state: observedState,
|
|
1222
|
+
within_tolerance: true,
|
|
1223
|
+
converged: true,
|
|
1224
|
+
attempts: attemptCount,
|
|
1225
|
+
adjustment_mode: lastAdjustmentMode
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1095
1228
|
}
|
|
1096
1229
|
|
|
1097
|
-
|
|
1230
|
+
verification = verificationResult.verification
|
|
1098
1231
|
lastObservedState = observedState
|
|
1099
1232
|
|
|
1100
1233
|
if (verificationResult.withinTolerance) {
|
|
@@ -1196,12 +1329,14 @@ export class ToolsInteract {
|
|
|
1196
1329
|
const [l,t,r,b] = bounds
|
|
1197
1330
|
if (r <= l || b <= t) return null
|
|
1198
1331
|
// Do not early-return on non-interactable elements — score them so we can locate their clickable ancestor later
|
|
1199
|
-
const interactable = !!(el.clickable || el.enabled || el.focusable)
|
|
1332
|
+
const interactable = !!(el.clickable || el.enabled || el.focusable || ToolsInteract._isSemanticActionable(el))
|
|
1200
1333
|
|
|
1201
1334
|
const text = normalize(el.text ?? el.label ?? el.value ?? '')
|
|
1202
1335
|
const content = normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? '')
|
|
1203
1336
|
const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '')
|
|
1204
1337
|
const className = normalize(el.type ?? el.class ?? '')
|
|
1338
|
+
const semanticRole = normalize(el.semantic?.semantic_role ?? '')
|
|
1339
|
+
const semanticActions = Array.isArray(el.semantic?.supported_actions) ? el.semantic.supported_actions.map((action) => normalize(action)).filter(Boolean) : []
|
|
1205
1340
|
|
|
1206
1341
|
let score = 0
|
|
1207
1342
|
let reason = 'best_scoring_candidate'
|
|
@@ -1243,6 +1378,29 @@ export class ToolsInteract {
|
|
|
1243
1378
|
reason = 'partial_class_match'
|
|
1244
1379
|
}
|
|
1245
1380
|
}
|
|
1381
|
+
if (!exact) {
|
|
1382
|
+
if (!score && semanticRole && semanticRole.includes(q)) {
|
|
1383
|
+
score = 0.5
|
|
1384
|
+
reason = 'semantic_role_match'
|
|
1385
|
+
}
|
|
1386
|
+
if (semanticActions.some((action) => action.includes(q))) {
|
|
1387
|
+
score = Math.max(score, score > 0 ? 0.65 : 0.6)
|
|
1388
|
+
reason = 'semantic_action_match'
|
|
1389
|
+
}
|
|
1390
|
+
if (score === 0 && el.semantic?.adjustable && /slider|stepper|dropdown|segment|control|adjust/.test(q)) {
|
|
1391
|
+
score = 0.45
|
|
1392
|
+
reason = 'semantic_control_match'
|
|
1393
|
+
}
|
|
1394
|
+
} else {
|
|
1395
|
+
if (!score && semanticRole && semanticRole === q) {
|
|
1396
|
+
score = 0.5
|
|
1397
|
+
reason = 'semantic_role_match'
|
|
1398
|
+
}
|
|
1399
|
+
if (semanticActions.some((action) => action === q)) {
|
|
1400
|
+
score = Math.max(score, score > 0 ? 0.65 : 0.6)
|
|
1401
|
+
reason = 'semantic_action_match'
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1246
1404
|
if (score > 0 && interactable) score += 0.05
|
|
1247
1405
|
if (score <= 0) return null
|
|
1248
1406
|
return { el, idx, score, reason, interactable }
|
|
@@ -1352,7 +1510,7 @@ export class ToolsInteract {
|
|
|
1352
1510
|
}
|
|
1353
1511
|
}
|
|
1354
1512
|
|
|
1355
|
-
if (best && !(best.el.clickable || best.el.focusable)) {
|
|
1513
|
+
if (best && !(best.el.clickable || best.el.focusable || ToolsInteract._isSemanticActionable(best.el))) {
|
|
1356
1514
|
const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best.el, idx: best.idx }, screen)
|
|
1357
1515
|
if (nearbyActionable) {
|
|
1358
1516
|
best = {
|
|
@@ -1462,6 +1620,10 @@ export class ToolsInteract {
|
|
|
1462
1620
|
let lastMatchedCount = 0
|
|
1463
1621
|
let lastMatchedElement: ActionTargetResolved | null = null
|
|
1464
1622
|
let lastConditionSatisfied = false
|
|
1623
|
+
let matchedAt: number | null = null
|
|
1624
|
+
let stableMatchCount = 0
|
|
1625
|
+
const stableObservationCount = 2
|
|
1626
|
+
const snapshotStaleThresholdMs = 500
|
|
1465
1627
|
|
|
1466
1628
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
1467
1629
|
const normalize = ToolsInteract._normalize
|
|
@@ -1567,25 +1729,35 @@ export class ToolsInteract {
|
|
|
1567
1729
|
lastMatchedCount = matchedCount
|
|
1568
1730
|
lastConditionSatisfied = conditionMet
|
|
1569
1731
|
lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
|
|
1732
|
+
const now = Date.now()
|
|
1570
1733
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
const latency_ms = now - overallStart
|
|
1574
|
-
const outEl = lastMatchedElement
|
|
1734
|
+
const snapshotAgeMs = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null
|
|
1735
|
+
const snapshotFresh = snapshotAgeMs === null || snapshotAgeMs <= snapshotStaleThresholdMs
|
|
1575
1736
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1737
|
+
if (conditionMet && snapshotFresh) {
|
|
1738
|
+
if (matchedAt === null) matchedAt = now
|
|
1739
|
+
stableMatchCount++
|
|
1740
|
+
if (stableMatchCount >= stableObservationCount) {
|
|
1741
|
+
const latency_ms = now - overallStart
|
|
1742
|
+
const outEl = lastMatchedElement
|
|
1743
|
+
|
|
1744
|
+
return {
|
|
1745
|
+
status: 'success',
|
|
1746
|
+
matched: matchedCount,
|
|
1747
|
+
element: outEl,
|
|
1748
|
+
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
1749
|
+
requested,
|
|
1750
|
+
observed: {
|
|
1751
|
+
matched_count: matchedCount,
|
|
1752
|
+
condition_satisfied: true,
|
|
1753
|
+
selected_index: outEl?.index ?? null,
|
|
1754
|
+
last_matched_element: outEl
|
|
1755
|
+
}
|
|
1587
1756
|
}
|
|
1588
1757
|
}
|
|
1758
|
+
} else {
|
|
1759
|
+
stableMatchCount = 0
|
|
1760
|
+
matchedAt = null
|
|
1589
1761
|
}
|
|
1590
1762
|
|
|
1591
1763
|
} catch (e) {
|
|
@@ -1950,189 +2122,256 @@ export class ToolsInteract {
|
|
|
1950
2122
|
property,
|
|
1951
2123
|
expected,
|
|
1952
2124
|
platform,
|
|
1953
|
-
deviceId
|
|
2125
|
+
deviceId,
|
|
2126
|
+
stabilization_window_ms = 1000,
|
|
2127
|
+
stable_observation_count = 2,
|
|
2128
|
+
snapshot_stale_threshold_ms = 500,
|
|
2129
|
+
poll_interval_ms = 150
|
|
1954
2130
|
}: {
|
|
1955
2131
|
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
|
|
1956
2132
|
element_id?: string,
|
|
1957
2133
|
property: string,
|
|
1958
2134
|
expected: boolean | number | string | Record<string, unknown>,
|
|
1959
2135
|
platform?: 'android' | 'ios',
|
|
1960
|
-
deviceId?: string
|
|
2136
|
+
deviceId?: string,
|
|
2137
|
+
stabilization_window_ms?: number,
|
|
2138
|
+
stable_observation_count?: number,
|
|
2139
|
+
snapshot_stale_threshold_ms?: number,
|
|
2140
|
+
poll_interval_ms?: number
|
|
1961
2141
|
}): Promise<ExpectStateResponse> {
|
|
1962
|
-
const
|
|
1963
|
-
const
|
|
1964
|
-
const
|
|
1965
|
-
const
|
|
2142
|
+
const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
|
|
2143
|
+
const compareString = (value: unknown) => typeof value === 'string' ? value : null
|
|
2144
|
+
const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
2145
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
2146
|
+
const start = Date.now()
|
|
2147
|
+
const deadline = start + Math.max(500, stabilization_window_ms)
|
|
2148
|
+
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
|
|
2149
|
+
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
2150
|
+
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
|
|
1966
2151
|
|
|
1967
|
-
let
|
|
2152
|
+
let attempts = 0
|
|
2153
|
+
let stableCount = 0
|
|
2154
|
+
let lastReason = 'element not found'
|
|
2155
|
+
let lastFailureCode: 'ELEMENT_NOT_FOUND' | 'UNKNOWN' = 'ELEMENT_NOT_FOUND'
|
|
2156
|
+
let lastObservedElement: (ActionTargetResolved & { state?: UIElementState | null }) | null = null
|
|
2157
|
+
let lastObservedValue: boolean | number | string | Record<string, unknown> | null = null
|
|
2158
|
+
let lastRawValue: boolean | number | string | null = null
|
|
2159
|
+
let lastResolvedElementId: string | null = element_id ?? null
|
|
1968
2160
|
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2161
|
+
while (Date.now() <= deadline) {
|
|
2162
|
+
attempts++
|
|
2163
|
+
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
2164
|
+
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
2165
|
+
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
|
|
2166
|
+
const treeDeviceId = tree?.device?.id || deviceId
|
|
2167
|
+
const treeAgeMs = typeof tree?.captured_at_ms === 'number' ? Date.now() - tree.captured_at_ms : null
|
|
1976
2168
|
|
|
1977
|
-
|
|
1978
|
-
matched = ToolsInteract._findFirstMatchingElement(elements, selector)
|
|
1979
|
-
}
|
|
2169
|
+
let matched: { el: UiElement, idx: number } | null = null
|
|
1980
2170
|
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
reason: 'element not found',
|
|
1988
|
-
failure_code: 'ELEMENT_NOT_FOUND',
|
|
1989
|
-
retryable: true
|
|
2171
|
+
if (element_id) {
|
|
2172
|
+
const resolved = ToolsInteract._resolvedUiElements.get(element_id)
|
|
2173
|
+
if (resolved) {
|
|
2174
|
+
const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
|
|
2175
|
+
if (current) matched = { el: current.el, idx: current.index }
|
|
2176
|
+
}
|
|
1990
2177
|
}
|
|
1991
|
-
}
|
|
1992
2178
|
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
matched.idx
|
|
1997
|
-
)
|
|
1998
|
-
const observedState = matched.el.state ?? null
|
|
1999
|
-
const actual = observedState?.[property as keyof UIElementState] ?? null
|
|
2179
|
+
if (!matched && selector) {
|
|
2180
|
+
matched = ToolsInteract._findFirstMatchingElement(elements, selector)
|
|
2181
|
+
}
|
|
2000
2182
|
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2183
|
+
if (!matched) {
|
|
2184
|
+
lastReason = 'element not found'
|
|
2185
|
+
lastFailureCode = 'ELEMENT_NOT_FOUND'
|
|
2186
|
+
stableCount = 0
|
|
2187
|
+
await sleep(pollDelay)
|
|
2188
|
+
continue
|
|
2189
|
+
}
|
|
2004
2190
|
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
} else if (actualBool === null) {
|
|
2020
|
-
reason = `${property} state unavailable`
|
|
2021
|
-
} else {
|
|
2022
|
-
rawValue = actualBool
|
|
2023
|
-
success = actualBool === expectedBool
|
|
2024
|
-
reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`
|
|
2025
|
-
}
|
|
2026
|
-
observedValue = actualBool
|
|
2027
|
-
break
|
|
2191
|
+
const resolvedElement = ToolsInteract._resolvedTargetFromElement(
|
|
2192
|
+
ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx),
|
|
2193
|
+
matched.el,
|
|
2194
|
+
matched.idx
|
|
2195
|
+
)
|
|
2196
|
+
lastResolvedElementId = resolvedElement.elementId
|
|
2197
|
+
lastObservedElement = { ...resolvedElement, state: matched.el.state ?? null }
|
|
2198
|
+
|
|
2199
|
+
if (treeAgeMs !== null && treeAgeMs > staleThreshold) {
|
|
2200
|
+
lastReason = 'stale snapshot'
|
|
2201
|
+
lastFailureCode = 'UNKNOWN'
|
|
2202
|
+
stableCount = 0
|
|
2203
|
+
await sleep(pollDelay)
|
|
2204
|
+
continue
|
|
2028
2205
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2206
|
+
|
|
2207
|
+
const observedState = matched.el.state ?? null
|
|
2208
|
+
const actual = observedState?.[property as keyof UIElementState] ?? null
|
|
2209
|
+
|
|
2210
|
+
let success = false
|
|
2211
|
+
let reason = ''
|
|
2212
|
+
let rawValue: boolean | number | string | null = null
|
|
2213
|
+
let observedValue: boolean | number | string | Record<string, unknown> | null = actual as any
|
|
2214
|
+
|
|
2215
|
+
switch (property) {
|
|
2216
|
+
case 'checked':
|
|
2217
|
+
case 'focused':
|
|
2218
|
+
case 'expanded':
|
|
2219
|
+
case 'enabled': {
|
|
2220
|
+
const expectedBool = compareBoolean(expected)
|
|
2221
|
+
const actualBool = compareBoolean(actual)
|
|
2222
|
+
if (expectedBool === null) {
|
|
2223
|
+
reason = `expected ${property} must be boolean`
|
|
2224
|
+
} else if (actualBool === null) {
|
|
2225
|
+
reason = `${property} state unavailable`
|
|
2226
|
+
} else {
|
|
2227
|
+
rawValue = actualBool
|
|
2228
|
+
success = actualBool === expectedBool
|
|
2229
|
+
reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`
|
|
2230
|
+
}
|
|
2231
|
+
observedValue = actualBool
|
|
2038
2232
|
break
|
|
2039
2233
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2234
|
+
case 'value':
|
|
2235
|
+
case 'raw_value': {
|
|
2236
|
+
const expectedNumber = compareNumber(expected)
|
|
2237
|
+
const actualNumber = compareNumber(actual)
|
|
2238
|
+
if (expectedNumber !== null && actualNumber !== null) {
|
|
2239
|
+
success = actualNumber === expectedNumber
|
|
2240
|
+
rawValue = actualNumber
|
|
2241
|
+
observedValue = actualNumber
|
|
2242
|
+
reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`
|
|
2243
|
+
break
|
|
2244
|
+
}
|
|
2245
|
+
const expectedString = typeof expected === 'string' ? expected : null
|
|
2246
|
+
const actualString = compareString(actual)
|
|
2247
|
+
if (expectedString !== null && actualString !== null) {
|
|
2248
|
+
success = actualString === expectedString
|
|
2249
|
+
rawValue = actualString
|
|
2250
|
+
observedValue = actualString
|
|
2251
|
+
reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`
|
|
2252
|
+
} else {
|
|
2253
|
+
reason = 'value state unavailable'
|
|
2254
|
+
}
|
|
2061
2255
|
break
|
|
2062
2256
|
}
|
|
2063
|
-
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
2257
|
+
case 'selected': {
|
|
2258
|
+
const expectedBool = typeof expected === 'boolean' ? expected : null
|
|
2259
|
+
const expectedString = typeof expected === 'string'
|
|
2260
|
+
? expected
|
|
2261
|
+
: expected && typeof expected === 'object'
|
|
2262
|
+
? String((expected as { id?: unknown; label?: unknown }).id ?? (expected as { id?: unknown; label?: unknown }).label ?? '')
|
|
2263
|
+
: null
|
|
2264
|
+
if (!observedState || observedState.selected === undefined || observedState.selected === null) {
|
|
2265
|
+
reason = 'selected state unavailable'
|
|
2067
2266
|
break
|
|
2068
2267
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2268
|
+
if (expectedBool !== null) {
|
|
2269
|
+
const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null
|
|
2270
|
+
if (actualBool === null) {
|
|
2271
|
+
reason = 'selected state is not boolean'
|
|
2272
|
+
break
|
|
2273
|
+
}
|
|
2274
|
+
rawValue = actualBool
|
|
2275
|
+
observedValue = actualBool
|
|
2276
|
+
success = actualBool === expectedBool
|
|
2277
|
+
reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`
|
|
2278
|
+
break
|
|
2279
|
+
}
|
|
2280
|
+
const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
|
|
2281
|
+
? String((observedState.selected as { id?: unknown; label?: unknown }).id ?? (observedState.selected as { id?: unknown; label?: unknown }).label ?? '')
|
|
2282
|
+
: String(observedState.selected)
|
|
2283
|
+
const actualString = actualSelected.trim()
|
|
2284
|
+
if (!expectedString) {
|
|
2285
|
+
reason = 'expected selected must be boolean, string, or object with id/label'
|
|
2286
|
+
break
|
|
2287
|
+
}
|
|
2288
|
+
rawValue = actualString
|
|
2289
|
+
observedValue = actualString
|
|
2290
|
+
success = actualString === expectedString
|
|
2291
|
+
reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`
|
|
2073
2292
|
break
|
|
2074
2293
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2294
|
+
case 'text_value': {
|
|
2295
|
+
const expectedString = typeof expected === 'string' ? expected : null
|
|
2296
|
+
const actualString = compareString(actual)
|
|
2297
|
+
if (!expectedString) {
|
|
2298
|
+
reason = 'expected text_value must be string'
|
|
2299
|
+
} else if (!actualString) {
|
|
2300
|
+
reason = 'text_value state unavailable'
|
|
2301
|
+
} else {
|
|
2302
|
+
success = actualString === expectedString
|
|
2303
|
+
rawValue = actualString
|
|
2304
|
+
observedValue = actualString
|
|
2305
|
+
reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`
|
|
2306
|
+
}
|
|
2081
2307
|
break
|
|
2082
2308
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
if (!expectedString) {
|
|
2093
|
-
reason = 'expected text_value must be string'
|
|
2094
|
-
} else if (!actualString) {
|
|
2095
|
-
reason = 'text_value state unavailable'
|
|
2096
|
-
} else {
|
|
2097
|
-
success = actualString === expectedString
|
|
2098
|
-
rawValue = actualString
|
|
2099
|
-
observedValue = actualString
|
|
2100
|
-
reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`
|
|
2309
|
+
default: {
|
|
2310
|
+
if (actual !== null && actual !== undefined) {
|
|
2311
|
+
success = actual === expected
|
|
2312
|
+
observedValue = actual as any
|
|
2313
|
+
rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null
|
|
2314
|
+
reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`
|
|
2315
|
+
} else {
|
|
2316
|
+
reason = `unsupported or unavailable state property: ${property}`
|
|
2317
|
+
}
|
|
2101
2318
|
}
|
|
2102
|
-
break
|
|
2103
2319
|
}
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2320
|
+
|
|
2321
|
+
if (success) {
|
|
2322
|
+
stableCount++
|
|
2323
|
+
if (stableCount >= stableTarget) {
|
|
2324
|
+
return {
|
|
2325
|
+
success: true,
|
|
2326
|
+
selector,
|
|
2327
|
+
element_id: lastResolvedElementId,
|
|
2328
|
+
expected_state: { property, expected },
|
|
2329
|
+
element: lastObservedElement,
|
|
2330
|
+
observed_state: {
|
|
2331
|
+
property,
|
|
2332
|
+
value: observedValue,
|
|
2333
|
+
...(rawValue !== null ? { raw_value: rawValue } : {})
|
|
2334
|
+
},
|
|
2335
|
+
reason,
|
|
2336
|
+
stabilization_attempts: attempts,
|
|
2337
|
+
stabilization_window_ms: Date.now() - start,
|
|
2338
|
+
stable_observation_count: stableCount,
|
|
2339
|
+
snapshot_freshness_ms: treeAgeMs ?? undefined
|
|
2340
|
+
} as ExpectStateResponse & {
|
|
2341
|
+
stabilization_attempts?: number;
|
|
2342
|
+
stabilization_window_ms?: number;
|
|
2343
|
+
stable_observation_count?: number;
|
|
2344
|
+
snapshot_freshness_ms?: number;
|
|
2345
|
+
}
|
|
2112
2346
|
}
|
|
2347
|
+
} else {
|
|
2348
|
+
stableCount = 0
|
|
2349
|
+
lastReason = reason || lastReason
|
|
2350
|
+
lastFailureCode = 'UNKNOWN'
|
|
2113
2351
|
}
|
|
2114
|
-
}
|
|
2115
2352
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2353
|
+
if (!success) {
|
|
2354
|
+
lastObservedValue = observedValue
|
|
2355
|
+
lastRawValue = rawValue
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
await sleep(pollDelay)
|
|
2118
2359
|
}
|
|
2119
2360
|
|
|
2120
2361
|
return {
|
|
2121
|
-
success,
|
|
2362
|
+
success: false,
|
|
2122
2363
|
selector,
|
|
2123
|
-
element_id:
|
|
2364
|
+
element_id: lastResolvedElementId,
|
|
2124
2365
|
expected_state: { property, expected },
|
|
2125
|
-
element:
|
|
2126
|
-
...resolvedElement,
|
|
2127
|
-
state: observedState
|
|
2128
|
-
},
|
|
2366
|
+
element: lastObservedElement,
|
|
2129
2367
|
observed_state: {
|
|
2130
2368
|
property,
|
|
2131
|
-
value:
|
|
2132
|
-
...(
|
|
2369
|
+
value: lastObservedValue,
|
|
2370
|
+
...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
|
|
2133
2371
|
},
|
|
2134
|
-
reason,
|
|
2135
|
-
|
|
2372
|
+
reason: lastReason,
|
|
2373
|
+
failure_code: lastFailureCode,
|
|
2374
|
+
retryable: true
|
|
2136
2375
|
}
|
|
2137
2376
|
}
|
|
2138
2377
|
|