mobile-debug-mcp 0.14.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/dist/android/interact.js +2 -2
- 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 +2 -2
- 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 +21 -5
- package/dist/shared/fingerprint.js +72 -0
- package/dist/shared/scroll_to_element.js +98 -0
- package/dist/tools/interact.js +2 -2
- package/dist/tools/manage.js +2 -2
- package/dist/tools/observe.js +45 -43
- 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 +4 -0
- package/docs/tools/observe.md +24 -0
- package/package.json +1 -1
- package/src/{android/interact.ts → interact/android.ts} +3 -3
- package/src/{tools/interact.ts → interact/index.ts} +4 -3
- package/src/{ios/interact.ts → interact/ios.ts} +3 -3
- package/src/interact/shared/fingerprint.ts +73 -0
- package/src/{tools → interact/shared}/scroll_to_element.ts +1 -1
- 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 +23 -6
- 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/{device/observe → observe/device}/run-scroll-test-android.ts +2 -2
- 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/{unit/observe → observe/unit}/scroll_to_element.test.ts +3 -3
- package/test/{unit/observe → observe/unit}/wait_for_element_mock.ts +2 -2
- package/test/unit/index.ts +12 -11
- 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.",
|
|
@@ -540,6 +551,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
540
551
|
const res = await ToolsObserve.getCurrentScreenHandler({ deviceId });
|
|
541
552
|
return wrapResponse(res);
|
|
542
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
|
+
}
|
|
543
559
|
if (name === "wait_for_element") {
|
|
544
560
|
const { platform, text, timeout, deviceId } = (args || {});
|
|
545
561
|
const res = await ToolsInteract.waitForElementHandler({ platform, text, timeout, deviceId });
|
|
@@ -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,6 +1,6 @@
|
|
|
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
5
|
static async getInteractionService(platform, deviceId) {
|
|
6
6
|
const effectivePlatform = platform || 'android';
|
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';
|
package/dist/tools/observe.js
CHANGED
|
@@ -1,80 +1,82 @@
|
|
|
1
1
|
import { resolveTargetDevice } from '../utils/resolve-device.js';
|
|
2
|
-
import { AndroidObserve } from '../
|
|
3
|
-
import { iOSObserve } from '../ios/observe.js';
|
|
2
|
+
import { AndroidObserve, iOSObserve } from '../observe/index.js';
|
|
4
3
|
export class ToolsObserve {
|
|
5
|
-
|
|
4
|
+
// Resolve a target device and return the appropriate observe instance and resolved info.
|
|
5
|
+
static async resolveObserve(platform, deviceId, appId) {
|
|
6
6
|
if (platform === 'android') {
|
|
7
|
-
const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
|
|
8
|
-
return
|
|
7
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId });
|
|
8
|
+
return { observe: new AndroidObserve(), resolved };
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
|
|
12
|
-
return
|
|
10
|
+
if (platform === 'ios') {
|
|
11
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId });
|
|
12
|
+
return { observe: new iOSObserve(), resolved };
|
|
13
|
+
}
|
|
14
|
+
// No platform specified: try android then ios
|
|
15
|
+
try {
|
|
16
|
+
const resolved = await resolveTargetDevice({ platform: 'android', deviceId, appId });
|
|
17
|
+
return { observe: new AndroidObserve(), resolved };
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
const resolved = await resolveTargetDevice({ platform: 'ios', deviceId, appId });
|
|
21
|
+
return { observe: new iOSObserve(), resolved };
|
|
13
22
|
}
|
|
14
23
|
}
|
|
24
|
+
static async getUITreeHandler({ platform, deviceId }) {
|
|
25
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
26
|
+
return await observe.getUITree(resolved.id);
|
|
27
|
+
}
|
|
15
28
|
static async getCurrentScreenHandler({ deviceId }) {
|
|
16
|
-
const resolved = await
|
|
17
|
-
|
|
29
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve('android', deviceId);
|
|
30
|
+
// getCurrentScreen is Android-specific
|
|
31
|
+
return await observe.getCurrentScreen(resolved.id);
|
|
18
32
|
}
|
|
19
33
|
static async getLogsHandler({ platform, appId, deviceId, lines }) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const response = await
|
|
34
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
|
|
35
|
+
if (observe instanceof AndroidObserve) {
|
|
36
|
+
const response = await observe.getLogs(appId, lines ?? 200, resolved.id);
|
|
23
37
|
const logs = Array.isArray(response.logs) ? response.logs : [];
|
|
24
38
|
const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'));
|
|
25
39
|
return { device: response.device, logs, crashLines };
|
|
26
40
|
}
|
|
27
41
|
else {
|
|
28
|
-
const
|
|
29
|
-
const resp = await new iOSObserve().getLogs(appId, resolved.id);
|
|
42
|
+
const resp = await observe.getLogs(appId, resolved.id);
|
|
30
43
|
const logs = Array.isArray(resp.logs) ? resp.logs : [];
|
|
31
44
|
const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'));
|
|
32
45
|
return { device: resp.device, logs, crashLines };
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
48
|
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
|
|
36
|
-
const effectivePlatform = platform || 'android';
|
|
37
49
|
const sid = sessionId || 'default';
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return await new AndroidObserve().startLogStream(packageName, level || 'error', resolved.id, sid);
|
|
50
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, packageName);
|
|
51
|
+
if (observe instanceof AndroidObserve) {
|
|
52
|
+
return await observe.startLogStream(packageName, level || 'error', resolved.id, sid);
|
|
42
53
|
}
|
|
43
54
|
else {
|
|
44
|
-
|
|
45
|
-
// Delegate to iOSObserve for starting log streams
|
|
46
|
-
return await new iOSObserve().startLogStream(packageName, resolved.id, sid);
|
|
55
|
+
return await observe.startLogStream(packageName, resolved.id, sid);
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
static async readLogStreamHandler({ platform, sessionId, limit, since }) {
|
|
50
|
-
const effectivePlatform = platform || 'android';
|
|
51
59
|
const sid = sessionId || 'default';
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
return await new iOSObserve().readLogStream(sid, limit ?? 100, since);
|
|
57
|
-
}
|
|
60
|
+
const { observe } = await ToolsObserve.resolveObserve(platform);
|
|
61
|
+
return await observe.readLogStream(sid, limit ?? 100, since);
|
|
58
62
|
}
|
|
59
63
|
static async stopLogStreamHandler({ platform, sessionId }) {
|
|
60
|
-
const effectivePlatform = platform || 'android';
|
|
61
64
|
const sid = sessionId || 'default';
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
return await new iOSObserve().stopLogStream(sid);
|
|
67
|
-
}
|
|
65
|
+
const { observe } = await ToolsObserve.resolveObserve(platform);
|
|
66
|
+
return await observe.stopLogStream(sid);
|
|
68
67
|
}
|
|
69
68
|
static async captureScreenshotHandler({ platform, deviceId }) {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
return await new AndroidObserve().captureScreen(resolved.id);
|
|
69
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
70
|
+
if (observe instanceof AndroidObserve) {
|
|
71
|
+
return await observe.captureScreen(resolved.id);
|
|
74
72
|
}
|
|
75
73
|
else {
|
|
76
|
-
|
|
77
|
-
return await new iOSObserve().captureScreenshot(resolved.id);
|
|
74
|
+
return await observe.captureScreenshot(resolved.id);
|
|
78
75
|
}
|
|
79
76
|
}
|
|
77
|
+
static async getScreenFingerprintHandler({ platform, deviceId } = {}) {
|
|
78
|
+
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId);
|
|
79
|
+
// Both observes implement getScreenFingerprint
|
|
80
|
+
return await observe.getScreenFingerprint(resolved.id);
|
|
81
|
+
}
|
|
80
82
|
}
|