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.
- package/dist/interact/index.js +496 -7
- package/dist/observe/ios.js +48 -4
- package/dist/server/tool-definitions.js +56 -0
- package/dist/server/tool-handlers.js +25 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +37 -5
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +69 -14
- package/docs/rfcs/008-adjustable-control-support-and-semantic-value-manipulation.md +273 -0
- package/docs/rfcs/009-semantic-control-modeling-for-custom-and-composite-controls.md +238 -0
- package/docs/specs/mcp-tooling-spec-v1.md +23 -1
- package/docs/tools/interact.md +21 -0
- package/package.json +1 -1
- package/src/interact/index.ts +625 -8
- package/src/observe/ios.ts +43 -4
- package/src/server/tool-definitions.ts +56 -0
- package/src/server/tool-handlers.ts +26 -0
- package/src/server-core.ts +1 -1
- package/src/types.ts +21 -0
- package/src/utils/android/utils.ts +32 -5
- package/test/unit/interact/adjust_control.test.ts +365 -0
- package/test/unit/observe/find_element.test.ts +46 -0
- package/test/unit/observe/state_extraction.test.ts +89 -2
- package/test/unit/server/contract.test.ts +8 -0
- package/test/unit/server/response_shapes.test.ts +39 -0
package/dist/interact/index.js
CHANGED
|
@@ -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 = {
|
package/dist/observe/ios.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) {
|