mobile-debug-mcp 0.26.5 → 0.28.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.
@@ -5,7 +5,26 @@ export { AndroidInteract, iOSInteract };
5
5
  import { resolveTargetDevice } from '../utils/resolve-device.js';
6
6
  import { ToolsObserve } from '../observe/index.js';
7
7
  import { computeSnapshotSignature } from '../observe/snapshot-metadata.js';
8
- import { buildActionExecutionResult } from '../server/common.js';
8
+ import { buildActionExecutionResult, createTraceStep, nextActionId } from '../server/common.js';
9
+ function buildObservationTrace({ actionType, stage, success, attempts, metadata }) {
10
+ const now = Date.now();
11
+ const actionId = nextActionId(actionType, now);
12
+ const steps = [
13
+ createTraceStep({
14
+ stage,
15
+ timestamp: now,
16
+ result: success ? 'success' : 'failure',
17
+ attemptIndex: 0,
18
+ metadata
19
+ })
20
+ ];
21
+ return {
22
+ action_id: actionId,
23
+ steps,
24
+ final_outcome: success ? 'success' : 'failure',
25
+ attempts: Math.max(1, Math.floor(attempts || 1))
26
+ };
27
+ }
9
28
  export class ToolsInteract {
10
29
  static _maxResolvedUiElements = 256;
11
30
  static _uiChangeKinds = ['hierarchy_diff', 'text_change', 'state_change'];
@@ -291,6 +310,38 @@ export class ToolsInteract {
291
310
  const safeRatio = Math.min(1 - (endpointMargin * 0.25), Math.max(endpointMargin, targetRatio + centerBias + directionBias + edgeBias));
292
311
  return ToolsInteract._buildControlPoint(bounds, safeRatio, axis);
293
312
  }
313
+ static _buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis) {
314
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis);
315
+ const currentPoint = currentValue !== null
316
+ ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
317
+ : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
318
+ const [left, top, right, bottom] = bounds;
319
+ const width = Math.max(1, right - left);
320
+ const height = Math.max(1, bottom - top);
321
+ const crossAxisBumps = axis === 'horizontal'
322
+ ? [Math.max(24, Math.floor(height * 0.75)), Math.max(40, Math.floor(height * 1.5))]
323
+ : [Math.max(24, Math.floor(width * 0.75)), Math.max(40, Math.floor(width * 1.5))];
324
+ const clampPoint = (point) => ({
325
+ x: axis === 'horizontal'
326
+ ? Math.max(left, Math.min(right, point.x))
327
+ : Math.max(left, Math.min(right + Math.max(width, height), point.x)),
328
+ y: axis === 'vertical'
329
+ ? Math.max(top, Math.min(bottom, point.y))
330
+ : Math.max(top, Math.min(bottom + Math.max(height, width), point.y))
331
+ });
332
+ const probes = [targetPoint, currentPoint];
333
+ for (const bump of crossAxisBumps) {
334
+ if (axis === 'horizontal') {
335
+ probes.push({ x: targetPoint.x, y: bottom + bump }, { x: currentPoint.x, y: bottom + bump });
336
+ }
337
+ else {
338
+ probes.push({ x: right + bump, y: targetPoint.y }, { x: right + bump, y: currentPoint.y });
339
+ }
340
+ }
341
+ return Array.from(new Map(probes
342
+ .map(clampPoint)
343
+ .map((point) => [`${point.x}:${point.y}`, point])).values());
344
+ }
294
345
  static _controlAxis(el, bounds) {
295
346
  const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
296
347
  const role = ToolsInteract._normalize(el.role ?? '');
@@ -501,7 +552,33 @@ export class ToolsInteract {
501
552
  let resolvedDeviceId = deviceId;
502
553
  const fingerprintBefore = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
503
554
  let semanticFallbackElement = null;
555
+ const traceSteps = [];
556
+ let traceAttemptIndex = 0;
557
+ const recordTraceStep = (stage, result, metadata) => {
558
+ traceSteps.push(createTraceStep({
559
+ stage,
560
+ timestamp: Date.now(),
561
+ result,
562
+ attemptIndex: traceAttemptIndex++,
563
+ metadata
564
+ }));
565
+ };
504
566
  const buildFailure = (failureCode, reason, resolved, device, actualState, attempts, adjustmentMode = 'gesture', retryable = false, uiFingerprintAfter = null) => {
567
+ if (!traceSteps.some((step) => step.stage === 'resolve')) {
568
+ recordTraceStep('resolve', 'failure', {
569
+ reason,
570
+ failure_code: failureCode
571
+ });
572
+ }
573
+ if (!traceSteps.some((step) => step.stage === 'recover')) {
574
+ recordTraceStep('recover', retryable ? 'retry' : 'failure', {
575
+ reason,
576
+ failure_code: failureCode,
577
+ retry_allowed: retryable,
578
+ recovery_attempts: attempts,
579
+ retry_depth: attempts
580
+ });
581
+ }
505
582
  const base = buildActionExecutionResult({
506
583
  actionType,
507
584
  sourceModule: 'interact',
@@ -522,7 +599,8 @@ export class ToolsInteract {
522
599
  converged: false,
523
600
  within_tolerance: false,
524
601
  reason
525
- }
602
+ },
603
+ traceSteps
526
604
  });
527
605
  return {
528
606
  ...base,
@@ -684,10 +762,23 @@ export class ToolsInteract {
684
762
  ? { property, value: currentValue, raw_value: typeof currentEl.state?.raw_value === 'number' ? currentEl.state.raw_value : undefined }
685
763
  : null;
686
764
  lastObservedState = actualState;
765
+ if (!traceSteps.some((step) => step.stage === 'resolve')) {
766
+ recordTraceStep('resolve', 'success', {
767
+ resolved_target: resolvedTarget,
768
+ current_value: currentValue,
769
+ adjustment_mode: lastAdjustmentMode
770
+ });
771
+ }
687
772
  if (property !== 'value' && property !== 'raw_value') {
688
773
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
689
774
  }
690
775
  if (currentValue !== null && Math.abs(currentValue - targetValue) <= normalizedTolerance) {
776
+ recordTraceStep('verify', 'success', {
777
+ property,
778
+ target_value: targetValue,
779
+ actual_state: actualState,
780
+ reason: 'control already within tolerance'
781
+ });
691
782
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
692
783
  const base = buildActionExecutionResult({
693
784
  actionType,
@@ -708,7 +799,8 @@ export class ToolsInteract {
708
799
  converged: true,
709
800
  within_tolerance: true,
710
801
  reason: 'control already within tolerance'
711
- }
802
+ },
803
+ traceSteps
712
804
  });
713
805
  return {
714
806
  ...base,
@@ -743,6 +835,7 @@ export class ToolsInteract {
743
835
  const currentPoint = currentValue !== null
744
836
  ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
745
837
  : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
838
+ const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis);
746
839
  const runVerification = async () => {
747
840
  const verification = await ToolsInteract.expectStateHandler({
748
841
  element_id: resolvedTarget?.elementId ?? element_id,
@@ -770,37 +863,92 @@ export class ToolsInteract {
770
863
  withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
771
864
  };
772
865
  };
773
- lastAdjustmentMode = 'coordinate';
774
- const primaryActionResult = await ToolsInteract.tapHandler({
775
- platform: resolvedPlatform,
776
- x: targetPoint.x,
777
- y: targetPoint.y,
778
- deviceId: resolvedDeviceId
779
- });
780
- let actionDevice = primaryActionResult.device ?? currentDevice;
781
- attemptCount++;
782
- if (!primaryActionResult.success) {
783
- lastAdjustmentMode = 'gesture';
784
- const fallbackActionResult = await ToolsInteract.swipeHandler({
866
+ let actionDevice = currentDevice;
867
+ let observedState = actualState;
868
+ let verification = null;
869
+ let verificationResult = { verification: null, observedState: actualState, withinTolerance: false };
870
+ for (let i = 0; i < probePoints.length; i++) {
871
+ const probePoint = probePoints[i];
872
+ lastAdjustmentMode = 'coordinate';
873
+ recordTraceStep('execute', 'retry', {
874
+ attempt: attemptCount + 1,
875
+ mode: 'coordinate',
876
+ point: probePoint
877
+ });
878
+ const actionResult = await ToolsInteract.tapHandler({
785
879
  platform: resolvedPlatform,
786
- x1: currentPoint.x,
787
- y1: currentPoint.y,
788
- x2: targetPoint.x,
789
- y2: targetPoint.y,
790
- duration: 220,
880
+ x: probePoint.x,
881
+ y: probePoint.y,
791
882
  deviceId: resolvedDeviceId
792
883
  });
793
884
  attemptCount++;
794
- if (!fallbackActionResult.success) {
795
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false);
885
+ actionDevice = actionResult.device ?? actionDevice;
886
+ if (!actionResult.success) {
887
+ recordTraceStep('execute', 'retry', {
888
+ attempt: attemptCount,
889
+ mode: 'coordinate',
890
+ point: probePoint,
891
+ success: false
892
+ });
893
+ continue;
894
+ }
895
+ verificationResult = await runVerification();
896
+ observedState = verificationResult.observedState;
897
+ lastObservedState = observedState;
898
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
899
+ attempt: attemptCount,
900
+ property,
901
+ target_value: targetValue,
902
+ actual_state: observedState,
903
+ reason: verificationResult.verification?.reason ?? 'control did not converge yet'
904
+ });
905
+ if (verificationResult.withinTolerance) {
906
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
907
+ const base = buildActionExecutionResult({
908
+ actionType,
909
+ sourceModule: 'interact',
910
+ device: actionDevice ?? currentDevice,
911
+ selector: targetSelector,
912
+ resolved: resolvedTarget,
913
+ success: true,
914
+ uiFingerprintBefore: fingerprintBefore,
915
+ uiFingerprintAfter,
916
+ details: {
917
+ target_value: targetValue,
918
+ tolerance: normalizedTolerance,
919
+ property,
920
+ attempts: attemptCount,
921
+ adjustment_mode: lastAdjustmentMode,
922
+ actual_state: observedState,
923
+ converged: true,
924
+ within_tolerance: true,
925
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
926
+ },
927
+ traceSteps
928
+ });
929
+ return {
930
+ ...base,
931
+ target_state: {
932
+ property,
933
+ target_value: targetValue,
934
+ tolerance: normalizedTolerance
935
+ },
936
+ actual_state: observedState,
937
+ within_tolerance: true,
938
+ converged: true,
939
+ attempts: attemptCount,
940
+ adjustment_mode: lastAdjustmentMode
941
+ };
796
942
  }
797
- actionDevice = fallbackActionResult.device ?? actionDevice;
798
943
  }
799
- let verificationResult = await runVerification();
800
- let observedState = verificationResult.observedState;
801
- lastObservedState = observedState;
802
- if (!verificationResult.withinTolerance && currentValue !== null) {
944
+ if (currentValue !== null) {
803
945
  lastAdjustmentMode = 'gesture';
946
+ recordTraceStep('execute', 'retry', {
947
+ attempt: attemptCount + 1,
948
+ mode: 'gesture',
949
+ start: currentPoint,
950
+ end: targetPoint
951
+ });
804
952
  const fallbackActionResult = await ToolsInteract.swipeHandler({
805
953
  platform: resolvedPlatform,
806
954
  x1: currentPoint.x,
@@ -812,14 +960,75 @@ export class ToolsInteract {
812
960
  });
813
961
  attemptCount++;
814
962
  if (!fallbackActionResult.success) {
815
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
963
+ recordTraceStep('execute', 'failure', {
964
+ attempt: attemptCount,
965
+ mode: 'gesture',
966
+ start: currentPoint,
967
+ end: targetPoint,
968
+ success: false
969
+ });
970
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? actionDevice, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
816
971
  }
972
+ actionDevice = fallbackActionResult.device ?? actionDevice;
817
973
  verificationResult = await runVerification();
818
974
  observedState = verificationResult.observedState;
975
+ lastObservedState = observedState;
976
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
977
+ attempt: attemptCount,
978
+ property,
979
+ target_value: targetValue,
980
+ actual_state: observedState,
981
+ reason: verificationResult.verification?.reason ?? 'gesture adjustment did not converge yet'
982
+ });
983
+ if (verificationResult.withinTolerance) {
984
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
985
+ const base = buildActionExecutionResult({
986
+ actionType,
987
+ sourceModule: 'interact',
988
+ device: actionDevice ?? currentDevice,
989
+ selector: targetSelector,
990
+ resolved: resolvedTarget,
991
+ success: true,
992
+ uiFingerprintBefore: fingerprintBefore,
993
+ uiFingerprintAfter,
994
+ details: {
995
+ target_value: targetValue,
996
+ tolerance: normalizedTolerance,
997
+ property,
998
+ attempts: attemptCount,
999
+ adjustment_mode: lastAdjustmentMode,
1000
+ actual_state: observedState,
1001
+ converged: true,
1002
+ within_tolerance: true,
1003
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1004
+ },
1005
+ traceSteps
1006
+ });
1007
+ return {
1008
+ ...base,
1009
+ target_state: {
1010
+ property,
1011
+ target_value: targetValue,
1012
+ tolerance: normalizedTolerance
1013
+ },
1014
+ actual_state: observedState,
1015
+ within_tolerance: true,
1016
+ converged: true,
1017
+ attempts: attemptCount,
1018
+ adjustment_mode: lastAdjustmentMode
1019
+ };
1020
+ }
819
1021
  }
820
- const verification = verificationResult.verification;
1022
+ verification = verificationResult.verification;
821
1023
  lastObservedState = observedState;
822
1024
  if (verificationResult.withinTolerance) {
1025
+ recordTraceStep('verify', 'success', {
1026
+ attempt: attemptCount,
1027
+ property,
1028
+ target_value: targetValue,
1029
+ actual_state: observedState,
1030
+ reason: verification?.reason ?? 'control converged to target value'
1031
+ });
823
1032
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
824
1033
  const base = buildActionExecutionResult({
825
1034
  actionType,
@@ -840,7 +1049,8 @@ export class ToolsInteract {
840
1049
  converged: true,
841
1050
  within_tolerance: true,
842
1051
  reason: verification?.reason ?? 'control converged to target value'
843
- }
1052
+ },
1053
+ traceSteps
844
1054
  });
845
1055
  return {
846
1056
  ...base,
@@ -1224,6 +1434,10 @@ export class ToolsInteract {
1224
1434
  let lastMatchedCount = 0;
1225
1435
  let lastMatchedElement = null;
1226
1436
  let lastConditionSatisfied = false;
1437
+ let matchedAt = null;
1438
+ let stableMatchCount = 0;
1439
+ const stableObservationCount = 2;
1440
+ const snapshotStaleThresholdMs = 500;
1227
1441
  // Precompute normalized selector values and helpers (constant across polls)
1228
1442
  const normalize = ToolsInteract._normalize;
1229
1443
  const containsFlag = !!selector?.contains;
@@ -1338,23 +1552,34 @@ export class ToolsInteract {
1338
1552
  lastMatchedCount = matchedCount;
1339
1553
  lastConditionSatisfied = conditionMet;
1340
1554
  lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null;
1341
- if (conditionMet) {
1342
- const now = Date.now();
1343
- const latency_ms = now - overallStart;
1344
- const outEl = lastMatchedElement;
1345
- return {
1346
- status: 'success',
1347
- matched: matchedCount,
1348
- element: outEl,
1349
- metrics: { latency_ms, poll_count: totalPollCount, attempts },
1350
- requested,
1351
- observed: {
1352
- matched_count: matchedCount,
1353
- condition_satisfied: true,
1354
- selected_index: outEl?.index ?? null,
1355
- last_matched_element: outEl
1356
- }
1357
- };
1555
+ const now = Date.now();
1556
+ const snapshotAgeMs = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null;
1557
+ const snapshotFresh = snapshotAgeMs === null || snapshotAgeMs <= snapshotStaleThresholdMs;
1558
+ if (conditionMet && snapshotFresh) {
1559
+ if (matchedAt === null)
1560
+ matchedAt = now;
1561
+ stableMatchCount++;
1562
+ if (stableMatchCount >= stableObservationCount) {
1563
+ const latency_ms = now - overallStart;
1564
+ const outEl = lastMatchedElement;
1565
+ return {
1566
+ status: 'success',
1567
+ matched: matchedCount,
1568
+ element: outEl,
1569
+ metrics: { latency_ms, poll_count: totalPollCount, attempts },
1570
+ requested,
1571
+ observed: {
1572
+ matched_count: matchedCount,
1573
+ condition_satisfied: true,
1574
+ selected_index: outEl?.index ?? null,
1575
+ last_matched_element: outEl
1576
+ }
1577
+ };
1578
+ }
1579
+ }
1580
+ else {
1581
+ stableMatchCount = 0;
1582
+ matchedAt = null;
1358
1583
  }
1359
1584
  }
1360
1585
  catch (e) {
@@ -1599,7 +1824,24 @@ export class ToolsInteract {
1599
1824
  basis,
1600
1825
  matched: success,
1601
1826
  reason
1602
- }
1827
+ },
1828
+ trace: buildObservationTrace({
1829
+ actionType: 'expect_screen',
1830
+ stage: 'verify',
1831
+ success,
1832
+ attempts: 1,
1833
+ metadata: {
1834
+ expected_screen: expectedScreen,
1835
+ observed_screen: {
1836
+ fingerprint: observedScreen.fingerprint,
1837
+ screen: observedScreenLabel
1838
+ },
1839
+ comparison: {
1840
+ basis,
1841
+ matched: success
1842
+ }
1843
+ }
1844
+ })
1603
1845
  };
1604
1846
  }
1605
1847
  static async expectElementVisibleHandler({ selector, element_id, timeout_ms = 5000, poll_interval_ms = 300, platform, deviceId }) {
@@ -1653,7 +1895,18 @@ export class ToolsInteract {
1653
1895
  semantic: result.element.semantic ?? null
1654
1896
  }
1655
1897
  },
1656
- reason: 'selector is visible'
1898
+ reason: 'selector is visible',
1899
+ trace: buildObservationTrace({
1900
+ actionType: 'expect_element_visible',
1901
+ stage: 'verify',
1902
+ success: true,
1903
+ attempts: 1,
1904
+ metadata: {
1905
+ selector,
1906
+ element_id: result.element.elementId ?? element_id ?? null,
1907
+ status: result.status
1908
+ }
1909
+ })
1657
1910
  };
1658
1911
  }
1659
1912
  const errorCode = result?.error?.code === 'INTERNAL_ERROR' ? 'UNKNOWN' : 'TIMEOUT';
@@ -1671,177 +1924,318 @@ export class ToolsInteract {
1671
1924
  },
1672
1925
  reason: result?.error?.message ?? 'selector is not visible',
1673
1926
  failure_code: errorCode,
1674
- retryable: errorCode === 'TIMEOUT'
1927
+ retryable: errorCode === 'TIMEOUT',
1928
+ trace: buildObservationTrace({
1929
+ actionType: 'expect_element_visible',
1930
+ stage: 'verify',
1931
+ success: false,
1932
+ attempts: 1,
1933
+ metadata: {
1934
+ selector,
1935
+ element_id: element_id ?? null,
1936
+ status: result?.status ?? null,
1937
+ reason: result?.error?.message ?? 'selector is not visible'
1938
+ }
1939
+ })
1675
1940
  };
1676
1941
  }
1677
- static async expectStateHandler({ selector, element_id, property, expected, platform, deviceId }) {
1678
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
1679
- const elements = Array.isArray(tree?.elements) ? tree.elements : [];
1680
- const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
1681
- const treeDeviceId = tree?.device?.id || deviceId;
1682
- let matched = null;
1683
- if (element_id) {
1684
- const resolved = ToolsInteract._resolvedUiElements.get(element_id);
1685
- if (resolved) {
1686
- const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
1687
- if (current)
1688
- matched = { el: current.el, idx: current.index };
1689
- }
1690
- }
1691
- if (!matched && selector) {
1692
- matched = ToolsInteract._findFirstMatchingElement(elements, selector);
1693
- }
1694
- if (!matched) {
1695
- return {
1696
- success: false,
1697
- selector,
1698
- element_id: element_id ?? null,
1699
- expected_state: { property, expected },
1700
- reason: 'element not found',
1701
- failure_code: 'ELEMENT_NOT_FOUND',
1702
- retryable: true
1703
- };
1704
- }
1705
- const resolvedElement = ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx), matched.el, matched.idx);
1706
- const observedState = matched.el.state ?? null;
1707
- const actual = observedState?.[property] ?? null;
1942
+ 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 }) {
1708
1943
  const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
1709
1944
  const compareString = (value) => typeof value === 'string' ? value : null;
1710
1945
  const compareNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
1711
- let success = false;
1712
- let reason = '';
1713
- let rawValue = null;
1714
- let observedValue = actual;
1715
- switch (property) {
1716
- case 'checked':
1717
- case 'focused':
1718
- case 'expanded':
1719
- case 'enabled': {
1720
- const expectedBool = compareBoolean(expected);
1721
- const actualBool = compareBoolean(actual);
1722
- if (expectedBool === null) {
1723
- reason = `expected ${property} must be boolean`;
1724
- }
1725
- else if (actualBool === null) {
1726
- reason = `${property} state unavailable`;
1727
- }
1728
- else {
1729
- rawValue = actualBool;
1730
- success = actualBool === expectedBool;
1731
- reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`;
1946
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
1947
+ const start = Date.now();
1948
+ const deadline = start + Math.max(500, stabilization_window_ms);
1949
+ const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2));
1950
+ const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200));
1951
+ const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800));
1952
+ let attempts = 0;
1953
+ let stableCount = 0;
1954
+ let lastReason = 'element not found';
1955
+ let lastFailureCode = 'ELEMENT_NOT_FOUND';
1956
+ let lastObservedElement = null;
1957
+ let lastObservedValue = null;
1958
+ let lastRawValue = null;
1959
+ let lastResolvedElementId = element_id ?? null;
1960
+ const traceSteps = [];
1961
+ let traceAttemptIndex = 0;
1962
+ let resolveRecorded = false;
1963
+ const recordTraceStep = (stage, result, metadata) => {
1964
+ traceSteps.push(createTraceStep({
1965
+ stage,
1966
+ timestamp: Date.now(),
1967
+ result,
1968
+ attemptIndex: traceAttemptIndex++,
1969
+ metadata
1970
+ }));
1971
+ };
1972
+ const buildStateTrace = (outcome) => ({
1973
+ action_id: nextActionId('expect_state', Date.now()),
1974
+ steps: traceSteps,
1975
+ final_outcome: outcome,
1976
+ attempts
1977
+ });
1978
+ while (Date.now() <= deadline) {
1979
+ attempts++;
1980
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
1981
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
1982
+ const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android');
1983
+ const treeDeviceId = tree?.device?.id || deviceId;
1984
+ const treeAgeMs = typeof tree?.captured_at_ms === 'number' ? Date.now() - tree.captured_at_ms : null;
1985
+ let matched = null;
1986
+ if (element_id) {
1987
+ const resolved = ToolsInteract._resolvedUiElements.get(element_id);
1988
+ if (resolved) {
1989
+ const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved);
1990
+ if (current)
1991
+ matched = { el: current.el, idx: current.index };
1732
1992
  }
1733
- observedValue = actualBool;
1734
- break;
1735
1993
  }
1736
- case 'value':
1737
- case 'raw_value': {
1738
- const expectedNumber = compareNumber(expected);
1739
- const actualNumber = compareNumber(actual);
1740
- if (expectedNumber !== null && actualNumber !== null) {
1741
- success = actualNumber === expectedNumber;
1742
- rawValue = actualNumber;
1743
- observedValue = actualNumber;
1744
- reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`;
1745
- break;
1746
- }
1747
- const expectedString = typeof expected === 'string' ? expected : null;
1748
- const actualString = compareString(actual);
1749
- if (expectedString !== null && actualString !== null) {
1750
- success = actualString === expectedString;
1751
- rawValue = actualString;
1752
- observedValue = actualString;
1753
- reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`;
1754
- }
1755
- else {
1756
- reason = 'value state unavailable';
1757
- }
1758
- break;
1994
+ if (!matched && selector) {
1995
+ matched = ToolsInteract._findFirstMatchingElement(elements, selector);
1759
1996
  }
1760
- case 'selected': {
1761
- const expectedBool = typeof expected === 'boolean' ? expected : null;
1762
- const expectedString = typeof expected === 'string'
1763
- ? expected
1764
- : expected && typeof expected === 'object'
1765
- ? String(expected.id ?? expected.label ?? '')
1766
- : null;
1767
- if (!observedState || observedState.selected === undefined || observedState.selected === null) {
1768
- reason = 'selected state unavailable';
1997
+ if (!matched) {
1998
+ lastReason = 'element not found';
1999
+ lastFailureCode = 'ELEMENT_NOT_FOUND';
2000
+ stableCount = 0;
2001
+ recordTraceStep('resolve', 'retry', {
2002
+ selector: selector ?? null,
2003
+ element_id: lastResolvedElementId,
2004
+ matched: false,
2005
+ reason: lastReason,
2006
+ attempt: attempts
2007
+ });
2008
+ recordTraceStep('stabilize', 'retry', {
2009
+ stabilization_attempts: attempts,
2010
+ stable_observation_count: stableCount,
2011
+ snapshot_freshness_ms: treeAgeMs,
2012
+ reason: lastReason
2013
+ });
2014
+ await sleep(pollDelay);
2015
+ continue;
2016
+ }
2017
+ const resolvedElement = ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx), matched.el, matched.idx);
2018
+ lastResolvedElementId = resolvedElement.elementId;
2019
+ lastObservedElement = { ...resolvedElement, state: matched.el.state ?? null };
2020
+ if (treeAgeMs !== null && treeAgeMs > staleThreshold) {
2021
+ lastReason = 'stale snapshot';
2022
+ lastFailureCode = 'UNKNOWN';
2023
+ stableCount = 0;
2024
+ recordTraceStep('resolve', 'retry', {
2025
+ selector: selector ?? null,
2026
+ element_id: lastResolvedElementId,
2027
+ matched: true,
2028
+ reason: lastReason,
2029
+ attempt: attempts
2030
+ });
2031
+ recordTraceStep('stabilize', 'retry', {
2032
+ stabilization_attempts: attempts,
2033
+ stable_observation_count: stableCount,
2034
+ snapshot_freshness_ms: treeAgeMs,
2035
+ reason: lastReason
2036
+ });
2037
+ await sleep(pollDelay);
2038
+ continue;
2039
+ }
2040
+ if (!resolveRecorded) {
2041
+ recordTraceStep('resolve', 'success', {
2042
+ selector: selector ?? null,
2043
+ element_id: lastResolvedElementId,
2044
+ matched: true,
2045
+ reason: 'element resolved'
2046
+ });
2047
+ resolveRecorded = true;
2048
+ }
2049
+ const observedState = matched.el.state ?? null;
2050
+ const actual = observedState?.[property] ?? null;
2051
+ let success = false;
2052
+ let reason = '';
2053
+ let rawValue = null;
2054
+ let observedValue = actual;
2055
+ switch (property) {
2056
+ case 'checked':
2057
+ case 'focused':
2058
+ case 'expanded':
2059
+ case 'enabled': {
2060
+ const expectedBool = compareBoolean(expected);
2061
+ const actualBool = compareBoolean(actual);
2062
+ if (expectedBool === null) {
2063
+ reason = `expected ${property} must be boolean`;
2064
+ }
2065
+ else if (actualBool === null) {
2066
+ reason = `${property} state unavailable`;
2067
+ }
2068
+ else {
2069
+ rawValue = actualBool;
2070
+ success = actualBool === expectedBool;
2071
+ reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`;
2072
+ }
2073
+ observedValue = actualBool;
1769
2074
  break;
1770
2075
  }
1771
- if (expectedBool !== null) {
1772
- const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null;
1773
- if (actualBool === null) {
1774
- reason = 'selected state is not boolean';
2076
+ case 'value':
2077
+ case 'raw_value': {
2078
+ const expectedNumber = compareNumber(expected);
2079
+ const actualNumber = compareNumber(actual);
2080
+ if (expectedNumber !== null && actualNumber !== null) {
2081
+ success = actualNumber === expectedNumber;
2082
+ rawValue = actualNumber;
2083
+ observedValue = actualNumber;
2084
+ reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`;
1775
2085
  break;
1776
2086
  }
1777
- rawValue = actualBool;
1778
- observedValue = actualBool;
1779
- success = actualBool === expectedBool;
1780
- reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`;
2087
+ const expectedString = typeof expected === 'string' ? expected : null;
2088
+ const actualString = compareString(actual);
2089
+ if (expectedString !== null && actualString !== null) {
2090
+ success = actualString === expectedString;
2091
+ rawValue = actualString;
2092
+ observedValue = actualString;
2093
+ reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`;
2094
+ }
2095
+ else {
2096
+ reason = 'value state unavailable';
2097
+ }
1781
2098
  break;
1782
2099
  }
1783
- const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
1784
- ? String(observedState.selected.id ?? observedState.selected.label ?? '')
1785
- : String(observedState.selected);
1786
- const actualString = actualSelected.trim();
1787
- if (!expectedString) {
1788
- reason = 'expected selected must be boolean, string, or object with id/label';
2100
+ case 'selected': {
2101
+ const expectedBool = typeof expected === 'boolean' ? expected : null;
2102
+ const expectedString = typeof expected === 'string'
2103
+ ? expected
2104
+ : expected && typeof expected === 'object'
2105
+ ? String(expected.id ?? expected.label ?? '')
2106
+ : null;
2107
+ if (!observedState || observedState.selected === undefined || observedState.selected === null) {
2108
+ reason = 'selected state unavailable';
2109
+ break;
2110
+ }
2111
+ if (expectedBool !== null) {
2112
+ const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null;
2113
+ if (actualBool === null) {
2114
+ reason = 'selected state is not boolean';
2115
+ break;
2116
+ }
2117
+ rawValue = actualBool;
2118
+ observedValue = actualBool;
2119
+ success = actualBool === expectedBool;
2120
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`;
2121
+ break;
2122
+ }
2123
+ const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
2124
+ ? String(observedState.selected.id ?? observedState.selected.label ?? '')
2125
+ : String(observedState.selected);
2126
+ const actualString = actualSelected.trim();
2127
+ if (!expectedString) {
2128
+ reason = 'expected selected must be boolean, string, or object with id/label';
2129
+ break;
2130
+ }
2131
+ rawValue = actualString;
2132
+ observedValue = actualString;
2133
+ success = actualString === expectedString;
2134
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`;
1789
2135
  break;
1790
2136
  }
1791
- rawValue = actualString;
1792
- observedValue = actualString;
1793
- success = actualString === expectedString;
1794
- reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`;
1795
- break;
1796
- }
1797
- case 'text_value': {
1798
- const expectedString = typeof expected === 'string' ? expected : null;
1799
- const actualString = compareString(actual);
1800
- if (!expectedString) {
1801
- reason = 'expected text_value must be string';
1802
- }
1803
- else if (!actualString) {
1804
- reason = 'text_value state unavailable';
2137
+ case 'text_value': {
2138
+ const expectedString = typeof expected === 'string' ? expected : null;
2139
+ const actualString = compareString(actual);
2140
+ if (!expectedString) {
2141
+ reason = 'expected text_value must be string';
2142
+ }
2143
+ else if (!actualString) {
2144
+ reason = 'text_value state unavailable';
2145
+ }
2146
+ else {
2147
+ success = actualString === expectedString;
2148
+ rawValue = actualString;
2149
+ observedValue = actualString;
2150
+ reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`;
2151
+ }
2152
+ break;
1805
2153
  }
1806
- else {
1807
- success = actualString === expectedString;
1808
- rawValue = actualString;
1809
- observedValue = actualString;
1810
- reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`;
2154
+ default: {
2155
+ if (actual !== null && actual !== undefined) {
2156
+ success = actual === expected;
2157
+ observedValue = actual;
2158
+ rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null;
2159
+ reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`;
2160
+ }
2161
+ else {
2162
+ reason = `unsupported or unavailable state property: ${property}`;
2163
+ }
1811
2164
  }
1812
- break;
1813
2165
  }
1814
- default: {
1815
- if (actual !== null && actual !== undefined) {
1816
- success = actual === expected;
1817
- observedValue = actual;
1818
- rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null;
1819
- reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`;
1820
- }
1821
- else {
1822
- reason = `unsupported or unavailable state property: ${property}`;
2166
+ if (success) {
2167
+ stableCount++;
2168
+ recordTraceStep('stabilize', 'success', {
2169
+ stabilization_attempts: attempts,
2170
+ stable_observation_count: stableCount,
2171
+ snapshot_freshness_ms: treeAgeMs,
2172
+ reason
2173
+ });
2174
+ recordTraceStep('verify', 'success', {
2175
+ property,
2176
+ expected,
2177
+ observed_value: observedValue,
2178
+ reason
2179
+ });
2180
+ if (stableCount >= stableTarget) {
2181
+ return {
2182
+ success: true,
2183
+ selector,
2184
+ element_id: lastResolvedElementId,
2185
+ expected_state: { property, expected },
2186
+ element: lastObservedElement,
2187
+ observed_state: {
2188
+ property,
2189
+ value: observedValue,
2190
+ ...(rawValue !== null ? { raw_value: rawValue } : {})
2191
+ },
2192
+ reason,
2193
+ stabilization_attempts: attempts,
2194
+ stabilization_window_ms: Date.now() - start,
2195
+ stable_observation_count: stableCount,
2196
+ snapshot_freshness_ms: treeAgeMs ?? undefined,
2197
+ trace: buildStateTrace('success')
2198
+ };
1823
2199
  }
1824
2200
  }
1825
- }
1826
- if (!success && !reason) {
1827
- reason = `${property} did not match expected value`;
2201
+ else {
2202
+ stableCount = 0;
2203
+ lastReason = reason || lastReason;
2204
+ lastFailureCode = 'UNKNOWN';
2205
+ recordTraceStep('stabilize', 'retry', {
2206
+ stabilization_attempts: attempts,
2207
+ stable_observation_count: stableCount,
2208
+ snapshot_freshness_ms: treeAgeMs,
2209
+ reason: lastReason
2210
+ });
2211
+ recordTraceStep('verify', 'retry', {
2212
+ property,
2213
+ expected,
2214
+ observed_value: observedValue,
2215
+ reason: lastReason
2216
+ });
2217
+ }
2218
+ if (!success) {
2219
+ lastObservedValue = observedValue;
2220
+ lastRawValue = rawValue;
2221
+ }
2222
+ await sleep(pollDelay);
1828
2223
  }
1829
2224
  return {
1830
- success,
2225
+ success: false,
1831
2226
  selector,
1832
- element_id: element_id ?? resolvedElement.elementId,
2227
+ element_id: lastResolvedElementId,
1833
2228
  expected_state: { property, expected },
1834
- element: {
1835
- ...resolvedElement,
1836
- state: observedState
1837
- },
2229
+ element: lastObservedElement,
1838
2230
  observed_state: {
1839
2231
  property,
1840
- value: observedValue,
1841
- ...(rawValue !== null ? { raw_value: rawValue } : {})
2232
+ value: lastObservedValue,
2233
+ ...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
1842
2234
  },
1843
- reason,
1844
- ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
2235
+ reason: lastReason,
2236
+ failure_code: lastFailureCode,
2237
+ retryable: true,
2238
+ trace: buildStateTrace('failure')
1845
2239
  };
1846
2240
  }
1847
2241
  static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {