mobile-debug-mcp 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/.github/workflows/ci.yml +21 -0
- package/AGENTS.md +21 -10
- package/README.md +69 -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 +9 -3
- package/agent-plugin/assets/mobile-debug-tools.svg +28 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +113 -1
- package/dist/debugger/android.js +78 -0
- package/dist/debugger/guard.js +30 -0
- package/dist/debugger/sessions.js +262 -0
- package/dist/debugger/worker.js +165 -0
- package/dist/interact/android.js +10 -4
- package/dist/interact/conditional-action.js +68 -0
- package/dist/interact/index.js +70 -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 +280 -0
- package/dist/observe/index.js +33 -6
- package/dist/observe/ios.js +25 -6
- package/dist/observe/readiness-service.js +124 -0
- package/dist/observe/readiness.js +88 -0
- package/dist/observe/recording.js +165 -0
- package/dist/observe/snapshot-metadata.js +10 -2
- package/dist/observe/system-trace-android.js +135 -0
- package/dist/observe/system-trace.js +285 -0
- package/dist/server/common.js +9 -0
- package/dist/server/condition-schema.js +32 -0
- package/dist/server/debugger-handlers.js +38 -0
- package/dist/server/debugger-schema.js +42 -0
- package/dist/server/journey-schema.js +4 -2
- package/dist/server/readiness-schema.js +12 -0
- package/dist/server/system-trace-schema.js +17 -0
- package/dist/server/tool-definitions.js +51 -0
- package/dist/server/tool-handlers.js +294 -105
- package/dist/server-core.js +3 -3
- package/dist/server.js +34 -0
- package/dist/system/index.js +5 -4
- package/dist/utils/exec.js +2 -0
- package/dist/utils/ios/utils.js +4 -4
- package/dist/utils/java.js +14 -12
- package/dist/utils/operation-budget.js +26 -2
- package/dist/utils/resolve-device.js +9 -0
- package/docs/CHANGELOG.md +15 -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/023-android-system-traces/device-evidence.json +38 -0
- package/docs/specs/023-android-system-traces/emulator-recheck.json +38 -0
- package/docs/specs/023-android-system-traces/intent.md +40 -0
- package/docs/specs/023-android-system-traces/plan.md +117 -0
- package/docs/specs/023-android-system-traces/spec.md +120 -0
- package/docs/specs/024-android-heap-dump-capture/intent.md +39 -0
- package/docs/specs/025-android-runtime-debugger/intent.md +40 -0
- package/docs/specs/025-android-runtime-debugger/plan.md +104 -0
- package/docs/specs/025-android-runtime-debugger/spec.md +182 -0
- package/docs/specs/process.md +213 -0
- package/docs/tools/TOOLS.md +5 -0
- package/docs/tools/debugger.md +104 -0
- package/docs/tools/evidence.md +82 -0
- package/docs/tools/interact.md +125 -2
- package/docs/tools/observe.md +70 -0
- package/docs/tools/system-traces.md +126 -0
- package/package.json +1 -1
- package/scripts/agent-plugin.mjs +68 -8
- package/src/debugger/android.ts +65 -0
- package/src/debugger/guard.ts +26 -0
- package/src/debugger/sessions.ts +168 -0
- package/src/debugger/worker.ts +122 -0
- package/src/interact/android.ts +10 -4
- package/src/interact/conditional-action.ts +75 -0
- package/src/interact/index.ts +57 -28
- 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 +262 -0
- package/src/observe/index.ts +32 -8
- package/src/observe/ios.ts +32 -8
- package/src/observe/readiness-service.ts +94 -0
- package/src/observe/readiness.ts +84 -0
- package/src/observe/snapshot-metadata.ts +12 -1
- package/src/observe/system-trace-android.ts +133 -0
- package/src/observe/system-trace.ts +218 -0
- package/src/server/common.ts +7 -0
- package/src/server/condition-schema.ts +32 -0
- package/src/server/debugger-handlers.ts +29 -0
- package/src/server/debugger-schema.ts +37 -0
- package/src/server/journey-schema.ts +4 -2
- package/src/server/readiness-schema.ts +12 -0
- package/src/server/system-trace-schema.ts +17 -0
- package/src/server/tool-definitions.ts +49 -0
- package/src/server/tool-handlers.ts +182 -21
- package/src/server-core.ts +3 -3
- package/src/server.ts +24 -0
- package/src/system/index.ts +7 -5
- package/src/types.ts +30 -1
- package/src/utils/exec.ts +2 -0
- package/src/utils/ios/utils.ts +4 -4
- package/src/utils/java.ts +13 -12
- package/src/utils/operation-budget.ts +24 -2
- package/src/utils/resolve-device.ts +10 -0
- package/test/device/automated/debugger/runtime.android.test.ts +203 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +70 -0
- package/test/device/automated/observe/system-trace.smoke.ts +104 -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/debugger-app/MainActivity.java +46 -0
- package/test/fixtures/debugger-app/NoLocals.java +7 -0
- package/test/fixtures/debugger-app/README.md +13 -0
- package/test/fixtures/debugger-app/build.ts +29 -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/helpers/perfetto.ts +59 -0
- package/test/unit/debugger/guards.test.ts +46 -0
- package/test/unit/debugger/native.test.ts +33 -0
- package/test/unit/debugger/sessions.test.ts +83 -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 +41 -0
- package/test/unit/observe/readiness_native.test.ts +68 -0
- package/test/unit/observe/system-trace-android.test.ts +52 -0
- package/test/unit/observe/system-trace.test.ts +166 -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/system-trace.test.ts +64 -0
- package/test/unit/server/tap_selector.test.ts +2 -0
- package/worker/DebuggerWorker.java +160 -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,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')
|
|
@@ -0,0 +1,194 @@
|
|
|
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 { AndroidObserve } from '../../../src/observe/android.js'
|
|
7
|
+
import { iOSObserve, _setIOSActiveLogStream, _clearIOSActiveLogStream } from '../../../src/observe/ios.js'
|
|
8
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
9
|
+
import { deriveSnapshotMetadata } from '../../../src/observe/snapshot-metadata.js'
|
|
10
|
+
|
|
11
|
+
async function run() {
|
|
12
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'mcp-evidence-'))
|
|
13
|
+
const binary = path.join(directory, 'native')
|
|
14
|
+
const events = path.join(directory, 'events')
|
|
15
|
+
const keys = ['ADB_PATH', 'IDB_PATH', 'MCP_IDB_PATH', 'XCRUN_PATH', 'EVIDENCE_FAIL_INPUT', 'EVIDENCE_INPUT_SLOW']
|
|
16
|
+
const env = Object.fromEntries(keys.map(key => [key, process.env[key]]))
|
|
17
|
+
writeFileSync(events, '')
|
|
18
|
+
writeFileSync(binary, `#!/usr/bin/env node
|
|
19
|
+
const fs = require('fs')
|
|
20
|
+
const args = process.argv.slice(2).join(' ')
|
|
21
|
+
fs.appendFileSync(${JSON.stringify(events)}, args + '\\n')
|
|
22
|
+
if (args.includes('ro.build.version.release')) console.log('16')
|
|
23
|
+
else if (args.includes('ro.product.model')) console.log('Fixture')
|
|
24
|
+
else if (args.includes('ro.kernel.qemu')) console.log('1')
|
|
25
|
+
else if (args.includes('simctl list devices')) console.log(JSON.stringify({devices:{'iOS-18':[{udid:'fixture-ios',name:'Fixture',state:'Booted'}]}}))
|
|
26
|
+
else if (args.includes('list-targets')) console.log('[]')
|
|
27
|
+
else if (args.includes('shell input') || args.includes('ui tap') || args.includes('ui swipe')) { if (process.env.EVIDENCE_FAIL_INPUT) process.exit(1); if (process.env.EVIDENCE_INPUT_SLOW) setTimeout(() => process.exit(0), 2000) }
|
|
28
|
+
else if (args.includes('pidof')) console.log('123')
|
|
29
|
+
else if (args.includes('logcat')) { console.log('2026-10-30 10:00:00.000 123 123 I Fixture: stream-owned'); setInterval(() => {}, 1000) }
|
|
30
|
+
else if (args.includes('monkey') || args.includes('simctl launch')) console.log('launched: 123')
|
|
31
|
+
else process.exit(1)
|
|
32
|
+
`)
|
|
33
|
+
chmodSync(binary, 0o755)
|
|
34
|
+
for (const key of keys.slice(0, 4)) process.env[key] = binary
|
|
35
|
+
const device = (platform: 'android' | 'ios', id?: string) => ({ platform, id: id ?? `fixture-${platform}`, osVersion: '16', model: 'Fixture', simulator: true })
|
|
36
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [device('android'), device('android', 'other')], listIOSDevices: async () => [device('ios')] })
|
|
37
|
+
const originals: Array<() => void> = []
|
|
38
|
+
let captureCount = 0
|
|
39
|
+
let failScreenshot = false
|
|
40
|
+
for (const [platform, klass] of [['android', AndroidObserve], ['ios', iOSObserve]] as const) {
|
|
41
|
+
const prototype = klass.prototype as any
|
|
42
|
+
const replace = (name: string, fn: (...args: any[]) => any) => {
|
|
43
|
+
const original = prototype[name]; originals.push(() => { prototype[name] = original }); prototype[name] = fn
|
|
44
|
+
}
|
|
45
|
+
replace('collectGetUITree', async (id: string) => {
|
|
46
|
+
captureCount++
|
|
47
|
+
const body = { device: device(platform, id), elements: [{ text: 'Ready', resourceId: 'target', contentDescription: 'target', type: 'Button', clickable: true, enabled: true, visible: true, bounds: [0, 0, 50, 50] }], resolution: { width: 100, height: 200 }, screen: 'Ready' }
|
|
48
|
+
return { ...body, ...deriveSnapshotMetadata(`${platform}:${id}`, body as any, 'ui_tree') }
|
|
49
|
+
})
|
|
50
|
+
replace(platform === 'android' ? 'collectCaptureScreen' : 'collectCaptureScreenshot', async (id: string) => {
|
|
51
|
+
captureCount++
|
|
52
|
+
if (failScreenshot) throw new Error('screenshot offline')
|
|
53
|
+
return { device: device(platform, id), screenshot: 'fixture-image', screenshot_mime: 'image/png', resolution: { width: 100, height: 200 } }
|
|
54
|
+
})
|
|
55
|
+
replace('collectGetLogs', async (filters: any) => {
|
|
56
|
+
captureCount++
|
|
57
|
+
return { device: device(platform, filters.deviceId), logs: [{ timestamp: '2020-01-01T00:00:00Z', level: 'INFO', message: 'preserved' }], logCount: 1 }
|
|
58
|
+
})
|
|
59
|
+
if (platform === 'android') replace('collectGetCurrentScreen', async (id: string) => ({ device: device(platform, id), activity: 'FixtureActivity', package: 'fixture.app' }))
|
|
60
|
+
}
|
|
61
|
+
const payload = (r: any) => JSON.parse(r.content[0].text)
|
|
62
|
+
const call = async (name: string, args: Record<string, unknown>) => payload(await handleToolCall(name, args))
|
|
63
|
+
const refFor = (value: any) => ({ sessionId: value.evidence.session_id, actionId: value.evidence.action_id })
|
|
64
|
+
try {
|
|
65
|
+
for (const platform of ['android', 'ios'] as const) {
|
|
66
|
+
const target = { platform, deviceId: `fixture-${platform}` }
|
|
67
|
+
for (const responseMode of ['compact', 'debug']) {
|
|
68
|
+
const a = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10 })
|
|
69
|
+
assert.equal(a.success, true)
|
|
70
|
+
assert.equal(a.action_id, a.evidence.action_id)
|
|
71
|
+
assert.equal(a.evidence.action_id, a.evidence.correlation_id)
|
|
72
|
+
assert.ok(a.evidence.dispatch_started_at_ms)
|
|
73
|
+
assert.ok(a.evidence.dispatch_completed_at_ms >= a.evidence.dispatch_started_at_ms)
|
|
74
|
+
assert.equal(a.evidence.verification_completed_at_ms, null)
|
|
75
|
+
if (responseMode === 'debug') assert.equal(a.trace.action_id, a.action_id)
|
|
76
|
+
const actionRef = refFor(a)
|
|
77
|
+
const image = await handleToolCall('capture_screenshot', { ...target, actionRef })
|
|
78
|
+
const imageMeta = payload(image)
|
|
79
|
+
assert.equal(imageMeta.evidence.action_id, a.action_id)
|
|
80
|
+
assert.equal(imageMeta.evidence.correlation_id, a.evidence.correlation_id)
|
|
81
|
+
assert.equal(image.content[1].data, 'fixture-image')
|
|
82
|
+
assert.equal(imageMeta.evidence.observations[0].freshness, 'fresh')
|
|
83
|
+
const logs = await handleToolCall('get_logs', { ...target, actionRef })
|
|
84
|
+
assert.equal(payload(logs).evidence.action_id, a.action_id)
|
|
85
|
+
assert.equal(JSON.parse(logs.content[1].text!).logs[0].message, 'preserved')
|
|
86
|
+
assert.equal(payload(logs).evidence.observations[0].event_time_comparable, false)
|
|
87
|
+
const tree = await call('get_ui_tree', { ...target, responseMode, actionRef })
|
|
88
|
+
assert.equal(tree.evidence.action_id, a.action_id)
|
|
89
|
+
assert.ok(tree.evidence.observations[0].snapshot_revision)
|
|
90
|
+
const verified = await call('tap', { ...target, responseMode, verificationMode: 'full', x: 10, y: 10 })
|
|
91
|
+
assert.ok(verified.evidence.verification_completed_at_ms >= verified.evidence.dispatch_completed_at_ms)
|
|
92
|
+
assert.ok(verified.evidence.observations.some((v: any) => v.freshness_reasons.includes('BEFORE_ACTION')))
|
|
93
|
+
assert.ok(verified.evidence.observations.some((v: any) => v.freshness === 'fresh'))
|
|
94
|
+
process.env.EVIDENCE_FAIL_INPUT = '1'
|
|
95
|
+
const failed = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10 })
|
|
96
|
+
assert.equal(failed.success, false)
|
|
97
|
+
assert.ok(failed.evidence.dispatch_started_at_ms)
|
|
98
|
+
assert.equal(failed.evidence.verification_completed_at_ms, null)
|
|
99
|
+
delete process.env.EVIDENCE_FAIL_INPUT
|
|
100
|
+
const blocked = await call('tap', { ...target, responseMode, verificationMode: 'none', x: 10, y: 10,
|
|
101
|
+
expectedState: { snapshotSessionId: 'expired-session', snapshotRevision: 1 } })
|
|
102
|
+
assert.equal(blocked.failure_code, 'PRECONDITION_UNAVAILABLE')
|
|
103
|
+
assert.equal(blocked.evidence.dispatch_started_at_ms, null)
|
|
104
|
+
assert.equal(blocked.evidence.verification_completed_at_ms, null)
|
|
105
|
+
}
|
|
106
|
+
failScreenshot = true
|
|
107
|
+
const partial = await call('capture_debug_snapshot', { ...target, includeLogs: false })
|
|
108
|
+
assert.equal(partial.raw.screenshot_error, 'screenshot offline')
|
|
109
|
+
assert.equal(partial.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'failed')
|
|
110
|
+
assert.equal(partial.evidence.observations.find((v: any) => v.kind === 'logs').status, 'omitted')
|
|
111
|
+
failScreenshot = false
|
|
112
|
+
const disabled = await call('capture_debug_snapshot', { ...target, includeScreenshot: false, includeLogs: false })
|
|
113
|
+
assert.equal(disabled.raw.screenshot, null)
|
|
114
|
+
assert.equal(disabled.evidence.observations.find((v: any) => v.kind === 'screenshot').status, 'omitted')
|
|
115
|
+
const snapshot = await call('capture_debug_snapshot', target)
|
|
116
|
+
assert.equal(snapshot.raw.screenshot, 'fixture-image')
|
|
117
|
+
assert.equal(snapshot.raw.logs[0].message, 'preserved')
|
|
118
|
+
assert.ok(snapshot.evidence.observations.every((v: any) => !v.device || v.device.id === target.deviceId))
|
|
119
|
+
}
|
|
120
|
+
process.env.EVIDENCE_INPUT_SLOW = '1'
|
|
121
|
+
const timeout = await call('tap', { platform: 'android', deviceId: 'fixture-android', x: 1, y: 1,
|
|
122
|
+
verificationMode: 'none', responseMode: 'compact', actionTimeoutMs: 500 })
|
|
123
|
+
delete process.env.EVIDENCE_INPUT_SLOW
|
|
124
|
+
assert.equal(timeout.failure_code, 'ACTION_TIMEOUT')
|
|
125
|
+
assert.ok(timeout.evidence.dispatch_started_at_ms)
|
|
126
|
+
assert.ok(timeout.evidence.dispatch_completed_at_ms)
|
|
127
|
+
assert.equal(timeout.evidence.verification_completed_at_ms, null)
|
|
128
|
+
const before = captureCount
|
|
129
|
+
const malformed = await call('capture_screenshot', { platform: 'android', actionRef: { actionId: '' } })
|
|
130
|
+
assert.match(malformed.error.message, /INVALID_ARGUMENT/)
|
|
131
|
+
assert.equal(captureCount, before)
|
|
132
|
+
const a = await call('tap', { platform: 'android', deviceId: 'fixture-android', x: 1, y: 1, verificationMode: 'none' })
|
|
133
|
+
const mismatch = await call('capture_screenshot', { platform: 'android', deviceId: 'other', actionRef: refFor(a) })
|
|
134
|
+
assert.equal(mismatch.evidence.association_reason, 'DEVICE_MISMATCH')
|
|
135
|
+
assert.notEqual(mismatch.evidence.correlation_id, a.evidence.correlation_id)
|
|
136
|
+
const journey = await call('run_journey', { platform: 'android', deviceId: 'fixture-android', responseMode: 'debug',
|
|
137
|
+
defaults: { verificationMode: 'full' }, steps: [{ id: 'one', type: 'start_app', appId: 'fixture.app' }, { id: 'two', type: 'start_app', appId: 'fixture.app' }] })
|
|
138
|
+
assert.equal(journey.success, true)
|
|
139
|
+
assert.equal(journey.steps[0].result.evidence.correlation_id, journey.evidence.correlation_id)
|
|
140
|
+
assert.equal(journey.steps[1].result.evidence.correlation_id, journey.evidence.correlation_id)
|
|
141
|
+
assert.notEqual(journey.steps[0].result.evidence.action_id, journey.steps[1].result.evidence.action_id)
|
|
142
|
+
assert.ok(journey.steps[0].result.evidence.observations.some((v: any) => v.freshness === 'stale'))
|
|
143
|
+
|
|
144
|
+
assert.ok(journey.steps[0].result.evidence.operation_completed_at_ms <= journey.steps[1].result.evidence.operation_started_at_ms)
|
|
145
|
+
const light = await call('run_journey', { platform: 'android', deviceId: 'fixture-android', responseMode: 'compact',
|
|
146
|
+
defaults: { verificationMode: 'light' }, steps: [
|
|
147
|
+
{ id: 'tap', type: 'tap', selector: { accessibility_id: 'target' } },
|
|
148
|
+
{ id: 'verify', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'target' } } }
|
|
149
|
+
] })
|
|
150
|
+
assert.equal(light.success, true)
|
|
151
|
+
const lightStep = light.steps[0].result
|
|
152
|
+
assert.ok(lightStep.evidence.verification_completed_at_ms)
|
|
153
|
+
const afterLight = await call('capture_screenshot', { platform: 'android', deviceId: 'fixture-android', actionRef: refFor(lightStep) })
|
|
154
|
+
assert.equal(afterLight.evidence.verification_completed_at_ms, lightStep.evidence.verification_completed_at_ms)
|
|
155
|
+
|
|
156
|
+
const started = await call('start_log_stream', { platform: 'android', deviceId: 'other', sessionId: 'evidence-owned', packageName: 'fixture.app' })
|
|
157
|
+
assert.equal(started.success, true)
|
|
158
|
+
let androidStream: any
|
|
159
|
+
for (let i = 0; i < 100; i++) {
|
|
160
|
+
androidStream = await call('read_log_stream', { platform: 'android', sessionId: 'evidence-owned', actionRef: refFor(a) })
|
|
161
|
+
if (androidStream.entries.length) break
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
163
|
+
}
|
|
164
|
+
assert.ok(androidStream.entries.length)
|
|
165
|
+
assert.equal(androidStream.evidence.device.id, 'other')
|
|
166
|
+
assert.equal(androidStream.evidence.association_reason, 'DEVICE_MISMATCH')
|
|
167
|
+
assert.match(readFileSync(events, 'utf8'), /-s other logcat/)
|
|
168
|
+
assert.equal((await call('stop_log_stream', { platform: 'android', sessionId: 'evidence-owned' })).success, true)
|
|
169
|
+
const streamFile = path.join(directory, 'stream.ndjson')
|
|
170
|
+
writeFileSync(streamFile, JSON.stringify({ message: 'stream-owned', _iso: '2020-01-01T00:00:00Z' }) + '\n')
|
|
171
|
+
_setIOSActiveLogStream('owned', streamFile, { platform: 'ios', id: 'fixture-ios' })
|
|
172
|
+
// Discovery returning a different device must not retarget this stream read.
|
|
173
|
+
_setDeviceListersForTests({ listIOSDevices: async () => { throw new Error('discovery must not run') } })
|
|
174
|
+
const stream = await call('read_log_stream', { platform: 'ios', sessionId: 'owned' })
|
|
175
|
+
assert.equal(stream.entries[0].message, 'stream-owned')
|
|
176
|
+
assert.equal(stream.evidence.device.id, 'fixture-ios')
|
|
177
|
+
assert.equal(stream.evidence.observations[0].device.id, 'fixture-ios')
|
|
178
|
+
for (const name of ['capture_screenshot', 'get_ui_tree', 'get_screen_fingerprint', 'capture_debug_snapshot', 'get_logs', 'read_log_stream']) {
|
|
179
|
+
assert.ok((toolDefinitions.find(v => v.name === name)!.inputSchema.properties as any).actionRef)
|
|
180
|
+
}
|
|
181
|
+
assert.match(readFileSync(events, 'utf8'), /shell input tap/)
|
|
182
|
+
assert.match(readFileSync(events, 'utf8'), /ui tap/)
|
|
183
|
+
} finally {
|
|
184
|
+
await new AndroidObserve().stopLogStream('evidence-owned')
|
|
185
|
+
rmSync(path.join(tmpdir(), 'mobile-debug-log-evidence-owned.ndjson'), { force: true })
|
|
186
|
+
_clearIOSActiveLogStream('owned')
|
|
187
|
+
for (const restore of originals) restore()
|
|
188
|
+
_resetDeviceListersForTests()
|
|
189
|
+
for (const [key, value] of Object.entries(env)) { if (value === undefined) delete process.env[key]; else process.env[key] = value }
|
|
190
|
+
rmSync(directory, { recursive: true, force: true })
|
|
191
|
+
}
|
|
192
|
+
console.log('Correlated MCP response, native dispatch, snapshot, journey and stream contracts: PASS')
|
|
193
|
+
}
|
|
194
|
+
run().catch(error => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'
|
|
3
|
+
import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
|
|
4
|
+
import { ToolsObserve, AndroidObserve } from '../../../src/observe/index.js'
|
|
5
|
+
import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
|
|
6
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests, withResolvedDevice } from '../../../src/utils/resolve-device.js'
|
|
7
|
+
import { runWithBudget } from '../../../src/utils/operation-budget.js'
|
|
8
|
+
|
|
9
|
+
async function run() {
|
|
10
|
+
const originalTree = ToolsObserve.getUITreeHandler
|
|
11
|
+
const originalNative = AndroidObserve.prototype.getUITree
|
|
12
|
+
const device = { platform: 'android', id: 'fixture', model: 'Fixture', osVersion: '16', simulator: true }
|
|
13
|
+
let reads = 0
|
|
14
|
+
let mode = 'stable'
|
|
15
|
+
let delayed = 0
|
|
16
|
+
let expireDuringRead: (() => void) | undefined
|
|
17
|
+
const tree = () => {
|
|
18
|
+
const label = mode === 'changing' ? `Item ${reads}` : mode === 'changeThenStable' && reads === 1 ? 'Before' : 'Ready'
|
|
19
|
+
const elements = [{ type: 'Button', text: label, contentDescription: 'target', visible: true, enabled: true, clickable: true, bounds: [0, 0, 50, 50] }]
|
|
20
|
+
if (mode === 'loading' || mode === 'loadingThenTimeout' || (mode === 'loaderThenReady' && reads <= 2)) elements.push({ type: 'android.widget.ProgressBar', text: '', contentDescription: '', visible: true, enabled: true, clickable: false, bounds: [0, 60, 20, 80] })
|
|
21
|
+
const body = { device, elements, screen: '', resolution: { width: 100, height: 200 } }
|
|
22
|
+
return { ...body, ...deriveSnapshotMetadata('android:fixture', body, 'ui_tree') }
|
|
23
|
+
}
|
|
24
|
+
ToolsObserve.getUITreeHandler = async () => {
|
|
25
|
+
reads++
|
|
26
|
+
if (delayed) await new Promise(resolve => setTimeout(resolve, delayed))
|
|
27
|
+
if (mode === 'loadingThenTimeout' && reads === 2) { expireDuringRead?.(); throw new Error('ACTION_TIMEOUT') }
|
|
28
|
+
if (mode === 'error') throw new Error('observation failed')
|
|
29
|
+
if (mode === 'disconnect') return { ...tree(), error: 'device fixture offline' } as any
|
|
30
|
+
return tree()
|
|
31
|
+
}
|
|
32
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [device] })
|
|
33
|
+
// Sample counts depend on elapsed time, not the host timer's wakeup precision.
|
|
34
|
+
// Advance both clocks exactly when a fixture/poll timer completes.
|
|
35
|
+
const originalDateNow = Date.now
|
|
36
|
+
const originalPerformanceNow = Object.getOwnPropertyDescriptor(performance, 'now')
|
|
37
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
38
|
+
const epoch = originalDateNow()
|
|
39
|
+
let elapsed = 0
|
|
40
|
+
Date.now = () => epoch + elapsed
|
|
41
|
+
Object.defineProperty(performance, 'now', { configurable: true, value: () => elapsed })
|
|
42
|
+
globalThis.setTimeout = ((callback: (...args: any[]) => void, delay = 0, ...args: any[]) => {
|
|
43
|
+
queueMicrotask(() => { elapsed += Math.max(0, Number(delay) || 0); callback(...args) })
|
|
44
|
+
return {} as ReturnType<typeof setTimeout>
|
|
45
|
+
}) as typeof setTimeout
|
|
46
|
+
const call = async (name: string, args: any = {}) => JSON.parse((await handleToolCall(name, { platform: 'android', deviceId: 'fixture', ...args })).content[0].text!)
|
|
47
|
+
try {
|
|
48
|
+
let result = await call('get_app_readiness', { stabilityMs: 100 })
|
|
49
|
+
assert.equal(result.readiness.state, 'settled')
|
|
50
|
+
assert.equal(result.success, true)
|
|
51
|
+
assert.equal(reads, 2)
|
|
52
|
+
mode = 'loading'; reads = 0
|
|
53
|
+
result = await call('get_app_readiness')
|
|
54
|
+
assert.equal(result.readiness.state, 'loading')
|
|
55
|
+
assert.equal(reads, 1)
|
|
56
|
+
mode = 'changing'; reads = 0
|
|
57
|
+
result = await call('get_app_readiness', { stabilityMs: 100 })
|
|
58
|
+
assert.equal(result.readiness.state, 'transitioning')
|
|
59
|
+
assert.equal(result.success, true)
|
|
60
|
+
mode = 'loaderThenReady'; reads = 0
|
|
61
|
+
result = await call('wait_for_app_ready', { stabilityMs: 100, pollIntervalMs: 100, timeoutMs: 1500 })
|
|
62
|
+
assert.equal(result.readiness.state, 'settled')
|
|
63
|
+
assert.equal(result.success, true)
|
|
64
|
+
assert.equal(reads, 4)
|
|
65
|
+
mode = 'loading'; reads = 0
|
|
66
|
+
result = await call('wait_for_app_ready', { timeoutMs: 200, pollIntervalMs: 100 })
|
|
67
|
+
assert.equal(result.failure_code, 'READINESS_TIMEOUT')
|
|
68
|
+
assert.equal(result.readiness.state, 'loading')
|
|
69
|
+
assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
70
|
+
assert.equal(result.readiness.capabilities.unresponsive, false)
|
|
71
|
+
mode = 'stable'; reads = 0; delayed = 150
|
|
72
|
+
result = await call('get_app_readiness', { timeoutMs: 100 })
|
|
73
|
+
assert.equal(result.success, false, 'late physical read cannot produce success')
|
|
74
|
+
assert.equal(result.readiness.state, 'unknown')
|
|
75
|
+
delayed = 0
|
|
76
|
+
mode = 'disconnect'; reads = 0
|
|
77
|
+
result = await call('wait_for_app_ready')
|
|
78
|
+
assert.equal(result.failure_code, 'DEVICE_UNAVAILABLE')
|
|
79
|
+
assert.equal(reads, 1)
|
|
80
|
+
mode = 'loading'; reads = 0
|
|
81
|
+
const ordinary = await call('wait_for_ui', { selector: { text: 'Ready' }, timeout_ms: 1500, poll_interval_ms: 100 })
|
|
82
|
+
assert.equal(ordinary.status, 'success', 'default wait keeps predicate-only semantics')
|
|
83
|
+
assert.equal(ordinary.readiness, undefined)
|
|
84
|
+
assert.equal(reads, 2)
|
|
85
|
+
reads = 0
|
|
86
|
+
const blocked = await call('wait_for_ui', { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 650, poll_interval_ms: 100 })
|
|
87
|
+
assert.equal(blocked.status, 'timeout')
|
|
88
|
+
assert.equal(blocked.readiness.state, 'loading')
|
|
89
|
+
assert.equal(blocked.observed.condition_satisfied, true, 'a readiness timeout must not pretend the visible target was absent')
|
|
90
|
+
assert.ok(blocked.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
91
|
+
// Force expiry inside a later acquisition rather than depending on timer rounding.
|
|
92
|
+
for (const tool of ['wait_for_ui', 'wait_for_app_ready']) {
|
|
93
|
+
const originalNow = Object.getOwnPropertyDescriptor(performance, 'now')
|
|
94
|
+
const nativeNow = performance.now.bind(performance)
|
|
95
|
+
let advance = 0
|
|
96
|
+
Object.defineProperty(performance, 'now', { configurable: true, value: () => nativeNow() + advance })
|
|
97
|
+
expireDuringRead = () => { advance += 2000 }
|
|
98
|
+
mode = 'loadingThenTimeout'; reads = 0
|
|
99
|
+
try {
|
|
100
|
+
const timedOut = await call(tool, tool === 'wait_for_ui'
|
|
101
|
+
? { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 1000, poll_interval_ms: 100 }
|
|
102
|
+
: { timeoutMs: 1000, pollIntervalMs: 100 })
|
|
103
|
+
assert.equal(reads, 2)
|
|
104
|
+
assert.equal(timedOut.readiness.state, 'loading', 'expiry retains the last valid loading evidence')
|
|
105
|
+
assert.ok(timedOut.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
106
|
+
assert.equal(timedOut.readiness.evidence.at(-1).code, 'NATIVE_LOADING_INDICATOR')
|
|
107
|
+
if (tool === 'wait_for_ui') assert.equal(timedOut.observed.condition_satisfied, true)
|
|
108
|
+
else assert.equal(timedOut.failure_code, 'READINESS_TIMEOUT')
|
|
109
|
+
} finally {
|
|
110
|
+
expireDuringRead = undefined
|
|
111
|
+
if (originalNow) Object.defineProperty(performance, 'now', originalNow)
|
|
112
|
+
else delete (performance as any).now
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
mode = 'loaderThenReady'; reads = 0
|
|
116
|
+
const ready = await call('wait_for_ui', { selector: { text: 'Ready' }, requireReady: true, timeout_ms: 2000, poll_interval_ms: 100 })
|
|
117
|
+
assert.equal(ready.status, 'success')
|
|
118
|
+
assert.equal(ready.readiness.state, 'settled')
|
|
119
|
+
assert.equal(reads, ready.readiness.sample_count, 'predicate and readiness share samples')
|
|
120
|
+
mode = 'changeThenStable'; reads = 0
|
|
121
|
+
const changed = await call('wait_for_ui_change', { requireReady: true, timeout_ms: 2500, stability_window_ms: 0 })
|
|
122
|
+
assert.equal(changed.success, true, JSON.stringify(changed))
|
|
123
|
+
assert.equal(changed.readiness.state, 'settled')
|
|
124
|
+
assert.equal(reads, changed.readiness.sample_count)
|
|
125
|
+
mode = 'stable'; reads = 0
|
|
126
|
+
result = await runWithBudget(100, () => call('wait_for_app_ready', { timeoutMs: 2000, stabilityMs: 500 }))
|
|
127
|
+
assert.equal(result.success, false, 'outer budget bounds the wait')
|
|
128
|
+
assert.ok(result.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
129
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => { await new Promise(resolve => setTimeout(resolve, 140)); return [] } })
|
|
130
|
+
const discoveryTimeout = await call('wait_for_app_ready', { timeoutMs: 100 })
|
|
131
|
+
assert.equal(discoveryTimeout.failure_code, 'READINESS_TIMEOUT', 'discovery deadline is not proof of a missing device')
|
|
132
|
+
assert.ok(discoveryTimeout.readiness.limitations.includes('DEADLINE_EXCEEDED'))
|
|
133
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [device] })
|
|
134
|
+
// The ordinary tree path adds single-sample metadata without extra acquisition.
|
|
135
|
+
ToolsObserve.getUITreeHandler = originalTree
|
|
136
|
+
let nativeReads = 0
|
|
137
|
+
AndroidObserve.prototype.getUITree = async () => { nativeReads++; return tree() as any }
|
|
138
|
+
for (const responseMode of ['debug', 'compact'] as const) {
|
|
139
|
+
const observed: any = await withResolvedDevice(device, () => ToolsObserve.getUITreeHandler({ platform: 'android', deviceId: 'fixture', responseMode }))
|
|
140
|
+
assert.equal(observed.readiness.state, 'unknown')
|
|
141
|
+
assert.equal(observed.readiness.sample_count, 1)
|
|
142
|
+
}
|
|
143
|
+
assert.equal(nativeReads, 2, 'one native read per public tree response')
|
|
144
|
+
const validator = new AjvJsonSchemaValidator()
|
|
145
|
+
for (const name of ['get_app_readiness', 'wait_for_app_ready']) {
|
|
146
|
+
const schema = validator.getValidator(toolDefinitions.find(tool => tool.name === name)!.inputSchema)
|
|
147
|
+
assert.equal(schema({ platform: 'android' }).valid, true)
|
|
148
|
+
for (const args of [{ platform: 'other' }, { platform: 'android', timeoutMs: 99 }, { platform: 'android', stabilityMs: 10001 }, { platform: 'android', extra: true }, { platform: 'android', deviceId: '' }]) {
|
|
149
|
+
assert.equal(schema(args).valid, false)
|
|
150
|
+
const result = JSON.parse((await handleToolCall(name, args)).content[0].text!)
|
|
151
|
+
assert.match(result.error.message, /INVALID_ARGUMENT/)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const name of ['wait_for_ui', 'wait_for_ui_change']) {
|
|
155
|
+
const bad = await call(name, { requireReady: 'yes' })
|
|
156
|
+
assert.match(bad.error.message, /INVALID_ARGUMENT/)
|
|
157
|
+
}
|
|
158
|
+
console.log('Readiness tools, schemas and opt-in waits: PASS')
|
|
159
|
+
} finally {
|
|
160
|
+
globalThis.setTimeout = originalSetTimeout
|
|
161
|
+
Date.now = originalDateNow
|
|
162
|
+
if (originalPerformanceNow) Object.defineProperty(performance, 'now', originalPerformanceNow)
|
|
163
|
+
else delete (performance as any).now
|
|
164
|
+
ToolsObserve.getUITreeHandler = originalTree
|
|
165
|
+
AndroidObserve.prototype.getUITree = originalNative
|
|
166
|
+
_resetDeviceListersForTests()
|
|
167
|
+
resetSnapshotMetadataForTests()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
3
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
4
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
5
|
+
import { remainingBudget } from '../../../src/utils/operation-budget.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const originalTree = ToolsObserve.getUITreeHandler
|
|
9
|
+
const originalTap = ToolsInteract.tapHandler
|
|
10
|
+
const tab = () => ({ bounds: [244, 1408, 476, 1528], clickable: true, visible: true, enabled: true, children: [1] })
|
|
11
|
+
const label = () => ({ text: 'GENER8', bounds: [330, 1489, 391, 1513], clickable: false, visible: true, enabled: true, parentId: 0 })
|
|
12
|
+
let elements: any[] = [tab(), label()]
|
|
13
|
+
let error: string | undefined
|
|
14
|
+
let observedBudget = 0
|
|
15
|
+
let reads = 0
|
|
16
|
+
const taps: Array<{ x: number; y: number }> = []
|
|
17
|
+
ToolsObserve.getUITreeHandler = async () => {
|
|
18
|
+
observedBudget = remainingBudget()
|
|
19
|
+
reads++
|
|
20
|
+
return { device: { platform: 'android', id: 'fixture' }, elements, captured_at_ms: Date.now(), error } as any
|
|
21
|
+
}
|
|
22
|
+
ToolsInteract.tapHandler = async args => {
|
|
23
|
+
taps.push({ x: args.x, y: args.y })
|
|
24
|
+
return { device: { platform: 'android', id: 'fixture' }, success: true } as any
|
|
25
|
+
}
|
|
26
|
+
const call = async (args: Record<string, unknown> = {}) => JSON.parse((await handleToolCall('tap_element', {
|
|
27
|
+
platform: 'android', deviceId: 'fixture', selector: { text: 'GENER8' }, responseMode: 'compact', verificationMode: 'none', ...args
|
|
28
|
+
})).content[0].text!)
|
|
29
|
+
try {
|
|
30
|
+
for (const waitFor of [undefined, {}, { condition: 'exists' }, { condition: 'visible' }, { condition: 'clickable' }]) {
|
|
31
|
+
reads = 0
|
|
32
|
+
const result = await call(waitFor ? { waitFor } : {})
|
|
33
|
+
assert.equal(result.success, true)
|
|
34
|
+
assert.deepEqual(taps.at(-1), { x: 360, y: 1468 })
|
|
35
|
+
assert.equal(reads, 1, 'Reuse the selector acquisition for dispatch resolution')
|
|
36
|
+
assert.ok(observedBudget > 1500 && observedBudget <= 5000, `One acquisition should use the action budget: ${observedBudget}`)
|
|
37
|
+
}
|
|
38
|
+
await call({ actionTimeoutMs: 12000 })
|
|
39
|
+
assert.ok(observedBudget > 10000 && observedBudget <= 12000)
|
|
40
|
+
await call({ actionTimeoutMs: 12000, waitFor: { condition: 'exists', timeoutMs: 700 } })
|
|
41
|
+
assert.ok(observedBudget > 0 && observedBudget <= 700)
|
|
42
|
+
const tapCount = taps.length
|
|
43
|
+
error = 'Failed to get UI tree: ACTION_TIMEOUT'
|
|
44
|
+
const timeout = await call()
|
|
45
|
+
assert.equal(timeout.failure_code, 'ACTION_TIMEOUT', 'Observation timeout must not claim that the label is missing')
|
|
46
|
+
assert.equal(timeout.evidence.dispatch_started_at_ms, null)
|
|
47
|
+
assert.equal(taps.length, tapCount)
|
|
48
|
+
error = undefined
|
|
49
|
+
|
|
50
|
+
for (const patch of [{ enabled: false }, { visible: false }]) {
|
|
51
|
+
for (const condition of ['exists', 'visible', 'clickable']) {
|
|
52
|
+
elements = [tab(), { ...label(), ...patch }]
|
|
53
|
+
// A short explicit wait bounds unsatisfied visible/clickable conditions.
|
|
54
|
+
const result = await call({ waitFor: { condition, timeoutMs: 100 } })
|
|
55
|
+
assert.equal(result.success, false)
|
|
56
|
+
assert.equal(taps.length, tapCount, 'Never bypass hidden/disabled labels through an enabled parent')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
elements = [{ ...tab(), enabled: false }, label()]
|
|
60
|
+
assert.equal((await call({ waitFor: { condition: 'exists' } })).failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
61
|
+
assert.equal(taps.length, tapCount)
|
|
62
|
+
elements = [label()]
|
|
63
|
+
assert.equal((await call({ waitFor: { condition: 'exists' } })).failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
64
|
+
assert.equal(taps.length, tapCount, 'A plain label without an actionable container remains untappable')
|
|
65
|
+
elements = [tab(), label(), { ...label(), parentId: 0 }]
|
|
66
|
+
assert.equal((await call()).failure_code, 'AMBIGUOUS_TARGET')
|
|
67
|
+
assert.equal(taps.length, tapCount)
|
|
68
|
+
// Standalone waits keep returning the matched leaf for exists; promotion is tap-only.
|
|
69
|
+
elements = [tab(), label()]
|
|
70
|
+
const wait = await ToolsInteract.waitForUIHandler({ selector: { text: 'GENER8' }, condition: 'exists', platform: 'android', singleObservation: true })
|
|
71
|
+
assert.equal(wait.element.index, 1)
|
|
72
|
+
} finally {
|
|
73
|
+
ToolsObserve.getUITreeHandler = originalTree
|
|
74
|
+
ToolsInteract.tapHandler = originalTap
|
|
75
|
+
ToolsInteract._resetResolvedUiElementsForTests()
|
|
76
|
+
}
|
|
77
|
+
console.log('Selector tap acquisition budget, executable ancestor, and rejection guards: PASS')
|
|
78
|
+
}
|
|
79
|
+
run().catch(error => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall, toolDefinitions } from '../../../src/server-core.js'
|
|
3
|
+
import { systemTraces } from '../../../src/observe/system-trace.js'
|
|
4
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
5
|
+
import { AndroidManage } from '../../../src/manage/index.js'
|
|
6
|
+
import { withEvidence, bindSystemTrace, bindEvidenceDevice } from '../../../src/observe/evidence.js'
|
|
7
|
+
|
|
8
|
+
const parse = (r: any) => JSON.parse(r.content[0].text)
|
|
9
|
+
async function run() {
|
|
10
|
+
const originalLink = systemTraces.link, originalStart = systemTraces.start, originalControl = systemTraces.control
|
|
11
|
+
const originalApp = AndroidManage.prototype.startApp
|
|
12
|
+
const id = '62a51510-c303-4a1b-af0e-f97cc976d740'
|
|
13
|
+
let dispatched = 0, starts = 0
|
|
14
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => [{ id: 'trace-device', platform: 'android', osVersion: '16', simulator: true, model: 'fixture' }] })
|
|
15
|
+
try {
|
|
16
|
+
for (const name of ['get_system_trace_capabilities', 'start_system_trace', 'get_system_trace_status', 'stop_system_trace', 'cancel_system_trace']) {
|
|
17
|
+
const tool = toolDefinitions.find(t => t.name === name)
|
|
18
|
+
assert.ok(tool); assert.equal(tool.inputSchema.additionalProperties, false)
|
|
19
|
+
}
|
|
20
|
+
assert.equal(toolDefinitions.find(t => t.name === 'run_journey').inputSchema.properties.systemTraceId.format, 'uuid')
|
|
21
|
+
for (const args of [ {}, { platform: 'android', extra: true }, { platform: 'android', expectedState: {} }, { platform: 'android', durationMs: 0 }, { platform: 'android', durationMs: 1000.5 }, { platform: 'android', durationMs: 120001 } ]) {
|
|
22
|
+
const r = await handleToolCall('start_system_trace', args)
|
|
23
|
+
assert.equal(r.isError, true); assert.equal(parse(r).error.code, 'INVALID_ARGUMENT')
|
|
24
|
+
}
|
|
25
|
+
systemTraces.start = async () => { starts++; throw new Error('must not run') }
|
|
26
|
+
const ios = await handleToolCall('start_system_trace', { platform: 'ios' })
|
|
27
|
+
assert.equal(parse(ios).error.code, 'UNSUPPORTED_PLATFORM'); assert.equal(starts, 0)
|
|
28
|
+
assert.equal(parse(await handleToolCall('get_system_trace_capabilities', { platform: 'ios' })).supported, false)
|
|
29
|
+
for (const name of ['stop_system_trace', 'cancel_system_trace', 'get_system_trace_status']) {
|
|
30
|
+
assert.equal(parse(await handleToolCall(name, { traceId: id, deviceId: 'wrong' })).error.code, 'INVALID_ARGUMENT')
|
|
31
|
+
assert.equal(parse(await handleToolCall(name, { traceId: id })).error.code, 'TRACE_NOT_FOUND')
|
|
32
|
+
}
|
|
33
|
+
AndroidManage.prototype.startApp = async () => { dispatched++; return { appStarted: true } as any }
|
|
34
|
+
const journey = { platform: 'android', deviceId: 'trace-device', systemTraceId: id, steps: [{ id: 'start', type: 'start_app', appId: 'com.android.settings', verificationMode: 'none' }] }
|
|
35
|
+
const rejected = await handleToolCall('run_journey', journey)
|
|
36
|
+
assert.equal(rejected.isError, true); assert.equal(dispatched, 0)
|
|
37
|
+
let correlation = ''
|
|
38
|
+
systemTraces.link = async (trace, device, cid) => {
|
|
39
|
+
assert.equal(trace, id); assert.equal(device.id, 'trace-device'); correlation = cid
|
|
40
|
+
return async () => ({ trace_id: trace, coverage: 'covered', clock_alignment: 'unknown' })
|
|
41
|
+
}
|
|
42
|
+
const result = parse(await handleToolCall('run_journey', journey))
|
|
43
|
+
assert.equal(result.success, true, JSON.stringify(result)); assert.equal(dispatched, 1)
|
|
44
|
+
assert.equal(result.evidence.trace_context.trace_id, id)
|
|
45
|
+
assert.equal(result.evidence.trace_context.supported, true)
|
|
46
|
+
assert.equal(result.evidence.correlation_id, correlation)
|
|
47
|
+
assert.equal(result.steps[0].result.evidence.trace_context.trace_id, id)
|
|
48
|
+
assert.equal(result.steps[0].result.evidence.correlation_id, correlation)
|
|
49
|
+
assert.equal(result.system_trace.coverage, 'covered')
|
|
50
|
+
const plain = parse(await handleToolCall('run_journey', { ...journey, systemTraceId: undefined }))
|
|
51
|
+
assert.deepEqual(plain.evidence.trace_context, { supported: false, trace_id: null, markers: [] })
|
|
52
|
+
const [linked, independent] = await Promise.all([
|
|
53
|
+
withEvidence({ journey: true }, async () => { bindSystemTrace(id); bindEvidenceDevice({ platform: 'android', id: 'trace-device' }); return withEvidence({ action: true }, async () => {}) }),
|
|
54
|
+
withEvidence({ journey: true }, async () => {})
|
|
55
|
+
])
|
|
56
|
+
assert.equal(linked.result.evidence.trace_context.trace_id, id)
|
|
57
|
+
assert.equal(independent.evidence.trace_context.trace_id, null)
|
|
58
|
+
console.log('system trace tool contracts and journey evidence: PASS')
|
|
59
|
+
} finally {
|
|
60
|
+
systemTraces.link = originalLink; systemTraces.start = originalStart; systemTraces.control = originalControl
|
|
61
|
+
AndroidManage.prototype.startApp = originalApp; _resetDeviceListersForTests()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -24,6 +24,8 @@ async function run() {
|
|
|
24
24
|
assert.equal(success.success, true)
|
|
25
25
|
assert.equal(tapCount, 1)
|
|
26
26
|
assert.equal(lastWait.condition, 'clickable')
|
|
27
|
+
assert.equal(lastWait.timeout_ms, 5000)
|
|
28
|
+
assert.equal(lastWait.resolveTapTarget, true)
|
|
27
29
|
assert.equal(lastWait.singleObservation, true)
|
|
28
30
|
assert.equal(lastWait.singleAttempt, true)
|
|
29
31
|
assert.equal(lastWait.rejectAmbiguous, true)
|