mobile-debug-mcp 0.21.2 → 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 +85 -10
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +118 -10
- package/dist/server.js +13 -6
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/docs/CHANGELOG.md +3 -0
- package/docs/tools/observe.md +15 -10
- package/package.json +1 -1
- package/src/interact/index.ts +18 -4
- package/src/observe/android.ts +85 -14
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +122 -10
- package/src/server.ts +13 -6
- package/src/types.ts +9 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- 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
|
@@ -80,23 +80,98 @@ export class AndroidObserve {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
async getLogs(
|
|
83
|
+
async getLogs(filters = {}) {
|
|
84
|
+
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters;
|
|
84
85
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
|
|
85
86
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
86
87
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
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;
|
|
90
91
|
if (appId) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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];
|
|
94
97
|
}
|
|
95
|
-
|
|
96
|
-
|
|
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 { }
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
|
-
|
|
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 };
|
|
100
175
|
}
|
|
101
176
|
catch (e) {
|
|
102
177
|
console.error("Error fetching logs:", e);
|
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
|
@@ -88,20 +88,128 @@ export class iOSObserve {
|
|
|
88
88
|
async getDeviceMetadata(deviceId = "booted") {
|
|
89
89
|
return getIOSDeviceMetadata(deviceId);
|
|
90
90
|
}
|
|
91
|
-
async getLogs(
|
|
92
|
-
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
|
+
}
|
|
93
104
|
if (appId) {
|
|
94
105
|
validateBundleId(appId);
|
|
106
|
+
// constrain to subsystem or process matching appId
|
|
95
107
|
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`);
|
|
96
108
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
}
|
|
105
213
|
}
|
|
106
214
|
async captureScreenshot(deviceId = "booted") {
|
|
107
215
|
const device = await getIOSDeviceMetadata(deviceId);
|
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
|
}
|
|
@@ -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);
|
package/docs/CHANGELOG.md
CHANGED
package/docs/tools/observe.md
CHANGED
|
@@ -3,27 +3,32 @@
|
|
|
3
3
|
Tools that retrieve device state, logs, screenshots and UI hierarchies.
|
|
4
4
|
|
|
5
5
|
## get_logs
|
|
6
|
-
Fetch recent logs from the app or device.
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
Fetch recent logs as structured entries optimized for AI agents.
|
|
8
|
+
|
|
9
|
+
Input (example):
|
|
9
10
|
|
|
10
11
|
```json
|
|
11
|
-
{ "platform": "android", "appId": "com.example.app", "deviceId": "emulator-5554", "
|
|
12
|
+
{ "platform": "android|ios", "appId": "com.example.app", "deviceId": "emulator-5554", "pid": 1234, "tag": "MyTag", "level": "ERROR", "contains": "timeout", "since_seconds": 60, "limit": 50 }
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
Defaults:
|
|
16
|
+
|
|
17
|
+
- No filters → return the most recent 50 log entries (app-scoped if appId provided), across all levels.
|
|
18
|
+
|
|
19
|
+
Response (structured):
|
|
15
20
|
|
|
16
21
|
```json
|
|
17
|
-
{ "
|
|
22
|
+
{ "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "count": 1, "filtered": true }
|
|
18
23
|
```
|
|
19
24
|
|
|
20
|
-
Followed by a raw log plain text block.
|
|
21
|
-
|
|
22
25
|
Notes:
|
|
23
|
-
- Android log parsing includes basic crash detection (searching for "FATAL EXCEPTION" and exception names).
|
|
24
|
-
- Use `lines` to control how many log lines are returned from `adb logcat`.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
- Each log entry: timestamp (ISO), level (VERBOSE|DEBUG|INFO|WARN|ERROR), tag (string), pid (number|null), message (string).
|
|
28
|
+
- Logs ordered oldest → newest. count equals number of entries returned. filtered is true if any filter was applied.
|
|
29
|
+
- Supported filters: pid, tag, level, contains, since_seconds, limit.
|
|
30
|
+
- Platform behaviour: Android uses `adb logcat` with source-side filters where possible; iOS uses unified logging (`log show`/simctl) and maps subsystem/category → tag.
|
|
31
|
+
- Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
|
|
27
32
|
|
|
28
33
|
## capture_screenshot
|
|
29
34
|
Capture screen. Returns JSON metadata then an image/png block with base64 PNG data.
|
package/package.json
CHANGED
package/src/interact/index.ts
CHANGED
|
@@ -223,6 +223,16 @@ export class ToolsInteract {
|
|
|
223
223
|
return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// Helper: normalize various log objects into plain message strings for comparison
|
|
227
|
+
private static _logsToMessages(logsArr: any[]): string[] {
|
|
228
|
+
if (!Array.isArray(logsArr)) return []
|
|
229
|
+
return logsArr.map((l: any) => {
|
|
230
|
+
if (typeof l === 'string') return l
|
|
231
|
+
if (l && (l.message || l.msg)) return l.message || l.msg
|
|
232
|
+
try { return JSON.stringify(l) } catch { return String(l) }
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
226
236
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
227
237
|
const start = Date.now()
|
|
228
238
|
let lastFingerprint: string | null = null
|
|
@@ -279,7 +289,9 @@ export class ToolsInteract {
|
|
|
279
289
|
if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
|
|
280
290
|
if (gl) {
|
|
281
291
|
const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
|
|
282
|
-
|
|
292
|
+
// Normalize to last message string for baseline comparison
|
|
293
|
+
const msgs = ToolsInteract._logsToMessages(logsArr)
|
|
294
|
+
baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
|
|
283
295
|
}
|
|
284
296
|
} catch (err) {
|
|
285
297
|
try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
@@ -348,13 +360,15 @@ export class ToolsInteract {
|
|
|
348
360
|
|
|
349
361
|
const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
|
|
350
362
|
const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
|
|
363
|
+
// Normalize to messages for comparison
|
|
364
|
+
const msgs = ToolsInteract._logsToMessages(logsArr)
|
|
351
365
|
let startIndex = 0
|
|
352
366
|
if (baselineLastLine) {
|
|
353
|
-
const idx =
|
|
367
|
+
const idx = msgs.lastIndexOf(baselineLastLine)
|
|
354
368
|
startIndex = idx >= 0 ? idx + 1 : 0
|
|
355
369
|
}
|
|
356
|
-
for (let i = startIndex; i <
|
|
357
|
-
const line =
|
|
370
|
+
for (let i = startIndex; i < msgs.length; i++) {
|
|
371
|
+
const line = msgs[i]
|
|
358
372
|
if (q && String(line).includes(q)) {
|
|
359
373
|
const now2 = Date.now()
|
|
360
374
|
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/src/observe/android.ts
CHANGED
|
@@ -93,26 +93,97 @@ export class AndroidObserve {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async getLogs(appId?: string,
|
|
96
|
+
async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
|
|
97
|
+
const { appId, deviceId, pid, tag, level, contains, since_seconds, limit } = filters
|
|
97
98
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
|
|
98
99
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
99
100
|
|
|
100
101
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
let
|
|
102
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
|
|
103
|
+
|
|
104
|
+
// If appId provided, try to get pid to filter at source
|
|
105
|
+
let pidArg: string | null = null
|
|
105
106
|
if (appId) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
try {
|
|
108
|
+
const pidOut = await execAdb(['shell', 'pidof', appId], deviceId).catch(() => '')
|
|
109
|
+
const pidTrim = (pidOut || '').trim()
|
|
110
|
+
if (pidTrim) pidArg = pidTrim.split('\n')[0]
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Log a warning so failures to detect PID are visible during debugging
|
|
113
|
+
try { console.warn(`getLogs: pid detection failed for appId=${appId}:`, err instanceof Error ? err.message : String(err)) } catch { }
|
|
114
|
+
}
|
|
113
115
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
|
|
117
|
+
const args = ['logcat', '-d', '-v', 'threadtime']
|
|
118
|
+
// Apply pid filter via --pid if available
|
|
119
|
+
if (pidArg) args.push(`--pid=${pidArg}`)
|
|
120
|
+
else if (pid) args.push(`--pid=${pid}`)
|
|
121
|
+
|
|
122
|
+
// Apply tag/level filter if provided
|
|
123
|
+
if (tag && level) {
|
|
124
|
+
// Map verbose/debug/info/warn/error to single-letter levels
|
|
125
|
+
const levelMap: Record<string, string> = { 'VERBOSE': 'V', 'DEBUG': 'D', 'INFO': 'I', 'WARN': 'W', 'ERROR': 'E' }
|
|
126
|
+
const L = (levelMap[(level || '').toUpperCase()] || 'V')
|
|
127
|
+
args.push(`${tag}:${L}`)
|
|
128
|
+
} else {
|
|
129
|
+
// Default: show all levels
|
|
130
|
+
args.push('*:V')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Use -t to limit lines (apply early)
|
|
134
|
+
args.push('-t', effectiveLimit.toString())
|
|
135
|
+
|
|
136
|
+
const stdout = await execAdb(args, deviceId)
|
|
137
|
+
const allLines = stdout ? stdout.split(/\r?\n/).filter(Boolean) : []
|
|
138
|
+
|
|
139
|
+
// Parse lines into structured entries
|
|
140
|
+
const parsed = allLines.map(l => {
|
|
141
|
+
const entry = parseLogLine(l)
|
|
142
|
+
// normalize level
|
|
143
|
+
const levelChar = (entry.level || '').toUpperCase()
|
|
144
|
+
const levelMapChar: Record<string,string> = { 'V':'VERBOSE','D':'DEBUG','I':'INFO','W':'WARN','E':'ERROR' }
|
|
145
|
+
const normLevel = levelMapChar[levelChar] || (entry.level && String(entry.level).toUpperCase()) || 'INFO'
|
|
146
|
+
const iso = entry._iso || (() => {
|
|
147
|
+
const d = new Date(entry.timestamp || '')
|
|
148
|
+
if (!isNaN(d.getTime())) return d.toISOString()
|
|
149
|
+
return null
|
|
150
|
+
})()
|
|
151
|
+
let pidNum: number | null = null
|
|
152
|
+
if (entry.pid) pidNum = Number(entry.pid)
|
|
153
|
+
else if (pidArg) pidNum = Number(pidArg)
|
|
154
|
+
const pidVal = (pidNum !== null && !isNaN(pidNum)) ? pidNum : null
|
|
155
|
+
return { timestamp: iso, level: normLevel, tag: entry.tag || '', pid: pidVal, message: entry.message || '' }
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Apply client-side filters: contains, since_seconds
|
|
159
|
+
let filtered = parsed
|
|
160
|
+
if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
|
|
161
|
+
|
|
162
|
+
if (since_seconds) {
|
|
163
|
+
const sinceMs = Date.now() - (since_seconds * 1000)
|
|
164
|
+
filtered = filtered.filter(e => {
|
|
165
|
+
if (!e.timestamp) return false
|
|
166
|
+
const t = new Date(e.timestamp).getTime()
|
|
167
|
+
return t >= sinceMs
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// If appId provided and no pidArg, try to filter by appId substring in message/tag
|
|
172
|
+
if (appId && !pidArg) {
|
|
173
|
+
const matched = filtered.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
|
|
174
|
+
if (matched.length > 0) filtered = matched
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ensure ordering oldest -> newest (by timestamp when available)
|
|
178
|
+
filtered.sort((a,b) => {
|
|
179
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
|
|
180
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
|
|
181
|
+
return ta - tb
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
185
|
+
|
|
186
|
+
return { device: deviceInfo, logs: limited, logCount: limited.length }
|
|
116
187
|
} catch (e) {
|
|
117
188
|
console.error("Error fetching logs:", e)
|
|
118
189
|
return { device: deviceInfo, logs: [], logCount: 0 }
|
package/src/observe/index.ts
CHANGED
|
@@ -38,18 +38,29 @@ export class ToolsObserve {
|
|
|
38
38
|
return await (observe as AndroidObserve).getCurrentScreen(resolved.id)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
static async getLogsHandler({ platform, appId, deviceId, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, lines?: number }) {
|
|
41
|
+
static async getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines }: { platform?: 'android' | 'ios', appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number, lines?: number }) {
|
|
42
42
|
const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId, appId)
|
|
43
|
+
const filters = { appId, deviceId: resolved.id, pid, tag, level, contains, since_seconds, limit: limit ?? lines }
|
|
44
|
+
|
|
45
|
+
// Validate filters
|
|
46
|
+
if (level && !['VERBOSE','DEBUG','INFO','WARN','ERROR'].includes(level.toString().toUpperCase())) {
|
|
47
|
+
return { device: resolved, logs: [], crashLines: [], logCount: 0, error: { code: 'INVALID_FILTER', message: `Unsupported level filter: ${level}` } } as any
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
if (observe instanceof AndroidObserve) {
|
|
44
|
-
const response = await observe.getLogs(
|
|
51
|
+
const response = await observe.getLogs(filters)
|
|
45
52
|
const logs = Array.isArray(response.logs) ? response.logs : []
|
|
46
|
-
const crashLines = logs.filter(
|
|
47
|
-
|
|
53
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
|
|
54
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
|
|
55
|
+
if (anyFilterApplied && logs.length === 0) return { device: response.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
56
|
+
return { device: response.device, logs, crashLines, logCount: response.logCount }
|
|
48
57
|
} else {
|
|
49
|
-
const resp = await (observe as iOSObserve).getLogs(
|
|
58
|
+
const resp = await (observe as iOSObserve).getLogs(filters)
|
|
50
59
|
const logs = Array.isArray(resp.logs) ? resp.logs : []
|
|
51
|
-
const crashLines = logs.filter(
|
|
52
|
-
|
|
60
|
+
const crashLines = logs.filter(entry => /FATAL EXCEPTION/i.test(entry.message))
|
|
61
|
+
const anyFilterApplied = !!(appId || pid || tag || level || contains || since_seconds)
|
|
62
|
+
if (anyFilterApplied && logs.length === 0) return { device: resp.device, logs: [], crashLines: [], logCount: 0, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
|
|
63
|
+
return { device: resp.device, logs, crashLines, logCount: resp.logCount }
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
|
|
@@ -96,7 +107,7 @@ export class ToolsObserve {
|
|
|
96
107
|
|
|
97
108
|
// Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
|
|
98
109
|
const sid = sessionId || 'default'
|
|
99
|
-
const tasks = {
|
|
110
|
+
const tasks: Record<string, Promise<any>> = {
|
|
100
111
|
screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
|
|
101
112
|
currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
102
113
|
fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
|
|
@@ -145,10 +156,20 @@ export class ToolsObserve {
|
|
|
145
156
|
let entries: any[] = Array.isArray(out._streamEntries) ? out._streamEntries : []
|
|
146
157
|
if (!entries || entries.length === 0) {
|
|
147
158
|
const gl = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines: logLines })
|
|
148
|
-
const raw:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return { timestamp: null, level, message:
|
|
159
|
+
const raw: any[] = (gl && (gl as any).logs) ? (gl as any).logs : []
|
|
160
|
+
// raw may be structured entries or strings
|
|
161
|
+
entries = raw.slice(-Math.max(0, logLines)).map(item => {
|
|
162
|
+
if (!item) return { timestamp: null, level: 'INFO', message: '' }
|
|
163
|
+
if (typeof item === 'string') {
|
|
164
|
+
const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(item) ? 'ERROR' : /\b(WARN| W )\b/i.test(item) ? 'WARN' : 'INFO'
|
|
165
|
+
return { timestamp: null, level, message: item }
|
|
166
|
+
}
|
|
167
|
+
const msg = item.message || item.msg || JSON.stringify(item)
|
|
168
|
+
const levelRaw = item.level || item.levelName || item._level || ''
|
|
169
|
+
const level = (levelRaw && String(levelRaw)).toUpperCase() || (/\bERROR\b/i.test(msg) ? 'ERROR' : /\bWARN\b/i.test(msg) ? 'WARN' : 'INFO')
|
|
170
|
+
const ts = item.timestamp || item._iso || null
|
|
171
|
+
const tsNum = (ts && typeof ts === 'string') ? (isNaN(new Date(ts).getTime()) ? null : new Date(ts).getTime()) : (typeof ts === 'number' ? ts : null)
|
|
172
|
+
return { timestamp: tsNum, level, message: msg }
|
|
152
173
|
})
|
|
153
174
|
} else {
|
|
154
175
|
entries = entries.map(ent => {
|
package/src/observe/ios.ts
CHANGED
|
@@ -121,20 +121,132 @@ export class iOSObserve {
|
|
|
121
121
|
return getIOSDeviceMetadata(deviceId);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
async getLogs(appId?: string, deviceId
|
|
125
|
-
const
|
|
124
|
+
async getLogs(filters: { appId?: string, deviceId?: string, pid?: number, tag?: string, level?: string, contains?: string, since_seconds?: number, limit?: number } = {}): Promise<GetLogsResponse> {
|
|
125
|
+
const { appId, deviceId = 'booted', pid, tag, level, contains, since_seconds, limit } = filters
|
|
126
|
+
const args: string[] = ['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog']
|
|
127
|
+
|
|
128
|
+
// Default to last N seconds if no since_seconds provided; limit lines handled after parsing
|
|
129
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : 50
|
|
130
|
+
|
|
131
|
+
if (since_seconds) {
|
|
132
|
+
// log show accepts --last <time>
|
|
133
|
+
args.push('--last', `${since_seconds}s`)
|
|
134
|
+
} else {
|
|
135
|
+
// default to last 60s to keep quick
|
|
136
|
+
args.push('--last', '60s')
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
if (appId) {
|
|
127
140
|
validateBundleId(appId)
|
|
141
|
+
// constrain to subsystem or process matching appId
|
|
128
142
|
args.push('--predicate', `subsystem contains "${appId}" or process == "${appId}"`)
|
|
143
|
+
} else if (tag) {
|
|
144
|
+
// predicate by subsystem/category
|
|
145
|
+
args.push('--predicate', `subsystem contains "${tag}"`)
|
|
129
146
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await execCommand(args, deviceId)
|
|
150
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
151
|
+
const rawLines = result.output ? result.output.split(/\r?\n/).filter(Boolean) : []
|
|
152
|
+
|
|
153
|
+
// Parse lines into structured entries. iOS log format: timestamp [PID:tid] <level> subsystem:category: message
|
|
154
|
+
const parsed = rawLines.map(line => {
|
|
155
|
+
// Example: 2023-08-12 12:34:56.789012+0000 pid <Debug> MyApp[123:456] <info> MySubsystem: MyCategory: Message here
|
|
156
|
+
// Simpler approach: try to extract ISO timestamp at start
|
|
157
|
+
let ts: string | null = null
|
|
158
|
+
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)/)
|
|
159
|
+
if (tsMatch) {
|
|
160
|
+
const d = new Date(tsMatch[1])
|
|
161
|
+
if (!isNaN(d.getTime())) ts = d.toISOString()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// level mapping
|
|
165
|
+
let lvl = 'INFO'
|
|
166
|
+
const lvlMatch = line.match(/\b(Debug|Info|Default|Error|Fault|Warning)\b/i)
|
|
167
|
+
if (lvlMatch) {
|
|
168
|
+
const map: Record<string, string> = { 'debug': 'DEBUG', 'info': 'INFO', 'default': 'DEBUG', 'error': 'ERROR', 'fault': 'ERROR', 'warning': 'WARN' }
|
|
169
|
+
lvl = map[(lvlMatch[1] || '').toLowerCase()] || 'INFO'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// subsystem/category -> tag
|
|
173
|
+
let tagVal = ''
|
|
174
|
+
const tagMatch = line.match(/\s([A-Za-z0-9_./-]+):\s/)
|
|
175
|
+
if (tagMatch) tagVal = tagMatch[1]
|
|
176
|
+
|
|
177
|
+
// pid extraction
|
|
178
|
+
let pidNum: number | null = null
|
|
179
|
+
const pidMatch = line.match(/\[(\d+):\d+\]/)
|
|
180
|
+
if (pidMatch) pidNum = Number(pidMatch[1])
|
|
181
|
+
|
|
182
|
+
// message: extract robustly after the subsystem/category token when available
|
|
183
|
+
let message = line
|
|
184
|
+
if (tagMatch) {
|
|
185
|
+
// tagMatch[0] includes the delimiter (e.g. " MySubsystem: ") — use it to find the message start
|
|
186
|
+
const marker = tagMatch[0]
|
|
187
|
+
const idx = line.indexOf(marker)
|
|
188
|
+
if (idx !== -1) {
|
|
189
|
+
message = line.slice(idx + marker.length).trim()
|
|
190
|
+
} else {
|
|
191
|
+
// fallback: try to trim off common prefixes (timestamp, pid, level) and keep the rest
|
|
192
|
+
const afterPidMatch = line.match(/\]\s+/)
|
|
193
|
+
if (afterPidMatch) {
|
|
194
|
+
const afterPidIdx = line.indexOf(afterPidMatch[0]) + afterPidMatch[0].length
|
|
195
|
+
message = line.slice(afterPidIdx).trim()
|
|
196
|
+
} else {
|
|
197
|
+
// remove leading level tokens like <Debug> and keep remainder
|
|
198
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// No tag found — strip obvious prefixes and keep remainder (preserve colons in message)
|
|
203
|
+
message = line.replace(/^.*?<.*?>\s*/,'').trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { timestamp: ts, level: lvl, tag: tagVal, pid: pidNum, message }
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Apply contains filter
|
|
210
|
+
let filtered = parsed
|
|
211
|
+
if (contains) filtered = filtered.filter(e => e.message && e.message.includes(contains))
|
|
212
|
+
|
|
213
|
+
// Apply since_seconds already applied by log show, but double-check timestamps
|
|
214
|
+
if (since_seconds) {
|
|
215
|
+
const sinceMs = Date.now() - (since_seconds * 1000)
|
|
216
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// level filter
|
|
220
|
+
if (level) {
|
|
221
|
+
const L = level.toUpperCase()
|
|
222
|
+
filtered = filtered.filter(e => e.level && e.level.toUpperCase() === L)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// tag filter
|
|
226
|
+
if (tag) filtered = filtered.filter(e => e.tag && e.tag.includes(tag))
|
|
227
|
+
|
|
228
|
+
// pid filter
|
|
229
|
+
if (pid) filtered = filtered.filter(e => e.pid === pid)
|
|
230
|
+
|
|
231
|
+
// If appId present but no predicate returned lines, try substring match
|
|
232
|
+
if (appId && filtered.length === 0) {
|
|
233
|
+
const matched = parsed.filter(e => (e.message && e.message.includes(appId)) || (e.tag && e.tag.includes(appId)))
|
|
234
|
+
if (matched.length > 0) filtered = matched
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Order oldest -> newest
|
|
238
|
+
filtered.sort((a,b) => {
|
|
239
|
+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0
|
|
240
|
+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0
|
|
241
|
+
return ta - tb
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const limited = filtered.slice(-Math.max(0, effectiveLimit))
|
|
245
|
+
return { device, logs: limited, logCount: limited.length }
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error('iOS getLogs failed:', err)
|
|
248
|
+
const device = await getIOSDeviceMetadata(deviceId)
|
|
249
|
+
return { device, logs: [], logCount: 0 }
|
|
138
250
|
}
|
|
139
251
|
}
|
|
140
252
|
|
package/src/server.ts
CHANGED
|
@@ -171,7 +171,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
171
171
|
|
|
172
172
|
{
|
|
173
173
|
name: "get_logs",
|
|
174
|
-
description: "Get recent logs from Android or iOS simulator. Returns device metadata and
|
|
174
|
+
description: "Get recent logs from Android or iOS simulator. Returns device metadata and structured logs suitable for AI consumption.",
|
|
175
175
|
inputSchema: {
|
|
176
176
|
type: "object",
|
|
177
177
|
properties: {
|
|
@@ -187,9 +187,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
187
187
|
type: "string",
|
|
188
188
|
description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected."
|
|
189
189
|
},
|
|
190
|
+
pid: { type: "number", description: "Filter by process id" },
|
|
191
|
+
tag: { type: "string", description: "Filter by tag (Android) or subsystem/category (iOS)" },
|
|
192
|
+
level: { type: "string", description: "Log level filter (VERBOSE, DEBUG, INFO, WARN, ERROR)" },
|
|
193
|
+
contains: { type: "string", description: "Substring to match in log message" },
|
|
194
|
+
since_seconds: { type: "number", description: "Only return logs from the last N seconds" },
|
|
195
|
+
limit: { type: "number", description: "Override default number of returned lines" },
|
|
190
196
|
lines: {
|
|
191
197
|
type: "number",
|
|
192
|
-
description: "
|
|
198
|
+
description: "Legacy - number of log lines (android only)"
|
|
193
199
|
}
|
|
194
200
|
},
|
|
195
201
|
required: ["platform"]
|
|
@@ -603,12 +609,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
603
609
|
|
|
604
610
|
|
|
605
611
|
if (name === "get_logs") {
|
|
606
|
-
const { platform, appId, deviceId, lines } = args as any
|
|
607
|
-
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines })
|
|
612
|
+
const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args as any
|
|
613
|
+
const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines })
|
|
614
|
+
const filtered = !!(pid || tag || level || contains || since_seconds || appId)
|
|
608
615
|
return {
|
|
609
616
|
content: [
|
|
610
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: {
|
|
611
|
-
{ type: 'text', text: (res.logs
|
|
617
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []) } }, null, 2) },
|
|
618
|
+
{ type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
|
|
612
619
|
]
|
|
613
620
|
}
|
|
614
621
|
}
|
package/src/types.ts
CHANGED
|
@@ -36,9 +36,17 @@ export interface ResetAppDataResponse {
|
|
|
36
36
|
diagnostics?: any;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export interface StructuredLogEntry {
|
|
40
|
+
timestamp: string | null; // ISO string
|
|
41
|
+
level: string; // VERBOSE, DEBUG, INFO, WARN, ERROR
|
|
42
|
+
tag: string;
|
|
43
|
+
pid: number | null;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export interface GetLogsResponse {
|
|
40
48
|
device: DeviceInfo;
|
|
41
|
-
logs:
|
|
49
|
+
logs: StructuredLogEntry[];
|
|
42
50
|
logCount: number;
|
|
43
51
|
}
|
|
44
52
|
|
|
@@ -17,7 +17,7 @@ async function main() {
|
|
|
17
17
|
console.log('screenshot OK? size:', shot && shot.screenshot ? shot.screenshot.length : 0)
|
|
18
18
|
|
|
19
19
|
console.log('[3] getLogs')
|
|
20
|
-
const logs = await obs.getLogs(appId,
|
|
20
|
+
const logs = await obs.getLogs({ appId, deviceId });
|
|
21
21
|
console.log('logs count:', logs.logCount)
|
|
22
22
|
|
|
23
23
|
console.log('[4] terminateApp')
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ToolsObserve } from '../../src/observe/index.js'
|
|
2
|
+
import minimist from 'minimist'
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const args = minimist(process.argv.slice(2))
|
|
6
|
+
const platform = args.platform || args.p || 'android'
|
|
7
|
+
const id = args.id || args.device || args.deviceId || 'default'
|
|
8
|
+
const limit = typeof args.limit === 'number' ? args.limit : (typeof args.lines === 'number' ? args.lines : 50)
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const res = await ToolsObserve.getLogsHandler({ platform, id, limit })
|
|
12
|
+
console.log(JSON.stringify(res))
|
|
13
|
+
process.exit(0)
|
|
14
|
+
} catch (err: any) {
|
|
15
|
+
console.error(JSON.stringify({ error: { message: err.message || String(err) } }))
|
|
16
|
+
process.exit(2)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
|
|
4
|
+
function log(msg: string) { console.log(msg) }
|
|
5
|
+
|
|
6
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
7
|
+
log('SKIP_DEVICE_TESTS=1 detected - skipping android device smoke test')
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Ensure helper script exists
|
|
12
|
+
const helperScript = 'test/helpers/run-get-logs.ts'
|
|
13
|
+
if (!fs.existsSync(helperScript)) {
|
|
14
|
+
console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
|
|
15
|
+
process.exit(1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Run the helper smoke script for android
|
|
20
|
+
const cmd = `tsx ${helperScript} --platform android --id default --limit 20`
|
|
21
|
+
const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
|
|
22
|
+
const parsed = JSON.parse(out)
|
|
23
|
+
|
|
24
|
+
if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
|
|
25
|
+
const count = parsed.count ?? parsed.logs.length
|
|
26
|
+
if (count !== parsed.logs.length) throw new Error('count mismatch')
|
|
27
|
+
if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
|
|
28
|
+
|
|
29
|
+
log('Android device smoke test: PASS')
|
|
30
|
+
process.exit(0)
|
|
31
|
+
} catch (err: any) {
|
|
32
|
+
console.error('Android device smoke test: FAIL')
|
|
33
|
+
console.error(err && err.message ? err.message : err)
|
|
34
|
+
process.exit(2)
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
|
|
4
|
+
function log(msg: string) { console.log(msg) }
|
|
5
|
+
|
|
6
|
+
if (process.env.SKIP_DEVICE_TESTS === '1') {
|
|
7
|
+
log('SKIP_DEVICE_TESTS=1 detected - skipping ios device smoke test')
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const helperScript = 'test/helpers/run-get-logs.ts'
|
|
12
|
+
if (!fs.existsSync(helperScript)) {
|
|
13
|
+
console.error(`Missing ${helperScript}. Run 'npm run build' first or ensure the helper exists.`)
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const cmd = `tsx ${helperScript} --platform ios --id booted --limit 20`
|
|
19
|
+
const out = execSync(cmd, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 30000 })
|
|
20
|
+
const parsed = JSON.parse(out)
|
|
21
|
+
|
|
22
|
+
if (!parsed || !Array.isArray(parsed.logs)) throw new Error('Output missing logs array')
|
|
23
|
+
const count = parsed.count ?? parsed.logs.length
|
|
24
|
+
if (count !== parsed.logs.length) throw new Error('count mismatch')
|
|
25
|
+
if (parsed.logs.some((e: any) => !e.timestamp || !e.level || typeof e.message !== 'string')) throw new Error('log entry missing fields')
|
|
26
|
+
|
|
27
|
+
log('iOS device smoke test: PASS')
|
|
28
|
+
process.exit(0)
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
console.error('iOS device smoke test: FAIL')
|
|
31
|
+
console.error(err && err.message ? err.message : err)
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { StructuredLogEntry } from '../../../src/types.js'
|
|
2
|
+
|
|
3
|
+
function assert(cond: boolean, msg?: string) { if (!cond) throw new Error(msg || 'Assertion failed') }
|
|
4
|
+
|
|
5
|
+
function applyFilters(entries: StructuredLogEntry[], opts: { contains?: string, level?: string, tag?: string, pid?: number, since_seconds?: number, limit?: number }) {
|
|
6
|
+
let filtered = entries.slice()
|
|
7
|
+
if (opts.contains) filtered = filtered.filter(e => e.message && e.message.includes(opts.contains!))
|
|
8
|
+
if (opts.since_seconds) {
|
|
9
|
+
const sinceMs = Date.now() - (opts.since_seconds * 1000)
|
|
10
|
+
filtered = filtered.filter(e => e.timestamp && (new Date(e.timestamp).getTime() >= sinceMs))
|
|
11
|
+
}
|
|
12
|
+
if (opts.level) filtered = filtered.filter(e => e.level && e.level.toUpperCase() === opts.level!.toUpperCase())
|
|
13
|
+
if (opts.tag) filtered = filtered.filter(e => e.tag && e.tag.includes(opts.tag!))
|
|
14
|
+
if (typeof opts.pid === 'number') filtered = filtered.filter(e => e.pid === opts.pid)
|
|
15
|
+
// oldest -> newest
|
|
16
|
+
filtered.sort((a,b) => (a.timestamp? new Date(a.timestamp).getTime():0) - (b.timestamp? new Date(b.timestamp).getTime():0))
|
|
17
|
+
const lim = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : 50
|
|
18
|
+
return filtered.slice(-Math.max(0, lim))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function run() {
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
const entries: StructuredLogEntry[] = [
|
|
24
|
+
{ timestamp: new Date(now - 60000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'startup complete' },
|
|
25
|
+
{ timestamp: new Date(now - 45000).toISOString(), level: 'WARN', tag: 'B', pid: 124, message: 'slow response' },
|
|
26
|
+
{ timestamp: new Date(now - 30000).toISOString(), level: 'ERROR', tag: 'A', pid: 123, message: 'Unhandled exception' },
|
|
27
|
+
{ timestamp: new Date(now - 15000).toISOString(), level: 'DEBUG', tag: 'C', pid: 125, message: 'debug info' },
|
|
28
|
+
{ timestamp: new Date(now - 5000).toISOString(), level: 'INFO', tag: 'A', pid: 123, message: 'user action happened' }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
// contains filter
|
|
32
|
+
const c1 = applyFilters(entries, { contains: 'user' })
|
|
33
|
+
assert(c1.length === 1 && c1[0].message.includes('user'), 'contains filter failed')
|
|
34
|
+
|
|
35
|
+
// level filter
|
|
36
|
+
const e1 = applyFilters(entries, { level: 'ERROR' })
|
|
37
|
+
assert(e1.length === 1 && e1[0].level === 'ERROR', 'level filter failed')
|
|
38
|
+
|
|
39
|
+
// tag filter
|
|
40
|
+
const t1 = applyFilters(entries, { tag: 'A' })
|
|
41
|
+
assert(t1.length === 3, 'tag filter failed')
|
|
42
|
+
|
|
43
|
+
// pid filter
|
|
44
|
+
const p1 = applyFilters(entries, { pid: 123 })
|
|
45
|
+
assert(p1.length === 3, 'pid filter failed')
|
|
46
|
+
|
|
47
|
+
// since_seconds filter (last 20s) should include last two entries
|
|
48
|
+
const s1 = applyFilters(entries, { since_seconds: 20 })
|
|
49
|
+
if (s1.length !== 2) throw new Error('since_seconds filter expected 2 entries, got ' + s1.length)
|
|
50
|
+
|
|
51
|
+
// limit
|
|
52
|
+
const l1 = applyFilters(entries, { limit: 2 })
|
|
53
|
+
assert(l1.length === 2 && l1[0].timestamp <= l1[1].timestamp, 'limit or ordering failed')
|
|
54
|
+
|
|
55
|
+
console.log('get_logs unit tests passed')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
run()
|