onsi/biloba

Stable, performant, automated browser testing for Ginkgo

26

stars

327

commits

Go

primary language

Sep 14, 2026

updated

onsi.github.io/biloba/

README

Biloba

test Biloba Docs


Biloba

"Automated browser testing is slow and flaky" - every developer, ever

Biloba builds on top of chromedp to bring stable, performant, automated browser testing to Ginkgo. It embraces three principles:

  • Performance via parallelization
  • Stability via pragmatism
  • Conciseness via Ginkgo and Gomega

It's blazing fast and designed to work really well with AI toolchains like Claude Code.

Take a look at the documentation to learn more and get started! Biloba tests can be written in Go using Ginkgo, and in typescript using vitest (quick-start for vitest).

Or let Claude Code set it up for you.

Biloba is remarkably feature complete and in active development. A 1.0 release milestone has not been reached yet, so the public API contract may shift as the project evolves. Send feedback!

Here's a quick taste of what Biloba specs look like in Ginkgo:

func login(tab *Biloba, user string, password string) {
	GinkgoHelper()
	tab.Navigate("/login")
	Eventually(tab.ByLabel("Username")).Should(tab.SetValue(user)) // locator: a form control by its label
	tab.SetValue(tab.ByLabel("Password"), password)
	tab.Click(tab.ByRole("button").WithName("Log in"))            // locator: role + accessible name
	Eventually(".chat-page").Should(tab.Exist())
}

Describe("a simple chat app", func() {
	// b is a *Biloba instance spun up in our BeforeSuite (not shown).  We open an
	// isolated tab per user, and generate reusable selectors/locators off b.
	var tabSally, tabJane *Biloba
	BeforeEach(func() {
		tabSally = b.NewTab()
		login(tabSally, "sally", "yllas")
		tabJane = b.NewTab()
		login(tabJane, "jane", "enaj")
	})

	It("shows all logged in users as present", func() {
		// both tabs should show both users online, by the names a user actually reads
		for _, tab := range []*Biloba{tabSally, tabJane} {
			Eventually(b.ByText("Sally").Within("#user-list")).Should(tab.HaveClass("online"))
			Eventually(b.ByText("Jane").Within("#user-list")).Should(tab.HaveClass("online"))
		}
	})

	It("shows Jane that Sally is typing", func() {
		lastEntry := b.ByRole("listitem").Within("#conversation").Last()
		tabSally.SetValue("#input", "Hey Jane, how are you?")
		Eventually(lastEntry).Should(SatisfyAll(
			tabJane.HaveText("Sally is typing..."),
			tabJane.HaveClass("typing"),
		))

		tabSally.SetValue("#input", "")
		Eventually(lastEntry).ShouldNot(SatisfyAny(
			tabJane.HaveText("Sally is typing..."),
			tabJane.HaveClass("typing"),
		))
	})

	It("delivers messages between Sally and Jane", func() {
		lastEntry := b.ByRole("listitem").Within("#conversation").Last()
		tabSally.Type("#input", "Hey Jane, how are you?") // real keystrokes...
		tabSally.Type("#input", biloba.Keys.Enter)        // ...sent by pressing Enter
		Eventually(lastEntry).Should(tabJane.HaveText("Hey Jane, how are you?"))

		tabJane.Type("#input", "I'm splendid, Sally!")
		tabJane.Click(b.ByRole("button").WithName("Send"))
		Eventually(lastEntry).Should(tabSally.HaveText("I'm splendid, Sally!"))
	})

	It("lets Sally share a document that Jane can download", func() {
		tabSally.SetUpload(b.ByLabel("Attach a file"), "./fixtures/report.pdf")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		doc := b.ByRole("link").WithName("report.pdf")
		Eventually(doc).Should(tabJane.BeVisible()) // Jane sees the shared document...
		tabJane.Click(doc)                          // ...and downloads it
		Eventually(tabJane).Should(tabJane.HaveDownloaded("report.pdf"))
	})

	It("reveals message actions on hover", func() {
		rb := tabSally.Realistic() // a view of the same tab, driven by real Chrome input
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		last := b.ByRole("listitem").Within("#conversation").Last()
		rb.Hover(last) // genuine CSS :hover — one of the few things the fast track can't do
		Eventually(b.ByRole("button").WithName("React").Within(last)).Should(tabSally.BeVisible())
	})

	It("renders a message bubble exactly as designed", func() {
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		// compare against a committed baseline — masking the volatile timestamp,
		// in both themes.  A failure says what moved and where, in words.
		Eventually(b.ByRole("listitem").Within("#conversation").Last()).Should(
			tabSally.HaveScreenshot("message-bubble",
				tabSally.Mask(".timestamp"),
				tabSally.InColorSchemes("light", "dark")))
	})

	It("shows an error when a message fails to send", func() {
		tabSally.AbortRequest(ContainSubstring("/messages")) // make the send fail, hermetically
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))
		Eventually(b.ByRole("alert")).Should(tabSally.HaveText("Message failed to send"))
	})

	It("loads conversation history", func() {
		// stub the history response
		tabSally.StubRequest(ContainSubstring("/history"), biloba.StubResponse{
			Body: `[{"from":"Jane","text":"Welcome back!"}]`,
		})
		tabSally.Navigate("/chat")
		Eventually(b.ByRole("listitem").Within("#conversation")).Should(tabSally.HaveText("Welcome back!"))
	})

	It("tracks when users aren't online", func() {
		jane := b.ByText("Jane").Within("#user-list")
		Eventually(jane).Should(tabSally.HaveClass("online"))

		tabJane.Close()
		Eventually(jane).Should(tabSally.HaveClass("offline"))
	})
})

