diff --git a/README.md b/README.md index daf7c4ab..e90c8ebe 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ How we help to scale mobile automation: - **`mobile_press_button`** - Press device buttons (HOME, BACK, VOLUME_UP/DOWN, ENTER, etc.) - **`mobile_open_url`** - Open URLs in the device browser +### Diagnostics & Debugging +- **`mobile_logcat_dump`** - Dump recent Android logcat lines for debugging automation runs (optional package/pid, buffers, priority, regex filters) + ### Platform Support - **iOS**: Simulators and real devices via native accessibility and WebDriverAgent - **Android**: Emulators and real devices via ADB and UI Automator diff --git a/src/Untitled b/src/Untitled new file mode 100644 index 00000000..1c619316 --- /dev/null +++ b/src/Untitled @@ -0,0 +1 @@ +logcatDump \ No newline at end of file diff --git a/src/android.ts b/src/android.ts index ae470415..4eecb544 100644 --- a/src/android.ts +++ b/src/android.ts @@ -11,6 +11,18 @@ export interface AndroidDevice { deviceType: "tv" | "mobile"; } +export interface LogcatDumpOptions { + lines?: number; // default 200, max 500 + format?: LogcatFormat; // default threadtime + buffers?: LogcatBuffer[]; // default ["main", "crash"] + minPriority?: LogcatPriority; // default I + pid?: number; + packageName?: string; + includeRegex?: string; + excludeRegex?: string; +} + + interface UiAutomatorXmlNode { node: UiAutomatorXmlNode[]; class?: string; @@ -69,8 +81,24 @@ const BUTTON_MAP: Record = { const TIMEOUT = 30000; const MAX_BUFFER_SIZE = 1024 * 1024 * 4; +const clampInt = (value: any, defaultValue: number, min: number, max: number): number => { + const n = Number(value); + if (!Number.isFinite(n)) {return defaultValue;} + return Math.max(min, Math.min(max, Math.floor(n))); +}; + +const truncateText = (text: string, maxChars: number): { text: string; truncated: boolean } => { + if (text.length <= maxChars) {return { text, truncated: false };} + return { text: text.slice(0, maxChars) + "\n\n[OUTPUT TRUNCATED - Use packageName, minPriority, includeRegex, or excludeRegex filters to reduce output]\n", truncated: true }; +}; + type AndroidDeviceType = "tv" | "mobile"; +type LogcatBuffer = "main" | "crash" | "system"; +type LogcatFormat = "threadtime" | "time" | "brief"; +type LogcatPriority = "V" | "D" | "I" | "W" | "E" | "F"; + + export class AndroidRobot implements Robot { public constructor(private deviceId: string) { @@ -501,6 +529,151 @@ export class AndroidRobot implements Robot { height: bottom - top, }; } + + public async logcatDump(opts: LogcatDumpOptions): Promise<{ + text: string; + truncated: boolean; + meta: { + linesRequested: number; + format: LogcatFormat; + buffers: LogcatBuffer[]; + minPriority: LogcatPriority; + pidRequested?: number; + pidResolved?: number; + pidFilterMode?: "logcat--pid" | "client-side" | "none"; + }; + }> { + const linesRequested = clampInt(opts.lines, 200, 1, 500); + const format: LogcatFormat = opts.format ?? "threadtime"; + const buffers: LogcatBuffer[] = (opts.buffers && opts.buffers.length > 0) ? opts.buffers : ["main", "crash"]; + const minPriority: LogcatPriority = opts.minPriority ?? "I"; + + let pidResolved: number | undefined = opts.pid; + + // Resolve PID from packageName if requested + if (!pidResolved && opts.packageName) { + pidResolved = await this.tryResolvePid(opts.packageName); + } + + const baseArgs: string[] = ["shell", "logcat", "-d", "-v", format]; + for (const b of buffers) { + baseArgs.push("-b", b); + } + baseArgs.push("-t", String(linesRequested), `*:${minPriority}`); + + let output = ""; + let pidFilterMode: "logcat--pid" | "client-side" | "none" = "none"; + + // First try: use logcat --pid if pid is available (not supported on all Android builds) + if (pidResolved) { + try { + const buf = this.silentAdb(...baseArgs, "--pid", String(pidResolved)); + output = buf.toString(); + pidFilterMode = "logcat--pid"; + } catch (e) { + // Fallback: run without --pid and filter client-side + const buf = this.silentAdb(...baseArgs); + output = buf.toString(); + pidFilterMode = "client-side"; + } + } else { + const buf = this.silentAdb(...baseArgs); + output = buf.toString(); + pidFilterMode = "none"; + } + + // Normalize + filter lines + let lines = output.split("\n").filter(l => l.length > 0); + + // Client-side PID filter fallback (only supported for threadtime format) + if (pidResolved && pidFilterMode === "client-side") { + if (format === "threadtime") { + // threadtime format: "MM-DD HH:MM:SS.mmm PID TID PRIORITY/TAG: message" + // PID is column index 2 (0-indexed) when split by whitespace + lines = lines.filter(l => { + const cols = l.trim().split(/\s+/); + if (cols.length < 3) {return false;} + const linePid = Number(cols[2]); + return linePid === pidResolved; + }); + } else { + // For non-threadtime formats (time, brief), PID column position varies or is absent + // Skip client-side filtering to avoid incorrect matches + pidFilterMode = "none"; + console.warn(`Client-side PID filtering is only supported for threadtime format, skipping for format "${format}".`); + } + } + + // include/exclude regex filters + if (opts.includeRegex) { + try { + const re = new RegExp(opts.includeRegex); + lines = lines.filter(l => re.test(l)); + } catch (e) { + console.error(`Invalid includeRegex pattern "${opts.includeRegex}": ${(e as Error).message}. Skipping include filter.`); + } + } + if (opts.excludeRegex) { + try { + const re = new RegExp(opts.excludeRegex); + lines = lines.filter(l => !re.test(l)); + } catch (e) { + console.error(`Invalid excludeRegex pattern "${opts.excludeRegex}": ${(e as Error).message}. Skipping exclude filter.`); + } + } + + const finalText = lines.join("\n") + (lines.length ? "\n" : ""); + const { text, truncated } = truncateText(finalText, 80_000); + + return { + text, + truncated, + meta: { + linesRequested, + format, + buffers, + minPriority, + pidRequested: opts.pid, + pidResolved, + pidFilterMode, + }, + }; + } + + private async tryResolvePid(packageName: string): Promise { + // 1) pidof + try { + const out = this.silentAdb("shell", "pidof", packageName).toString().trim(); + // pidof may return multiple PIDs separated by spaces + const first = out.split(/\s+/)[0]; + const pid = Number(first); + if (Number.isFinite(pid)) {return pid;} + } catch (e) { + // ignore + } + + // 2) fallback: ps -A | grep + try { + const out = this.silentAdb("shell", "ps", "-A").toString(); + // ps output: USER PID ... NAME (NAME is the last column) + // Require exact match on NAME column to avoid partial package name matches + const line = out.split("\n").find(l => { + const cols = l.trim().split(/\s+/); + if (cols.length < 2) {return false;} + const name = cols[cols.length - 1]; + return name === packageName; + }); + if (!line) {return undefined;} + + const cols = line.trim().split(/\s+/); + const pid = Number(cols[1]); + if (Number.isFinite(pid)) {return pid;} + + return undefined; + } catch (e) { + return undefined; + } + } } export class AndroidDeviceManager { diff --git a/src/server.ts b/src/server.ts index 3c7d7b5b..8e90d62a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -637,5 +637,42 @@ export const createMcpServer = (): McpServer => { } ); + tool( + "mobile_logcat_dump", + "Logcat Dump (Android)", + "Dump recent Android logcat lines for debugging automation flows (crashes, permission errors, intent handling, etc). Optional filtering by package/pid, buffers, priority, and regex.", + { + device: z.string().describe("Android device id (from mobile_list_available_devices)"), + lines: z.number().optional().describe("Number of lines to dump (default 200, max 500)"), + format: z.enum(["threadtime", "time", "brief"]).optional().describe("Log line format (default threadtime)"), + buffers: z.array(z.enum(["main", "crash", "system"])).optional().describe("Log buffers to include (default [main, crash])"), + minPriority: z.enum(["V", "D", "I", "W", "E", "F"]).optional().describe("Minimum log priority (default I)"), + pid: z.number().optional().describe("PID to filter on (optional)"), + packageName: z.string().optional().describe("Package name; will resolve PID and filter (optional)"), + includeRegex: z.string().optional().describe("Only include lines matching this regex (optional)"), + excludeRegex: z.string().optional().describe("Exclude lines matching this regex (optional)"), + }, + { readOnlyHint: true }, + async args => { + const robot = getRobotFromDevice(args.device); + if (!(robot instanceof AndroidRobot)) { + throw new ActionableError("mobile_logcat_dump is only supported on Android devices"); + } + + const result = await robot.logcatDump({ + lines: args.lines, + format: args.format, + buffers: args.buffers, + minPriority: args.minPriority, + pid: args.pid, + packageName: args.packageName, + includeRegex: args.includeRegex, + excludeRegex: args.excludeRegex, + }); + + // Return structured JSON (agent-friendly) + return JSON.stringify(result, null, 2); + } + ); return server; }; diff --git a/test/android.ts b/test/android.ts index 5b216898..98a6212a 100644 --- a/test/android.ts +++ b/test/android.ts @@ -136,4 +136,47 @@ describe("android", () => { // screen size should not have changed assert.deepEqual(screenSize1, screenSize2); }); + + + it("should be able to dump logcat for a package", async function() { + hasOneAndroidDevice || this.skip(); + + const res = await android.logcatDump({ + packageName: "com.android.settings", + lines: 120, + format: "threadtime", + buffers: ["main", "crash"], + minPriority: "I", + }); + + assert.ok(typeof res.text === "string"); + assert.ok(res.text.length > 0); + assert.equal(res.truncated, false); + assert.ok(res.meta.pidResolved !== undefined, "Expected pidResolved to be set"); + assert.ok(res.meta.pidResolved > 0, "Expected pidResolved to be > 0"); + assert.ok( + res.meta.pidFilterMode === "logcat--pid" || res.meta.pidFilterMode === "client-side" || res.meta.pidFilterMode === "none", + "Unexpected pidFilterMode" + ); + + // logcat often prints this marker line + assert.ok(res.text.includes("beginning of"), "Expected log output to include buffer marker"); + }); + + it("should support includeRegex / excludeRegex filtering in logcat dump", async function() { + hasOneAndroidDevice || this.skip(); + + const res = await android.logcatDump({ + packageName: "com.android.settings", + lines: 400, + includeRegex: "SecurityException|FATAL|Exception|ANR|E ", + excludeRegex: "Choreographer", + }); + + assert.ok(typeof res.text === "string"); + // If there are no matches, text may be empty; but tool should still succeed and return meta. + assert.ok(res.meta.pidResolved !== undefined, "Expected pidResolved to be set"); + assert.ok(res.meta.pidResolved > 0, "Expected pidResolved to be > 0"); + }); + });