use-browser
v0.1.1
Published
Run bun:test files inside a real headless browser by adding a single "use browser" directive — zero external browser-automation dependencies.
Maintainers
Readme
use-browser
Run bun:test files inside a real headless browser by adding a single directive to the top of the file. The same directive runs a plain script in the browser with bun <file> — see Run mode.
Quick start
1. Install
bun install use-browser2. Add the preload to bunfig.toml
Create (or update) bunfig.toml at the root of your project:
[test]
preload = ["use-browser/preload"]This registers a Bun.plugin hook that rewrites any test file starting with "use browser" so it runs in a Bun.WebView (WebKit on macOS, Chromium elsewhere). Files without the directive are untouched and run on the host as usual.
3. Add "use browser" to a test file
// counter.test.ts
"use browser";
import { describe, expect, test } from "bun:test";
describe("counter widget", () => {
test("increments when clicked", () => {
document.body.innerHTML = `<button id="counter">0</button>`;
const button = document.querySelector<HTMLButtonElement>("#counter")!;
let count = 0;
button.addEventListener("click", () => {
count++;
button.textContent = String(count);
});
button.click();
button.click();
expect(button.textContent).toBe("2");
});
});4. Run your tests
bun testThat's it — document, window, CSSOM, requestAnimationFrame, and the rest of the platform are real. describe / test / expect / hooks all work exactly as they do under bun:test.
console.log from inside the browser is forwarded to your terminal by default. Set BTR_FORWARD_CONSOLE=0 to silence it.
Errors outside the test's call stack
An error thrown from a setTimeout callback, an event listener, or a promise nobody awaited fails the test that is running, with that error. This covers the case a done-style test would otherwise hide:
test("fails with the real assertion, not a timeout", (done) => {
setTimeout(() => {
expect(value).toBe(440); // throws into the event loop — done() is never reached
done();
}, 10);
});The harness reports AssertionError: expected 0 to be 440 immediately instead of stalling until the 5s timeout. An error that escapes while no test is running is printed to the console rather than dropped.
Failures point at your source line
Browser stacks name positions inside the bundle. Every failure is mapped back through the bundle's source map, so bun test draws its code frame on the line you wrote:
9 | expect(heading.textContent).toBe("BTR_EXPECTED_TEXT");
^
AssertionError: expected "hello" to be "BTR_EXPECTED_TEXT"
at <anonymous> (/repo/example/dom.test.ts:9:16)When the assertion lives in a helper module, the mapped frames are added to the message and the code frame points at the call site in your test file.
More examples
See example/ for a counter test, a DOM/platform-API test, a Web Audio test, and a host-side test sitting side by side.
Screenshots
screenshot() captures whatever is on the page at the moment you call it. It
takes no arguments, and it needs no context object:
"use browser";
import { expect, test } from "bun:test";
import { screenshot } from "use-browser/context";
test("renders the empty state", async () => {
document.body.innerHTML = `<p class="empty">Nothing here yet</p>`;
const path = await screenshot();
expect(path).toContain("renders_the_empty_state");
// → test-results/my_component_test-renders_the_empty_state-1.png
});The page cannot capture itself, so the call travels to the host over a loopback
control channel the runner starts for the length of the run. The host captures
its Bun.WebView, writes the file, and answers with the path. Your await
resolves after the file is written, so the image shows the DOM as it was at the
call — not as it looks when the test ends.
| Call | Writes |
|---|---|
| screenshot() | test-results/<file>-<test>-<n>.png |
| screenshot("hero") | test-results/hero.png |
| screenshot({ name: "hero", format: "jpeg", quality: 90 }) | test-results/hero.jpg |
Every call returns the path the host wrote. Auto-generated names carry a
counter, so two calls in one test write two files. format accepts png
(default), jpeg, and webp — webp requires the Chrome backend.
BTR_SCREENSHOTS_DIR moves the whole run somewhere other than
./test-results.
page.screenshot() is the same function, for code that already holds the
page helper. A "use browser" script imports it from use-browser/script
instead:
"use browser";
import { screenshot } from "use-browser/script";
document.body.innerHTML = "<h1>ready</h1>";
console.log("wrote", await screenshot("ready"));screenshot() throws when the page has no control channel — a page you drive
yourself through Bun.WebView has to call view.screenshot() instead.
This is separate from the screenshot the runner writes on its own when a test or a script fails. That one is best-effort and never masks the failure.
Run mode — bun <file>
The directive is not only for tests. A plain script that starts with "use browser" runs in the browser too, with bun <file>.
1. Add the run preload to bunfig.toml
preload = ["use-browser/run"]
[test]
preload = ["use-browser/preload"]The two entries never collide. bun test loads only the [test] preload, and bun <file> loads only the top-level one.
2. Write a script
// render.ts
"use browser";
import { greeting } from "./greeting";
const el = document.createElement("h1");
el.textContent = greeting("browser");
document.body.append(el);
console.log("rendered:", document.querySelector("h1")?.textContent);bun render.tsYour file never runs on the host. It is bundled with everything it imports, the bundle is loaded into a Bun.WebView, and the run ends when module evaluation settles — top-level await included. console.log is forwarded to your terminal, and the exit code is 0.
A throw exits 1 and prints a stack mapped back to your own source:
before the throw
Error: script blew up
at boom (/abs/path/throws.ts:4:12)
at /abs/path/throws.ts:8:1
screenshot: ./test-results/throws.png3. Keep a long-lived script alive
A script that installs a timer, an event listener, or an audio graph keeps working after module evaluation settles. Such a script says so, and says when it is done:
"use browser";
import { exit, keepAlive } from "use-browser/script";
keepAlive();
const ctx = new AudioContext();
const osc = ctx.createOscillator();
osc.connect(ctx.destination);
osc.start();
setTimeout(() => {
osc.stop();
console.log("played for 1s at", ctx.sampleRate, "Hz");
exit();
}, 1_000);| Export | Description |
|---|---|
| keepAlive() | Keeps the run open after module evaluation settles. Call it during module evaluation. |
| exit(code?) | Ends the run. code becomes the exit code of the bun <file> process and defaults to 0. |
| screenshot(name?) | Captures the page as it looks now and resolves with the path written. Also exported from use-browser/context. |
keepAlive() and exit() are script-only. Calling either from a bun test browser test throws an error that names the limitation. screenshot() works in both modes.
An error that escapes into the event loop — a throw inside a listener, a rejected floating promise — ends the run and exits 1, so a broken long-lived script never hangs. BTR_RUN_TIMEOUT_MS puts an upper bound on a run that would otherwise never end.
Without the directive
--useBrowser runs the entry file in the browser without touching its source:
bun index.ts --useBrowserRun mode and test mode
| | bun test | bun <file> |
|---|---|---|
| Preload | [test] preload = ["use-browser/preload"] | preload = ["use-browser/run"] |
| Files rewritten | *.test.* / *.spec.* carrying the directive | any file carrying the directive, except under node_modules |
| bun:test in the page | describe / test / expect shim | not used — a script has no tests |
| Reported as | one host-side test per browser test | one exit code |
| Ends when | the last test finishes | module evaluation settles, or exit() is called |
| Snapshots | __snapshots__/<file>.snap | not used |
bun:test API support
Inside a "use browser" file, bun:test resolves to a shim that runs in the
page. The shim implements the whole bun:test surface: all 84 matchers, the
asymmetric matchers, .resolves / .rejects, mocks and spies, fake timers,
every test / describe modifier, and file-backed snapshots.
A compile-time guard in src/browser/parity.ts
compares the shim against Bun's own type declarations, so a missing matcher
breaks bun run typecheck instead of your test run. A matcher name the shim
does not know throws an error that names it.
| Area | Support |
|---|---|
| Matchers, negation, expect(value, "label") | full |
| expect.any / anything / arrayContaining / objectContaining / stringContaining / stringMatching / closeTo, and expect.not.* | full |
| .resolves / .rejects | full |
| expect.assertions / hasAssertions / unreachable / extend / addSnapshotSerializer | full |
| mock(), jest.fn(), vi.fn(), spyOn(), mock.restore(), mock.clearAllMocks() | full |
| Mock matchers (toHaveBeenCalledWith, toHaveReturnedTimes, …) | full |
| Fake timers (useFakeTimers, advanceTimersByTime, runAllTimers, setSystemTime, …) | full |
| test / describe modifiers: .skip, .only, .todo, .failing, .each, .if, .skipIf, .todoIf | full |
| TestOptions (timeout, retry, repeats), setDefaultTimeout, onTestFinished | full |
| toMatchSnapshot, toThrowErrorMatchingSnapshot | full — the host reads and writes __snapshots__/<file>.snap |
| toMatchInlineSnapshot, toThrowErrorMatchingInlineSnapshot | not supported — writing one back means editing your source file from inside the page |
| mock.module() | not supported — the page bundle is built before it runs, so exports cannot be swapped. Inject the dependency, or spyOn() the imported object |
| expect.resolvesTo.* / expect.rejectsTo.* | not supported — matching a promise needs an async comparison inside a synchronous equality walk |
| test.concurrent / test.serial | accepted, but the page runs one test at a time |
| expectTypeOf | runtime no-op — type assertions are checked by tsc |
Each unsupported member throws a message that names the limitation, rather
than failing as undefined is not a function.
Snapshots update with bun test --update-snapshots, or with
BTR_UPDATE_SNAPSHOTS=1.
Environment variables
All knobs are read from process.env on the host side. Set them when invoking
bun test or bun <file>:
| Variable | Default | Description |
|---|---|---|
| BTR_FORWARD_CONSOLE | on | Browser-side console.* output is piped to the host process so logs show up in bun test. Internal __BTR__: sentinel lines are filtered out. Set to 0 or false to drop browser output instead. |
| BTR_POOL_SIZE | 1 | Maximum number of Bun.WebView instances kept warm across files. Increasing trades memory for parallelism — the pool is reused across leases for per-file isolation via fresh navigations. |
| BTR_BACKEND | webkit on macOS, chrome elsewhere | Force a specific backend (webkit or chrome). |
| BTR_CONSOLE_DEPTH | 3 | Max nesting depth used when the in-browser console patch snapshots host objects (AudioContext, DOM nodes, …) for forwarded console.* output. |
| BTR_UPDATE_SNAPSHOTS | off | Set to 1 to rewrite every snapshot the run compares. Equivalent to passing --update-snapshots to bun test. |
| BTR_SCREENSHOTS_DIR | ./test-results | Directory every screenshot of the run goes into — both the ones screenshot() asks for and the ones the runner writes on failure. |
| BTR_RUN_TIMEOUT_MS | off | Run mode only. Fail a bun <file> run that has not finished after this many milliseconds. Unset means a script that calls keepAlive() may run for as long as it wants. |
Programmatic API
useBrowser() — run a function in a real browser
For driving a browser from a regular script (not a test file), useBrowser
ships a self-contained function into a Bun.WebView and returns its result
to the host:
import { useBrowser } from "use-browser";
const result = await useBrowser({
main: async () => {
const ctx = new AudioContext({ sampleRate: 48_000 });
const osc = ctx.createOscillator();
osc.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 1);
return { sampleRate: ctx.sampleRate };
},
});Closure variables are not captured — main is serialized via
Function#toString and re-evaluated inside the page. Pass anything it needs
through parameters:
await useBrowser({
main: async (opts: { read: (p: string) => Promise<string> }) => {
const txt = await opts.read("README.md");
document.body.textContent = txt;
return txt.length;
},
parameters: [
{ read: async (p: string) => await Bun.file(p).text() },
],
});Boundary encoding:
- JSON-safe values pass through unchanged.
- Functions become callable proxies inside the browser — invoking them round-trips back to the host, where the original function runs (async is awaited) and its return value is shipped back.
Uint8Array/Buffer/ArrayBuffercross as base64 and are reconstituted asUint8Arrayon the other side.- Cyclic graphs are not supported.
Options:
| Option | Default | Description |
|---|---|---|
| main | required | Self-contained function evaluated inside the page. |
| parameters | [] | Arguments passed to main. Encoded as described above. |
| backend | webkit on macOS, chrome elsewhere | Force a specific backend. |
| forwardConsole | true | Pipe browser-side console.* to the host. |
See example.ts for a fuller walk-through (oscillator playback
plus host-side Bun.file read/write callbacks).
Driving the test runner directly
For tooling that wants to drive the test runner instead of using the preload:
import {
WebViewDriver,
runUserFileWithDriver,
resultToError,
} from "use-browser";
const driver = WebViewDriver.from({ maxSize: 2 });
const { results } = await runUserFileWithDriver({
userFile: "/abs/path/to/some.test.ts",
driver,
});
driver.close();Driving a script run directly
The run preload calls this for you. Use it when your own tooling owns the process:
import { runScriptWithDriver, ScriptRunError } from "use-browser";
try {
const { exitCode } = await runScriptWithDriver({
userFile: "/abs/path/to/render.ts",
timeoutMs: 30_000,
});
console.log("script asked for exit code", exitCode);
} catch (err) {
if (err instanceof ScriptRunError) {
console.error(err.serialized.stack, err.screenshotPath);
}
}runScriptAsMain({ userFile }) wraps the same call with the reporting, the exit code, and the pool teardown that bun <file> needs.
