From d31bfaddea494c83f1646d487860192ad377c058 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 20 Jan 2026 16:41:28 +0530 Subject: [PATCH 1/2] Console log capture enhancement --- example/wdio.conf.ts | 2 +- .../app/src/components/workbench/console.ts | 26 +++- packages/script/src/collectors/consoleLogs.ts | 2 +- packages/service/src/constants.ts | 35 +++++ packages/service/src/index.ts | 2 +- packages/service/src/session.ts | 141 +++++++++++------- packages/service/src/types.ts | 2 + 7 files changed, 155 insertions(+), 55 deletions(-) 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..d118a12 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,15 @@ 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 +229,8 @@ 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..929d529 100644 --- a/packages/service/src/constants.ts +++ b/packages/service/src/constants.ts @@ -5,6 +5,41 @@ 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/index.ts b/packages/service/src/index.ts index c25a5b2..e3c6f7f 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -359,7 +359,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { await fs.writeFile(traceFilePath, JSON.stringify(traceLog)) log.info(`DevTools trace saved to ${traceFilePath}`) - // Clean up console patching + // Clean up console patching (but keep process output patched for final reporter output) this.#sessionCapturer.cleanup() } diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index 68a6b2c..de24f55 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -8,21 +8,54 @@ 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 + #originalProcessMethods: { + stdoutWrite: typeof process.stdout.write + stderrWrite: typeof process.stderr.write } + #isCapturingConsole = false commandsLog: CommandLog[] = [] sources = new Map() mutations: TraceMutation[] = [] @@ -55,7 +88,6 @@ export class SessionCapturer { ) } - // Store original console methods this.#originalConsoleMethods = { log: console.log, info: console.info, @@ -63,69 +95,78 @@ 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 +286,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) From 20cdde7eb04f5fae1d25a056f673ecf509efb7f4 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 20 Jan 2026 16:44:34 +0530 Subject: [PATCH 2/2] Eslint fix --- .../app/src/components/workbench/console.ts | 19 +++-- packages/service/src/constants.ts | 5 +- packages/service/src/index.ts | 2 +- packages/service/src/session.ts | 76 ++++++++++++++----- 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/workbench/console.ts b/packages/app/src/components/workbench/console.ts index d118a12..ad062d3 100644 --- a/packages/app/src/components/workbench/console.ts +++ b/packages/app/src/components/workbench/console.ts @@ -211,13 +211,14 @@ 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 sourceLabel = + log.source === 'test' + ? '[TEST]' + : log.source === 'terminal' + ? '[WDIO]' + : log.source === 'browser' + ? '[BROWSER]' + : '' const sourceClass = log.source ? `source-${log.source}` : '' return html` @@ -230,7 +231,9 @@ export class DevtoolsConsoleLogs extends Element {
${icon}
${sourceLabel - ? html`${sourceLabel}` + ? html`${sourceLabel}` : nothing} ${this.#formatArgs(log.args)}
diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts index 929d529..1df1a45 100644 --- a/packages/service/src/constants.ts +++ b/packages/service/src/constants.ts @@ -18,7 +18,10 @@ 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 }> = [ +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 }, diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index e3c6f7f..c25a5b2 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -359,7 +359,7 @@ export default class DevToolsHookService implements Services.ServiceInstance { await fs.writeFile(traceFilePath, JSON.stringify(traceLog)) log.info(`DevTools trace saved to ${traceFilePath}`) - // Clean up console patching (but keep process output patched for final reporter output) + // Clean up console patching this.#sessionCapturer.cleanup() } diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index de24f55..704e57c 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -8,7 +8,14 @@ import { resolve } from 'import-meta-resolve' import { SevereServiceError } from 'webdriverio' import type { WebDriverCommands } from '@wdio/protocols' -import { PAGE_TRANSITION_COMMANDS, ANSI_REGEX, CONSOLE_METHODS, LOG_LEVEL_PATTERNS, ERROR_INDICATORS, LOG_SOURCES } from './constants.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') @@ -26,11 +33,17 @@ const detectLogLevel = (text: string): LogLevel => { // Check log level patterns in priority order for (const { level, pattern } of LOG_LEVEL_PATTERNS) { - if (pattern.test(cleanText)) return level + if (pattern.test(cleanText)) { + return level + } } // Check for error indicators - if (ERROR_INDICATORS.some(indicator => cleanText.includes(indicator.toLowerCase()))) { + if ( + ERROR_INDICATORS.some((indicator) => + cleanText.includes(indicator.toLowerCase()) + ) + ) { return 'error' } @@ -40,7 +53,11 @@ const detectLogLevel = (text: string): LogLevel => { /** * Generic helper to create a console log entry */ -const createLogEntry = (type: LogLevel, args: any[], source: typeof LOG_SOURCES[keyof typeof LOG_SOURCES]): ConsoleLogs => ({ +const createLogEntry = ( + type: LogLevel, + args: any[], + source: (typeof LOG_SOURCES)[keyof typeof LOG_SOURCES] +): ConsoleLogs => ({ timestamp: Date.now(), type, args, @@ -50,7 +67,10 @@ const createLogEntry = (type: LogLevel, args: any[], source: typeof LOG_SOURCES[ export class SessionCapturer { #ws: WebSocket | undefined #isInjected = false - #originalConsoleMethods: Record + #originalConsoleMethods: Record< + (typeof CONSOLE_METHODS)[number], + typeof console.log + > #originalProcessMethods: { stdoutWrite: typeof process.stdout.write stderrWrite: typeof process.stderr.write @@ -108,13 +128,23 @@ export class SessionCapturer { CONSOLE_METHODS.forEach((method) => { const originalMethod = this.#originalConsoleMethods[method] console[method] = (...args: any[]) => { - const serializedArgs = args.map(arg => + const serializedArgs = args.map((arg) => typeof arg === 'object' && arg !== null - ? (() => { try { return JSON.stringify(arg) } catch { return String(arg) } })() + ? (() => { + try { + return JSON.stringify(arg) + } catch { + return String(arg) + } + })() : String(arg) ) - const logEntry = createLogEntry(method, serializedArgs, LOG_SOURCES.TEST) + const logEntry = createLogEntry( + method, + serializedArgs, + LOG_SOURCES.TEST + ) this.consoleLogs.push(logEntry) this.sendUpstream('consoleLogs', [logEntry]) @@ -129,22 +159,34 @@ export class SessionCapturer { #patchProcessOutput() { const captureOutput = (data: string | Uint8Array) => { const text = typeof data === 'string' ? data : data.toString() - if (!text?.trim()) return + if (!text?.trim()) { + return + } - text.split('\n') - .filter(line => line.trim()) - .forEach(line => { - const logEntry = createLogEntry(detectLogLevel(line), [stripAnsi(line)], LOG_SOURCES.TERMINAL) + 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 patchStream = ( + stream: NodeJS.WriteStream, + originalWrite: (...args: any[]) => boolean + ) => { const self = this - stream.write = function(data: any, ...rest: any[]): boolean { + stream.write = function (data: any, ...rest: any[]): boolean { const result = originalWrite.call(stream, data, ...rest) - if (data && !self.#isCapturingConsole) captureOutput(data) + if (data && !self.#isCapturingConsole) { + captureOutput(data) + } return result } as any } @@ -154,7 +196,7 @@ export class SessionCapturer { } #restoreConsole() { - CONSOLE_METHODS.forEach(method => { + CONSOLE_METHODS.forEach((method) => { console[method] = this.#originalConsoleMethods[method] }) }