BDD JavaScript / TypeScript API

Cucumber-js-shaped surface, native-backed. Step bodies in JS / TS run through the same Rust TestRunner that drives Rust #[ferritest] tests — same workers, retries, reporters, fixtures.

Step registration

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 contains {string}", async function (fragment: string) {
  if (!this.page.url().includes(fragment)) throw new Error("mismatch");
});

// Keyword-agnostic (matches Given / When / Then / And / But)
defineStep("I wait {int} seconds", async function (n: number) {
  await new Promise((r) => setTimeout(r, n * 1000));
});

Pattern can be a Cucumber expression (default) or a RegExp:

Given(/^I have (\d+) items$/, async function (count: string) {
  // count is the matched substring; parse if needed
});

Per-step timeout

Per-step options object goes between pattern and handler:

Given("slow thing", { timeout: 30000 }, async function () { /* ... */ });

Hooks

Before(async function () {
  await this.context.clearCookies();
});

// Tag-filtered
Before("@auth", async function () {
  await this.page.goto("https://app.example.com/login");
});

// With explicit options
Before({ tags: "@auth", name: "login", timeout: 10000 }, async function () { /* ... */ });

After(async function (result) {
  if (result?.result?.status === "FAILED") {
    this.attach(await this.page.screenshot(), "image/png");
  }
});

BeforeStep(async function () { /* before every step */ });
AfterStep(async function () { /* after every step */ });

BeforeAll(async () => { /* once per run */ });
AfterAll(async () => { /* once per run */ });

Tag expressions support the full boolean grammar: @smoke and not @wip, (@fast or @critical) and not @wip.

After* hooks run even when earlier hooks or steps failed (cleanup guarantee). Hook order: Before hooks run in ascending order, After hooks run in descending — cleanup mirrors setup.

The World

this inside any step or hook is the World — a per-scenario object carrying fixtures and helpers:

PropertyTypeNotes
this.pagePageLive browser page (Playwright-shaped).
this.contextBrowserContextCookies, permissions, init scripts, geolocation.
this.browserBrowserMulti-page operations.
this.requestHttpClientRunner-side HTTP. Net-restricted if allow.net is set.
this.parametersRecord<string, any>--world-parameters JSON.
this.attach(content, mediaType?)functionAttach bytes / strings to the test report (screenshots, logs).
this.log(message)functionFree-text log line attached to the report.
this.skip()functionMark scenario as skipped (throws the __ferri_skip__ sentinel).

Custom World

setWorldConstructor(class MyWorld {
  constructor({ parameters }: { parameters: Record<string, any> }) {
    this.tenant = parameters.tenant ?? "default";
  }
  tenant: string;
});

setWorldConstructor is per-VM (last call wins). Fixtures (page, context, browser, request) are augmented onto the instance after construction.

Step return values

Return / throwResult
(nothing) / resolved promisepassed
string "pending"pending (yellow; --strict makes it fail)
string "skipped" or this.skip()skipped
throwfailed — error remapped to original .ts / .js location via the rolldown source map, including the stack

Parameter types

Built-in Cucumber expression parameters:

TypeRegexTypeScript
{string}"[^"]*" | '[^']*'string
{int}[+-]?\d+number
{float}[+-]?\d+\.\d+number
{word}\S+ (non-whitespace)string
{}\S+ (anonymous)string

Custom parameter type

defineParameterType({
  name: "color",
  regexp: "red|green|blue",
  transformer: (s: string) => ({ name: s, hex: colorMap[s] }),
});

Given("I pick {color}", async function (color: { hex: string }) {
  // color.hex is "#ff0000" etc.
});

Or shorthand: defineParameterType("color", "red|green|blue").

Type inference: Given('I have {int} {string}', (count, item) => {}) gives count: number, item: string in TS-aware editors.

Data tables

A step that ends with a table receives a DataTable argument by name:

Given I have these users:
  | name | role  |
  | Ada  | admin |
  | Grace| editor|
Given("I have these users:", async function (table: DataTable) {
  for (const row of table.hashes()) {
    // row = { name: "Ada", role: "admin" }
    await this.request.post("/users", { data: row });
  }
});

DataTable methods

MethodReturnsNotes
raw()string[][]All rows including header
rows()string[][]Data rows (header excluded)
hashes()Record<string, string>[]One object per data row, keyed by header
rowsHash()Record<string, string>Two-column tables → {key: value, ...}
transpose()DataTableSwap rows / columns

Doc strings

A """ block after a step is passed as a string argument:

When I send this JSON:
  """json
  { "name": "Ada", "role": "admin" }
  """
When("I send this JSON:", async function (body: string) {
  await this.request.post("/users", { data: JSON.parse(body) });
});

Media-type hints ("""json, """yaml) are parsed and surfaced in the report but do not change the value type (always string).

Defaults and globals

setDefaultTimeout(10000);                   // ms; per-registry default
setDefinitionFunctionWrapper((fn) => fn);   // wrap every step body (retry, trace)
setParallelCanAssign((/* ignored */) => true); // accepted but inert

setParallelCanAssign is accepted for cucumber-js compat but is inert: ferridriver parallelises at the test-runner worker level (one VM per worker), not cucumber-js's per-pickle scheduler.

Built-in Rust step library

There is a shipped Rust step library (ferridriver-bdd/src/steps/) of 144 steps — see Built-in steps. Those are registered via #[given] / #[when] macros on the Rust side and merge into the same registry as your JS / TS steps. A .feature file can mix steps from both languages freely.

Imports

import { helper } from "./helpers.js";          // resolved by rolldown
import * as utils from "../shared/utils.ts";    // TS imports work
import semver from "semver";                    // node_modules bundled

No bare specifier resolution from inside extensions / steps outside what rolldown can resolve at bundle time. There is no runtime require() and no Node module loader.

See also