- name
- write-tests
- description
- Author Biloba specs in a Go Ginkgo/Gomega suite — the dual immediate/matcher API (act now vs. return a matcher you poll with Eventually), capturing a matcher's observed value with .Capture instead of asserting-then-re-reading, first-vs-all naming, the navigate-then-readiness-anchor shape (gate on the DOM, then read GetLocation), selecting elements (CSS targeting stable hooks as the default, semantic role/text/label locators, anchoring a locator scope so a negative assertion isn't vacuous, the >>> piercing combinator, XPath), the interaction vocabulary (click variants, drag, scroll, tap, text selection), realistic mode for occlusion/hover smoke tests, visual regression with b.HaveScreenshot against a committed baseline, hermetic tests via request stubbing/aborting/modifying/holding, the GetJSValue app-state barrier (and when it gates nothing), multi-tab flows, cross-origin iframes (b.Frame frame handles), and seeding state. Use when writing or reviewing Biloba browser tests.
# Writing Biloba specs
Assume a **Go/Ginkgo** suite, wired with `setup`, and apply the principles in `overview`. Method list → `api`. XPath → `xpath`. Flakes → `flaky-specs`. Docs: <https://onsi.github.io/biloba/#working-with-the-dom>. Sibling skills are named here without a prefix; invoke one with the same plugin prefix you loaded this skill under.
## RULE — get these two right in the first draft
1. **Selecting elements.** User-facing things → a **Locator** by role/name/text (`b.ByRole("button").WithName("Save")`, `b.ByText("Sign in")`), which doubles as an a11y guard. Structural/state hooks you own → **CSS on a stable `#id`/`[data-testid]`**, never a styling class.
2. **Assert observable outcomes** — visible text, counts, URL/title, network effects — not internal class/structure.
**Smells to catch in your own draft** (generic-automation muscle memory gets these wrong):
| Smell | Instead |
|---|---|
| `:nth-of-type`, `.btn-primary` | `b.ByCSS(sel).Nth(i)`, or a stable `#id`/`[data-testid]` |
| text-matching XPath | `b.ByText(...)` / `b.ByRole(...).WithName(...)` |
| `b.Run("...querySelectorAll(s).length")` | `b.HaveCount(...)` — reinventing a matcher |
| IIFE-wrapping a script to get `return` | `b.RunAsync` |
| `SetValue` when you meant keystrokes | `b.Type` |
| `b.Run(expr, &x)` then `Expect(x)` — single-shot read | `Eventually(b.Run).WithArguments(expr)` |
| matcher assertion, then a getter for the same value | `.Capture(&x)` on the matcher — one read |
| a guarded/conditional interaction ("click X only if…") | fix the spec's state determinism upstream → `flaky-specs` |
## The one pattern: dual, and poll-by-default
Most DOM methods have **two forms keyed on argument count**.
```go
// Fully applied → POLLS until ready, acts/reads exactly once, stops. The everyday form.
b.Click("#go") // polls until clickable, clicks once
b.SetValue("#name", "Jane") // polls until settable, sets once
text := b.GetInnerText("#title") // polls until present, reads once
// Under-applied → returns a matcher YOU poll. For a custom timeout, a Consistently, or .And().
Eventually("#go").Should(b.Click())
Eventually("#name").Should(b.SetValue("Jane"))
Eventually("#title").Should(b.HaveInnerText("Welcome"))
```
A fully-applied action dispatches **once** on the first success and stops — safe even on a **toggle**, no oscillation, no "fired a frame too early" race.
**Tune or opt out** with shallow `*Biloba` clones: `b.WithTimeout(5*time.Second).Click("#go")`, `b.WithPolling(d)`, `b.WithContext(ctx)`. `b.Immediate()` reverts to act-once/fail-fast — **you almost never want it**. Misapplying a knob (e.g. `WithPolling` on a snapshot, or any knob on the bare matcher form) is a hard error → four-bucket table in `api`.
**First-vs-all naming.** A bare method acts on the **first** match. Plural getters are **snapshots** (no poll — gate presence first); plural actions carry an **`Immediately` suffix** (act now, no poll; the long suffix is a deliberate "know what you're doing" smell). Matchers over all matches are `Each*` and **fail on zero matches**.
| first (polls) | all (snapshot / immediate) | matcher over all |
|---|---|---|
| `GetInnerText` | `CurrentInnerTextForEach` | `EachHaveInnerText` |
| `GetProperty` | `CurrentPropertyForEach` | `EachHaveProperty` |
| `Click` | `ClickEachImmediately` | — |
`EachHaveInnerText`/`EachHaveTextContent`'s sub-matcher and `.Capture` see a typed `[]string` (same slice their `Current*ForEach` getter returns) — `Equal([]string{...})` works. The generic `EachHaveProperty` hands over the raw JSON-decoded `[]any` (properties are heterogeneous) — `Equal([]string{...})` always fails there; reach for `HaveExactElements(...)`. Same shape as `EvaluateTo`'s raw-vs-typed asymmetry, below.
## The spec shape
```go
var _ = Describe("the search page", func() {
BeforeEach(func() {
b.Navigate("http://localhost:8080/search")
Eventually("#results").Should(b.Exist()) // readiness anchor
})
It("finds matches", func() {
b.SetValue("#q", "biloba")
b.Click(b.ByRole("button").WithName("Search"))
Eventually(".result").Should(b.HaveCount(BeNumerically(">", 0)))
})
})
```
- `b.Navigate(url)` asserts the response was `200` (`NavigateWithStatus` for other codes).
- Pick a **stable, meaningful** anchor (a heading, a key container) with `b.Exist()`/`b.BeVisible()`.
**For an in-app navigation, gate on a DOM anchor and *then* read the location — don't poll the URL.** The URL flips the instant the router pushes it, so it's the weakest proof the destination rendered.
```go
b.Click("#submit")
Eventually("#dashboard-root").Should(b.Exist()) // proves the navigation landed…
Ω(b.GetLocation()).Should(HaveSuffix("/dashboard")) // …so this is a formality
```
`b.GetLocation()`/`b.GetTitle()` are polling getters honoring all four knobs, and a transient CDP error mid-navigation (`Inspected target navigated or closed`) is a retryable miss — so `Eventually(b.GetLocation)` is safe, just weaker.
## Pocket matcher cheat-sheet — reach here before `b.Run`
| Want to assert… | Matcher |
|---|---|
| present / visible | `b.Exist()` / `b.BeVisible()` |
| how many match | `b.HaveCount(BeNumerically(">", 0))`; distinct by key: `b.HaveDistinctCount("data-key", 3)` |
| visible text | `b.HaveInnerText("…")` / `b.HaveTextContent(…)` / `b.HaveText(…)` (whitespace-collapsed) |
| a DOM/JS property | `b.HaveProperty("href", …)` / `b.HaveClass("active")`; JSON attr `b.HaveJSONAttribute("data-state", HaveKeyWithValue(…))` (getter `b.GetJSONAttribute(sel, attr, &out)`) |
| actually clickable (visible+enabled+topmost) | `b.BeClickable()` |
| form value | `b.HaveValue(…)` |
| tab URL / title / spawned tab | `b.HaveURL(…)` / `b.HaveTitle(…)` / `b.HaveSpawnedTab(…)`; getters `b.GetLocation()`/`b.GetTitle()` |
| the app applied a server response | `b.GetJSValue("window.__storeLog", &log)` — app-state barrier (below); *not* the DOM, *not* a Go-side HTTP read |
| a network request was made | `Eventually(b).Should(b.HaveMadeRequest(…))` |
| layout / box / scroll | `b.HaveBoundingBox(HaveField("Top", …))` / `b.HaveOffsetTopWithin(container, …)` / `b.HaveScrollOffset(…)`. `Box.Width`/`Height` = border-box; `ClientWidth`/`ClientHeight` = scrollbar-excluded content box |
| A relative to B | `b.BeAbove` / `BeBelow` / `BeLeftOf` / `BeRightOf` / `b.Encloses` / `b.Overlaps`; numeric `b.GetGapBetween(a,b)` → `BoxDelta` |
| on screen / document order | `b.BeInViewport()` (partial; `b.BeInViewport(b.Fully())` = whole box) / `b.BePrecededBy` / `b.BeFollowedBy` |
| computed style | `b.HaveComputedStyle(prop, …)` / `b.HaveComputedStyleNumeric(prop, …)`; color across syntaxes `b.HaveComputedStyle(prop, b.MatchColor("var(--tok)"))`; getters `b.GetComputedStyle`/`GetComputedStyleNumeric`/`NormalizeColor` |
| scroll a target into view (instant) | `b.ScrollIntoView(sel)` — options `b.WithinScroller(container)`, `b.AtTopOffset(px)` |
| an arbitrary JS expression | `Eventually(expr).Should(b.EvaluateTo(matcher))` |
| it still *looks* like this | `b.HaveScreenshot("name")` against a committed baseline → `visual-assertions` |
- `EvaluateTo`/`Run` decode JS numbers to **float64** — `BeNumerically("==", n)`, not `Equal(intLiteral)`.
- **`BePrecededBy`/`BeFollowedBy` read subject-first**: `Eventually(X).Should(b.BeFollowedBy(Y))` ⇔ X comes **BEFORE** Y. A backwards one passes silently — also assert the inverse doesn't hold.
- **Geometry matchers error on a missing element**, so `Eventually(sel).ShouldNot(b.BeInViewport())` times out. Assert disappearance with `ShouldNot(b.Exist())` / `Should(b.HaveCount(0))`.
## `.Capture(&x)` — don't re-read what you just asserted on
```go
var blockID string
Eventually(".figure-frame").Should(b.HaveAttribute("data-block-id", Not(BeEmpty())).Capture(&blockID))
b.Click("#block-" + blockID)
```
Asserting and *then* calling the getter is two reads of a page that may have changed in between. `Capture` hands you the value from the winning read, decoded into your Go type, and writes only on a match (`ShouldNot` captures nothing). Value-less matchers (`Exist`, `BeVisible`, `BeInViewport`, the relational ones) and actions-as-matchers deliberately don't compile with it.
Decode rules (typed-decode mismatches, absent-vs-zero via `**T`, the new-matcher-each-time behavior, narrowing a JS object vs. a Biloba struct) → `flaky-specs` §1. Full capturable list → `api`.
## Selecting elements — the vocabulary
A `selector` is a **CSS string** (fastest; `:has()`; `>>>` pierces shadow/iframe), a **`Locator`** (`b.By*`; most resilient, slowest — full-document ARIA scan), or an **`XPath`** (`xpath`; axis/ordinal; pierces neither).
```go
b.Click("#go") // CSS by id — the preferred default
b.Click("[data-testid=save]") // CSS by intentional test-id
Eventually("tr:has(td.overdue)").Should(b.Exist()) // CSS :has() — "the row that contains X"
b.Click(b.ByRole("button").WithName("Save")) // Locator — role + accessible name (a11y guard)
b.Click(b.ByText("Submit")) // Locator — visible text (ByTextContains for substring)
b.SetValue(b.ByLabel("Email"), "jane@acme.com") // Locator — a control by its label
b.Click(b.XPath("li").WithText("OK").Ancestor("ul"))// XPath — an axis query nothing else expresses
```
Constructors (each text-valued one has a `*Contains` variant): `b.ByRole`, `b.ByText`, `b.ByLabel`, `b.ByPlaceholder`, `b.ByAltText`, `b.ByTitle`, `b.ByTestID` (attr = `biloba.TestIDAttribute`, default `data-testid`), `b.ByCSS(sel)` (raw CSS into the algebra — the structural escape hatch). Refine a role with `.WithName(n)`, `.Level(n)`, or ARIA states `.Checked()`/`.Disabled()`/`.Expanded()`/`.Pressed()`/`.Selected()`.
Locators **compose**, and every filter accepts **any** selector (CSS/XPath/Locator):
```go
b.ByRole("listitem").ContainingText("Product 2") // .ContainingText / .NotContainingText
b.ByRole("listitem").Containing(b.ByText("Delete")) // .Containing / .NotContaining (a descendant)
b.ByRole("button").And(".primary") // .And / .Or — intersection / union
b.ByRole("button").WithName("Delete").Within("#dialog") // .Within / .NotWithin
b.ByText("Item").Nth(2) // .Nth(i) / .First() / .Last()
b.ByCSS(".story").Nth(1) // the 2nd .story
```
**Trap — an unresolved `.Within`/`.Containing` scope matches nothing, so a negative assertion is vacuous** — it passes instantly and forever if the scope never renders. Anchor the scope first:
```go
Eventually("#published-list").Should(b.Exist()) // the scope is real…
GitHubで見る