Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
59 changes: 59 additions & 0 deletions .github/workflows/vitest-pool-bun.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: 'vitest-pool-bun'

permissions: {}
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'vitest-pool-bun/**'
- '.github/workflows/vitest-pool-bun.yml'
pull_request:
paths:
- 'vitest-pool-bun/**'
- '.github/workflows/vitest-pool-bun.yml'

concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true

jobs:
tests:
timeout-minutes: 10
runs-on: ubuntu-latest
defaults:
run:
working-directory: vitest-pool-bun
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup NodeJS
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14

- name: Install dependencies
run: |
npm install
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

- name: Run tests (green example suite in Bun)
run: |
npm run demo

- name: Install dependencies (clickhouse-js root)
working-directory: .
run: |
npm install

- name: Run selected Node unit tests in Bun pool
working-directory: .
env:
CLICKHOUSE_TEST_SKIP_INIT: '1'
run: |
node vitest-pool-bun/node_modules/vitest/vitest.mjs run --config vitest-pool-bun/examples/clickhouse-node-unit.config.mjs
28 changes: 28 additions & 0 deletions vitest-pool-bun/ENVIRONMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Environment

Exact versions used to develop and validate this proof of concept. Other
versions may work but are unverified.

| Component | Version | Role |
| --------- | ------- | ---- |
| Node.js | `v24.16.0` (host requires `>= 20`) | Host / orchestrator: owns the Vite transform pipeline, scheduling, reporters |
| Bun | `1.3.14` (requires `>= 1.2`, `1.3+` preferred) | Worker runtime: evaluates modules and runs the test callbacks |
| vitest | `4.1.8` (requires `^4.1`) | Custom-pool API (`vitest/node`, `vitest/worker`) |
| flatted | `3.4.2` | JSON-safe (circular-ref-tolerant) serialization across the Node ↔ Bun IPC channel |

## Reproducing the version check

```bash
node --version # v24.16.0
bun --version # 1.3.14
node -e 'console.log(require("vitest/package.json").version)' # 4.1.8
```

## Notes

- The **host process is always launched with `node`** (`node node_modules/vitest/vitest.mjs run`). Only the per-file worker is `bun`.
- Package manager: installing with `bun`, `npm`, or `pnpm` is fine; it does not
affect which runtime executes tests.
- Targets **macOS / Linux only**. Windows is an explicit non-goal for the PoC.
- The `bun` executable must be discoverable. Resolution order:
`bunBinary` pool option → `BUN_BINARY` env var → `bun` on `PATH`.
140 changes: 140 additions & 0 deletions vitest-pool-bun/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# vitest-pool-bun

