mobile-debug-mcp 0.24.7 → 0.25.0

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/src/types.ts CHANGED
@@ -79,6 +79,21 @@ export interface GetCrashResponse {
79
79
  crashes: string[];
80
80
  }
81
81
 
82
+ export interface UIElementState {
83
+ checked?: boolean | null;
84
+ selected?: boolean | string | { id: string; label?: string } | null;
85
+ focused?: boolean | null;
86
+ expanded?: boolean | null;
87
+ enabled?: boolean | null;
88
+ text_value?: string | null;
89
+ value?: number | string | null;
90
+ raw_value?: number | string | null;
91
+ value_range?: {
92
+ min: number;
93
+ max: number;
94
+ } | null;
95
+ }
96
+
82
97
  export interface CaptureAndroidScreenResponse {
83
98
  device: DeviceInfo;
84
99
  screenshot: string; // base64 encoded string
@@ -116,6 +131,7 @@ export interface UIElement {
116
131
  children?: number[];
117
132
  center?: [number, number];
118
133
  depth?: number;
134
+ state?: UIElementState | null;
119
135
  }
120
136
 
121
137
  export interface GetUITreeResponse {
@@ -198,6 +214,7 @@ export interface ActionTargetResolved {
198
214
  class: string | null;
199
215
  bounds: [number, number, number, number] | null;
200
216
  index: number | null;
217
+ state?: UIElementState | null;
201
218
  }
202
219
 
203
220
  export interface ActionExecutionResult {
@@ -260,6 +277,30 @@ export interface ExpectElementVisibleResponse {
260
277
  retryable?: boolean;
261
278
  }
262
279
 
280
+ export interface ExpectStateResponse {
281
+ success: boolean;
282
+ selector?: {
283
+ text?: string;
284
+ resource_id?: string;
285
+ accessibility_id?: string;
286
+ contains?: boolean;
287
+ };
288
+ element_id: string | null;
289
+ expected_state: {
290
+ property: string;
291
+ expected: boolean | number | string | Record<string, unknown>;
292
+ };
293
+ element?: (ActionTargetResolved & { state?: UIElementState | null }) | null;
294
+ observed_state?: {
295
+ property: string;
296
+ value: boolean | number | string | Record<string, unknown> | null;
297
+ raw_value?: boolean | number | string | null;
298
+ };
299
+ reason?: string;
300
+ failure_code?: 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
301
+ retryable?: boolean;
302
+ }
303
+
263
304
  export interface SwipeResponse {
264
305
  device: DeviceInfo;
265
306
  success: boolean;
@@ -1,4 +1,4 @@
1
- import { DeviceInfo, UIElement } from "../../types.js"
1
+ import { DeviceInfo, UIElement, UIElementState } from "../../types.js"
2
2
  import { promises as fsPromises, existsSync } from 'fs'
3
3
  import path from 'path'
4
4
  import { detectJavaHome } from '../java.js'
@@ -323,6 +323,68 @@ export function getCenter(bounds: [number, number, number, number]): [number, nu
323
323
  return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
324
324
  }
325
325
 
326
+ function parseBooleanAttr(value: unknown): boolean | null {
327
+ if (value === true || value === 'true') return true
328
+ if (value === false || value === 'false') return false
329
+ return null
330
+ }
331
+
332
+ function parseNumberAttr(value: unknown): number | null {
333
+ if (typeof value === 'number' && Number.isFinite(value)) return value
334
+ if (typeof value !== 'string') return null
335
+ const parsed = Number(value)
336
+ return Number.isFinite(parsed) ? parsed : null
337
+ }
338
+
339
+ function isSliderLikeAndroid(node: any): boolean {
340
+ const className = String(node['@_class'] || '').toLowerCase()
341
+ return /seekbar|slider|range|progress/i.test(className)
342
+ }
343
+
344
+ function extractAndroidState(node: any): UIElementState | null {
345
+ const checked = parseBooleanAttr(node['@_checked'])
346
+ const selectedFlag = parseBooleanAttr(node['@_selected'])
347
+ const focused = parseBooleanAttr(node['@_focused'])
348
+ const expanded = parseBooleanAttr(node['@_expanded'])
349
+ const enabled = parseBooleanAttr(node['@_enabled'])
350
+ const textValue = typeof node['@_text'] === 'string' && node['@_text'].trim().length > 0 ? node['@_text'] : null
351
+ const state: UIElementState = {}
352
+
353
+ if (checked !== null) state.checked = checked
354
+ if (selectedFlag !== null) {
355
+ state.selected = textValue || node['@_content-desc'] || true
356
+ }
357
+ if (focused !== null) state.focused = focused
358
+ if (expanded !== null) state.expanded = expanded
359
+ if (enabled !== null) state.enabled = enabled
360
+
361
+ if (textValue && /edittext|textfield|search/i.test(String(node['@_class'] || ''))) {
362
+ state.text_value = textValue
363
+ }
364
+
365
+ if (isSliderLikeAndroid(node)) {
366
+ const rawProgress = parseNumberAttr(node['@_progress'])
367
+ const max = parseNumberAttr(node['@_max'])
368
+ const fallbackValue = rawProgress ?? parseNumberAttr(node['@_value']) ?? parseNumberAttr(node['@_content-desc'])
369
+ const numericValue = rawProgress ?? fallbackValue
370
+ if (numericValue !== null) {
371
+ state.raw_value = numericValue
372
+ state.value_range = max !== null && max > 0 ? { min: 0, max } : null
373
+ state.value = max !== null && max > 0 ? Math.round((numericValue / max) * 100) : numericValue
374
+ }
375
+ } else {
376
+ const numericValue = parseNumberAttr(node['@_value'])
377
+ if (numericValue !== null) {
378
+ state.value = numericValue
379
+ state.raw_value = numericValue
380
+ } else if (textValue) {
381
+ state.value = textValue
382
+ }
383
+ }
384
+
385
+ return Object.keys(state).length > 0 ? state : null
386
+ }
387
+
326
388
  export async function getScreenResolution(deviceId?: string): Promise<{ width: number; height: number }> {
327
389
  try {
328
390
  const output = await execAdb(['shell', 'wm', 'size'], deviceId);
@@ -339,27 +401,29 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
339
401
 
340
402
  let currentIndex = -1;
341
403
 
342
- if (node['@_class']) {
343
- const text = node['@_text'] || null;
344
- const contentDescription = node['@_content-desc'] || null;
345
- const clickable = node['@_clickable'] === 'true';
346
- const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
404
+ if (node['@_class']) {
405
+ const text = node['@_text'] || null;
406
+ const contentDescription = node['@_content-desc'] || null;
407
+ const clickable = node['@_clickable'] === 'true';
408
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
409
+ const state = extractAndroidState(node);
347
410
 
348
- const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
411
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
349
412
 
350
- if (isUseful) {
351
- const element: UIElement = {
413
+ if (isUseful) {
414
+ const element: UIElement = {
352
415
  text,
353
416
  contentDescription,
354
417
  type: node['@_class'] || 'unknown',
355
418
  resourceId: node['@_resource-id'] || null,
356
419
  clickable,
357
420
  enabled: node['@_enabled'] === 'true',
358
- visible: true,
359
- bounds,
360
- center: getCenter(bounds),
361
- depth
362
- };
421
+ visible: true,
422
+ bounds,
423
+ center: getCenter(bounds),
424
+ depth,
425
+ state
426
+ };
363
427
 
364
428
  if (parentIndex !== -1) {
365
429
  element.parentId = parentIndex;
@@ -75,14 +75,73 @@ async function run() {
75
75
  assert.ok(pass4, 'Child text should resolve to a clickable parent ancestor')
76
76
  process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
77
77
 
78
- // Test 5: not found
79
- ;(ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
80
- const res5: any = await ToolsInteract.findElementHandler({ query: 'nope', exact: false, platform: 'android', timeoutMs: 300 })
78
+ // Test 5: duration label should resolve to the nearby slider control
79
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
80
+ device: { platform: 'android', id: 'mock' },
81
+ screen: '',
82
+ resolution: { width: 1080, height: 1920 },
83
+ elements: [
84
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
85
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,1040,140], resourceId: null }
86
+ ]
87
+ })
88
+
89
+ const res5: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
81
90
  process.stdout.write('res5 ' + JSON.stringify(res5, null, 2) + '\n');
82
- const pass5 = res5.found === false
83
- assert.ok(pass5, 'Missing elements should return found=false')
91
+ const pass5 = res5.found === true && res5.element && res5.element.clickable === true && res5.element.bounds && res5.element.bounds.top === 60 && res5.element.bounds.bottom === 140
92
+ assert.ok(pass5, 'Duration label should resolve to the slider control below it')
84
93
  process.stdout.write('Test 5: ' + (pass5 ? 'PASS' : 'FAIL') + '\n');
85
94
 
95
+ // Test 6: prefer track-like control over a closer texty sibling
96
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
97
+ device: { platform: 'android', id: 'mock' },
98
+ screen: '',
99
+ resolution: { width: 1080, height: 1920 },
100
+ elements: [
101
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
102
+ { text: 'Reset', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,150,120], resourceId: 'btn_reset' },
103
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,130,1040,210], resourceId: null }
104
+ ]
105
+ })
106
+
107
+ const res6: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
108
+ process.stdout.write('res6 ' + JSON.stringify(res6, null, 2) + '\n');
109
+ const pass6 = res6.found === true && res6.element && res6.element.clickable === true && res6.element.bounds && res6.element.bounds.top === 130 && res6.element.bounds.bottom === 210
110
+ assert.ok(pass6, 'Duration lookup should prefer the track-like control over a closer text button')
111
+ process.stdout.write('Test 6: ' + (pass6 ? 'PASS' : 'FAIL') + '\n');
112
+ const pass6b = res6.element && res6.element.telemetry && res6.element.telemetry.sliderLike === true && res6.element.interactionHint && res6.element.interactionHint.kind === 'slider'
113
+ assert.ok(pass6b, 'Duration lookup should include slider-specific telemetry')
114
+ process.stdout.write('Test 6b: ' + (pass6b ? 'PASS' : 'FAIL') + '\n');
115
+
116
+ // Test 7: prefer vertical track-like control over a closer text button
117
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
118
+ device: { platform: 'android', id: 'mock' },
119
+ screen: '',
120
+ resolution: { width: 1080, height: 2400 },
121
+ elements: [
122
+ { text: 'Duration: 5 min', type: 'android.widget.TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [10,10,260,50], resourceId: null },
123
+ { text: 'Reset', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [10,60,150,120], resourceId: 'btn_reset' },
124
+ { text: null, type: 'android.view.View', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [270,20,350,1040], resourceId: null }
125
+ ]
126
+ })
127
+
128
+ const res7: any = await ToolsInteract.findElementHandler({ query: 'duration', exact: false, platform: 'android', timeoutMs: 300 })
129
+ process.stdout.write('res7 ' + JSON.stringify(res7, null, 2) + '\n');
130
+ const pass7 = res7.found === true && res7.element && res7.element.clickable === true && res7.element.bounds && res7.element.bounds.left === 270 && res7.element.bounds.right === 350
131
+ assert.ok(pass7, 'Duration lookup should prefer a vertical track-like control')
132
+ process.stdout.write('Test 7: ' + (pass7 ? 'PASS' : 'FAIL') + '\n');
133
+ const pass7b = res7.element && res7.element.interactionHint && res7.element.interactionHint.axis === 'vertical'
134
+ assert.ok(pass7b, 'Vertical sliders should report a vertical interaction axis')
135
+ process.stdout.write('Test 7b: ' + (pass7b ? 'PASS' : 'FAIL') + '\n');
136
+
137
+ // Test 8: not found
138
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
139
+ const res8: any = await ToolsInteract.findElementHandler({ query: 'nope', exact: false, platform: 'android', timeoutMs: 300 })
140
+ process.stdout.write('res8 ' + JSON.stringify(res8, null, 2) + '\n');
141
+ const pass8 = res8.found === false
142
+ assert.ok(pass8, 'Missing elements should return found=false')
143
+ process.stdout.write('Test 8: ' + (pass8 ? 'PASS' : 'FAIL') + '\n');
144
+
86
145
  } finally {
87
146
  ;(ToolsObserve as any).getUITreeHandler = origGetTree
88
147
  }
@@ -0,0 +1,43 @@
1
+ import assert from 'assert'
2
+ import { traverseNode } from '../../../src/utils/android/utils.js'
3
+ import { traverseIDBNode } from '../../../src/observe/ios.js'
4
+
5
+ async function run() {
6
+ const androidElements: any[] = []
7
+ traverseNode({
8
+ '@_class': 'android.widget.SeekBar',
9
+ '@_text': '',
10
+ '@_content-desc': 'Duration',
11
+ '@_clickable': 'true',
12
+ '@_enabled': 'true',
13
+ '@_selected': 'true',
14
+ '@_progress': '7',
15
+ '@_max': '14',
16
+ '@_bounds': '[0,0][200,40]'
17
+ }, androidElements)
18
+
19
+ assert.strictEqual(androidElements.length, 1)
20
+ assert.deepStrictEqual(androidElements[0].state?.selected, 'Duration')
21
+ assert.strictEqual(androidElements[0].state?.raw_value, 7)
22
+ assert.strictEqual(androidElements[0].state?.value, 50)
23
+ assert.deepStrictEqual(androidElements[0].state?.value_range, { min: 0, max: 14 })
24
+
25
+ const iosElements: any[] = []
26
+ traverseIDBNode({
27
+ AXElementType: 'Slider',
28
+ AXLabel: 'Playback speed',
29
+ AXValue: '0.75',
30
+ AXTraits: ['UIAccessibilityTraitAdjustable']
31
+ }, iosElements)
32
+
33
+ assert.strictEqual(iosElements.length, 1)
34
+ assert.strictEqual(iosElements[0].state?.value, 75)
35
+ assert.strictEqual(iosElements[0].state?.raw_value, 0.75)
36
+
37
+ console.log('state extraction tests passed')
38
+ }
39
+
40
+ run().catch((error) => {
41
+ console.error(error)
42
+ process.exit(1)
43
+ })
@@ -12,6 +12,7 @@ async function run() {
12
12
  const originalTapHandler = (ToolsInteract as any).tapHandler
13
13
  const originalExpectScreenHandler = (ToolsInteract as any).expectScreenHandler
14
14
  const originalExpectElementVisibleHandler = (ToolsInteract as any).expectElementVisibleHandler
15
+ const originalExpectStateHandler = (ToolsInteract as any).expectStateHandler
15
16
  const originalStartApp = AndroidManage.prototype.startApp
16
17
  const originalCaptureScreenshotHandler = (ToolsObserve as any).captureScreenshotHandler
17
18
  const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
@@ -130,8 +131,8 @@ async function run() {
130
131
  selector: { text: 'Ready' },
131
132
  element_id: 'el_ready',
132
133
  expected_condition: 'visible',
133
- element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0 },
134
- observed: { status: 'success', matched_count: 1, condition_satisfied: true, selected_index: 0, last_matched_element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0 } },
134
+ element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0, state: { enabled: true } },
135
+ observed: { status: 'success', matched_count: 1, condition_satisfied: true, selected_index: 0, last_matched_element: { elementId: 'el_ready', text: 'Ready', resource_id: null, accessibility_id: null, class: 'TextView', bounds: [0, 0, 10, 10], index: 0, state: { enabled: true } } },
135
136
  reason: 'selector is visible'
136
137
  })
