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.
@@ -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?: { is_clickable: boolean, is_container: boolean } | null
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
- lastAdjustmentMode = 'coordinate'
1044
- const primaryActionResult = await ToolsInteract.tapHandler({
1045
- platform: resolvedPlatform,
1046
- x: targetPoint.x,
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
- if (!primaryActionResult.success) {
1054
- lastAdjustmentMode = 'gesture'
1055
- const fallbackActionResult = await ToolsInteract.swipeHandler({
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
- x1: currentPoint.x,
1058
- y1: currentPoint.y,
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 (!fallbackActionResult.success) {
1067
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false)
1121
+ if (!actionResult.success) {
1122
+ continue
1068
1123
  }
1069
1124
 
1070
- actionDevice = fallbackActionResult.device ?? actionDevice
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
- let verificationResult = await runVerification()
1074
- let observedState = verificationResult.observedState
1075
- lastObservedState = observedState
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 (!verificationResult.withinTolerance && currentValue !== null) {
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
- const verification = verificationResult.verification
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
- if (conditionMet) {
1572
- const now = Date.now()
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
- return {
1577
- status: 'success',
1578
- matched: matchedCount,
1579
- element: outEl,
1580
- metrics: { latency_ms, poll_count: totalPollCount, attempts },
1581
- requested,
1582
- observed: {
1583
- matched_count: matchedCount,
1584
- condition_satisfied: true,
1585
- selected_index: outEl?.index ?? null,
1586
- last_matched_element: outEl
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 tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
1963
- const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
1964
- const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
1965
- const treeDeviceId = tree?.device?.id || deviceId
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 matched: { el: UiElement, idx: number } | null = null
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
- if (element_id) {
1970
- const resolved = ToolsInteract._resolvedUiElements.get(element_id)
1971
- if (resolved) {
1972
- const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
1973
- if (current) matched = { el: current.el, idx: current.index }
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
- if (!matched && selector) {
1978
- matched = ToolsInteract._findFirstMatchingElement(elements, selector)
1979
- }
2169
+ let matched: { el: UiElement, idx: number } | null = null
1980
2170
 
1981
- if (!matched) {
1982
- return {
1983
- success: false,
1984
- selector,
1985
- element_id: element_id ?? null,
1986
- expected_state: { property, expected },
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
- const resolvedElement = ToolsInteract._resolvedTargetFromElement(
1994
- ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx),
1995
- matched.el,
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
- const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
2002
- const compareString = (value: unknown) => typeof value === 'string' ? value : null
2003
- const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
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
- let success = false
2006
- let reason = ''
2007
- let rawValue: boolean | number | string | null = null
2008
- let observedValue: boolean | number | string | Record<string, unknown> | null = actual as any
2009
-
2010
- switch (property) {
2011
- case 'checked':
2012
- case 'focused':
2013
- case 'expanded':
2014
- case 'enabled': {
2015
- const expectedBool = compareBoolean(expected)
2016
- const actualBool = compareBoolean(actual)
2017
- if (expectedBool === null) {
2018
- reason = `expected ${property} must be boolean`
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
- case 'value':
2030
- case 'raw_value': {
2031
- const expectedNumber = compareNumber(expected)
2032
- const actualNumber = compareNumber(actual)
2033
- if (expectedNumber !== null && actualNumber !== null) {
2034
- success = actualNumber === expectedNumber
2035
- rawValue = actualNumber
2036
- observedValue = actualNumber
2037
- reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`
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
- const expectedString = typeof expected === 'string' ? expected : null
2041
- const actualString = compareString(actual)
2042
- if (expectedString !== null && actualString !== null) {
2043
- success = actualString === expectedString
2044
- rawValue = actualString
2045
- observedValue = actualString
2046
- reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`
2047
- } else {
2048
- reason = 'value state unavailable'
2049
- }
2050
- break
2051
- }
2052
- case 'selected': {
2053
- const expectedBool = typeof expected === 'boolean' ? expected : null
2054
- const expectedString = typeof expected === 'string'
2055
- ? expected
2056
- : expected && typeof expected === 'object'
2057
- ? String((expected as { id?: unknown; label?: unknown }).id ?? (expected as { id?: unknown; label?: unknown }).label ?? '')
2058
- : null
2059
- if (!observedState || observedState.selected === undefined || observedState.selected === null) {
2060
- reason = 'selected state unavailable'
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
- if (expectedBool !== null) {
2064
- const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null
2065
- if (actualBool === null) {
2066
- reason = 'selected state is not boolean'
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
- rawValue = actualBool
2070
- observedValue = actualBool
2071
- success = actualBool === expectedBool
2072
- reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`
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
- const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
2076
- ? String((observedState.selected as { id?: unknown; label?: unknown }).id ?? (observedState.selected as { id?: unknown; label?: unknown }).label ?? '')
2077
- : String(observedState.selected)
2078
- const actualString = actualSelected.trim()
2079
- if (!expectedString) {
2080
- reason = 'expected selected must be boolean, string, or object with id/label'
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
- rawValue = actualString
2084
- observedValue = actualString
2085
- success = actualString === expectedString
2086
- reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`
2087
- break
2088
- }
2089
- case 'text_value': {
2090
- const expectedString = typeof expected === 'string' ? expected : null
2091
- const actualString = compareString(actual)
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
- default: {
2105
- if (actual !== null && actual !== undefined) {
2106
- success = actual === expected
2107
- observedValue = actual as any
2108
- rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null
2109
- reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`
2110
- } else {
2111
- reason = `unsupported or unavailable state property: ${property}`
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
- if (!success && !reason) {
2117
- reason = `${property} did not match expected value`
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: element_id ?? resolvedElement.elementId,
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: observedValue,
2132
- ...(rawValue !== null ? { raw_value: rawValue } : {})
2369
+ value: lastObservedValue,
2370
+ ...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
2133
2371
  },
2134
- reason,
2135
- ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
2372
+ reason: lastReason,
2373
+ failure_code: lastFailureCode,
2374
+ retryable: true
2136
2375
  }
2137
2376
  }
2138
2377