Run these in series with ginkgo. And in parallel with ginkgo -p for fast, stable, isolated browser tests.

Poll by default

Browsers are asynchronous, so Biloba's interactions and value-getters poll by default. A fully-applied call like tab.Click("#go") or tab.SetValue("#input", "hi") retries — finding-and-acting atomically in the browser — until it succeeds or times out.

When you want to make the wait explicit (to compose with Consistently, or assert on a richer condition), every interaction also has a Gomega matcher form:

Eventually("#go").Should(tab.Click())
Eventually(tab.ByLabel("Email")).Should(tab.SetValue("me@example.com"))

And when you genuinely want act-once / fail-fast semantics — no polling — opt out with tab.Immediate():

tab.Immediate().Click("#go") // act now; fail immediately if it isn't clickable yet

Polling timeout, interval, and context are configurable Gomega-style with tab.WithTimeout(...), tab.WithPolling(...), and tab.WithContext(...).

Fast and realistic interaction tracks

By default Biloba interactions are fast: atomic JavaScript simulations (el.click(), value-set, synthetic events) that run as a single in-browser snippet — no scroll, no occlusion check, no real cursor. This is what keeps Biloba quick and stable, and it's the right default for the vast majority of specs.

For the handful of specs that need genuine input fidelity — real CSS :hover, occlusion-aware clicks, scroll-into-view, real keystrokes/drags/wheel/touch — b.Realistic() returns a view of the same tab whose interactions route through real Chrome DevTools Protocol input. Same API, just a more faithful (and slightly slower) interaction engine. See the documentation (and the biloba-go:realistic-mode Claude Code skill).

Performance

Biloba is fast. onsi/biloba-comparison is a reproducible, three-way speed comparison against Playwright — an identical 32-scenario suite run under biloba-fast, biloba-realistic, and Playwright. On an Apple M1 Max (whole-suite wall clock, median of 15 runs):

configparallel (8 workers)serial
biloba-fast2.57s9.55s
biloba-realistic3.26s18.60s
playwright8.23s38.37s

biloba-fast runs the suite ~3.2× faster in parallel / ~4.0× serial than Playwright; even biloba-realistic — doing the same real-CDP-input work Playwright does — stays ~2.5× / ~2.1× ahead. See the comparison repo for the methodology, the per-bucket breakdown, and the charts.

Of course, synthetic benchmarks don't necessarily capture real-world performance. Here are two real-life data-points:

Fast browser test suites foster better discipline and open the door to more stable suites. A recommended workflow is to run a local flake-hunt periodically after an extended coding session. The 1,689 spec suite described above has a less than 1% suite flake rate thanks to this ceremony (it takes more than 60 suite runs to see a flake appear). The documentation and flake-hunt skill describe how to set flake hunts up.

Using Biloba with Claude Code

Biloba ships separate Claude Code plugins for its Go/Gomega and TypeScript/Vitest clients, with this repo doubling as the marketplace. Install the client you use:

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

(or use claude plugin marketplace add onsi/biloba followed by claude plugin install biloba-go@biloba or claude plugin install biloba-vitest@biloba.)

Or let Claude Code do the whole setup. Paste one of these into Claude Code at the root of your project and it will install the plugin, add Biloba, and get a first suite running:

Go (Ginkgo and Gomega):

