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/interact/index.js +202 -7
- package/dist/observe/android.js +88 -11
- package/dist/observe/index.js +32 -10
- package/dist/observe/ios.js +149 -12
- package/dist/server.js +30 -16
- package/dist/utils/cli/ios/run-ios-smoke.js +1 -1
- package/docs/CHANGELOG.md +7 -0
- package/docs/tools/observe.md +25 -10
- package/package.json +1 -1
- package/src/interact/index.ts +195 -7
- package/src/observe/android.ts +88 -15
- package/src/observe/index.ts +33 -12
- package/src/observe/ios.ts +151 -12
- package/src/server.ts +31 -16
- package/src/types.ts +13 -1
- package/src/utils/cli/ios/run-ios-smoke.ts +1 -1
- package/test/helpers/run-get-logs.ts +20 -0
- package/test/interact/unit/wait_for_ui_contract.test.ts +33 -0
- package/test/interact/unit/wait_for_ui_new.test.ts +57 -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/test/observe/unit/ios-getlogs.test.ts +67 -0
- package/test/manage/device/run-install-kmp.ts +0 -18
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"]
|
|
@@ -316,17 +322,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
316
322
|
},
|
|
317
323
|
{
|
|
318
324
|
name: "wait_for_ui",
|
|
319
|
-
description: "
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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: {
|
|
571
|
-
{ type: 'text', text: (res.logs
|
|
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 {
|
|
626
|
-
const res = await ToolsInteract.waitForUIHandler({
|
|
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,
|
|
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
|
|
package/docs/tools/observe.md
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
+
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
|
-
{ "
|
|
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
package/src/interact/index.ts
CHANGED
|
@@ -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({
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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 =
|
|
541
|
+
const idx = msgs.lastIndexOf(baselineLastLine)
|
|
354
542
|
startIndex = idx >= 0 ? idx + 1 : 0
|
|
355
543
|
}
|
|
356
|
-
for (let i = startIndex; i <
|
|
357
|
-
const line =
|
|
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 }
|
package/src/observe/android.ts
CHANGED
|
@@ -93,29 +93,102 @@ 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
|
+
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
|
|
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, 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(
|
|
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, 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:
|
|
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 => {
|