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.
@@ -226,6 +226,13 @@ export class ToolsInteract {
226
226
  const role = ToolsInteract._normalize(el.role ?? '');
227
227
  return !!el.state?.value_range || /slider|seekbar|stepper|adjustable|range/.test(type) || /slider|seekbar|stepper|adjustable|range/.test(role);
228
228
  }
229
+ static _isSemanticActionable(el) {
230
+ if (!el?.semantic)
231
+ return false;
232
+ if (el.semantic.adjustable)
233
+ return true;
234
+ return Array.isArray(el.semantic.supported_actions) && el.semantic.supported_actions.length > 0;
235
+ }
229
236
  static _readNumericControlValue(el, property) {
230
237
  if (!el?.state)
231
238
  return null;
@@ -284,6 +291,38 @@ export class ToolsInteract {
284
291
  const safeRatio = Math.min(1 - (endpointMargin * 0.25), Math.max(endpointMargin, targetRatio + centerBias + directionBias + edgeBias));
285
292
  return ToolsInteract._buildControlPoint(bounds, safeRatio, axis);
286
293
  }
294
+ static _buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis) {
295
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis);
296
+ const currentPoint = currentValue !== null
297
+ ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
298
+ : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
299
+ const [left, top, right, bottom] = bounds;
300
+ const width = Math.max(1, right - left);
301
+ const height = Math.max(1, bottom - top);
302
+ const crossAxisBumps = axis === 'horizontal'
303
+ ? [Math.max(24, Math.floor(height * 0.75)), Math.max(40, Math.floor(height * 1.5))]
304
+ : [Math.max(24, Math.floor(width * 0.75)), Math.max(40, Math.floor(width * 1.5))];
305
+ const clampPoint = (point) => ({
306
+ x: axis === 'horizontal'
307
+ ? Math.max(left, Math.min(right, point.x))
308
+ : Math.max(left, Math.min(right + Math.max(width, height), point.x)),
309
+ y: axis === 'vertical'
310
+ ? Math.max(top, Math.min(bottom, point.y))
311
+ : Math.max(top, Math.min(bottom + Math.max(height, width), point.y))
312
+ });
313
+ const probes = [targetPoint, currentPoint];
314
+ for (const bump of crossAxisBumps) {
315
+ if (axis === 'horizontal') {
316
+ probes.push({ x: targetPoint.x, y: bottom + bump }, { x: currentPoint.x, y: bottom + bump });
317
+ }
318
+ else {
319
+ probes.push({ x: right + bump, y: targetPoint.y }, { x: right + bump, y: currentPoint.y });
320
+ }
321
+ }
322
+ return Array.from(new Map(probes
323
+ .map(clampPoint)
324
+ .map((point) => [`${point.x}:${point.y}`, point])).values());
325
+ }
287
326
  static _controlAxis(el, bounds) {
288
327
  const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
289
328
  const role = ToolsInteract._normalize(el.role ?? '');
@@ -318,11 +357,11 @@ export class ToolsInteract {
318
357
  static _resolveActionableAncestor(elements, chosen) {
319
358
  if (!chosen)
320
359
  return null;
321
- if (chosen.el.clickable || chosen.el.focusable)
360
+ if (chosen.el.clickable || chosen.el.focusable || ToolsInteract._isSemanticActionable(chosen.el))
322
361
  return chosen;
323
362
  let current = chosen;
324
363
  let safety = 0;
325
- while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
364
+ while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) && current.el.parentId !== undefined && current.el.parentId !== null) {
326
365
  const parentId = current.el.parentId;
327
366
  let parentIndex = null;
328
367
  if (typeof parentId === 'number')
@@ -331,7 +370,7 @@ export class ToolsInteract {
331
370
  parentIndex = Number(parentId);
332
371
  if (parentIndex !== null && elements[parentIndex]) {
333
372
  current = { el: elements[parentIndex], idx: parentIndex };
334
- if (current.el.clickable || current.el.focusable)
373
+ if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
335
374
  return current;
336
375
  }
337
376
  else if (typeof parentId === 'string') {
@@ -339,7 +378,7 @@ export class ToolsInteract {
339
378
  if (foundIndex === -1)
340
379
  break;
341
380
  current = { el: elements[foundIndex], idx: foundIndex };
342
- if (current.el.clickable || current.el.focusable)
381
+ if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
343
382
  return current;
344
383
  }
345
384
  else {
@@ -355,7 +394,7 @@ export class ToolsInteract {
355
394
  let bestArea = Infinity;
356
395
  for (let i = 0; i < elements.length; i++) {
357
396
  const el = elements[i];
358
- if (!el || !(el.clickable || el.focusable))
397
+ if (!el || !(el.clickable || el.focusable || ToolsInteract._isSemanticActionable(el)))
359
398
  continue;
360
399
  const bounds = ToolsInteract._normalizeBounds(el.bounds);
361
400
  if (!bounds)
@@ -736,6 +775,7 @@ export class ToolsInteract {
736
775
  const currentPoint = currentValue !== null
737
776
  ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
738
777
  : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
778
+ const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis);
739
779
  const runVerification = async () => {
740
780
  const verification = await ToolsInteract.expectStateHandler({
741
781
  element_id: resolvedTarget?.elementId ?? element_id,
@@ -763,36 +803,66 @@ export class ToolsInteract {
763
803
  withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
764
804
  };
765
805
  };
766
- lastAdjustmentMode = 'coordinate';
767
- const primaryActionResult = await ToolsInteract.tapHandler({
768
- platform: resolvedPlatform,
769
- x: targetPoint.x,
770
- y: targetPoint.y,
771
- deviceId: resolvedDeviceId
772
- });
773
- let actionDevice = primaryActionResult.device ?? currentDevice;
774
- attemptCount++;
775
- if (!primaryActionResult.success) {
776
- lastAdjustmentMode = 'gesture';
777
- const fallbackActionResult = await ToolsInteract.swipeHandler({
806
+ let actionDevice = currentDevice;
807
+ let observedState = actualState;
808
+ let verification = null;
809
+ let verificationResult = { verification: null, observedState: actualState, withinTolerance: false };
810
+ for (let i = 0; i < probePoints.length; i++) {
811
+ const probePoint = probePoints[i];
812
+ lastAdjustmentMode = 'coordinate';
813
+ const actionResult = await ToolsInteract.tapHandler({
778
814
  platform: resolvedPlatform,
779
- x1: currentPoint.x,
780
- y1: currentPoint.y,
781
- x2: targetPoint.x,
782
- y2: targetPoint.y,
783
- duration: 220,
815
+ x: probePoint.x,
816
+ y: probePoint.y,
784
817
  deviceId: resolvedDeviceId
785
818
  });
786
819
  attemptCount++;
787
- if (!fallbackActionResult.success) {
788
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false);
820
+ actionDevice = actionResult.device ?? actionDevice;
821
+ if (!actionResult.success) {
822
+ continue;
823
+ }
824
+ verificationResult = await runVerification();
825
+ observedState = verificationResult.observedState;
826
+ lastObservedState = observedState;
827
+ if (verificationResult.withinTolerance) {
828
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
829
+ const base = buildActionExecutionResult({
830
+ actionType,
831
+ sourceModule: 'interact',
832
+ device: actionDevice ?? currentDevice,
833
+ selector: targetSelector,
834
+ resolved: resolvedTarget,
835
+ success: true,
836
+ uiFingerprintBefore: fingerprintBefore,
837
+ uiFingerprintAfter,
838
+ details: {
839
+ target_value: targetValue,
840
+ tolerance: normalizedTolerance,
841
+ property,
842
+ attempts: attemptCount,
843
+ adjustment_mode: lastAdjustmentMode,
844
+ actual_state: observedState,
845
+ converged: true,
846
+ within_tolerance: true,
847
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
848
+ }
849
+ });
850
+ return {
851
+ ...base,
852
+ target_state: {
853
+ property,
854
+ target_value: targetValue,
855
+ tolerance: normalizedTolerance
856
+ },
857
+ actual_state: observedState,
858
+ within_tolerance: true,
859
+ converged: true,
860
+ attempts: attemptCount,
861
+ adjustment_mode: lastAdjustmentMode
862
+ };
789
863
  }
790
- actionDevice = fallbackActionResult.device ?? actionDevice;
791
864
  }
792
- let verificationResult = await runVerification();
793
- let observedState = verificationResult.observedState;
794
- lastObservedState = observedState;
795
- if (!verificationResult.withinTolerance && currentValue !== null) {
865
+ if (currentValue !== null) {
796
866
  lastAdjustmentMode = 'gesture';
797
867
  const fallbackActionResult = await ToolsInteract.swipeHandler({
798
868
  platform: resolvedPlatform,
@@ -805,12 +875,51 @@ export class ToolsInteract {
805
875
  });
806
876
  attemptCount++;
807
877
  if (!fallbackActionResult.success) {
808
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
878
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? actionDevice, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
809
879
  }
880
+ actionDevice = fallbackActionResult.device ?? actionDevice;
810
881
  verificationResult = await runVerification();
811
882
  observedState = verificationResult.observedState;
883
+ lastObservedState = observedState;
884
+ if (verificationResult.withinTolerance) {
885
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
886
+ const base = buildActionExecutionResult({
887
+ actionType,
888
+ sourceModule: 'interact',
889
+ device: actionDevice ?? currentDevice,
890
+ selector: targetSelector,
891
+ resolved: resolvedTarget,
892
+ success: true,
893
+ uiFingerprintBefore: fingerprintBefore,
894
+ uiFingerprintAfter,
895
+ details: {
896
+ target_value: targetValue,
897
+ tolerance: normalizedTolerance,
898
+ property,
899
+ attempts: attemptCount,
900
+ adjustment_mode: lastAdjustmentMode,
901
+ actual_state: observedState,
902
+ converged: true,
903
+ within_tolerance: true,
904
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
905
+ }
906
+ });
907
+ return {
908
+ ...base,
909
+ target_state: {
910
+ property,
911
+ target_value: targetValue,
912
+ tolerance: normalizedTolerance
913
+ },
914
+ actual_state: observedState,
915
+ within_tolerance: true,
916
+ converged: true,
917
+ attempts: attemptCount,
918
+ adjustment_mode: lastAdjustmentMode
919
+ };
920
+ }
812
921
  }
813
- const verification = verificationResult.verification;
922
+ verification = verificationResult.verification;
814
923
  lastObservedState = observedState;
815
924
  if (verificationResult.withinTolerance) {
816
925
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
@@ -904,11 +1013,13 @@ export class ToolsInteract {
904
1013
  if (r <= l || b <= t)
905
1014
  return null;
906
1015
  // Do not early-return on non-interactable elements — score them so we can locate their clickable ancestor later
907
- const interactable = !!(el.clickable || el.enabled || el.focusable);
1016
+ const interactable = !!(el.clickable || el.enabled || el.focusable || ToolsInteract._isSemanticActionable(el));
908
1017
  const text = normalize(el.text ?? el.label ?? el.value ?? '');
909
1018
  const content = normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? '');
910
1019
  const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '');
911
1020
  const className = normalize(el.type ?? el.class ?? '');
1021
+ const semanticRole = normalize(el.semantic?.semantic_role ?? '');
1022
+ const semanticActions = Array.isArray(el.semantic?.supported_actions) ? el.semantic.supported_actions.map((action) => normalize(action)).filter(Boolean) : [];
912
1023
  let score = 0;
913
1024
  let reason = 'best_scoring_candidate';
914
1025
  if (exact) {
@@ -959,6 +1070,30 @@ export class ToolsInteract {
959
1070
  reason = 'partial_class_match';
960
1071
  }
961
1072
  }
1073
+ if (!exact) {
1074
+ if (!score && semanticRole && semanticRole.includes(q)) {
1075
+ score = 0.5;
1076
+ reason = 'semantic_role_match';
1077
+ }
1078
+ if (semanticActions.some((action) => action.includes(q))) {
1079
+ score = Math.max(score, score > 0 ? 0.65 : 0.6);
1080
+ reason = 'semantic_action_match';
1081
+ }
1082
+ if (score === 0 && el.semantic?.adjustable && /slider|stepper|dropdown|segment|control|adjust/.test(q)) {
1083
+ score = 0.45;
1084
+ reason = 'semantic_control_match';
1085
+ }
1086
+ }
1087
+ else {
1088
+ if (!score && semanticRole && semanticRole === q) {
1089
+ score = 0.5;
1090
+ reason = 'semantic_role_match';
1091
+ }
1092
+ if (semanticActions.some((action) => action === q)) {
1093
+ score = Math.max(score, score > 0 ? 0.65 : 0.6);
1094
+ reason = 'semantic_action_match';
1095
+ }
1096
+ }
962
1097
  if (score > 0 && interactable)
963
1098
  score += 0.05;
964
1099
  if (score <= 0)
@@ -1087,7 +1222,7 @@ export class ToolsInteract {
1087
1222
  interactable: true
1088
1223
  };
1089
1224
  }
1090
- if (best && !(best.el.clickable || best.el.focusable)) {
1225
+ if (best && !(best.el.clickable || best.el.focusable || ToolsInteract._isSemanticActionable(best.el))) {
1091
1226
  const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best.el, idx: best.idx }, screen);
1092
1227
  if (nearbyActionable) {
1093
1228
  best = {
@@ -1191,6 +1326,10 @@ export class ToolsInteract {
1191
1326
  let lastMatchedCount = 0;
1192
1327
  let lastMatchedElement = null;
1193
1328
  let lastConditionSatisfied = false;
1329
+ let matchedAt = null;
1330
+ let stableMatchCount = 0;
1331
+ const stableObservationCount = 2;
1332
+ const snapshotStaleThresholdMs = 500;
1194
1333
  // Precompute normalized selector values and helpers (constant across polls)
1195
1334
  const normalize = ToolsInteract._normalize;
1196
1335
  const containsFlag = !!selector?.contains;
@@ -1305,23 +1444,34 @@ export class ToolsInteract {
1305
1444
  lastMatchedCount = matchedCount;
1306
1445
  lastConditionSatisfied = conditionMet;
1307
1446
  lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null;
1308
- if (conditionMet) {
1309
- const now = Date.now();
1310
- const latency_ms = now - overallStart;
1311
- const outEl = lastMatchedElement;
1312
- return {
1313
- status: 'success',
1314
- matched: matchedCount,
1315
- element: outEl,
1316
- metrics: { latency_ms, poll_count: totalPollCount, attempts },
1317
- requested,
1318
- observed: {
1319
- matched_count: matchedCount,
1320
- condition_satisfied: true,
1321
- selected_index: outEl?.index ?? null,
1322
- last_matched_element: outEl
1323
- }
1324
- };
1447
+ const now = Date.now();
1448
+ const snapshotAgeMs = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null;
1449
+ const snapshotFresh = snapshotAgeMs === null || snapshotAgeMs <= snapshotStaleThresholdMs;
1450
+ if (conditionMet && snapshotFresh) {
1451
+ if (matchedAt === null)
1452
+ matchedAt = now;
1453
+ stableMatchCount++;
1454
+ if (stableMatchCount >= stableObservationCount) {
1455
+ const latency_ms = now - overallStart;
1456
+ const outEl = lastMatchedElement;
1457
+ return {
1458
+ status: 'success',
1459
+ matched: matchedCount,
1460
+ element: outEl,
1461
+ metrics: { latency_ms, poll_count: totalPollCount, attempts },
1462
+ requested,
1463
+ observed: {
1464
+ matched_count: matchedCount,
1465
+ condition_satisfied: true,
1466
+ selected_index: outEl?.index ?? null,
1467
+ last_matched_element: outEl
1468
+ }
1469
+ };
1470
+ }
1471
+ }
1472
+ else {
1473
+ stableMatchCount = 0;
1474
+ matchedAt = null;
1325
1475
  }
1326
1476
  }
1327
1477
  catch (e) {
@@ -1641,174 +1791,224 @@ export class ToolsInteract {
1641
1791
  retryable: errorCode === 'TIMEOUT'
1642
1792
  };
1643
1793
  }
1644
- static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId }) {
1645
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
1646
- const elements = Array.isArray(tree?.elements) ? tree.elements : [];
1647
- const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
1648
- const treeDeviceId = tree?.device?.id || deviceId;
1649
- let matched = null;
1650
- if (element_id) {
1651
- const resolved = ToolsInteract._resolvedUiElements.get(element_id);
1652
- if (resolved) {
1653
- const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
1654
- if (current)
1655
- matched = { el: current.el, idx: current.index };
1656
- }
1657
- }
1658
- if (!matched && selector) {
1659
- matched = ToolsInteract._findFirstMatchingElement(elements, selector);
1660
- }
1661
- if (!matched) {
1662
- return {
1663
- success: false,
1664
- selector,
1665
- element_id: element_id ?? null,
1666
- expected_state: { property, expected },
1667
- reason: 'element not found',
1668
- failure_code: 'ELEMENT_NOT_FOUND',
1669
- retryable: true
1670
- };
1671
- }
1672
- const resolvedElement = ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx), matched.el, matched.idx);
1673
- const observedState = matched.el.state ?? null;
1674
- const actual = observedState?.[property] ?? null;
1794
+ static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId, stabilization_window_ms = 1000, stable_observation_count = 2, snapshot_stale_threshold_ms = 500, poll_interval_ms = 150 }) {
1675
1795
  const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
1676
1796
  const compareString = (value) => typeof value === 'string' ? value : null;
1677
1797
  const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
1678
- let success = false;
1679
- let reason = '';
1680
- let rawValue = null;
1681
- let observedValue = actual;
1682
- switch (property) {
1683
- case 'checked':
1684
- case 'focused':
1685
- case 'expanded':
1686
- case 'enabled': {
1687
- const expectedBool = compareBoolean(expected);
1688
- const actualBool = compareBoolean(actual);
1689
- if (expectedBool === null) {
1690
- reason = `expected ${property} must be boolean`;
1691
- }
1692
- else if (actualBool === null) {
1693
- reason = `${property} state unavailable`;
1694
- }
1695
- else {
1696
- rawValue = actualBool;
1697
- success = actualBool === expectedBool;
1698
- reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`;
1798
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
1799
+ const start = Date.now();
1800
+ const deadline = start + Math.max(500, stabilization_window_ms);
1801
+ const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2));
1802
+ const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200));
1803
+ const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800));
1804
+ let attempts = 0;
1805
+ let stableCount = 0;
1806
+ let lastReason = 'element not found';
1807
+ let lastFailureCode = 'ELEMENT_NOT_FOUND';
1808
+ let lastObservedElement = null;
1809
+ let lastObservedValue = null;
1810
+ let lastRawValue = null;
1811
+ let lastResolvedElementId = element_id ?? null;
1812
+ while (Date.now() <= deadline) {
1813
+ attempts++;
1814
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
1815
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
1816
+ const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
1817
+ const treeDeviceId = tree?.device?.id || deviceId;
1818
+ const treeAgeMs = typeof tree?.captured_at_ms === 'number' ? Date.now() - tree.captured_at_ms : null;
1819
+ let matched = null;
1820
+ if (element_id) {
1821
+ const resolved = ToolsInteract._resolvedUiElements.get(element_id);
1822
+ if (resolved) {
1823
+ const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
1824
+ if (current)
1825
+ matched = { el: current.el, idx: current.index };
1699
1826
  }
1700
- observedValue = actualBool;
1701
- break;
1702
1827
  }
1703
- case 'value':
1704
- case 'raw_value': {
1705
- const expectedNumber = compareNumber(expected);
1706
- const actualNumber = compareNumber(actual);
1707
- if (expectedNumber !== null && actualNumber !== null) {
1708
- success = actualNumber === expectedNumber;
1709
- rawValue = actualNumber;
1710
- observedValue = actualNumber;
1711
- reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`;
1712
- break;
1713
- }
1714
- const expectedString = typeof expected === 'string' ? expected : null;
1715
- const actualString = compareString(actual);
1716
- if (expectedString !== null && actualString !== null) {
1717
- success = actualString === expectedString;
1718
- rawValue = actualString;
1719
- observedValue = actualString;
1720
- reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`;
1721
- }
1722
- else {
1723
- reason = 'value state unavailable';
1724
- }
1725
- break;
1828
+ if (!matched && selector) {
1829
+ matched = ToolsInteract._findFirstMatchingElement(elements, selector);
1726
1830
  }
1727
- case 'selected': {
1728
- const expectedBool = typeof expected === 'boolean' ? expected : null;
1729
- const expectedString = typeof expected === 'string'
1730
- ? expected
1731
- : expected && typeof expected === 'object'
1732
- ? String(expected.id ?? expected.label ?? '')
1733
- : null;
1734
- if (!observedState || observedState.selected === undefined || observedState.selected === null) {
1735
- reason = 'selected state unavailable';
1831
+ if (!matched) {
1832
+ lastReason = 'element not found';
1833
+ lastFailureCode = 'ELEMENT_NOT_FOUND';
1834
+ stableCount = 0;
1835
+ await sleep(pollDelay);
1836
+ continue;
1837
+ }
1838
+ const resolvedElement = ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx), matched.el, matched.idx);
1839
+ lastResolvedElementId = resolvedElement.elementId;
1840
+ lastObservedElement = { ...resolvedElement, state: matched.el.state ?? null };
1841
+ if (treeAgeMs !== null && treeAgeMs > staleThreshold) {
1842
+ lastReason = 'stale snapshot';
1843
+ lastFailureCode = 'UNKNOWN';
1844
+ stableCount = 0;
1845
+ await sleep(pollDelay);
1846
+ continue;
1847
+ }
1848
+ const observedState = matched.el.state ?? null;
1849
+ const actual = observedState?.[property] ?? null;
1850
+ let success = false;
1851
+ let reason = '';
1852
+ let rawValue = null;
1853
+ let observedValue = actual;
1854
+ switch (property) {
1855
+ case 'checked':
1856
+ case 'focused':
1857
+ case 'expanded':
1858
+ case 'enabled': {
1859
+ const expectedBool = compareBoolean(expected);
1860
+ const actualBool = compareBoolean(actual);
1861
+ if (expectedBool === null) {
1862
+ reason = `expected ${property} must be boolean`;
1863
+ }
1864
+ else if (actualBool === null) {
1865
+ reason = `${property} state unavailable`;
1866
+ }
1867
+ else {
1868
+ rawValue = actualBool;
1869
+ success = actualBool === expectedBool;
1870
+ reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`;
1871
+ }
1872
+ observedValue = actualBool;
1736
1873
  break;
1737
1874
  }
1738
- if (expectedBool !== null) {
1739
- const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null;
1740
- if (actualBool === null) {
1741
- reason = 'selected state is not boolean';
1875
+ case 'value':
1876
+ case 'raw_value': {
1877
+ const expectedNumber = compareNumber(expected);
1878
+ const actualNumber = compareNumber(actual);
1879
+ if (expectedNumber !== null && actualNumber !== null) {
1880
+ success = actualNumber === expectedNumber;
1881
+ rawValue = actualNumber;
1882
+ observedValue = actualNumber;
1883
+ reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`;
1742
1884
  break;
1743
1885
  }
1744
- rawValue = actualBool;
1745
- observedValue = actualBool;
1746
- success = actualBool === expectedBool;
1747
- reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`;
1886
+ const expectedString = typeof expected === 'string' ? expected : null;
1887
+ const actualString = compareString(actual);
1888
+ if (expectedString !== null && actualString !== null) {
1889
+ success = actualString === expectedString;
1890
+ rawValue = actualString;
1891
+ observedValue = actualString;
1892
+ reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`;
1893
+ }
1894
+ else {
1895
+ reason = 'value state unavailable';
1896
+ }
1748
1897
  break;
1749
1898
  }
1750
- const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
1751
- ? String(observedState.selected.id ?? observedState.selected.label ?? '')
1752
- : String(observedState.selected);
1753
- const actualString = actualSelected.trim();
1754
- if (!expectedString) {
1755
- reason = 'expected selected must be boolean, string, or object with id/label';
1899
+ case 'selected': {
1900
+ const expectedBool = typeof expected === 'boolean' ? expected : null;
1901
+ const expectedString = typeof expected === 'string'
1902
+ ? expected
1903
+ : expected && typeof expected === 'object'
1904
+ ? String(expected.id ?? expected.label ?? '')
1905
+ : null;
1906
+ if (!observedState || observedState.selected === undefined || observedState.selected === null) {
1907
+ reason = 'selected state unavailable';
1908
+ break;
1909
+ }
1910
+ if (expectedBool !== null) {
1911
+ const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null;
1912
+ if (actualBool === null) {
1913
+ reason = 'selected state is not boolean';
1914
+ break;
1915
+ }
1916
+ rawValue = actualBool;
1917
+ observedValue = actualBool;
1918
+ success = actualBool === expectedBool;
1919
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`;
1920
+ break;
1921
+ }
1922
+ const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
1923
+ ? String(observedState.selected.id ?? observedState.selected.label ?? '')
1924
+ : String(observedState.selected);
1925
+ const actualString = actualSelected.trim();
1926
+ if (!expectedString) {
1927
+ reason = 'expected selected must be boolean, string, or object with id/label';
1928
+ break;
1929
+ }
1930
+ rawValue = actualString;
1931
+ observedValue = actualString;
1932
+ success = actualString === expectedString;
1933
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`;
1756
1934
  break;
1757
1935
  }
1758
- rawValue = actualString;
1759
- observedValue = actualString;
1760
- success = actualString === expectedString;
1761
- reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`;
1762
- break;
1763
- }
1764
- case 'text_value': {
1765
- const expectedString = typeof expected === 'string' ? expected : null;
1766
- const actualString = compareString(actual);
1767
- if (!expectedString) {
1768
- reason = 'expected text_value must be string';
1769
- }
1770
- else if (!actualString) {
1771
- reason = 'text_value state unavailable';
1936
+ case 'text_value': {
1937
+ const expectedString = typeof expected === 'string' ? expected : null;
1938
+ const actualString = compareString(actual);
1939
+ if (!expectedString) {
1940
+ reason = 'expected text_value must be string';
1941
+ }
1942
+ else if (!actualString) {
1943
+ reason = 'text_value state unavailable';
1944
+ }
1945
+ else {
1946
+ success = actualString === expectedString;
1947
+ rawValue = actualString;
1948
+ observedValue = actualString;
1949
+ reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`;
1950
+ }
1951
+ break;
1772
1952
  }
1773
- else {
1774
- success = actualString === expectedString;
1775
- rawValue = actualString;
1776
- observedValue = actualString;
1777
- reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`;
1953
+ default: {
1954
+ if (actual !== null && actual !== undefined) {
1955
+ success = actual === expected;
1956
+ observedValue = actual;
1957
+ rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null;
1958
+ reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`;
1959
+ }
1960
+ else {
1961
+ reason = `unsupported or unavailable state property: ${property}`;
1962
+ }
1778
1963
  }
1779
- break;
1780
1964
  }
1781
- default: {
1782
- if (actual !== null && actual !== undefined) {
1783
- success = actual === expected;
1784
- observedValue = actual;
1785
- rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null;
1786
- reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`;
1787
- }
1788
- else {
1789
- reason = `unsupported or unavailable state property: ${property}`;
1965
+ if (success) {
1966
+ stableCount++;
1967
+ if (stableCount >= stableTarget) {
1968
+ return {
1969
+ success: true,
1970
+ selector,
1971
+ element_id: lastResolvedElementId,
1972
+ expected_state: { property, expected },
1973
+ element: lastObservedElement,
1974
+ observed_state: {
1975
+ property,
1976
+ value: observedValue,
1977
+ ...(rawValue !== null ? { raw_value: rawValue } : {})
1978
+ },
1979
+ reason,
1980
+ stabilization_attempts: attempts,
1981
+ stabilization_window_ms: Date.now() - start,
1982
+ stable_observation_count: stableCount,
1983
+ snapshot_freshness_ms: treeAgeMs ?? undefined
1984
+ };
1790
1985
  }
1791
1986
  }
1792
- }
1793
- if (!success && !reason) {
1794
- reason = `${property} did not match expected value`;
1987
+ else {
1988
+ stableCount = 0;
1989
+ lastReason = reason || lastReason;
1990
+ lastFailureCode = 'UNKNOWN';
1991
+ }
1992
+ if (!success) {
1993
+ lastObservedValue = observedValue;
1994
+ lastRawValue = rawValue;
1995
+ }
1996
+ await sleep(pollDelay);
1795
1997
  }
1796
1998
  return {
1797
- success,
1999
+ success: false,
1798
2000
  selector,
1799
- element_id: element_id ?? resolvedElement.elementId,
2001
+ element_id: lastResolvedElementId,
1800
2002
  expected_state: { property, expected },
1801
- element: {
1802
- ...resolvedElement,
1803
- state: observedState
1804
- },
2003
+ element: lastObservedElement,
1805
2004
  observed_state: {
1806
2005
  property,
1807
- value: observedValue,
1808
- ...(rawValue !== null ? { raw_value: rawValue } : {})
2006
+ value: lastObservedValue,
2007
+ ...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
1809
2008
  },
1810
- reason,
1811
- ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
2009
+ reason: lastReason,
2010
+ failure_code: lastFailureCode,
2011
+ retryable: true
1812
2012
  };
1813
2013
  }
1814
2014
  static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {