mobile-debug-mcp 0.25.0 → 0.25.1

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.
@@ -40,6 +40,11 @@ interface UiElement {
40
40
  _interactable?: boolean
41
41
  _sliderLike?: boolean
42
42
  state?: UIElementState | null
43
+ stable_id?: string | null
44
+ role?: string | null
45
+ test_tag?: string | null
46
+ selector?: { value: string | null, confidence: { score: number, reason: string } | null } | null
47
+ semantic?: { is_clickable: boolean, is_container: boolean } | null
43
48
  }
44
49
 
45
50
  interface ResolvedUiElementContext {
@@ -157,7 +162,13 @@ export class ToolsInteract {
157
162
  class: el.type ?? el.class ?? null,
158
163
  bounds,
159
164
  index,
160
- elementId
165
+ elementId,
166
+ state: el.state ?? null,
167
+ stable_id: el.stable_id ?? null,
168
+ role: el.role ?? null,
169
+ test_tag: el.test_tag ?? null,
170
+ selector: el.selector ?? null,
171
+ semantic: el.semantic ?? null
161
172
  }
162
173
  }
163
174
 
@@ -197,7 +208,12 @@ export class ToolsInteract {
197
208
  class: element.type ?? element.class ?? null,
198
209
  bounds: ToolsInteract._normalizeBounds(element.bounds),
199
210
  index,
200
- state: element.state ?? null
211
+ state: element.state ?? null,
212
+ stable_id: element.stable_id ?? null,
213
+ role: element.role ?? null,
214
+ test_tag: element.test_tag ?? null,
215
+ selector: element.selector ?? null,
216
+ semantic: element.semantic ?? null
201
217
  }
202
218
  }
203
219
 
