mobile-debug-mcp 0.21.4 → 0.22.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.
Files changed (84) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +24 -5
  3. package/dist/interact/index.js +263 -41
  4. package/dist/observe/ios.js +10 -3
  5. package/dist/server-core.js +707 -0
  6. package/dist/server.js +6 -693
  7. package/dist/utils/resolve-device.js +15 -3
  8. package/docs/CHANGELOG.md +9 -1
  9. package/docs/tools/interact.md +69 -30
  10. package/package.json +3 -3
  11. package/skills/README.md +35 -0
  12. package/skills/test-authoring/SKILL.md +57 -0
  13. package/skills/test-authoring/references/repo-test-layout.md +47 -0
  14. package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
  15. package/skills/test-authoring/references/test-quality-checklist.md +39 -0
  16. package/src/interact/index.ts +286 -38
  17. package/src/observe/ios.ts +12 -3
  18. package/src/server-core.ts +762 -0
  19. package/src/server.ts +8 -754
  20. package/src/types.ts +10 -1
  21. package/src/utils/resolve-device.ts +19 -3
  22. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
  23. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
  24. package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
  25. package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
  26. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
  27. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
  28. package/test/device/index.ts +52 -0
  29. package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
  30. package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
  31. package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
  32. package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
  33. package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
  34. package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
  35. package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
  36. package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
  37. package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
  38. package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
  39. package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
  40. package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
  41. package/test/unit/index.ts +47 -27
  42. package/test/unit/interact/handler_shapes.test.ts +55 -0
  43. package/test/unit/interact/tap_element.test.ts +170 -0
  44. package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
  45. package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
  46. package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
  47. package/test/unit/manage/handler_shapes.test.ts +43 -0
  48. package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
  49. package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
  50. package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
  51. package/test/unit/observe/ios-getlogs.test.ts +53 -0
  52. package/test/unit/observe/scroll_to_element.test.ts +127 -0
  53. package/test/unit/server/contract.test.ts +45 -0
  54. package/test/unit/server/response_shapes.test.ts +93 -0
  55. package/test/unit/system/adb_version.test.ts +35 -0
  56. package/test/unit/system/get_system_status.test.ts +20 -0
  57. package/test/unit/system/system_status.test.ts +141 -0
  58. package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
  59. package/test/unit/utils/exec.test.ts +51 -0
  60. package/test/unit/utils/resolve_device.test.ts +63 -0
  61. package/tsconfig.json +2 -2
  62. package/test/interact/device/run-real-test.ts +0 -3
  63. package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
  64. package/test/interact/unit/wait_for_ui.test.ts +0 -76
  65. package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
  66. package/test/observe/device/wait_for_element_real.ts +0 -3
  67. package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
  68. package/test/observe/unit/ios-getlogs.test.ts +0 -67
  69. package/test/observe/unit/scroll_to_element.test.ts +0 -129
  70. package/test/observe/unit/wait_for_element_mock.ts +0 -2
  71. package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
  72. package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
  73. package/test/system/adb_version.test.ts +0 -25
  74. package/test/system/get_system_status.test.ts +0 -52
  75. package/test/system/system_status.test.ts +0 -109
  76. /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
  77. /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
  78. /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
  79. /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
  80. /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
  81. /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
  82. /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
  83. /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
  84. /package/test/{observe/unit → unit/observe}/logstream.test.ts +0 -0
@@ -1,9 +1,11 @@
1
+ import { createHash } from 'crypto'
1
2
  import { AndroidInteract } from './android.js';
2
3
  import { iOSInteract } from './ios.js';
3
4
  export { AndroidInteract, iOSInteract };
4
5
 
5
6
  import { resolveTargetDevice } from '../utils/resolve-device.js'
6
7
  import { ToolsObserve } from '../observe/index.js'
8
+ import type { TapElementResponse } from '../types.js'
7
9
 
8
10
  interface ScreenFingerprintResponse { fingerprint: string | null }
9
11
 
@@ -29,8 +31,159 @@ interface UiElement {
29
31
  _interactable?: boolean
30
32
  }
31
33
 
34
+ interface ResolvedUiElementContext {
35
+ elementId: string
36
+ platform: 'android' | 'ios'
37
+ deviceId?: string
38
+ bounds: [number, number, number, number] | null
39
+ index: number
40
+ }
41
+
32
42
 
