Architecture
ferridriver is a single Rust engine wrapped in many shapes. The test runner, BDD framework, MCP server, and NAPI binding don't ship their own browser logic — they all dispatch to the same core. That is where the consistency comes from, and it is also where the speed comes from.
Layers
A Gherkin step, a Rust #[ferritest], an MCP tool call, and a Node.js
page.click() all reach the same Page::click in ferridriver.
Backends at a glance
Backends dispatch through a Rust enum (BackendKind), not a trait
object. Calls monomorphize to a single backend path — no vtable lookup,
the compiler can inline across the boundary. You pay for exactly one
backend per process.
See Concepts → Backends for when to pick which.
Test execution
Every test — Rust #[ferritest], parameterized #[ferritest_each], and
BDD scenarios — runs through one pipeline: TestRunner::run(). The BDD
crate translates .feature files into the same TestPlan; JavaScript /
TypeScript step bodies execute on the embedded QuickJS engine inside that
pipeline. There is no second runner.
A few consequences:
- Workers launch browsers concurrently via
tokio::join!, not sequentially. On a warm machine, overlapping launches save 80–100 ms per extra worker. - The dispatcher is work-stealing. Fast workers pick up more tests. You don't hand-balance anything.
- Retry re-enqueues. A failed test goes back on the shared queue — any worker can grab it, not just the one that failed.
A single test, end to end
Three things to notice:
- The
Browsersurvives between tests. TheBrowserContextdoes not. afterEachruns even when the test body fails. That is how teardown stays reliable.- Retry is separate from this loop — a failed test goes back into the dispatcher; the diagram plays out again, possibly on a different worker.
Why the shape is this shape
- One engine, many frontends. Adding a new test style (a new macro,
a new DSL, an MCP tool) doesn't fork the execution path. It translates
into a
TestPlanand lets the core handle the rest. - Rust owns the hot path. Polling, actionability checks, selector
compilation, CDP transport — all Rust. The TypeScript
expectwrapper is a thin shim that issues one NAPI call per assertion; the retry loop stays inside Rust. - Per-worker browser, per-test context. Launching a browser is the most expensive thing you can do. Creating a context is cheap. Amortize the first, refresh the second.
- Dispatch via enum, not trait object. Uniform API without the vtable cost.
For the file-level map, see the workspace section in the root README.