-
-
Notifications
You must be signed in to change notification settings - Fork 113
Add inline console.log display feature #668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
4142c7b
0c122a9
5ade4e7
e07ec67
2b61db7
e52b90e
bbe6f87
57619e2
9876e66
9abe38b
ff25649
73c40e8
ce6da25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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') | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
| 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' | ||
|
|
@@ -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') | ||
|
|
@@ -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) | ||
|
|
@@ -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, | ||
|
||
| testItem, | ||
| ) | ||
| } | ||
| else { | ||
| log.info('[TEST]', content) | ||
| log.info('[TEST]', consoleLog.content) | ||
| } | ||
|
|
||
| // Add to inline console log manager | ||
| if (this.inlineConsoleLog) { | ||
| this.inlineConsoleLog.addConsoleLog(consoleLog) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot use
parseErrorStacktracefrom@vitest/utils/source-mapinstead. You should do that in the worker thread (packages/worker and packages/worker-legacy) before sending the log via RPC.There was a problem hiding this comment.
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
parseErrorStacktracefrom@vitest/utils/source-mapin both worker and worker-legacy reporter threads. The parsed location is sent via RPC as part of an extendedExtensionUserConsoleLogtype, and the extension'sInlineConsoleLogManagersimply uses the pre-parsed location.