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,37 @@
1
+ import assert from 'node:assert/strict'
2
+ import { ReadinessTracker, withReadinessTracker } from '../../../src/observe/readiness.js'
3
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
4
+ import { getSnapshotSessionId } from '../../../src/observe/snapshot-metadata.js'
5
+ import { waitForUIChange } from '../../../src/interact/waits/ui-change-wait.js'
6
+ import { resolveUiChangeScope } from '../../../src/interact/targeting/target-scope.js'
7
+ import type { Clock } from '../../../src/runtime/clock.js'
8
+
9
+ async function run() {
10
+ let now = 10000
11
+ const clock: Clock = { now: () => now, sleep: async ms => { now += ms } }
12
+ class Tracker extends ReadinessTracker {
13
+ override observe(tree: unknown, started: number) { return super.observe(tree, started, now) }
14
+ }
15
+ const tracker = new Tracker('android', 'fixture', 100)
16
+ const readTimes: number[] = []
17
+ const result = await withDeviceReadinessProbe(async (_command, args) => ({
18
+ exitCode: 0, stderr: '', stdout: args.includes('power')
19
+ ? `mWakefulness=${now >= 10600 && now < 11200 ? 'Asleep' : 'Awake'}\nmWakefulnessChanging=false`
20
+ : 'KeyguardServiceDelegate\n showing=false\n mIsShowing=false'
21
+ }), () => withReadinessTracker(tracker, () => waitForUIChange({ platform: 'android', deviceId: 'fixture', timeout_ms: 3000, stability_window_ms: 1200, expected_change: 'text_change' }, {
22
+ clock,
23
+ observe: async () => {
24
+ const text = readTimes.length ? 'After' : 'Before'
25
+ readTimes.push(now)
26
+ return { device: { platform: 'android', id: 'fixture' }, screen: '', resolution: { width: 100, height: 100 },
27
+ elements: [{ text, type: 'TextView', stable_id: 'label', visible: true, bounds: [0, 0, 100, 40] }],
28
+ captured_at_ms: now, snapshot_revision: readTimes.length, snapshot_session_id: getSnapshotSessionId() }
29
+ },
30
+ scope: (tree, scope, target) => resolveUiChangeScope(tree, scope, target, () => null)
31
+ })))
32
+ assert.equal(result.success, true)
33
+ assert.deepEqual(readTimes, [10000, 10300, 11200, 11500, 11800, 12100, 12400])
34
+ assert.equal(result.elapsed_ms, 2400, 'the full change stability window starts again after the blocker')
35
+ console.log('PASS readiness interruption resets UI-change stability, excluding blocked time')
36
+ }
37
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -1,6 +1,8 @@
1
1
  import { ToolsObserve } from '../../../src/observe/index.js'
2
2
  import assert from 'assert'
3
3
  import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
4
+ import { computeScreenFingerprint } from '../../../src/utils/ui/index.js'
5
+ import type { DeviceInfo } from '../../../src/types.js'
4
6
 
5
7
  async function run() {
6
8
  console.log('Starting capture_debug_snapshot unit tests...')
@@ -13,7 +15,7 @@ async function run() {
13
15
  const origReadLogStreamHandler = (ToolsObserve as any).readLogStreamHandler
14
16
  const origGetLogsHandler = (ToolsObserve as any).getLogsHandler
15
17
 
16
- const device = { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }
18
+ const device: DeviceInfo = { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }
17
19
  let resolutions = 0
18
20
  _setDeviceListersForTests({
19
21
  listAndroidDevices: async () => { resolutions++; return [device] },
@@ -30,17 +32,23 @@ async function run() {
30
32
  assertTarget(args)
31
33
  return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screenshot: 'BASE64PNG', resolution: { width: 1080, height: 1920 } }
32
34
  }
35
+ const current = { device, package: 'com.example', activity: 'com.example.Main', shortActivity: 'Main' }
36
+ const postTreeActivity = { ...current, activity: 'com.example.After' }
37
+ let activityCalls = 0
33
38
  ;(ToolsObserve as any).getCurrentScreenHandler = async function(args: any) {
34
39
  assert.strictEqual(args.deviceId, device.id)
35
- return { device: { platform: 'android', id: 'mock' }, package: 'com.example', activity: 'com.example.Main', shortActivity: 'Main' }
40
+ return ++activityCalls === 1 ? current : postTreeActivity
36
41
  }
37
- ;(ToolsObserve as any).getScreenFingerprintHandler = async function(args: any) {
38
- assertTarget(args)
39
- return { fingerprint: 'abc123', activity: 'Main' }
42
+ let independentFingerprints = 0
43
+ ;(ToolsObserve as any).getScreenFingerprintHandler = async function() {
44
+ independentFingerprints++
45
+ return { fingerprint: 'independent' }
40
46
  }
47
+ let treeReads = 0
41
48
  ;(ToolsObserve as any).getUITreeHandler = async function(args: any) {
42
49
  assertTarget(args)
43
- return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] }
50
+ treeReads++
51
+ return { device, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] }
44
52
  }
45
53
  ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) {
46
54
  assertTarget(args)
@@ -53,8 +61,11 @@ async function run() {
53
61
 
54
62
  const res1: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 50, sessionId: 's1' })
55
63
  console.log('res1:', JSON.stringify(res1, null, 2))
56
- const pass1 = res1 && res1.raw && res1.raw.screenshot === 'BASE64PNG' && res1.raw.activity && res1.raw.fingerprint === 'abc123' && Array.isArray(res1.raw.logs) && res1.raw.logs.length === 1
64
+ const pass1 = res1 && res1.raw && res1.raw.screenshot === 'BASE64PNG' && res1.raw.activity === current.activity && Array.isArray(res1.raw.logs) && res1.raw.logs.length === 1
57
65
  assert.ok(pass1, 'captureDebugSnapshot should aggregate successful handler results')
66
+ assert.equal(treeReads, 1)
67
+ assert.equal(independentFingerprints, 0)
68
+ assert.equal(res1.raw.fingerprint, computeScreenFingerprint(res1.raw.ui_tree, postTreeActivity, 'android').fingerprint)
58
69
  assert.strictEqual(res1.semantic.screen, 'Main')
