Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **Run**, **debug**, and **watch** Vitest tests in Visual Studio Code.
- **Coverage** support (requires VS Code >= 1.88)
- An `@open` tag can be used when filtering tests, to only show the tests open in the editor.
- **Inline console.log display**: Show `console.log` output inline in the editor next to the line that produced it (enabled by default, requires `printConsoleTrace: true` in Vitest config)

## Requirements

Expand Down Expand Up @@ -99,6 +100,7 @@ These options are resolved relative to the [workspace file](https://code.visuals
- `vitest.logLevel`: How verbose should the logger be in the "Output" channel. Default: `info`
- `vitest.applyDiagnostic`: Show a squiggly line where the error was thrown. This also enables the error count in the File Tab. Default: `true`
- `vitest.experimentalStaticAstCollect`: uses AST parses to collect tests instead of running files and collecting them at runtime. Default: `true`
- `vitest.showConsoleLogInline`: Show `console.log` output inline in the editor next to the line that produced it. Note: This requires `printConsoleTrace: true` in your Vitest config to include stack trace information. Default: `true`

### Commands

Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@
"description": "Show a squiggly line where the error was thrown. This also enables the error count in the File Tab.",
"type": "boolean",
"default": true
},
"vitest.showConsoleLogInline": {
"description": "Show console.log output inline in the editor next to the line that produced it.",
"type": "boolean",
"default": true,
"scope": "resource"
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/extension/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
const debugOutFiles = get<string[]>('debugOutFiles', [])
const applyDiagnostic = get<boolean>('applyDiagnostic', true)
const ignoreWorkspace = get<boolean>('ignoreWorkspace', false) ?? false
const showConsoleLogInline = get<boolean>('showConsoleLogInline', true)!

return {
env: get<null | Record<string, string>>('nodeEnv', null),
Expand All @@ -82,6 +83,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) {
terminalShellPath,
shellType,
applyDiagnostic,
showConsoleLogInline,
cliArguments,
nodeExecArgs,
experimentalStaticAstCollect,
Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export async function debugTests(
tree,
api,
diagnostic,
undefined, // No inline console log for debug sessions
)
disposables.push(api, runner)

Expand Down
5 changes: 5 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { configGlob, workspaceGlob } from './constants'
import { coverageContext } from './coverage'
import { DebugManager, debugTests } from './debug'
import { ExtensionDiagnostic } from './diagnostic'
import { InlineConsoleLogManager } from './inlineConsoleLog'
import { log } from './log'
import { TestRunner } from './runner'
import { TagsManager } from './tagsManager'
Expand Down Expand Up @@ -41,6 +42,7 @@ class VitestExtension {
private disposables: vscode.Disposable[] = []
private diagnostic: ExtensionDiagnostic | undefined
private debugManager: DebugManager
private inlineConsoleLog: InlineConsoleLogManager

/** @internal */
_debugDisposable: vscode.Disposable | undefined
Expand All @@ -58,6 +60,7 @@ class VitestExtension {
this.testTree = new TestTree(this.testController, this.loadingTestItem)
this.tagsManager = new TagsManager(this.testTree)
this.debugManager = new DebugManager()
this.inlineConsoleLog = new InlineConsoleLogManager()
}

private _defineTestProfilePromise: Promise<void> | undefined
Expand Down Expand Up @@ -150,6 +153,7 @@ class VitestExtension {
this.testTree,
api,
this.diagnostic,
this.inlineConsoleLog,
)
this.runners.push(runner)

Expand Down Expand Up @@ -428,6 +432,7 @@ class VitestExtension {
this.api?.dispose()
this.testTree.dispose()
this.tagsManager.dispose()
this.inlineConsoleLog.dispose()
this.testController.dispose()
this.runProfiles.forEach(profile => profile.dispose())
this.runProfiles.clear()
Expand Down
220 changes: 220 additions & 0 deletions packages/extension/src/inlineConsoleLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type { ExtensionUserConsoleLog } from 'vitest-vscode-shared'
import { stripVTControlCharacters } from 'node:util'
import * as vscode from 'vscode'
import { getConfig } from './config'

interface ConsoleLogEntry {
content: string
time: number
}

export class InlineConsoleLogManager extends vscode.Disposable {
private decorationType: vscode.TextEditorDecorationType
private consoleLogsByFile = new Map<string, Map<number, ConsoleLogEntry[]>>()
private disposables: vscode.Disposable[] = []

constructor() {
super(() => {
this.decorationType.dispose()
this.disposables.forEach(d => d.dispose())
this.disposables = []
})

this.decorationType = vscode.window.createTextEditorDecorationType({
after: {
margin: '0 0 0 3em',
textDecoration: 'none',
},
rangeBehavior: vscode.DecorationRangeBehavior.ClosedOpen,
})

// Update decorations when active editor changes
this.disposables.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor) {
this.updateDecorations(editor)
}
}),
)

// Update decorations when document changes
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((event) => {
const file = event.document.uri.fsPath
const fileMap = this.consoleLogsByFile.get(file)

if (!fileMap || fileMap.size === 0) {
return
}

// Adjust line numbers based on document changes
for (const change of event.contentChanges) {
const startLine = change.range.start.line
const endLine = change.range.end.line
const newLineCount = change.text.split('\n').length - 1
const oldLineCount = endLine - startLine

// Calculate the net change in line numbers
const lineDelta = newLineCount - oldLineCount

if (lineDelta !== 0) {
// Create a new map with adjusted line numbers
const newFileMap = new Map<number, ConsoleLogEntry[]>()

fileMap.forEach((entries, line) => {
let newLine = line

// If the console log is after the change, adjust its line number
if (line > endLine) {
newLine = line + lineDelta
}
// If the console log is within the changed range, keep it at the start of the change
else if (line >= startLine && line <= endLine) {
newLine = startLine + newLineCount
}

// Ensure line number is valid
if (newLine >= 0) {
if (!newFileMap.has(newLine)) {
newFileMap.set(newLine, [])
}
newFileMap.get(newLine)!.push(...entries)
}
})

this.consoleLogsByFile.set(file, newFileMap)
}
}

// Update all visible editors showing this file
vscode.window.visibleTextEditors.forEach((editor) => {
if (editor.document === event.document) {
this.updateDecorations(editor)
}
})
}),
)

// Update decorations when configuration changes
this.disposables.push(
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('vitest.showConsoleLogInline')) {
this.refresh()
}
}),
)
}

addConsoleLog(consoleLog: ExtensionUserConsoleLog): void {
const config = getConfig()
if (!config.showConsoleLogInline) {
return
}

// Use pre-parsed location from worker
if (!consoleLog.parsedLocation) {
return
}

const { file, line } = consoleLog.parsedLocation

// Store console log entry
if (!this.consoleLogsByFile.has(file)) {
this.consoleLogsByFile.set(file, new Map())
}

const fileMap = this.consoleLogsByFile.get(file)!
if (!fileMap.has(line)) {
fileMap.set(line, [])
}

fileMap.get(line)!.push({
content: consoleLog.content,
time: consoleLog.time,
})

// Update decorations for all visible editors showing this file
vscode.window.visibleTextEditors.forEach((editor) => {
if (editor.document.uri.fsPath === file) {
this.updateDecorations(editor)
}
})
}

clear(): void {
this.consoleLogsByFile.clear()
// Update all visible editors
vscode.window.visibleTextEditors.forEach(editor => this.updateDecorations(editor))
}

clearFile(file: string): void {
this.consoleLogsByFile.delete(file)
// Update all visible editors showing this file
vscode.window.visibleTextEditors.forEach((editor) => {
if (editor.document.uri.fsPath === file) {
this.updateDecorations(editor)
}
})
}

private updateDecorations(editor: vscode.TextEditor): void {
const config = getConfig()
if (!config.showConsoleLogInline) {
editor.setDecorations(this.decorationType, [])
return
}

const file = editor.document.uri.fsPath
const fileMap = this.consoleLogsByFile.get(file)

if (!fileMap || fileMap.size === 0) {
editor.setDecorations(this.decorationType, [])
return
}

const decorations: vscode.DecorationOptions[] = []

fileMap.forEach((entries, line) => {
// Skip if line is out of range
if (line >= editor.document.lineCount) {
return
}

// Combine multiple console logs on the same line
const content = entries.map(e => this.formatContent(e.content)).join(' ')

const lineRange = editor.document.lineAt(line).range
const decoration: vscode.DecorationOptions = {
range: lineRange,
renderOptions: {
after: {
contentText: content,
color: new vscode.ThemeColor('editorCodeLens.foreground'),
fontStyle: 'italic',
},
},
}

decorations.push(decoration)
})

editor.setDecorations(this.decorationType, decorations)
}

