mobile-debug-mcp 0.30.2 → 0.32.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/README.md +50 -5
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +20 -11
- package/dist/observe/android.js +6 -1
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +7 -2
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +358 -55
- package/dist/server-core.js +1 -1
- package/dist/system/index.js +485 -48
- package/dist/system/ios.js +15 -7
- package/dist/utils/android/utils.js +4 -4
- package/dist/utils/cli/idb/check-idb.js +10 -3
- package/dist/utils/cli/idb/idb-helper.js +47 -13
- package/dist/utils/exec.js +4 -2
- package/dist/utils/ios/utils.js +10 -11
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +4 -4
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/specs/baseline-spec-v0.md +1 -1
- package/docs/specs-deltas/016-environment-tools.md +533 -0
- package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
- package/docs/tools/TOOLS.md +2 -0
- package/docs/tools/interact.md +30 -0
- package/docs/tools/manage.md +4 -2
- package/docs/tools/system.md +9 -2
- package/package.json +1 -1
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +19 -11
- package/src/observe/android.ts +5 -1
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +6 -2
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +324 -53
- package/src/server-core.ts +1 -1
- package/src/system/index.ts +572 -50
- package/src/system/ios.ts +14 -7
- package/src/types.ts +31 -2
- package/src/utils/android/utils.ts +3 -3
- package/src/utils/cli/idb/check-idb.ts +11 -2
- package/src/utils/cli/idb/idb-helper.ts +49 -12
- package/src/utils/exec.ts +4 -2
- package/src/utils/ios/utils.ts +9 -10
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
- package/test/fixtures/latency-journey-app/README.md +10 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
- package/test/unit/interact/tap_element.test.ts +1 -1
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +32 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +90 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
- package/test/unit/system/get_system_status.test.ts +3 -0
- package/test/unit/system/system_status.test.ts +40 -0
- package/test/unit/utils/idb_health.test.ts +74 -0
|
@@ -83,6 +83,12 @@ async function run() {
|
|
|
83
83
|
assert.ok(pass3, 'captureDebugSnapshot should return an empty logs array when includeLogs is false')
|
|
84
84
|
console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
|
|
85
85
|
|
|
86
|
+
let screenshotCalls = 0
|
|
87
|
+
;(ToolsObserve as any).captureScreenshotHandler = async function() { screenshotCalls++; return { screenshot: 'unexpected' } }
|
|
88
|
+
const res4: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false, includeScreenshot: false })
|
|
89
|
+
assert.strictEqual(screenshotCalls, 0, 'includeScreenshot=false must not invoke screenshot capture')
|
|
90
|
+
assert.strictEqual(res4.raw.screenshot, null)
|
|
91
|
+
|
|
86
92
|
} finally {
|
|
87
93
|
;(ToolsObserve as any).captureScreenshotHandler = origCaptureHandler
|
|
88
94
|
;(ToolsObserve as any).getCurrentScreenHandler = origGetCurrentHandler
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'assert'
|
|
2
|
-
import { deriveSnapshotMetadata, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
|
|
2
|
+
import { deriveSnapshotMetadata, getStateDelta, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
|
|
3
3
|
|
|
4
4
|
async function run() {
|
|
5
5
|
console.log('Starting snapshot_metadata unit tests...')
|
|
@@ -57,6 +57,25 @@ async function run() {
|
|
|
57
57
|
total_elements: 1
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
const delta = getStateDelta(deviceKey, 1, 2)
|
|
61
|
+
assert.ok(delta)
|
|
62
|
+
assert.strictEqual(delta!.added.length, 0)
|
|
63
|
+
assert.strictEqual(delta!.removed.length, 0)
|
|
64
|
+
assert.strictEqual(delta!.changed.length, 1)
|
|
65
|
+
assert.strictEqual(delta!.changed[0].text, 'Beta')
|
|
66
|
+
assert.strictEqual(delta!.truncated, false)
|
|
67
|
+
assert.strictEqual(getStateDelta(deviceKey, 99, 2), null)
|
|
68
|
+
|
|
69
|
+
for (let index = 3; index <= 10; index++) {
|
|
70
|
+
deriveSnapshotMetadata(deviceKey, {
|
|
71
|
+
screen: 'Home',
|
|
72
|
+
resolution: { width: 100, height: 200 },
|
|
73
|
+
elements: [{ text: `Revision ${index}`, stable_id: 'stable-row', visible: true }]
|
|
74
|
+
}, 'ui_tree')
|
|
75
|
+
}
|
|
76
|
+
assert.ok(getStateDelta(deviceKey, 3, 10), 'recent revisions remain available after pruning')
|
|
77
|
+
assert.strictEqual(getStateDelta(deviceKey, 2, 10), null, 'only the oldest revision is pruned')
|
|
78
|
+
|
|
60
79
|
resetSnapshotMetadataForTests()
|
|
61
80
|
console.log('snapshot_metadata unit tests passed')
|
|
62
81
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { deriveSnapshotMetadata, getStateDelta, getLatestStateDelta, resetSnapshotMetadataForTests } from '../../../src/observe/snapshot-metadata.js'
|
|
3
|
+
resetSnapshotMetadataForTests()
|
|
4
|
+
const tree = (text: string) => ({ elements: [{ stable_id: 'one', text }, { stable_id: 'unchanged', text: 'fixed' }] }) as any
|
|
5
|
+
const first = deriveSnapshotMetadata('android:test', tree('before'), 'test')
|
|
6
|
+
const second = deriveSnapshotMetadata('android:test', tree('after'), 'test')
|
|
7
|
+
const delta = getStateDelta('android:test', first.snapshot_revision, second.snapshot_revision)!
|
|
8
|
+
assert.equal(delta.changed.length, 1)
|
|
9
|
+
assert.equal(delta.changed[0].text, 'after')
|
|
10
|
+
deriveSnapshotMetadata('android:test', tree('after'), 'test')
|
|
11
|
+
assert.equal(getLatestStateDelta('android:test')!.changed.length, 0)
|
|
12
|
+
const large = { elements: Array.from({ length: 501 }, (_, i) => ({ stable_id: String(i), text: String(i) })) } as any
|
|
13
|
+
const third = deriveSnapshotMetadata('android:test', large, 'test')
|
|
14
|
+
assert.equal(getStateDelta('android:test', second.snapshot_revision, third.snapshot_revision)!.truncated, true)
|
|
15
|
+
assert.equal(getStateDelta('android:test', -1, third.snapshot_revision), null)
|
|
@@ -49,7 +49,7 @@ async function run() {
|
|
|
49
49
|
|
|
50
50
|
const tapElement = toolDefinitions.find((tool) => tool.name === 'tap_element')
|
|
51
51
|
assert(tapElement, 'tap_element should be registered')
|
|
52
|
-
assert.deepStrictEqual((tapElement as any).inputSchema.required, ['elementId'])
|
|
52
|
+
assert.deepStrictEqual((tapElement as any).inputSchema.oneOf, [{ required: ['elementId'], not: { required: ['selector'] } }, { required: ['selector'], not: { required: ['elementId'] } }])
|
|
53
53
|
assert.match((tapElement as any).description, /RESOLVE → ACT → WAIT \(if needed\) → EXPECT/)
|
|
54
54
|
assert.match((tapElement as any).description, /If needed, wait for transition using wait_for_\*/)
|
|
55
55
|
assert.match((tapElement as any).description, /Verify outcome using expect_\*/)
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { AndroidManage } from '../../../src/manage/android.js'
|
|
6
|
+
import { markDispatchStarted } from '../../../src/utils/operation-budget.js'
|
|
7
|
+
|
|
8
|
+
async function run() {
|
|
9
|
+
const treeOriginal = ToolsObserve.getUITreeHandler
|
|
10
|
+
const fingerprintOriginal = ToolsObserve.getScreenFingerprintHandler
|
|
11
|
+
const tapOriginal = ToolsInteract.tapHandler
|
|
12
|
+
const launchOriginal = AndroidManage.prototype.startApp
|
|
13
|
+
const device: any = { platform: 'android', id: 'fixture' }
|
|
14
|
+
let dispatched = false
|
|
15
|
+
let postReads = 0
|
|
16
|
+
let fingerprints = 0
|
|
17
|
+
ToolsObserve.getUITreeHandler = async () => {
|
|
18
|
+
if (dispatched) postReads++
|
|
19
|
+
return { device, elements: [{ text: 'Go', stable_id: 'go', bounds: [0, 0, 40, 40], visible: true, enabled: true, clickable: true }] } as any
|
|
20
|
+
}
|
|
21
|
+
ToolsObserve.getScreenFingerprintHandler = async () => { fingerprints++; return { fingerprint: 'fixture' } as any }
|
|
22
|
+
ToolsInteract.tapHandler = async () => { dispatched = true; return { success: true, device } as any }
|
|
23
|
+
AndroidManage.prototype.startApp = async () => { dispatched = true; return { appStarted: true, device } as any }
|
|
24
|
+
try {
|
|
25
|
+
for (const tool of ['tap_element', 'start_app']) {
|
|
26
|
+
for (const mode of ['none', 'light', 'full']) {
|
|
27
|
+
dispatched = false; postReads = 0; fingerprints = 0
|
|
28
|
+
const response = await handleToolCall(tool, { platform: 'android', deviceId: 'fixture', ...(tool === 'tap_element' ? { selector: { text: 'Go' } } : { appId: 'fixture' }), verificationMode: mode, responseMode: 'compact' })
|
|
29
|
+
const result = JSON.parse(response.content[0].text!)
|
|
30
|
+
assert.equal(result.success, true, JSON.stringify(result))
|
|
31
|
+
assert.equal(postReads, mode === 'light' ? 1 : 0)
|
|
32
|
+
assert.equal(fingerprints, mode === 'full' ? 2 : 0)
|
|
33
|
+
assert.equal(result.device, undefined)
|
|
34
|
+
assert.equal(result.trace, undefined)
|
|
35
|
+
assert.equal(result.dispatch_started, true)
|
|
36
|
+
assert.deepEqual(Object.keys(result.timing), ['total_ms'])
|
|
37
|
+
if (mode !== 'full') assert.equal(result.lifecycle_state, 'pending_verification')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ToolsObserve.getUITreeHandler = async () => { throw new Error('offline') }
|
|
41
|
+
const result = JSON.parse((await handleToolCall('start_app', { platform: 'android', appId: 'fixture', verificationMode: 'light', responseMode: 'compact' })).content[0].text!)
|
|
42
|
+
assert.equal(result.success, true)
|
|
43
|
+
assert.equal(result.verification.status, 'unavailable')
|
|
44
|
+
assert.equal(result.verification_diagnostic.retryable, true)
|
|
45
|
+
ToolsInteract.tapHandler = async () => { markDispatchStarted(); dispatched = true; return { success: false, error: 'ACTION_TIMEOUT', device } as any }
|
|
46
|
+
const timedOut = JSON.parse((await handleToolCall('tap', { platform: 'android', deviceId: 'fixture', x: 1, y: 1, verificationMode: 'none', responseMode: 'compact' })).content[0].text!)
|
|
47
|
+
assert.equal(timedOut.failure_code, 'ACTION_TIMEOUT')
|
|
48
|
+
assert.equal(timedOut.delivery_status, 'unknown')
|
|
49
|
+
assert.equal(timedOut.retryable, false)
|
|
50
|
+
AndroidManage.prototype.startApp = async () => ({ appStarted: false, device, error: 'launch failed', diagnostics: { runResult: { exitCode: 1, stdout: '', stderr: 'launch failed' } } } as any)
|
|
51
|
+
const failedLaunch = JSON.parse((await handleToolCall('start_app', { platform: 'android', appId: 'fixture' })).content[0].text!)
|
|
52
|
+
assert.equal(failedLaunch.details.diagnostics.runResult.exitCode, 1)
|
|
53
|
+
} finally {
|
|
54
|
+
ToolsObserve.getUITreeHandler = treeOriginal
|
|
55
|
+
ToolsObserve.getScreenFingerprintHandler = fingerprintOriginal
|
|
56
|
+
ToolsInteract.tapHandler = tapOriginal
|
|
57
|
+
AndroidManage.prototype.startApp = launchOriginal
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { runWithBudget, remainingBudget, markDispatchStarted } from '../../../src/utils/operation-budget.js'
|
|
3
|
+
import { execCmd } from '../../../src/utils/exec.js'
|
|
4
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
5
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
await runWithBudget(100, async budget => {
|
|
9
|
+
assert.equal(budget.dispatchStarted, false)
|
|
10
|
+
markDispatchStarted()
|
|
11
|
+
assert.equal(budget.dispatchStarted, true)
|
|
12
|
+
const response = await execCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'])
|
|
13
|
+
assert.equal(response.stderr, 'ACTION_TIMEOUT')
|
|
14
|
+
assert.throws(() => remainingBudget(), /ACTION_TIMEOUT/)
|
|
15
|
+
})
|
|
16
|
+
await runWithBudget(1, async budget => {
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
18
|
+
assert.throws(() => markDispatchStarted(), /ACTION_TIMEOUT/)
|
|
19
|
+
assert.equal(budget.dispatchStarted, false)
|
|
20
|
+
})
|
|
21
|
+
const originalTree = ToolsObserve.getUITreeHandler
|
|
22
|
+
ToolsObserve.getUITreeHandler = async () => { throw new Error('transient observation error') }
|
|
23
|
+
try {
|
|
24
|
+
await assert.rejects(
|
|
25
|
+
() => runWithBudget(30, () => ToolsInteract.waitForUIHandler({ selector: { text: 'missing' }, timeout_ms: 1000, poll_interval_ms: 5, platform: 'android', deviceId: 'fixture' })),
|
|
26
|
+
/ACTION_TIMEOUT/
|
|
27
|
+
)
|
|
28
|
+
} finally {
|
|
29
|
+
ToolsObserve.getUITreeHandler = originalTree
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -37,7 +37,7 @@ async function run() {
|
|
|
37
37
|
|
|
38
38
|
const missingBuildResponse = await handleToolCall('build_app', { projectPath: '/tmp/project' })
|
|
39
39
|
const missingBuildPayload = JSON.parse((missingBuildResponse as any).content[0].text)
|
|
40
|
-
assert.deepStrictEqual(missingBuildPayload, {
|
|
40
|
+
assert.deepStrictEqual({ error: missingBuildPayload.error }, {
|
|
41
41
|
error: {
|
|
42
42
|
tool: 'build_app',
|
|
43
43
|
message: 'Missing or invalid string argument: platform'
|
|
@@ -236,7 +236,7 @@ async function run() {
|
|
|
236
236
|
const failingTapResponse = await handleToolCall('tap', { platform: 'android', x: 1, y: 2 })
|
|
237
237
|
assert.strictEqual((failingTapResponse as any).content.length, 1)
|
|
238
238
|
const failingTapPayload = JSON.parse((failingTapResponse as any).content[0].text)
|
|
239
|
-
assert.deepStrictEqual(failingTapPayload, {
|
|
239
|
+
assert.deepStrictEqual({ error: failingTapPayload.error }, {
|
|
240
240
|
error: {
|
|
241
241
|
tool: 'tap',
|
|
242
242
|
message: 'boom'
|
|
@@ -255,7 +255,7 @@ async function run() {
|
|
|
255
255
|
|
|
256
256
|
const missingArgResponse = await handleToolCall('tap', { platform: 'android', x: 1 })
|
|
257
257
|
const missingArgPayload = JSON.parse((missingArgResponse as any).content[0].text)
|
|
258
|
-
assert.deepStrictEqual(missingArgPayload, {
|
|
258
|
+
assert.deepStrictEqual({ error: missingArgPayload.error }, {
|
|
259
259
|
error: {
|
|
260
260
|
tool: 'tap',
|
|
261
261
|
message: 'Missing or invalid number argument: y'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { _setDeviceListersForTests, _resetDeviceListersForTests } from '../../../src/utils/resolve-device.js'
|
|
2
|
+
import assert from 'assert'
|
|
3
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
4
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
5
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const originalWait = (ToolsInteract as any).waitForUIHandler
|
|
9
|
+
const originalExpectVisible = (ToolsInteract as any).expectElementVisibleHandler
|
|
10
|
+
const originalTap = (ToolsInteract as any).tapElementHandler
|
|
11
|
+
const originalTree = (ToolsObserve as any).getUITreeHandler
|
|
12
|
+
let resolutions = 0
|
|
13
|
+
_setDeviceListersForTests({ listAndroidDevices: async () => { resolutions++; return [{ id: 'fixture', platform: 'android' } as any] } })
|
|
14
|
+
try {
|
|
15
|
+
const calls: string[] = []
|
|
16
|
+
;(ToolsInteract as any).waitForUIHandler = async () => {
|
|
17
|
+
calls.push('wait')
|
|
18
|
+
return { status: 'success', element: { elementId: 'ready' } }
|
|
19
|
+
}
|
|
20
|
+
;(ToolsInteract as any).expectElementVisibleHandler = async () => {
|
|
21
|
+
calls.push('assert')
|
|
22
|
+
return { success: false, failure_code: 'TIMEOUT' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const response: any = await handleToolCall('run_journey', {
|
|
26
|
+
platform: 'android',
|
|
27
|
+
steps: [
|
|
28
|
+
{ id: 'wait-ready', type: 'wait', selector: { text: 'Ready' } },
|
|
29
|
+
{ id: 'assert-ready', type: 'assert', assertion: { kind: 'element_visible', selector: { text: 'Ready' } } },
|
|
30
|
+
{ id: 'not-run', type: 'wait', selector: { text: 'Later' } }
|
|
31
|
+
]
|
|
32
|
+
})
|
|
33
|
+
const result = JSON.parse(response.content[0].text)
|
|
34
|
+
assert.strictEqual(resolutions, 1)
|
|
35
|
+
assert.deepStrictEqual(calls, ['wait', 'assert'])
|
|
36
|
+
await handleToolCall('run_journey', { platform: 'android', steps: [{ id: 'valid', type: 'wait', selector: { text: 'Ready' } }, { id: 'bad', type: 'tap' }] })
|
|
37
|
+
assert.strictEqual(resolutions, 1, 'Invalid later steps must fail before device work')
|
|
38
|
+
assert.deepStrictEqual(calls, ['wait', 'assert'])
|
|
39
|
+
assert.strictEqual(result.success, false)
|
|
40
|
+
assert.strictEqual(result.stopped_at_step_id, 'assert-ready')
|
|
41
|
+
assert.strictEqual(result.steps[0].status, 'passed')
|
|
42
|
+
assert.strictEqual(result.steps[1].status, 'failed')
|
|
43
|
+
assert.deepStrictEqual(result.steps[2], {
|
|
44
|
+
id: 'not-run', type: 'wait', status: 'not_run', success: false,
|
|
45
|
+
reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
let postActionReads = 0
|
|
49
|
+
;(ToolsObserve as any).getUITreeHandler = async () => { postActionReads++; throw new Error('Unexpected duplicate observation') }
|
|
50
|
+
;(ToolsInteract as any).waitForUIHandler = async () => ({ status: 'success', element: { elementId: 'target' }, _tree: {} })
|
|
51
|
+
;(ToolsInteract as any).tapElementHandler = async (args: any) => {
|
|
52
|
+
assert.strictEqual(args.verificationMode, 'none')
|
|
53
|
+
return { success: true, action_id: 'tap', action_type: 'tap_element' }
|
|
54
|
+
}
|
|
55
|
+
;(ToolsInteract as any).expectElementVisibleHandler = async () => ({ success: true, observed: { matched_count: 1 } })
|
|
56
|
+
const lightResponse: any = await handleToolCall('run_journey', {
|
|
57
|
+
platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
|
|
58
|
+
steps: [
|
|
59
|
+
{ id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
|
|
60
|
+
{ id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
|
|
61
|
+
]
|
|
62
|
+
})
|
|
63
|
+
const light = JSON.parse(lightResponse.content[0].text)
|
|
64
|
+
assert.strictEqual(light.success, true)
|
|
65
|
+
assert.deepStrictEqual(Object.keys(light.timing), ['total_ms'])
|
|
66
|
+
assert.deepStrictEqual(light.steps[0].result.verification, { mode: 'light', status: 'observed' })
|
|
67
|
+
assert.strictEqual(light.steps[0].result.verification_diagnostic, undefined)
|
|
68
|
+
assert.strictEqual(postActionReads, 0, 'The following assertion supplies the fresh observation')
|
|
69
|
+
;(ToolsInteract as any).expectElementVisibleHandler = async () => ({ success: false, failure_code: 'TIMEOUT', observed: { matched_count: 0 } })
|
|
70
|
+
const failedAssertionResponse: any = await handleToolCall('run_journey', {
|
|
71
|
+
platform: 'android', responseMode: 'compact', defaults: { verificationMode: 'light' },
|
|
72
|
+
steps: [
|
|
73
|
+
{ id: 'tap-target', type: 'tap', selector: { accessibility_id: 'target' } },
|
|
74
|
+
{ id: 'assert-result', type: 'assert', assertion: { kind: 'element_visible', selector: { accessibility_id: 'result' } } }
|
|
75
|
+
]
|
|
76
|
+
})
|
|
77
|
+
const failedAssertion = JSON.parse(failedAssertionResponse.content[0].text)
|
|
78
|
+
assert.strictEqual(failedAssertion.success, false)
|
|
79
|
+
assert.deepStrictEqual(failedAssertion.steps[0].result.verification, { mode: 'light', status: 'observed' })
|
|
80
|
+
assert.strictEqual(postActionReads, 0)
|
|
81
|
+
} finally {
|
|
82
|
+
_resetDeviceListersForTests()
|
|
83
|
+
;(ToolsInteract as any).waitForUIHandler = originalWait
|
|
84
|
+
;(ToolsInteract as any).expectElementVisibleHandler = originalExpectVisible
|
|
85
|
+
;(ToolsInteract as any).tapElementHandler = originalTap
|
|
86
|
+
;(ToolsObserve as any).getUITreeHandler = originalTree
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
run().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
3
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
const originalWait = ToolsInteract.waitForUIHandler
|
|
7
|
+
const originalTap = ToolsInteract.tapElementHandler
|
|
8
|
+
let tapCount = 0
|
|
9
|
+
let lastWait: any
|
|
10
|
+
let resolution: any
|
|
11
|
+
ToolsInteract.waitForUIHandler = async (args: any) => { lastWait = args; return resolution }
|
|
12
|
+
ToolsInteract.tapElementHandler = async (args: any) => {
|
|
13
|
+
tapCount++
|
|
14
|
+
assert.equal(args.elementId, 'selected')
|
|
15
|
+
assert.deepEqual(args.freshTree, { elements: [] })
|
|
16
|
+
return { action_id: 'tap', action_type: 'tap_element', success: true, lifecycle_state: 'pending_verification' } as any
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const selector = { accessibility_id: 'save' }
|
|
20
|
+
resolution = { status: 'success', element: { elementId: 'selected' }, _tree: { elements: [] } }
|
|
21
|
+
const success = JSON.parse((await handleToolCall('tap_element', {
|
|
22
|
+
selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
|
|
23
|
+
})).content[0].text!)
|
|
24
|
+
assert.equal(success.success, true)
|
|
25
|
+
assert.equal(tapCount, 1)
|
|
26
|
+
assert.equal(lastWait.condition, 'clickable')
|
|
27
|
+
assert.equal(lastWait.singleObservation, true)
|
|
28
|
+
assert.equal(lastWait.singleAttempt, true)
|
|
29
|
+
assert.equal(lastWait.rejectAmbiguous, true)
|
|
30
|
+
|
|
31
|
+
await handleToolCall('tap_element', {
|
|
32
|
+
selector, waitFor: { condition: 'visible', timeoutMs: 700, pollIntervalMs: 80 },
|
|
33
|
+
platform: 'android', verificationMode: 'none', responseMode: 'compact'
|
|
34
|
+
})
|
|
35
|
+
assert.equal(lastWait.condition, 'visible')
|
|
36
|
+
assert.equal(lastWait.timeout_ms, 700)
|
|
37
|
+
assert.equal(lastWait.poll_interval_ms, 80)
|
|
38
|
+
assert.equal(lastWait.singleAttempt, false)
|
|
39
|
+
assert.equal(lastWait.rejectAmbiguous, false)
|
|
40
|
+
assert.equal(tapCount, 2)
|
|
41
|
+
|
|
42
|
+
resolution = { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
|
|
43
|
+
const ambiguous = JSON.parse((await handleToolCall('tap_element', {
|
|
44
|
+
selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
|
|
45
|
+
})).content[0].text!)
|
|
46
|
+
assert.equal(ambiguous.failure_code, 'AMBIGUOUS_TARGET')
|
|
47
|
+
assert.equal(tapCount, 2)
|
|
48
|
+
|
|
49
|
+
resolution = { status: 'timeout', error: { code: 'ELEMENT_NOT_FOUND' } }
|
|
50
|
+
const missing = JSON.parse((await handleToolCall('tap_element', {
|
|
51
|
+
selector, platform: 'android', verificationMode: 'none', responseMode: 'compact'
|
|
52
|
+
})).content[0].text!)
|
|
53
|
+
assert.equal(missing.failure_code, 'ELEMENT_NOT_FOUND')
|
|
54
|
+
assert.equal(missing.dispatch_started, false)
|
|
55
|
+
assert.equal(missing.retryable, true)
|
|
56
|
+
assert.equal(tapCount, 2)
|
|
57
|
+
|
|
58
|
+
const invalid = await handleToolCall('tap_element', {
|
|
59
|
+
elementId: 'old', selector, waitFor: {}, platform: 'android'
|
|
60
|
+
})
|
|
61
|
+
assert.match(JSON.parse(invalid.content[0].text!).error.message, /Invalid input/)
|
|
62
|
+
assert.equal(tapCount, 2)
|
|
63
|
+
} finally {
|
|
64
|
+
ToolsInteract.waitForUIHandler = originalWait
|
|
65
|
+
ToolsInteract.tapElementHandler = originalTap
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
run().catch(error => { console.error(error); process.exitCode = 1 })
|
|
@@ -9,6 +9,9 @@ async function run() {
|
|
|
9
9
|
assert(typeof (payload as any).status === 'string')
|
|
10
10
|
assert(Array.isArray(payload.issues))
|
|
11
11
|
assert((payload as any).summary && typeof (payload as any).summary.overall === 'string')
|
|
12
|
+
assert((payload as any).host && typeof (payload as any).host.os === 'string')
|
|
13
|
+
assert((payload as any).android && typeof (payload as any).android.status === 'string')
|
|
14
|
+
assert((payload as any).ios && typeof (payload as any).ios.status === 'string')
|
|
12
15
|
|
|
13
16
|
const adb = ensureAdbAvailable()
|
|
14
17
|
assert(adb && typeof adb.ok === 'boolean')
|
|
@@ -74,6 +74,7 @@ async function run() {
|
|
|
74
74
|
XCRUN_VERSION_OUTPUT: process.env.XCRUN_VERSION_OUTPUT,
|
|
75
75
|
XCRUN_VERSION_STATUS: process.env.XCRUN_VERSION_STATUS,
|
|
76
76
|
SIMCTL_LIST_OUTPUT: process.env.SIMCTL_LIST_OUTPUT,
|
|
77
|
+
MOBILE_DEBUG_MCP_HOST_OS: process.env.MOBILE_DEBUG_MCP_HOST_OS,
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
try {
|
|
@@ -82,6 +83,7 @@ async function run() {
|
|
|
82
83
|
process.env.XCRUN_PATH = xcrunPath
|
|
83
84
|
|
|
84
85
|
setScenario({
|
|
86
|
+
MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
|
|
85
87
|
ADB_VERSION_STATUS: '0',
|
|
86
88
|
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
87
89
|
ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
|
|
@@ -94,12 +96,16 @@ async function run() {
|
|
|
94
96
|
const healthy = await systemStatus.getSystemStatus()
|
|
95
97
|
assert.strictEqual(healthy.success, true)
|
|
96
98
|
assert.strictEqual((healthy as any).status, 'ready')
|
|
99
|
+
assert.strictEqual((healthy as any).host.supported, true)
|
|
97
100
|
assert.strictEqual(healthy.adbAvailable, true)
|
|
98
101
|
assert.strictEqual(typeof healthy.adbVersion, 'string')
|
|
102
|
+
assert.strictEqual((healthy as any).android.status, 'ready')
|
|
103
|
+
assert.strictEqual((healthy as any).ios.status, 'ready')
|
|
99
104
|
assert.strictEqual((healthy as any).summary.android.ready, true)
|
|
100
105
|
assert.strictEqual(typeof (healthy as any).summary.ios.summary, 'string')
|
|
101
106
|
|
|
102
107
|
setScenario({
|
|
108
|
+
MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
|
|
103
109
|
ADB_VERSION_STATUS: '1',
|
|
104
110
|
ADB_VERSION_OUTPUT: 'not found',
|
|
105
111
|
ADB_DEVICES_OUTPUT: 'List of devices attached\n',
|
|
@@ -112,6 +118,7 @@ async function run() {
|
|
|
112
118
|
assert(missingAdb.issues.some((issue: string) => issue.includes('ADB')))
|
|
113
119
|
|
|
114
120
|
setScenario({
|
|
121
|
+
MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
|
|
115
122
|
ADB_VERSION_STATUS: '0',
|
|
116
123
|
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
117
124
|
ADB_DEVICES_OUTPUT: 'List of devices attached\nserial1\tunauthorized\nserial2\toffline\n',
|
|
@@ -125,6 +132,7 @@ async function run() {
|
|
|
125
132
|
assert(unauthorized.issues.some((issue: string) => issue.includes('offline')))
|
|
126
133
|
|
|
127
134
|
setScenario({
|
|
135
|
+
MOBILE_DEBUG_MCP_HOST_OS: 'darwin',
|
|
128
136
|
ADB_VERSION_STATUS: '0',
|
|
129
137
|
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
130
138
|
ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
|
|
@@ -136,6 +144,38 @@ async function run() {
|
|
|
136
144
|
assert.strictEqual(missingXcrun.adbAvailable, true)
|
|
137
145
|
assert.strictEqual(Array.isArray(missingXcrun.issues), true)
|
|
138
146
|
assert.strictEqual((missingXcrun as any).summary.ios.ready, false)
|
|
147
|
+
assert.strictEqual((missingXcrun as any).ios.status, 'unavailable')
|
|
148
|
+
|
|
149
|
+
setScenario({
|
|
150
|
+
MOBILE_DEBUG_MCP_HOST_OS: 'win32',
|
|
151
|
+
ADB_VERSION_STATUS: '0',
|
|
152
|
+
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
153
|
+
ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
|
|
154
|
+
XCRUN_VERSION_STATUS: '1',
|
|
155
|
+
XCRUN_VERSION_OUTPUT: 'not found',
|
|
156
|
+
})
|
|
157
|
+
const windowsHost = await systemStatus.getSystemStatus()
|
|
158
|
+
assert.strictEqual((windowsHost as any).host.os, 'windows')
|
|
159
|
+
assert.strictEqual((windowsHost as any).host.supported, true)
|
|
160
|
+
assert.strictEqual((windowsHost as any).ios.status, 'unsupported')
|
|
161
|
+
assert.deepStrictEqual((windowsHost as any).ios.tools, {})
|
|
162
|
+
assert.deepStrictEqual((windowsHost as any).ios.providers, {})
|
|
163
|
+
assert.deepStrictEqual((windowsHost as any).ios.capabilities, {})
|
|
164
|
+
assert.deepStrictEqual((windowsHost as any).ios.devices, [])
|
|
165
|
+
assert.strictEqual((windowsHost as any).ios.failures[0].code, 'PLATFORM_UNAVAILABLE')
|
|
166
|
+
assert.strictEqual((windowsHost as any).summary.ios.ready, false)
|
|
167
|
+
|
|
168
|
+
setScenario({ MOBILE_DEBUG_MCP_HOST_OS: 'freebsd' })
|
|
169
|
+
const unsupported = await systemStatus.getSystemStatus()
|
|
170
|
+
assert.strictEqual((unsupported as any).host.os, 'freebsd')
|
|
171
|
+
assert.strictEqual((unsupported as any).host.supported, false)
|
|
172
|
+
assert.strictEqual((unsupported as any).android.status, 'unsupported')
|
|
173
|
+
assert.strictEqual((unsupported as any).ios.status, 'unsupported')
|
|
174
|
+
assert.strictEqual((unsupported as any).android.failures[0].code, 'HOST_OS_UNSUPPORTED')
|
|
175
|
+
assert.strictEqual((unsupported as any).ios.failures[0].code, 'HOST_OS_UNSUPPORTED')
|
|
176
|
+
assert.strictEqual(unsupported.adbAvailable, false)
|
|
177
|
+
assert.strictEqual(unsupported.iosAvailable, false)
|
|
178
|
+
assert.strictEqual((unsupported as any).summary.overall, 'blocked')
|
|
139
179
|
|
|
140
180
|
console.log('system_status checks passed')
|
|
141
181
|
} finally {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
|
|
6
|
+
import { isIDBInstalled, probeIdb } from '../../../src/utils/cli/idb/idb-helper.js'
|
|
7
|
+
|
|
8
|
+
async function run() {
|
|
9
|
+
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-idb-health-'))
|
|
10
|
+
const idbPath = path.join(binDir, 'idb')
|
|
11
|
+
const python3Path = path.join(binDir, 'python3')
|
|
12
|
+
const pythonPath = path.join(binDir, 'python')
|
|
13
|
+
const pythonLog = path.join(binDir, 'python.log')
|
|
14
|
+
const originalIdbPath = process.env.MCP_IDB_PATH
|
|
15
|
+
const originalPath = process.env.PATH
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await fs.writeFile(idbPath, `#!/bin/sh
|
|
19
|
+
if [ "$1" = "list-targets" ] && [ "$2" = "--json" ]; then
|
|
20
|
+
printf '{"targets":[]}\n'
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
23
|
+
if [ "$1" = "--version" ]; then
|
|
24
|
+
printf 'idb: error: unrecognized arguments: --version\n' >&2
|
|
25
|
+
exit 2
|
|
26
|
+
fi
|
|
27
|
+
exit 0
|
|
28
|
+
`, { mode: 0o755 })
|
|
29
|
+
|
|
30
|
+
await fs.writeFile(python3Path, `#!/bin/sh
|
|
31
|
+
printf 'python3 called\\n' >> "${pythonLog}"
|
|
32
|
+
exit 0
|
|
33
|
+
`, { mode: 0o755 })
|
|
34
|
+
|
|
35
|
+
await fs.writeFile(pythonPath, `#!/bin/sh
|
|
36
|
+
printf 'python called\\n' >> "${pythonLog}"
|
|
37
|
+
exit 0
|
|
38
|
+
`, { mode: 0o755 })
|
|
39
|
+
|
|
40
|
+
process.env.MCP_IDB_PATH = idbPath
|
|
41
|
+
process.env.PATH = `${binDir}${path.delimiter}${originalPath || ''}`
|
|
42
|
+
|
|
43
|
+
const probe = probeIdb(idbPath)
|
|
44
|
+
assert.strictEqual(probe.ok, true)
|
|
45
|
+
assert.strictEqual(isIDBInstalled(), true)
|
|
46
|
+
assert(probe.raw === null || typeof probe.raw === 'string')
|
|
47
|
+
|
|
48
|
+
await fs.rm(pythonLog, { force: true }).catch(() => {})
|
|
49
|
+
const failedIdbPath = path.join(binDir, 'idb-failing')
|
|
50
|
+
await fs.writeFile(failedIdbPath, `#!/bin/sh
|
|
51
|
+
if [ "$1" = "list-targets" ] && [ "$2" = "--json" ]; then
|
|
52
|
+
printf 'idb unavailable\\n' >&2
|
|
53
|
+
exit 2
|
|
54
|
+
fi
|
|
55
|
+
exit 2
|
|
56
|
+
`, { mode: 0o755 })
|
|
57
|
+
|
|
58
|
+
const failureProbe = probeIdb(failedIdbPath)
|
|
59
|
+
assert.strictEqual(failureProbe.ok, false)
|
|
60
|
+
assert.strictEqual(await fs.readFile(pythonLog, 'utf8').catch(() => ''), '')
|
|
61
|
+
|
|
62
|
+
console.log('idb health probe test passed')
|
|
63
|
+
} finally {
|
|
64
|
+
if (originalIdbPath === undefined) delete process.env.MCP_IDB_PATH
|
|
65
|
+
else process.env.MCP_IDB_PATH = originalIdbPath
|
|
66
|
+
|
|
67
|
+
if (originalPath === undefined) delete process.env.PATH
|
|
68
|
+
else process.env.PATH = originalPath
|
|
69
|
+
|
|
70
|
+
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
run().catch((error) => { console.error(error); process.exit(1) })
|