mobile-debug-mcp 0.21.1 → 0.21.3
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/dist/interact/index.js +25 -4
- package/dist/observe/android.js +170 -31
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +168 -11
- package/dist/server.js +24 -12
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/dist/utils/image.js +18 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/tools/observe.md +15 -10
- package/package.json +3 -2
- package/src/interact/index.ts +18 -4
- package/src/observe/android.ts +169 -34
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +176 -13
- package/src/server.ts +23 -11
- package/src/types.ts +15 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- package/src/utils/image.ts +14 -0
- package/test/helpers/.gitkeep +0 -0
- package/test/helpers/run-get-logs.ts +20 -0
- package/test/observe/device/get_logs.android.smoke.ts +35 -0
- package/test/observe/device/get_logs.ios.smoke.ts +33 -0
- package/test/observe/unit/get_logs.test.ts +58 -0
package/dist/interact/index.js
CHANGED
|
@@ -219,6 +219,23 @@ export class ToolsInteract {
|
|
|
219
219
|
// Backwards-compatible wrapper that delegates to the core waitForUICore implementation
|
|
220
220
|
return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
|
|
221
221
|
}
|
|
222
|
+
// Helper: normalize various log objects into plain message strings for comparison
|
|
223
|
+
static _logsToMessages(logsArr) {
|
|
224
|
+
if (!Array.isArray(logsArr))
|
|
225
|
+
return [];
|
|
226
|
+
return logsArr.map((l) => {
|
|
227
|
+
if (typeof l === 'string')
|
|
228
|
+
return l;
|
|
229
|
+
if (l && (l.message || l.msg))
|
|
230
|
+
return l.message || l.msg;
|
|
231
|
+
try {
|
|
232
|
+
return JSON.stringify(l);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return String(l);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
222
239
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
|
|
223
240
|
const start = Date.now();
|
|
224
241
|
let lastFingerprint = null;
|
|
@@ -275,7 +292,9 @@ export class ToolsInteract {
|
|
|
275
292
|
initialFingerprint = fpRes.fingerprint ?? null;
|
|
276
293
|
if (gl) {
|
|
277
294
|
const logsArr = Array.isArray(gl.logs) ? gl.logs : [];
|
|
278
|
-
|
|
295
|
+
// Normalize to last message string for baseline comparison
|
|
296
|
+
const msgs = ToolsInteract._logsToMessages(logsArr);
|
|
297
|
+
baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null;
|
|
279
298
|
}
|
|
280
299
|
}
|
|
281
300
|
catch (err) {
|
|
@@ -354,13 +373,15 @@ export class ToolsInteract {
|
|
|
354
373
|
}
|
|
355
374
|
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 });
|
|
356
375
|
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : [];
|
|
376
|
+
// Normalize to messages for comparison
|
|
377
|
+
const msgs = ToolsInteract._logsToMessages(logsArr);
|
|
357
378
|
let startIndex = 0;
|
|
358
379
|
if (baselineLastLine) {
|
|
359
|
-
const idx =
|
|
380
|
+
const idx = msgs.lastIndexOf(baselineLastLine);
|
|
360
381
|
startIndex = idx >= 0 ? idx + 1 : 0;
|
|
361
382
|
}
|
|
362
|
-
for (let i = startIndex; i <
|
|
363
|
-
const line =
|
|
383
|
+
for (let i = startIndex; i < msgs.length; i++) {
|
|
384
|
+
const line = msgs[i];
|
|
364
385
|
if (q && String(line).includes(q)) {
|
|
365
386
|
const now2 = Date.now();
|
|
366
387
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true };
|
package/dist/observe/android.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createWriteStream } from "fs";
|
|
|
5
5
|
import { promises as fsPromises } from "fs";
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { computeScreenFingerprint } from "../utils/ui/index.js";
|
|
8
|
+
import { parsePngSize } from "../utils/image.js";
|
|
8
9
|
const activeLogStreams = new Map();
|
|
9
10
|
export class AndroidObserve {
|
|
10
11
|
async getDeviceMetadata(appId, deviceId) {
|
|
@@ -79,23 +80,98 @@ export class AndroidObserve {
|
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
async getLogs(
|
|
83
|
+
async getLogs(filters = {}) {
|
|
84
|
+
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
|
|
83
85
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
84
86
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
85
87
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
let
|
|
88
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
|
|
89
|
+
// If appId provided, try to get pid to filter at source
|
|
90
|
+
let pidArg = null;
|
|
89
91
|
if (appId) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
try {
|
|
93
|
+
const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '');
|
|
94
|
+
const pidTrim = (pidOut || '').trim();
|
|
95
|
+
if (pidTrim)
|
|
96
|
+
pidArg = pidTrim.split('\n')[0];
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
catch (err) {
|
|
99
|
+
// Log a warning so failures to detect PID are visible during debugging
|
|
100
|
+
try {
|
|
101
|
+
console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err));
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
|
-
|
|
106
|
+
const args = ['logcat', '-d', '-v', 'threadtime'];
|
|
107
|
+
// Apply pid filter via --pid if available
|
|
108
|
+
if (pidArg)
|
|
109
|
+
args.push(`--pid=${pidArg}`);
|
|
110
|
+
else if (pid)
|
|
111
|
+
args.push(`--pid=${pid}`);
|
|
112
|
+
// Apply tag/level filter if provided
|
|
113
|
+
if (tag && level) {
|
|
114
|
+
// Map verbose/debug/info/warn/error to single-letter levels
|
|
115
|
+
const levelMap = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' };
|
|
116
|
+
const L = (levelMap[(level || '').toUpperCase()] || 'V');
|
|
117
|
+
args.push(`${tag}:${L}`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Default: show all levels
|
|
121
|
+
args.push('*:V');
|
|
122
|
+
}
|
|
123
|
+
// Use -t to limit lines (apply early)
|
|
124
|
+
args.push('-t', effectiveLimit.toString());
|
|
125
|
+
const stdout = await execAdb(args, deviceId);
|
|
126
|
+
const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : [];
|
|
127
|
+
// Parse lines into structured entries
|
|
128
|
+
const parsed = allLines.map(l => {
|
|
129
|
+
const entry = parseLogLine(l);
|
|
130
|
+
// normalize level
|
|
131
|
+
const levelChar = (entry.level || '').toUpperCase();
|
|
132
|
+
const levelMapChar = { 'V': 'VERBOSE', 'D': 'DEBUG', 'I': 'INFO', 'W': 'WARN', 'E': 'ERROR' };
|
|
133
|
+
const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO';
|
|
134
|
+
const iso = entry._iso || (() => {
|
|
135
|
+
const d = new Date(entry.timestamp || '');
|
|
136
|
+
if (!isNaN(d.getTime()))
|
|
137
|
+
return d.toISOString();
|
|
138
|
+
return null;
|
|
139
|
+
})();
|
|
140
|
+
let pidNum = null;
|
|
141
|
+
if (entry.pid)
|
|
142
|
+
pidNum = Number(entry.pid);
|
|
143
|
+
else if (pidArg)
|
|
144
|
+
pidNum = Number(pidArg);
|
|
145
|
+
const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null;
|
|
146
|
+
return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' };
|
|
147
|
+
});
|
|
148
|
+
// Apply client-side filters: contains, since_seconds
|
|
149
|
+
let filtered = parsed;
|
|
150
|
+
if (contains)
|
|
151
|
+
filtered = filtered.filter(e => e.message && e.message.includes(contains));
|
|
152
|
+
if (since_seconds) {
|
|
153
|
+
const sinceMs = Date.now() - (since_seconds * 1000);
|
|
154
|
+
filtered = filtered.filter(e => {
|
|
155
|
+
if (!e.timestamp)
|
|
156
|
+
return false;
|
|
157
|
+
const t = new Date(e.timestamp).getTime();
|
|
158
|
+
return t >= sinceMs;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// If appId provided and no pidArg, try to filter by appId substring in message/tag
|
|
162
|
+
if (appId && !pidArg) {
|
|
163
|
+
const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
|
|
164
|
+
if (matched.length > 0)
|
|
165
|
+
filtered = matched;
|
|
166
|
+
}
|
|
167
|
+
// Ensure ordering oldest -> newest (by timestamp when available)
|
|
168
|
+
filtered.sort((a, b) => {
|
|
169
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
170
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
171
|
+
return ta - tb;
|
|
172
|
+
});
|
|
173
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
174
|
+
return { device: deviceInfo, logs: limited, logCount: limited.length };
|
|
99
175
|
}
|
|
100
176
|
catch (e) {
|
|
101
177
|
console.error("Error fetching logs:", e);
|
|
@@ -120,7 +196,7 @@ export class AndroidObserve {
|
|
|
120
196
|
child.kill();
|
|
121
197
|
reject(new Error(`ADB screencap timed out after 10s`));
|
|
122
198
|
}, 10000);
|
|
123
|
-
child.on('close', (code) => {
|
|
199
|
+
child.on('close', async (code) => {
|
|
124
200
|
clearTimeout(timeout);
|
|
125
201
|
if (code !== 0) {
|
|
126
202
|
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
|
|
@@ -128,28 +204,91 @@ export class AndroidObserve {
|
|
|
128
204
|
}
|
|
129
205
|
const screenshotBuffer = Buffer.concat(chunks);
|
|
130
206
|
const screenshotBase64 = screenshotBuffer.toString('base64');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
207
|
+
const parsed = parsePngSize(screenshotBuffer);
|
|
208
|
+
if (parsed.width > 0 && parsed.height > 0) {
|
|
209
|
+
// Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
|
|
210
|
+
try {
|
|
211
|
+
const sharpModule = await import('sharp');
|
|
212
|
+
const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
|
|
213
|
+
const buf = screenshotBuffer;
|
|
214
|
+
const img = sharp(buf);
|
|
215
|
+
const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed (Android):', err); return {}; });
|
|
216
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
217
|
+
let webpBuf = null;
|
|
218
|
+
let jpegBuf = null;
|
|
219
|
+
try {
|
|
220
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
224
|
+
webpBuf = null;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
231
|
+
jpegBuf = null;
|
|
232
|
+
}
|
|
233
|
+
if (hasAlpha) {
|
|
234
|
+
if (webpBuf) {
|
|
235
|
+
const webpB64 = webpBuf.toString('base64');
|
|
236
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
|
|
237
|
+
resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const pngB64 = buf.toString('base64');
|
|
241
|
+
resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (webpBuf) {
|
|
245
|
+
const webpB64 = webpBuf.toString('base64');
|
|
246
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null;
|
|
247
|
+
resolve({ device: deviceInfo, screenshot: webpB64, screenshot_mime: 'image/webp', screenshot_fallback: jpegB64, screenshot_fallback_mime: jpegB64 ? 'image/jpeg' : undefined, resolution: { width: parsed.width, height: parsed.height } });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (jpegBuf) {
|
|
251
|
+
resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// No conversions succeeded; return original PNG
|
|
255
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
256
|
+
return;
|
|
139
257
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
resolution: { width, height }
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
258
|
+
catch (err) {
|
|
259
|
+
console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
|
|
260
|
+
// Conversion failed - fall back to original PNG with parsed resolution
|
|
261
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Fallback to querying wm size if parsing failed
|
|
267
|
+
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
268
|
+
.then(sizeStdout => {
|
|
269
|
+
let width = 0;
|
|
270
|
+
let height = 0;
|
|
271
|
+
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
|
|
272
|
+
if (match) {
|
|
273
|
+
width = parseInt(match[1], 10);
|
|
274
|
+
height = parseInt(match[2], 10);
|
|
275
|
+
}
|
|
276
|
+
resolve({
|
|
277
|
+
device: deviceInfo,
|
|
278
|
+
screenshot: screenshotBase64,
|
|
279
|
+
screenshot_mime: 'image/png',
|
|
280
|
+
resolution: { width, height }
|
|
281
|
+
});
|
|
282
|
+
})
|
|
283
|
+
.catch(() => {
|
|
284
|
+
resolve({
|
|
285
|
+
device: deviceInfo,
|
|
286
|
+
screenshot: screenshotBase64,
|
|
287
|
+
screenshot_mime: 'image/png',
|
|
288
|
+
resolution: { width: 0, height: 0 }
|
|
289
|
+
});
|
|
151
290
|
});
|
|
152
|
-
}
|
|
291
|
+
}
|
|
153
292
|
});
|
|
154
293
|
child.on('error', (err) => {
|
|
155
294
|
clearTimeout(timeout);
|
package/dist/observe/index.js
CHANGED
|
@@ -33,19 +33,30 @@ export class ToolsObserve {
|
|
|
33
33
|
// getCurrentScreen is Android-specific
|
|
34
34
|
return await observe.getCurrentScreen(resolved.id);
|
|
35
35
|
}
|
|
36
|
-
static async getLogsHandler({ platform, appId, deviceId, lines }) {
|
|
36
|
+
static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }) {
|
|
37
37
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId);
|
|
38
|
+
const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines };
|
|
39
|
+
// Validate filters
|
|
40
|
+
if (level && !['VERBOSE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].includes(level.toString().toUpperCase())) {
|
|
41
|
+
return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } };
|
|
42
|
+
}
|
|
38
43
|
if (observe instanceof AndroidObserve) {
|
|
39
|
-
const response = await observe.getLogs(
|
|
44
|
+
const response = await observe.getLogs(filters);
|
|
40
45
|
const logs = Array.isArray(response.logs) ? response.logs : [];
|
|
41
|
-
const crashLines = logs.filter(
|
|
42
|
-
|
|
46
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
47
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
48
|
+
if (anyFilterApplied && logs.length === 0)
|
|
49
|
+
return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
50
|
+
return { device: response.device, logs, crashLines, logCount: response.logCount };
|
|
43
51
|
}
|
|
44
52
|
else {
|
|
45
|
-
const resp = await observe.getLogs(
|
|
53
|
+
const resp = await observe.getLogs(filters);
|
|
46
54
|
const logs = Array.isArray(resp.logs) ? resp.logs : [];
|
|
47
|
-
const crashLines = logs.filter(
|
|
48
|
-
|
|
55
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message));
|
|
56
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds);
|
|
57
|
+
if (anyFilterApplied && logs.length === 0)
|
|
58
|
+
return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } };
|
|
59
|
+
return { device: resp.device, logs, crashLines, logCount: resp.logCount };
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
static async startLogStreamHandler({ platform, packageName, level, sessionId, deviceId }) {
|
|
@@ -154,9 +165,20 @@ export class ToolsObserve {
|
|
|
154
165
|
if (!entries || entries.length === 0) {
|
|
155
166
|
const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines });
|
|
156
167
|
const raw = (gl && gl.logs) ? gl.logs : [];
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// raw may be structured entries or strings
|
|
169
|
+
entries = raw.slice(-Math.max(0, logLines)).map(item => {
|
|
170
|
+
if (!item)
|
|
171
|
+
return { timestamp: null, level: 'INFO', message: '' };
|
|
172
|
+
if (typeof item === 'string') {
|
|
173
|
+
const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO';
|
|
174
|
+
return { timestamp: null, level, message: item };
|
|
175
|
+
}
|
|
176
|
+
const msg = item.message || item.msg || JSON.stringify(item);
|
|
177
|
+
const levelRaw = item.level || item.levelName || item._level || '';
|
|
178
|
+
const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO');
|
|
179
|
+
const ts = item.timestamp || item._iso || null;
|
|
180
|
+
const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null);
|
|
181
|
+
return { timestamp: tsNum, level, message: msg };
|
|
160
182
|
});
|
|
161
183
|
}
|
|
162
184
|
else {
|
package/dist/observe/ios.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createWriteStream, promises as fsPromises } from 'fs';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { parseLogLine } from '../utils/android/utils.js';
|
|
7
7
|
import { computeScreenFingerprint } from '../utils/ui/index.js';
|
|
8
|
+
import { parsePngSize } from '../utils/image.js';
|
|
8
9
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
10
|
function parseIDBFrame(frame) {
|
|
10
11
|
if (!frame)
|
|
@@ -87,20 +88,128 @@ export class iOSObserve {
|
|
|
87
88
|
async getDeviceMetadata(deviceId = "booted") {
|
|
88
89
|
return getIOSDeviceMetadata(deviceId);
|
|
89
90
|
}
|
|
90
|
-
async getLogs(
|
|
91
|
-
const
|
|
91
|
+
async getLogs(filters = {}) {
|
|
92
|
+
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters;
|
|
93
|
+
const args = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog'];
|
|
94
|
+
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
95
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50;
|
|
96
|
+
if (since_seconds) {
|
|
97
|
+
// log show accepts --last <time>
|
|
98
|
+
args.push('--last', `${since_seconds}s`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// default to last 60s to keep quick
|
|
102
|
+
args.push('--last', '60s');
|
|
103
|
+
}
|
|
92
104
|
if (appId) {
|
|
93
105
|
validateBundleId(appId);
|
|
106
|
+
// constrain to subsystem or process matching appId
|
|
94
107
|
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
|
|
95
108
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
else if (tag) {
|
|
110
|
+
// predicate by subsystem/category
|
|
111
|
+
args.push('--predicate', `subsystem contains "${tag}"`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const result = await execCommand(args, deviceId);
|
|
115
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
116
|
+
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : [];
|
|
117
|
+
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
118
|
+
const parsed = rawLines.map(line => {
|
|
119
|
+
// Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
|
|
120
|
+
// Simpler approach: try to extract ISO timestamp at start
|
|
121
|
+
let ts = null;
|
|
122
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
|
|
123
|
+
if (tsMatch) {
|
|
124
|
+
const d = new Date(tsMatch[1]);
|
|
125
|
+
if (!isNaN(d.getTime()))
|
|
126
|
+
ts = d.toISOString();
|
|
127
|
+
}
|
|
128
|
+
// level mapping
|
|
129
|
+
let lvl = 'INFO';
|
|
130
|
+
const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i);
|
|
131
|
+
if (lvlMatch) {
|
|
132
|
+
const map = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' };
|
|
133
|
+
lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO';
|
|
134
|
+
}
|
|
135
|
+
// subsystem/category -> tag
|
|
136
|
+
let tagVal = '';
|
|
137
|
+
const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/);
|
|
138
|
+
if (tagMatch)
|
|
139
|
+
tagVal = tagMatch[1];
|
|
140
|
+
// pid extraction
|
|
141
|
+
let pidNum = null;
|
|
142
|
+
const pidMatch = line.match(/\[(\d+):\d+\]/);
|
|
143
|
+
if (pidMatch)
|
|
144
|
+
pidNum = Number(pidMatch[1]);
|
|
145
|
+
// message: extract robustly after the subsystem/category token when available
|
|
146
|
+
let message = line;
|
|
147
|
+
if (tagMatch) {
|
|
148
|
+
// tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
|
|
149
|
+
const marker = tagMatch[0];
|
|
150
|
+
const idx = line.indexOf(marker);
|
|
151
|
+
if (idx !== -1) {
|
|
152
|
+
message = line.slice(idx + marker.length).trim();
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
// fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
|
|
156
|
+
const afterPidMatch = line.match(/\]\s+/);
|
|
157
|
+
if (afterPidMatch) {
|
|
158
|
+
const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length;
|
|
159
|
+
message = line.slice(afterPidIdx).trim();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
// remove leading level tokens like <Debug> and keep remainder
|
|
163
|
+
message = line.replace(/^.*?<.*?>\s*/, '').trim();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
|
|
169
|
+
message = line.replace(/^.*?<.*?>\s*/, '').trim();
|
|
170
|
+
}
|
|
171
|
+
return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message };
|
|
172
|
+
});
|
|
173
|
+
// Apply contains filter
|
|
174
|
+
let filtered = parsed;
|
|
175
|
+
if (contains)
|
|
176
|
+
filtered = filtered.filter(e => e.message && e.message.includes(contains));
|
|
177
|
+
// Apply since_seconds already applied by log show, but double-check timestamps
|
|
178
|
+
if (since_seconds) {
|
|
179
|
+
const sinceMs = Date.now() - (since_seconds * 1000);
|
|
180
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs));
|
|
181
|
+
}
|
|
182
|
+
// level filter
|
|
183
|
+
if (level) {
|
|
184
|
+
const L = level.toUpperCase();
|
|
185
|
+
filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L);
|
|
186
|
+
}
|
|
187
|
+
// tag filter
|
|
188
|
+
if (tag)
|
|
189
|
+
filtered = filtered.filter(e => e.tag && e.tag.includes(tag));
|
|
190
|
+
// pid filter
|
|
191
|
+
if (pid)
|
|
192
|
+
filtered = filtered.filter(e => e.pid === pid);
|
|
193
|
+
// If appId present but no predicate returned lines, try substring match
|
|
194
|
+
if (appId && filtered.length === 0) {
|
|
195
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)));
|
|
196
|
+
if (matched.length > 0)
|
|
197
|
+
filtered = matched;
|
|
198
|
+
}
|
|
199
|
+
// Order oldest -> newest
|
|
200
|
+
filtered.sort((a, b) => {
|
|
201
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
202
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
203
|
+
return ta - tb;
|
|
204
|
+
});
|
|
205
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit));
|
|
206
|
+
return { device, logs: limited, logCount: limited.length };
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
console.error('iOS getLogs failed:', err);
|
|
210
|
+
const device = await getIOSDeviceMetadata(deviceId);
|
|
211
|
+
return { device, logs: [], logCount: 0 };
|
|
212
|
+
}
|
|
104
213
|
}
|
|
105
214
|
async captureScreenshot(deviceId = "booted") {
|
|
106
215
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
@@ -109,11 +218,59 @@ export class iOSObserve {
|
|
|
109
218
|
await execCommand(['simctl', 'io', deviceId, 'screenshot', tmpFile], deviceId);
|
|
110
219
|
const buffer = await fs.readFile(tmpFile);
|
|
111
220
|
const base64 = buffer.toString('base64');
|
|
221
|
+
const dims = parsePngSize(buffer);
|
|
222
|
+
// Try to generate WebP (preferred) and JPEG fallback using sharp (in-process, cross-platform)
|
|
223
|
+
try {
|
|
224
|
+
const sharpModule = await import('sharp');
|
|
225
|
+
const sharp = sharpModule && sharpModule.default ? sharpModule.default : sharpModule;
|
|
226
|
+
const img = sharp(buffer);
|
|
227
|
+
const meta = await img.metadata().catch((err) => { console.error('sharp.metadata failed:', err); return {}; });
|
|
228
|
+
// If image has alpha channel, prefer lossless PNG to preserve transparency
|
|
229
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
230
|
+
// Generate WebP and JPEG buffers; log failures
|
|
231
|
+
let webpBuf = null;
|
|
232
|
+
let jpegBuf = null;
|
|
233
|
+
try {
|
|
234
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
console.error('WebP conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
238
|
+
webpBuf = null;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.error('JPEG conversion failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
245
|
+
jpegBuf = null;
|
|
246
|
+
}
|
|
247
|
+
await fs.rm(tmpFile).catch(() => { });
|
|
248
|
+
if (hasAlpha) {
|
|
249
|
+
// preserve alpha: return PNG if WebP not available
|
|
250
|
+
if (webpBuf) {
|
|
251
|
+
return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: base64, screenshot_fallback_mime: 'image/png', resolution: { width: dims.width, height: dims.height } };
|
|
252
|
+
}
|
|
253
|
+
// if webp unavailable, return original PNG
|
|
254
|
+
return { device, screenshot: base64, screenshot_mime: 'image/png', resolution: { width: dims.width, height: dims.height } };
|
|
255
|
+
}
|
|
256
|
+
// No alpha: prefer webp, fall back to jpeg
|
|
257
|
+
if (webpBuf) {
|
|
258
|
+
return { device, screenshot: webpBuf.toString('base64'), screenshot_mime: 'image/webp', screenshot_fallback: jpegBuf ? jpegBuf.toString('base64') : undefined, screenshot_fallback_mime: jpegBuf ? 'image/jpeg' : undefined, resolution: { width: dims.width, height: dims.height } };
|
|
259
|
+
}
|
|
260
|
+
if (jpegBuf) {
|
|
261
|
+
return { device, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: dims.width, height: dims.height } };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
console.error('Screenshot conversion pipeline failed (iOS):', err instanceof Error ? err.message : String(err));
|
|
266
|
+
// fall through to png fallback
|
|
267
|
+
}
|
|
112
268
|
await fs.rm(tmpFile).catch(() => { });
|
|
113
269
|
return {
|
|
114
270
|
device,
|
|
115
271
|
screenshot: base64,
|
|
116
|
-
|
|
272
|
+
screenshot_mime: 'image/png',
|
|
273
|
+
resolution: { width: dims.width, height: dims.height },
|
|
117
274
|
};
|
|
118
275
|
}
|
|
119
276
|
catch (e) {
|
package/dist/server.js
CHANGED
|
@@ -148,7 +148,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
150
|
name: "get_logs",
|
|
151
|
-
description: "Get recent logs from Android or iOS simulator. Returns device metadata and
|
|
151
|
+
description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
|
|
152
152
|
inputSchema: {
|
|
153
153
|
type: "object",
|
|
154
154
|
properties: {
|
|
@@ -164,9 +164,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
164
164
|
type: "string",
|
|
165
165
|
description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
|
|
166
166
|
},
|
|
167
|
+
pid: { type: "number", description: "Filter by process id" },
|
|
168
|
+
tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
|
|
169
|
+
level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
|
|
170
|
+
contains: { type: "string", description: "Substring to match in log message" },
|
|
171
|
+
since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
|
|
172
|
+
limit: { type: "number", description: "Override default number of returned lines" },
|
|
167
173
|
lines: {
|
|
168
174
|
type: "number",
|
|
169
|
-
description: "
|
|
175
|
+
description: "Legacy - number of log lines (android only)"
|
|
170
176
|
}
|
|
171
177
|
},
|
|
172
178
|
required: ["platform"]
|
|
@@ -563,12 +569,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
563
569
|
};
|
|
564
570
|
}
|
|
565
571
|
if (name === "get_logs") {
|
|
566
|
-
const { platform, appId, deviceId, lines } = args;
|
|
567
|
-
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
|
|
572
|
+
const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args;
|
|
573
|
+
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines });
|
|
574
|
+
const filtered = !!(pid || tag || level || contains || since_seconds || appId);
|
|
568
575
|
return {
|
|
569
576
|
content: [
|
|
570
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: {
|
|
571
|
-
{ type: 'text', text: (res.logs
|
|
577
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
|
|
578
|
+
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
572
579
|
]
|
|
573
580
|
};
|
|
574
581
|
}
|
|
@@ -584,12 +591,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
584
591
|
if (name === "capture_screenshot") {
|
|
585
592
|
const { platform, deviceId } = args;
|
|
586
593
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId });
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
594
|
+
const mime = res.screenshot_mime || 'image/png';
|
|
595
|
+
const content = [
|
|
596
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: res.resolution, mimeType: mime } }, null, 2) },
|
|
597
|
+
{ type: 'image', data: res.screenshot, mimeType: mime }
|
|
598
|
+
];
|
|
599
|
+
// If a jpeg fallback is available, include a small note and the fallback as an additional image block for compatibility
|
|
600
|
+
if (res.screenshot_fallback) {
|
|
601
|
+
content.push({ type: 'text', text: JSON.stringify({ note: 'JPEG fallback included for compatibility', mimeType: res.screenshot_fallback_mime || 'image/jpeg' }) });
|
|
602
|
+
content.push({ type: 'image', data: res.screenshot_fallback, mimeType: res.screenshot_fallback_mime || 'image/jpeg' });
|
|
603
|
+
}
|
|
604
|
+
return { content };
|
|
593
605
|
}
|
|
594
606
|
if (name === "capture_debug_snapshot") {
|
|
595
607
|
const { reason, includeLogs, logLines, platform, appId, deviceId, sessionId } = args;
|
|
@@ -13,7 +13,7 @@ async function main() {
|
|
|
13
13
|
const shot = await obs.captureScreenshot(deviceId);
|
|
14
14
|
console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0);
|
|
15
15
|
console.log('[3] getLogs');
|
|
16
|
-
const logs = await obs.getLogs(appId,
|
|
16
|
+
const logs = await obs.getLogs({ appId, deviceId });
|
|
17
17
|
console.log('logs count:', logs.logCount);
|
|
18
18
|
console.log('[4] terminateApp');
|
|
19
19
|
const term = await manage.terminateApp(appId, deviceId);
|