mobile-debug-mcp 0.21.5 → 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 +220 -13
  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 +6 -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 +250 -13
  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,14 +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>()
34
46
 
35
47
  private static _normalize(s: any): string {
36
48
  if (s === null || s === undefined) return ''
37
49
  try { return String(s).toLowerCase().trim() } catch { return '' }
38
50
  }
39
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
+
40
187
 
41
188
  private static async getInteractionService(platform?: 'android' | 'ios', deviceId?: string) {
42
189
  const effectivePlatform = platform || 'android'
@@ -50,6 +197,99 @@ export class ToolsInteract {
50
197
  return await interact.tap(x, y, resolved.id)
51
198
  }
52
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
+
53
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 }) {
54
294
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
55
295
  return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
@@ -335,6 +575,7 @@ export class ToolsInteract {
335
575
  }
336
576
 
337
577
  let conditionMet = false
578
+ let matchedElement = chosen
338
579
  if (condition === 'exists') {
339
580
  // when an index is specified, existence requires that specific index be present
340
581
  conditionMet = (pickIndex !== undefined) ? (chosen !== null) : (matchedCount >= 1)
@@ -348,11 +589,12 @@ export class ToolsInteract {
348
589
  conditionMet = visibleFlag
349
590
  } else conditionMet = false
350
591
  } else if (condition === 'clickable') {
351
- if (chosen) {
352
- const b = chosen.el.bounds
353
- const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
354
- const enabled = !!chosen.el.enabled
355
- 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
356
598
  conditionMet = visibleFlag && enabled && clickable
357
599
  } else conditionMet = false
358
600
  }
@@ -361,14 +603,9 @@ export class ToolsInteract {
361
603
  const now = Date.now()
362
604
  const latency_ms = now - overallStart
363
605
  // Build element output per spec
364
- const outEl = chosen ? {
365
- text: chosen.el.text ?? null,
366
- resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
367
- accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
368
- class: chosen.el.type ?? chosen.el.class ?? null,
369
- bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
370
- index: chosen.idx
371
- } : 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
372
609
 
373
610
  return {
374
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')