${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)