mobile-debug-mcp 0.11.0 → 0.12.1
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 +10 -5
- package/dist/android/diagnostics.js +24 -0
- package/dist/android/manage.js +33 -8
- package/dist/android/observe.js +4 -4
- package/dist/android/utils.js +3 -3
- package/dist/cli/idb/check-idb.js +84 -0
- package/dist/cli/idb/idb-helper.js +91 -0
- package/dist/cli/idb/install-idb.js +82 -0
- package/dist/cli/ios/preflight-ios.js +155 -0
- package/dist/cli/ios/run-ios-smoke.js +28 -0
- package/dist/cli/ios/run-ios-ui-tree-tap.js +29 -0
- package/dist/ios/interact.js +4 -8
- package/dist/ios/manage.js +177 -45
- package/dist/ios/observe.js +24 -15
- package/dist/ios/utils.js +121 -8
- package/dist/server.js +18 -0
- package/dist/utils/diagnostics.js +25 -0
- package/docs/CHANGELOG.md +19 -0
- package/eslint.config.js +21 -1
- package/package.json +10 -5
- package/src/android/diagnostics.ts +23 -0
- package/src/android/manage.ts +30 -8
- package/src/android/observe.ts +4 -4
- package/src/android/utils.ts +3 -3
- package/src/cli/idb/check-idb.ts +73 -0
- package/src/cli/idb/idb-helper.ts +75 -0
- package/src/cli/idb/install-idb.ts +90 -0
- package/src/cli/ios/preflight-ios.ts +144 -0
- package/src/cli/ios/run-ios-smoke.ts +34 -0
- package/src/cli/ios/run-ios-ui-tree-tap.ts +33 -0
- package/src/ios/interact.ts +4 -8
- package/src/ios/manage.ts +202 -64
- package/src/ios/observe.ts +24 -16
- package/src/ios/utils.ts +109 -8
- package/src/server.ts +19 -0
- package/src/types.ts +9 -0
- package/src/utils/diagnostics.ts +36 -0
- package/test/device/README.md +49 -0
- package/test/device/index.ts +27 -0
- package/test/device/manage/run-build-install-ios.ts +82 -0
- package/test/{integration → device/manage}/run-install-android.ts +4 -4
- package/test/{integration → device/manage}/run-install-ios.ts +4 -4
- package/test/{integration → device/utils}/test-dist.ts +2 -2
- package/test/unit/index.ts +10 -6
- package/test/unit/{build.test.ts → manage/build.test.ts} +16 -17
- package/test/unit/{build_and_install.test.ts → manage/build_and_install.test.ts} +20 -18
- package/test/unit/manage/diagnostics.test.ts +85 -0
- package/test/unit/{install.test.ts → manage/install.test.ts} +26 -17
- package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
- package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +2 -2
- package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
- package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
- package/tsconfig.json +2 -1
- package/test/integration/index.ts +0 -8
- package/test/integration/test-dist.mjs +0 -41
- /package/test/{integration → device/interact}/run-real-test.ts +0 -0
- /package/test/{integration → device/interact}/smoke-test.ts +0 -0
- /package/test/{integration → device/manage}/install.integration.ts +0 -0
- /package/test/{integration → device/observe}/logstream-real.ts +0 -0
- /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
- /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { iOSObserve } from '../../ios/observe.js';
|
|
2
|
+
import { iOSInteract } from '../../ios/interact.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();
|
package/dist/ios/interact.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { getIOSDeviceMetadata,
|
|
2
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
|
|
3
3
|
import { iOSObserve } from "./observe.js";
|
|
4
4
|
export class iOSInteract {
|
|
5
5
|
observe = new iOSObserve();
|
|
@@ -31,12 +31,8 @@ export class iOSInteract {
|
|
|
31
31
|
}
|
|
32
32
|
async tap(x, y, deviceId = "booted") {
|
|
33
33
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
const idbExists = await new Promise((resolve) => {
|
|
37
|
-
child.on('error', () => resolve(false));
|
|
38
|
-
child.on('close', (code) => resolve(code === 0));
|
|
39
|
-
});
|
|
34
|
+
// Use shared helper to detect idb
|
|
35
|
+
const idbExists = await isIDBInstalled();
|
|
40
36
|
if (!idbExists) {
|
|
41
37
|
return {
|
|
42
38
|
device,
|
|
@@ -53,7 +49,7 @@ export class iOSInteract {
|
|
|
53
49
|
args.push('--udid', targetUdid);
|
|
54
50
|
}
|
|
55
51
|
await new Promise((resolve, reject) => {
|
|
56
|
-
const proc = spawn(
|
|
52
|
+
const proc = spawn(getIdbCmd(), args);
|
|
57
53
|
let stderr = '';
|
|
58
54
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
59
55
|
proc.on('close', code => {
|
package/dist/ios/manage.js
CHANGED
|
@@ -1,43 +1,152 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
2
|
import { spawn } from "child_process";
|
|
3
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
3
|
+
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
|
|
4
4
|
import path from "path";
|
|
5
5
|
export class iOSManage {
|
|
6
6
|
async build(projectPath, _variant) {
|
|
7
7
|
void _variant;
|
|
8
8
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Look for an Xcode workspace or project at the provided path. If not present, scan subdirectories (limited depth)
|
|
10
|
+
async function findProject(root, maxDepth = 3) {
|
|
11
|
+
try {
|
|
12
|
+
const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
13
|
+
for (const e of ents) {
|
|
14
|
+
// .xcworkspace and .xcodeproj are directories on disk (bundles), not regular files
|
|
15
|
+
if (e.name.endsWith('.xcworkspace'))
|
|
16
|
+
return { dir: root, workspace: e.name };
|
|
17
|
+
if (e.name.endsWith('.xcodeproj'))
|
|
18
|
+
return { dir: root, proj: e.name };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
if (maxDepth <= 0)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const ents = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
26
|
+
for (const e of ents) {
|
|
27
|
+
if (e.isDirectory()) {
|
|
28
|
+
const candidate = await findProject(path.join(root, e.name), maxDepth - 1);
|
|
29
|
+
if (candidate)
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const projectInfo = await findProject(projectPath, 3);
|
|
38
|
+
if (!projectInfo)
|
|
13
39
|
return { error: 'No Xcode project or workspace found' };
|
|
40
|
+
const projectRootDir = projectInfo.dir || projectPath;
|
|
41
|
+
const workspace = projectInfo.workspace;
|
|
42
|
+
const proj = projectInfo.proj;
|
|
43
|
+
// Determine destination: prefer explicit env var, otherwise use booted simulator UDID
|
|
44
|
+
let destinationUDID = process.env.MCP_XCODE_DESTINATION_UDID || process.env.MCP_XCODE_DESTINATION || '';
|
|
45
|
+
if (!destinationUDID) {
|
|
46
|
+
try {
|
|
47
|
+
const meta = await getIOSDeviceMetadata('booted');
|
|
48
|
+
if (meta && meta.id)
|
|
49
|
+
destinationUDID = meta.id;
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}
|
|
14
53
|
let buildArgs;
|
|
15
54
|
if (workspace) {
|
|
16
|
-
const workspacePath = path.join(
|
|
55
|
+
const workspacePath = path.join(projectRootDir, workspace);
|
|
17
56
|
const scheme = workspace.replace(/\.xcworkspace$/, '');
|
|
18
57
|
buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
19
58
|
}
|
|
20
59
|
else {
|
|
21
|
-
const projectPathFull = path.join(
|
|
60
|
+
const projectPathFull = path.join(projectRootDir, proj);
|
|
22
61
|
const scheme = proj.replace(/\.xcodeproj$/, '');
|
|
23
62
|
buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
|
|
24
63
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
64
|
+
// If we have a destination UDID, add an explicit destination to avoid xcodebuild picking an ambiguous target
|
|
65
|
+
if (destinationUDID) {
|
|
66
|
+
buildArgs.push('-destination', `platform=iOS Simulator,id=${destinationUDID}`);
|
|
67
|
+
}
|
|
68
|
+
// Add result bundle path for diagnostics
|
|
69
|
+
const resultsDir = path.join(projectPath, 'build-results');
|
|
70
|
+
// Remove any stale results to avoid xcodebuild complaining about existing result bundles
|
|
71
|
+
await fs.rm(resultsDir, { recursive: true, force: true }).catch(() => { });
|
|
72
|
+
await fs.mkdir(resultsDir, { recursive: true }).catch(() => { });
|
|
73
|
+
// Skip specifying -resultBundlePath to avoid platform-specific collisions; rely on stdout/stderr logs
|
|
74
|
+
const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
|
|
75
|
+
const XCODEBUILD_TIMEOUT = parseInt(process.env.MCP_XCODEBUILD_TIMEOUT || '', 10) || 180000; // default 3 minutes
|
|
76
|
+
const MAX_RETRIES = parseInt(process.env.MCP_XCODEBUILD_RETRIES || '', 10) || 1;
|
|
77
|
+
const tries = MAX_RETRIES + 1;
|
|
78
|
+
let lastStdout = '';
|
|
79
|
+
let lastStderr = '';
|
|
80
|
+
let lastErr = null;
|
|
81
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
82
|
+
// Run xcodebuild with a watchdog
|
|
83
|
+
const res = await new Promise((resolve) => {
|
|
84
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
|
|
85
|
+
let stdout = '';
|
|
86
|
+
let stderr = '';
|
|
87
|
+
proc.stdout?.on('data', d => stdout += d.toString());
|
|
88
|
+
proc.stderr?.on('data', d => stderr += d.toString());
|
|
89
|
+
let killed = false;
|
|
90
|
+
const to = setTimeout(() => {
|
|
91
|
+
killed = true;
|
|
92
|
+
try {
|
|
93
|
+
proc.kill('SIGKILL');
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}, XCODEBUILD_TIMEOUT);
|
|
97
|
+
proc.on('close', (code) => {
|
|
98
|
+
clearTimeout(to);
|
|
99
|
+
resolve({ code, stdout, stderr, killedByWatchdog: killed });
|
|
100
|
+
});
|
|
101
|
+
proc.on('error', (err) => {
|
|
102
|
+
clearTimeout(to);
|
|
103
|
+
resolve({ code: null, stdout, stderr: String(err), killedByWatchdog: killed });
|
|
104
|
+
});
|
|
34
105
|
});
|
|
35
|
-
|
|
36
|
-
|
|
106
|
+
lastStdout = res.stdout;
|
|
107
|
+
lastStderr = res.stderr;
|
|
108
|
+
if (res.code === 0) {
|
|
109
|
+
// success — clear any previous error and stop retrying
|
|
110
|
+
lastErr = null;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
// record the failure for reporting
|
|
114
|
+
lastErr = new Error(res.stderr || `xcodebuild failed with code ${res.code}`);
|
|
115
|
+
// write logs for diagnostics (helpful whether killed or not)
|
|
116
|
+
try {
|
|
117
|
+
await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stdout.log`), res.stdout).catch(() => { });
|
|
118
|
+
await fs.writeFile(path.join(resultsDir, `xcodebuild-${attempt}.stderr.log`), res.stderr).catch(() => { });
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
// If killed by watchdog and there are remaining attempts, continue to retry
|
|
122
|
+
if (res.killedByWatchdog && attempt < tries) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// no more retries or not a watchdog kill — break to report lastErr
|
|
126
|
+
if (attempt >= tries)
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (lastErr) {
|
|
130
|
+
// Include diagnostics and result bundle path when available
|
|
131
|
+
return { error: `xcodebuild failed: ${lastErr.message}. See build-results for logs.`, output: `stdout:\n${lastStdout}\nstderr:\n${lastStderr}` };
|
|
132
|
+
}
|
|
133
|
+
// Try to locate built .app. First search project tree, then DerivedData if necessary
|
|
37
134
|
const built = await findAppBundle(projectPath);
|
|
38
|
-
if (
|
|
39
|
-
return {
|
|
40
|
-
|
|
135
|
+
if (built)
|
|
136
|
+
return { artifactPath: built };
|
|
137
|
+
// Fallback: search DerivedData for matching product
|
|
138
|
+
const dd = path.join(process.env.HOME || '', 'Library', 'Developer', 'Xcode', 'DerivedData');
|
|
139
|
+
try {
|
|
140
|
+
const entries = await fs.readdir(dd).catch(() => []);
|
|
141
|
+
for (const e of entries) {
|
|
142
|
+
const candidate = path.join(dd, e);
|
|
143
|
+
const found = await findAppBundle(candidate).catch(() => undefined);
|
|
144
|
+
if (found)
|
|
145
|
+
return { artifactPath: found };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
149
|
+
return { error: 'Could not find .app after build' };
|
|
41
150
|
}
|
|
42
151
|
catch (e) {
|
|
43
152
|
return { error: e instanceof Error ? e.message : String(e) };
|
|
@@ -71,15 +180,18 @@ export class iOSManage {
|
|
|
71
180
|
return { device, installed: true, output: res.output };
|
|
72
181
|
}
|
|
73
182
|
catch (e) {
|
|
183
|
+
// Gather diagnostics for simctl failure
|
|
184
|
+
const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
|
|
74
185
|
try {
|
|
75
|
-
const child = spawn(
|
|
186
|
+
const child = spawn(getIdbCmd(), ['--version']);
|
|
76
187
|
const idbExists = await new Promise((resolve) => {
|
|
77
188
|
child.on('error', () => resolve(false));
|
|
78
189
|
child.on('close', (code) => resolve(code === 0));
|
|
79
190
|
});
|
|
80
191
|
if (idbExists) {
|
|
192
|
+
// attempt idb install via spawn but include diagnostics
|
|
81
193
|
await new Promise((resolve, reject) => {
|
|
82
|
-
const proc = spawn(
|
|
194
|
+
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
|
|
83
195
|
let stderr = '';
|
|
84
196
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
85
197
|
proc.on('close', code => {
|
|
@@ -94,7 +206,7 @@ export class iOSManage {
|
|
|
94
206
|
}
|
|
95
207
|
}
|
|
96
208
|
catch { }
|
|
97
|
-
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
209
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
98
210
|
}
|
|
99
211
|
}
|
|
100
212
|
catch (e) {
|
|
@@ -103,15 +215,29 @@ export class iOSManage {
|
|
|
103
215
|
}
|
|
104
216
|
async startApp(bundleId, deviceId = "booted") {
|
|
105
217
|
validateBundleId(bundleId);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
218
|
+
try {
|
|
219
|
+
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
220
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
221
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000 };
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
|
|
225
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
226
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
227
|
+
}
|
|
109
228
|
}
|
|
110
229
|
async terminateApp(bundleId, deviceId = "booted") {
|
|
111
230
|
validateBundleId(bundleId);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
231
|
+
try {
|
|
232
|
+
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
233
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
234
|
+
return { device, appTerminated: true };
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
|
|
238
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
239
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
240
|
+
}
|
|
115
241
|
}
|
|
116
242
|
async restartApp(bundleId, deviceId = "booted") {
|
|
117
243
|
await this.terminateApp(bundleId, deviceId);
|
|
@@ -122,24 +248,30 @@ export class iOSManage {
|
|
|
122
248
|
validateBundleId(bundleId);
|
|
123
249
|
await this.terminateApp(bundleId, deviceId);
|
|
124
250
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
125
|
-
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
126
|
-
const dataPath = containerResult.output.trim();
|
|
127
|
-
if (!dataPath)
|
|
128
|
-
throw new Error(`Could not find data container for ${bundleId}`);
|
|
129
251
|
try {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
252
|
+
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
253
|
+
const dataPath = containerResult.output.trim();
|
|
254
|
+
if (!dataPath)
|
|
255
|
+
throw new Error(`Could not find data container for ${bundleId}`);
|
|
256
|
+
try {
|
|
257
|
+
const libraryPath = `${dataPath}/Library`;
|
|
258
|
+
const documentsPath = `${dataPath}/Documents`;
|
|
259
|
+
const tmpPath = `${dataPath}/tmp`;
|
|
260
|
+
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
|
|
261
|
+
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
|
|
262
|
+
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
|
|
263
|
+
await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
|
|
264
|
+
await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
|
|
265
|
+
await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
|
|
266
|
+
return { device, dataCleared: true };
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
270
|
+
}
|
|
140
271
|
}
|
|
141
272
|
catch (e) {
|
|
142
|
-
|
|
273
|
+
const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
|
|
274
|
+
return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
|
|
143
275
|
}
|
|
144
276
|
}
|
|
145
277
|
}
|
package/dist/ios/observe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { promises as fs } from "fs";
|
|
3
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
3
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js";
|
|
4
4
|
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { parseLogLine } from '../android/utils.js';
|
|
@@ -9,6 +9,17 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
9
9
|
function parseIDBFrame(frame) {
|
|
10
10
|
if (!frame)
|
|
11
11
|
return [0, 0, 0, 0];
|
|
12
|
+
// Handle string frames like "{{0, 0}, {402, 874}}"
|
|
13
|
+
if (typeof frame === 'string') {
|
|
14
|
+
const nums = frame.match(/-?\d+(?:\.\d+)?/g);
|
|
15
|
+
if (!nums || nums.length < 4)
|
|
16
|
+
return [0, 0, 0, 0];
|
|
17
|
+
const x = Number(nums[0]);
|
|
18
|
+
const y = Number(nums[1]);
|
|
19
|
+
const w = Number(nums[2]);
|
|
20
|
+
const h = Number(nums[3]);
|
|
21
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
22
|
+
}
|
|
12
23
|
const x = Number(frame.x || 0);
|
|
13
24
|
const y = Number(frame.y || 0);
|
|
14
25
|
const w = Number(frame.width || frame.w || 0);
|
|
@@ -72,15 +83,6 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
|
|
|
72
83
|
}
|
|
73
84
|
return currentIndex;
|
|
74
85
|
}
|
|
75
|
-
// Check if IDB is installed
|
|
76
|
-
async function isIDBInstalled() {
|
|
77
|
-
return new Promise((resolve) => {
|
|
78
|
-
// Check if 'idb' is in path by trying to run it
|
|
79
|
-
const child = spawn(IDB, ['--version']);
|
|
80
|
-
child.on('error', () => resolve(false));
|
|
81
|
-
child.on('close', (code) => resolve(code === 0));
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
86
|
// iOS live log stream support (moved from ios/utils to observe)
|
|
85
87
|
const iosActiveLogStreams = new Map();
|
|
86
88
|
// Test helpers
|
|
@@ -161,12 +163,12 @@ export class iOSObserve {
|
|
|
161
163
|
try {
|
|
162
164
|
// Stabilization delay
|
|
163
165
|
await delay(300 + (attempts * 100));
|
|
164
|
-
const args = ['ui', 'describe', '--json'];
|
|
166
|
+
const args = ['ui', 'describe-all', '--json'];
|
|
165
167
|
if (targetUdid) {
|
|
166
168
|
args.push('--udid', targetUdid);
|
|
167
169
|
}
|
|
168
170
|
const output = await new Promise((resolve, reject) => {
|
|
169
|
-
const child = spawn(
|
|
171
|
+
const child = spawn(getIdbCmd(), args);
|
|
170
172
|
let stdout = '';
|
|
171
173
|
let stderr = '';
|
|
172
174
|
child.stdout.on('data', (data) => stdout += data.toString());
|
|
@@ -201,8 +203,15 @@ export class iOSObserve {
|
|
|
201
203
|
}
|
|
202
204
|
try {
|
|
203
205
|
const elements = [];
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
// idb describe-all returns either a root object or an array of root nodes
|
|
207
|
+
if (Array.isArray(jsonContent)) {
|
|
208
|
+
for (const node of jsonContent) {
|
|
209
|
+
traverseIDBNode(node, elements);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
traverseIDBNode(jsonContent, elements);
|
|
214
|
+
}
|
|
206
215
|
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
207
216
|
let width = 0;
|
|
208
217
|
let height = 0;
|
|
@@ -240,7 +249,7 @@ export class iOSObserve {
|
|
|
240
249
|
iosActiveLogStreams.delete(sessionId);
|
|
241
250
|
}
|
|
242
251
|
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
|
|
243
|
-
const proc = spawn(
|
|
252
|
+
const proc = spawn(getXcrunCmd(), args);
|
|
244
253
|
// Prepare output file
|
|
245
254
|
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
246
255
|
const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
|
package/dist/ios/utils.js
CHANGED
|
@@ -1,8 +1,90 @@
|
|
|
1
|
-
import { execFile, spawn } from "child_process";
|
|
1
|
+
import { execFile, spawn, execSync, spawnSync } from "child_process";
|
|
2
2
|
import { promises as fsPromises } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
|
|
5
|
-
export
|
|
4
|
+
import { makeEnvSnapshot } from '../utils/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
|
+
}
|
|
6
88
|
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
7
89
|
export function validateBundleId(bundleId) {
|
|
8
90
|
if (!bundleId)
|
|
@@ -15,7 +97,7 @@ export function validateBundleId(bundleId) {
|
|
|
15
97
|
export function execCommand(args, deviceId = "booted") {
|
|
16
98
|
return new Promise((resolve, reject) => {
|
|
17
99
|
// Use spawn for better stream control and consistency with Android implementation
|
|
18
|
-
const child = spawn(
|
|
100
|
+
const child = spawn(getXcrunCmd(), args);
|
|
19
101
|
let stdout = '';
|
|
20
102
|
let stderr = '';
|
|
21
103
|
if (child.stdout) {
|
|
@@ -28,10 +110,12 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
28
110
|
stderr += data.toString();
|
|
29
111
|
});
|
|
30
112
|
}
|
|
31
|
-
const
|
|
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
|
|
32
116
|
const timeout = setTimeout(() => {
|
|
33
117
|
child.kill();
|
|
34
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${
|
|
118
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`));
|
|
35
119
|
}, timeoutMs);
|
|
36
120
|
child.on('close', (code) => {
|
|
37
121
|
clearTimeout(timeout);
|
|
@@ -48,6 +132,35 @@ export function execCommand(args, deviceId = "booted") {
|
|
|
48
132
|
});
|
|
49
133
|
});
|
|
50
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
|
+
}
|
|
51
164
|
function parseRuntimeName(runtime) {
|
|
52
165
|
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
53
166
|
try {
|
|
@@ -84,7 +197,7 @@ export async function findAppBundle(dir) {
|
|
|
84
197
|
export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
85
198
|
return new Promise((resolve) => {
|
|
86
199
|
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
87
|
-
execFile(
|
|
200
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
88
201
|
const fallback = {
|
|
89
202
|
platform: "ios",
|
|
90
203
|
id: deviceId,
|
|
@@ -126,7 +239,7 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
|
126
239
|
}
|
|
127
240
|
export async function listIOSDevices(appId) {
|
|
128
241
|
return new Promise((resolve) => {
|
|
129
|
-
execFile(
|
|
242
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
130
243
|
if (err || !stdout)
|
|
131
244
|
return resolve([]);
|
|
132
245
|
try {
|
package/dist/server.js
CHANGED
|
@@ -126,6 +126,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
126
126
|
required: ["appPath"]
|
|
127
127
|
}
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
name: "build_app",
|
|
131
|
+
description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
|
|
136
|
+
projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
|
|
137
|
+
variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
|
|
138
|
+
},
|
|
139
|
+
required: ["projectPath"]
|
|
140
|
+
}
|
|
141
|
+
},
|
|
129
142
|
{
|
|
130
143
|
name: "get_logs",
|
|
131
144
|
description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
|
|
@@ -411,6 +424,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
411
424
|
};
|
|
412
425
|
return wrapResponse(response);
|
|
413
426
|
}
|
|
427
|
+
if (name === "build_app") {
|
|
428
|
+
const { platform, projectPath, variant } = args;
|
|
429
|
+
const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant });
|
|
430
|
+
return wrapResponse(res);
|
|
431
|
+
}
|
|
414
432
|
if (name === 'build_and_install') {
|
|
415
433
|
const { platform, projectPath, deviceId, timeout } = args;
|
|
416
434
|
const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function makeEnvSnapshot(keys) {
|
|
2
|
+
const snap = {};
|
|
3
|
+
for (const k of keys)
|
|
4
|
+
snap[k] = process.env[k];
|
|
5
|
+
return snap;
|
|
6
|
+
}
|
|
7
|
+
export function wrapExecResult(command, args, res) {
|
|
8
|
+
return {
|
|
9
|
+
exitCode: res.status,
|
|
10
|
+
stdout: res.stdout ? (typeof res.stdout === 'string' ? res.stdout : res.stdout.toString()) : '',
|
|
11
|
+
stderr: res.stderr ? (typeof res.stderr === 'string' ? res.stderr : res.stderr.toString()) : '',
|
|
12
|
+
envSnapshot: makeEnvSnapshot(['PATH', 'IDB_PATH', 'JAVA_HOME', 'HOME']),
|
|
13
|
+
command,
|
|
14
|
+
args,
|
|
15
|
+
suggestedFixes: []
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export class DiagnosticError extends Error {
|
|
19
|
+
runResult;
|
|
20
|
+
constructor(message, runResult) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'DiagnosticError';
|
|
23
|
+
this.runResult = runResult;
|
|
24
|
+
}
|
|
25
|
+
}
|