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.
- package/dist/interact/index.js +352 -185
- package/dist/server/common.js +39 -0
- package/dist/server-core.js +1 -1
- package/docs/CHANGELOG.md +3 -0
- package/docs/ROADMAP.md +109 -11
- package/docs/rfcs/010-verification-stabilization-and-temporal-convergence.md +265 -0
- package/docs/rfcs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +321 -0
- package/docs/rfcs/011.1-recovery-contract-types-and-runtime-wiring-spec.md +253 -0
- package/docs/rfcs/012.md +203 -0
- package/docs/specs/mcp-tooling-spec-v1.md +12 -0
- package/docs/tools/interact.md +10 -0
- package/package.json +1 -1
- package/src/interact/index.ts +393 -186
- package/src/server/common.ts +44 -1
- package/src/server-core.ts +1 -1
- package/src/types.ts +36 -0
- package/test/unit/interact/adjust_control.test.ts +77 -1
- package/test/unit/interact/verification_stabilization.test.ts +94 -0
- package/test/unit/server/common.test.ts +36 -1
package/src/interact/index.ts
CHANGED
|
@@ -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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
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 (!
|
|
1074
|
-
|
|
1121
|
+
if (!actionResult.success) {
|
|
1122
|
+
continue
|
|
1075
1123
|
}
|
|
1076
1124
|
|
|
1077
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
|
1995
|
-
const
|
|
1996
|
-
const
|
|
1997
|
-
const
|
|
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
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2010
|
-
matched = ToolsInteract._findFirstMatchingElement(elements, selector)
|
|
2011
|
-
}
|
|
2169
|
+
let matched: { el: UiElement, idx: number } | null = null
|
|
2012
2170
|
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
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:
|
|
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:
|
|
2164
|
-
...(
|
|
2369
|
+
value: lastObservedValue,
|
|
2370
|
+
...(lastRawValue !== null ? { raw_value: lastRawValue } : {})
|
|
2165
2371
|
},
|
|
2166
|
-
reason,
|
|
2167
|
-
|
|
2372
|
+
reason: lastReason,
|
|
2373
|
+
failure_code: lastFailureCode,
|
|
2374
|
+
retryable: true
|
|
2168
2375
|
}
|
|
2169
2376
|
}
|
|
2170
2377
|
|