mobile-debug-mcp 0.21.5 → 0.23.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 (90) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +24 -5
  3. package/dist/interact/classify.js +35 -0
  4. package/dist/interact/index.js +220 -13
  5. package/dist/network/index.js +232 -0
  6. package/dist/observe/ios.js +10 -3
  7. package/dist/server-core.js +822 -0
  8. package/dist/server.js +6 -693
  9. package/dist/utils/resolve-device.js +15 -3
  10. package/docs/CHANGELOG.md +10 -1
  11. package/docs/tools/interact.md +69 -30
  12. package/package.json +3 -3
  13. package/skills/README.md +35 -0
  14. package/skills/test-authoring/SKILL.md +57 -0
  15. package/skills/test-authoring/references/repo-test-layout.md +47 -0
  16. package/skills/test-authoring/references/test-authoring-workflow.md +73 -0
  17. package/skills/test-authoring/references/test-quality-checklist.md +39 -0
  18. package/src/interact/classify.ts +64 -0
  19. package/src/interact/index.ts +250 -13
  20. package/src/network/index.ts +268 -0
  21. package/src/observe/ios.ts +12 -3
  22. package/src/server-core.ts +879 -0
  23. package/src/server.ts +8 -754
  24. package/src/types.ts +10 -1
  25. package/src/utils/resolve-device.ts +19 -3
  26. package/test/device/automated/observe/capture_screenshot.android.smoke.ts +30 -0
  27. package/test/device/automated/observe/capture_screenshot.ios.smoke.ts +30 -0
  28. package/test/{observe/device → device/automated/observe}/get_logs.android.smoke.ts +1 -1
  29. package/test/{observe/device → device/automated/observe}/get_logs.ios.smoke.ts +1 -1
  30. package/test/device/automated/observe/get_ui_tree.android.smoke.ts +31 -0
  31. package/test/device/automated/observe/get_ui_tree.ios.smoke.ts +31 -0
  32. package/test/device/index.ts +52 -0
  33. package/test/{interact/device/smoke-test.ts → device/manual/interact/app_lifecycle.manual.ts} +5 -5
  34. package/test/{manage/device/run-build-install-ios.ts → device/manual/manage/build_install_ios.manual.ts} +1 -1
  35. package/test/{manage/device → device/manual/manage}/install.integration.ts +6 -6
  36. package/test/{manage/device/run-install-android.ts → device/manual/manage/install_android.manual.ts} +1 -1
  37. package/test/{manage/device/run-install-ios.ts → device/manual/manage/install_ios.manual.ts} +1 -1
  38. package/test/device/manual/observe/capture_screenshot.manual.ts +29 -0
  39. package/test/{helpers/run-get-logs.ts → device/manual/observe/get_logs.manual.ts} +1 -1
  40. package/test/device/manual/observe/get_ui_tree.manual.ts +29 -0
  41. package/test/{observe/device/logstream-real.ts → device/manual/observe/logstream.manual.ts} +1 -1
  42. package/test/{observe/device/run-screen-fingerprint.ts → device/manual/observe/screen_fingerprint.manual.ts} +1 -1
  43. package/test/{observe/device/run-scroll-test-android.ts → device/manual/observe/scroll_to_element_android.manual.ts} +1 -1
  44. package/test/{observe/device/test-ui-tree.ts → device/manual/observe/ui_tree.manual.ts} +6 -6
  45. package/test/unit/index.ts +47 -27
  46. package/test/unit/interact/classify_action_outcome.test.ts +110 -0
  47. package/test/unit/interact/handler_shapes.test.ts +55 -0
  48. package/test/unit/interact/tap_element.test.ts +170 -0
  49. package/test/unit/interact/wait_for_screen_change.test.ts +34 -0
  50. package/test/{interact/unit → unit/interact}/wait_for_ui_contract.test.ts +11 -10
  51. package/test/unit/interact/wait_for_ui_selector_matching.test.ts +76 -0
  52. package/test/unit/manage/handler_shapes.test.ts +43 -0
  53. package/test/unit/network/get_network_activity.test.ts +181 -0
  54. package/test/{observe/unit → unit/observe}/capture_debug_snapshot.test.ts +5 -1
  55. package/test/{observe/unit → unit/observe}/find_element.test.ts +12 -6
  56. package/test/unit/observe/get_screen_fingerprint.test.ts +71 -0
  57. package/test/unit/observe/ios-getlogs.test.ts +53 -0
  58. package/test/unit/observe/scroll_to_element.test.ts +127 -0
  59. package/test/unit/server/contract.test.ts +45 -0
  60. package/test/unit/server/response_shapes.test.ts +93 -0
  61. package/test/unit/system/adb_version.test.ts +35 -0
  62. package/test/unit/system/get_system_status.test.ts +20 -0
  63. package/test/unit/system/system_status.test.ts +141 -0
  64. package/test/{utils → unit/utils}/detect_java.test.ts +1 -1
  65. package/test/unit/utils/exec.test.ts +51 -0
  66. package/test/unit/utils/resolve_device.test.ts +63 -0
  67. package/tsconfig.json +2 -2
  68. package/test/interact/device/run-real-test.ts +0 -3
  69. package/test/interact/unit/wait_for_screen_change.test.ts +0 -32
  70. package/test/interact/unit/wait_for_ui.test.ts +0 -76
  71. package/test/interact/unit/wait_for_ui_new.test.ts +0 -57
  72. package/test/observe/device/wait_for_element_real.ts +0 -3
  73. package/test/observe/unit/get_screen_fingerprint.test.ts +0 -69
  74. package/test/observe/unit/ios-getlogs.test.ts +0 -67
  75. package/test/observe/unit/scroll_to_element.test.ts +0 -129
  76. package/test/observe/unit/wait_for_element_mock.ts +0 -2
  77. package/test/observe/unit/wait_for_ui_edge_cases.test.ts +0 -41
  78. package/test/observe/unit/wait_for_ui_stability.test.ts +0 -30
  79. package/test/system/adb_version.test.ts +0 -25
  80. package/test/system/get_system_status.test.ts +0 -52
  81. package/test/system/system_status.test.ts +0 -109
  82. /package/test/{manage/unit → unit/manage}/build.test.ts +0 -0
  83. /package/test/{manage/unit → unit/manage}/build_and_install.test.ts +0 -0
  84. /package/test/{manage/unit → unit/manage}/detection.test.ts +0 -0
  85. /package/test/{manage/unit → unit/manage}/diagnostics.test.ts +0 -0
  86. /package/test/{manage/unit → unit/manage}/install.test.ts +0 -0
  87. /package/test/{manage/unit → unit/manage}/mcp_disable_autodetect.test.ts +0 -0
  88. /package/test/{observe/unit → unit/observe}/get_logs.test.ts +0 -0
  89. /package/test/{observe/unit → unit/observe}/logparse.test.ts +0 -0
  90. /package/test/{observe/unit → unit/observe}/logstream.test.ts +0 -0
