mobile-debug-mcp 0.26.3 → 0.26.5

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.
@@ -219,6 +219,87 @@ export class ToolsInteract {
219
219
  reason: candidate.reason
220
220
  };
221
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 _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
+ }
236
+ static _readNumericControlValue(el, property) {
237
+ if (!el?.state)
238
+ return null;
239
+ const stateValue = el.state[property];
240
+ if (typeof stateValue === 'number' && Number.isFinite(stateValue))
241
+ return stateValue;
242
+ if (property === 'value' || property === 'raw_value') {
243
+ const fallback = el.state.raw_value ?? el.state.value;
244
+ if (typeof fallback === 'number' && Number.isFinite(fallback))
245
+ return fallback;
246
+ }
247
+ return null;
248
+ }
249
+ static _buildControlPoint(bounds, ratio, axis) {
250
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
251
+ const [left, top, right, bottom] = bounds;
252
+ const width = Math.max(1, right - left);
253
+ const height = Math.max(1, bottom - top);
254
+ const insetX = Math.max(8, Math.floor(width * 0.08));
255
+ const insetY = Math.max(8, Math.floor(height * 0.08));
256
+ if (axis === 'vertical') {
257
+ const usableHeight = Math.max(1, height - (insetY * 2));
258
+ return {
259
+ x: Math.floor((left + right) / 2),
260
+ y: Math.floor(bottom - insetY - (usableHeight * clampedRatio))
261
+ };
262
+ }
263
+ const usableWidth = Math.max(1, width - (insetX * 2));
264
+ return {
265
+ x: Math.floor(left + insetX + (usableWidth * clampedRatio)),
266
+ y: Math.floor((top + bottom) / 2)
267
+ };
268
+ }
269
+ static _buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis) {
270
+ const range = Math.max(1, max - min);
271
+ const targetRatio = (targetValue - min) / range;
272
+ const stepRatio = 1 / range;
273
+ const centerBias = stepRatio / 2;
274
+ const direction = currentValue === null ? 0 : Math.sign(targetValue - currentValue);
275
+ const controlLengthPx = axis === 'vertical' ? Math.max(1, bounds[3] - bounds[1]) : Math.max(1, bounds[2] - bounds[0]);
276
+ const edgeWindow = Math.max(3, Math.floor(range * 0.1));
277
+ const isNearLowEdge = targetValue - min <= edgeWindow;
278
+ const isNearHighEdge = max - targetValue <= edgeWindow;
279
+ const directionBias = direction > 0
280
+ ? -stepRatio * 0.15
281
+ : direction < 0
282
+ ? stepRatio * 0.65
283
+ : 0;
284
+ const pixelBasedMargin = Math.min(0.03, Math.max(0.005, 2 / controlLengthPx));
285
+ const endpointMargin = Math.max(stepRatio * 0.5, pixelBasedMargin);
286
+ const edgeBias = isNearLowEdge
287
+ ? endpointMargin
288
+ : isNearHighEdge
289
+ ? Math.max(stepRatio * 0.4, endpointMargin * 0.75)
290
+ : 0;
291
+ const safeRatio = Math.min(1 - (endpointMargin * 0.25), Math.max(endpointMargin, targetRatio + centerBias + directionBias + edgeBias));
292
+ return ToolsInteract._buildControlPoint(bounds, safeRatio, axis);
293
+ }
294
+ static _controlAxis(el, bounds) {
295
+ const type = ToolsInteract._normalize(el.type ?? el.class ?? '');
296
+ const role = ToolsInteract._normalize(el.role ?? '');
297
+ if (/vertical/.test(type) || /vertical/.test(role))
298
+ return 'vertical';
299
+ if (/horizontal/.test(type) || /horizontal/.test(role))
300
+ return 'horizontal';
301
+ return (bounds[3] - bounds[1]) > (bounds[2] - bounds[0]) ? 'vertical' : 'horizontal';
302
+ }
222
303
  static _actionFailure(actionType, selector, resolved, failureCode, retryable, uiFingerprintBefore, uiFingerprintAfter, sourceModule = 'interact') {
223
304
  return buildActionExecutionResult({
224
305
  actionType,
@@ -244,11 +325,11 @@ export class ToolsInteract {
244
325
  static _resolveActionableAncestor(elements, chosen) {
245
326
  if (!chosen)
246
327
  return null;
247
- if (chosen.el.clickable || chosen.el.focusable)
328
+ if (chosen.el.clickable || chosen.el.focusable || ToolsInteract._isSemanticActionable(chosen.el))
248
329
  return chosen;
249
330
  let current = chosen;
250
331
  let safety = 0;
251
- while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
332
+ while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) && current.el.parentId !== undefined && current.el.parentId !== null) {
252
333
  const parentId = current.el.parentId;
253
334
  let parentIndex = null;
254
335
  if (typeof parentId === 'number')
@@ -257,7 +338,7 @@ export class ToolsInteract {
257
338
  parentIndex = Number(parentId);
258
339
  if (parentIndex !== null && elements[parentIndex]) {
259
340
  current = { el: elements[parentIndex], idx: parentIndex };
260
- if (current.el.clickable || current.el.focusable)
341
+ if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
261
342
  return current;
262
343
  }
263
344
  else if (typeof parentId === 'string') {
@@ -265,7 +346,7 @@ export class ToolsInteract {
265
346
  if (foundIndex === -1)
266
347
  break;
267
348
  current = { el: elements[foundIndex], idx: foundIndex };
268
- if (current.el.clickable || current.el.focusable)
349
+ if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
269
350
  return current;
270
351
  }
271
352
  else {
@@ -281,7 +362,7 @@ export class ToolsInteract {
281
362
  let bestArea = Infinity;
282
363
  for (let i = 0; i < elements.length; i++) {
283
364
  const el = elements[i];
284
- if (!el || !(el.clickable || el.focusable))
365
+ if (!el || !(el.clickable || el.focusable || ToolsInteract._isSemanticActionable(el)))
285
366
  continue;
286
367
  const bounds = ToolsInteract._normalizeBounds(el.bounds);
287
368
  if (!bounds)
@@ -410,6 +491,388 @@ export class ToolsInteract {
410
491
  sourceModule: 'interact'
411
492
  });
412
493
  }
494
+ static async adjustControlHandler({ selector, element_id, property = 'value', targetValue, tolerance = 0, maxAttempts = 3, platform, deviceId }) {
495
+ const actionType = 'adjust_control';
496
+ const targetSelector = selector ?? (element_id ? { elementId: element_id } : null);
497
+ const normalizedTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0;
498
+ const attemptsLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
499
+ const sourcePlatform = platform || 'android';
500
+ let resolvedPlatform = sourcePlatform;
501
+ let resolvedDeviceId = deviceId;
502
+ const fingerprintBefore = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
503
+ let semanticFallbackElement = null;
504
+ const buildFailure = (failureCode, reason, resolved, device, actualState, attempts, adjustmentMode = 'gesture', retryable = false, uiFingerprintAfter = null) => {
505
+ const base = buildActionExecutionResult({
506
+ actionType,
507
+ sourceModule: 'interact',
508
+ device,
509
+ selector: targetSelector,
510
+ resolved,
511
+ success: false,
512
+ uiFingerprintBefore: fingerprintBefore,
513
+ uiFingerprintAfter,
514
+ failure: { failureCode, retryable },
515
+ details: {
516
+ target_value: targetValue,
517
+ tolerance: normalizedTolerance,
518
+ property,
519
+ attempts,
520
+ adjustment_mode: adjustmentMode,
521
+ actual_state: actualState,
522
+ converged: false,
523
+ within_tolerance: false,
524
+ reason
525
+ }
526
+ });
527
+ return {
528
+ ...base,
529
+ target_state: {
530
+ property,
531
+ target_value: targetValue,
532
+ tolerance: normalizedTolerance
533
+ },
534
+ actual_state: actualState,
535
+ within_tolerance: false,
536
+ converged: false,
537
+ attempts,
538
+ adjustment_mode: adjustmentMode
539
+ };
540
+ };
541
+ const resolveCurrentMatch = async () => {
542
+ const tree = await ToolsObserve.getUITreeHandler({ platform: resolvedPlatform, deviceId: resolvedDeviceId });
543
+ resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : resolvedPlatform;
544
+ resolvedDeviceId = tree?.device?.id || resolvedDeviceId;
545
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
546
+ if (element_id) {
547
+ const stored = ToolsInteract._resolvedUiElements.get(element_id);
548
+ if (!stored) {
549
+ return null;
550
+ }
551
+ const current = ToolsInteract._findCurrentResolvedElement(elements, resolvedPlatform, resolvedDeviceId, stored);
552
+ if (!current) {
553
+ return null;
554
+ }
555
+ return {
556
+ tree,
557
+ device: tree?.device,
558
+ match: { el: current.el, idx: current.index },
559
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, current.el, current.index), current.el, current.index)
560
+ };
561
+ }
562
+ if (semanticFallbackElement) {
563
+ const fallbackBounds = ToolsInteract._normalizeBounds(Array.isArray(semanticFallbackElement.bounds)
564
+ ? semanticFallbackElement.bounds
565
+ : semanticFallbackElement.bounds && typeof semanticFallbackElement.bounds === 'object'
566
+ ? [
567
+ Number(semanticFallbackElement.bounds.left),
568
+ Number(semanticFallbackElement.bounds.top),
569
+ Number(semanticFallbackElement.bounds.right),
570
+ Number(semanticFallbackElement.bounds.bottom)
571
+ ]
572
+ : null);
573
+ let matchedIndex = -1;
574
+ if (fallbackBounds) {
575
+ matchedIndex = elements.findIndex((el) => {
576
+ const bounds = ToolsInteract._normalizeBounds(el.bounds);
577
+ return !!bounds && bounds[0] === fallbackBounds[0] && bounds[1] === fallbackBounds[1] && bounds[2] === fallbackBounds[2] && bounds[3] === fallbackBounds[3];
578
+ });
579
+ }
580
+ if (matchedIndex === -1 && fallbackBounds) {
581
+ const fallbackCenterX = Math.floor((fallbackBounds[0] + fallbackBounds[2]) / 2);
582
+ const fallbackCenterY = Math.floor((fallbackBounds[1] + fallbackBounds[3]) / 2);
583
+ let bestDistance = Infinity;
584
+ for (let i = 0; i < elements.length; i++) {
585
+ const el = elements[i];
586
+ if (!ToolsInteract._isAdjustableControl(el))
587
+ continue;
588
+ const bounds = ToolsInteract._normalizeBounds(el.bounds);
589
+ if (!bounds)
590
+ continue;
591
+ const centerX = Math.floor((bounds[0] + bounds[2]) / 2);
592
+ const centerY = Math.floor((bounds[1] + bounds[3]) / 2);
593
+ const distance = Math.abs(centerX - fallbackCenterX) + Math.abs(centerY - fallbackCenterY);
594
+ if (distance < bestDistance) {
595
+ bestDistance = distance;
596
+ matchedIndex = i;
597
+ }
598
+ }
599
+ }
600
+ if (matchedIndex >= 0 && elements[matchedIndex]) {
601
+ const matched = { el: elements[matchedIndex], idx: matchedIndex };
602
+ return {
603
+ tree,
604
+ device: tree?.device,
605
+ match: matched,
606
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, matched.el, matched.idx), matched.el, matched.idx)
607
+ };
608
+ }
609
+ }
610
+ if (selector) {
611
+ const matched = ToolsInteract._findFirstMatchingElement(elements, selector);
612
+ if (!matched) {
613
+ return null;
614
+ }
615
+ return {
616
+ tree,
617
+ device: tree?.device,
618
+ match: matched,
619
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, matched.el, matched.idx), matched.el, matched.idx)
620
+ };
621
+ }
622
+ return null;
623
+ };
624
+ if (!selector && !element_id) {
625
+ return buildFailure('ELEMENT_NOT_FOUND', 'selector or element_id is required', null, undefined, null, 0, 'gesture', false);
626
+ }
627
+ if (selector && !element_id) {
628
+ const waitResult = await ToolsInteract.waitForUIHandler({
629
+ selector,
630
+ condition: 'clickable',
631
+ timeout_ms: 5000,
632
+ poll_interval_ms: 300,
633
+ platform: resolvedPlatform,
634
+ deviceId: resolvedDeviceId
635
+ });
636
+ if (waitResult?.status !== 'success' || !waitResult?.element?.elementId) {
637
+ const semanticQuery = selector.text ?? selector.resource_id ?? selector.accessibility_id ?? '';
638
+ if (!semanticQuery) {
639
+ 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');
640
+ }
641
+ const fallback = await ToolsInteract.findElementHandler({
642
+ query: semanticQuery,
643
+ exact: false,
644
+ timeoutMs: 3000,
645
+ platform: resolvedPlatform,
646
+ deviceId: resolvedDeviceId
647
+ });
648
+ if (!fallback.found || !fallback.element) {
649
+ return buildFailure('ELEMENT_NOT_FOUND', waitResult?.error?.message ?? 'adjustable control not found', null, waitResult?.device, null, 0, 'gesture', true);
650
+ }
651
+ semanticFallbackElement = fallback.element;
652
+ }
653
+ else {
654
+ element_id = waitResult.element.elementId;
655
+ semanticFallbackElement = null;
656
+ }
657
+ }
658
+ let lastObservedState = null;
659
+ let lastAdjustmentMode = 'gesture';
660
+ let resolvedTarget = null;
661
+ let currentDevice = undefined;
662
+ let attemptCount = 0;
663
+ let cachedResolvedMatch = null;
664
+ for (let attempt = 0; attempt < attemptsLimit; attempt++) {
665
+ const resolved = cachedResolvedMatch
666
+ ? {
667
+ tree: null,
668
+ device: currentDevice,
669
+ match: cachedResolvedMatch,
670
+ resolvedTarget: ToolsInteract._resolvedTargetFromElement(ToolsInteract._computeElementId(resolvedPlatform, resolvedDeviceId, cachedResolvedMatch.el, cachedResolvedMatch.idx), cachedResolvedMatch.el, cachedResolvedMatch.idx)
671
+ }
672
+ : await resolveCurrentMatch();
673
+ if (!resolved || !resolved.match || !resolved.resolvedTarget) {
674
+ return buildFailure('STALE_REFERENCE', 'adjustable control could not be resolved', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true);
675
+ }
676
+ currentDevice = resolved.device;
677
+ resolvedTarget = resolved.resolvedTarget;
678
+ const currentEl = resolved.match.el;
679
+ cachedResolvedMatch = resolved.match;
680
+ const bounds = ToolsInteract._normalizeBounds(currentEl.bounds);
681
+ const valueRange = currentEl.state?.value_range ?? null;
682
+ const currentValue = ToolsInteract._readNumericControlValue(currentEl, property);
683
+ const actualState = currentValue !== null
684
+ ? { property, value: currentValue, raw_value: typeof currentEl.state?.raw_value === 'number' ? currentEl.state.raw_value : undefined }
685
+ : null;
686
+ lastObservedState = actualState;
687
+ if (property !== 'value' && property !== 'raw_value') {
688
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
689
+ }
690
+ if (currentValue !== null && Math.abs(currentValue - targetValue) <= normalizedTolerance) {
691
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
692
+ const base = buildActionExecutionResult({
693
+ actionType,
694
+ sourceModule: 'interact',
695
+ device: currentDevice,
696
+ selector: targetSelector,
697
+ resolved: resolvedTarget,
698
+ success: true,
699
+ uiFingerprintBefore: fingerprintBefore,
700
+ uiFingerprintAfter,
701
+ details: {
702
+ target_value: targetValue,
703
+ tolerance: normalizedTolerance,
704
+ property,
705
+ attempts: attemptCount,
706
+ adjustment_mode: 'semantic',
707
+ actual_state: actualState,
708
+ converged: true,
709
+ within_tolerance: true,
710
+ reason: 'control already within tolerance'
711
+ }
712
+ });
713
+ return {
714
+ ...base,
715
+ target_state: {
716
+ property,
717
+ target_value: targetValue,
718
+ tolerance: normalizedTolerance
719
+ },
720
+ actual_state: actualState,
721
+ within_tolerance: true,
722
+ converged: true,
723
+ attempts: attemptCount,
724
+ adjustment_mode: 'semantic'
725
+ };
726
+ }
727
+ if (!ToolsInteract._isAdjustableControl(currentEl)) {
728
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
729
+ }
730
+ if (!bounds) {
731
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
732
+ }
733
+ const min = typeof valueRange?.min === 'number' ? valueRange.min : null;
734
+ const max = typeof valueRange?.max === 'number' ? valueRange.max : null;
735
+ if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
736
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
737
+ }
738
+ if (targetValue < min || targetValue > max) {
739
+ return buildFailure('UNKNOWN', `targetValue ${targetValue} is outside the control range ${min}..${max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
740
+ }
741
+ const axis = ToolsInteract._controlAxis(currentEl, bounds);
742
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis);
743
+ const currentPoint = currentValue !== null
744
+ ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
745
+ : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
746
+ const runVerification = async () => {
747
+ const verification = await ToolsInteract.expectStateHandler({
748
+ element_id: resolvedTarget?.elementId ?? element_id,
749
+ selector: selector ?? undefined,
750
+ property,
751
+ expected: targetValue,
752
+ platform: resolvedPlatform,
753
+ deviceId: resolvedDeviceId
754
+ });
755
+ const observedValue = typeof verification?.observed_state?.value === 'number'
756
+ ? verification.observed_state.value
757
+ : typeof verification?.observed_state?.raw_value === 'number'
758
+ ? verification.observed_state.raw_value
759
+ : null;
760
+ const observedState = observedValue !== null
761
+ ? {
762
+ property,
763
+ value: observedValue,
764
+ raw_value: typeof verification?.observed_state?.raw_value === 'number' ? verification.observed_state.raw_value : undefined
765
+ }
766
+ : actualState;
767
+ return {
768
+ verification,
769
+ observedState,
770
+ withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
771
+ };
772
+ };
773
+ lastAdjustmentMode = 'coordinate';
774
+ const primaryActionResult = await ToolsInteract.tapHandler({
775
+ platform: resolvedPlatform,
776
+ x: targetPoint.x,
777
+ y: targetPoint.y,
778
+ deviceId: resolvedDeviceId
779
+ });
780
+ let actionDevice = primaryActionResult.device ?? currentDevice;
781
+ attemptCount++;
782
+ if (!primaryActionResult.success) {
783
+ lastAdjustmentMode = 'gesture';
784
+ const fallbackActionResult = await ToolsInteract.swipeHandler({
785
+ platform: resolvedPlatform,
786
+ x1: currentPoint.x,
787
+ y1: currentPoint.y,
788
+ x2: targetPoint.x,
789
+ y2: targetPoint.y,
790
+ duration: 220,
791
+ deviceId: resolvedDeviceId
792
+ });
793
+ attemptCount++;
794
+ if (!fallbackActionResult.success) {
795
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? primaryActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device ?? primaryActionResult.device, actualState, attemptCount, lastAdjustmentMode, false);
796
+ }
797
+ actionDevice = fallbackActionResult.device ?? actionDevice;
798
+ }
799
+ let verificationResult = await runVerification();
800
+ let observedState = verificationResult.observedState;
801
+ lastObservedState = observedState;
802
+ if (!verificationResult.withinTolerance && currentValue !== null) {
803
+ lastAdjustmentMode = 'gesture';
804
+ const fallbackActionResult = await ToolsInteract.swipeHandler({
805
+ platform: resolvedPlatform,
806
+ x1: currentPoint.x,
807
+ y1: currentPoint.y,
808
+ x2: targetPoint.x,
809
+ y2: targetPoint.y,
810
+ duration: 220,
811
+ deviceId: resolvedDeviceId
812
+ });
813
+ attemptCount++;
814
+ if (!fallbackActionResult.success) {
815
+ return buildFailure('UNKNOWN', fallbackActionResult.error ?? 'adjustment gesture failed', resolvedTarget, fallbackActionResult.device, observedState ?? actualState, attemptCount, lastAdjustmentMode, false);
816
+ }
817
+ verificationResult = await runVerification();
818
+ observedState = verificationResult.observedState;
819
+ }
820
+ const verification = verificationResult.verification;
821
+ lastObservedState = observedState;
822
+ if (verificationResult.withinTolerance) {
823
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
824
+ const base = buildActionExecutionResult({
825
+ actionType,
826
+ sourceModule: 'interact',
827
+ device: actionDevice ?? currentDevice,
828
+ selector: targetSelector,
829
+ resolved: resolvedTarget,
830
+ success: true,
831
+ uiFingerprintBefore: fingerprintBefore,
832
+ uiFingerprintAfter,
833
+ details: {
834
+ target_value: targetValue,
835
+ tolerance: normalizedTolerance,
836
+ property,
837
+ attempts: attemptCount,
838
+ adjustment_mode: lastAdjustmentMode,
839
+ actual_state: observedState,
840
+ converged: true,
841
+ within_tolerance: true,
842
+ reason: verification?.reason ?? 'control converged to target value'
843
+ }
844
+ });
845
+ return {
846
+ ...base,
847
+ target_state: {
848
+ property,
849
+ target_value: targetValue,
850
+ tolerance: normalizedTolerance
851
+ },
852
+ actual_state: observedState,
853
+ within_tolerance: true,
854
+ converged: true,
855
+ attempts: attemptCount,
856
+ adjustment_mode: lastAdjustmentMode
857
+ };
858
+ }
859
+ cachedResolvedMatch = {
860
+ el: {
861
+ ...currentEl,
862
+ state: {
863
+ ...(currentEl.state ?? null),
864
+ ...(observedState ? {
865
+ [observedState.property]: observedState.value,
866
+ raw_value: observedState.raw_value ?? observedState.value
867
+ } : {})
868
+ }
869
+ },
870
+ idx: resolved.match.idx
871
+ };
872
+ }
873
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
874
+ return buildFailure('TIMEOUT', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
875
+ }
413
876
  static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
414
877
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
415
878
  return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
@@ -448,11 +911,13 @@ export class ToolsInteract {
448
911
  if (r <= l || b <= t)
449
912
  return null;
450
913
  // Do not early-return on non-interactable elements — score them so we can locate their clickable ancestor later
451
- const interactable = !!(el.clickable || el.enabled || el.focusable);
914
+ const interactable = !!(el.clickable || el.enabled || el.focusable || ToolsInteract._isSemanticActionable(el));
452
915
  const text = normalize(el.text ?? el.label ?? el.value ?? '');
453
916
  const content = normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? '');
454
917
  const resourceId = normalize(el.resourceId ?? el.resourceID ?? el.id ?? '');
455
918
  const className = normalize(el.type ?? el.class ?? '');
919
+ const semanticRole = normalize(el.semantic?.semantic_role ?? '');
920
+ const semanticActions = Array.isArray(el.semantic?.supported_actions) ? el.semantic.supported_actions.map((action) => normalize(action)).filter(Boolean) : [];
456
921
  let score = 0;
457
922
  let reason = 'best_scoring_candidate';
458
923
  if (exact) {
@@ -503,6 +968,30 @@ export class ToolsInteract {
503
968
  reason = 'partial_class_match';
504
969
  }
505
970
  }
971
+ if (!exact) {
972
+ if (!score && semanticRole && semanticRole.includes(q)) {
973
+ score = 0.5;
974
+ reason = 'semantic_role_match';
975
+ }
976
+ if (semanticActions.some((action) => action.includes(q))) {
977
+ score = Math.max(score, score > 0 ? 0.65 : 0.6);
978
+ reason = 'semantic_action_match';
979
+ }
980
+ if (score === 0 && el.semantic?.adjustable && /slider|stepper|dropdown|segment|control|adjust/.test(q)) {
981
+ score = 0.45;
982
+ reason = 'semantic_control_match';
983
+ }
984
+ }
985
+ else {
986
+ if (!score && semanticRole && semanticRole === q) {
987
+ score = 0.5;
988
+ reason = 'semantic_role_match';
989
+ }
990
+ if (semanticActions.some((action) => action === q)) {
991
+ score = Math.max(score, score > 0 ? 0.65 : 0.6);
992
+ reason = 'semantic_action_match';
993
+ }
994
+ }
506
995
  if (score > 0 && interactable)
507
996
  score += 0.05;
508
997
  if (score <= 0)
@@ -631,7 +1120,7 @@ export class ToolsInteract {
631
1120
  interactable: true
632
1121
  };
633
1122
  }
634
- if (best && !(best.el.clickable || best.el.focusable)) {
1123
+ if (best && !(best.el.clickable || best.el.focusable || ToolsInteract._isSemanticActionable(best.el))) {
635
1124
  const nearbyActionable = ToolsInteract._resolveNearbyActionableControl(elements, { el: best.el, idx: best.idx }, screen);
636
1125
  if (nearbyActionable) {
637
1126
  best = {
@@ -52,6 +52,12 @@ function normalizeIOSType(value) {
52
52
  function inferIOSRole(type, traits) {
53
53
  if (/slider|adjustable/.test(type) || traits.some((trait) => /adjustable|slider/.test(trait)))
54
54
  return 'slider';
55
+ if (/stepper/.test(type))
56
+ return 'stepper';
57
+ if (/picker|pop up button|dropdown/.test(type))
58
+ return 'dropdown';
59
+ if (/segmented control/.test(type))
60
+ return 'segmented_control';
55
61
  if (/button/.test(type) || traits.some((trait) => /button/.test(trait)))
56
62
  return 'button';
57
63
  if (/cell/.test(type))
@@ -99,14 +105,52 @@ function buildIOSSelector(type, label, value, stableId) {
99
105
  return { value: type, confidence: buildIOSSelectorConfidence('type') };
100
106
  return null;
101
107
  }
102
- function buildIOSSemantic(type, traits) {
103
- return {
108
+ function buildIOSSemantic(type, traits, role, value) {
109
+ const semantic = {
104
110
  is_clickable: traits.includes("UIAccessibilityTraitButton") || /adjustable|slider/.test(type) || type === "Button" || type === "Cell",
105
111
  is_container: /window|application|group|scroll view|collection view/.test(type)
106
112
  };
113
+ if (role === 'slider') {
114
+ semantic.semantic_role = 'slider';
115
+ semantic.adjustable = true;
116
+ semantic.supported_actions = ['adjust'];
117
+ semantic.state_shape = 'continuous';
118
+ }
119
+ else if (role === 'stepper') {
120
+ semantic.semantic_role = 'stepper';
121
+ semantic.adjustable = true;
122
+ semantic.supported_actions = ['increment', 'decrement'];
123
+ semantic.state_shape = 'discrete';
124
+ }
125
+ else if (role === 'dropdown') {
126
+ semantic.semantic_role = 'dropdown';
127
+ semantic.supported_actions = ['tap', 'expand'];
128
+ semantic.state_shape = 'semantic';
129
+ }
130
+ else if (role === 'segmented_control') {
131
+ semantic.semantic_role = 'segmented_control';
132
+ semantic.supported_actions = ['tap'];
133
+ semantic.state_shape = 'discrete';
134
+ }
135
+ else if (traits.some((trait) => /adjustable|slider/i.test(trait)) || /adjustable|slider/.test(type)) {
136
+ semantic.semantic_role = 'custom_adjustable';
137
+ semantic.adjustable = true;
138
+ semantic.supported_actions = ['adjust'];
139
+ semantic.state_shape = 'continuous';
140
+ }
141
+ else if (semantic.is_clickable) {
142
+ semantic.supported_actions = ['tap'];
143
+ }
144
+ if (semantic.state_shape === undefined && semantic.adjustable && value !== null) {
145
+ const numericValue = parseIOSNumber(value);
146
+ if (numericValue !== null && numericValue >= 0 && numericValue <= 1) {
147
+ semantic.state_shape = 'continuous';
148
+ }
149
+ }
150
+ return semantic;
107
151
  }
108
152
  function isIOSAdjustable(node, type, traits) {
109
- return /slider|adjustable|stepper|progress/i.test(type) || traits.some((trait) => /adjustable|slider|progress/i.test(trait));
153
+ return /slider|adjustable|stepper/i.test(type) || traits.some((trait) => /adjustable|slider/i.test(trait));
110
154
  }
111
155
  function extractIOSState(node, type, label, value, traits) {
112
156
  const state = {};
@@ -162,8 +206,8 @@ export function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
162
206
  const normalizedType = normalizeIOSType(type);
163
207
  const stableId = getIOSStableId(node);
164
208
  const selector = buildIOSSelector(type, label, value, stableId);
165
- const semantic = buildIOSSemantic(normalizedType, traits);
166
209
  const role = inferIOSRole(normalizedType, traits);
210
+ const semantic = buildIOSSemantic(normalizedType, traits, role, value);
167
211
  const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
168
212
  const isUseful = clickable || (label && label.length > 0) || (value && value.length > 0) || type === "Application" || type === "Window";
169
213
  if (isUseful) {