Running

Configuration

Everything lives in saffron.config.json at the project root. Every key is optional: the defaults below are what runs when the file is absent. saffron status --json prints the effective configuration, so you can see what a run will actually use. A number is written as a number: a quoted one ("actionTimeoutMs": "10000"), a negative one, or a screenshot mode other than off, viewport or full-page stops the run with exit code 2, naming the key. So does a key that names a known one misspelt ("maxCostUSD" says to write maxCostUsd), a capabilities value in quotes and a snapshotMode other than full or none; any other key Saffron does not read is said and ignored. status --json masks a password written in a URL and a header value written whole.

{
  "baseURL": "https://stage.your-app.com",
  "features": "features",
  "actionTimeoutMs": 5000,
  "pollIntervalMs": 100,
  "retries": 1
}

Timing

Option Default Description
actionTimeoutMs 5000 How long a single action may take before it fails. This is the budget for Playwright's own waiting (visible, stable, enabled, receives events) and the deadline for a polled assertion. Raise it for a slow app; a step that needs much longer is usually better written as an explicit wait.
pollIntervalMs 100 How often a polled assertion re-checks the page inside that budget. Lower reacts sooner and costs more CPU; raise it on a busy CI box or for an app that repaints slowly. Values below 10ms are clamped.
retries 1 Extra attempts per action, with a 250ms × attempt backoff, before the step counts as failed and the agent is asked to heal. This is not a scenario re-run. Absorbed retries are visible: a retried ×N chip in the report, a ↻ line in the terminal.

Project

Option Default Description
baseURL none The app under test, so steps can say "the login page" instead of a full URL. Overridden by --base-url. It may carry a path and a query (https://stage.test/booking/?flow=checkout-guest): recorded navigations are cached relative to it, keep its path and inherit its query on replay, so switching ?flow=standard to ?flow=checkout-guest needs no re-recording.
dataDir "data" Directory of JSON and CSV files read by {data:file.key} tokens and Examples: {data:file}. See §16.
env none Data environment: lays data/<file>.<env>.json over data/<file>.json. Overridden by SAFFRON_ENV, then by --env.
features "features" Directory holding .feature and .saffron files, searched recursively. Every command and both editors follow it, and saffron init creates it (init --examples installs the example suite into it, and its data into dataDir).
storageState none Playwright storage-state JSON (cookies and localStorage) applied to every browser context, so replays and the agent start authenticated. See §8.
sessions none Settings per browser session, by the name steps give it (As "buyer" ...): { "buyer": { "storageState": ".auth/buyer.json" } } starts that session signed in as its own person. A session without an entry takes storageState. See §8.
api none The API under test (§23): { "baseURL": "https://api.example.test", "contract": "api/openapi.json" }. services names more APIs, each { "baseURL": ..., "contract": ... }, which a request names instead of a host; --api-url overrides any base URL for a run. baseURL is where the HTTP steps' relative paths go (the run's base URL when absent); contract is the pinned OpenAPI 3 document that saffron api pin writes, which schema checks validate responses against, and whose change makes the recordings checked against it stale. transcript sets what the request transcript keeps: "on-failure" (default), "on" or "off" (§23).

Runtime

Option Default Description
browser "chromium" chromium, chrome, msedge, firefox or webkit. Recording and healing need one of the first three, because the agent attaches over CDP. chrome and msedge use the browser installed on the machine. A list, ["chromium", "firefox", "webkit"], runs the suite in each into one report (§15).
device none A Playwright device profile every scenario runs under, such as "iPhone 15" or "Pixel 7": viewport, user agent, touch and scale (§15).
viewport none Viewport size as "WIDTHxHEIGHT", such as "1280x720". Wins over the device's viewport when both are set.
timezone this machine's An IANA time zone, such as "Europe/Berlin": every browser runs in it, and {date...} tokens count days on its calendar (§3). An unknown name stops the run.
dateLocale "en-US" The locale of month and weekday names in a {date...} token that names none, such as "de-DE" (§3).
workers 1 Parallel replay workers. Speeds up cached runs; recordings and heals go by agentWorkers.
agentWorkers 1 Agent sessions (recording, healing) that may run at once, each in a browser of its own (§15). Each one is a paid session: raise it deliberately. With a browser already running (transport.browser, or a transport.module), every session would share it, so recordings run one at a time, and the run says so.
shardBy "scenario" How --shard splits the suite: "scenario" spreads every file over the machines, "file" keeps each feature file on one. --shard-by wins over it.
snapshotMode "none" none has the agent take page snapshots deliberately rather than after every action, measured at about 60% fewer AI calls and 44% lower cost per recording. Use "full" when the agent struggles to locate elements on a highly dynamic page.

