Skip to main content

biloba-testing

How to write and run Biloba's own Ginkgo test suite. Use when adding or modifying specs in this repo, asserting that a Biloba call should fail the test, working with the fixture server, or running the suite (including the driver, parity, e2e, and npm packaging lanes). Covers the run commands, the failure-capturing gt/bilobaT harness, ExpectFailures, fixtures, and spec structure.

Quellinformationen

Repository
onsi/biloba
Letzte Quellaktivität
14. September 2026 um 04:55
Erkannte Sprache von SKILL.md
Englisch
Sterne
29
Forks
9

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
biloba-testing
description
How to write and run Biloba's own Ginkgo test suite. Use when adding or modifying specs in this repo, asserting that a Biloba call should fail the test, working with the fixture server, or running the suite (including the driver, parity, e2e, and npm packaging lanes). Covers the run commands, the failure-capturing gt/bilobaT harness, ExpectFailures, fixtures, and spec structure.
# Testing Biloba Every Go test in this repo is a **Ginkgo spec** (Gomega for assertions). There is no `go test`-style table testing here — write `Describe`/`Context`/`It`. The TypeScript client's tests under `typescript/` are Vitest. ## Running the suite The `Makefile` wraps the canonical invocations — prefer these: | Command | What it runs | When | |---|---|---| | `make test` | headless (chrome-headless-shell), parallel + randomized | your default, every change | | `make test-all` | `make test`, then the same suite in full ("new") headless google-chrome (`BILOBA_TEST_HIGH_FIDELITY=true`) | before changes touching tab/Chrome lifecycle, DOM interaction, focus, input, viewport, or scrolling — behavior differs between the lanes, and CI runs both | | `make stress-test` | 6 procs under moderate CPU/IO load (`stress`), 41 repeats, generous total budget | **only periodically, when you suspect a change might be flaky, or after adding specs that drive asynchronous page state** — it's slow and needs `stress` (`brew install stress`) | | `make driver-test` | the Go driver packages (`./engine ./protocol ./cmd/bilobad`, including the generated-protocol and biloba.js drift specs) and the TypeScript unit suites | when touching `engine/`, `protocol/`, `cmd/bilobad/`, or `typescript/` | | `make driver-parity` | the Go/TypeScript parity contract: a real `bilobad` driving a real shared Chrome, asserted against the same fixture from both languages | same, plus before trusting a new vertical slice | | `make driver-e2e` | the shipping topology: three vitest worker *processes*, one `bilobad` each, one shared Chrome | before anything touching session isolation, daemon lifecycle, or shared-browser attach/detach | | `make packaging-check` | `make npm-pack` (the five npm packages, with `bilobad` cross-compiled), then installs the tarballs into an empty project the way a user would and runs `tsc`, `npx biloba install-chrome`, and one Vitest spec (`VITEST=vitest@3` to try another version) | when touching packaging, the `biloba` CLI, daemon resolution, `typescript/package.json`, or anything a fresh `npm install biloba` depends on — CI runs it on Node 20 + vitest@3 and Node 22 + vitest@latest | | `make check-plugins` | both plugin manifests (`biloba-go` and `biloba-vitest`), versions, namespaces, skill names, and client-separation rules | whenever changing `plugins/`, marketplace metadata, docs links, or the release version | Under the hood `make test` is just `ginkgo -r -p --randomize-all --randomize-suites`. `-p` (parallel) is the realistic mode — Biloba is built for it (one shared Chrome, one isolated root tab per process); `--randomize-all` enforces spec independence. `make stress-test` exists because Biloba's flakes are timing/concurrency races in the Chrome DevTools target lifecycle that a single clean run won't surface. It runs `ginkgo -procs=6 --repeat 40 --timeout=1500s --poll-progress-after=45s` under background `stress` load: the load perturbs scheduling so races show up, `--poll-progress-after` dumps the wedged goroutine stack within 45s of any hang, and the generous `--timeout` is a *total* budget across all repeats (so size it above repeats × per-run, or a healthy run looks like a timeout). Don't run it on every change — reach for it after touching tab create/close, `AllTabs`, `ConnectToChrome`, or anything in the chromedp bridge, and after adding specs that drive asynchronous page state (network interception, fetch-then-render fixtures, anything with two DOM writes): it finds races in newly written specs that both normal lanes pass. **Don't run two root-package `ginkgo` invocations from the same checkout at once** (say, a focused probe while `make test-all` runs in the background). `SpinUpChrome` writes `./.biloba-config-<process>` and its cleanup deletes it, so the second run's Chrome takes over — and then removes — the connection file the first run's specs read whenever they `ConnectToChrome` a fresh root. Those specs then panic on a nil `*Biloba`, which reads like a real regression. The `./engine` suite doesn't share the file. To focus while debugging, run in serial and optionally non-headless/interactive: ``` ginkgo --focus="..." # serial, easier to read BILOBA_INTERACTIVE=true ginkgo # headed; pauses on failure until ^C (serial, few specs) ``` ### The driver lanes, and why they refuse to skip CI runs every one of these (`.github/workflows/test.yml`). Two rules keep them honest: - **The engine suite fails rather than skips when it cannot get Chrome.** Process 1 resolves the binary through the same search the Go suite and `bilobad` use (explicit path → `BILOBA_CHROME_HEADLESS_SHELL` → `$PATH` → the newest build in the puppeteer/Biloba caches) and installs Chrome for Testing's stable shell when none is found. It is the only guard that `engine/biloba.js` still matches the canonical `biloba.js`, so a quiet skip there would let the copy drift unnoticed. If the install fails, run `make update-chrome`. - **`typescript/test/parity.test.ts` fails rather than skips when `BILOBA_DAEMON_EXECUTABLE` is unset.** It is the only test that exercises the real daemon end to end; "4 skipped, exit 0" is indistinguishable from "4 passed" at a glance. Run it via `make driver-parity` (which builds the daemon first). To opt out on purpose, set `BILOBA_SKIP_PARITY=true`. `bilobad` resolves Chrome itself, so neither lane needs a browser environment variable. `make driver-test` deliberately does *not* run `go generate ./engine` — regenerating `engine/biloba.js` there would silently repair the drift the engine suite exists to catch. Run `go generate ./engine` yourself after editing `biloba.js`. ### The e2e suite tests the real topology `typescript/test/e2e/` is the only place the shipping arrangement — N worker processes, one daemon each, one shared Chrome — exists as such. `globalSetup` starts the single Chrome in the main process and provides its websocket URL; each test *file* becomes its own forked worker with its own daemon. - **The `rendezvous()` barriers in `test/e2e/harness.ts` are the proof of concurrency.** They order the workers so an assertion happens while the others are demonstrably live, and a serial run blocks until it times out, so a green run is evidence of the topology. If you add a worker file, update the expected count in every `rendezvous()` call — a stale count releases the barrier early and it quietly stops proving anything. - **The isolation assertion is self-evidencing.** All three workers set `owner=<name>` on the *same* origin, so three different values coexisting is what browser-context isolation means. - **`crash.e2e.test.ts` runs in a second vitest invocation with its own Chrome**, because it kills things on purpose. Its specs run in source order: the recoverable crashes first, killing Chrome last. - **Every crash case pins a diagnosis.** A crash reported as an assertion timeout sends the reader to debug a test that was fine. The cases: a crashed renderer (`PAGE_CRASHED`, recoverable by navigating), a dead browser (`BROWSER_GONE`), a worker killed without teardown (its daemon reaps itself; Chrome and the other workers are untouched), and Chrome unreachable at daemon startup (`DRIVER_CLOSED` with the daemon's stderr attached). Two of them carry timing bounds; don't loosen them. CDP stops answering calls against a dead renderer or browser instead of failing them, so a loose bound lets the crash come back as a timeout. ### Guards on the wire protocol `protocol/` is the Go↔TypeScript boundary. Extend its guards rather than work around them: - `protocol/encoding_test.go` marshals every response type and compares the keys that appear against the keys the **generated** TypeScript declares required. It catches `omitempty` on a struct (a no-op in Go: the field ships on every response while TypeScript calls it optional — make the Go field a pointer). When you add a wire field, add its type to the table. - `typescript/test/protocol-golden.test.ts` asserts the goldens with `toEqual`, not `toMatchObject`, so a field the daemon starts or stops emitting fails. - `protocol/generated_test.go` renders the generated TypeScript and golden frames from the Go wire structs and compares them with what is on disk. It deliberately doesn't ask git (that would pass a staged divergent file and fail a correctly regenerated working tree). After editing the wire structs, run `go generate ./protocol` and commit the generated files with the change. Conventions the wire specs enforce: - **A bad request costs the request, not the daemon.** One worker's daemon owns every session that worker drives, so an unknown method, mis-shaped params, a duplicate in-flight id, a zero id, or an unparseable frame body is answered as an error and the loop keeps going. Only a desynced stream (short read, bad length prefix) ends it. `protocol/stdio_test.go` pins each case with a `stillServing()` call afterwards — follow that shape. - **A stub daemon and the real one answer to one contract.** `typescript/test/support/assertions.ts` holds the invariants a timed-out operation owes; both `client.test.ts` (fast, stub) and `parity.test.ts` (slow, real `bilobad`) assert through it. Generated types constrain the *shape* a stub can send, not its *behaviour* (it caught the stub reporting a timed-out click with an empty trajectory, which the real daemon cannot produce). Put invariants there and payload specifics at the call site. - **Some failures are not worth retrying.** `engine.Poll` stops immediately on a fatal attempt error — the engine's counterpart to `gomega.StopTrying`, which the Go API uses for the same reason (a decode into the wrong pointer type, a missing baseline, a selector that will never parse). Mark one with `engine.Fatal(err)`, or give an `engine.Error` a code whose `Fatal()` is true. Getting it wrong burns the whole budget and then blames the page with a *timeout*. A `ReferenceError` or `TypeError` is the ordinary shape of "not there yet" (biloba.js not installed, an element not rendered) and stays retryable; a `SyntaxError` can't start parsing later, so it is fatal, as is a malformed *expectation* (invalid `expectedJson` is the caller's bug). - **Meaning goes on the wire, not into inference.** `evaluate` carries an explicit `invoke` flag rather than guessing from whether the argument array is empty. If you find yourself deriving one field's meaning from another's emptiness, add the field instead. ## Suite setup (`biloba_suite_test.go`) - A single shared `b *biloba.Biloba` is created in `SynchronizedBeforeSuite` (process 1 runs `SpinUpChrome`, every process runs `ConnectToChrome`). - `b.Prepare()` runs in a `BeforeEach` decorated `OncePerOrdered` (so it doesn't reset between `It`s inside an `Ordered` container). - Specs are served HTML fixtures from `./fixtures/*.html` by a `ghttp` server reachable at the package var `fixtureServer`. Add a `.html` file there when you need new DOM to test against. - `crossOriginFixtureServer` serves the same fixtures from another port — a different origin on the same site — for cross-origin iframe specs (`fixtures/frames.html?child=<url>` embeds any URL). Swap `127.0.0.1` for `localhost` to make it cross-*site*; whether that runs out of process depends on the lane (chrome-headless-shell keeps it in the tab's renderer, full Chrome doesn't). `frames_test.go`'s "Out-of-process iframes" container pins the out-of-process path in both lanes by starting its own `--site-per-process` chrome-headless-shell with `engine.StartBrowser` and connecting a fresh root to it with `biloba.ConnectToChrome(gt, biloba.BilobaConfigWithChromeConnection(...))`. Start that browser with `context.Background()`, not the node's `SpecContext`: the context ends with the node and takes the browser with it. ## Typical spec shape ```go var _ = Describe("...", func() { BeforeEach(func() { b.Navigate(fixtureServer + "/dom.html") Eventually("#hello").Should(b.Exist()) // confirm the page is ready before exercising it }) It("does the thing", func() { Ω("#hello").Should(b.BeVisible()) }) }) ``` Navigate, then `Eventually(<anchor>).Should(b.Exist())` to gate on readiness, then exercise behavior. `Ω` and `Expect` are interchangeable. ## Poll-by-default changes how you assert failures **Biloba polls by default.** The fully-applied form of a DOM method (`b.Click("#go")`, `b.GetValue("#x")`) *waits*: it runs the method's matcher under `Eventually` bound to `gt`. Two consequences for specs: - **A not-found / not-actionable call doesn't fail immediately.** It polls until a deadline and then surfaces a Gomega **"Timed out after…"** failure that wraps the matcher message (and, for a genuine JS error, the error text inside that wrapper). So drive these specs with a *short* timeout and assert the timeout substring, **not** an exact message: ```go b.WithTimeout(time.Millisecond * 60).GetInnerText("#non-existing") ExpectFailures(ContainSubstring("Timed out after")) ``` - **`b.Immediate()` acts once and fails fast** (it uses `Expect`, a single evaluation). Reach for it when you want to assert the bare matcher message without waiting out a poll: ```go Ω(b.Immediate().GetInnerText("#non-existing")).Should(Equal("")) ExpectFailures(ContainSubstring(`have property "innerText"`)) ``` ## Asserting that a Biloba call SHOULD fail the spec This is the non-obvious part. Biloba normally turns errors into Ginkgo failures via `GinkgoT().Fatalf`. In this suite, Biloba is wired to a custom `*bilobaT` (the package var `gt`) that **captures** `Fatal`/`Fatalf` into `gt.failures` instead of aborting the spec. So to test Biloba's own failure behavior: ```go It("errors when the selector is malformed", func() { b.HasElement(b.XPath("//[blarg]")) // would normally fail the spec ExpectFailures(ContainSubstring("is not a valid XPath expression")) }) ``` - `ExpectFailures(expected ...any)` is **`ConsistOf`-based**: it expects **one matcher per captured failure** (each arg is a Gomega matcher or a string compared with `Equal`), then clears the buffer. To assert **two substrings against a single failure**, pass one `SatisfyAll(...)`, not two args: ```go b.WithTimeout(time.Millisecond*60).SetValue("#non-existing", "foo") ExpectFailures(SatisfyAll( ContainSubstring("Timed out after"), ContainSubstring("could not find DOM element matching selector: #non-existing"), )) ``` - An `AfterEach` asserts `gt.failures` is empty — **if a spec triggers a Biloba failure and you don't consume it with `ExpectFailures`, the spec fails** with "Did you forget to call ExpectFailures?". This `gt`/`ExpectFailures` path is also how you assert **hard errors from the four-bucket guards** — e.g. configuring a method that doesn't support a knob (`b.WithPolling(...).Navigate(...)`, `b.Immediate().Count(...)`) or configuring a bare matcher (`b.WithTimeout(d).Click()`). These are `gt.Fatalf` calls, so capture them with `ExpectFailures(ContainSubstring("does not support WithPolling"))` (or `ContainSubstring("returns a matcher")` for the bare-matcher guard). For **matchers**, you usually don't go through `gt` — call `Match` directly and inspect the returned error: ```go match, err := b.BeVisible().Match("#non-existing") Ω(match).Should(BeFalse()) Ω(err).Should(MatchError("could not find DOM element matching selector: #non-existing")) ``` ### The `FailureMessage` gotcha gcustom matchers render their template from data populated **during `Match`**. So to assert a matcher's `FailureMessage`, reuse the **same matcher instance** the assertion already `Match`-ed — calling `FailureMessage` on a *fresh* matcher renders against empty data: ```go m := b.EachBeVisible() Ω(".non-existing").ShouldNot(m) // this Match populates m's template data Ω(m.FailureMessage(".non-existing")).Should(ContainSubstring("Expected at least one element to match")) ``` (See the `HaveCount`/`EachBeVisible` specs.) ### Half-coverage of a two-sided API reads as coverage A spec that composes a *view* (`Realistic()`, `WithTimeout(d)`, `WithPolling(d)`, `Immediate()`) with a method that registers or mutates state has two halves: the knob has to take effect, **and** the registration has to land on the tab. This spec proves only the first: ```go It("fails the spec with a directive message when Await times out", func() { b.WithTimeout(60 * time.Millisecond).HoldResponse(ContainSubstring("/api/never-requested")).Await() ExpectFailures(ContainSubstring("Timed out after 60ms waiting for HoldResponse to intercept...")) }) ``` The URL is never requested, so registration is never exercised — `0 matching response(s) have been intercepted so far` sits in the expected output as a *success* condition. Registration through a view was broken at nine call sites while this spec stayed green. The smell: **an assertion on a timeout, a status, an empty tally, or an error path, where the happy path it implies is never taken.** Ask of any zero in an expected output whether it could ever have been non-zero in this spec; if not, the spec is asserting its fixture rather than the code. The fix is a **second spec from the opposite direction** (one proves the deadline is honoured, another proves the handler fires), not a bigger assertion on the first. ## Spec-authoring idioms (reach for these before `b.Run`) `b.Run` is the escape hatch; most things people reinvent with it already exist as a matcher that polls cleanly under `Eventually`. Keep `b.Run` for genuinely app-specific state. - **Counting:** `Eventually(sel).Should(b.HaveCount(7))` (or `b.HaveCount(BeNumerically(">", 10))`) — not `b.Run("...querySelectorAll(sel).length", &n)`. - **Attributes/properties:** `b.GetAttribute`/`b.GetProperty` (or the `b.HaveAttribute`/`b.HaveProperty` matchers) — not `getAttribute`/property reads in JS.
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen