Fixtureframedocs
Adapters

Writing your own

Scaffold a workspace adapter, implement the hooks your application needs, register it, and point an environment at it.

When none of the shipped adapters fits — a bespoke login widget, a region that has to be chosen, an application that needs a moment before it is truthful to photograph — write one. It lives in your workspace, beside the flows it serves, and it is a plain ESM file that Fixtureframe imports.

Scaffold it

ff adapter init acme

That creates the file, registers it, and enables it in one go:

{
  "status": "created",
  "adapter": "acme",
  "module": "/…/.fixtureframe/adapters/acme/adapter.mjs"
}

The id is normalised — Acme Corp becomes acme-corp — because it is used as a directory name and as the value an environment stores.

The directory

Everything belongs to the workspace, and nothing about it is global:

your-repo/
└── .fixtureframe/
    ├── adapters.json              # which adapters exist, and where they live
    ├── adapters/
    │   └── acme/
    │       └── adapter.mjs        # yours
    ├── auth/                      # session material — gitignored
    ├── flows/
    └── config.json

adapters.json is the registry. init writes the entry; you only edit it by hand to disable something or to move a file:

{
  "schema_version": "1.0",
  "adapters": [
    { "id": "form-login", "enabled": true },
    { "id": "token", "enabled": true },
    { "id": "api-key", "enabled": true },
    { "id": "acme", "enabled": true, "module": "./adapters/acme/adapter.mjs" }
  ]
}

Two rules that produce most of the errors:

  • module is resolved from .fixtureframe/, not from the repository root. An entry with no module means "a built-in adapter of this name", which is why a typo'd id reports that there is no built-in adapter rather than that your file is missing.
  • The file must export an object whose id matches the registration — as adapter or as the default export. A mismatch is refused at load rather than at the first step.

Write it

The whole contract is one required pair, the secrets it declares, and five optional hooks. Implement only what the engine cannot work out for itself, and delete the rest — an application with a recordable login and no special readiness needs none of them.

// .fixtureframe/adapters/acme/adapter.mjs

export const adapter = {
  id: "acme",
  displayName: "Acme Cloud",

  // Declared so `ff doctor` and `adapter inspect` can report on them
  // before anything runs. The engine never resolves these itself.
  secrets: [
    { name: "ACME_USERNAME", description: "Service account for staging.", required: true },
    { name: "ACME_PASSWORD", description: "Its password.", required: true },
    { name: "ACME_REGION", description: "Region to choose at sign-in. Defaults to EU.", required: false },
  ],

  /**
   * Sign in. Given a Playwright page already at the environment's base URL,
   * and a resolver for the secrets above.
   */
  async authenticate(page, secrets) {
    // Nothing to do when a session was restored: the wall is not on screen.
    const wall = page.locator("#username");
    if (!(await wall.isVisible().catch(() => false))) return;

    await page.getByRole("button", { name: secrets("ACME_REGION") ?? "EU" }).click();
    await page.fill("#username", secrets("ACME_USERNAME"));
    await page.fill("#password", secrets("ACME_PASSWORD"));
    await page.click("button[type=submit]");

    // Prove it worked before returning. The form going away is the signal that
    // needs no product knowledge; a dashboard element is fine too when you own
    // the application and it is stable.
    await page.locator("#password").waitFor({ state: "hidden", timeout: 45_000 });
  },

  /** Block until the application is hydrated enough to photograph truthfully. */
  async waitForReady(page) {
    await page.getByRole("main").waitFor({ state: "visible" });
  },
};

The built-in helpers are not importable. The published CLI ships as a single executable with no library exports, so a workspace adapter cannot import signInWithForm from it. Write the steps with Playwright directly — a sign-in is usually five lines — or keep using form-login and configure it. Composing the built-ins is only available to adapters living in the Fixtureframe repository itself.

The other hooks

HookWhen to implement it
waitForReadyThe page renders before its data arrives, so a capture would photograph a skeleton.
seedRoutesNavigation a survey cannot see — every nav item is a styled div with a click handler, no anchor and no ARIA role.
classifyProduct-specific elements the generic HTML/ARIA pass cannot recognise — a chart, a KPI tile.
fixturePolicyNetwork interception. Parked, and most adapters should not implement it.

seedRoutes is the one people discover late. A survey follows anchors and ARIA navigation roles, which covers a conventional application — but a single-page app whose menu carries no web semantics is, to a survey, a site with one page. Returning the routes yourself is knowledge only the adapter has:

  seedRoutes() {
    return ["/dashboard", "/expenses", "/settings/team"];
  },

Without it ff survey reaches almost nothing, and ff lint then reports names as unsurveyed that are perfectly real.

The session, and why authenticate sometimes does nothing

authenticate runs only when there is no valid session. A successful sign-in saves Playwright storage state to .fixtureframe/auth/session.json, and every later command restores it — which is why the example above returns early when the login wall is not on screen. Write that check; without it an adapter throws the first time it runs against a session it already had.

Two consequences worth knowing:

  • Delete .fixtureframe/auth/session.json to force a fresh sign-in. It is gitignored, and disposable.
  • A broken adapter can look like a working one for as long as an old session survives. That is exactly what adapter check exists to catch: it never reuses a session, so it answers the question you asked rather than the one the cookies answer.

Check it before you run anything

The one that signs in:

ff adapter check acme --url https://staging.acme.test --headed

Exit 0 and the adapter completed — authenticate returned and waitForReady resolved. Exit 2 and either a required secret is missing, reported by name before a browser is even launched, or a stage threw. A stage that throws saves the page as it stood to .fixtureframe/discovery/adapter-check-acme.png, which is usually the whole diagnosis: a rejected password, an MFA prompt, a tenant chooser, a consent screen. Add --json when something else needs to branch on the result.

Nothing here guesses whether a page looks logged in. The verdict is your adapter's own contract, so a check that passes and a capture that fails cannot disagree about what signing in means.

And the two that answer without a browser:

ff adapter list
ff adapter inspect acme

inspect answers the question people actually open it for — which variables this adapter needs, and whether this machine has them:

{
  "id": "acme",
  "display_name": "Acme Cloud",
  "source": "project_local",
  "registration": { "id": "acme", "enabled": true, "module": "./adapters/acme/adapter.mjs" },
  "capabilities": { "authenticate": true, "waitForReady": true },
  "secrets": [
    { "name": "ACME_USERNAME", "required": true, "set": true, "description": "Service account for staging." },
    { "name": "ACME_PASSWORD", "required": true, "set": false, "description": "Its password." }
  ]
}

set: false on a required secret is the run that was going to fail. ff doctor reports the same thing across every enabled adapter, which is what to put in a setup script.

Use it

An adapter is chosen per environment, so the same flow replays against staging and a demo tenant with different credentials and, if need be, different sign-in code. Set the adapter on the environment in Studio — Settings → Environments, open the one you want, and choose it there — then supply the secrets to whatever runs the flow:

ACME_USERNAME=demo@acme.test ACME_PASSWORD=… \
ff run --flow "Submit an expense" --environment staging

Recording and discovery take one by name, for the sessions that happen before an environment exists:

ff record acme --url https://staging.acme.test
ff survey acme --environment staging
ff discover acme --url https://staging.acme.test

record and survey resolve an environment for you and take --url only to override where it starts. discover does not — it requires --url outright.

If an adapter is registered but switched off, the failure says so and how to undo it — init registers all three built-ins and enables them, so an environment bound to one that was later disabled arrives here on its first run.

Keeping it honest

The reason this file exists in your workspace rather than in Fixtureframe is that it holds facts about your application: seed routes, chart selectors, the name of a region button. Those do not belong in the tool every customer installs, and the moment they end up there they start rotting on somebody else's release schedule.

The same rule applies inside your adapter: what changes per environment — an address, an account, a region — is a secret or an environment setting, not a constant in the file.

There is no safety net for a credential written into an adapter. .fixtureframe/auth/ and .fixtureframe/discovery/ are gitignored; .fixtureframe/adapters/ is not, because the adapter is source you are meant to review and commit. And while flows are checked for surviving literals before they are written, nothing reads adapter source for the same thing. A password typed into this file will be committed, and no tool will say so. Declare it in secrets and resolve it at call time.

On this page