Auto-waiting
Test flake usually comes from one of three places: clicking before the
target is clickable, asserting before the DOM catches up, or racing a
navigation. ferridriver handles all three for you — no sleep(200), no
manual waitForSelector dance.
There are two layers of waiting, and they compose:
- Actionability checks — run before every action (
click,fill,hover,press, …). Runs for at most 5 seconds, polling every 50 ms. expectpolling — runs on assertions (to_be_visible,to_have_text, …). Uses Playwright's interval schedule[100, 250, 500, 1000, 1000, ...]up toexpectTimeout(default 5000 ms).
Actionability — before actions
Before executing any action, the Rust core asks the browser to evaluate
window.__fd.isActionable(el). An element is actionable when it is:
- Attached (
el.isConnected) - Visible (non-zero box;
display/visibility/opacitypass) - Enabled (not
aria-disabled) - Stable (bounding box unchanged across animation frames — an element mid-transition keeps polling until it settles)
- Receives events (for clicks: the click point hit-tests to the target and is not occluded by another element)
This is the same set Playwright enforces. The exact subset depends on the
action — hover waits for visible + stable, fill for visible + enabled
- editable,
clickfor visible + enabled + stable plus the hit-test. If any check fails, the Rust side yields for 50 ms and retries. After 5 seconds the call fails withTimeout: element not actionable.
The poll loop is in Rust, not JavaScript (tokio::time::sleep). Other
pages in the same worker keep making progress while one element is
waiting — there is no blocking JS promise holding up the event loop.
expect polling — on assertions
expect(...) returns an assertion builder. Calling a matcher starts a
retry loop in the Rust core:
The loop stops when the assertion passes or expectTimeout elapses
(default 5000 ms). The schedule matches Playwright so test durations are
portable.
TypeScript keeps the same schedule under the hood — the matcher is a single NAPI call per assertion and the retry loop stays inside Rust.
Negation (.not)
Negating a matcher flips the success condition but keeps the polling.
expect(&loc).not().to_be_visible() waits for the element to
disappear. Same cadence, same timeout.
Soft assertions
.soft() records a failure but does not stop the test. Useful for
collecting multiple independent checks in one pass:
Navigations
page.goto(url, None) resolves on load by default. Override with
GotoOptions { wait_until: Some("networkidle".into()), .. } —
wait_until is a string: "load" (default), "domcontentloaded",
"networkidle", or "commit".
For navigations triggered by clicks, use page.expect_navigation which
registers the listener before the action and awaits it afterwards — no
lost-event races.
When to extend timeouts
The defaults (5 s actionability, 5 s expect) are tuned for interactive apps. Bump them for:
- Heavy server-rendered pages that finish painting only after multiple XHRs.
- CI machines that are occasionally 2–3× slower than laptops.
- Tests that wait on real backends (batch jobs, queue-processing UIs).
Prefer raising expectTimeout or timeout (per-test) over adding
manual sleep calls. Polling short-circuits as soon as the condition
passes; a blind sleep always waits the full duration.
force — skipping the checks
Pass force: true to bypass actionability entirely and dispatch the
action immediately, same as Playwright. Use it only when you deliberately
want to act on a not-yet-actionable element (e.g. asserting a disabled
button does nothing).
The stable gate is requestAnimationFrame-driven. On a backgrounded
page rAF is throttled, so the gate races a setTimeout watchdog: if it
hasn't settled within ~1 s the element is treated as stable (a page whose
rAF is paused is not animating). Foreground pages settle in one frame, so
there is no added latency in the common case.