Biloba for Vitest

Biloba’s TypeScript client lets a vitest suite drive Biloba browser automation through a worker-local daemon and one shared Chrome.

Biloba’s main documentation is written for its Go client, but much of the thinking behind it carries over to this one. In particular, Nurturing Maintainable Suites covers flake hunts and keeping track of a suite’s performance. The ideas apply to a Vitest suite too, and the biloba-vitest:flake-hunt skill has a Vitest version of the scripts.

Status: pre-1.0. The biloba package has an API that will continue to shift before 1.0. See the support policy. What follows describes what works today.

Claude Code skills

Biloba ships a dedicated TypeScript/Vitest plugin for Claude Code. The Biloba repository doubles as its marketplace:

/plugin marketplace add onsi/biloba
/plugin install biloba-vitest@biloba

The former biloba@biloba plugin has been removed; it only ever carried the Go client skills. If you still have it installed, uninstall it and install biloba-vitest@biloba instead.

The installed biloba-vitest:* skills activate automatically and can also be invoked explicitly:

Skill What it covers
biloba-vitest:overview The daemon, shared-Chrome, session, and polling mental model.
biloba-vitest:setup Vitest global setup, worker daemons, reusable sessions, and launch modes.
biloba-vitest:write-tests Locators, actions, assertions, tabs, frames, network control, and realistic input.
biloba-vitest:visual-assertions Screenshot baselines, masks, tolerances, color schemes, and diagnosis.
biloba-vitest:debug-failures Structured errors, trajectories, artifacts, console output, and crash codes.
biloba-vitest:flaky-tests Redundant polling, order dependence, lifecycle leakage, and latent races.
biloba-vitest:flake-hunt Running the suite many times to measure each test’s failure rate, reading the JSON reports, and keeping a performance record.

Why there’s a daemon

Biloba’s performance model is parallelization: one shared Chrome, with each parallel process driving its own isolated tab. Vitest workers are Node processes while Biloba’s Chrome DevTools Protocol plumbing lives in the bilobad executable. Each worker therefore spawns its own daemon and talks to it over a framed JSON protocol on stdin/stdout. Every daemon attaches to one shared Chrome:

vitest worker 1  ──▶  bilobad  ──┐
vitest worker 2  ──▶  bilobad  ──┼──▶  one shared Chrome
vitest worker 3  ──▶  bilobad  ──┘

Creating isolated tabs is much cheaper than creating browsers; the daemon preserves that advantage across worker processes.

This has a consequence worth internalizing early: polling happens on the daemon, not in your test. When you write expectText("ready", {timeoutMs: 1000}), that is one request. The daemon runs the whole retry loop in-process, next to Chrome, and answers once with the outcome and the trajectory it took to get there. You are not paying a round trip per attempt, which is what makes a 5ms polling interval a reasonable thing to ask for.

Getting set up

npm install -D vitest biloba

This pulls in biloba plus exactly one platform package - biloba-darwin-arm64, biloba-darwin-x64, biloba-linux-x64, or biloba-linux-arm64 - chosen by npm for the machine running the install. That package carries the bilobad daemon binary (a few MB, no install scripts). Windows isn’t supported yet: biloba has nothing to resolve there, and the error names the fallback, go install github.com/onsi/biloba/cmd/bilobad@vX.Y.Z plus BILOBA_DAEMON_EXECUTABLE.

Then, once per Chrome version, fetch the browser build the daemon drives:

npx biloba install-chrome

This runs bilobad install-chrome, which downloads Chrome for Testing’s current Stable chrome-headless-shell into a per-user cache (~/Library/Caches/biloba/… on macOS, ~/.cache/biloba/… on Linux) and prints the path it resolved to. Re-running it is a no-op once that version is already cached. Biloba never downloads Chrome on its own outside this command - autoInstall: true on startSharedBrowser/connect is the explicit opt-in for that - so run it once locally and again in CI (see below).

Chrome lookup, in order: an explicit chromePath, BILOBA_CHROME_HEADLESS_SHELL, chrome-headless-shell on PATH, then the newest build cached across the puppeteer and Biloba caches. When none of those find anything, the error names npx biloba install-chrome.

Linux on arm64 (including Docker on Apple silicon) is a partial case: bilobad installs and runs there, but Chrome for Testing does not publish a chrome-headless-shell build for that platform, so install-chrome can’t help. Install your distro’s Chromium package instead and point a full-Chrome launch at it:

await startSharedBrowser({mode: "headless", chromePath: "/usr/bin/chromium"});

The path varies by distro - check where your package manager put it. On Debian-based images, including the official node images, apt-get install -y chromium puts it at /usr/bin/chromium. Ubuntu’s chromium package installs a snap, which doesn’t run inside a container.

