mobile-debug-mcp 0.6.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 +230 -2
- package/dist/android/interact.js +87 -0
- package/dist/android/utils.js +311 -4
- package/dist/ios/interact.js +109 -1
- package/dist/ios/utils.js +154 -0
- package/dist/resolve-device.js +70 -0
- package/dist/server.js +367 -20
- package/docs/CHANGELOG.md +17 -2
- package/package.json +6 -2
- package/src/android/interact.ts +98 -1
- package/src/android/utils.ts +314 -4
- package/src/ios/interact.ts +116 -2
- package/src/ios/utils.ts +157 -0
- package/src/resolve-device.ts +80 -0
- package/src/server.ts +425 -21
- package/src/types.ts +44 -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-ui-tree.js +0 -68
package/dist/ios/interact.js
CHANGED
|
@@ -1,6 +1,114 @@
|
|
|
1
1
|
import { promises as fs } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
|
|
4
|
+
import { iOSObserve } from "./observe.js";
|
|
3
5
|
export class iOSInteract {
|
|
6
|
+
observe = new iOSObserve();
|
|
7
|
+
async waitForElement(text, timeout, deviceId = "booted") {
|
|
8
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
9
|
+
const startTime = Date.now();
|
|
10
|
+
while (Date.now() - startTime < timeout) {
|
|
11
|
+
try {
|
|
12
|
+
const tree = await this.observe.getUITree(deviceId);
|
|
13
|
+
if (tree.error) {
|
|
14
|
+
return { device, found: false, error: tree.error };
|
|
15
|
+
}
|
|
16
|
+
const element = tree.elements.find(e => e.text === text);
|
|
17
|
+
if (element) {
|
|
18
|
+
return { device, found: true, element };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
// Ignore errors during polling and retry
|
|
23
|
+
console.error("Error polling UI tree:", e);
|
|
24
|
+
}
|
|
25
|
+
const elapsed = Date.now() - startTime;
|
|
26
|
+
const remaining = timeout - elapsed;
|
|
27
|
+
if (remaining <= 0)
|
|
28
|
+
break;
|
|
29
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
30
|
+
}
|
|
31
|
+
return { device, found: false };
|
|
32
|
+
}
|
|
33
|
+
async tap(x, y, deviceId = "booted") {
|
|
34
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
35
|
+
// Check for idb
|
|
36
|
+
const child = spawn(IDB, ['--version']);
|
|
37
|
+
const idbExists = await new Promise((resolve) => {
|
|
38
|
+
child.on('error', () => resolve(false));
|
|
39
|
+
child.on('close', (code) => resolve(code === 0));
|
|
40
|
+
});
|
|
41
|
+
if (!idbExists) {
|
|
42
|
+
return {
|
|
43
|
+
device,
|
|
44
|
+
success: false,
|
|
45
|
+
x,
|
|
46
|
+
y,
|
|
47
|
+
error: "iOS tap requires 'idb' (iOS Device Bridge)."
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
|
|
52
|
+
const args = ['ui', 'tap', x.toString(), y.toString()];
|
|
53
|
+
if (targetUdid) {
|
|
54
|
+
args.push('--udid', targetUdid);
|
|
55
|
+
}
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
const proc = spawn(IDB, args);
|
|
58
|
+
let stderr = '';
|
|
59
|
+
proc.stderr.on('data', d => stderr += d.toString());
|
|
60
|
+
proc.on('close', code => {
|
|
61
|
+
if (code === 0)
|
|
62
|
+
resolve();
|
|
63
|
+
else
|
|
64
|
+
reject(new Error(`idb ui tap failed: ${stderr}`));
|
|
65
|
+
});
|
|
66
|
+
proc.on('error', err => reject(err));
|
|
67
|
+
});
|
|
68
|
+
return { device, success: true, x, y };
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
|
|
72
|
+
}
|
|
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
|
+
}
|
|
4
112
|
async startApp(bundleId, deviceId = "booted") {
|
|
5
113
|
validateBundleId(bundleId);
|
|
6
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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { listAndroidDevices } from "./android/utils.js";
|
|
2
|
+
import { listIOSDevices } from "./ios/utils.js";
|
|
3
|
+
function parseNumericVersion(v) {
|
|
4
|
+
if (!v)
|
|
5
|
+
return 0;
|
|
6
|
+
// extract first number groups like 17.0 -> 17.0 or Android 12 -> 12
|
|
7
|
+
const m = v.match(/(\d+)(?:[\.\-](\d+))?/);
|
|
8
|
+
if (!m)
|
|
9
|
+
return 0;
|
|
10
|
+
const major = parseInt(m[1], 10) || 0;
|
|
11
|
+
const minor = parseInt(m[2] || "0", 10) || 0;
|
|
12
|
+
return major + minor / 100;
|
|
13
|
+
}
|
|
14
|
+
export async function listDevices(platform, appId) {
|
|
15
|
+
if (!platform || platform === "android") {
|
|
16
|
+
const android = await listAndroidDevices(appId);
|
|
17
|
+
if (platform === "android")
|
|
18
|
+
return android;
|
|
19
|
+
// if no platform specified, merge with ios below
|
|
20
|
+
const ios = await listIOSDevices(appId);
|
|
21
|
+
return [...android, ...ios];
|
|
22
|
+
}
|
|
23
|
+
return listIOSDevices(appId);
|
|
24
|
+
}
|
|
25
|
+
export async function resolveTargetDevice(opts) {
|
|
26
|
+
const { platform, appId, prefer, deviceId } = opts;
|
|
27
|
+
const devices = await listDevices(platform, appId);
|
|
28
|
+
if (deviceId) {
|
|
29
|
+
const found = devices.find(d => d.id === deviceId);
|
|
30
|
+
if (!found)
|
|
31
|
+
throw new Error(`Device '${deviceId}' not found for platform ${platform}`);
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
let candidates = devices.slice();
|
|
35
|
+
// Apply prefer filter
|
|
36
|
+
if (prefer === "physical")
|
|
37
|
+
candidates = candidates.filter(d => !d.simulator);
|
|
38
|
+
if (prefer === "emulator")
|
|
39
|
+
candidates = candidates.filter(d => d.simulator);
|
|
40
|
+
// If appId provided, prefer devices with appInstalled
|
|
41
|
+
if (appId) {
|
|
42
|
+
const installed = candidates.filter(d => d.appInstalled);
|
|
43
|
+
if (installed.length > 0)
|
|
44
|
+
candidates = installed;
|
|
45
|
+
}
|
|
46
|
+
if (candidates.length === 1)
|
|
47
|
+
return candidates[0];
|
|
48
|
+
if (candidates.length > 1) {
|
|
49
|
+
// Prefer physical over emulator unless prefer=emulator
|
|
50
|
+
if (!prefer) {
|
|
51
|
+
const physical = candidates.filter(d => !d.simulator);
|
|
52
|
+
if (physical.length === 1)
|
|
53
|
+
return physical[0];
|
|
54
|
+
if (physical.length > 1)
|
|
55
|
+
candidates = physical;
|
|
56
|
+
}
|
|
57
|
+
// Pick highest OS version
|
|
58
|
+
candidates.sort((a, b) => parseNumericVersion(b.osVersion) - parseNumericVersion(a.osVersion));
|
|
59
|
+
// If top is unique (numeric differs), return it
|
|
60
|
+
if (candidates.length > 1 && parseNumericVersion(candidates[0].osVersion) > parseNumericVersion(candidates[1].osVersion)) {
|
|
61
|
+
return candidates[0];
|
|
62
|
+
}
|
|
63
|
+
// Ambiguous: throw an error with candidate list so caller (agent) can present choices
|
|
64
|
+
const list = candidates.map(d => ({ id: d.id, platform: d.platform, osVersion: d.osVersion, model: d.model, simulator: d.simulator, appInstalled: d.appInstalled }));
|
|
65
|
+
const err = new Error(`Multiple matching devices found: ${JSON.stringify(list, null, 2)}`);
|
|
66
|
+
err.devices = list;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`No devices found for platform ${platform}`);
|
|
70
|
+
}
|