mobile-debug-mcp 0.32.4 → 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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.github/copilot-instructions.md +7 -0
- package/AGENTS.md +21 -10
- package/README.md +62 -4
- package/agent-plugin/.claude-plugin/plugin.json +20 -0
- package/agent-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-plugin/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/README.md +5 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +28 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +54 -14
- package/dist/interact/ios.js +14 -8
- package/dist/manage/android.js +4 -3
- package/dist/manage/ios.js +3 -2
- package/dist/observe/android.js +25 -4
- package/dist/observe/evidence.js +273 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +115 -0
- package/dist/observe/readiness.js +81 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/journey-schema.js +3 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/tool-definitions.js +46 -0
- package/dist/server/tool-handlers.js +99 -17
- package/dist/server-core.js +1 -1
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/operation-budget.js +24 -2
- package/dist/utils/resolve-device.js +7 -0
- package/docs/CHANGELOG.md +11 -0
- package/docs/specs/020-conditional-actions/intent.md +62 -0
- package/docs/specs/020-conditional-actions/plan.md +214 -0
- package/docs/specs/020-conditional-actions/spec.md +301 -0
- package/docs/specs/021-app-readiness-signals/intent.md +40 -0
- package/docs/specs/021-app-readiness-signals/plan.md +102 -0
- package/docs/specs/021-app-readiness-signals/spec.md +144 -0
- package/docs/specs/022-correlated-evidence/device-evidence.json +60 -0
- package/docs/specs/022-correlated-evidence/intent.md +37 -0
- package/docs/specs/022-correlated-evidence/modul8-evidence.json +1062 -0
- package/docs/specs/022-correlated-evidence/modul8-selector-fix-evidence.json +336 -0
- package/docs/specs/022-correlated-evidence/plan.md +171 -0
- package/docs/specs/022-correlated-evidence/spec.md +123 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +3 -0
- package/docs/tools/evidence.md +79 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +63 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +42 -14
- package/src/interact/ios.ts +14 -8
- package/src/manage/android.ts +4 -3
- package/src/manage/ios.ts +3 -2
- package/src/observe/android.ts +33 -6
- package/src/observe/evidence.ts +255 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +89 -0
- package/src/observe/readiness.ts +78 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/journey-schema.ts +3 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/tool-definitions.ts +44 -0
- package/src/server/tool-handlers.ts +93 -17
- package/src/server-core.ts +1 -1
- package/src/types.ts +30 -1
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/operation-budget.ts +22 -2
- package/src/utils/resolve-device.ts +8 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/manual/interact/conditional_actions.manual.ts +95 -0
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +63 -0
- package/test/device/manual/interact/readiness.manual.ts +99 -0
- package/test/fixtures/readiness-app/MainActivity.java +23 -0
- package/test/fixtures/readiness-app/README.md +12 -0
- package/test/fixtures/readiness-app/build.ts +37 -0
- package/test/fixtures/readiness-app/main.m +27 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +42 -32
- package/test/unit/observe/compact_tree_error.test.ts +24 -0
- package/test/unit/observe/conditional_snapshots.test.ts +33 -0
- package/test/unit/observe/correlated-evidence.test.ts +128 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +2 -0
- package/test/unit/observe/readiness.test.ts +39 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/server/conditional_actions.test.ts +299 -0
- package/test/unit/server/conditional_schema.test.ts +59 -0
- package/test/unit/server/correlated-evidence.test.ts +194 -0
- package/test/unit/server/readiness.test.ts +170 -0
- package/test/unit/server/selector_tap_target.test.ts +79 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/skills/mobile-debug-tools-usage/SKILL.md +0 -96
- /package/{skills → .agents/skills}/README.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/SKILL.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/build-flags.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/diagnostics-schema.md +0 -0
- /package/{skills → .agents/skills}/mcp-builder/references/toolchain-details.md +0 -0
- /package/{skills → .agents/skills}/spec-review/SKILL.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-checklist.md +0 -0
- /package/{skills → .agents/skills}/spec-review/references/spec-review-template.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/SKILL.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/repo-test-layout.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-authoring-workflow.md +0 -0
- /package/{skills → .agents/skills}/test-authoring/references/test-quality-checklist.md +0 -0
|
@@ -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) })
|
|
@@ -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 })
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
|
|
3
|
+
import { toolDefinitions } from '../../../src/server/tool-definitions.js'
|
|
4
|
+
import { conditionalTools, validateExpectedState } from '../../../src/server/condition-schema.js'
|
|
5
|
+
import { journeySchema } from '../../../src/server/journey-schema.js'
|
|
6
|
+
|
|
7
|
+
const provider = new AjvJsonSchemaValidator()
|
|
8
|
+
const expectedState = { snapshotSessionId: 'session-fixture', snapshotRevision: 1 }
|
|
9
|
+
for (const name of conditionalTools) {
|
|
10
|
+
const schema = toolDefinitions.find(tool => tool.name === name)!.inputSchema
|
|
11
|
+
const validate = provider.getValidator(schema)
|
|
12
|
+
const args = { platform: 'android', deviceId: 'emulator-5554', expectedState,
|
|
13
|
+
...({ tap: { x: 1, y: 1 }, tap_element: { selector: { text: 'Go' } }, swipe: { x1: 1, y1: 1, x2: 2, y2: 2, duration: 100 }, type_text: { text: 'hello' }, press_back: {} }[name]) }
|
|
14
|
+
assert.equal(validate(args).valid, true, `${name} schema accepts valid guarded input`)
|
|
15
|
+
assert.deepEqual(validateExpectedState(name, args), expectedState)
|
|
16
|
+
for (const extra of [
|
|
17
|
+
{ expectedState: null }, { expectedState: {} }, { expectedState: { ...expectedState, extra: true } },
|
|
18
|
+
{ expectedState: { ...expectedState, snapshotRevision: 0 } }, { expectedState: { ...expectedState, snapshotRevision: 1.5 } },
|
|
19
|
+
{ expectedState: { ...expectedState, snapshotRevision: Number.MAX_SAFE_INTEGER + 1 } },
|
|
20
|
+
{ expectedState: { ...expectedState, snapshotSessionId: '' } }, { expectedState: { ...expectedState, timeoutMs: 99 } },
|
|
21
|
+
{ expectedState: { ...expectedState, timeoutMs: 60001 } }, { expectedState: { ...expectedState, timeoutMs: '15000' } },
|
|
22
|
+
{ platform: null }, { deviceId: 'booted' }, { deviceId: 'default' }, { deviceId: '' }, { deviceId: ' ' }
|
|
23
|
+
]) {
|
|
24
|
+
const input = { ...args, ...extra }
|
|
25
|
+
assert.equal(validate(input).valid, false, JSON.stringify(input))
|
|
26
|
+
assert.throws(() => validateExpectedState(name, input), /INVALID_ARGUMENT/)
|
|
27
|
+
}
|
|
28
|
+
const withoutDevice: any = { ...args }; delete withoutDevice.deviceId
|
|
29
|
+
assert.equal(validate(withoutDevice).valid, false)
|
|
30
|
+
assert.throws(() => validateExpectedState(name, withoutDevice), /INVALID_ARGUMENT/)
|
|
31
|
+
const ios = { ...args, platform: 'ios', deviceId: 'concrete-udid' }
|
|
32
|
+
const supported = !['type_text', 'press_back'].includes(name)
|
|
33
|
+
assert.equal(validate(ios).valid, supported)
|
|
34
|
+
if (supported) assert.deepEqual(validateExpectedState(name, ios), expectedState)
|
|
35
|
+
else assert.throws(() => validateExpectedState(name, ios), /INVALID_ARGUMENT/)
|
|
36
|
+
}
|
|
37
|
+
for (const name of ['start_app', 'restart_app', 'terminate_app', 'reset_app_data', 'scroll_to_element', 'adjust_control', 'get_ui_tree']) {
|
|
38
|
+
const schema = toolDefinitions.find(tool => tool.name === name)!.inputSchema
|
|
39
|
+
const args = { platform: 'android', deviceId: 'emulator-5554', appId: 'example.app', selector: { text: 'Go' }, targetValue: 1, expectedState }
|
|
40
|
+
assert.equal(provider.getValidator(schema)(args).valid, false)
|
|
41
|
+
assert.throws(() => validateExpectedState(name, args), /INVALID_ARGUMENT/)
|
|
42
|
+
}
|
|
43
|
+
const validateJourney = provider.getValidator(toolDefinitions.find(tool => tool.name === 'run_journey')!.inputSchema)
|
|
44
|
+
const step = { id: 'tap', type: 'tap', selector: { text: 'Go' }, expectedState }
|
|
45
|
+
const journey = { platform: 'android', steps: [step] }
|
|
46
|
+
assert.equal(validateJourney(journey).valid, true)
|
|
47
|
+
assert.equal(journeySchema.safeParse(journey).success, true)
|
|
48
|
+
for (const input of [
|
|
49
|
+
{ ...journey, expectedState },
|
|
50
|
+
{ ...journey, defaults: { expectedState } },
|
|
51
|
+
{ ...journey, steps: [{ id: 'wait', type: 'wait', selector: { text: 'Go' }, expectedState }] },
|
|
52
|
+
{ ...journey, steps: [{ id: 'launch', type: 'start_app', appId: 'example.app', expectedState }] },
|
|
53
|
+
{ ...journey, steps: [{ id: 'assert', type: 'assert', assertion: { kind: 'element_visible', selector: { text: 'Go' } }, expectedState }] },
|
|
54
|
+
{ ...journey, steps: [{ ...step, expectedState: { ...expectedState, timeoutMs: 0 } }] }
|
|
55
|
+
]) {
|
|
56
|
+
assert.equal(validateJourney(input).valid, false)
|
|
57
|
+
assert.equal(journeySchema.safeParse(input).success, false)
|
|
58
|
+
}
|
|
59
|
+
console.log('Conditional JSON schema/runtime parity: PASS')
|