vitest-probe
v0.0.8
Published
A tree-shakable probe statement for vitest
Readme
vitest-probe
A tiny probe you can drop into your codebase to observe internals in tests—without breaking encapsulation or shipping debug code to production.
Just sprinkle // #probe('label', expr) comments in your code where you want visibility.
Then in tests, use getProbe({ … }) to read a scoped, timeout-safe stream of those emissions.
- Parallel-safe via
AsyncLocalStorage: each test can isolate its own scope withprobe.run(...). - Zero prod overhead & deps: the directive is enabled only in tests; no runtime import is needed in app code.
Why?
Unit tests often need to “peek” at intermediate values or call non-public helpers. Exposing internals just for tests or writing elaborate harnesses creates maintenance drag.
This library gives you assertion-like tracepoints that:
- stay purely observational
- are scoped to the current test
- are compiled away in production bundles
Install
npm i -D vitest-probe
# or
pnpm add -D vitest-probe
# or
yarn add -D vitest-probeRequires Node 16+ (uses
AsyncLocalStorage). Designed for Vitest/Jest (Node env).
Quick start
1) Enable the directive in Vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { probeDirective } from 'vitest-probe'
export default defineConfig({
test: { environment: 'node' },
plugins: [probeDirective.vite()],
})2) Instrument your code with directive comments
// src/my-service.ts
export class MyService {
async doStuff(n: number) {
const mid = n * 2
// #probe('mid', mid)
await new Promise(r => setTimeout(r, 5))
const done = mid + 1
// #probe('done', done)
return done
}
}3) Consume emissions in tests (parallel-safe)
// tests/my-service.test.ts
import { it, expect } from 'vitest'
import { MyService } from '../src/my-service'
import { getProbe } from 'vitest-probe'
it.concurrent('emits scoped values', async () => {
const svc = new MyService()
const probe = getProbe<{ label: 'mid'|'done'; value: number }>({ timeoutMs: 200 })
await probe.run(async () => {
const p = svc.doStuff(10)
expect(await probe.next()).toEqual({ label: 'mid', value: 20 })
expect(await probe.next()).toEqual({ label: 'done', value: 21 })
await p
})
probe.dispose()
})Each getProbe() instance uses a unique AsyncLocalStorage scope; only emissions produced within its probe.run(...) block are delivered to that probe—so it.concurrent(...) stays clean.
API
Directive: // #probe(label, value)
Adds a probe emission at build time (only when the directive plugin is active, e.g., in tests). No imports required in your app code.
Tips
Treat labels as a typed union in your module for refactor safety:
export type ParseLabel = 'parse:tokens'|'parse:ast'|'parse:done'Avoid emitting secrets/PII. Redact if needed.
getProbe(options?): Probe
Create a probe bound to a unique async scope.
Options:
timeoutMs?: number– default timeout fornext()(default 1000ms).filter?: (e) => boolean– per-probe filter for events.bufferSize?: number– max buffered events (100). Oldest are dropped if exceeded.
Returns a Probe:
await probe.next(timeoutMs?)
Resolves with the next { label, value } emitted within the probe’s scope. Rejects on timeout.
const e = await probe.next() // uses default timeout
const e2 = await probe.next(500) // override per callprobe.run(fn)
Run fn inside the probe’s AsyncLocalStorage scope. Only emissions within this call chain are delivered to this probe.
await probe.run(async () => {
await svc.doWork()
})Do not nest different probes’
run()around the same code region; only the innermost scope receives events.
probe[Symbol.asyncIterator]()
Use as an async iterator (infinite; prefer next() with explicit expectations):
for await (const e of probe) {
// break once you’ve asserted what you need
break
}probe.dispose()
Unsubscribe and reject any pending next() calls. Call in afterEach() to avoid leaks.
Patterns & examples
Filter by label
const probe = getProbe({ filter: e => e.label === 'ast', timeoutMs: 200 })
await probe.run(async () => {
await parser.parse('a,b,c')
expect(await probe.next()).toEqual({ label: 'ast', value: expect.any(Object) })
})
probe.dispose()Parallel tests
it.concurrent('A', async () => {
const probe = getProbe({ timeoutMs: 200 })
await probe.run(async () => {
await svc.doStuff(10)
expect(await probe.next()).toEqual({ label: 'mid', value: 20 })
})
probe.dispose()
})
it.concurrent('B', async () => {
const probe = getProbe({ timeoutMs: 200 })
await probe.run(async () => {
await svc.doStuff(5)
expect(await probe.next()).toEqual({ label: 'mid', value: 10 })
})
probe.dispose()
})Typed labels, end-to-end
// src/math.ts
export type MathLabel = 'integrate:area'|'integrate:sum'
export function integrate(...) {
__TEST__ && probeEmit<MathLabel, unknown>('integrate:sum', acc)
}// tests/math.test.ts
const probe = getProbe<{ label: MathLabel; value: number }>()TypeScript types
This package is written in TypeScript and ships types. You can narrow labels via generics:
const probe = getProbe<{ label: 'ast'|'done'; value: unknown }>()Advanced configuration
By default #probe directive is transformed to __PROBE__('mid', mid) at build time, where __PROBE__ is a named import for probeEmit.
You can configure the transformation with the probeDirective plugin:
import { defineConfig } from 'vitest/config'
import { probeDirective } from 'vitest-probe'
export default defineConfig({
test: { environment: 'node' },
define: { __TEST__: true }, // enables probe during tests
plugins: [probeDirective.vite({
probeIdent: '__PROBE_EMIT__', //code will be transformed to __PROBE_EMIT__('foo', bar)
directive: '#observe', // parser will look for #observe('foo', bar)
include: 'src/controllers',
exclude: 'src/controllers/health',
keepDefaultExcludes: false, //dont exclude node_modules and virtual modules, defaults to true
})],
})License
MIT © Nicola Dal Pont