59
70
  assert.strictEqual(res1.semantic.confidence >= 0.7, true)
60
71
  assert.deepStrictEqual(res1.semantic.actions_available, null)
@@ -63,7 +74,7 @@ async function run() {
63
74
 
64
75
  // --- Test 2: screenshot and ui tree fail; logs fallback to getLogs ---
65
76
  ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); throw new Error('screencap failed') }
66
- ;(ToolsObserve as any).getUITreeHandler = async function(args: any) { assertTarget(args); throw new Error('uie_error') }
77
+ ;(ToolsObserve as any).getUITreeHandler = async function(args: any) { assertTarget(args); treeReads++; throw new Error('ACTION_TIMEOUT') }
67
78
  ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) { assertTarget(args); return { entries: [] } }
68
79
  ;(ToolsObserve as any).getLogsHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, logs: ['INFO starting','ERROR crash here'], logCount: 2 } }
69
80
 
@@ -71,33 +82,76 @@ async function run() {
71
82
  console.log('res2:', JSON.stringify(res2, null, 2))
72
83
  const pass2 = res2 && res2.raw && res2.raw.screenshot_error && res2.raw.ui_tree_error && Array.isArray(res2.raw.logs) && res2.raw.logs.length === 2
73
84
  assert.ok(pass2, 'captureDebugSnapshot should surface partial failures and fallback logs')
85
+ assert.equal(res2.raw.ui_tree_error, 'ACTION_TIMEOUT')
86
+ assert.equal(res2.raw.fingerprint_error, 'ACTION_TIMEOUT')
87
+ assert.equal(res2.raw.fingerprint, null)
88
+ assert.equal(treeReads, 2)
74
89
  console.log('Test 2:', pass2 ? 'PASS' : 'FAIL')
75
90
 
76
91
  // --- Test 3: includeLogs=false should omit logs ---
77
92
  ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, screenshot: null } }
78
- ;(ToolsObserve as any).getCurrentScreenHandler = async function(args: any) { assert.strictEqual(args.deviceId, device.id); return { device: { platform: 'android', id: 'mock' }, package: '', activity: '', shortActivity: '' } }
79
- ;(ToolsObserve as any).getScreenFingerprintHandler = async function(args: any) { assertTarget(args); return { fingerprint: null } }
80
- ;(ToolsObserve as any).getUITreeHandler = async function(args: any) { assertTarget(args); return { device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 0, height: 0 }, elements: [] } }
93
+ ;(ToolsObserve as any).getCurrentScreenHandler = async function(args: any) { assert.strictEqual(args.deviceId, device.id); throw new Error('activity unavailable') }
94
+ ;(ToolsObserve as any).getUITreeHandler = async function(args: any) {
95
+ assertTarget(args)
96
+ const revision = ++treeReads
97
+ return { device, screen: '', snapshot_revision: revision, captured_at_ms: 1000 + revision,
98
+ resolution: { width: 1080, height: 1920 }, elements: [{ visible: true, text: `State ${revision}` }] }
99
+ }
81
100
  ;(ToolsObserve as any).readLogStreamHandler = async function(args: any) { assertTarget(args); return { entries: [] } }
82
101
 
83
102
  const res3: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false })
84
103
  console.log('res3:', JSON.stringify(res3, null, 2))
85
104
  const pass3 = res3 && res3.raw && typeof res3.raw.logs !== 'undefined' && res3.raw.logs.length === 0
86
105
  assert.ok(pass3, 'captureDebugSnapshot should return an empty logs array when includeLogs is false')
106
+ assert.equal(res3.raw.activity_error, 'activity unavailable')
107
+ assert.equal(res3.raw.fingerprint, computeScreenFingerprint(res3.raw.ui_tree, null, 'android').fingerprint)
87
108
  console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
88
109
 
89
110
  let screenshotCalls = 0
90
111
  ;(ToolsObserve as any).captureScreenshotHandler = async function(args: any) { assertTarget(args); screenshotCalls++; return { screenshot: 'unexpected' } }
91
- const res4: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false, includeScreenshot: false })
112
+ const res4: any = await ToolsObserve.captureDebugSnapshotHandler({ includeLogs: false, includeScreenshot: false })
92
113
  assert.strictEqual(screenshotCalls, 0, 'includeScreenshot=false must not invoke screenshot capture')
93
114
  assert.strictEqual(res4.raw.screenshot, null)
94
115
  assert.strictEqual(resolutions, 4, 'Each snapshot resolves its own target once')
116
+ assert.equal(treeReads, 4)
117
+ assert.notEqual(res4.raw.fingerprint, res3.raw.fingerprint)
118
+ assert.equal(res4.raw.snapshot_revision, 4)
119
+ assert.equal(res4.raw.captured_at_ms, 1004)
120
+ assert.equal(independentFingerprints, 0)
95
121
 
96
122
  // Discovery failure must not collect from an arbitrary/default device.
97
123
  _setDeviceListersForTests({ listAndroidDevices: async () => { throw new Error('fixture discovery unavailable') } })
98
124
  await assert.rejects(() => ToolsObserve.captureDebugSnapshotHandler({ platform: 'android' }), /fixture discovery unavailable/)
99
125
  assert.strictEqual(screenshotCalls, 0, 'No component capture should run without a resolved target')
100
126
 
