mobile-debug-mcp 0.26.5 → 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.
@@ -291,6 +291,38 @@ export class ToolsInteract {
291
291
  const safeRatio = Math.min(1 - (endpointMargin * 0.25), Math.max(endpointMargin, targetRatio + centerBias + directionBias + edgeBias));
292
292
  return ToolsInteract._buildControlPoint(bounds, safeRatio, axis);
293
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
+ }
294
326
  static _controlAxis(el, bounds) {
295
327
  const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
296
328
  const role = ToolsInteract._normalize(el.role ?? '');
@@ -743,6 +775,7 @@ export class ToolsInteract {
743
775
  const currentPoint = currentValue !== null
744
776
  ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
745
777
  : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
778
+ const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis);
746
779
  const runVerification = async () => {
747
780
  const verification = await ToolsInteract.expectStateHandler({
748
781
  element_id: resolvedTarget?.elementId ?? element_id,
@@ -770,36 +803,66 @@ export class ToolsInteract {
770
803
  withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
771
804
  };
772
805
  };
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({
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({
785
814
  platform: resolvedPlatform,
786
- x1: currentPoint.x,
787
- y1: currentPoint.y,
788
- x2: targetPoint.x,
789
- y2: targetPoint.y,
790
- duration: 220,
815
+ x: probePoint.x,
816
+ y: probePoint.y,
791
817
  deviceId: resolvedDeviceId
792
818
  });
793
819
  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);
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
+ };
796
863
  }
797
- actionDevice = fallbackActionResult.device ?? actionDevice;
798
864
  }
799
- let verificationResult = await runVerification();
800
- let observedState = verificationResult.observedState;
801
- lastObservedState = observedState;
802
- if (!verificationResult.withinTolerance && currentValue !== null) {
865
+ if (currentValue !== null) {
803
866
  lastAdjustmentMode = 'gesture';
804
867
  const fallbackActionResult = await ToolsInteract.swipeHandler({
805
868
  platform: resolvedPlatform,
@@ -812,12 +875,51 @@ export class ToolsInteract {
812
875
  });
813
876
  attemptCount++;
814
877
  if (!fallbackActionResult.success) {
815
- 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);
816
879
  }
880
+ actionDevice = fallbackActionResult.device ?? actionDevice;
817
881
  verificationResult = await runVerification();
818
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
+ }
819
921
  }
820
- const verification = verificationResult.verification;
922
+ verification = verificationResult.verification;
821
923
  lastObservedState = observedState;
822
924
  if (verificationResult.withinTolerance) {
823
925
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
@@ -1224,6 +1326,10 @@ export class ToolsInteract {
1224
1326
  let lastMatchedCount = 0;
1225
1327
  let lastMatchedElement = null;
1226
1328
  let lastConditionSatisfied = false;
1329
+ let matchedAt = null;
1330
+ let stableMatchCount = 0;
1331
+ const stableObservationCount = 2;
1332
+ const snapshotStaleThresholdMs = 500;
1227
1333
  // Precompute normalized selector values and helpers (constant across polls)
1228
1334
  const normalize = ToolsInteract._normalize;
1229
1335
  const containsFlag = !!selector?.contains;
@@ -1338,23 +1444,34 @@ export class ToolsInteract {
1338
1444
  lastMatchedCount = matchedCount;
1339
1445
  lastConditionSatisfied = conditionMet;
1340
1446
  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
- };
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;
1358
1475
  }
1359
1476
  }
1360
1477
  catch (e) {
@@ -1674,174 +1791,224 @@ export class ToolsInteract {
1674
1791
  retryable: errorCode === 'TIMEOUT'
1675
1792
  };
1676
1793
  }
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;
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 }) {
1708
1795
  const compareBoolean = (value) => typeof value === 'boolean' ? value : null;
1709
1796
  const compareString = (value) => typeof value === 'string' ? value : null;
1710
1797
  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}`;
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 };
1732
1826
  }
1733
- observedValue = actualBool;
1734
- break;
1735
1827
  }
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;
1828
+ if (!matched && selector) {
1829
+ matched = ToolsInteract._findFirstMatchingElement(elements, selector);
1759
1830
  }
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';
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;
1769
1873
  break;
1770
1874
  }
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';
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}`;
1775
1884
  break;
1776
1885
  }
1777
- rawValue = actualBool;
1778
- observedValue = actualBool;
1779
- success = actualBool === expectedBool;
1780
- 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
+ }
1781
1897
  break;
1782
1898
  }
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';
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}`;
1789
1934
  break;
1790
1935
  }
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';
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;
1805
1952
  }
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}`;
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
+ }
1811
1963
  }
1812
- break;
1813
1964
  }
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}`;
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
+ };
1823
1985
  }
1824
1986
  }
1825
- }
1826
- if (!success && !reason) {
1827
- 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);
1828
1997
  }
1829
1998
  return {
1830
- success,
1999
+ success: false,
1831
2000
  selector,
1832
- element_id: element_id ?? resolvedElement.elementId,
2001
+ element_id: lastResolvedElementId,
1833
2002
  expected_state: { property, expected },
1834
- element: {
1835
- ...resolvedElement,
1836
- state: observedState
1837
- },
2003
+ element: lastObservedElement,
1838
2004
  observed_state: {
1839
2005
  property,
1840
- value: observedValue,
1841
- ...(rawValue !== null ? { raw_value: rawValue } : {})
2006
+ value: lastObservedValue,
2007
+ ...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
1842
2008
  },
1843
- reason,
1844
- ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
2009
+ reason: lastReason,
2010
+ failure_code: lastFailureCode,
2011
+ retryable: true
1845
2012
  };
1846
2013
  }
1847
2014
  static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {