mobile-debug-mcp 0.32.2 → 0.33.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 (127) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/{skills → .agents/skills}/mcp-builder/SKILL.md +1 -2
  3. package/{skills → .agents/skills}/spec-review/SKILL.md +1 -1
  4. package/{skills → .agents/skills}/test-authoring/SKILL.md +1 -1
  5. package/.claude-plugin/marketplace.json +14 -0
  6. package/.cursor-plugin/marketplace.json +16 -0
  7. package/.github/copilot-instructions.md +7 -0
  8. package/AGENTS.md +21 -10
  9. package/README.md +67 -5
  10. package/agent-plugin/.claude-plugin/plugin.json +20 -0
  11. package/agent-plugin/.codex-plugin/plugin.json +34 -0
  12. package/agent-plugin/.cursor-plugin/plugin.json +23 -0
  13. package/agent-plugin/.mcp.json +14 -0
  14. package/agent-plugin/LICENSE +201 -0
  15. package/agent-plugin/README.md +29 -0
  16. package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
  17. package/agent-plugin/mcp.json +11 -0
  18. package/agent-plugin/plugin.json +29 -0
  19. package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +124 -0
  20. package/dist/interact/android.js +10 -4
  21. package/dist/interact/conditional-action.js +68 -0
  22. package/dist/interact/index.js +54 -14
  23. package/dist/interact/ios.js +14 -8
  24. package/dist/manage/android.js +4 -3
  25. package/dist/manage/ios.js +3 -2
  26. package/dist/observe/android.js +27 -22
  27. package/dist/observe/evidence.js +273 -0
  28. package/dist/observe/index.js +33 -6
  29. package/dist/observe/ios.js +25 -6
  30. package/dist/observe/readiness-service.js +115 -0
  31. package/dist/observe/readiness.js +81 -0
  32. package/dist/observe/recording.js +165 -0
  33. package/dist/observe/snapshot-metadata.js +10 -2
  34. package/dist/server/common.js +9 -0
  35. package/dist/server/condition-schema.js +32 -0
  36. package/dist/server/journey-schema.js +3 -2
  37. package/dist/server/readiness-schema.js +12 -0
  38. package/dist/server/tool-definitions.js +46 -0
  39. package/dist/server/tool-handlers.js +99 -17
  40. package/dist/server-core.js +1 -1
  41. package/dist/utils/ios/utils.js +4 -4
  42. package/dist/utils/operation-budget.js +24 -2
  43. package/dist/utils/resolve-device.js +7 -0
  44. package/docs/CHANGELOG.md +16 -2
  45. package/docs/benchmarks/ui-tree-performance/README.md +4 -3
  46. package/docs/specs/020-conditional-actions/intent.md +62 -0
  47. package/docs/specs/020-conditional-actions/plan.md +214 -0
  48. package/docs/specs/020-conditional-actions/spec.md +301 -0
  49. package/docs/specs/021-app-readiness-signals/intent.md +40 -0
  50. package/docs/specs/021-app-readiness-signals/plan.md +102 -0
  51. package/docs/specs/021-app-readiness-signals/spec.md +144 -0
  52. package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
  53. package/docs/specs/022-correlated-evidence/intent.md +37 -0
  54. package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
  55. package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
  56. package/docs/specs/022-correlated-evidence/plan.md +171 -0
  57. package/docs/specs/022-correlated-evidence/spec.md +123 -0
  58. package/docs/specs/process.md +213 -0
  59. package/docs/tools/TOOLS.md +3 -0
  60. package/docs/tools/evidence.md +79 -0
  61. package/docs/tools/interact.md +125 -2
  62. package/docs/tools/observe.md +64 -2
  63. package/package.json +4 -2
  64. package/scripts/agent-plugin.mjs +98 -0
  65. package/src/interact/android.ts +10 -4
  66. package/src/interact/conditional-action.ts +75 -0
  67. package/src/interact/index.ts +42 -14
  68. package/src/interact/ios.ts +14 -8
  69. package/src/manage/android.ts +4 -3
  70. package/src/manage/ios.ts +3 -2
  71. package/src/observe/android.ts +35 -21
  72. package/src/observe/evidence.ts +255 -0
  73. package/src/observe/index.ts +32 -8
  74. package/src/observe/ios.ts +32 -8
  75. package/src/observe/readiness-service.ts +89 -0
  76. package/src/observe/readiness.ts +78 -0
  77. package/src/observe/snapshot-metadata.ts +12 -1
  78. package/src/server/common.ts +7 -0
  79. package/src/server/condition-schema.ts +32 -0
  80. package/src/server/journey-schema.ts +3 -2
  81. package/src/server/readiness-schema.ts +12 -0
  82. package/src/server/tool-definitions.ts +44 -0
  83. package/src/server/tool-handlers.ts +93 -17
  84. package/src/server-core.ts +1 -1
  85. package/src/types.ts +30 -1
  86. package/src/utils/ios/utils.ts +4 -4
  87. package/src/utils/operation-budget.ts +22 -2
  88. package/src/utils/resolve-device.ts +8 -0
  89. package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
  90. package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
  91. package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
  92. package/test/device/manual/interact/readiness.manual.ts +99 -0
  93. package/test/fixtures/readiness-app/MainActivity.java +23 -0
  94. package/test/fixtures/readiness-app/README.md +12 -0
  95. package/test/fixtures/readiness-app/build.ts +37 -0
  96. package/test/fixtures/readiness-app/main.m +27 -0
  97. package/test/unit/observe/android_tree_acquisition.test.ts +6 -0
  98. package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
  99. package/test/unit/observe/compact_tree_error.test.ts +24 -0
  100. package/test/unit/observe/conditional_snapshots.test.ts +33 -0
  101. package/test/unit/observe/correlated-evidence.test.ts +128 -0
  102. package/test/unit/observe/ios-getlogs.test.ts +3 -3
  103. package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
  104. package/test/unit/observe/readiness.test.ts +39 -0
  105. package/test/unit/observe/readiness_native.test.ts +68 -0
  106. package/test/unit/server/conditional_actions.test.ts +299 -0
  107. package/test/unit/server/conditional_schema.test.ts +59 -0
  108. package/test/unit/server/correlated-evidence.test.ts +194 -0
  109. package/test/unit/server/readiness.test.ts +170 -0
  110. package/test/unit/server/selector_tap_target.test.ts +79 -0
  111. package/test/unit/server/tap_selector.test.ts +2 -0
  112. package/skills/mobile-debug-tools-usage/SKILL.md +0 -74
  113. package/src/observe/android-tree-bridge.ts +0 -178
  114. package/test/device/automated/ui-tree-performance/bridge-probe.ts +0 -197
  115. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +0 -6
  116. package/test/fixtures/ui-tree-bridge/build.ts +0 -34
  117. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +0 -126
  118. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +0 -95
  119. /package/{skills → .agents/skills}/README.md +0 -0
  120. /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
  121. /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
  122. /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
  123. /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
  124. /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
  125. /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
  126. /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
  127. /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { AndroidObserve, ToolsObserve } from '../../../src/observe/index.js'
3
+ import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
4
+ import { withResolvedDevice } from '../../../src/utils/resolve-device.js'
5
+
6
+ async function run() {
7
+ const original = AndroidObserve.prototype.getUITree
8
+ const device = { platform: 'android', id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
9
+ const body = { screen: '', resolution: { width: 100, height: 200 }, elements: [] }
10
+ const metadata = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
11
+ AndroidObserve.prototype.getUITree = async () => ({ ...body, device, ...metadata, error: 'observation unavailable' })
12
+ try {
13
+ const compact: any = await withResolvedDevice(device, () => ToolsObserve.getUITreeHandler({ platform: 'android', deviceId: 'fixture', responseMode: 'compact', sinceSnapshotRevision: metadata.snapshot_revision }))
14
+ assert.equal(compact.error, 'observation unavailable', 'compact observations must not conceal an error behind a retained revision')
15
+ assert.equal(compact.delta_available, false)
16
+ assert.equal(compact.snapshot_session_id, metadata.snapshot_session_id)
17
+ assert.equal(compact.state_delta, undefined)
18
+ console.log('Compact UI-tree error evidence: PASS')
19
+ } finally {
20
+ AndroidObserve.prototype.getUITree = original
21
+ resetSnapshotMetadataForTests()
22
+ }
23
+ }
24
+ run().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict'
2
+ import { deriveSnapshotMetadata, hasSnapshotBaseline, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
3
+
4
+ const originalNow = Date.now
5
+ try {
6
+ resetSnapshotMetadataForTests()
7
+ let now = originalNow()
8
+ Date.now = () => now
9
+ const body = { screen: '', resolution: { width: 100, height: 200 }, elements: Array.from({ length: 501 }, (_, index) => ({ text: `Item ${index}`, stable_id: `item-${index}`, visible: true, bounds: [0, index, 20, index + 1] })) }
10
+ const first = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
11
+ assert.equal(hasSnapshotBaseline('android:other', first.snapshot_revision), false)
12
+ assert.equal(hasSnapshotBaseline('android:fixture', first.snapshot_revision), true)
13
+ now += 9 * 60 * 1000
14
+ const repeat = deriveSnapshotMetadata('android:fixture', body, 'ui_tree')
15
+ assert.equal(repeat.snapshot_revision, first.snapshot_revision)
16
+ now += 2 * 60 * 1000
17
+ assert.equal(hasSnapshotBaseline('android:fixture', repeat.snapshot_revision), true, 'successful re-observation refreshes retention')
18
+ const changed = structuredClone(body)
19
+ changed.elements[500].text = 'Changed beyond delta history limit'
20
+ const second = deriveSnapshotMetadata('android:fixture', changed, 'ui_tree')
21
+ assert.notEqual(second.snapshot_revision, first.snapshot_revision, 'every element contributes to conditional revision')
22
+ assert.equal(second.snapshot_session_id, first.snapshot_session_id)
23
+ now += 11 * 60 * 1000
24
+ assert.equal(hasSnapshotBaseline('android:fixture', second.snapshot_revision), false)
25
+ const fresh = deriveSnapshotMetadata('android:fixture', changed, 'ui_tree')
26
+ assert.equal(hasSnapshotBaseline('android:fixture', fresh.snapshot_revision), true)
27
+ const failed = deriveSnapshotMetadata('android:empty', null, 'ui_tree')
28
+ assert.equal(hasSnapshotBaseline('android:empty', failed.snapshot_revision), false, 'error metadata never creates a baseline')
29
+ console.log('Conditional snapshot retention/session coverage: PASS')
30
+ } finally {
31
+ Date.now = originalNow
32
+ resetSnapshotMetadataForTests()
33
+ }
@@ -0,0 +1,128 @@
1
+ import assert from 'node:assert/strict'
2
+ import { withEvidence, captureEvidence, evidenceDispatch, currentEvidenceActionId, markEvidenceVerification, omitEvidence, validateActionRef, type EvidenceDevice } from '../../../src/observe/evidence.js'
3
+
4
+ async function run() {
5
+ const android = { platform: 'android' as const, id: 'evidence-unit-android' }
6
+ const ios = { platform: 'ios' as const, id: 'evidence-unit-ios' }
7
+ const originalWall = Date.now
8
+ const originalMono = Object.getOwnPropertyDescriptor(performance, 'now')
9
+ let wall = 1000000
10
+ let mono = 0
11
+ Date.now = () => wall
12
+ Object.defineProperty(performance, 'now', { configurable: true, value: () => mono })
13
+ const tick = (n = 10) => { wall += n; mono += n }
14
+ const observe = (device: EvidenceDevice = android) => captureEvidence('ui_tree', device, async () => {
15
+ tick()
16
+ return { device, captured_at_ms: wall, snapshot_session_id: 'tree-session', snapshot_revision: 1 }
17
+ })
18
+ const act = async (device: EvidenceDevice = android) => withEvidence({ action: true }, async () => {
19
+ const id = currentEvidenceActionId()
20
+ await evidenceDispatch(device, async () => { tick() })
21
+ return id
22
+ })
23
+ try {
24
+ const a = await withEvidence({ action: true }, async () => {
25
+ await observe()
26
+ tick()
27
+ const id = currentEvidenceActionId()
28
+ await evidenceDispatch(android, async () => { tick() })
29
+ await observe()
30
+ markEvidenceVerification()
31
+ return id
32
+ })
33
+ assert.equal(a.evidence.action_id, a.result)
34
+ assert.equal(a.evidence.correlation_id, a.result)
35
+ assert.deepEqual(a.evidence.observations.map(v => v.freshness), ['stale', 'fresh'])
36
+ assert.deepEqual(a.evidence.observations[0].freshness_reasons, ['BEFORE_ACTION'])
37
+ assert.ok(a.evidence.dispatch_started_at_ms! < a.evidence.dispatch_completed_at_ms!)
38
+ assert.ok(a.evidence.verification_completed_at_ms! >= a.evidence.dispatch_completed_at_ms!)
39
+ assert.deepEqual(a.evidence.trace_context, { supported: false, trace_id: null, markers: [] })
40
+ const ref = { sessionId: a.evidence.session_id, actionId: a.evidence.action_id! }
41
+ const linked = await withEvidence({ ref }, () => observe())
42
+ assert.equal(linked.evidence.association, 'explicit')
43
+ assert.equal(linked.evidence.correlation_id, a.evidence.correlation_id)
44
+ assert.equal(linked.evidence.verification_completed_at_ms, a.evidence.verification_completed_at_ms)
45
+ assert.equal(linked.evidence.observations[0].snapshot_revision, 1)
46
+ assert.ok(linked.evidence.observations[0].age_ms! >= 0)
47
+ const mismatch = await withEvidence({ ref }, () => observe(ios))
48
+ assert.equal(mismatch.evidence.association_reason, 'DEVICE_MISMATCH')
49
+ assert.equal(mismatch.evidence.action_id, null)
50
+ assert.notEqual(mismatch.evidence.correlation_id, a.evidence.correlation_id)
51
+ const foreign = await withEvidence({ ref: { ...ref, sessionId: 'previous-process' } }, () => observe())
52
+ assert.equal(foreign.evidence.association_reason, 'SESSION_MISMATCH')
53
+ assert.equal(foreign.evidence.action_id, null)
54
+
55
+ const oldSource = wall - 100
56
+ const reused = await withEvidence({}, () => captureEvidence('ui_tree', ios, async () => ({ device: ios, captured_at_ms: oldSource })))
57
+ assert.equal(reused.evidence.observations[0].source_captured_at_ms, oldSource)
58
+ assert.deepEqual(reused.evidence.observations[0].freshness_reasons, ['REUSED_OBSERVATION'])
59
+ const cachedBeforeAction = await withEvidence({ ref }, () => captureEvidence('ui_tree', android, async () => ({ captured_at_ms: a.evidence.operation_started_at_ms - 10 })))
60
+ assert.deepEqual(cachedBeforeAction.evidence.observations[0].freshness_reasons, ['BEFORE_ACTION'])
61
+
62
+ let release!: () => void
63
+ let entered!: () => void
64
+ const ready = new Promise<void>(resolve => { entered = resolve })
65
+ const gate = new Promise<void>(resolve => { release = resolve })
66
+ const pending = withEvidence({ action: true }, () => evidenceDispatch(android, async () => { entered(); await gate; tick() }))
67
+ await ready
68
+ const overlap = await withEvidence({}, () => observe())
69
+ assert.deepEqual(overlap.evidence.observations[0].freshness_reasons, ['OVERLAPPING_ACTION'])
70
+ const separate = await withEvidence({}, () => observe(ios))
71
+ assert.equal(separate.evidence.observations[0].freshness, 'fresh')
72
+ release(); await pending
73
+ const superseded = await withEvidence({}, async () => { await observe(); tick(); await act() })
74
+ assert.deepEqual(superseded.evidence.observations[0].freshness_reasons, ['SUPERSEDED_BY_ACTION'])
75
+ const [one, two] = await Promise.all([act(), act(ios)])
76
+ assert.notEqual(one.evidence.action_id, two.evidence.action_id)
77
+ assert.equal(one.evidence.device?.platform, 'android')
78
+ assert.equal(two.evidence.device?.platform, 'ios')
79
+
80
+ const logs = await withEvidence({}, () => captureEvidence('logs', android, async () => ({ logs: [{ timestamp: null, message: 'untimed' }, { timestamp: 42 }, { timestamp: '2020-01-01T00:00:00Z' }] })))
81
+ const log = logs.evidence.observations[0]
82
+ assert.equal(log.freshness, 'unknown')
83
+ assert.equal(log.earliest_entry_at_ms, 42)
84
+ assert.equal(log.event_time_comparable, false)
85
+ assert.equal(log.age_ms, null)
86
+ const partial = await withEvidence({}, async () => {
87
+ omitEvidence('logs'); omitEvidence('screenshot')
88
+ await captureEvidence('ui_tree', android, async () => { throw new Error('offline') }).catch(() => null)
89
+ })
90
+ assert.deepEqual(partial.evidence.observations.map(v => v.status), ['omitted', 'omitted', 'failed'])
91
+ assert.equal(partial.evidence.observations[0].freshness, 'not_applicable')
92
+ assert.equal(partial.evidence.observations[2].error, 'offline')
93
+ const unknown = await withEvidence({}, () => captureEvidence('screenshot', null, async () => ({})))
94
+ assert.equal(unknown.evidence.observations[0].freshness, 'unknown')
95
+ const reversal = await withEvidence({}, async () => {
96
+ await observe(); wall -= 1000; mono += 10
97
+ })
98
+ assert.deepEqual(reversal.evidence.observations[0].freshness_reasons, ['CLOCK_DISCONTINUITY'])
99
+ assert.equal(reversal.evidence.observations[0].age_ms, null)
100
+
101
+ tick(600001)
102
+ const expired = await withEvidence({ ref }, () => observe())
103
+ assert.equal(expired.evidence.association_reason, 'ACTION_UNAVAILABLE')
104
+ const first = await act()
105
+ for (let i = 0; i < 1000; i++) await act()
106
+ const evicted = await withEvidence({ ref: { sessionId: first.evidence.session_id, actionId: first.evidence.action_id! } }, () => observe())
107
+ assert.equal(evicted.evidence.association_reason, 'ACTION_UNAVAILABLE')
108
+ const noDispatch = await withEvidence({ action: true }, async () => undefined)
109
+ assert.equal(noDispatch.evidence.dispatch_started_at_ms, null)
110
+ assert.equal(noDispatch.evidence.verification_completed_at_ms, null)
111
+ const multi = await withEvidence({ action: true }, async () => {
112
+ await evidenceDispatch(android, async () => tick())
113
+ tick()
114
+ await evidenceDispatch(android, async () => tick())
115
+ })
116
+ assert.equal(multi.evidence.dispatch_completed_at_ms! - multi.evidence.dispatch_started_at_ms!, 30)
117
+ for (const invalid of [null, [], {}, { sessionId: 'a', actionId: '' }, { sessionId: 'a', actionId: 'x'.repeat(129) }, { ...ref, extra: true }]) {
118
+ assert.throws(() => validateActionRef(invalid), /INVALID_ARGUMENT/)
119
+ }
120
+ assert.deepEqual(validateActionRef(ref), ref)
121
+ } finally {
122
+ Date.now = originalWall
123
+ if (originalMono) Object.defineProperty(performance, 'now', originalMono)
124
+ else delete (performance as any).now
125
+ }
126
+ console.log('Correlated evidence clocks, freshness, references, isolation and bounds: PASS')
127
+ }
128
+ run().catch(error => { console.error(error); process.exit(1) })
@@ -9,9 +9,9 @@ function stubExecCommand(expectedArgsChecker: (args: string[]) => boolean, outpu
9
9
  }
10
10
 
11
11
  async function run() {
12
- const bundle = 'com.ideamechanics.modul8'
12
+ const bundle = 'com.example.sampleapp'
13
13
  const pgrepOutput = '12345\n'
14
- const logOutput = '2026-03-31 09:21:20.085 Module[12345:678] <Info> Modul8: Test message'
14
+ const logOutput = '2026-03-31 09:21:20.085 SampleApp[12345:678] <Info> SampleApp: Test message'
15
15
 
16
16
  try {
17
17
  const obs = new iOSObserve()
@@ -28,7 +28,7 @@ async function run() {
28
28
  })
29
29
 
30
30
  const pidResult = await obs.getLogs({ appId: bundle, deviceId: 'booted' })
31
- assert(pidResult.meta.processNameUsed === 'modul8' || pidResult.meta.processNameUsed === 'Modul8' || !!pidResult.meta.processNameUsed)
31
+ assert(pidResult.meta.processNameUsed === 'sampleapp')
32
32
  assert(pidResult.meta.detectedPid === 12345)
33
33
  assert(pidResult.source === 'pid')
34
34
  assert(pidResult.logCount === 1)
@@ -43,6 +43,8 @@ console.log(JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-
43
43
  const first = await observe.getUITree('fake-a')
44
44
  const second = await observe.getUITree('fake-b')
45
45
  assert.equal(first.error, undefined)
46
+ assert.equal(typeof first.snapshot_session_id, 'string')
47
+ assert.equal(first.snapshot_session_id, second.snapshot_session_id)
46
48
  assert.equal(second.error, undefined)
47
49
  assert.equal(first.elements[0].text, 'Ready')
48
50
  assert.equal(second.snapshot_delta, null, 'different simulators keep independent delta history')
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict'
2
+ import { ReadinessTracker } from '../../../src/observe/readiness.js'
3
+ import { getSnapshotSessionId } from '../../../src/observe/snapshot-metadata.js'
4
+
5
+ const device = { platform: 'android', id: 'fixture' }
6
+ const sample = (label = 'Ready', extra: any = {}) => ({ device, snapshot_session_id: getSnapshotSessionId(), snapshot_revision: 1,
7
+ captured_at_ms: 10000, screen: '', resolution: { width: 100, height: 200 },
8
+ elements: [{ type: 'Button', text: label, visible: true, enabled: true, bounds: [0, 0, 50, 50] }], ...extra })
9
+ const tracker = new ReadinessTracker('android', 'fixture')
10
+ assert.equal(tracker.observe(sample(), 10000, 0).state, 'unknown')
11
+ assert.equal(tracker.observe(sample(), 10000, 499).state, 'unknown')
12
+ assert.equal(tracker.observe(sample(), 10000, 500).state, 'settled')
13
+ assert.equal(tracker.observe(sample('Changed'), 10000, 1000).state, 'transitioning')
14
+ assert.equal(tracker.observe(sample('Changed'), 10000, 1500).state, 'settled')
15
+ for (const label of [' Loading… ', 'Please wait...', 'please wait']) {
16
+ const state = tracker.observe(sample(label), 10000, 2000)
17
+ assert.equal(state.state, 'loading')
18
+ assert.equal(state.basis, 'heuristic')
19
+ assert.equal(tracker.observe(sample(label), 10000, 3000).state, 'loading', 'identical loaders never settle')
20
+ }
21
+ for (const type of ['android.widget.ProgressBar', 'ActivityIndicator', 'ProgressIndicator']) {
22
+ const tree = sample('Progress', { elements: [{ type, visible: true }] })
23
+ assert.equal(tracker.observe(tree, 10000, 4000).state, 'loading')
24
+ assert.equal(tracker.summary.basis, 'observed')
25
+ }
26
+ assert.notEqual(tracker.observe(sample('Progress'), 10000, 5000).state, 'loading', 'legacy broad keywords are not loaders')
27
+ for (const extra of [{ error: 'offline' }, { elements: [] }, { captured_at_ms: 9999 }, { snapshot_session_id: 'old' }, { device: { ...device, id: 'other' } }, { resolution: { width: Infinity, height: 2 } }]) {
28
+ assert.equal(tracker.observe(sample('Ready', extra), 10000, 6000).state, 'unknown')
29
+ assert.equal(tracker.observe(sample(), 10000, 7000).state, 'unknown', 'failure breaks continuity')
30
+ assert.equal(tracker.observe(sample(), 10000, 7500).state, 'settled')
31
+ }
32
+ for (let index = 0; index < 20; index++) tracker.observe(sample(), 10000, 8000 + index)
33
+ assert.equal(tracker.summary.evidence.length, 8)
34
+ assert.equal(tracker.summary.capabilities.unresponsive, false)
35
+ assert.equal(tracker.summary.capabilities.debugger_paused, false)
36
+ assert.ok(tracker.deadline().limitations.includes('DEADLINE_EXCEEDED'))
37
+ assert.equal(new ReadinessTracker('android', 'fixture').observe(sample(), 10000, 9000).state, 'unknown', 'no cross-request history')
38
+ assert.ok(!JSON.stringify(tracker.summary).includes('Ready'), 'evidence contains codes rather than private labels')
39
+ console.log('Readiness classifier: PASS')
@@ -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,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 })