mobile-debug-mcp 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/android/interact.js +13 -1
- package/dist/android/observe.js +13 -0
- package/dist/cli/ios/run-ios-smoke.js +2 -2
- package/dist/cli/ios/run-ios-ui-tree-tap.js +2 -2
- package/dist/interact/android.js +91 -0
- package/dist/interact/index.js +37 -0
- package/dist/interact/ios.js +120 -0
- package/dist/interact/shared/fingerprint.js +72 -0
- package/dist/interact/shared/scroll_to_element.js +98 -0
- package/dist/ios/interact.js +52 -1
- package/dist/ios/observe.js +12 -0
- package/dist/manage/android.js +162 -0
- package/dist/manage/index.js +364 -0
- package/dist/manage/ios.js +353 -0
- package/dist/observe/android.js +351 -0
- package/dist/observe/fingerprint.js +1 -0
- package/dist/observe/index.js +85 -0
- package/dist/observe/ios.js +320 -0
- package/dist/observe/test/device/logstream-real.js +34 -0
- package/dist/observe/test/device/run-screen-fingerprint.js +29 -0
- package/dist/observe/test/device/run-scroll-test-android.js +22 -0
- package/dist/observe/test/device/test-ui-tree.js +67 -0
- package/dist/observe/test/device/wait_for_element_real.js +69 -0
- package/dist/observe/test/unit/get_screen_fingerprint.test.js +54 -0
- package/dist/observe/test/unit/logparse.test.js +39 -0
- package/dist/observe/test/unit/logstream.test.js +41 -0
- package/dist/observe/test/unit/scroll_to_element.test.js +113 -0
- package/dist/observe/test/unit/wait_for_element_mock.js +92 -0
- package/dist/server.js +54 -9
- package/dist/shared/fingerprint.js +72 -0
- package/dist/shared/scroll_to_element.js +98 -0
- package/dist/tools/interact.js +19 -22
- package/dist/tools/manage.js +2 -2
- package/dist/tools/observe.js +45 -43
- package/dist/tools/scroll_to_element.js +98 -0
- package/dist/utils/android/utils.js +429 -0
- package/dist/utils/cli/idb/check-idb.js +84 -0
- package/dist/utils/cli/idb/idb-helper.js +91 -0
- package/dist/utils/cli/idb/install-idb.js +82 -0
- package/dist/utils/cli/ios/preflight-ios.js +155 -0
- package/dist/utils/cli/ios/run-ios-smoke.js +28 -0
- package/dist/utils/cli/ios/run-ios-ui-tree-tap.js +29 -0
- package/dist/utils/diagnostics.js +1 -1
- package/dist/utils/ios/utils.js +301 -0
- package/dist/utils/resolve-device.js +2 -2
- package/docs/CHANGELOG.md +11 -0
- package/docs/tools/TOOLS.md +3 -3
- package/docs/tools/interact.md +31 -0
- package/docs/tools/observe.md +24 -0
- package/package.json +1 -1
- package/src/{android/interact.ts → interact/android.ts} +15 -2
- package/src/interact/index.ts +47 -0
- package/src/{ios/interact.ts → interact/ios.ts} +58 -3
- package/src/interact/shared/fingerprint.ts +73 -0
- package/src/interact/shared/scroll_to_element.ts +110 -0
- package/src/{android/manage.ts → manage/android.ts} +2 -2
- package/src/{tools/manage.ts → manage/index.ts} +7 -4
- package/src/{ios/manage.ts → manage/ios.ts} +1 -1
- package/src/{android/observe.ts → observe/android.ts} +14 -26
- package/src/observe/index.ts +92 -0
- package/src/{ios/observe.ts → observe/ios.ts} +17 -35
- package/src/server.ts +57 -10
- package/src/{android → utils/android}/utils.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-smoke.ts +2 -2
- package/src/{cli → utils/cli}/ios/run-ios-ui-tree-tap.ts +3 -3
- package/src/utils/diagnostics.ts +1 -1
- package/src/{ios → utils/ios}/utils.ts +2 -2
- package/src/utils/resolve-device.ts +2 -2
- package/test/{device/interact → interact/device}/smoke-test.ts +3 -4
- package/test/{device/manage → manage/device}/run-install-android.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-ios.ts +1 -1
- package/test/{device/manage → manage/device}/run-install-kmp.ts +1 -1
- package/test/{unit/manage → manage/unit}/build.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/build_and_install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/detection.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/diagnostics.test.ts +2 -2
- package/test/{unit/manage → manage/unit}/install.test.ts +1 -1
- package/test/{unit/manage → manage/unit}/mcp_disable_autodetect.test.ts +1 -1
- package/test/{device/observe → observe/device}/logstream-real.ts +1 -1
- package/test/observe/device/run-screen-fingerprint.ts +36 -0
- package/test/observe/device/run-scroll-test-android.ts +24 -0
- package/test/{device/observe → observe/device}/test-ui-tree.ts +2 -2
- package/test/{device/observe → observe/device}/wait_for_element_real.ts +2 -2
- package/test/observe/unit/get_screen_fingerprint.test.ts +69 -0
- package/test/{unit/observe → observe/unit}/logparse.test.ts +1 -1
- package/test/{unit/observe → observe/unit}/logstream.test.ts +1 -1
- package/test/observe/unit/scroll_to_element.test.ts +129 -0
- package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +3 -3
- package/test/unit/index.ts +12 -11
- package/src/tools/interact.ts +0 -45
- package/src/tools/observe.ts +0 -82
- package/test/device/README.md +0 -49
- package/test/device/index.ts +0 -27
- package/test/device/utils/test-dist.ts +0 -41
- package/test/unit/utils/detect-java.test.ts +0 -22
- /package/src/{cli → utils/cli}/idb/check-idb.ts +0 -0
- /package/src/{cli → utils/cli}/idb/idb-helper.ts +0 -0
- /package/src/{cli → utils/cli}/idb/install-idb.ts +0 -0
- /package/src/{cli → utils/cli}/ios/preflight-ios.ts +0 -0
- /package/test/{device/interact → interact/device}/run-real-test.ts +0 -0
- /package/test/{device/manage → manage/device}/install.integration.ts +0 -0
- /package/test/{device/manage → manage/device}/run-build-install-ios.ts +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { AndroidObserve } from '../../../../../index.js';
|
|
5
|
+
async function run() {
|
|
6
|
+
const tmp = os.tmpdir();
|
|
7
|
+
const sessionId = 'unit-test-logstream';
|
|
8
|
+
const file = path.join(tmp, `mobile-debug-log-${sessionId}.ndjson`);
|
|
9
|
+
// Prepare NDJSON with one crash entry and one info entry
|
|
10
|
+
const crashEntry = { timestamp: '2026-03-13T14:00:00.000Z', level: 'E', tag: 'AndroidRuntime', message: 'FATAL EXCEPTION: main\njava.lang.NullPointerException' };
|
|
11
|
+
const infoEntry = { timestamp: '2026-03-13T14:01:00.000Z', level: 'I', tag: 'MyTag', message: 'Info message' };
|
|
12
|
+
await fs.writeFile(file, JSON.stringify(crashEntry) + '\n' + JSON.stringify(infoEntry) + '\n');
|
|
13
|
+
try {
|
|
14
|
+
// Read all via AndroidObserve (falls back to session NDJSON file)
|
|
15
|
+
const obs = new AndroidObserve();
|
|
16
|
+
const { entries, crash_summary } = await obs.readLogStream(sessionId, 10);
|
|
17
|
+
if (!Array.isArray(entries) || entries.length !== 2)
|
|
18
|
+
throw new Error('Expected 2 entries');
|
|
19
|
+
if (!crash_summary || crash_summary.crash_detected !== true)
|
|
20
|
+
throw new Error('Expected crash_detected true');
|
|
21
|
+
if (!crash_summary.exception || !/NullPointerException/.test(crash_summary.exception))
|
|
22
|
+
throw new Error('Expected NullPointerException detected');
|
|
23
|
+
console.log('Test 1 PASS: basic parsing & crash detection');
|
|
24
|
+
// Test since filter (after first entry)
|
|
25
|
+
const since = new Date('2026-03-13T14:00:30.000Z').toISOString();
|
|
26
|
+
const r2 = await obs.readLogStream(sessionId, 10, since);
|
|
27
|
+
if (r2.entries.length !== 1)
|
|
28
|
+
throw new Error('Expected 1 entry after since filter');
|
|
29
|
+
console.log('Test 2 PASS: since filter');
|
|
30
|
+
// Test limit
|
|
31
|
+
const r3 = await obs.readLogStream(sessionId, 1);
|
|
32
|
+
if (r3.entries.length !== 1)
|
|
33
|
+
throw new Error('Expected 1 entry with limit=1');
|
|
34
|
+
console.log('Test 3 PASS: limit works');
|
|
35
|
+
console.log('ALL logstream tests passed');
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await fs.unlink(file).catch(() => { });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
run().catch(err => { console.error('Logstream tests failed:', err); process.exit(1); });
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ToolsInteract } from '../../../src/tools/interact.js';
|
|
2
|
+
import { ToolsObserve } from '../../../src/tools/observe.js';
|
|
3
|
+
const origGet = ToolsObserve.getUITreeHandler;
|
|
4
|
+
const origSwipe = ToolsInteract.swipeHandler;
|
|
5
|
+
async function runTests() {
|
|
6
|
+
// Use a stable logger to avoid test harness replacing console.log between calls
|
|
7
|
+
console.log = (...args) => { try {
|
|
8
|
+
process.stdout.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
|
|
9
|
+
}
|
|
10
|
+
catch { } };
|
|
11
|
+
console.log('Starting tests for scroll_to_element...');
|
|
12
|
+
// Test 1: Element found immediately
|
|
13
|
+
console.log('\nTest 1: Element found immediately')(ToolsObserve).getUITreeHandler = async () => ({
|
|
14
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
15
|
+
screen: '',
|
|
16
|
+
resolution: { width: 1080, height: 1920 },
|
|
17
|
+
elements: [{
|
|
18
|
+
text: 'Target',
|
|
19
|
+
type: 'Button',
|
|
20
|
+
contentDescription: null,
|
|
21
|
+
clickable: true,
|
|
22
|
+
enabled: true,
|
|
23
|
+
visible: true,
|
|
24
|
+
bounds: [0, 0, 100, 100],
|
|
25
|
+
resourceId: null
|
|
26
|
+
}]
|
|
27
|
+
});
|
|
28
|
+
const res1 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Target' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
|
|
29
|
+
console.log('Result:', res1.success === true ? 'PASS' : 'FAIL');
|
|
30
|
+
console.log('scrollsPerformed:', res1.scrollsPerformed);
|
|
31
|
+
// Test 2: Element found after scrolling
|
|
32
|
+
console.log('\nTest 2: Element found after scrolling');
|
|
33
|
+
let calls = 0(ToolsObserve).getUITreeHandler = async () => {
|
|
34
|
+
calls++;
|
|
35
|
+
if (calls < 3) {
|
|
36
|
+
return {
|
|
37
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
38
|
+
screen: '',
|
|
39
|
+
resolution: { width: 1080, height: 1920 },
|
|
40
|
+
elements: []
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
45
|
+
screen: '',
|
|
46
|
+
resolution: { width: 1080, height: 1920 },
|
|
47
|
+
elements: [{
|
|
48
|
+
text: 'Target',
|
|
49
|
+
type: 'Button',
|
|
50
|
+
contentDescription: null,
|
|
51
|
+
clickable: true,
|
|
52
|
+
enabled: true,
|
|
53
|
+
visible: true,
|
|
54
|
+
bounds: [0, 0, 100, 100],
|
|
55
|
+
resourceId: null
|
|
56
|
+
}]
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
// Stub swipe so it doesn't try to call adb/idb
|
|
60
|
+
ToolsInteract.swipeHandler = async () => ({ success: true });
|
|
61
|
+
const res2 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Target' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
|
|
62
|
+
console.log('Result:', res2.success === true ? 'PASS' : 'FAIL');
|
|
63
|
+
console.log('calls:', calls, calls >= 3 ? 'PASS' : 'FAIL');
|
|
64
|
+
// Test 3: UI unchanged stops early
|
|
65
|
+
console.log('\nTest 3: UI unchanged stops early')(ToolsObserve).getUITreeHandler = async () => ({
|
|
66
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
67
|
+
screen: '',
|
|
68
|
+
resolution: { width: 1080, height: 1920 },
|
|
69
|
+
elements: []
|
|
70
|
+
})(ToolsInteract).swipeHandler = async () => ({ success: true });
|
|
71
|
+
const res3 = await ToolsInteract.scrollToElementHandler({ platform: 'android', selector: { text: 'Missing' }, direction: 'down', maxScrolls: 5, scrollAmount: 0.7 });
|
|
72
|
+
console.log('Result:', res3.success === false && res3.attempts === 1 ? 'PASS' : 'FAIL');
|
|
73
|
+
console.log('Reason:', res3.reason || JSON.stringify(res3));
|
|
74
|
+
// Test 4: Offscreen element scrolls into view
|
|
75
|
+
console.log('\nTest 4: Offscreen element scrolls into view');
|
|
76
|
+
const ai = new (await import('../../../src/android/interact.js')).AndroidInteract();
|
|
77
|
+
const origObserveGet = ai['observe'].getUITree;
|
|
78
|
+
const origAiSwipe = ai.swipe;
|
|
79
|
+
let swiped = false;
|
|
80
|
+
let swipeCalled = 0;
|
|
81
|
+
ai['observe'].getUITree = async () => {
|
|
82
|
+
if (!swiped) {
|
|
83
|
+
return {
|
|
84
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
85
|
+
screen: '',
|
|
86
|
+
resolution: { width: 1080, height: 1920 },
|
|
87
|
+
elements: [{ text: null, type: 'android.view.View', resourceId: null, contentDescription: null, bounds: [0, 0, 1080, 200], visible: true }]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
device: { platform: 'android', id: 'mock', osVersion: '12', model: 'Pixel', simulator: true },
|
|
92
|
+
screen: '',
|
|
93
|
+
resolution: { width: 1080, height: 1920 },
|
|
94
|
+
elements: [{ text: 'OffscreenTarget', type: 'android.widget.Button', contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [100, 400, 300, 460], resourceId: null }]
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
ai.swipe = async () => { swipeCalled++; swiped = true; return { success: true }; };
|
|
98
|
+
const r4 = await ai.scrollToElement({ text: 'OffscreenTarget' }, 'down', 3, 0.7, 'mock');
|
|
99
|
+
const ok4 = r4 && r4.success === true && r4.scrollsPerformed === 1 && swipeCalled === 1;
|
|
100
|
+
console.log('Result:', ok4 ? 'PASS' : 'FAIL');
|
|
101
|
+
console.log(' success:', r4.success, 'scrollsPerformed:', r4.scrollsPerformed, 'swipeCalled:', swipeCalled);
|
|
102
|
+
ai['observe'].getUITree = origObserveGet;
|
|
103
|
+
ai.swipe = origAiSwipe(ToolsObserve).getUITreeHandler = origGet;
|
|
104
|
+
ToolsInteract.swipeHandler = origSwipe;
|
|
105
|
+
}
|
|
106
|
+
// Ensure console.log is a function (some test runners replace it)
|
|
107
|
+
if (typeof console.log !== 'function') {
|
|
108
|
+
console.log = (...args) => { try {
|
|
109
|
+
process.stdout.write(args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
|
|
110
|
+
}
|
|
111
|
+
catch { /* swallow */ } };
|
|
112
|
+
}
|
|
113
|
+
runTests().catch(console.error);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AndroidInteract } from '../../../src/android/interact.js';
|
|
2
|
+
import { AndroidObserve } from '../../../../../index.js';
|
|
3
|
+
const originalGetUITree = AndroidObserve.prototype.getUITree;
|
|
4
|
+
async function runTests() {
|
|
5
|
+
console.log("Starting tests for wait_for_element...");
|
|
6
|
+
const interact = new AndroidInteract();
|
|
7
|
+
console.log("\nTest 1: Element found immediately");
|
|
8
|
+
AndroidObserve.prototype.getUITree = async () => ({
|
|
9
|
+
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
10
|
+
screen: "",
|
|
11
|
+
resolution: { width: 1080, height: 1920 },
|
|
12
|
+
elements: [{
|
|
13
|
+
text: "Target",
|
|
14
|
+
type: "Button",
|
|
15
|
+
contentDescription: null,
|
|
16
|
+
clickable: true,
|
|
17
|
+
enabled: true,
|
|
18
|
+
visible: true,
|
|
19
|
+
bounds: [0, 0, 100, 100],
|
|
20
|
+
resourceId: null
|
|
21
|
+
}]
|
|
22
|
+
});
|
|
23
|
+
const start1 = Date.now();
|
|
24
|
+
const result1 = await interact.waitForElement("Target", 1000);
|
|
25
|
+
const elapsed1 = Date.now() - start1;
|
|
26
|
+
console.log("Result:", result1.found === true ? "PASS" : "FAIL");
|
|
27
|
+
console.log("Element:", result1.element ? "FOUND" : "MISSING");
|
|
28
|
+
console.log("Elapsed:", elapsed1, "ms");
|
|
29
|
+
console.log("\nTest 2: Element not found (timeout)");
|
|
30
|
+
AndroidObserve.prototype.getUITree = async () => ({
|
|
31
|
+
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
32
|
+
screen: "",
|
|
33
|
+
resolution: { width: 1080, height: 1920 },
|
|
34
|
+
elements: []
|
|
35
|
+
});
|
|
36
|
+
const start2 = Date.now();
|
|
37
|
+
const result2 = await interact.waitForElement("Target", 1200);
|
|
38
|
+
const elapsed2 = Date.now() - start2;
|
|
39
|
+
console.log("Result:", result2.found === false ? "PASS" : "FAIL");
|
|
40
|
+
console.log("Elapsed time (should be >= 1200ms):", elapsed2, elapsed2 >= 1200 ? "PASS" : "FAIL");
|
|
41
|
+
console.log("\nTest 3: Element found after polling");
|
|
42
|
+
let calls = 0;
|
|
43
|
+
AndroidObserve.prototype.getUITree = async () => {
|
|
44
|
+
calls++;
|
|
45
|
+
if (calls < 3) {
|
|
46
|
+
return {
|
|
47
|
+
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
48
|
+
screen: "",
|
|
49
|
+
resolution: { width: 1080, height: 1920 },
|
|
50
|
+
elements: []
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
55
|
+
screen: "",
|
|
56
|
+
resolution: { width: 1080, height: 1920 },
|
|
57
|
+
elements: [{
|
|
58
|
+
text: "Target",
|
|
59
|
+
type: "Button",
|
|
60
|
+
contentDescription: null,
|
|
61
|
+
clickable: true,
|
|
62
|
+
enabled: true,
|
|
63
|
+
visible: true,
|
|
64
|
+
bounds: [0, 0, 100, 100],
|
|
65
|
+
resourceId: null
|
|
66
|
+
}]
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const start3 = Date.now();
|
|
70
|
+
const result3 = await interact.waitForElement("Target", 2000);
|
|
71
|
+
const elapsed3 = Date.now() - start3;
|
|
72
|
+
console.log("Result:", result3.found === true ? "PASS" : "FAIL");
|
|
73
|
+
console.log("Calls:", calls, calls >= 3 ? "PASS" : "FAIL");
|
|
74
|
+
console.log("Elapsed time (should be >= 1000ms):", elapsed3, elapsed3 >= 1000 ? "PASS" : "FAIL");
|
|
75
|
+
console.log("\nTest 4: Error handling (fast failure)");
|
|
76
|
+
AndroidObserve.prototype.getUITree = async () => ({
|
|
77
|
+
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
78
|
+
screen: "",
|
|
79
|
+
resolution: { width: 0, height: 0 },
|
|
80
|
+
elements: [],
|
|
81
|
+
error: "ADB Connection Failed"
|
|
82
|
+
});
|
|
83
|
+
const start4 = Date.now();
|
|
84
|
+
const result4 = await interact.waitForElement("Target", 5000);
|
|
85
|
+
const elapsed4 = Date.now() - start4;
|
|
86
|
+
console.log("Result:", result4.found === false && result4.error === "ADB Connection Failed" ? "PASS" : "FAIL");
|
|
87
|
+
console.log("Error Message:", result4.error);
|
|
88
|
+
console.log("Elapsed time (should be < 1000ms):", elapsed4, elapsed4 < 1000 ? "PASS" : "FAIL");
|
|
89
|
+
// Restore
|
|
90
|
+
AndroidObserve.prototype.getUITree = originalGetUITree;
|
|
91
|
+
}
|
|
92
|
+
runTests().catch(console.error);
|
package/dist/server.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import { ToolsManage } from './
|
|
6
|
-
import { ToolsInteract } from './
|
|
7
|
-
import { ToolsObserve } from './
|
|
8
|
-
import { AndroidManage } from './
|
|
9
|
-
import { iOSManage } from './
|
|
5
|
+
import { ToolsManage } from './manage/index.js';
|
|
6
|
+
import { ToolsInteract } from './interact/index.js';
|
|
7
|
+
import { ToolsObserve } from './observe/index.js';
|
|
8
|
+
import { AndroidManage } from './manage/index.js';
|
|
9
|
+
import { iOSManage } from './manage/index.js';
|
|
10
10
|
const server = new Server({
|
|
11
11
|
name: "mobile-debug-mcp",
|
|
12
12
|
version: "0.7.0"
|
|
@@ -262,6 +262,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
},
|
|
265
|
+
{
|
|
266
|
+
name: "get_screen_fingerprint",
|
|
267
|
+
description: "Generate a stable fingerprint representing the current visible screen (activity + visible UI elements).",
|
|
268
|
+
inputSchema: {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: {
|
|
271
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override (android|ios)" },
|
|
272
|
+
deviceId: { type: "string", description: "Optional device id/udid to target" }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
265
276
|
{
|
|
266
277
|
name: "wait_for_element",
|
|
267
278
|
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
@@ -325,8 +336,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
325
336
|
properties: {
|
|
326
337
|
platform: {
|
|
327
338
|
type: "string",
|
|
328
|
-
enum: ["android"],
|
|
329
|
-
description: "Platform to swipe on (
|
|
339
|
+
enum: ["android", "ios"],
|
|
340
|
+
description: "Platform to swipe on (android or ios)"
|
|
330
341
|
},
|
|
331
342
|
x1: { type: "number", description: "Start X coordinate" },
|
|
332
343
|
y1: { type: "number", description: "Start Y coordinate" },
|
|
@@ -341,6 +352,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
341
352
|
required: ["x1", "y1", "x2", "y2", "duration"]
|
|
342
353
|
}
|
|
343
354
|
},
|
|
355
|
+
{
|
|
356
|
+
name: "scroll_to_element",
|
|
357
|
+
description: "Scroll the current screen until a target UI element becomes visible, then return its details.",
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
properties: {
|
|
361
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Platform to operate on (required)" },
|
|
362
|
+
selector: {
|
|
363
|
+
type: "object",
|
|
364
|
+
properties: {
|
|
365
|
+
text: { type: "string" },
|
|
366
|
+
resourceId: { type: "string" },
|
|
367
|
+
contentDesc: { type: "string" },
|
|
368
|
+
className: { type: "string" }
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
direction: { type: "string", enum: ["down", "up"], default: "down" },
|
|
372
|
+
maxScrolls: { type: "number", default: 10 },
|
|
373
|
+
scrollAmount: { type: "number", default: 0.7 },
|
|
374
|
+
deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
|
|
375
|
+
},
|
|
376
|
+
required: ["platform", "selector"]
|
|
377
|
+
}
|
|
378
|
+
},
|
|
344
379
|
{
|
|
345
380
|
name: "type_text",
|
|
346
381
|
description: "Type text into the currently focused input field on an Android device.",
|
|
@@ -516,6 +551,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
516
551
|
const res = await ToolsObserve.getCurrentScreenHandler({ deviceId });
|
|
517
552
|
return wrapResponse(res);
|
|
518
553
|
}
|
|
554
|
+
if (name === "get_screen_fingerprint") {
|
|
555
|
+
const { platform, deviceId } = (args || {});
|
|
556
|
+
const res = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId });
|
|
557
|
+
return wrapResponse(res);
|
|
558
|
+
}
|
|
519
559
|
if (name === "wait_for_element") {
|
|
520
560
|
const { platform, text, timeout, deviceId } = (args || {});
|
|
521
561
|
const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
|
|
@@ -527,8 +567,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
527
567
|
return wrapResponse(res);
|
|
528
568
|
}
|
|
529
569
|
if (name === "swipe") {
|
|
530
|
-
const { x1, y1, x2, y2, duration, deviceId } = (args || {});
|
|
531
|
-
const res = await ToolsInteract.swipeHandler({ x1, y1, x2, y2, duration, deviceId });
|
|
570
|
+
const { platform = 'android', x1, y1, x2, y2, duration, deviceId } = (args || {});
|
|
571
|
+
const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId });
|
|
572
|
+
return wrapResponse(res);
|
|
573
|
+
}
|
|
574
|
+
if (name === "scroll_to_element") {
|
|
575
|
+
const { platform, selector, direction, maxScrolls, scrollAmount, deviceId } = (args || {});
|
|
576
|
+
const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId });
|
|
532
577
|
return wrapResponse(res);
|
|
533
578
|
}
|
|
534
579
|
if (name === "type_text") {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
const ANDROID_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewGroup', 'LinearLayout', 'FrameLayout', 'RelativeLayout', 'ScrollView', 'RecyclerView', 'TextView', 'ImageView'];
|
|
3
|
+
const IOS_STRUCTURAL_TYPES = ['Window', 'Application', 'View', 'ViewController', 'UITableView', 'UICollectionView', 'UILabel', 'UIImageView', 'UIView', 'UIWindow', 'UIStackView', 'UITextView', 'UITableViewCell'];
|
|
4
|
+
function isDynamicText(t) {
|
|
5
|
+
if (!t)
|
|
6
|
+
return false;
|
|
7
|
+
const txt = t.trim();
|
|
8
|
+
if (!txt)
|
|
9
|
+
return false;
|
|
10
|
+
if (/\b\d{1,2}:\d{2}\b/.test(txt))
|
|
11
|
+
return true;
|
|
12
|
+
if (/\b\d{4}-\d{2}-\d{2}\b/.test(txt))
|
|
13
|
+
return true;
|
|
14
|
+
if (/^\d+(?:\.\d+)?%$/.test(txt))
|
|
15
|
+
return true;
|
|
16
|
+
if (/^\d+$/.test(txt))
|
|
17
|
+
return true;
|
|
18
|
+
if (/^[\d,]{1,10}$/.test(txt))
|
|
19
|
+
return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function normalizeElement(e) {
|
|
23
|
+
return {
|
|
24
|
+
type: (e.type || '').toString(),
|
|
25
|
+
resourceId: (e.resourceId || '').toString(),
|
|
26
|
+
text: typeof e.text === 'string' ? (isDynamicText(e.text) ? '' : e.text.trim().toLowerCase()) : '',
|
|
27
|
+
contentDesc: (e.contentDescription || '').toString(),
|
|
28
|
+
bounds: Array.isArray(e.bounds) ? e.bounds.slice(0, 4).map((n) => Number(n) || 0) : [0, 0, 0, 0]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function computeScreenFingerprint(tree, current, platform, limit = 50) {
|
|
32
|
+
try {
|
|
33
|
+
if (!tree || tree.error)
|
|
34
|
+
return { fingerprint: null, error: tree.error };
|
|
35
|
+
const activity = current && (current.activity || current.shortActivity) ? (current.activity || current.shortActivity) : '';
|
|
36
|
+
const candidates = (tree.elements || []).filter(e => {
|
|
37
|
+
if (!e)
|
|
38
|
+
return false;
|
|
39
|
+
if (!e.visible)
|
|
40
|
+
return false;
|
|
41
|
+
const hasStableText = typeof e.text === 'string' && e.text.trim().length > 0;
|
|
42
|
+
const hasResource = !!e.resourceId;
|
|
43
|
+
const interactable = !!e.clickable || !!e.enabled;
|
|
44
|
+
const structuralList = platform === 'android' ? ANDROID_STRUCTURAL_TYPES : IOS_STRUCTURAL_TYPES;
|
|
45
|
+
const structurallySignificant = hasStableText || hasResource || structuralList.includes(e.type || '');
|
|
46
|
+
return interactable || structurallySignificant;
|
|
47
|
+
});
|
|
48
|
+
const normalized = candidates.map(normalizeElement);
|
|
49
|
+
const filteredNormalized = normalized.filter(e => (e.text && e.text.length > 0) || (e.resourceId && e.resourceId.length > 0) || (e.contentDesc && e.contentDesc.length > 0));
|
|
50
|
+
filteredNormalized.sort((a, b) => {
|
|
51
|
+
const ay = (a.bounds && a.bounds[1]) || 0;
|
|
52
|
+
const by = (b.bounds && b.bounds[1]) || 0;
|
|
53
|
+
if (ay !== by)
|
|
54
|
+
return ay - by;
|
|
55
|
+
const ax = (a.bounds && a.bounds[0]) || 0;
|
|
56
|
+
const bx = (b.bounds && b.bounds[0]) || 0;
|
|
57
|
+
return ax - bx;
|
|
58
|
+
});
|
|
59
|
+
const limited = filteredNormalized.slice(0, Math.max(0, limit));
|
|
60
|
+
const payload = {
|
|
61
|
+
activity: platform === 'android' ? (activity || '') : '',
|
|
62
|
+
resolution: tree.resolution || { width: 0, height: 0 },
|
|
63
|
+
elements: limited.map(e => ({ type: e.type, resourceId: e.resourceId, text: e.text, contentDesc: e.contentDesc }))
|
|
64
|
+
};
|
|
65
|
+
const combined = JSON.stringify(payload);
|
|
66
|
+
const hash = crypto.createHash('sha256').update(combined).digest('hex');
|
|
67
|
+
return { fingerprint: hash, activity: activity };
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return { fingerprint: null, error: e instanceof Error ? e.message : String(e) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export async function scrollToElementShared(opts) {
|
|
2
|
+
const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
|
|
3
|
+
const matchElement = (el) => {
|
|
4
|
+
if (!el)
|
|
5
|
+
return false;
|
|
6
|
+
if (selector.text !== undefined && selector.text !== el.text)
|
|
7
|
+
return false;
|
|
8
|
+
if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
|
|
9
|
+
return false;
|
|
10
|
+
if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
|
|
11
|
+
return false;
|
|
12
|
+
if (selector.className !== undefined && selector.className !== el.type)
|
|
13
|
+
return false;
|
|
14
|
+
return true;
|
|
15
|
+
};
|
|
16
|
+
const isVisible = (el, resolution) => {
|
|
17
|
+
if (!el)
|
|
18
|
+
return false;
|
|
19
|
+
if (el.visible === false)
|
|
20
|
+
return false;
|
|
21
|
+
if (!el.bounds || !resolution || !resolution.width || !resolution.height)
|
|
22
|
+
return (el.visible === undefined ? true : !!el.visible);
|
|
23
|
+
const [left, top, right, bottom] = el.bounds;
|
|
24
|
+
const withinY = bottom > 0 && top < resolution.height;
|
|
25
|
+
const withinX = right > 0 && left < resolution.width;
|
|
26
|
+
return withinX && withinY;
|
|
27
|
+
};
|
|
28
|
+
const findVisibleMatch = (elements, resolution) => {
|
|
29
|
+
if (!Array.isArray(elements))
|
|
30
|
+
return null;
|
|
31
|
+
for (const e of elements) {
|
|
32
|
+
if (matchElement(e) && isVisible(e, resolution))
|
|
33
|
+
return e;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
// Initial check
|
|
38
|
+
let tree = await fetchTree();
|
|
39
|
+
if (tree.error)
|
|
40
|
+
return { success: false, reason: tree.error, scrollsPerformed: 0 };
|
|
41
|
+
let found = findVisibleMatch(tree.elements, tree.resolution);
|
|
42
|
+
if (found) {
|
|
43
|
+
return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
|
|
44
|
+
}
|
|
45
|
+
const fingerprintOf = (t) => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
let prevFingerprint = fingerprintOf(tree);
|
|
54
|
+
const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
|
|
55
|
+
const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
|
|
56
|
+
const centerX = Math.round(width / 2) || 50;
|
|
57
|
+
const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
|
|
58
|
+
const computeCoords = () => {
|
|
59
|
+
const defaultStart = direction === 'down' ? 0.8 : 0.2;
|
|
60
|
+
const startPct = clampPct(defaultStart);
|
|
61
|
+
const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
|
|
62
|
+
const x1 = centerX;
|
|
63
|
+
const x2 = centerX;
|
|
64
|
+
const y1 = Math.round((height || 100) * startPct);
|
|
65
|
+
const y2 = Math.round((height || 100) * endPct);
|
|
66
|
+
return { x1, y1, x2, y2 };
|
|
67
|
+
};
|
|
68
|
+
const duration = 300;
|
|
69
|
+
let scrollsPerformed = 0;
|
|
70
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
71
|
+
const { x1, y1, x2, y2 } = computeCoords();
|
|
72
|
+
try {
|
|
73
|
+
await swipe(x1, y1, x2, y2, duration, deviceId);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
// Log swipe failures to aid debugging but don't fail the overall flow
|
|
77
|
+
try {
|
|
78
|
+
console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
scrollsPerformed++;
|
|
83
|
+
await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
|
|
84
|
+
tree = await fetchTree();
|
|
85
|
+
if (tree.error)
|
|
86
|
+
return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
|
|
87
|
+
found = findVisibleMatch(tree.elements, tree.resolution);
|
|
88
|
+
if (found) {
|
|
89
|
+
return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
|
|
90
|
+
}
|
|
91
|
+
const fp = fingerprintOf(tree);
|
|
92
|
+
if (fp === prevFingerprint) {
|
|
93
|
+
return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
|
|
94
|
+
}
|
|
95
|
+
prevFingerprint = fp;
|
|
96
|
+
}
|
|
97
|
+
return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
|
|
98
|
+
}
|
package/dist/tools/interact.js
CHANGED
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
2
|
-
import { AndroidInteract } from '../
|
|
3
|
-
import { iOSInteract } from '../
|
|
2
|
+
import { AndroidInteract } from '../interact/index.js';
|
|
3
|
+
import { iOSInteract } from '../interact/index.js';
|
|
4
4
|
export class ToolsInteract {
|
|
5
|
+
static async getInteractionService(platform, deviceId) {
|
|
6
|
+
const effectivePlatform = platform || 'android';
|
|
7
|
+
const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
|
|
8
|
+
const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
|
|
9
|
+
return { interact: interact, resolved, platform: effectivePlatform };
|
|
10
|
+
}
|
|
5
11
|
static async waitForElementHandler({ platform, text, timeout, deviceId }) {
|
|
6
12
|
const effectiveTimeout = timeout ?? 10000;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
|
|
10
|
-
}
|
|
11
|
-
else {
|
|
12
|
-
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
13
|
-
return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
|
|
14
|
-
}
|
|
13
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
14
|
+
return await interact.waitForElement(text, effectiveTimeout, resolved.id);
|
|
15
15
|
}
|
|
16
16
|
static async tapHandler({ platform, x, y, deviceId }) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
20
|
-
return await new AndroidInteract().tap(x, y, resolved.id);
|
|
21
|
-
}
|
|
22
|
-
else {
|
|
23
|
-
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
24
|
-
return await new iOSInteract().tap(x, y, resolved.id);
|
|
25
|
-
}
|
|
17
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
18
|
+
return await interact.tap(x, y, resolved.id);
|
|
26
19
|
}
|
|
27
|
-
static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
|
|
28
|
-
const resolved = await
|
|
29
|
-
return await
|
|
20
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
|
|
21
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
22
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
|
|
30
23
|
}
|
|
31
24
|
static async typeTextHandler({ text, deviceId }) {
|
|
32
25
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
@@ -36,4 +29,8 @@ export class ToolsInteract {
|
|
|
36
29
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
37
30
|
return await new AndroidInteract().pressBack(resolved.id);
|
|
38
31
|
}
|
|
32
|
+
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
|
|
33
|
+
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
34
|
+
return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
|
|
35
|
+
}
|
|
39
36
|
}
|
package/dist/tools/manage.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { resolveTargetDevice, listDevices } from '../utils/resolve-device.js';
|
|
4
|
-
import { AndroidManage } from '../
|
|
5
|
-
import { iOSManage } from '../
|
|
4
|
+
import { AndroidManage } from '../manage/index.js';
|
|
5
|
+
import { iOSManage } from '../manage/index.js';
|
|
6
6
|
import { findApk } from '../android/utils.js';
|
|
7
7
|
import { findAppBundle } from '../ios/utils.js';
|
|
8
8
|
import { execSync } from 'child_process';
|