mobile-debug-mcp 0.7.0 → 0.8.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 +83 -0
- package/dist/android/interact.js +11 -0
- package/dist/android/utils.js +311 -4
- package/dist/ios/interact.js +38 -0
- package/dist/ios/utils.js +154 -0
- package/dist/resolve-device.js +70 -0
- package/dist/server.js +186 -26
- package/docs/CHANGELOG.md +10 -0
- package/package.json +6 -2
- package/src/android/interact.ts +12 -0
- package/src/android/utils.ts +314 -4
- package/src/ios/interact.ts +36 -0
- package/src/ios/utils.ts +157 -0
- package/src/resolve-device.ts +80 -0
- package/src/server.ts +199 -27
- package/src/types.ts +7 -0
- package/test/integration/index.ts +8 -0
- package/test/integration/logstream-real.ts +35 -0
- package/test/integration/run-real-test.ts +19 -0
- package/{smoke-test.ts → test/integration/smoke-test.ts} +17 -25
- package/test/integration/test-dist.mjs +41 -0
- package/{test-ui-tree.ts → test/integration/test-ui-tree.ts} +2 -2
- package/test/integration/wait_for_element_real.ts +80 -0
- package/test/unit/index.ts +6 -0
- package/test/unit/logparse.test.ts +41 -0
- package/test/unit/logstream.test.ts +46 -0
- package/test/unit/wait_for_element_mock.ts +104 -0
- package/tsconfig.json +1 -1
- package/smoke-test.js +0 -102
- package/test/run-real-test.js +0 -24
- package/test/wait_for_element_mock.js +0 -113
- package/test/wait_for_element_real.js +0 -67
- package/test-ui-tree.js +0 -68
package/README.md
CHANGED
|
@@ -98,6 +98,21 @@ Example WebUI MCP config using `npx --yes` and environment variables:
|
|
|
98
98
|
|
|
99
99
|
All tools accept a JSON input payload and return a structured JSON response. **Every response includes a `device` object** (with information about the selected device/simulator used for the operation), plus the tool-specific output.
|
|
100
100
|
|
|
101
|
+
### list_devices
|
|
102
|
+
Enumerate connected Android devices and iOS simulators.
|
|
103
|
+
|
|
104
|
+
Input (optional):
|
|
105
|
+
```jsonc
|
|
106
|
+
{ "platform": "android" | "ios" }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Response:
|
|
110
|
+
```json
|
|
111
|
+
{ "devices": [ { "id": "emulator-5554", "platform": "android", "osVersion": "11", "model": "sdk_gphone64_arm64", "simulator": true, "appInstalled": false } ] }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use `list_devices` when multiple devices are attached to inspect metadata and pick a device explicitly by passing `deviceId` to subsequent tool calls.
|
|
115
|
+
|
|
101
116
|
### start_app
|
|
102
117
|
Launch a mobile app.
|
|
103
118
|
|
|
@@ -226,6 +241,74 @@ Clear app storage (reset to fresh install state).
|
|
|
226
241
|
}
|
|
227
242
|
```
|
|
228
243
|
|
|
244
|
+
### install_app
|
|
245
|
+
Install an app onto a connected device or simulator (APK for Android, .app/.ipa for iOS).
|
|
246
|
+
|
|
247
|
+
**Input:**
|
|
248
|
+
```jsonc
|
|
249
|
+
{
|
|
250
|
+
"platform": "android" | "ios",
|
|
251
|
+
"appPath": "/path/to/app.apk_or_app.app_or_ipa", // Host path to the app file (Required)
|
|
252
|
+
"deviceId": "emulator-5554" // Optional: target specific device/simulator
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Response:**
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"device": { /* device info */ },
|
|
260
|
+
"installed": true,
|
|
261
|
+
"output": "Platform-specific installer output (adb/simctl/idb)",
|
|
262
|
+
"error": "Optional error message if installation failed"
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Notes:
|
|
267
|
+
- Android: uses `adb install -r <apkPath>`. The APK must be accessible from the host running the MCP server.
|
|
268
|
+
- iOS: attempts `xcrun simctl install` for simulators and falls back to `idb install` if available for physical devices. Ensure `XCRUN_PATH` and `IDB` are configured if using non-standard locations.
|
|
269
|
+
- Installation output and errors are surfaced in the response for debugging.
|
|
270
|
+
|
|
271
|
+
### start_log_stream / read_log_stream / stop_log_stream
|
|
272
|
+
Start a live log stream for an Android app and poll the accumulated entries.
|
|
273
|
+
|
|
274
|
+
start_log_stream starts a background adb logcat process filtered by the app PID. It returns immediately with success and creates a per-session NDJSON file of parsed log entries.
|
|
275
|
+
|
|
276
|
+
read_log_stream retrieves recent parsed entries and includes crash detection metadata.
|
|
277
|
+
|
|
278
|
+
Input (start_log_stream):
|
|
279
|
+
```jsonc
|
|
280
|
+
{
|
|
281
|
+
"packageName": "com.example.app", // Required
|
|
282
|
+
"level": "error" | "warn" | "info" | "debug", // Optional, defaults to "error"
|
|
283
|
+
"sessionId": "optional-session-id" // Optional - used to track stream per debugging session
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Input (read_log_stream):
|
|
288
|
+
```jsonc
|
|
289
|
+
{
|
|
290
|
+
"sessionId": "optional-session-id",
|
|
291
|
+
"limit": 100, // Optional, max number of entries to return (default 100)
|
|
292
|
+
"since": "2026-03-13T14:00:00Z" // Optional, ISO timestamp or epoch ms to return only newer entries
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Response (read_log_stream):
|
|
297
|
+
```json
|
|
298
|
+
{
|
|
299
|
+
"entries": [
|
|
300
|
+
{ "timestamp": "2026-03-13T14:01:04.123Z", "level": "E", "tag": "AndroidRuntime", "message": "FATAL EXCEPTION: main", "crash": true, "exception": "NullPointerException" }
|
|
301
|
+
],
|
|
302
|
+
"crash_summary": { "crash_detected": true, "exception": "NullPointerException", "sample": "FATAL EXCEPTION: main" }
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Notes:
|
|
307
|
+
- The read_log_stream `since` parameter accepts ISO timestamps or epoch milliseconds. Use it to poll incrementally (pass last seen timestamp).
|
|
308
|
+
- Crash detection is heuristic-based (looks for 'FATAL EXCEPTION' and Exception names). It helps agents decide to capture traces or stop tests quickly.
|
|
309
|
+
- stop_log_stream stops the background adb process for the session.
|
|
310
|
+
|
|
311
|
+
|
|
229
312
|
### get_ui_tree
|
|
230
313
|
Get the current UI hierarchy from the device. Returns a structured JSON representation of the screen content.
|
|
231
314
|
|
package/dist/android/interact.js
CHANGED
|
@@ -76,6 +76,17 @@ export class AndroidInteract {
|
|
|
76
76
|
return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
async installApp(apkPath, deviceId) {
|
|
80
|
+
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
81
|
+
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
82
|
+
try {
|
|
83
|
+
const output = await execAdb(['install', '-r', apkPath], deviceId);
|
|
84
|
+
return { device: deviceInfo, installed: true, output };
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
79
90
|
async startApp(appId, deviceId) {
|
|
80
91
|
const metadata = await getAndroidDeviceMetadata(appId, deviceId);
|
|
81
92
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
package/dist/android/utils.js
CHANGED
|
@@ -67,16 +67,323 @@ export function getDeviceInfo(deviceId, metadata = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
export async function getAndroidDeviceMetadata(appId, deviceId) {
|
|
69
69
|
try {
|
|
70
|
+
// If no deviceId provided, try to auto-detect a single connected device
|
|
71
|
+
let resolvedDeviceId = deviceId;
|
|
72
|
+
if (!resolvedDeviceId) {
|
|
73
|
+
try {
|
|
74
|
+
const devicesOutput = await execAdb(['devices']);
|
|
75
|
+
// Parse lines like: "<serial>\tdevice"
|
|
76
|
+
const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
|
|
77
|
+
const deviceLines = lines.slice(1) // skip header
|
|
78
|
+
.map(l => l.split('\t'))
|
|
79
|
+
.filter(parts => parts.length >= 2 && parts[1] === 'device')
|
|
80
|
+
.map(parts => parts[0]);
|
|
81
|
+
if (deviceLines.length === 1) {
|
|
82
|
+
resolvedDeviceId = deviceLines[0];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// ignore and continue without resolvedDeviceId
|
|
87
|
+
}
|
|
88
|
+
}
|
|
70
89
|
// Run these in parallel to avoid sequential timeouts
|
|
71
90
|
const [osVersion, model, simOutput] = await Promise.all([
|
|
72
|
-
execAdb(['shell', 'getprop', 'ro.build.version.release'],
|
|
73
|
-
execAdb(['shell', 'getprop', 'ro.product.model'],
|
|
74
|
-
execAdb(['shell', 'getprop', 'ro.kernel.qemu'],
|
|
91
|
+
execAdb(['shell', 'getprop', 'ro.build.version.release'], resolvedDeviceId).catch(() => ''),
|
|
92
|
+
execAdb(['shell', 'getprop', 'ro.product.model'], resolvedDeviceId).catch(() => ''),
|
|
93
|
+
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], resolvedDeviceId).catch(() => '0')
|
|
75
94
|
]);
|
|
76
95
|
const simulator = simOutput === '1';
|
|
77
|
-
return { platform: 'android', id:
|
|
96
|
+
return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator };
|
|
78
97
|
}
|
|
79
98
|
catch (e) {
|
|
80
99
|
return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
|
|
81
100
|
}
|
|
82
101
|
}
|
|
102
|
+
export async function listAndroidDevices(appId) {
|
|
103
|
+
try {
|
|
104
|
+
const devicesOutput = await execAdb(['devices', '-l']);
|
|
105
|
+
const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
|
|
106
|
+
// Skip header if present (some adb versions include 'List of devices attached')
|
|
107
|
+
const deviceLines = lines.filter(l => !l.startsWith('List of devices')).map(l => l);
|
|
108
|
+
const serials = deviceLines.map(line => line.split(/\s+/)[0]).filter(Boolean);
|
|
109
|
+
const infos = await Promise.all(serials.map(async (serial) => {
|
|
110
|
+
try {
|
|
111
|
+
const [osVersion, model, simOutput] = await Promise.all([
|
|
112
|
+
execAdb(['shell', 'getprop', 'ro.build.version.release'], serial).catch(() => ''),
|
|
113
|
+
execAdb(['shell', 'getprop', 'ro.product.model'], serial).catch(() => ''),
|
|
114
|
+
execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
|
|
115
|
+
]);
|
|
116
|
+
const simulator = simOutput === '1';
|
|
117
|
+
let appInstalled = false;
|
|
118
|
+
if (appId) {
|
|
119
|
+
try {
|
|
120
|
+
const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
|
|
121
|
+
appInstalled = !!(pm && pm.includes('package:'));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
appInstalled = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
|
|
131
|
+
}
|
|
132
|
+
}));
|
|
133
|
+
return infos;
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Log stream management (one stream per session)
|
|
140
|
+
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
141
|
+
import path from 'path';
|
|
142
|
+
const activeLogStreams = new Map();
|
|
143
|
+
// Test helper to register a pre-existing NDJSON file as the active stream for a session (used by unit tests)
|
|
144
|
+
export function _setActiveLogStream(sessionId, file) {
|
|
145
|
+
activeLogStreams.set(sessionId, { proc: { kill: () => { } }, file });
|
|
146
|
+
}
|
|
147
|
+
export function _clearActiveLogStream(sessionId) {
|
|
148
|
+
activeLogStreams.delete(sessionId);
|
|
149
|
+
}
|
|
150
|
+
// Robust log line parser supporting multiple logcat formats
|
|
151
|
+
export function parseLogLine(line) {
|
|
152
|
+
// Collapse internal newlines so multiline stack traces are parseable as a single entry
|
|
153
|
+
const rawLine = line;
|
|
154
|
+
const normalizedLine = rawLine.replace(/\r?\n/g, ' ');
|
|
155
|
+
const entry = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false };
|
|
156
|
+
const nowYear = new Date().getFullYear();
|
|
157
|
+
const tryIso = (ts) => {
|
|
158
|
+
if (!ts)
|
|
159
|
+
return null;
|
|
160
|
+
// If it's already ISO
|
|
161
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(ts))
|
|
162
|
+
return ts;
|
|
163
|
+
// If format MM-DD HH:MM:SS(.sss)
|
|
164
|
+
const m = ts.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
|
|
165
|
+
if (m) {
|
|
166
|
+
const month = m[1];
|
|
167
|
+
const day = m[2];
|
|
168
|
+
const time = m[3];
|
|
169
|
+
const candidate = `${nowYear}-${month}-${day}T${time}`;
|
|
170
|
+
const d = new Date(candidate);
|
|
171
|
+
if (!isNaN(d.getTime()))
|
|
172
|
+
return d.toISOString();
|
|
173
|
+
}
|
|
174
|
+
// If format YYYY-MM-DD HH:MM:SS(.sss)
|
|
175
|
+
const m2 = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
|
|
176
|
+
if (m2) {
|
|
177
|
+
const candidate = `${m2[1]}T${m2[2]}`;
|
|
178
|
+
const d = new Date(candidate);
|
|
179
|
+
if (!isNaN(d.getTime()))
|
|
180
|
+
return d.toISOString();
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
184
|
+
// Patterns to try (ordered)
|
|
185
|
+
const patterns = [
|
|
186
|
+
// MM-DD HH:MM:SS.mmm PID TID LEVEL/Tag: msg
|
|
187
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
188
|
+
// MM-DD HH:MM:SS.mmm PID TID LEVEL Tag: msg (space between level and tag)
|
|
189
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
190
|
+
// YYYY-MM-DD full date with PID TID LEVEL/Tag
|
|
191
|
+
{ re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
192
|
+
// YYYY-MM-DD with space separation
|
|
193
|
+
{ re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
|
|
194
|
+
// MM-DD PID LEVEL/Tag: msg
|
|
195
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
|
|
196
|
+
// MM-DD PID LEVEL Tag: msg (space)
|
|
197
|
+
{ re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
|
|
198
|
+
// Short form LEVEL/Tag: msg
|
|
199
|
+
{ re: /^([VDIWE])\/([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
|
|
200
|
+
// Short form LEVEL Tag: msg
|
|
201
|
+
{ re: /^([VDIWE])\s+([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
|
|
202
|
+
];
|
|
203
|
+
for (const p of patterns) {
|
|
204
|
+
const m = normalizedLine.match(p.re);
|
|
205
|
+
if (m) {
|
|
206
|
+
const g = p.groups;
|
|
207
|
+
const vals = {};
|
|
208
|
+
for (let i = 0; i < g.length; i++)
|
|
209
|
+
vals[g[i]] = m[i + 1];
|
|
210
|
+
const ts = vals.ts;
|
|
211
|
+
if (ts) {
|
|
212
|
+
const iso = tryIso(ts);
|
|
213
|
+
if (iso) {
|
|
214
|
+
entry.timestamp = ts;
|
|
215
|
+
entry._iso = iso;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
entry.timestamp = ts;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (vals.level)
|
|
222
|
+
entry.level = vals.level;
|
|
223
|
+
if (vals.tag)
|
|
224
|
+
entry.tag = vals.tag.trim();
|
|
225
|
+
entry.message = vals.msg || entry.message;
|
|
226
|
+
// Crash heuristics
|
|
227
|
+
const msg = (entry.message || '').toString();
|
|
228
|
+
const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
|
|
229
|
+
if (crash) {
|
|
230
|
+
entry.crash = true;
|
|
231
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
232
|
+
if (exMatch)
|
|
233
|
+
entry.exception = exMatch[1];
|
|
234
|
+
}
|
|
235
|
+
return entry;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// No pattern matched: attempt to extract level/tag like '... E/Tag: msg'
|
|
239
|
+
const alt = normalizedLine.match(/([VDIWE])\/([^:]+):\s*(.*)$/);
|
|
240
|
+
if (alt) {
|
|
241
|
+
entry.level = alt[1];
|
|
242
|
+
entry.tag = alt[2].trim();
|
|
243
|
+
entry.message = alt[3];
|
|
244
|
+
const msg = entry.message;
|
|
245
|
+
const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
|
|
246
|
+
if (crash) {
|
|
247
|
+
entry.crash = true;
|
|
248
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
249
|
+
if (exMatch)
|
|
250
|
+
entry.exception = exMatch[1];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return entry;
|
|
254
|
+
}
|
|
255
|
+
export async function startAndroidLogStream(packageName, level = 'error', deviceId, sessionId = 'default') {
|
|
256
|
+
try {
|
|
257
|
+
// Determine PID
|
|
258
|
+
const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '');
|
|
259
|
+
const pid = (pidOutput || '').trim();
|
|
260
|
+
if (!pid) {
|
|
261
|
+
return { success: false, error: 'app_not_running' };
|
|
262
|
+
}
|
|
263
|
+
// Map level to logcat filter
|
|
264
|
+
const levelMap = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' };
|
|
265
|
+
const filter = levelMap[level] || levelMap['error'];
|
|
266
|
+
// Prevent multiple streams per session
|
|
267
|
+
if (activeLogStreams.has(sessionId)) {
|
|
268
|
+
// stop existing
|
|
269
|
+
try {
|
|
270
|
+
activeLogStreams.get(sessionId).proc.kill();
|
|
271
|
+
}
|
|
272
|
+
catch (e) { }
|
|
273
|
+
activeLogStreams.delete(sessionId);
|
|
274
|
+
}
|
|
275
|
+
// Start logcat process
|
|
276
|
+
const args = ['logcat', `--pid=${pid}`, filter];
|
|
277
|
+
const proc = spawn(ADB, args);
|
|
278
|
+
// Prepare output file
|
|
279
|
+
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
280
|
+
const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
|
|
281
|
+
const stream = createWriteStream(file, { flags: 'a' });
|
|
282
|
+
proc.stdout.on('data', (chunk) => {
|
|
283
|
+
const text = chunk.toString();
|
|
284
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
285
|
+
for (const l of lines) {
|
|
286
|
+
const entry = parseLogLine(l);
|
|
287
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
proc.stderr.on('data', (chunk) => {
|
|
291
|
+
// write stderr lines as message with level 'E'
|
|
292
|
+
const text = chunk.toString();
|
|
293
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
294
|
+
for (const l of lines) {
|
|
295
|
+
const entry = { timestamp: '', level: 'E', tag: 'adb', message: l };
|
|
296
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
proc.on('close', (code) => {
|
|
300
|
+
stream.end();
|
|
301
|
+
activeLogStreams.delete(sessionId);
|
|
302
|
+
});
|
|
303
|
+
activeLogStreams.set(sessionId, { proc, file });
|
|
304
|
+
return { success: true, stream_started: true };
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
return { success: false, error: 'log_stream_start_failed' };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export async function stopAndroidLogStream(sessionId = 'default') {
|
|
311
|
+
const entry = activeLogStreams.get(sessionId);
|
|
312
|
+
if (!entry)
|
|
313
|
+
return { success: true };
|
|
314
|
+
try {
|
|
315
|
+
entry.proc.kill();
|
|
316
|
+
}
|
|
317
|
+
catch (e) { }
|
|
318
|
+
activeLogStreams.delete(sessionId);
|
|
319
|
+
return { success: true };
|
|
320
|
+
}
|
|
321
|
+
export async function readLogStreamLines(sessionId = 'default', limit = 100, since) {
|
|
322
|
+
const entry = activeLogStreams.get(sessionId);
|
|
323
|
+
if (!entry)
|
|
324
|
+
return { entries: [] };
|
|
325
|
+
try {
|
|
326
|
+
const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
|
|
327
|
+
if (!data)
|
|
328
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
329
|
+
const lines = data.split(/\r?\n/).filter(Boolean);
|
|
330
|
+
// Parse NDJSON lines into objects. Prefer fields written by parseLogLine. For backward compatibility, if _iso or crash are missing, enrich minimally here (avoid duplicating full parse logic).
|
|
331
|
+
const parsed = lines.map(l => {
|
|
332
|
+
try {
|
|
333
|
+
const obj = JSON.parse(l);
|
|
334
|
+
// Ensure _iso: if missing, try to derive using Date()
|
|
335
|
+
if (typeof obj._iso === 'undefined') {
|
|
336
|
+
let iso = null;
|
|
337
|
+
if (obj.timestamp) {
|
|
338
|
+
const d = new Date(obj.timestamp);
|
|
339
|
+
if (!isNaN(d.getTime()))
|
|
340
|
+
iso = d.toISOString();
|
|
341
|
+
}
|
|
342
|
+
obj._iso = iso;
|
|
343
|
+
}
|
|
344
|
+
// Ensure crash flag: if missing, run minimal heuristic
|
|
345
|
+
if (typeof obj.crash === 'undefined') {
|
|
346
|
+
const msg = (obj.message || '').toString();
|
|
347
|
+
const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
|
|
348
|
+
if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
|
|
349
|
+
obj.crash = true;
|
|
350
|
+
if (exMatch)
|
|
351
|
+
obj.exception = exMatch[1];
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
obj.crash = false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return obj;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return { message: l, _iso: null, crash: false };
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
// Filter by since if provided (accept ISO or epoch ms)
|
|
364
|
+
let filtered = parsed;
|
|
365
|
+
if (since) {
|
|
366
|
+
let sinceMs = null;
|
|
367
|
+
// If numeric string
|
|
368
|
+
if (/^\d+$/.test(since))
|
|
369
|
+
sinceMs = Number(since);
|
|
370
|
+
else {
|
|
371
|
+
const sDate = new Date(since);
|
|
372
|
+
if (!isNaN(sDate.getTime()))
|
|
373
|
+
sinceMs = sDate.getTime();
|
|
374
|
+
}
|
|
375
|
+
if (sinceMs !== null) {
|
|
376
|
+
filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Return the last `limit` entries (most recent)
|
|
380
|
+
const entries = filtered.slice(-Math.max(0, limit));
|
|
381
|
+
// Crash summary
|
|
382
|
+
const crashEntry = entries.find(e => e.crash);
|
|
383
|
+
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
|
|
384
|
+
return { entries, crash_summary };
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
388
|
+
}
|
|
389
|
+
}
|
package/dist/ios/interact.js
CHANGED
|
@@ -71,6 +71,44 @@ export class iOSInteract {
|
|
|
71
71
|
return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
async installApp(appPath, deviceId = "booted") {
|
|
75
|
+
// Try simulator install first
|
|
76
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
77
|
+
try {
|
|
78
|
+
const res = await execCommand(['simctl', 'install', deviceId, appPath], deviceId);
|
|
79
|
+
return { device, installed: true, output: res.output };
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
// If simctl fails and idb is available, try idb install for physical devices
|
|
83
|
+
try {
|
|
84
|
+
const child = spawn(IDB, ['--version']);
|
|
85
|
+
const idbExists = await new Promise((resolve) => {
|
|
86
|
+
child.on('error', () => resolve(false));
|
|
87
|
+
child.on('close', (code) => resolve(code === 0));
|
|
88
|
+
});
|
|
89
|
+
if (idbExists) {
|
|
90
|
+
// Use idb to install (works for physical devices and simulators)
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
const proc = spawn(IDB, ['install', appPath, '--udid', device.id]);
|
|
93
|
+
let stderr = '';
|
|
94
|
+
proc.stderr.on('data', d => stderr += d.toString());
|
|
95
|
+
proc.on('close', code => {
|
|
96
|
+
if (code === 0)
|
|
97
|
+
resolve();
|
|
98
|
+
else
|
|
99
|
+
reject(new Error(stderr || `idb install failed with code ${code}`));
|
|
100
|
+
});
|
|
101
|
+
proc.on('error', err => reject(err));
|
|
102
|
+
});
|
|
103
|
+
return { device, installed: true };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (inner) {
|
|
107
|
+
// fallthrough
|
|
108
|
+
}
|
|
109
|
+
return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
74
112
|
async startApp(bundleId, deviceId = "booted") {
|
|
75
113
|
validateBundleId(bundleId);
|
|
76
114
|
const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
|
package/dist/ios/utils.js
CHANGED
|
@@ -112,3 +112,157 @@ export async function getIOSDeviceMetadata(deviceId = "booted") {
|
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
|
+
export async function listIOSDevices(appId) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
execFile(XCRUN, ['simctl', 'list', 'devices', '--json'], (err, stdout) => {
|
|
118
|
+
if (err || !stdout)
|
|
119
|
+
return resolve([]);
|
|
120
|
+
try {
|
|
121
|
+
const data = JSON.parse(stdout);
|
|
122
|
+
const devicesMap = data.devices || {};
|
|
123
|
+
const out = [];
|
|
124
|
+
const checks = [];
|
|
125
|
+
for (const runtime in devicesMap) {
|
|
126
|
+
const devices = devicesMap[runtime];
|
|
127
|
+
if (Array.isArray(devices)) {
|
|
128
|
+
for (const device of devices) {
|
|
129
|
+
const info = {
|
|
130
|
+
platform: 'ios',
|
|
131
|
+
id: device.udid,
|
|
132
|
+
osVersion: parseRuntimeName(runtime),
|
|
133
|
+
model: device.name,
|
|
134
|
+
simulator: true
|
|
135
|
+
};
|
|
136
|
+
if (appId) {
|
|
137
|
+
// check if installed
|
|
138
|
+
const p = execCommand(['simctl', 'get_app_container', device.udid, appId, 'data'], device.udid)
|
|
139
|
+
.then(() => { info.appInstalled = true; })
|
|
140
|
+
.catch(() => { info.appInstalled = false; })
|
|
141
|
+
.then(() => { out.push(info); });
|
|
142
|
+
checks.push(p);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
out.push(info);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
Promise.all(checks).then(() => resolve(out)).catch(() => resolve(out));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
resolve([]);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// --- iOS live log stream support ---
|
|
159
|
+
import { createWriteStream, promises as fsPromises } from 'fs';
|
|
160
|
+
import path from 'path';
|
|
161
|
+
import { parseLogLine } from '../android/utils.js';
|
|
162
|
+
const iosActiveLogStreams = new Map();
|
|
163
|
+
// Test helpers
|
|
164
|
+
export function _setIOSActiveLogStream(sessionId, file) {
|
|
165
|
+
iosActiveLogStreams.set(sessionId, { proc: {}, file });
|
|
166
|
+
}
|
|
167
|
+
export function _clearIOSActiveLogStream(sessionId) {
|
|
168
|
+
iosActiveLogStreams.delete(sessionId);
|
|
169
|
+
}
|
|
170
|
+
export async function startIOSLogStream(bundleId, level = 'error', deviceId = 'booted', sessionId = 'default') {
|
|
171
|
+
try {
|
|
172
|
+
// Build predicate to filter by process or subsystem
|
|
173
|
+
const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
|
|
174
|
+
// Prevent multiple streams per session
|
|
175
|
+
if (iosActiveLogStreams.has(sessionId)) {
|
|
176
|
+
try {
|
|
177
|
+
iosActiveLogStreams.get(sessionId).proc.kill();
|
|
178
|
+
}
|
|
179
|
+
catch (e) { }
|
|
180
|
+
iosActiveLogStreams.delete(sessionId);
|
|
181
|
+
}
|
|
182
|
+
// Start simctl log stream: xcrun simctl spawn <device> log stream --style syslog --predicate '<predicate>'
|
|
183
|
+
const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
|
|
184
|
+
const proc = spawn(XCRUN, args);
|
|
185
|
+
// Prepare output file
|
|
186
|
+
const tmpDir = process.env.TMPDIR || '/tmp';
|
|
187
|
+
const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
|
|
188
|
+
const stream = createWriteStream(file, { flags: 'a' });
|
|
189
|
+
proc.stdout.on('data', (chunk) => {
|
|
190
|
+
const text = chunk.toString();
|
|
191
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
192
|
+
for (const l of lines) {
|
|
193
|
+
// Try to parse with shared parser; parser may be optimized for Android but extracts exceptions and message
|
|
194
|
+
const entry = parseLogLine(l);
|
|
195
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
proc.stderr.on('data', (chunk) => {
|
|
199
|
+
const text = chunk.toString();
|
|
200
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
201
|
+
for (const l of lines) {
|
|
202
|
+
const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l };
|
|
203
|
+
stream.write(JSON.stringify(entry) + '\n');
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
proc.on('close', (code) => {
|
|
207
|
+
stream.end();
|
|
208
|
+
iosActiveLogStreams.delete(sessionId);
|
|
209
|
+
});
|
|
210
|
+
iosActiveLogStreams.set(sessionId, { proc, file });
|
|
211
|
+
return { success: true, stream_started: true };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
return { success: false, error: 'log_stream_start_failed' };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function stopIOSLogStream(sessionId = 'default') {
|
|
218
|
+
const entry = iosActiveLogStreams.get(sessionId);
|
|
219
|
+
if (!entry)
|
|
220
|
+
return { success: true };
|
|
221
|
+
try {
|
|
222
|
+
entry.proc.kill();
|
|
223
|
+
}
|
|
224
|
+
catch (e) { }
|
|
225
|
+
iosActiveLogStreams.delete(sessionId);
|
|
226
|
+
return { success: true };
|
|
227
|
+
}
|
|
228
|
+
export async function readIOSLogStreamLines(sessionId = 'default', limit = 100, since) {
|
|
229
|
+
const entry = iosActiveLogStreams.get(sessionId);
|
|
230
|
+
if (!entry)
|
|
231
|
+
return { entries: [] };
|
|
232
|
+
try {
|
|
233
|
+
const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
|
|
234
|
+
if (!data)
|
|
235
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
236
|
+
const lines = data.split(/\r?\n/).filter(Boolean);
|
|
237
|
+
const parsed = lines.map(l => {
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(l);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return { message: l, _iso: null, crash: false };
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
// Minimal since filtering if provided
|
|
246
|
+
let filtered = parsed;
|
|
247
|
+
if (since) {
|
|
248
|
+
let sinceMs = null;
|
|
249
|
+
if (/^\d+$/.test(since))
|
|
250
|
+
sinceMs = Number(since);
|
|
251
|
+
else {
|
|
252
|
+
const sDate = new Date(since);
|
|
253
|
+
if (!isNaN(sDate.getTime()))
|
|
254
|
+
sinceMs = sDate.getTime();
|
|
255
|
+
}
|
|
256
|
+
if (sinceMs !== null) {
|
|
257
|
+
filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const entries = filtered.slice(-Math.max(0, limit));
|
|
261
|
+
const crashEntry = entries.find(e => e.crash);
|
|
262
|
+
const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
|
|
263
|
+
return { entries, crash_summary };
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
return { entries: [], crash_summary: { crash_detected: false } };
|
|
267
|
+
}
|
|
268
|
+
}
|