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.
- package/AGENTS.md +74 -0
- package/README.md +24 -5
- package/dist/interact/classify.js +35 -0
- package/dist/interact/index.js +220 -13
- package/dist/network/index.js +232 -0
- package/dist/observe/ios.js +10 -3
- package/dist/server-core.js +822 -0
- package/dist/server.js +6 -693
- package/dist/utils/resolve-device.js +15 -3
- package/docs/CHANGELOG.md +10 -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/classify.ts +64 -0
- package/src/interact/index.ts +250 -13
- package/src/network/index.ts +268 -0
- package/src/observe/ios.ts +12 -3
- package/src/server-core.ts +879 -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/classify_action_outcome.test.ts +110 -0
- 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/unit/network/get_network_activity.test.ts +181 -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,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
|
+
})
|