import type { BirpcReturn } from 'birpc' import type { ErrorWithDiff, RunnerTask, RunnerTestFile, TaskResultPack, UserConsoleLog, } from 'vitest' import type { ExtensionWorkerEvents, ExtensionWorkerTransport } from 'vitest-vscode-shared' import type { BrowserCommand, TestCase, TestModule, TestResult, TestSuite, Vitest as VitestCore, WorkspaceProject, } from 'vitest/node' import type { Reporter } from 'vitest/reporters' import { Console } from 'node:console' import { nextTick } from 'node:process' import { Writable } from 'node:stream' import { parseErrorStacktrace } from '@vitest/utils/source-map' import { ExtensionWorker } from './worker' interface VSCodeReporterOptions { setupFilePaths: string[] } export class VSCodeReporter implements Reporter { public rpc!: BirpcReturn private vitest!: VitestCore private setupFilePaths: string[] private silent: boolean | 'passed-only' = false constructor(options: VSCodeReporterOptions) { this.setupFilePaths = options.setupFilePaths } private get collecting(): boolean { return ( (this.vitest as any).configOverride.testNamePattern?.toString() === `/${ExtensionWorker.COLLECT_NAME_PATTERN}/` ) } onInit(vitest: VitestCore) { this.vitest = vitest this.silent = vitest.config.silent const server = vitest.server.config.server this.setupFilePaths.forEach((setupFile) => { if (!server.fs.allow.includes(setupFile)) server.fs.allow.push(setupFile) vitest.projects.forEach((project) => { project.config.setupFiles = [...(project.config.setupFiles || []), setupFile] const server = project.server.config.server if (!server.fs.allow.includes(setupFile)) server.fs.allow.push(setupFile) // @ts-expect-error internal, Vitest 3 if (project._initBrowserProvider) { this.overrideInitBrowserProvider(project, '_initBrowserProvider') } // @ts-expect-error internal, Vitest 2 else if (project.initBrowserProvider) { this.overrideInitBrowserProvider(project, 'initBrowserProvider') } const browser = project.browser as any if (!browser) { return } const config = 'vite' in browser ? browser.vite.config.server : browser.config.server if (!config.fs.allow.includes(setupFile)) config.fs.allow.push(setupFile) }) }) } overrideInitBrowserProvider(project: WorkspaceProject, name: string) { // @ts-expect-error internal const original = project[name].bind(project) const setupFilePaths = this.setupFilePaths // @ts-expect-error internal project[name] = async function _initBrowserProvider(this: WorkspaceProject) { await original() if (!this.browser) { return } const config = this.browser!.vite.config setupFilePaths.forEach((setupFile) => { if (!config.server.fs.allow.includes(setupFile)) { config.server.fs.allow.push(setupFile) } }) const __vscode_waitForDebugger: BrowserCommand<[]> = () => { return new Promise((resolve, reject) => { ExtensionWorker.emitter.on('onDebugAttached', (fullfilled) => { if (fullfilled) { resolve() } else { reject(new Error(`Browser Debugger failed to connect.`)) } }) }) } if (!('parent' in this.browser) || !('commands' in (this.browser.parent as any))) { return } // @ts-expect-error private "parent" property this.browser!.parent.commands.__vscode_waitForDebugger = __vscode_waitForDebugger } } initRpc(rpc: BirpcReturn) { this.rpc = rpc } onUserConsoleLog(log: UserConsoleLog, taskState?: TestResult['state']) { if (!this.shouldLog(log, taskState)) { return } // Parse stack trace to extract file location for inline display const extendedLog = log as any if (log.origin) { try { const stacks = this.parseStackTrace({ stack: log.origin } as any, log.taskId) if (stacks && stacks.length > 0) { const firstStack = stacks[0] if (firstStack.file && firstStack.line != null && firstStack.column != null) { extendedLog.parsedLocation = { file: firstStack.file, line: firstStack.line - 1, // Convert to 0-based column: firstStack.column, } } } } catch { // If parsing fails, continue without parsed location } } return this.rpc.onConsoleLog(extendedLog) } onTestCaseResult(testCase: TestCase): void { if (testCase.result().state === 'failed') { this.logFailedTask(getRunnerTask(testCase)) } } onTestSuiteResult(testSuite: TestSuite): void { if (testSuite.state() === 'failed') { this.logFailedTask(getRunnerTask(testSuite)) } } onTestModuleEnd(testModule: TestModule): void { if (testModule.state() === 'failed') { this.logFailedTask(getRunnerTask(testModule)) } } private logFailedTask(task: RunnerTask) { if (this.vitest.config.silent === 'passed-only') { for (const log of task.logs || []) { this.onUserConsoleLog(log, 'failed') } } } shouldLog(log: UserConsoleLog, taskState?: TestResult['state']): boolean { if (this.silent === true) { return false } if (this.silent === 'passed-only' && taskState !== 'failed') { return false } if (this.vitest.config.onConsoleLog) { const shouldLog = this.vitest.config.onConsoleLog(log.content, log.type) if (shouldLog === false) { return false } } return true } private logPromises = new Set>() sendTerminalLog(type: 'stderr' | 'stdout', message: string) { if (!this.rpc) { return } const promise = this.rpc .onProcessLog(type, message) .catch(() => {}) .finally(() => { this.logPromises.delete(promise) }) this.logPromises.add(promise) } parseStackTrace(obj: ErrorWithDiff, taskId: string | undefined) { const project = taskId ? this.vitest.getProjectByTaskId(taskId) : this.vitest.getCoreWorkspaceProject() // the new version uses browser.parseErrorStacktrace if ('getBrowserSourceMapModuleById' in project) { return parseErrorStacktrace(obj as Error, { getSourceMap: (file) => (project as any).getBrowserSourceMapModuleById(file), }) } const task = taskId && this.vitest.state.idMap.get(taskId) const isBrowser = task && task.file?.pool === 'browser' return isBrowser ? project.browser?.parseErrorStacktrace(obj) : parseErrorStacktrace(obj as Error) } onTaskUpdate(packs: TaskResultPack[]) { packs.forEach(([taskId, result]) => { const project = this.vitest.getProjectByTaskId(taskId) // the new version uses browser.parseErrorStacktrace if ('getBrowserSourceMapModuleById' in project) { result?.errors?.forEach((error) => { if (typeof error === 'object' && error) { error.stacks = this.parseStackTrace(error, taskId) } }) return } result?.errors?.forEach((error) => { if (isPrimitive(error)) { return } error.stacks = this.parseStackTrace(error, taskId) }) }) this.rpc.onTaskUpdate(packs) } async onFinished( files?: RunnerTestFile[], errors: unknown[] = this.vitest.state.getUnhandledErrors(), coverage?: any, ) { const collecting = this.collecting let output = '' if (errors.length) { const writable = new Writable({ write(chunk, _encoding, callback) { output += String(chunk) callback() }, }) const _console = this.vitest.logger.console const errorStream = this.vitest.logger.errorStream const outputStream = this.vitest.logger.outputStream this.vitest.logger.errorStream = writable as any this.vitest.logger.outputStream = writable as any this.vitest.logger.console = new Console(writable, writable) await this.vitest.logger.printUnhandledErrors(errors) this.vitest.logger.console = _console this.vitest.logger.errorStream = errorStream this.vitest.logger.outputStream = outputStream } // Make sure we rendered everything before ending the test run // If test run is no active, the log will be lost if (this.logPromises.size) { await Promise.all([...this.logPromises]) } nextTick(() => { this.rpc.onTestRunEnd( files || [], output, collecting, coverage ? coverage.toJSON() : undefined, ) }) } onCollected(files?: RunnerTestFile[]) { files?.forEach((file) => this.rpc.onCollected(file, this.collecting)) } onWatcherRerun(files: string[]) { this.rpc.onTestRunStart(files) } toJSON() { return {} } } function isPrimitive(value: unknown) { return value === null || (typeof value !== 'function' && typeof value !== 'object') } function getRunnerTask(value: any): RunnerTask { return value.task }