mobile-debug-mcp 0.32.4 → 0.34.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 (170) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +14 -0
  3. package/.cursor-plugin/marketplace.json +16 -0
  4. package/.github/copilot-instructions.md +7 -0
  5. package/.github/workflows/ci.yml +21 -0
  6. package/AGENTS.md +21 -10
  7. package/README.md +69 -4
  8. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  9. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  10. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  11. package/agent-plugin/.mcp.json +14 -0
  12. package/agent-plugin/README.md +9 -3
  13. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  14. package/agent-plugin/mcp.json +1 -1
  15. package/agent-plugin/plugin.json +1 -1
  16. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
  17. package/dist/debugger/android.js +78 -0
  18. package/dist/debugger/guard.js +30 -0
  19. package/dist/debugger/sessions.js +262 -0
  20. package/dist/debugger/worker.js +165 -0
  21. package/dist/interact/android.js +10 -4
  22. package/dist/interact/conditional-action.js +68 -0
  23. package/dist/interact/index.js +70 -14
  24. package/dist/interact/ios.js +14 -8
  25. package/dist/manage/android.js +4 -3
  26. package/dist/manage/ios.js +3 -2
  27. package/dist/observe/android.js +25 -4
  28. package/dist/observe/evidence.js +280 -0
  29. package/dist/observe/index.js +33 -6
  30. package/dist/observe/ios.js +25 -6
  31. package/dist/observe/readiness-service.js +124 -0
  32. package/dist/observe/readiness.js +88 -0
  33. package/dist/observe/recording.js +165 -0
  34. package/dist/observe/snapshot-metadata.js +10 -2
  35. package/dist/observe/system-trace-android.js +135 -0
  36. package/dist/observe/system-trace.js +285 -0
  37. package/dist/server/common.js +9 -0
  38. package/dist/server/condition-schema.js +32 -0
  39. package/dist/server/debugger-handlers.js +38 -0
  40. package/dist/server/debugger-schema.js +42 -0
  41. package/dist/server/journey-schema.js +4 -2
  42. package/dist/server/readiness-schema.js +12 -0
  43. package/dist/server/system-trace-schema.js +17 -0
  44. package/dist/server/tool-definitions.js +51 -0
  45. package/dist/server/tool-handlers.js +294 -105
  46. package/dist/server-core.js +3 -3
  47. package/dist/server.js +34 -0
  48. package/dist/system/index.js +5 -4
  49. package/dist/utils/exec.js +2 -0
  50. package/dist/utils/ios/utils.js +4 -4
  51. package/dist/utils/java.js +14 -12
  52. package/dist/utils/operation-budget.js +26 -2
  53. package/dist/utils/resolve-device.js +9 -0
  54. package/docs/CHANGELOG.md +15 -0
  55. package/docs/specs/020-conditional-actions/intent.md +62 -0
  56. package/docs/specs/020-conditional-actions/plan.md +214 -0
  57. package/docs/specs/020-conditional-actions/spec.md +301 -0
  58. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  59. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  60. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  61. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  62. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  63. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  64. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  65. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  66. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  67. package/docs/specs/023-android-system-traces/device-evidence.json +38 -0
  68. package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
  69. package/docs/specs/023-android-system-traces/intent.md +40 -0
  70. package/docs/specs/023-android-system-traces/plan.md +117 -0
  71. package/docs/specs/023-android-system-traces/spec.md +120 -0
  72. package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
  73. package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
  74. package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
  75. package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
  76. package/docs/specs/process.md +213 -0
  77. package/docs/tools/TOOLS.md +5 -0
  78. package/docs/tools/debugger.md +104 -0
  79. package/docs/tools/evidence.md +82 -0
  80. package/docs/tools/interact.md +125 -2
  81. package/docs/tools/observe.md +70 -0
  82. package/docs/tools/system-traces.md +126 -0
  83. package/package.json +1 -1
  84. package/scripts/agent-plugin.mjs +68 -8
  85. package/src/debugger/android.ts +65 -0
  86. package/src/debugger/guard.ts +26 -0
  87. package/src/debugger/sessions.ts +168 -0
  88. package/src/debugger/worker.ts +122 -0
  89. package/src/interact/android.ts +10 -4
  90. package/src/interact/conditional-action.ts +75 -0
  91. package/src/interact/index.ts +57 -28
  92. package/src/interact/ios.ts +14 -8
  93. package/src/manage/android.ts +4 -3
  94. package/src/manage/ios.ts +3 -2
  95. package/src/observe/android.ts +33 -6
  96. package/src/observe/evidence.ts +262 -0
  97. package/src/observe/index.ts +32 -8
  98. package/src/observe/ios.ts +32 -8
  99. package/src/observe/readiness-service.ts +94 -0
  100. package/src/observe/readiness.ts +84 -0
  101. package/src/observe/snapshot-metadata.ts +12 -1
  102. package/src/observe/system-trace-android.ts +133 -0
  103. package/src/observe/system-trace.ts +218 -0
  104. package/src/server/common.ts +7 -0
  105. package/src/server/condition-schema.ts +32 -0
  106. package/src/server/debugger-handlers.ts +29 -0
  107. package/src/server/debugger-schema.ts +37 -0
  108. package/src/server/journey-schema.ts +4 -2
  109. package/src/server/readiness-schema.ts +12 -0
  110. package/src/server/system-trace-schema.ts +17 -0
  111. package/src/server/tool-definitions.ts +49 -0
  112. package/src/server/tool-handlers.ts +182 -21
  113. package/src/server-core.ts +3 -3
  114. package/src/server.ts +24 -0
  115. package/src/system/index.ts +7 -5
  116. package/src/types.ts +30 -1
  117. package/src/utils/exec.ts +2 -0
  118. package/src/utils/ios/utils.ts +4 -4
  119. package/src/utils/java.ts +13 -12
  120. package/src/utils/operation-budget.ts +24 -2
  121. package/src/utils/resolve-device.ts +10 -0
  122. package/test/device/automated/debugger/runtime.android.test.ts +203 -0
  123. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  124. package/test/device/automated/observe/system-trace.smoke.ts +104 -0
  125. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  126. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  127. package/test/device/manual/interact/readiness.manual.ts +99 -0
  128. package/test/fixtures/debugger-app/MainActivity.java +46 -0
  129. package/test/fixtures/debugger-app/NoLocals.java +7 -0
  130. package/test/fixtures/debugger-app/README.md +13 -0
  131. package/test/fixtures/debugger-app/build.ts +29 -0
  132. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  133. package/test/fixtures/readiness-app/README.md +12 -0
  134. package/test/fixtures/readiness-app/build.ts +37 -0
  135. package/test/fixtures/readiness-app/main.m +27 -0
  136. package/test/helpers/perfetto.ts +59 -0
  137. package/test/unit/debugger/guards.test.ts +46 -0
  138. package/test/unit/debugger/native.test.ts +33 -0
  139. package/test/unit/debugger/sessions.test.ts +83 -0
  140. package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
  141. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  142. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  143. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  144. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  145. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  146. package/test/unit/observe/readiness.test.ts +41 -0
  147. package/test/unit/observe/readiness_native.test.ts +68 -0
  148. package/test/unit/observe/system-trace-android.test.ts +52 -0
  149. package/test/unit/observe/system-trace.test.ts +166 -0
  150. package/test/unit/server/conditional_actions.test.ts +299 -0
  151. package/test/unit/server/conditional_schema.test.ts +59 -0
  152. package/test/unit/server/correlated-evidence.test.ts +194 -0
  153. package/test/unit/server/readiness.test.ts +170 -0
  154. package/test/unit/server/selector_tap_target.test.ts +79 -0
  155. package/test/unit/server/system-trace.test.ts +64 -0
  156. package/test/unit/server/tap_selector.test.ts +2 -0
  157. package/worker/DebuggerWorker.java +160 -0
  158. package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
  159. /package/{skills → .agents/skills}/README.md +0 -0
  160. /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
  161. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  162. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  163. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  164. /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
  165. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  166. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  167. /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
  168. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  169. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  170. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
package/src/types.ts CHANGED
@@ -218,6 +218,7 @@ export interface UIElement {
218
218
  }
219
219
 
220
220
  export interface GetUITreeResponse {
221
+ readiness?: ReadinessSummary;
221
222
  device: DeviceInfo;
222
223
  screen: string;
223
224
  resolution: {
@@ -226,12 +227,27 @@ export interface GetUITreeResponse {
226
227
  };
227
228
  elements: UIElement[];
228
229
  snapshot_revision: number;
230
+ snapshot_session_id?: string;
229
231
  captured_at_ms: number;
230
232
  snapshot_delta?: SnapshotDelta | null;
231
233
  loading_state?: LoadingState | null;
232
234
  error?: string;
233
235
  }
234
236
 
237
+ export interface ReadinessSummary {
238
+ state: 'loading' | 'transitioning' | 'settled' | 'unknown' | 'unresponsive' | 'debugger_paused';
239
+ scope: 'device_ui';
240
+ basis: 'observed' | 'heuristic' | 'insufficient';
241
+ captured_at_ms: number | null;
242
+ sample_count: number;
243
+ stable_for_ms: number;
244
+ snapshot_session_id: string | null;
245
+ snapshot_revision: number | null;
246
+ evidence: Array<{ code: string; source: string; captured_at_ms: number | null; snapshot_revision: number | null }>;
247
+ limitations: string[];
248
+ capabilities: { loading: boolean; transitioning: boolean; settled: boolean; unresponsive: boolean; debugger_paused: boolean };
249
+ }
250
+
235
251
  export interface GetCurrentScreenResponse {
236
252
  device: DeviceInfo;
237
253
  package: string;
@@ -289,6 +305,8 @@ export interface TapResponse {
289
305
  }
290
306
 
291
307
  export type ActionFailureCode =
308
+ | 'PRECONDITION_FAILED'
309
+ | 'PRECONDITION_UNAVAILABLE'
292
310
  | 'ELEMENT_NOT_FOUND'
293
311
  | 'ELEMENT_NOT_INTERACTABLE'
294
312
  | 'TIMEOUT'
@@ -417,6 +435,15 @@ export interface TimingBreakdown {
417
435
  serialization_ms?: number;
418
436
  }
419
437
 
438
+ export interface ActionConditionResult {
439
+ status: 'matched' | 'mismatch' | 'unavailable' | 'not_checked';
440
+ expected: { snapshot_session_id: string; snapshot_revision: number };
441
+ observed: GetUITreeResponse | null;
442
+ elapsed_ms: number;
443
+ reason?: 'BASELINE_UNAVAILABLE' | 'SESSION_MISMATCH' | 'DEVICE_MISMATCH' |
444
+ 'OBSERVATION_FAILED' | 'OBSERVATION_INVALID' | 'CONDITION_TIMEOUT' | 'ACTION_NOT_REACHED';
445
+ }
446
+
420
447
  export interface ActionExecutionResult {
421
448
  action_id: string;
422
449
  timestamp: string;
@@ -435,6 +462,7 @@ export interface ActionExecutionResult {
435
462
  dispatch_started?: boolean;
436
463
  verification?: { mode: 'none' | 'light' | 'full'; status: 'not_requested' | 'observed' | 'verified' | 'unavailable' | 'failed' };
437
464
  state_delta?: StateDelta;
465
+ condition?: ActionConditionResult;
438
466
  timing?: TimingBreakdown;
439
467
  recovery?: RecoveryState;
440
468
  trace: ActionTrace;
@@ -535,6 +563,7 @@ export interface AdjustControlResponse extends ActionExecutionResult {
535
563
  }
536
564
 
537
565
  export interface WaitForUIChangeResponse {
566
+ readiness?: ReadinessSummary;
538
567
  success: boolean;
539
568
  observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
540
569
  snapshot_revision?: number;
@@ -554,7 +583,7 @@ export interface WaitForUIChangeResponse {
554
583
  reason?: string;
555
584
  loading_state?: LoadingState | null;
556
585
  error?: {
557
- code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND';
586
+ code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND' | 'TIMEOUT' | 'DEVICE_UNAVAILABLE';
558
587
  message: string;
559
588
  };
560
589
  }
package/src/utils/exec.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { remainingBudget } from './operation-budget.js'
2
3
  import { spawn } from 'child_process'
3
4
 
@@ -7,6 +8,7 @@ export async function execCmd(cmd: string, args: string[], opts: ExecOptions = {
7
8
  const { env, cwd, shell } = opts
8
9
  const timeout = remainingBudget(opts.timeout ?? 0)
9
10
  return new Promise((resolve, reject) => {
11
+ checkDebuggerGuard()
10
12
  const child = spawn(cmd, args, { env: { ...process.env, ...(env || {}) }, cwd, shell })
11
13
  let stdout = ''
12
14
  let stderr = ''
@@ -62,7 +62,7 @@ export async function isIDBInstalled(): Promise<boolean> {
62
62
  const cmd = getIdbCmd()
63
63
  if (!cmd) return false
64
64
  try {
65
- execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','pipe'], timeout: 5000 })
65
+ execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','pipe'], timeout: remainingBudget(5000), killSignal: 'SIGKILL' })
66
66
  return true
67
67
  } catch (e: unknown) {
68
68
  console.debug(`[isIDBInstalled] idb probe failed for '${cmd}': ${e instanceof Error ? e.message : String(e)}`)
@@ -200,7 +200,7 @@ export async function findAppBundle(dir: string): Promise<string | undefined> {
200
200
  export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
201
201
  return new Promise((resolve) => {
202
202
  // If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
203
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
203
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
204
204
  const fallback: DeviceInfo = {
205
205
  platform: "ios",
206
206
  id: deviceId,
@@ -247,7 +247,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
247
247
  export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
248
248
  return new Promise((resolve) => {
249
249
  // Query all devices and separately query booted devices to mark them
250
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
250
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err, stdout) => {
251
251
  if (err || !stdout) return resolve([])
252
252
  try {
253
253
  const data = JSON.parse(stdout)
@@ -256,7 +256,7 @@ export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
256
256
  const checks: Promise<void>[] = []
257
257
 
258
258
  // Get booted devices set
259
- execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
259
+ execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], { timeout: remainingBudget(), killSignal: 'SIGKILL' }, (err2, stdout2) => {
260
260
  const bootedSet = new Set<string>()
261
261
  if (!err2 && stdout2) {
262
262
  try {
package/src/utils/java.ts CHANGED
@@ -21,17 +21,18 @@ function javaVersionOf(javaBin: string): string | undefined {
21
21
  } catch (e: unknown) { console.debug('[javaVersionOf] java -version failed: ' + String(e)); return undefined }
22
22
  }
23
23
 
24
- export async function detectJavaHome(): Promise<string | undefined> {
24
+ export async function detectJavaHome(options: { quiet?: boolean } = {}): Promise<string | undefined> {
25
+ const debug = (...args: unknown[]) => { if (!options.quiet) console.debug(...args) }
25
26
  try {
26
27
  // 1) Honor explicit ANDROID_STUDIO_JDK env (highest priority)
27
28
  const envStudio = process.env.ANDROID_STUDIO_JDK || process.env.ANDROID_STUDIO_JBR
28
29
  if (envStudio && existsSync(path.join(envStudio, 'bin', 'java'))) {
29
30
  const v = javaVersionOf(path.join(envStudio, 'bin', 'java'))
30
31
  if (isJavaVersionAcceptable(v)) {
31
- console.debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
32
+ debug('[java.detect] Using ANDROID_STUDIO_JDK from env:', envStudio)
32
33
  return envStudio
33
34
  }
34
- console.debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
35
+ debug('[java.detect] ANDROID_STUDIO_JDK present but java -version did not match expected versions')
35
36
  }
36
37
 
37
38
  // 2) Android Studio JBR candidates (prefer these over JAVA_HOME)
@@ -50,7 +51,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
50
51
  if (existsSync(javaBin)) {
51
52
  const v = javaVersionOf(javaBin)
52
53
  if (isJavaVersionAcceptable(v)) {
53
- console.debug('[java.detect] Found Android Studio JBR at:', p)
54
+ debug('[java.detect] Found Android Studio JBR at:', p)
54
55
  return p
55
56
  }
56
57
  }
@@ -62,12 +63,12 @@ export async function detectJavaHome(): Promise<string | undefined> {
62
63
  const javaBin = path.join(process.env.JAVA_HOME, 'bin', 'java')
63
64
  const v = javaVersionOf(javaBin)
64
65
  if (isJavaVersionAcceptable(v)) {
65
- console.debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
66
+ debug('[java.detect] Using JAVA_HOME from env:', process.env.JAVA_HOME)
66
67
  return process.env.JAVA_HOME
67
68
  }
68
- console.debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
69
+ debug('[java.detect] Existing JAVA_HOME does not appear to be acceptable Java (17/21), will search')
69
70
  } catch {
70
- console.debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
71
+ debug('[java.detect] Failed to validate existing JAVA_HOME, searching for JDK')
71
72
  }
72
73
  }
73
74
 
@@ -79,11 +80,11 @@ export async function detectJavaHome(): Promise<string | undefined> {
79
80
  try {
80
81
  const out17 = execSync('/usr/libexec/java_home -v 17', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
81
82
  if (out17) return out17
82
- } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
83
+ } catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 17 failed: ' + String(e)) }
83
84
  try {
84
85
  const out21 = execSync('/usr/libexec/java_home -v 21', { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim()
85
86
  if (out21) return out21
86
- } catch (e: unknown) { console.debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
87
+ } catch (e: unknown) { debug('[java.detect] /usr/libexec/java_home -v 21 failed: ' + String(e)) }
87
88
 
88
89
  // 6) macOS common JDK locations
89
90
  try {
@@ -94,7 +95,7 @@ export async function detectJavaHome(): Promise<string | undefined> {
94
95
  return candidate
95
96
  }
96
97
  }
97
- } catch (e: unknown) { console.debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
98
+ } catch (e: unknown) { debug('[java.detect] listing /Library/Java/JavaVirtualMachines failed: ' + String(e)) }
98
99
 
99
100
  // 7) Linux locations
100
101
  const linuxCandidates = [
@@ -106,10 +107,10 @@ export async function detectJavaHome(): Promise<string | undefined> {
106
107
  '/usr/lib/jvm/java-21-openjdk-amd64'
107
108
  ]
108
109
  for (const p of linuxCandidates) {
109
- try { if (existsSync(p)) return p } catch (e: unknown) { console.debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
110
+ try { if (existsSync(p)) return p } catch (e: unknown) { debug(`[java.detect] checking linux candidate ${p} failed: ${String(e)}`) }
110
111
  }
111
112
  } catch (e: unknown) {
112
- console.debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
113
+ debug('[java.detect] error detecting java home:', e instanceof Error ? e.message : String(e))
113
114
  }
114
115
  return undefined
115
116
  }
@@ -1,3 +1,4 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
1
2
  import { AsyncLocalStorage } from 'node:async_hooks'
2
3
 
3
4
  export interface OperationBudget { deadline: number; dispatchStarted: boolean; parent?: OperationBudget }
@@ -10,12 +11,33 @@ export function runWithBudget<T>(timeoutMs: number, operation: (budget: Operatio
10
11
  }
11
12
  export function remainingBudget(fallback = 0): number {
12
13
  const budget = budgets.getStore()
13
- if (!budget || !Number.isFinite(budget.deadline)) return fallback
14
- const remaining = Math.floor(budget.deadline - performance.now())
14
+ let deadline = budget?.deadline ?? Infinity
15
+ for (let ancestor = budget?.parent; ancestor; ancestor = ancestor.parent) deadline = Math.min(deadline, ancestor.deadline)
16
+ if (!Number.isFinite(deadline)) return fallback
17
+ const remaining = Math.floor(deadline - performance.now())
15
18
  if (remaining <= 0) throw new Error('ACTION_TIMEOUT')
16
19
  return fallback > 0 ? Math.min(fallback, remaining) : remaining
17
20
  }
21
+
22
+ // Pause only this action's budget. Any enclosing operation still bounds the read.
23
+ export async function runWithSeparateBudget<T>(timeoutMs: number, operation: () => Promise<T>): Promise<T> {
24
+ remainingBudget()
25
+ const action = budgets.getStore()
26
+ const started = performance.now()
27
+ const parent = action?.parent
28
+ const budget: OperationBudget = { deadline: Math.min(parent?.deadline ?? Infinity, started + timeoutMs), dispatchStarted: false, parent }
29
+ try {
30
+ return await budgets.run(budget, async () => {
31
+ const result = await operation()
32
+ remainingBudget()
33
+ return result
34
+ })
35
+ } finally {
36
+ if (action) action.deadline += performance.now() - started
37
+ }
38
+ }
18
39
  export function markDispatchStarted() {
40
+ checkDebuggerGuard(true)
19
41
  remainingBudget()
20
42
  let budget = budgets.getStore()
21
43
  while (budget) { budget.dispatchStarted = true; budget = budget.parent }
@@ -1,3 +1,5 @@
1
+ import { checkDebuggerGuard } from '../debugger/guard.js'
2
+ import { bindEvidenceDevice } from '../observe/evidence.js'
1
3
  import { retainConnectedDeviceSnapshots } from '../observe/snapshot-metadata.js'
2
4
  import { measure } from './timing.js'
3
5
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -50,10 +52,18 @@ export async function listDevices(platform?: "android" | "ios", appId?: string):
50
52
  const deviceContext = new AsyncLocalStorage<DeviceInfo>()
51
53
 
52
54
  export function withResolvedDevice<T>(device: DeviceInfo, operation: () => Promise<T>): Promise<T> {
55
+ bindEvidenceDevice(device)
53
56
  return deviceContext.run(device, operation)
54
57
  }
55
58
 
56
59
  export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceInfo> {
60
+ checkDebuggerGuard()
61
+ const device = await resolveDevice(opts)
62
+ bindEvidenceDevice(device)
63
+ return device
64
+ }
65
+
66
+ async function resolveDevice(opts: ResolveOptions): Promise<DeviceInfo> {
57
67
  const { platform, appId, prefer, deviceId } = opts
58
68
  const current = deviceContext.getStore()
59
69
  if (current && current.platform === platform && (!deviceId || current.id === deviceId)) return current
@@ -0,0 +1,203 @@
1
+ import assert from 'node:assert/strict'
2
+ import { execFileSync, spawn } from 'node:child_process'
3
+ import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
6
+ import { createHash } from 'node:crypto'
7
+ import { buildDebuggerFixture, debuggerAppId } from '../../../fixtures/debugger-app/build.js'
8
+
9
+ // Explicit opt-in: installs/removes only this isolated fixture on the selected device.
10
+ if (!process.env.MCP_DEBUGGER_DEVICE) {
11
+ console.log('UNRUN Android debugger fixture: set MCP_DEBUGGER_DEVICE to an authorized emulator serial')
12
+ } else {
13
+ const deviceId = process.env.MCP_DEBUGGER_DEVICE
14
+ const adb = (...args: string[]) => execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, ...args], { encoding: 'utf8', timeout: 15000 })
15
+ const client = new Client({ name: 'android-debugger-smoke', version: '1.0.0' })
16
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
17
+ const call = async (name: string, args: Record<string, unknown>) => {
18
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000 })
19
+ const text = (response.content as any[]).find(c => c.type === 'text')!.text!
20
+ return JSON.parse(text)
21
+ }
22
+ const hash = createHash('sha256')
23
+ const fingerprint = async (directory: string): Promise<void> => {
24
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
25
+ const file = directory + '/' + entry.name
26
+ if (entry.isDirectory()) await fingerprint(file)
27
+ else hash.update(file).update(await readFile(file))
28
+ }
29
+ }
30
+ await fingerprint('dist'); await fingerprint('worker')
31
+ const buildHash = hash.digest('hex')
32
+ const fixture = buildDebuggerFixture()
33
+ const source = await readFile(new URL('../../../fixtures/debugger-app/MainActivity.java', import.meta.url), 'utf8')
34
+ const line = (marker: string) => source.split('\n').findIndex(l => l.includes(marker)) + 1
35
+ let debugSessionId = ''
36
+ let cursor = 0
37
+ const eventsUntilStop = async () => {
38
+ for (let i = 0; i < 10; i++) {
39
+ const result = await call('wait_for_debugger_event', { debugSessionId, afterSequence: cursor, timeoutMs: 1000 })
40
+ cursor = result.next_cursor
41
+ if (result.events.length) console.log('Debug events', JSON.stringify(result.events))
42
+ const event = result.events.find((e: any) => e.event === 'stopped')
43
+ if (event) return event
44
+ }
45
+ throw new Error('No debugger stop observed')
46
+ }
47
+ const forwardsBefore = adb('forward', '--list').trim()
48
+ try {
49
+ await client.connect(transport)
50
+ const listed = await client.listTools()
51
+ assert.ok(listed.tools.some(tool => tool.name === 'attach_debugger'))
52
+ adb('install', '-r', fixture.artifact)
53
+ adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
54
+ const cap = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId })
55
+ assert.equal(cap.supported, true, JSON.stringify(cap))
56
+ const timeout = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 100 })
57
+ assert.equal(timeout.error.code, 'DEBUGGER_TIMEOUT')
58
+ const unsupported = await call('get_debugger_capabilities', { platform: 'android', deviceId, appId: 'com.missing.debugfixture' })
59
+ assert.equal(unsupported.supported, false)
60
+ assert.ok(unsupported.reasons.includes('PROCESS_NOT_FOUND'))
61
+ const attached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
62
+ assert.equal(attached.success, true, JSON.stringify(attached)); debugSessionId = attached.debug_session_id
63
+ const bp = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
64
+ assert.equal(bp.result.status, 'verified', JSON.stringify(bp))
65
+ const pending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.LateWork`, line: line('BREAK_LATE') })
66
+ assert.equal(pending.result.status, 'pending', JSON.stringify(pending))
67
+ const unavailable = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: 9999 })
68
+ assert.equal(unavailable.result.status, 'rejected')
69
+ // Fixture button placement is obtained from real UI semantics, not fixed coordinates.
70
+ const tree = await call('get_ui_tree', { platform: 'android', deviceId })
71
+ const button = tree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
72
+ assert.ok(button, 'Calculate button visible in real UI tree: ' + JSON.stringify(tree))
73
+ console.log('Fixture target', JSON.stringify({ bounds: button.bounds, device: tree.device, resolution: tree.resolution }))
74
+ const [left, top, right, bottom] = button.bounds
75
+ // Shell input is asynchronous so suspension cannot block the test's next debugger call.
76
+ await writeFile(fixture.directory + '/fixture.png', execFileSync(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'exec-out', 'screencap', '-p']))
77
+ const tap = spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((left + right) / 2)), String(Math.floor((top + bottom) / 2))], { stdio: 'ignore' })
78
+ tap.on('error', error => { throw error })
79
+ const stop = await eventsUntilStop()
80
+ assert.equal(stop.stops[0].location.line, line('BREAK_CALCULATE'))
81
+ const stack = await call('get_debugger_stack', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id })
82
+ assert.equal(stack.result.frames[0].location.method, 'calculate')
83
+ const frameId = stack.result.frames[0].frame_id
84
+ const locals = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
85
+ assert.equal(locals.result.locals.find((v: any) => v.name === 'known')?.value, '42', JSON.stringify(locals))
86
+ const longText = locals.result.locals.find((v: any) => v.name === 'longText')
87
+ assert.equal(longText.value.length, 256); assert.equal(longText.truncated, true)
88
+ const object = locals.result.locals.find((v: any) => v.name === 'object')
89
+ assert.equal(object.value.type, 'java.lang.Object'); assert.equal(typeof object.value.object_id, 'string')
90
+ const readiness = await call('get_app_readiness', { platform: 'android', deviceId })
91
+ assert.equal(readiness.readiness.state, 'debugger_paused')
92
+ for (const name of ['tap', 'wait_for_ui', 'terminate_app', 'run_journey']) {
93
+ const blocked = await call(name, { platform: 'android', deviceId, x: 5, y: 5, appId: debuggerAppId })
94
+ assert.equal(blocked.failure_code, 'DEBUGGER_PAUSED', name)
95
+ assert.equal(blocked.dispatch_started, false)
96
+ }
97
+ await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: bp.result.breakpoint_id })
98
+ const step = await call('step_debugger', { debugSessionId, stopId: stop.stop_id, threadId: stop.thread_id, kind: 'into' })
99
+ assert.equal(step.success, true, JSON.stringify(step))
100
+ const stale = await call('get_debugger_locals', { debugSessionId, stopId: stop.stop_id, frameId })
101
+ assert.equal(stale.error.code, 'DEBUGGER_STALE_REFERENCE')
102
+ const into = await eventsUntilStop()
103
+ assert.equal(into.stops[0].location.method, 'increment')
104
+ assert.equal((await call('step_debugger', { debugSessionId, stopId: into.stop_id, threadId: into.thread_id, kind: 'over' })).success, true)
105
+ const over = await eventsUntilStop()
106
+ assert.equal(over.stops[0].location.method, 'increment')
107
+ assert.equal((await call('step_debugger', { debugSessionId, stopId: over.stop_id, threadId: over.thread_id, kind: 'out' })).success, true)
108
+ const out = await eventsUntilStop()
109
+ assert.equal(out.stops[0].location.method, 'calculate')
110
+ assert.equal((await call('resume_debugger', { debugSessionId, stopId: out.stop_id })).success, true)
111
+ let resultVisible = false
112
+ for (let i = 0; i < 10; i++) {
113
+ const observed = await call('get_ui_tree', { platform: 'android', deviceId })
114
+ if (observed.elements.some((e: any) => e.text === 'Result 43')) { resultVisible = true; break }
115
+ }
116
+ assert.ok(resultVisible, 'UI tree independently establishes resumed execution: Result 43')
117
+ const noLocalsSource = await readFile(new URL('../../../fixtures/debugger-app/NoLocals.java', import.meta.url), 'utf8')
118
+ const noLocalsLine = noLocalsSource.split('\n').findIndex(line => line.includes('BREAK_NO_LOCALS')) + 1
119
+ const removedPending = await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
120
+ await call('remove_debugger_breakpoint', { debugSessionId, breakpointId: removedPending.result.breakpoint_id })
121
+ await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.NoLocals`, line: noLocalsLine })
122
+ const metadataTree = await call('get_ui_tree', { platform: 'android', deviceId })
123
+ const mb = metadataTree.elements.find((e: any) => /^no locals$/i.test(e.text ?? '')).bounds
124
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((mb[0] + mb[2]) / 2)), String(Math.floor((mb[1] + mb[3]) / 2))], { stdio: 'ignore' })
125
+ const metadataStop = await eventsUntilStop()
126
+ assert.ok(metadataStop.stops.every((stop: any) => stop.breakpoint_id !== removedPending.result.breakpoint_id), 'removed pending breakpoint never rebinds')
127
+ const metadataStack = await call('get_debugger_stack', { debugSessionId, stopId: metadataStop.stop_id, threadId: metadataStop.thread_id })
128
+ const metadataLocals = await call('get_debugger_locals', { debugSessionId, stopId: metadataStop.stop_id, frameId: metadataStack.result.frames[0].frame_id })
129
+ assert.equal(metadataLocals.result.availability, 'unknown')
130
+ assert.equal(metadataLocals.result.reason, 'EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA')
131
+ await call('resume_debugger', { debugSessionId, stopId: metadataStop.stop_id })
132
+ const lateTree = await call('get_ui_tree', { platform: 'android', deviceId })
133
+ const lateButton = lateTree.elements.find((e: any) => /^late class$/i.test(e.text ?? ''))
134
+ assert.ok(lateButton)
135
+ const bounds = lateButton.bounds
136
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((bounds[0] + bounds[2]) / 2)), String(Math.floor((bounds[1] + bounds[3]) / 2))], { stdio: 'ignore' })
137
+ const lateStop = await eventsUntilStop()
138
+ assert.equal(lateStop.stops[0].location.class_name, `${debuggerAppId}.LateWork`)
139
+ assert.equal(lateStop.stops[0].location.line, line('BREAK_LATE'))
140
+ const aborted = new AbortController()
141
+ const cancelled = client.callTool({ name: 'wait_for_debugger_event', arguments: { debugSessionId, afterSequence: cursor, timeoutMs: 1000 } }, undefined, { signal: aborted.signal, timeout: 5000 })
142
+ const cancelTimer = setTimeout(() => aborted.abort(), 100)
143
+ try { await assert.rejects(cancelled) } finally { clearTimeout(cancelTimer) }
144
+ assert.equal((await call('get_debugger_state', { debugSessionId })).state, 'paused')
145
+ if (process.env.MCP_DEBUGGER_EXTENDED === '1') {
146
+ console.log('Checking real 60-second pause expiry while polling state')
147
+ const deadline = Date.now() + 65000
148
+ let state: any
149
+ do {
150
+ await new Promise(resolve => setTimeout(resolve, 1000))
151
+ state = await call('get_debugger_state', { debugSessionId })
152
+ } while (state.state === 'paused' && Date.now() < deadline)
153
+ assert.equal(state.state, 'detached', JSON.stringify(state))
154
+ assert.equal(state.reason, 'pause_limit')
155
+ assert.equal(state.cleanup.status, 'complete')
156
+ console.log('PASS real pause expiry despite active client polling')
157
+ }
158
+ const detach = await call('detach_debugger', { debugSessionId })
159
+ assert.equal(detach.cleanup.status, 'complete', JSON.stringify(detach))
160
+ assert.equal((await call('detach_debugger', { debugSessionId })).cleanup.status, 'complete')
161
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'unrelated forwards preserved')
162
+ const reattached = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
163
+ assert.equal(reattached.success, true, JSON.stringify(reattached))
164
+ debugSessionId = reattached.debug_session_id
165
+ adb('shell', 'am', 'force-stop', debuggerAppId)
166
+ let disconnected: any
167
+ for (let i = 0; i < 20; i++) {
168
+ disconnected = await call('get_debugger_state', { debugSessionId })
169
+ if (disconnected.cleanup.status === 'complete') break
170
+ await new Promise(resolve => setTimeout(resolve, 100))
171
+ }
172
+ assert.equal(disconnected.state, 'disconnected', JSON.stringify(disconnected))
173
+ assert.equal(disconnected.cleanup.status, 'complete')
174
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore)
175
+ // Disconnect the MCP client with an actively paused session; shutdown owns resume/dispose.
176
+ adb('shell', 'am', 'start', '-W', '-n', `${debuggerAppId}/.MainActivity`)
177
+ const shutdownSession = await call('attach_debugger', { platform: 'android', deviceId, appId: debuggerAppId, timeoutMs: 30000 })
178
+ assert.equal(shutdownSession.success, true, JSON.stringify(shutdownSession))
179
+ debugSessionId = shutdownSession.debug_session_id; cursor = 0
180
+ await call('set_debugger_breakpoint', { debugSessionId, className: `${debuggerAppId}.MainActivity`, line: line('BREAK_CALCULATE') })
181
+ const shutdownTree = await call('get_ui_tree', { platform: 'android', deviceId })
182
+ const shutdownButton = shutdownTree.elements.find((e: any) => /^calculate$/i.test(e.text ?? ''))
183
+ const sb = shutdownButton.bounds
184
+ spawn(process.env.ADB_PATH || 'adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.floor((sb[0] + sb[2]) / 2)), String(Math.floor((sb[1] + sb[3]) / 2))], { stdio: 'ignore' })
185
+ await eventsUntilStop()
186
+ await client.close(); debugSessionId = ''
187
+ assert.equal(adb('forward', '--list').trim(), forwardsBefore, 'MCP disconnect cleans active owned forward')
188
+ const verifier = new Client({ name: 'debugger-shutdown-verifier', version: '1.0.0' })
189
+ try {
190
+ await verifier.connect(new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' }))
191
+ const result = await verifier.callTool({ name: 'get_ui_tree', arguments: { platform: 'android', deviceId } })
192
+ const treeAfterShutdown = JSON.parse((result.content as any[]).find(c => c.type === 'text').text)
193
+ assert.ok(treeAfterShutdown.elements.some((e: any) => e.text === 'Result 43'), 'fresh UI proves paused fixture resumed after server shutdown')
194
+ } finally { await verifier.close() }
195
+ console.log(JSON.stringify({ status: 'PASS', build_sha256: buildHash, device: deviceId, api: adb('shell', 'getprop', 'ro.build.version.sdk').trim(), runtime: process.version, transport: 'MCP stdio', extended: process.env.MCP_DEBUGGER_EXTENDED === '1' }))
196
+ console.log('PASS Android debugger: explicit attach, source stop, stack/known locals, pause guards, into/over/out, stale frame, resume UI, detach/forward ownership')
197
+ } finally {
198
+ if (debugSessionId) await call('detach_debugger', { debugSessionId }).catch(() => {})
199
+ await client.close()
200
+ adb('uninstall', debuggerAppId)
201
+ await rm(fixture.directory, { recursive: true, force: true })
202
+ }
203
+ }
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict'
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
4
+
5
+ // Exercises the rebuilt stdio server, not the connected/published MCP instance.
6
+ async function run() {
7
+ if (process.env.SKIP_DEVICE_TESTS === '1') {
8
+ console.log('Correlated evidence device smoke: SKIPPED (SKIP_DEVICE_TESTS=1)')
9
+ return
10
+ }
11
+ const client = new Client({ name: 'correlated-evidence-smoke', version: '1.0.0' })
12
+ const transport = new StdioClientTransport({ command: process.execPath, args: ['dist/server.js'], stderr: 'pipe' })
13
+ const parse = (response: any) => JSON.parse(response.content.find((item: any) => item.type === 'text').text)
14
+ const call = (name: string, args: Record<string, unknown> = {}) => client.callTool({ name, arguments: args }, undefined, { timeout: 120000 })
15
+ try {
16
+ await client.connect(transport)
17
+ const discovery = parse(await call('list_devices'))
18
+ for (const platform of ['android', 'ios'] as const) {
19
+ const device = discovery.devices.find((d: any) => d.platform === platform && d.simulator &&
20
+ (platform === 'android' || d.booted === true))
21
+ assert.ok(device, `A running ${platform} emulator/simulator is required`)
22
+ const target = { platform, deviceId: device.id }
23
+ const appId = platform === 'android' ? 'com.android.settings' : 'com.apple.Preferences'
24
+ const action = parse(await call('start_app', { ...target, appId, verificationMode: 'none', responseMode: 'compact' }))
25
+ assert.equal(action.success, true, JSON.stringify(action))
26
+ assert.equal(action.action_id, action.evidence.action_id)
27
+ assert.ok(action.evidence.dispatch_started_at_ms)
28
+ assert.ok(action.evidence.dispatch_completed_at_ms >= action.evidence.dispatch_started_at_ms)
29
+ assert.equal(action.evidence.verification_completed_at_ms, null)
30
+ const actionRef = { sessionId: action.evidence.session_id, actionId: action.evidence.action_id }
31
+ const screenResponse: any = await call('capture_screenshot', { ...target, actionRef })
32
+ const screen = parse(screenResponse)
33
+ assert.equal(screen.evidence.action_id, action.action_id)
34
+ assert.equal(screen.evidence.correlation_id, action.evidence.correlation_id)
35
+ assert.equal(screen.evidence.association, 'explicit')
36
+ assert.ok(screenResponse.content.some((item: any) => item.type === 'image' && item.data.length > 0))
37
+ const screenshot = screen.evidence.observations.find((v: any) => v.kind === 'screenshot')
38
+ assert.equal(screenshot.status, 'captured')
39
+ assert.equal(screenshot.freshness, 'fresh')
40
+ assert.ok(screenshot.capture_started_at_ms >= action.evidence.dispatch_completed_at_ms)
41
+ const logsResponse: any = await call('get_logs', { ...target, appId, limit: 5, actionRef })
42
+ const logs = parse(logsResponse)
43
+ assert.equal(logs.evidence.action_id, action.action_id)
44
+ assert.ok(Array.isArray(JSON.parse(logsResponse.content[1].text).logs))
45
+ const log = logs.evidence.observations.find((v: any) => v.kind === 'logs')
46
+ assert.equal(log.status, 'captured', log.error)
47
+ assert.equal(log.event_time_comparable, false)
48
+ assert.equal(log.freshness, 'unknown')
49
+ const snapshot = parse(await call('capture_debug_snapshot', { ...target, actionRef, includeScreenshot: false, includeLogs: false }))
50
+ assert.equal(snapshot.evidence.action_id, action.action_id)
51
+ assert.equal(snapshot.raw.screenshot, null)
52
+ assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'omitted')
53
+ assert.equal(snapshot.evidence.observations.find((v: any) => v.kind === 'logs').status, 'omitted')
54
+ assert.ok(snapshot.evidence.observations.every((v: any) => !v.device || v.device.id === device.id))
55
+ const tree = snapshot.evidence.observations.find((v: any) => v.kind === 'ui_tree')
56
+ assert.equal(tree.status, 'captured', tree.error)
57
+ const unassociated = parse(await call('get_ui_tree', { ...target, responseMode: 'compact', actionRef: { ...actionRef, sessionId: 'previous-process' } }))
58
+ assert.equal(unassociated.evidence.association_reason, 'SESSION_MISMATCH')
59
+ assert.equal(unassociated.evidence.action_id, null)
60
+ console.log(JSON.stringify({ status: 'PASS', server: client.getServerVersion(), platform, device: device.id,
61
+ osVersion: device.osVersion, startingApp: appId, actionId: action.action_id,
62
+ phases: { dispatchStarted: action.evidence.dispatch_started_at_ms, dispatchCompleted: action.evidence.dispatch_completed_at_ms,
63
+ screenshotStarted: screenshot.capture_started_at_ms, screenshotCompleted: screenshot.capture_completed_at_ms },
64
+ screenshot: screenshot.status, logs: log.status, uiTree: tree.status,
65
+ disabledComponents: 'omitted', foreignReference: 'unavailable',
66
+ cleanup: 'No settings, app data or streams changed; Settings remains foreground. No content artifacts saved.' }))
67
+ }
68
+ } finally { await client.close() }
69
+ }
70
+ run().catch(error => { console.error(error); process.exitCode = 1 })