@@ -621,6 +637,11 @@ export class ToolsInteract {
621
637
  bounds: boundsObj,
622
638
  clickable: !!best.clickable,
623
639
  enabled: !!best.enabled,
640
+ stable_id: best.stable_id ?? null,
641
+ role: best.role ?? null,
642
+ test_tag: best.test_tag ?? null,
643
+ selector: best.selector ?? null,
644
+ semantic: best.semantic ?? null,
624
645
  tapCoordinates,
625
646
  telemetry: {
626
647
  matchedIndex: best?._index ?? null,
@@ -1040,7 +1061,12 @@ export class ToolsInteract {
1040
1061
  class: result.element.class ?? null,
1041
1062
  bounds: result.element.bounds ?? null,
1042
1063
  index: typeof result.element.index === 'number' ? result.element.index : null,
1043
- state: (result.element as any).state ?? null
1064
+ state: (result.element as any).state ?? null,
1065
+ stable_id: (result.element as any).stable_id ?? null,
1066
+ role: (result.element as any).role ?? null,
1067
+ test_tag: (result.element as any).test_tag ?? null,
1068
+ selector: (result.element as any).selector ?? null,
1069
+ semantic: (result.element as any).semantic ?? null
1044
1070
  },
1045
1071
  observed: {
1046
1072
  status: result.status,
@@ -1055,7 +1081,12 @@ export class ToolsInteract {
1055
1081
  class: result.element.class ?? null,
1056
1082
  bounds: result.element.bounds ?? null,
1057
1083
  index: typeof result.element.index === 'number' ? result.element.index : null,
1058
- state: (result.element as any).state ?? null
1084
+ state: (result.element as any).state ?? null,
1085
+ stable_id: (result.element as any).stable_id ?? null,
1086
+ role: (result.element as any).role ?? null,
1087
+ test_tag: (result.element as any).test_tag ?? null,
1088
+ selector: (result.element as any).selector ?? null,
1089
+ semantic: (result.element as any).semantic ?? null
1059
1090
  }
1060
1091
  },
1061
1092
  reason: 'selector is visible'
@@ -21,6 +21,12 @@ interface SnapshotTreeElementLike {
21
21
  clickable?: boolean
22
22
  enabled?: boolean
23
23
  visible?: boolean
24
+ state?: unknown
25
+ stable_id?: string | null
26
+ role?: string | null
27
+ test_tag?: string | null
28
+ selector?: unknown
29
+ semantic?: unknown
24
30
  }
25
31
 
26
32
  interface SnapshotTreeLike {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process"
2
2
  import { promises as fs } from "fs"
3
- import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementState } from "../types.js"
3
+ import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
4
4
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
5
5
  import { createWriteStream, promises as fsPromises } from 'fs'
6
6
  import path from 'path'
@@ -22,6 +22,9 @@ export function _resetIOSExecCommandForTests() {
22
22
  interface IDBElement {
23
23
  AXFrame?: { x: number | string, y: number | string, width: number | string, height: number | string, w?: number | string, h?: number | string };
24
24
  frame?: { x: number | string, y: number | string, width: number | string, height: number | string, w?: number | string, h?: number | string };
25
+ AXIdentifier?: string;
26
+ accessibilityIdentifier?: string;
27
+ identifier?: string;
25
28
  AXUniqueId?: string;
26
29
  AXLabel?: string;
27
30
  AXValue?: string;
@@ -63,6 +66,59 @@ function parseIOSNumber(value: unknown): number | null {
63
66
  return Number.isFinite(parsed) ? parsed : null
64
67
  }
65
68
 
69
+ function normalizeIOSType(value: unknown): string {
70
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
71
+ }
72
+
73
+ function inferIOSRole(type: string, traits: string[]): string | null {
74
+ if (/slider|adjustable/.test(type) || traits.some((trait) => /adjustable|slider/.test(trait))) return 'slider'
75
+ if (/button/.test(type) || traits.some((trait) => /button/.test(trait))) return 'button'
76
+ if (/cell/.test(type)) return 'cell'
77
+ if (/switch/.test(type)) return 'switch'
78
+ if (/text field|textfield|search field/.test(type)) return 'text_field'
79
+ if (/image/.test(type)) return 'image'
80
+ if (/window|application|group|scroll view|collection view/.test(type)) return 'container'
81
+ return null
82
+ }
83
+
84
+ function getIOSStableId(node: IDBElement): string | null {
85
+ const candidates = [node.AXIdentifier, node.accessibilityIdentifier, node.identifier, node.AXUniqueId]
86
+ for (const candidate of candidates) {
87
+ if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate
88
+ }
89
+ return null
90
+ }
91
+
92
+ function buildIOSSelectorConfidence(source: 'identifier' | 'label' | 'value' | 'type' | 'none'): SelectorConfidence | null {
93
+ switch (source) {
94
+ case 'identifier':
95
+ return { score: 1, reason: 'accessibility_identifier' }
96
+ case 'label':
97
+ return { score: 0.9, reason: 'label_match' }
98
+ case 'value':
99
+ return { score: 0.75, reason: 'value_match' }
100
+ case 'type':
101
+ return { score: 0.35, reason: 'type_match' }
102
+ default:
103
+ return null
104
+ }
105
+ }
106
+
107
+ function buildIOSSelector(type: string, label: string | null, value: string | null, stableId: string | null): UIResolutionSelector | null {
108
+ if (stableId) return { value: stableId, confidence: buildIOSSelectorConfidence('identifier') }
109
+ if (label) return { value: label, confidence: buildIOSSelectorConfidence('label') }
110
+ if (value) return { value: value, confidence: buildIOSSelectorConfidence('value') }
111
+ if (type) return { value: type, confidence: buildIOSSelectorConfidence('type') }
112
+ return null
113
+ }
114
+
115
+ function buildIOSSemantic(type: string, traits: string[]): UIElementSemanticMetadata {
116
+ return {
117
+ is_clickable: traits.includes("UIAccessibilityTraitButton") || /adjustable|slider/.test(type) || type === "Button" || type === "Cell",
118
+ is_container: /window|application|group|scroll view|collection view/.test(type)
119
+ }
120
+ }
121
+
66
122
  function isIOSAdjustable(node: IDBElement, type: string, traits: string[]): boolean {
67
123
  return /slider|adjustable|stepper|progress/i.test(type) || traits.some((trait) => /adjustable|slider|progress/i.test(trait))
68
124
  }
@@ -124,6 +180,11 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
124
180
  const frame = node.AXFrame || node.frame;
125
181
  const traits = node.AXTraits || [];
126
182
  const state = extractIOSState(node, type, label, value, traits);
183
+ const normalizedType = normalizeIOSType(type)
184
+ const stableId = getIOSStableId(node)
185
+ const selector = buildIOSSelector(type, label, value, stableId)
186
+ const semantic = buildIOSSemantic(normalizedType, traits)
187
+ const role = inferIOSRole(normalizedType, traits)
127
188
 
128
189
  const clickable = traits.includes("UIAccessibilityTraitButton") || type === "Button" || type === "Cell";
129
190
 
@@ -135,14 +196,19 @@ export function traverseIDBNode(node: IDBElement, elements: UIElement[], parentI
135
196
  text: label,
136
197
  contentDescription: value,
137
198
  type: type,
138
- resourceId: node.AXUniqueId || null,
199
+ resourceId: stableId,
139
200
  clickable: clickable,
140
201
  enabled: true,
141
202
  visible: true,
142
203
  bounds: bounds,
143
204
  center: getCenter(bounds),
144
205
  depth: depth,
145
- state
206
+ state,
207
+ stable_id: stableId,
208
+ role,
209
+ test_tag: stableId,
210
+ selector,
211
+ semantic
146
212
  };
147
213
 
148
214
  if (parentIndex !== -1) {
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.25.0'
16
+ version: '0.25.1'
17
17
  }
18
18
 
19
19
  export function createServer() {
package/src/types.ts CHANGED
@@ -94,6 +94,21 @@ export interface UIElementState {
94
94
  } | null;
95
95
  }
96
96
 
97
+ export interface SelectorConfidence {
98
+ score: number;
99
+ reason: string;
100
+ }
101
+
102
+ export interface UIResolutionSelector {
103
+ value: string | null;
104
+ confidence: SelectorConfidence | null;
105
+ }
106
+
107
+ export interface UIElementSemanticMetadata {
108
+ is_clickable: boolean;
109
+ is_container: boolean;
110
+ }
111
+
97
112
  export interface CaptureAndroidScreenResponse {
98
113
  device: DeviceInfo;
99
114
  screenshot: string; // base64 encoded string
@@ -132,6 +147,11 @@ export interface UIElement {
132
147
  center?: [number, number];
133
148
  depth?: number;
134
149
  state?: UIElementState | null;
150
+ stable_id?: string | null;
151
+ role?: string | null;
152
+ test_tag?: string | null;
153
+ selector?: UIResolutionSelector | null;
154
+ semantic?: UIElementSemanticMetadata | null;
135
155
  }
136
156
 
137
157
  export interface GetUITreeResponse {
@@ -167,7 +187,7 @@ export interface CaptureDebugSnapshotRawResponse {
167
187
  activity: string | null;
168
188
  fingerprint: string | null;
169
189
  screenshot: string | null;
170
- ui_tree: unknown | null;
190
+ ui_tree: GetUITreeResponse | null;
171
191
  logs: StructuredLogEntry[];
172
192
  device?: DeviceInfo;
173
193
  screenshot_error?: string;
@@ -215,6 +235,11 @@ export interface ActionTargetResolved {
215
235
  bounds: [number, number, number, number] | null;
216
236
  index: number | null;
217
237
  state?: UIElementState | null;
238
+ stable_id?: string | null;
239
+ role?: string | null;
240
+ test_tag?: string | null;
241
+ selector?: UIResolutionSelector | null;
242
+ semantic?: UIElementSemanticMetadata | null;
218
243
  }
219
244
 
220
245
  export interface ActionExecutionResult {
@@ -1,4 +1,4 @@
1
- import { DeviceInfo, UIElement, UIElementState } from "../../types.js"
1
+ import { DeviceInfo, UIElement, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } 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'
@@ -336,6 +336,52 @@ function parseNumberAttr(value: unknown): number | null {
336
336
  return Number.isFinite(parsed) ? parsed : null
337
337
  }
338
338
 
339
+ function normalizeClassName(value: unknown): string {
340
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
341
+ }
342
+
343
+ function inferAndroidRole(className: string): string | null {
344
+ if (/seekbar|slider|progress/.test(className)) return 'slider'
345
+ if (/switch|toggle/.test(className)) return 'switch'
346
+ if (/checkbox/.test(className)) return 'checkbox'
347
+ if (/radiobutton|radio/.test(className)) return 'radio'
348
+ if (/edittext|textfield|search/.test(className)) return 'text_field'
349
+ if (/button|fab/.test(className)) return 'button'
350
+ if (/imageview|icon/.test(className)) return 'image'
351
+ if (/recyclerview|scroll|layout|viewgroup|frame/.test(className)) return 'container'
352
+ return null
353
+ }
354
+
355
+ function buildAndroidSelectorConfidence(source: 'resource_id' | 'content_desc' | 'text' | 'class' | 'none'): SelectorConfidence | null {
356
+ switch (source) {
357
+ case 'resource_id':
358
+ return { score: 1, reason: 'resource_id' }
359
+ case 'content_desc':
360
+ return { score: 0.9, reason: 'content_description' }
361
+ case 'text':
362
+ return { score: 0.6, reason: 'text_match' }
363
+ case 'class':
364
+ return { score: 0.35, reason: 'class_match' }
365
+ default:
366
+ return null
367
+ }
368
+ }
369
+
370
+ function buildAndroidSelector(text: string | null, contentDescription: string | null, resourceId: string | null, className: string): UIResolutionSelector | null {
371
+ if (resourceId) return { value: resourceId, confidence: buildAndroidSelectorConfidence('resource_id') }
372
+ if (contentDescription) return { value: contentDescription, confidence: buildAndroidSelectorConfidence('content_desc') }
373
+ if (text) return { value: text, confidence: buildAndroidSelectorConfidence('text') }
374
+ if (className) return { value: className, confidence: buildAndroidSelectorConfidence('class') }
375
+ return null
376
+ }
377
+
378
+ function buildAndroidSemantic(clickable: boolean, className: string): UIElementSemanticMetadata {
379
+ return {
380
+ is_clickable: clickable,
381
+ is_container: /recyclerview|scroll|layout|viewgroup|frame/.test(className)
382
+ }
383
+ }
384
+
339
385
  function isSliderLikeAndroid(node: any): boolean {
340
386
  const className = String(node['@_class'] || '').toLowerCase()
341
387
  return /seekbar|slider|range|progress/i.test(className)
@@ -401,29 +447,41 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
401
447
 
402
448
  let currentIndex = -1;
403
449
 
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);
410
-
411
- const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
412
-
413
- if (isUseful) {
414
- const element: UIElement = {
450
+ if (node['@_class']) {
451
+ const text = node['@_text'] || null;
452
+ const contentDescription = node['@_content-desc'] || null;
453
+ const clickable = node['@_clickable'] === 'true';
454
+ const className = String(node['@_class'] || 'unknown');
455
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
456
+ const state = extractAndroidState(node);
457
+ const role = inferAndroidRole(normalizeClassName(className));
458
+ const resourceId = typeof node['@_resource-id'] === 'string' && node['@_resource-id'].trim().length > 0 ? node['@_resource-id'] : null
459
+ const stableId = resourceId ?? (typeof contentDescription === 'string' && contentDescription.trim().length > 0 ? contentDescription : null)
460
+ const testTag = stableId
461
+ const selector = buildAndroidSelector(text, contentDescription, resourceId, normalizeClassName(className))
462
+ const semantic = buildAndroidSemantic(clickable, normalizeClassName(className))
463
+
464
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
465
+
466
+ if (isUseful) {
467
+ const element: UIElement = {
415
468
  text,
416
469
  contentDescription,
417
- type: node['@_class'] || 'unknown',
418
- resourceId: node['@_resource-id'] || null,
470
+ type: className,
471
+ resourceId,
419
472
  clickable,
420
473
  enabled: node['@_enabled'] === 'true',
421
- visible: true,
422
- bounds,
423
- center: getCenter(bounds),
424
- depth,
425
- state
426
- };
474
+ visible: true,
475
+ bounds,
476
+ center: getCenter(bounds),
477
+ depth,
478
+ state,
479
+ stable_id: stableId,
480
+ role,
481
+ test_tag: testTag,
482
+ selector,
483
+ semantic
484
+ };
427
485
 
428
486
  if (parentIndex !== -1) {
429
487
  element.parentId = parentIndex;
@@ -8,6 +8,7 @@ async function run() {
8
8
  '@_class': 'android.widget.SeekBar',
9
9
  '@_text': '',
10
10
  '@_content-desc': 'Duration',
11
+ '@_resource-id': 'com.example:id/duration',
11
12
  '@_clickable': 'true',
12
13
  '@_enabled': 'true',
13
14
  '@_selected': 'true',
@@ -21,18 +22,64 @@ async function run() {
21
22
  assert.strictEqual(androidElements[0].state?.raw_value, 7)
22
23
  assert.strictEqual(androidElements[0].state?.value, 50)
23
24
  assert.deepStrictEqual(androidElements[0].state?.value_range, { min: 0, max: 14 })
25
+ assert.strictEqual(androidElements[0].stable_id, 'com.example:id/duration')
26
+ assert.strictEqual(androidElements[0].role, 'slider')
27
+ assert.strictEqual(androidElements[0].test_tag, 'com.example:id/duration')
28
+ assert.deepStrictEqual(androidElements[0].selector, {
29
+ value: 'com.example:id/duration',
30
+ confidence: { score: 1, reason: 'resource_id' }
31
+ })
32
+ assert.deepStrictEqual(androidElements[0].semantic, { is_clickable: true, is_container: false })
33
+
34
+ const androidFallbackElements: any[] = []
35
+ traverseNode({
36
+ '@_class': 'android.widget.Button',
37
+ '@_text': '',
38
+ '@_content-desc': 'Save',
39
+ '@_clickable': 'true',
40
+ '@_enabled': 'true',
41
+ '@_bounds': '[0,0][100,50]'
42
+ }, androidFallbackElements)
43
+
44
+ assert.strictEqual(androidFallbackElements.length, 1)
45
+ assert.strictEqual(androidFallbackElements[0].resourceId, null)
46
+ assert.strictEqual(androidFallbackElements[0].stable_id, 'Save')
47
+ assert.deepStrictEqual(androidFallbackElements[0].selector, {
48
+ value: 'Save',
49
+ confidence: { score: 0.9, reason: 'content_description' }
50
+ })
24
51
 
25
52
  const iosElements: any[] = []
26
53
  traverseIDBNode({
27
54
  AXElementType: 'Slider',
28
55
  AXLabel: 'Playback speed',
29
56
  AXValue: '0.75',
57
+ AXUniqueId: 'playback_speed_slider',
30
58
  AXTraits: ['UIAccessibilityTraitAdjustable']
31
59
  }, iosElements)
32
60
 
33
61
  assert.strictEqual(iosElements.length, 1)
34
62
  assert.strictEqual(iosElements[0].state?.value, 75)
35
63
  assert.strictEqual(iosElements[0].state?.raw_value, 0.75)
64
+ assert.strictEqual(iosElements[0].stable_id, 'playback_speed_slider')
65
+ assert.strictEqual(iosElements[0].role, 'slider')
66
+ assert.strictEqual(iosElements[0].test_tag, 'playback_speed_slider')
67
+ assert.deepStrictEqual(iosElements[0].selector, {
68
+ value: 'playback_speed_slider',
69
+ confidence: { score: 1, reason: 'accessibility_identifier' }
70
+ })
71
+ assert.deepStrictEqual(iosElements[0].semantic, { is_clickable: true, is_container: false })
72
+
73
+ const iosFallbackElements: any[] = []
74
+ traverseIDBNode({
75
+ AXElementType: 'Button',
76
+ AXLabel: 'Save',
77
+ AXTraits: ['UIAccessibilityTraitButton'],
78
+ AXUniqueId: 'fallback_unique_id'
79
+ }, iosFallbackElements)
80
+
81
+ assert.strictEqual(iosFallbackElements.length, 1)
82
+ assert.strictEqual(iosFallbackElements[0].stable_id, 'fallback_unique_id')
36
83
 
37
84
  console.log('state extraction tests passed')
38
85
  }