mobile-debug-mcp 0.5.0 → 0.7.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 +244 -31
- package/dist/android/interact.js +106 -0
- package/dist/android/observe.js +313 -0
- package/dist/android/utils.js +82 -0
- package/dist/android.js +131 -1
- package/dist/ios/interact.js +135 -0
- package/dist/ios/observe.js +219 -0
- package/dist/ios/utils.js +114 -0
- package/dist/ios.js +134 -0
- package/dist/server.js +271 -27
- package/docs/CHANGELOG.md +11 -1
- package/package.json +2 -1
- package/smoke-test.ts +17 -10
- package/src/android/interact.ts +126 -0
- package/src/android/observe.ts +360 -0
- package/src/android/utils.ts +94 -0
- package/src/ios/interact.ts +153 -0
- package/src/ios/observe.ts +269 -0
- package/src/ios/utils.ts +133 -0
- package/src/server.ts +322 -28
- package/src/types.ts +71 -0
- package/test/run-real-test.js +24 -0
- package/test/wait_for_element_mock.js +113 -0
- package/test/wait_for_element_real.js +67 -0
- package/test-ui-tree.js +68 -0
- package/test-ui-tree.ts +76 -0
- package/tsconfig.json +2 -1
- package/src/android.ts +0 -222
- package/src/ios.ts +0 -243
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AndroidInteract } from "../src/android/interact.js";
|
|
2
|
+
import { AndroidObserve } from "../src/android/observe.js";
|
|
3
|
+
// Configure ADB path and target via env vars or CLI args.
|
|
4
|
+
// Usage: node test/wait_for_element_real.js [deviceId] [appId]
|
|
5
|
+
// Priority: CLI args > environment variables
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const DEVICE_ID = args[0] || process.env.DEVICE_ID;
|
|
8
|
+
const APP_ID = args[1] || process.env.APP_ID;
|
|
9
|
+
|
|
10
|
+
// Do not hard-code ADB_PATH here. If the user supplied ADB_PATH in the environment, leave it as-is.
|
|
11
|
+
// (process.env.ADB_PATH may be set externally before running this script.)
|
|
12
|
+
|
|
13
|
+
if (!DEVICE_ID || !APP_ID) {
|
|
14
|
+
console.error("Usage: node test/wait_for_element_real.js <deviceId> <appId> or set DEVICE_ID and APP_ID env vars");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
async function runRealTest() {
|
|
18
|
+
console.log(`Connecting to device ${DEVICE_ID}...`);
|
|
19
|
+
const interact = new AndroidInteract();
|
|
20
|
+
const observe = new AndroidObserve();
|
|
21
|
+
try {
|
|
22
|
+
// 1. Start App
|
|
23
|
+
console.log(`\nStarting app ${APP_ID}...`);
|
|
24
|
+
await interact.startApp(APP_ID, DEVICE_ID);
|
|
25
|
+
// Give it a moment to render
|
|
26
|
+
console.log("Waiting 3s for app to render...");
|
|
27
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
28
|
+
// 2. Get UI Tree to find a valid text target
|
|
29
|
+
console.log("\nFetching UI Tree to find a target text...");
|
|
30
|
+
const tree = await observe.getUITree(DEVICE_ID);
|
|
31
|
+
if (tree.error) {
|
|
32
|
+
console.error("Failed to get UI Tree:", tree.error);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Find first visible element with text
|
|
36
|
+
const targetElement = tree.elements.find(e => e.text && e.text.length > 0 && e.visible);
|
|
37
|
+
if (!targetElement || !targetElement.text) {
|
|
38
|
+
console.warn("No visible text elements found on screen to test with.");
|
|
39
|
+
console.log("Elements found:", tree.elements.length);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const targetText = targetElement.text;
|
|
43
|
+
console.log(`Found target element: "${targetText}"`);
|
|
44
|
+
// 3. Test waitForElement (Success Case)
|
|
45
|
+
console.log(`\nTest 1: Waiting for existing element "${targetText}" (should succeed)...`);
|
|
46
|
+
const start1 = Date.now();
|
|
47
|
+
const result1 = await interact.waitForElement(targetText, 5000, DEVICE_ID);
|
|
48
|
+
const elapsed1 = Date.now() - start1;
|
|
49
|
+
console.log(`Result: ${result1.found ? "PASS" : "FAIL"}`);
|
|
50
|
+
console.log(`Found Element: ${result1.element?.text}`);
|
|
51
|
+
console.log(`Time taken: ${elapsed1}ms`);
|
|
52
|
+
// 4. Test waitForElement (Timeout Case)
|
|
53
|
+
const missingText = "THIS_TEXT_SHOULD_NOT_EXIST_XYZ_123";
|
|
54
|
+
console.log(`\nTest 2: Waiting for missing element "${missingText}" (should timeout)...`);
|
|
55
|
+
const start2 = Date.now();
|
|
56
|
+
// Use short timeout 2s
|
|
57
|
+
const result2 = await interact.waitForElement(missingText, 2000, DEVICE_ID);
|
|
58
|
+
const elapsed2 = Date.now() - start2;
|
|
59
|
+
console.log(`Result: ${!result2.found ? "PASS" : "FAIL"}`);
|
|
60
|
+
console.log(`Found: ${result2.found}`);
|
|
61
|
+
console.log(`Time taken: ${elapsed2}ms (expected ~2000ms)`);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error("Test failed with error:", error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
runRealTest();
|
package/test-ui-tree.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test script for verify UI Tree functionality.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx test-ui-tree.ts [android|ios] [deviceId]
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* npx tsx test-ui-tree.ts android
|
|
9
|
+
* npx tsx test-ui-tree.ts ios booted
|
|
10
|
+
*/
|
|
11
|
+
import { AndroidObserve } from "./src/android/observe.js";
|
|
12
|
+
import { iOSObserve } from "./src/ios/observe.js";
|
|
13
|
+
async function main() {
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const platform = (args[0] || 'android').toLowerCase();
|
|
16
|
+
const deviceId = args[1];
|
|
17
|
+
console.log(`Starting UI Tree Test for ${platform}...`);
|
|
18
|
+
if (deviceId)
|
|
19
|
+
console.log(`Targeting device: ${deviceId}`);
|
|
20
|
+
try {
|
|
21
|
+
let result;
|
|
22
|
+
if (platform === 'ios') {
|
|
23
|
+
const observer = new iOSObserve();
|
|
24
|
+
result = await observer.getUITree(deviceId || 'booted');
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const observer = new AndroidObserve();
|
|
28
|
+
result = await observer.getUITree(deviceId);
|
|
29
|
+
}
|
|
30
|
+
console.log("\nUI Tree Result Summary:");
|
|
31
|
+
console.log("-----------------------");
|
|
32
|
+
if (result.error) {
|
|
33
|
+
console.error("❌ Error:", result.error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
console.log(`Device: ${result.device.platform} (${result.device.model || 'Unknown Model'})`);
|
|
37
|
+
console.log(`Resolution: ${result.resolution.width}x${result.resolution.height}`);
|
|
38
|
+
console.log(`Elements Found: ${result.elements.length}`);
|
|
39
|
+
if (result.elements.length === 0) {
|
|
40
|
+
console.warn("⚠️ Warning: No elements found. Is the screen empty or locked?");
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// Print sample element to verify structure
|
|
44
|
+
const first = result.elements[0];
|
|
45
|
+
console.log("\nSample Element (First):");
|
|
46
|
+
console.log(JSON.stringify(first, null, 2));
|
|
47
|
+
// Check for new fields
|
|
48
|
+
if (first.center && first.depth !== undefined) {
|
|
49
|
+
console.log("\n✅ Verified 'center' and 'depth' fields exist.");
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.error("\n❌ 'center' or 'depth' fields missing!");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
// Check for filtering
|
|
56
|
+
const interactive = result.elements.filter(e => e.clickable).length;
|
|
57
|
+
const withText = result.elements.filter(e => e.text).length;
|
|
58
|
+
console.log(`\nStats:`);
|
|
59
|
+
console.log(`- Interactive elements: ${interactive}`);
|
|
60
|
+
console.log(`- Elements with text: ${withText}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error("\n❌ Test Failed:", error);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
main();
|
package/test-ui-tree.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test script for verify UI Tree functionality.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx test-ui-tree.ts [android|ios] [deviceId]
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* npx tsx test-ui-tree.ts android
|
|
9
|
+
* npx tsx test-ui-tree.ts ios booted
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { AndroidObserve } from "./src/android/observe.js";
|
|
13
|
+
import { iOSObserve } from "./src/ios/observe.js";
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const platform = (args[0] || 'android').toLowerCase();
|
|
18
|
+
const deviceId = args[1];
|
|
19
|
+
|
|
20
|
+
console.log(`Starting UI Tree Test for ${platform}...`);
|
|
21
|
+
if (deviceId) console.log(`Targeting device: ${deviceId}`);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
let result;
|
|
25
|
+
|
|
26
|
+
if (platform === 'ios') {
|
|
27
|
+
const observer = new iOSObserve();
|
|
28
|
+
result = await observer.getUITree(deviceId || 'booted');
|
|
29
|
+
} else {
|
|
30
|
+
const observer = new AndroidObserve();
|
|
31
|
+
result = await observer.getUITree(deviceId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log("\nUI Tree Result Summary:");
|
|
35
|
+
console.log("-----------------------");
|
|
36
|
+
|
|
37
|
+
if (result.error) {
|
|
38
|
+
console.error("❌ Error:", result.error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`Device: ${result.device.platform} (${result.device.model || 'Unknown Model'})`);
|
|
43
|
+
console.log(`Resolution: ${result.resolution.width}x${result.resolution.height}`);
|
|
44
|
+
console.log(`Elements Found: ${result.elements.length}`);
|
|
45
|
+
|
|
46
|
+
if (result.elements.length === 0) {
|
|
47
|
+
console.warn("⚠️ Warning: No elements found. Is the screen empty or locked?");
|
|
48
|
+
} else {
|
|
49
|
+
// Print sample element to verify structure
|
|
50
|
+
const first = result.elements[0];
|
|
51
|
+
console.log("\nSample Element (First):");
|
|
52
|
+
console.log(JSON.stringify(first, null, 2));
|
|
53
|
+
|
|
54
|
+
// Check for new fields
|
|
55
|
+
if (first.center && first.depth !== undefined) {
|
|
56
|
+
console.log("\n✅ Verified 'center' and 'depth' fields exist.");
|
|
57
|
+
} else {
|
|
58
|
+
console.error("\n❌ 'center' or 'depth' fields missing!");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for filtering
|
|
63
|
+
const interactive = result.elements.filter(e => e.clickable).length;
|
|
64
|
+
const withText = result.elements.filter(e => e.text).length;
|
|
65
|
+
console.log(`\nStats:`);
|
|
66
|
+
console.log(`- Interactive elements: ${interactive}`);
|
|
67
|
+
console.log(`- Elements with text: ${withText}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error("\n❌ Test Failed:", error);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main();
|
package/tsconfig.json
CHANGED
package/src/android.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from "child_process"
|
|
2
|
-
import { StartAppResponse, GetLogsResponse, CaptureAndroidScreenResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, DeviceInfo } from "./types.js"
|
|
3
|
-
|
|
4
|
-
const ADB = process.env.ADB_PATH || "adb"
|
|
5
|
-
|
|
6
|
-
// Helper to construct ADB args with optional device ID
|
|
7
|
-
function getAdbArgs(args: string[], deviceId?: string): string[] {
|
|
8
|
-
if (deviceId) {
|
|
9
|
-
return ['-s', deviceId, ...args]
|
|
10
|
-
}
|
|
11
|
-
return args
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function execAdb(args: string[], deviceId?: string, options: any = {}): Promise<string> {
|
|
15
|
-
const adbArgs = getAdbArgs(args, deviceId)
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
// Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
|
|
18
|
-
const child = spawn(ADB, adbArgs, options)
|
|
19
|
-
|
|
20
|
-
let stdout = ''
|
|
21
|
-
let stderr = ''
|
|
22
|
-
|
|
23
|
-
if (child.stdout) {
|
|
24
|
-
child.stdout.on('data', (data) => {
|
|
25
|
-
stdout += data.toString()
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (child.stderr) {
|
|
30
|
-
child.stderr.on('data', (data) => {
|
|
31
|
-
stderr += data.toString()
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const timeoutMs = args.includes('logcat') ? 10000 : 2000 // Shorter timeout for metadata queries
|
|
36
|
-
const timeout = setTimeout(() => {
|
|
37
|
-
child.kill()
|
|
38
|
-
reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`))
|
|
39
|
-
}, timeoutMs)
|
|
40
|
-
|
|
41
|
-
child.on('close', (code) => {
|
|
42
|
-
clearTimeout(timeout)
|
|
43
|
-
if (code !== 0) {
|
|
44
|
-
// If there's an actual error (non-zero exit code), reject
|
|
45
|
-
reject(new Error(stderr.trim() || `Command failed with code ${code}`))
|
|
46
|
-
} else {
|
|
47
|
-
// If exit code is 0, resolve with stdout
|
|
48
|
-
resolve(stdout.trim())
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
child.on('error', (err) => {
|
|
53
|
-
clearTimeout(timeout)
|
|
54
|
-
reject(err)
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function getDeviceInfo(deviceId: string, metadata: Partial<DeviceInfo> = {}): DeviceInfo {
|
|
60
|
-
return {
|
|
61
|
-
platform: 'android',
|
|
62
|
-
id: deviceId || 'default',
|
|
63
|
-
osVersion: metadata.osVersion || '',
|
|
64
|
-
model: metadata.model || '',
|
|
65
|
-
simulator: metadata.simulator || false
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function getAndroidDeviceMetadata(appId: string, deviceId?: string): Promise<DeviceInfo> {
|
|
70
|
-
try {
|
|
71
|
-
// Run these in parallel to avoid sequential timeouts
|
|
72
|
-
const [osVersion, model, simOutput] = await Promise.all([
|
|
73
|
-
execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
|
|
74
|
-
execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
|
|
75
|
-
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
|
|
76
|
-
])
|
|
77
|
-
|
|
78
|
-
const simulator = simOutput === '1'
|
|
79
|
-
return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator }
|
|
80
|
-
} catch (e) {
|
|
81
|
-
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false }
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function startAndroidApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
|
|
86
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
87
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
88
|
-
|
|
89
|
-
await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
|
|
90
|
-
|
|
91
|
-
return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function terminateAndroidApp(appId: string, deviceId?: string): Promise<TerminateAppResponse> {
|
|
95
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
96
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
97
|
-
|
|
98
|
-
await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
|
|
99
|
-
|
|
100
|
-
return { device: deviceInfo, appTerminated: true }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export async function restartAndroidApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
|
|
104
|
-
await terminateAndroidApp(appId, deviceId)
|
|
105
|
-
const startResult = await startAndroidApp(appId, deviceId)
|
|
106
|
-
return {
|
|
107
|
-
device: startResult.device,
|
|
108
|
-
appRestarted: startResult.appStarted,
|
|
109
|
-
launchTimeMs: startResult.launchTimeMs
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function resetAndroidAppData(appId: string, deviceId?: string): Promise<ResetAppDataResponse> {
|
|
114
|
-
const metadata = await getAndroidDeviceMetadata(appId, deviceId)
|
|
115
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
116
|
-
|
|
117
|
-
const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
|
|
118
|
-
|
|
119
|
-
return { device: deviceInfo, dataCleared: output === 'Success' }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export async function getAndroidLogs(appId?: string, lines = 200, deviceId?: string): Promise<GetLogsResponse> {
|
|
123
|
-
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
|
|
124
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
// We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
|
|
128
|
-
// and rely on robust string matching against the log line.
|
|
129
|
-
|
|
130
|
-
// Get logs
|
|
131
|
-
const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
|
|
132
|
-
const allLogs = stdout.split('\n')
|
|
133
|
-
|
|
134
|
-
let filteredLogs = allLogs
|
|
135
|
-
if (appId) {
|
|
136
|
-
// Filter by checking if the line contains the appId string.
|
|
137
|
-
const matchingLogs = allLogs.filter(line => line.includes(appId))
|
|
138
|
-
|
|
139
|
-
if (matchingLogs.length > 0) {
|
|
140
|
-
filteredLogs = matchingLogs
|
|
141
|
-
} else {
|
|
142
|
-
// Fallback: if no logs match the appId, return the raw logs (last N lines)
|
|
143
|
-
// This matches the behavior of the "working" version provided by the user,
|
|
144
|
-
// ensuring they at least see system activity if the app is silent or crashing early.
|
|
145
|
-
filteredLogs = allLogs
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length }
|
|
150
|
-
} catch (e) {
|
|
151
|
-
console.error("Error fetching logs:", e)
|
|
152
|
-
return { device: deviceInfo, logs: [], logCount: 0 }
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function captureAndroidScreen(deviceId?: string): Promise<CaptureAndroidScreenResponse> {
|
|
157
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
158
|
-
const deviceInfo: DeviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
159
|
-
|
|
160
|
-
return new Promise((resolve, reject) => {
|
|
161
|
-
const adbArgs = getAdbArgs(['exec-out', 'screencap', '-p'], deviceId)
|
|
162
|
-
|
|
163
|
-
// Using spawn for screencap as well to ensure consistent process handling
|
|
164
|
-
const child = spawn(ADB, adbArgs)
|
|
165
|
-
|
|
166
|
-
const chunks: Buffer[] = []
|
|
167
|
-
let stderr = ''
|
|
168
|
-
|
|
169
|
-
child.stdout.on('data', (chunk) => {
|
|
170
|
-
chunks.push(Buffer.from(chunk))
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
child.stderr.on('data', (data) => {
|
|
174
|
-
stderr += data.toString()
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const timeout = setTimeout(() => {
|
|
178
|
-
child.kill()
|
|
179
|
-
reject(new Error(`ADB screencap timed out after 10s`))
|
|
180
|
-
}, 10000)
|
|
181
|
-
|
|
182
|
-
child.on('close', (code) => {
|
|
183
|
-
clearTimeout(timeout)
|
|
184
|
-
if (code !== 0) {
|
|
185
|
-
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`))
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const screenshotBuffer = Buffer.concat(chunks)
|
|
190
|
-
const screenshotBase64 = screenshotBuffer.toString('base64')
|
|
191
|
-
|
|
192
|
-
// Get resolution
|
|
193
|
-
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
194
|
-
.then(sizeStdout => {
|
|
195
|
-
let width = 0
|
|
196
|
-
let height = 0
|
|
197
|
-
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/)
|
|
198
|
-
if (match) {
|
|
199
|
-
width = parseInt(match[1], 10)
|
|
200
|
-
height = parseInt(match[2], 10)
|
|
201
|
-
}
|
|
202
|
-
resolve({
|
|
203
|
-
device: deviceInfo,
|
|
204
|
-
screenshot: screenshotBase64,
|
|
205
|
-
resolution: { width, height }
|
|
206
|
-
})
|
|
207
|
-
})
|
|
208
|
-
.catch(() => {
|
|
209
|
-
resolve({
|
|
210
|
-
device: deviceInfo,
|
|
211
|
-
screenshot: screenshotBase64,
|
|
212
|
-
resolution: { width: 0, height: 0 }
|
|
213
|
-
})
|
|
214
|
-
})
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
child.on('error', (err) => {
|
|
218
|
-
clearTimeout(timeout)
|
|
219
|
-
reject(err)
|
|
220
|
-
})
|
|
221
|
-
})
|
|
222
|
-
}
|
package/src/ios.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from "child_process"
|
|
2
|
-
import { promises as fs } from "fs"
|
|
3
|
-
import { pathToFileURL } from "url"
|
|
4
|
-
import { StartAppResponse, GetLogsResponse, GetCrashResponse, CaptureIOSScreenshotResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, DeviceInfo } from "./types.js"
|
|
5
|
-
|
|
6
|
-
const XCRUN = process.env.XCRUN_PATH || "xcrun"
|
|
7
|
-
|
|
8
|
-
interface IOSResult {
|
|
9
|
-
output: string
|
|
10
|
-
device: DeviceInfo
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Validate bundle ID to prevent any potential injection or invalid characters
|
|
14
|
-
function validateBundleId(bundleId: string) {
|
|
15
|
-
if (!bundleId) return
|
|
16
|
-
// Allow alphanumeric, dots, hyphens, and underscores.
|
|
17
|
-
if (!/^[a-zA-Z0-9.\-_]+$/.test(bundleId)) {
|
|
18
|
-
throw new Error(`Invalid Bundle ID: ${bundleId}. Must contain only alphanumeric characters, dots, hyphens, or underscores.`)
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function execCommand(args: string[], deviceId: string = "booted"): Promise<IOSResult> {
|
|
23
|
-
return new Promise((resolve, reject) => {
|
|
24
|
-
// Use spawn for better stream control and consistency with Android implementation
|
|
25
|
-
const child = spawn(XCRUN, args)
|
|
26
|
-
|
|
27
|
-
let stdout = ''
|
|
28
|
-
let stderr = ''
|
|
29
|
-
|
|
30
|
-
if (child.stdout) {
|
|
31
|
-
child.stdout.on('data', (data) => {
|
|
32
|
-
stdout += data.toString()
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (child.stderr) {
|
|
37
|
-
child.stderr.on('data', (data) => {
|
|
38
|
-
stderr += data.toString()
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const timeoutMs = args.includes('log') ? 10000 : 5000 // 10s for logs, 5s for others
|
|
43
|
-
const timeout = setTimeout(() => {
|
|
44
|
-
child.kill()
|
|
45
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms: ${XCRUN} ${args.join(' ')}`))
|
|
46
|
-
}, timeoutMs)
|
|
47
|
-
|
|
48
|
-
child.on('close', (code) => {
|
|
49
|
-
clearTimeout(timeout)
|
|
50
|
-
if (code !== 0) {
|
|
51
|
-
reject(new Error(stderr.trim() || `Command failed with code ${code}`))
|
|
52
|
-
} else {
|
|
53
|
-
resolve({ output: stdout.trim(), device: { platform: "ios", id: deviceId } as DeviceInfo })
|
|
54
|
-
}
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
child.on('error', (err) => {
|
|
58
|
-
clearTimeout(timeout)
|
|
59
|
-
reject(err)
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parseRuntimeName(runtime: string): string {
|
|
65
|
-
// Example: com.apple.CoreSimulator.SimRuntime.iOS-17-0 -> iOS 17.0
|
|
66
|
-
try {
|
|
67
|
-
const parts = runtime.split('.')
|
|
68
|
-
const lastPart = parts[parts.length - 1]
|
|
69
|
-
return lastPart.replace(/-/g, ' ').replace('iOS ', 'iOS ') // Keep iOS prefix
|
|
70
|
-
} catch {
|
|
71
|
-
return runtime
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function getIOSDeviceMetadata(deviceId: string = "booted"): Promise<DeviceInfo> {
|
|
76
|
-
return new Promise((resolve) => {
|
|
77
|
-
// If deviceId is provided (and not "booted"), we could try to list just that device.
|
|
78
|
-
// But listing all booted devices is usually fine to find the one we want or just one.
|
|
79
|
-
// Let's stick to listing all and filtering if needed, or just return basic info if we can't find it.
|
|
80
|
-
execFile(XCRUN, ['simctl', 'list', 'devices', 'booted', '--json'], (err, stdout) => {
|
|
81
|
-
// Default fallback
|
|
82
|
-
const fallback: DeviceInfo = {
|
|
83
|
-
platform: "ios",
|
|
84
|
-
id: deviceId,
|
|
85
|
-
osVersion: "Unknown",
|
|
86
|
-
model: "Simulator",
|
|
87
|
-
simulator: true,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (err || !stdout) {
|
|
91
|
-
resolve(fallback)
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
const data = JSON.parse(stdout)
|
|
97
|
-
const devicesMap = data.devices || {}
|
|
98
|
-
|
|
99
|
-
// Find the device
|
|
100
|
-
for (const runtime in devicesMap) {
|
|
101
|
-
const devices = devicesMap[runtime]
|
|
102
|
-
if (Array.isArray(devices)) {
|
|
103
|
-
for (const device of devices) {
|
|
104
|
-
if (deviceId === "booted" || device.udid === deviceId) {
|
|
105
|
-
resolve({
|
|
106
|
-
platform: "ios",
|
|
107
|
-
id: device.udid,
|
|
108
|
-
osVersion: parseRuntimeName(runtime),
|
|
109
|
-
model: device.name,
|
|
110
|
-
simulator: true,
|
|
111
|
-
})
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
resolve(fallback)
|
|
118
|
-
} catch (error) {
|
|
119
|
-
resolve(fallback)
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function startIOSApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
|
|
126
|
-
validateBundleId(bundleId)
|
|
127
|
-
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
|
|
128
|
-
const device = await getIOSDeviceMetadata(deviceId)
|
|
129
|
-
// Simulate launch time and appStarted for demonstration
|
|
130
|
-
return {
|
|
131
|
-
device,
|
|
132
|
-
appStarted: !!result.output,
|
|
133
|
-
launchTimeMs: 1000,
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export async function terminateIOSApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
|
|
138
|
-
validateBundleId(bundleId)
|
|
139
|
-
await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
|
|
140
|
-
const device = await getIOSDeviceMetadata(deviceId)
|
|
141
|
-
return {
|
|
142
|
-
device,
|
|
143
|
-
appTerminated: true
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export async function restartIOSApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
|
|
148
|
-
// terminateIOSApp already validates bundleId
|
|
149
|
-
await terminateIOSApp(bundleId, deviceId)
|
|
150
|
-
const startResult = await startIOSApp(bundleId, deviceId)
|
|
151
|
-
return {
|
|
152
|
-
device: startResult.device,
|
|
153
|
-
appRestarted: startResult.appStarted,
|
|
154
|
-
launchTimeMs: startResult.launchTimeMs
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export async function resetIOSAppData(bundleId: string, deviceId: string = "booted"): Promise<ResetAppDataResponse> {
|
|
159
|
-
validateBundleId(bundleId)
|
|
160
|
-
await terminateIOSApp(bundleId, deviceId)
|
|
161
|
-
const device = await getIOSDeviceMetadata(deviceId)
|
|
162
|
-
|
|
163
|
-
// Get data container path
|
|
164
|
-
const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId)
|
|
165
|
-
const dataPath = containerResult.output.trim()
|
|
166
|
-
|
|
167
|
-
if (!dataPath) {
|
|
168
|
-
throw new Error(`Could not find data container for ${bundleId}`)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Clear contents of Library and Documents
|
|
172
|
-
try {
|
|
173
|
-
const libraryPath = `${dataPath}/Library`
|
|
174
|
-
const documentsPath = `${dataPath}/Documents`
|
|
175
|
-
const tmpPath = `${dataPath}/tmp`
|
|
176
|
-
|
|
177
|
-
await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => {})
|
|
178
|
-
await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => {})
|
|
179
|
-
await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => {})
|
|
180
|
-
|
|
181
|
-
// Re-create empty directories as they are expected by apps
|
|
182
|
-
await fs.mkdir(libraryPath, { recursive: true }).catch(() => {})
|
|
183
|
-
await fs.mkdir(documentsPath, { recursive: true }).catch(() => {})
|
|
184
|
-
await fs.mkdir(tmpPath, { recursive: true }).catch(() => {})
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
device,
|
|
188
|
-
dataCleared: true
|
|
189
|
-
}
|
|
190
|
-
} catch (err) {
|
|
191
|
-
throw new Error(`Failed to clear data for ${bundleId}: ${err instanceof Error ? err.message : String(err)}`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export async function getIOSLogs(appId?: string, deviceId: string = "booted"): Promise<GetLogsResponse> {
|
|
196
|
-
// If appId is provided, use predicate filtering
|
|
197
|
-
// Note: execFile passes args directly, so we don't need shell escaping for the predicate string itself,
|
|
198
|
-
// but we do need to construct the predicate correctly for log show.
|
|
199
|
-
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--last', '1m']
|
|
200
|
-
if (appId) {
|
|
201
|
-
validateBundleId(appId)
|
|
202
|
-
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const result = await execCommand(args, deviceId)
|
|
206
|
-
const device = await getIOSDeviceMetadata(deviceId)
|
|
207
|
-
const logs = result.output ? result.output.split('\n') : []
|
|
208
|
-
return {
|
|
209
|
-
device,
|
|
210
|
-
logs,
|
|
211
|
-
logCount: logs.length,
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
export async function captureIOSScreenshot(deviceId: string = "booted"): Promise<CaptureIOSScreenshotResponse> {
|
|
217
|
-
const device = await getIOSDeviceMetadata(deviceId)
|
|
218
|
-
const tmpFile = `/tmp/mcp-ios-screenshot-${Date.now()}.png`
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
// 1. Capture screenshot to temp file
|
|
222
|
-
await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId)
|
|
223
|
-
|
|
224
|
-
// 2. Read file as base64
|
|
225
|
-
const buffer = await fs.readFile(tmpFile)
|
|
226
|
-
const base64 = buffer.toString('base64')
|
|
227
|
-
|
|
228
|
-
// 3. Clean up
|
|
229
|
-
await fs.rm(tmpFile).catch(() => {})
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
device,
|
|
233
|
-
screenshot: base64,
|
|
234
|
-
// Default resolution since we can't easily parse it without extra libs
|
|
235
|
-
// Clients will read the real dimensions from the PNG header anyway
|
|
236
|
-
resolution: { width: 0, height: 0 },
|
|
237
|
-
}
|
|
238
|
-
} catch (err) {
|
|
239
|
-
// Ensure cleanup happens even on error
|
|
240
|
-
await fs.rm(tmpFile).catch(() => {})
|
|
241
|
-
throw new Error(`Failed to capture screenshot: ${err instanceof Error ? err.message : String(err)}`)
|
|
242
|
-
}
|
|
243
|
-
}
|