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.
@@ -417,6 +417,59 @@ export class ToolsInteract {
417
417
  return ToolsInteract._buildControlPoint(bounds, safeRatio, axis)
418
418
  }
419
419
 
420
+ private static _buildAdjustmentProbePoints(
421
+ bounds: [number, number, number, number],
422
+ targetValue: number,
423
+ currentValue: number | null,
424
+ min: number,
425
+ max: number,
426
+ axis: 'horizontal' | 'vertical'
427
+ ) {
428
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis)
429
+ const currentPoint = currentValue !== null
430
+ ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
431
+ : ToolsInteract._buildControlPoint(bounds, 0.5, axis)
432
+
433
+ const [left, top, right, bottom] = bounds
434
+ const width = Math.max(1, right - left)
435
+ const height = Math.max(1, bottom - top)
436
+ const crossAxisBumps = axis === 'horizontal'
437
+ ? [Math.max(24, Math.floor(height * 0.75)), Math.max(40, Math.floor(height * 1.5))]
438
+ : [Math.max(24, Math.floor(width * 0.75)), Math.max(40, Math.floor(width * 1.5))]
439
+
440
+ const clampPoint = (point: { x: number, y: number }) => ({
441
+ x: axis === 'horizontal'
442
+ ? Math.max(left, Math.min(right, point.x))
443
+ : Math.max(left, Math.min(right + Math.max(width, height), point.x)),
444
+ y: axis === 'vertical'
445
+ ? Math.max(top, Math.min(bottom, point.y))
446
+ : Math.max(top, Math.min(bottom + Math.max(height, width), point.y))
447
+ })
448
+
449
+ const probes = [targetPoint, currentPoint]
450
+ for (const bump of crossAxisBumps) {
451
+ if (axis === 'horizontal') {
452
+ probes.push(
453
+ { x: targetPoint.x, y: bottom + bump },
454
+ { x: currentPoint.x, y: bottom + bump }
455
+ )
456
+ } else {
457
+ probes.push(
458
+ { x: right + bump, y: targetPoint.y },
459
+ { x: right + bump, y: currentPoint.y }
460
+ )
461
+ }
462
+ }
463
+
464
+ return Array.from(
465
+ new Map(
466
+ probes
467
+ .map(clampPoint)
468
+ .map((point) => [`${point.x}:${point.y}`, point] as const)
469
+ ).values()
470
+ )
471
+ }
472
+
420
473
  private static _controlAxis(el: UiElement, bounds: [number, number, number, number]): 'horizontal' | 'vertical' {
421
474
  const type = ToolsInteract._normalize(el.type ?? el.class ?? '')
422
475
  const role = ToolsInteract._normalize(el.role ?? '')
@@ -1012,6 +1065,7 @@ export class ToolsInteract {
1012
1065
  const currentPoint = currentValue !== null
1013
1066
  ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
1014
1067
  : ToolsInteract._buildControlPoint(bounds, 0.5, axis)
1068
+ const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis)
1015
1069
 
1016
1070
  const runVerification = async (): Promise<{
1017
1071
  verification: any
@@ -1047,41 +1101,72 @@ export class ToolsInteract {
1047
1101
  }
1048
1102
  }
1049
1103
 
1050
- lastAdjustmentMode = 'coordinate'
1051
- const primaryActionResult = await ToolsInteract.tapHandler({
1052
- platform: resolvedPlatform,
1053
- x: targetPoint.x,
1054
- y: targetPoint.y,
1055
- deviceId: resolvedDeviceId
1056
- })
1057
- let actionDevice = primaryActionResult.device ?? currentDevice
1058
- attemptCount++
1104
+ let actionDevice: any = currentDevice
1105
+ let observedState: { property: string; value: number | null; raw_value?: number | null } | null = actualState
1106
+ let verification: any = null
1107
+ let verificationResult: any = { verification: null, observedState: actualState, withinTolerance: false }
1059
1108
 
1060
- if (!primaryActionResult.success) {
1061
- lastAdjustmentMode = 'gesture'
1062
- const fallbackActionResult = await ToolsInteract.swipeHandler({
1109
+ for (let i = 0; i < probePoints.length; i++) {
1110
+ const probePoint = probePoints[i]
1111
+ lastAdjustmentMode = 'coordinate'
1112
+ const actionResult = await ToolsInteract.tapHandler({
1063
1113
  platform: resolvedPlatform,
1064
- x1: currentPoint.x,
1065
- y1: currentPoint.y,
1066
- x2: targetPoint.x,
1067
- y2: targetPoint.y,
1068
- duration: 220,
1114
+ x: probePoint.x,
1115
+ y: probePoint.y,
1069
1116
  deviceId: resolvedDeviceId
1070
1117
  })
1071
1118
  attemptCount++
1119
+ actionDevice = actionResult.device ?? actionDevice
1072
1120
 
1073
- if (!fallbackActionResult.success) {
1074
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false)
1121
+ if (!actionResult.success) {
1122
+ continue
1075
1123
  }
1076
1124
 
1077
- actionDevice = fallbackActionResult.device ?? actionDevice
1078
- }
1125
+ verificationResult = await runVerification()
1126
+ observedState = verificationResult.observedState
1127
+ lastObservedState = observedState
1128
+
1129
+ if (verificationResult.withinTolerance) {
1130
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1131
+ const base = buildActionExecutionResult({
1132
+ actionType,
1133
+ sourceModule: 'interact',
1134
+ device: actionDevice ?? currentDevice,
1135
+ selector: targetSelector,
1136
+ resolved: resolvedTarget,
1137
+ success: true,
1138
+ uiFingerprintBefore: fingerprintBefore,
1139
+ uiFingerprintAfter,
1140
+ details: {
1141
+ target_value: targetValue,
1142
+ tolerance: normalizedTolerance,
1143
+ property,
1144
+ attempts: attemptCount,
1145
+ adjustment_mode: lastAdjustmentMode,
1146
+ actual_state: observedState,
1147
+ converged: true,
1148
+ within_tolerance: true,
1149
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1150
+ }
1151
+ }) as AdjustControlResponse
1079
1152
 
1080
- let verificationResult = await runVerification()
1081
- let observedState = verificationResult.observedState
1082
- lastObservedState = observedState
1153
+ return {
1154
+ ...base,
1155
+ target_state: {
1156
+ property,
1157
+ target_value: targetValue,
1158
+ tolerance: normalizedTolerance
1159
+ },
1160
+ actual_state: observedState,
1161
+ within_tolerance: true,
1162
+ converged: true,
1163
+ attempts: attemptCount,
1164
+ adjustment_mode: lastAdjustmentMode
1165
+ }
1166
+ }
1167
+ }
1083
1168
 
1084
- if (!verificationResult.withinTolerance && currentValue !== null) {
1169
+ if (currentValue !== null) {
1085
1170
  lastAdjustmentMode = 'gesture'
1086
1171
  const fallbackActionResult = await ToolsInteract.swipeHandler({
1087
1172
  platform: resolvedPlatform,
@@ -1094,14 +1179,55 @@ export class ToolsInteract {
1094
1179
  })
1095
1180
  attemptCount++
1096
1181
  if (!fallbackActionResult.success) {
1097
- return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false)
1182
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? actionDevice, observedState ?? actualState, attemptCount, lastAdjustmentMode, false)
1098
1183
  }
1099
1184
 
1185
+ actionDevice = fallbackActionResult.device ?? actionDevice
1100
1186
  verificationResult = await runVerification()
1101
1187
  observedState = verificationResult.observedState
1188
+ lastObservedState = observedState
1189
+
1190
+ if (verificationResult.withinTolerance) {
1191
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1192
+ const base = buildActionExecutionResult({
1193
+ actionType,
1194
+ sourceModule: 'interact',
1195
+ device: actionDevice ?? currentDevice,
1196
+ selector: targetSelector,
1197
+ resolved: resolvedTarget,
1198
+ success: true,
1199
+ uiFingerprintBefore: fingerprintBefore,
1200
+ uiFingerprintAfter,
1201
+ details: {
1202
+ target_value: targetValue,
1203
+ tolerance: normalizedTolerance,
1204
+ property,
1205
+ attempts: attemptCount,
1206
+ adjustment_mode: lastAdjustmentMode,
1207
+ actual_state: observedState,
1208
+ converged: true,
1209
+ within_tolerance: true,
1210
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1211
+ }
1212
+ }) as AdjustControlResponse
1213
+
1214
+ return {
1215
+ ...base,
1216
+ target_state: {
1217
+ property,
1218
+ target_value: targetValue,
1219
+ tolerance: normalizedTolerance
1220
+ },
1221
+ actual_state: observedState,
1222
+ within_tolerance: true,
1223
+ converged: true,
1224
+ attempts: attemptCount,
1225
+ adjustment_mode: lastAdjustmentMode
1226
+ }
1227
+ }
1102
1228
  }
1103
1229
 
1104
- const verification = verificationResult.verification
1230
+ verification = verificationResult.verification
1105
1231
  lastObservedState = observedState
1106
1232
 
1107
1233
  if (verificationResult.withinTolerance) {
@@ -1494,6 +1620,10 @@ export class ToolsInteract {
1494
1620
  let lastMatchedCount = 0
1495
1621
  let lastMatchedElement: ActionTargetResolved | null = null
1496
1622
  let lastConditionSatisfied = false
1623
+ let matchedAt: number | null = null
1624
+ let stableMatchCount = 0
1625
+ const stableObservationCount = 2
1626
+ const snapshotStaleThresholdMs = 500
1497
1627
 
1498
1628
  // Precompute normalized selector values and helpers (constant across polls)
1499
1629
  const normalize = ToolsInteract._normalize
@@ -1599,25 +1729,35 @@ export class ToolsInteract {
1599
1729
  lastMatchedCount = matchedCount
1600
1730
  lastConditionSatisfied = conditionMet
1601
1731
  lastMatchedElement = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
1732
+ const now = Date.now()
1602
1733
 
1603
- if (conditionMet) {
1604
- const now = Date.now()
1605
- const latency_ms = now - overallStart
1606
- const outEl = lastMatchedElement
1734
+ const snapshotAgeMs = typeof tree?.captured_at_ms === 'number' ? now - tree.captured_at_ms : null
1735
+ const snapshotFresh = snapshotAgeMs === null || snapshotAgeMs <= snapshotStaleThresholdMs
1607
1736
 
1608
- return {
1609
- status: 'success',
1610
- matched: matchedCount,
1611
- element: outEl,
1612
- metrics: { latency_ms, poll_count: totalPollCount, attempts },
1613
- requested,
1614
- observed: {
1615
- matched_count: matchedCount,
1616
- condition_satisfied: true,
1617
- selected_index: outEl?.index ?? null,
1618
- last_matched_element: outEl
1737
+ if (conditionMet && snapshotFresh) {
1738
+ if (matchedAt === null) matchedAt = now
1739
+ stableMatchCount++
1740
+ if (stableMatchCount >= stableObservationCount) {
1741
+ const latency_ms = now - overallStart
1742
+ const outEl = lastMatchedElement
1743
+
1744
+ return {
1745
+ status: 'success',
1746
+ matched: matchedCount,
1747
+ element: outEl,
1748
+ metrics: { latency_ms, poll_count: totalPollCount, attempts },
1749
+ requested,
1750
+ observed: {
1751
+ matched_count: matchedCount,
1752
+ condition_satisfied: true,
1753
+ selected_index: outEl?.index ?? null,
1754
+ last_matched_element: outEl
1755
+ }
1619
1756
  }
1620
1757
  }
1758
+ } else {
1759
+ stableMatchCount = 0
1760
+ matchedAt = null
1621
1761
  }
1622
1762
 
1623
1763
  } catch (e) {
@@ -1982,189 +2122,256 @@ export class ToolsInteract {
1982
2122
  property,
1983
2123
  expected,
1984
2124
  platform,
1985
- deviceId
2125
+ deviceId,
2126
+ stabilization_window_ms = 1000,
2127
+ stable_observation_count = 2,
2128
+ snapshot_stale_threshold_ms = 500,
2129
+ poll_interval_ms = 150
1986
2130
  }: {
1987
2131
  selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
1988
2132
  element_id?: string,
1989
2133
  property: string,
1990
2134
  expected: boolean | number | string | Record<string, unknown>,
1991
2135
  platform?: 'android' | 'ios',
1992
- deviceId?: string
2136
+ deviceId?: string,
2137
+ stabilization_window_ms?: number,
2138
+ stable_observation_count?: number,
2139
+ snapshot_stale_threshold_ms?: number,
2140
+ poll_interval_ms?: number
1993
2141
  }): Promise<ExpectStateResponse> {
1994
- const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
1995
- const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
1996
- const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
1997
- const treeDeviceId = tree?.device?.id || deviceId
2142
+ const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
2143
+ const compareString = (value: unknown) => typeof value === 'string' ? value : null
2144
+ const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
2145
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
2146
+ const start = Date.now()
2147
+ const deadline = start + Math.max(500, stabilization_window_ms)
2148
+ const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
2149
+ const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
2150
+ const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
1998
2151
 
1999
- let matched: { el: UiElement, idx: number } | null = null
2152
+ let attempts = 0
2153
+ let stableCount = 0
2154
+ let lastReason = 'element not found'
2155
+ let lastFailureCode: 'ELEMENT_NOT_FOUND' | 'UNKNOWN' = 'ELEMENT_NOT_FOUND'
2156
+ let lastObservedElement: (ActionTargetResolved & { state?: UIElementState | null }) | null = null
2157
+ let lastObservedValue: boolean | number | string | Record<string, unknown> | null = null
2158
+ let lastRawValue: boolean | number | string | null = null
2159
+ let lastResolvedElementId: string | null = element_id ?? null
2000
2160
 
2001
- if (element_id) {
2002
- const resolved = ToolsInteract._resolvedUiElements.get(element_id)
2003
- if (resolved) {
2004
- const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
2005
- if (current) matched = { el: current.el, idx: current.index }
2006
- }
2007
- }
2161
+ while (Date.now() <= deadline) {
2162
+ attempts++
2163
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2164
+ const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
2165
+ const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
2166
+ const treeDeviceId = tree?.device?.id || deviceId
2167
+ const treeAgeMs = typeof tree?.captured_at_ms === 'number' ? Date.now() - tree.captured_at_ms : null
2008
2168
 
2009
- if (!matched && selector) {
2010
- matched = ToolsInteract._findFirstMatchingElement(elements, selector)
2011
- }
2169
+ let matched: { el: UiElement, idx: number } | null = null
2012
2170
 
2013
- if (!matched) {
2014
- return {
2015
- success: false,
2016
- selector,
2017
- element_id: element_id ?? null,
2018
- expected_state: { property, expected },
2019
- reason: 'element not found',
2020
- failure_code: 'ELEMENT_NOT_FOUND',
2021
- retryable: true
2171
+ if (element_id) {
2172
+ const resolved = ToolsInteract._resolvedUiElements.get(element_id)
2173
+ if (resolved) {
2174
+ const current = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
2175
+ if (current) matched = { el: current.el, idx: current.index }
2176
+ }
2022
2177
  }
2023
- }
2024
2178
 
2025
- const resolvedElement = ToolsInteract._resolvedTargetFromElement(
2026
- ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx),
2027
- matched.el,
2028
- matched.idx
2029
- )
2030
- const observedState = matched.el.state ?? null
2031
- const actual = observedState?.[property as keyof UIElementState] ?? null
2179
+ if (!matched && selector) {
2180
+ matched = ToolsInteract._findFirstMatchingElement(elements, selector)
2181
+ }
2032
2182
 
2033
- const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
2034
- const compareString = (value: unknown) => typeof value === 'string' ? value : null
2035
- const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
2183
+ if (!matched) {
2184
+ lastReason = 'element not found'
2185
+ lastFailureCode = 'ELEMENT_NOT_FOUND'
2186
+ stableCount = 0
2187
+ await sleep(pollDelay)
2188
+ continue
2189
+ }
2036
2190
 
2037
- let success = false
2038
- let reason = ''
2039
- let rawValue: boolean | number | string | null = null
2040
- let observedValue: boolean | number | string | Record<string, unknown> | null = actual as any
2041
-
2042
- switch (property) {
2043
- case 'checked':
2044
- case 'focused':
2045
- case 'expanded':
2046
- case 'enabled': {
2047
- const expectedBool = compareBoolean(expected)
2048
- const actualBool = compareBoolean(actual)
2049
- if (expectedBool === null) {
2050
- reason = `expected ${property} must be boolean`
2051
- } else if (actualBool === null) {
2052
- reason = `${property} state unavailable`
2053
- } else {
2054
- rawValue = actualBool
2055
- success = actualBool === expectedBool
2056
- reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`
2057
- }
2058
- observedValue = actualBool
2059
- break
2191
+ const resolvedElement = ToolsInteract._resolvedTargetFromElement(
2192
+ ToolsInteract._computeElementId(treePlatform, treeDeviceId, matched.el, matched.idx),
2193
+ matched.el,
2194
+ matched.idx
2195
+ )
2196
+ lastResolvedElementId = resolvedElement.elementId
2197
+ lastObservedElement = { ...resolvedElement, state: matched.el.state ?? null }
2198
+
2199
+ if (treeAgeMs !== null && treeAgeMs > staleThreshold) {
2200
+ lastReason = 'stale snapshot'
2201
+ lastFailureCode = 'UNKNOWN'
2202
+ stableCount = 0
2203
+ await sleep(pollDelay)
2204
+ continue
2060
2205
  }
2061
- case 'value':
2062
- case 'raw_value': {
2063
- const expectedNumber = compareNumber(expected)
2064
- const actualNumber = compareNumber(actual)
2065
- if (expectedNumber !== null && actualNumber !== null) {
2066
- success = actualNumber === expectedNumber
2067
- rawValue = actualNumber
2068
- observedValue = actualNumber
2069
- reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`
2206
+
2207
+ const observedState = matched.el.state ?? null
2208
+ const actual = observedState?.[property as keyof UIElementState] ?? null
2209
+
2210
+ let success = false
2211
+ let reason = ''
2212
+ let rawValue: boolean | number | string | null = null
2213
+ let observedValue: boolean | number | string | Record<string, unknown> | null = actual as any
2214
+
2215
+ switch (property) {
2216
+ case 'checked':
2217
+ case 'focused':
2218
+ case 'expanded':
2219
+ case 'enabled': {
2220
+ const expectedBool = compareBoolean(expected)
2221
+ const actualBool = compareBoolean(actual)
2222
+ if (expectedBool === null) {
2223
+ reason = `expected ${property} must be boolean`
2224
+ } else if (actualBool === null) {
2225
+ reason = `${property} state unavailable`
2226
+ } else {
2227
+ rawValue = actualBool
2228
+ success = actualBool === expectedBool
2229
+ reason = success ? `${property} matches expected value` : `expected ${property}=${expectedBool} but observed ${actualBool}`
2230
+ }
2231
+ observedValue = actualBool
2070
2232
  break
2071
2233
  }
2072
- const expectedString = typeof expected === 'string' ? expected : null
2073
- const actualString = compareString(actual)
2074
- if (expectedString !== null && actualString !== null) {
2075
- success = actualString === expectedString
2076
- rawValue = actualString
2077
- observedValue = actualString
2078
- reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`
2079
- } else {
2080
- reason = 'value state unavailable'
2081
- }
2082
- break
2083
- }
2084
- case 'selected': {
2085
- const expectedBool = typeof expected === 'boolean' ? expected : null
2086
- const expectedString = typeof expected === 'string'
2087
- ? expected
2088
- : expected && typeof expected === 'object'
2089
- ? String((expected as { id?: unknown; label?: unknown }).id ?? (expected as { id?: unknown; label?: unknown }).label ?? '')
2090
- : null
2091
- if (!observedState || observedState.selected === undefined || observedState.selected === null) {
2092
- reason = 'selected state unavailable'
2234
+ case 'value':
2235
+ case 'raw_value': {
2236
+ const expectedNumber = compareNumber(expected)
2237
+ const actualNumber = compareNumber(actual)
2238
+ if (expectedNumber !== null && actualNumber !== null) {
2239
+ success = actualNumber === expectedNumber
2240
+ rawValue = actualNumber
2241
+ observedValue = actualNumber
2242
+ reason = success ? 'value matches expected value' : `expected value=${expectedNumber} but observed ${actualNumber}`
2243
+ break
2244
+ }
2245
+ const expectedString = typeof expected === 'string' ? expected : null
2246
+ const actualString = compareString(actual)
2247
+ if (expectedString !== null && actualString !== null) {
2248
+ success = actualString === expectedString
2249
+ rawValue = actualString
2250
+ observedValue = actualString
2251
+ reason = success ? 'value matches expected value' : `expected value=${expectedString} but observed ${actualString}`
2252
+ } else {
2253
+ reason = 'value state unavailable'
2254
+ }
2093
2255
  break
2094
2256
  }
2095
- if (expectedBool !== null) {
2096
- const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null
2097
- if (actualBool === null) {
2098
- reason = 'selected state is not boolean'
2257
+ case 'selected': {
2258
+ const expectedBool = typeof expected === 'boolean' ? expected : null
2259
+ const expectedString = typeof expected === 'string'
2260
+ ? expected
2261
+ : expected && typeof expected === 'object'
2262
+ ? String((expected as { id?: unknown; label?: unknown }).id ?? (expected as { id?: unknown; label?: unknown }).label ?? '')
2263
+ : null
2264
+ if (!observedState || observedState.selected === undefined || observedState.selected === null) {
2265
+ reason = 'selected state unavailable'
2099
2266
  break
2100
2267
  }
2101
- rawValue = actualBool
2102
- observedValue = actualBool
2103
- success = actualBool === expectedBool
2104
- reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`
2268
+ if (expectedBool !== null) {
2269
+ const actualBool = typeof observedState.selected === 'boolean' ? observedState.selected : null
2270
+ if (actualBool === null) {
2271
+ reason = 'selected state is not boolean'
2272
+ break
2273
+ }
2274
+ rawValue = actualBool
2275
+ observedValue = actualBool
2276
+ success = actualBool === expectedBool
2277
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedBool} but observed ${actualBool}`
2278
+ break
2279
+ }
2280
+ const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
2281
+ ? String((observedState.selected as { id?: unknown; label?: unknown }).id ?? (observedState.selected as { id?: unknown; label?: unknown }).label ?? '')
2282
+ : String(observedState.selected)
2283
+ const actualString = actualSelected.trim()
2284
+ if (!expectedString) {
2285
+ reason = 'expected selected must be boolean, string, or object with id/label'
2286
+ break
2287
+ }
2288
+ rawValue = actualString
2289
+ observedValue = actualString
2290
+ success = actualString === expectedString
2291
+ reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`
2105
2292
  break
2106
2293
  }
2107
- const actualSelected = typeof observedState.selected === 'object' && observedState.selected !== null
2108
- ? String((observedState.selected as { id?: unknown; label?: unknown }).id ?? (observedState.selected as { id?: unknown; label?: unknown }).label ?? '')
2109
- : String(observedState.selected)
2110
- const actualString = actualSelected.trim()
2111
- if (!expectedString) {
2112
- reason = 'expected selected must be boolean, string, or object with id/label'
2294
+ case 'text_value': {
2295
+ const expectedString = typeof expected === 'string' ? expected : null
2296
+ const actualString = compareString(actual)
2297
+ if (!expectedString) {
2298
+ reason = 'expected text_value must be string'
2299
+ } else if (!actualString) {
2300
+ reason = 'text_value state unavailable'
2301
+ } else {
2302
+ success = actualString === expectedString
2303
+ rawValue = actualString
2304
+ observedValue = actualString
2305
+ reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`
2306
+ }
2113
2307
  break
2114
2308
  }
2115
- rawValue = actualString
2116
- observedValue = actualString
2117
- success = actualString === expectedString
2118
- reason = success ? 'selected matches expected value' : `expected selected=${expectedString} but observed ${actualString}`
2119
- break
2120
- }
2121
- case 'text_value': {
2122
- const expectedString = typeof expected === 'string' ? expected : null
2123
- const actualString = compareString(actual)
2124
- if (!expectedString) {
2125
- reason = 'expected text_value must be string'
2126
- } else if (!actualString) {
2127
- reason = 'text_value state unavailable'
2128
- } else {
2129
- success = actualString === expectedString
2130
- rawValue = actualString
2131
- observedValue = actualString
2132
- reason = success ? 'text_value matches expected value' : `expected text_value=${expectedString} but observed ${actualString}`
2309
+ default: {
2310
+ if (actual !== null && actual !== undefined) {
2311
+ success = actual === expected
2312
+ observedValue = actual as any
2313
+ rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null
2314
+ reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`
2315
+ } else {
2316
+ reason = `unsupported or unavailable state property: ${property}`
2317
+ }
2133
2318
  }
2134
- break
2135
2319
  }
2136
- default: {
2137
- if (actual !== null && actual !== undefined) {
2138
- success = actual === expected
2139
- observedValue = actual as any
2140
- rawValue = typeof actual === 'string' || typeof actual === 'number' || typeof actual === 'boolean' ? actual : null
2141
- reason = success ? `${property} matches expected value` : `expected ${property} to match but observed ${String(actual)}`
2142
- } else {
2143
- reason = `unsupported or unavailable state property: ${property}`
2320
+
2321
+ if (success) {
2322
+ stableCount++
2323
+ if (stableCount >= stableTarget) {
2324
+ return {
2325
+ success: true,
2326
+ selector,
2327
+ element_id: lastResolvedElementId,
2328
+ expected_state: { property, expected },
2329
+ element: lastObservedElement,
2330
+ observed_state: {
2331
+ property,
2332
+ value: observedValue,
2333
+ ...(rawValue !== null ? { raw_value: rawValue } : {})
2334
+ },
2335
+ reason,
2336
+ stabilization_attempts: attempts,
2337
+ stabilization_window_ms: Date.now() - start,
2338
+ stable_observation_count: stableCount,
2339
+ snapshot_freshness_ms: treeAgeMs ?? undefined
2340
+ } as ExpectStateResponse & {
2341
+ stabilization_attempts?: number;
2342
+ stabilization_window_ms?: number;
2343
+ stable_observation_count?: number;
2344
+ snapshot_freshness_ms?: number;
2345
+ }
2144
2346
  }
2347
+ } else {
2348
+ stableCount = 0
2349
+ lastReason = reason || lastReason
2350
+ lastFailureCode = 'UNKNOWN'
2145
2351
  }
2146
- }
2147
2352
 
2148
- if (!success && !reason) {
2149
- reason = `${property} did not match expected value`
2353
+ if (!success) {
2354
+ lastObservedValue = observedValue
2355
+ lastRawValue = rawValue
2356
+ }
2357
+
2358
+ await sleep(pollDelay)
2150
2359
  }
2151
2360
 
2152
2361
  return {
2153
- success,
2362
+ success: false,
2154
2363
  selector,
2155
- element_id: element_id ?? resolvedElement.elementId,
2364
+ element_id: lastResolvedElementId,
2156
2365
  expected_state: { property, expected },
2157
- element: {
2158
- ...resolvedElement,
2159
- state: observedState
2160
- },
2366
+ element: lastObservedElement,
2161
2367
  observed_state: {
2162
2368
  property,
2163
- value: observedValue,
2164
- ...(rawValue !== null ? { raw_value: rawValue } : {})
2369
+ value: lastObservedValue,
2370
+ ...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
2165
2371
  },
2166
- reason,
2167
- ...(success ? {} : { failure_code: 'UNKNOWN', retryable: false })
2372
+ reason: lastReason,
2373
+ failure_code: lastFailureCode,
2374
+ retryable: true
2168
2375
  }
2169
2376
  }
2170
2377