33
43
  export class ToolsInteract {
44
+ private static readonly _maxResolvedUiElements = 256
45
+ private static _resolvedUiElements = new Map<string, ResolvedUiElementContext>()
46
+
47
+ private static _normalize(s: any): string {
48
+ if (s === null || s === undefined) return ''
49
+ try { return String(s).toLowerCase().trim() } catch { return '' }
50
+ }
51
+
52
+ private static _normalizeBounds(bounds: any): [number, number, number, number] | null {
53
+ if (!Array.isArray(bounds) || bounds.length < 4) return null
54
+ const normalized = bounds.slice(0, 4).map((value: any) => Number(value))
55
+ if (normalized.some((value: number) => Number.isNaN(value))) return null
56
+ return normalized as [number, number, number, number]
57
+ }
58
+
59
+ private static _isVisibleElement(el: UiElement): boolean {
60
+ const bounds = ToolsInteract._normalizeBounds(el.bounds)
61
+ return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1]
62
+ }
63
+
64
+ private static _computeElementId(platform: 'android' | 'ios', deviceId: string | undefined, el: UiElement, index: number): string {
65
+ const identity = {
66
+ platform,
67
+ deviceId: deviceId || '',
68
+ text: ToolsInteract._normalize(el.text ?? el.label ?? el.value ?? ''),
69
+ resourceId: ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? ''),
70
+ accessibilityId: ToolsInteract._normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? ''),
71
+ class: ToolsInteract._normalize(el.type ?? el.class ?? ''),
72
+ bounds: ToolsInteract._normalizeBounds(el.bounds) ?? [0, 0, 0, 0],
73
+ index
74
+ }
75
+ return `el_${createHash('sha1').update(JSON.stringify(identity)).digest('hex').slice(0, 24)}`
76
+ }
77
+
78
+ private static _buildResolvedElement(platform: 'android' | 'ios', deviceId: string | undefined, el: UiElement, index: number) {
79
+ const bounds = ToolsInteract._normalizeBounds(el.bounds)
80
+ const elementId = ToolsInteract._computeElementId(platform, deviceId, el, index)
81
+
82
+ ToolsInteract._rememberResolvedElement(elementId, {
83
+ elementId,
84
+ platform,
85
+ deviceId,
86
+ bounds,
87
+ index
88
+ })
89
+
90
+ return {
91
+ text: el.text ?? null,
92
+ resource_id: el.resourceId ?? el.resourceID ?? el.id ?? null,
93
+ accessibility_id: el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? el.label ?? null,
94
+ class: el.type ?? el.class ?? null,
95
+ bounds,
96
+ index,
97
+ elementId
98
+ }
99
+ }
100
+
101
+ private static _rememberResolvedElement(elementId: string, context: ResolvedUiElementContext) {
102
+ if (ToolsInteract._resolvedUiElements.has(elementId)) {
103
+ ToolsInteract._resolvedUiElements.delete(elementId)
104
+ }
105
+
106
+ ToolsInteract._resolvedUiElements.set(elementId, context)
107
+
108
+ while (ToolsInteract._resolvedUiElements.size > ToolsInteract._maxResolvedUiElements) {
109
+ const oldestElementId = ToolsInteract._resolvedUiElements.keys().next().value
110
+ if (!oldestElementId) break
111
+ ToolsInteract._resolvedUiElements.delete(oldestElementId)
112
+ }
113
+ }
114
+
115
+ static _resetResolvedUiElementsForTests() {
116
+ ToolsInteract._resolvedUiElements.clear()
117
+ }
118
+
119
+ private static _findCurrentResolvedElement(
120
+ elements: UiElement[],
121
+ platform: 'android' | 'ios',
122
+ deviceId: string | undefined,
123
+ resolved: ResolvedUiElementContext
124
+ ): { el: UiElement, index: number } | null {
125
+ const indexedCandidate = elements[resolved.index]
126
+ if (indexedCandidate && ToolsInteract._computeElementId(platform, deviceId, indexedCandidate, resolved.index) === resolved.elementId) {
127
+ return { el: indexedCandidate, index: resolved.index }
128
+ }
129
+
130
+ return null
131
+ }
132
+
133
+ private static _resolveActionableAncestor(elements: UiElement[], chosen: { el: UiElement, idx: number } | null): { el: UiElement, idx: number } | null {
134
+ if (!chosen) return null
135
+ if (chosen.el.clickable || chosen.el.focusable) return chosen
136
+
137
+ let current = chosen
138
+ let safety = 0
139
+
140
+ while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable) && current.el.parentId !== undefined && current.el.parentId !== null) {
141
+ const parentId = current.el.parentId
142
+ let parentIndex: number | null = null
143
+
144
+ if (typeof parentId === 'number') parentIndex = parentId
145
+ else if (typeof parentId === 'string' && /^\d+$/.test(parentId)) parentIndex = Number(parentId)
146
+
147
+ if (parentIndex !== null && elements[parentIndex]) {
148
+ current = { el: elements[parentIndex], idx: parentIndex }
149
+ if (current.el.clickable || current.el.focusable) return current
150
+ } else if (typeof parentId === 'string') {
151
+ const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId)
152
+ if (foundIndex === -1) break
153
+ current = { el: elements[foundIndex], idx: foundIndex }
154
+ if (current.el.clickable || current.el.focusable) return current
155
+ } else {
156
+ break
157
+ }
158
+
159
+ safety++
160
+ }
161
+
162
+ const childBounds = ToolsInteract._normalizeBounds(chosen.el.bounds)
163
+ if (!childBounds) return null
164
+ const [cl, ct, cr, cb] = childBounds
165
+
166
+ let best: { el: UiElement, idx: number } | null = null
167
+ let bestArea = Infinity
168
+
169
+ for (let i = 0; i < elements.length; i++) {
170
+ const el = elements[i]
171
+ if (!el || !(el.clickable || el.focusable)) continue
172
+ const bounds = ToolsInteract._normalizeBounds(el.bounds)
173
+ if (!bounds) continue
174
+ const [pl, pt, pr, pb] = bounds
175
+ if (pl <= cl && pt <= ct && pr >= cr && pb >= cb) {
176
+ const area = (pr - pl) * (pb - pt)
177
+ if (area < bestArea) {
178
+ bestArea = area
179
+ best = { el, idx: i }
180
+ }
181
+ }
182
+ }
183
+
184
+ return best
185
+ }
186
+
34
187
 
