mobile-debug-mcp 0.21.2 → 0.21.4

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/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 the log output.",
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: "Number of log lines (android only)"
175
+ description: "Legacy - number of log lines (android only)"
170
176
  }
171
177
  },
172
178
  required: ["platform"]
@@ -316,17 +322,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
316
322
  },
317
323
  {
318
324
  name: "wait_for_ui",
319
- description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
325
+ description: "Deterministic UI wait primitive. Waits for selector condition with retries and backoff.",
320
326
  inputSchema: {
321
327
  type: "object",
322
328
  properties: {
323
- type: { type: "string", enum: ["ui", "log", "screen", "idle"], description: "Condition type to observe", default: "ui" },
324
- query: { type: "string", description: "Optional query string for ui/log/screen types" },
325
- timeoutMs: { type: "number", description: "Timeout in ms to wait for condition (default 30000)", default: 30000 },
326
- pollIntervalMs: { type: "number", description: "Polling interval in ms (default 300, clamped to 250-500)", default: 300 },
327
- match: { type: "string", enum: ["present", "absent"], description: "Match mode for UI checks: 'present' or 'absent' (default 'present')", default: "present" },
328
- stability_ms: { type: "number", description: "Stability window in ms that the condition must hold before returning success (default 700)", default: 700 },
329
- includeSnapshotOnFailure: { type: "boolean", description: "Whether to include a debug snapshot on timeout (default true)", default: true },
329
+ selector: {
330
+ type: "object",
331
+ properties: {
332
+ text: { type: "string" },
333
+ resource_id: { type: "string" },
334
+ accessibility_id: { type: "string" },
335
+ contains: { type: "boolean", description: "When true, perform substring matching", default: false }
336
+ }
337
+ },
338
+ condition: { type: "string", enum: ["exists", "not_exists", "visible", "clickable"], default: "exists" },
339
+ timeout_ms: { type: "number", default: 60000 },
340
+ poll_interval_ms: { type: "number", default: 300 },
341
+ match: { type: "object", properties: { index: { type: "number" } } },
342
+ retry: { type: "object", properties: { max_attempts: { type: "number", default: 1 }, backoff_ms: { type: "number", default: 0 } } },
330
343
  platform: { type: "string", enum: ["android", "ios"], description: "Optional platform override" },
331
344
  deviceId: { type: "string", description: "Optional device serial/udid" }
332
345
  }
@@ -563,12 +576,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
563
576
  };
564
577
  }
565
578
  if (name === "get_logs") {
566
- const { platform, appId, deviceId, lines } = args;
567
- const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, lines });
579
+ const { platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines } = args;
580
+ const res = await ToolsObserve.getLogsHandler({ platform, appId, deviceId, pid, tag, level, contains, since_seconds, limit, lines });
581
+ const filtered = !!(pid || tag || level || contains || since_seconds || appId);
568
582
  return {
569
583
  content: [
570
- { type: 'text', text: JSON.stringify({ device: res.device, result: { lines: res.logs.length, crashLines: res.crashLines.length > 0 ? res.crashLines : undefined } }, null, 2) },
571
- { type: 'text', text: (res.logs || []).join('\n') }
584
+ { type: 'text', text: JSON.stringify({ device: res.device, result: { count: res.logCount, filtered, crashLines: (res.crashLines || []), source: res.source, meta: res.meta || {} } }, null, 2) },
585
+ { type: 'text', text: JSON.stringify({ logs: res.logs }, null, 2) }
572
586
  ]
573
587
  };
574
588
  }
@@ -622,8 +636,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
622
636
  return wrapResponse(res);
623
637
  }
624
638
  if (name === "wait_for_ui") {
625
- const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {});
626
- const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
639
+ const { selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry, platform, deviceId } = (args || {});
640
+ const res = await ToolsInteract.waitForUIHandler({ selector, condition, timeout_ms, poll_interval_ms, match, retry, platform, deviceId });
627
641
  return wrapResponse(res);
628
642
  }
629
643
  if (name === "find_element") {
@@ -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, undefined);
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
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.21.4]
6
+ - updated `wait_for_ui` with better contract and observability
7
+ - update `get_logs` to get better output
8
+
9
+ ## [0.21.3]
10
+ - Added structured logs
11
+
5
12
  ## [0.21.2]
6
13
  - Fixed screenshots not working, imnproved tool
7
14
 
@@ -3,27 +3,42 @@
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
- Input:
7
+ Fetch recent logs as structured entries optimized for AI agents. Use logs as a debugging aid only — prefer UI validation (wait_for_ui) first.
8
+
9
+ Input (example):
9
10
 
10
11
  ```json
11
- { "platform": "android", "appId": "com.example.app", "deviceId": "emulator-5554", "lines": 200 }
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
- Response (metadata):
15
+ Defaults:
16
+
17
+ - No filters → return the most recent 50 log entries (app-scoped if appId provided), across all levels.
18
+
19
+ When to use get_logs:
20
+
21
+ - After a UI validation (wait_for_ui) fails to confirm the expected outcome.
22
+ - When you suspect a crash, error, or silent failure that the UI doesn't expose.
23
+ - To provide additional debugging context correlated with an action.
24
+
25
+ Do NOT use get_logs as the primary signal for success/failure, or call it repeatedly without new actions.
26
+
27
+ Response (structured):
15
28
 
16
29
  ```json
17
- { "entries": 200, "crash_summary": { "crash_detected": false } }
30
+ { "device": { "platform": "android", "id": "emulator-5554" }, "logs": [ { "timestamp": "2026-03-30T16:00:00.000Z", "level": "ERROR", "tag": "MyTag", "pid": 1234, "message": "Something failed" } ], "logCount": 1, "source": "pid|package|process|broad", "meta": { "filters": { "tag": "MyTag", "level": "ERROR" }, "pidArg": 1234 } }
18
31
  ```
19
32
 
20
- Followed by a raw log plain text block.
21
-
22
33
  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
34
 
26
- ---
35
+ - Each log entry: timestamp (ISO), level (VERBOSE|DEBUG|INFO|WARN|ERROR), tag (string), pid (number|null), message (string).
36
+ - Logs ordered oldest → newest. logCount equals number of entries returned.
37
+ - `source`: indicates how logs were filtered at collection time. Values: `pid` (filtered by process id), `package` / `process` (filtered by app/package/bundle), or `broad` (unfiltered system logs).
38
+ - `meta`: debugging information about filters and collection method (e.g., pid detection, effective limit).
39
+ - Supported filters: pid, tag, level, contains, since_seconds, limit.
40
+ - Platform behaviour: Android uses `adb logcat` with source-side filters where possible; iOS uses unified logging (`log show`/simctl) and maps subsystem/category → tag.
41
+ - Errors are returned as structured objects with `error.code` and `error.message`. Possible codes: LOGS_UNAVAILABLE, INVALID_FILTER, PLATFORM_NOT_SUPPORTED, INTERNAL_ERROR.
27
42
 
28
43
  ## capture_screenshot
29
44
  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.2",
3
+ "version": "0.21.4",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -218,9 +218,193 @@ export class ToolsInteract {
218
218
  return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
219
219
  }
220
220
 
221
- static async waitForUIHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
222
- // Backwards-compatible wrapper that delegates to the core waitForUICore implementation
223
- return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
221
+ static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
222
+ const overallStart = Date.now()
223
+
224
+ // Validate selector: require at least one of text, resource_id, or accessibility_id
225
+ if (!selector || (typeof selector === 'object' && Object.keys(selector).length === 0)) {
226
+ return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'At least one selector field must be provided (text, resource_id, or accessibility_id)' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
227
+ }
228
+
229
+ const hasText = selector && typeof (selector as any).text === 'string' && (selector as any).text.trim().length > 0
230
+ const hasResId = selector && typeof (selector as any).resource_id === 'string' && (selector as any).resource_id.trim().length > 0
231
+ const hasAccId = selector && typeof (selector as any).accessibility_id === 'string' && (selector as any).accessibility_id.trim().length > 0
232
+ if (!hasText && !hasResId && !hasAccId) {
233
+ return { status: 'timeout', error: { code: 'INVALID_SELECTOR', message: 'Selector must include at least one of: text, resource_id, accessibility_id' }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
234
+ }
235
+
236
+ // Validate condition
237
+ if (!['exists','not_exists','visible','clickable'].includes(condition)) {
238
+ return { status: 'timeout', error: { code: 'INVALID_CONDITION', message: `Unsupported condition: ${condition}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
239
+ }
240
+
241
+ // Platform check
242
+ if (platform && !['android','ios'].includes(platform)) {
243
+ return { status: 'timeout', error: { code: 'PLATFORM_NOT_SUPPORTED', message: `Unsupported platform: ${platform}` }, metrics: { latency_ms: Date.now() - overallStart, poll_count: 0, attempts: 0 } }
244
+ }
245
+
246
+ const effectivePoll = Math.max(50, Math.min(poll_interval_ms || 300, 2000))
247
+ const maxAttempts = (retry && retry.max_attempts) ? Math.max(1, retry.max_attempts) : 1
248
+ const backoff = (retry && retry.backoff_ms) ? Math.max(0, retry.backoff_ms) : 0
249
+
250
+ let attempts = 0
251
+ let totalPollCount = 0
252
+
253
+ // Precompute normalized selector values and helpers (constant across polls)
254
+ const normalize = (s: any) => (s === null || s === undefined) ? '' : String(s).toLowerCase().trim()
255
+ const containsFlag = !!selector.contains
256
+ const selText = normalize((selector as any).text)
257
+ const selRid = normalize((selector as any).resource_id)
258
+ const selAid = normalize((selector as any).accessibility_id)
259
+
260
+ try {
261
+ while (attempts < maxAttempts) {
262
+ attempts++
263
+ const attemptStart = Date.now()
264
+ const deadline = attemptStart + (timeout_ms || 0)
265
+
266
+ while (Date.now() <= deadline) {
267
+ totalPollCount++
268
+ try {
269
+ const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
270
+ const elements = (tree && Array.isArray(tree.elements)) ? tree.elements as any[] : []
271
+
272
+ const matches: { el: any, idx: number }[] = []
273
+
274
+ for (let i = 0; i < elements.length; i++) {
275
+ const el = elements[i]
276
+ let ok = true
277
+
278
+ // text
279
+ if (selector.text !== undefined && selector.text !== null) {
280
+ const val = normalize(el.text || el.label || el.value || '')
281
+ if (containsFlag) {
282
+ if (!val.includes(selText)) ok = false
283
+ } else {
284
+ if (val !== selText) ok = false
285
+ }
286
+ }
287
+
288
+ // resource_id
289
+ if (ok && selector.resource_id !== undefined && selector.resource_id !== null) {
290
+ const rid = normalize(el.resourceId || el.resourceID || el.id || '')
291
+ if (containsFlag) {
292
+ if (!rid.includes(selRid)) ok = false
293
+ } else {
294
+ if (rid !== selRid) ok = false
295
+ }
296
+ }
297
+
298
+ // accessibility_id
299
+ if (ok && selector.accessibility_id !== undefined && selector.accessibility_id !== null) {
300
+ const aid = normalize(el.contentDescription || el.contentDesc || el.accessibilityLabel || el.label || '')
301
+ if (containsFlag) {
302
+ if (!aid.includes(selAid)) ok = false
303
+ } else {
304
+ if (aid !== selAid) ok = false
305
+ }
306
+ }
307
+
308
+ if (ok) matches.push({ el, idx: i })
309
+ }
310
+
311
+ // Evaluate condition
312
+ const matchedCount = matches.length
313
+ const pickIndexProvided = (match && typeof (match as any).index === 'number')
314
+ const pickIndex: number = pickIndexProvided ? Number((match as any).index) : 0
315
+ let chosen: { el: any, idx: number } | null = null
316
+ if (matches.length === 0) {
317
+ chosen = null
318
+ } else if (pickIndexProvided) {
319
+ // If a specific index is requested but out of bounds, treat as not matched for this poll (deterministic)
320
+ if (pickIndex >= 0 && pickIndex < matches.length) chosen = matches[pickIndex]
321
+ else chosen = null
322
+ } else {
323
+ chosen = matches[0]
324
+ }
325
+
326
+ let conditionMet = false
327
+ if (condition === 'exists') {
328
+ // when an index is specified, existence requires that specific index be present
329
+ conditionMet = pickIndexProvided ? (chosen !== null) : (matchedCount >= 1)
330
+ } else if (condition === 'not_exists') {
331
+ // when an index is specified, not_exists is true if that index is absent
332
+ conditionMet = pickIndexProvided ? (chosen === null) : (matchedCount === 0)
333
+ } else if (condition === 'visible') {
334
+ if (chosen) {
335
+ const b = chosen.el.bounds
336
+ const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
337
+ conditionMet = visibleFlag
338
+ } else conditionMet = false
339
+ } else if (condition === 'clickable') {
340
+ if (chosen) {
341
+ const b = chosen.el.bounds
342
+ const visibleFlag = !!chosen.el.visible && Array.isArray(b) && b.length >= 4 && (b[2] > b[0] && b[3] > b[1])
343
+ const enabled = !!chosen.el.enabled
344
+ const clickable = !!chosen.el.clickable || !!chosen.el._interactable
345
+ conditionMet = visibleFlag && enabled && clickable
346
+ } else conditionMet = false
347
+ }
348
+
349
+ if (conditionMet) {
350
+ const now = Date.now()
351
+ const latency_ms = now - overallStart
352
+ // Build element output per spec
353
+ const outEl = chosen ? {
354
+ text: chosen.el.text ?? null,
355
+ resource_id: chosen.el.resourceId ?? chosen.el.resourceID ?? chosen.el.id ?? null,
356
+ accessibility_id: chosen.el.contentDescription ?? chosen.el.contentDesc ?? chosen.el.accessibilityLabel ?? chosen.el.label ?? null,
357
+ class: chosen.el.type ?? chosen.el.class ?? null,
358
+ bounds: Array.isArray(chosen.el.bounds) && chosen.el.bounds.length >= 4 ? chosen.el.bounds : null,
359
+ index: chosen.idx
360
+ } : null
361
+
362
+ return {
363
+ status: 'success',
364
+ matched: matchedCount,
365
+ element: outEl,
366
+ metrics: { latency_ms, poll_count: totalPollCount, attempts }
367
+ }
368
+ }
369
+
370
+ } catch (e) {
371
+ // Non-fatal per-poll error; record and continue
372
+ console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
373
+ }
374
+
375
+ // Sleep until next poll
376
+ await new Promise(r => setTimeout(r, effectivePoll || 50))
377
+ }
378
+
379
+ // Attempt timed out; if more attempts allowed, backoff then retry
380
+ if (attempts < maxAttempts) {
381
+ if (backoff > 0) await new Promise(r => setTimeout(r, backoff))
382
+ continue
383
+ }
384
+
385
+ // Final failure for this call
386
+ const elapsed = Date.now() - overallStart
387
+ return {
388
+ status: 'timeout',
389
+ error: { code: 'ELEMENT_NOT_FOUND', message: `Condition ${condition} not satisfied within timeout` },
390
+ metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts }
391
+ }
392
+ }
393
+
394
+ } catch (err) {
395
+ const elapsed = Date.now() - overallStart
396
+ return { status: 'timeout', error: { code: 'INTERNAL_ERROR', message: err instanceof Error ? err.message : String(err) }, metrics: { latency_ms: elapsed, poll_count: totalPollCount, attempts } }
397
+ }
398
+ }
399
+
400
+ // Helper: normalize various log objects into plain message strings for comparison
401
+ private static _logsToMessages(logsArr: any[]): string[] {
402
+ if (!Array.isArray(logsArr)) return []
403
+ return logsArr.map((l: any) => {
404
+ if (typeof l === 'string') return l
405
+ if (l && (l.message || l.msg)) return l.message || l.msg
406
+ try { return JSON.stringify(l) } catch { return String(l) }
407
+ })
224
408
  }
