From 2e088656816d1493bda52c0aa9582346b895461e Mon Sep 17 00:00:00 2001 From: N0SAFE Date: Tue, 8 Sep 2026 21:09:00 +0200 Subject: [PATCH] feat: add Bun runtime support Add support for running Vitest tests with the Bun runtime. Bun v1.4 added native Vitest support including --coverage with threads and forks pools. Changes: - Add 'bun' to vitest.runtime configuration enum - Extend WorkerInitMetadata.runtime and VitestPackage.runtime types - Auto-detect Bun projects via bun.lock, bun.lockb, or bunfig.toml - Pass --bun flag to Bun when spawning processes (replaces Node.js APIs) - Support bun in child_process, terminal, and debug modes - Add Bun version guard: require Bun >= 1.4.0 for Vitest support - Add Bun sample project for testing Refs: https://github.com/vitest-dev/vscode/discussions/473 --- package.json | 3 +- packages/extension/src/config.ts | 2 +- packages/extension/src/constants.ts | 2 + packages/extension/src/debug.ts | 3 + packages/extension/src/spawn/child_process.ts | 3 + packages/extension/src/spawn/pkg.ts | 78 +++++++++++++------ packages/extension/src/spawn/terminal.ts | 3 + packages/extension/src/utils.ts | 47 ++++++++++- packages/shared/src/index.ts | 2 +- pnpm-lock.yaml | 6 ++ samples/bun/package.json | 14 ++++ samples/bun/src/index.ts | 7 ++ samples/bun/test/index.test.ts | 22 ++++++ samples/bun/vitest.config.ts | 7 ++ 14 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 samples/bun/package.json create mode 100644 samples/bun/src/index.ts create mode 100644 samples/bun/test/index.test.ts create mode 100644 samples/bun/vitest.config.ts diff --git a/package.json b/package.json index 85b2ea10..dd05bd1c 100644 --- a/package.json +++ b/package.json @@ -393,7 +393,8 @@ "enum": [ "auto", "node", - "deno" + "deno", + "bun" ] }, "vitest.watchOnStartup": { diff --git a/packages/extension/src/config.ts b/packages/extension/src/config.ts index 60cf85ef..7f1bb971 100644 --- a/packages/extension/src/config.ts +++ b/packages/extension/src/config.ts @@ -86,7 +86,7 @@ export function getConfig(workspaceFolder?: WorkspaceFolder) { const ignoreWorkspace = get('ignoreWorkspace', false) ?? false const showInlineConsoleLog = get('showInlineConsoleLog', true) ?? true const forceCancelTimeout = get('forceCancelTimeout', 1000) ?? 1000 - const runtime = get<'node' | 'deno' | 'auto'>('runtime', 'auto') ?? 'auto' + const runtime = get<'node' | 'deno' | 'bun' | 'auto'>('runtime', 'auto') ?? 'auto' const watchOnStartup = get('watchOnStartup', false) ?? false return { diff --git a/packages/extension/src/constants.ts b/packages/extension/src/constants.ts index 3de8f621..64324c89 100644 --- a/packages/extension/src/constants.ts +++ b/packages/extension/src/constants.ts @@ -3,6 +3,8 @@ import { resolve } from 'pathe' export const minimumVersion = '1.4.0' // follows minimum Vitest export const minimumNodeVersion = '18.0.0' +// Bun 1.4 added native Vitest support +export const minimumBunVersion = '1.4.0' export const distDir = __dirname export const workerPath = resolve(__dirname, 'worker.js') diff --git a/packages/extension/src/debug.ts b/packages/extension/src/debug.ts index 8562f213..dbc9e1a5 100644 --- a/packages/extension/src/debug.ts +++ b/packages/extension/src/debug.ts @@ -49,6 +49,9 @@ export async function debugTests( if (pkg.runtime === 'deno') { runtimeArgs.push('-A') } + if (pkg.runtime === 'bun') { + runtimeArgs.push('--bun') + } log.info('[DEBUG]', 'Starting debugging session', runtimeExecutable, ...(runtimeArgs || [])) diff --git a/packages/extension/src/spawn/child_process.ts b/packages/extension/src/spawn/child_process.ts index 58c19e08..6d42e21e 100644 --- a/packages/extension/src/spawn/child_process.ts +++ b/packages/extension/src/spawn/child_process.ts @@ -42,6 +42,9 @@ export async function createVitestProcess(pkg: VitestPackage, options?: ProcessS execArgv.push('-A') executablePath = pathToFileURL(workerPath).toString() } + if (folderConfig.runtime === 'bun') { + execArgv.push('--bun') + } const arvString = execArgv.join(' ') const script = `${executable} ${arvString ? `${arvString} ` : ''}${executablePath}`.trim() log.info('[API]', `Running ${formatPkg(pkg)} with "${script}"`) diff --git a/packages/extension/src/spawn/pkg.ts b/packages/extension/src/spawn/pkg.ts index 9b22bdad..b06b0294 100644 --- a/packages/extension/src/spawn/pkg.ts +++ b/packages/extension/src/spawn/pkg.ts @@ -4,8 +4,9 @@ import { gte } from 'semver' import { getSuggestedInstallCommand } from 'vitest-vscode-shared' import * as vscode from 'vscode' import { getConfig } from '../config' -import { configGlob, minimumVersion, workspaceGlob } from '../constants' +import { configGlob, minimumBunVersion, minimumVersion, workspaceGlob } from '../constants' import { log } from '../log' +import { getBunVersion, validateBunVersion } from '../utils' import { resolveVitestPackage } from './resolve' function nonNullable(value: T | null | undefined): value is T { @@ -27,7 +28,7 @@ export interface VitestPackage { workspaceFile?: string loader?: string pnp?: string - runtime: 'deno' | 'node' + runtime: 'deno' | 'node' | 'bun' } // Before 5.0.0-rc.1 `testNamePattern` was matched the same way Jest does it: @@ -48,10 +49,10 @@ function isVitestInPackageJson(root: string) { return false } -function resolveVitestConfig( +async function resolveVitestConfig( showWarning: boolean, configOrWorkspaceFile: vscode.Uri, -): VitestPackage | null { +): Promise { const folder = vscode.workspace.getWorkspaceFolder(configOrWorkspaceFile)! if (!folder) throw new Error(`Workspace folder not found for ${configOrWorkspaceFile}. Does the file exist?`) @@ -88,6 +89,14 @@ function resolveVitestConfig( const prefix = `${basename(dirname(id))}:${basename(id)}` const runtime = guessRuntime(cwd, folder) + if (runtime === 'bun') { + const bunVersion = await getBunVersion(cwd) + if (bunVersion && !(await validateBunVersion(bunVersion, cwd, showWarning))) { + log.error('[API]', `Skipping ${configOrWorkspaceFile.fsPath} due to unsupported Bun version.`) + return null + } + } + if (vitest.pnp) { return { folder, @@ -155,8 +164,10 @@ export async function resolveVitestPackages( ]) if (!workspaceConfigs.meta.length && !configs.meta.length) { const pkg = await resolveVitestPackagesViaPackageJson(showWarning) - if (!pkg.meta.length && !pkg.warned) - return { configs: resolveVitestWorkspacePackages(showWarning).meta, workspaces: [] } + if (!pkg.meta.length && !pkg.warned) { + const workspacePackages = await resolveVitestWorkspacePackages(showWarning) + return { configs: workspacePackages.meta, workspaces: [] } + } return { configs: pkg.meta, workspaces: [] } } return { @@ -165,10 +176,11 @@ export async function resolveVitestPackages( } } -function resolveVitestWorkspacePackages(showWarning: boolean) { +async function resolveVitestWorkspacePackages(showWarning: boolean) { let warned = false const meta: VitestPackage[] = [] - vscode.workspace.workspaceFolders?.forEach((folder) => { + + const processFolder = async (folder: vscode.WorkspaceFolder) => { const cwd = normalize(folder.uri.fsPath) const vitest = resolveVitestPackage(cwd, folder) if (!vitest) return @@ -181,6 +193,16 @@ function resolveVitestWorkspacePackages(showWarning: boolean) { const id = normalize(folder.uri.fsPath) const prefix = `${basename(cwd)}:${basename(id)}` const runtime = guessRuntime(cwd, folder) + + if (runtime === 'bun') { + const bunVersion = await getBunVersion(cwd) + if (bunVersion && !(await validateBunVersion(bunVersion, cwd, showWarning))) { + log.error('[API]', `Skipping ${cwd} due to unsupported Bun version.`) + warned = true + return + } + } + meta.push({ folder, id, @@ -192,7 +214,10 @@ function resolveVitestWorkspacePackages(showWarning: boolean) { runtime, name: vitest.packageName, }) - }) + } + + await Promise.all(vscode.workspace.workspaceFolders?.map(processFolder) ?? []) + return { meta, warned, @@ -280,16 +305,18 @@ async function resolveVitestWorkspaceConfigs() { if (vitestWorkspaces.length) { // if there is a workspace config, use it as root + const resolved = await Promise.all( + vitestWorkspaces.map((config) => + resolveVitestConfig( + /* don't show warnings for workspaces because they have limited support */ false, + config, + ), + ), + ) const meta = resolvePackagUniquePrefixes( - vitestWorkspaces - .map((config) => { - const vitest = resolveVitestConfig( - /* don't show warnings for workspaces because they have limited support */ false, - config, - ) - if (!vitest) { - return null - } + resolved + .filter(nonNullable) + .map((vitest) => { // Version 4 doesn't support workspace files if (gte(vitest.version, '4.0.0')) { return null @@ -348,8 +375,8 @@ async function resolveVitestConfigs(showWarning: boolean) { const filteredConfigFiles = hasViteAndVitestConfig ? configFiles.filter((file) => !basename(file.fsPath).includes('vite.')) : configFiles - filteredConfigFiles.forEach((config) => { - const vitest = resolveVitestConfig(showWarning, config) + for (const config of filteredConfigFiles) { + const vitest = await resolveVitestConfig(showWarning, config) if (vitest) { resolvedMeta.push({ ...vitest, @@ -358,7 +385,7 @@ async function resolveVitestConfigs(showWarning: boolean) { } else { warned = true } - }) + } } return { @@ -367,7 +394,7 @@ async function resolveVitestConfigs(showWarning: boolean) { } } -function guessRuntime(cwd: string, folder: vscode.WorkspaceFolder): 'deno' | 'node' { +function guessRuntime(cwd: string, folder: vscode.WorkspaceFolder): 'deno' | 'node' | 'bun' { const vitestConfig = getConfig(folder) if (vitestConfig.runtime !== 'auto') { return vitestConfig.runtime @@ -379,6 +406,13 @@ function guessRuntime(cwd: string, folder: vscode.WorkspaceFolder): 'deno' | 'no if (existsSync(resolve(cwd, 'deno.json'))) { return 'deno' } + if ( + existsSync(resolve(cwd, 'bun.lock')) || + existsSync(resolve(cwd, 'bun.lockb')) || + existsSync(resolve(cwd, 'bunfig.toml')) + ) { + return 'bun' + } return 'node' } diff --git a/packages/extension/src/spawn/terminal.ts b/packages/extension/src/spawn/terminal.ts index 0c25b5b0..0cf05a4a 100644 --- a/packages/extension/src/spawn/terminal.ts +++ b/packages/extension/src/spawn/terminal.ts @@ -63,6 +63,9 @@ export async function createVitestTerminalProcess( if (pkg.runtime === 'deno') { command += ' -A' } + if (pkg.runtime === 'bun') { + command += ' --bun' + } command += ` ${workerPath};` log.info('[TERMINAL]', `Initiated ws connection via ${wsAddress}`) diff --git a/packages/extension/src/utils.ts b/packages/extension/src/utils.ts index a86f935f..f1b68b0a 100644 --- a/packages/extension/src/utils.ts +++ b/packages/extension/src/utils.ts @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process' import fs from 'node:fs' import { inspect, stripVTControlCharacters } from 'node:util' import { dirname, relative } from 'pathe' +import { gte } from 'semver' import * as vscode from 'vscode' import which from 'which' import { getConfig } from './config' @@ -32,6 +33,47 @@ export function pluralize(count: number, singular: string) { return `${count} ${singular}${count === 1 ? '' : 's'}` } +export async function getBunVersion(cwd: string): Promise { + const executable = await findRuntimeExecutable('bun', cwd).catch(() => null) + if (!executable) return null + + return new Promise((resolve) => { + const child = spawn(executable, ['--version'], { + cwd, + stdio: 'pipe', + }) + + let output = '' + child.stdout.on('data', (data) => (output += data.toString())) + child.on('error', () => resolve(null)) + child.on('exit', (exitCode) => { + if (exitCode !== 0) return resolve(null) + const version = output.trim() + if (!version) return resolve(null) + resolve(version) + }) + }) +} + +export async function validateBunVersion( + bunVersion: string, + cwd: string, + showWarning: boolean, +): Promise { + const { minimumBunVersion } = await import('./constants') + if (gte(bunVersion, minimumBunVersion)) { + return true + } + + const message = `Bun v${bunVersion} is not supported. Bun v${minimumBunVersion} or newer is required for Vitest support.` + if (showWarning) { + vscode.window.showWarningMessage(message) + } else { + log.error('[API]', message) + } + return false +} + export function debounce void>(cb: T, wait = 20) { let h: NodeJS.Timeout | undefined const callable = (...args: any) => { @@ -57,13 +99,14 @@ export function waitUntilExists(file: string, timeoutMs = 5000) { } const pathToRuntime: { + bun?: string deno?: string node?: string } = {} // based on https://github.com/microsoft/playwright-vscode/blob/main/src/utils.ts#L144 export async function findRuntimeExecutable( - runtime: 'node' | 'deno', + runtime: 'node' | 'deno' | 'bun', cwd: string, ): Promise { if (getConfig().nodeExecutable) @@ -94,7 +137,7 @@ export async function findRuntimeExecutable( return node } -async function findRuntimeViaShell(runtime: 'node' | 'deno', cwd: string): Promise { +async function findRuntimeViaShell(runtime: 'node' | 'deno' | 'bun', cwd: string): Promise { if (process.platform === 'win32') return null return new Promise((resolve) => { const startToken = '___START_SHELL__' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e1cd884..fa6f1777 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -143,7 +143,7 @@ export interface WorkerInitMetadata { id: string cwd: string arguments?: string - runtime: 'node' | 'deno' + runtime: 'node' | 'deno' | 'bun' configFile?: string workspaceFile?: string env: Record | undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 261fb98b..b1e47b3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,6 +405,12 @@ importers: specifier: catalog:latest version: 4.1.0(@types/node@24.10.1)(@vitest/browser-playwright@4.1.0)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + samples/bun: + devDependencies: + vitest: + specifier: catalog:v3 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.1.1)(tsx@4.21.0)(yaml@2.8.2) + samples/continuous: devDependencies: vitest: diff --git a/samples/bun/package.json b/samples/bun/package.json new file mode 100644 index 00000000..f857273a --- /dev/null +++ b/samples/bun/package.json @@ -0,0 +1,14 @@ +{ + "name": "bun-sample", + "version": "1.0.0", + "private": true, + "description": "Sample project using Bun runtime with Vitest", + "license": "ISC", + "scripts": { + "test": "bun --bun vitest run", + "test:watch": "bun --bun vitest" + }, + "devDependencies": { + "vitest": "catalog:v3" + } +} diff --git a/samples/bun/src/index.ts b/samples/bun/src/index.ts new file mode 100644 index 00000000..88cc3c1f --- /dev/null +++ b/samples/bun/src/index.ts @@ -0,0 +1,7 @@ +export function greet(name: string): string { + return `Hello, ${name}!` +} + +export function add(a: number, b: number): number { + return a + b +} diff --git a/samples/bun/test/index.test.ts b/samples/bun/test/index.test.ts new file mode 100644 index 00000000..61eb359e --- /dev/null +++ b/samples/bun/test/index.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { greet, add } from '../src/index' + +describe('greet', () => { + it('should greet by name', () => { + expect(greet('World')).toBe('Hello, World!') + }) + + it('should handle empty string', () => { + expect(greet('')).toBe('Hello, !') + }) +}) + +describe('add', () => { + it('should add two numbers', () => { + expect(add(1, 2)).toBe(3) + }) + + it('should handle negative numbers', () => { + expect(add(-1, -2)).toBe(-3) + }) +}) diff --git a/samples/bun/vitest.config.ts b/samples/bun/vitest.config.ts new file mode 100644 index 00000000..a0546216 --- /dev/null +++ b/samples/bun/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + pool: 'forks', + }, +})