Setup and teardown

Option Default Description
setup none A shell command, or a list of them, run once before the run, in the project root. A failure stops the run before any browser opens. See §17.
teardown none A shell command, or a list, run once after the run, whatever its outcome.
transport none How Saffron reaches a browser and an API that are not on this machine as it is: browser.cdpEndpoint (a running Chromium; records and replays) or browser.wsEndpoint (a Playwright server; replays only), api.proxy, api.ignoreHTTPSErrors and api.headers (sent only to an API under test, each {env:NAME} read when sent, refused when not set, and masked like any secret), and module, a project module whose browser() and api() hooks decide them at run time (§9).
capabilities none What this environment offers or lacks, by name: { "issuer-redirect": false }. A scenario tagged @requires:issuer-redirect is not run where it is false: it is red, blocked by the environment, and names the missing prerequisite, instead of being recorded or healed around it. One the config does not name is not known to be missing. saffron run --plan lists such scenarios as refused.
requirementTag ^@[A-Z][A-Z0-9]*-[A-Z0-9-]*[0-9]$ The tags that name a requirement, as a regular expression: @REQ-12 and @WA-TC1 match, @smoke does not. What saffron coverage maps (§9).
reset none A shell command, or a list, run before each fresh execution a recording or a proof opens (§17), so a scenario that creates bookings or payments starts each attempt from the same data. With agentWorkers above 1 and the agent on, recordings run one at a time, and the run says so: one session's reset would wipe the data another is recording on. saffron run --plan lists the scenarios with side effects and how many times the run executes each.
hookTimeoutMs 300000 The longest one setup or teardown command may take before Saffron stops it and counts it as failed.

CI

Option Default Description
ci.provider the repository's host Where saffron runs and saffron import --run look for CI runs: "github" (GitHub Actions, through gh) or "azure" (Azure Pipelines, through az). Absent, an Azure Repos remote means Azure, anything else GitHub. --provider wins over it. See §19.
ci.organization from the git remote The Azure DevOps organization URL, https://dev.azure.com/<org>, when the remote does not name it (a GitHub repository built by Azure Pipelines).
ci.project from the git remote The Azure DevOps project, in the same case.
ci.cacheRuns 10 How many viewed CI runs .saffron/ci/ keeps, the oldest removed first (§19). A value that is not a positive whole number keeps the default.
ci.cacheMB 1024 How many megabytes those runs may take together, the oldest removed first. A value that is not a positive whole number keeps the default.

Failure evidence

