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