Skip to content

Commit ed32a4a

Browse files
committed
Console log capture enhancement
1 parent e4b28ce commit ed32a4a

7 files changed

Lines changed: 155 additions & 55 deletions

File tree

example/wdio.conf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
6363
capabilities: [
6464
{
6565
browserName: 'chrome',
66-
browserVersion: '143.0.7499.193', // specify chromium browser version for testing
66+
browserVersion: '144.0.7559.60', // specify chromium browser version for testing
6767
'goog:chromeOptions': {
6868
args: [
6969
'--headless',

packages/app/src/components/workbench/console.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ export class DevtoolsConsoleLogs extends Element {
8888
color: var(--vscode-foreground);
8989
opacity: 0.8;
9090
margin-right: 4px;
91+
font-weight: 600;
92+
}
93+
94+
.log-prefix.source-test {
95+
color: #4ec9b0;
96+
}
97+
98+
.log-prefix.source-terminal {
99+
color: #ce9178;
100+
}
101+
102+
.log-prefix.source-browser {
103+
color: #569cd6;
91104
}
92105
93106
.log-content {
@@ -198,6 +211,15 @@ export class DevtoolsConsoleLogs extends Element {
198211
<div class="console-container">
199212
${this.logs.map((log: any) => {
200213
const icon = LOG_ICONS[log.type] || LOG_ICONS.log
214+
const sourceLabel = log.source === 'test'
215+
? '[TEST]'
216+
: log.source === 'terminal'
217+
? '[WDIO]'
218+
: log.source === 'browser'
219+
? '[BROWSER]'
220+
: ''
221+
const sourceClass = log.source ? `source-${log.source}` : ''
222+
201223
return html`
202224
<div class="log-entry log-type-${log.type || 'log'}">
203225
${log.timestamp
@@ -207,8 +229,8 @@ export class DevtoolsConsoleLogs extends Element {
207229
: nothing}
208230
<div class="log-icon">${icon}</div>
209231
<div class="log-content">
210-
${log.source === 'test'
211-
? html`<span class="log-prefix">>>></span>`
232+
${sourceLabel
233+
? html`<span class="log-prefix ${sourceClass}">${sourceLabel}</span>`
212234
: nothing}
213235
<span class="log-message">${this.#formatArgs(log.args)}</span>
214236
</div>

packages/script/src/collectors/consoleLogs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export interface ConsoleLogs {
55
type: 'log' | 'info' | 'warn' | 'error'
66
args: any[]
77
timestamp: number
8-
source?: 'browser' | 'test'
8+
source?: 'browser' | 'test' | 'terminal'
99
}
1010

1111
export class ConsoleLogCollector implements Collector<ConsoleLogs> {

packages/service/src/constants.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@ export const PAGE_TRANSITION_COMMANDS: string[] = [
55
'click'
66
]
77

8+
/**
9+
* Regular expression to strip ANSI escape codes from terminal output
10+
*/
11+
export const ANSI_REGEX = /\x1b\[[0-9;]*m/g
12+
13+
/**
14+
* Console method types for log capturing
15+
*/
16+
export const CONSOLE_METHODS = ['log', 'info', 'warn', 'error'] as const
17+
18+
/**
19+
* Log level detection patterns with priority order (highest to lowest)
20+
*/
21+
export const LOG_LEVEL_PATTERNS: ReadonlyArray<{ level: 'trace' | 'debug' | 'info' | 'warn' | 'error'; pattern: RegExp }> = [
22+
{ level: 'trace', pattern: /\btrace\b/i },
23+
{ level: 'debug', pattern: /\bdebug\b/i },
24+
{ level: 'info', pattern: /\binfo\b/i },
25+
{ level: 'warn', pattern: /\bwarn(ing)?\b/i },
26+
{ level: 'error', pattern: /\berror\b/i }
27+
] as const
28+
29+
/**
30+
* Visual indicators that suggest error-level logs
31+
*/
32+
export const ERROR_INDICATORS = ['✗', '✓', 'failed', 'failure'] as const
33+
34+
/**
35+
* Console log source types
36+
*/
37+
export const LOG_SOURCES = {
38+
BROWSER: 'browser',
39+
TEST: 'test',
40+
TERMINAL: 'terminal'
41+
} as const
42+
843
export const DEFAULT_LAUNCH_CAPS: WebdriverIO.Capabilities = {
944
browserName: 'chrome',
1045
'goog:chromeOptions': {

packages/service/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export default class DevToolsHookService implements Services.ServiceInstance {
359359
await fs.writeFile(traceFilePath, JSON.stringify(traceLog))
360360
log.info(`DevTools trace saved to ${traceFilePath}`)
361361

362-
// Clean up console patching
362+
// Clean up console patching (but keep process output patched for final reporter output)
363363
this.#sessionCapturer.cleanup()
364364
}
365365

packages/service/src/session.ts

Lines changed: 91 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,54 @@ import { resolve } from 'import-meta-resolve'
88
import { SevereServiceError } from 'webdriverio'
99
import type { WebDriverCommands } from '@wdio/protocols'
1010

11-
import { PAGE_TRANSITION_COMMANDS } from './constants.js'
12-
import { type CommandLog } from './types.js'
13-
import { type TraceLog } from './types.js'
11+
import { PAGE_TRANSITION_COMMANDS, ANSI_REGEX, CONSOLE_METHODS, LOG_LEVEL_PATTERNS, ERROR_INDICATORS, LOG_SOURCES } from './constants.js'
12+
import { type CommandLog, type TraceLog, type LogLevel } from './types.js'
1413

1514
const log = logger('@wdio/devtools-service:SessionCapturer')
1615

16+
/**
17+
* Generic helper to strip ANSI escape codes from text
18+
*/
19+
const stripAnsi = (text: string): string => text.replace(ANSI_REGEX, '')
20+
21+
/**
22+
* Generic helper to detect log level from text content
23+
*/
24+
const detectLogLevel = (text: string): LogLevel => {
25+
const cleanText = stripAnsi(text).toLowerCase()
26+
27+
// Check log level patterns in priority order
28+
for (const { level, pattern } of LOG_LEVEL_PATTERNS) {
29+
if (pattern.test(cleanText)) return level
30+
}
31+
32+
// Check for error indicators
33+
if (ERROR_INDICATORS.some(indicator => cleanText.includes(indicator.toLowerCase()))) {
34+
return 'error'
35+
}
36+
37+
return 'log'
38+
}
39+
40+
/**
41+
* Generic helper to create a console log entry
42+
*/
43+
const createLogEntry = (type: LogLevel, args: any[], source: typeof LOG_SOURCES[keyof typeof LOG_SOURCES]): ConsoleLogs => ({
44+
timestamp: Date.now(),
45+
type,
46+
args,
47+
source
48+
})
49+
1750
export class SessionCapturer {
1851
#ws: WebSocket | undefined
1952
#isInjected = false
20-
#originalConsoleMethods: {
21-
log: typeof console.log
22-
info: typeof console.info
23-
warn: typeof console.warn
24-
error: typeof console.error
53+
#originalConsoleMethods: Record<typeof CONSOLE_METHODS[number], typeof console.log>
54+
#originalProcessMethods: {
55+
stdoutWrite: typeof process.stdout.write
56+
stderrWrite: typeof process.stderr.write
2557
}
58+
#isCapturingConsole = false
2659
commandsLog: CommandLog[] = []
2760
sources = new Map<string, string>()
2861
mutations: TraceMutation[] = []
@@ -55,77 +88,85 @@ export class SessionCapturer {
5588
)
5689
}
5790

58-
// Store original console methods
5991
this.#originalConsoleMethods = {
6092
log: console.log,
6193
info: console.info,
6294
warn: console.warn,
6395
error: console.error
6496
}
6597

66-
// Patch console methods to capture test logs
98+
this.#originalProcessMethods = {
99+
stdoutWrite: process.stdout.write.bind(process.stdout),
100+
stderrWrite: process.stderr.write.bind(process.stderr)
101+
}
102+
67103
this.#patchConsole()
104+
this.#patchProcessOutput()
68105
}
69106

70107
#patchConsole() {
71-
const consoleMethods = ['log', 'info', 'warn', 'error'] as const
72-
73-
consoleMethods.forEach((method) => {
108+
CONSOLE_METHODS.forEach((method) => {
74109
const originalMethod = this.#originalConsoleMethods[method]
75110
console[method] = (...args: any[]) => {
76-
const logEntry: ConsoleLogs = {
77-
timestamp: Date.now(),
78-
type: method,
79-
args: args.map((arg) =>
80-
typeof arg === 'object' && arg !== null
81-
? (() => {
82-
try {
83-
return JSON.stringify(arg)
84-
} catch {
85-
return String(arg)
86-
}
87-
})()
88-
: String(arg)
89-
),
90-
source: 'test'
91-
}
111+
const serializedArgs = args.map(arg =>
112+
typeof arg === 'object' && arg !== null
113+
? (() => { try { return JSON.stringify(arg) } catch { return String(arg) } })()
114+
: String(arg)
115+
)
116+
117+
const logEntry = createLogEntry(method, serializedArgs, LOG_SOURCES.TEST)
92118
this.consoleLogs.push(logEntry)
93119
this.sendUpstream('consoleLogs', [logEntry])
94-
return originalMethod.apply(console, args)
120+
121+
this.#isCapturingConsole = true
122+
const result = originalMethod.apply(console, args)
123+
this.#isCapturingConsole = false
124+
return result
95125
}
96126
})
97127
}
98128

129+
#patchProcessOutput() {
130+
const captureOutput = (data: string | Uint8Array) => {
131+
const text = typeof data === 'string' ? data : data.toString()
132+
if (!text?.trim()) return
133+
134+
text.split('\n')
135+
.filter(line => line.trim())
136+
.forEach(line => {
137+
const logEntry = createLogEntry(detectLogLevel(line), [stripAnsi(line)], LOG_SOURCES.TERMINAL)
138+
this.consoleLogs.push(logEntry)
139+
this.sendUpstream('consoleLogs', [logEntry])
140+
})
141+
}
142+
143+
const patchStream = (stream: NodeJS.WriteStream, originalWrite: (...args: any[]) => boolean) => {
144+
const self = this
145+
stream.write = function(data: any, ...rest: any[]): boolean {
146+
const result = originalWrite.call(stream, data, ...rest)
147+
if (data && !self.#isCapturingConsole) captureOutput(data)
148+
return result
149+
} as any
150+
}
151+
152+
patchStream(process.stdout, this.#originalProcessMethods.stdoutWrite)
153+
patchStream(process.stderr, this.#originalProcessMethods.stderrWrite)
154+
}
155+
99156
#restoreConsole() {
100-
console.log = this.#originalConsoleMethods.log
101-
console.info = this.#originalConsoleMethods.info
102-
console.warn = this.#originalConsoleMethods.warn
103-
console.error = this.#originalConsoleMethods.error
157+
CONSOLE_METHODS.forEach(method => {
158+
console[method] = this.#originalConsoleMethods[method]
159+
})
104160
}
105161

106162
cleanup() {
107163
this.#restoreConsole()
108-
if (this.#ws) {
109-
this.#ws.close()
110-
}
111164
}
112165

113166
get isReportingUpstream() {
114167
return Boolean(this.#ws) && this.#ws?.readyState === WebSocket.OPEN
115168
}
116169

117-
/**
118-
* after command hook
119-
*
120-
* Used to
121-
* - capture command logs
122-
* - capture trace data from the application under test
123-
*
124-
* @param {string} command command name
125-
* @param {Array} args command arguments
126-
* @param {object} result command result
127-
* @param {Error} error command error
128-
*/
129170
async afterCommand(
130171
browser: WebdriverIO.Browser,
131172
command: keyof WebDriverCommands,
@@ -245,7 +286,7 @@ export class SessionCapturer {
245286
}
246287
if (Array.isArray(consoleLogs)) {
247288
const browserLogs = consoleLogs as ConsoleLogs[]
248-
browserLogs.forEach((log) => (log.source = 'browser'))
289+
browserLogs.forEach((log) => (log.source = LOG_SOURCES.BROWSER))
249290
this.consoleLogs.push(...browserLogs)
250291
this.sendUpstream('consoleLogs', browserLogs)
251292
}

packages/service/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface ExtendedCapabilities extends WebdriverIO.Capabilities {
4040
'wdio:devtoolsOptions'?: ServiceOptions
4141
}
4242

43+
export type LogLevel = 'trace' | 'debug' | 'log' | 'info' | 'warn' | 'error'
44+
4345
export interface ServiceOptions {
4446
/**
4547
* port to launch the application on (default: random)

0 commit comments

Comments
 (0)