mobile-debug-mcp 0.35.0 → 0.37.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 (247) hide show
  1. package/.cursor-plugin/marketplace.json +1 -1
  2. package/.github/workflows/ci.yml +1 -1
  3. package/README.md +96 -24
  4. package/agent-plugin/.antigravity-plugin/README.md +48 -0
  5. package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
  6. package/agent-plugin/.claude-plugin/plugin.json +2 -2
  7. package/agent-plugin/.codex-plugin/plugin.json +1 -1
  8. package/agent-plugin/.cursor-plugin/plugin.json +2 -2
  9. package/agent-plugin/.mcp.json +1 -1
  10. package/agent-plugin/README.md +37 -8
  11. package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
  12. package/agent-plugin/cursor/LICENSE +201 -0
  13. package/agent-plugin/cursor/README.md +9 -0
  14. package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
  15. package/agent-plugin/mcp.json +1 -1
  16. package/agent-plugin/mcp_config.json +13 -0
  17. package/agent-plugin/plugin.json +1 -1
  18. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
  19. package/dist/contracts/device-readiness.js +1 -0
  20. package/dist/debugger/guard.js +4 -4
  21. package/dist/debugger/ios-worker.js +62 -0
  22. package/dist/debugger/ios.js +57 -0
  23. package/dist/debugger/sessions.js +33 -16
  24. package/dist/debugger/worker.js +6 -4
  25. package/dist/interact/index.js +12 -2
  26. package/dist/interact/waits/ui-change-wait.js +9 -2
  27. package/dist/interact/waits/ui-wait.js +7 -3
  28. package/dist/manage/index.js +3 -0
  29. package/dist/manage/ios.js +2 -0
  30. package/dist/observe/device-readiness.js +116 -0
  31. package/dist/observe/evidence.js +31 -0
  32. package/dist/observe/ios-trace/artifact.js +63 -0
  33. package/dist/observe/ios-trace/capabilities.js +86 -0
  34. package/dist/observe/ios-trace/native.js +93 -0
  35. package/dist/observe/ios-trace/notification.js +35 -0
  36. package/dist/observe/ios-trace/process.js +98 -0
  37. package/dist/observe/ios-trace/sessions.js +246 -0
  38. package/dist/observe/readiness-service.js +22 -10
  39. package/dist/observe/readiness.js +34 -4
  40. package/dist/observe/screenshot-encoding.js +4 -0
  41. package/dist/observe/snapshot-collection.js +7 -2
  42. package/dist/observe/system-trace-error.js +7 -0
  43. package/dist/observe/system-trace-router.js +81 -0
  44. package/dist/observe/system-trace.js +4 -8
  45. package/dist/server/definitions/manage.js +3 -2
  46. package/dist/server/handlers/action-execution.js +23 -14
  47. package/dist/server/handlers/compact-interaction.js +106 -0
  48. package/dist/server/handlers/compact-state.js +47 -0
  49. package/dist/server/handlers/debugger-handlers.js +27 -8
  50. package/dist/server/handlers/diagnostic.js +12 -4
  51. package/dist/server/handlers/input.js +2 -0
  52. package/dist/server/handlers/interact.js +2 -0
  53. package/dist/server/handlers/interaction-diagnostics.js +62 -0
  54. package/dist/server/handlers/journey.js +18 -20
  55. package/dist/server/handlers/observe.js +2 -1
  56. package/dist/server/handlers/tool-handlers.js +28 -14
  57. package/dist/server/schemas/debugger-schema.js +10 -7
  58. package/dist/server/schemas/interaction-diagnostics.js +9 -0
  59. package/dist/server/schemas/system-trace-schema.js +14 -9
  60. package/dist/server/tool-definitions.js +9 -4
  61. package/dist/server-core.js +5 -2
  62. package/dist/server.js +5 -5
  63. package/dist/system/android.js +19 -15
  64. package/dist/system/index.js +2 -1
  65. package/dist/utils/android/utils.js +7 -16
  66. package/dist/utils/app-installation.js +77 -0
  67. package/dist/utils/benchmark-metrics.js +43 -0
  68. package/dist/utils/exec.js +4 -0
  69. package/dist/utils/ios/utils.js +7 -11
  70. package/dist/utils/resolve-device.js +3 -2
  71. package/dist/utils/timing.js +3 -3
  72. package/docs/CHANGELOG.md +48 -1
  73. package/docs/agent-plugin-installation-verification.md +39 -0
  74. package/docs/benchmarks/README.md +1 -0
  75. package/docs/benchmarks/end-to-end-latency/README.md +98 -0
  76. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
  77. package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
  78. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
  79. package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
  80. package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
  81. package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
  82. package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
  83. package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
  84. package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
  85. package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
  86. package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
  87. package/docs/code-structure.md +3 -1
  88. package/docs/specs/031-ios-system-traces/intent.md +38 -0
  89. package/docs/specs/031-ios-system-traces/investigation.md +92 -0
  90. package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
  91. package/docs/specs/031-ios-system-traces/plan.md +166 -0
  92. package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
  93. package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
  94. package/docs/specs/031-ios-system-traces/spec.md +135 -0
  95. package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
  96. package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
  97. package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
  98. package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
  99. package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
  100. package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
  101. package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
  102. package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
  103. package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
  104. package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
  105. package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
  106. package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
  107. package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
  108. package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
  109. package/docs/specs/034-app-installation-status/intent.md +41 -0
  110. package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
  111. package/docs/specs/034-app-installation-status/plan.md +73 -0
  112. package/docs/specs/034-app-installation-status/spec.md +114 -0
  113. package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
  114. package/docs/specs/035-compact-interaction-results/intent.md +38 -0
  115. package/docs/specs/035-compact-interaction-results/plan.md +43 -0
  116. package/docs/specs/035-compact-interaction-results/spec.md +118 -0
  117. package/docs/specs/035-compact-interaction-results/verification.md +111 -0
  118. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
  119. package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
  120. package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
  121. package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
  122. package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
  123. package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
  124. package/docs/tools/TOOLS.md +3 -2
  125. package/docs/tools/compact-interactions.md +56 -0
  126. package/docs/tools/correlated-evidence.md +6 -0
  127. package/docs/tools/debugger.md +45 -5
  128. package/docs/tools/interact.md +17 -8
  129. package/docs/tools/manage.md +10 -2
  130. package/docs/tools/observe.md +42 -2
  131. package/docs/tools/system-traces.md +81 -14
  132. package/docs/tools/system.md +6 -0
  133. package/package.json +2 -1
  134. package/scripts/agent-plugin.mjs +55 -5
  135. package/src/contracts/device-readiness.ts +8 -0
  136. package/src/debugger/android.ts +1 -1
  137. package/src/debugger/guard.ts +5 -5
  138. package/src/debugger/ios-worker.ts +49 -0
  139. package/src/debugger/ios.ts +45 -0
  140. package/src/debugger/lldb/breakpoints.py +51 -0
  141. package/src/debugger/lldb/inspection.py +109 -0
  142. package/src/debugger/lldb/worker.py +203 -0
  143. package/src/debugger/sessions.ts +24 -12
  144. package/src/debugger/worker.ts +7 -4
  145. package/src/interact/index.ts +13 -2
  146. package/src/interact/waits/ui-change-wait.ts +7 -2
  147. package/src/interact/waits/ui-wait.ts +6 -3
  148. package/src/manage/index.ts +2 -0
  149. package/src/manage/ios.ts +2 -0
  150. package/src/observe/device-readiness.ts +96 -0
  151. package/src/observe/evidence.ts +30 -1
  152. package/src/observe/ios-trace/artifact.ts +55 -0
  153. package/src/observe/ios-trace/capabilities.ts +74 -0
  154. package/src/observe/ios-trace/native.ts +72 -0
  155. package/src/observe/ios-trace/notification.ts +30 -0
  156. package/src/observe/ios-trace/process.ts +82 -0
  157. package/src/observe/ios-trace/sessions.ts +206 -0
  158. package/src/observe/readiness-service.ts +22 -10
  159. package/src/observe/readiness.ts +37 -4
  160. package/src/observe/screenshot-encoding.ts +4 -0
  161. package/src/observe/snapshot-collection.ts +7 -2
  162. package/src/observe/system-trace-error.ts +3 -0
  163. package/src/observe/system-trace-router.ts +61 -0
  164. package/src/observe/system-trace.ts +4 -4
  165. package/src/server/common.ts +2 -2
  166. package/src/server/definitions/manage.ts +3 -2
  167. package/src/server/handlers/action-execution.ts +22 -14
  168. package/src/server/handlers/compact-interaction.ts +91 -0
  169. package/src/server/handlers/compact-state.ts +43 -0
  170. package/src/server/handlers/debugger-handlers.ts +28 -9
  171. package/src/server/handlers/diagnostic.ts +11 -4
  172. package/src/server/handlers/input.ts +2 -0
  173. package/src/server/handlers/interact.ts +2 -0
  174. package/src/server/handlers/interaction-diagnostics.ts +50 -0
  175. package/src/server/handlers/journey.ts +15 -17
  176. package/src/server/handlers/observe.ts +2 -1
  177. package/src/server/handlers/payload.ts +2 -1
  178. package/src/server/handlers/tool-handlers.ts +29 -14
  179. package/src/server/schemas/debugger-schema.ts +10 -7
  180. package/src/server/schemas/interaction-diagnostics.ts +10 -0
  181. package/src/server/schemas/system-trace-schema.ts +15 -9
  182. package/src/server/tool-definitions.ts +8 -4
  183. package/src/server-core.ts +5 -2
  184. package/src/server.ts +4 -4
  185. package/src/system/android.ts +18 -11
  186. package/src/system/index.ts +2 -1
  187. package/src/types.ts +2 -0
  188. package/src/utils/android/utils.ts +8 -10
  189. package/src/utils/app-installation.ts +73 -0
  190. package/src/utils/benchmark-metrics.ts +42 -0
  191. package/src/utils/exec.ts +5 -0
  192. package/src/utils/ios/utils.ts +7 -10
  193. package/src/utils/resolve-device.ts +4 -2
  194. package/src/utils/timing.ts +3 -2
  195. package/test/device/automated/debugger/runtime.android.test.ts +5 -0
  196. package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
  197. package/test/device/automated/interact/compact-results.smoke.ts +58 -0
  198. package/test/device/automated/latency/end-to-end-environment.ts +61 -0
  199. package/test/device/automated/latency/end-to-end-report.ts +90 -0
  200. package/test/device/automated/latency/end-to-end-session.ts +98 -0
  201. package/test/device/automated/latency/end-to-end.ts +149 -0
  202. package/test/device/automated/latency/owned-transport.ts +81 -0
  203. package/test/device/automated/latency/snapshot-pair.ts +143 -0
  204. package/test/device/automated/latency-ios/benchmark.ts +126 -0
  205. package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
  206. package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
  207. package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
  208. package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
  209. package/test/device/automated/observe/system-trace.smoke.ts +2 -2
  210. package/test/fixtures/contracts/tool-definitions.json +243 -47
  211. package/test/fixtures/ios-debugger-app/Main.swift +95 -0
  212. package/test/fixtures/ios-debugger-app/README.md +27 -0
  213. package/test/fixtures/ios-debugger-app/build.ts +21 -0
  214. package/test/fixtures/latency-journey-app/build.ts +3 -3
  215. package/test/helpers/ios-trace-shutdown.ts +29 -0
  216. package/test/unit/debugger/handle-guards.test.ts +89 -0
  217. package/test/unit/debugger/ios-preparation.test.ts +57 -0
  218. package/test/unit/debugger/ios.test.ts +104 -0
  219. package/test/unit/debugger/lldb-worker.py +166 -0
  220. package/test/unit/debugger/lldb-worker.test.ts +10 -0
  221. package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
  222. package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
  223. package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
  224. package/test/unit/observe/device-readiness.test.ts +97 -0
  225. package/test/unit/observe/ios-trace-native.test.ts +90 -0
  226. package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
  227. package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
  228. package/test/unit/observe/readiness.test.ts +1 -1
  229. package/test/unit/observe/system-trace-router.test.ts +49 -0
  230. package/test/unit/quality/agent-plugin-export.test.ts +38 -0
  231. package/test/unit/server/compact-interaction.test.ts +87 -0
  232. package/test/unit/server/compact-journey-evidence.test.ts +40 -0
  233. package/test/unit/server/compact-review-regressions.test.ts +78 -0
  234. package/test/unit/server/conditional_actions.test.ts +7 -3
  235. package/test/unit/server/correlated-evidence.test.ts +18 -6
  236. package/test/unit/server/end-to-end-report.test.ts +46 -0
  237. package/test/unit/server/input-device-readiness.test.ts +55 -0
  238. package/test/unit/server/interaction-diagnostics.test.ts +45 -0
  239. package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
  240. package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
  241. package/test/unit/server/readiness.test.ts +24 -1
  242. package/test/unit/server/run_journey.test.ts +2 -1
  243. package/test/unit/server/selector_tap_target.test.ts +2 -1
  244. package/test/unit/server/system-trace.test.ts +18 -7
  245. package/test/unit/utils/app-installation.test.ts +78 -0
  246. package/test/unit/utils/benchmark-metrics.test.ts +36 -0
  247. package/test/unit/utils/resolve_device.test.ts +11 -0
@@ -2,7 +2,8 @@ import { DeviceInfo } from "../../types.js"
2
2
  import { promises as fsPromises } from 'fs'
3
3
  import path from 'path'
4
4
  export { findInPath, resolveAdbCmd, getAdbCmd, ensureAdbAvailable, getAdbArgs, getAdbTimeout, type SpawnOptionsWithTimeout, execAdb, spawnAdb } from './commands.js'
5
- import { execAdb } from './commands.js'
5
+ import { execAdb, getAdbCmd } from './commands.js'
6
+ import { installationFields, probeInstallation } from '../app-installation.js'
6
7
  export { type GradleEnvOverrides, mergeEnv, prepareGradle } from './gradle.js'
7
8
  export { parseBounds, getCenter, parseBooleanAttr, parseNumberAttr, normalizeClassName, inferAndroidRole, buildAndroidSelectorConfidence, buildAndroidSelector, buildAndroidSemantic, isSliderLikeAndroid, extractAndroidState, traverseNode } from './ui-tree.js'
8
9
  export { parseLogLine } from './logs.js'
@@ -82,15 +83,12 @@ export async function listAndroidDevices(appId?: string): Promise<DeviceInfo[]>
82
83
  execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
83
84
  ])
84
85
  const simulator = simOutput === '1'
