mobile-debug-mcp 0.4.0 → 0.5.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/smoke-test.js ADDED
@@ -0,0 +1,102 @@
1
+ import { startAndroidApp, terminateAndroidApp, captureAndroidScreen, getAndroidLogs } from "./src/android.js";
2
+ import { startIOSApp, terminateIOSApp, captureIOSScreenshot, getIOSLogs } from "./src/ios.js";
3
+ import fs from "fs/promises";
4
+ async function main() {
5
+ const args = process.argv.slice(2);
6
+ const platform = args[0]; // Cast to string first
7
+ const appId = args[1];
8
+ if ((platform !== "android" && platform !== "ios") || !appId) {
9
+ console.error("Usage: npx tsx smoke-test.ts <android|ios> <appId>");
10
+ process.exit(1);
11
+ }
12
+ console.log(`\nšŸš€ Starting smoke test for ${platform} app: ${appId}`);
13
+ try {
14
+ // 1. Start App
15
+ console.log(`[1/4] Starting app...`);
16
+ let startResult;
17
+ let launchTimeMs;
18
+ if (platform === "android") {
19
+ const result = await startAndroidApp(appId);
20
+ startResult = result.appStarted;
21
+ launchTimeMs = result.launchTimeMs;
22
+ }
23
+ else {
24
+ const result = await startIOSApp(appId);
25
+ startResult = result.appStarted;
26
+ launchTimeMs = result.launchTimeMs;
27
+ }
28
+ if (startResult) {
29
+ console.log(`āœ… App started successfully (Launch time: ${launchTimeMs}ms)`);
30
+ }
31
+ else {
32
+ throw new Error("Failed to start app");
33
+ }
34
+ // Wait for app to settle
35
+ console.log(`ā³ Waiting 3s for app to load...`);
36
+ await new Promise(r => setTimeout(r, 3000));
37
+ // 2. Capture Screenshot
38
+ console.log(`[2/4] Capturing screenshot...`);
39
+ let screenshotBase64;
40
+ let resolution;
41
+ if (platform === "android") {
42
+ const result = await captureAndroidScreen(appId);
43
+ screenshotBase64 = result.screenshot;
44
+ resolution = result.resolution;
45
+ }
46
+ else {
47
+ const result = await captureIOSScreenshot();
48
+ screenshotBase64 = result.screenshot;
49
+ resolution = result.resolution;
50
+ }
51
+ if (screenshotBase64) {
52
+ const fileName = `smoke-test-${platform}.png`;
53
+ await fs.writeFile(fileName, Buffer.from(screenshotBase64, 'base64'));
54
+ console.log(`āœ… Screenshot saved to ./${fileName} (${resolution.width}x${resolution.height})`);
55
+ }
56
+ else {
57
+ throw new Error("Failed to capture screenshot");
58
+ }
59
+ // 3. Get Logs
60
+ console.log(`[3/4] Fetching logs...`);
61
+ let logsCount = 0;
62
+ let logs = [];
63
+ if (platform === "android") {
64
+ const result = await getAndroidLogs(appId, 50);
65
+ logsCount = result.logCount;
66
+ logs = result.logs;
67
+ }
68
+ else {
69
+ const result = await getIOSLogs();
70
+ logsCount = result.logCount;
71
+ logs = result.logs;
72
+ }
73
+ console.log(`āœ… Retrieved ${logsCount} log lines`);
74
+ // Print last log line as sample
75
+ if (logs.length > 0) {
76
+ console.log(` Sample: "${logs[logs.length - 1].substring(0, 80)}..."`);
77
+ }
78
+ // 4. Terminate App
79
+ console.log(`[4/4] Terminating app...`);
80
+ let termResult;
81
+ if (platform === "android") {
82
+ const result = await terminateAndroidApp(appId);
83
+ termResult = result.appTerminated;
84
+ }
85
+ else {
86
+ const result = await terminateIOSApp(appId);
87
+ termResult = result.appTerminated;
88
+ }
89
+ if (termResult) {
90
+ console.log(`āœ… App terminated successfully`);
91
+ }
92
+ else {
93
+ throw new Error("Failed to terminate app");
94
+ }
95
+ console.log(`\n✨ Smoke test COMPLETED SUCCESSFULLY! ✨\n`);
96
+ }
97
+ catch (error) {
98
+ console.error(`\nāŒ Smoke test FAILED:`, error);
99
+ process.exit(1);
100
+ }
101
+ }
102
+ main();
package/smoke-test.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { startAndroidApp, terminateAndroidApp, captureAndroidScreen, getAndroidLogs } from "./src/android.js";
2
+ import { startIOSApp, terminateIOSApp, captureIOSScreenshot, getIOSLogs } from "./src/ios.js";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ const platform = args[0] as string; // Cast to string first
9
+ const appId = args[1];
10
+
11
+ if ((platform !== "android" && platform !== "ios") || !appId) {
12
+ console.error("Usage: npx tsx smoke-test.ts <android|ios> <appId>");
13
+ process.exit(1);
14
+ }
15
+
16
+ console.log(`\nšŸš€ Starting smoke test for ${platform} app: ${appId}`);
17
+
18
+ try {
19
+ // 1. Start App
20
+ console.log(`[1/4] Starting app...`);
21
+ let startResult: boolean;
22
+ let launchTimeMs: number;
23
+
24
+ if (platform === "android") {
25
+ const result = await startAndroidApp(appId);
26
+ startResult = result.appStarted;
27
+ launchTimeMs = result.launchTimeMs;
28
+ } else {
29
+ const result = await startIOSApp(appId);
30
+ startResult = result.appStarted;
31
+ launchTimeMs = result.launchTimeMs;
32
+ }
33
+
34
+ if (startResult) {
35
+ console.log(`āœ… App started successfully (Launch time: ${launchTimeMs}ms)`);
36
+ } else {
37
+ throw new Error("Failed to start app");
38
+ }
39
+
40
+ // Wait for app to settle
41
+ console.log(`ā³ Waiting 3s for app to load...`);
42
+ await new Promise(r => setTimeout(r, 3000));
43
+
44
+ // 2. Capture Screenshot
45
+ console.log(`[2/4] Capturing screenshot...`);
46
+ let screenshotBase64: string;
47
+ let resolution: { width: number; height: number };
48
+
49
+ if (platform === "android") {
50
+ const result = await captureAndroidScreen();
51
+ screenshotBase64 = result.screenshot;
52
+ resolution = result.resolution;
53
+ } else {
54
+ const result = await captureIOSScreenshot();
55
+ screenshotBase64 = result.screenshot;
56
+ resolution = result.resolution;
57
+ }
58
+
59
+ if (screenshotBase64) {
60
+ const fileName = `smoke-test-${platform}.png`;
61
+ await fs.writeFile(fileName, Buffer.from(screenshotBase64, 'base64'));
62
+ console.log(`āœ… Screenshot saved to ./${fileName} (${resolution.width}x${resolution.height})`);
63
+ } else {
64
+ throw new Error("Failed to capture screenshot");
65
+ }
66
+
67
+ // 3. Get Logs
68
+ console.log(`[3/4] Fetching logs...`);
69
+ let logsCount = 0;
70
+ let logs: string[] = [];
71
+
72
+ if (platform === "android") {
73
+ const result = await getAndroidLogs(appId, 50);
74
+ logsCount = result.logCount;
75
+ logs = result.logs;
76
+ } else {
77
+ const result = await getIOSLogs();
78
+ logsCount = result.logCount;
79
+ logs = result.logs;
80
+ }
81
+
82
+ console.log(`āœ… Retrieved ${logsCount} log lines`);
83
+ // Print last log line as sample
84
+ if (logs.length > 0) {
85
+ console.log(` Sample: "${logs[logs.length - 1].substring(0, 80)}..."`);
86
+ }
87
+
88
+ // 4. Terminate App
89
+ console.log(`[4/4] Terminating app...`);
90
+ let termResult: boolean;
91
+
92
+ if (platform === "android") {
93
+ const result = await terminateAndroidApp(appId);
94
+ termResult = result.appTerminated;
95
+ } else {
96
+ const result = await terminateIOSApp(appId);
97
+ termResult = result.appTerminated;
98
+ }
99
+
100
+ if (termResult) {
101
+ console.log(`āœ… App terminated successfully`);
102
+ } else {
103
+ throw new Error("Failed to terminate app");
104
+ }
105
+
106
+ console.log(`\n✨ Smoke test COMPLETED SUCCESSFULLY! ✨\n`);
107
+
108
+ } catch (error) {
109
+ console.error(`\nāŒ Smoke test FAILED:`, error);
110
+ process.exit(1);
111
+ }
112
+ }
113
+
114
+ main();
115
+
package/src/android.ts CHANGED
@@ -1,48 +1,222 @@
1
- import { exec } from "child_process"
1
+ import { execFile, spawn } from "child_process"
2
+ import { StartAppResponse, GetLogsResponse, CaptureAndroidScreenResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, DeviceInfo } from "./types.js"
2
3
 