225
409
 
226
410
  static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
@@ -279,7 +463,9 @@ export class ToolsInteract {
279
463
  if (fpRes && typeof fpRes === 'object') initialFingerprint = (fpRes as ScreenFingerprintResponse).fingerprint ?? null
280
464
  if (gl) {
281
465
  const logsArr = Array.isArray((gl as any).logs) ? (gl as any).logs : []
282
- baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
466
+ // Normalize to last message string for baseline comparison
467
+ const msgs = ToolsInteract._logsToMessages(logsArr)
468
+ baselineLastLine = msgs.length ? msgs[msgs.length - 1] : null
283
469
  }
284
470
  } catch (err) {
285
471
  try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
@@ -348,13 +534,15 @@ export class ToolsInteract {
348
534
 
349
535
  const gl = await ToolsObserve.getLogsHandler({ platform, deviceId, lines: 200 }) as any
350
536
  const logsArr = Array.isArray(gl && gl.logs) ? gl.logs : []
537
+ // Normalize to messages for comparison
538
+ const msgs = ToolsInteract._logsToMessages(logsArr)
351
539
  let startIndex = 0
352
540
  if (baselineLastLine) {
353
- const idx = logsArr.lastIndexOf(baselineLastLine)
541
+ const idx = msgs.lastIndexOf(baselineLastLine)
354
542
  startIndex = idx >= 0 ? idx + 1 : 0
355
543
  }
356
- for (let i = startIndex; i < logsArr.length; i++) {
357
- const line = logsArr[i]
544
+ for (let i = startIndex; i < msgs.length; i++) {
545
+ const line = msgs[i]
358
546
  if (q && String(line).includes(q)) {
359
547
  const now2 = Date.now()
360
548
  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 }
@@ -93,29 +93,102 @@ export class AndroidObserve {
93
93
  }
94
94
  }
95
95
 
96
- async getLogs(appId?: string, lines = 200, deviceId?: string): Promise<GetLogsResponse> {
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 stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId)
102
- const allLogs = stdout.split('\n')
103
-
104
- let filteredLogs = allLogs
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
- const matchingLogs = allLogs.filter(line => line.includes(appId))
107
-
108
- if (matchingLogs.length > 0) {
109
- filteredLogs = matchingLogs
110
- } else {
111
- filteredLogs = allLogs
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
- return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length }
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
+ const source = pidArg ? 'pid' : (appId ? 'package' : 'broad')
187
+ const meta = { pidArg, appIdProvided: !!appId, filters: { tag, level, contains, since_seconds, limit: effectiveLimit }, pidExplicit: !!pid }
188
+ return { device: deviceInfo, logs: limited, logCount: limited.length, source, meta }
116
189
  } catch (e) {
117
190
  console.error("Error fetching logs:", e)
118
- return { device: deviceInfo, logs: [], logCount: 0 }
191
+ return { device: deviceInfo, logs: [], logCount: 0, source: 'broad', meta: { error: e instanceof Error ? e.message : String(e) } }
119
192
  }
120
193
  }
121
194
 
@@ -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(appId, lines ?? 200, resolved.id)
51
+ const response = await observe.getLogs(filters)
45
52
  const logs = Array.isArray(response.logs) ? response.logs : []
46
- const crashLines = logs.filter(line => line.includes('FATAL EXCEPTION'))
47
- return { device: response.device, logs, crashLines }
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, source: response.source, meta: response.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
56
+ return { device: response.device, logs, crashLines, logCount: response.logCount, source: response.source, meta: response.meta }
48
57
  } else {
49
- const resp = await (observe as iOSObserve).getLogs(appId, resolved.id)
58
+ const resp = await (observe as iOSObserve).getLogs(filters)
50
59
  const logs = Array.isArray(resp.logs) ? resp.logs : []
51
- const crashLines = logs.filter(l => l.includes('FATAL EXCEPTION'))
52
- return { device: resp.device, logs, crashLines }
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, source: resp.source, meta: resp.meta, error: { code: 'LOGS_UNAVAILABLE', message: 'No logs match filters' } } as any
63
+ return { device: resp.device, logs, crashLines, logCount: resp.logCount, source: resp.source, meta: resp.meta }
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: string[] = (gl && (gl as any).logs) ? (gl as any).logs : []
149
- entries = raw.slice(-Math.max(0, logLines)).map(line => {
150
- const level = /\b(FATAL EXCEPTION|ERROR| E )\b/i.test(line) ? 'ERROR' : /\b(WARN| W )\b/i.test(line) ? 'WARN' : 'INFO'
151
- return { timestamp: null, level, message: line }
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 => {