mobile-debug-mcp 0.21.5 → 0.23.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 (90) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +24 -5
  3. package/dist/interact/classify.js +35 -0
  4. package/dist/interact/index.js +220 -13
  5. package/dist/network/index.js +232 -0
  6. package/dist/observe/ios.js +10 -3
  7. package/dist/server-core.js +822 -0
  8. package/dist/server.js +6 -693
  9. package/dist/utils/resolve-device.js +15 -3
  10. package/docs/CHANGELOG.md +10 -1
  11. package/docs/tools/interact.md +69 -30
  12. package/package.json +3 -3
  13. package/skills/README.md +35 -0
  14. package/skills/test-authoring/SKILL.md +57 -0
  15. package/skills/test-authoring/references/repo-test-layout.md +47 -0
  16. package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
  17. package/skills/test-authoring/references/test-quality-checklist.md +39 -0
  18. package/src/interact/classify.ts +64 -0
  19. package/src/interact/index.ts +250 -13
  20. package/src/network/index.ts +268 -0
  21. package/src/observe/ios.ts +12 -3
  22. package/src/server-core.ts +879 -0
  23. package/src/server.ts +8 -754
  24. package/src/types.ts +10 -1
  25. package/src/utils/resolve-device.ts +19 -3
  26. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
  27. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
  28. package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
  29. package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
  30. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
  31. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
  32. package/test/device/index.ts +52 -0
  33. package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
  34. package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
  35. package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
  36. package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
  37. package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
  38. package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
  39. package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
  40. package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
  41. package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
  42. package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
  43. package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
  44. package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
  45. package/test/unit/index.ts +47 -27
  46. package/test/unit/interact/classify_action_outcome.test.ts +110 -0
  47. package/test/unit/interact/handler_shapes.test.ts +55 -0
  48. package/test/unit/interact/tap_element.test.ts +170 -0
  49. package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
  50. package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
  51. package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
  52. package/test/unit/manage/handler_shapes.test.ts +43 -0
  53. package/test/unit/network/get_network_activity.test.ts +181 -0
  54. package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
  55. package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
  56. package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
  57. package/test/unit/observe/ios-getlogs.test.ts +53 -0
  58. package/test/unit/observe/scroll_to_element.test.ts +127 -0
  59. package/test/unit/server/contract.test.ts +45 -0
  60. package/test/unit/server/response_shapes.test.ts +93 -0
  61. package/test/unit/system/adb_version.test.ts +35 -0
  62. package/test/unit/system/get_system_status.test.ts +20 -0
  63. package/test/unit/system/system_status.test.ts +141 -0
  64. package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
  65. package/test/unit/utils/exec.test.ts +51 -0
  66. package/test/unit/utils/resolve_device.test.ts +63 -0
  67. package/tsconfig.json +2 -2
  68. package/test/interact/device/run-real-test.ts +0 -3
  69. package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
  70. package/test/interact/unit/wait_for_ui.test.ts +0 -76
  71. package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
  72. package/test/observe/device/wait_for_element_real.ts +0 -3
  73. package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
  74. package/test/observe/unit/ios-getlogs.test.ts +0 -67
  75. package/test/observe/unit/scroll_to_element.test.ts +0 -129
  76. package/test/observe/unit/wait_for_element_mock.ts +0 -2
  77. package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
  78. package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
  79. package/test/system/adb_version.test.ts +0 -25
  80. package/test/system/get_system_status.test.ts +0 -52
  81. package/test/system/system_status.test.ts +0 -109
  82. /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
  83. /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
  84. /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
  85. /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
  86. /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
  87. /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
  88. /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
  89. /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
  90. /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',