127
+ const other: DeviceInfo = { ...device, id: 'other-fixture' }
128
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device, other] })
129
+ ;(ToolsObserve as any).getUITreeHandler = async ({ deviceId }: { deviceId: string }) => ({
130
+ device: deviceId === device.id ? device : other, screen: '',
131
+ resolution: { width: 100, height: 200 }, elements: [{ visible: true, text: deviceId }]
132
+ })
133
+ ;(ToolsObserve as any).getCurrentScreenHandler = async ({ deviceId }: { deviceId: string }) => ({
134
+ activity: 'com.example.Main', device: deviceId === device.id ? device : other
135
+ })
136
+ const [first, second] = await Promise.all([device, other].map(target => ToolsObserve.captureDebugSnapshotHandler({
137
+ platform: 'android', deviceId: target.id, includeLogs: false, includeScreenshot: false
138
+ })))
139
+ assert.equal(first.raw.ui_tree?.device.id, device.id)
140
+ assert.equal(second.raw.ui_tree?.device.id, other.id)
141
+ assert.notEqual(first.raw.fingerprint, second.raw.fingerprint)
142
+ assert.equal(independentFingerprints, 0)
143
+
144
+ const ios: DeviceInfo = { ...device, platform: 'ios', id: 'ios-fixture' }
145
+ _setDeviceListersForTests({ listIOSDevices: async () => [ios] })
146
+ ;(ToolsObserve as any).getUITreeHandler = async ({ platform, deviceId }: { platform: string; deviceId: string }) => {
147
+ assert.equal(platform, 'ios')
148
+ assert.equal(deviceId, ios.id)
149
+ return { device: ios, screen: '', resolution: { width: 100, height: 200 }, elements: [] }
150
+ }
151
+ const iosResult = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'ios', deviceId: ios.id, includeLogs: false, includeScreenshot: false })
152
+ assert.equal(iosResult.raw.fingerprint, 'independent')
153
+ assert.equal(independentFingerprints, 1)
154
+
101
155
  } finally {
102
156
  _resetDeviceListersForTests()
103
157
  ;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { cleanupReadinessDevice } from '../../device/automated/observe/device-readiness-cleanup.js'
3
+
4
+ async function run() {
5
+ for (const failedStep of [0, 1, 2]) {
6
+ const commands: string[][] = []
7
+ let closed = 0
8
+ const commandFailure = new Error('disconnected')
9
+ const closeFailure = new Error('close failed')
10
+ const failures = await cleanupReadinessDevice(true, (...args) => {
11
+ commands.push(args)
12
+ if (commands.length - 1 === failedStep) throw commandFailure
13
+ }, async () => { closed++; throw closeFailure })
14
+ assert.equal(commands.length, 3, 'a failed restoration must not skip later steps')
15
+ assert.equal(commands[2].at(-1), '3', 'Home is attempted')
16
+ assert.equal(closed, 1, 'client close always attempted')
17
+ assert.deepEqual(failures, [commandFailure, closeFailure])
18
+ }
19
+ let closed = false
20
+ assert.deepEqual(await cleanupReadinessDevice(false, () => { assert.fail('restoration disabled') }, async () => { closed = true }), [])
21
+ assert.equal(closed, true)
22
+ console.log('PASS readiness cleanup continues after each ADB failure and closes the client')
23
+ }
24
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,97 @@
1
+ import { handleAdjustControl } from '../../../src/server/handlers/interact.js'
2
+ import { ToolsInteract } from '../../../src/interact/index.js'
3
+ import type { AdjustControlResponse } from '../../../src/types.js'
4
+ import { ReadinessTracker } from '../../../src/observe/readiness.js'
5
+ import { getSnapshotSessionId } from '../../../src/observe/snapshot-metadata.js'
6
+ import assert from 'node:assert/strict'
7
+ import { parseAndroidPower, parseAndroidLock, observeDeviceReadiness, parseIosLock, withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
8
+ import { registerDebuggerPauseProvider } from '../../../src/debugger/guard.js'
9
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
10
+ import { compactAction, actionHandler } from '../../../src/server/handlers/action-execution.js'
11
+ import { wrapResponse } from '../../../src/server/common.js'
12
+ import { withResolvedDevice } from '../../../src/utils/resolve-device.js'
13
+
14
+ const power = (state: string, changing = 'false') => `mWakefulness=${state}\nmWakefulnessChanging=${changing}`
15
+ const lock = (state: string) => `KeyguardServiceDelegate\n showing=${state}\n KeyguardStateMonitor\n mIsShowing=${state}`
16
+ for (const [text, expected] of [[power('Awake'), 'awake'], [power('Asleep'), 'asleep'], [power('Dozing'), 'asleep'], [power('Awake', 'true'), 'transitioning'], [power('Dreaming'), 'unknown'], ['mWakefulness=Awake', 'unknown'], [power('Awake') + '\nmWakefulness=Asleep', 'unknown']]) assert.equal(parseAndroidPower(text), expected)
17
+ assert.equal(parseAndroidPower('Power Manager State:\n' + power('Dozing') + '\nSettings and Configuration:\nmWakefulness=3'), 'asleep', 'vendor debug fields outside current-state section cannot conflict with it')
18
+ assert.equal(parseAndroidLock(lock('true')), 'locked')
19
+ assert.equal(parseAndroidLock(lock('false')), 'unlocked')
20
+ for (const text of ['', 'deviceHasKeyguard=true', 'showing=false', lock('true').replace('mIsShowing=true', 'mIsShowing=false')]) assert.equal(parseAndroidLock(text), 'unknown')
21
+ const unsupported = JSON.stringify({ error: { domain: 'com.apple.dt.CoreDeviceError', code: 1001, userInfo: { CapabilityFeatureIdentifier: { string: 'com.apple.coredevice.feature.getlockstate' } } } })
22
+ assert.equal(parseIosLock(unsupported), 'unsupported')
23
+ assert.equal(parseIosLock(unsupported.replace('getlockstate', 'other')), 'unknown')
24
+ assert.equal(parseIosLock('{"result":{"passcodeRequired":false}}'), 'unknown', 'unverified success semantics cannot establish unlocked')
25
+ const device = { platform: 'android', id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
26
+ async function run() {
27
+ const calls: string[][] = []
28
+ const probe = async (_command: string, args: string[], timeout: number) => {
29
+ calls.push(args)
30
+ assert.ok(timeout > 0 && timeout <= 1000)
31
+ return { exitCode: 0, stdout: args.includes('power') ? power('Asleep') : lock('true'), stderr: '' }
32
+ }
33
+ const started = Date.now()
34
+ const state = await runWithBudget(500, () => observeDeviceReadiness(device, probe))
35
+ assert.deepEqual(state.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
36
+ assert.equal(state.input_ready, false)
37
+ assert.ok(state.evidence.every(item => item.captured_at_ms >= started))
38
+ assert.ok(calls.every(args => args[0] === '-s' && args[1] === device.id))
39
+ assert.ok(!JSON.stringify(state).includes('mWakefulness'))
40
+ const partial = await observeDeviceReadiness(device, async (...args) => {
41
+ if (args[1].includes('power')) throw new Error('private diagnostic')
42
+ return probe(...args)
43
+ })
44
+ assert.equal(partial.wakefulness, 'unknown')
45
+ assert.equal(partial.lock_state, 'locked')
46
+ assert.ok(!JSON.stringify(partial).includes('private diagnostic'))
47
+ const recovered = await observeDeviceReadiness(device, async (_cmd, args) => ({ exitCode: 0, stdout: args.includes('power') ? power('Awake') : lock('false'), stderr: '' }))
48
+ assert.equal(recovered.input_ready, null)
49
+ assert.deepEqual(recovered.blockers, [])
50
+ const tracker = new ReadinessTracker(device.platform, device.id, 100)
51
+ const tree = { device, captured_at_ms: Date.now(), snapshot_revision: 1, snapshot_session_id: getSnapshotSessionId(), resolution: { width: 100, height: 100 }, elements: [{ type: 'Button', visible: true, text: 'Ready' }] }
52
+ tracker.observe(tree, tree.captured_at_ms, 0)
53
+ assert.equal(tracker.observe(tree, tree.captured_at_ms, 100).state, 'settled')
54
+ await withDeviceReadinessProbe(probe, () => tracker.observeDevice())
55
+ assert.equal(tracker.ready, false)
56
+ await withDeviceReadinessProbe(async (_cmd, args) => ({ exitCode: 0, stdout: args.includes('power') ? power('Awake') : lock('false'), stderr: '' }), () => tracker.observeDevice())
57
+ assert.equal(tracker.observe(tree, tree.captured_at_ms, 500).state, 'unknown', 'blocker breaks UI stability continuity')
58
+ assert.equal(tracker.observe(tree, tree.captured_at_ms, 600).state, 'settled')
59
+ const before = calls.length
60
+ const expired = await runWithBudget(0, () => observeDeviceReadiness(device, probe))
61
+ assert.equal(calls.length, before, 'no probe starts after expiry')
62
+ assert.equal(expired.wakefulness, 'unknown')
63
+ assert.equal((await observeDeviceReadiness(null, probe)).limitations[0], 'DEVICE_TARGET_UNAVAILABLE')
64
+ const ios = await observeDeviceReadiness({ platform: 'ios', id: 'ios-fixture' }, async (_cmd, args, timeout) => { assert.equal(args[args.indexOf('--timeout') + 1], '5'); assert.ok(timeout <= 1000); return { exitCode: 1, stdout: unsupported, stderr: '' } })
65
+ assert.equal(ios.lock_state, 'unsupported')
66
+ assert.equal(ios.wakefulness, 'unknown')
67
+ registerDebuggerPauseProvider(() => ({ debug_session_id: 'paused', captured_at_ms: started, target: {} }))
68
+ try {
69
+ const paused = await observeDeviceReadiness(device, probe)
70
+ assert.deepEqual(paused.blockers, ['DEBUGGER_PAUSED'])
71
+ assert.equal(calls.length, before)
72
+ } finally { registerDebuggerPauseProvider(() => null) }
73
+ assert.deepEqual(compactAction({ success: true, device_readiness: state }, 'none').device_readiness, state)
74
+ for (const responseMode of ['debug', 'compact']) {
75
+ const response = await withDeviceReadinessProbe(probe, () => withResolvedDevice(device, () => actionHandler('start_app', async () => wrapResponse({ success: true, device }))( { platform: 'android', deviceId: device.id, responseMode, verificationMode: 'none', actionTimeoutMs: 500 })))
76
+ const result = JSON.parse(response.content[0].text!)
77
+ assert.equal(result.success, true, 'blocker observation preserves dispatch outcome')
78
+ assert.equal(result.verification.status, 'not_requested')
79
+ assert.deepEqual(result.device_readiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
80
+ }
81
+ const failedProbe = await withDeviceReadinessProbe(async () => { throw new Error('private'); }, () => withResolvedDevice(device, () => actionHandler('tap', async () => wrapResponse({ success: true, device }))({ responseMode: 'compact', verificationMode: 'none', actionTimeoutMs: 200 })))
82
+ const unknown = JSON.parse(failedProbe.content[0].text!)
83
+ assert.equal(unknown.success, true)
84
+ assert.equal(unknown.device_readiness.wakefulness, 'unknown')
85
+ assert.equal(unknown.device_readiness.lock_state, 'unknown')
86
+ assert.ok(!JSON.stringify(unknown.device_readiness).includes('private'))
87
+ const adjust = ToolsInteract.adjustControlHandler
88
+ ToolsInteract.adjustControlHandler = async () => ({ success: true, device } as AdjustControlResponse)
89
+ try {
90
+ const response = await withDeviceReadinessProbe(probe, () => handleAdjustControl({ selector: { text: 'Volume' }, targetValue: 50 }))
91
+ const result = JSON.parse(response.content[0].text!)
92
+ assert.equal(result.success, true)
93
+ assert.equal(result.device_readiness.input_ready, false, 'adjust_control carries the same blocker contract')
94
+ } finally { ToolsInteract.adjustControlHandler = adjust }
95
+ console.log('Device readiness: parsing, probes, budgets, recovery, debugger and action contracts PASS')
96
+ }
97
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,90 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { iosCapabilities, resolveIosApp } from '../../../src/observe/ios-trace/capabilities.js'
6
+ import { validateArtifact, validateToc, traceSize, IOS_TRACE_MAX_BYTES } from '../../../src/observe/ios-trace/artifact.js'
7
+ import { IosTraceNative } from '../../../src/observe/ios-trace/native.js'
8
+ import { spawnTrace, stopTraceChild, classifyTraceFailure, type TraceSpawn, type TraceCommandResult } from '../../../src/observe/ios-trace/process.js'
9
+ const app={app_id:'fixture',pid:123,executable:'/Fixture.app/Fixture',started_at:'Wed Nov 4 10:00:00 2026'}
10
+ const toc=(id='sim',pid=123,template='Time Profiler')=>`<trace-toc><run><info><target><device uuid="${id}"/><process pid="${pid}"/></target><summary><template-name>${template}</template-name></summary></info><processes><process pid="${pid}" path="/Fixture.app/Fixture"/></processes><data><table schema="time-sample"/></data></run></trace-toc>`
11
+ function fake(){
12
+ const calls:{command:string;args:string[];signals:NodeJS.Signals[]}[]=[],lines:string[]=[]
13
+ let mismatch=false,failExport=false,failReady=false
14
+ const spawn:TraceSpawn=(command,args)=>{
15
+ const call={command,args,signals:[] as NodeJS.Signals[]};calls.push(call)
16
+ const recording=args.includes('record'),listener=command.endsWith('/notification'),pending=recording||listener
17
+ let alive=pending,resolve!:(r:TraceCommandResult)=>void
18
+ const done=new Promise<TraceCommandResult>(r=>{resolve=r})
19
+ const execute=async()=>{
20
+ let output=''
21
+ if(args.includes('get_app_container'))output='/Fixture.app'
22
+ else if(args.includes('launchctl'))output='123 0 UIKitApplication:fixture[abc]'
23
+ else if(command==='/bin/ps')output=args.includes('lstart=')?app.started_at:mismatch?'/Other.app/Other':app.executable
24
+ else if(args.includes('clang'))await fs.writeFile(args[args.indexOf('-o')+1],'binary')
25
+ else if(args.includes('xcodebuild'))output='Xcode 27.2'
26
+ else if(args.includes('templates'))output='== Standard Templates ==\nTime Profiler\nSystem Trace\nAnimation Hitches'
27
+ else if(args.includes('xctrace')&&args.includes('devices'))output='fixture (sim)'
28
+ else if(args.includes('devices'))output=JSON.stringify({devices:{'iOS-26-4':[{udid:'sim',state:'Booted',name:'fixture',isAvailable:true}]}})
29
+ else if(args.includes('export')){
30
+ if(failExport){resolve({code:2,output:'private stderr',timedOut:false});return}
31
+ const file=args[args.indexOf('--output')+1]
32
+ await fs.writeFile(file,args.includes('--toc')?toc():'<trace-query-result><node><schema name="time-sample"/><row><sample-time>1</sample-time></row></node></trace-query-result>')
33
+ }
34
+ if(!pending)resolve({code:0,output,timedOut:false})
35
+ }
36
+ void execute()
37
+ return {done,alive:()=>alive,signal:s=>{call.signals.push(s);alive=false;resolve({code:0,output:'Stopped',timedOut:false})},line:async(line)=>{lines.push(line);if(failReady&&line==='READY')throw new Error('READINESS_TIMEOUT')}}
38
+ }
39
+ return {spawn,calls,lines,flags:(v:{mismatch?:boolean;export?:boolean;ready?:boolean})=>{mismatch=v.mismatch??false;failExport=v.export??false;failReady=v.ready??false}}
40
+ }
41
+ async function run(){
42
+ const f=fake(),directory=await fs.mkdtemp(path.join(os.tmpdir(),'ios-trace-unit-'))
43
+ try{
44
+ assert.deepEqual(await resolveIosApp('sim','fixture',f.spawn),app)
45
+ f.flags({mismatch:true});await assert.rejects(()=>resolveIosApp('sim','fixture',f.spawn),/APP_IDENTITY_UNAVAILABLE/);f.flags({})
46
+ const cap=await iosCapabilities('sim','fixture','Time Profiler',f.spawn,'darwin')
47
+ assert.equal(cap.supported,true);assert.equal(cap.prerequisites.permissions,'unknown');assert.equal(cap.templates.filter(t=>t.supported).length,1)
48
+ assert.equal((await iosCapabilities('sim',undefined,'System Trace',f.spawn,'darwin')).supported,false)
49
+ assert.equal((await iosCapabilities('physical',undefined,'Time Profiler',f.spawn,'darwin')).supported,false)
50
+ assert.equal((await iosCapabilities('sim',undefined,'Time Profiler',f.spawn,'linux')).reasons[0],'MACOS_REQUIRED')
51
+ assert.ok(!f.calls.some(c=>c.args.includes('record')),'Discovery must not record')
52
+ validateToc(toc(),'sim',app)
53
+ assert.throws(()=>validateToc(toc(),'sim',{...app,executable:'/Other.app/Other'}),/ARTIFACT_APP_MISMATCH/)
54
+ assert.equal(classifyTraceFailure({code:1,output:'secret path: permission denied',timedOut:false}),'PROFILING_PERMISSION_REQUIRED')
55
+ assert.equal(classifyTraceFailure({code:1,output:'Cannot find process for provided pid: 123',timedOut:false}),'APP_NOT_RUNNING')
56
+ for(const xml of [toc('other'),toc('sim',456),toc('sim',123,'System Trace'),'<invalid>'])assert.throws(()=>validateToc(xml,'sim',app))
57
+ const file=path.join(directory,'capture.trace');await fs.mkdir(file);await fs.writeFile(path.join(file,'data'),'samples')
58
+ const artifact=await validateArtifact(file,'sim',app,f.spawn)
59
+ assert.equal(artifact.validation.sample_rows,1);assert.equal(artifact.kind,'directory');assert.equal((await fs.stat(path.join(file,'data'))).mode&0o777,0o600)
60
+ await assert.rejects(()=>fs.stat(path.join(directory,'samples.xml')),{code:'ENOENT'})
61
+ f.flags({export:true});await assert.rejects(()=>validateArtifact(file,'sim',app,f.spawn));f.flags({})
62
+ const outside=path.join(directory,'link');await fs.symlink(file,outside);await assert.rejects(()=>traceSize(outside),/UNSAFE_ARTIFACT/)
63
+ const huge=path.join(directory,'huge');await fs.writeFile(huge,'');await fs.truncate(huge,IOS_TRACE_MAX_BYTES+1)
64
+ assert.ok(await traceSize(huge)>IOS_TRACE_MAX_BYTES)
65
+ const native=new IosTraceNative(f.spawn)
66
+ const recording=await native.start('sim',app,directory,30000,()=>{})
67
+ assert.deepEqual(f.lines,['ARMED','READY'])
68
+ const recorder=f.calls.find(c=>c.args.includes('record'))!,listener=f.calls.find(c=>c.command.endsWith('/notification'))!
69
+ assert.equal(recorder.args[recorder.args.indexOf('--device')+1],'sim')
70
+ assert.equal(recorder.args[recorder.args.indexOf('--attach')+1],'123')
71
+ assert.equal(recorder.args[recorder.args.indexOf('--notify-tracing-started')+1],listener.args[0])
72
+ assert.ok(!recorder.args.includes('--no-prompt'));await native.stop(recording);assert.deepEqual(recorder.signals,['SIGINT'])
73
+ const failed=fake();failed.flags({ready:true});const other=new IosTraceNative(failed.spawn)
74
+ await assert.rejects(()=>other.start('sim',app,directory,30000,()=>{}),/READINESS_TIMEOUT/)
75
+ assert.ok(failed.calls.find(c=>c.args.includes('record'))?.signals.includes('SIGINT'))
76
+ native.shutdown();other.shutdown()
77
+ const child=spawnTrace(process.execPath,['-e','process.stdout.write("READY\\n");setInterval(()=>{},1000)'],2000)
78
+ await child.line('READY',1000);await stopTraceChild(child,1000);assert.equal(child.alive(),false)
79
+ const signals:NodeJS.Signals[]=[]
80
+ const stuck={done:new Promise<TraceCommandResult>(()=>{}),alive:()=>true,line:async()=>{},signal:(s:NodeJS.Signals)=>{signals.push(s)}}
81
+ await assert.rejects(()=>stopTraceChild(stuck,1),/ACTION_TIMEOUT/)
82
+ assert.deepEqual(signals,['SIGINT','SIGKILL'])
83
+ const noisy=await spawnTrace(process.execPath,['-e','process.stdout.write("x".repeat(100000))'],2000).done
84
+ assert.equal(noisy.output.length,65536)
85
+ const timeout=await spawnTrace(process.execPath,['-e','setInterval(()=>{},1000)'],30).done
86
+ assert.equal(timeout.timedOut,true)
87
+ console.log('PASS iOS native discovery, notification, ownership, artifact schemas and bounded children')
88
+ }finally{await fs.rm(directory,{recursive:true,force:true})}
89
+ }
90
+ run().catch(error=>{console.error(error);process.exitCode=1})
@@ -0,0 +1,154 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { IosTraceSessions } from '../../../src/observe/ios-trace/sessions.js'
5
+ import { IosTraceNative, type IosRecording } from '../../../src/observe/ios-trace/native.js'
6
+ import type { TraceClock } from '../../../src/observe/system-trace.js'
7
+ import type { TraceCommandResult } from '../../../src/observe/ios-trace/process.js'
8
+
9
+ const target = { platform: 'ios' as const, id: 'sim', simulator: true, osVersion: '26.4', model: 'fixture' }
10
+ function fixture() {
11
+ let monitorTick = 0, exitOnMonitor = false, exportMessage = 'EXPORT_FAILED'
12
+ let now = 1000, failStart = false, failStop = false, failExport = false, monitor = '', stops = 0, starts = 0, validations = 0
13
+ const timers = new Map<ReturnType<typeof setTimeout>, () => void>(), recordings: IosRecording[] = [], directories: string[] = []
14
+ const time: TraceClock = { wall: () => now, mono: () => now, sleep: async ms => {now += ms},
15
+ schedule: fn => { const id = {} as ReturnType<typeof setTimeout>; timers.set(id,fn); return id }, clear: id => {timers.delete(id)} }
16
+ class Native extends IosTraceNative {
17
+ resolve = async () => ({ app_id:'fixture', pid:100, executable:'/fixture.app/Fixture',started_at:'fixture-start' })
18
+ async start(_d: string, _app: unknown, directory: string, _duration: number, own: (r: IosRecording) => void) {
19
+ starts++; directories.push(directory)
20
+ let alive = true, resolve!: (v: TraceCommandResult) => void
21
+ const done = new Promise<TraceCommandResult>(r => {resolve=r})
22
+ const recording: IosRecording = {file:path.join(directory,'capture.trace'), ready_at_ms:now,
23
+ recorder:{done, alive:()=>alive, line:async()=>{},signal:()=>{alive=false;resolve({code:0,output:'Reached specified time limit',timedOut:false})}}}
24
+ own(recording); recordings.push(recording)
25
+ await fs.mkdir(recording.file)
26
+ await fs.writeFile(path.join(recording.file,'data'),'sample')
27
+ if(failStart) throw new Error('READINESS_TIMEOUT')
28
+ return recording
29
+ }
30
+ async stop(r: IosRecording) {stops++; if(failStop) throw new Error('ACTION_TIMEOUT');r.recorder.signal('SIGINT');return r.recorder.done}
31
+ async monitor(device: string, app: {pid:number;executable:string}, recording: IosRecording) {
32
+ assert.equal(device,target.id);assert.equal(app.pid,100);assert.equal(app.executable,'/fixture.app/Fixture')
33
+ assert.equal(recording,recordings.at(-1),'Retries must retain the same owned recording')
34
+ now+=monitorTick
35
+ if(exitOnMonitor)recording.recorder.signal('SIGINT')
36
+ if(monitor)throw new Error(monitor)
37
+ }
38
+ async validate(r: IosRecording) {validations++;if(failExport)throw new Error(exportMessage);return {path:r.file,format:'instruments' as const,kind:'directory' as const,size_bytes:6,validation:{readable:true,schema:'time-sample',sample_rows:1}}}
39
+ shutdown() {for(const r of recordings)r.recorder.signal('SIGINT')}
40
+ }
41
+ const sessions = new IosTraceSessions(new Native(),time)
42
+ return {sessions,recordings,timers,flags:(v:{start?:boolean;stop?:boolean;export?:boolean;monitor?:string;monitorTick?:number;exitOnMonitor?:boolean;exportMessage?:string})=>{failStart=v.start??false;failStop=v.stop??false;failExport=v.export??false;monitor=v.monitor??'';monitorTick=v.monitorTick??0;exitOnMonitor=v.exitOnMonitor??false;exportMessage=v.exportMessage??'EXPORT_FAILED'},
43
+ tick:(ms:number)=>{now+=ms},counts:()=>({stops,starts,validations}),
44
+ cleanup:async()=>{await sessions.shutdown();for(const d of directories)await fs.rm(d,{recursive:true,force:true})}}
45
+ }
46
+ async function run() {
47
+ const f=fixture()
48
+ try {
49
+ const start=await f.sessions.start(target,'fixture')
50
+ await assert.rejects(()=>f.sessions.start(target,'fixture'),/TRACE_BUSY/)
51
+ assert.equal(start.state,'recording');assert.equal(start.recording_ready_at_ms,1000)
52
+ const finish=await f.sessions.link(start.trace_id,target,'journey')
53
+ for(const device of [{platform:'android',id:'sim'},{platform:'ios',id:'other'}])await assert.rejects(()=>f.sessions.link(start.trace_id,device,'wrong'),/TRACE_NOT_FOUND/)
54
+ const [a,b]=await Promise.all([f.sessions.control(start.trace_id,'stop'),f.sessions.control(start.trace_id,'stop')])
55
+ assert.equal(a.state,'completed');assert.deepEqual(a,b);assert.deepEqual(a.correlation_ids,['journey']);assert.equal((await finish()).coverage,'partial')
56
+ assert.equal(f.counts().validations,1)
57
+ await assert.rejects(()=>f.sessions.link(start.trace_id,target,'stale'),/TRACE_NOT_FOUND/)
58
+ await assert.rejects(()=>f.sessions.control('foreign','stop'),/TRACE_NOT_FOUND/)
59
+ assert.equal((await f.sessions.control(start.trace_id,'cancel')).artifact?.path,a.artifact?.path)
60
+ const cancelled=await f.sessions.start(target,'fixture')
61
+ const dir=path.dirname(f.recordings[1].file)
62
+ assert.equal((await f.sessions.control(cancelled.trace_id,'cancel')).state,'cancelled')
63
+ await assert.rejects(()=>fs.stat(dir),{code:'ENOENT'})
64
+ const expires=await f.sessions.start(target,'fixture',1000);f.tick(1001)
65
+ const expired=await f.sessions.control(expires.trace_id,'status')
66
+ assert.equal(expired.state,'completed');assert.equal(expired.completion_reason,'duration_limit')
67
+ f.tick(600001);await assert.rejects(()=>f.sessions.control(start.trace_id,'status'),/TRACE_NOT_FOUND/)
68
+ }finally{await f.cleanup()}
69
+ for(const failure of ['start','stop','export','monitor'] as const){
70
+ const f=fixture()
71
+ try{
72
+ if(failure==='start')f.flags({start:true})
73
+ const s=await f.sessions.start(target,'fixture')
74
+ if(failure==='start'){assert.equal(s.state,'failed');assert.equal(s.reason,'READINESS_TIMEOUT');assert.equal(s.cleanup.status,'complete');assert.equal(f.recordings[0].recorder.alive(),false);continue}
75
+ f.flags(failure==='monitor'?{monitor:'STORAGE_LIMIT'}:{[failure]:true})
76
+ const failed=await f.sessions.control(s.trace_id,failure==='monitor'?'status':'stop')
77
+ assert.equal(failed.state,'failed');assert.equal(failed.artifact,null)
78
+ if(failure==='monitor'){assert.equal(failed.reason,'STORAGE_LIMIT');assert.equal(failed.cleanup.status,'complete');continue}
79
+ f.flags({})
80
+ assert.equal((await f.sessions.control(s.trace_id,'stop')).state,'completed')
81
+ assert.equal(f.counts().starts,1,'Retry must not start a new recording')
82
+ }finally{await f.cleanup()}
83
+ }
84
+ const exportClassification=fixture()
85
+ try {
86
+ const s=await exportClassification.sessions.start(target,'fixture')
87
+ exportClassification.flags({export:true,exportMessage:'COMMAND_FAILED'})
88
+ const failed=await exportClassification.sessions.control(s.trace_id,'stop')
89
+ assert.equal(failed.reason,'TRACE_ARTIFACT_FAILED','Monitor classification must not alter export errors')
90
+ assert.match(failed.recovery!,/Retry stop/)
91
+ }finally{await exportClassification.cleanup()}
92
+ for(const reason of ['COMMAND_FAILED','ACTION_TIMEOUT','TARGET_DISCONNECTED','private unexpected output']) {
93
+ const f=fixture()
94
+ try {
95
+ const s=await f.sessions.start(target,'fixture')
96
+ f.flags({monitor:reason})
97
+ for(let n=0;n<2;n++)assert.equal((await f.sessions.control(s.trace_id,'status')).state,'recording')
98
+ assert.equal(f.counts().stops,0)
99
+ f.flags({});assert.equal((await f.sessions.control(s.trace_id,'status')).state,'recording')
100
+ f.flags({monitor:reason})
101
+ for(let n=0;n<2;n++)assert.equal((await f.sessions.control(s.trace_id,'status')).state,'recording','Successful poll must reset consecutive failure count')
102
+ const failed=await f.sessions.control(s.trace_id,'status')
103
+ assert.equal(failed.state,'failed');assert.equal(failed.reason,reason==='private unexpected output'?'TRACE_MONITOR_FAILED':reason)
104
+ assert.equal(failed.cleanup.status,'complete');assert.equal(failed.artifact,null)
105
+ assert.equal(f.counts().starts,1);assert.equal(f.recordings[0].recorder.alive(),false)
106
+ assert.ok(!JSON.stringify(failed).includes('private unexpected output'))
107
+ }finally{await f.cleanup()}
108
+ }
109
+ for(const reason of ['APP_IDENTITY_CHANGED','APP_NOT_RUNNING']) {
110
+ const f=fixture()
111
+ try {
112
+ const s=await f.sessions.start(target,'fixture');f.flags({monitor:reason})
113
+ const failed=await f.sessions.control(s.trace_id,'status')
114
+ assert.equal(failed.state,'failed');assert.equal(failed.reason,reason);assert.equal(failed.cleanup.status,'complete')
115
+ }finally{await f.cleanup()}
116
+ }
117
+ for(const ended of ['deadline','recorder'] as const) {
118
+ const f=fixture()
119
+ try {
120
+ const s=await f.sessions.start(target,'fixture',1000)
121
+ f.flags({monitor:'COMMAND_FAILED',monitorTick:ended==='deadline'?1001:0,exitOnMonitor:ended==='recorder'})
122
+ const result=await f.sessions.control(s.trace_id,'status')
123
+ assert.equal(result.state,'completed','A tolerated failed poll must still reconcile duration/recorder completion')
124
+ assert.equal(result.completion_reason,'duration_limit');assert.ok(result.artifact)
125
+ }finally{await f.cleanup()}
126
+ }
127
+ const cancelledStopFailure=fixture()
128
+ try {
129
+ const started=await cancelledStopFailure.sessions.start(target,'fixture')
130
+ const directory=path.dirname(cancelledStopFailure.recordings[0].file)
131
+ cancelledStopFailure.flags({stop:true})
132
+ const failed=await cancelledStopFailure.sessions.control(started.trace_id,'cancel')
133
+ assert.equal(failed.cleanup.status,'failed')
134
+ await fs.stat(directory) // Never remove a directory while the recorder may still write.
135
+ cancelledStopFailure.flags({})
136
+ assert.equal((await cancelledStopFailure.sessions.control(started.trace_id,'cancel')).cleanup.status,'complete')
137
+ } finally {await cancelledStopFailure.cleanup()}
138
+ const exitedStopFailure=fixture()
139
+ try {
140
+ const started=await exitedStopFailure.sessions.start(target,'fixture')
141
+ const directory=path.dirname(exitedStopFailure.recordings[0].file)
142
+ exitedStopFailure.flags({stop:true})
143
+ assert.deepEqual(await exitedStopFailure.sessions.shutdown(),[], 'a stop failure after confirmed recorder exit must not strand discarded files')
144
+ const result=await exitedStopFailure.sessions.control(started.trace_id,'status')
145
+ assert.equal(result.state,'failed', 'cleanup must not hide the recorder failure')
146
+ assert.equal(result.reason,'ACTION_TIMEOUT')
147
+ assert.equal(result.cleanup.status,'complete')
148
+ await assert.rejects(()=>fs.stat(directory),{code:'ENOENT'})
149
+ } finally {await exitedStopFailure.cleanup()}
150
+ const shutdown=fixture()
151
+ try{const s=await shutdown.sessions.start(target,'fixture');assert.deepEqual(await shutdown.sessions.shutdown(),[]);assert.equal((await shutdown.sessions.control(s.trace_id,'status')).state,'cancelled');await assert.rejects(()=>shutdown.sessions.start(target,'fixture'),/TRACE_BUSY/)}finally{await shutdown.cleanup()}
152
+ console.log('PASS iOS trace lifecycle, linkage, failure cleanup, retries and expiry')
153
+ }
154
+ run().catch(error=>{console.error(error);process.exitCode=1})
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict'
2
+ import { ReadinessTracker } from '../../../src/observe/readiness.js'
3
+ import { withDeviceReadinessProbe } from '../../../src/observe/device-readiness.js'
4
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
5
+
6
+ async function run() {
7
+ let power = 'Asleep', lock = 'true', fail = false
8
+ const tracker = new ReadinessTracker('android', 'fixture')
9
+ const observe = () => withDeviceReadinessProbe(async (_command, args) => {
10
+ if (fail) throw new Error('native failure')
11
+ return { exitCode: 0, stderr: '', stdout: args.includes('power')
12
+ ? (power ? `mWakefulness=${power}\nmWakefulnessChanging=false` : '')
13
+ : (lock ? `KeyguardServiceDelegate\n showing=${lock}\n mIsShowing=${lock}` : '') }
14
+ }, () => tracker.observeDevice())
15
+ await observe()
16
+ const blockedEvidence = structuredClone(tracker.deviceReadiness.evidence)
17
+ fail = true
18
+ await observe()
19
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
20
+ assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence, 'retained evidence keeps its original timestamps')
21
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_POWER_OBSERVATION_FAILED'))
22
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_LOCK_OBSERVATION_FAILED'))
23
+ assert.ok(tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
24
+ await runWithBudget(0, observe)
25
+ tracker.deadline()
26
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_ASLEEP', 'DEVICE_LOCKED'])
27
+ assert.deepEqual(tracker.deviceReadiness.evidence, blockedEvidence)
28
+ fail = false; power = 'Awake'; lock = ''
29
+ await observe()
30
+ assert.deepEqual(tracker.deviceReadiness.blockers, ['DEVICE_LOCKED'], 'fresh awake evidence clears only the power blocker')
31
+ assert.equal(tracker.deviceReadiness.input_ready, false)
32
+ power = ''; lock = 'false'
33
+ await observe()
34
+ assert.deepEqual(tracker.deviceReadiness.blockers, [], 'fresh unlocked evidence clears the remaining lock blocker')
35
+ assert.equal(tracker.deviceReadiness.wakefulness, 'unknown', 'do not retain previously clear observations')
36
+ assert.equal(tracker.deviceReadiness.input_ready, null)
37
+ assert.ok(!tracker.deviceReadiness.limitations.includes('DEVICE_BLOCKER_EVIDENCE_RETAINED'))
38
+ console.log('PASS inconclusive/expired probes retain blockers per axis; only fresh clear signals remove them')
39
+ }
40
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -36,6 +36,6 @@ assert.equal(tracker.summary.capabilities.debugger_paused, true)
36
36
  assert.ok(tracker.deadline().limitations.includes('DEADLINE_EXCEEDED'))
37
37
  assert.equal(new ReadinessTracker('android', 'fixture').observe(sample(), 10000, 9000).state, 'unknown', 'no cross-request history')
38
38
  assert.ok(!JSON.stringify(tracker.summary).includes('Ready'), 'evidence contains codes rather than private labels')
39
- assert.equal(new ReadinessTracker('ios', 'fixture').summary.capabilities.debugger_paused, false)
39
+ assert.equal(new ReadinessTracker('ios', 'fixture').summary.capabilities.debugger_paused, true)
40
40
  assert.ok(tracker.summary.limitations.includes('MANAGED_DEBUGGER_ONLY'))
41
41
  console.log('Readiness classifier: PASS')