@@ -0,0 +1,181 @@
1
+ import assert from 'assert'
2
+ import {
3
+ parseMessageToEvent,
4
+ normalizeEndpoint,
5
+ classifyStatus,
6
+ _setTimestampsForTests,
7
+ ToolsNetwork
8
+ } from '../../../src/network/index.js'
9
+
10
+ function run() {
11
+
12
+ // ─── normalizeEndpoint ──────────────────────────────────────────────────────
13
+
14
+ {
15
+ const r = normalizeEndpoint('https://api.example.com/v1/users?page=2')
16
+ assert.strictEqual(r, '/v1/users', `normalizeEndpoint full URL: expected /v1/users, got ${r}`)
17
+ }
18
+
19
+ {
20
+ const r = normalizeEndpoint('/api/login/')
21
+ assert.strictEqual(r, '/api/login', `normalizeEndpoint path trailing slash: expected /api/login, got ${r}`)
22
+ }
23
+
24
+ {
25
+ const r = normalizeEndpoint('/Api/Login')
26
+ assert.strictEqual(r, '/api/login', `normalizeEndpoint uppercase: expected /api/login, got ${r}`)
27
+ }
28
+
29
+ // ─── classifyStatus ─────────────────────────────────────────────────────────
30
+
31
+ {
32
+ const s = classifyStatus(200, null)
33
+ assert.strictEqual(s, 'success', `200 should be success`)
34
+ }
35
+
36
+ {
37
+ const s = classifyStatus(404, null)
38
+ assert.strictEqual(s, 'failure', `404 should be failure`)
39
+ }
40
+
41
+ {
42
+ const s = classifyStatus(500, null)
43
+ assert.strictEqual(s, 'retryable', `500 should be retryable`)
44
+ }
45
+
46
+ {
47
+ const s = classifyStatus(null, 'timeout')
48
+ assert.strictEqual(s, 'retryable', `networkError should override to retryable`)
49
+ }
50
+
51
+ {
52
+ const s = classifyStatus(200, 'connection_reset')
53
+ assert.strictEqual(s, 'retryable', `networkError beats statusCode`)
54
+ }
55
+
56
+ {
57
+ const s = classifyStatus(null, null)
58
+ assert.strictEqual(s, 'success', `null/null (request detected, no error) = success`)
59
+ }
60
+
61
+ // ─── parseMessageToEvent — emission criteria ─────────────────────────────────
62
+
63
+ // Condition 1: full URL
64
+ {
65
+ const e = parseMessageToEvent('OkHttp: GET https://api.example.com/v1/login 200')
66
+ assert.ok(e !== null, 'full URL line should emit')
67
+ assert.strictEqual(e!.endpoint, '/v1/login')
68
+ assert.strictEqual(e!.method, 'GET')
69
+ assert.strictEqual(e!.statusCode, 200)
70
+ assert.strictEqual(e!.status, 'success')
71
+ }
72
+
73
+ // Condition 2: explicit HTTP status line
74
+ {
75
+ const e = parseMessageToEvent('HTTP/1.1 404 Not Found')
76
+ assert.ok(e !== null, 'HTTP status line should emit')
77
+ assert.strictEqual(e!.statusCode, 404)
78
+ assert.strictEqual(e!.status, 'failure')
79
+ }
80
+
81
+ // Condition 3: method + path
82
+ {
83
+ const e = parseMessageToEvent('Sending POST /api/register HTTP/1.1')
84
+ assert.ok(e !== null, 'method+path line should emit')
85
+ assert.strictEqual(e!.method, 'POST')
86
+ assert.strictEqual(e!.endpoint, '/api/register')
87
+ assert.strictEqual(e!.statusCode, null)
88
+ assert.strictEqual(e!.status, 'success') // no error signal
89
+ }
90
+
91
+ // No criteria met — keyword-only noise
92
+ {
93
+ const e = parseMessageToEvent('HTTP connection pool initialised')
94
+ assert.strictEqual(e, null, 'keyword-only line should not emit')
95
+ }
96
+
97
+ {
98
+ const e = parseMessageToEvent('Request interceptor registered')
99
+ assert.strictEqual(e, null, 'generic Request line should not emit')
100
+ }
101
+
102
+ {
103
+ const e = parseMessageToEvent('Task 200 completed')
104
+ assert.strictEqual(e, null, 'bare status-like numbers should not emit')
105
+ }
106
+
107
+ {
108
+ const e = parseMessageToEvent('Response code: 404')
109
+ assert.strictEqual(e, null, 'labeled status without endpoint or HTTP context should not emit')
110
+ }
111
+
112
+ {
113
+ const e = parseMessageToEvent('GetBestInfo: /data/app/~~pkg/base.apk status=447')
114
+ assert.strictEqual(e, null, 'filesystem paths should not emit as network endpoints')
115
+ }
116
+
117
+ {
118
+ const e = parseMessageToEvent('system/gd/hci/le_address_manager.cc:576 GetNextPrivateAddressIntervalRange')
119
+ assert.strictEqual(e, null, 'source file paths should not emit as network endpoints')
120
+ }
121
+
122
+ {
123
+ const e = parseMessageToEvent('status=503 for /api/session/generate')
124
+ assert.ok(e !== null, 'status with plausible endpoint should emit')
125
+ assert.strictEqual(e!.endpoint, '/api/session/generate')
126
+ assert.strictEqual(e!.statusCode, 503)
127
+ assert.strictEqual(e!.status, 'retryable')
128
+ }
129
+
130
+ // Network error detection
131
+ {
132
+ const e = parseMessageToEvent('java.net.SocketTimeoutException: POST /api/data timed out after 30s')
133
+ assert.ok(e !== null, 'timeout error should emit')
134
+ assert.strictEqual(e!.networkError, 'timeout')
135
+ assert.strictEqual(e!.status, 'retryable')
136
+ }
137
+
138
+ {
139
+ const e = parseMessageToEvent('SSL handshake failed for https://api.example.com/v1/auth')
140
+ assert.ok(e !== null, 'TLS error should emit')
141
+ assert.strictEqual(e!.networkError, 'tls_error')
142
+ assert.strictEqual(e!.status, 'retryable')
143
+ }
144
+
145
+ {
146
+ const e = parseMessageToEvent('DNS resolution failed: GET /api/users')
147
+ assert.ok(e !== null, 'DNS error should emit')
148
+ assert.strictEqual(e!.networkError, 'dns_error')
149
+ assert.strictEqual(e!.status, 'retryable')
150
+ }
151
+
152
+ // 5xx → retryable even without networkError
153
+ {
154
+ const e = parseMessageToEvent('Response 503 for https://api.example.com/v1/data')
155
+ assert.ok(e !== null, '5xx should emit')
156
+ assert.strictEqual(e!.statusCode, 503)
157
+ assert.strictEqual(e!.status, 'retryable')
158
+ }
159
+
160
+ // ─── lastConsumedTimestamp dedupe ────────────────────────────────────────────
161
+
162
+ {
163
+ // Simulate: action happened 1000ms ago, last consumed 500ms ago → use consumed
164
+ _setTimestampsForTests(Date.now() - 1000, Date.now() - 500)
165
+ // We can't easily verify the sinceMs value from outside without deep mocking,
166
+ // but we can confirm getNetworkActivity resolves without throwing.
167
+ const promise = ToolsNetwork.getNetworkActivity({ platform: 'android' })
168
+ assert.ok(promise instanceof Promise, 'getNetworkActivity should return a Promise')
169
+ // Allow the promise to settle (logcat may fail in test env — that's fine)
170
+ promise.catch(() => {})
171
+ }
172
+
173
+ console.log('get_network_activity tests passed')
174
+ }
175
+
176
+ try {
177
+ run()
178
+ } catch (err) {
179
+ console.error(err)
180
+ process.exit(1)
181
+ }
@@ -1,4 +1,5 @@
1
1
  import { ToolsObserve } from '../../../src/observe/index.js'
