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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parsePngSize(buf) {
|
|
2
|
+
try {
|
|
3
|
+
if (!buf || buf.length < 24)
|
|
4
|
+
return { width: 0, height: 0 };
|
|
5
|
+
// PNG signature + IHDR checks
|
|
6
|
+
if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a)
|
|
7
|
+
return { width: 0, height: 0 };
|
|
8
|
+
const ihdr = buf.toString('ascii', 12, 16);
|
|
9
|
+
if (ihdr !== 'IHDR')
|
|
10
|
+
return { width: 0, height: 0 };
|
|
11
|
+
const width = buf.readUInt32BE(16);
|
|
12
|
+
const height = buf.readUInt32BE(20);
|
|
13
|
+
return { width, height };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { width: 0, height: 0 };
|
|
17
|
+
}
|
|
18
|
+
}
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.21.3]
|
|
6
|
+
- Added structured logs
|
|
7
|
+
|
|
8
|
+
## [0.21.2]
|
|
9
|
+
- Fixed screenshots not working, imnproved tool
|
|
10
|
+
|
|
5
11
|
## [0.21.1]
|
|
6
12
|
- Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
|
|
7
13
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-debug-mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
4
4
|
"description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
29
29
|
"fast-xml-parser": "^5.5.1",
|
|
30
|
-
"zod": "^3.22.4"
|
|
30
|
+
"zod": "^3.22.4",
|
|
31
|
+
"sharp": "^0.32.1"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/node": "^25.4.0",
|
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
|
@@ -6,6 +6,7 @@ import { createWriteStream } from "fs"
|
|
|
6
6
|
import { promises as fsPromises } from "fs"
|
|
7
7
|
import path from "path"
|
|
8
8
|
import { computeScreenFingerprint } from "../utils/ui/index.js"
|
|
9
|
+
import { parsePngSize } from "../utils/image.js"
|
|
9
10
|
|
|
10
11
|
const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
|
|
11
12
|
|
|
@@ -92,26 +93,97 @@ export class AndroidObserve {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
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
|
|
96
98
|
const metadata = await getAndroidDeviceMetadata(appId || "", deviceId)
|
|
97
99
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
98
100
|
|
|
99
101
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
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
|
|
104
106
|
if (appId) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
115
|
}
|
|
113
|
-
|
|
114
|
-
|
|
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 }
|
|
115
187
|
} catch (e) {
|
|
116
188
|
console.error("Error fetching logs:", e)
|
|
117
189
|
return { device: deviceInfo, logs: [], logCount: 0 }
|
|
@@ -142,7 +214,7 @@ export class AndroidObserve {
|
|
|
142
214
|
reject(new Error(`ADB screencap timed out after 10s`))
|
|
143
215
|
}, 10000)
|
|
144
216
|
|
|
145
|
-
child.on('close', (code) => {
|
|
217
|
+
child.on('close', async (code) => {
|
|
146
218
|
clearTimeout(timeout)
|
|
147
219
|
if (code !== 0) {
|
|
148
220
|
reject(new Error(stderr.trim() || `Screencap failed with code ${code}`))
|
|
@@ -152,28 +224,91 @@ export class AndroidObserve {
|
|
|
152
224
|
const screenshotBuffer = Buffer.concat(chunks)
|
|
153
225
|
const screenshotBase64 = screenshotBuffer.toString('base64')
|
|
154
226
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
227
|
+
const parsed = parsePngSize(screenshotBuffer)
|
|
228
|
+
if (parsed.width > 0 && parsed.height > 0) {
|
|
229
|
+
// Attempt to convert to WebP (preferred) and provide JPEG fallback (awaited to avoid race)
|
|
230
|
+
try {
|
|
231
|
+
const sharpModule = await import('sharp'); const sharp = sharpModule && (sharpModule as any).default ? (sharpModule as any).default : sharpModule;
|
|
232
|
+
const buf = screenshotBuffer;
|
|
233
|
+
const img = sharp(buf);
|
|
234
|
+
const meta = await img.metadata().catch((err: any) => { console.error('sharp.metadata failed (Android):', err); return {} as any });
|
|
235
|
+
const hasAlpha = !!meta.hasAlpha || (meta.channels && meta.channels > 3);
|
|
236
|
+
|
|
237
|
+
let webpBuf: Buffer | null = null;
|
|
238
|
+
let jpegBuf: Buffer | null = null;
|
|
239
|
+
try {
|
|
240
|
+
webpBuf = await img.webp({ quality: 80 }).toBuffer();
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error('WebP conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
243
|
+
webpBuf = null;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
jpegBuf = await img.jpeg({ quality: 80 }).toBuffer();
|
|
247
|
+
} catch (err) {
|
|
248
|
+
console.error('JPEG conversion failed (Android):', err instanceof Error ? err.message : String(err));
|
|
249
|
+
jpegBuf = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (hasAlpha) {
|
|
253
|
+
if (webpBuf) {
|
|
254
|
+
const webpB64 = webpBuf.toString('base64')
|
|
255
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
|
|
256
|
+
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 } } as any)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const pngB64 = buf.toString('base64')
|
|
260
|
+
resolve({ device: deviceInfo, screenshot: pngB64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
261
|
+
return
|
|
163
262
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
263
|
+
|
|
264
|
+
if (webpBuf) {
|
|
265
|
+
const webpB64 = webpBuf.toString('base64')
|
|
266
|
+
const jpegB64 = jpegBuf ? jpegBuf.toString('base64') : null
|
|
267
|
+
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 } } as any)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (jpegBuf) {
|
|
272
|
+
resolve({ device: deviceInfo, screenshot: jpegBuf.toString('base64'), screenshot_mime: 'image/jpeg', resolution: { width: parsed.width, height: parsed.height } })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// No conversions succeeded; return original PNG
|
|
277
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
278
|
+
return
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error('Screenshot conversion pipeline failed (Android):', err instanceof Error ? err.message : String(err));
|
|
281
|
+
// Conversion failed - fall back to original PNG with parsed resolution
|
|
282
|
+
resolve({ device: deviceInfo, screenshot: screenshotBase64, screenshot_mime: 'image/png', resolution: { width: parsed.width, height: parsed.height } })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
// Fallback to querying wm size if parsing failed
|
|
287
|
+
execAdb(['shell', 'wm', 'size'], deviceId)
|
|
288
|
+
.then(sizeStdout => {
|
|
289
|
+
let width = 0
|
|
290
|
+
let height = 0
|
|
291
|
+
const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/)
|
|
292
|
+
if (match) {
|
|
293
|
+
width = parseInt(match[1], 10)
|
|
294
|
+
height = parseInt(match[2], 10)
|
|
295
|
+
}
|
|
296
|
+
resolve({
|
|
297
|
+
device: deviceInfo,
|
|
298
|
+
screenshot: screenshotBase64,
|
|
299
|
+
screenshot_mime: 'image/png',
|
|
300
|
+
resolution: { width, height }
|
|
301
|
+
})
|
|
168
302
|
})
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
303
|
+
.catch(() => {
|
|
304
|
+
resolve({
|
|
305
|
+
device: deviceInfo,
|
|
306
|
+
screenshot: screenshotBase64,
|
|
307
|
+
screenshot_mime: 'image/png',
|
|
308
|
+
resolution: { width: 0, height: 0 }
|
|
309
|
+
})
|
|
175
310
|
})
|
|
176
|
-
|
|
311
|
+
}
|
|
177
312
|
})
|
|
178
313
|
|
|
179
314
|
child.on('error', (err) => {
|
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 => {
|