import type { RunnerTaskResultPack, UserConsoleLog } from 'vitest' import type { VitestWorkerRPC, WorkerInitMetadata, WorkerRunnerOptions } from 'vitest-vscode-shared' import type { BrowserCommand, Reporter, ResolvedConfig, RunnerTask, RunnerTestFile, TestCase, TestModule, TestProject, TestResult, TestSpecification, TestSuite, Vite, Vitest as VitestCore, } from 'vitest/node' import { parseErrorStacktrace } from '@vitest/utils/source-map' import { ExtensionWorker } from './worker' export class VSCodeReporter implements Reporter { public rpc!: VitestWorkerRPC private vitest!: VitestCore private setupFilePaths: WorkerInitMetadata['setupFilePaths'] private debug: WorkerRunnerOptions['debug'] private execArgv: string[] = [] private debuggerAttached: boolean | undefined = undefined private coverageData: Record | undefined = undefined private silent: boolean | 'passed-only' = false constructor(meta: WorkerInitMetadata, debug: WorkerRunnerOptions['debug']) { this.setupFilePaths = meta.setupFilePaths this.debug = debug if (meta.pnpApi && meta.pnpLoader) { this.execArgv.push('--require', meta.pnpApi, '--experimental-loader', meta.pnpLoader) } } onInit(vitest: VitestCore) { this.vitest = vitest this.configureAttachDebugging(vitest) this.silent = vitest.config.silent vitest.projects.forEach((project) => { this.ensureSetupFileIsAllowed(project.vite.config) this.ensurePnpSuported(project.config) }) } initRpc(rpc: VitestWorkerRPC) { this.rpc = rpc } onBrowserInit(project: TestProject) { const config = project.browser!.vite.config this.ensureSetupFileIsAllowed(config) const __vscode_waitForDebugger: BrowserCommand<[]> = () => { return this.createAttachPromise() } // TODO: move this command init to configureVitest when Vitest 4 is out // @ts-expect-error private "parent" property project.browser!.parent.commands.__vscode_waitForDebugger = __vscode_waitForDebugger } 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 task = log.taskId ? this.vitest.state.idMap.get(log.taskId) : null const project = task ? this.vitest.state.getReportedEntity(task)!.project : this.vitest.getRootProject() const stacks = log.browser ? project.browser?.parseErrorStacktrace({ stack: log.origin } as any) : parseErrorStacktrace({ stack: log.origin } as any) 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)) } } protected logFailedTask(task: RunnerTask): void { if (this.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 task = log.taskId ? this.vitest.state.idMap.get(log.taskId) : undefined const entity = task && this.vitest.state.getReportedEntity(task) const shouldLog = this.vitest.config.onConsoleLog(log.content, log.type, entity) if (shouldLog === false) { return false } } return true } onTaskUpdate(packs: RunnerTaskResultPack[]) { this.rpc.onTaskUpdate( // remove the meta because it is not used, // mark todo tests with a result, because // it is not set if the test was skipped during collection packs.map((pack) => { const task = this.vitest.state.idMap.get(pack[0]) if (pack[1] || !task) { return [pack[0], pack[1], {}] } if (task.mode === 'todo' || task.mode === 'skip') { return [pack[0], { state: task.mode }, {}] } return [pack[0], pack[1], {}] }), ) } onTestRunStart(specifications: ReadonlyArray) { const files = specifications.map((spec) => spec.moduleId) this.rpc.onTestRunStart([...new Set(files)]) this.vitest.state.filesMap.clear() } onCoverage(coverage: unknown) { this.coverageData = (coverage as any).toJSON() } async onTestRunEnd(testModules: ReadonlyArray) { const files = testModules.map((m) => getEntityJSONTask(m)) // 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]) } const coverage = this.coverageData this.coverageData = undefined // as any because Vitest types are different between v3 and v4, // and shared packages uses the lowest Vitest version this.rpc.onTestRunEnd(files as any, '', false, coverage) } onTestModuleCollected(testModule: TestModule) { // TODO: is it possible to make types happy with both V3 and V4? this.rpc.onCollected(getEntityJSONTask(testModule) as any, false) } ensureSetupFileIsAllowed(config: Vite.ResolvedConfig) { ;[this.setupFilePaths.browserDebug].forEach((filepath) => { if (!config.server.fs.allow.includes(filepath)) { config.server.fs.allow.push(filepath) } }) } ensurePnpSuported(config: ResolvedConfig) { config.execArgv.push(...this.execArgv) } private createAttachPromise() { return new Promise((resolve, reject) => { if (this.debuggerAttached !== undefined) { if (this.debuggerAttached) { resolve() return } else if (this.debuggerAttached === false) { reject(new Error(`Browser Debugger failed to connect.`)) return } } ExtensionWorker.emitter.on('onDebugAttached', (fullfilled) => { if (fullfilled) { resolve() } else { reject(new Error(`Browser Debugger failed to connect.`)) } }) }) } configureAttachDebugging(vitest: VitestCore) { ExtensionWorker.emitter.on('onDebugAttached', (fullfilled) => { this.debuggerAttached = fullfilled }) if (this.debug !== undefined && typeof this.debug === 'object') { vitest.projects.forEach((project) => { if (project.config.browser?.enabled) { project.config.setupFiles.push(this.setupFilePaths.browserDebug) } }) } } toJSON() { return {} } 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) } } function getEntityJSONTask(entity: TestModule) { return (entity as any).task as RunnerTestFile } function getRunnerTask(value: any): RunnerTask { return value.task }