2
+ import assert from 'assert'
2
3
 
3
4
  async function run() {
4
5
  console.log('Starting capture_debug_snapshot unit tests...')
@@ -35,6 +36,7 @@ async function run() {
35
36
  const res1: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 50, sessionId: 's1' })
36
37
  console.log('res1:', JSON.stringify(res1, null, 2))
37
38
  const pass1 = res1 && res1.screenshot === 'BASE64PNG' && res1.activity && res1.fingerprint === 'abc123' && Array.isArray(res1.logs) && res1.logs.length === 1
39
+ assert.ok(pass1, 'captureDebugSnapshot should aggregate successful handler results')
38
40
  console.log('Test 1:', pass1 ? 'PASS' : 'FAIL')
39
41
 
40
42
  // Restore handlers before next test
@@ -54,6 +56,7 @@ async function run() {
54
56
  const res2: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: true, logLines: 10, appId: 'com.example' })
55
57
  console.log('res2:', JSON.stringify(res2, null, 2))
56
58
  const pass2 = res2 && res2.screenshot_error && res2.ui_tree_error && Array.isArray(res2.logs) && res2.logs.length === 2
59
+ assert.ok(pass2, 'captureDebugSnapshot should surface partial failures and fallback logs')
57
60
  console.log('Test 2:', pass2 ? 'PASS' : 'FAIL')