137
138
 
@@ -141,6 +142,42 @@ async function run() {
141
142
  assert.strictEqual(expectElementPayload.element_id, 'el_ready')
142
143
  assert.strictEqual(expectElementPayload.expected_condition, 'visible')
143
144
 
145
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
146
+ device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
147
+ resolution: { width: 1080, height: 2400 },
148
+ elements: [{
149
+ text: 'Notifications',
150
+ depth: 0,
151
+ center: { x: 50, y: 20 },
152
+ state: { checked: true, selected: 'Notifications' }
153
+ }]
154
+ })
155
+
156
+ ;(ToolsInteract as any).expectStateHandler = async () => ({
157
+ success: true,
158
+ selector: { text: 'Notifications' },
159
+ element_id: 'el_notifications',
160
+ expected_state: { property: 'checked', expected: true },
161
+ element: {
162
+ elementId: 'el_notifications',
163
+ text: 'Notifications',
164
+ resource_id: null,
165
+ accessibility_id: null,
166
+ class: 'Switch',
167
+ bounds: [0, 0, 10, 10],
168
+ index: 0,
169
+ state: { checked: true, selected: 'Notifications' }
170
+ },
171
+ observed_state: { property: 'checked', value: true, raw_value: true },
172
+ reason: 'checked matches expected value'
173
+ })
174
+
175
+ const expectStateResponse = await handleToolCall('expect_state', { selector: { text: 'Notifications' }, property: 'checked', expected: true })
176
+ const expectStatePayload = JSON.parse((expectStateResponse as any).content[0].text)
177
+ assert.strictEqual(expectStatePayload.success, true)
178
+ assert.strictEqual(expectStatePayload.expected_state.property, 'checked')
179
+ assert.strictEqual(expectStatePayload.observed_state.value, true)
180
+
144
181
  ;(ToolsInteract as any).tapHandler = async () => {
145
182
  throw new Error('boom')
146
183
  }
@@ -234,6 +271,7 @@ async function run() {
234
271
  ;(ToolsInteract as any).tapHandler = originalTapHandler
235
272
  ;(ToolsInteract as any).expectScreenHandler = originalExpectScreenHandler
236
273
  ;(ToolsInteract as any).expectElementVisibleHandler = originalExpectElementVisibleHandler
274
+ ;(ToolsInteract as any).expectStateHandler = originalExpectStateHandler
237
275
  AndroidManage.prototype.startApp = originalStartApp
238
276
  ;(ToolsObserve as any).captureScreenshotHandler = originalCaptureScreenshotHandler
239
277
  ;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler