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
@@ -0,0 +1,95 @@
1
+ import UIKit
2
+
3
+ @inline(never) func increment(_ value: Int) -> Int {
4
+ let adjusted = value + 1 // STEP_INCREMENT
5
+ return adjusted // STEP_RETURN
6
+ }
7
+ @inline(never) func helper(_ value: Int) -> Int {
8
+ let result = increment(value) // STEP_HELPER
9
+ return result // HELPER_RETURN
10
+ }
11
+ actor AsyncWork {
12
+ func child(_ input: Int) async -> Int {
13
+ try? await Task.sleep(nanoseconds: 300_000_000)
14
+ let childValue = input + 1
15
+ return childValue // CHILD_RETURN
16
+ }
17
+ @inline(never) func calculate(_ seed: Int) async -> Int {
18
+ let known = seed
19
+ async let childResult = child(known)
20
+ let after = await childResult // BEFORE_AWAIT
21
+ let answer = helper(after + known - seed) // AFTER_AWAIT
22
+ return answer // ASYNC_RETURN
23
+ }
24
+ }
25
+ class Controller: UIViewController {
26
+ let label = UILabel()
27
+ var task: Task<Void, Never>?
28
+ override func viewDidLoad() {
29
+ super.viewDidLoad()
30
+ view.backgroundColor = .white
31
+ label.text = "Debugger ready"
32
+ label.accessibilityIdentifier = "debugger-result"
33
+ let stack = UIStackView()
34
+ stack.axis = .vertical
35
+ stack.spacing = 24
36
+ stack.addArrangedSubview(label)
37
+ for (title, action) in [("Calculate", #selector(calculate)), ("Run async", #selector(runAsync)), ("Start cancellation", #selector(startCancellation)), ("Cancel task", #selector(cancelTask))] {
38
+ let button = UIButton(type: .system)
39
+ button.setTitle(title, for: .normal)
40
+ button.addTarget(self, action: action, for: .touchUpInside)
41
+ stack.addArrangedSubview(button)
42
+ }
43
+ stack.translatesAutoresizingMaskIntoConstraints = false
44
+ view.addSubview(stack)
45
+ NSLayoutConstraint.activate([stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)])
46
+ }
47
+ @objc func calculate() {
48
+ let known = 41
49
+ let result = helper(known) // BREAK_CALCULATE
50
+ label.text = "Debugger result \(result)" // CALCULATE_DONE
51
+ }
52
+ @objc func runAsync() {
53
+ task = Task { @MainActor in
54
+ let result = await AsyncWork().calculate(42)
55
+ label.text = "Async result \(result)"
56
+ }
57
+ }
58
+ @objc func startCancellation() {
59
+ label.text = "Task running"
60
+ task = Task { @MainActor in
61
+ do {
62
+ try await Task.sleep(nanoseconds: 60_000_000_000)
63
+ label.text = "Unexpected completion"
64
+ } catch {
65
+ let cancelled = Task.isCancelled
66
+ label.text = cancelled ? "Task cancelled" : "Unexpected error" // CANCELLED
67
+ }
68
+ }
69
+ }
70
+ @objc func cancelTask() { task?.cancel() }
71
+ }
72
+ @main class App: UIResponder, UIApplicationDelegate {
73
+ var window: UIWindow?
74
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
75
+ window = UIWindow(frame: UIScreen.main.bounds)
76
+ window?.rootViewController = Controller()
77
+ window?.makeKeyAndVisible()
78
+ // Test-only control channel; no HID dependency or debugger evaluation.
79
+ Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
80
+ let commandFile = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("debugger-command")
81
+ guard let command = try? String(contentsOf: commandFile, encoding: .utf8),
82
+ let controller = self?.window?.rootViewController as? Controller else { return }
83
+ try? FileManager.default.removeItem(at: commandFile)
84
+ switch command {
85
+ case "exit": exit(0)
86
+ case "calculate": controller.calculate()
87
+ case "async": controller.runAsync()
88
+ case "start-cancellation": controller.startCancellation()
89
+ case "cancel": controller.cancelTask()
90
+ default: break
91
+ }
92
+ }
93
+ return true
94
+ }
95
+ }
@@ -0,0 +1,27 @@
1
+ # iOS debugger fixture
2
+
3
+ Disposable UIKit app for simulator-only LLDB MCP checks. Builds with Xcode's
4
+ `swiftc -g -Onone`; no external package or user app is used. Select an explicit
5
+ booted simulator:
6
+
7
+ ```sh
8
+ npm run build
9
+ MCP_IOS_DEBUGGER_DEVICE=<UDID> node --import tsx test/device/automated/debugger/runtime.ios.test.ts
10
+ # Include the real 60-second pause expiry:
11
+ MCP_IOS_DEBUGGER_DEVICE=<UDID> MCP_IOS_DEBUGGER_EXTENDED=1 node --import tsx test/device/automated/debugger/runtime.ios.test.ts
12
+ ```
13
+
14
+ Requires macOS, full Xcode with Swift-enabled LLDB/Python, and working idb UI-tree
15
+ reads. The test installs/removes only `dev.mobiledebug.iosfixture`, and cleans its
16
+ private temporary build/module-cache directory. It does not boot/shut down devices.
17
+ The app's fixture-only `Documents/debugger-command` file invokes the same actions
18
+ as its buttons through a main-loop timer. The smoke runner writes only that file
19
+ because the installed idb HID backend expects SimulatorKit at a path absent in
20
+ this Xcode. This is a fixture trigger, not evidence that MCP tap delivery
21
+ succeeded. Paused MCP taps must be rejected before delivery. Completion and
22
+ cancellation use fresh MCP UI-tree assertions.
23
+
24
+ Async work uses a suspended child operation in an actor, known values on either
25
+ side of await, a main-actor UI update and a separate cancellation task. Boundary
26
+ stepping is recorded independently from source-breakpoint coverage. Stop-scoped
27
+ thread/frame IDs do not establish logical task identity.
@@ -0,0 +1,21 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ export const iosDebuggerAppId = 'dev.mobiledebug.iosfixture'
7
+ export const iosDebuggerSource = fileURLToPath(new URL('./Main.swift', import.meta.url))
8
+ export function buildIOSDebuggerFixture() {
9
+ const directory = mkdtempSync(path.join(os.tmpdir(), 'mobile-debug-ios-fixture-'))
10
+ const app = path.join(directory, 'DebuggerFixture.app')
11
+ try {
12
+ execFileSync('/bin/mkdir', ['-p', app])
13
+ const sdk = execFileSync('xcrun', ['--sdk', 'iphonesimulator', '--show-sdk-path'], { encoding: 'utf8' }).trim()
14
+ execFileSync('xcrun', ['swiftc', '-g', '-module-cache-path', path.join(directory, 'modules'), '-Onone', '-parse-as-library', '-sdk', sdk, '-target', `${os.arch() === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios18.0-simulator`, '-module-name', 'DebuggerFixture', iosDebuggerSource, '-o', path.join(app, 'DebuggerFixture')], { timeout: 120000, stdio: 'pipe' })
15
+ writeFileSync(path.join(app, 'Info.plist'), `<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>${iosDebuggerAppId}</string><key>CFBundleExecutable</key><string>DebuggerFixture</string><key>CFBundleName</key><string>DebuggerFixture</string><key>CFBundlePackageType</key><string>APPL</string><key>CFBundleVersion</key><string>1</string><key>CFBundleShortVersionString</key><string>1.0</string><key>LSRequiresIPhoneOS</key><true/><key>UILaunchScreen</key><dict/></dict></plist>`)
16
+ return { app, directory }
17
+ } catch (error) {
18
+ rmSync(directory, { recursive: true, force: true })
19
+ throw error
20
+ }
21
+ }
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process'
2
- import { mkdirSync } from 'node:fs'
2
+ import { existsSync, mkdirSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
@@ -11,7 +11,7 @@ export function buildFixture() {
11
11
  const androidJar = path.join(sdk, 'platforms', 'android-36', 'android.jar')
12
12
  const java = process.env.JAVA_HOME
13
13
  const tool = (name: string) => java ? path.join(java, 'bin', name) : name
14
- const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: out, stdio: 'pipe' })
14
+ const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { cwd: out, stdio: 'pipe', timeout: 60000, killSignal: 'SIGKILL' })
15
15
  mkdirSync(path.join(out, 'classes'), { recursive: true })
16
16
  run(tool('javac'), ['--release', '8', '-classpath', androidJar, '-d', 'classes', path.join(root, 'src/MainActivity.java')])
17
17
  run(tool('jar'), ['cf', 'classes.jar', '-C', 'classes', '.'])
@@ -20,7 +20,7 @@ export function buildFixture() {
20
20
  run('zip', ['-q', 'unsigned.apk', 'classes.dex'])
21
21
  run(path.join(buildTools, 'zipalign'), ['-f', '4', 'unsigned.apk', 'aligned.apk'])
22
22
  // Ephemeral signing material is generated outside version control.
23
- try { run(tool('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Latency Fixture', '-keyalg', 'RSA', '-validity', '3650']) } catch { /* Existing local fixture key. */ }
23
+ if (!existsSync(path.join(out, 'fixture.jks'))) run(tool('keytool'), ['-genkeypair', '-keystore', 'fixture.jks', '-storepass', 'android', '-keypass', 'android', '-alias', 'fixture', '-dname', 'CN=Latency Fixture', '-keyalg', 'RSA', '-validity', '3650'])
24
24
  run(path.join(buildTools, 'apksigner'), ['sign', '--ks', 'fixture.jks', '--ks-pass', 'pass:android', '--out', 'fixture.apk', 'aligned.apk'])
25
25
  return path.join(out, 'fixture.apk')
26
26
  }
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
4
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
5
+
6
+ export async function verifyIosTraceShutdown(target: Record<string,unknown>, env: Record<string,string> = {}) {
7
+ const client=new Client({name:'ios-trace-shutdown',version:'1.0.0'})
8
+ const transport=new StdioClientTransport({command:process.execPath,args:['dist/server.js'],stderr:'pipe',env})
9
+ let diagnostics=''
10
+ transport.stderr?.on('data',chunk=>{diagnostics=(diagnostics+String(chunk)).slice(-4096)})
11
+ try {
12
+ await client.connect(transport)
13
+ const capability=await client.callTool({name:'get_system_trace_capabilities',arguments:target},undefined,{timeout:120000})
14
+ const capabilities=JSON.parse((capability.content as any[]).find(c=>c.type==='text').text)
15
+ assert.equal(capabilities.supported,true,JSON.stringify(capabilities))
16
+ const response=await client.callTool({name:'start_system_trace',arguments:{...target,durationMs:30000}},undefined,{timeout:120000})
17
+ const result=JSON.parse((response.content as any[]).find(c=>c.type==='text').text)
18
+ assert.equal(result.state,'recording',JSON.stringify(result))
19
+ const directory=result.cleanup.remaining_resources[0]
20
+ assert.ok(transport.pid)
21
+ process.kill(transport.pid,'SIGTERM')
22
+ let removed=false
23
+ for(let i=0;i<24;i++){
24
+ try {await fs.stat(directory)} catch(error) {if((error as NodeJS.ErrnoException).code==='ENOENT'){removed=true;break}throw error}
25
+ await new Promise(resolve=>setTimeout(resolve,500))
26
+ }
27
+ assert.equal(removed,true,`Server shutdown must remove its owned partial trace directory; ${diagnostics}`)
28
+ } finally {await client.close()}
29
+ }
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { debuggerSessions, iosDebuggerSessions } from '../../../src/debugger/sessions.js'
3
+ import { handleToolCall } from '../../../src/server-core.js'
4
+ import { ToolsObserve } from '../../../src/observe/index.js'
5
+ import { ToolsInteract } from '../../../src/interact/index.js'
6
+ import { markDispatchStarted } from '../../../src/utils/operation-budget.js'
7
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
8
+
9
+ const original = {
10
+ androidPaused: debuggerSessions.paused, androidActive: debuggerSessions.hasActiveSessions,
11
+ iosPaused: iosDebuggerSessions.paused, iosActive: iosDebuggerSessions.hasActiveSessions,
12
+ tree: ToolsObserve.getUITreeHandler, tap: ToolsInteract.tapHandler
13
+ }
14
+ let iosPaused = false, androidPaused = false, racePause = false, reads = 0, androidResolutions = 0
15
+ const inputs: { platform?: string; deviceId?: string }[] = []
16
+ const pause: any = { debug_session_id: 'ios-session', target: { app_id: 'test.app', pid: 1 }, state: 'paused', captured_at_ms: 1 }
17
+ const tree = (platform: string, id: string): any => ({ device: { platform, id }, resolution: { width: 100, height: 100 },
18
+ elements: [{ text: 'Target', resourceId: 'target', bounds: [0, 0, 40, 40], visible: true, enabled: true, clickable: true }] })
19
+ const payload = (response: any) => JSON.parse(response.content.find((item: any) => item.type === 'text').text)
20
+ const call = async (elementId: string, hints: Record<string, unknown> = {}) => payload(await handleToolCall('tap_element', {
21
+ elementId, verificationMode: 'none', responseMode: 'compact', ...hints
22
+ }))
23
+ _setDeviceListersForTests({
24
+ listAndroidDevices: async () => { androidResolutions++; return [{ id: 'android', platform: 'android' } as any] },
25
+ listIOSDevices: async () => ['ios-a', 'ios-b'].map(id => ({ id, platform: 'ios', booted: true } as any))
26
+ })
27
+ debuggerSessions.hasActiveSessions = () => true
28
+ iosDebuggerSessions.hasActiveSessions = () => true
29
+ debuggerSessions.paused = id => androidPaused && id === 'android' ? { ...pause, debug_session_id: 'android-session' } : null
30
+ iosDebuggerSessions.paused = id => iosPaused && id === 'ios-a' ? pause : null
31
+ ToolsInteract._resetResolvedUiElementsForTests()
32
+ try {
33
+ ToolsObserve.getUITreeHandler = async ({ platform, deviceId }) => {
34
+ reads++
35
+ if (racePause) iosPaused = true
36
+ return tree(platform!, deviceId!)
37
+ }
38
+ ToolsInteract.tapHandler = async ({ platform, deviceId, x, y }) => {
39
+ markDispatchStarted()
40
+ inputs.push({ platform, deviceId })
41
+ return { success: true, device: { platform, id: deviceId }, x, y } as any
42
+ }
43
+ const handle = async (platform: 'android' | 'ios', deviceId: string) => {
44
+ const result = await ToolsInteract.waitForUIHandler({ platform, deviceId, selector: { text: 'Target' }, condition: 'exists', timeout_ms: 1000 })
45
+ assert.equal(result.status, 'success')
46
+ return result.element.elementId
47
+ }
48
+ const ios = await handle('ios', 'ios-a')
49
+ const otherIOS = await handle('ios', 'ios-b')
50
+ const android = await handle('android', 'android')
51
+ reads = 0; androidResolutions = 0; iosPaused = true
52
+ for (const hints of [{}, { deviceId: 'ios-a' }, { platform: 'ios' }]) {
53
+ const blocked = await call(ios, hints)
54
+ assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED')
55
+ const details = payload(await handleToolCall('get_interaction_diagnostics', { sessionId: blocked.diagnostics.session_id, resultId: blocked.diagnostics.result_id }))
56
+ assert.equal(details.result.debugger.debug_session_id, 'ios-session')
57
+ assert.equal(blocked.delivery_status, 'not_delivered'); assert.equal(blocked.dispatch_started, false)
58
+ }
59
+ assert.equal(reads, 0, 'A known pause must block before hierarchy reads')
60
+ assert.equal(inputs.length, 0); assert.equal(androidResolutions, 0, 'An iOS handle must not resolve an Android device')
61
+
62
+ iosPaused = false
63
+ assert.equal((await call(ios)).success, true)
64
+ assert.deepEqual(inputs.pop(), { platform: 'ios', deviceId: 'ios-a' })
65
+ racePause = true
66
+ const raced = await call(ios)
67
+ assert.equal(raced.failure_code, 'DEBUGGER_PAUSED')
68
+ assert.equal(raced.delivery_status, 'not_delivered'); assert.equal(inputs.length, 0, 'A pause during target refresh must block native dispatch')
69
+ racePause = false
70
+
71
+ assert.equal((await call(android)).success, true, 'An unrelated iOS pause must not block Android')
72
+ assert.deepEqual(inputs.pop(), { platform: 'android', deviceId: 'android' })
73
+ androidPaused = true
74
+ assert.equal((await call(android)).failure_code, 'DEBUGGER_PAUSED')
75
+ assert.equal((await call(otherIOS)).success, true, 'Other iOS devices must remain usable')
76
+ assert.deepEqual(inputs.pop(), { platform: 'ios', deviceId: 'ios-b' })
77
+ androidPaused = false; reads = 0
78
+ for (const hints of [{ platform: 'android', deviceId: 'android' }, { platform: 'ios', deviceId: 'ios-b' }]) {
79
+ assert.equal((await call(ios, hints)).failure_code, 'STALE_REFERENCE', 'Explicit mismatches retain handle validation')
80
+ }
81
+ assert.equal((await call('unknown', { platform: 'ios', deviceId: 'ios-b' })).failure_code, 'STALE_REFERENCE')
82
+ assert.equal(reads, 0); assert.equal(inputs.length, 0)
83
+ console.log('PASS stored-handle debugger guards: omitted target, no observation/input while paused, racing pause, platform/device isolation and mismatches')
84
+ } finally {
85
+ debuggerSessions.paused = original.androidPaused; debuggerSessions.hasActiveSessions = original.androidActive
86
+ iosDebuggerSessions.paused = original.iosPaused; iosDebuggerSessions.hasActiveSessions = original.iosActive
87
+ ToolsObserve.getUITreeHandler = original.tree; ToolsInteract.tapHandler = original.tap
88
+ ToolsInteract._resetResolvedUiElementsForTests(); _resetDeviceListersForTests()
89
+ }
@@ -0,0 +1,57 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ const exec = promisify(execFile)
7
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'debugger-fake-lldb-'))
8
+ const python = path.join(directory, 'python')
9
+ const xcrun = path.join(directory, 'xcrun')
10
+ const log = path.join(directory, 'calls')
11
+ const failed = path.join(directory, 'failed')
12
+ try {
13
+ await writeFile(xcrun, `#!${process.execPath}
14
+ const fs=require('node:fs');fs.appendFileSync(${JSON.stringify(log)},'xcrun\\n');
15
+ console.log(process.argv[2]==='lldb'?'/fake/lldb-python':${JSON.stringify(python)});
16
+ `, { mode: 0o700 })
17
+ await writeFile(python, `#!${process.execPath}
18
+ const fs=require('node:fs');
19
+ if(process.argv.includes('-c')) {
20
+ fs.appendFileSync(${JSON.stringify(log)},'probe\\n');
21
+ if(!fs.existsSync(${JSON.stringify(failed)})){fs.writeFileSync(${JSON.stringify(failed)},'');process.exit(1)}
22
+ console.log('ready');
23
+ } else {
24
+ fs.appendFileSync(${JSON.stringify(log)},'worker\\n');
25
+ require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
26
+ const request=JSON.parse(line);console.log(JSON.stringify({id:request.id,result:{detached:true}}));
27
+ });
28
+ }
29
+ `, { mode: 0o700 })
30
+ const code = `
31
+ import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
32
+ // Load the TypeScript toolchain before emulating macOS so Linux selects its own esbuild.
33
+ const {prepareIOSWorker,createIOSWorker}=await import('./src/debugger/ios-worker.ts');
34
+ // Isolate platform emulation and native binaries from the unit runner process.
35
+ Object.defineProperty(process,'platform',{value:'darwin'});
36
+ const first=prepareIOSWorker();assert.strictEqual(first,prepareIOSWorker());
37
+ const failed=await Promise.allSettled([first,prepareIOSWorker()]);
38
+ assert.ok(failed.every(result=>result.status==='rejected'));
39
+ const retry=prepareIOSWorker();assert.notStrictEqual(first,retry);
40
+ assert.strictEqual(retry,prepareIOSWorker());
41
+ const configuration=await retry;
42
+ assert.equal(configuration.python,${JSON.stringify(python)});
43
+ assert.equal(configuration.env.PYTHONPATH,'/fake/lldb-python');
44
+ assert.equal(configuration.env.PYTHONNOUSERSITE,'1');
45
+ assert.strictEqual(await prepareIOSWorker(),configuration);
46
+ const worker=await createIOSWorker(()=>{});await worker.close();
47
+ const calls=readFileSync(${JSON.stringify(log)},'utf8').trim().split('\\n');
48
+ assert.equal(calls.filter(call=>call==='xcrun').length,4);
49
+ assert.equal(calls.filter(call=>call==='probe').length,2);
50
+ assert.equal(calls.filter(call=>call==='worker').length,1);
51
+ console.log('PASS shared iOS preparation, failed-probe retry, configuration and worker cache reuse');
52
+ `
53
+ const result = await exec(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', code], {
54
+ env: { ...process.env, XCRUN_PATH: xcrun }, timeout: 15000
55
+ })
56
+ console.log(result.stdout.trim())
57
+ } finally { await rm(directory, { recursive: true, force: true }) }
@@ -0,0 +1,104 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { IOSDebugNative } from '../../../src/debugger/ios.js'
6
+ import { DebuggerSessions, iosDebuggerSessions, debuggerSessions } from '../../../src/debugger/sessions.js'
7
+ import { iosCapabilities, iosLimitations } from '../../../src/debugger/ios-worker.js'
8
+ import { debuggerContracts, debuggerTools } from '../../../src/server/schemas/debugger-schema.js'
9
+ import { withDebuggerGuard, checkDebuggerGuard, pausedReadiness } from '../../../src/debugger/guard.js'
10
+ import { execCommandWithDiagnostics } from '../../../src/utils/ios/utils.js'
11
+ import { handleDebugger } from '../../../src/server/handlers/debugger-handlers.js'
12
+ import { DebuggerError, type WorkerEvent } from '../../../src/debugger/worker.js'
13
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'ios-debugger-unit-'))
14
+ await writeFile(path.join(directory, 'App'), '')
15
+ const executable = await realpath(path.join(directory, 'App'))
16
+ const device = 'F9243BAF-62A8-4990-8A99-17F527F56FDA'
17
+ let processPath = executable, started = 'Mon Nov 4 12:00:00 2026', services = '123\t0\tUIKitApplication:dev.fixture.app[uuid]'
18
+ const calls: string[][] = []
19
+ const native = new IOSDebugNative(async (file, args) => {
20
+ calls.push([file, ...args])
21
+ if (args[0] === 'simctl' && args[1] === 'list') return JSON.stringify({ devices: { runtime: [{ udid: device, state: 'Booted' }] } })
22
+ if (args.includes('get_app_container')) return directory
23
+ if (file === '/usr/bin/plutil') return 'App'
24
+ if (args.includes('launchctl')) return services
25
+ if (args.includes('comm=')) return processPath
26
+ if (args.includes('lstart=')) return started
27
+ throw new Error('unexpected native command')
28
+ })
29
+ const base = { debugSessionId: 's', line: 1 }
30
+ for (const selector of [{}, { className: 'dev.App', sourceFile: '/tmp/App.swift' }, { sourceFile: 'App.swift' }]) {
31
+ assert.equal(debuggerContracts.set_debugger_breakpoint.schema.safeParse({ ...base, ...selector }).success, false)
32
+ }
33
+ for (const selector of [{ className: 'dev.App' }, { sourceFile: '/tmp/App.swift' }]) {
34
+ assert.equal(debuggerContracts.set_debugger_breakpoint.schema.safeParse({ ...base, ...selector }).success, true)
35
+ }
36
+ assert.ok(debuggerTools.find(t => t.name === 'attach_debugger')!.inputSchema.properties.platform)
37
+ let emit: (event: WorkerEvent) => void = () => {}
38
+ let closed = 0
39
+ const sessions = new DebuggerSessions(native, async onEvent => {
40
+ emit = onEvent
41
+ return { call: async (command, args) => {
42
+ if (command === 'step' && args?.threadId === 'foreign') throw new DebuggerError('DEBUGGER_STALE_REFERENCE')
43
+ if (command === 'attach') { assert.equal(args?.pid, 123); assert.equal(args?.executablePath, executable); emit({ event: 'stopped', stop_id: 'racing-stop', thread_id: 't' }) }
44
+ return { breakpoints: [] }
45
+ }, close: async () => { closed++ } }
46
+ }, async () => {}, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations })
47
+ const androidPause = debuggerSessions.paused, iosPause = iosDebuggerSessions.paused
48
+ try {
49
+ const candidates = await native.candidates(device, 'dev.fixture.app')
50
+ assert.equal(candidates[0].pid, 123); assert.equal(candidates[0].executable_path, executable)
51
+ assert.equal(candidates[0].uid, null)
52
+ await assert.rejects(native.candidates('booted', 'dev.fixture.app'), /INVALID_ARGUMENT/)
53
+ await assert.rejects(native.candidates('00000000-0000-0000-0000-000000000000', 'dev.fixture.app'), /DEBUGGER_UNSUPPORTED/)
54
+ processPath = '/tmp/foreign-process'
55
+ await assert.rejects(native.identity(device, candidates[0]), /PROCESS_CHANGED/)
56
+ processPath = executable; started = 'new process'
57
+ await assert.rejects(native.identity(device, candidates[0]), /PROCESS_CHANGED/)
58
+ started = candidates[0].start_identity
59
+ services = '999\t0\tUIKitApplication:dev.fixture.other[uuid]'
60
+ await assert.rejects(native.identity(device, candidates[0]), /PROCESS_CHANGED/)
61
+ services = '123\t0\tUIKitApplication:dev.fixture.app[uuid]'
62
+ const attached = await sessions.attach(device, 'dev.fixture.app')
63
+ assert.equal(attached.device.platform, 'ios'); assert.equal(attached.state, 'paused', 'racing attach stop is preserved')
64
+ assert.deepEqual(attached.cleanup.remaining_resources, ['lldb_worker'])
65
+ await assert.rejects(sessions.command(attached.debug_session_id, 'set', { className: 'Wrong', line: 1 }), /INVALID_ARGUMENT/)
66
+ emit({ event: 'stopped', stop_id: 'fresh', thread_id: 't' })
67
+ await assert.rejects(sessions.command(attached.debug_session_id, 'step', { stopId: 'fresh', threadId: 'foreign', kind: 'into' }), /DEBUGGER_STALE_REFERENCE/)
68
+ assert.equal(sessions.paused(device)?.stop?.stop_id, 'fresh', 'Rejected step preserves known suspension and guard')
69
+ await assert.rejects(sessions.command(attached.debug_session_id, 'locals', { stopId: 'racing-stop' }), /DEBUGGER_STALE_REFERENCE/)
70
+ iosDebuggerSessions.paused = id => id === 'same-id' ? { ...attached, debug_session_id: 'ios' } : null
71
+ debuggerSessions.paused = id => id === 'same-id' ? { ...attached, debug_session_id: 'android' } : null
72
+ assert.equal(pausedReadiness('same-id', 'ios')?.debug_session_id, 'ios')
73
+ assert.equal(pausedReadiness('same-id', 'android')?.debug_session_id, 'android')
74
+ await assert.rejects(withDebuggerGuard('ios', 'same-id', async () => checkDebuggerGuard(true)), /DEBUGGER_PAUSED/)
75
+ await withDebuggerGuard('ios', 'other', async () => checkDebuggerGuard())
76
+ await assert.rejects(withDebuggerGuard('ios', 'same-id', async () => execCommandWithDiagnostics(['simctl', 'launch', 'same-id', 'dev.fixture.app'])), /DEBUGGER_PAUSED/, 'Diagnostic fallback cannot resend a paused lifecycle command')
77
+ assert.equal((await sessions.command(attached.debug_session_id, 'detach')).cleanup.status, 'complete')
78
+ assert.equal(closed, 1)
79
+ assert.ok(!calls.some(call => call.includes('forward')), 'iOS must not allocate or remove ADB forwards')
80
+ const original = iosDebuggerSessions.capabilities
81
+ try {
82
+ iosDebuggerSessions.capabilities = async () => { throw new DebuggerError('DEBUGGER_UNSUPPORTED') }
83
+ const response = await handleDebugger('get_debugger_capabilities', { platform: 'ios', deviceId: device, appId: 'dev.fixture.app' })
84
+ const payload = JSON.parse(response.content[0].text)
85
+ assert.equal(payload.device.platform, 'ios'); assert.equal(payload.capabilities.coroutines, false)
86
+ assert.ok(!/JDK|Java worker|ADB/.test(payload.error.diagnostic.recovery))
87
+ } finally { iosDebuggerSessions.capabilities = original }
88
+ console.log('PASS iOS target membership, process identity, contracts, session routing, racing stops and platform guards')
89
+ } finally {
90
+ debuggerSessions.paused = androidPause; iosDebuggerSessions.paused = iosPause
91
+ await sessions.shutdown(); await rm(directory, { recursive: true, force: true })
92
+ }
93
+
94
+ // Confirmed detach and process-exit evidence are required for cleanup success.
95
+ const { IOSWorker } = await import('../../../src/debugger/ios-worker.js')
96
+ let transportClosed = 0
97
+ const disconnected = new IOSWorker({ call: async () => { throw new Error('transport lost') }, close: async () => { transportClosed++ } }, () => false)
98
+ await assert.rejects(disconnected.close(), /DEBUGGER_CLEANUP_FAILED/)
99
+ assert.equal(transportClosed, 1)
100
+ const exited = new IOSWorker({ call: async () => { throw new Error('must not need detach') }, close: async () => {} }, () => true)
101
+ await exited.close()
102
+ const confirmed = new IOSWorker({ call: async () => ({ detached: true }), close: async () => {} }, () => false)
103
+ await confirmed.close()
104
+ console.log('PASS iOS cleanup requires confirmed release; worker exit alone is insufficient')
@@ -0,0 +1,166 @@
1
+ """Platform-independent regression checks for the optional LLDB worker."""
2
+ import pathlib
3
+ import sys
4
+ import types
5
+
6
+ root = pathlib.Path(__file__).resolve().parents[3] / 'src/debugger/lldb'
7
+ sys.path.insert(0, str(root))
8
+ lldb = types.SimpleNamespace(eStateInvalid=0, eStateStopped=5, eStateExited=10, eStateDetached=9,
9
+ eStopReasonNone=1, eStopReasonInvalid=0, eStopReasonBreakpoint=3)
10
+ sys.modules['lldb'] = lldb
11
+ import worker
12
+ from inspection import Inspection
13
+ from breakpoints import Breakpoints
14
+
15
+ # Independent pause, idle and absolute expiry cannot be extended by heartbeats.
16
+ w = object.__new__(worker.Worker)
17
+ clock = [100.0]
18
+ worker.time.monotonic = lambda: clock[0]
19
+ w.created, w.activity, w.paused_at = 0, 100, 40
20
+ assert w.expired() == 'pause_limit'
21
+ w.paused_at = None
22
+ clock[0] = 400
23
+ assert w.expired() == 'idle_limit'
24
+ w.activity = 1800
25
+ clock[0] = 1800
26
+ assert w.expired() == 'session_limit'
27
+
28
+ # An internal transition with an empty thread list must not create a pause epoch.
29
+ i = Inspection()
30
+ p = types.SimpleNamespace(GetNumThreads=lambda: 0, GetState=lambda: 5, GetSelectedThread=lambda: types.SimpleNamespace(IsValid=lambda: False))
31
+ assert i.stopped(p) is None
32
+ assert i.stop_id is None
33
+ for method, args in [(i.thread, {'stopId': 'old', 'threadId': 'old'}),
34
+ (i.locals, {'stopId': 'old', 'frameId': 'old'})]:
35
+ try:
36
+ method(args)
37
+ raise AssertionError('Stale reference accepted')
38
+ except ValueError as error:
39
+ assert str(error) == 'DEBUGGER_STALE_REFERENCE'
40
+
41
+ # Exact source-line resolver must never request nearest-code relocation.
42
+ class Entry:
43
+ def IsValid(self): return True
44
+ def GetFileSpec(self): return '/tmp/App.swift'
45
+ def GetLine(self): return 13
46
+ class Location:
47
+ def GetAddress(self): return types.SimpleNamespace(GetLineEntry=lambda: Entry())
48
+ def SetEnabled(self, enabled): self.enabled = enabled
49
+ class Breakpoint:
50
+ def __init__(self): self.location = Location()
51
+ def __iter__(self): return iter([self.location])
52
+ def GetID(self): return 1
53
+ class Target:
54
+ def __init__(self): self.deleted = False; self.bp = Breakpoint()
55
+ def BreakpointCreateByLocation(self, *args):
56
+ assert args[-1] is False
57
+ return self.bp
58
+ def BreakpointDelete(self, identity): self.deleted = True
59
+ def FindCompileUnits(self, source): return types.SimpleNamespace(GetSize=lambda: 1)
60
+ lldb.SBFileSpec = lambda source: source
61
+ lldb.SBFileSpecList = list
62
+ t = Target()
63
+ b = Breakpoints(t)
64
+ r = b.set({'sourceFile': '/tmp/App.swift', 'line': 12})
65
+ assert r['status'] == 'rejected'
66
+ assert t.bp.location.enabled is False and t.deleted
67
+
68
+ # Polling must consume events before reading the process snapshot.
69
+ trace = []
70
+ class Listener:
71
+ def GetNextEvent(self, event): trace.append('consume'); return False
72
+ class Process:
73
+ def GetState(self): trace.append('state'); return 6
74
+ w.debugger = types.SimpleNamespace(GetListener=lambda: Listener())
75
+ w.process, w.terminal = Process(), False
76
+ lldb.SBEvent = object
77
+ w.poll()
78
+ assert trace == ['consume', 'state']
79
+
80
+ # EOF cleanup must release an actual target, never call Kill or expression APIs.
81
+ w.attached = True
82
+ w.inspection = Inspection()
83
+ w.breakpoints = types.SimpleNamespace(clear=lambda: None)
84
+ w.process = types.SimpleNamespace(IsValid=lambda: True, GetState=lambda: 5,
85
+ Detach=lambda: types.SimpleNamespace(Success=lambda: True))
86
+ assert w.detach() == {'detached': True}
87
+ assert w.terminal
88
+ print('PASS LLDB worker expiry, transient stops, stale references, exact lines, events and detach')
89
+
90
+ # Static Swift bool decoding must preserve boolean meaning without formatters.
91
+ lldb.eNoDynamicValues, lldb.eTypeIsScalar, lldb.eTypeIsInteger, lldb.eTypeIsFloat = 0, 1, 2, 4
92
+ class BoolValue:
93
+ def GetNonSyntheticValue(self): return self
94
+ def GetName(self): return 'cancelled'
95
+ def GetTypeName(self): return 'Swift.Bool'
96
+ def GetError(self): return types.SimpleNamespace(Success=lambda: True)
97
+ def GetType(self): return types.SimpleNamespace(GetTypeFlags=lambda: 0)
98
+ def GetValue(self): return '1'
99
+ def GetChildMemberWithName(self, name):
100
+ assert name == '_value'
101
+ return types.SimpleNamespace(GetNonSyntheticValue=lambda: self)
102
+ def IsValid(self): return True
103
+ values = types.SimpleNamespace(GetSize=lambda: 1, GetValueAtIndex=lambda index: BoolValue())
104
+ i.stop_id = '00000000-0000-0000-0000-000000000001'
105
+ i.frames['frame'] = types.SimpleNamespace(GetVariables=lambda *args: values)
106
+ assert i.locals({'stopId': i.stop_id, 'frameId': 'frame'})['locals'][0]['value'] == 'true'
107
+
108
+ # Reference summaries are stable within a stop and never expose the pointer.
109
+ pointer = types.SimpleNamespace(GetValue=lambda: '0x1234')
110
+ first = i.object_identity(pointer, 'Controller')
111
+ assert first == i.object_identity(pointer, 'Controller') and '1234' not in first
112
+ i.stop_id = '00000000-0000-0000-0000-000000000002'
113
+ assert first != i.object_identity(pointer, 'Controller')
114
+
115
+ # Repeated stack pages reuse references and cannot grow beyond the stop cap.
116
+ import inspection
117
+ original_location = inspection.location
118
+ inspection.location = lambda frame: {'source_file': '/tmp/App.swift', 'line': 1}
119
+ try:
120
+ i.invalidate()
121
+ i.stop_id = '00000000-0000-0000-0000-000000000001'
122
+ i.threads['thread'] = types.SimpleNamespace(GetNumFrames=lambda: 1001, GetFrameAtIndex=lambda index: object())
123
+ args = {'stopId': i.stop_id, 'threadId': 'thread', 'limit': 100}
124
+ assert i.stack(args)['frames'][0]['frame_id'] == i.stack(args)['frames'][0]['frame_id']
125
+ for offset in range(0, 1000, 100): i.stack({**args, 'offset': offset})
126
+ assert len(i.frames) == 1000
127
+ i.threads['other'] = i.threads['thread']
128
+ result = i.stack({**args, 'threadId': 'other'})
129
+ assert result['frames'] == [] and result['truncated']
130
+ finally:
131
+ inspection.location = original_location
132
+ print('PASS static bool decoding, opaque identities and bounded stable frame references')
133
+
134
+ # Error-reporting StepInto requires LLDB's UINT32_MAX sentinel, not line zero.
135
+ lldb.LLDB_INVALID_LINE_NUMBER, lldb.eOnlyDuringStepping = 4294967295, 0
136
+ lldb.SBError = lambda: types.SimpleNamespace(Success=lambda: True)
137
+ class StepThread:
138
+ def StepInto(self, target, end_line, error, run_mode):
139
+ assert target is None and end_line == 4294967295
140
+ w.terminal = False
141
+ w.inspection = Inspection()
142
+ w.inspection.stop_id = 'stop'
143
+ w.inspection.threads['thread'] = StepThread()
144
+ w.process = types.SimpleNamespace(GetState=lambda: 5)
145
+ assert w.command({'command': 'step', 'stopId': 'stop', 'threadId': 'thread', 'kind': 'into'}) == {'resumed': True}
146
+ assert w.inspection.stop_id is None
147
+ print('PASS LLDB step error overload uses the documented invalid-line sentinel')
148
+
149
+ # Signals and exceptions must not be misrepresented as completed source steps.
150
+ lldb.eStopReasonPlanComplete, lldb.eStopReasonSignal, lldb.eStopReasonException = 8, 5, 6
151
+ lldb.eStopReasonWatchpoint, lldb.eStopReasonExec = 4, 7
152
+ assert inspection.stop_reason(types.SimpleNamespace(GetStopReason=lambda: 5)) == 'signal'
153
+ assert inspection.stop_reason(types.SimpleNamespace(GetStopReason=lambda: 6)) == 'exception'
154
+ assert inspection.stop_reason(types.SimpleNamespace(GetStopReason=lambda: 8)) == 'step'
155
+ assert inspection.stop_reason(types.SimpleNamespace(GetStopReason=lambda: 99)) == 'unknown'
156
+ print('PASS LLDB stop reasons preserve signal/exception versus step evidence')
157
+
158
+ # Losing a previously owned process handle cannot prove detach or resume.
159
+ w.attached = True
160
+ w.process = types.SimpleNamespace(IsValid=lambda: False)
161
+ try:
162
+ w.detach()
163
+ raise AssertionError('Lost process handle incorrectly confirmed detach')
164
+ except ValueError as error:
165
+ assert str(error) == 'DEBUGGER_WORKER_FAILED'
166
+ print('PASS lost process handles preserve cleanup uncertainty')
@@ -0,0 +1,10 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { fileURLToPath } from 'node:url'
4
+ const result = spawnSync('python3', ['-B', fileURLToPath(new URL('./lldb-worker.py', import.meta.url))], { encoding: 'utf8', timeout: 15000 })
5
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
6
+ console.log('UNRUN optional LLDB Python worker checks: python3 is unavailable')
7
+ } else {
8
+ assert.equal(result.status, 0, result.stderr || result.error?.message)
9
+ console.log(result.stdout.trim())
10
+ }