private formatContent(content: string): string {
// Strip ANSI control characters using Node.js util
const stripped = stripVTControlCharacters(content)
// Remove trailing newlines and limit length
const cleaned = stripped.trim().replace(/\n/g, ' ')
const maxLength = 100
if (cleaned.length > maxLength) {
return `${cleaned.substring(0, maxLength)}...`
}
return cleaned
}

private refresh(): void {
// Update all visible editors
vscode.window.visibleTextEditors.forEach(editor => this.updateDecorations(editor))
}
}
19 changes: 15 additions & 4 deletions packages/extension/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ParsedStack, RunnerTaskResult, TestError } from 'vitest'
import type { VitestFolderAPI } from './api'
import type { ExtensionDiagnostic } from './diagnostic'
import type { InlineConsoleLogManager } from './inlineConsoleLog'
import type { TestTree } from './testTree'
import { rm } from 'node:fs/promises'
import path from 'node:path'
Expand Down Expand Up @@ -33,6 +34,7 @@ export class TestRunner extends vscode.Disposable {
private readonly tree: TestTree,
private readonly api: VitestFolderAPI,
private readonly diagnostic: ExtensionDiagnostic | undefined,
private readonly inlineConsoleLog: InlineConsoleLogManager | undefined,
) {
super(() => {
log.verbose?.('Disposing test runner')
Expand Down Expand Up @@ -65,6 +67,10 @@ export class TestRunner extends vscode.Disposable {
const uri = vscode.Uri.file(file)
this.diagnostic?.deleteDiagnostic(uri)
})
// Clear all inline console logs when tests start to avoid accumulation
if (this.inlineConsoleLog) {
this.inlineConsoleLog.clear()
}
log.verbose?.('Starting a test run because', ...files.map(f => this.relative(f)), 'triggered a watch rerun event')
this.startTestRun(files)
}
Expand Down Expand Up @@ -153,18 +159,23 @@ export class TestRunner extends vscode.Disposable {
this.endTestRun()
})

api.onConsoleLog(({ content, taskId }) => {
const testItem = taskId ? tree.getTestItemByTaskId(taskId) : undefined
api.onConsoleLog((consoleLog) => {
const testItem = consoleLog.taskId ? tree.getTestItemByTaskId(consoleLog.taskId) : undefined
const testRun = this.testRun
if (testRun) {
testRun.appendOutput(
formatTestOutput(content),
formatTestOutput(consoleLog.content),
undefined,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot

Actually, vscode already supports showing logs. If you provide a vscode.Location here, everything will be handled automatically and you can remove your class and simplify the implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit ${git_hash}. Removed the custom InlineConsoleLogManager class and simplified the implementation by using VSCode's built-in location support. Now passing vscode.Location to testRun.appendOutput() and VSCode handles the inline display automatically. This reduced the code by ~200 lines while providing the same functionality.

testItem,
)
}
else {
log.info('[TEST]', content)
log.info('[TEST]', consoleLog.content)
}

// Add to inline console log manager
if (this.inlineConsoleLog) {
this.inlineConsoleLog.addConsoleLog(consoleLog)
}
})
}
Expand Down
11 changes: 10 additions & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export type ExtensionTestFileSpecification = [
ExtensionTestFileMetadata,
]

export interface ExtensionUserConsoleLog extends UserConsoleLog {
// Parsed location from stack trace for inline display
parsedLocation?: {
file: string
line: number // 0-based line number
column: number
}
}

export interface ExtensionWorkerTransport {
getFiles: () => Promise<ExtensionTestFileSpecification[]>
collectTests: (testFile: ExtensionTestSpecification[]) => Promise<void>
Expand All @@ -54,7 +63,7 @@ export interface ExtensionWorkerTransport {
}

export interface ExtensionWorkerEvents {
onConsoleLog: (log: UserConsoleLog) => void
onConsoleLog: (log: ExtensionUserConsoleLog) => void
onTaskUpdate: (task: RunnerTaskResultPack[]) => void
onTestRunEnd: (files: RunnerTestFile[], unhandledError: string, collecting?: boolean) => void
onCollected: (file: RunnerTestFile, collecting?: boolean) => void
Expand Down
Loading
Loading