biloba supports Vitest 3, 4, and 5 (peer range >=3 <6). Vitest 5 requires Node 22.12 or later; biloba itself needs Node 20 or later. If you’re on an older config with poolOptions/minWorkers, note those were removed in Vitest 4 - use top-level maxWorkers/isolate instead.

Wire the vitest config:

// vitest.config.ts
import {defineConfig} from "vitest/config";
export default defineConfig({
  test: {
    globalSetup: ["./test/global-setup.ts"],
    pool: "forks",
    testTimeout: 30_000,
    hookTimeout: 30_000,
  },
});

Start one Chrome for the whole run in vitest’s global setup, and hand its connection to the workers:

// test/global-setup.ts
import {startSharedBrowser, type SharedBrowserConnection, type SharedBrowserProcess} from "biloba";
import type {TestProject} from "vitest/node";

declare module "vitest" {
  export interface ProvidedContext {
    chromeConnection: SharedBrowserConnection;
  }
}

let browser: SharedBrowserProcess | undefined;

export async function setup(project: TestProject): Promise<void> {
  browser = await startSharedBrowser({mode: "headless-shell"});
  project.provide("chromeConnection", browser.connection);
}

export async function teardown(): Promise<void> {
  await browser?.stop();
}

The declare module "vitest" block augments Vitest’s ProvidedContext type; without it, project.provide and inject below don’t type-check. Declare it once, in the global setup file - Vitest picks it up for every test file in the run.

Then, in each test file, connect a daemon of your own and open a session:

import {inject} from "vitest";
import {connect, type Browser, type Session} from "biloba";

let browser: Browser;
let session: Session;

beforeAll(async () => {
  browser = await connect({chromeConnection: inject("chromeConnection")});
  session = await browser.openSession();
});

afterAll(async () => { await browser.close(); });

connect and startSharedBrowser resolve the daemon executable the same way, in order: an explicit daemonExecutable/executable option, then BILOBA_DAEMON_EXECUTABLE, then the platform package installed alongside biloba. You normally don’t pass either option - the platform package covers it. Reach for BILOBA_DAEMON_EXECUTABLE (or the option) to point at a daemon built from source, which is the path for Windows and for any platform npm doesn’t ship a package for yet. If the platform package is missing at run time, the error calls out --omit=optional/--no-optional installs and a lockfile or node_modules moved between operating systems (a Mac install reused inside a Linux container, say) as the likely causes.

bilobad version reports the daemon’s own version; when you’ve overridden the daemon and its version differs from the biloba package’s, connect emits a BILOBA_VERSION_MISMATCH process warning (skipped for dev builds, which don’t carry a comparable version).

Omit chromeConnection and the daemon launches Chrome itself - fine for a single file, wasteful for a suite. The older chromeWsUrl attachment remains available, but it cannot report how an external Chrome was launched; prefer chromeConnection so every worker receives the host’s validated launch metadata.

Both startSharedBrowser and a self-launching connect accept mode: "headless-shell" | "headless" | "headful", chromePath, autoInstall, chromeSandbox, ordered chromeArgs, and windowSize. The default is the fast headless shell at 1024×768. browser.launch reports the resolved executable, mode, arguments, size, and whether Biloba installed the shell. Set BILOBA_INTERACTIVE=true for the headful interactive default, or select a mode explicitly.

On Linux, in a headless mode, bilobad automatically launches Chrome with --no-sandbox when the process is running as root, or when the kernel reports AppArmor is restricting unprivileged user namespaces - the default on Ubuntu 23.10+, including GitHub’s ubuntu-latest runner, which is otherwise where chrome-headless-shell fails to start with “No usable sandbox”. Headful Chrome, and every other OS, are left alone. chromeSandbox: true forces the sandbox on, chromeSandbox: false forces it off (on any OS or mode); leave it unset for the automatic behavior. browser.launch.chromeArgs includes --no-sandbox whenever it was added, so you can see it happened. None of this applies when you attach to an existing browser via chromeConnection or chromeWsUrl - attaching launches nothing.

In CI, cache the Chrome download across runs and install it before the suite:

- uses: actions/setup-node@v4
  with: {node-version: 22, cache: npm}
- uses: actions/cache@v4
  with: {path: ~/.cache/biloba, key: biloba-chrome-${{ runner.os }}}
- run: npm ci
- run: npx biloba install-chrome
- run: npx vitest run

A Session is the TypeScript analogue of a Biloba tab. A session returned by browser.openSession() owns its own browser context, so its cookies and storage are isolated from every other root session. session.prepare() is b.Prepare() - it resets the session between tests and is what makes reuse cheap:

beforeEach(async () => { await session.prepare(); });

Open a sibling tab with newTab(). It shares the parent’s browser context - including cookies and storage - but has its own document lifecycle:

const popup = await session.newTab();
await popup.navigate("http://example.com/checkout");
await popup.activate();
await popup.close();

Use tabs() and spawnedTabs() for snapshots, findTab() for an optional match, and waitForTab() when a popup is expected.

frames() snapshots the cross-origin iframe documents below a session; waitForFrame() polls for one by url, title, and/or a has locator. This covers both out-of-process frames (cross-site OOPIF targets) and same-site cross-origin frames such as two local servers on different ports. Biloba asks CDP for Chrome’s frame tree and scopes operations directly to the selected frame; it does not read the iframe through parent-page JavaScript or relax the browser’s same-origin policy:

const frame = await session.waitForFrame(
  {url: /child-form/, has: 'input[name="email"]'},
  {timeoutMs: 10_000},
);

await frame.locator('input[name="email"]').setValue("ada@example.com");
await frame.locator('button[type="submit"]').click();
await frame.locator("#success").expectVisible();

Frame handles expose the normal locator, action, assertion, JavaScript, upload, and frame-local storage APIs. Trusted pointer input is translated through the iframe owner’s content geometry, including parent scrolling, borders, and CSS transforms. For a same-process frame, realistic actions also check the translated point against the whole tab, so an overlay in the embedding page that covers the frame fails the action instead of taking the click. frame.evaluate() runs in the selected frame’s normal JavaScript environment, just like session.evaluate() does for a tab, so it can read and update globals created by the frame’s own scripts. Same-origin iframe and open-shadow-root piercing remains session.locator("outer >>> inner"); frames() is the cross-origin boundary API.

A frame handle is a document, not a tab. Tab and browser-context controls fail on it with INVALID_ARGUMENT: navigate(), prepare(), addInitScript(), activate(), newTab(), setWindowSize(), the emulation setters (device metrics, geolocation, permissions, locale, timezone, media), setCookies()/clearCookies(), request stubbing, aborting, modifying, routing and holding, network state and cache, and handleDialogs(). Call them on the session that owns the frame. Reads that don’t change the tab, such as getCookies() and windowSize(), still work.

A frame handle records its own document’s console messages and requests from the moment it is found, so frame.expectConsoleMessage() and frame.waitForRequest() work for either kind of frame. After navigation, request history keeps only the original document’s requests; waitForRequest() and expectNetworkIdle() reject the stale handle. The owning tab also sees the console output and requests of its same-process frames, but not those of out-of-process frames. Dialogs from any frame are reported and handled on the owning tab. Network interception registered on the tab applies to its same-process frames’ requests, not to an out-of-process frame’s.

frame.captureScreenshot() and a page-level frame.expectScreenshot() capture the frame’s viewport; element screenshots and masks work inside the frame. Chrome can only capture top-level targets, so screenshots on an out-of-process frame’s handle fail with INVALID_ARGUMENT; capture the iframe element from the owning session instead.

An iframe with no src, or with a srcdoc, shares the page’s origin, so it is not listed here: reach into it with >>>.

If several frames match a waitForFrame() query, it returns a ready match without waiting for other renderers. Use a specific URL, title, or has locator when you need a particular frame.

frame.frameId is the CDP frame identity, while frame.targetId is the renderer target that owns it; several same-process frames can therefore share a target. Discovery follows actual frame ancestry across nested renderer boundaries, so a root session includes same-process descendants inside an OOPIF while frame-local discovery excludes siblings. frame.frameUrl is the URL snapshot from discovery. Removing, replacing, or navigating the iframe—or navigating its parent tab—makes that document handle stale; its next operation fails with TARGET_NOT_FOUND, and waitForFrame() returns the replacement document’s new handle. Closing a non-owning frame handle does not close independently acquired nested handles. Closing or preparing an owning session invalidates all descendant handles locally with DRIVER_CLOSED.

Navigation insists on an expected HTTP status:

await session.navigate("http://example.com/search?q=foo");
await session.navigateWithStatus("http://example.com/not-found", 404);

navigate asserting 200 is a deliberate assertion, not a transport rule. An unexpected error page is a broken fixture far more often than it’s the subject of the test, and letting one through surfaces three lines later as a baffling assertion failure rather than at the navigation that caused it. When the 4xx page is what you meant to load, navigateWithStatus says so.

Selecting elements is done with locators, which are lazy - building one talks to nobody:

