Setting up Biloba in your suite
One-time wiring for a Go/Ginkgo suite. Authoring model → write-tests. Mental model → overview. Docs: https://onsi.github.io/biloba/#getting-started. Sibling skills are named here without a prefix; invoke one with the same plugin prefix you loaded this skill under.
1. Add Biloba and bootstrap a suite
go get github.com/onsi/biloba
mkdir browser && cd browser
ginkgo bootstrap
Edit the generated *_suite_test.go:
package browser_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/biloba"
)
func TestBrowser(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Browser Suite")
}
var b *biloba.Biloba
var _ = SynchronizedBeforeSuite(func() {
biloba.SpinUpChrome(GinkgoT())
}, func() {
b = biloba.ConnectToChrome(GinkgoT())
})
var _ = BeforeEach(func() {
b.Prepare()
}, OncePerOrdered)
GinkgoT()is the seam: Chrome errors become suite failures.SpinUpChromeruns once (process 1) and writes connection info to disk;ConnectToChromeruns on every parallel process and opens that process's reusable root tabb.b.Prepare()resetsbbetween specs (closes other tabs, clears state, navigates toabout:blank).OncePerOrderedkeeps it from resetting betweenIts inside anOrderedcontainer — which is also why network/dialog handlers accumulate inside anOrderedblock (seeflaky-specs).
2. Get chrome-headless-shell
SpinUpChrome drives chrome-headless-shell by default — the lightweight headless build that is ~an order of magnitude faster and lets one Chrome process drive many isolated contexts in parallel. It's a standalone binary, separate from your Chrome install. Lookup order:
biloba.HeadlessShellPath("/path/to/chrome-headless-shell")BILOBA_CHROME_HEADLESS_SHELLenv var- your
PATH - the
@puppeteer/browsersand Biloba download caches (the newest cached version wins)
If none turn it up Biloba fails fast with instructions — it never silently downloads. Install it once:
npx @puppeteer/browsers install chrome-headless-shell@stable
Or let Biloba download+cache it on first use (opt-in, since it reaches the network):
biloba.SpinUpChrome(GinkgoT(), biloba.AutoInstallHeadlessShell())
Full-browser realism: biloba.SpinUpChrome(GinkgoT(), biloba.HighFidelityHeadless()) runs the full ("new") headless Chrome — pixel-accurate, extensions — but markedly slower, and it serializes parallel work. Keep the bulk of the suite on the shell and run a focused high-fidelity lane where it earns its keep.
Linux sandbox: Ubuntu 23.10+ (including ubuntu-latest) restricts unprivileged user namespaces via AppArmor for binaries with no AppArmor profile, which is why a cached chrome-headless-shell can fail with "No usable sandbox" there. SpinUpChrome handles it for you: on Linux, in a headless mode, it adds --no-sandbox automatically when running as root or when the kernel reports that restriction — headful and every other OS are untouched. Override with biloba.ChromeSandbox(true) (force it on) or biloba.ChromeSandbox(false) (force it off).
3. Choose a bootstrap variation
Trade isolation against performance. All three are small edits — try them on your suite.
Default — shared browser, reused root tab (most performant, good-enough isolation): the snippet in step 1.
Per-process browser (stronger isolation, slower startup):
var _ = BeforeSuite(func() {
biloba.SpinUpChrome(GinkgoT())
b = biloba.ConnectToChrome(GinkgoT())
})
var _ = BeforeEach(func() { b.Prepare() }, OncePerOrdered)
Fresh tab per spec (per-spec cleanup, a per-spec cost):
var rootB *biloba.Biloba
var b *biloba.Biloba
var _ = SynchronizedBeforeSuite(func() {
biloba.SpinUpChrome(GinkgoT())
}, func() {
rootB = biloba.ConnectToChrome(GinkgoT())
})
var _ = BeforeEach(func() {
rootB.Prepare()
b = rootB.NewTab()
}, OncePerOrdered)
Per-process browsers and fresh-tab-per-spec can be combined.
Serve the app from a stable origin — the same trade, one level out. The Go reflex is one httptest.NewServer per spec, which puts every spec on a new ephemeral port: a new origin, every asset URL changed, the renderer cold on every navigation. Measured on one real suite (1.67 MB bundle): ~31ms extra per b.Navigate, ~20s off a 1,558-spec --procs=6 run. It is not the HTTP cache — disabling that on a stable origin costs nothing, and the recovered time lands in script time. Small static fixtures will see much less.
You give up no isolation to get it: a brand-new server with brand-new state, bound to the same port, is as fast as re-navigating the warm one. Nothing but the origin has to be shared.
Get the stable port the way Ginkgo shards any per-process resource — a shared baseline plus the process index, not an ephemeral port (→ ginkgo:parallelism):
port := 4000 + GinkgoParallelProcess() // 4001, 4002, ... one per shard, same every run
l, _ := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
server := httptest.NewUnstartedServer(handler)
server.Listener.Close()
server.Listener = l
server.Start()
Then pick one of two shapes deliberately — different trades, not better and worse:
- Keep one server up per process and reset its state per spec. Start in
SynchronizedBeforeSuite's second function withDeferCleanup, clear state in aBeforeEach. Suits a server whose state is handed back cheaply — in-memory store, stub registry, seeded fixtures. (Biloba's own suite runs one fixture server per process; static files, nothing to reset.) - Bounce the server between specs over the same port. Suits a server whose per-spec isolation is a resource it holds for its lifetime (a
GinkgoT().TempDir()store), where resetting would mean re-pointing a live server at a new root. Also buys a durability check the other shape can't: restart over the same directory and make the new process re-read from disk what the old one wrote.
If you pin a port, give each fixture its own &http.Transport{}. A zero-value http.Client uses http.DefaultTransport, whose pool is process-global and keyed on host:port — so a spec's first request can get a keep-alive socket to the previous spec's closed server, surfacing as an intermittent EOF out of a BeforeEach that points at nothing. CloseIdleConnections() on teardown. Only the pinned-port shape has this problem.
4. Suite-level config
SpinUpChrome(GinkgoT(), ...):
biloba.HighFidelityHeadless()— full headless Chrome.biloba.AutoInstallHeadlessShell()— download the shell if missing.biloba.HeadlessShellPath(path)— point at a specific shell binary.biloba.StartingWindowSize(w, h)— default tab size (default1024x768); process-wide. Per-spec override:b.SetWindowSize(w, h)(self-restoring).biloba.ChromeFlags(...)— rawchromedp.ExecAllocatorOptions (e.g.chromedp.Flag("headless", false)to watch).biloba.ChromeSandbox(enabled)— override the automatic Linux--no-sandboxdecision (see above).
ConnectToChrome(GinkgoT(), ...) carries Biloba-specific config — mostly failure artifacts (outlines, screenshots, inline images) → debug-failures. Under CI or an AI agent, failure artifacts need zero config: Biloba auto-detects and emits a DOM outline plus screenshot files on disk.
5. Two directories, and your .gitignore
Biloba writes into two directories with opposite lifecycles. Get this right up front:
# .gitignore
biloba-screenshots/
biloba-screenshots/— ignore it. Failure screenshots, plus the.actual.png/.diff.pngartifacts a failed visual assertion produces. Throwaway output, regenerated every run. (BILOBA_SCREENSHOTS_DIR/BilobaConfigScreenshotsToDir(dir)moves it — on CI, point it at a directory you upload as a build artifact.)biloba-baselines/— commit it. The reference PNGsb.HaveScreenshotcompares against: few, small, reviewed. Only created once you write a visual assertion. (BILOBA_SCREENSHOT_BASELINES_DIR/BilobaConfigScreenshotBaselinesDir(dir)moves it.) →visual-assertions
Committing the artifacts directory is how a repo's history ends up mostly rejected PNGs; gitignoring the baselines makes every visual assertion fail on a fresh clone.
6. Run it
ginkgo -r -p # parallel — Biloba is built for this
ginkgo -r -p -randomize-all # also enforces spec independence
Next
Before you write a single spec, load write-tests — the most consequential authoring decision (CSS hook vs. locator vs. XPath) lives there, and generic-automation muscle memory gets it wrong. Reach for api for exact method/matcher names.