35
188
  private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
36
189
  const effectivePlatform = platform || 'android'
@@ -44,6 +197,99 @@ export class ToolsInteract {
44
197
  return await interact.tap(x, y, resolved.id)
45
198
  }
46
199
 
200
+ static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
201
+ const action = 'tap' as const
202
+ const resolved = ToolsInteract._resolvedUiElements.get(elementId)
203
+ if (!resolved) {
204
+ return {
205
+ success: false,
206
+ elementId,
207
+ action,
208
+ error: {
209
+ code: 'element_not_found',
210
+ message: 'Element ID was not found in the current UI context'
211
+ }
212
+ }
213
+ }
214
+
215
+ const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
216
+ const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
217
+ const treeDeviceId = tree?.device?.id || resolved.deviceId
218
+ const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
219
+ const currentMatch = ToolsInteract._findCurrentResolvedElement(elements, treePlatform, treeDeviceId, resolved)
220
+
221
+ if (!currentMatch) {
222
+ return {
223
+ success: false,
224
+ elementId,
225
+ action,
226
+ error: {
227
+ code: 'element_not_found',
228
+ message: 'Element ID is not present in the current UI context'
229
+ }
230
+ }
231
+ }
232
+
233
+ if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
234
+ return {
235
+ success: false,
236
+ elementId,
237
+ action,
238
+ error: {
239
+ code: 'element_not_visible',
240
+ message: 'Element is not visible'
241
+ }
242
+ }
243
+ }
244
+
245
+ if (currentMatch.el.enabled === false) {
246
+ return {
247
+ success: false,
248
+ elementId,
249
+ action,
250
+ error: {
251
+ code: 'element_not_enabled',
252
+ message: 'Element is not enabled'
253
+ }
254
+ }
255
+ }
256
+
257
+ const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds
258
+ if (!bounds || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
259
+ return {
260
+ success: false,
261
+ elementId,
262
+ action,
263
+ error: {
264
+ code: 'element_not_visible',
265
+ message: 'Element does not have valid visible bounds'
266
+ }
267
+ }
268
+ }
269
+
270
+ const x = Math.floor((bounds[0] + bounds[2]) / 2)
271
+ const y = Math.floor((bounds[1] + bounds[3]) / 2)
272
+ const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
273
+
274
+ if (!tapResult.success) {
275
+ return {
276
+ success: false,
277
+ elementId,
278
+ action,
279
+ error: {
280
+ code: 'tap_failed',
281
+ message: tapResult.error || 'Tap failed'
282
+ }
283
+ }
284
+ }
285
+
286
+ return {
287
+ success: true,
288
+ elementId,
289
+ action
290
+ }
291
+ }
292
+
47
293
  static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