Option Default Description
trace "off" Execution trace per scenario: "retain-on-failure" keeps a trace of every scenario that is not green, "on" keeps one for every scenario, "off" records none. Open one with saffron trace. See §18. Overridden by --trace <mode> for one run.
video "off" A video of each scenario's run, kept beside its screenshots as video.webm: "retain-on-failure" keeps the video of every scenario that is not green, "on" keeps every one, "off" records none. A heal or a recording is in the video of the run it happened in; proof replays are not filmed. Each tab, and each browser session, has its own video (video-2.webm and on). The report names the files. A video shows every frame of the page as drawn, so it holds what the page displays and every value typed into a field that is not a password field, {env:...} values included: masking covers text Saffron writes, not pixels. Keep video off for scenarios that type secrets (retain-on-failure still films a failing one), and treat the files, and the bundles and CI artifacts that carry them, as you treat the secrets. A summary export (--report-only) carries no video, and a full one leaves videos out when it is too big, green scenarios' first, and any video over 1 GB. A browser that cannot record stops the run before any scenario (exit 2), naming what to install (npx playwright install ffmpeg); a value other than the three modes exits 2 too. Overridden by --video <mode> for one run.
screenshot "viewport" When a step fails, Saffron saves a screenshot of the tab it failed in and shows it in the report, up to 20 MB of screenshots per report page: past that, the report names the file instead of embedding it, the failures first. "full-page" captures the whole scrollable page, "off" disables it. Files go to .saffron/artifacts/, which saffron init git-ignores, and hold the latest run only. A screenshot is a picture of your app: {env:VAR} masking covers text Saffron writes, not pixels, so a secret your page displays will be in it.

The agent

Option Default Description
provider "claude" Whose agent records and heals, on its login on this machine: claude, codex (ChatGPT plan), antigravity (Google AI Pro/Ultra, experimental) or cursor. See AI providers and sign-in. Overridden by --provider for one run.
model the provider's current default Model for recording and healing, in the provider's own names: for Claude a model id such as claude-opus-4-8 or an alias such as sonnet; for Codex e.g. gpt-5.5; for Antigravity a slug from agy models; for Cursor e.g. composer-2.5 or auto. Unset, each run takes whatever the provider defaults to at the time, which changes as models are released. Pin it here so every recording in the project is made, and priced, the same way. It belongs to the configured provider: a run switched with --provider uses that provider's default instead. Overridden by --model for one run.
healModel same as model A cheaper model for heal sessions and their refine passes, for example claude-sonnet-5. Recordings keep model. Overridden by --heal-model.
antigravityApiKey false Antigravity only: sign in with GEMINI_API_KEY (or GOOGLE_API_KEY) even when agy has a Google login, so runs bill your Gemini API account. With no key set, every Antigravity session (recording, healing, authoring) refuses to start. Unset, a login wins over a key. Codex's equivalent is forced_login_method = "api" in ~/.codex/config.toml. See AI providers and sign-in.
maxTurns 100 Turn limit for one agent session. Raise it for a very long scenario.
maxCostUsd none Start no new agent session once a run's AI cost reaches this many dollars (at API rates on a subscription). A session already running finishes; the scenarios left are red, blocked by the run's budget. Codex, Antigravity and Cursor report no price, so it cannot stop them (the run says so at their first session): limit them with maxAgentMinutes. Also --max-cost.
maxAgentMinutes none Start no new agent session once a run has gone on this many minutes. Also --max-agent-minutes.
maxSessionMinutes none End one agent session that has gone on this many minutes: its tools refuse, and it ends unfinished. Also --max-session-minutes.
reuseSteps true Seed a new recording from steps already recorded elsewhere in the project, so the agent only fills the gaps. --no-reuse disables it for one run.
verifyProposals true Proof-replay every recording at zero AI before filing it, so a proposal you review is one that already replays. --no-verify disables it for one run.
rowProofs every row How many of an Outline's other rows a recording is proved on before it is filed (§5), in Examples order. A row past it is "not run" in the review, and replays the proposal on its own row later in the run.
stabilityProofs 0 After a recording passes its proof, replay it this many more times, each in a fresh execution, before filing it (0 to 10). One that fails any gets an unstable recording: note and is left out of accept --all. Also --stability-proofs.

Gates

Option Default Description
strict false Treat yellow (passed, pending review) as failure, so CI is green only on reviewed caches. Also --strict.
assertionPolicy "strict" strict never lets the agent adapt an assertion. adaptable-mid allows mid-scenario checkpoint assertions to adapt with a recorded note; the final assertion block of a scenario is always strict.

How timing actually works

