diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts index e790c82..7401cae 100644 --- a/example/wdio.conf.ts +++ b/example/wdio.conf.ts @@ -63,7 +63,7 @@ export const config: Options.Testrunner = { capabilities: [ { browserName: 'chrome', - browserVersion: '143.0.7499.193', // specify chromium browser version for testing + browserVersion: '144.0.7559.60', // specify chromium browser version for testing 'goog:chromeOptions': { args: [ '--headless', diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index 1773796..ad062d3 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -88,6 +88,19 @@ export class DevtoolsConsoleLogs extends Element { color: var(--vscode-foreground); opacity: 0.8; margin-right: 4px; + font-weight: 600; + } + + .log-prefix.source-test { + color: #4ec9b0; + } + + .log-prefix.source-terminal { + color: #ce9178; + } + + .log-prefix.source-browser { + color: #569cd6; } .log-content { @@ -198,6 +211,16 @@ export class DevtoolsConsoleLogs extends Element {
${this.logs.map((log: any) => { const icon = LOG_ICONS[log.type] || LOG_ICONS.log + const sourceLabel = + log.source === 'test' + ? '[TEST]' + : log.source === 'terminal' + ? '[WDIO]' + : log.source === 'browser' + ? '[BROWSER]' + : '' + const sourceClass = log.source ? `source-${log.source}` : '' + return html`
${log.timestamp @@ -207,8 +230,10 @@ export class DevtoolsConsoleLogs extends Element { : nothing}
${icon}
- ${log.source === 'test' - ? html`>>>` + ${sourceLabel + ? html`${sourceLabel}` : nothing} ${this.#formatArgs(log.args)}
diff --git a/packages/script/src/collectors/consoleLogs.ts b/packages/script/src/collectors/consoleLogs.ts index d8b6f64..5cedb05 100644 --- a/packages/script/src/collectors/consoleLogs.ts +++ b/packages/script/src/collectors/consoleLogs.ts @@ -5,7 +5,7 @@ export interface ConsoleLogs { type: 'log' | 'info' | 'warn' | 'error' args: any[] timestamp: number - source?: 'browser' | 'test' + source?: 'browser' | 'test' | 'terminal' } export class ConsoleLogCollector implements Collector { diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts index 0c4704b..1df1a45 100644 --- a/packages/service/src/constants.ts +++ b/packages/service/src/constants.ts @@ -5,6 +5,44 @@ export const PAGE_TRANSITION_COMMANDS: string[] = [ 'click' ] +/** + * Regular expression to strip ANSI escape codes from terminal output + */ +export const ANSI_REGEX = /\x1b\[[0-9;]*m/g + +/** + * Console method types for log capturing + */ +export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const + +/** + * Log level detection patterns with priority order (highest to lowest) + */ +export const LOG_LEVEL_PATTERNS: ReadonlyArray<{ + level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + pattern: RegExp +}> = [ + { level: 'trace', pattern: /\btrace\b/i }, + { level: 'debug', pattern: /\bdebug\b/i }, + { level: 'info', pattern: /\binfo\b/i }, + { level: 'warn', pattern: /\bwarn(ing)?\b/i }, + { level: 'error', pattern: /\berror\b/i } +] as const + +/** + * Visual indicators that suggest error-level logs + */ +export const ERROR_INDICATORS = ['✗', '✓', 'failed', 'failure'] as const + +/** + * Console log source types + */ +export const LOG_SOURCES = { + BROWSER: 'browser', + TEST: 'test', + TERMINAL: 'terminal' +} as const + export const DEFAULT_LAUNCH_CAPS: WebdriverIO.Capabilities = { browserName: 'chrome', 'goog:chromeOptions': { diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index 68a6b2c..704e57c 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -8,21 +8,74 @@ import { resolve } from 'import-meta-resolve' import { SevereServiceError } from 'webdriverio' import type { WebDriverCommands } from '@wdio/protocols' -import { PAGE_TRANSITION_COMMANDS } from './constants.js' -import { type CommandLog } from './types.js' -import { type TraceLog } from './types.js' +import { + PAGE_TRANSITION_COMMANDS, + ANSI_REGEX, + CONSOLE_METHODS, + LOG_LEVEL_PATTERNS, + ERROR_INDICATORS, + LOG_SOURCES +} from './constants.js' +import { type CommandLog, type TraceLog, type LogLevel } from './types.js' const log = logger('@wdio/devtools-service:SessionCapturer') +/** + * Generic helper to strip ANSI escape codes from text + */ +const stripAnsi = (text: string): string => text.replace(ANSI_REGEX, '') + +/** + * Generic helper to detect log level from text content + */ +const detectLogLevel = (text: string): LogLevel => { + const cleanText = stripAnsi(text).toLowerCase() + + // Check log level patterns in priority order + for (const { level, pattern } of LOG_LEVEL_PATTERNS) { + if (pattern.test(cleanText)) { + return level + } + } + + // Check for error indicators + if ( + ERROR_INDICATORS.some((indicator) => + cleanText.includes(indicator.toLowerCase()) + ) + ) { + return 'error' + } + + return 'log' +} + +/** + * Generic helper to create a console log entry + */ +const createLogEntry = ( + type: LogLevel, + args: any[], + source: (typeof LOG_SOURCES)[keyof typeof LOG_SOURCES] +): ConsoleLogs => ({ + timestamp: Date.now(), + type, + args, + source +}) + export class SessionCapturer { #ws: WebSocket | undefined #isInjected = false - #originalConsoleMethods: { - log: typeof console.log - info: typeof console.info - warn: typeof console.warn - error: typeof console.error + #originalConsoleMethods: Record< + (typeof CONSOLE_METHODS)[number], + typeof console.log + > + #originalProcessMethods: { + stdoutWrite: typeof process.stdout.write + stderrWrite: typeof process.stderr.write } + #isCapturingConsole = false commandsLog: CommandLog[] = [] sources = new Map() mutations: TraceMutation[] = [] @@ -55,7 +108,6 @@ export class SessionCapturer { ) } - // Store original console methods this.#originalConsoleMethods = { log: console.log, info: console.info, @@ -63,69 +115,100 @@ export class SessionCapturer { error: console.error } - // Patch console methods to capture test logs + this.#originalProcessMethods = { + stdoutWrite: process.stdout.write.bind(process.stdout), + stderrWrite: process.stderr.write.bind(process.stderr) + } + this.#patchConsole() + this.#patchProcessOutput() } #patchConsole() { - const consoleMethods = ['log', 'info', 'warn', 'error'] as const - - consoleMethods.forEach((method) => { + CONSOLE_METHODS.forEach((method) => { const originalMethod = this.#originalConsoleMethods[method] console[method] = (...args: any[]) => { - const logEntry: ConsoleLogs = { - timestamp: Date.now(), - type: method, - args: args.map((arg) => - typeof arg === 'object' && arg !== null - ? (() => { - try { - return JSON.stringify(arg) - } catch { - return String(arg) - } - })() - : String(arg) - ), - source: 'test' - } + const serializedArgs = args.map((arg) => + typeof arg === 'object' && arg !== null + ? (() => { + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } + })() + : String(arg) + ) + + const logEntry = createLogEntry( + method, + serializedArgs, + LOG_SOURCES.TEST + ) this.consoleLogs.push(logEntry) this.sendUpstream('consoleLogs', [logEntry]) - return originalMethod.apply(console, args) + + this.#isCapturingConsole = true + const result = originalMethod.apply(console, args) + this.#isCapturingConsole = false + return result } }) } + #patchProcessOutput() { + const captureOutput = (data: string | Uint8Array) => { + const text = typeof data === 'string' ? data : data.toString() + if (!text?.trim()) { + return + } + + text + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + const logEntry = createLogEntry( + detectLogLevel(line), + [stripAnsi(line)], + LOG_SOURCES.TERMINAL + ) + this.consoleLogs.push(logEntry) + this.sendUpstream('consoleLogs', [logEntry]) + }) + } + + const patchStream = ( + stream: NodeJS.WriteStream, + originalWrite: (...args: any[]) => boolean + ) => { + const self = this + stream.write = function (data: any, ...rest: any[]): boolean { + const result = originalWrite.call(stream, data, ...rest) + if (data && !self.#isCapturingConsole) { + captureOutput(data) + } + return result + } as any + } + + patchStream(process.stdout, this.#originalProcessMethods.stdoutWrite) + patchStream(process.stderr, this.#originalProcessMethods.stderrWrite) + } + #restoreConsole() { - console.log = this.#originalConsoleMethods.log - console.info = this.#originalConsoleMethods.info - console.warn = this.#originalConsoleMethods.warn - console.error = this.#originalConsoleMethods.error + CONSOLE_METHODS.forEach((method) => { + console[method] = this.#originalConsoleMethods[method] + }) } cleanup() { this.#restoreConsole() - if (this.#ws) { - this.#ws.close() - } } get isReportingUpstream() { return Boolean(this.#ws) && this.#ws?.readyState === WebSocket.OPEN } - /** - * after command hook - * - * Used to - * - capture command logs - * - capture trace data from the application under test - * - * @param {string} command command name - * @param {Array} args command arguments - * @param {object} result command result - * @param {Error} error command error - */ async afterCommand( browser: WebdriverIO.Browser, command: keyof WebDriverCommands, @@ -245,7 +328,7 @@ export class SessionCapturer { } if (Array.isArray(consoleLogs)) { const browserLogs = consoleLogs as ConsoleLogs[] - browserLogs.forEach((log) => (log.source = 'browser')) + browserLogs.forEach((log) => (log.source = LOG_SOURCES.BROWSER)) this.consoleLogs.push(...browserLogs) this.sendUpstream('consoleLogs', browserLogs) } diff --git a/packages/service/src/types.ts b/packages/service/src/types.ts index 02c99f9..084152c 100644 --- a/packages/service/src/types.ts +++ b/packages/service/src/types.ts @@ -40,6 +40,8 @@ export interface ExtendedCapabilities extends WebdriverIO.Capabilities { 'wdio:devtoolsOptions'?: ServiceOptions } +export type LogLevel = 'trace' | 'debug' | 'log' | 'info' | 'warn' | 'error' + export interface ServiceOptions { /** * port to launch the application on (default: random)