48
294
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
49
295
  return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
@@ -68,7 +314,7 @@ export class ToolsInteract {
68
314
  // Try to use observe layer to fetch the current UI tree and perform a fast semantic search
69
315
  const start = Date.now()
70
316
  const deadline = start + timeoutMs
71
- const normalize = (s: any) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim()
317
+ const normalize = ToolsInteract._normalize
72
318
 
73
319
  const q = normalize(query)
74
320
  if (!q) return { found: false, error: 'Empty query' }
@@ -221,16 +467,20 @@ export class ToolsInteract {
221
467
  static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
222
468
  const overallStart = Date.now()
223
469
 
224
- // Validate selector: require at least one of text, resource_id, or accessibility_id
225
- if (!selector || (typeof selector === 'object' && Object.keys(selector).length === 0)) {
226
- return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'At least one selector field must be provided (text, resource_id, or accessibility_id)' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
227
- }
470
+ // Validate selector: require at least one non-empty field (text, resource_id, or accessibility_id)
471
+ const hasText = typeof selector?.text === 'string' && selector.text.trim().length > 0;
472
+ const hasResId = typeof selector?.resource_id === 'string' && selector.resource_id.trim().length > 0;
473
+ const hasAccId = typeof selector?.accessibility_id === 'string' && selector.accessibility_id.trim().length > 0;
228
474
 
229
- const hasText = selector && typeof (selector as any).text === 'string' && (selector as any).text.trim().length > 0
230
- const hasResId = selector && typeof (selector as any).resource_id === 'string' && (selector as any).resource_id.trim().length > 0
231
- const hasAccId = selector && typeof (selector as any).accessibility_id === 'string' && (selector as any).accessibility_id.trim().length > 0
232
475
  if (!hasText && !hasResId && !hasAccId) {
233
- return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'Selector must include at least one of: text, resource_id, accessibility_id' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
476
+ return {
477
+ status: 'timeout',
478
+ error: {
479
+ code: 'INVALID_SELECTOR',
480
+ message: 'Selector must include at least one non-empty field: text, resource_id, or accessibility_id'
481
+ },
482
+ metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 }
483
+ };
234
484
  }
235
485
 
236
486
  // Validate condition
@@ -251,11 +501,11 @@ export class ToolsInteract {
251
501
  let totalPollCount = 0
252
502
 
253
503
  // Precompute normalized selector values and helpers (constant across polls)
254
- const normalize = (s: any) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim()
255
- const containsFlag = !!selector.contains
256
- const selText = normalize((selector as any).text)
257
- const selRid = normalize((selector as any).resource_id)
258
- const selAid = normalize((selector as any).accessibility_id)
504
+ const normalize = ToolsInteract._normalize
505
+ const containsFlag = !!selector?.contains
506
+ const selText = normalize(selector?.text)
507
+ const selRid = normalize(selector?.resource_id)
508
+ const selAid = normalize(selector?.accessibility_id)
259
509
 
260
510
  try {
261
511
  while (attempts < maxAttempts) {
@@ -310,26 +560,28 @@ export class ToolsInteract {
310
560
 
311
561
  // Evaluate condition
312
562
  const matchedCount = matches.length
313
- const pickIndexProvided = (match && typeof (match as any).index === 'number')
314
- const pickIndex: number = pickIndexProvided ? Number((match as any).index) : 0
563
+ const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
315
564
  let chosen: { el: any, idx: number } | null = null
316
- if (matches.length === 0) {
317
- chosen = null
318
- } else if (pickIndexProvided) {
319
- // If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
320
- if (pickIndex >= 0 && pickIndex < matches.length) chosen = matches[pickIndex]
321
- else chosen = null
565
+ if (matches.length > 0) {
566
+ if (pickIndex !== undefined) {
567
+ // If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
568
+ if (pickIndex >= 0 && pickIndex < matches.length) chosen = matches[pickIndex]
569
+ else chosen = null
570
+ } else {
571
+ chosen = matches[0]
572
+ }
322
573
  } else {
323
- chosen = matches[0]
574
+ chosen = null
324
575
  }
325
576
 
326
577
  let conditionMet = false
578
+ let matchedElement = chosen
327
579
  if (condition === 'exists') {
328
580
  // when an index is specified, existence requires that specific index be present
329
- conditionMet = pickIndexProvided ? (chosen !== null) : (matchedCount >= 1)
581
+ conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1)
330
582
  } else if (condition === 'not_exists') {
331
583
  // when an index is specified, not_exists is true if that index is absent
332
- conditionMet = pickIndexProvided ? (chosen === null) : (matchedCount === 0)
584
+ conditionMet = (pickIndex !== undefined) ? (chosen === null) : (matchedCount === 0)
333
585
  } else if (condition === 'visible') {
334
586
  if (chosen) {
335
587
  const b = chosen.el.bounds
@@ -337,11 +589,12 @@ export class ToolsInteract {
337
589
  conditionMet = visibleFlag
338
590
  } else conditionMet = false
339
591
  } else if (condition === 'clickable') {
340
- if (chosen) {
341
- const b = chosen.el.bounds
342
- const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
343
- const enabled = !!chosen.el.enabled
344
- const clickable = !!chosen.el.clickable || !!chosen.el._interactable
592
+ matchedElement = chosen ? (ToolsInteract._resolveActionableAncestor(elements, chosen as { el: UiElement, idx: number }) || chosen) : null
593
+ if (matchedElement) {
594
+ const b = matchedElement.el.bounds
595
+ const visibleFlag = !!matchedElement.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
596
+ const enabled = !!matchedElement.el.enabled
597
+ const clickable = !!matchedElement.el.clickable || !!matchedElement.el._interactable || !!matchedElement.el.focusable
345
598
  conditionMet = visibleFlag && enabled && clickable
346
599
  } else conditionMet = false
347
600
  }
@@ -350,14 +603,9 @@ export class ToolsInteract {
350
603
  const now = Date.now()
351
604
  const latency_ms = now - overallStart
352
605
  // Build element output per spec
353
- const outEl = chosen ? {
354
- text: chosen.el.text ?? null,
355
- resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
356
- accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
357
- class: chosen.el.type ?? chosen.el.class ?? null,
358
- bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
359
- index: chosen.idx
360
- } : null
606
+ const resolvedPlatform = tree?.device?.platform === 'ios' ? 'ios' : (platform || 'android')
607
+ const resolvedDeviceId = tree?.device?.id || deviceId
608
+ const outEl = matchedElement ? ToolsInteract._buildResolvedElement(resolvedPlatform, resolvedDeviceId, matchedElement.el, matchedElement.idx) : null
361
609
 
362
610
  return {
363
611
  status: 'success',
@@ -9,6 +9,15 @@ import { computeScreenFingerprint } from '../utils/ui/index.js'
9
9
  import { parsePngSize } from '../utils/image.js'
10
10
 
11
11
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
12
+ let iosExecCommand = execCommand
13
+
14
+ export function _setIOSExecCommandForTests(fn: typeof execCommand) {
15
+ iosExecCommand = fn
16
+ }
17
+
18
+ export function _resetIOSExecCommandForTests() {
19
+ iosExecCommand = execCommand
20
+ }
12
21
 
13
22
  interface IDBElement {
14
23
  AXFrame?: { x: number | string, y: number | string, width: number | string, height: number | string, w?: number | string, h?: number | string };
@@ -157,7 +166,7 @@ export class iOSObserve {
157
166
  const parts = appId.split('.')
158
167
  const simpleName = parts[parts.length - 1]
159
168
  try {
160
- const pgrepRes = await execCommand(['simctl','spawn', deviceId, 'pgrep', '-f', simpleName], deviceId)
169
+ const pgrepRes = await iosExecCommand(['simctl','spawn', deviceId, 'pgrep', '-f', simpleName], deviceId)
161
170
  const out = pgrepRes && pgrepRes.output ? pgrepRes.output.trim() : ''
162
171
  const firstLine = out.split(/\r?\n/).find(Boolean)
163
172
  if (firstLine) {
@@ -169,7 +178,7 @@ export class iOSObserve {
169
178
  }
170
179
  }
171
180
  const effectivePid = pid || detectedPid || null
172
- const result = await execCommand(args, deviceId)
181
+ const result = await iosExecCommand(args, deviceId)
173
182
  const device = await getIOSDeviceMetadata(deviceId)
174
183
  const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
175
184
 
@@ -281,7 +290,7 @@ export class iOSObserve {
281
290
  const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
282
291
 
283
292
  try {
284
- await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
293
+ await iosExecCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
285
294
 
286
295
  const buffer = await fs.readFile(tmpFile)
287
296
  const base64 = buffer.toString('base64')