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
|
@@ -78,6 +78,52 @@ async function run() {
|
|
|
78
78
|
assert.ok((res4.resolution?.alternates || []).length >= 1, 'Parent promotion should preserve alternates')
|
|
79
79
|
process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
|
|
80
80
|
|
|
81
|
+
// Test 4b: semantic-only stepper should be discoverable by supported action
|
|
82
|
+
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
83
|
+
device: { platform: 'android', id: 'mock' },
|
|
84
|
+
screen: '',
|
|
85
|
+
resolution: { width: 1080, height: 1920 },
|
|
86
|
+
elements: [
|
|
87
|
+
{
|
|
88
|
+
text: null,
|
|
89
|
+
contentDescription: 'Quantity stepper',
|
|
90
|
+
type: 'android.widget.NumberPicker',
|
|
91
|
+
clickable: false,
|
|
92
|
+
enabled: true,
|
|
93
|
+
visible: true,
|
|
94
|
+
bounds: [10,10,200,80],
|
|
95
|
+
resourceId: 'picker_quantity',
|
|
96
|
+
semantic: {
|
|
97
|
+
is_clickable: false,
|
|
98
|
+
is_container: true,
|
|
99
|
+
semantic_role: 'stepper',
|
|
100
|
+
supported_actions: ['increment', 'decrement'],
|
|
101
|
+
adjustable: true,
|
|
102
|
+
state_shape: 'discrete'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const res4b: any = await ToolsInteract.findElementHandler({ query: 'increment', exact: false, platform: 'android', timeoutMs: 300 })
|
|
109
|
+
process.stdout.write('res4b ' + JSON.stringify(res4b, null, 2) + '\n');
|
|
110
|
+
const pass4b = res4b.found === true && res4b.element && res4b.element.resourceId === 'picker_quantity' && res4b.element.semantic?.semantic_role === 'stepper'
|
|
111
|
+
assert.ok(pass4b, 'Semantic-only steppers should be discoverable by supported actions')
|
|
112
|
+
assert.strictEqual(res4b.resolution?.reason, 'semantic_action_match')
|
|
113
|
+
process.stdout.write('Test 4b: ' + (pass4b ? 'PASS' : 'FAIL') + '\n');
|
|
114
|
+
|
|
115
|
+
const res4bb: any = await ToolsInteract.findElementHandler({ query: 'increment', exact: true, platform: 'android', timeoutMs: 300 })
|
|
116
|
+
process.stdout.write('res4bb ' + JSON.stringify(res4bb, null, 2) + '\n');
|
|
117
|
+
const pass4bb = res4bb.found === true && res4bb.element && res4bb.element.resourceId === 'picker_quantity' && res4bb.resolution?.reason === 'semantic_action_match'
|
|
118
|
+
assert.ok(pass4bb, 'Exact searches should still match exact semantic actions')
|
|
119
|
+
process.stdout.write('Test 4bb: ' + (pass4bb ? 'PASS' : 'FAIL') + '\n');
|
|
120
|
+
|
|
121
|
+
const res4c: any = await ToolsInteract.findElementHandler({ query: 'control', exact: true, platform: 'android', timeoutMs: 300 })
|
|
122
|
+
process.stdout.write('res4c ' + JSON.stringify(res4c, null, 2) + '\n');
|
|
123
|
+
const pass4c = res4c.found === false
|
|
124
|
+
assert.ok(pass4c, 'Exact searches should not fall back to broad semantic keywords')
|
|
125
|
+
process.stdout.write('Test 4c: ' + (pass4c ? 'PASS' : 'FAIL') + '\n');
|
|
126
|
+
|
|
81
127
|
// Test 5: duration label should resolve to the nearby slider control
|
|
82
128
|
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
83
129
|
device: { platform: 'android', id: 'mock' },
|
|
@@ -29,7 +29,43 @@ async function run() {
|
|
|
29
29
|
value: 'com.example:id/duration',
|
|
30
30
|
confidence: { score: 1, reason: 'resource_id' }
|
|
31
31
|
})
|
|
32
|
-
assert.deepStrictEqual(androidElements[0].semantic, {
|
|
32
|
+
assert.deepStrictEqual(androidElements[0].semantic, {
|
|
33
|
+
is_clickable: true,
|
|
34
|
+
is_container: false,
|
|
35
|
+
semantic_role: 'slider',
|
|
36
|
+
supported_actions: ['adjust'],
|
|
37
|
+
adjustable: true,
|
|
38
|
+
state_shape: 'continuous'
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const androidProgressElements: any[] = []
|
|
42
|
+
traverseNode({
|
|
43
|
+
'@_class': 'android.widget.ProgressBar',
|
|
44
|
+
'@_text': 'Loading progress',
|
|
45
|
+
'@_content-desc': 'Loading progress',
|
|
46
|
+
'@_enabled': 'true',
|
|
47
|
+
'@_progress': '40',
|
|
48
|
+
'@_max': '100',
|
|
49
|
+
'@_bounds': '[0,0][200,40]'
|
|
50
|
+
}, androidProgressElements)
|
|
51
|
+
|
|
52
|
+
assert.notStrictEqual(androidProgressElements[0]?.role, 'slider')
|
|
53
|
+
assert.notStrictEqual(androidProgressElements[0]?.state?.value, 40)
|
|
54
|
+
assert.notStrictEqual(androidProgressElements[0]?.semantic?.adjustable, true)
|
|
55
|
+
|
|
56
|
+
const androidStepperElements: any[] = []
|
|
57
|
+
traverseNode({
|
|
58
|
+
'@_class': 'android.widget.NumberPicker',
|
|
59
|
+
'@_text': 'Quantity',
|
|
60
|
+
'@_content-desc': 'Quantity stepper',
|
|
61
|
+
'@_clickable': 'false',
|
|
62
|
+
'@_enabled': 'true',
|
|
63
|
+
'@_bounds': '[0,0][200,80]'
|
|
64
|
+
}, androidStepperElements)
|
|
65
|
+
assert.strictEqual(androidStepperElements[0].role, 'stepper')
|
|
66
|
+
assert.deepStrictEqual(androidStepperElements[0].semantic?.semantic_role, 'stepper')
|
|
67
|
+
assert.deepStrictEqual(androidStepperElements[0].semantic?.supported_actions, ['increment', 'decrement'])
|
|
68
|
+
assert.strictEqual(androidStepperElements[0].semantic?.adjustable, true)
|
|
33
69
|
|
|
34
70
|
const androidFallbackElements: any[] = []
|
|
35
71
|
traverseNode({
|
|
@@ -68,7 +104,58 @@ async function run() {
|
|
|
68
104
|
value: 'playback_speed_slider',
|
|
69
105
|
confidence: { score: 1, reason: 'accessibility_identifier' }
|
|
70
106
|
})
|
|
71
|
-
assert.deepStrictEqual(iosElements[0].semantic, {
|
|
107
|
+
assert.deepStrictEqual(iosElements[0].semantic, {
|
|
108
|
+
is_clickable: true,
|
|
109
|
+
is_container: false,
|
|
110
|
+
semantic_role: 'slider',
|
|
111
|
+
supported_actions: ['adjust'],
|
|
112
|
+
adjustable: true,
|
|
113
|
+
state_shape: 'continuous'
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const iosProgressElements: any[] = []
|
|
117
|
+
traverseIDBNode({
|
|
118
|
+
AXElementType: 'ProgressIndicator',
|
|
119
|
+
AXLabel: 'Loading progress',
|
|
120
|
+
AXValue: '0.4',
|
|
121
|
+
AXTraits: ['UIAccessibilityTraitUpdatesFrequently']
|
|
122
|
+
}, iosProgressElements)
|
|
123
|
+
|
|
124
|
+
assert.notStrictEqual(iosProgressElements[0]?.role, 'slider')
|
|
125
|
+
|
|
126
|
+
const iosStepperElements: any[] = []
|
|
127
|
+
traverseIDBNode({
|
|
128
|
+
AXElementType: 'Stepper',
|
|
129
|
+
AXLabel: 'Quantity',
|
|
130
|
+
AXValue: '1',
|
|
131
|
+
AXTraits: ['UIAccessibilityTraitAdjustable']
|
|
132
|
+
}, iosStepperElements)
|
|
133
|
+
assert.strictEqual(iosStepperElements[0].role, 'stepper')
|
|
134
|
+
assert.strictEqual(iosStepperElements[0].semantic?.semantic_role, 'stepper')
|
|
135
|
+
assert.deepStrictEqual(iosStepperElements[0].semantic?.supported_actions, ['increment', 'decrement'])
|
|
136
|
+
assert.strictEqual(iosStepperElements[0].semantic?.state_shape, 'discrete')
|
|
137
|
+
|
|
138
|
+
const iosSegmentedElements: any[] = []
|
|
139
|
+
traverseIDBNode({
|
|
140
|
+
AXElementType: 'Segmented Control',
|
|
141
|
+
AXLabel: 'Playback mode',
|
|
142
|
+
AXTraits: ['UIAccessibilityTraitButton']
|
|
143
|
+
}, iosSegmentedElements)
|
|
144
|
+
assert.strictEqual(iosSegmentedElements[0].role, 'segmented_control')
|
|
145
|
+
assert.strictEqual(iosSegmentedElements[0].semantic?.semantic_role, 'segmented_control')
|
|
146
|
+
assert.deepStrictEqual(iosSegmentedElements[0].semantic?.supported_actions, ['tap'])
|
|
147
|
+
|
|
148
|
+
const iosCustomAdjustableElements: any[] = []
|
|
149
|
+
traverseIDBNode({
|
|
150
|
+
AXElementType: 'CustomControl',
|
|
151
|
+
AXLabel: 'Intensity',
|
|
152
|
+
AXValue: '0.25',
|
|
153
|
+
AXTraits: ['UIAccessibilityTraitAdjustable']
|
|
154
|
+
}, iosCustomAdjustableElements)
|
|
155
|
+
assert.strictEqual(iosCustomAdjustableElements[0].semantic?.semantic_role, 'custom_adjustable')
|
|
156
|
+
assert.strictEqual(iosCustomAdjustableElements[0].semantic?.adjustable, true)
|
|
157
|
+
assert.deepStrictEqual(iosCustomAdjustableElements[0].semantic?.supported_actions, ['adjust'])
|
|
158
|
+
assert.strictEqual(iosCustomAdjustableElements[0].semantic?.state_shape, 'continuous')
|
|
72
159
|
|
|
73
160
|
const iosFallbackElements: any[] = []
|
|
74
161
|
traverseIDBNode({
|
|
@@ -16,6 +16,7 @@ async function run() {
|
|
|
16
16
|
assert(names.includes('capture_screenshot'))
|
|
17
17
|
assert(names.includes('get_ui_tree'))
|
|
18
18
|
assert(names.includes('tap_element'))
|
|
19
|
+
assert(names.includes('adjust_control'))
|
|
19
20
|
|
|
20
21
|
const waitForUI = toolDefinitions.find((tool) => tool.name === 'wait_for_ui')
|
|
21
22
|
assert(waitForUI, 'wait_for_ui should be registered')
|
|
@@ -66,6 +67,13 @@ async function run() {
|
|
|
66
67
|
assert.match((expectElementVisible as any).description, /selector is the primary input/i)
|
|
67
68
|
assert.match((expectElementVisible as any).description, /Returns structured binary success\/failure only/i)
|
|
68
69
|
|
|
70
|
+
const adjustControl = toolDefinitions.find((tool) => tool.name === 'adjust_control')
|
|
71
|
+
assert(adjustControl, 'adjust_control should be registered')
|
|
72
|
+
assert.deepStrictEqual((adjustControl as any).inputSchema.required, ['targetValue'])
|
|
73
|
+
assert.strictEqual((adjustControl as any).inputSchema.properties.targetValue.type, 'number')
|
|
74
|
+
assert.match((adjustControl as any).description, /numeric control value/i)
|
|
75
|
+
assert.match((adjustControl as any).description, /expect_state/i)
|
|
76
|
+
|
|
69
77
|
const classifyActionOutcome = toolDefinitions.find((tool) => tool.name === 'classify_action_outcome')
|
|
70
78
|
assert(classifyActionOutcome, 'classify_action_outcome should be registered')
|
|
71
79
|
assert.match((classifyActionOutcome as any).description, /action_type/i)
|
|
@@ -14,6 +14,7 @@ async function run() {
|
|
|
14
14
|
const originalExpectScreenHandler = (ToolsInteract as any).expectScreenHandler
|
|
15
15
|
const originalExpectElementVisibleHandler = (ToolsInteract as any).expectElementVisibleHandler
|
|
16
16
|
const originalExpectStateHandler = (ToolsInteract as any).expectStateHandler
|
|
17
|
+
const originalAdjustControlHandler = (ToolsInteract as any).adjustControlHandler
|
|
17
18
|
const originalStartApp = AndroidManage.prototype.startApp
|
|
18
19
|
const originalCaptureScreenshotHandler = (ToolsObserve as any).captureScreenshotHandler
|
|
19
20
|
const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
|
|
@@ -191,6 +192,43 @@ async function run() {
|
|
|
191
192
|
assert.strictEqual(expectStatePayload.expected_state.property, 'checked')
|
|
192
193
|
assert.strictEqual(expectStatePayload.observed_state.value, true)
|
|
193
194
|
|
|
195
|
+
;(ToolsInteract as any).adjustControlHandler = async () => ({
|
|
196
|
+
action_id: 'adjust_control_1',
|
|
197
|
+
timestamp: '2026-04-29T08:00:00.000Z',
|
|
198
|
+
action_type: 'adjust_control',
|
|
199
|
+
lifecycle_state: 'pending_verification',
|
|
200
|
+
source_module: 'interact',
|
|
201
|
+
target: {
|
|
202
|
+
selector: { elementId: 'el_duration' },
|
|
203
|
+
resolved: {
|
|
204
|
+
elementId: 'el_duration',
|
|
205
|
+
text: 'Duration',
|
|
206
|
+
resource_id: null,
|
|
207
|
+
accessibility_id: null,
|
|
208
|
+
class: 'android.view.View',
|
|
209
|
+
bounds: [0, 0, 100, 20],
|
|
210
|
+
index: 0
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
success: true,
|
|
214
|
+
ui_fingerprint_before: 'fp_before',
|
|
215
|
+
ui_fingerprint_after: 'fp_after',
|
|
216
|
+
target_state: { property: 'value', target_value: 30, tolerance: 0.5 },
|
|
217
|
+
actual_state: { property: 'value', value: 30, raw_value: 30 },
|
|
218
|
+
within_tolerance: true,
|
|
219
|
+
converged: true,
|
|
220
|
+
attempts: 1,
|
|
221
|
+
adjustment_mode: 'semantic'
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const adjustControlResponse = await handleToolCall('adjust_control', { element_id: 'el_duration', targetValue: 30, tolerance: 0.5, property: 'value' })
|
|
225
|
+
const adjustControlPayload = JSON.parse((adjustControlResponse as any).content[0].text)
|
|
226
|
+
assert.strictEqual(adjustControlPayload.success, true)
|
|
227
|
+
assert.strictEqual(adjustControlPayload.action_type, 'adjust_control')
|
|
228
|
+
assert.strictEqual(adjustControlPayload.target_state.target_value, 30)
|
|
229
|
+
assert.strictEqual(adjustControlPayload.within_tolerance, true)
|
|
230
|
+
assert.strictEqual(adjustControlPayload.converged, true)
|
|
231
|
+
|
|
194
232
|
;(ToolsInteract as any).tapHandler = async () => {
|
|
195
233
|
throw new Error('boom')
|
|
196
234
|
}
|
|
@@ -319,6 +357,7 @@ async function run() {
|
|
|
319
357
|
;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
|
|
320
358
|
;(ToolsObserve as any).getScreenFingerprintHandler = originalGetScreenFingerprintHandler
|
|
321
359
|
;(ToolsObserve as any).captureDebugSnapshotHandler = originalCaptureDebugSnapshotHandler
|
|
360
|
+
;(ToolsInteract as any).adjustControlHandler = originalAdjustControlHandler
|
|
322
361
|
}
|
|
323
362
|
}
|
|
324
363
|
|