Skip to content
Merged
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
103 changes: 60 additions & 43 deletions packages/service/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ 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, '')
const stripAnsiCodes = (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()
const cleanText = stripAnsiCodes(text).toLowerCase()

// Check log level patterns in priority order
for (const { level, pattern } of LOG_LEVEL_PATTERNS) {
Expand All @@ -53,7 +53,7 @@ const detectLogLevel = (text: string): LogLevel => {
/**
* Generic helper to create a console log entry
*/
const createLogEntry = (
const createConsoleLogEntry = (
type: LogLevel,
args: any[],
source: (typeof LOG_SOURCES)[keyof typeof LOG_SOURCES]
Expand All @@ -66,7 +66,7 @@ const createLogEntry = (

export class SessionCapturer {
#ws: WebSocket | undefined
#isInjected = false
#isScriptInjected = false
#originalConsoleMethods: Record<
(typeof CONSOLE_METHODS)[number],
typeof console.log
Expand Down Expand Up @@ -121,14 +121,14 @@ export class SessionCapturer {
}

this.#patchConsole()
this.#patchProcessOutput()
this.#interceptProcessStreams()
}

#patchConsole() {
CONSOLE_METHODS.forEach((method) => {
const originalMethod = this.#originalConsoleMethods[method]
console[method] = (...args: any[]) => {
const serializedArgs = args.map((arg) =>
console[method] = (...consoleArgs: any[]) => {
const serializedArgs = consoleArgs.map((arg) =>
typeof arg === 'object' && arg !== null
? (() => {
try {
Expand All @@ -140,7 +140,7 @@ export class SessionCapturer {
: String(arg)
)

const logEntry = createLogEntry(
const logEntry = createConsoleLogEntry(
method,
serializedArgs,
LOG_SOURCES.TEST
Expand All @@ -149,50 +149,61 @@ export class SessionCapturer {
this.sendUpstream('consoleLogs', [logEntry])

this.#isCapturingConsole = true
const result = originalMethod.apply(console, args)
const result = originalMethod.apply(console, consoleArgs)
this.#isCapturingConsole = false
return result
}
})
}

#patchProcessOutput() {
const captureOutput = (data: string | Uint8Array) => {
const text = typeof data === 'string' ? data : data.toString()
if (!text?.trim()) {
#interceptProcessStreams() {
const captureTerminalOutput = (outputData: string | Uint8Array) => {
const outputText =
typeof outputData === 'string' ? outputData : outputData.toString()
if (!outputText?.trim()) {
return
}

text
outputText
.split('\n')
.filter((line) => line.trim())
.forEach((line) => {
const logEntry = createLogEntry(
const logEntry = createConsoleLogEntry(
detectLogLevel(line),
[stripAnsi(line)],
[stripAnsiCodes(line)],
LOG_SOURCES.TERMINAL
)
this.consoleLogs.push(logEntry)
this.sendUpstream('consoleLogs', [logEntry])
})
}

const patchStream = (
const interceptStreamWrite = (
stream: NodeJS.WriteStream,
originalWrite: (...args: any[]) => boolean
originalWriteMethod: (...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)
const capturer = this
stream.write = function (chunk: any, ...additionalArgs: any[]): boolean {
const writeResult = originalWriteMethod.call(
stream,
chunk,
...additionalArgs
)
if (chunk && !capturer.#isCapturingConsole) {
captureTerminalOutput(chunk)
}
return result
return writeResult
} as any
}

patchStream(process.stdout, this.#originalProcessMethods.stdoutWrite)
patchStream(process.stderr, this.#originalProcessMethods.stderrWrite)
interceptStreamWrite(
process.stdout,
this.#originalProcessMethods.stdoutWrite
)
interceptStreamWrite(
process.stderr,
this.#originalProcessMethods.stderrWrite
)
}

#restoreConsole() {
Expand All @@ -217,7 +228,7 @@ export class SessionCapturer {
error: Error | undefined,
callSource?: string
) {
const sourceFile =
const sourceFileLocation =
parse(new Error(''))
.filter((frame) => Boolean(frame.getFileName()))
.map((frame) =>
Expand All @@ -235,34 +246,40 @@ export class SessionCapturer {
!fileName.includes('/dist/')
)
.shift() || ''
const absPath = sourceFile.startsWith('file://')
? url.fileURLToPath(sourceFile)
: sourceFile
const sourceFilePath = absPath.split(':')[0]
const fileExist = await fs.access(sourceFilePath).then(
const absolutePath = sourceFileLocation.startsWith('file://')
? url.fileURLToPath(sourceFileLocation)
: sourceFileLocation
const sourceFilePath = absolutePath.split(':')[0]
const doesFileExist = await fs.access(sourceFilePath).then(
() => true,
() => false
)
if (sourceFile && !this.sources.has(sourceFile) && fileExist) {
if (
sourceFileLocation &&
!this.sources.has(sourceFileLocation) &&
doesFileExist
) {
const sourceCode = await fs.readFile(sourceFilePath, 'utf-8')
this.sources.set(sourceFilePath, sourceCode.toString())
this.sendUpstream('sources', { [sourceFilePath]: sourceCode.toString() })
}
const newCommand: CommandLog = {
const commandLogEntry: CommandLog = {
command,
args,
result,
error,
timestamp: Date.now(),
callSource: callSource ?? absPath
callSource: callSource ?? absolutePath
}
try {
newCommand.screenshot = await browser.takeScreenshot()
} catch (shotErr) {
log.warn(`failed to capture screenshot: ${(shotErr as Error).message}`)
commandLogEntry.screenshot = await browser.takeScreenshot()
} catch (screenshotError) {
log.warn(
`failed to capture screenshot: ${(screenshotError as Error).message}`
)
}
this.commandsLog.push(newCommand)
this.sendUpstream('commands', [newCommand])
this.commandsLog.push(commandLogEntry)
this.sendUpstream('commands', [commandLogEntry])

/**
* capture trace and write to file on commands that could trigger a page transition
Expand All @@ -273,7 +290,7 @@ export class SessionCapturer {
}

async injectScript(browser: WebdriverIO.Browser) {
if (this.#isInjected) {
if (this.#isScriptInjected) {
log.info('Script already injected, skipping')
return
}
Expand All @@ -284,7 +301,7 @@ export class SessionCapturer {
)
}

this.#isInjected = true
this.#isScriptInjected = true
log.info('Injecting devtools script...')
const script = await resolve('@wdio/devtools-script', import.meta.url)
const source = (await fs.readFile(url.fileURLToPath(script))).toString()
Expand All @@ -297,7 +314,7 @@ export class SessionCapturer {
}

async #captureTrace(browser: WebdriverIO.Browser) {
if (!this.#isInjected) {
if (!this.#isScriptInjected) {
log.warn('Script not injected, skipping trace capture')
return
}
Expand Down
39 changes: 31 additions & 8 deletions packages/service/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { SessionCapturer } from '../src/session.js'
import { WebSocket } from 'ws'
import fs from 'node:fs/promises'
import { LOG_SOURCES } from '../src/constants.js'

vi.mock('ws')
vi.mock('node:fs/promises')
Expand Down Expand Up @@ -211,7 +212,7 @@ describe('SessionCapturer', () => {
const logEntry = capturer.consoleLogs[initialLength]
expect(logEntry.type).toBe('log')
expect(logEntry.args).toEqual(['Log message'])
expect(logEntry.source).toBe('test')
expect(logEntry.source).toBe(LOG_SOURCES.TEST)
expect(logEntry.timestamp).toBeDefined()

// Validate console.info capture with multiple arguments
Expand All @@ -222,19 +223,19 @@ describe('SessionCapturer', () => {
'with multiple',
'arguments'
])
expect(infoEntry.source).toBe('test')
expect(infoEntry.source).toBe(LOG_SOURCES.TEST)

// Validate console.warn capture
const warnEntry = capturer.consoleLogs[initialLength + 2]
expect(warnEntry.type).toBe('warn')
expect(warnEntry.args).toEqual(['Warning message'])
expect(warnEntry.source).toBe('test')
expect(warnEntry.source).toBe(LOG_SOURCES.TEST)

// Validate console.error capture
const errorEntry = capturer.consoleLogs[initialLength + 3]
expect(errorEntry.type).toBe('error')
expect(errorEntry.args).toEqual(['Error message'])
expect(errorEntry.source).toBe('test')
expect(errorEntry.source).toBe(LOG_SOURCES.TEST)
})

/**
Expand Down Expand Up @@ -317,7 +318,7 @@ describe('SessionCapturer', () => {
expect(sentData.scope).toBe('consoleLogs')
expect(sentData.data).toHaveLength(1)
expect(sentData.data[0].args).toEqual(['Test message'])
expect(sentData.data[0].source).toBe('test')
expect(sentData.data[0].source).toBe(LOG_SOURCES.TEST)

capturer.cleanup()

Expand Down Expand Up @@ -356,11 +357,33 @@ describe('SessionCapturer', () => {
)

const browserLogs = capturer.consoleLogs.filter(
(log) => log.source === 'browser'
(log) => log.source === LOG_SOURCES.BROWSER
)
expect(browserLogs).toHaveLength(2)
expect(browserLogs[0].source).toBe('browser')
expect(browserLogs[1].source).toBe('browser')
expect(browserLogs[0].source).toBe(LOG_SOURCES.BROWSER)
expect(browserLogs[1].source).toBe(LOG_SOURCES.BROWSER)
})

/**
* Test: Terminal logs are captured with proper log level detection
*/
it('should capture terminal logs with correct log levels and source', () => {
const capturer = new SessionCapturer()
const initialLength = capturer.consoleLogs.length

process.stdout.write('INFO: Test message\n')
process.stderr.write('ERROR: Test error\n')

const terminalLogs = capturer.consoleLogs.slice(initialLength)
expect(terminalLogs.length).toBeGreaterThanOrEqual(2)

const infoLog = terminalLogs.find((log) => log.type === 'info')
const errorLog = terminalLogs.find((log) => log.type === 'error')

expect(infoLog?.source).toBe(LOG_SOURCES.TERMINAL)
expect(errorLog?.source).toBe(LOG_SOURCES.TERMINAL)

capturer.cleanup()
})
})

Expand Down