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
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { handleToolCall } from '../../../src/server-core.js'
6
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
7
+
8
+ async function run() {
9
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-ready-native-'))
10
+ const binary = path.join(directory, 'native')
11
+ const marker = path.join(directory, 'closed')
12
+ const keys = ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH', 'READY_SLOW', 'READY_LOADER', 'READY_SLOW_META']
13
+ const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]))
14
+ writeFileSync(binary, `#!/usr/bin/env node
15
+ const fs = require('fs'); const a = process.argv.slice(2).join(' ')
16
+ function slow() { process.on('SIGTERM', () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(marker)}, 'closed'); process.exit(0) }, 40)); setInterval(() => {}, 1000) }
17
+ if (a.includes('ro.build.version.release')) console.log('16')
18
+ else if (a.includes('ro.product.model')) console.log('Fixture')
19
+ else if (a.includes('ro.kernel.qemu')) console.log('1')
20
+ else if (a.includes('wm size')) console.log('Physical size: 100x200')
21
+ else if (a.includes('uiautomator dump')) { if (process.env.READY_SLOW) slow(); else console.log('dumped') }
22
+ else if (a.includes('cat /sdcard/ui.xml')) console.log('<hierarchy><node class="' + (process.env.READY_LOADER ? 'android.widget.ProgressBar' : 'android.widget.Button') + '" text="Ready" visible-to-user="true" enabled="true" bounds="[0,0][100,200]" /></hierarchy>')
23
+ else if (a.includes('simctl list devices')) { if (process.env.READY_SLOW_META) { fs.writeFileSync(${JSON.stringify(marker)}, String(process.pid)); setInterval(() => {}, 1000) } else console.log(JSON.stringify({ devices: { 'iOS-18': [{ udid: 'fixture', name: 'Fixture', state: 'Booted' }] } })) }
24
+ else if (a.includes('list-targets')) console.log('[]')
25
+ else if (a.includes('ui describe-all')) { if (process.env.READY_SLOW) slow(); else console.log(JSON.stringify({ AXElementType: process.env.READY_LOADER ? 'ActivityIndicator' : 'Button', AXLabel: 'Ready', AXFrame: { x: 0, y: 0, width: 100, height: 200 } })) }
26
+ else process.exit(1)
27
+ `)
28
+ chmodSync(binary, 0o755)
29
+ for (const key of ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH']) process.env[key] = binary
30
+ const device = (platform: string) => ({ platform, id: 'fixture', model: 'Fixture', simulator: true, osVersion: '16' })
31
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device('android')], listIOSDevices: async () => [device('ios')] })
32
+ const call = async (platform: string, args: any) => JSON.parse((await handleToolCall('get_app_readiness', { platform, deviceId: 'fixture', ...args })).content[0].text!)
33
+ try {
34
+ for (const platform of ['android', 'ios']) {
35
+ const stable = await call(platform, { stabilityMs: 100 })
36
+ assert.equal(stable.readiness.state, 'settled', JSON.stringify(stable))
37
+ assert.equal(stable.readiness.sample_count, 2)
38
+ process.env.READY_LOADER = '1'
39
+ const loader = await call(platform, {})
40
+ assert.equal(loader.readiness.state, 'loading')
41
+ assert.equal(loader.readiness.basis, 'observed')
42
+ delete process.env.READY_LOADER
43
+ process.env.READY_SLOW = '1'
44
+ writeFileSync(marker, '')
45
+ const timed = await call(platform, { timeoutMs: 1500 })
46
+ assert.equal(timed.success, false)
47
+ assert.equal(timed.readiness.state, 'unknown')
48
+ assert.ok(timed.readiness.limitations.includes('DEADLINE_EXCEEDED'))
49
+ assert.equal(readFileSync(marker, 'utf8'), 'closed', 'timed-out native acquisition closes its child before responding')
50
+ delete process.env.READY_SLOW
51
+ }
52
+ process.env.READY_SLOW_META = '1'
53
+ writeFileSync(marker, '')
54
+ const metadataTimeout = await call('ios', { timeoutMs: 300 })
55
+ assert.equal(metadataTimeout.success, false)
56
+ assert.ok(metadataTimeout.readiness.limitations.includes('DEADLINE_EXCEEDED'))
57
+ const pid = Number(readFileSync(marker, 'utf8'))
58
+ assert.ok(pid > 0)
59
+ assert.throws(() => process.kill(pid, 0), (error: any) => error.code === 'ESRCH', 'metadata child must be closed after its deadline')
60
+ delete process.env.READY_SLOW_META
61
+ console.log('Readiness native Android/iOS acquisition and timeout cleanup: PASS')
62
+ } finally {
63
+ _resetDeviceListersForTests()
64
+ for (const key of keys) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key] }
65
+ rmSync(directory, { recursive: true, force: true })
66
+ }
67
+ }
68
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidTraceNative } from '../../../src/observe/system-trace-android.js'
3
+ import type { execCmd } from '../../../src/utils/exec.js'
4
+
5
+ async function run() {
6
+ const calls: Array<{ args: string[]; timeout: number | undefined }> = []
7
+ let failure = '', processes = ''
8
+ const execute: typeof execCmd = async (_cmd, args, options = {}) => {
9
+ calls.push({ args, timeout: options.timeout })
10
+ assert.equal(args[0], '-s'); assert.equal(args[1], 'serial with spaces')
11
+ if (failure && args.join(' ').includes(failure)) return { exitCode: null, stdout: '', stderr: 'ACTION_TIMEOUT' }
12
+ let out = ''
13
+ if (args.includes('--help')) return { exitCode: 1, stdout: '', stderr: 'Usage: perfetto --background-wait --config' }
14
+ if (args.includes('getprop')) out = '36'
15
+ if (args.includes('--list_categories')) out = ' sched - Scheduling\n input - Input\n gfx - Graphics'
16
+ if (args.join(' ').includes('YQ==')) out = 'a'
17
+ if (args.join(' ').includes('for pid')) out = processes
18
+ if (args.includes('stat')) out = '1024'
19
+ return { exitCode: 0, stdout: out, stderr: '' }
20
+ }
21
+ const native = new AndroidTraceNative(execute)
22
+ const remote = '/data/misc/perfetto-traces/mcp-62a51510-c303-4a1b-af0e-f97cc976d740.perfetto-trace'
23
+ assert.equal((await native.capabilities('serial with spaces')).supported, true)
24
+ for (const probe of ['--help', '--query', 'getprop', '--list_categories', 'test -w', 'YQ==']) {
25
+ failure = probe
26
+ assert.equal((await native.capabilities('serial with spaces')).supported, false, probe)
27
+ }
28
+ failure = ''
29
+ await native.start('serial with spaces', remote, 1001, ['sched', 'input'])
30
+ assert.equal(calls.at(-1)!.timeout, 35000)
31
+ assert.ok(calls.at(-1)!.args.join(' ').includes('--background-wait -c -'))
32
+ assert.equal(await native.find('serial with spaces', remote), null)
33
+ processes = '123 456'
34
+ assert.deepEqual(await native.find('serial with spaces', remote), { pid: 123, startTime: '456' })
35
+ assert.ok(calls.at(-1)!.args.join(' ').includes('stat=$(cat "$p/stat" 2>/dev/null) || { [ ! -d "$p" ] && continue; exit 1; }'), 'Unreadable process identity must fail closed')
36
+ processes = '123 456\n999 888'
37
+ await assert.rejects(() => native.find('serial with spaces', remote), /IDENTITY_UNAVAILABLE/)
38
+ const previous = calls.length
39
+ await assert.rejects(() => native.stop('serial with spaces', remote, { pid: 0, startTime: '4' }))
40
+ await assert.rejects(() => native.start('serial with spaces', '/tmp/other; killall perfetto', 1000, []))
41
+ assert.equal(calls.length, previous)
42
+ await native.stop('serial with spaces', remote, { pid: 123, startTime: '456' })
43
+ const signal = calls.at(-1)!.args.join(' ')
44
+ assert.ok(signal.indexOf('awk') < signal.indexOf('kill -TERM'))
45
+ assert.ok(signal.indexOf('grep -Fx') < signal.indexOf('kill -TERM'))
46
+ assert.ok(!signal.includes('killall'))
47
+ failure = 'pull'
48
+ await assert.rejects(() => native.pull('serial with spaces', remote, '/tmp/fixture'), /ACTION_TIMEOUT/)
49
+ assert.equal(calls.at(-1)!.timeout, 30000)
50
+ console.log('Android Perfetto adapter probes, targeting, guards and deadlines: PASS')
51
+ }
52
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,166 @@
1
+ import assert from 'node:assert/strict'
2
+ import { promises as fs } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { SystemTraceSessions, type TraceClock } from '../../../src/observe/system-trace.js'
5
+ import { TRACE_MAX_BYTES, traceConfig, type TraceNative, type TraceCapabilities, type TraceProcess } from '../../../src/observe/system-trace-android.js'
6
+
7
+ function fixture() {
8
+ let now = 1000000
9
+ let failStart = '', failStop = '', failPull = '', failRemove = '', failFind = ''
10
+ let supported = true, empty = false, oversized = false, stuck = false
11
+ let starts = 0, stops = 0, pulls = 0, removes = 0
12
+ const processes = new Map<string, TraceProcess>()
13
+ const pending = new Map<any, () => void>()
14
+ const time: TraceClock = { wall: () => now, mono: () => now, sleep: async ms => { now += ms },
15
+ schedule(fn) { const id = {} as any; pending.set(id, fn); return id }, clear: id => { pending.delete(id) } }
16
+ const native: TraceNative = {
17
+ async capabilities(device) { return { supported, backend: 'perfetto', device: { platform: 'android', id: device }, prerequisites: {}, reasons: [], enabled_sources: ['sched', 'input'], unavailable_categories: [], limitations: [] } as TraceCapabilities },
18
+ async start(_d, remote) { starts++; processes.set(remote, { pid: starts + 100, startTime: String(starts) }); if (failStart) throw new Error(failStart) },
19
+ async find(_d, remote) { if (failFind) throw new Error(failFind); return processes.get(remote) ?? null },
20
+ async stop(_d, remote) { stops++; if (failStop) throw new Error(failStop); if (!stuck) processes.delete(remote) },
21
+ async pull(_d, _r, local) { pulls++; await fs.writeFile(local, empty ? '' : 'perfetto-fixture'); if (oversized) await fs.truncate(local, TRACE_MAX_BYTES + 1); if (failPull) throw new Error(failPull) },
22
+ async remove() { removes++; if (failRemove) throw new Error(failRemove) }
23
+ }
24
+ const sessions = new SystemTraceSessions(native, time)
25
+ const files: string[] = []
26
+ return { sessions, native, time, processes, pending, files,
27
+ tick(ms: number) { now += ms }, get counts() { return { starts, stops, pulls, removes } },
28
+ flags(v: { start?: string; stop?: string; pull?: string; remove?: string; find?: string; supported?: boolean; empty?: boolean; oversized?: boolean; stuck?: boolean }) {
29
+ failStart = v.start ?? ''; failStop = v.stop ?? ''; failPull = v.pull ?? ''; failRemove = v.remove ?? ''; failFind = v.find ?? ''
30
+ supported = v.supported ?? true; empty = v.empty ?? false; oversized = v.oversized ?? false; stuck = v.stuck ?? false
31
+ },
32
+ async cleanup() { await sessions.shutdown(); await Promise.all(files.map(file => fs.rm(path.dirname(file), { recursive: true, force: true }))) }
33
+ }
34
+ }
35
+ async function run() {
36
+ const f = fixture()
37
+ try {
38
+ const a = await f.sessions.start('a', 1001)
39
+ assert.equal(a.state, 'recording')
40
+ assert.equal(a.capture_completed_at_ms, null)
41
+ await assert.rejects(() => f.sessions.start('a'), /TRACE_BUSY/)
42
+ const b = await f.sessions.start('b')
43
+ const end = await f.sessions.link(a.trace_id, { platform: 'android', id: 'a' }, 'journey-a')
44
+ await assert.rejects(() => f.sessions.link(a.trace_id, { platform: 'android', id: 'b' }, 'wrong'), /TRACE_NOT_FOUND/)
45
+ await assert.rejects(() => f.sessions.link(a.trace_id, { platform: 'ios', id: 'a' }, 'wrong'), /TRACE_NOT_FOUND/)
46
+ const [stop, again] = await Promise.all([f.sessions.control(a.trace_id, 'stop'), f.sessions.control(a.trace_id, 'stop')])
47
+ assert.equal(stop.state, 'completed'); assert.deepEqual(stop, again)
48
+ f.files.push(stop.artifact!.path)
49
+ assert.equal((await end()).coverage, 'partial') // another call stopped capture during the linked journey
50
+ assert.deepEqual(stop.correlation_ids, ['journey-a'])
51
+ assert.equal((await fs.stat(stop.artifact!.path)).mode & 0o777, 0o600)
52
+ assert.equal((await f.sessions.control(a.trace_id, 'cancel')).artifact?.path, stop.artifact?.path)
53
+ assert.equal(f.counts.pulls, 1)
54
+ assert.equal((await f.sessions.control(b.trace_id, 'cancel')).state, 'cancelled')
55
+ assert.equal(f.counts.pulls, 1)
56
+ const expired = await f.sessions.start('c', 1000)
57
+ f.tick(1001)
58
+ const duration = await f.sessions.control(expired.trace_id, 'status')
59
+ assert.equal(duration.completion_reason, 'duration_limit'); f.files.push(duration.artifact!.path)
60
+ f.tick(600001)
61
+ await assert.rejects(() => f.sessions.control(a.trace_id, 'status'), /TRACE_NOT_FOUND/)
62
+ await assert.rejects(() => f.sessions.control('previous-server', 'stop'), /TRACE_NOT_FOUND/)
63
+ } finally { await f.cleanup() }
64
+
65
+ for (const failure of ['start', 'stop', 'pull', 'remove', 'find', 'empty', 'oversized'] as const) {
66
+ const f = fixture()
67
+ try {
68
+ if (failure === 'start') f.flags({ start: 'ACTION_TIMEOUT' })
69
+ const a = await f.sessions.start('a')
70
+ if (failure === 'start') {
71
+ assert.equal(a.reason, 'ACTION_TIMEOUT'); assert.equal(a.state, 'failed'); assert.equal(a.cleanup.status, 'complete')
72
+ assert.equal(f.processes.size, 0); continue
73
+ }
74
+ f.flags(failure === 'empty' || failure === 'oversized' ? { [failure]: true } : { [failure]: 'ACTION_TIMEOUT' })
75
+ const stop = await f.sessions.control(a.trace_id, 'stop')
76
+ assert.equal(stop.state, 'failed', failure); assert.equal(stop.cleanup.status, 'failed', failure)
77
+ if (stop.artifact) f.files.push(stop.artifact.path)
78
+ if (failure === 'pull' || failure === 'empty' || failure === 'oversized') assert.equal(stop.artifact, null)
79
+ f.flags({})
80
+ const retry = await f.sessions.control(a.trace_id, 'stop')
81
+ assert.equal(retry.state, 'completed', failure); assert.equal(retry.cleanup.status, 'complete')
82
+ if (!f.files.includes(retry.artifact!.path)) f.files.push(retry.artifact!.path)
83
+ } finally { await f.cleanup() }
84
+ }
85
+ const reuse = fixture()
86
+ try {
87
+ const a = await reuse.sessions.start('a')
88
+ const [remote, process] = [...reuse.processes.entries()][0]
89
+ reuse.processes.set(remote, { ...process, startTime: '999999' })
90
+ const result = await reuse.sessions.control(a.trace_id, 'stop')
91
+ assert.equal(result.reason, 'TRACE_STOP_FAILED'); assert.equal(reuse.counts.stops, 0)
92
+ reuse.processes.delete(remote)
93
+ assert.equal((await reuse.sessions.control(a.trace_id, 'cancel')).state, 'cancelled')
94
+ } finally { await reuse.cleanup() }
95
+ const cap = fixture()
96
+ try {
97
+ cap.flags({ supported: false })
98
+ assert.equal((await cap.sessions.start('a')).reason, 'TRACE_UNSUPPORTED'); assert.equal(cap.counts.starts, 0)
99
+ cap.flags({})
100
+ await Promise.all(['a', 'b', 'c', 'd'].map(d => cap.sessions.start(d)))
101
+ await assert.rejects(() => cap.sessions.start('e'), /TRACE_BUSY/)
102
+ await cap.sessions.shutdown(); assert.equal(cap.processes.size, 0)
103
+ await assert.rejects(() => cap.sessions.start('e'), /TRACE_BUSY/)
104
+ } finally { await cap.cleanup() }
105
+ const timer = fixture()
106
+ try {
107
+ const a = await timer.sessions.start('timer', 1000)
108
+ timer.tick(1000)
109
+ for (const callback of timer.pending.values()) callback()
110
+ const ended = await timer.sessions.control(a.trace_id, 'status')
111
+ assert.equal(ended.state, 'completed'); assert.equal(ended.completion_reason, 'duration_limit')
112
+ timer.files.push(ended.artifact!.path)
113
+ } finally { await timer.cleanup() }
114
+ const stuck = fixture()
115
+ try {
116
+ const a = await stuck.sessions.start('stuck')
117
+ stuck.flags({ stuck: true })
118
+ const failed = await stuck.sessions.control(a.trace_id, 'stop')
119
+ assert.equal(failed.reason, 'ACTION_TIMEOUT'); assert.equal(failed.artifact, null)
120
+ assert.equal(stuck.counts.pulls, 0)
121
+ stuck.flags({})
122
+ assert.equal((await stuck.sessions.control(a.trace_id, 'cancel')).cleanup.status, 'complete')
123
+ } finally { await stuck.cleanup() }
124
+ const natural = fixture()
125
+ try {
126
+ const a = await natural.sessions.start('natural')
127
+ natural.native.stop = async (_device, remote) => { natural.processes.delete(remote); throw new Error('PROCESS_ALREADY_EXITED') }
128
+ assert.equal((await natural.sessions.control(a.trace_id, 'cancel')).state, 'cancelled')
129
+ } finally { await natural.cleanup() }
130
+ const interrupted = fixture()
131
+ try {
132
+ const a = await interrupted.sessions.start('journey')
133
+ const finish = await interrupted.sessions.link(a.trace_id, { platform: 'android', id: 'journey' }, 'j')
134
+ interrupted.flags({ find: 'DEVICE_DISCONNECTED' })
135
+ assert.equal((await finish()).coverage, 'partial')
136
+ interrupted.flags({})
137
+ } finally { await interrupted.cleanup() }
138
+ const racing = fixture()
139
+ let release!: () => void
140
+ const gate = new Promise<void>(resolve => { release = resolve })
141
+ const originalCapabilities = racing.native.capabilities
142
+ racing.native.capabilities = async device => { await gate; return originalCapabilities(device) }
143
+ const starting = racing.sessions.start('race')
144
+ const closing = racing.sessions.shutdown()
145
+ release()
146
+ assert.equal((await starting).reason, 'TRACE_BUSY')
147
+ assert.deepEqual(await closing, [])
148
+ assert.equal(racing.counts.starts, 0)
149
+ const retention = fixture()
150
+ try {
151
+ const first = await retention.sessions.start('retention')
152
+ await retention.sessions.control(first.trace_id, 'cancel')
153
+ for (let n = 0; n < 100; n++) {
154
+ const next = await retention.sessions.start('retention')
155
+ await retention.sessions.control(next.trace_id, 'cancel')
156
+ }
157
+ await assert.rejects(() => retention.sessions.control(first.trace_id, 'status'), /TRACE_NOT_FOUND/)
158
+ } finally { await retention.cleanup() }
159
+ // Wire bytes decode independently to protect actual proto field numbers/limits.
160
+ const config = traceConfig(1001, ['sched', 'input'])
161
+ assert.ok(config.includes(Buffer.from('linux.ftrace')))
162
+ assert.ok(config.includes(Buffer.from([0x18, 0xe9, 0x07]))) // TraceConfig.duration_ms = 3
163
+ assert.ok(config.includes(Buffer.from([0x50, 0x80, 0x80, 0x80, 0x10]))) // max_file_size_bytes = 10
164
+ console.log('system trace session lifecycle: PASS')
165
+ }
166
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,299 @@
1
+ import assert from 'node:assert/strict'
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
6
+ import { ToolsObserve } from '../../../src/observe/index.js'
7
+ import { ToolsNetwork } from '../../../src/network/index.js'
8
+ import { deriveSnapshotMetadata, getSnapshotSessionId, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
9
+ import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
10
+ import { runWithBudget } from '../../../src/utils/operation-budget.js'
11
+
12
+ async function run() {
13
+ const directory = mkdtempSync(path.join(tmpdir(), 'mcp-conditional-'))
14
+ const eventsPath = path.join(directory, 'events')
15
+ const native = path.join(directory, 'native')
16
+ const changedPath = path.join(directory, 'prepared')
17
+ const envKeys = ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH', 'MCP_CONDITION_PREPARE', 'MCP_CONDITION_INPUT_FAIL', 'MCP_CONDITION_TREE_TEXT', 'MCP_CONDITION_SLOW']
18
+ const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
19
+ writeFileSync(eventsPath, '')
20
+ writeFileSync(native, `#!/usr/bin/env node
21
+ const fs = require('fs')
22
+ const args = process.argv.slice(2).join(' ')
23
+ function slow() {
24
+ process.on('SIGTERM', () => setTimeout(() => { fs.writeFileSync(${JSON.stringify(changedPath)}, 'closed'); process.exit(0) }, 40))
25
+ setInterval(() => {}, 1000)
26
+ }
27
+ fs.appendFileSync(${JSON.stringify(eventsPath)}, args + '\\n')
28
+ if (args.includes('ro.build.version.release')) console.log('16')
29
+ else if (args.includes('ro.product.model')) { if (process.env.MCP_CONDITION_PREPARE) fs.writeFileSync(${JSON.stringify(changedPath)}, 'changed'); console.log('Fixture') }
30
+ else if (args.includes('ro.kernel.qemu')) console.log('1')
31
+ else if (args.includes('simctl list devices')) console.log(JSON.stringify({ devices: { 'iOS-18': [{ udid: 'fixture', name: 'Fixture', state: 'Booted' }] } }))
32
+ else if (args.includes('list-targets')) { if (process.env.MCP_CONDITION_PREPARE) fs.writeFileSync(${JSON.stringify(changedPath)}, 'changed'); console.log('[]') }
33
+ else if (args.includes('wm size')) console.log('Physical size: 100x200')
34
+ else if (args.includes('uiautomator dump')) { if (process.env.MCP_CONDITION_SLOW) slow(); else console.log('dumped') }
35
+ else if (args.includes('cat /sdcard/ui.xml')) console.log('<hierarchy><node class="android.widget.Button" resource-id="target" text="' + (process.env.MCP_CONDITION_TREE_TEXT || 'Ready') + '" clickable="true" enabled="true" visible-to-user="true" bounds="[0,0][50,50]" /></hierarchy>')
36
+ else if (args.includes('ui describe-all')) { if (process.env.MCP_CONDITION_SLOW) slow(); else console.log(JSON.stringify({ AXElementType: 'Button', AXLabel: process.env.MCP_CONDITION_TREE_TEXT || 'Ready', AXUniqueId: 'target', AXFrame: { x: 0, y: 0, width: 100, height: 200 } })) }
37
+ else if (args.includes('shell input') || args.includes('ui tap') || args.includes('ui swipe')) { if (process.env.MCP_CONDITION_INPUT_FAIL) process.exit(1) }
38
+ else process.exit(1)
39
+ `)
40
+ chmodSync(native, 0o755)
41
+ for (const key of ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH']) process.env[key] = native
42
+ const originalTree = ToolsObserve.getUITreeHandler
43
+ const originalFingerprint = ToolsObserve.getScreenFingerprintHandler
44
+ const originalNotify = ToolsNetwork.notifyActionStart
45
+ let networkStarts = 0
46
+ let reads = 0
47
+ let observationMode = 'normal'
48
+ let readDelay = 0
49
+ let uiState = 'Ready'
50
+ let propertyChange: Record<string, unknown> = {}
51
+ let platform: 'android' | 'ios' = 'android'
52
+ const device = () => ({ platform, id: 'fixture', osVersion: '16', model: 'Fixture', simulator: true })
53
+ const tree = () => {
54
+ const elements = [{ text: uiState, contentDescription: 'target', type: 'Button', stable_id: 'target', resourceId: 'target', bounds: [0, 0, 50, 50], enabled: true, visible: true, clickable: true, state: { checked: false }, ...propertyChange }]
55
+ const body = { device: device(), screen: '', resolution: { width: 100, height: 200 }, elements }
56
+ return { ...body, ...deriveSnapshotMetadata(`${platform}:fixture`, body, 'ui_tree') }
57
+ }
58
+ ToolsObserve.getUITreeHandler = async () => {
59
+ reads++
60
+ if (readDelay) await new Promise(resolve => setTimeout(resolve, readDelay))
61
+ if (process.env.MCP_CONDITION_PREPARE && readFileSync(changedPath, 'utf8') === 'changed') uiState = 'Changed during preparation'
62
+ if (observationMode === 'throw') throw new Error('offline')
63
+ const fresh = tree()
64
+ if (observationMode === 'error') return { ...fresh, error: 'offline' } as any
65
+ if (observationMode === 'invalid') return { ...fresh, snapshot_session_id: undefined } as any
66
+ if (observationMode === 'device') return { ...fresh, device: { ...device(), id: 'other' } } as any
67
+ return fresh
68
+ }
69
+ ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'unchanged-screen' }) as any
70
+ ToolsNetwork.notifyActionStart = () => { networkStarts++ }
71
+ _setDeviceListersForTests({ listAndroidDevices: async () => [device()], listIOSDevices: async () => [device()] })
72
+ const payload = (response: any) => JSON.parse(response.content[0].text)
73
+ const nativeInputs = () => readFileSync(eventsPath, 'utf8').split('\n').filter(line => /shell input|ui tap|ui swipe/.test(line))
74
+ const baseline = () => {
75
+ uiState = 'Ready'; propertyChange = {}; observationMode = 'normal'; readDelay = 0
76
+ const value = tree()
77
+ return { snapshotSessionId: value.snapshot_session_id, snapshotRevision: value.snapshot_revision }
78
+ }
79
+ const argsFor = (tool: string) => ({
80
+ platform, deviceId: 'fixture', verificationMode: 'none', responseMode: 'compact',
81
+ ...({ tap: { x: 10, y: 10 }, tap_element: { selector: { accessibility_id: 'target' } }, swipe: { x1: 10, y1: 10, x2: 20, y2: 20, duration: 100 }, type_text: { text: 'hello' }, press_back: {} }[tool])
82
+ })
83
+ const call = async (tool: string, extra: Record<string, unknown> = {}) => payload(await handleToolCall(tool, { ...argsFor(tool), ...extra }))
84
+ try {
85
+ resetSnapshotMetadataForTests()
86
+ // Exercise production dispatch adapters, not mocked ToolsInteract input handlers.
87
+ for (platform of ['android', 'ios'] as const) {
88
+ const tools = platform === 'android' ? ['tap', 'tap_element', 'swipe', 'type_text', 'press_back'] : ['tap', 'tap_element', 'swipe']
89
+ for (const tool of tools) {
90
+ const expectedState = baseline()
91
+ const inputs = nativeInputs().length
92
+ const starts = networkStarts
93
+ const result = await call(tool, { expectedState })
94
+ assert.equal(result.success, true, JSON.stringify(result))
95
+ assert.equal(result.condition.status, 'matched')
96
+ assert.equal(result.condition.observed.elements, undefined, 'compact matching conditions omit tree')
97
+ assert.equal(result.condition.observed.snapshot_revision, expectedState.snapshotRevision)
98
+ assert.equal(nativeInputs().length, inputs + 1, `${platform} ${tool} dispatches once`)
99
+ assert.equal(networkStarts, starts + 1)
100
+ }
101
+ // Change happens after ordinary resolution and metadata/capability preparation.
102
+ for (const tool of ['tap', 'tap_element', 'swipe']) {
103
+ const expectedState = baseline()
104
+ writeFileSync(changedPath, '')
105
+ process.env.MCP_CONDITION_PREPARE = '1'
106
+ const inputs = nativeInputs().length
107
+ const starts = networkStarts
108
+ const result = await call(tool, { expectedState, responseMode: 'debug' })
109
+ delete process.env.MCP_CONDITION_PREPARE
110
+ assert.equal(result.failure_code, 'PRECONDITION_FAILED', JSON.stringify(result))
111
+ assert.equal(result.dispatch_started, false)
112
+ assert.equal(result.condition.observed.elements[0].text, 'Changed during preparation')
113
+ assert.equal(nativeInputs().length, inputs)
114
+ assert.equal(networkStarts, starts)
115
+ assert.ok(!result.trace.steps.some((step: any) => step.stage === 'execute'))
116
+ }
117
+ }
118
+ platform = 'android'
119
+ for (const verificationMode of ['none', 'light', 'full']) for (const responseMode of ['compact', 'debug']) {
120
+ for (const changed of [{ text: 'Changed' }, { bounds: [1, 1, 51, 51] }, { enabled: false }, { state: { checked: true } }]) {
121
+ const expectedState = baseline()
122
+ propertyChange = changed
123
+ const inputs = nativeInputs().length
124
+ const starts = networkStarts
125
+ reads = 0
126
+ const result = await call('tap', { expectedState, verificationMode, responseMode })
127
+ assert.equal(result.failure_code, 'PRECONDITION_FAILED')
128
+ assert.equal(result.condition.status, 'mismatch')
129
+ assert.equal(result.retryable, false)
130
+ assert.equal(result.delivery_status, 'not_delivered')
131
+ assert.equal(result.verification.status, 'not_requested')
132
+ assert.deepEqual(result.condition.observed.elements[0], tree().elements[0])
133
+ assert.equal(reads, 1, 'no post-dispatch tree read after guard failure')
134
+ assert.equal(nativeInputs().length, inputs)
135
+ assert.equal(networkStarts, starts)
136
+ }
137
+ const expectedState = baseline()
138
+ const matched = await call('tap', { expectedState, verificationMode, responseMode })
139
+ assert.equal(matched.success, true)
140
+ assert.equal(matched.condition.status, 'matched')
141
+ assert.equal(Array.isArray(matched.condition.observed.elements), responseMode === 'debug')
142
+ }
143
+ for (const [mode, reason] of [['error', 'OBSERVATION_FAILED'], ['throw', 'OBSERVATION_FAILED'], ['invalid', 'OBSERVATION_INVALID'], ['device', 'DEVICE_MISMATCH']]) {
144
+ const expectedState = baseline()
145
+ observationMode = mode
146
+ const inputs = nativeInputs().length
147
+ const result = await call('tap', { expectedState })
148
+ assert.equal(result.failure_code, 'PRECONDITION_UNAVAILABLE')
149
+ assert.equal(result.condition.reason, reason)
150
+ assert.equal(result.condition.observed, null)
151
+ assert.equal(nativeInputs().length, inputs)
152
+ }
153
+ for (const expectation of [
154
+ { ...baseline(), snapshotSessionId: 'previous-process' },
155
+ { ...baseline(), snapshotRevision: 999999 }
156
+ ]) {
157
+ reads = 0
158
+ const result = await call('tap', { expectedState: expectation })
159
+ assert.equal(result.failure_code, 'PRECONDITION_UNAVAILABLE')
160
+ assert.equal(reads, 0, 'unknown expectations cannot authorize a read/input')
161
+ }
162
+ const foreign = baseline()
163
+ platform = 'ios'
164
+ const foreignResult = await call('tap', { expectedState: foreign })
165
+ assert.equal(foreignResult.condition.reason, 'BASELINE_UNAVAILABLE')
166
+ platform = 'android'
167
+ const expired = baseline()
168
+ const originalNow = Date.now
169
+ try {
170
+ Date.now = () => originalNow() + 11 * 60 * 1000
171
+ const result = await call('tap', { expectedState: expired })
172
+ assert.equal(result.condition.reason, 'BASELINE_UNAVAILABLE')
173
+ } finally { Date.now = originalNow }
174
+ const evicted = baseline()
175
+ for (let index = 0; index < 9; index++) { uiState = `screen-${index}`; tree() }
176
+ assert.equal((await call('tap', { expectedState: evicted })).condition.reason, 'BASELINE_UNAVAILABLE')
177
+
178
+ const timed = baseline()
179
+ readDelay = 180
180
+ const inputs = nativeInputs().length
181
+ const timedResult = await call('tap', { expectedState: { ...timed, timeoutMs: 100 } })
182
+ assert.equal(timedResult.condition.reason, 'CONDITION_TIMEOUT')
183
+ assert.equal(timedResult.dispatch_started, false)
184
+ assert.equal(nativeInputs().length, inputs, 'late observation never dispatches')
185
+ const budgeted = baseline()
186
+ readDelay = 180
187
+ const separate = await call('tap', { expectedState: budgeted, actionTimeoutMs: 150 })
188
+ assert.equal(separate.success, true, 'condition read does not consume the action budget')
189
+ const outerExpected = baseline()
190
+ readDelay = 180
191
+ const outerInputs = nativeInputs().length
192
+ const outer = await runWithBudget(150, () => call('tap', { expectedState: outerExpected, actionTimeoutMs: 5000 }))
193
+ assert.equal(outer.success, false)
194
+ assert.equal(nativeInputs().length, outerInputs, 'outer budget still prevents dispatch')
195
+
196
+ // A matched condition cannot turn a native dispatch failure into success.
197
+ const nativeFailure = baseline()
198
+ process.env.MCP_CONDITION_INPUT_FAIL = '1'
199
+ const failedInput = await call('tap', { expectedState: nativeFailure })
200
+ delete process.env.MCP_CONDITION_INPUT_FAIL
201
+ assert.equal(failedInput.success, false)
202
+ assert.equal(failedInput.condition.status, 'matched')
203
+ assert.equal(failedInput.dispatch_started, true)
204
+ assert.equal(failedInput.delivery_status, 'unknown')
205
+
206
+ const targetFailure = baseline()
207
+ const notChecked = await call('tap_element', { expectedState: targetFailure, selector: undefined, elementId: 'missing' })
208
+ assert.equal(notChecked.condition.status, 'not_checked')
209
+ assert.equal(notChecked.condition.reason, 'ACTION_NOT_REACHED')
210
+
211
+ const valid = baseline()
212
+ const badConditions: unknown[] = [null, {}, { ...valid, extra: true }, { ...valid, snapshotRevision: 0 }, { ...valid, snapshotRevision: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, timeoutMs: 99 }, { ...valid, timeoutMs: '100' }, { ...valid, snapshotSessionId: '' }]
213
+ for (const expectedState of badConditions) {
214
+ const count = readFileSync(eventsPath, 'utf8').length
215
+ const result = await call('tap', { expectedState })
216
+ assert.match(result.error.message, /INVALID_ARGUMENT/)
217
+ assert.equal(readFileSync(eventsPath, 'utf8').length, count)
218
+ }
219
+ for (const extra of [{ platform: undefined }, { deviceId: 'booted' }, { deviceId: 'default' }, { deviceId: ' ' }, { platform: ['android'] }]) {
220
+ const result = await call('tap', { ...extra, expectedState: valid })
221
+ assert.match(result.error.message, /INVALID_ARGUMENT/)
222
+ }
223
+ for (const name of ['start_app', 'restart_app', 'terminate_app', 'reset_app_data', 'scroll_to_element', 'adjust_control', 'get_ui_tree']) {
224
+ const result = payload(await handleToolCall(name, { platform, deviceId: 'fixture', expectedState: valid }))
225
+ assert.match(result.error.message, /INVALID_ARGUMENT/)
226
+ }
227
+ for (const tool of toolDefinitions) {
228
+ assert.equal(!!tool.inputSchema.properties.expectedState, ['tap', 'tap_element', 'swipe', 'type_text', 'press_back'].includes(tool.name))
229
+ }
230
+ for (const responseMode of ['compact', 'debug']) {
231
+ const expectedState = baseline()
232
+ uiState = 'Changed'
233
+ const before = nativeInputs().length
234
+ const journey = payload(await handleToolCall('run_journey', { platform, deviceId: 'fixture', responseMode, defaults: { verificationMode: 'light' }, steps: [
235
+ { id: 'ready', type: 'wait', selector: { accessibility_id: 'target' }, timeoutMs: 500 },
236
+ { id: 'guarded', type: 'tap', selector: { accessibility_id: 'target' }, expectedState },
237
+ { id: 'later', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'target' } } }
238
+ ] }))
239
+ assert.equal(journey.stopped_at_step_id, 'guarded')
240
+ assert.equal(journey.steps[0].status, 'passed')
241
+ assert.equal(journey.steps[1].result.failure_code, 'PRECONDITION_FAILED')
242
+ assert.equal(journey.steps[1].result.condition.observed.elements[0].text, 'Changed')
243
+ assert.equal(journey.steps[1].result.verification.status, 'not_requested')
244
+ assert.equal(journey.steps[2].status, 'not_run')
245
+ assert.equal(nativeInputs().length, before)
246
+ }
247
+ // Calls overlap, but only the request with its own expectation is rejected.
248
+ const overlapping = baseline()
249
+ uiState = 'Changed'
250
+ const before = nativeInputs().length
251
+ const [guarded, unguarded] = await Promise.all([call('tap', { expectedState: overlapping }), call('tap')])
252
+ assert.equal(guarded.failure_code, 'PRECONDITION_FAILED')
253
+ assert.equal(unguarded.success, true)
254
+ assert.equal(unguarded.condition, undefined)
255
+ assert.equal(nativeInputs().length, before + 1)
256
+ assert.equal(getSnapshotSessionId(), valid.snapshotSessionId)
257
+ // Complete native observation + input paths with fake platform tools, including
258
+ // subprocess timeout cleanup. No mocked tree or adapter is used in these checks.
259
+ ToolsObserve.getUITreeHandler = originalTree
260
+ for (platform of ['android', 'ios'] as const) {
261
+ delete process.env.MCP_CONDITION_TREE_TEXT
262
+ const observed: any = await ToolsObserve.getUITreeHandler({ platform, deviceId: 'fixture' })
263
+ assert.equal(observed.error, undefined)
264
+ const expectedState = { snapshotSessionId: observed.snapshot_session_id, snapshotRevision: observed.snapshot_revision }
265
+ const matched = await call('tap', { expectedState })
266
+ assert.equal(matched.success, true, JSON.stringify(matched))
267
+ process.env.MCP_CONDITION_TREE_TEXT = 'Changed through native hierarchy'
268
+ const before = nativeInputs().length
269
+ const mismatch = await call('tap', { expectedState })
270
+ assert.equal(mismatch.failure_code, 'PRECONDITION_FAILED', JSON.stringify(mismatch))
271
+ assert.equal(mismatch.condition.observed.elements[0].text, 'Changed through native hierarchy')
272
+ assert.equal(nativeInputs().length, before)
273
+ const compact: any = await ToolsObserve.getUITreeHandler({ platform, deviceId: 'fixture', responseMode: 'compact' })
274
+ assert.equal(compact.snapshot_session_id, expectedState.snapshotSessionId)
275
+ const current: any = await ToolsObserve.getUITreeHandler({ platform, deviceId: 'fixture' })
276
+ process.env.MCP_CONDITION_SLOW = '1'
277
+ writeFileSync(changedPath, '')
278
+ const unavailable = await call('tap', { expectedState: { snapshotSessionId: current.snapshot_session_id, snapshotRevision: current.snapshot_revision, timeoutMs: 1200 } })
279
+ delete process.env.MCP_CONDITION_SLOW
280
+ assert.equal(unavailable.condition.reason, 'CONDITION_TIMEOUT', JSON.stringify(unavailable))
281
+ assert.equal(unavailable.dispatch_started, false)
282
+ assert.equal(readFileSync(changedPath, 'utf8'), 'closed', 'timed-out hierarchy subprocess closed before response')
283
+ assert.equal(nativeInputs().length, before)
284
+ }
285
+ console.log('Conditional actions: PASS (production Android/iOS input adapters with fake native tools)')
286
+ } finally {
287
+ ToolsObserve.getUITreeHandler = originalTree
288
+ ToolsObserve.getScreenFingerprintHandler = originalFingerprint
289
+ ToolsNetwork.notifyActionStart = originalNotify
290
+ _resetDeviceListersForTests()
291
+ resetSnapshotMetadataForTests()
292
+ for (const key of envKeys) {
293
+ if (originalEnv[key] === undefined) delete process.env[key]
294
+ else process.env[key] = originalEnv[key]
295
+ }
296
+ rmSync(directory, { recursive: true, force: true })
297
+ }
298
+ }
299
+ run().catch(error => { console.error(error); process.exitCode = 1 })