@@ -0,0 +1,268 @@
1
+ import { execAdb, parseLogLine } from '../utils/android/utils.js'
2
+ import { execCommand } from '../utils/ios/utils.js'
3
+
4
+ export type NetworkErrorCode =
5
+ | 'timeout'
6
+ | 'dns_error'
7
+ | 'tls_error'
8
+ | 'connection_refused'
9
+ | 'connection_reset'
10
+ | 'unknown_network_error'
11
+
12
+ export type NetworkActivityStatus = 'success' | 'failure' | 'retryable'
13
+
14
+ export interface NetworkEvent {
15
+ endpoint: string
16
+ method: string
17
+ statusCode: number | null
18
+ networkError: NetworkErrorCode | null
19
+ status: NetworkActivityStatus
20
+ durationMs: number
21
+ }
22
+
23
+ export interface GetNetworkActivityResult {
24
+ requests: NetworkEvent[]
25
+ count: number
26
+ }
27
+
28
+ // ─── Module state ─────────────────────────────────────────────────────────────
29
+ // lastActionTimestamp: set when an action tool fires (tap, swipe, etc.)
30
+ // lastConsumedTimestamp: advanced after each get_network_activity call to prevent duplicates
31
+ let lastActionTimestamp = 0
32
+ let lastConsumedTimestamp = 0
33
+
34
+ export function notifyActionStart(): void {
35
+ lastActionTimestamp = Date.now()
36
+ lastConsumedTimestamp = 0
37
+ }
38
+
39
+ /** Exposed for unit tests only. */
40
+ export function _setTimestampsForTests(actionTs: number, consumedTs: number): void {
41
+ lastActionTimestamp = actionTs
42
+ lastConsumedTimestamp = consumedTs
43
+ }
44
+
45
+ // ─── Parsing constants ────────────────────────────────────────────────────────
46
+ const URL_RE = /https?:\/\/[^\s"'\]\)><]+/
47
+ const PATH_RE = /\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+/
48
+ const METHOD_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b/
49
+
50
+ const NETWORK_ERROR_PATTERNS: Array<{ re: RegExp; code: NetworkErrorCode }> = [
51
+ { re: /timed?\s*out|timeout/i, code: 'timeout' },
52
+ { re: /dns|name[\s_]resolution|host\s*not\s*found|nodename/i, code: 'dns_error' },
53
+ { re: /\btls\b|\bssl\b|certificate|handshake/i, code: 'tls_error' },
54
+ { re: /connection\s*refused/i, code: 'connection_refused' },
55
+ { re: /connection\s*reset|reset\s*by\s*peer/i, code: 'connection_reset' },
56
+ ]
57
+
58
+ const BACKGROUND_TOKENS = ['/analytics', '/metrics', '/tracking', '/log', '/events', '/telemetry', '/ping', '/beacon']
59
+ const BACKGROUND_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.css', '.js', '.svg', '.ico', '.woff', '.ttf']
60
+ const FILESYSTEM_PREFIXES = ['/data/', '/system/', '/apex/', '/proc/', '/dev/', '/vendor/', '/product/', '/storage/', '/sdcard/', '/mnt/', '/odm/', '/cache/', '/metadata/', '/acct/', '/sys/']
61
+ const FILESYSTEM_EXTENSIONS = ['.apk', '.apex', '.odex', '.vdex', '.dex', '.so', '.jar', '.bin', '.img', '.db', '.sqlite', '.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.m', '.mm', '.kt', '.java', '.swift']
62
+
63
+ // ─── Parsing helpers ─────────────────────────────────────────────────────────
64
+
65
+ function extractUrl(text: string): string | null {
66
+ const m = text.match(URL_RE)
67
+ return m ? m[0] : null
68
+ }
69
+
70
+ function isPlausibleEndpointPath(path: string): boolean {
71
+ const lower = path.toLowerCase()
72
+ if (!lower.startsWith('/')) return false
73
+ if (FILESYSTEM_PREFIXES.some((prefix) => lower.startsWith(prefix))) return false
74
+ if (FILESYSTEM_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false
75
+ return true
76
+ }
77
+
78
+ function extractPath(text: string): string | null {
79
+ const m = text.match(PATH_RE)
80
+ if (!m) return null
81
+ return isPlausibleEndpointPath(m[0]) ? m[0] : null
82
+ }
83
+
84
+ function toStatusCode(value: string | undefined): number | null {
85
+ if (!value) return null
86
+ const code = Number(value)
87
+ return code >= 100 && code <= 599 ? code : null
88
+ }
89
+
90
+ function escapeRegExp(value: string): string {
91
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92
+ }
93
+
94
+ function extractStatusCode(text: string, url: string | null, path: string | null, method: string | null): number | null {
95
+ const directHttpMatch = text.match(/\bHTTP\/\d(?:\.\d)?\s+([1-5]\d{2})\b/i) || text.match(/\bHTTP\s+([1-5]\d{2})\b/i)
96
+ if (directHttpMatch) return toStatusCode(directHttpMatch[1])
97
+
98
+ const endpointToken = url || path
99
+ const hasEndpointContext = endpointToken !== null
100
+ if (!hasEndpointContext && method === null) return null
101
+
102
+ const labeledMatch = text.match(/\b(?:status(?:\s*code)?|response(?:\s*code)?)\s*[:=]?\s*([1-5]\d{2})\b/i)
103
+ if (labeledMatch && hasEndpointContext) return toStatusCode(labeledMatch[1])
104
+
105
+ if (endpointToken) {
106
+ const escapedEndpoint = escapeRegExp(endpointToken)
107
+ const endpointThenCode = new RegExp(`${escapedEndpoint}[^\\n]*?\\b([1-5]\\d{2})\\b`, 'i')
108
+ const codeThenEndpoint = new RegExp(`\\b([1-5]\\d{2})\\b[^\\n]*?${escapedEndpoint}`, 'i')
109
+ const contextualMatch = text.match(endpointThenCode) || text.match(codeThenEndpoint)
110
+ if (contextualMatch) return toStatusCode(contextualMatch[1])
111
+ }
112
+
113
+ if (method !== null && path !== null) {
114
+ const methodPathCodeMatch = text.match(/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b[^\n]*?\b([1-5]\d{2})\b/i)
115
+ if (methodPathCodeMatch) return toStatusCode(methodPathCodeMatch[1])
116
+ }
117
+
118
+ return null
119
+ }
120
+
121
+ function extractMethod(text: string): string | null {
122
+ const m = text.match(METHOD_RE)
123
+ return m ? m[1] : null
124
+ }
125
+
126
+ function detectNetworkError(text: string): NetworkErrorCode | null {
127
+ for (const { re, code } of NETWORK_ERROR_PATTERNS) {
128
+ if (re.test(text)) return code
129
+ }
130
+ return null
131
+ }
132
+
133
+ export function normalizeEndpoint(raw: string): string {
134
+ try {
135
+ const u = new URL(raw.startsWith('/') ? `https://x${raw}` : raw)
136
+ const p = u.pathname.toLowerCase().replace(/\/+$/, '')
137
+ return p || '/'
138
+ } catch {
139
+ return raw.toLowerCase().replace(/\?.*$/, '').replace(/\/+$/, '') || '/'
140
+ }
141
+ }
142
+
143
+ export function classifyStatus(statusCode: number | null, networkError: NetworkErrorCode | null): NetworkActivityStatus {
144
+ if (networkError !== null) return 'retryable'
145
+ if (statusCode === null) return 'success' // request detected, no failure signal
146
+ if (statusCode >= 200 && statusCode <= 299) return 'success'
147
+ if (statusCode >= 400 && statusCode <= 499) return 'failure'
148
+ return 'retryable' // 5xx, 1xx, 3xx
149
+ }
150
+
151
+ function meetsEmissionCriteria(url: string | null, path: string | null, statusCode: number | null, method: string | null): boolean {
152
+ if (url !== null) return true // condition 1: full http/https URL
153
+ if (statusCode !== null) return true // condition 2: valid HTTP status code
154
+ if (method !== null && path !== null) return true // condition 3: method + path
155
+ return false
156
+ }
157
+
158
+ function classifyEventType(endpoint: string): 'primary' | 'background' {
159
+ const lower = endpoint.toLowerCase()
160
+ if (BACKGROUND_TOKENS.some(t => lower.includes(t))) return 'background'
161
+ if (BACKGROUND_EXTENSIONS.some(e => lower.endsWith(e))) return 'background'
162
+ return 'primary'
163
+ }
164
+
165
+ function filterToSignificantEvents(events: NetworkEvent[]): NetworkEvent[] {
166
+ if (events.length === 0) return events
167
+ const hasPrimary = events.some(e => classifyEventType(e.endpoint) === 'primary')
168
+ return hasPrimary ? events.filter(e => classifyEventType(e.endpoint) === 'primary') : events
169
+ }
170
+
171
+ /** Exported for unit testing. */
172
+ export function parseMessageToEvent(message: string): NetworkEvent | null {
173
+ const url = extractUrl(message)
174
+ const path = url ? null : extractPath(message)
175
+ const method = extractMethod(message)
176
+ const statusCode = extractStatusCode(message, url, path, method)
177
+ const networkError = detectNetworkError(message)
178
+
179
+ if (!meetsEmissionCriteria(url, path, statusCode, method)) return null
180
+
181
+ const rawEndpoint = url || path || 'unknown'
182
+ return {
183
+ endpoint: normalizeEndpoint(rawEndpoint),
184
+ method: method || 'unknown',
185
+ statusCode,
186
+ networkError,
187
+ status: classifyStatus(statusCode, networkError),
188
+ durationMs: 0
189
+ }
190
+ }
191
+
192
+ // ─── Android ─────────────────────────────────────────────────────────────────
193
+
194
+ async function getAndroidEvents(sinceMs: number, deviceId?: string): Promise<NetworkEvent[]> {
195
+ try {
196
+ const stdout = await execAdb(['logcat', '-d', '-v', 'threadtime', '*:V', '-t', '2000'], deviceId)
197
+ const lines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
198
+
199
+ const events: NetworkEvent[] = []
200
+ for (const line of lines) {
201
+ const parsed = parseLogLine(line)
202
+ if (parsed._iso) {
203
+ const ts = new Date(parsed._iso).getTime()
204
+ if (ts > 0 && ts <= sinceMs) continue
205
+ }
206
+ const event = parseMessageToEvent(parsed.message || line)
207
+ if (event) events.push(event)
208
+ }
209
+ return events
210
+ } catch {
211
+ return []
212
+ }
213
+ }
214
+
215
+ // ─── iOS ─────────────────────────────────────────────────────────────────────
216
+
217
+ async function getIOSEvents(sinceMs: number, deviceId = 'booted'): Promise<NetworkEvent[]> {
218
+ try {
219
+ const lookbackSeconds = Math.max(15, Math.ceil((Date.now() - sinceMs) / 1000) + 5)
220
+ const args = [
221
+ 'simctl', 'spawn', deviceId, 'log', 'show',
222
+ '--last', `${lookbackSeconds}s`,
223
+ '--style', 'syslog',
224
+ '--predicate', 'eventMessage contains "http" OR eventMessage contains "URLSession" OR eventMessage contains "Task <" OR eventMessage contains "HTTP/"'
225
+ ]
226
+ const result = await execCommand(args, deviceId)
227
+ const lines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
228
+
229
+ const events: NetworkEvent[] = []
230
+ for (const line of lines) {
231
+ const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/)
232
+ if (tsMatch) {
233
+ const ts = new Date(tsMatch[1]).getTime()
234
+ if (ts > 0 && ts <= sinceMs) continue
235
+ }
236
+ const event = parseMessageToEvent(line)
237
+ if (event) events.push(event)
238
+ }
239
+ return events
240
+ } catch {
241
+ return []
242
+ }
243
+ }
244
+
245
+ // ─── Public API ───────────────────────────────────────────────────────────────
246
+
247
+ export class ToolsNetwork {
248
+ static notifyActionStart(): void {
249
+ notifyActionStart()
250
+ }
251
+
252
+ static async getNetworkActivity(params: { platform: string; deviceId?: string }): Promise<GetNetworkActivityResult> {
253
+ const { platform, deviceId } = params
254
+
255
+ const sinceMs = lastConsumedTimestamp > lastActionTimestamp
256
+ ? lastConsumedTimestamp
257
+ : lastActionTimestamp > 0 ? lastActionTimestamp : Date.now() - 30000
258
+
259
+ const raw = platform === 'android'
260
+ ? await getAndroidEvents(sinceMs, deviceId)
261
+ : await getIOSEvents(sinceMs, deviceId)
262
+
263
+ const requests = filterToSignificantEvents(raw)
264
+ lastConsumedTimestamp = Date.now()
265
+
266
+ return { requests, count: requests.length }
267
+ }
268
+ }
@@ -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')