Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
29 changes: 27 additions & 2 deletions packages/app/src/components/workbench/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -198,6 +211,16 @@ export class DevtoolsConsoleLogs extends Element {
<div class="console-container">
${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`
<div class="log-entry log-type-${log.type || 'log'}">
${log.timestamp
Expand All @@ -207,8 +230,10 @@ export class DevtoolsConsoleLogs extends Element {
: nothing}
<div class="log-icon">${icon}</div>
<div class="log-content">
${log.source === 'test'
? html`<span class="log-prefix">>>></span>`
${sourceLabel
? html`<span class="log-prefix ${sourceClass}"
>${sourceLabel}</span
>`
: nothing}
<span class="log-message">${this.#formatArgs(log.args)}</span>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/script/src/collectors/consoleLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConsoleLogs> {
Expand Down
38 changes: 38 additions & 0 deletions packages/service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
183 changes: 133 additions & 50 deletions packages/service/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()
mutations: TraceMutation[] = []
Expand Down Expand Up @@ -55,77 +108,107 @@ export class SessionCapturer {
)
}

// Store original console methods
this.#originalConsoleMethods = {
log: console.log,
info: console.info,
warn: console.warn,
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,
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions packages/service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down