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
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { promises as fsPromises, existsSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { detectJavaHome } from '../java.js';
|
|
5
|
+
export function getAdbCmd() { return process.env.ADB_PATH || 'adb'; }
|
|
6
|
+
/**
|
|
7
|
+
* Prepare Gradle execution options for building an Android project.
|
|
8
|
+
* Returns execCmd (wrapper or gradle), base gradleArgs array, and spawn options including env.
|
|
9
|
+
*/
|
|
10
|
+
export async function prepareGradle(projectPath) {
|
|
11
|
+
const gradlewPath = path.join(projectPath, 'gradlew');
|
|
12
|
+
const gradleCmd = existsSync(gradlewPath) ? './gradlew' : 'gradle';
|
|
13
|
+
const execCmd = existsSync(gradlewPath) ? gradlewPath : gradleCmd;
|
|
14
|
+
// Start with a default task; callers may append/override via env flags
|
|
15
|
+
const gradleArgs = [process.env.MCP_GRADLE_TASK || 'assembleDebug'];
|
|
16
|
+
// Respect generic MCP_BUILD_JOBS and Android-specific MCP_GRADLE_WORKERS
|
|
17
|
+
const workers = process.env.MCP_GRADLE_WORKERS || process.env.MCP_BUILD_JOBS;
|
|
18
|
+
if (workers) {
|
|
19
|
+
gradleArgs.push(`--max-workers=${workers}`);
|
|
20
|
+
}
|
|
21
|
+
// Respect gradle cache env: default enabled; set MCP_GRADLE_CACHE=0 to disable
|
|
22
|
+
if (process.env.MCP_GRADLE_CACHE === '0') {
|
|
23
|
+
gradleArgs.push('-Dorg.gradle.caching=false');
|
|
24
|
+
}
|
|
25
|
+
const detectedJavaHome = await detectJavaHome().catch(() => undefined);
|
|
26
|
+
const env = Object.assign({}, process.env);
|
|
27
|
+
if (detectedJavaHome) {
|
|
28
|
+
if (env.JAVA_HOME !== detectedJavaHome) {
|
|
29
|
+
env.JAVA_HOME = detectedJavaHome;
|
|
30
|
+
env.PATH = `${path.join(detectedJavaHome, 'bin')}${path.delimiter}${env.PATH || ''}`;
|
|
31
|
+
}
|
|
32
|
+
gradleArgs.push(`-Dorg.gradle.java.home=${detectedJavaHome}`);
|
|
33
|
+
gradleArgs.push('--no-daemon');
|
|
34
|
+
env.GRADLE_JAVA_HOME = detectedJavaHome;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
delete env.SHELL;
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
const useWrapper = existsSync(gradlewPath);
|
|
41
|
+
const spawnOpts = { cwd: projectPath, env };
|
|
42
|
+
if (useWrapper) {
|
|
43
|
+
try {
|
|
44
|
+
await fsPromises.chmod(gradlewPath, 0o755);
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
spawnOpts.shell = false;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
spawnOpts.shell = true;
|
|
51
|
+
}
|
|
52
|
+
return { execCmd, gradleArgs, spawnOpts };
|
|
53
|
+
}
|
|
54
|
+
// Helper to construct ADB args with optional device ID
|
|
55
|
+
function getAdbArgs(args, deviceId) {
|
|
56
|
+
if (deviceId) {
|
|
57
|
+
return ['-s', deviceId, ...args];
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Determine an effective ADB timeout (ms) prioritizing:
|
|
63
|
+
* 1. provided customTimeout
|
|
64
|
+
* 2. MCP_ADB_TIMEOUT or ADB_TIMEOUT env vars
|
|
65
|
+
* 3. sensible per-command defaults
|
|
66
|
+
*/
|
|
67
|
+
function getAdbTimeout(args, customTimeout) {
|
|
68
|
+
if (typeof customTimeout === 'number' && !isNaN(customTimeout))
|
|
69
|
+
return customTimeout;
|
|
70
|
+
const envTimeout = parseInt(process.env.MCP_ADB_TIMEOUT || process.env.ADB_TIMEOUT || '', 10);
|
|
71
|
+
if (!isNaN(envTimeout) && envTimeout > 0)
|
|
72
|
+
return envTimeout;
|
|
73
|
+
if (args.includes('logcat'))
|
|
74
|
+
return 10000;
|
|
75
|
+
if (args.includes('uiautomator') && args.includes('dump'))
|
|
76
|
+
return 20000;
|
|
77
|
+
return 120000;
|
|
78
|
+
}
|
|
79
|
+
export function execAdb(args, deviceId, options = {}) {
|
|
80
|
+
const adbArgs = getAdbArgs(args, deviceId);
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
// Extract timeout from options if present, otherwise pass options to spawn
|
|
83
|
+
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
84
|
+
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
85
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
|
|
86
|
+
let stdout = '';
|
|
87
|
+
let stderr = '';
|
|
88
|
+
if (child.stdout) {
|
|
89
|
+
child.stdout.on('data', (data) => {
|
|
90
|
+
stdout += data.toString();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (child.stderr) {
|
|
94
|
+
child.stderr.on('data', (data) => {
|
|
95
|
+
stderr += data.toString();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const timeoutMs = getAdbTimeout(args, customTimeout);
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
child.kill();
|
|
101
|
+
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
child.on('close', (code) => {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
if (code !== 0) {
|
|
106
|
+
// If there's an actual error (non-zero exit code), reject
|
|
107
|
+
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// If exit code is 0, resolve with stdout
|
|
111
|
+
resolve(stdout.trim());
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
child.on('error', (err) => {
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
reject(err);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Spawn adb but return full streams and exit code so callers can implement fallbacks or stream output
|
|
121
|
+
export function spawnAdb(args, deviceId, options = {}) {
|
|
122
|
+
const adbArgs = getAdbArgs(args, deviceId);
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
125
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions);
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
if (child.stdout)
|
|
129
|
+
child.stdout.on('data', d => { stdout += d.toString(); });
|
|
130
|
+
if (child.stderr)
|
|
131
|
+
child.stderr.on('data', d => { stderr += d.toString(); });
|
|
132
|
+
const timeoutMs = getAdbTimeout(args, customTimeout);
|
|
133
|
+
const timeout = setTimeout(() => {
|
|
134
|
+
try {
|
|
135
|
+
child.kill();
|
|
136
|
+
}
|
|
137
|
+
catch { }
|
|
138
|
+
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
child.on('close', (code) => {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
|
|
143
|
+
});
|
|
144
|
+
child.on('error', (err) => {
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
reject(err);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function getDeviceInfo(deviceId, metadata = {}) {
|
|
151
|
+
return {
|
|
152
|
+
platform: 'android',
|
|
153
|
+
id: deviceId || 'default',
|
|
154
|
+
osVersion: metadata.osVersion || '',
|
|
155
|
+
model: metadata.model || '',
|
|
156
|
+
simulator: metadata.simulator || false
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export async function getAndroidDeviceMetadata(appId, deviceId) {
|
|
160
|
+
try {
|
|
161
|
+
// If no deviceId provided, try to auto-detect a single connected device
|
|
162
|
+
let resolvedDeviceId = deviceId;
|
|
163
|
+
if (!resolvedDeviceId) {
|
|
164
|
+
try {
|
|
165
|
+
const devicesOutput = await execAdb(['devices']);
|
|
166
|
+
// Parse lines like: "<serial>\tdevice"
|
|
167
|
+
const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
|
|
168
|
+
const deviceLines = lines.slice(1) // skip header
|
|
169
|
+
.map(l => l.split('\t'))
|
|
170
|
+
.filter(parts => parts.length >= 2 && parts[1] === 'device')
|
|
171
|
+
.map(parts => parts[0]);
|
|
172
|
+
if (deviceLines.length === 1) {
|
|
173
|
+
resolvedDeviceId = deviceLines[0];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// ignore and continue without resolvedDeviceId
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Run these in parallel to avoid sequential timeouts
|
|
181
|
+
const [osVersion, model, simOutput] = await Promise.all([
|
|
182
|
+
execAdb(['shell', 'getprop', 'ro.build.version.release'], resolvedDeviceId).catch(() => ''),
|
|
183
|
+
execAdb(['shell', 'getprop', 'ro.product.model'], resolvedDeviceId).catch(() => ''),
|
|
184
|
+
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], resolvedDeviceId).catch(() => '0')
|
|
185
|
+
]);
|
|
186
|
+
const simulator = simOutput === '1';
|
|
187
|
+
return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function findApk(dir) {
|
|
194
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
195
|
+
for (const e of entries) {
|
|
196
|
+
const full = path.join(dir, e.name);
|
|
197
|
+
if (e.isDirectory()) {
|
|
198
|
+
const found = await findApk(full);
|
|
199
|
+
if (found)
|
|
200
|
+
return found;
|
|
201
|
+
}
|
|
202
|
+
else if (e.isFile() && full.endsWith('.apk')) {
|
|
203
|
+
return full;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
export async function listAndroidDevices(appId) {
|
|
209
|
+
try {
|
|
210
|
+
const devicesOutput = await execAdb(['devices', '-l']);
|
|
211
|
+
const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
|
|
212
|
+
// Skip header if present (some adb versions include 'List of devices attached')
|
|
213
|
+
const deviceLines = lines.filter(l => !l.startsWith('List of devices')).map(l => l);
|
|
214
|
+
const serials = deviceLines.map(line => line.split(/\s+/)[0]).filter(Boolean);
|
|
215
|
+
const infos = await Promise.all(serials.map(async (serial) => {
|
|
216
|
+
try {
|
|
217
|
+
const [osVersion, model, simOutput] = await Promise.all([
|
|
218
|
+
execAdb(['shell', 'getprop', 'ro.build.version.release'], serial).catch(() => ''),
|
|
219
|
+
execAdb(['shell', 'getprop', 'ro.product.model'], serial).catch(() => ''),
|
|
220
|
+
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
|
|
221
|
+
]);
|
|
222
|
+
const simulator = simOutput === '1';
|
|
223
|
+
let appInstalled = false;
|
|
224
|
+
if (appId) {
|
|
225
|
+
try {
|
|
226
|
+
const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
|
|
227
|
+
appInstalled = !!(pm && pm.includes('package:'));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
appInstalled = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
return infos;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// UI helper utilities shared by observe/interact
|
|
246
|
+
export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
247
|
+
export function parseBounds(bounds) {
|
|
248
|
+
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
249
|
+
if (match) {
|
|
250
|
+
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
|
|
251
|
+
}
|
|
252
|
+
return [0, 0, 0, 0];
|
|
253
|
+
}
|
|
254
|
+
export function getCenter(bounds) {
|
|
255
|
+
const [x1, y1, x2, y2] = bounds;
|
|
256
|
+
return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
|
|
257
|
+
}
|
|
258
|
+
export async function getScreenResolution(deviceId) {
|
|
259
|
+
try {
|
|
260
|
+
const output = await execAdb(['shell', 'wm', 'size'], deviceId);
|
|
261
|
+
const match = output.match(/Physical size: (\d+)x(\d+)/);
|
|
262
|
+
if (match) {
|
|
263
|
+
return { width: parseInt(match[1]), height: parseInt(match[2]) };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// ignore
|
|
268
|
+
}
|
|
269
|
+
return { width: 0, height: 0 };
|
|
270
|
+
}
|
|
271
|
+
export function traverseNode(node, elements, parentIndex = -1, depth = 0) {
|
|
272
|
+
if (!node)
|
|
273
|
+
return -1;
|
|
274
|
+
let currentIndex = -1;
|
|
275
|
+
if (node['@_class']) {
|
|
276
|
+
const text = node['@_text'] || null;
|
|
277
|
+
const contentDescription = node['@_content-desc'] || null;
|
|
278
|
+
const clickable = node['@_clickable'] === 'true';
|
|
279
|
+
const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
|
|
280
|
+
const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
|
|
281
|
+
if (isUseful) {
|
|
282
|
+
const element = {
|
|
283
|
+
text,
|
|
284
|
+
contentDescription,
|
|
285
|
+
type: node['@_class'] || 'unknown',
|
|
286
|
+
resourceId: node['@_resource-id'] || null,
|
|
287
|
+
clickable,
|
|
288
|
+
enabled: node['@_enabled'] === 'true',
|
|
289
|
+
visible: true,
|
|
290
|
+
bounds,
|
|
291
|
+
center: getCenter(bounds),
|
|
292
|
+
depth
|
|
293
|
+
};
|
|
294
|
+
if (parentIndex !== -1) {
|
|
295
|
+
element.parentId = parentIndex;
|
|
296
|
+
}
|
|
297
|
+
elements.push(element);
|
|
298
|
+
currentIndex = elements.length - 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
|
|
302
|
+
const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
|
|
303
|
+
const childrenIndices = [];
|
|
304
|
+
if (node.node) {
|
|
305
|
+
if (Array.isArray(node.node)) {
|
|
306
|
+
node.node.forEach((child) => {
|
|
307
|
+
const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
|
|
308
|
+
if (childIndex !== -1)
|
|
309
|
+
childrenIndices.push(childIndex);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
|
|
314
|
+
if (childIndex !== -1)
|
|
315
|
+
childrenIndices.push(childIndex);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (currentIndex !== -1 && childrenIndices.length > 0) {
|
|
319
|
+
elements[currentIndex].children = childrenIndices;
|
|
320
|
+
}
|
|
321
|
+
return currentIndex;
|
|
322
|
+
}
|
|
323
|
+
// Log stream management (one stream per session)
|
|
324
|
+
// (Legacy active stream map removed from utils during refactor; Observe modules manage their own active streams.)
|
|
325
|
+
// Robust log line parser supporting multiple logcat formats
|
|
326
|
+
export function parseLogLine(line) {
|
|
327
|
+
const rawLine = line;
|
|
328
|
+
const normalizedLine = rawLine.replace(/\r?\n/g, ' ');
|
|
329
|
+
const entry = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false };
|
|
330
|
+
const nowYear = new Date().getFullYear();
|
|
331
|
+
const tryIso = (ts) => {
|
|
332
|
+
if (!ts)
|
|
333
|
+
return null;
|
|
334
|
+
// If it's already ISO
|
|
335
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(ts))
|
|
336
|
+
return ts;
|
|
337
|
+
// If format MM-DD HH:MM:SS(.sss)
|
|
338
|
+
const m = ts.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
|
|
339
|
+
if (m) {
|
|
340
|
+
const month = m[1];
|
|
341
|
+
const day = m[2];
|
|
342
|
+
const time = m[3];
|
|
343
|
+
const candidate = `${nowYear}-${month}-${day}T${time}`;
|
|
344
|
+
const d = new Date(candidate);
|
|
345
|
+
if (!isNaN(d.getTime()))
|
|
346
|
+
return d.toISOString();
|
|
347
|
+
}
|
|
348
|
+
// If format YYYY-MM-DD HH:MM:SS(.sss)
|
|
349
|
+
const m2 = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
|
|
350
|
+
if (m2) {
|
|
351
|
+
const candidate = `${m2[1]}T${m2[2]}`;
|
|
352
|
+
const d = new Date(candidate);
|
|
353
|
+
if (!isNaN(d.getTime()))
|
|
354
|
+
return d.toISOString();
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
};
|
|
358
|
+
// Patterns to try (ordered)
|
|
359
|
+
const patterns = [
|
|
360
|
+
// MM-DD HH:MM:SS.mmm PID TID LEVEL/Tag: msg
|
|
361
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
362
|
+
// MM-DD HH:MM:SS.mmm PID TID LEVEL Tag: msg (space between level and tag)
|
|
363
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
364
|
+
// YYYY-MM-DD full date with PID TID LEVEL/Tag
|
|
365
|
+
{ re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
366
|
+
// YYYY-MM-DD with space separation
|
|
367
|
+
{ re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
368
|
+
// MM-DD PID LEVEL/Tag: msg
|
|
369
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
|
|
370
|
+
// MM-DD PID LEVEL Tag: msg (space)
|
|
371
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
|
|
372
|
+
// Short form LEVEL/Tag: msg
|
|
373
|
+
{ re: /^([VDIWE])\/([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
|
|
374
|
+
// Short form LEVEL Tag: msg
|
|
375
|
+
{ re: /^([VDIWE])\s+([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
|
|
376
|
+
];
|
|
377
|
+
for (const p of patterns) {
|
|
378
|
+
const m = normalizedLine.match(p.re);
|
|
379
|
+
if (m) {
|
|
380
|
+
const g = p.groups;
|
|
381
|
+
const vals = {};
|
|
382
|
+
for (let i = 0; i < g.length; i++)
|
|
383
|
+
vals[g[i]] = m[i + 1];
|
|
384
|
+
const ts = vals.ts;
|
|
385
|
+
if (ts) {
|
|
386
|
+
const iso = tryIso(ts);
|
|
387
|
+
if (iso) {
|
|
388
|
+
entry.timestamp = ts;
|
|
389
|
+
entry._iso = iso;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
entry.timestamp = ts;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (vals.level)
|
|
396
|
+
entry.level = vals.level;
|
|
397
|
+
if (vals.tag)
|
|
398
|
+
entry.tag = vals.tag.trim();
|
|
399
|
+
entry.message = vals.msg || entry.message;
|
|
400
|
+
// Crash heuristics
|
|
401
|
+
const msg = (entry.message || '').toString();
|
|
402
|
+
const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
|
|
403
|
+
if (crash) {
|
|
404
|
+
entry.crash = true;
|
|
405
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
406
|
+
if (exMatch)
|
|
407
|
+
entry.exception = exMatch[1];
|
|
408
|
+
}
|
|
409
|
+
return entry;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// No pattern matched: attempt to extract level/tag like '... E/Tag: msg'
|
|
413
|
+
const alt = normalizedLine.match(/([VDIWE])\/([^:]+):\s*(.*)$/);
|
|
414
|
+
if (alt) {
|
|
415
|
+
entry.level = alt[1];
|
|
416
|
+
entry.tag = alt[2].trim();
|
|
417
|
+
entry.message = alt[3];
|
|
418
|
+
const msg = entry.message;
|
|
419
|
+
const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
|
|
420
|
+
if (crash) {
|
|
421
|
+
entry.crash = true;
|
|
422
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
423
|
+
if (exMatch)
|
|
424
|
+
entry.exception = exMatch[1];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return entry;
|
|
428
|
+
}
|
|
429
|
+
// Legacy readLogStreamLines shim removed. Use AndroidObserve.readLogStream(sessionId, limit, since) instead.
|