ferridriver
Browser automation written in Rust with a Playwright-compatible API. Four backends (Chromium over CDP pipes, Chromium over CDP WebSocket, Playwright WebKit, Firefox over WebDriver BiDi) behind one surface. Ships as:
- A Rust library —
ferridriver - A test runner —
ferridriver-testwith#[ferritest], fixtures, hooks, expect matchers - A BDD framework —
ferridriver-bddwith native Gherkin and step bodies in Rust or JavaScript/TypeScript - A core browser binding for Node.js / Bun —
@ferridriver/node(NAPI-RS) - A CLI binary —
ferridriver(project scaffolding, MCP server, test runner, BDD runner, script runner, trace viewer, browser installer)
JavaScript / TypeScript BDD step files run natively through the single
Rust binary: they are bundled with rolldown, compiled to QuickJS bytecode
once at startup, and executed on the embedded ferridriver-script engine.
No Node or Bun is involved in the run path.
Status: pre-1.0. The API tracks Playwright closely but is not API-stable — expect breaking changes between minor versions.
Documentation: https://salamaashoush.github.io/ferridriver/
Project layout
12 workspace crates plus one example crate.
Everything above the core is a thin translator. The same Page::click
implementation is reached by a Rust #[ferritest], a Gherkin When I click "..." step, a JavaScript line in a run_script MCP call, and a Node.js
page.locator(...).click() over NAPI.
Install
One-line install (Linux, macOS)
curl -fsSL https://raw.githubusercontent.com/salamaashoush/ferridriver/main/install.sh | bash
Installs system dependencies (Linux), the ferridriver binary, and
Chromium for Testing.
Manual install
# From crates.io
cargo install ferridriver-cli
# From GitHub releases (prebuilt binaries)
curl -fsSL https://github.com/salamaashoush/ferridriver/releases/latest/download/ferridriver-VERSION-TARGET.tar.gz | tar xz
Browsers
ferridriver install chromium # default
ferridriver install --with-deps chromium # Linux: also install system libraries
ferridriver install firefox chromium-headless-shell # multiple at once
The WebKit backend uses Playwright's WebKit binary. ferridriver install webkit downloads it into ferridriver's own cache. Alternatively, set
FERRIDRIVER_WEBKIT to a Playwright WebKit checkout containing
pw_run.sh, or install Playwright once (npx playwright install webkit)
and ferridriver picks up that cache.
Staying current
ferridriver upgrade # newest release on your channel
ferridriver upgrade --check # report what is available, change nothing
ferridriver upgrade --canary # follow the rolling build from main
ferridriver upgrade --stable # move a canary build back onto releases
ferridriver upgrade --tag v0.4.0 # install one exact release
upgrade replaces the running binary in place, after verifying the
.sha256 published beside the archive. Two channels:
A build follows its own channel by default, so a canary never silently
moves itself onto stable. --version names the artifact:
$ ferridriver --version
ferridriver 0.5.0-canary.3945f9357 (3945f9357, canary, aarch64-apple-darwin)
Install a canary from scratch with install.sh --canary. Set
GITHUB_TOKEN (or GH_TOKEN) if the unauthenticated API rate limit
bites.
Scaffold a project
ferridriver init # config + a first spec + the editor's type declarations
ferridriver init --bdd # also a feature file and a step file
ferridriver init --config-format yaml
ferridriver doctor # check config, extensions, sidecars and browsers
Shell completions
ferridriver completions zsh > ~/.zfunc/_ferridriver
ferridriver completions bash >> ~/.bashrc
ferridriver completions fish > ~/.config/fish/completions/ferridriver.fish
Output conventions
Every command takes the same global flags: --format json (or --json)
for a machine-readable document — including failures, which are rendered
as JSON too — --quiet to drop progress narration, --color auto|always|never, and -v/-vv for debug and protocol tracing. Colour
is dropped automatically when output is redirected, when NO_COLOR is
set, and when a coding agent rather than a person is reading it.
The config flags belong to the commands that read configuration, so they
follow the command rather than preceding it, and the commands that never
open a config file (install, codegen, init, upgrade,
completions) do not accept them at all:
ferridriver test --config ./ci.toml --no-inherit
ferridriver session --config ./ci.toml open dev # or: session open dev --config …
Node.js / Bun (core browser binding only)
npm install @ferridriver/node
# or
bun add @ferridriver/node
Platform binaries are pulled in via optionalDependencies
(@ferridriver/node-{darwin-arm64,linux-x64-gnu,linux-arm64-gnu}).
Quick start (Rust)
use ferridriver::{Browser, browser_type::chromium};
use ferridriver::options::LaunchOptions;
use ferridriver::url_matcher::UrlMatcher;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let browser = chromium().launch(LaunchOptions::default()).await?;
let page = browser.page().await?;
page.goto("https://example.com").await?;
page.locator("#email").fill("[email protected]").await?;
page.locator("button[type=submit]").click().await?;
page.wait_for_url(UrlMatcher::glob("**/dashboard")?).await?;
let png = page.screenshot().await?;
std::fs::write("home.png", png)?;
browser.close().await?;
Ok(())
}
firefox() and webkit() are factories with the same shape. chromium()
defaults to the CdpPipe backend; use BrowserType::chromium_with(&BrowserTypeOptions { transport: Some(ChromiumTransport::Ws), .. }) for CdpRaw.
Quick start (Node.js / Bun)
import { Browser } from '@ferridriver/node';
const browser = await Browser.launch();
const page = await browser.newPageWithUrl('https://example.com');
await page.locator('#email').fill('[email protected]');
await page.locator('button[type=submit]').click();
await page.waitForUrl('/dashboard');
await browser.close();
Tests
Three first-class authoring styles. All run on the same TestRunner —
same workers, same retries, same reporters.
1. Rust #[ferritest]
use ferridriver_test::prelude::*;
#[ferritest]
async fn loads_homepage(page: Arc<Page>) {
page.goto("https://example.com").await?;
expect(&page).to_have_title("Example Domain").await?;
}
#[ferritest(retries = 2, tag = "smoke", timeout = "30s")]
async fn login_flow(page: Arc<Page>) {
page.goto("https://app.example.com/login").await?;
page.locator("#email").fill("[email protected]").await?;
page.locator("button[type=submit]").click().await?;
expect(&page).to_have_url("/dashboard").await?;
}
Wire a binary entry point:
// tests/harness.rs
mod homepage;
mod login;
ferridriver_test::main!();
# Cargo.toml
[[test]]
name = "e2e"
path = "tests/harness.rs"
harness = false
[dev-dependencies]
ferridriver-test = "0.4"
cargo test --test e2e
cargo test --test e2e -- --headless --backend webkit -j 1
cargo test --test e2e -- -g smoke --retries 2
2. BDD with Rust step bodies
use ferridriver_bdd::prelude::*;
#[given("I navigate to {string}")]
async fn navigate(world: &mut BrowserWorld, url: String) {
world.page().goto(&url).await.unwrap();
}
#[when("I click {string}")]
async fn click(world: &mut BrowserWorld, selector: String) {
world.page().locator(&selector).click().await.unwrap();
}
// tests/bdd.rs
ferridriver_bdd::bdd_main!();
cargo test --test bdd
# or via the CLI:
ferridriver bdd tests/features/
ferridriver-bdd ships 145 built-in steps (assertions, interaction,
navigation, network, API, storage, keyboard, mouse, frame, dialog,
emulation, etc.) — write your own only for app-specific vocabulary.
3. BDD with JavaScript / TypeScript step bodies
// steps/login.ts
Given('I navigate to {string}', async function (url: string) {
await this.page.goto(url);
});
When('I click {string}', async function (selector: string) {
await this.page.locator(selector).click();
});
Then('the URL should contain {string}', async function (fragment: string) {
if (!this.page.url().includes(fragment)) {
throw new Error(`URL ${this.page.url()} does not contain ${fragment}`);
}
});
ferridriver bdd --steps 'steps/**/*.{js,ts}' tests/features/
Given / When / Then / defineStep / Before / After /
defineParameterType / setWorldConstructor / setDefaultTimeout are
global; this is the World with page / context / request /
browser / parameters / attach / log / skip. No package.json,
no node_modules.
Planning a run: listing, selection, streaming
ferridriver test and ferridriver bdd can hand a run to something that
plans it elsewhere (a CI matrix, a test grid) and fold the results back.
--list --format json prints one document, { "schema": 1, "tests": [...], "errors": [...] }, without starting a web server or launching a
browser. Each test is one record per project and repeatEach index:
{
"stableId": "faf9e4ed725dbebe0699-6fd9282311e49be5da68",
"project": "chromium",
"file": "tests/cart.spec.ts",
"line": 7,
"column": null,
"titlePath": ["tests/cart.spec.ts", "checkout", "payment", "pays"],
"tags": ["@smoke"],
"annotations": [{ "type": "issue", "description": "https://example.com/issues/7" }],
"retries": 2,
"timeout": 30000,
"groupKey": "[chromium] tests/cart.spec.ts > checkout",
"repeatEachIndex": 0
}
Paths are relative to the config file's directory, so the same suite lists
the same ids on every machine and from any working directory. groupKey
names the serial group (describe.serial, describe.configure({ mode: 'serial' }), a @serial feature) that has to run whole on one worker, or
is null. bdd --list adds stepIssues (undefined and ambiguous steps)
to a scenario and an entry per step to errors, and exits 1 when a run
would fail on them; bdd --dry-run runs the scenarios that way, matching
every step without executing one.
Selection: --test-id ID (repeatable) and --test-list FILE (one id per
line, # comments) run exactly those tests, and an id that matches nothing
is an error; tests/cart.spec.ts:12 runs the test declared on that line.
--shard X/N keeps serial groups whole, and --shard-assignment FILE
places tests explicitly with a JSON object of stable id to shard index:
{ "faf9e4ed725dbebe0699-6fd9282311e49be5da68": 2, "faf9e4ed725dbebe0699-672e1fb5742dbe19438e": 1 }
--reporter stream writes every event as one line of the blob format,
flushed as it happens, to stdout, stream=fd:3, stream=stderr or
stream=<path>; attachments are written under the output directory and
referenced by path. The first line is a header (the second is abridged):
{"kind":"header","schema":4,"shard_index":null,"shard_total":null}
{"kind":"test-finished","outcome":{"test_id":{"file":"tests/cart.spec.ts","root_path":"tests/cart.spec.ts","name":"pays","line":7},"status":"passed","attempt":1}}
merge-reports takes blob zips and stream .jsonl files alike. A shard's
blob is report-<N>.zip with the shard in its header, and attempts of one
test from different inputs fold into one test, so a test that failed on
one machine and passed when re-run on another is reported flaky.
Exit status: 0 passed, 1 tests failed, 2 usage or configuration
error, 3 interrupted by SIGINT or SIGTERM (tests in flight finish and
report first), 4 infrastructure failure (a browser that did not launch,
a worker that crashed, a web server that did not start).
MCP server
Scripting-focused MCP server for AI agent browser automation. Works with Claude Code, Claude Desktop, Cursor, or any MCP client.
# stdio transport (Claude Code, Cursor)
ferridriver mcp
# HTTP transport (remote clients); a token is required off loopback
ferridriver mcp --transport http --port 8080
FERRIDRIVER_MCP_TOKEN=… ferridriver mcp --transport http --host 0.0.0.0
# Backend choice + headless
ferridriver mcp --backend webkit --headless
# Attach to an already-running Chrome
ferridriver mcp --auto-connect chrome
ferridriver mcp --connect ws://localhost:9222/devtools/browser/...
10 tools. connect, navigate, page (session bootstrap) · snapshot,
screenshot, evaluate, search_page, diagnostics (observation) ·
run_script (action) · ferridriver_extensions (introspection).
run_script runs sandboxed JavaScript against the live session with full
Page / Locator / BrowserContext / APIRequestContext bindings. One script
can navigate, fill forms, click, assert, and make HTTP calls in a single
LLM turn:
await page.goto(args[0]);
await page.getByLabel('Email').fill(args[1]);
await page.getByLabel('Password').fill(args[2]);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForSelector('[data-testid="dashboard"]');
return { title: await page.title(), cookies: await context.cookies() };
Globals in a script: page, context, request, browser, args
(bound, not interpolated — prompt-injection safe), vars (session-level
key/value store), console, fs (scoped to script_root), artifacts
(dedicated output dir), plus standard fetch / Headers / Request /
Response / AbortController. Error responses include stack, line,
column, and a source snippet so the model can self-correct.
See site/docs/mcp/tools.md and docs/extensions.md for the full surface and the plugin/extension contract.
Named sessions
The same scripting surface from a terminal, against a browser that stays
open between commands. session open publishes a browser under an id; every
run --session then sees the pages, cookies, storage and globalThis the
last one left behind.
ferridriver session open work https://example.com
ferridriver run --session work --eval "return page.url()"
ferridriver run --session work login.ts -- [email protected]
ferridriver session list
ferridriver session close work
Everything done to a session is one command — run this script — so there is
no verb table to drift behind the scripting API; the only other verbs stream
frames, relay input and record (below). TypeScript and relative
imports work exactly as they do locally: the client bundles (its working
directory is the one imports resolve against) and the host compiles and
runs, so bytecode never crosses between differently-built binaries. Console
output, browser actions (--trace) and generated source (--code) all
stream back as the script produces them.
Codegen by doing: every action a script performs can be rendered as
TypeScript, Rust #[ferritest], or Gherkin, and --code-out writes a file
that runs unchanged both standalone and against the session.
ferridriver run -s work --code-out login.ts --eval "await page.goto('/login'); await page.locator('#email').fill('[email protected]')"
ferridriver run login.ts
--as-test wraps the same lines as an @ferridriver/test spec instead:
ferridriver run -s work --as-test --code-out login.spec.ts --eval "await page.goto('/login')"
ferridriver test login.spec.ts
Recording, and driving a session from elsewhere
codegen records what a person does into a script or a spec, in a headed
browser of its own or on a live session's page:
ferridriver codegen https://example.com --target test -o login.spec.ts
ferridriver codegen --attach work --target test -o login.spec.ts
While attached, type visible, text or value and click an element to
record an assertion about it; url and title assert the page; stop ends
the recording.
A session also lets a client on another machine watch and drive a headless
browser, as a web app embedding a remote browser does: a screencast verb
streams JPEG frames, an input verb dispatches mouse, wheel and keyboard
events straight to the browser's input protocol, and recorder.start /
recorder.mode / recorder.assert / recorder.stop record the user's
actions as they happen and end with the spec. Listening beyond loopback
requires a token, carried in the endpoint string:
FERRIDRIVER_SESSION_TOKEN=… ferridriver session open rec --headless --host 0.0.0.0 --port 9300
From Rust, Recorder::attach(&page, sink) records any page, headless and
remote ones included.
See site/docs/scripting/named-sessions.md for the wire format.
Backends
Backends dispatch through a Rust enum, not a trait object — monomorphic
calls, no vtable lookup.
WebKit speaks Playwright's WebKit Inspector protocol over a NUL-byte-
delimited JSON pipe to a pw_run.sh child process. Same code on every
platform (macOS, Linux, Windows). The binary is shipped by Playwright;
ferridriver locates it via FERRIDRIVER_WEBKIT, then the Playwright
cache, then the ferridriver cache. Run ferridriver install webkit
(or npx playwright install webkit, or set FERRIDRIVER_WEBKIT) to
provide it.
Build and test
The repository uses just (justfile) and cargo aliases (.cargo/config.toml).
The Node binding lives outside the workspace default-members. To build and test it locally:
cd crates/ferridriver-node
bun install
bun run build:debug
bun test
Requirements
- Rust nightly (edition 2024). The toolchain is pinned in
rust-toolchain.toml;rust-version(MSRV) is 1.91. - Chrome or Chromium (
ferridriver install chromium, or setFERRIDRIVER_BROWSERS_PATHto use an existing install). - Firefox installed locally for the
bidibackend (ferridriver does not bundle Firefox). - Playwright WebKit binary for the
webkitbackend (ferridriver install webkit, setFERRIDRIVER_WEBKIT, or use Playwright's cache). ffmpegonPATHat runtime for video recording (optional).- Node.js 16+ or Bun 1.0+ only if you build or consume
@ferridriver/node.
Documentation
- Full site: https://salamaashoush.github.io/ferridriver/
- Per-crate rustdoc: https://docs.rs/ferridriver
- Architecture and internals:
CLAUDE.md,docs/
License
MIT OR Apache-2.0