Set up Biloba (https://github.com/onsi/biloba) browser tests for this Go project.

1. Install the Claude Code plugins for this project:
     claude plugin marketplace add onsi/biloba --scope project
     claude plugin install biloba-go@biloba --scope project
     claude plugin marketplace add onsi/ginkgo --scope project
     claude plugin install ginkgo@ginkgo --scope project
     claude plugin marketplace add onsi/gomega --scope project
     claude plugin install gomega@gomega --scope project
   If the biloba-go skills aren't available in this session afterwards, stop and ask me to restart Claude Code, then carry on from step 2.
2. Read the biloba-go:overview and biloba-go:setup skills and follow setup: go get github.com/onsi/biloba, install chrome-headless-shell, and bootstrap a Ginkgo suite for the browser specs (in ./e2e unless this project has a better place).
3. Work out how the suite should start and serve the app, one stable origin per parallel process (ask me if it isn't obvious). Then use biloba-go:explore-unfamiliar-page to look at a real page and write a first spec against it with biloba-go:write-tests.
4. Run the suite with ginkgo -p until it passes, gitignore the failure screenshots directory, and tell me how to run the suite myself.

TypeScript (Vitest):

Set up Biloba (https://github.com/onsi/biloba) browser tests with Vitest for this TypeScript project.

1. Install the Claude Code plugin for this project:
     claude plugin marketplace add onsi/biloba --scope project
     claude plugin install biloba-vitest@biloba --scope project
   If the biloba-vitest skills aren't available in this session afterwards, stop and ask me to restart Claude Code, then carry on from step 2.
2. Read the biloba-vitest:overview and biloba-vitest:setup skills and follow setup: add vitest and biloba as dev dependencies with this project's package manager, run npx biloba install-chrome, start one shared Chrome in Vitest's global setup, and connect a session per test file.
3. Work out how the tests should start and serve the app (ask me if it isn't obvious). Then write a first test against a real page with biloba-vitest:write-tests.
4. Run the suite until it passes, gitignore the failure screenshots directory, add a script to package.json that runs it, and tell me how to run it myself.

Failure Output

Biloba automatically captures and emits screenshots and any JavaScript console output when tests fail. It even hooks into Ginkgo's progress emitter infrastructure so ^T/SIGNIFO on a mac (SIGUSR2 on linux) will spit out a screenshot.

Screenshots are great for humans but won't show up in most CI systems and don't help AI agents. Biloba autodetects when it's being run in CI or by an agent and spits out DOM outlines and puts screenshot files on disk instead automatically.

The same instinct shapes b.HaveScreenshot, Biloba's visual-regression matcher: when a comparison against a committed baseline fails, Biloba writes the usual .actual.png/.diff.png pair and tells an agnet in words what changed — how many pixels, where the changed boxes are, and whether the shape of the change reads as one region, a uniform shift, or something scattered across every text run. Those three are different bugs, and you can tell them apart without opening an image.

Vitest Support

Biloba's TypeScript client lets a vitest suite drive Chrome through Biloba. Read Biloba for Vitest to learn more. Each vitest worker process spawns a small Go daemon (bilobad) and talks to it over framed JSON on stdin/stdout. Every daemon attaches to one shared Chrome — the same "one browser, one isolated tab per parallel process" model that makes the Go suites fast.

To get started:

npm install -D vitest biloba
npx biloba install-chrome   # fetches chrome-headless-shell; once per Chrome version

the install command pulls in the library and a precompiled bilobad — no Go toolchain required - for macOS/Linux, x64/armn64. Windows isn't supported yet - open an issue if you want it.

Here's the chat app from the top of this README, in TypeScript. Actions and assertions poll by default, exactly as they do in Go:

import {beforeEach, describe, it} from "vitest";
import {contains, Keys, not, type Session} from "biloba";

async function login(tab: Session, user: string, password: string) {
  await tab.navigate("/login");
  await tab.getByLabel("Username").setValue(user);              // locator: a form control by its label
  await tab.getByLabel("Password").setValue(password);
  await tab.getByRole("button", {name: "Log in"}).click();      // locator: role + accessible name
  await tab.locator(".chat-page").expectExists();
}

describe("a simple chat app", () => {
  // session is a root Session opened in a beforeAll (not shown).  We open an isolated tab per
  // user off of it, and generate reusable locators off each tab.
  let tabSally: Session, tabJane: Session;
  beforeEach(async () => {
    tabSally = await session.newTab();
    await login(tabSally, "sally", "yllas");
    tabJane = await session.newTab();
    await login(tabJane, "jane", "enaj");
  });

  it("shows all logged in users as present", async () => {
    // both tabs should show both users online, by the names a user actually reads
    for (const tab of [tabSally, tabJane]) {
      await tab.getByText("Sally").within("#user-list").expectClass("online");
      await tab.getByText("Jane").within("#user-list").expectClass("online");
    }
  });

  it("shows Jane that Sally is typing", async () => {
    const lastEntry = tabJane.getByRole("listitem").within("#conversation").last();
    await tabSally.locator("#input").setValue("Hey Jane, how are you?");
    await lastEntry.expectText("Sally is typing...");
    await lastEntry.expectClass("typing");

    await tabSally.locator("#input").setValue("");
    await lastEntry.expectNotText("Sally is typing...");
    await lastEntry.expectClass(not(contains("typing")));
  });

  it("delivers messages between Sally and Jane", async () => {
    await tabSally.locator("#input").type("Hey Jane, how are you?"); // real keystrokes...
    await tabSally.locator("#input").type(Keys.Enter);               // ...sent by pressing Enter
    await tabJane.getByRole("listitem").within("#conversation").last()
      .expectText("Hey Jane, how are you?");

    await tabJane.locator("#input").type("I'm splendid, Sally!");
    await tabJane.getByRole("button", {name: "Send"}).click();
    await tabSally.getByRole("listitem").within("#conversation").last()
      .expectText("I'm splendid, Sally!");
  });

  it("lets Sally share a document that Jane can download", async () => {
    await tabSally.getByLabel("Attach a file").setUploadFiles(["./fixtures/report.pdf"]);
    await tabSally.getByRole("button", {name: "Send"}).click();

    const doc = tabJane.getByRole("link", {name: "report.pdf"});
    await doc.expectVisible();                              // Jane sees the shared document...
    await doc.click();                                      // ...and downloads it
    await tabJane.expectDownload({filename: "report.pdf"});
  });

  it("reveals message actions on hover", async () => {
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();

    const last = tabSally.getByRole("listitem").within("#conversation").last();
    await last.realistic().hover(); // genuine CSS :hover — one of the few things the fast track can't do
    await tabSally.getByRole("button", {name: "React"}).within(last).expectVisible();
  });

  it("renders a message bubble exactly as designed", async () => {
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();

    // compare against a committed baseline — masking the volatile timestamp,
    // in both themes.  A failure says what moved and where, in words.
    await tabSally.getByRole("listitem").within("#conversation").last()
      .expectScreenshot("message-bubble", {
        mask: [tabSally.locator(".timestamp")],
        colorSchemes: ["light", "dark"],
      });
  });

  it("shows an error when a message fails to send", async () => {
    await tabSally.abortRequest(contains("/messages")); // make the send fail, hermetically
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();
    await tabSally.getByRole("alert").expectText("Message failed to send");
  });

  it("loads conversation history", async () => {
    // stub the history response
    await tabSally.stubRequest(contains("/history"), {
      body: new TextEncoder().encode('[{"from":"Jane","text":"Welcome back!"}]'),
    });
    await tabSally.navigate("/chat");
    await tabSally.getByRole("listitem").within("#conversation").expectText("Welcome back!");
  });

  it("tracks when users aren't online", async () => {
    const jane = tabSally.getByText("Jane").within("#user-list");
    await jane.expectClass("online");

    await tabJane.close();
    await jane.expectClass("offline");
  });
});

Running Vitest Tests

Start one Chrome for the whole run in vitest's global setup and hand its connection to the workers. Register that setup — and a process pool, so each test file really is its own worker with its own daemon — in your vitest config:

// vitest.config.ts
import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globalSetup: ["./test/global-setup.ts"],
    pool: "forks",
    fileParallelism: true,
  },
});

Then run the suite the way you run any other vitest suite:

npx vitest run       # the whole suite, files in parallel across worker processes
npx vitest           # watch mode
npx vitest run chat  # just the files whose path matches "chat"

Every worker shares the one Chrome that global setup started, so adding workers costs a daemon and a tab rather than a browser. See the setup section of the Vitest docs for the global-setup.ts and per-file connect/openSession boilerplate.


Ginkgo Tree Graphics Designed By 可行 From LovePik.com

Contributors

onsi

258 commits

dajulia3

64 commits

dgruber

1 commits

onsi/biloba

Stable, performant, automated browser testing for Ginkgo

26

stars

327

commits

Go

primary language

Sep 14, 2026

updated

onsi.github.io/biloba/

README

Biloba

test Biloba Docs


Biloba

"Automated browser testing is slow and flaky" - every developer, ever

Biloba builds on top of chromedp to bring stable, performant, automated browser testing to Ginkgo. It embraces three principles:

  • Performance via parallelization
  • Stability via pragmatism
  • Conciseness via Ginkgo and Gomega

It's blazing fast and designed to work really well with AI toolchains like Claude Code.

Take a look at the documentation to learn more and get started! Biloba tests can be written in Go using Ginkgo, and in typescript using vitest (quick-start for vitest).

Or let Claude Code set it up for you.

Biloba is remarkably feature complete and in active development. A 1.0 release milestone has not been reached yet, so the public API contract may shift as the project evolves. Send feedback!

Here's a quick taste of what Biloba specs look like in Ginkgo:

func login(tab *Biloba, user string, password string) {
	GinkgoHelper()
	tab.Navigate("/login")
	Eventually(tab.ByLabel("Username")).Should(tab.SetValue(user)) // locator: a form control by its label
	tab.SetValue(tab.ByLabel("Password"), password)
	tab.Click(tab.ByRole("button").WithName("Log in"))            // locator: role + accessible name
	Eventually(".chat-page").Should(tab.Exist())
}

Describe("a simple chat app", func() {
	// b is a *Biloba instance spun up in our BeforeSuite (not shown).  We open an
	// isolated tab per user, and generate reusable selectors/locators off b.
	var tabSally, tabJane *Biloba
	BeforeEach(func() {
		tabSally = b.NewTab()
		login(tabSally, "sally", "yllas")
		tabJane = b.NewTab()
		login(tabJane, "jane", "enaj")
	})

	It("shows all logged in users as present", func() {
		// both tabs should show both users online, by the names a user actually reads
		for _, tab := range []*Biloba{tabSally, tabJane} {
			Eventually(b.ByText("Sally").Within("#user-list")).Should(tab.HaveClass("online"))
			Eventually(b.ByText("Jane").Within("#user-list")).Should(tab.HaveClass("online"))
		}
	})

	It("shows Jane that Sally is typing", func() {
		lastEntry := b.ByRole("listitem").Within("#conversation").Last()
		tabSally.SetValue("#input", "Hey Jane, how are you?")
		Eventually(lastEntry).Should(SatisfyAll(
			tabJane.HaveText("Sally is typing..."),
			tabJane.HaveClass("typing"),
		))

		tabSally.SetValue("#input", "")
		Eventually(lastEntry).ShouldNot(SatisfyAny(
			tabJane.HaveText("Sally is typing..."),
			tabJane.HaveClass("typing"),
		))
	})

	It("delivers messages between Sally and Jane", func() {
		lastEntry := b.ByRole("listitem").Within("#conversation").Last()
		tabSally.Type("#input", "Hey Jane, how are you?") // real keystrokes...
		tabSally.Type("#input", biloba.Keys.Enter)        // ...sent by pressing Enter
		Eventually(lastEntry).Should(tabJane.HaveText("Hey Jane, how are you?"))

		tabJane.Type("#input", "I'm splendid, Sally!")
		tabJane.Click(b.ByRole("button").WithName("Send"))
		Eventually(lastEntry).Should(tabSally.HaveText("I'm splendid, Sally!"))
	})

	It("lets Sally share a document that Jane can download", func() {
		tabSally.SetUpload(b.ByLabel("Attach a file"), "./fixtures/report.pdf")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		doc := b.ByRole("link").WithName("report.pdf")
		Eventually(doc).Should(tabJane.BeVisible()) // Jane sees the shared document...
		tabJane.Click(doc)                          // ...and downloads it
		Eventually(tabJane).Should(tabJane.HaveDownloaded("report.pdf"))
	})

	It("reveals message actions on hover", func() {
		rb := tabSally.Realistic() // a view of the same tab, driven by real Chrome input
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		last := b.ByRole("listitem").Within("#conversation").Last()
		rb.Hover(last) // genuine CSS :hover — one of the few things the fast track can't do
		Eventually(b.ByRole("button").WithName("React").Within(last)).Should(tabSally.BeVisible())
	})

	It("renders a message bubble exactly as designed", func() {
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))

		// compare against a committed baseline — masking the volatile timestamp,
		// in both themes.  A failure says what moved and where, in words.
		Eventually(b.ByRole("listitem").Within("#conversation").Last()).Should(
			tabSally.HaveScreenshot("message-bubble",
				tabSally.Mask(".timestamp"),
				tabSally.InColorSchemes("light", "dark")))
	})

	It("shows an error when a message fails to send", func() {
		tabSally.AbortRequest(ContainSubstring("/messages")) // make the send fail, hermetically
		tabSally.SetValue("#input", "Hey Jane")
		tabSally.Click(b.ByRole("button").WithName("Send"))
		Eventually(b.ByRole("alert")).Should(tabSally.HaveText("Message failed to send"))
	})

	It("loads conversation history", func() {
		// stub the history response
		tabSally.StubRequest(ContainSubstring("/history"), biloba.StubResponse{
			Body: `[{"from":"Jane","text":"Welcome back!"}]`,
		})
		tabSally.Navigate("/chat")
		Eventually(b.ByRole("listitem").Within("#conversation")).Should(tabSally.HaveText("Welcome back!"))
	})

	It("tracks when users aren't online", func() {
		jane := b.ByText("Jane").Within("#user-list")
		Eventually(jane).Should(tabSally.HaveClass("online"))

		tabJane.Close()
		Eventually(jane).Should(tabSally.HaveClass("offline"))
	})
})

Run these in series with ginkgo. And in parallel with ginkgo -p for fast, stable, isolated browser tests.

Poll by default

Browsers are asynchronous, so Biloba's interactions and value-getters poll by default. A fully-applied call like tab.Click("#go") or tab.SetValue("#input", "hi") retries — finding-and-acting atomically in the browser — until it succeeds or times out.

When you want to make the wait explicit (to compose with Consistently, or assert on a richer condition), every interaction also has a Gomega matcher form:

Eventually("#go").Should(tab.Click())
Eventually(tab.ByLabel("Email")).Should(tab.SetValue("me@example.com"))

And when you genuinely want act-once / fail-fast semantics — no polling — opt out with tab.Immediate():

tab.Immediate().Click("#go") // act now; fail immediately if it isn't clickable yet

Polling timeout, interval, and context are configurable Gomega-style with tab.WithTimeout(...), tab.WithPolling(...), and tab.WithContext(...).

Fast and realistic interaction tracks

By default Biloba interactions are fast: atomic JavaScript simulations (el.click(), value-set, synthetic events) that run as a single in-browser snippet — no scroll, no occlusion check, no real cursor. This is what keeps Biloba quick and stable, and it's the right default for the vast majority of specs.

For the handful of specs that need genuine input fidelity — real CSS :hover, occlusion-aware clicks, scroll-into-view, real keystrokes/drags/wheel/touch — b.Realistic() returns a view of the same tab whose interactions route through real Chrome DevTools Protocol input. Same API, just a more faithful (and slightly slower) interaction engine. See the documentation (and the biloba-go:realistic-mode Claude Code skill).

