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.
- package/AGENTS.md +3 -0
- package/dist/interact/index.js +600 -70
- package/dist/observe/ios.js +1 -1
- package/dist/server/tool-definitions.js +59 -1
- package/dist/server/tool-handlers.js +25 -0
- package/dist/server-core.js +1 -1
- package/dist/utils/android/utils.js +2 -2
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +72 -16
- package/docs/rfcs/007-actionability-resolution-and-executable-target-selection.md +277 -0
- package/docs/rfcs/008-adjustable-control-support-and-semantic-value-manipulation.md +273 -0
- package/docs/specs/mcp-tooling-spec-v1.md +1 -1
- package/docs/tools/interact.md +30 -1
- package/package.json +1 -1
- package/src/interact/index.ts +761 -72
- package/src/observe/ios.ts +1 -1
- package/src/server/tool-definitions.ts +59 -1
- package/src/server/tool-handlers.ts +26 -0
- package/src/server-core.ts +1 -1
- package/src/types.ts +90 -0
- package/src/utils/android/utils.ts +2 -2
- package/test/unit/interact/adjust_control.test.ts +365 -0
- package/test/unit/observe/find_element.test.ts +5 -0
- package/test/unit/observe/state_extraction.test.ts +24 -0
- package/test/unit/server/contract.test.ts +8 -0
- package/test/unit/server/response_shapes.test.ts +39 -0
package/src/observe/ios.ts
CHANGED
|
@@ -121,7 +121,7 @@ function buildIOSSemantic(type: string, traits: string[]): UIElementSemanticMeta
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
function isIOSAdjustable(node: IDBElement, type: string, traits: string[]): boolean {
|
|
124
|
-
return /slider|adjustable|stepper
|
|
124
|
+
return /slider|adjustable|stepper/i.test(type) || traits.some((trait) => /adjustable|slider/i.test(trait))
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
function extractIOSState(node: IDBElement, type: string, label: string | null, value: string | null, traits: string[]): UIElementState | null {
|
|
@@ -555,6 +555,62 @@ Failure Handling:
|
|
|
555
555
|
required: ['property', 'expected']
|
|
556
556
|
}
|
|
557
557
|
},
|
|
558
|
+
{
|
|
559
|
+
name: 'adjust_control',
|
|
560
|
+
description: `Purpose:
|
|
561
|
+
Adjust a numeric control value with verification.
|
|
562
|
+
|
|
563
|
+
This is the initial adjustable-control surface for slider-like controls and other controls that expose a numeric value or value_range.
|
|
564
|
+
|
|
565
|
+
Inputs:
|
|
566
|
+
- selector or element_id
|
|
567
|
+
- property (defaults to "value")
|
|
568
|
+
- targetValue
|
|
569
|
+
- tolerance (optional)
|
|
570
|
+
- maxAttempts (optional)
|
|
571
|
+
- platform/deviceId (optional)
|
|
572
|
+
|
|
573
|
+
Output Structure:
|
|
574
|
+
- action_id, timestamp (ISO 8601), action_type
|
|
575
|
+
- lifecycle_state: post-dispatch lifecycle state (pending_verification or failed)
|
|
576
|
+
- source_module: runtime source of the action envelope
|
|
577
|
+
- target_state / actual_state / within_tolerance / converged / attempts / adjustment_mode
|
|
578
|
+
- target.selector = original selector or element handle
|
|
579
|
+
- success = true when the control converges within tolerance
|
|
580
|
+
|
|
581
|
+
Verification Guidance:
|
|
582
|
+
- Prefer direct target placement when value_range is available; fall back to a drag only if the direct tap does not converge
|
|
583
|
+
- Use expect_state for the control value readback
|
|
584
|
+
- Treat coordinate fallback as degraded mode
|
|
585
|
+
|
|
586
|
+
Failure Handling:
|
|
587
|
+
- ELEMENT_NOT_FOUND → re-resolve the control
|
|
588
|
+
- ELEMENT_NOT_INTERACTABLE → the control cannot be adjusted through the current runtime
|
|
589
|
+
- TIMEOUT → the control did not converge within bounded retries
|
|
590
|
+
- UNKNOWN → capture a snapshot and stop`,
|
|
591
|
+
inputSchema: {
|
|
592
|
+
type: 'object',
|
|
593
|
+
properties: {
|
|
594
|
+
selector: {
|
|
595
|
+
type: 'object',
|
|
596
|
+
properties: {
|
|
597
|
+
text: { type: 'string' },
|
|
598
|
+
resource_id: { type: 'string' },
|
|
599
|
+
accessibility_id: { type: 'string' },
|
|
600
|
+
contains: { type: 'boolean', default: false }
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
element_id: { type: 'string', description: 'Optional previously resolved element identifier.' },
|
|
604
|
+
property: { type: 'string', description: 'Readable numeric state property to adjust.', default: 'value' },
|
|
605
|
+
targetValue: { type: 'number', description: 'Target numeric value.' },
|
|
606
|
+
tolerance: { type: 'number', description: 'Accepted numeric tolerance around the target value.', default: 0 },
|
|
607
|
+
maxAttempts: { type: 'number', description: 'Maximum adjustment attempts.', default: 3 },
|
|
608
|
+
platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override' },
|
|
609
|
+
deviceId: { type: 'string', description: 'Optional device serial/udid' }
|
|
610
|
+
},
|
|
611
|
+
required: ['targetValue']
|
|
612
|
+
}
|
|
613
|
+
},
|
|
558
614
|
{
|
|
559
615
|
name: 'wait_for_ui',
|
|
560
616
|
description: `Purpose:
|
|
@@ -596,7 +652,9 @@ Recommended Usage:
|
|
|
596
652
|
},
|
|
597
653
|
{
|
|
598
654
|
name: 'find_element',
|
|
599
|
-
description:
|
|
655
|
+
description: `Find a UI element by semantic query (text, content-desc, resource-id, class).
|
|
656
|
+
|
|
657
|
+
Returns the best match plus resolution metadata when available, including confidence, selection reason, and fallback alternates.`,
|
|
600
658
|
inputSchema: {
|
|
601
659
|
type: 'object',
|
|
602
660
|
properties: {
|
|
@@ -277,6 +277,31 @@ async function handleExpectState(args: ToolCallArgs) {
|
|
|
277
277
|
return wrapResponse(res)
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
async function handleAdjustControl(args: ToolCallArgs) {
|
|
281
|
+
const selector = getObjectArg<ExpectElementSelectorArg>(args, 'selector')
|
|
282
|
+
const element_id = getStringArg(args, 'element_id')
|
|
283
|
+
const property = getStringArg(args, 'property') ?? 'value'
|
|
284
|
+
const targetValue = requireNumberArg(args, 'targetValue')
|
|
285
|
+
const tolerance = getNumberArg(args, 'tolerance') ?? 0
|
|
286
|
+
const maxAttempts = getNumberArg(args, 'maxAttempts') ?? 3
|
|
287
|
+
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
288
|
+
const deviceId = getStringArg(args, 'deviceId')
|
|
289
|
+
if (!selector && !element_id) {
|
|
290
|
+
throw new Error('Missing selector or element_id argument')
|
|
291
|
+
}
|
|
292
|
+
const res = await ToolsInteract.adjustControlHandler({
|
|
293
|
+
selector: selector ?? undefined,
|
|
294
|
+
element_id: element_id ?? undefined,
|
|
295
|
+
property,
|
|
296
|
+
targetValue,
|
|
297
|
+
tolerance,
|
|
298
|
+
maxAttempts,
|
|
299
|
+
platform,
|
|
300
|
+
deviceId
|
|
301
|
+
})
|
|
302
|
+
return wrapResponse(res)
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
async function handleWaitForUI(args: ToolCallArgs) {
|
|
281
306
|
const selector = getObjectArg<ExpectElementSelectorArg>(args, 'selector')
|
|
282
307
|
const condition = (getStringArg(args, 'condition') as 'exists' | 'not_exists' | 'visible' | 'clickable' | undefined) ?? 'exists'
|
|
@@ -496,6 +521,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
496
521
|
expect_screen: handleExpectScreen,
|
|
497
522
|
expect_element_visible: handleExpectElementVisible,
|
|
498
523
|
expect_state: handleExpectState,
|
|
524
|
+
adjust_control: handleAdjustControl,
|
|
499
525
|
wait_for_ui: handleWaitForUI,
|
|
500
526
|
find_element: handleFindElement,
|
|
501
527
|
tap: handleTap,
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -254,6 +254,79 @@ export interface ActionTargetResolved {
|
|
|
254
254
|
semantic?: UIElementSemanticMetadata | null;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
export interface ResolutionAlternate {
|
|
258
|
+
text: string | null;
|
|
259
|
+
resource_id: string | null;
|
|
260
|
+
accessibility_id: string | null;
|
|
261
|
+
class: string | null;
|
|
262
|
+
bounds: {
|
|
263
|
+
left: number;
|
|
264
|
+
top: number;
|
|
265
|
+
right: number;
|
|
266
|
+
bottom: number;
|
|
267
|
+
} | null;
|
|
268
|
+
clickable: boolean;
|
|
269
|
+
enabled: boolean;
|
|
270
|
+
score: number;
|
|
271
|
+
reason: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface ResolutionSummary {
|
|
275
|
+
confidence: number;
|
|
276
|
+
reason: string;
|
|
277
|
+
fallback_available: boolean;
|
|
278
|
+
matched_count: number;
|
|
279
|
+
alternates: ResolutionAlternate[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface FindElementElement {
|
|
283
|
+
text: string | null;
|
|
284
|
+
resourceId: string | null;
|
|
285
|
+
contentDesc: string | null;
|
|
286
|
+
class: string | null;
|
|
287
|
+
bounds: {
|
|
288
|
+
left: number;
|
|
289
|
+
top: number;
|
|
290
|
+
right: number;
|
|
291
|
+
bottom: number;
|
|
292
|
+
} | null;
|
|
293
|
+
clickable: boolean;
|
|
294
|
+
enabled: boolean;
|
|
295
|
+
stable_id?: string | null;
|
|
296
|
+
role?: string | null;
|
|
297
|
+
test_tag?: string | null;
|
|
298
|
+
selector?: UIResolutionSelector | null;
|
|
299
|
+
semantic?: UIElementSemanticMetadata | null;
|
|
300
|
+
tapCoordinates: {
|
|
301
|
+
x: number;
|
|
302
|
+
y: number;
|
|
303
|
+
} | null;
|
|
304
|
+
telemetry: {
|
|
305
|
+
matchedIndex: number | null;
|
|
306
|
+
matchedInteractable: boolean;
|
|
307
|
+
sliderLike: boolean;
|
|
308
|
+
};
|
|
309
|
+
interactionHint?: {
|
|
310
|
+
kind: 'slider';
|
|
311
|
+
axis: 'horizontal' | 'vertical';
|
|
312
|
+
trackBounds: {
|
|
313
|
+
left: number;
|
|
314
|
+
top: number;
|
|
315
|
+
right: number;
|
|
316
|
+
bottom: number;
|
|
317
|
+
} | null;
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface FindElementResponse {
|
|
322
|
+
found: boolean;
|
|
323
|
+
element?: FindElementElement | null;
|
|
324
|
+
score?: number;
|
|
325
|
+
confidence?: number;
|
|
326
|
+
resolution?: ResolutionSummary | null;
|
|
327
|
+
error?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
257
330
|
export interface ActionExecutionResult {
|
|
258
331
|
action_id: string;
|
|
259
332
|
timestamp: string;
|
|
@@ -340,6 +413,23 @@ export interface ExpectStateResponse {
|
|
|
340
413
|
retryable?: boolean;
|
|
341
414
|
}
|
|
342
415
|
|
|
416
|
+
export interface AdjustControlResponse extends ActionExecutionResult {
|
|
417
|
+
target_state: {
|
|
418
|
+
property: string;
|
|
419
|
+
target_value: number;
|
|
420
|
+
tolerance: number;
|
|
421
|
+
};
|
|
422
|
+
actual_state: {
|
|
423
|
+
property: string;
|
|
424
|
+
value: number | null;
|
|
425
|
+
raw_value?: number | null;
|
|
426
|
+
} | null;
|
|
427
|
+
within_tolerance: boolean;
|
|
428
|
+
converged: boolean;
|
|
429
|
+
attempts: number;
|
|
430
|
+
adjustment_mode: 'semantic' | 'gesture' | 'coordinate';
|
|
431
|
+
}
|
|
432
|
+
|
|
343
433
|
export interface WaitForUIChangeResponse {
|
|
344
434
|
success: boolean;
|
|
345
435
|
observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
|
|
@@ -341,7 +341,7 @@ function normalizeClassName(value: unknown): string {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
function inferAndroidRole(className: string): string | null {
|
|
344
|
-
if (/seekbar|slider
|
|
344
|
+
if (/seekbar|slider/.test(className)) return 'slider'
|
|
345
345
|
if (/switch|toggle/.test(className)) return 'switch'
|
|
346
346
|
if (/checkbox/.test(className)) return 'checkbox'
|
|
347
347
|
if (/radiobutton|radio/.test(className)) return 'radio'
|
|
@@ -384,7 +384,7 @@ function buildAndroidSemantic(clickable: boolean, className: string): UIElementS
|
|
|
384
384
|
|
|
385
385
|
function isSliderLikeAndroid(node: any): boolean {
|
|
386
386
|
const className = String(node['@_class'] || '').toLowerCase()
|
|
387
|
-
return /seekbar|slider|range
|
|
387
|
+
return /seekbar|slider|range/i.test(className)
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
function extractAndroidState(node: any): UIElementState | null {
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
3
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
console.log('Starting adjust_control unit tests...')
|
|
7
|
+
|
|
8
|
+
const originalGetUITreeHandler = (Observe as any).ToolsObserve.getUITreeHandler
|
|
9
|
+
const originalGetScreenFingerprintHandler = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
10
|
+
const originalTapHandler = (ToolsInteract as any).tapHandler
|
|
11
|
+
const originalSwipeHandler = (ToolsInteract as any).swipeHandler
|
|
12
|
+
const originalExpectStateHandler = (ToolsInteract as any).expectStateHandler
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
16
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
17
|
+
screen: '',
|
|
18
|
+
resolution: { width: 1080, height: 2400 },
|
|
19
|
+
elements: [
|
|
20
|
+
{
|
|
21
|
+
text: 'Duration',
|
|
22
|
+
type: 'android.widget.SeekBar',
|
|
23
|
+
contentDescription: null,
|
|
24
|
+
clickable: true,
|
|
25
|
+
enabled: true,
|
|
26
|
+
visible: true,
|
|
27
|
+
bounds: [0, 0, 200, 40],
|
|
28
|
+
resourceId: 'seek_duration',
|
|
29
|
+
state: {
|
|
30
|
+
value: 10,
|
|
31
|
+
raw_value: 10,
|
|
32
|
+
value_range: { min: 0, max: 100 }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'fp_slider', activity: 'MainActivity' })
|
|
39
|
+
|
|
40
|
+
const wait = await ToolsInteract.waitForUIHandler({
|
|
41
|
+
selector: { text: 'Duration' },
|
|
42
|
+
condition: 'clickable',
|
|
43
|
+
timeout_ms: 200,
|
|
44
|
+
poll_interval_ms: 50,
|
|
45
|
+
platform: 'android'
|
|
46
|
+
})
|
|
47
|
+
assert.strictEqual(wait.status, 'success')
|
|
48
|
+
assert.ok(wait.element?.elementId)
|
|
49
|
+
|
|
50
|
+
const tapCalls: Array<{ platform?: string, x: number, y: number, deviceId?: string }> = []
|
|
51
|
+
const swipeCalls: Array<{ platform?: string, x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }> = []
|
|
52
|
+
;(ToolsInteract as any).tapHandler = async ({ platform, x, y, deviceId }: any) => {
|
|
53
|
+
tapCalls.push({ platform, x, y, deviceId })
|
|
54
|
+
return {
|
|
55
|
+
device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
56
|
+
success: true,
|
|
57
|
+
x,
|
|
58
|
+
y
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
;(ToolsInteract as any).swipeHandler = async ({ platform, x1, y1, x2, y2, duration, deviceId }: any) => {
|
|
62
|
+
swipeCalls.push({ platform, x1, y1, x2, y2, duration, deviceId })
|
|
63
|
+
return {
|
|
64
|
+
device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
65
|
+
success: true,
|
|
66
|
+
start: [x1, y1],
|
|
67
|
+
end: [x2, y2],
|
|
68
|
+
duration
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
;(ToolsInteract as any).expectStateHandler = async () => ({
|
|
73
|
+
success: true,
|
|
74
|
+
selector: { text: 'Duration' },
|
|
75
|
+
element_id: wait.element.elementId,
|
|
76
|
+
expected_state: { property: 'value', expected: 30 },
|
|
77
|
+
element: {
|
|
78
|
+
elementId: wait.element.elementId,
|
|
79
|
+
text: 'Duration',
|
|
80
|
+
resource_id: 'seek_duration',
|
|
81
|
+
accessibility_id: null,
|
|
82
|
+
class: 'android.widget.SeekBar',
|
|
83
|
+
bounds: [0, 0, 200, 40],
|
|
84
|
+
index: 0,
|
|
85
|
+
state: { value: 30, raw_value: 30, value_range: { min: 0, max: 100 } }
|
|
86
|
+
},
|
|
87
|
+
observed_state: { property: 'value', value: 30, raw_value: 30 },
|
|
88
|
+
reason: 'value matches expected value'
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const adjust = await ToolsInteract.adjustControlHandler({
|
|
92
|
+
element_id: wait.element.elementId,
|
|
93
|
+
property: 'value',
|
|
94
|
+
targetValue: 30,
|
|
95
|
+
tolerance: 0.5,
|
|
96
|
+
maxAttempts: 2,
|
|
97
|
+
platform: 'android'
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
assert.strictEqual(adjust.success, true)
|
|
101
|
+
assert.strictEqual(adjust.converged, true)
|
|
102
|
+
assert.strictEqual(adjust.within_tolerance, true)
|
|
103
|
+
assert.strictEqual(adjust.adjustment_mode, 'coordinate')
|
|
104
|
+
assert.strictEqual(adjust.target_state.target_value, 30)
|
|
105
|
+
assert.strictEqual(adjust.attempts, 1)
|
|
106
|
+
assert.strictEqual(tapCalls.length, 1)
|
|
107
|
+
assert.strictEqual(swipeCalls.length, 0)
|
|
108
|
+
assert.ok(tapCalls[0].x <= 66, 'tap should bias inward from the exact target point')
|
|
109
|
+
assert.strictEqual(adjust.action_type, 'adjust_control')
|
|
110
|
+
assert.strictEqual(adjust.target.selector.elementId, wait.element.elementId)
|
|
111
|
+
|
|
112
|
+
;(ToolsInteract as any).expectStateHandler = async () => ({
|
|
113
|
+
success: true,
|
|
114
|
+
selector: { text: 'Duration' },
|
|
115
|
+
element_id: wait.element.elementId,
|
|
116
|
+
expected_state: { property: 'value', expected: 2 },
|
|
117
|
+
element: {
|
|
118
|
+
elementId: wait.element.elementId,
|
|
119
|
+
text: 'Duration',
|
|
120
|
+
resource_id: 'seek_duration',
|
|
121
|
+
accessibility_id: null,
|
|
122
|
+
class: 'android.widget.SeekBar',
|
|
123
|
+
bounds: [0, 0, 200, 40],
|
|
124
|
+
index: 0,
|
|
125
|
+
state: { value: 2, raw_value: 2, value_range: { min: 0, max: 100 } }
|
|
126
|
+
},
|
|
127
|
+
observed_state: { property: 'value', value: 2, raw_value: 2 },
|
|
128
|
+
reason: 'value matches expected value'
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const lowEndAdjust = await ToolsInteract.adjustControlHandler({
|
|
132
|
+
element_id: wait.element.elementId,
|
|
133
|
+
property: 'value',
|
|
134
|
+
targetValue: 2,
|
|
135
|
+
tolerance: 0.5,
|
|
136
|
+
maxAttempts: 2,
|
|
137
|
+
platform: 'android'
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
assert.strictEqual(lowEndAdjust.success, true)
|
|
141
|
+
assert.strictEqual(lowEndAdjust.converged, true)
|
|
142
|
+
assert.strictEqual(lowEndAdjust.within_tolerance, true)
|
|
143
|
+
assert.strictEqual(lowEndAdjust.attempts, 1)
|
|
144
|
+
assert.strictEqual(tapCalls.length, 2)
|
|
145
|
+
assert.strictEqual(swipeCalls.length, 0)
|
|
146
|
+
assert.ok(tapCalls[1].x >= 22, 'low-end tap should stay inside the first step instead of hugging the edge')
|
|
147
|
+
|
|
148
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
149
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
150
|
+
screen: '',
|
|
151
|
+
resolution: { width: 1080, height: 2400 },
|
|
152
|
+
elements: [
|
|
153
|
+
{
|
|
154
|
+
text: 'Duration',
|
|
155
|
+
type: 'android.widget.SeekBar',
|
|
156
|
+
contentDescription: null,
|
|
157
|
+
clickable: true,
|
|
158
|
+
enabled: true,
|
|
159
|
+
visible: true,
|
|
160
|
+
bounds: [0, 0, 200, 40],
|
|
161
|
+
resourceId: 'seek_duration',
|
|
162
|
+
state: {
|
|
163
|
+
value: 18,
|
|
164
|
+
raw_value: 18,
|
|
165
|
+
value_range: { min: 0, max: 20 }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
;(ToolsInteract as any).expectStateHandler = async () => ({
|
|
172
|
+
success: true,
|
|
173
|
+
selector: { text: 'Duration' },
|
|
174
|
+
element_id: wait.element.elementId,
|
|
175
|
+
expected_state: { property: 'value', expected: 20 },
|
|
176
|
+
element: {
|
|
177
|
+
elementId: wait.element.elementId,
|
|
178
|
+
text: 'Duration',
|
|
179
|
+
resource_id: 'seek_duration',
|
|
180
|
+
accessibility_id: null,
|
|
181
|
+
class: 'android.widget.SeekBar',
|
|
182
|
+
bounds: [0, 0, 200, 40],
|
|
183
|
+
index: 0,
|
|
184
|
+
state: { value: 20, raw_value: 20, value_range: { min: 0, max: 20 } }
|
|
185
|
+
},
|
|
186
|
+
observed_state: { property: 'value', value: 20, raw_value: 20 },
|
|
187
|
+
reason: 'value matches expected value'
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const highEndAdjust = await ToolsInteract.adjustControlHandler({
|
|
191
|
+
element_id: wait.element.elementId,
|
|
192
|
+
property: 'value',
|
|
193
|
+
targetValue: 20,
|
|
194
|
+
tolerance: 0.5,
|
|
195
|
+
maxAttempts: 2,
|
|
196
|
+
platform: 'android'
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
assert.strictEqual(highEndAdjust.success, true)
|
|
200
|
+
assert.strictEqual(highEndAdjust.converged, true)
|
|
201
|
+
assert.strictEqual(highEndAdjust.within_tolerance, true)
|
|
202
|
+
assert.strictEqual(highEndAdjust.attempts, 1)
|
|
203
|
+
assert.strictEqual(tapCalls.length, 3)
|
|
204
|
+
assert.strictEqual(swipeCalls.length, 0)
|
|
205
|
+
assert.ok(tapCalls[2].x >= 180, 'high-end tap should bias into the last step without hitting the edge')
|
|
206
|
+
|
|
207
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
208
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
209
|
+
screen: '',
|
|
210
|
+
resolution: { width: 1440, height: 3200 },
|
|
211
|
+
elements: [
|
|
212
|
+
{
|
|
213
|
+
text: 'Precision',
|
|
214
|
+
type: 'android.widget.SeekBar',
|
|
215
|
+
contentDescription: null,
|
|
216
|
+
clickable: true,
|
|
217
|
+
enabled: true,
|
|
218
|
+
visible: true,
|
|
219
|
+
bounds: [0, 0, 3000, 40],
|
|
220
|
+
resourceId: 'seek_precision',
|
|
221
|
+
state: {
|
|
222
|
+
value: 9000,
|
|
223
|
+
raw_value: 9000,
|
|
224
|
+
value_range: { min: 0, max: 10000 }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
;(ToolsInteract as any).expectStateHandler = async () => ({
|
|
231
|
+
success: true,
|
|
232
|
+
selector: { text: 'Precision' },
|
|
233
|
+
element_id: wait.element.elementId,
|
|
234
|
+
expected_state: { property: 'value', expected: 9999 },
|
|
235
|
+
element: {
|
|
236
|
+
elementId: wait.element.elementId,
|
|
237
|
+
text: 'Precision',
|
|
238
|
+
resource_id: 'seek_precision',
|
|
239
|
+
accessibility_id: null,
|
|
240
|
+
class: 'android.widget.SeekBar',
|
|
241
|
+
bounds: [0, 0, 3000, 40],
|
|
242
|
+
index: 0,
|
|
243
|
+
state: { value: 9999, raw_value: 9999, value_range: { min: 0, max: 10000 } }
|
|
244
|
+
},
|
|
245
|
+
observed_state: { property: 'value', value: 9999, raw_value: 9999 },
|
|
246
|
+
reason: 'value matches expected value'
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const precisionAdjust = await ToolsInteract.adjustControlHandler({
|
|
250
|
+
selector: { text: 'Precision' },
|
|
251
|
+
property: 'value',
|
|
252
|
+
targetValue: 9999,
|
|
253
|
+
tolerance: 0.5,
|
|
254
|
+
maxAttempts: 2,
|
|
255
|
+
platform: 'android'
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
assert.strictEqual(precisionAdjust.success, true)
|
|
259
|
+
assert.strictEqual(precisionAdjust.converged, true)
|
|
260
|
+
assert.strictEqual(precisionAdjust.within_tolerance, true)
|
|
261
|
+
assert.strictEqual(precisionAdjust.attempts, 1)
|
|
262
|
+
assert.strictEqual(tapCalls.length, 4)
|
|
263
|
+
assert.strictEqual(swipeCalls.length, 0)
|
|
264
|
+
assert.ok(tapCalls[3].x > 2750, 'wide, high-range control should not be clamped to a 3% endpoint margin')
|
|
265
|
+
|
|
266
|
+
let treeFetches = 0
|
|
267
|
+
let retryVerificationCount = 0
|
|
268
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
|
|
269
|
+
treeFetches++
|
|
270
|
+
return {
|
|
271
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
272
|
+
screen: '',
|
|
273
|
+
resolution: { width: 1080, height: 2400 },
|
|
274
|
+
elements: [
|
|
275
|
+
{
|
|
276
|
+
text: 'Duration',
|
|
277
|
+
type: 'android.widget.SeekBar',
|
|
278
|
+
contentDescription: null,
|
|
279
|
+
clickable: true,
|
|
280
|
+
enabled: true,
|
|
281
|
+
visible: true,
|
|
282
|
+
bounds: [0, 0, 200, 40],
|
|
283
|
+
resourceId: 'seek_duration',
|
|
284
|
+
state: {
|
|
285
|
+
value: 10,
|
|
286
|
+
raw_value: 10,
|
|
287
|
+
value_range: { min: 0, max: 20 }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
;(ToolsInteract as any).tapHandler = async ({ platform, x, y, deviceId }: any) => {
|
|
295
|
+
tapCalls.push({ platform, x, y, deviceId })
|
|
296
|
+
return {
|
|
297
|
+
device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
298
|
+
success: false,
|
|
299
|
+
error: 'tap failed'
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
;(ToolsInteract as any).swipeHandler = async ({ platform, x1, y1, x2, y2, duration, deviceId }: any) => {
|
|
304
|
+
swipeCalls.push({ platform, x1, y1, x2, y2, duration, deviceId })
|
|
305
|
+
return {
|
|
306
|
+
device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
307
|
+
success: true,
|
|
308
|
+
start: [x1, y1],
|
|
309
|
+
end: [x2, y2],
|
|
310
|
+
duration
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
;(ToolsInteract as any).expectStateHandler = async () => {
|
|
315
|
+
retryVerificationCount++
|
|
316
|
+
const value = retryVerificationCount === 1 ? 11 : 12
|
|
317
|
+
return {
|
|
318
|
+
success: true,
|
|
319
|
+
selector: { text: 'Duration' },
|
|
320
|
+
element_id: wait.element.elementId,
|
|
321
|
+
expected_state: { property: 'value', expected: 12 },
|
|
322
|
+
element: {
|
|
323
|
+
elementId: wait.element.elementId,
|
|
324
|
+
text: 'Duration',
|
|
325
|
+
resource_id: 'seek_duration',
|
|
326
|
+
accessibility_id: null,
|
|
327
|
+
class: 'android.widget.SeekBar',
|
|
328
|
+
bounds: [0, 0, 200, 40],
|
|
329
|
+
index: 0,
|
|
330
|
+
state: { value, raw_value: value, value_range: { min: 0, max: 20 } }
|
|
331
|
+
},
|
|
332
|
+
observed_state: { property: 'value', value, raw_value: value },
|
|
333
|
+
reason: value === 12 ? 'value matches expected value' : 'value still below target'
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const cachedResolveAdjust = await ToolsInteract.adjustControlHandler({
|
|
338
|
+
element_id: wait.element.elementId,
|
|
339
|
+
property: 'value',
|
|
340
|
+
targetValue: 12,
|
|
341
|
+
tolerance: 0.5,
|
|
342
|
+
maxAttempts: 2,
|
|
343
|
+
platform: 'android'
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
assert.strictEqual(cachedResolveAdjust.success, true)
|
|
347
|
+
assert.strictEqual(cachedResolveAdjust.converged, true)
|
|
348
|
+
assert.strictEqual(cachedResolveAdjust.within_tolerance, true)
|
|
349
|
+
assert.strictEqual(cachedResolveAdjust.attempts, 3)
|
|
350
|
+
assert.strictEqual(treeFetches, 1, 'second attempt should reuse the resolved element instead of refetching the UI tree')
|
|
351
|
+
|
|
352
|
+
console.log('adjust_control unit tests passed')
|
|
353
|
+
} finally {
|
|
354
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
|
|
355
|
+
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = originalGetScreenFingerprintHandler
|
|
356
|
+
;(ToolsInteract as any).tapHandler = originalTapHandler
|
|
357
|
+
;(ToolsInteract as any).swipeHandler = originalSwipeHandler
|
|
358
|
+
;(ToolsInteract as any).expectStateHandler = originalExpectStateHandler
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
run().catch((error) => {
|
|
363
|
+
console.error(error)
|
|
364
|
+
process.exit(1)
|
|
365
|
+
})
|
|
@@ -73,6 +73,9 @@ async function run() {
|
|
|
73
73
|
process.stdout.write('res4 ' + JSON.stringify(res4, null, 2) + '\n');
|
|
74
74
|
const pass4 = res4.found === true && res4.element && res4.element.clickable === true && res4.element.resourceId === 'btn_generate' && res4.element.tapCoordinates && typeof res4.element.tapCoordinates.x === 'number' && typeof res4.element.tapCoordinates.y === 'number' && typeof res4.confidence === 'number'
|
|
75
75
|
assert.ok(pass4, 'Child text should resolve to a clickable parent ancestor')
|
|
76
|
+
assert.strictEqual(res4.resolution?.reason, 'clickable_parent_preferred')
|
|
77
|
+
assert.strictEqual(res4.resolution?.fallback_available, true)
|
|
78
|
+
assert.ok((res4.resolution?.alternates || []).length >= 1, 'Parent promotion should preserve alternates')
|
|
76
79
|
process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
|
|
77
80
|
|
|
78
81
|
// Test 5: duration label should resolve to the nearby slider control
|
|
@@ -111,6 +114,8 @@ async function run() {
|
|
|
111
114
|
process.stdout.write('Test 6: ' + (pass6 ? 'PASS' : 'FAIL') + '\n');
|
|
112
115
|
const pass6b = res6.element && res6.element.telemetry && res6.element.telemetry.sliderLike === true && res6.element.interactionHint && res6.element.interactionHint.kind === 'slider'
|
|
113
116
|
assert.ok(pass6b, 'Duration lookup should include slider-specific telemetry')
|
|
117
|
+
assert.strictEqual(res6.resolution?.reason, 'slider_track_preferred')
|
|
118
|
+
assert.strictEqual(res6.resolution?.fallback_available, true)
|
|
114
119
|
process.stdout.write('Test 6b: ' + (pass6b ? 'PASS' : 'FAIL') + '\n');
|
|
115
120
|
|
|
116
121
|
// Test 7: prefer vertical track-like control over a closer text button
|
|
@@ -31,6 +31,20 @@ async function run() {
|
|
|
31
31
|
})
|
|
32
32
|
assert.deepStrictEqual(androidElements[0].semantic, { is_clickable: true, is_container: false })
|
|
33
33
|
|
|
34
|
+
const androidProgressElements: any[] = []
|
|
35
|
+
traverseNode({
|
|
36
|
+
'@_class': 'android.widget.ProgressBar',
|
|
37
|
+
'@_text': 'Loading progress',
|
|
38
|
+
'@_content-desc': 'Loading progress',
|
|
39
|
+
'@_enabled': 'true',
|
|
40
|
+
'@_progress': '40',
|
|
41
|
+
'@_max': '100',
|
|
42
|
+
'@_bounds': '[0,0][200,40]'
|
|
43
|
+
}, androidProgressElements)
|
|
44
|
+
|
|
45
|
+
assert.notStrictEqual(androidProgressElements[0]?.role, 'slider')
|
|
46
|
+
assert.notStrictEqual(androidProgressElements[0]?.state?.value, 40)
|
|
47
|
+
|
|
34
48
|
const androidFallbackElements: any[] = []
|
|
35
49
|
traverseNode({
|
|
36
50
|
'@_class': 'android.widget.Button',
|
|
@@ -70,6 +84,16 @@ async function run() {
|
|
|
70
84
|
})
|
|
71
85
|
assert.deepStrictEqual(iosElements[0].semantic, { is_clickable: true, is_container: false })
|
|
72
86
|
|
|
87
|
+
const iosProgressElements: any[] = []
|
|
88
|
+
traverseIDBNode({
|
|
89
|
+
AXElementType: 'ProgressIndicator',
|
|
90
|
+
AXLabel: 'Loading progress',
|
|
91
|
+
AXValue: '0.4',
|
|
92
|
+
AXTraits: ['UIAccessibilityTraitUpdatesFrequently']
|
|
93
|
+
}, iosProgressElements)
|
|
94
|
+
|
|
95
|
+
assert.notStrictEqual(iosProgressElements[0]?.role, 'slider')
|
|
96
|
+
|
|
73
97
|
const iosFallbackElements: any[] = []
|
|
74
98
|
traverseIDBNode({
|
|
75
99
|
AXElementType: 'Button',
|