Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
210 changes: 210 additions & 0 deletions packages/extension/src/inlineConsoleLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type { UserConsoleLog } from 'vitest'
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 editor = vscode.window.activeTextEditor
if (editor && event.document === editor.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: UserConsoleLog): void {
const config = getConfig()
if (!config.showConsoleLogInline) {
return
}

// Parse origin to extract file and line number
const location = this.parseOrigin(consoleLog.origin)
if (!location) {
return
}

const { file, line } = location

// 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 active editor if it's the affected file
const editor = vscode.window.activeTextEditor
if (editor && editor.document.uri.fsPath === file) {
this.updateDecorations(editor)
}
}

clear(): void {
this.consoleLogsByFile.clear()
this.refresh()
}

clearFile(file: string): void {
this.consoleLogsByFile.delete(file)
const editor = vscode.window.activeTextEditor
if (editor && editor.document.uri.fsPath === file) {
this.updateDecorations(editor)
}
}

private parseOrigin(origin?: string): { file: string; line: number } | null {
if (!origin) {
return null
}

// Origin is a stack trace string. We need to extract the file path and line number.
// Stack trace formats vary but typically look like:
// at functionName (file:///path/to/file.ts:10:5)
// at /path/to/file.ts:10:5
// at Object.<anonymous> (/path/to/file.ts:10:5)
// We look for the first line that contains a file path with line:column

const lines = origin.split('\n')
Copy link
Member

Choose a reason for hiding this comment

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

@copilot use parseErrorStacktrace from @vitest/utils/source-map instead. You should do that in the worker thread (packages/worker and packages/worker-legacy) before sending the log via RPC.

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}. Now using parseErrorStacktrace from @vitest/utils/source-map in both worker and worker-legacy reporter threads. The parsed location is sent via RPC as part of an extended ExtensionUserConsoleLog type, and the extension's InlineConsoleLogManager simply uses the pre-parsed location.

for (const line of lines) {
// Match various stack trace formats
// Handles: (file:///path/to/file.ts:10:5) or (/path/to/file.ts:10:5) or just /path/to/file.ts:10:5
const match = line.match(/(?:file:\/\/)?([^():\s]+\.(?:ts|js|jsx|tsx|mjs|cjs|cts|mts)):(\d+):(\d+)/)
if (match) {
const [, file, lineStr] = match
const lineNum = Number.parseInt(lineStr, 10) - 1 // Convert to 0-based line number

if (!Number.isNaN(lineNum) && lineNum >= 0) {
// Clean up file:// protocol if present and decode URI components
let cleanPath = file
if (cleanPath.startsWith('file://')) {
cleanPath = cleanPath.substring(7)
}
try {
cleanPath = decodeURIComponent(cleanPath)
}
catch {
// If decoding fails, use the original path
}

return { file: cleanPath, line: lineNum }
}
}
}

return null
}

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 {
// Remove trailing newlines and limit length
const cleaned = content.trim().replace(/\n/g, ' ')
const maxLength = 100
if (cleaned.length > maxLength) {
return `${cleaned.substring(0, maxLength)}...`
}
return cleaned
}

private refresh(): void {
const editor = vscode.window.activeTextEditor
if (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 @@ -64,6 +66,10 @@ export class TestRunner extends vscode.Disposable {
files.forEach((file) => {
const uri = vscode.Uri.file(file)
this.diagnostic?.deleteDiagnostic(uri)
// Clear inline console logs for this file
if (this.inlineConsoleLog) {
this.inlineConsoleLog.clearFile(file)
}
})
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
Loading