UNPKG

appium-adb

Version:

Android Debug Bridge interface

245 lines (225 loc) 7.32 kB
import {logger, util} from '@appium/support'; import B from 'bluebird'; import _ from 'lodash'; import {EventEmitter} from 'node:events'; import {SubProcess, exec} from 'teen_process'; import {LRUCache} from 'lru-cache'; import type {ExecError} from 'teen_process'; import type {ADBExecutable} from './types'; import type {LogEntry, LogcatOpts as StartCaptureOptions} from './tools/types'; const log = logger.getLogger('Logcat'); const MAX_BUFFER_SIZE = 10000; const LOGCAT_PROC_STARTUP_TIMEOUT = 10000; const SUPPORTED_FORMATS = [ 'brief', 'process', 'tag', 'thread', 'raw', 'time', 'threadtime', 'long', ] as const; const SUPPORTED_PRIORITIES = ['v', 'd', 'i', 'w', 'e', 'f', 's'] as const; const DEFAULT_PRIORITY = 'v'; const DEFAULT_TAG = '*'; const DEFAULT_FORMAT = 'threadtime'; const TRACE_PATTERN = /W\/Trace/; const EXECVP_ERR_PATTERN = /execvp\(\)/; export interface LogcatOptions { adb: ADBExecutable; clearDeviceLogsOnStart?: boolean; debug?: boolean; debugTrace?: boolean; maxBufferSize?: number; } export class Logcat extends EventEmitter { private readonly adb: ADBExecutable; private readonly clearLogs: boolean; private readonly debug?: boolean; private readonly debugTrace?: boolean; private readonly maxBufferSize: number; private readonly logs: LRUCache<number, [string, number]>; private logIndexSinceLastRequest: number | null; private proc: SubProcess | null; constructor(opts: LogcatOptions) { super(); this.adb = opts.adb; this.clearLogs = opts.clearDeviceLogsOnStart || false; this.debug = opts.debug; this.debugTrace = opts.debugTrace; this.maxBufferSize = opts.maxBufferSize || MAX_BUFFER_SIZE; this.logs = new LRUCache({ max: this.maxBufferSize, }); this.logIndexSinceLastRequest = null; this.proc = null; } async startCapture(opts: StartCaptureOptions = {}): Promise<void> { let started = false; return await new B(async (_resolve, _reject) => { const resolve = function (...args: any[]) { started = true; _resolve(...args); }; const reject = function (...args: any[]) { started = true; _reject(...args); }; if (this.clearLogs) { await this.clear(); } const {format = DEFAULT_FORMAT, filterSpecs = []} = opts; const cmd = [ ...this.adb.defaultArgs, 'logcat', '-v', requireFormat(format), ...formatFilterSpecs(filterSpecs), ]; log.debug(`Starting logs capture with command: ${util.quote([this.adb.path, ...cmd])}`); this.proc = new SubProcess(this.adb.path, cmd); this.proc.on('exit', (code, signal) => { log.error(`Logcat terminated with code ${code}, signal ${signal}`); this.proc = null; if (!started) { log.warn('Logcat not started. Continuing'); resolve(); } }); this.proc.on('line-stderr', (line) => { if (!started && EXECVP_ERR_PATTERN.test(line)) { log.error('Logcat process failed to start'); return reject(new Error(`Logcat process failed to start. stderr: ${line}`)); } this.outputHandler(line, 'STDERR: '); resolve(); }); this.proc.on('line-stdout', (line) => { this.outputHandler(line); resolve(); }); await this.proc.start(0); // resolve after a timeout, even if no output was recorded setTimeout(resolve, LOGCAT_PROC_STARTUP_TIMEOUT); }); } async stopCapture(): Promise<void> { log.debug('Stopping logcat capture'); if (!this.proc?.isRunning) { log.debug('Logcat already stopped'); this.proc = null; return; } this.proc.removeAllListeners('exit'); await this.proc.stop(); this.proc = null; } getLogs(): LogEntry[] { const result: LogEntry[] = []; let recentLogIndex: number | null = null; for (const entry of this.logs.rentries()) { const [index, value] = entry; if (typeof index !== 'number' || !Array.isArray(value)) { continue; } const [message, timestamp] = value; if ( (this.logIndexSinceLastRequest && index > this.logIndexSinceLastRequest) || !this.logIndexSinceLastRequest ) { recentLogIndex = index; result.push(toLogEntry(message, timestamp)); } } if (_.isInteger(recentLogIndex)) { this.logIndexSinceLastRequest = recentLogIndex; } return result; } getAllLogs(): LogEntry[] { const result: LogEntry[] = []; for (const value of this.logs.rvalues()) { if (!Array.isArray(value)) { continue; } const [message, timestamp] = value; result.push(toLogEntry(message, timestamp)); } return result; } async clear(): Promise<void> { log.debug('Clearing logcat logs from device'); try { const args = [...this.adb.defaultArgs, 'logcat', '-c']; await exec(this.adb.path, args); } catch (err) { const execErr = err as ExecError; log.warn(`Failed to clear logcat logs: ${execErr.stderr || execErr.message}`); } } private outputHandler(logLine: string, prefix: string = ''): void { const timestamp = Date.now(); let recentIndex = -1; for (const key of this.logs.keys()) { recentIndex = key; break; } this.logs.set(++recentIndex, [logLine, timestamp]); if (this.listenerCount('output')) { this.emit('output', toLogEntry(logLine, timestamp)); } if (this.debug && (this.debugTrace || !TRACE_PATTERN.test(logLine))) { log.debug(prefix + logLine); } } } export default Logcat; // Private entities type LogFormat = (typeof SUPPORTED_FORMATS)[number]; function requireFormat(format: string): LogFormat { if (!SUPPORTED_FORMATS.includes(format as LogFormat)) { log.info(`The format value '${format}' is unknown. Supported values are: ${SUPPORTED_FORMATS}`); log.info(`Defaulting to '${DEFAULT_FORMAT}'`); return DEFAULT_FORMAT; } return format as LogFormat; } function toLogEntry(message: string, timestamp: number): LogEntry { return { timestamp, level: 'ALL', message, }; } function requireSpec(spec: string): string { const [tag, priority] = spec.split(':'); let resultTag = tag; if (!resultTag) { log.info(`The tag value in spec '${spec}' cannot be empty`); log.info(`Defaulting to '${DEFAULT_TAG}'`); resultTag = DEFAULT_TAG; } if (!priority) { log.info( `The priority value in spec '${spec}' is empty. Defaulting to Verbose (${DEFAULT_PRIORITY})`, ); return `${resultTag}:${DEFAULT_PRIORITY}`; } if (!SUPPORTED_PRIORITIES.some((p) => _.toLower(priority) === _.toLower(p))) { log.info( `The priority value in spec '${spec}' is unknown. Supported values are: ${SUPPORTED_PRIORITIES}`, ); log.info(`Defaulting to Verbose (${DEFAULT_PRIORITY})`); return `${resultTag}:${DEFAULT_PRIORITY}`; } return spec; } function formatFilterSpecs(filterSpecs: string | string[]): string[] { if (!_.isArray(filterSpecs)) { filterSpecs = [filterSpecs]; } return filterSpecs .filter((spec) => spec && _.isString(spec) && !spec.startsWith('-')) .map((spec) => (spec.includes(':') ? requireSpec(spec) : spec)); }