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,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { getIdbCmd, isIDBInstalled, commandWhich } from '../idb/idb-helper.js';
|
|
6
|
+
async function exists(p) {
|
|
7
|
+
try {
|
|
8
|
+
await fs.promises.access(p);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function findFirst(root, patterns, maxDepth = 4) {
|
|
16
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
17
|
+
while (queue.length) {
|
|
18
|
+
const { dir, depth } = queue.shift();
|
|
19
|
+
try {
|
|
20
|
+
console.error('DEBUG findFirst: reading dir', dir, 'depth', depth);
|
|
21
|
+
const ents = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
22
|
+
console.error('DEBUG findFirst: entries', ents.map(e => e.name));
|
|
23
|
+
for (const e of ents) {
|
|
24
|
+
const full = path.join(dir, e.name);
|
|
25
|
+
for (const p of patterns) {
|
|
26
|
+
if (e.name.endsWith(p)) {
|
|
27
|
+
console.error('DEBUG findFirst: matched', full);
|
|
28
|
+
return full;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (e.isDirectory() && depth < maxDepth)
|
|
32
|
+
queue.push({ dir: full, depth: depth + 1 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error('DEBUG findFirst: read failed for', dir, err);
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function startCompanionIfNeeded(companionPath, udid) {
|
|
43
|
+
if (!companionPath || !udid)
|
|
44
|
+
return { started: false, error: 'missing companion or udid' };
|
|
45
|
+
try {
|
|
46
|
+
const child = spawn(companionPath, ['--udid', udid], { detached: true, stdio: 'ignore' });
|
|
47
|
+
child.unref();
|
|
48
|
+
return { started: true };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { started: false, error: e.message };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
const args = process.argv.slice(2);
|
|
56
|
+
let projectArg;
|
|
57
|
+
let udid;
|
|
58
|
+
let startCompanion = false;
|
|
59
|
+
let kmpBuild = null;
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const a = args[i];
|
|
62
|
+
if (a === '--project' && args[i + 1]) {
|
|
63
|
+
projectArg = args[i + 1];
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
else if (a === '--udid' && args[i + 1]) {
|
|
67
|
+
udid = args[i + 1];
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
else if (a === '--start-companion')
|
|
71
|
+
startCompanion = true;
|
|
72
|
+
else if (!projectArg)
|
|
73
|
+
projectArg = a;
|
|
74
|
+
}
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
const projectRoot = projectArg ? path.resolve(projectArg) : cwd;
|
|
77
|
+
// If user passed a direct .xcodeproj or .xcworkspace path, accept it as the projectFile
|
|
78
|
+
let projectFile = null;
|
|
79
|
+
try {
|
|
80
|
+
const stat = await fs.promises.stat(projectRoot);
|
|
81
|
+
if (stat.isFile() && (projectRoot.endsWith('.xcodeproj') || projectRoot.endsWith('.xcworkspace'))) {
|
|
82
|
+
projectFile = projectRoot;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
// detect project if not a direct file
|
|
89
|
+
if (!projectFile) {
|
|
90
|
+
const projectFound = await exists(projectRoot);
|
|
91
|
+
if (projectFound) {
|
|
92
|
+
projectFile = await findFirst(projectRoot, ['.xcworkspace', '.xcodeproj'], 2);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// attempt to find under cwd
|
|
96
|
+
projectFile = await findFirst(cwd, ['.xcworkspace', '.xcodeproj'], 3);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 2) KMP Shared.framework detection (search for *.framework named Shared.framework)
|
|
100
|
+
let kmpFramework = null;
|
|
101
|
+
const projectSearchRoot = projectFile ? (fs.existsSync(projectFile) && fs.lstatSync(projectFile).isDirectory() ? projectFile : path.dirname(projectFile)) : cwd;
|
|
102
|
+
kmpFramework = await findFirst(projectSearchRoot, ['Shared.framework'], 5);
|
|
103
|
+
if (!kmpFramework)
|
|
104
|
+
kmpFramework = await findFirst(cwd, ['Shared.framework'], 6);
|
|
105
|
+
// 3) idb detection
|
|
106
|
+
const idbPath = getIdbCmd();
|
|
107
|
+
const idbAvailable = idbPath ? isIDBInstalled() : false;
|
|
108
|
+
// 4) idb_companion
|
|
109
|
+
const companionPath = commandWhich('idb_companion');
|
|
110
|
+
const companionAvailable = !!companionPath;
|
|
111
|
+
const suggestions = [];
|
|
112
|
+
if (!projectFile)
|
|
113
|
+
suggestions.push('Provide correct project path or ensure .xcodeproj/.xcworkspace exists in project dir');
|
|
114
|
+
if (!kmpFramework)
|
|
115
|
+
suggestions.push('Run KMP Gradle task to produce Shared.framework before xcodebuild (e.g., :shared:embedAndSignAppleFrameworkForXcode)');
|
|
116
|
+
if (!idbAvailable)
|
|
117
|
+
suggestions.push('Ensure idb is in PATH or set MCP_IDB_PATH / IDB_PATH');
|
|
118
|
+
if (!companionAvailable)
|
|
119
|
+
suggestions.push('Install idb_companion and ensure it is in PATH');
|
|
120
|
+
const result = {
|
|
121
|
+
ok: !!projectFile && !!idbAvailable,
|
|
122
|
+
project: {
|
|
123
|
+
root: projectRoot,
|
|
124
|
+
found: !!projectFile,
|
|
125
|
+
projectFile: projectFile
|
|
126
|
+
},
|
|
127
|
+
kmp: {
|
|
128
|
+
found: !!kmpFramework,
|
|
129
|
+
path: kmpFramework,
|
|
130
|
+
build: kmpBuild
|
|
131
|
+
},
|
|
132
|
+
idb: {
|
|
133
|
+
cmd: idbPath,
|
|
134
|
+
installed: idbAvailable
|
|
135
|
+
},
|
|
136
|
+
idb_companion: {
|
|
137
|
+
cmd: companionPath,
|
|
138
|
+
installed: companionAvailable
|
|
139
|
+
},
|
|
140
|
+
suggestions
|
|
141
|
+
};
|
|
142
|
+
if (startCompanion && udid) {
|
|
143
|
+
const started = startCompanionIfNeeded(companionPath, udid);
|
|
144
|
+
result.idb_companion.start = started;
|
|
145
|
+
if (started.started)
|
|
146
|
+
result.ok = result.ok && true;
|
|
147
|
+
}
|
|
148
|
+
console.log(JSON.stringify(result, null, 2));
|
|
149
|
+
process.exit(result.ok ? 0 : 2);
|
|
150
|
+
}
|
|
151
|
+
main().catch(e => {
|
|
152
|
+
// Report structured error on stdout (avoid noisy stderr in normal runs)
|
|
153
|
+
console.log(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }, null, 2));
|
|
154
|
+
process.exit(2);
|
|
155
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { iOSObserve } from '../../../observe/index.js';
|
|
2
|
+
import { iOSManage } from '../../../manage/index.js';
|
|
3
|
+
async function main() {
|
|
4
|
+
const appId = process.argv[2] || 'com.apple.springboard';
|
|
5
|
+
const deviceId = 'booted';
|
|
6
|
+
const obs = new iOSObserve();
|
|
7
|
+
const manage = new iOSManage();
|
|
8
|
+
try {
|
|
9
|
+
console.log('[1] startApp ->', appId);
|
|
10
|
+
const start = await manage.startApp(appId, deviceId);
|
|
11
|
+
console.log('start result:', start);
|
|
12
|
+
console.log('[2] captureScreenshot');
|
|
13
|
+
const shot = await obs.captureScreenshot(deviceId);
|
|
14
|
+
console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
|
|
15
|
+
console.log('[3] getLogs');
|
|
16
|
+
const logs = await obs.getLogs(appId, undefined);
|
|
17
|
+
console.log('logs count:', logs.logCount);
|
|
18
|
+
console.log('[4] terminateApp');
|
|
19
|
+
const term = await manage.terminateApp(appId, deviceId);
|
|
20
|
+
console.log('terminate:', term);
|
|
21
|
+
console.log('SMOKE OK');
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error('SMOKE ERROR:', err instanceof Error ? err.message : String(err));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
main();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { iOSObserve } from '../../../observe/index.js';
|
|
2
|
+
import { iOSInteract } from '../../../interact/index.js';
|
|
3
|
+
async function main() {
|
|
4
|
+
const deviceId = 'booted';
|
|
5
|
+
const obs = new iOSObserve();
|
|
6
|
+
const interact = new iOSInteract();
|
|
7
|
+
console.log('Fetching UI tree...');
|
|
8
|
+
const tree = await obs.getUITree(deviceId);
|
|
9
|
+
if (tree.error) {
|
|
10
|
+
console.error('getUITree error:', tree.error);
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
console.log('Elements found:', tree.elements.length);
|
|
14
|
+
if (!tree.elements || tree.elements.length === 0) {
|
|
15
|
+
console.error('No elements found; aborting');
|
|
16
|
+
process.exit(3);
|
|
17
|
+
}
|
|
18
|
+
const clickable = tree.elements.find((e) => e.clickable) || tree.elements[0];
|
|
19
|
+
console.log('Using element:', clickable.text || '(no text)', 'clickable=', clickable.clickable, 'center=', clickable.center);
|
|
20
|
+
const [x, y] = clickable.center || [0, 0];
|
|
21
|
+
console.log(`Tapping at ${x},${y}...`);
|
|
22
|
+
const res = await interact.tap(x, y, deviceId);
|
|
23
|
+
console.log('Tap result:', res);
|
|
24
|
+
if (res.success)
|
|
25
|
+
process.exit(0);
|
|
26
|
+
else
|
|
27
|
+
process.exit(4);
|
|
28
|
+
}
|
|
29
|
+
main();
|
|
@@ -25,7 +25,7 @@ export class DiagnosticError extends Error {
|
|
|
25
25
|
}
|
|
26
26
|
// Exec ADB with diagnostics — moved from src/android/diagnostics.ts
|
|
27
27
|
import { spawnSync } from 'child_process';
|
|
28
|
-
import { getAdbCmd } from '
|
|
28
|
+
import { getAdbCmd } from './android/utils.js';
|
|
29
29
|
export function execAdbWithDiagnostics(args, deviceId) {
|
|
30
30
|
const adbArgs = deviceId ? ['-s', deviceId, ...args] : args;
|
|
31
31
|
const timeout = 120000;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { execFile, spawn, execSync, spawnSync } from "child_process";
|
|
2
|
+
import { promises as fsPromises } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { makeEnvSnapshot } from '../diagnostics.js';
|
|
5
|
+
export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun'; }
|
|
6
|
+
export function getConfiguredIdbPath() {
|
|
7
|
+
if (process.env.MCP_IDB_PATH)
|
|
8
|
+
return process.env.MCP_IDB_PATH;
|
|
9
|
+
if (process.env.IDB_PATH)
|
|
10
|
+
return process.env.IDB_PATH;
|
|
11
|
+
const cfgPaths = [
|
|
12
|
+
process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
|
|
13
|
+
`${process.cwd()}/mcp.config.json`
|
|
14
|
+
];
|
|
15
|
+
try {
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
for (const p of cfgPaths) {
|
|
18
|
+
if (!p)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(p)) {
|
|
22
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
23
|
+
const json = JSON.parse(raw);
|
|
24
|
+
if (json) {
|
|
25
|
+
if (json.idbPath)
|
|
26
|
+
return json.idbPath;
|
|
27
|
+
if (json.IDB_PATH)
|
|
28
|
+
return json.IDB_PATH;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function getIdbCmd() {
|
|
39
|
+
const cfg = getConfiguredIdbPath();
|
|
40
|
+
if (cfg)
|
|
41
|
+
return cfg;
|
|
42
|
+
if (process.env.IDB_PATH)
|
|
43
|
+
return process.env.IDB_PATH;
|
|
44
|
+
try {
|
|
45
|
+
const p = execSync('which idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
46
|
+
if (p)
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
try {
|
|
51
|
+
const p2 = execSync('command -v idb', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
52
|
+
if (p2)
|
|
53
|
+
return p2;
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
// check common user locations
|
|
57
|
+
const common = [
|
|
58
|
+
`${process.env.HOME}/Library/Python/3.9/bin/idb`,
|
|
59
|
+
`${process.env.HOME}/Library/Python/3.10/bin/idb`,
|
|
60
|
+
'/opt/homebrew/bin/idb',
|
|
61
|
+
'/usr/local/bin/idb',
|
|
62
|
+
];
|
|
63
|
+
for (const c of common) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`test -x ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
66
|
+
return c;
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
70
|
+
return 'idb';
|
|
71
|
+
}
|
|
72
|
+
export async function isIDBInstalled() {
|
|
73
|
+
const cmd = getIdbCmd();
|
|
74
|
+
try {
|
|
75
|
+
execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
try {
|
|
80
|
+
execSync(`${cmd} list-targets --json`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
89
|
+
export function validateBundleId(bundleId) {
|
|
90
|
+
if (!bundleId)
|
|
91
|
+
return;
|
|
92
|
+
// Allow alphanumeric, dots, hyphens, and underscores.
|
|
93
|
+
if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
|
|
94
|
+
throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function execCommand(args, deviceId = "booted") {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
// Use spawn for better stream control and consistency with Android implementation
|
|
100
|
+
const child = spawn(getXcrunCmd(), args);
|
|
101
|
+
let stdout = '';
|
|
102
|
+
let stderr = '';
|
|
103
|
+
if (child.stdout) {
|
|
104
|
+
child.stdout.on('data', (data) => {
|
|
105
|
+
stdout += data.toString();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (child.stderr) {
|
|
109
|
+
child.stderr.on('data', (data) => {
|
|
110
|
+
stderr += data.toString();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
|
|
114
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
|
|
115
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
|
|
116
|
+
const timeout = setTimeout(() => {
|
|
117
|
+
child.kill();
|
|
118
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
child.on('close', (code) => {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
if (code !== 0) {
|
|
123
|
+
reject(new Error(stderr.trim() || `Command failed with code ${code}`));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on('error', (err) => {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
reject(err);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export function execCommandWithDiagnostics(args, deviceId = "booted") {
|
|
136
|
+
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
137
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
|
|
138
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
|
|
139
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT;
|
|
140
|
+
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs });
|
|
141
|
+
const runResult = {
|
|
142
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
143
|
+
stdout: res.stdout || '',
|
|
144
|
+
stderr: res.stderr || '',
|
|
145
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
|
|
146
|
+
command: getXcrunCmd(),
|
|
147
|
+
args,
|
|
148
|
+
deviceId
|
|
149
|
+
};
|
|
150
|
+
if (res.status !== 0) {
|
|
151
|
+
// include suggested fixes for common errors
|
|
152
|
+
const suggested = [];
|
|
153
|
+
if ((runResult.stderr || '').includes('xcodebuild: error')) {
|
|
154
|
+
suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.');
|
|
155
|
+
}
|
|
156
|
+
if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
|
|
157
|
+
suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.');
|
|
158
|
+
}
|
|
159
|
+
// Return diagnostics object
|
|
160
|
+
return { runResult: { ...runResult, suggestedFixes: suggested } };
|
|
161
|
+
}
|
|
162
|
+
return { runResult: { ...runResult, suggestedFixes: [] } };
|
|
163
|
+
}
|
|
164
|
+
function parseRuntimeName(runtime) {
|
|
165
|
+
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
166
|
+
try {
|
|
167
|
+
const parts = runtime.split('.');
|
|
168
|
+
const lastPart = parts[parts.length - 1]; // e.g. "iOS-17-0"
|
|
169
|
+
// Split by hyphen to separate OS from version numbers
|
|
170
|
+
// e.g. "iOS-17-0" -> ["iOS", "17", "0"]
|
|
171
|
+
const segments = lastPart.split('-');
|
|
172
|
+
if (segments.length > 1) {
|
|
173
|
+
const os = segments[0]; // "iOS"
|
|
174
|
+
const version = segments.slice(1).join('.'); // "17.0"
|
|
175
|
+
return `${os} ${version}`;
|
|
176
|
+
}
|
|
177
|
+
return lastPart;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return runtime;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function findAppBundle(dir) {
|
|
184
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
185
|
+
for (const e of entries) {
|
|
186
|
+
const full = path.join(dir, e.name);
|
|
187
|
+
if (e.isDirectory()) {
|
|
188
|
+
if (full.endsWith('.app'))
|
|
189
|
+
return full;
|
|
190
|
+
const found = await findAppBundle(full);
|
|
191
|
+
if (found)
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
200
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
201
|
+
const fallback = {
|
|
202
|
+
platform: "ios",
|
|
203
|
+
id: deviceId,
|
|
204
|
+
osVersion: "Unknown",
|
|
205
|
+
model: "Simulator",
|
|
206
|
+
simulator: true,
|
|
207
|
+
};
|
|
208
|
+
if (err || !stdout) {
|
|
209
|
+
resolve(fallback);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const data = JSON.parse(stdout);
|
|
214
|
+
const devicesMap = data.devices || {};
|
|
215
|
+
for (const runtime in devicesMap) {
|
|
216
|
+
const devices = devicesMap[runtime];
|
|
217
|
+
if (Array.isArray(devices)) {
|
|
218
|
+
for (const device of devices) {
|
|
219
|
+
if (deviceId === "booted" || device.udid === deviceId) {
|
|
220
|
+
resolve({
|
|
221
|
+
platform: "ios",
|
|
222
|
+
id: device.udid,
|
|
223
|
+
osVersion: parseRuntimeName(runtime),
|
|
224
|
+
model: device.name,
|
|
225
|
+
simulator: true,
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
resolve(fallback);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
resolve(fallback);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export async function listIOSDevices(appId) {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
// Query all devices and separately query booted devices to mark them
|
|
243
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
244
|
+
if (err || !stdout)
|
|
245
|
+
return resolve([]);
|
|
246
|
+
try {
|
|
247
|
+
const data = JSON.parse(stdout);
|
|
248
|
+
const devicesMap = data.devices || {};
|
|
249
|
+
const out = [];
|
|
250
|
+
const checks = [];
|
|
251
|
+
// Get booted devices set
|
|
252
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err2, stdout2) => {
|
|
253
|
+
const bootedSet = new Set();
|
|
254
|
+
if (!err2 && stdout2) {
|
|
255
|
+
try {
|
|
256
|
+
const bdata = JSON.parse(stdout2);
|
|
257
|
+
const bmap = bdata.devices || {};
|
|
258
|
+
for (const rt in bmap) {
|
|
259
|
+
const devs = bmap[rt];
|
|
260
|
+
if (Array.isArray(devs))
|
|
261
|
+
for (const d of devs)
|
|
262
|
+
bootedSet.add(d.udid);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
}
|
|
267
|
+
for (const runtime in devicesMap) {
|
|
268
|
+
const devices = devicesMap[runtime];
|
|
269
|
+
if (Array.isArray(devices)) {
|
|
270
|
+
for (const device of devices) {
|
|
271
|
+
const info = {
|
|
272
|
+
platform: 'ios',
|
|
273
|
+
id: device.udid,
|
|
274
|
+
osVersion: parseRuntimeName(runtime),
|
|
275
|
+
model: device.name,
|
|
276
|
+
simulator: true,
|
|
277
|
+
booted: bootedSet.has(device.udid)
|
|
278
|
+
};
|
|
279
|
+
if (appId) {
|
|
280
|
+
// check if installed
|
|
281
|
+
const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
|
|
282
|
+
.then(() => { info.appInstalled = true; })
|
|
283
|
+
.catch(() => { info.appInstalled = false; })
|
|
284
|
+
.then(() => { out.push(info); });
|
|
285
|
+
checks.push(p);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
out.push(info);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
resolve([]);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { listAndroidDevices } from "
|
|
2
|
-
import { listIOSDevices } from "
|
|
1
|
+
import { listAndroidDevices } from "./android/utils.js";
|
|
2
|
+
import { listIOSDevices } from "./ios/utils.js";
|
|
3
3
|
function parseNumericVersion(v) {
|
|
4
4
|
if (!v)
|
|
5
5
|
return 0;
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.15.0]
|
|
6
|
+
- Reorganised repository for cohesion: merged tool handlers into feature entrypoints (src/observe, src/interact, src/manage) and moved platform helpers and CLI tooling into src/utils/{android,ios,cli}.
|
|
7
|
+
- Added computeScreenFingerprint utility used by observe/interact to normalise UI element significance across platforms (fingerprint shared between Android and iOS implementations).
|
|
8
|
+
|
|
5
9
|
## [0.14.0]
|
|
6
10
|
- Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
|
|
7
11
|
- Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
|
package/docs/tools/observe.md
CHANGED
|
@@ -76,6 +76,30 @@ Response:
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## get_screen_fingerprint
|
|
80
|
+
Generate a stable fingerprint representing the visible screen. Useful for detecting navigation changes, preventing loops, and synchronisation.
|
|
81
|
+
|
|
82
|
+
Input (optional):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
{ "platform": "android", "deviceId": "emulator-5554" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Response:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "fingerprint": "<sha256_hex>", "activity": "com.example.app.MainActivity" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Notes:
|
|
95
|
+
- Uses get_ui_tree and (on Android) get_current_screen as inputs.
|
|
96
|
+
- Normalises visible, interactable or structurally significant elements (class/type, resourceId, text, contentDesc).
|
|
97
|
+
- Trims and lowercases text, filters out likely dynamic values (timestamps, counters).
|
|
98
|
+
- Sorts deterministically (top-to-bottom, left-to-right) and limits elements to 50.
|
|
99
|
+
- Returns fingerprint: null and an error message if the UI tree or activity cannot be retrieved.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
79
103
|
## start_log_stream / read_log_stream / stop_log_stream
|
|
80
104
|
Start a background adb logcat stream and retrieve parsed NDJSON entries.
|
|
81
105
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WaitForElementResponse, TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
|
-
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "
|
|
3
|
-
import { AndroidObserve } from "
|
|
4
|
-
import { scrollToElementShared } from "../
|
|
2
|
+
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
|
+
import { AndroidObserve } from "../observe/index.js"
|
|
4
|
+
import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export class AndroidInteract {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AndroidInteract } from './android.js';
|
|
2
|
+
import { iOSInteract } from './ios.js';
|
|
3
|
+
export { AndroidInteract, iOSInteract };
|
|
4
|
+
|
|
1
5
|
import { resolveTargetDevice } from '../utils/resolve-device.js'
|
|
2
|
-
import { AndroidInteract } from '../android/interact.js'
|
|
3
|
-
import { iOSInteract } from '../ios/interact.js'
|
|
4
6
|
|
|
5
7
|
export class ToolsInteract {
|
|
6
8
|
|
|
@@ -43,4 +45,3 @@ export class ToolsInteract {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
}
|
|
46
|
-
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { WaitForElementResponse, TapResponse, SwipeResponse } from "../types.js"
|
|
3
|
-
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "
|
|
4
|
-
import { iOSObserve } from "
|
|
5
|
-
import { scrollToElementShared } from "../
|
|
3
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
|
+
import { iOSObserve } from "../observe/index.js"
|
|
5
|
+
import { scrollToElementShared } from "../interact/shared/scroll_to_element.js"
|
|
6
6
|
|
|
7
7
|
export class iOSInteract {
|
|
8
8
|
private observe = new iOSObserve();
|