85
- let appInstalled = false
86
- if (appId) {
87
- try {
88
- const pm = await execAdb(['shell', 'pm', 'path', appId], serial)
89
- appInstalled = !!(pm && pm.includes('package:'))
90
- } catch (e: unknown) { console.debug(`[listAndroidDevices] pm check failed for ${serial}: ${String(e)}`); appInstalled = false }
91
- }
92
- return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled } as DeviceInfo & { appInstalled?: boolean }
93
- } catch (e: unknown) { console.debug(`[listAndroidDevices] failed gathering metadata for ${serial}: ${String(e)}`); return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false } as DeviceInfo & { appInstalled?: boolean } }
86
+ const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', deviceLines.find(line => line.split(/\s+/)[0] === serial)?.split(/\s+/)[1] === 'device')
87
+ return { platform: 'android', id: serial, osVersion, model, simulator, ...installationFields(check) } as DeviceInfo
88
+ } catch {
89
+ const check = await probeInstallation(getAdbCmd(), serial, appId, 'android-current-user', false)
90
+ return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, ...installationFields(check) } as DeviceInfo
91
+ }
94
92
  }))
95
93
 
96
94
  return infos
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { execCmd } from './exec.js'
3
+ import { remainingBudget } from './operation-budget.js'
4
+
5
+ export interface AppInstallation {
6
+ status: 'installed' | 'absent' | 'unknown'
7
+ appId: string | null
8
+ deviceId: string
9
+ scope: 'android-current-user' | 'ios-simulator'
10
+ checkedAt: string | null
11
+ reason?: 'APP_ID_NOT_PROVIDED' | 'DEVICE_UNAVAILABLE' | 'CHECK_FAILED' | 'UNRECOGNIZED_RESPONSE'
12
+ }
13
+ export function validAppId(appId: string) { return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(appId) }
14
+ export function installationContext(deviceId: string, appId: string | undefined, scope: AppInstallation['scope']): AppInstallation {
15
+ return { status: 'unknown', appId: appId ?? null, deviceId, scope, checkedAt: null, reason: appId === undefined ? 'APP_ID_NOT_PROVIDED' : 'CHECK_FAILED' }
16
+ }
17
+ export function installationFields(appInstallation: AppInstallation) {
18
+ return { appInstallation, ...(appInstallation.status === 'unknown' ? {} : { appInstalled: appInstallation.status === 'installed' }) }
19
+ }
20
+ export async function probeInstallation(command: string, deviceId: string, appId: string | undefined, scope: AppInstallation['scope'], available = true): Promise<AppInstallation> {
21
+ const result = installationContext(deviceId, appId, scope)
22
+ if (appId === undefined || !validAppId(appId)) return result
23
+ if (!available) return { ...result, reason: 'DEVICE_UNAVAILABLE' }
24
+ try {
25
+ return await runProbe(command, result)
26
+ } catch {
27
+ return { ...result, checkedAt: new Date().toISOString(), reason: 'CHECK_FAILED' }
28
+ }
29
+ }
30
+ async function runProbe(command: string, context: AppInstallation): Promise<AppInstallation> {
31
+ const android = context.scope === 'android-current-user'
32
+ const started = performance.now()
33
+ const timeout = remainingBudget(5000)
34
+ const args = android ? ['-s', context.deviceId, 'shell', 'pm', 'path', context.appId!] : ['simctl', 'listapps', context.deviceId]
35
+ const output = await execCmd(command, args, { timeout })
36
+ const result = { ...context, checkedAt: new Date().toISOString() }
37
+ if (output.exitCode !== 0) {
38
+ if (android && !output.stdout && !output.stderr) return confirmAndroidAbsence(command, result, Math.max(1, timeout - (performance.now() - started)))
39
+ return { ...result, reason: 'CHECK_FAILED' }
40
+ }
41
+ let status: AppInstallation['status']
42
+ if (android) {
43
+ const lines = output.stdout.trim().split('\n').filter(Boolean)
44
+ if (lines.some(line => !/^package:\/\S+$/.test(line.trim()))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
45
+ status = lines.length ? 'installed' : 'absent'
46
+ } else {
47
+ try {
48
+ const budget = remainingBudget(Math.max(1, Math.floor(timeout - (performance.now() - started))))
49
+ if (performance.now() - started >= timeout) return { ...result, reason: 'CHECK_FAILED' }
50
+ const json = await new Promise<string>((resolve, reject) => {
51
+ const child = execFile('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '--', '-'], { timeout: budget, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => error ? reject(error) : resolve(stdout))
52
+ child.stdin?.on('error', reject)
53
+ child.stdin?.end(output.stdout)
54
+ })
55
+ const apps: unknown = JSON.parse(json)
56
+ if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
57
+ if (Object.values(apps).some(value => !value || typeof value !== 'object' || Array.isArray(value))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
58
+ status = Object.hasOwn(apps, context.appId!) ? 'installed' : 'absent'
59
+ } catch { return { ...result, reason: 'UNRECOGNIZED_RESPONSE' } }
60
+ }
61
+ const { reason: _reason, ...confirmed } = result
62
+ return { ...confirmed, status }
63
+ }
64
+
65
+ async function confirmAndroidAbsence(command: string, context: AppInstallation, timeout: number): Promise<AppInstallation> {
66
+ const output = await execCmd(command, ['-s', context.deviceId, 'shell', 'pm', 'list', 'packages', context.appId!], { timeout })
67
+ const result = { ...context, checkedAt: new Date().toISOString() }
68
+ if (output.exitCode !== 0) return { ...result, reason: 'CHECK_FAILED' }
69
+ const lines = output.stdout.split('\n').map(line => line.trim()).filter(Boolean)
70
+ if (lines.some(line => !/^package:[A-Za-z0-9_.-]+$/.test(line))) return { ...result, reason: 'UNRECOGNIZED_RESPONSE' }
71
+ const { reason: _reason, ...confirmed } = result
72
+ return { ...confirmed, status: lines.includes(`package:${context.appId}`) ? 'installed' : 'absent' }
73
+ }
@@ -0,0 +1,42 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ interface Span { stage: string; start_ms: number; end_ms: number }
5
+ interface Measurement { id: string; started: number; spans: Span[] }
6
+ const measurement = new AsyncLocalStorage<Measurement>()
7
+
8
+ // Private benchmark diagnostics: no arguments, device IDs or response contents.
9
+ export function recordBenchmarkSpan(stage: string, duration: number) {
10
+ const current = measurement.getStore()
11
+ if (!current) return
12
+ const end = performance.now() - current.started
13
+ current.spans.push({ stage, start_ms: Math.max(0, end - duration), end_ms: end })
14
+ }
15
+
16
+ export async function benchmarkSubprocess<T>(operation: () => Promise<T>): Promise<T> {
17
+ return benchmarkStage('subprocess_ms', operation)
18
+ }
19
+
20
+ export async function benchmarkStage<T>(stage: string, operation: () => Promise<T>): Promise<T> {
21
+ if (!measurement.getStore()) return operation()
22
+ const started = performance.now()
23
+ try { return await operation() }
24
+ finally { recordBenchmarkSpan(stage, performance.now() - started) }
25
+ }
26
+
27
+ export async function withBenchmarkRequest<T>(tool: string, operation: () => Promise<T>): Promise<T> {
28
+ if (process.env.MOBILE_DEBUG_MCP_LATENCY_METRICS !== '1') return operation()
29
+ const current: Measurement = { id: randomUUID(), started: performance.now(), spans: [] }
30
+ return measurement.run(current, async () => {
31
+ let completed = false
32
+ try {
33
+ const result = await operation()
34
+ completed = true
35
+ return result
36
+ } finally {
37
+ process.stderr.write(`${JSON.stringify({ event: 'latency_metric', request_id: current.id,
38
+ tool, handler_completed: completed, elapsed_ms: performance.now() - current.started,
39
+ spans: current.spans })}\n`)
40
+ }
41
+ })
42
+ }
package/src/utils/exec.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { checkDebuggerGuard } from '../debugger/guard.js'
2
2
  import { remainingBudget } from './operation-budget.js'
3
3
  import { spawn } from 'child_process'
4
+ import { benchmarkSubprocess } from './benchmark-metrics.js'
4
5
 
5
6
  export type ExecOptions = { timeout?: number; env?: NodeJS.ProcessEnv; cwd?: string; shell?: boolean }
6
7
 
7
8
  export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {}): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
9
+ return benchmarkSubprocess(() => execute(cmd, args, opts))
10
+ }
11
+
12
+ async function execute(cmd: string, args: string[], opts: ExecOptions): Promise<{ exitCode: number | null, stdout: string, stderr: string }> {
8
13
  const { env, cwd, shell } = opts
9
14
  const timeout = remainingBudget(opts.timeout ?? 0)
10
15
  return new Promise((resolve, reject) => {
@@ -1,3 +1,5 @@
1
+ import { installationFields, probeInstallation } from '../app-installation.js'
2
+ import { checkDebuggerGuard } from '../../debugger/guard.js'
1
3
  import { remainingBudget } from '../operation-budget.js'
2
4
  import { execFile, spawn, execSync, spawnSync } from "child_process"
3
5
  import { DeviceInfo } from "../../types.js"
@@ -85,6 +87,7 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
85
87
  return new Promise((resolve, reject) => {
86
88
  // Use spawn for better stream control and consistency with Android implementation
87
89
  const budgetMs = remainingBudget()
90
+ checkDebuggerGuard()
88
91
  const child = spawn(getXcrunCmd(), args)
89
92
 
90
93
  let stdout = ''
@@ -132,6 +135,7 @@ export function execCommandWithDiagnostics(args: string[], deviceId: string = "b
132
135
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
133
136
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
134
137
  const timeoutMs = budgetMs || (args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT)
138
+ checkDebuggerGuard()
135
139
  const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs })
136
140
  const runResult = {
137
141
  exitCode: typeof res.status === 'number' ? res.status : null,
@@ -279,16 +283,9 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
279
283
  booted: bootedSet.has(device.udid)
280
284
  }
281
285
 
282
- if (appId) {
283
- // check if installed
284
- const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
285
- .then(() => { info.appInstalled = true })
286
- .catch(() => { info.appInstalled = false })
287
- .then(() => { out.push(info) })
288
- checks.push(p)
289
- } else {
290
- out.push(info)
291
- }
286
+ out.push(info)
287
+ checks.push(probeInstallation(getXcrunCmd(), device.udid, appId, 'ios-simulator', info.booted === true)
288
+ .then(check => { Object.assign(info, installationFields(check)) }))
292
289
  }
293
290
  }
294
291
 
@@ -51,6 +51,8 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
51
51
 
52
52
  const deviceContext = new AsyncLocalStorage<DeviceInfo>()
53
53
 
54
+ export function currentResolvedDevice() { return deviceContext.getStore() }
55
+
54
56
  export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
55
57
  bindEvidenceDevice(device)
56
58
  return deviceContext.run(device, operation)
@@ -92,7 +94,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
92
94
  if (prefer === "emulator") candidates = candidates.filter(d => d.simulator)
93
95
 
94
96
  if (appId) {
95
- const installed = candidates.filter(d => d.appInstalled)
97
+ const installed = candidates.filter(d => d.appInstallation ? d.appInstallation.status === 'installed' && d.appInstallation.appId === appId && d.appInstallation.deviceId === d.id : d.appInstalled === true)
96
98
  if (installed.length > 0) candidates = installed
97
99
  }
98
100
 
@@ -117,7 +119,7 @@ function selectDevice(devices: DeviceInfo[], platform: string, appId?: string, p
117
119
  return candidates[0]
118
120
  }
119
121
 
120
- const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }))
122
+ const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled, appInstallation: d.appInstallation }))
121
123
  const err = Object.assign(new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`), { devices: list })
122
124
  throw err
123
125
  }
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { recordBenchmarkSpan } from './benchmark-metrics.js'
2
3
  const timings = new AsyncLocalStorage<Record<string, number>>()
3
4
  export function withTiming<T>(values: Record<string, number>, operation: () => Promise<T>) { return timings.run(values, operation) }
4
5
  export function recordTiming(component: string, milliseconds: number) {
6
+ recordBenchmarkSpan(component, milliseconds)
5
7
  const values = timings.getStore()
6
8
  if (values) values[component] = (values[component] ?? 0) + milliseconds
7
9
  }
@@ -9,7 +11,6 @@ export async function measure<T>(component: string, operation: () => Promise<T>)
9
11
  const start = performance.now()
10
12
  try { return await operation() }
11
13
  finally {
12
- const values = timings.getStore()
13
- if (values) values[component] = (values[component] ?? 0) + performance.now() - start
14
+ recordTiming(component, performance.now() - start)
14
15
  }
15
16
  }
@@ -104,6 +104,11 @@ if (!process.env.MCP_DEBUGGER_DEVICE) {
104
104
  assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
105
105
  const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
106
106
  assert.equal(readiness.readiness.state, 'debugger_paused')
107
+ assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
108
+ assert.equal(readiness.device_readiness.input_ready, false)
109
+ const readyWait = await call('wait_for_app_ready', { platform: 'android', deviceId })
110
+ assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
111
+ assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
107
112
  for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
108
113
  const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
109
114
  assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
@@ -0,0 +1,227 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { readFile, rm, readdir, writeFile, rename } from 'node:fs/promises'
4
+ import { createHash } from 'node:crypto'
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
7
+ import { buildIOSDebuggerFixture, iosDebuggerAppId, iosDebuggerSource } from '../../../fixtures/ios-debugger-app/build.js'
8
+
9
+ if (!process.env.MCP_IOS_DEBUGGER_DEVICE) {
10
+ console.log('UNRUN iOS debugger: select a booted simulator with MCP_IOS_DEBUGGER_DEVICE')
11
+ } else {
12
+ const deviceId = process.env.MCP_IOS_DEBUGGER_DEVICE
13
+ const sim = (...args: string[]) => execFileSync('xcrun', ['simctl', ...args], { encoding: 'utf8', timeout: 30000 }).trim()
14
+ console.log('Building disposable iOS fixture')
15
+ const fixture = buildIOSDebuggerFixture()
16
+ console.log('Fixture built')
17
+ const source = await readFile(iosDebuggerSource, 'utf8')
18
+ const line = (marker: string) => source.split('\n').findIndex(row => row.includes('// ' + marker)) + 1
19
+ const client = new Client({ name: 'ios-debugger-smoke', version: '1.0.0' })
20
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
21
+ transport.stderr?.on('data', data => process.stderr.write(data))
22
+ const call = async (name: string, args: Record<string, unknown> = {}, signal?: AbortSignal) => {
23
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000, signal })
24
+ return JSON.parse((response.content as { type: string; text: string }[]).find(item => item.type === 'text')!.text)
25
+ }
26
+ const hash = createHash('sha256')
27
+ const fingerprint = async (directory: string): Promise<void> => {
28
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
29
+ const file = directory + '/' + entry.name
30
+ if (entry.isDirectory()) await fingerprint(file)
31
+ else if (!file.includes('__pycache__')) hash.update(file).update(await readFile(file))
32
+ }
33
+ }
34
+ await fingerprint('dist'); await fingerprint('src/debugger/lldb')
35
+ const serverHash = hash.digest('hex')
36
+ const target = { platform: 'ios', deviceId, appId: iosDebuggerAppId }
37
+ let debugSessionId = '', cursor = 0
38
+ const session = () => ({ debugSessionId })
39
+ const attach = async () => {
40
+ const response = await call('attach_debugger', { ...target, timeoutMs: 30000 })
41
+ assert.equal(response.success, true, JSON.stringify(response))
42
+ assert.equal(response.device.platform, 'ios')
43
+ debugSessionId = response.debug_session_id; cursor = 0
44
+ }
45
+ const breakpoint = async (marker: string) => {
46
+ const response = await call('set_debugger_breakpoint', { ...session(), sourceFile: iosDebuggerSource, line: line(marker) })
47
+ assert.equal(response.success, true, JSON.stringify(response))
48
+ assert.equal(response.result.status, 'resolved', JSON.stringify(response))
49
+ assert.ok(response.result.locations.every((loc: any) => loc.line === line(marker)))
50
+ return response.result.breakpoint_id
51
+ }
52
+ const remove = (breakpointId: string) => call('remove_debugger_breakpoint', { ...session(), breakpointId })
53
+ const stop = async () => {
54
+ for (let i = 0; i < 15; i++) {
55
+ const response = await call('wait_for_debugger_event', { ...session(), afterSequence: cursor, timeoutMs: 1000 })
56
+ assert.equal(response.success, true, JSON.stringify(response)); cursor = response.next_cursor
57
+ const found = response.events.find((event: any) => event.event === 'stopped')
58
+ if (found) { console.log('STOP', JSON.stringify(found)); return found }
59
+ }
60
+ throw new Error('No iOS debugger stop observed')
61
+ }
62
+ const resume = (event: any) => call('resume_debugger', { ...session(), stopId: event.stop_id })
63
+ const locals = async (event: any) => {
64
+ const stack = await call('get_debugger_stack', { ...session(), stopId: event.stop_id, threadId: event.thread_id })
65
+ assert.equal(stack.success, true, JSON.stringify(stack))
66
+ const frame = stack.result.frames.find((f: any) => f.source_file === iosDebuggerSource)
67
+ assert.ok(frame, JSON.stringify(stack))
68
+ const result = await call('get_debugger_locals', { ...session(), stopId: event.stop_id, frameId: frame.frame_id })
69
+ assert.equal(result.success, true, JSON.stringify(result)); console.log('LOCALS', JSON.stringify(result.result))
70
+ return { result: result.result, frame }
71
+ }
72
+ const expectLocal = (result: any, name: string, value: string) => assert.equal(result.locals.find((v: any) => v.name === name)?.value, value, JSON.stringify(result))
73
+ let commandFile = ''
74
+ const trigger = async (command: string) => {
75
+ console.log('TRIGGER', command)
76
+ await writeFile(commandFile + '.next', command)
77
+ await rename(commandFile + '.next', commandFile)
78
+ }
79
+ const tap = async (text: string) => {
80
+ const tree = await call('get_ui_tree', { platform: 'ios', deviceId })
81
+ assert.ok(tree.elements.some((e: any) => e.text === text), JSON.stringify(tree))
82
+ const commands: Record<string, string> = { Calculate: 'calculate', 'Run async': 'async', 'Start cancellation': 'start-cancellation', 'Cancel task': 'cancel' }
83
+ // The installed idb HID backend is unavailable. A fixture-only main-loop
84
+ // command file invokes the same button actions without debugger evaluation.
85
+ await trigger(commands[text])
86
+ }
87
+ const expectUI = async (text: string) => {
88
+ for (let i = 0; i < 15; i++) {
89
+ const tree = await call('get_ui_tree', { platform: 'ios', deviceId })
90
+ if (tree.elements.some((e: any) => e.text === text)) return
91
+ await new Promise(resolve => setTimeout(resolve, 200))
92
+ }
93
+ throw new Error('Missing independent UI state: ' + text)
94
+ }
95
+ const detach = async () => {
96
+ const response = await call('detach_debugger', session())
97
+ assert.equal(response.cleanup.status, 'complete', JSON.stringify(response))
98
+ assert.deepEqual(response.cleanup.remaining_resources, [])
99
+ assert.equal((await call('detach_debugger', session())).cleanup.status, 'complete')
100
+ }
101
+ try {
102
+ sim('install', deviceId, fixture.app)
103
+ sim('launch', deviceId, iosDebuggerAppId)
104
+ commandFile = sim('get_app_container', deviceId, iosDebuggerAppId, 'data') + '/Documents/debugger-command'
105
+ await client.connect(transport)
106
+ const shortProbe = await call('get_debugger_capabilities', { ...target, timeoutMs: 100 })
107
+ assert.equal(shortProbe.error?.code, 'DEBUGGER_TIMEOUT')
108
+ const absent = await call('get_debugger_capabilities', { ...target, appId: 'dev.mobiledebug.absentfixture' })
109
+ assert.equal(absent.supported, false); assert.ok(absent.reasons.includes('PROCESS_NOT_FOUND'))
110
+ const cap = await call('get_debugger_capabilities', target)
111
+ assert.equal(cap.supported, true, JSON.stringify(cap))
112
+ assert.equal(cap.capabilities.async_execution_inspection, true)
113
+ assert.equal(cap.capabilities.async_stepping, true)
114
+ const foreign = await call('attach_debugger', { ...target, processId: process.pid })
115
+ assert.equal(foreign.success, false); assert.equal(foreign.error.code, 'PROCESS_NOT_FOUND')
116
+ await attach()
117
+ const bp = await breakpoint('BREAK_CALCULATE')
118
+ const pending = await call('set_debugger_breakpoint', { ...session(), sourceFile: '/tmp/nonexistent-ios-debugger-source.swift', line: 1 })
119
+ assert.equal(pending.result.status, 'pending'); await remove(pending.result.breakpoint_id)
120
+ const noncode = await call('set_debugger_breakpoint', { ...session(), sourceFile: iosDebuggerSource, line: 9999 })
121
+ assert.equal(noncode.result.status, 'rejected'); await remove(noncode.result.breakpoint_id)
122
+ const storedHandle = await call('wait_for_ui', { platform: 'ios', deviceId, selector: { text: 'Calculate' }, condition: 'exists', timeout_ms: 10000 })
123
+ assert.equal(storedHandle.status, 'success', JSON.stringify(storedHandle))
124
+ assert.ok(storedHandle.element.elementId)
125
+ await tap('Calculate')
126
+ let event = await stop()
127
+ const originalStop = event
128
+ assert.equal(event.location.line, line('BREAK_CALCULATE'))
129
+ const inspected = await locals(event); expectLocal(inspected.result, 'known', '41')
130
+ const guarded = await call('tap', { platform: 'ios', deviceId, x: 10, y: 10 })
131
+ assert.equal(guarded.failure_code, 'DEBUGGER_PAUSED'); assert.equal(guarded.delivery_status, 'not_delivered')
132
+ const storedTap = await call('tap_element', { elementId: storedHandle.element.elementId, verificationMode: 'none', responseMode: 'compact' })
133
+ assert.equal(storedTap.failure_code, 'DEBUGGER_PAUSED', JSON.stringify(storedTap))
134
+ assert.equal(storedTap.delivery_status, 'not_delivered'); assert.equal(storedTap.dispatch_started, false)
135
+ assert.deepEqual(storedTap.debugger.device, { platform: 'ios', id: deviceId })
136
+ console.log('PASS paused stored iOS handle without platform/device hints rejected before dispatch')
137
+ const readiness = await call('get_app_readiness', { platform: 'ios', deviceId })
138
+ assert.equal(readiness.readiness.state, 'debugger_paused')
139
+ assert.deepEqual(readiness.device_readiness.blockers, ['DEBUGGER_PAUSED'])
140
+ assert.equal(readiness.device_readiness.input_ready, false)
141
+ const readyWait = await call('wait_for_app_ready', { platform: 'ios', deviceId })
142
+ assert.equal(readyWait.failure_code, 'DEBUGGER_PAUSED')
143
+ assert.deepEqual(readyWait.device_readiness.blockers, ['DEBUGGER_PAUSED'])
144
+ await remove(bp)
145
+ for (const kind of ['into', 'over', 'out']) {
146
+ const response = await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind })
147
+ assert.equal(response.success, true, JSON.stringify(response)); event = await stop()
148
+ }
149
+ const stale = await call('get_debugger_locals', { ...session(), stopId: originalStop.stop_id, frameId: inspected.frame.frame_id })
150
+ assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
151
+ await resume(event)
152
+ await expectUI('Debugger result 42')
153
+ const controller = new AbortController()
154
+ const waiting = call('wait_for_debugger_event', { ...session(), afterSequence: cursor, timeoutMs: 30000 }, controller.signal)
155
+ setTimeout(() => controller.abort(), 100)
156
+ await assert.rejects(waiting)
157
+ assert.equal((await call('get_debugger_state', session())).state, 'running')
158
+ await detach()
159
+
160
+ await attach()
161
+ const before = await breakpoint('BEFORE_AWAIT'), after = await breakpoint('AFTER_AWAIT')
162
+ await tap('Run async')
163
+ event = await stop(); assert.equal(event.location.line, line('BEFORE_AWAIT'))
164
+ expectLocal((await locals(event)).result, 'known', '42')
165
+ await remove(before)
166
+ await resume(event)
167
+ event = await stop(); assert.equal(event.location.line, line('AFTER_AWAIT'))
168
+ expectLocal((await locals(event)).result, 'after', '43')
169
+ await remove(after); await resume(event)
170
+ await expectUI('Async result 44')
171
+ await detach()
172
+
173
+ // Boundary stepping has a distinct evidence result, independent of breakpoint coverage.
174
+ await attach()
175
+ const boundary = await breakpoint('BEFORE_AWAIT')
176
+ await tap('Run async'); event = await stop(); await remove(boundary)
177
+ assert.equal((await call('step_debugger', { ...session(), stopId: event.stop_id, threadId: event.thread_id, kind: 'over' })).success, true)
178
+ event = await stop()
179
+ assert.equal(event.location.source_file, iosDebuggerSource)
180
+ assert.equal(event.location.line, line('AFTER_AWAIT'), 'Advertised async stepping must follow the continuation')
181
+ console.log('ASYNC_BOUNDARY_STEP', JSON.stringify({ expected_line: line('AFTER_AWAIT'), observed: event.location }))
182
+ await detach(); await expectUI('Async result 44')
183
+
184
+ await attach()
185
+ const cancel = await breakpoint('CANCELLED')
186
+ await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task')
187
+ event = await stop(); expectLocal((await locals(event)).result, 'cancelled', 'true')
188
+ await remove(cancel); await resume(event); await detach(); await expectUI('Task cancelled')
189
+
190
+ if (process.env.MCP_IOS_DEBUGGER_EXTENDED === '1') {
191
+ await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
192
+ const deadline = Date.now() + 75000
193
+ let state: any
194
+ do { await new Promise(resolve => setTimeout(resolve, 1000)); state = await call('get_debugger_state', session()) } while (state.state === 'paused' && Date.now() < deadline)
195
+ assert.equal(state.reason, 'pause_limit'); assert.equal(state.cleanup.status, 'complete')
196
+ await expectUI('Debugger result 42')
197
+ }
198
+ await attach()
199
+ await trigger('exit')
200
+ let exited: any
201
+ for (let i = 0; i < 100; i++) {
202
+ exited = await call('get_debugger_state', session())
203
+ if (exited.state === 'disconnected' && exited.cleanup.status === 'complete') break
204
+ await new Promise(resolve => setTimeout(resolve, 100))
205
+ }
206
+ assert.equal(exited.state, 'disconnected'); assert.equal(exited.cleanup.status, 'complete')
207
+ sim('launch', deviceId, iosDebuggerAppId); await expectUI('Debugger ready')
208
+ // Clear the earlier calculate result before proving shutdown recovery.
209
+ await tap('Start cancellation'); await expectUI('Task running'); await tap('Cancel task'); await expectUI('Task cancelled')
210
+ // Closing the server while paused must release its owned suspension.
211
+ await attach(); await breakpoint('BREAK_CALCULATE'); await tap('Calculate'); await stop()
212
+ await client.close()
213
+ const verifier = new Client({ name: 'ios-cleanup-verifier', version: '1.0.0' })
214
+ await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
215
+ try {
216
+ const response = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'ios', deviceId } })
217
+ const tree = JSON.parse((response.content as any[])[0].text)
218
+ assert.ok(tree.elements.some((e: any) => e.text === 'Debugger result 42'), JSON.stringify(tree))
219
+ } finally { await verifier.close() }
220
+ console.log('PASS iOS MCP debugger core/async/cancellation/cleanup', JSON.stringify({ deviceId, server_sha256: serverHash, source_sha256: createHash('sha256').update(source).digest('hex'), xcode: execFileSync('xcodebuild', ['-version'], { encoding: 'utf8' }).trim(), lldb: execFileSync('xcrun', ['lldb', '--version'], { encoding: 'utf8' }).trim() }))
221
+ } finally {
222
+ if (debugSessionId) { try { await call('detach_debugger', session()) } catch {} }
223
+ await client.close().catch(() => {})
224
+ sim('uninstall', deviceId, iosDebuggerAppId)
225
+ await rm(fixture.directory, { recursive: true, force: true })
226
+ }
227
+ }
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
4
+
5
+ const client = new Client({ name: 'compact-results-smoke', version: '1.0.0' })
6
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
7
+ async function call(name: string, args: Record<string, unknown> = {}) {
8
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
9
+ const item = response.content.find(item => item.type === 'text')
10
+ assert(item?.type === 'text')
11
+ return { value: JSON.parse(item.text), bytes: Buffer.byteLength(item.text) }
12
+ }
13
+ async function details(value: Record<string, any>) {
14
+ const result = await call('get_interaction_diagnostics', { sessionId: value.diagnostics.session_id, resultId: value.diagnostics.result_id })
15
+ assert.equal(result.value.success, true)
16
+ return result.value.result
17
+ }
18
+ try {
19
+ await client.connect(transport)
20
+ const discovery = await call('list_devices')
21
+ for (const platform of ['android', 'ios']) {
22
+ const device = discovery.value.devices.find((d: Record<string, any>) => d.platform === platform && d.simulator && (platform === 'android' || d.booted))
23
+ assert(device, `A booted ${platform} simulator/emulator is required`)
24
+ const target = { platform, deviceId: device.id }, appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
25
+ const none = await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' })
26
+ assert.equal(none.value.success, true)
27
+ assert.equal(none.value.verification.status, 'not_requested')
28
+ assert.equal(none.value.observed_state.status, 'unknown')
29
+ assert.ok(none.bytes <= 1280 + Buffer.byteLength(device.id))
30
+ assert.equal((await details(none.value)).evidence.action_id, none.value.action_id)
31
+ const observed = await call('start_app', { ...target, appId, verificationMode: 'light', verificationTimeoutMs: 30000, responseMode: 'compact' })
32
+ assert.equal(observed.value.success, true)
33
+ assert.equal(observed.value.observed_state.status, 'observed', JSON.stringify(observed.value))
34
+ assert.ok(observed.bytes <= 1280 + Buffer.byteLength(device.id))
35
+ const blocked = await call('tap_element', { ...target, selector: { text: '__mcp_absent_compact_fixture__' }, verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 1000 })
36
+ assert.equal(blocked.value.success, false)
37
+ assert.equal(blocked.value.dispatch_started, false)
38
+ assert.equal(blocked.value.observed_state.status, 'unknown')
39
+ assert.equal((await details(blocked.value)).success, false)
40
+ const tree = await call('get_ui_tree', target)
41
+ const element = tree.value.elements?.find((e: Record<string, any>) => e.visible && typeof e.text === 'string' && e.text.trim())
42
+ assert(element, 'Settings must expose a visible text target')
43
+ const journey = await call('run_journey', { ...target, defaults: { verificationMode: 'none' }, steps: [
44
+ { id: 'open', type: 'start_app', appId },
45
+ { id: 'observe', type: 'assert', assertion: { kind: 'element_visible', selector: { text: element.text } }, timeoutMs: 15000 }
46
+ ] })
47
+ assert.equal(journey.value.success, true, JSON.stringify(journey.value))
48
+ assert.equal(journey.value.steps[1].result.observed_state.status, 'observed')
49
+ const retainedJourney = await details(journey.value)
50
+ const retainedStep = await details(journey.value.steps[0].result)
51
+ assert.equal(retainedJourney.steps[0].result.evidence.action_id, retainedStep.evidence.action_id)
52
+ assert.ok(retainedStep.evidence.dispatch_completed_at_ms)
53
+ console.log(JSON.stringify({ status: 'PASS', platform, device: device.id, os: device.osVersion, server: client.getServerVersion(),
54
+ bytes: { none: none.bytes, observed: observed.bytes, blocked: blocked.bytes, journey: journey.bytes },
55
+ observed: observed.value.observed_state, journeyAssertion: journey.value.steps[1].result.observed_state,
56
+ checks: ['dispatch-only unknown', 'fresh post-observation', 'no-dispatch failure', 'journey', 'read-only original/child lookup'], cleanup: 'Settings remains foreground; no app data/settings changed' }))
57
+ }
58
+ } finally { await client.close() }
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync, readdirSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ export function command(command: string, args: string[], timeout = 30000) {
8
+ return execFileSync(command, args, { encoding: 'utf8', timeout, killSignal: 'SIGKILL', stdio: 'pipe' }).trim()
9
+ }
10
+ export function fixturePresent(adb: (...args: string[]) => string, app: string) {
11
+ return adb('shell', 'pm', 'list', 'packages', app).split('\n').includes(`package:${app}`)
12
+ }
13
+
14
+ export function cleanupFixture(adb: (...args: string[]) => string, app: string, remove: boolean) {
15
+ if (!fixturePresent(adb, app)) return false
16
+ adb('shell', 'am', 'force-stop', app)
17
+ if (!remove) return false
18
+ adb('uninstall', app)
19
+ if (fixturePresent(adb, app)) throw new Error('FIXTURE_REMOVAL_FAILED')
20
+ return true
21
+ }
22
+
23
+ export const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex')
24
+ export function treeHash(root: string): string {
25
+ const hash = createHash('sha256')
26
+ const visit = (directory: string) => {
27
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
28
+ const file = path.join(directory, entry.name)
29
+ if (entry.isDirectory()) visit(file)
30
+ else if (entry.isFile()) { hash.update(path.relative(root, file)); hash.update(readFileSync(file)) }
31
+ }
32
+ }
33
+ visit(root)
34
+ return hash.digest('hex')
35
+ }
36
+ function version(tool: string, args: string[]) {
37
+ try { return command(tool, args).split('\n').filter(line => !/Installed as/.test(line)).slice(0, 3).join('\n') }
38
+ catch { return 'unavailable' }
39
+ }
40
+ export function environment(deviceId: string, apk: string) {
41
+ const adb = (...args: string[]) => command('adb', ['-s', deviceId, ...args])
42
+ const connected = command('adb', ['devices']).split('\n').filter(line => /\sdevice$/.test(line.trim()))
43
+ return {
44
+ commit: command('git', ['rev-parse', 'HEAD']),
45
+ working_tree: command('git', ['status', '--porcelain', '--untracked-files=all']),
46
+ source_sha256: treeHash('src'), build_sha256: treeHash('dist'), harness_sha256: treeHash('test/device/automated/latency'), fixture_sha256: sha256(apk),
47
+ package_version: JSON.parse(readFileSync('package.json', 'utf8')).version,
48
+ node: process.version, host: { platform: os.platform(), release: os.release(), arch: os.arch() },
49
+ platform: 'android', model: adb('shell', 'getprop', 'ro.product.model'),
50
+ discovery_topology: { android_emulators: connected.filter(line => line.startsWith('emulator-')).length,
51
+ android_other_devices: connected.filter(line => !line.startsWith('emulator-')).length },
52
+ os: adb('shell', 'getprop', 'ro.build.version.release'), api: adb('shell', 'getprop', 'ro.build.version.sdk'),
53
+ avd: adb('emu', 'avd', 'name').split('\n')[0], display: adb('shell', 'wm', 'size'), density: adb('shell', 'wm', 'density'),
54
+ tool_versions: { adb: version('adb', ['version']), javac: version(process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', 'javac') : 'javac', ['--version']), android_build_tools: '36.0.0',
55
+ emulator: version(path.join(process.env.ANDROID_HOME ?? path.join(os.homedir(), 'Library/Android/sdk'), 'emulator/emulator'), ['-version']),
56
+ android_compile_platform: '36', xcode: version('xcodebuild', ['-version']), idb: version('idb', ['--version']) },
57
+ timeouts: { setup_ms: 30000, call_ms: 300000, action_ms: 30000, wait_ms: 10000, verification_ms: 60000,
58
+ adb_ms: Number(process.env.MCP_ADB_TIMEOUT ?? 20000) },
59
+ ios: { journey: 'blocked: shared #89 fixture unavailable', observations: 'blocked: idb executable unavailable and no booted simulator at investigation', physical_device: 'excluded' }
60
+ }
61
+ }