A cached replay does not sleep. Three mechanisms stack, and each one is visible in the report:

  1. Playwright's actionability wait. Every interaction waits for the element to be visible, stable, enabled and able to receive the event, up to actionTimeoutMs. Nothing is recorded for this: it is how Playwright behaves.
  2. Polled assertions. An assertion re-checks every pollIntervalMs until actionTimeoutMs runs out, sweeping every recorded locator on each tick, so a match inside an iframe does not have to wait out the main page's full budget first.
  3. Action retries. After that, the action is retried retries times with a growing backoff. Retries that were absorbed are reported rather than hidden, because a step that only passes on the second attempt is something you want to know about.

A failure that survives all three is a real failure, and that is what the agent heals. There is no whole-scenario retry: rerunning a flaky test until it passes is what Saffron replaces with a recorded, reviewable fix.

Authenticated apps

Generate a Playwright storage state once, point Saffron at it:

npx playwright open --save-storage=.auth/state.json https://stage.your-app.com
{ "storageState": ".auth/state.json" }

Every scenario's browser context (replay and agent) starts with those cookies/localStorage. Refresh the file when the session expires; keep it out of git.

A scenario with several browser sessions (As "buyer" ..., §3) can sign each one in as its own person:

{
  "storageState": ".auth/state.json",
  "sessions": {
    "buyer": { "storageState": ".auth/buyer.json" },
    "seller": { "storageState": ".auth/seller.json" }
  }
}

A session without an entry, and the default session, start from storageState (or --storage-state). An entry for "default" naming another file stops the run: the default session signs in with storageState only. Every file is checked when the run starts, so a missing one stops the run by name, as does an entry that is not { "storageState": "<file>" } (a misspelt key would otherwise start that person signed in as the default one), and when the whole suite runs, a configured session no step names gets a warning (a typo would otherwise start that person signed in as the default one). When storageState is set and two or more sessions the steps name have no entry of their own, the run warns once, naming them: they would all start signed in as the same person.

Parallel and cross-browser runs have their own section: §15.

Secrets the AI agent never sees

A step that signs in writes its credentials as {env:...} tokens. The values stay on your machine, in the environment or a git-ignored .env, and no AI provider ever receives them, not even while the agent records:

The step as you write it When I enter "{env:ADMIN_PASSWORD}" in the password field AI PROVIDER: TOKENS ONLY AI agent Claude, Codex, Antigravity, Cursor Saffron browser tools every browser call passes here Real browser your app, on your machine the prompt keeps the token browser_type text= '{env:ADMIN_PASSWORD}' 1 The recording and the trace keep the token 2 The value is put in; guards check the real URL or path fill('••••••••') the real value, here only snapshot, code, console, network, evaluate, downloads: the value inside 3 Every spelling of the value becomes the token again textbox "Password": {env:ADMIN_PASSWORD} The recording you commit "value": "{env:ADMIN_PASSWORD}" reports and traces: the token too Environment or .env The only place the value lives. .env is git-ignored; a real variable always wins. Every later run Replay reads the value at each action. No AI, no provider: nothing leaves your machine.
Agent-blind secrets: only tokens reach the AI provider; the value goes from your environment to the browser and nowhere else
  1. The prompt gives the agent the step as written, token included.
  2. The agent types the token: browser_type text='{env:ADMIN_PASSWORD}'. A token counts only where the browser types, picks, opens or uploads it; in browser_evaluate code it is refused. A page the agent opens with a token in its URL must be on the app's origin, or one the scenario itself writes: a page's text cannot lead the agent to send a secret anywhere else. A credential an API hands out during the session is shown to the agent as its {captured:name} token from then on.
  3. Every browser call, from Claude, Codex, Antigravity or Cursor alike, passes through Saffron's browser tools. There the recording and the trace take the call as the agent wrote it, with the token. Then the value is put in, and the safety checks judge the real value: a URL must be a web page, and an upload a file inside the project.
  4. The browser types the real value.
  5. Whatever comes back (the page snapshot, the code Playwright echoes, console messages, network requests and bodies, browser_evaluate results, Saffron's own replies about a download) has every spelling of the value turned back into the token before the agent sees it: as written, percent-encoded, JSON- and HTML-escaped, and quoted in code. Screenshots never reach the agent. The prompt's base URL, and a heal or refine session's quotes of replay's failure and the page's address, are masked the same way.

The recording therefore holds the token, and replay reads the value from the environment at each action, with no AI involved. Section 3 lists what masking cannot cover: values under three characters, a value the page shows transformed, and the same text elsewhere masked along with it.

More on authentication

storageState (config or --storage-state) applies a Playwright storage-state JSON (cookies + localStorage) to every scenario's browser context: cached replays and the agent start authenticated. All knobs live in saffron.config.json: baseURL, features, actionTimeoutMs, pollIntervalMs, retries, model, maxTurns, storageState, strict. Every one of them, with its default and what it changes, is in the configuration reference.

Runtime: browsers, workers, models

  • Setup and teardown: "setup" and "teardown" in the config are shell commands (one or a list) run once around the run: seed before any browser opens, clean up after the last scenario. A failed setup stops the run (exit 2) and no scenario runs; teardown always runs, after reds, after a failed setup and after Ctrl-C. A failed teardown leaves the verdicts alone and exits 2 when everything else passed. Commands get SAFFRON_BASE_URL and SAFFRON_ENV; --no-hooks skips them; hookTimeoutMs bounds them; the report lists each one.
  • Cross-browser replay: --browser firefox|webkit replays the committed suite on Gecko/WebKit engines: replay runs on Playwright's browser engine, which drives all three. Recording and healing need a Chromium-family browser (the agent attaches over CDP); under another engine those scenarios fail with explicit record-on-chromium guidance instead of a confusing error.
  • Google Chrome and Microsoft Edge: --browser chrome|msedge runs the branded browser installed on the machine, for teams that must test what their users have. Replay, recording and healing all work there. A missing browser stops the run up front, exit 2, by name.
  • Parallel workers: --workers N runs zero-AI replays concurrently (one browser context each). Scenarios that turn out to need the agent run again afterwards with full agent semantics, so mid-run healing still continues from the exact failed page state.
  • Parallel recording and healing: --agent-workers N (config agentWorkers, default 1) lets up to N agent sessions run at once, each in a browser of its own, so no session sees another's tabs. An outline's rows share one recording and stay together on one worker, in order: the first records, the rest replay it. Off by default: N sessions spend N times as fast.
  • Per-worker timing: every report has a timing panel (each worker's runs, busy time and share of its pass, and the five slowest scenarios), and the terminal prints the slowest three and each worker's share. Merged shards list each machine's workers.
  • Browser matrix in one run: --browser chromium,firefox,webkit (or a list in the config) runs the suite in each browser into one report, a row per scenario per browser. Chromium-family browsers go first, so the replay-only engines replay what they recorded; every browser starts before anything runs; evidence is kept per browser; the dashboard stands a scenario at its worst browser and never reads a difference between browsers as flakiness. Refused with --shard (a CI matrix is the parallel form).
  • Device and viewport profiles: --device "iPhone 15" runs every scenario, recordings and heals included, under a Playwright device profile; --viewport 1280x720 sets the size alone. A desktop recording replays under a profile, or fails at the step where the page differs. An unknown device, or a mobile profile in Firefox (which Playwright does not emulate), stops the run up front.
  • Sharding across CI machines: --shard 2/4 runs a fixed quarter of the suite, split by scenario (sorted, every Nth, with a Scenario Outline's rows kept on one shard), so several machines share one suite; --shard-by file (config shardBy) keeps each feature file on one machine instead, largest file first onto the least-loaded shard. Shards write shard-<i>-of-<n>.json instead of latest.json and never append to history; saffron report --merge combines a complete set into latest.json, latest.html and one history line equal to an unsharded run. The merge refuses a missing or duplicated shard, shards of different suites, and shards of different CI runs; merging twice does not count the run twice.
  • healModel: point heal sessions (and their refine passes) at a cheaper model than recordings: most heals are simple locator fixes.