Performance

Biloba is fast. onsi/biloba-comparison is a reproducible, three-way speed comparison against Playwright — an identical 32-scenario suite run under biloba-fast, biloba-realistic, and Playwright. On an Apple M1 Max (whole-suite wall clock, median of 15 runs):

configparallel (8 workers)serial
biloba-fast2.57s9.55s
biloba-realistic3.26s18.60s
playwright8.23s38.37s

biloba-fast runs the suite ~3.2× faster in parallel / ~4.0× serial than Playwright; even biloba-realistic — doing the same real-CDP-input work Playwright does — stays ~2.5× / ~2.1× ahead. See the comparison repo for the methodology, the per-bucket breakdown, and the charts.

Of course, synthetic benchmarks don't necessarily capture real-world performance. Here are two real-life data-points:

Fast browser test suites foster better discipline and open the door to more stable suites. A recommended workflow is to run a local flake-hunt periodically after an extended coding session. The 1,689 spec suite described above has a less than 1% suite flake rate thanks to this ceremony (it takes more than 60 suite runs to see a flake appear). The documentation and flake-hunt skill describe how to set flake hunts up.

Using Biloba with Claude Code

Biloba ships separate Claude Code plugins for its Go/Gomega and TypeScript/Vitest clients, with this repo doubling as the marketplace. Install the client you use:

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

