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.
Files changed (84) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +24 -5
  3. package/dist/interact/index.js +220 -13
  4. package/dist/observe/ios.js +10 -3
  5. package/dist/server-core.js +707 -0
  6. package/dist/server.js +6 -693
  7. package/dist/utils/resolve-device.js +15 -3
  8. package/docs/CHANGELOG.md +6 -1
  9. package/docs/tools/interact.md +69 -30
  10. package/package.json +3 -3
  11. package/skills/README.md +35 -0
  12. package/skills/test-authoring/SKILL.md +57 -0
  13. package/skills/test-authoring/references/repo-test-layout.md +47 -0
  14. package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
  15. package/skills/test-authoring/references/test-quality-checklist.md +39 -0
  16. package/src/interact/index.ts +250 -13
  17. package/src/observe/ios.ts +12 -3
  18. package/src/server-core.ts +762 -0
  19. package/src/server.ts +8 -754
  20. package/src/types.ts +10 -1
  21. package/src/utils/resolve-device.ts +19 -3
  22. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
  23. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
  24. package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
  25. package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
  26. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
  27. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
  28. package/test/device/index.ts +52 -0
  29. package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
  30. package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
  31. package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
  32. package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
  33. package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
  34. package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
  35. package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
  36. package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
  37. package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
  38. package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
  39. package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
  40. package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
  41. package/test/unit/index.ts +47 -27
  42. package/test/unit/interact/handler_shapes.test.ts +55 -0
  43. package/test/unit/interact/tap_element.test.ts +170 -0
  44. package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
  45. package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
  46. package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
  47. package/test/unit/manage/handler_shapes.test.ts +43 -0
  48. package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
  49. package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
  50. package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
  51. package/test/unit/observe/ios-getlogs.test.ts +53 -0
  52. package/test/unit/observe/scroll_to_element.test.ts +127 -0
  53. package/test/unit/server/contract.test.ts +45 -0
  54. package/test/unit/server/response_shapes.test.ts +93 -0
  55. package/test/unit/system/adb_version.test.ts +35 -0
  56. package/test/unit/system/get_system_status.test.ts +20 -0
  57. package/test/unit/system/system_status.test.ts +141 -0
  58. package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
  59. package/test/unit/utils/exec.test.ts +51 -0
  60. package/test/unit/utils/resolve_device.test.ts +63 -0
  61. package/tsconfig.json +2 -2
  62. package/test/interact/device/run-real-test.ts +0 -3
  63. package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
  64. package/test/interact/unit/wait_for_ui.test.ts +0 -76
  65. package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
  66. package/test/observe/device/wait_for_element_real.ts +0 -3
  67. package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
  68. package/test/observe/unit/ios-getlogs.test.ts +0 -67
  69. package/test/observe/unit/scroll_to_element.test.ts +0 -129
  70. package/test/observe/unit/wait_for_element_mock.ts +0 -2
  71. package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
  72. package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
  73. package/test/system/adb_version.test.ts +0 -25
  74. package/test/system/get_system_status.test.ts +0 -52
  75. package/test/system/system_status.test.ts +0 -109
  76. /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
  77. /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
  78. /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
  79. /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
  80. /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
  81. /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
  82. /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
  83. /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
  84. /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) })
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
- import { detectJavaHome } from '../../src/utils/java.js'
3
+ import { detectJavaHome } from '../../../src/utils/java.js'
4
4
 
5
5
  async function run() {
6
6
  // Create a temporary fake JDK that reports Java 17
@@ -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
@@ -9,6 +9,6 @@
9
9
  "skipLibCheck": true,
10
10
  "esModuleInterop": true
11
11
  },
12
- "exclude": ["smoke-test.ts", "test-ui-tree.ts", "test/**/*.ts"],
12
+ "exclude": ["test/**/*.ts"],
13
13
  "include": ["src/**/*"]
14
- }
14
+ }
@@ -1,3 +0,0 @@
1
- // wait_for_element device runner removed
2
- console.log('wait_for_element device runner removed');
3
- process.exit(0);
@@ -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)