Skip to content

Commit 87ba6ac

Browse files
authored
feat: add the ability to write log to file (#41)
## Proposed changes Add the ability to output logs to a file, mainly for the purpose of analyzing logs output to OutputChannel when the screen cannot be referenced, such as E2E testing in CI. If a directory path is set in the environment variable `VSCODE_WDIO_TRACE_LOG_PATH`, a file `vscode-webdriverio.log` will be output to that directory, and all log messages will be output to that file. This will contain all output regardless of the log level setting. ## Types of changes [//]: # 'What types of changes does your code introduce to WebdriverIO?' [//]: # '_Put an `x` in the boxes that apply_' - [ ] Polish (an improvement for an existing feature) - [ ] Bugfix (non-breaking change which fixes an issue) - [X] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update (improvements to the project's docs) - [ ] Internal updates (everything related to internal scripts, governance documentation and CI files) ## Checklist [//]: # "_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._" - [X] I have read the [CONTRIBUTING](https://github.com/webdriverio/vscode-webdriverio/blob/main/CONTRIBUTION.md) doc - [X] I have added tests that prove my fix is effective or that my feature works - [X] I have added the necessary documentation (if appropriate) - [ ] I have added proper type definitions for new commands (if appropriate) ## Further comments [//]: # 'If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc...' ### Reviewers: @webdriverio/project-committers
1 parent d9dc7d3 commit 87ba6ac

5 files changed

Lines changed: 370 additions & 9 deletions

File tree

CONTRIBUTION.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ Like in many Open Source projects we ask you to sign a **CLA** which is a Contri
9494

9595
The WebdriverIO maintainer will review your pull request as soon as possible. They will then either approve and merge your changes, request modifications or close with an explanation.
9696

97+
### Analyze the output of the `vscode.OutputChannel`
98+
99+
If you would to read or save the log message of vscode.OutputChannel, you can set the directory path of the log file as environment variable `VSCODE_WDIO_TRACE_LOG_PATH`.
100+
Then You will then find a file called `vscode-webdriverio.log`, which contains log messages for all levels.
101+
102+
e.g.
103+
104+
```bash
105+
$ VSCODE_WDIO_TRACE_LOG_PATH=/path/to/log code .
106+
107+
# Execute some test on the vscode with our extension.
108+
109+
$ ls /path/to/log
110+
vscode-webdriverio.log
111+
112+
$ cat /path/to/log/vscode-webdriverio.log
113+
[01-15 15:03:40+00:00] [INFO] WebdriverIO Runner extension is now active
114+
[01-15 15:03:40+00:00] [DEBUG] Target workspace path: D:\a\vscode-webdriverio\vscode-webdriverio\samples\e2e\mocha
115+
[01-15 15:03:40+00:00] [DEBUG] Detecting the configuration file for WebdriverIO...: **/wdio.conf.{ts,js}
116+
```
117+
97118
### Package structure
98119

99120
This Extension consists of several packages. Eventually, these packages will be bundled with `esbuild` and published as an Extension of VSCode.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
4+
import type * as vscode from 'vscode'
5+
6+
const LOG_FILE_NAME = 'vscode-webdriverio.log'
7+
8+
export class FileLogger implements vscode.Disposable {
9+
private _writeStream: fs.WriteStream
10+
private _isDisposed = false
11+
private _isWritable = false
12+
13+
constructor(logFilePath: string) {
14+
try {
15+
const absLogFilePath = path.normalize(
16+
path.isAbsolute(logFilePath)
17+
? path.join(logFilePath, LOG_FILE_NAME)
18+
: path.join(process.cwd(), logFilePath, LOG_FILE_NAME)
19+
)
20+
21+
// Ensure directory exists
22+
const logDir = path.dirname(absLogFilePath)
23+
if (!fs.existsSync(logDir)) {
24+
fs.mkdirSync(logDir, { recursive: true })
25+
}
26+
27+
// Create write stream
28+
this._writeStream = fs.createWriteStream(absLogFilePath, {
29+
flags: 'a', // append mode
30+
encoding: 'utf8',
31+
})
32+
33+
this._isWritable = true
34+
35+
// Handle stream errors
36+
this._writeStream.on('error', () => {
37+
this.dispose()
38+
this._isWritable = false
39+
})
40+
} catch (error) {
41+
throw new Error(
42+
`Failed to initialize FileLogger: ${error instanceof Error ? error.message : String(error)}`
43+
)
44+
}
45+
}
46+
47+
/**
48+
* Get writable status
49+
*/
50+
public get isWritable(): boolean {
51+
return this._isWritable && !this._isDisposed
52+
}
53+
54+
/**
55+
* Write log message to file
56+
* @param message - Log message to write
57+
*/
58+
public write(message: string): void {
59+
if (!this.isWritable) {
60+
return
61+
}
62+
63+
try {
64+
this._writeStream.write(`${message}\n`)
65+
} catch (error) {
66+
this._isWritable = false
67+
this.dispose()
68+
throw new Error(`Failed to write log: ${error instanceof Error ? error.message : String(error)}`)
69+
}
70+
}
71+
72+
/**
73+
* Dispose the file logger and close the write stream
74+
*/
75+
public dispose(): void {
76+
if (this._isDisposed) {
77+
return
78+
}
79+
80+
try {
81+
this._writeStream.end()
82+
} catch {
83+
// pass
84+
} finally {
85+
this._isDisposed = true
86+
}
87+
}
88+
}

packages/vscode-wdio-logger/src/logger.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { EXTENSION_ID, LOG_LEVEL } from '@vscode-wdio/constants'
22
import * as vscode from 'vscode'
33

4+
import { FileLogger } from './fileLogger.js'
45
import type { LoggerInterface, WdioLogLevel } from '@vscode-wdio/types'
56

67
export const LOG_LEVEL_NAMES: Record<LOG_LEVEL, string> = {
@@ -12,17 +13,27 @@ export const LOG_LEVEL_NAMES: Record<LOG_LEVEL, string> = {
1213
[LOG_LEVEL.SILENT]: 'SILENT ',
1314
} as const
1415

15-
export class VscodeWdioLogger implements LoggerInterface {
16+
export class VscodeWdioLogger implements LoggerInterface, vscode.Disposable {
1617
private _timezoneString: string | undefined
1718
private _disposables: vscode.Disposable[] = []
1819
private _logLevel: LOG_LEVEL
20+
private _fileLogger: FileLogger | undefined
1921

2022
constructor(
2123
logLevel?: WdioLogLevel,
2224
private _outputChannel = vscode.window.createOutputChannel('WebdriverIO')
2325
) {
2426
this._logLevel = this.updateLogLevel(logLevel)
2527

28+
if (process.env.VSCODE_WDIO_TRACE_LOG_PATH) {
29+
try {
30+
this._fileLogger = new FileLogger(process.env.VSCODE_WDIO_TRACE_LOG_PATH)
31+
this._disposables.push(this._fileLogger)
32+
} catch (error) {
33+
this.error(error instanceof Error ? error.message : String(error))
34+
}
35+
}
36+
2637
_outputChannel.show(true)
2738
this._disposables.push(_outputChannel)
2839

@@ -61,15 +72,24 @@ export class VscodeWdioLogger implements LoggerInterface {
6172
}
6273

6374
private log(level: LOG_LEVEL, message: unknown): void {
64-
if (level < this._logLevel) {
65-
return
66-
}
67-
6875
const timestamp = `[${this.getDateTime()}]`
6976
const serializedMsg = typeof message !== 'string' ? JSON.stringify(message) : message
7077
const levelText = `[${LOG_LEVEL_NAMES[level]}] `.substring(0, 7)
7178
const logMessage = `${timestamp} ${levelText} ${serializedMsg}`
72-
this._outputChannel.appendLine(logMessage)
79+
80+
if (level >= this._logLevel) {
81+
this._outputChannel.appendLine(logMessage)
82+
}
83+
84+
if (this._fileLogger) {
85+
try {
86+
this._fileLogger.write(logMessage)
87+
} catch (error) {
88+
this._fileLogger = undefined
89+
console.log('====')
90+
this.error(error instanceof Error ? error.message : String(error))
91+
}
92+
}
7393
}
7494

7595
public trace(message: unknown): void {
@@ -114,4 +134,10 @@ export class VscodeWdioLogger implements LoggerInterface {
114134

115135
this._timezoneString = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMins).padStart(2, '0')}`
116136
}
137+
138+
dispose() {
139+
for (const disposable of this._disposables) {
140+
disposable.dispose()
141+
}
142+
}
117143
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import * as fs from 'node:fs'
2+
import * as path from 'node:path'
3+
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
6+
import { FileLogger } from '../src/fileLogger.js'
7+
8+
vi.mock('node:fs', () => {
9+
return {
10+
existsSync: vi.fn(),
11+
mkdirSync: vi.fn(),
12+
createWriteStream: vi.fn(() => ({
13+
on: vi.fn(),
14+
})),
15+
}
16+
})
17+
18+
describe('FileLogger', () => {
19+
const mockFileAbsPath = process.platform === 'win32' ? 'c:\\work\\path\\to\\log' : '/work/path/to/log'
20+
const mockFileRelativePath = process.platform === 'win32' ? 'path\\to\\log' : 'path/to/log'
21+
22+
const mockStream = {
23+
on: vi.fn(),
24+
end: vi.fn(),
25+
write: vi.fn(),
26+
} as unknown as fs.WriteStream
27+
28+
afterEach(() => {
29+
vi.restoreAllMocks()
30+
})
31+
32+
describe('constructor', () => {
33+
it('should create WriteStream with absolute directory path', () => {
34+
new FileLogger(mockFileAbsPath)
35+
36+
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(mockFileAbsPath, 'vscode-webdriverio.log'), {
37+
flags: 'a',
38+
encoding: 'utf8',
39+
})
40+
})
41+
42+
it('should create WriteStream with relative directory path', () => {
43+
new FileLogger(mockFileRelativePath)
44+
45+
expect(fs.createWriteStream).toHaveBeenCalledWith(
46+
path.join(process.cwd(), mockFileRelativePath, 'vscode-webdriverio.log'),
47+
{
48+
flags: 'a',
49+
encoding: 'utf8',
50+
}
51+
)
52+
})
53+
54+
it('should ensure directory exists', () => {
55+
const logFilePath = path.join(mockFileAbsPath, 'vscode-webdriverio.log')
56+
vi.mocked(fs.existsSync).mockReturnValue(false)
57+
58+
new FileLogger(mockFileAbsPath)
59+
60+
expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(logFilePath), {
61+
recursive: true,
62+
})
63+
})
64+
65+
it('should throw error when failed to create stream', () => {
66+
vi.mocked(fs.createWriteStream).mockImplementation(() => {
67+
throw new Error('DUMMY ERROR')
68+
})
69+
70+
expect(() => new FileLogger(mockFileAbsPath)).toThrowError('Failed to initialize FileLogger: DUMMY ERROR')
71+
})
72+
73+
it('should not write the logs when error occurred once', () => {
74+
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream)
75+
76+
const fileLogger = new FileLogger(mockFileAbsPath)
77+
78+
const event = vi.mocked(mockStream.on).mock.calls[0][0]
79+
const cb = vi.mocked(mockStream.on).mock.calls[0][1] as Function
80+
81+
// Emulate the Error
82+
cb()
83+
fileLogger.write('Expects that this is not written')
84+
85+
// Assertions
86+
expect(event).toBe('error')
87+
expect(fileLogger.isWritable).toBe(false)
88+
expect(mockStream.write).not.toHaveBeenCalled()
89+
})
90+
})
91+
92+
describe('write', () => {
93+
it('should write the logs', () => {
94+
const dummyMsg = 'log message'
95+
96+
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream)
97+
98+
const fileLogger = new FileLogger(mockFileAbsPath)
99+
fileLogger.write(dummyMsg)
100+
101+
expect(mockStream.write).toHaveBeenCalledWith(`${dummyMsg}\n`)
102+
})
103+
104+
it('should be disposed when error occurred', () => {
105+
vi.mocked(mockStream.write).mockImplementation(() => {
106+
throw new Error('DUMMY ERROR')
107+
})
108+
const dummyMsg = 'log message'
109+
110+
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream)
111+
112+
const fileLogger = new FileLogger(mockFileAbsPath)
113+
expect(() => fileLogger.write(dummyMsg)).toThrowError('Failed to write log: DUMMY ERROR')
114+
expect(fileLogger.isWritable).toBe(false)
115+
expect(mockStream.end).toHaveBeenCalledOnce()
116+
})
117+
})
118+
119+
describe('dispose', () => {
120+
it('should call end of the WriteStream', () => {
121+
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream)
122+
const fileLogger = new FileLogger(mockFileAbsPath)
123+
124+
// Pre-assertion
125+
expect(fileLogger.isWritable).toBe(true)
126+
127+
// Act
128+
fileLogger.dispose()
129+
130+
// Assertions
131+
expect(mockStream.end).toHaveBeenCalledOnce()
132+
expect(fileLogger.isWritable).toBe(false)
133+
})
134+
135+
it('should call end of the WriteStream only once', () => {
136+
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream)
137+
const fileLogger = new FileLogger(mockFileAbsPath)
138+
139+
// Pre-assertion
140+
expect(fileLogger.isWritable).toBe(true)
141+
142+
// Act
143+
fileLogger.dispose()
144+
fileLogger.dispose()
145+
146+
// Assertions
147+
expect(mockStream.end).toHaveBeenCalledOnce()
148+
expect(fileLogger.isWritable).toBe(false)
149+
})
150+
})
151+
})

0 commit comments

Comments
 (0)