(or use claude plugin marketplace add onsi/biloba followed by claude plugin install biloba-go@biloba or claude plugin install biloba-vitest@biloba.)

Or let Claude Code do the whole setup. Paste one of these into Claude Code at the root of your project and it will install the plugin, add Biloba, and get a first suite running:

Go (Ginkgo and Gomega):

Set up Biloba (https://github.com/onsi/biloba) browser tests for this Go project.

1. Install the Claude Code plugins for this project:
     claude plugin marketplace add onsi/biloba --scope project
     claude plugin install biloba-go@biloba --scope project
     claude plugin marketplace add onsi/ginkgo --scope project
     claude plugin install ginkgo@ginkgo --scope project
     claude plugin marketplace add onsi/gomega --scope project
     claude plugin install gomega@gomega --scope project
   If the biloba-go skills aren't available in this session afterwards, stop and ask me to restart Claude Code, then carry on from step 2.
2. Read the biloba-go:overview and biloba-go:setup skills and follow setup: go get github.com/onsi/biloba, install chrome-headless-shell, and bootstrap a Ginkgo suite for the browser specs (in ./e2e unless this project has a better place).
3. Work out how the suite should start and serve the app, one stable origin per parallel process (ask me if it isn't obvious). Then use biloba-go:explore-unfamiliar-page to look at a real page and write a first spec against it with biloba-go:write-tests.
4. Run the suite with ginkgo -p until it passes, gitignore the failure screenshots directory, and tell me how to run the suite myself.

TypeScript (Vitest):

Set up Biloba (https://github.com/onsi/biloba) browser tests with Vitest for this TypeScript project.

1. Install the Claude Code plugin for this project:
     claude plugin marketplace add onsi/biloba --scope project
     claude plugin install biloba-vitest@biloba --scope project
   If the biloba-vitest skills aren't available in this session afterwards, stop and ask me to restart Claude Code, then carry on from step 2.
2. Read the biloba-vitest:overview and biloba-vitest:setup skills and follow setup: add vitest and biloba as dev dependencies with this project's package manager, run npx biloba install-chrome, start one shared Chrome in Vitest's global setup, and connect a session per test file.
3. Work out how the tests should start and serve the app (ask me if it isn't obvious). Then write a first test against a real page with biloba-vitest:write-tests.
4. Run the suite until it passes, gitignore the failure screenshots directory, add a script to package.json that runs it, and tell me how to run it myself.

Failure Output

Biloba automatically captures and emits screenshots and any JavaScript console output when tests fail. It even hooks into Ginkgo's progress emitter infrastructure so ^T/SIGNIFO on a mac (SIGUSR2 on linux) will spit out a screenshot.

Screenshots are great for humans but won't show up in most CI systems and don't help AI agents. Biloba autodetects when it's being run in CI or by an agent and spits out DOM outlines and puts screenshot files on disk instead automatically.

The same instinct shapes b.HaveScreenshot, Biloba's visual-regression matcher: when a comparison against a committed baseline fails, Biloba writes the usual .actual.png/.diff.png pair and tells an agnet in words what changed — how many pixels, where the changed boxes are, and whether the shape of the change reads as one region, a uniform shift, or something scattered across every text run. Those three are different bugs, and you can tell them apart without opening an image.

Vitest Support

Biloba's TypeScript client lets a vitest suite drive Chrome through Biloba. Read Biloba for Vitest to learn more. Each vitest worker process spawns a small Go daemon (bilobad) and talks to it over framed JSON on stdin/stdout. Every daemon attaches to one shared Chrome — the same "one browser, one isolated tab per parallel process" model that makes the Go suites fast.

To get started:

npm install -D vitest biloba
npx biloba install-chrome   # fetches chrome-headless-shell; once per Chrome version

the install command pulls in the library and a precompiled bilobad — no Go toolchain required - for macOS/Linux, x64/armn64. Windows isn't supported yet - open an issue if you want it.

Here's the chat app from the top of this README, in TypeScript. Actions and assertions poll by default, exactly as they do in Go:

import {beforeEach, describe, it} from "vitest";
import {contains, Keys, not, type Session} from "biloba";

async function login(tab: Session, user: string, password: string) {
  await tab.navigate("/login");
  await tab.getByLabel("Username").setValue(user);              // locator: a form control by its label
  await tab.getByLabel("Password").setValue(password);
  await tab.getByRole("button", {name: "Log in"}).click();      // locator: role + accessible name
  await tab.locator(".chat-page").expectExists();
}

describe("a simple chat app", () => {
  // session is a root Session opened in a beforeAll (not shown).  We open an isolated tab per
  // user off of it, and generate reusable locators off each tab.
  let tabSally: Session, tabJane: Session;
  beforeEach(async () => {
    tabSally = await session.newTab();
    await login(tabSally, "sally", "yllas");
    tabJane = await session.newTab();
    await login(tabJane, "jane", "enaj");
  });

  it("shows all logged in users as present", async () => {
    // both tabs should show both users online, by the names a user actually reads
    for (const tab of [tabSally, tabJane]) {
      await tab.getByText("Sally").within("#user-list").expectClass("online");
      await tab.getByText("Jane").within("#user-list").expectClass("online");
    }
  });

  it("shows Jane that Sally is typing", async () => {
    const lastEntry = tabJane.getByRole("listitem").within("#conversation").last();
    await tabSally.locator("#input").setValue("Hey Jane, how are you?");
    await lastEntry.expectText("Sally is typing...");
    await lastEntry.expectClass("typing");

    await tabSally.locator("#input").setValue("");
    await lastEntry.expectNotText("Sally is typing...");
    await lastEntry.expectClass(not(contains("typing")));
  });

  it("delivers messages between Sally and Jane", async () => {
    await tabSally.locator("#input").type("Hey Jane, how are you?"); // real keystrokes...
    await tabSally.locator("#input").type(Keys.Enter);               // ...sent by pressing Enter
    await tabJane.getByRole("listitem").within("#conversation").last()
      .expectText("Hey Jane, how are you?");

    await tabJane.locator("#input").type("I'm splendid, Sally!");
    await tabJane.getByRole("button", {name: "Send"}).click();
    await tabSally.getByRole("listitem").within("#conversation").last()
      .expectText("I'm splendid, Sally!");
  });

  it("lets Sally share a document that Jane can download", async () => {
    await tabSally.getByLabel("Attach a file").setUploadFiles(["./fixtures/report.pdf"]);
    await tabSally.getByRole("button", {name: "Send"}).click();

    const doc = tabJane.getByRole("link", {name: "report.pdf"});
    await doc.expectVisible();                              // Jane sees the shared document...
    await doc.click();                                      // ...and downloads it
    await tabJane.expectDownload({filename: "report.pdf"});
  });

  it("reveals message actions on hover", async () => {
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();

    const last = tabSally.getByRole("listitem").within("#conversation").last();
    await last.realistic().hover(); // genuine CSS :hover — one of the few things the fast track can't do
    await tabSally.getByRole("button", {name: "React"}).within(last).expectVisible();
  });

  it("renders a message bubble exactly as designed", async () => {
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();

    // compare against a committed baseline — masking the volatile timestamp,
    // in both themes.  A failure says what moved and where, in words.
    await tabSally.getByRole("listitem").within("#conversation").last()
      .expectScreenshot("message-bubble", {
        mask: [tabSally.locator(".timestamp")],
        colorSchemes: ["light", "dark"],
      });
  });

  it("shows an error when a message fails to send", async () => {
    await tabSally.abortRequest(contains("/messages")); // make the send fail, hermetically
    await tabSally.locator("#input").setValue("Hey Jane");
    await tabSally.getByRole("button", {name: "Send"}).click();
    await tabSally.getByRole("alert").expectText("Message failed to send");
  });

  it("loads conversation history", async () => {
    // stub the history response
    await tabSally.stubRequest(contains("/history"), {
      body: new TextEncoder().encode('[{"from":"Jane","text":"Welcome back!"}]'),
    });
    await tabSally.navigate("/chat");
    await tabSally.getByRole("listitem").within("#conversation").expectText("Welcome back!");
  });

  it("tracks when users aren't online", async () => {
    const jane = tabSally.getByText("Jane").within("#user-list");
    await jane.expectClass("online");

    await tabJane.close();
    await jane.expectClass("offline");
  });
});

Running Vitest Tests

Start one Chrome for the whole run in vitest's global setup and hand its connection to the workers. Register that setup — and a process pool, so each test file really is its own worker with its own daemon — in your vitest config:

// vitest.config.ts
import {defineConfig} from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globalSetup: ["./test/global-setup.ts"],
    pool: "forks",
    fileParallelism: true,
  },
});

Then run the suite the way you run any other vitest suite:

npx vitest run       # the whole suite, files in parallel across worker processes
npx vitest           # watch mode
npx vitest run chat  # just the files whose path matches "chat"

Every worker shares the one Chrome that global setup started, so adding workers costs a daemon and a tab rather than a browser. See the setup section of the Vitest docs for the global-setup.ts and per-file connect/openSession boilerplate.


Ginkgo Tree Graphics Designed By 可行 From LovePik.com

Contributors

onsi

258 commits

dajulia3

64 commits

dgruber

1 commits

Languages

Go

73.9%

TypeScript

16.3%

JavaScript

5.9%

HTML

3.1%