58
61
 
59
62
  // Restore handlers before next test
@@ -74,6 +77,7 @@ async function run() {
74
77
  const res3: any = await ToolsObserve.captureDebugSnapshotHandler({ platform: 'android', includeLogs: false })
75
78
  console.log('res3:', JSON.stringify(res3, null, 2))
76
79
  const pass3 = res3 && typeof res3.logs !== 'undefined' && res3.logs.length === 0
80
+ assert.ok(pass3, 'captureDebugSnapshot should return an empty logs array when includeLogs is false')
77
81
  console.log('Test 3:', pass3 ? 'PASS' : 'FAIL')
78
82
 
79
83
  } finally {
@@ -86,4 +90,4 @@ async function run() {
86
90
  }
87
91
  }
88
92
 
89
- run().catch(console.error)
93
+ run().catch((error) => { console.error(error); process.exit(1) })
@@ -1,5 +1,6 @@
1
1
  import { ToolsInteract } from '../../../src/interact/index.js'
2
2
  import { ToolsObserve } from '../../../src/observe/index.js'
3
+ import assert from 'assert'
3
4
 
4
5
  async function run() {
5
6
  process.stdout.write('Starting find_element unit tests...\n')
@@ -8,7 +9,7 @@ async function run() {
8
9
 
9
10
  try {
10
11
  // Test 1: exact text match
11
- (ToolsObserve as any).getUITreeHandler = async () => ({
12
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
12
13
  device: { platform: 'android', id: 'mock' },
13
14
  screen: '',
14
15
  resolution: { width: 1080, height: 1920 },
@@ -21,10 +22,11 @@ async function run() {
21
22
  const res1: any = await ToolsInteract.findElementHandler({ query: 'login', exact: true, platform: 'android' })
22
23
  process.stdout.write('res1 ' + JSON.stringify(res1, null, 2) + '\n');
23
24
  const pass1 = res1.found === true && res1.element && res1.element.resourceId === 'btn_login' && res1.element.tapCoordinates && typeof res1.element.tapCoordinates.x === 'number' && typeof res1.element.tapCoordinates.y === 'number' && typeof res1.confidence === 'number'
25
+ assert.ok(pass1, 'Exact text match should find the actionable login button')
24
26
  process.stdout.write('Test 1: ' + (pass1 ? 'PASS' : 'FAIL') + '\n');
25
27
 
26
28
  // Test 2: partial match & scoring
27
- (ToolsObserve as any).getUITreeHandler = async () => ({
29
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
28
30
  device: { platform: 'android', id: 'mock' },
29
31
  screen: '',
30
32
  resolution: { width: 1080, height: 1920 },
@@ -37,10 +39,11 @@ async function run() {
37
39
  const res2: any = await ToolsInteract.findElementHandler({ query: 'login', exact: false, platform: 'android' })
38
40
  process.stdout.write('res2 ' + JSON.stringify(res2, null, 2) + '\n');
39
41
  const pass2 = res2.found === true && res2.element && res2.element.resourceId === 'btn_login_email' && res2.element.tapCoordinates && typeof res2.element.tapCoordinates.x === 'number' && typeof res2.element.tapCoordinates.y === 'number' && typeof res2.confidence === 'number'
42
+ assert.ok(pass2, 'Partial text matching should pick the best scoring element')
40
43
  process.stdout.write('Test 2: ' + (pass2 ? 'PASS' : 'FAIL') + '\n');
41
44
 
42
45
  // Test 3: resourceId match
43
- (ToolsObserve as any).getUITreeHandler = async () => ({
46
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
44
47
  device: { platform: 'android', id: 'mock' },
45
48
  screen: '',
46
49
  resolution: { width: 1080, height: 1920 },
@@ -52,10 +55,11 @@ async function run() {
52
55
  const res3: any = await ToolsInteract.findElementHandler({ query: 'icon_login', exact: false, platform: 'android' })
53
56
  process.stdout.write('res3 ' + JSON.stringify(res3, null, 2) + '\n');
54
57
  const pass3 = res3.found === true && res3.element && res3.element.resourceId === 'icon_login' && res3.element.tapCoordinates && typeof res3.element.tapCoordinates.x === 'number' && typeof res3.element.tapCoordinates.y === 'number' && typeof res3.confidence === 'number'
58
+ assert.ok(pass3, 'Resource-id matching should find icon_login')
55
59
  process.stdout.write('Test 3: ' + (pass3 ? 'PASS' : 'FAIL') + '\n');
56
60
 
57
61
  // Test 4: parent-clickable child-text scenario
58
- (ToolsObserve as any).getUITreeHandler = async () => ({
62
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
59
63
  device: { platform: 'android', id: 'mock' },
60
64
  screen: '',
61
65
  resolution: { width: 1080, height: 1920 },
@@ -68,13 +72,15 @@ async function run() {
68
72
  const res4: any = await ToolsInteract.findElementHandler({ query: 'generate', exact: false, platform: 'android', timeoutMs: 300 })
69
73
  process.stdout.write('res4 ' + JSON.stringify(res4, null, 2) + '\n');
70
74
  const pass4 = res4.found === true && res4.element && res4.element.clickable === true && res4.element.resourceId === 'btn_generate' && res4.element.tapCoordinates && typeof res4.element.tapCoordinates.x === 'number' && typeof res4.element.tapCoordinates.y === 'number' && typeof res4.confidence === 'number'
75
+ assert.ok(pass4, 'Child text should resolve to a clickable parent ancestor')
71
76
  process.stdout.write('Test 4: ' + (pass4 ? 'PASS' : 'FAIL') + '\n');
72
77
 
73
78
  // Test 5: not found
74
- (ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
79
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({ device: { platform: 'android', id: 'mock' }, screen: '', resolution: { width: 1080, height: 1920 }, elements: [] })
75
80
  const res5: any = await ToolsInteract.findElementHandler({ query: 'nope', exact: false, platform: 'android', timeoutMs: 300 })
76
81
  process.stdout.write('res5 ' + JSON.stringify(res5, null, 2) + '\n');
77
82
  const pass5 = res5.found === false
83
+ assert.ok(pass5, 'Missing elements should return found=false')
78
84
  process.stdout.write('Test 5: ' + (pass5 ? 'PASS' : 'FAIL') + '\n');
79
85
 
80
86
  } finally {
@@ -82,4 +88,4 @@ async function run() {
82
88
  }
83
89
  }
84
90
 
85
- run().catch(console.error)
91
+ run().catch((error) => { console.error(error); process.exit(1) })
@@ -0,0 +1,71 @@
1
+ import { AndroidObserve } from '../../../src/observe/index.js'
2
+ import assert from 'assert'
3
+
4
+ async function run() {
5
+ console.log('Starting get_screen_fingerprint unit tests...')
6
+
7
+ const origGet = (AndroidObserve as any).prototype.getUITree
8
+ const origCurrent = (AndroidObserve as any).prototype.getCurrentScreen
9
+
10
+ try {
11
+ ;(AndroidObserve as any).prototype.getUITree = async function() {
12
+ return {
13
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
14
+ screen: '',
15
+ resolution: { width: 1080, height: 1920 },
16
+ elements: [
17
+ { text: 'Title', type: 'TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [0,0,1080,100], resourceId: 'id/title' },
18
+ { text: 'Sign in', type: 'Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,200,200,260], resourceId: 'id/signin' }
19
+ ]
20
+ }
21
+ }
22
+
23
+ ;(AndroidObserve as any).prototype.getCurrentScreen = async function() {
24
+ return { device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true }, package: 'com.example', activity: 'com.example.MainActivity', shortActivity: 'MainActivity' }
25
+ }
26
+
27
+ const ai = new AndroidObserve()
28
+ const a = await ai.getScreenFingerprint('mock')
29
+ const b = await ai.getScreenFingerprint('mock')
30
+ assert.strictEqual(a.fingerprint, b.fingerprint, 'Identical screens should produce the same fingerprint')
31
+ console.log('Test 1: PASS')
32
+
33
+ ;(AndroidObserve as any).prototype.getUITree = async function() {
34
+ return {
35
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
36
+ screen: '',
37
+ resolution: { width: 1080, height: 1920 },
38
+ elements: [
39
+ { text: 'Title', type: 'TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [0,0,1080,100], resourceId: 'id/title' },
40
+ { text: 'Profile', type: 'Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,200,200,260], resourceId: 'id/signin' }
41
+ ]
42
+ }
43
+ }
44
+
45
+ const c = await ai.getScreenFingerprint('mock')
46
+ assert.notStrictEqual(a.fingerprint, c.fingerprint, 'Meaningful UI text changes should change the fingerprint')
47
+ console.log('Test 2: PASS')
48
+
49
+ ;(AndroidObserve as any).prototype.getUITree = async function() {
50
+ return {
51
+ device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
52
+ screen: '',
53
+ resolution: { width: 1080, height: 1920 },
54
+ elements: [
55
+ { text: 'Title', type: 'TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [0,0,1080,100], resourceId: 'id/title' },
56
+ { text: 'Sign in', type: 'Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,200,200,260], resourceId: 'id/signin' },
57
+ { text: '12:34', type: 'TextView', contentDescription: null, clickable: false, enabled: true, visible: true, bounds: [900,10,1080,40], resourceId: null }
58
+ ]
59
+ }
60
+ }
61
+
62
+ const d = await ai.getScreenFingerprint('mock')
63
+ assert.strictEqual(a.fingerprint, d.fingerprint, 'Dynamic timestamp-like text should be ignored')
64
+ console.log('Test 3: PASS')
65
+ } finally {
66
+ ;(AndroidObserve as any).prototype.getUITree = origGet
67
+ ;(AndroidObserve as any).prototype.getCurrentScreen = origCurrent
68
+ }
69
+ }
70
+
71
+ run().catch((error) => { console.error(error); process.exit(1) })
@@ -0,0 +1,53 @@
1
+ import { iOSObserve, _resetIOSExecCommandForTests, _setIOSExecCommandForTests } from '../../../src/observe/ios'
2
+ import assert from 'assert'
3
+
4
+ function stubExecCommand(expectedArgsChecker: (args: string[]) => boolean, output: string) {
5
+ return async function (args: string[], deviceId?: string) {
6
+ if (!expectedArgsChecker(args)) throw new Error('Unexpected args: ' + JSON.stringify(args))
7
+ return { output, device: { platform: 'ios', id: deviceId || 'booted' } }
8
+ }
9
+ }
10
+
11
+ async function run() {
12
+ const bundle = 'com.ideamechanics.modul8'
13
+ const pgrepOutput = '12345\n'
14
+ const logOutput = '2026-03-31 09:21:20.085 Module[12345:678] <Info> Modul8: Test message'
15
+
16
+ try {
17
+ const obs = new iOSObserve()
18
+ _setIOSExecCommandForTests(stubExecCommand((args) => args.includes('pgrep'), pgrepOutput))
19
+
20
+ let called = false
21
+ _setIOSExecCommandForTests(async function (args: string[]) {
22
+ if (args.includes('pgrep')) return { output: pgrepOutput, device: { platform: 'ios', id: 'booted' } }
23
+ if (args.includes('log') && args.includes('show')) {
24
+ called = true
25
+ return { output: logOutput, device: { platform: 'ios', id: 'booted' } }
26
+ }
27
+ throw new Error('Unexpected args: ' + JSON.stringify(args))
28
+ })
29
+
30
+ const pidResult = await obs.getLogs({ appId: bundle, deviceId: 'booted' })
31
+ assert(pidResult.meta.processNameUsed === 'modul8' || pidResult.meta.processNameUsed === 'Modul8' || !!pidResult.meta.processNameUsed)
32
+ assert(pidResult.meta.detectedPid === 12345)
33
+ assert(pidResult.source === 'pid')
34
+ assert(pidResult.logCount === 1)
35
+ assert(pidResult.logs[0].message.includes('Test message'))
36
+ assert(called, 'log show must have been called')
37
+
38
+ _setIOSExecCommandForTests(async function (args: string[]) {
39
+ if (args.includes('log') && args.includes('show')) return { output: '2026-03-31 09:21:20.085 SomeOther[222:333] <Info> Other: Hello', device: { platform: 'ios', id: 'booted' } }
40
+ throw new Error('Unexpected args: ' + JSON.stringify(args))
41
+ })
42
+
43
+ const broadResult = await new iOSObserve().getLogs({ deviceId: 'booted' })
44
+ assert(broadResult.source === 'broad')
45
+ assert(broadResult.logCount === 1)
46
+
47
+ console.log('iOS getLogs predicate and meta tests passed')
48
+ } finally {
49
+ _resetIOSExecCommandForTests()
50
+ }
51
+ }
52
+
53
+ run().catch((error) => { console.error(error); process.exit(1) })
@@ -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
+ })