session.locator("#content")                            // css
session.getByTestId("name")                            // [data-testid="name"]
session.getByText("Save", {exact: true})               // by text content
session.getByRole("button", {name: "Increment"})       // by ARIA role, optionally by accessible name
session.getByLabel("Email")                            // by associated label
session.getByPlaceholder("Search")                     // by placeholder
session.getByAltText("Profile photo")                  // by image alt text
session.getByTitle("Close")                            // by title
session.xpath("//button[@name='save']")                 // a raw XPath expression
session.locator(".row").first()                        // just the first match

Use semantic locators for user-facing behavior and stable CSS hooks for structural state.

The XPath DSL is a runner-independent string builder. Build an expression, then bind it to a session:

import {relativeXPath, xpath} from "biloba";

const save = xpath("button").withClass("primary").withText("Save");
await session.xpath(save).click();

const languageList = xpath("ul").withChildMatching(
  relativeXPath("li").withText("Francais"),
);
await session.xpath(languageList).expectVisible();

The builder includes attributes, text, boolean predicates, tree axes, sibling axes, and XPath’s one-based first()/nth(position)/last() positions. Locator-level .nth(index) remains zero-based after the expression is bound with session.xpath(...).

Acting and asserting

Actions and assertions hang off a locator and poll by default:

await session.getByTestId("name").setValue("Ada");
await session.getByRole("button", {name: "Increment"}).click();

await session.locator("#count").expectText("1");
await session.locator("#spinner").expectCount(0);
await session.getByTestId("name").expectValue("Ada");
await session.locator("a.home").expectAttribute("href", "/");
await session.getByRole("heading", {name: "Dashboard"}).expectVisible();

Two assertions live on the session rather than a locator, because they’re about the page as a whole:

await session.expectUrl("/dashboard", {pathname: true});
await session.expectEvaluation("window.app.ready", true);

Every one of these takes {timeoutMs, intervalMs, signal, mode}. The default mode is "eventually"; "immediate" makes one attempt, and "consistently" requires the condition to remain true for the timeout. On actions, {immediate: true} is shorthand for {mode: "immediate"}:

await session.getByRole("button", {name: "Save"}).click({immediate: true});

Actions and assertions are async methods. Call them directly instead of wrapping them in another polling abstraction.

For interactions where browser-faithful input matters, call realistic() on the locator:

await session.getByRole("button", {name: "Pay"}).realistic().click();
await session.getByTestId("card-number").realistic().setValue("4242 4242 4242 4242");

The realistic track covers clicks and click variants, tap, hover, typing and value changes, wheel input, and drag. Biloba scrolls the target into view, checks actionability, and uses real CDP input. The fast default keeps each action atomic in the page runtime.

An assertion resolves to an AssertionResult describing how it got there, which is occasionally useful and always available:

const result = await session.locator("#delayed").expectText("ready", {timeoutMs: 1_000, intervalMs: 5});
expect(result.attemptCount).toBeGreaterThan(1);

Running JavaScript

evaluate reads an expression, or calls a function when you pass an arguments array:

const title = await session.evaluate<string>("document.title");
const sum = await session.evaluate<number>("(a, b) => a + b", [40, 2]);
const now = await session.evaluate<number>("() => Date.now()", []);

The distinction is the presence of the array, not its length - [] still means “call this”. That’s deliberate: it means a zero-argument function doesn’t have to be spelled differently from a two-argument one.

Cookies accept Date for expiry. You can read, filter, wait for, count, and clear them as well as set them:

await session.setCookies([{name: "session", value: "abc123", path: "/"}]);
const cookie = await session.expectCookie({name: "session"});
await session.clearCookies();

localStorage() and sessionStorage() expose typed set/get/remove/clear, snapshot, length, and polling assertions.

TypeScript supports request and response history, stubbing, aborting, request and response modification, callback-based response routing, response holding, cache control, and network emulation:

await session.expectRequest(endsWith("/orders"), {method: "POST"});

const stub = await session.stubRequest(endsWith("/feature-flags"), {
  status: 200,
  headers: [{name: "content-type", value: "application/json"}],
  body: new TextEncoder().encode('{"available":true}'),
});

const hold = await session.holdResponse(endsWith("/inventory"));
try {
  await session.getByRole("button", {name: "Refresh"}).click();
  const response = await hold.await();
  expect(response.status).toBe(200);
} finally {
  await hold.release();
}

Import endsWith (or another Biloba expectation) from the package. Always release a hold. Network handlers are first-match-wins; use each handler’s count() or stats() and networkShadowDiagnostics() to prove that the intended handler claimed the request.

