npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-browser

2. 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 test

That'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 webpwebp 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.ts

Your 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.png

3. 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 --useBrowser

Run 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 / ArrayBuffer cross as base64 and are reconstituted as Uint8Array on 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.