3
4
  const ADB = process.env.ADB_PATH || "adb"
4
5
 
5
- export function startAndroidApp(pkg: string): Promise<string> {
6
- return new Promise((resolve, reject) => {
7
- exec(
8
- `${ADB} shell monkey -p ${pkg} -c android.intent.category.LAUNCHER 1`,
9
- (err, stdout, stderr) => {
10
- if (err) reject(stderr)
11
- else resolve(stdout)
12
- }
13
- )
14
- })
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
15
12
  }
16
13
 
17
- export function getAndroidLogs(pkg: string, lines = 200): Promise<string> {
14
+ function execAdb(args: string[], deviceId?: string, options: any = {}): Promise<string> {
15
+ const adbArgs = getAdbArgs(args, deviceId)
18
16
  return new Promise((resolve, reject) => {
19
- exec(`${ADB} shell pidof -s ${pkg}`, (pidErr, pidStdout, pidStderr) => {
20
- if (pidErr || !pidStdout.trim()) {
21
- reject(pidStderr || "App process not running")
22
- return
23
- }
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 = ''
24
22
 
25
- const pid = pidStdout.trim()
23
+ if (child.stdout) {
24
+ child.stdout.on('data', (data) => {
25
+ stdout += data.toString()
26
+ })
27
+ }
26
28
 
27
- exec(`${ADB} logcat -d --pid=${pid} -t ${lines} -v threadtime`, (err, stdout, stderr) => {
28
- if (err) reject(stderr || err.message)
29
- else resolve(stdout)
29
+ if (child.stderr) {
30
+ child.stderr.on('data', (data) => {
31
+ stderr += data.toString()
30
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)
31
55
  })
32
56
  })
33
57
  }
34
58
 
35
- export async function getAndroidCrash(pkg: string, lines = 200): Promise<string> {
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> {
36
70
  try {
37
- const logs = await getAndroidLogs(pkg, lines)
38
- const crashLines = logs
39
- .split('\n')
40
- .filter(line => line.includes('FATAL EXCEPTION'))
41
- if (crashLines.length === 0) {
42
- return "No crashes found."
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
+ }
43
147
  }
44
- return crashLines.join('\n')
45
- } catch (error) {
46
- return `Error retrieving crash logs: ${error}`
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 }
47
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
+ })
48
222
  }