mobile-debug-mcp 0.21.5 → 0.22.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.md +74 -0
- package/README.md +24 -5
- package/dist/interact/index.js +220 -13
- package/dist/observe/ios.js +10 -3
- package/dist/server-core.js +707 -0
- package/dist/server.js +6 -693
- package/dist/utils/resolve-device.js +15 -3
- package/docs/CHANGELOG.md +6 -1
- package/docs/tools/interact.md +69 -30
- package/package.json +3 -3
- package/skills/README.md +35 -0
- package/skills/test-authoring/SKILL.md +57 -0
- package/skills/test-authoring/references/repo-test-layout.md +47 -0
- package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
- package/skills/test-authoring/references/test-quality-checklist.md +39 -0
- package/src/interact/index.ts +250 -13
- package/src/observe/ios.ts +12 -3
- package/src/server-core.ts +762 -0
- package/src/server.ts +8 -754
- package/src/types.ts +10 -1
- package/src/utils/resolve-device.ts +19 -3
- package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
- package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
- package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
- package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
- package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
- package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
- package/test/device/index.ts +52 -0
- package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
- package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
- package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
- package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
- package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
- package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
- package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
- package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
- package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
- package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
- package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
- package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
- package/test/unit/index.ts +47 -27
- package/test/unit/interact/handler_shapes.test.ts +55 -0
- package/test/unit/interact/tap_element.test.ts +170 -0
- package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
- package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
- package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
- package/test/unit/manage/handler_shapes.test.ts +43 -0
- package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
- package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
- package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
- package/test/unit/observe/ios-getlogs.test.ts +53 -0
- package/test/unit/observe/scroll_to_element.test.ts +127 -0
- package/test/unit/server/contract.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +93 -0
- package/test/unit/system/adb_version.test.ts +35 -0
- package/test/unit/system/get_system_status.test.ts +20 -0
- package/test/unit/system/system_status.test.ts +141 -0
- package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
- package/test/unit/utils/exec.test.ts +51 -0
- package/test/unit/utils/resolve_device.test.ts +63 -0
- package/tsconfig.json +2 -2
- package/test/interact/device/run-real-test.ts +0 -3
- package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
- package/test/interact/unit/wait_for_ui.test.ts +0 -76
- package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
- package/test/observe/device/wait_for_element_real.ts +0 -3
- package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
- package/test/observe/unit/ios-getlogs.test.ts +0 -67
- package/test/observe/unit/scroll_to_element.test.ts +0 -129
- package/test/observe/unit/wait_for_element_mock.ts +0 -2
- package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
- package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
- package/test/system/adb_version.test.ts +0 -25
- package/test/system/get_system_status.test.ts +0 -52
- package/test/system/system_status.test.ts +0 -109
- /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
- /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
- /package/test/{observe/unit → unit/observe}/logstream.test.ts +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AndroidInteract } from '../../../src/interact/index.js'
|
|
2
|
+
import assert from 'assert'
|
|
3
|
+
|
|
4
|
+
async function runTests() {
|
|
5
|
+
console.log = (...args: any[]) => { try { process.stdout.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n') } catch {} }
|
|
6
|
+
console.log('Starting tests for scroll_to_element...')
|
|
7
|
+
|
|
8
|
+
const ai = new AndroidInteract()
|
|
9
|
+
const origObserveGet = ai['observe'].getUITree
|
|
10
|
+
const origSwipe = ai.swipe
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
console.log('\nTest 1: Element found immediately')
|
|
14
|
+
;(ai['observe'] as any).getUITree = async () => ({
|
|
15
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
16
|
+
screen: '',
|
|
17
|
+
resolution: { width: 1080, height: 1920 },
|
|
18
|
+
elements: [{
|
|
19
|
+
text: 'Target',
|
|
20
|
+
type: 'Button',
|
|
21
|
+
contentDescription: null,
|
|
22
|
+
clickable: true,
|
|
23
|
+
enabled: true,
|
|
24
|
+
visible: true,
|
|
25
|
+
bounds: [0, 0, 100, 100],
|
|
26
|
+
resourceId: null
|
|
27
|
+
}]
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const res1 = await ai.scrollToElement({ text: 'Target' }, 'down', 5, 0.7, 'mock')
|
|
31
|
+
assert.strictEqual(res1.success, true, 'Element visible on first screen should be found immediately')
|
|
32
|
+
console.log('Result: PASS')
|
|
33
|
+
console.log('scrollsPerformed:', (res1 as any).scrollsPerformed)
|
|
34
|
+
|
|
35
|
+
console.log('\nTest 2: Element found after scrolling')
|
|
36
|
+
let calls = 0
|
|
37
|
+
;(ai['observe'] as any).getUITree = async () => {
|
|
38
|
+
calls++
|
|
39
|
+
if (calls < 3) {
|
|
40
|
+
return {
|
|
41
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
42
|
+
screen: '',
|
|
43
|
+
resolution: { width: 1080, height: 1920 },
|
|
44
|
+
elements: [{
|
|
45
|
+
text: `Placeholder ${calls}`,
|
|
46
|
+
type: 'TextView',
|
|
47
|
+
contentDescription: null,
|
|
48
|
+
clickable: false,
|
|
49
|
+
enabled: true,
|
|
50
|
+
visible: true,
|
|
51
|
+
bounds: [0, calls * 10, 100, calls * 10 + 20],
|
|
52
|
+
resourceId: `placeholder-${calls}`
|
|
53
|
+
}]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
59
|
+
screen: '',
|
|
60
|
+
resolution: { width: 1080, height: 1920 },
|
|
61
|
+
elements: [{
|
|
62
|
+
text: 'Target',
|
|
63
|
+
type: 'Button',
|
|
64
|
+
contentDescription: null,
|
|
65
|
+
clickable: true,
|
|
66
|
+
enabled: true,
|
|
67
|
+
visible: true,
|
|
68
|
+
bounds: [0, 0, 100, 100],
|
|
69
|
+
resourceId: null
|
|
70
|
+
}]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
;(ai as any).swipe = async () => ({ success: true })
|
|
74
|
+
|
|
75
|
+
const res2 = await ai.scrollToElement({ text: 'Target' }, 'down', 5, 0.7, 'mock')
|
|
76
|
+
assert.strictEqual(res2.success, true, 'Element found after scrolling should succeed')
|
|
77
|
+
assert.ok(calls >= 3, 'scroll_to_element should retry until the target appears')
|
|
78
|
+
console.log('Result: PASS')
|
|
79
|
+
console.log('calls:', calls)
|
|
80
|
+
|
|
81
|
+
console.log('\nTest 3: UI unchanged stops early')
|
|
82
|
+
;(ai['observe'] as any).getUITree = async () => ({
|
|
83
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
84
|
+
screen: '',
|
|
85
|
+
resolution: { width: 1080, height: 1920 },
|
|
86
|
+
elements: []
|
|
87
|
+
})
|
|
88
|
+
;(ai as any).swipe = async () => ({ success: true })
|
|
89
|
+
|
|
90
|
+
const res3 = await ai.scrollToElement({ text: 'Missing' }, 'down', 5, 0.7, 'mock')
|
|
91
|
+
assert.ok(res3.success === false && (res3 as any).scrollsPerformed === 1, 'Unchanged UI should stop early after the first unchanged scroll')
|
|
92
|
+
console.log('Result: PASS')
|
|
93
|
+
console.log('Reason:', (res3 as any).reason || JSON.stringify(res3))
|
|
94
|
+
|
|
95
|
+
console.log('\nTest 4: Offscreen element scrolls into view')
|
|
96
|
+
let swiped = false
|
|
97
|
+
let swipeCalled = 0
|
|
98
|
+
;(ai['observe'] as any).getUITree = async () => {
|
|
99
|
+
if (!swiped) {
|
|
100
|
+
return {
|
|
101
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
102
|
+
screen: '',
|
|
103
|
+
resolution: { width: 1080, height: 1920 },
|
|
104
|
+
elements: [{ text: null, type: 'android.view.View', resourceId: null, contentDescription: null, bounds: [0, 0, 1080, 200], visible: true }]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
110
|
+
screen: '',
|
|
111
|
+
resolution: { width: 1080, height: 1920 },
|
|
112
|
+
elements: [{ text: 'OffscreenTarget', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [100, 400, 300, 460], resourceId: null }]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
;(ai as any).swipe = async () => { swipeCalled++; swiped = true; return { success: true } }
|
|
116
|
+
|
|
117
|
+
const res4 = await ai.scrollToElement({ text: 'OffscreenTarget' }, 'down', 3, 0.7, 'mock')
|
|
118
|
+
assert.ok(res4 && (res4 as any).success === true && (res4 as any).scrollsPerformed === 1 && swipeCalled === 1, 'Offscreen target should be found after one swipe')
|
|
119
|
+
console.log('Result: PASS')
|
|
120
|
+
console.log(' success:', (res4 as any).success, 'scrollsPerformed:', (res4 as any).scrollsPerformed, 'swipeCalled:', swipeCalled)
|
|
121
|
+
} finally {
|
|
122
|
+
;(ai['observe'] as any).getUITree = origObserveGet
|
|
123
|
+
;(ai as any).swipe = origSwipe
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
runTests().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { handleToolCall, serverInfo, toolDefinitions } from '../../../src/server-core.js'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
const names = toolDefinitions.map((tool) => tool.name)
|
|
6
|
+
const uniqueNames = new Set(names)
|
|
7
|
+
|
|
8
|
+
assert.strictEqual(serverInfo.name, 'mobile-debug-mcp')
|
|
9
|
+
assert.strictEqual(names.length, uniqueNames.size, 'tool names should be unique')
|
|
10
|
+
assert(names.includes('wait_for_ui'))
|
|
11
|
+
assert(names.includes('capture_screenshot'))
|
|
12
|
+
assert(names.includes('get_ui_tree'))
|
|
13
|
+
assert(names.includes('tap_element'))
|
|
14
|
+
|
|
15
|
+
const waitForUI = toolDefinitions.find((tool) => tool.name === 'wait_for_ui')
|
|
16
|
+
assert(waitForUI, 'wait_for_ui should be registered')
|
|
17
|
+
assert.strictEqual((waitForUI as any).inputSchema.properties.timeout_ms.default, 60000)
|
|
18
|
+
assert.strictEqual((waitForUI as any).inputSchema.properties.condition.default, 'exists')
|
|
19
|
+
|
|
20
|
+
const captureDebugSnapshot = toolDefinitions.find((tool) => tool.name === 'capture_debug_snapshot')
|
|
21
|
+
assert(captureDebugSnapshot, 'capture_debug_snapshot should be registered')
|
|
22
|
+
assert.strictEqual((captureDebugSnapshot as any).inputSchema.properties.includeLogs.default, true)
|
|
23
|
+
assert.strictEqual((captureDebugSnapshot as any).inputSchema.properties.logLines.default, 200)
|
|
24
|
+
|
|
25
|
+
const startLogStream = toolDefinitions.find((tool) => tool.name === 'start_log_stream')
|
|
26
|
+
assert(startLogStream, 'start_log_stream should be registered')
|
|
27
|
+
assert.strictEqual((startLogStream as any).inputSchema.properties.platform.default, 'android')
|
|
28
|
+
|
|
29
|
+
const startApp = toolDefinitions.find((tool) => tool.name === 'start_app')
|
|
30
|
+
assert(startApp, 'start_app should be registered')
|
|
31
|
+
assert.deepStrictEqual((startApp as any).inputSchema.required, ['platform', 'appId'])
|
|
32
|
+
|
|
33
|
+
const tapElement = toolDefinitions.find((tool) => tool.name === 'tap_element')
|
|
34
|
+
assert(tapElement, 'tap_element should be registered')
|
|
35
|
+
assert.deepStrictEqual((tapElement as any).inputSchema.required, ['elementId'])
|
|
36
|
+
|
|
37
|
+
await assert.rejects(() => handleToolCall('unknown_tool'), /Unknown tool: unknown_tool/)
|
|
38
|
+
|
|
39
|
+
console.log('server contract tests passed')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
run().catch((error) => {
|
|
43
|
+
console.error(error)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { handleToolCall } from '../../../src/server-core.js'
|
|
3
|
+
import { ToolsManage } from '../../../src/manage/index.js'
|
|
4
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
5
|
+
import { ToolsObserve } from '../../../src/observe/index.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const originalInstallAppHandler = (ToolsManage as any).installAppHandler
|
|
9
|
+
const originalWaitForUIHandler = (ToolsInteract as any).waitForUIHandler
|
|
10
|
+
const originalTapElementHandler = (ToolsInteract as any).tapElementHandler
|
|
11
|
+
const originalCaptureScreenshotHandler = (ToolsObserve as any).captureScreenshotHandler
|
|
12
|
+
const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
;(ToolsManage as any).installAppHandler = async () => ({
|
|
16
|
+
device: { platform: 'android', id: 'emulator-5554', osVersion: '14', model: 'Pixel', simulator: true },
|
|
17
|
+
installed: true,
|
|
18
|
+
output: 'Success'
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const installResponse = await handleToolCall('install_app', { platform: 'android', projectType: 'native', appPath: '/tmp/app.apk' })
|
|
22
|
+
assert.strictEqual((installResponse as any).content.length, 1)
|
|
23
|
+
const installPayload = JSON.parse((installResponse as any).content[0].text)
|
|
24
|
+
assert.strictEqual(installPayload.installed, true)
|
|
25
|
+
assert.strictEqual(installPayload.output, 'Success')
|
|
26
|
+
assert.strictEqual(installPayload.device.id, 'emulator-5554')
|
|
27
|
+
|
|
28
|
+
;(ToolsInteract as any).waitForUIHandler = async () => ({
|
|
29
|
+
status: 'success',
|
|
30
|
+
matched: 1,
|
|
31
|
+
element: { text: 'Ready', bounds: [0, 0, 10, 10], index: 0, elementId: 'el_ready' },
|
|
32
|
+
metrics: { latency_ms: 12, poll_count: 1, attempts: 1 }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const waitForUIResponse = await handleToolCall('wait_for_ui', { selector: { text: 'Ready' } })
|
|
36
|
+
const waitForUIPayload = JSON.parse((waitForUIResponse as any).content[0].text)
|
|
37
|
+
assert.strictEqual(waitForUIPayload.status, 'success')
|
|
38
|
+
assert.strictEqual(waitForUIPayload.metrics.poll_count, 1)
|
|
39
|
+
assert.strictEqual(waitForUIPayload.element.text, 'Ready')
|
|
40
|
+
assert.strictEqual(waitForUIPayload.element.elementId, 'el_ready')
|
|
41
|
+
|
|
42
|
+
;(ToolsInteract as any).tapElementHandler = async () => ({
|
|
43
|
+
success: true,
|
|
44
|
+
elementId: 'el_ready',
|
|
45
|
+
action: 'tap'
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const tapElementResponse = await handleToolCall('tap_element', { elementId: 'el_ready' })
|
|
49
|
+
const tapElementPayload = JSON.parse((tapElementResponse as any).content[0].text)
|
|
50
|
+
assert.strictEqual(tapElementPayload.success, true)
|
|
51
|
+
assert.strictEqual(tapElementPayload.elementId, 'el_ready')
|
|
52
|
+
assert.strictEqual(tapElementPayload.action, 'tap')
|
|
53
|
+
|
|
54
|
+
;(ToolsObserve as any).captureScreenshotHandler = async () => ({
|
|
55
|
+
device: { platform: 'ios', id: 'booted', osVersion: '18.0', model: 'Simulator', simulator: true },
|
|
56
|
+
screenshot: Buffer.from('png-data').toString('base64'),
|
|
57
|
+
screenshot_mime: 'image/png',
|
|
58
|
+
resolution: { width: 390, height: 844 }
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const screenshotResponse = await handleToolCall('capture_screenshot', { platform: 'ios' })
|
|
62
|
+
assert.strictEqual((screenshotResponse as any).content.length, 2)
|
|
63
|
+
const screenshotMeta = JSON.parse((screenshotResponse as any).content[0].text)
|
|
64
|
+
assert.strictEqual((screenshotResponse as any).content[1].type, 'image')
|
|
65
|
+
assert.strictEqual((screenshotResponse as any).content[1].mimeType, 'image/png')
|
|
66
|
+
assert.strictEqual(screenshotMeta.result.resolution.width, 390)
|
|
67
|
+
|
|
68
|
+
;(ToolsObserve as any).getUITreeHandler = async () => ({
|
|
69
|
+
device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
|
|
70
|
+
resolution: { width: 1080, height: 2400 },
|
|
71
|
+
elements: [{ text: 'Login', depth: 0, center: { x: 50, y: 20 } }]
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const uiTreeResponse = await handleToolCall('get_ui_tree', { platform: 'android' })
|
|
75
|
+
const uiTreePayload = JSON.parse((uiTreeResponse as any).content[0].text)
|
|
76
|
+
assert.strictEqual(uiTreePayload.elements.length, 1)
|
|
77
|
+
assert.strictEqual(uiTreePayload.resolution.height, 2400)
|
|
78
|
+
assert.strictEqual(uiTreePayload.elements[0].text, 'Login')
|
|
79
|
+
|
|
80
|
+
console.log('server response-shape tests passed')
|
|
81
|
+
} finally {
|
|
82
|
+
;(ToolsManage as any).installAppHandler = originalInstallAppHandler
|
|
83
|
+
;(ToolsInteract as any).waitForUIHandler = originalWaitForUIHandler
|
|
84
|
+
;(ToolsInteract as any).tapElementHandler = originalTapElementHandler
|
|
85
|
+
;(ToolsObserve as any).captureScreenshotHandler = originalCaptureScreenshotHandler
|
|
86
|
+
;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
run().catch((error) => {
|
|
91
|
+
console.error(error)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import * as systemStatus from '../../../src/system/index.js'
|
|
6
|
+
|
|
7
|
+
async function run() {
|
|
8
|
+
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-system-adb-'))
|
|
9
|
+
const adbPath = path.join(binDir, 'adb')
|
|
10
|
+
const originalAdbPath = process.env.ADB_PATH
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await fs.writeFile(adbPath, `#!/bin/sh
|
|
14
|
+
if [ "$1" = "--version" ]; then
|
|
15
|
+
printf 'Android Debug Bridge version 1.0.41\nRevision 8f3b7\n'
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
if [ "$1" = "devices" ]; then
|
|
19
|
+
printf 'List of devices attached\n'
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
exit 0
|
|
23
|
+
`, { mode: 0o755 })
|
|
24
|
+
process.env.ADB_PATH = adbPath
|
|
25
|
+
const res = await systemStatus.getSystemStatus()
|
|
26
|
+
assert.strictEqual(res.adbVersion, 'Android Debug Bridge version 1.0.41')
|
|
27
|
+
console.log('adb version parsing test passed')
|
|
28
|
+
} finally {
|
|
29
|
+
if (originalAdbPath === undefined) delete process.env.ADB_PATH
|
|
30
|
+
else process.env.ADB_PATH = originalAdbPath
|
|
31
|
+
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
run().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ensureAdbAvailable } from '../../../src/utils/android/utils.js'
|
|
3
|
+
import { getXcrunCmd } from '../../../src/utils/ios/utils.js'
|
|
4
|
+
import { getSystemStatus } from '../../../src/system/index.js'
|
|
5
|
+
|
|
6
|
+
async function run() {
|
|
7
|
+
const payload = await getSystemStatus()
|
|
8
|
+
assert(typeof payload.success === 'boolean')
|
|
9
|
+
assert(Array.isArray(payload.issues))
|
|
10
|
+
|
|
11
|
+
const adb = ensureAdbAvailable()
|
|
12
|
+
assert(adb && typeof adb.ok === 'boolean')
|
|
13
|
+
|
|
14
|
+
const cmd = getXcrunCmd()
|
|
15
|
+
assert(typeof cmd === 'string')
|
|
16
|
+
|
|
17
|
+
console.log('get_system_status unit tests passed')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
run().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
|
|
6
|
+
import * as systemStatus from '../../../src/system/index.js'
|
|
7
|
+
|
|
8
|
+
async function writeFakeCommands(binDir: string) {
|
|
9
|
+
const adbPath = path.join(binDir, 'adb')
|
|
10
|
+
const xcrunPath = path.join(binDir, 'xcrun')
|
|
11
|
+
|
|
12
|
+
await fs.writeFile(adbPath, `#!/bin/sh
|
|
13
|
+
if [ "$1" = "--version" ]; then
|
|
14
|
+
if [ "\${ADB_VERSION_STATUS:-0}" != "0" ]; then
|
|
15
|
+
printf '%s' "\${ADB_VERSION_OUTPUT:-not found}" >&2
|
|
16
|
+
exit "\${ADB_VERSION_STATUS}"
|
|
17
|
+
fi
|
|
18
|
+
printf '%s' "\${ADB_VERSION_OUTPUT:-Android Debug Bridge version 8.1.0}"
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
if [ "$1" = "devices" ]; then
|
|
22
|
+
printf '%s' "\${ADB_DEVICES_OUTPUT:-List of devices attached}"
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
if [ "$1" = "logcat" ]; then
|
|
26
|
+
printf '%s' "\${ADB_LOGCAT_OUTPUT:-I/Tag: ok}"
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "path" ]; then
|
|
30
|
+
printf '%s' "\${ADB_PM_PATH_OUTPUT:-package:/data/app/com.example/base.apk}"
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
printf '%s' "\${ADB_DEFAULT_OUTPUT:-OK}"
|
|
34
|
+
exit 0
|
|
35
|
+
`, { mode: 0o755 })
|
|
36
|
+
|
|
37
|
+
await fs.writeFile(xcrunPath, `#!/bin/sh
|
|
38
|
+
if [ "$1" = "--version" ]; then
|
|
39
|
+
if [ "\${XCRUN_VERSION_STATUS:-0}" != "0" ]; then
|
|
40
|
+
printf '%s' "\${XCRUN_VERSION_OUTPUT:-not found}" >&2
|
|
41
|
+
exit "\${XCRUN_VERSION_STATUS}"
|
|
42
|
+
fi
|
|
43
|
+
printf '%s' "\${XCRUN_VERSION_OUTPUT:-xcrun version 123}"
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "booted" ] && [ "$5" = "--json" ]; then
|
|
47
|
+
printf '%s' "\${SIMCTL_LIST_OUTPUT:-{\"devices\":{}}}"
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
printf '%s' "\${XCRUN_DEFAULT_OUTPUT:-ok}"
|
|
51
|
+
exit 0
|
|
52
|
+
`, { mode: 0o755 })
|
|
53
|
+
|
|
54
|
+
return { adbPath, xcrunPath }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setScenario(env: Record<string, string | undefined>) {
|
|
58
|
+
for (const [key, value] of Object.entries(env)) {
|
|
59
|
+
if (value === undefined) delete process.env[key]
|
|
60
|
+
else process.env[key] = value
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function run() {
|
|
65
|
+
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-system-status-'))
|
|
66
|
+
const originalEnv = {
|
|
67
|
+
ADB_PATH: process.env.ADB_PATH,
|
|
68
|
+
XCRUN_PATH: process.env.XCRUN_PATH,
|
|
69
|
+
ADB_VERSION_OUTPUT: process.env.ADB_VERSION_OUTPUT,
|
|
70
|
+
ADB_VERSION_STATUS: process.env.ADB_VERSION_STATUS,
|
|
71
|
+
ADB_DEVICES_OUTPUT: process.env.ADB_DEVICES_OUTPUT,
|
|
72
|
+
ADB_LOGCAT_OUTPUT: process.env.ADB_LOGCAT_OUTPUT,
|
|
73
|
+
ADB_PM_PATH_OUTPUT: process.env.ADB_PM_PATH_OUTPUT,
|
|
74
|
+
XCRUN_VERSION_OUTPUT: process.env.XCRUN_VERSION_OUTPUT,
|
|
75
|
+
XCRUN_VERSION_STATUS: process.env.XCRUN_VERSION_STATUS,
|
|
76
|
+
SIMCTL_LIST_OUTPUT: process.env.SIMCTL_LIST_OUTPUT,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { adbPath, xcrunPath } = await writeFakeCommands(binDir)
|
|
81
|
+
process.env.ADB_PATH = adbPath
|
|
82
|
+
process.env.XCRUN_PATH = xcrunPath
|
|
83
|
+
|
|
84
|
+
setScenario({
|
|
85
|
+
ADB_VERSION_STATUS: '0',
|
|
86
|
+
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
87
|
+
ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
|
|
88
|
+
ADB_LOGCAT_OUTPUT: 'I/Tag: ok\n',
|
|
89
|
+
XCRUN_VERSION_STATUS: '0',
|
|
90
|
+
XCRUN_VERSION_OUTPUT: 'xcrun version 123\n',
|
|
91
|
+
SIMCTL_LIST_OUTPUT: JSON.stringify({ devices: { runtime: [{ state: 'Booted' }] } }),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const healthy = await systemStatus.getSystemStatus()
|
|
95
|
+
assert.strictEqual(healthy.success, true)
|
|
96
|
+
assert.strictEqual(healthy.adbAvailable, true)
|
|
97
|
+
assert.strictEqual(typeof healthy.adbVersion, 'string')
|
|
98
|
+
|
|
99
|
+
setScenario({
|
|
100
|
+
ADB_VERSION_STATUS: '1',
|
|
101
|
+
ADB_VERSION_OUTPUT: 'not found',
|
|
102
|
+
ADB_DEVICES_OUTPUT: 'List of devices attached\n',
|
|
103
|
+
XCRUN_VERSION_STATUS: '0',
|
|
104
|
+
XCRUN_VERSION_OUTPUT: 'xcrun version\n',
|
|
105
|
+
})
|
|
106
|
+
const missingAdb = await systemStatus.getSystemStatus()
|
|
107
|
+
assert.strictEqual(missingAdb.success, false)
|
|
108
|
+
assert(missingAdb.issues.some((issue: string) => issue.includes('ADB')))
|
|
109
|
+
|
|
110
|
+
setScenario({
|
|
111
|
+
ADB_VERSION_STATUS: '0',
|
|
112
|
+
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
113
|
+
ADB_DEVICES_OUTPUT: 'List of devices attached\nserial1\tunauthorized\nserial2\toffline\n',
|
|
114
|
+
XCRUN_VERSION_STATUS: '0',
|
|
115
|
+
XCRUN_VERSION_OUTPUT: 'xcrun version\n',
|
|
116
|
+
})
|
|
117
|
+
const unauthorized = await systemStatus.getSystemStatus()
|
|
118
|
+
assert.strictEqual(unauthorized.success, false)
|
|
119
|
+
assert(unauthorized.issues.some((issue: string) => issue.includes('unauthorized')))
|
|
120
|
+
assert(unauthorized.issues.some((issue: string) => issue.includes('offline')))
|
|
121
|
+
|
|
122
|
+
setScenario({
|
|
123
|
+
ADB_VERSION_STATUS: '0',
|
|
124
|
+
ADB_VERSION_OUTPUT: '8.1.0\n',
|
|
125
|
+
ADB_DEVICES_OUTPUT: 'List of devices attached\nemulator-5554\tdevice product:sdk\n',
|
|
126
|
+
XCRUN_VERSION_STATUS: '1',
|
|
127
|
+
XCRUN_VERSION_OUTPUT: 'not found',
|
|
128
|
+
})
|
|
129
|
+
const missingXcrun = await systemStatus.getSystemStatus()
|
|
130
|
+
assert.strictEqual(missingXcrun.iosAvailable, false)
|
|
131
|
+
assert.strictEqual(missingXcrun.adbAvailable, true)
|
|
132
|
+
assert.strictEqual(Array.isArray(missingXcrun.issues), true)
|
|
133
|
+
|
|
134
|
+
console.log('system_status checks passed')
|
|
135
|
+
} finally {
|
|
136
|
+
setScenario(originalEnv)
|
|
137
|
+
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
run().catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { execAdbWithDiagnostics } from '../../../src/utils/diagnostics.js'
|
|
6
|
+
import { execCmd } from '../../../src/utils/exec.js'
|
|
7
|
+
|
|
8
|
+
async function run() {
|
|
9
|
+
const envResult = await execCmd(process.execPath, ['-e', 'console.log(process.env.MCP_EXEC_TEST); console.error("warn");'], {
|
|
10
|
+
env: { MCP_EXEC_TEST: 'hello' }
|
|
11
|
+
})
|
|
12
|
+
assert.strictEqual(envResult.exitCode, 0)
|
|
13
|
+
assert.strictEqual(envResult.stdout, 'hello')
|
|
14
|
+
assert.strictEqual(envResult.stderr, 'warn')
|
|
15
|
+
|
|
16
|
+
const timeoutResult = await execCmd(process.execPath, ['-e', 'setTimeout(() => console.log("late"), 200)'], {
|
|
17
|
+
timeout: 50
|
|
18
|
+
})
|
|
19
|
+
assert.strictEqual(timeoutResult.exitCode, null)
|
|
20
|
+
assert.strictEqual(timeoutResult.stdout, '')
|
|
21
|
+
|
|
22
|
+
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-exec-adb-'))
|
|
23
|
+
const adbPath = path.join(binDir, 'adb')
|
|
24
|
+
const script = `#!/bin/sh
|
|
25
|
+
echo "device not found" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
`
|
|
28
|
+
const originalAdbPath = process.env.ADB_PATH
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await fs.writeFile(adbPath, script, { mode: 0o755 })
|
|
32
|
+
process.env.ADB_PATH = adbPath
|
|
33
|
+
|
|
34
|
+
const adbResult = execAdbWithDiagnostics(['shell', 'getprop'], 'emulator-5554')
|
|
35
|
+
assert.strictEqual(adbResult.runResult.command, adbPath)
|
|
36
|
+
assert.deepStrictEqual(adbResult.runResult.args.slice(0, 2), ['-s', 'emulator-5554'])
|
|
37
|
+
assert.match(adbResult.runResult.stderr, /device not found/)
|
|
38
|
+
assert(adbResult.runResult.suggestedFixes?.some((fix) => fix.includes('adb devices')))
|
|
39
|
+
|
|
40
|
+
console.log('exec utility tests passed')
|
|
41
|
+
} finally {
|
|
42
|
+
if (typeof originalAdbPath === 'undefined') delete process.env.ADB_PATH
|
|
43
|
+
else process.env.ADB_PATH = originalAdbPath
|
|
44
|
+
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
run().catch((error) => {
|
|
49
|
+
console.error(error)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import {
|
|
3
|
+
listDevices,
|
|
4
|
+
resolveTargetDevice,
|
|
5
|
+
_setDeviceListersForTests,
|
|
6
|
+
_resetDeviceListersForTests
|
|
7
|
+
} from '../../../src/utils/resolve-device.js'
|
|
8
|
+
|
|
9
|
+
async function run() {
|
|
10
|
+
try {
|
|
11
|
+
_setDeviceListersForTests({
|
|
12
|
+
listAndroidDevices: async () => ([
|
|
13
|
+
{ id: 'android-1', platform: 'android', osVersion: '14', model: 'Pixel 8', simulator: true },
|
|
14
|
+
{ id: 'android-2', platform: 'android', osVersion: '13', model: 'Pixel 7', simulator: false, appInstalled: true } as any
|
|
15
|
+
]),
|
|
16
|
+
listIOSDevices: async () => ([
|
|
17
|
+
{ id: 'ios-1', platform: 'ios', osVersion: '18.0', model: 'iPhone 15', simulator: true, booted: false } as any,
|
|
18
|
+
{ id: 'ios-2', platform: 'ios', osVersion: '17.4', model: 'iPhone 14', simulator: true, booted: true } as any
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const combined = await listDevices()
|
|
23
|
+
assert.strictEqual(combined.length, 4)
|
|
24
|
+
|
|
25
|
+
const explicit = await resolveTargetDevice({ platform: 'android', deviceId: 'android-1' })
|
|
26
|
+
assert.strictEqual(explicit.id, 'android-1')
|
|
27
|
+
|
|
28
|
+
const preferredPhysical = await resolveTargetDevice({ platform: 'android', prefer: 'physical' })
|
|
29
|
+
assert.strictEqual(preferredPhysical.id, 'android-2')
|
|
30
|
+
|
|
31
|
+
const installedForApp = await resolveTargetDevice({ platform: 'android', appId: 'com.example.app' })
|
|
32
|
+
assert.strictEqual(installedForApp.id, 'android-2')
|
|
33
|
+
|
|
34
|
+
const bootedIOS = await resolveTargetDevice({ platform: 'ios' })
|
|
35
|
+
assert.strictEqual(bootedIOS.id, 'ios-2')
|
|
36
|
+
|
|
37
|
+
_setDeviceListersForTests({
|
|
38
|
+
listAndroidDevices: async () => ([
|
|
39
|
+
{ id: 'android-a', platform: 'android', osVersion: '14.0', model: 'Pixel 8', simulator: false },
|
|
40
|
+
{ id: 'android-b', platform: 'android', osVersion: '14.0', model: 'Pixel 8 Pro', simulator: false }
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await assert.rejects(
|
|
45
|
+
() => resolveTargetDevice({ platform: 'android' }),
|
|
46
|
+
(error: unknown) => {
|
|
47
|
+
assert(error instanceof Error)
|
|
48
|
+
assert.match(error.message, /Multiple matching devices found/)
|
|
49
|
+
assert.strictEqual(Array.isArray((error as any).devices), true)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
console.log('resolve-device tests passed')
|
|
55
|
+
} finally {
|
|
56
|
+
_resetDeviceListersForTests()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
run().catch((error) => {
|
|
61
|
+
console.error(error)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
2
|
-
import * as Observe from '../../../src/observe/index.js'
|
|
3
|
-
|
|
4
|
-
const original = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
5
|
-
|
|
6
|
-
async function runTests() {
|
|
7
|
-
console.log('Starting tests for wait_for_screen_change...')
|
|
8
|
-
|
|
9
|
-
// Test 1: Immediate change
|
|
10
|
-
let seq1: Array<string | null> = ['B','B']
|
|
11
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq1.length ? seq1.shift() : null })
|
|
12
|
-
const start1 = Date.now()
|
|
13
|
-
const res1 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 2000, pollIntervalMs: 50 })
|
|
14
|
-
const elapsed1 = Date.now() - start1
|
|
15
|
-
console.log('Test 1: Immediate change ->', (res1 && (res1 as any).success === true && (res1 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL', 'Elapsed:', elapsed1, 'ms')
|
|
16
|
-
|
|
17
|
-
// Test 2: Transient nulls then stable change
|
|
18
|
-
let seq2: Array<string | null> = [null, null, 'B', 'B']
|
|
19
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq2.length ? seq2.shift() : 'B' })
|
|
20
|
-
const res2 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 3000, pollIntervalMs: 50 })
|
|
21
|
-
console.log('Test 2: Transient nulls ->', (res2 && (res2 as any).success === true && (res2 as any).newFingerprint === 'B') ? 'PASS' : 'FAIL')
|
|
22
|
-
|
|
23
|
-
// Test 3: Timeout
|
|
24
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: 'A' })
|
|
25
|
-
const res3 = await ToolsInteract.waitForScreenChangeHandler({ platform: 'android', previousFingerprint: 'A', timeoutMs: 300, pollIntervalMs: 50 })
|
|
26
|
-
console.log('Test 3: Timeout ->', (res3 && (res3 as any).success === false && (res3 as any).reason === 'timeout') ? 'PASS' : 'FAIL')
|
|
27
|
-
|
|
28
|
-
// Restore original
|
|
29
|
-
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = original
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
runTests().catch(console.error)
|