> **Proof of concept.** A custom [Vitest](https://vitest.dev) 4.1 pool that
> executes test files **inside the [Bun](https://bun.sh) runtime**, analogous to
> how `@cloudflare/vitest-pool-workers` executes them inside `workerd`.

The **Node host** owns the Vite transform pipeline (transforming modules,
scheduling, collecting results, running reporters); **Bun** owns test execution
(evaluating the transformed modules and running the test callbacks). The two
roles talk over a JSON-only IPC channel.

```
┌────────────────────────┐ WorkerRequest / RPC (JSON IPC) ┌─────────────────────────┐
│ Node host (Vitest) │ ───────────────────────────────► │ Bun worker │
│ • Vite transform │ │ • vitest/worker init() │
│ • scheduling │ ◄─────────────────────────────── │ • module runner │
│ • reporters │ WorkerResponse / module code │ • runs test callbacks │
└────────────────────────┘ └─────────────────────────┘
src/pool-worker.ts (BunPoolWorker) src/worker-entry.ts
```

## Why this works (and what made it non-trivial)

- Bun natively executes TypeScript and imports from disk, so almost none of the
elaborate module-fallback machinery that the `workerd` pool needs is required.
- Vitest's plain (non-`vm`) pool evaluates modules with Vite's
AsyncFunction-based `ESModulesEvaluator`, **not** `node:vm`. Bun's `vm` is only
~90% conformant, but the AsyncFunction evaluator works cleanly under Bun — this
is the single most important reason the PoC is viable (de-risked in Phase 0).
- Node ↔ Bun IPC only supports JSON serialization. All channel traffic is routed
through `flatted` so circular object graphs and Vitest's serialized errors
survive a JSON-only channel. See [`src/channel.ts`](./src/channel.ts).

## Requirements

See [`ENVIRONMENT.md`](./ENVIRONMENT.md) for pinned versions. In short: Node
`>= 20` (host), Bun `>= 1.2` (worker), `vitest@^4.1`. macOS / Linux only.

## Install

```bash
# from this package directory
npm install
# Bun must be installed and on PATH (https://bun.sh)
bun --version
```

## Configure

Minimal config via the helper:

```ts
// vitest.config.ts
import { defineBunPoolConfig } from 'vitest-pool-bun'

export default defineBunPoolConfig({
test: {
include: ['tests/**/*.test.ts'],
},
})
```

Or wire the pool directly:

```ts
import { defineConfig } from 'vitest/config'
import { bunPool } from 'vitest-pool-bun'

export default defineConfig({
test: {
pool: bunPool({
// bunBinary: '/custom/path/to/bun', // defaults to BUN_BINARY env or `bun`
// bunArgs: ['--smol'],
// env: { MY_FLAG: '1' },
}),
},
})
```

## Run

The host is launched with **node**; the pool spawns **bun** per test file:

```bash
node node_modules/vitest/vitest.mjs run
```

### Demo scripts

| Script | What it shows |
| ------ | ------------- |
| `npm run demo` | Green example suite executed in Bun (9 passing tests) |
| `npm run demo:failure` | A failing assertion reported with a stack trace pointing at the original `.ts` line |
| `npm run demo:diagnostics` | A crashed Bun worker surfaced as a clear host error (not a hang) |
| `npm run spike` | The throwaway Phase 0 de-risk spike (`bun run spikes/m0-runner.ts`) |

`npm run demo` performs a clean `build` first, so a fresh clone only needs
`npm install` followed by `npm run demo`.

## Options (`BunPoolOptions`)

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `bunBinary` | `string` | `BUN_BINARY` env, else `bun` | Path or command for the Bun executable |
| `bunArgs` | `string[]` | `[]` | Extra args passed to `bun run <worker-entry>` |
| `env` | `Record<string, string \| undefined>` | `{}` | Extra env vars for the spawned worker |

## Layout

```
vitest-pool-bun/
├─ src/
│ ├─ index.ts # bunPool() + defineBunPoolConfig()
│ ├─ pool-worker.ts # BunPoolWorker (host side, runs under Node)
│ ├─ worker-entry.ts # runs under Bun; calls init() from 'vitest/worker'
│ ├─ channel.ts # flatted-based JSON-safe (de)serialization
│ └─ types.ts # BunPoolOptions
├─ spikes/m0-runner.ts # Phase 0 de-risk spike (throwaway)
├─ examples/ # runnable example suite + configs
├─ ENVIRONMENT.md # pinned versions
└─ VALIDATION.md # what works / partially works / unsupported
```

## Known limitations

This is a proof of concept. See [`VALIDATION.md`](./VALIDATION.md) for the full
matrix. Highlights:

- **V8 coverage is unsupported** under Bun (`node:inspector` lacks the Profiler
APIs). Use the `istanbul` provider, which works.
- Isolation uses **one Bun process per file** (or a reused process when
`isolate: false`). Thread isolation is intentionally not used: Bun's
`worker_threads` cannot access the parent IPC channel.
- No watch/HMR rerun loop, browser mode, type-testing, or Windows support.
- Performance tuning, worker-reuse pooling, and full `vi.mock` parity are out of
scope for the PoC.

## License

Apache-2.0.
79 changes: 79 additions & 0 deletions vitest-pool-bun/VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Validation

What was actually exercised against this PoC, and the boundaries. Versions are
pinned in [`ENVIRONMENT.md`](./ENVIRONMENT.md) (Node 24, Bun 1.3.14,
vitest 4.1.8). Everything below was observed by running the host under `node`
and executing tests in `bun` via this pool.

Legend: ✅ works · ⚠️ works with caveats · ❌ unsupported · ⛔ not attempted (non-goal)

## Core execution

| Capability | Status | Notes |
| ---------- | :----: | ----- |
| Sync tests | ✅ | `examples/tests/smoke.test.ts` |
| Async / `await` tests | ✅ | `examples/tests/async.test.ts`, `concurrent.test.ts` |
| `describe` / nested suites | ✅ | `examples/tests/concurrent.test.ts` |
| Importing local TS modules | ✅ | Resolved + transformed by the host module runner over the channel |
| Importing `node_modules` deps | ✅ | e.g. `vitest` matchers; externalized/transformed via host |
| `setupFiles` | ✅ | `examples/setup.ts` runs in the Bun worker |
| Pass/fail reporting to standard reporters | ✅ | Totals are correct across multiple files |
| Failing-assertion stack traces | ✅ | Point at the original `.ts` source line (host-side Vite source maps); see `npm run demo:failure` |
| Bun globals (`Bun`, `Bun.file`, `Bun.env`, `Bun.version`) | ✅ | `examples/tests/bun-api.test.ts` |

## Isolation & lifecycle

| Capability | Status | Notes |
| ---------- | :----: | ----- |
| Process-per-file isolation (`isolate: true`, default) | ✅ | One spawned `bun` per file; Vitest's scheduler owns this |
| Reused-process mode (`isolate: false`) | ✅ | Correct totals; runner reuse handled by Vitest |
| No leaked Bun processes after a run | ✅ | Verified via `pgrep` after exit, both isolation modes |
| Thread isolation (`worker_threads`) | ❌ | Intentionally not used — Bun threads can't access the parent IPC channel |

## Diagnostics (failure legibility)

| Scenario | Status | Behaviour |
| -------- | :----: | --------- |
| `bun` not found / bad `bunBinary` | ✅ | Fails fast with an actionable message naming `bunBinary` / `BUN_BINARY`; no hang |
| Soft crash (`process.exit` in a test) | ✅ | Reported as a test error with TS-mapped line |
| Hard crash / channel disconnect (worker `SIGKILL`'d mid-run) | ✅ | Surfaced as `[vitest-pool]: Worker bun emitted error. Caused by: Worker exited unexpectedly`; no indefinite hang |

## Test-author features

| Feature | Status | Notes |
| ------- | :----: | ----- |
| Spies (`vi.fn`, `toHaveBeenCalledWith`) | ✅ | Verified |
| Fake timers (`vi.useFakeTimers`, `advanceTimersByTime`) | ✅ | Verified |
| Snapshots (inline & file) | ⚠️ | Mechanism works; not stress-tested for format parity across runtimes (a PoC non-goal) |
| Module mocking (`vi.mock` hoisting) | ⚠️ | Basic factory hoisting verified to work; full `vi.mock` parity is **not** guaranteed and edge cases are untested |

## Coverage

| Provider | Status | Notes |
| -------- | :----: | ----- |
| `istanbul` (`@vitest/coverage-istanbul`) | ✅ | Works — instrumentation happens at transform time on the host, counters are plain JS collected in Bun. Reported 100% on the example source |
| `v8` (`@vitest/coverage-v8`) | ❌ | Fails with `Coverage APIs are not supported` — Bun's `node:inspector` lacks the Profiler/coverage APIs |

## Explicit non-goals (not attempted)

| Area | Status |
| ---- | :----: |
| Watch mode / HMR rerun loop | ⛔ |
| Browser mode | ⛔ |
| Type testing (`expect-type` / `*.test-d.ts`) | ⛔ |
| Windows support | ⛔ |
| Performance / parallelism tuning, worker-reuse pooling | ⛔ |

## Performance note

Independent benchmarks report Bun (JavaScriptCore) can be slower than Node (V8)
on heavily async test workloads. This is a performance characteristic, not a
correctness issue, and was **not** benchmarked here. Treat it as a known caveat
rather than a guarantee in either direction.

## Topology used

The PoC uses the **target topology**: a Node host driving a spawned-process Bun
worker over an IPC channel (not the §7 fallback of running the entire Vitest
process under `bun`). The Phase 0 spike confirmed module evaluation works under
Bun before any pool plumbing was built, so the fallback was never needed.
26 changes: 26 additions & 0 deletions vitest-pool-bun/examples/clickhouse-node-unit.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { fileURLToPath } from 'node:url'
import { defineBunPoolConfig } from '../dist/index.js'

export default defineBunPoolConfig({
test: {
root: fileURLToPath(new URL('../..', import.meta.url)),
include: [
'packages/client-node/__tests__/unit/node_user_agent.test.ts',
'packages/client-node/__tests__/unit/node_default_logger.test.ts',
],
watch: false,
},
resolve: {
alias: {
'@clickhouse/client-common': fileURLToPath(
new URL('../../packages/client-common/src', import.meta.url),
),
'@clickhouse/client-node': fileURLToPath(
new URL('../../packages/client-node/src', import.meta.url),
),
'@test': fileURLToPath(
new URL('../../packages/client-common/__tests__', import.meta.url),
),
},
},
})
10 changes: 10 additions & 0 deletions vitest-pool-bun/examples/diagnostics.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineBunPoolConfig } from '../dist/index.js'

// Separate config so the crash fixture is never picked up by the demo suite.
export default defineBunPoolConfig({
test: {
root: __dirname,
include: ['diagnostics/**/*.test.ts'],
watch: false,
},
})
15 changes: 15 additions & 0 deletions vitest-pool-bun/examples/diagnostics/crash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { expect, test } from 'vitest'

// Diagnostics fixture (NOT part of the default demo suite).
//
// When BUN_POOL_CRASH=1, the test abruptly kills the Bun worker mid-run to
// simulate a runtime crash / channel disconnect. The host (`BunPoolWorker` +
// Vitest's PoolRunner) must surface a clear, actionable error and terminate the
// run instead of hanging indefinitely (Phase 4 / STOP gate 4).
test('worker crash is surfaced, not hung', () => {
if (process.env.BUN_POOL_CRASH === '1') {
// Hard-kill this Bun process while the host is waiting for results.
process.exit(137)
}
expect(true).toBe(true)
})
11 changes: 11 additions & 0 deletions vitest-pool-bun/examples/expected-failures.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineBunPoolConfig } from '../dist/index.js'

// Separate config so the intentionally-failing fixture is never part of the
// green demo suite. Used to demonstrate failure reporting (STOP gate 2).
export default defineBunPoolConfig({
test: {
root: __dirname,
include: ['expected-failures/**/*.test.ts'],
watch: false,
},
})
12 changes: 12 additions & 0 deletions vitest-pool-bun/examples/expected-failures/assertion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from 'vitest'
import { add } from '../src/math.js'

// Intentionally failing test (NOT part of the green demo suite).
//
// Demonstrates STOP gate 2: a failing assertion is reported as a real failure
// whose stack trace points at the original TypeScript source line below — the
// `expect(...)` call — proving host-side Vite source maps survive the Bun round
// trip. Run with `npm run demo:failure` (expected to exit non-zero).
test('failing assertion reports a TS-mapped stack', () => {
expect(add(2, 2)).toBe(5)
})
3 changes: 3 additions & 0 deletions vitest-pool-bun/examples/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Runs inside the Bun worker before each test file (Vitest `setupFiles`).
// Used by Phase 2 to prove setup files are executed in the Bun runtime.
;(globalThis as Record<string, unknown>).__BUN_POOL_SETUP__ = true
Loading
Loading