mobile-debug-mcp 0.11.0 → 0.12.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 +18 -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/ios/interact.js +4 -8
- package/dist/ios/manage.js +50 -26
- 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 +10 -0
- package/eslint.config.js +22 -1
- package/package.json +8 -5
- package/scripts/check-idb.js +83 -0
- package/scripts/check-idb.ts +73 -0
- package/scripts/idb-helper.ts +76 -0
- package/scripts/install-idb.js +88 -0
- package/scripts/install-idb.ts +90 -0
- package/scripts/run-ios-smoke.ts +34 -0
- package/scripts/run-ios-ui-tree-tap.ts +33 -0
- 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/ios/interact.ts +4 -8
- package/src/ios/manage.ts +69 -48
- 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
package/src/android/manage.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { spawn } from 'child_process'
|
|
|
3
3
|
import path from 'path'
|
|
4
4
|
import { existsSync } from 'fs'
|
|
5
5
|
import { execAdb, spawnAdb, getAndroidDeviceMetadata, getDeviceInfo, findApk } from './utils.js'
|
|
6
|
+
import { execAdbWithDiagnostics } from './diagnostics.js'
|
|
6
7
|
import { detectJavaHome } from '../utils/java.js'
|
|
7
8
|
import { InstallAppResponse, StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse } from '../types.js'
|
|
8
9
|
|
|
@@ -35,8 +36,8 @@ export class AndroidManage {
|
|
|
35
36
|
const metadata = await getAndroidDeviceMetadata('', deviceId)
|
|
36
37
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
37
38
|
|
|
39
|
+
let apkToInstall: string = apkPath
|
|
38
40
|
try {
|
|
39
|
-
let apkToInstall = apkPath
|
|
40
41
|
const stat = await fs.stat(apkPath).catch(() => null)
|
|
41
42
|
if (stat && stat.isDirectory()) {
|
|
42
43
|
const detectedJavaHome = await detectJavaHome().catch(() => undefined)
|
|
@@ -98,22 +99,38 @@ export class AndroidManage {
|
|
|
98
99
|
try { await execAdb(['shell', 'rm', remotePath], deviceId) } catch {}
|
|
99
100
|
return { device: deviceInfo, installed: true, output: pmOut }
|
|
100
101
|
} catch (e) {
|
|
101
|
-
|
|
102
|
+
// gather diagnostics for attempted adb operations
|
|
103
|
+
const basename = path.basename(apkToInstall)
|
|
104
|
+
const remotePath = `/data/local/tmp/${basename}`
|
|
105
|
+
const installDiag = execAdbWithDiagnostics(['install', '-r', apkToInstall], deviceId)
|
|
106
|
+
const pushDiag = execAdbWithDiagnostics(['push', apkToInstall, remotePath], deviceId)
|
|
107
|
+
const pmDiag = execAdbWithDiagnostics(['shell', 'pm', 'install', '-r', remotePath], deviceId)
|
|
108
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: { installDiag, pushDiag, pmDiag } }
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
106
113
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
107
114
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
try {
|
|
116
|
+
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
117
|
+
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
118
|
+
} catch (e:any) {
|
|
119
|
+
const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
120
|
+
return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
121
|
+
}
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
async terminateApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
113
125
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
114
126
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
try {
|
|
128
|
+
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
129
|
+
return { device: deviceInfo, appTerminated: true }
|
|
130
|
+
} catch (e:any) {
|
|
131
|
+
const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
|
|
132
|
+
return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
133
|
+
}
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
@@ -129,7 +146,12 @@ export class AndroidManage {
|
|
|
129
146
|
async resetAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
130
147
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
131
148
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
try {
|
|
150
|
+
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
151
|
+
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
152
|
+
} catch (e:any) {
|
|
153
|
+
const diag = execAdbWithDiagnostics(['shell', 'pm', 'clear', appId], deviceId)
|
|
154
|
+
return { device: deviceInfo, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
155
|
+
}
|
|
134
156
|
}
|
|
135
157
|
}
|
package/src/android/observe.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { XMLParser } from "fast-xml-parser"
|
|
3
3
|
import { GetLogsResponse, CaptureAndroidScreenResponse, GetUITreeResponse, GetCurrentScreenResponse, UIElement, DeviceInfo } from "../types.js"
|
|
4
|
-
import {
|
|
4
|
+
import { getAdbCmd, execAdb, getAndroidDeviceMetadata, getDeviceInfo, delay, getScreenResolution, traverseNode, parseLogLine } from "./utils.js"
|
|
5
5
|
import { createWriteStream } from "fs"
|
|
6
6
|
import { promises as fsPromises } from "fs"
|
|
7
7
|
import path from "path"
|
|
@@ -81,7 +81,7 @@ export class AndroidObserve {
|
|
|
81
81
|
elements
|
|
82
82
|
};
|
|
83
83
|
} catch (e) {
|
|
84
|
-
const errorMessage = `Failed to get UI tree. ADB Path: '${
|
|
84
|
+
const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
|
|
85
85
|
console.error(errorMessage);
|
|
86
86
|
return {
|
|
87
87
|
device: deviceInfo,
|
|
@@ -136,7 +136,7 @@ export class AndroidObserve {
|
|
|
136
136
|
const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
|
|
137
137
|
|
|
138
138
|
// Using spawn for screencap as well to ensure consistent process handling
|
|
139
|
-
const child = spawn(
|
|
139
|
+
const child = spawn(getAdbCmd(), args)
|
|
140
140
|
|
|
141
141
|
const chunks: Buffer[] = []
|
|
142
142
|
let stderr = ''
|
|
@@ -283,7 +283,7 @@ export class AndroidObserve {
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
const args = ['logcat', `--pid=${pid}`, filter]
|
|
286
|
-
const proc = spawn(
|
|
286
|
+
const proc = spawn(getAdbCmd(), args)
|
|
287
287
|
|
|
288
288
|
const tmpDir = process.env.TMPDIR || '/tmp'
|
|
289
289
|
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`)
|
package/src/android/utils.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fsPromises, existsSync } from 'fs'
|
|
|
4
4
|
import path from 'path'
|
|
5
5
|
import { detectJavaHome } from '../utils/java.js'
|
|
6
6
|
|
|
7
|
-
export
|
|
7
|
+
export function getAdbCmd() { return process.env.ADB_PATH || 'adb' }
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Prepare Gradle execution options for building an Android project.
|
|
@@ -78,7 +78,7 @@ export function execAdb(args: string[], deviceId?: string, options: SpawnOptions
|
|
|
78
78
|
const { timeout: customTimeout, ...spawnOptions } = options;
|
|
79
79
|
|
|
80
80
|
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
81
|
-
const child = spawn(
|
|
81
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
|
|
82
82
|
|
|
83
83
|
let stdout = ''
|
|
84
84
|
let stderr = ''
|
|
@@ -126,7 +126,7 @@ export function spawnAdb(args: string[], deviceId?: string, options: SpawnOption
|
|
|
126
126
|
const adbArgs = getAdbArgs(args, deviceId)
|
|
127
127
|
return new Promise((resolve, reject) => {
|
|
128
128
|
const { timeout: customTimeout, ...spawnOptions } = options
|
|
129
|
-
const child = spawn(
|
|
129
|
+
const child = spawn(getAdbCmd(), adbArgs, spawnOptions)
|
|
130
130
|
|
|
131
131
|
let stdout = ''
|
|
132
132
|
let stderr = ''
|
package/src/ios/interact.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { WaitForElementResponse, TapResponse } from "../types.js"
|
|
3
|
-
import { getIOSDeviceMetadata,
|
|
3
|
+
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js"
|
|
4
4
|
import { iOSObserve } from "./observe.js"
|
|
5
5
|
|
|
6
6
|
export class iOSInteract {
|
|
@@ -39,12 +39,8 @@ export class iOSInteract {
|
|
|
39
39
|
async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
|
|
40
40
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
const
|
|
44
|
-
const idbExists = await new Promise<boolean>((resolve) => {
|
|
45
|
-
child.on('error', () => resolve(false));
|
|
46
|
-
child.on('close', (code) => resolve(code === 0));
|
|
47
|
-
});
|
|
42
|
+
// Use shared helper to detect idb
|
|
43
|
+
const idbExists = await isIDBInstalled();
|
|
48
44
|
|
|
49
45
|
if (!idbExists) {
|
|
50
46
|
return {
|
|
@@ -64,7 +60,7 @@ export class iOSInteract {
|
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
await new Promise<void>((resolve, reject) => {
|
|
67
|
-
const proc = spawn(
|
|
63
|
+
const proc = spawn(getIdbCmd(), args);
|
|
68
64
|
let stderr = '';
|
|
69
65
|
proc.stderr.on('data', d => stderr += d.toString());
|
|
70
66
|
proc.on('close', code => {
|
package/src/ios/manage.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "fs"
|
|
2
2
|
import { spawn } from "child_process"
|
|
3
3
|
import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
|
|
4
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
4
|
+
import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js"
|
|
5
5
|
import path from "path"
|
|
6
6
|
|
|
7
7
|
export class iOSManage {
|
|
@@ -25,7 +25,8 @@ export class iOSManage {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
await new Promise<void>((resolve, reject) => {
|
|
28
|
-
const
|
|
28
|
+
const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild'
|
|
29
|
+
const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath })
|
|
29
30
|
let stderr = ''
|
|
30
31
|
proc.stderr?.on('data', d => stderr += d.toString())
|
|
31
32
|
proc.on('close', code => {
|
|
@@ -67,31 +68,34 @@ export class iOSManage {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
child
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await new Promise<void>((resolve, reject) => {
|
|
81
|
-
const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
|
|
82
|
-
let stderr = '';
|
|
83
|
-
proc.stderr.on('data', d => stderr += d.toString());
|
|
84
|
-
proc.on('close', code => {
|
|
85
|
-
if (code === 0) resolve();
|
|
86
|
-
else reject(new Error(stderr || `idb install failed with code ${code}`));
|
|
87
|
-
});
|
|
88
|
-
proc.on('error', err => reject(err));
|
|
71
|
+
const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId)
|
|
72
|
+
return { device, installed: true, output: res.output }
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Gather diagnostics for simctl failure
|
|
75
|
+
const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId)
|
|
76
|
+
try {
|
|
77
|
+
const child = spawn(getIdbCmd(), ['--version'])
|
|
78
|
+
const idbExists = await new Promise<boolean>((resolve) => {
|
|
79
|
+
child.on('error', () => resolve(false));
|
|
80
|
+
child.on('close', (code) => resolve(code === 0));
|
|
89
81
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
82
|
+
if (idbExists) {
|
|
83
|
+
// attempt idb install via spawn but include diagnostics
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
|
|
86
|
+
let stderr = '';
|
|
87
|
+
proc.stderr.on('data', d => stderr += d.toString());
|
|
88
|
+
proc.on('close', code => {
|
|
89
|
+
if (code === 0) resolve();
|
|
90
|
+
else reject(new Error(stderr || `idb install failed with code ${code}`));
|
|
91
|
+
});
|
|
92
|
+
proc.on('error', err => reject(err));
|
|
93
|
+
});
|
|
94
|
+
return { device, installed: true }
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
|
|
98
|
+
}
|
|
95
99
|
} catch (e) {
|
|
96
100
|
return { device, installed: false, error: e instanceof Error ? e.message : String(e) }
|
|
97
101
|
}
|
|
@@ -99,16 +103,28 @@ export class iOSManage {
|
|
|
99
103
|
|
|
100
104
|
async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
|
|
101
105
|
validateBundleId(bundleId)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
try {
|
|
107
|
+
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
108
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
109
|
+
return { device, appStarted: !!result.output, launchTimeMs: 1000 }
|
|
110
|
+
} catch (e:any) {
|
|
111
|
+
const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
112
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
113
|
+
return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
114
|
+
}
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
|
|
108
118
|
validateBundleId(bundleId)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
try {
|
|
120
|
+
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
121
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
122
|
+
return { device, appTerminated: true }
|
|
123
|
+
} catch (e:any) {
|
|
124
|
+
const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
125
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
126
|
+
return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
127
|
+
}
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
|
|
@@ -121,23 +137,28 @@ export class iOSManage {
|
|
|
121
137
|
validateBundleId(bundleId)
|
|
122
138
|
await this.terminateApp(bundleId, deviceId)
|
|
123
139
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
124
|
-
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
|
|
125
|
-
const dataPath = containerResult.output.trim()
|
|
126
|
-
if (!dataPath) throw new Error(`Could not find data container for ${bundleId}`)
|
|
127
|
-
|
|
128
140
|
try {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
|
|
142
|
+
const dataPath = containerResult.output.trim()
|
|
143
|
+
if (!dataPath) throw new Error(`Could not find data container for ${bundleId}`)
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const libraryPath = `${dataPath}/Library`
|
|
147
|
+
const documentsPath = `${dataPath}/Documents`
|
|
148
|
+
const tmpPath = `${dataPath}/tmp`
|
|
149
|
+
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
|
|
150
|
+
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
|
|
151
|
+
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
|
|
152
|
+
await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
|
|
153
|
+
await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
|
|
154
|
+
await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
|
|
155
|
+
return { device, dataCleared: true }
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`)
|
|
158
|
+
}
|
|
159
|
+
} catch (e:any) {
|
|
160
|
+
const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
|
|
161
|
+
return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
|
|
141
162
|
}
|
|
142
163
|
}
|
|
143
164
|
}
|
package/src/ios/observe.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
2
|
import { promises as fs } from "fs"
|
|
3
3
|
import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo } from "../types.js"
|
|
4
|
-
import { execCommand, getIOSDeviceMetadata, validateBundleId,
|
|
4
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js"
|
|
5
5
|
import { createWriteStream, promises as fsPromises } from 'fs'
|
|
6
6
|
import path from 'path'
|
|
7
7
|
import { parseLogLine } from '../android/utils.js'
|
|
@@ -25,6 +25,17 @@ interface IDBElement {
|
|
|
25
25
|
|
|
26
26
|
function parseIDBFrame(frame: any): [number, number, number, number] {
|
|
27
27
|
if (!frame) return [0, 0, 0, 0];
|
|
28
|
+
// Handle string frames like "{{0, 0}, {402, 874}}"
|
|
29
|
+
if (typeof frame === 'string') {
|
|
30
|
+
const nums = frame.match(/-?\d+(?:\.\d+)?/g);
|
|
31
|
+
if (!nums || nums.length < 4) return [0, 0, 0, 0];
|
|
32
|
+
const x = Number(nums[0]);
|
|
33
|
+
const y = Number(nums[1]);
|
|
34
|
+
const w = Number(nums[2]);
|
|
35
|
+
const h = Number(nums[3]);
|
|
36
|
+
return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
const x = Number(frame.x || 0);
|
|
29
40
|
const y = Number(frame.y || 0);
|
|
30
41
|
const w = Number(frame.width || frame.w || 0);
|
|
@@ -102,15 +113,7 @@ function traverseIDBNode(node: IDBElement, elements: UIElement[], parentIndex: n
|
|
|
102
113
|
return currentIndex;
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
async function isIDBInstalled(): Promise<boolean> {
|
|
107
|
-
return new Promise((resolve) => {
|
|
108
|
-
// Check if 'idb' is in path by trying to run it
|
|
109
|
-
const child = spawn(IDB, ['--version']);
|
|
110
|
-
child.on('error', () => resolve(false));
|
|
111
|
-
child.on('close', (code) => resolve(code === 0));
|
|
112
|
-
});
|
|
113
|
-
}
|
|
116
|
+
|
|
114
117
|
|
|
115
118
|
// iOS live log stream support (moved from ios/utils to observe)
|
|
116
119
|
const iosActiveLogStreams: Map<string, { proc: ReturnType<typeof import('child_process').spawn>, file: string }> = new Map()
|
|
@@ -207,13 +210,13 @@ export class iOSObserve {
|
|
|
207
210
|
// Stabilization delay
|
|
208
211
|
await delay(300 + (attempts * 100));
|
|
209
212
|
|
|
210
|
-
const args = ['ui', 'describe', '--json'];
|
|
213
|
+
const args = ['ui', 'describe-all', '--json'];
|
|
211
214
|
if (targetUdid) {
|
|
212
215
|
args.push('--udid', targetUdid);
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
const output = await new Promise<string>((resolve, reject) => {
|
|
216
|
-
const child = spawn(
|
|
219
|
+
const child = spawn(getIdbCmd(), args);
|
|
217
220
|
let stdout = '';
|
|
218
221
|
let stderr = '';
|
|
219
222
|
|
|
@@ -252,9 +255,14 @@ export class iOSObserve {
|
|
|
252
255
|
|
|
253
256
|
try {
|
|
254
257
|
const elements: UIElement[] = [];
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
// idb describe-all returns either a root object or an array of root nodes
|
|
259
|
+
if (Array.isArray(jsonContent)) {
|
|
260
|
+
for (const node of jsonContent) {
|
|
261
|
+
traverseIDBNode(node, elements);
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
traverseIDBNode(jsonContent, elements);
|
|
265
|
+
}
|
|
258
266
|
|
|
259
267
|
// Infer resolution from root element if possible (usually the Window/Application frame)
|
|
260
268
|
let width = 0;
|
|
@@ -293,7 +301,7 @@ export class iOSObserve {
|
|
|
293
301
|
}
|
|
294
302
|
|
|
295
303
|
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate]
|
|
296
|
-
const proc = spawn(
|
|
304
|
+
const proc = spawn(getXcrunCmd(), args)
|
|
297
305
|
|
|
298
306
|
// Prepare output file
|
|
299
307
|
const tmpDir = process.env.TMPDIR || '/tmp'
|
package/src/ios/utils.ts
CHANGED
|
@@ -1,10 +1,76 @@
|
|
|
1
|
-
import { execFile, spawn } from "child_process"
|
|
1
|
+
import { execFile, spawn, execSync, spawnSync } from "child_process"
|
|
2
2
|
import { DeviceInfo } from "../types.js"
|
|
3
3
|
import { promises as fsPromises } from 'fs'
|
|
4
4
|
import path from 'path'
|
|
5
|
+
import { makeEnvSnapshot } from '../utils/diagnostics.js'
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
-
|
|
7
|
+
export function getXcrunCmd() { return process.env.XCRUN_PATH || 'xcrun' }
|
|
8
|
+
|
|
9
|
+
export function getConfiguredIdbPath(): string | undefined {
|
|
10
|
+
if (process.env.MCP_IDB_PATH) return process.env.MCP_IDB_PATH
|
|
11
|
+
if (process.env.IDB_PATH) return process.env.IDB_PATH
|
|
12
|
+
const cfgPaths = [
|
|
13
|
+
process.env.MCP_CONFIG_PATH || (process.env.HOME ? `${process.env.HOME}/.mcp/config.json` : ''),
|
|
14
|
+
`${process.cwd()}/mcp.config.json`
|
|
15
|
+
]
|
|
16
|
+
try {
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
for (const p of cfgPaths) {
|
|
19
|
+
if (!p) 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) return json.idbPath
|
|
26
|
+
if (json.IDB_PATH) return json.IDB_PATH
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getIdbCmd() {
|
|
36
|
+
const cfg = getConfiguredIdbPath()
|
|
37
|
+
if (cfg) return cfg
|
|
38
|
+
if (process.env.IDB_PATH) return process.env.IDB_PATH
|
|
39
|
+
try {
|
|
40
|
+
const p = execSync('which idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
|
|
41
|
+
if (p) return p
|
|
42
|
+
} catch {}
|
|
43
|
+
try {
|
|
44
|
+
const p2 = execSync('command -v idb', { stdio: ['ignore','pipe','ignore'] }).toString().trim()
|
|
45
|
+
if (p2) return p2
|
|
46
|
+
} catch {}
|
|
47
|
+
// check common user locations
|
|
48
|
+
const common = [
|
|
49
|
+
`${process.env.HOME}/Library/Python/3.9/bin/idb`,
|
|
50
|
+
`${process.env.HOME}/Library/Python/3.10/bin/idb`,
|
|
51
|
+
'/opt/homebrew/bin/idb',
|
|
52
|
+
'/usr/local/bin/idb',
|
|
53
|
+
]
|
|
54
|
+
for (const c of common) {
|
|
55
|
+
try { execSync(`test -x ${c}`, { stdio: ['ignore','pipe','ignore'] }); return c } catch {}
|
|
56
|
+
}
|
|
57
|
+
return 'idb'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function isIDBInstalled(): Promise<boolean> {
|
|
61
|
+
const cmd = getIdbCmd()
|
|
62
|
+
try {
|
|
63
|
+
execSync(`command -v ${cmd}`, { stdio: ['ignore','pipe','ignore'] })
|
|
64
|
+
return true
|
|
65
|
+
} catch {
|
|
66
|
+
try {
|
|
67
|
+
execSync(`${cmd} list-targets --json`, { stdio: ['ignore','pipe','ignore'], timeout: 2000 })
|
|
68
|
+
return true
|
|
69
|
+
} catch {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
8
74
|
|
|
9
75
|
export interface IOSResult {
|
|
10
76
|
output: string
|
|
@@ -23,7 +89,7 @@ export function validateBundleId(bundleId: string) {
|
|
|
23
89
|
export function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
|
|
24
90
|
return new Promise((resolve, reject) => {
|
|
25
91
|
// Use spawn for better stream control and consistency with Android implementation
|
|
26
|
-
const child = spawn(
|
|
92
|
+
const child = spawn(getXcrunCmd(), args)
|
|
27
93
|
|
|
28
94
|
let stdout = ''
|
|
29
95
|
let stderr = ''
|
|
@@ -40,10 +106,12 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
40
106
|
})
|
|
41
107
|
}
|
|
42
108
|
|
|
43
|
-
const
|
|
109
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000 // env (ms) or default 30s
|
|
110
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000 // env (ms) or default 60s
|
|
111
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT // choose appropriate timeout
|
|
44
112
|
const timeout = setTimeout(() => {
|
|
45
113
|
child.kill()
|
|
46
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${
|
|
114
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${getXcrunCmd()} ${args.join(' ')}`))
|
|
47
115
|
}, timeoutMs)
|
|
48
116
|
|
|
49
117
|
child.on('close', (code) => {
|
|
@@ -62,6 +130,39 @@ export function execCommand(args: string[], deviceId: string = "booted"): Promis
|
|
|
62
130
|
})
|
|
63
131
|
}
|
|
64
132
|
|
|
133
|
+
export function execCommandWithDiagnostics(args: string[], deviceId: string = "booted") {
|
|
134
|
+
// Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
|
|
135
|
+
const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000
|
|
136
|
+
const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000
|
|
137
|
+
const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT
|
|
138
|
+
const res = spawnSync(getXcrunCmd(), args, { encoding: 'utf8', timeout: timeoutMs }) as any
|
|
139
|
+
const runResult = {
|
|
140
|
+
exitCode: typeof res.status === 'number' ? res.status : null,
|
|
141
|
+
stdout: res.stdout || '',
|
|
142
|
+
stderr: res.stderr || '',
|
|
143
|
+
envSnapshot: makeEnvSnapshot(['PATH','IDB_PATH','JAVA_HOME','HOME']),
|
|
144
|
+
command: getXcrunCmd(),
|
|
145
|
+
args,
|
|
146
|
+
deviceId
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (res.status !== 0) {
|
|
150
|
+
// include suggested fixes for common errors
|
|
151
|
+
const suggested: string[] = []
|
|
152
|
+
if ((runResult.stderr || '').includes('xcodebuild: error')) {
|
|
153
|
+
suggested.push('Ensure the project/workspace path is correct and xcodebuild is installed and accessible.')
|
|
154
|
+
}
|
|
155
|
+
if ((runResult.stderr || '').includes('No such file or directory') || (runResult.stderr || '').includes('not found')) {
|
|
156
|
+
suggested.push('Check that Xcode Command Line Tools are installed and XCRUN_PATH is set if using non-standard location.')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Return diagnostics object
|
|
160
|
+
return { runResult: { ...runResult, suggestedFixes: suggested } }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { runResult: { ...runResult, suggestedFixes: [] } }
|
|
164
|
+
}
|
|
165
|
+
|
|
65
166
|
function parseRuntimeName(runtime: string): string {
|
|
66
167
|
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
67
168
|
try {
|
|
@@ -99,7 +200,7 @@ export async function findAppBundle(dir: string): Promise<string | undefined> {
|
|
|
99
200
|
export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
|
|
100
201
|
return new Promise((resolve) => {
|
|
101
202
|
// If deviceId is provided (and not "booted"), attempt to find that device among booted simulators.
|
|
102
|
-
execFile(
|
|
203
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
103
204
|
const fallback: DeviceInfo = {
|
|
104
205
|
platform: "ios",
|
|
105
206
|
id: deviceId,
|
|
@@ -145,7 +246,7 @@ export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise
|
|
|
145
246
|
|
|
146
247
|
export async function listIOSDevices(appId?: string): Promise<DeviceInfo[]> {
|
|
147
248
|
return new Promise((resolve) => {
|
|
148
|
-
execFile(
|
|
249
|
+
execFile(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
149
250
|
if (err || !stdout) return resolve([])
|
|
150
251
|
try {
|
|
151
252
|
const data = JSON.parse(stdout)
|
package/src/server.ts
CHANGED
|
@@ -145,6 +145,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
145
145
|
required: ["appPath"]
|
|
146
146
|
}
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
name: "build_app",
|
|
150
|
+
description: "Build a project for Android or iOS and return the built artifact path. Does not install.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
platform: { type: "string", enum: ["android", "ios"], description: "Optional. If omitted the server will attempt to detect platform from projectPath files." },
|
|
155
|
+
projectPath: { type: "string", description: "Path to project directory (contains gradlew or xcodeproj/xcworkspace)" },
|
|
156
|
+
variant: { type: "string", description: "Optional build variant (e.g., Debug/Release)" }
|
|
157
|
+
},
|
|
158
|
+
required: ["projectPath"]
|
|
159
|
+
}
|
|
160
|
+
},
|
|
148
161
|
{
|
|
149
162
|
name: "get_logs",
|
|
150
163
|
description: "Get recent logs from Android or iOS simulator. Returns device metadata and the log output.",
|
|
@@ -438,6 +451,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
438
451
|
return wrapResponse(response)
|
|
439
452
|
}
|
|
440
453
|
|
|
454
|
+
if (name === "build_app") {
|
|
455
|
+
const { platform, projectPath, variant } = args as any
|
|
456
|
+
const res = await ToolsManage.buildAppHandler({ platform, projectPath, variant })
|
|
457
|
+
return wrapResponse(res)
|
|
458
|
+
}
|
|
459
|
+
|
|
441
460
|
if (name === 'build_and_install') {
|
|
442
461
|
const { platform, projectPath, deviceId, timeout } = args as any
|
|
443
462
|
const res = await ToolsManage.buildAndInstallHandler({ platform, projectPath, deviceId, timeout })
|
package/src/types.ts
CHANGED
|
@@ -10,22 +10,30 @@ export interface StartAppResponse {
|
|
|
10
10
|
device: DeviceInfo;
|
|
11
11
|
appStarted: boolean;
|
|
12
12
|
launchTimeMs: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
diagnostics?: any;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface TerminateAppResponse {
|
|
16
18
|
device: DeviceInfo;
|
|
17
19
|
appTerminated: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
diagnostics?: any;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface RestartAppResponse {
|
|
21
25
|
device: DeviceInfo;
|
|
22
26
|
appRestarted: boolean;
|
|
23
27
|
launchTimeMs: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
diagnostics?: any;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export interface ResetAppDataResponse {
|
|
27
33
|
device: DeviceInfo;
|
|
28
34
|
dataCleared: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
diagnostics?: any;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface GetLogsResponse {
|
|
@@ -133,4 +141,5 @@ export interface InstallAppResponse {
|
|
|
133
141
|
installed: boolean;
|
|
134
142
|
output?: string;
|
|
135
143
|
error?: string;
|
|
144
|
+
diagnostics?: any;
|
|
136
145
|
}
|