mobile-debug-mcp 0.26.2 → 0.26.4

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.
@@ -203,6 +203,96 @@ export class ToolsInteract {
203
203
  semantic: element.semantic ?? null
204
204
  };
205
205
  }
206
+ static _summarizeResolutionCandidate(candidate) {
207
+ const bounds = ToolsInteract._normalizeBounds(candidate.el.bounds);
208
+ return {
209
+ text: candidate.el.text ?? null,
210
+ resource_id: candidate.el.resourceId ?? candidate.el.resourceID ?? candidate.el.id ?? null,
211
+ accessibility_id: candidate.el.contentDescription ?? candidate.el.contentDesc ?? candidate.el.accessibilityLabel ?? candidate.el.label ?? null,
212
+ class: candidate.el.type ?? candidate.el.class ?? null,
213
+ bounds: bounds
214
+ ? { left: bounds[0], top: bounds[1], right: bounds[2], bottom: bounds[3] }
215
+ : null,
216
+ clickable: !!candidate.el.clickable,
217
+ enabled: !!candidate.el.enabled,
218
+ score: candidate.score,
219
+ reason: candidate.reason
220
+ };
221
+ }
222
+ static _isAdjustableControl(el) {
223
+ if (!el)
224
+ return false;
225
+ const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
226
+ const role = ToolsInteract._normalize(el.role ?? '');
227
+ return !!el.state?.value_range || /slider|seekbar|stepper|adjustable|range/.test(type) || /slider|seekbar|stepper|adjustable|range/.test(role);
228
+ }
229
+ static _readNumericControlValue(el, property) {
230
+ if (!el?.state)
231
+ return null;
232
+ const stateValue = el.state[property];
233
+ if (typeof stateValue === 'number' && Number.isFinite(stateValue))
234
+ return stateValue;
235
+ if (property === 'value' || property === 'raw_value') {
236
+ const fallback = el.state.raw_value ?? el.state.value;
237
+ if (typeof fallback === 'number' && Number.isFinite(fallback))
238
+ return fallback;
239
+ }
240
+ return null;
241
+ }
242
+ static _buildControlPoint(bounds, ratio, axis) {
243
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
244
+ const [left, top, right, bottom] = bounds;
245
+ const width = Math.max(1, right - left);
246
+ const height = Math.max(1, bottom - top);
247
+ const insetX = Math.max(8, Math.floor(width * 0.08));
248
+ const insetY = Math.max(8, Math.floor(height * 0.08));
249
+ if (axis === 'vertical') {
250
+ const usableHeight = Math.max(1, height - (insetY * 2));
251
+ return {
252
+ x: Math.floor((left + right) / 2),
253
+ y: Math.floor(bottom - insetY - (usableHeight * clampedRatio))
254
+ };
255
+ }
256
+ const usableWidth = Math.max(1, width - (insetX * 2));
257
+ return {
258
+ x: Math.floor(left + insetX + (usableWidth * clampedRatio)),
259
+ y: Math.floor((top + bottom) / 2)
260
+ };
261
+ }
262
+ static _buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis) {
263
+ const range = Math.max(1, max - min);
264
+ const targetRatio = (targetValue - min) / range;
265
+ const stepRatio = 1 / range;
266
+ const centerBias = stepRatio / 2;
267
+ const direction = currentValue === null ? 0 : Math.sign(targetValue - currentValue);
268
+ const controlLengthPx = axis === 'vertical' ? Math.max(1, bounds[3] - bounds[1]) : Math.max(1, bounds[2] - bounds[0]);
269
+ const edgeWindow = Math.max(3, Math.floor(range * 0.1));
270
+ const isNearLowEdge = targetValue - min <= edgeWindow;
271
+ const isNearHighEdge = max - targetValue <= edgeWindow;
272
+ const directionBias = direction > 0
273
+ ? -stepRatio * 0.15
274
+ : direction < 0
275
+ ? stepRatio * 0.65
276
+ : 0;
277
+ const pixelBasedMargin = Math.min(0.03, Math.max(0.005, 2 / controlLengthPx));
278
+ const endpointMargin = Math.max(stepRatio * 0.5, pixelBasedMargin);
279
+ const edgeBias = isNearLowEdge
280
+ ? endpointMargin
281
+ : isNearHighEdge
282
+ ? Math.max(stepRatio * 0.4, endpointMargin * 0.75)
283
+ : 0;
284
+ const safeRatio = Math.min(1 - (endpointMargin * 0.25), Math.max(endpointMargin, targetRatio + centerBias + directionBias + edgeBias));
285
+ return ToolsInteract._buildControlPoint(bounds, safeRatio, axis);
286
+ }
287
+ static _controlAxis(el, bounds) {
288
+ const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
289
+ const role = ToolsInteract._normalize(el.role ?? '');
290
+ if (/vertical/.test(type) || /vertical/.test(role))
291
+ return 'vertical';
292
+ if (/horizontal/.test(type) || /horizontal/.test(role))
293
+ return 'horizontal';
294
+ return (bounds[3] - bounds[1]) > (bounds[2] - bounds[0]) ? 'vertical' : 'horizontal';
295
+ }
206
296
  static _actionFailure(actionType, selector, resolved, failureCode, retryable, uiFingerprintBefore, uiFingerprintAfter, sourceModule = 'interact') {
207
297
  return buildActionExecutionResult({
208
298
  actionType,
@@ -394,6 +484,388 @@ export class ToolsInteract {
394
484
  sourceModule: 'interact'
395
485
  });
396
486
  }
487
+ static async adjustControlHandler({ selector, element_id, property = 'value', targetValue, tolerance = 0, maxAttempts = 3, platform, deviceId }) {
488
+ const actionType = 'adjust_control';
489
+ const targetSelector = selector ?? (element_id ? { elementId: element_id } : null);
490
+ const normalizedTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0;
491
+ const attemptsLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
492
+ const sourcePlatform = platform || 'android';
493
+ let resolvedPlatform = sourcePlatform;
494
+ let resolvedDeviceId = deviceId;
495
+ const fingerprintBefore = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
496
+ let semanticFallbackElement = null;
497
+ const buildFailure = (failureCode, reason, resolved, device, actualState, attempts, adjustmentMode = 'gesture', retryable = false, uiFingerprintAfter = null) => {
498
+ const base = buildActionExecutionResult({
499
+ actionType,
500
+ sourceModule: 'interact',
501
+ device,
502
+ selector: targetSelector,
503
+ resolved,
504
+ success: false,
505
+ uiFingerprintBefore: fingerprintBefore,
506
+ uiFingerprintAfter,
507
+ failure: { failureCode, retryable },
508
+ details: {
509
+ target_value: targetValue,
510
+ tolerance: normalizedTolerance,
511
+ property,
512
+ attempts,
513
+ adjustment_mode: adjustmentMode,
514
+ actual_state: actualState,
515
+ converged: false,
516
+ within_tolerance: false,
517
+ reason
518
+ }
519
+ });
520
+ return {
521
+ ...base,
522
+ target_state: {
523
+ property,
524
+ target_value: targetValue,
525
+ tolerance: normalizedTolerance
526
+ },
527
+ actual_state: actualState,
528
+ within_tolerance: false,
529
+ converged: false,
530
+ attempts,
531
+ adjustment_mode: adjustmentMode
532
+ };
533
+ };
534
+ const resolveCurrentMatch = async () => {
535
+ const tree = await ToolsObserve.getUITreeHandler({ platform: resolvedPlatform, deviceId: resolvedDeviceId });
536
+ resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : resolvedPlatform;
537
+ resolvedDeviceId = tree?.device?.id || resolvedDeviceId;
538
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
539
+ if (element_id) {
540
+ const stored = ToolsInteract._resolvedUiElements.get(element_id);
541
+ if (!stored) {
542
+ return null;
543
+ }
544
+ const current = ToolsInteract._findCurrentResolvedElement(elements, resolvedPlatform, resolvedDeviceId, stored);
545
+ if (!current) {
546
+ return null;
547
+ }
548
+ return {
549
+ tree,
550
+ device: tree?.device,
551
+ match: { el: current.el, idx: current.index },
552
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, current.el, current.index), current.el, current.index)
553
+ };
554
+ }
555
+ if (semanticFallbackElement) {
556
+ const fallbackBounds = ToolsInteract._normalizeBounds(Array.isArray(semanticFallbackElement.bounds)
557
+ ? semanticFallbackElement.bounds
558
+ : semanticFallbackElement.bounds && typeof semanticFallbackElement.bounds === 'object'
559
+ ? [
560
+ Number(semanticFallbackElement.bounds.left),
561
+ Number(semanticFallbackElement.bounds.top),
562
+ Number(semanticFallbackElement.bounds.right),
563
+ Number(semanticFallbackElement.bounds.bottom)
564
+ ]
565
+ : null);
566
+ let matchedIndex = -1;
567
+ if (fallbackBounds) {
568
+ matchedIndex = elements.findIndex((el) => {
569
+ const bounds = ToolsInteract._normalizeBounds(el.bounds);
570
+ return !!bounds && bounds[0] === fallbackBounds[0] && bounds[1] === fallbackBounds[1] && bounds[2] === fallbackBounds[2] && bounds[3] === fallbackBounds[3];
571
+ });
572
+ }
573
+ if (matchedIndex === -1 && fallbackBounds) {
574
+ const fallbackCenterX = Math.floor((fallbackBounds[0] + fallbackBounds[2]) / 2);
575
+ const fallbackCenterY = Math.floor((fallbackBounds[1] + fallbackBounds[3]) / 2);
576
+ let bestDistance = Infinity;
577
+ for (let i = 0; i < elements.length; i++) {
578
+ const el = elements[i];
579
+ if (!ToolsInteract._isAdjustableControl(el))
580
+ continue;
581
+ const bounds = ToolsInteract._normalizeBounds(el.bounds);
582
+ if (!bounds)
583
+ continue;
584
+ const centerX = Math.floor((bounds[0] + bounds[2]) / 2);
585
+ const centerY = Math.floor((bounds[1] + bounds[3]) / 2);
586
+ const distance = Math.abs(centerX - fallbackCenterX) + Math.abs(centerY - fallbackCenterY);
587
+ if (distance < bestDistance) {
588
+ bestDistance = distance;
589
+ matchedIndex = i;
590
+ }
591
+ }
592
+ }
593
+ if (matchedIndex >= 0 && elements[matchedIndex]) {
594
+ const matched = { el: elements[matchedIndex], idx: matchedIndex };
595
+ return {
596
+ tree,
597
+ device: tree?.device,
598
+ match: matched,
599
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, matched.el, matched.idx), matched.el, matched.idx)
600
+ };
601
+ }
602
+ }
603
+ if (selector) {
604
+ const matched = ToolsInteract._findFirstMatchingElement(elements, selector);
605
+ if (!matched) {
606
+ return null;
607
+ }
608
+ return {
609
+ tree,
610
+ device: tree?.device,
611
+ match: matched,
612
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, matched.el, matched.idx), matched.el, matched.idx)
613
+ };
614
+ }
615
+ return null;
616
+ };
617
+ if (!selector && !element_id) {
618
+ return buildFailure('ELEMENT_NOT_FOUND', 'selector or element_id is required', null, undefined, null, 0, 'gesture', false);
619
+ }
620
+ if (selector && !element_id) {
621
+ const waitResult = await ToolsInteract.waitForUIHandler({
622
+ selector,
623
+ condition: 'clickable',
624
+ timeout_ms: 5000,
625
+ poll_interval_ms: 300,
626
+ platform: resolvedPlatform,
627
+ deviceId: resolvedDeviceId
628
+ });
629
+ if (waitResult?.status !== 'success' || !waitResult?.element?.elementId) {
630
+ const semanticQuery = selector.text ?? selector.resource_id ?? selector.accessibility_id ?? '';
631
+ if (!semanticQuery) {
632
+ return buildFailure(waitResult?.error?.code === 'ELEMENT_NOT_FOUND' ? 'ELEMENT_NOT_FOUND' : 'TIMEOUT', waitResult?.error?.message ?? 'adjustable control not found', null, waitResult?.device, null, 0, 'gesture', waitResult?.error?.code === 'ELEMENT_NOT_FOUND');
633
+ }
634
+ const fallback = await ToolsInteract.findElementHandler({
635
+ query: semanticQuery,
636
+ exact: false,
637
+ timeoutMs: 3000,
638
+ platform: resolvedPlatform,
639
+ deviceId: resolvedDeviceId
640
+ });
641
+ if (!fallback.found || !fallback.element) {
642
+ return buildFailure('ELEMENT_NOT_FOUND', waitResult?.error?.message ?? 'adjustable control not found', null, waitResult?.device, null, 0, 'gesture', true);
643
+ }
644
+ semanticFallbackElement = fallback.element;
645
+ }
646
+ else {
647
+ element_id = waitResult.element.elementId;
648
+ semanticFallbackElement = null;
649
+ }
650
+ }
651
+ let lastObservedState = null;
652
+ let lastAdjustmentMode = 'gesture';
653
+ let resolvedTarget = null;
654
+ let currentDevice = undefined;
655
+ let attemptCount = 0;
656
+ let cachedResolvedMatch = null;
657
+ for (let attempt = 0; attempt < attemptsLimit; attempt++) {
658
+ const resolved = cachedResolvedMatch
659
+ ? {
660
+ tree: null,
661
+ device: currentDevice,
662
+ match: cachedResolvedMatch,
663
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, cachedResolvedMatch.el, cachedResolvedMatch.idx), cachedResolvedMatch.el, cachedResolvedMatch.idx)
664
+ }
665
+ : await resolveCurrentMatch();
666
+ if (!resolved || !resolved.match || !resolved.resolvedTarget) {
667
+ return buildFailure('STALE_REFERENCE', 'adjustable control could not be resolved', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true);
668
+ }
669
+ currentDevice = resolved.device;
670
+ resolvedTarget = resolved.resolvedTarget;
671
+ const currentEl = resolved.match.el;
672
+ cachedResolvedMatch = resolved.match;
673
+ const bounds = ToolsInteract._normalizeBounds(currentEl.bounds);
674
+ const valueRange = currentEl.state?.value_range ?? null;
675
+ const currentValue = ToolsInteract._readNumericControlValue(currentEl, property);
676
+ const actualState = currentValue !== null
677
+ ? { property, value: currentValue, raw_value: typeof currentEl.state?.raw_value === 'number' ? currentEl.state.raw_value : undefined }
678
+ : null;
679
+ lastObservedState = actualState;
680
+ if (property !== 'value' && property !== 'raw_value') {
681
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
682
+ }
683
+ if (currentValue !== null && Math.abs(currentValue - targetValue) <= normalizedTolerance) {
684
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
685
+ const base = buildActionExecutionResult({
686
+ actionType,
687
+ sourceModule: 'interact',
688
+ device: currentDevice,
689
+ selector: targetSelector,
690
+ resolved: resolvedTarget,
691
+ success: true,
692
+ uiFingerprintBefore: fingerprintBefore,
693
+ uiFingerprintAfter,
694
+ details: {
695
+ target_value: targetValue,
696
+ tolerance: normalizedTolerance,
697
+ property,
698
+ attempts: attemptCount,
699
+ adjustment_mode: 'semantic',
700
+ actual_state: actualState,
701
+ converged: true,
702
+ within_tolerance: true,
703
+ reason: 'control already within tolerance'
704
+ }
705
+ });
706
+ return {
707
+ ...base,
708
+ target_state: {
709
+ property,
710
+ target_value: targetValue,
711
+ tolerance: normalizedTolerance
712
+ },
713
+ actual_state: actualState,
714
+ within_tolerance: true,
715
+ converged: true,
716
+ attempts: attemptCount,
717
+ adjustment_mode: 'semantic'
718
+ };
719
+ }
720
+ if (!ToolsInteract._isAdjustableControl(currentEl)) {
721
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
722
+ }
723
+ if (!bounds) {
724
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
725
+ }
726
+ const min = typeof valueRange?.min === 'number' ? valueRange.min : null;
727
+ const max = typeof valueRange?.max === 'number' ? valueRange.max : null;
728
+ if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
729
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
730
+ }
731
+ if (targetValue < min || targetValue > max) {
732
+ return buildFailure('UNKNOWN', `targetValue ${targetValue} is outside the control range ${min}..${max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
733
+ }
734
+ const axis = ToolsInteract._controlAxis(currentEl, bounds);
735
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis);
736
+ const currentPoint = currentValue !== null
737
+ ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
738
+ : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
739
+ const runVerification = async () => {
740
+ const verification = await ToolsInteract.expectStateHandler({
741
+ element_id: resolvedTarget?.elementId ?? element_id,
742
+ selector: selector ?? undefined,
743
+ property,
744
+ expected: targetValue,
745
+ platform: resolvedPlatform,
746
+ deviceId: resolvedDeviceId
747
+ });
748
+ const observedValue = typeof verification?.observed_state?.value === 'number'
749
+ ? verification.observed_state.value
750
+ : typeof verification?.observed_state?.raw_value === 'number'
751
+ ? verification.observed_state.raw_value
752
+ : null;
753
+ const observedState = observedValue !== null
754
+ ? {
755
+ property,
756
+ value: observedValue,
757
+ raw_value: typeof verification?.observed_state?.raw_value === 'number' ? verification.observed_state.raw_value : undefined
758
+ }
759
+ : actualState;
760
+ return {
761
+ verification,
762
+ observedState,
763
+ withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
764
+ };
765
+ };
766
+ lastAdjustmentMode = 'coordinate';
767
+ const primaryActionResult = await ToolsInteract.tapHandler({
768
+ platform: resolvedPlatform,
769
+ x: targetPoint.x,
770
+ y: targetPoint.y,
771
+ deviceId: resolvedDeviceId
772
+ });
773
+ let actionDevice = primaryActionResult.device ?? currentDevice;
774
+ attemptCount++;
775
+ if (!primaryActionResult.success) {
776
+ lastAdjustmentMode = 'gesture';
777
+ const fallbackActionResult = await ToolsInteract.swipeHandler({
778
+ platform: resolvedPlatform,
779
+ x1: currentPoint.x,
780
+ y1: currentPoint.y,
781
+ x2: targetPoint.x,
782
+ y2: targetPoint.y,
783
+ duration: 220,
784
+ deviceId: resolvedDeviceId
785
+ });
786
+ attemptCount++;
787
+ if (!fallbackActionResult.success) {
788
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false);
789
+ }
790
+ actionDevice = fallbackActionResult.device ?? actionDevice;
791
+ }
792
+ let verificationResult = await runVerification();
793
+ let observedState = verificationResult.observedState;
794
+ lastObservedState = observedState;
795
+ if (!verificationResult.withinTolerance && currentValue !== null) {
796
+ lastAdjustmentMode = 'gesture';
797
+ const fallbackActionResult = await ToolsInteract.swipeHandler({
798
+ platform: resolvedPlatform,
799
+ x1: currentPoint.x,
800
+ y1: currentPoint.y,
801
+ x2: targetPoint.x,
802
+ y2: targetPoint.y,
803
+ duration: 220,
804
+ deviceId: resolvedDeviceId
805
+ });
806
+ attemptCount++;
807
+ if (!fallbackActionResult.success) {
808
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
809
+ }
810
+ verificationResult = await runVerification();
811
+ observedState = verificationResult.observedState;
812
+ }
813
+ const verification = verificationResult.verification;
814
+ lastObservedState = observedState;
815
+ if (verificationResult.withinTolerance) {
816
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
817
+ const base = buildActionExecutionResult({
818
+ actionType,
819
+ sourceModule: 'interact',
820
+ device: actionDevice ?? currentDevice,
821
+ selector: targetSelector,
822
+ resolved: resolvedTarget,
823
+ success: true,
824
+ uiFingerprintBefore: fingerprintBefore,
825
+ uiFingerprintAfter,
826
+ details: {
827
+ target_value: targetValue,
828
+ tolerance: normalizedTolerance,
829
+ property,
830
+ attempts: attemptCount,
831
+ adjustment_mode: lastAdjustmentMode,
832
+ actual_state: observedState,
833
+ converged: true,
834
+ within_tolerance: true,
835
+ reason: verification?.reason ?? 'control converged to target value'
836
+ }
837
+ });
838
+ return {
839
+ ...base,
840
+ target_state: {
841
+ property,
842
+ target_value: targetValue,
843
+ tolerance: normalizedTolerance
844
+ },
845
+ actual_state: observedState,
846
+ within_tolerance: true,
847
+ converged: true,
848
+ attempts: attemptCount,
849
+ adjustment_mode: lastAdjustmentMode
850
+ };
851
+ }
852
+ cachedResolvedMatch = {
853
+ el: {
854
+ ...currentEl,
855
+ state: {
856
+ ...(currentEl.state ?? null),
857
+ ...(observedState ? {
858
+ [observedState.property]: observedState.value,
859
+ raw_value: observedState.raw_value ?? observedState.value
860
+ } : {})
861
+ }
862
+ },
863
+ idx: resolved.match.idx
864
+ };
865
+ }
866
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
867
+ return buildFailure('TIMEOUT', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
868
+ }
397
869
  static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
398
870
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
399
871
  return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
@@ -419,17 +891,18 @@ export class ToolsInteract {
419
891
  if (!q)
420
892
  return { found: false, error: 'Empty query' };
421
893
  let best = null;
422
- let bestScore = 0;
423
- let lastTree = null;
424
- const scoreElement = (el) => {
894
+ let bestTree = null;
895
+ let bestIterationCandidates = [];
896
+ let shouldStop = false;
897
+ const scoreElement = (el, idx) => {
425
898
  if (!el || !el.visible)
426
- return 0;
899
+ return null;
427
900
  const bounds = el.bounds || [0, 0, 0, 0];
428
901
  if (!Array.isArray(bounds) || bounds.length < 4)
429
- return 0;
902
+ return null;
430
903
  const [l, t, r, b] = bounds;
431
904
  if (r <= l || b <= t)
432
- return 0;
905
+ return null;
433
906
  // Do not early-return on non-interactable elements — score them so we can locate their clickable ancestor later
434
907
  const interactable = !!(el.clickable || el.enabled || el.focusable);
435
908
  const text = normalize(el.text ?? el.label ?? el.value ?? '');
@@ -437,64 +910,98 @@ export class ToolsInteract {
437
910
  const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '');
438
911
  const className = normalize(el.type ?? el.class ?? '');
439
912
  let score = 0;
913
+ let reason = 'best_scoring_candidate';
440
914
  if (exact) {
441
- if (text && text === q)
915
+ if (text && text === q) {
442
916
  score = 1.0;
443
- else if (content && content === q)
917
+ reason = 'exact_text_match';
918
+ }
919
+ else if (content && content === q) {
444
920
  score = 0.95;
921
+ reason = 'exact_content_desc_match';
922
+ }
923
+ else if (resourceId && resourceId === q) {
924
+ score = 0.92;
925
+ reason = 'exact_resource_id_match';
926
+ }
927
+ else if (className && className === q) {
928
+ score = 0.3;
929
+ reason = 'exact_class_match';
930
+ }
445
931
  }
446
932
  else {
447
- if (text && text === q)
933
+ if (text && text === q) {
448
934
  score = 1.0;
449
- else if (content && content === q)
935
+ reason = 'exact_text_match';
936
+ }
937
+ else if (content && content === q) {
450
938
  score = 0.95;
451
- else if (text && text.includes(q))
939
+ reason = 'exact_content_desc_match';
940
+ }
941
+ else if (resourceId && resourceId === q) {
942
+ score = 0.92;
943
+ reason = 'exact_resource_id_match';
944
+ }
945
+ else if (text && text.includes(q)) {
452
946
  score = 0.6;
453
- else if (content && content.includes(q))
947
+ reason = 'partial_text_match';
948
+ }
949
+ else if (content && content.includes(q)) {
454
950
  score = 0.55;
455
- else if (resourceId && resourceId.includes(q))
951
+ reason = 'partial_content_desc_match';
952
+ }
953
+ else if (resourceId && resourceId.includes(q)) {
456
954
  score = 0.7;
457
- else if (className && className.includes(q))
955
+ reason = 'partial_resource_id_match';
956
+ }
957
+ else if (className && className.includes(q)) {
458
958
  score = 0.3;
959
+ reason = 'partial_class_match';
960
+ }
459
961
  }
460
962
  if (score > 0 && interactable)
461
963
  score += 0.05;
462
- return score;
964
+ if (score <= 0)
965
+ return null;
966
+ return { el, idx, score, reason, interactable };
463
967
  };
464
968
  while (Date.now() <= deadline) {
465
969
  try {
466
970
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
467
- lastTree = tree;
468
971
  if (tree && Array.isArray(tree.elements)) {
469
972
  const elements = tree.elements;
973
+ const iterationCandidates = [];
974
+ let iterationImprovedBest = false;
470
975
  for (let i = 0; i < elements.length; i++) {
471
976
  const el = elements[i];
472
977
  try {
473
- const s = scoreElement(el);
474
- const interactable = !!(el.clickable || el.enabled || el.focusable);
475
- if (s > bestScore) {
476
- bestScore = s;
477
- best = el;
478
- if (best) {
479
- best._index = i;
480
- best._interactable = interactable;
978
+ const candidate = scoreElement(el, i);
979
+ if (!candidate)
980
+ continue;
981
+ iterationCandidates.push(candidate);
982
+ if (!best || candidate.score > best.score) {
983
+ best = candidate;
984
+ bestTree = tree;
985
+ iterationImprovedBest = true;
986
+ if (best.score >= 0.95) {
987
+ shouldStop = true;
988
+ break;
481
989
  }
482
990
  }
483
- if (bestScore >= 0.95)
484
- break;
485
991
  }
486
992
  catch (e) {
487
993
  console.error('Error scoring element:', e);
488
994
  }
489
995
  }
490
- if (bestScore >= 0.95)
491
- break;
996
+ if (iterationImprovedBest) {
997
+ bestIterationCandidates = iterationCandidates.slice();
998
+ }
492
999
  }
493
1000
  }
494
1001
  catch (e) {
495
1002
  console.error('Error fetching UI tree:', e);
496
1003
  }
497
- if (Date.now() > deadline)
1004
+ if (shouldStop || Date.now() > deadline)
498
1005
  break;
499
1006
  await new Promise(r => setTimeout(r, 100));
500
1007
  }
@@ -502,17 +1009,17 @@ export class ToolsInteract {
502
1009
  return { found: false, error: 'Element not found' };
503
1010
  // If the best match is not interactable, try to resolve an actionable ancestor.
504
1011
  try {
505
- const elements = (lastTree && Array.isArray(lastTree.elements)) ? lastTree.elements : [];
506
- const screen = lastTree?.resolution && typeof lastTree.resolution === 'object' ? lastTree.resolution : null;
1012
+ const elements = (bestTree && Array.isArray(bestTree.elements)) ? bestTree.elements : [];
1013
+ const screen = bestTree?.resolution && typeof bestTree.resolution === 'object' ? bestTree.resolution : null;
507
1014
  let chosen = best;
508
- const childBounds = Array.isArray(chosen?.bounds) ? chosen.bounds : null;
1015
+ const childBounds = Array.isArray(chosen?.el?.bounds) ? chosen.el.bounds : null;
509
1016
  // Strategy 1: if parentId references an index, climb that chain
510
1017
  let resolvedAncestor = null;
511
- if (childBounds && (chosen.parentId !== undefined && chosen.parentId !== null)) {
1018
+ if (childBounds && (chosen.el.parentId !== undefined && chosen.el.parentId !== null)) {
512
1019
  let cur = chosen;
513
1020
  let safety = 0;
514
- while (cur && safety < 20 && !(cur.clickable || cur.focusable) && (cur.parentId !== undefined && cur.parentId !== null)) {
515
- let pid = cur.parentId;
1021
+ while (cur && safety < 20 && !(cur.el.clickable || cur.el.focusable) && (cur.el.parentId !== undefined && cur.el.parentId !== null)) {
1022
+ let pid = cur.el.parentId;
516
1023
  let idx = null;
517
1024
  if (typeof pid === 'number')
518
1025
  idx = pid;
@@ -520,18 +1027,19 @@ export class ToolsInteract {
520
1027
  idx = Number(pid);
521
1028
  // If parentId is not an index, try to find by matching resourceId or id field
522
1029
  if (idx !== null && elements[idx]) {
523
- cur = elements[idx];
524
- if (cur && (cur.clickable || cur.enabled || cur.focusable)) {
1030
+ cur = { el: elements[idx], idx };
1031
+ if (cur && (cur.el.clickable || cur.el.enabled || cur.el.focusable)) {
525
1032
  resolvedAncestor = cur;
526
1033
  break;
527
1034
  }
528
1035
  }
529
1036
  else if (typeof pid === 'string') {
530
1037
  // fallback: search elements for matching resourceId or id
531
- const found = elements.find((el) => (el.resourceId === pid || el.id === pid));
1038
+ const foundIndex = elements.findIndex((el) => (el.resourceId === pid || el.id === pid));
1039
+ const found = foundIndex >= 0 ? elements[foundIndex] : null;
532
1040
  if (found) {
533
- cur = found;
534
- if (cur && (cur.clickable || cur.enabled || cur.focusable)) {
1041
+ cur = { el: found, idx: foundIndex };
1042
+ if (cur && (cur.el.clickable || cur.el.enabled || cur.el.focusable)) {
535
1043
  resolvedAncestor = cur;
536
1044
  break;
537
1045
  }
@@ -551,16 +1059,19 @@ export class ToolsInteract {
551
1059
  if (!resolvedAncestor && childBounds) {
552
1060
  const [cl, ct, cr, cb] = childBounds;
553
1061
  // find candidates that are clickable and contain the child bounds
554
- const candidates = elements.filter((el) => el && (el.clickable || el.focusable) && Array.isArray(el.bounds) && el.bounds.length >= 4).map((el) => ({ el, bounds: el.bounds }));
1062
+ const candidates = elements
1063
+ .map((el, idx) => ({ el, idx }))
1064
+ .filter(({ el }) => el && (el.clickable || el.focusable) && Array.isArray(el.bounds) && el.bounds.length >= 4);
555
1065
  let bestCandidate = null;
556
1066
  let bestCandidateArea = Infinity;
557
1067
  for (const c of candidates) {
558
- const [pl, pt, pr, pb] = c.bounds;
1068
+ const bounds = c.el.bounds;
1069
+ const [pl, pt, pr, pb] = bounds;
559
1070
  if (pl <= cl && pt <= ct && pr >= cr && pb >= cb) {
560
1071
  const area = (pr - pl) * (pb - pt);
561
1072
  if (area < bestCandidateArea) {
562
1073
  bestCandidateArea = area;
563
- bestCandidate = c.el;
1074
+ bestCandidate = c;
564
1075
  }
565
1076
  }
566
1077
  }
@@ -568,17 +1079,24 @@ export class ToolsInteract {
568
1079
  resolvedAncestor = bestCandidate;
569
1080
  }
570
1081
  if (resolvedAncestor) {
571
- best = resolvedAncestor;
572
- // small score bump to reflect actionability
573
- bestScore = Math.min(1, bestScore + 0.02);
1082
+ best = {
1083
+ el: resolvedAncestor.el,
1084
+ idx: resolvedAncestor.idx,
1085
+ score: Math.min(1, best.score + 0.02),
1086
+ reason: 'clickable_parent_preferred',
1087
+ interactable: true
1088
+ };
574
1089
  }
575
- if (best && !(best.clickable || best.focusable)) {
576
- const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best, idx: best._index ?? elements.indexOf(best) }, screen);
1090
+ if (best && !(best.el.clickable || best.el.focusable)) {
1091
+ const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best.el, idx: best.idx }, screen);
577
1092
  if (nearbyActionable) {
578
- best = nearbyActionable.el;
579
- best._index = nearbyActionable.idx;
580
- best._interactable = true;
581
- best._sliderLike = nearbyActionable.sliderLike;
1093
+ best = {
1094
+ el: nearbyActionable.el,
1095
+ idx: nearbyActionable.idx,
1096
+ score: Math.min(1, best.score + 0.02),
1097
+ reason: nearbyActionable.sliderLike ? 'slider_track_preferred' : 'nearby_actionable_control',
1098
+ interactable: true
1099
+ };
582
1100
  }
583
1101
  }
584
1102
  }
@@ -587,29 +1105,34 @@ export class ToolsInteract {
587
1105
  }
588
1106
  if (!best)
589
1107
  return { found: false, error: 'Element not found' };
590
- const boundsObj = Array.isArray(best.bounds) ? { left: best.bounds[0], top: best.bounds[1], right: best.bounds[2], bottom: best.bounds[3] } : null;
1108
+ const boundsObj = Array.isArray(best.el.bounds) ? { left: best.el.bounds[0], top: best.el.bounds[1], right: best.el.bounds[2], bottom: best.el.bounds[3] } : null;
591
1109
  const tapCoordinates = boundsObj ? { x: Math.floor((boundsObj.left + boundsObj.right) / 2), y: Math.floor((boundsObj.top + boundsObj.bottom) / 2) } : null;
1110
+ const uniqueRanked = bestIterationCandidates.filter((candidate, index, array) => index === array.findIndex((other) => other.idx === candidate.idx && other.el === candidate.el));
1111
+ const alternateCandidates = uniqueRanked
1112
+ .filter((candidate) => candidate.idx !== best.idx || candidate.el !== best.el)
1113
+ .slice(0, 3)
1114
+ .map((candidate) => ToolsInteract._summarizeResolutionCandidate(candidate));
592
1115
  const outEl = {
593
- text: best.text ?? null,
594
- resourceId: best.resourceId ?? null,
595
- contentDesc: best.contentDescription ?? best.contentDesc ?? null,
596
- class: best.type ?? best.class ?? null,
1116
+ text: best.el.text ?? null,
1117
+ resourceId: best.el.resourceId ?? null,
1118
+ contentDesc: best.el.contentDescription ?? best.el.contentDesc ?? null,
1119
+ class: best.el.type ?? best.el.class ?? null,
597
1120
  bounds: boundsObj,
598
- clickable: !!best.clickable,
599
- enabled: !!best.enabled,
600
- stable_id: best.stable_id ?? null,
601
- role: best.role ?? null,
602
- test_tag: best.test_tag ?? null,
603
- selector: best.selector ?? null,
604
- semantic: best.semantic ?? null,
1121
+ clickable: !!best.el.clickable,
1122
+ enabled: !!best.el.enabled,
1123
+ stable_id: best.el.stable_id ?? null,
1124
+ role: best.el.role ?? null,
1125
+ test_tag: best.el.test_tag ?? null,
1126
+ selector: best.el.selector ?? null,
1127
+ semantic: best.el.semantic ?? null,
605
1128
  tapCoordinates,
606
1129
  telemetry: {
607
- matchedIndex: best?._index ?? null,
608
- matchedInteractable: !!best?._interactable,
609
- sliderLike: !!best?._sliderLike
1130
+ matchedIndex: best.idx ?? null,
1131
+ matchedInteractable: !!best.interactable,
1132
+ sliderLike: best.reason === 'slider_track_preferred'
610
1133
  }
611
1134
  };
612
- if (best?._sliderLike) {
1135
+ if (best.reason === 'slider_track_preferred') {
613
1136
  const isVertical = !!boundsObj && (boundsObj.bottom - boundsObj.top) > (boundsObj.right - boundsObj.left);
614
1137
  const interactionHint = {
615
1138
  kind: 'slider',
@@ -618,8 +1141,15 @@ export class ToolsInteract {
618
1141
  };
619
1142
  outEl.interactionHint = interactionHint;
620
1143
  }
621
- const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
622
- return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
1144
+ const scoreVal = Math.min(1, Number(best.score.toFixed(3)));
1145
+ const resolution = {
1146
+ confidence: scoreVal,
1147
+ reason: best.reason,
1148
+ fallback_available: alternateCandidates.length > 0,
1149
+ matched_count: uniqueRanked.length,
1150
+ alternates: alternateCandidates
1151
+ };
1152
+ return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution };
623
1153
  }
624
1154
  static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }) {
625
1155
  const overallStart = Date.now();