routeResponse() starts its callback timeout after reading the response body. Body reads have a separate timeout of five seconds, or timeoutMs if it is longer. For example, {timeoutMs: 30_000} allows up to 30 seconds to read the body, then another 30 seconds for the callback. If Chrome refuses to provide a body, as it does for redirects, Biloba continues the response without calling the callback and records the error in the handler’s stats().

Dialog handlers are newest-first and removable. Dialog history records both explicitly handled and safely auto-handled dialogs. Downloads expose lifecycle metadata, bounded binary content, cancellation, snapshot filters, and polling assertions.

const prompts = await session.handleDialogs("prompt", {message: "Name?", promptText: "Ada"});
const download = await session.expectDownload({filename: endsWith(".csv")});
const bytes = await download.content();
await prompts.remove();

Screenshots and visual assertions

Capture a page or locator as bounded bytes, or ask the daemon to write a sanitized artifact path:

const png = await session.captureScreenshot();
const path = await session.getByTestId("chart").captureScreenshot({output: "path", name: "chart"});

expectScreenshot() supports page and element baselines, masks, exact or tolerant pixel comparison, animation freezing with an opt-out, light and dark color schemes, update mode, diff artifacts, and structured diagnosis.

const result = await session.getByTestId("chart").expectScreenshot("revenue-chart", {
  mask: [session.getByTestId("last-updated")],
  colorSchemes: ["light", "dark"],
  pixelTolerance: 0.001,
});
expect(result.match).toBe(true);

A mismatch throws, and the message carries the pixel counts, amplitude verdict, and baseline, actual, and diff paths, so a CI log tells you what changed without opening anything. Capture warnings (a baseline that never settled, two color schemes that rendered identically) go to stderr; pass onScreenshotWarning to connect to route them somewhere else.

When you need to absorb rendering noise, reach for channelTolerance before pixelTolerance: rasterization differences are a small delta spread over many pixels, while a pixel budget lets a handful of pixels change by any amount - which is exactly the one-pixel border or small glyph worth catching.

When something fails

Every failure arrives as a BilobaError, and it carries the context you’d otherwise have to go find:

try {
  await session.locator("#never").expectText("ready", {timeoutMs: 500});
} catch (error) {
  const failure = error as BilobaError;
  failure.code;            // "TIMEOUT"
  failure.locator;         // 'locator("#never")'
  failure.expected;        // "ready"
  failure.trajectory;      // every attempt, with what it observed and why it retried
  failure.domOutline;      // the DOM at failure time
  failure.screenshotPath;  // the primary PNG path, when disk artifacts are enabled
  failure.diagnostics;     // context-wide tab artifacts, when available
  failure.visual;          // structured visual comparison, for expectScreenshot
  failure.artifactPaths;   // all associated artifact paths
}

That trajectory records every polling attempt as structured data instead of reducing the failure to its final observation. Pass diagnostics: {artifactDir} to connect to get screenshots written to disk.

For runner-level capture, load installBilobaVitestHooks from biloba/vitest in a Vitest setup file. The hook captures every live tab after any failed test, can capture a slow test after progressAfterMs, replays browser errors, and turns console.assert into a test-boundary failure. session.captureDiagnostics() provides the same context-wide capture on demand. Configure screenshots, outlines, artifact paths, inline output, viewport, byte limits, and poll trajectories under connect({diagnostics: {...}}); explicit members override CI and interactive defaults independently.

Use consoleMessages() for bounded history and onConsoleMessage() for live delivery. warnings() and onWarning() expose auto-handled dialog and dropped-event warnings. Pass debugLog to connect for bounded structured daemon/CDP diagnostics; Biloba never mixes them into framed stdout.

failure.code is worth narrowing on, because a few of the codes mean something quite specific:

Code What happened
TIMEOUT The poll ran out of time. The ordinary assertion failure.
TARGET_NOT_FOUND No element matched the locator.
TARGET_NOT_READY The element matched but refused the operation - hidden, disabled. Means not yet, so a retry might succeed.
NAVIGATION The page loaded with a status you didn’t ask for. Waiting will never fix it; see navigateWithStatus.
JAVASCRIPT_ERROR Your expression threw in the page.
INVALID_ARGUMENT The request was malformed - a cookie with no domain, say.
PAGE_CRASHED This session’s renderer died. The browser is fine; navigate again to recover.
BROWSER_GONE The shared Chrome exited or crashed underneath this worker.
DRIVER_CLOSED This worker’s daemon died. daemonDetail carries its stderr.

The last three exist so that a crash reports itself as a crash. Chrome doesn’t fail calls to a dead renderer - it stops answering them - so without a dedicated signal a crashed page looks exactly like an assertion that never came true, and sends you off to debug a test that was fine.