Configuration
ferridriver looks for ferridriver.{toml,yaml,yml,json} in the current
directory, then in ~/.config/ferridriver/. Override with -c PATH.
Wire keys are camelCase.
Example
Projects (matrix runs)
Run a single slice with --project firefox.
BDD projects
A project can also bring its own Gherkin corpus — features globs,
steps globs, and tags, a Cucumber tag EXPRESSION with the grammar
--tags takes:
Those three are a discovery, not a narrowing: features chooses
different files, steps a different registry, tags a selection made
before the outline rows exist. So a project naming any of them is
planned separately, and two projects with different steps get
different worker VMs rather than silently sharing the first one's step
definitions. Two projects that resolve to the same three inputs share
one build, and a project naming none of them narrows the shared plan
exactly as it did before.
tags is distinct from tag, which is a list of Playwright test tags
that must all be present.
use and option fixtures
use is the Playwright use block: context options (locale,
colorScheme, testIdAttribute, …) plus any key of your own. A key no
built-in option claims is the value of a fixture registered with
{ option: true }.
It may be written at the top of [test], where Playwright writes it, or
as [test.browser.use]; they are the same block, and a key written in
both keeps the browser.use one. A project takes [test.projects.use]
or [test.projects.browser.use] for the same reason.
Devices
device names an entry of Playwright's device registry and pre-seeds
every key that descriptor carries — userAgent, viewport, screen,
deviceScaleFactor, isMobile, hasTouch and defaultBrowserType.
Anything you write beside it wins, in that layer or a higher one:
The same table is a value on the framework module, so a TypeScript config spreads it the way a Playwright config does:
Runner options in use
Playwright spells baseURL, actionTimeout, navigationTimeout,
trace, video and screenshot inside use, so they are resolvable
per project. Each wins over the top-level [test] key that says the
same thing:
trace, video and screenshot each take a mode or an object:
The modes are Playwright's, whole: off, on, retain-on-failure,
on-first-retry, on-all-retries, retain-on-first-failure and
retain-on-failure-and-retries for trace and video; off, on,
only-on-failure and on-first-failure for screenshot. Recording and
keeping are separate decisions — retain-on-failure records every
attempt and keeps only the failures, while on-first-retry does not
record the first attempt at all.
screenshotOnFailure is the older boolean spelling of
screenshot = "only-on-failure". actionTimeout and navigationTimeout
are test-scoped, so a spec may also set them with test.use({ … });
trace, video and screenshot are worker options and come from the
config or the project.
defaultBrowserType only decides the engine nobody else named — an
explicit browserName, or a project browser block, wins. viewport in
use overrides [test.browser].viewport, the spelling that predates
the block, and viewport = null (in YAML/JSON/TS) asks for no fixed
viewport at all, which is not the same as leaving it out.
Viewport
Written nowhere, the viewport is Playwright's default — 1280x720 —
for every host: the test runner, the MCP server and ferridriver run
alike. viewport = null is the only way to ask for none, and it means
the page takes whatever size the browser window happens to have.
The distinction matters most on a persistent profile. A browser launched
with a userDataDir comes back at the window bounds its last run left
behind, so a host that emulates nothing inherits whatever size that
browser was resized to — and one launch then decides the viewport of
every session after it. Emulating the default is what keeps a run
reproducible.
Declare it once at the top level and every host inherits it:
A section that states its own wins: [mcp.browser].viewport and
[test.browser].viewport (or use: { viewport }) each override the
top-level key without restating the rest of it. Precedence, most
specific first:
Pages ferridriver opens are emulated with
Emulation.setDeviceMetricsOverride, and on a headed Chromium the
window is resized to match (viewport plus the platform's window chrome),
so what you see is what the page reports.
Precedence, innermost first: a test.use({ … }) in the spec — which
takes device too — then the project's block, then the config's, then
the fixture's own default. Each
layer overlays key by key, so a project setting one key keeps the
config's others.
A key naming a fixture that is NOT an option (including page,
context, request and browser) fails the run — only option fixtures
can be set from a config. A key naming nothing at all is reported as
use.unknownKey and ignored.
Web server
Multiple [[test.webServer]] blocks can run in parallel.
Config in TypeScript
.ts / .js is a config format, not a special case. A
ferridriver.config.ts is discovered in the same places a
ferridriver.toml is, holds the same document, layers by the same
rules, and shadows the same way when two formats sit in one directory:
Discovered basenames, in precedence order per directory:
ferridriver.toml, .yaml, .yml, .json, then
ferridriver.config.ts, .mts, .js, .mjs. --config <path> names
one explicitly, in any of those formats.
The module is bundled and evaluated through the same rolldown → QuickJS pipeline every spec takes, so a config can import helpers and be written in TypeScript with no build step.
A configuration written in documents never constructs a bundler or a
JavaScript runtime to read itself. The loader is installed only when
the stack actually holds a module layer, so a Rust suite on
ferridriver.toml pays nothing for a format it does not use — measured
at +2.1 ms above process floor for a document config against +4.9 ms for
a module one.
Four settings cannot come from a module, and each is refused by name
rather than ignored: extensions, bundler, scripting and
[test].moduleAliases. Every one of them had to be read before any
module could be compiled, which is why the stack folds the documents
first and the modules second. Put them in a .toml / .yaml / .json
layer.
defineConfig
defineConfig is Playwright's function and folds Playwright's shape,
which is ferridriver's [test] section — so it goes inside the
document, not around it:
It folds layers rightmost-winning: scalars are replaced; use, expect
and build merge one level deep; webServer normalizes each side to a
list and concatenates; projects merge by name, each match taking the
incoming project's use on top, with new names appended.
Priority
Lowest to highest:
- Extension
defineDefaultscontributions - Config file defaults, in any format — a
.tslayer is a config file main!()/HarnessConfigmacro arguments (Rust)- Environment variables —
FERRIDRIVER_BACKEND,FERRIDRIVER_WORKERS,FERRIDRIVER_TIMEOUT,FERRIDRIVER_RETRIES, … - CLI flags —
--headless,--backend,--workers,--timeout, …
Profiles
Named presets that merge into the base config via --profile NAME,
passed through the Rust test harness:
Full schema
The TestConfig Rust type is the canonical reference. Notable fields:
Snapshot paths
snapshotPathTemplate decides where a baseline lives; the default is
Playwright's legacy layout, a -snapshots directory beside the spec:
expect.toHaveScreenshot.pathTemplate and
expect.toMatchAriaSnapshot.pathTemplate override it for those two
matchers. A relative template resolves against the directory of the
config file that declared it.
A token may carry a separator INSIDE its braces — {-projectName} emits
-chromium for a named project and nothing at all for an unnamed one.
That is why {arg}{-projectName}{ext} gives button.png in a
single-project run and button-chromium.png in a matrix.
testInfo.snapshotPath(...name, { kind }) answers with the same path
the matcher of that kind ('snapshot', 'screenshot', 'aria')
would write, without consuming a snapshot index.
Expect block
[test.expect] carries the defaults every assertion starts from, and
each matcher's own sub-table carries its options. A per-call option bag
layers on top: a key the call names wins, a key it leaves out comes from
here.
A project may carry its own expect block, and it replaces the
config's whole object rather than merging into it — Playwright's
takeFirst(projectConfig.expect, config.expect, {}). A project setting
only expect.timeout therefore starts from the defaults for everything
else, including the screenshot budget:
Bundler
[bundler] governs how JS / TS imports are resolved and transformed for
every bundle ferridriver produces — spec files, BDD step files,
extensions and ferridriver run scripts.
Every one of these participates in the bytecode cache key, and the
governing tsconfig is tracked as a bundle input, so changing a condition
or a paths mapping rebuilds instead of serving stale bytecode.
Plus per-project ProjectConfig and per-context ContextConfig
(viewport, locale, timezone, geolocation, permissions, etc.). See the
rustdoc for ferridriver-config for the full struct.