Your first run
Recording
npx saffron run # everything under features/
npx saffron run features/login.saffron # one file (.saffron or .feature)
npx saffron run --filter @smoke # by tag
npx saffron run --filter @TC-1,@TC-2 # any of several tags (or repeat --filter)
npx saffron run --filter "@e2e and not @wip" # a tag expression
npx saffron run --headed # watch the browser
--filter takes a tag expression in the shape Cucumber and pytest use:
and, or, not and parentheses, so "(@smoke or @sanity) and not @flaky" reads the way it sounds. and binds tighter than or. A plain
list (@a,@b, @a @b, @a|@b, or the flag repeated) means or, and the
@ may be left off. Once and, not or parentheses appear, every tag
needs its and or or: "not @wip @slow" is refused rather than read
as not @wip or @slow. An empty list entry (@a,@b,) is skipped, and a
tag's own parentheses (@issue(42)) stay part of it. A malformed
expression stops the run with exit 2 and says what is wrong. As in
Cucumber, tags on a Feature or a Rule apply to every scenario in it, and
tags on an Examples block to its rows: --filter @smoke takes a feature
tagged @smoke whole, and not @wip leaves out the rows of an Examples
block tagged @wip.
If your project already has caches, steps whose exact text matches an
existing recording are seeded: the leading run of known steps replays
zero-AI and the agent records only the novel ones (⚡ 4/6 steps seeded).
The more scenarios you record, the cheaper each new one gets, write
shared steps with identical wording (and the page in the step text, e.g.
"…on the guest information page") to maximize hits. Quoted values may
differ (I enter "admin"… seeds from I enter "bob"…) as long as the
old value appears nowhere else in the recording: I delete "ann" is not
seeded from I delete "bob" when bob's recording clicks a delete-bob
test id or a #row-bob selector, since replay would act on bob's row. A
recording whose locators name a placeholder the new scenario does not
have is not seeded either. Data-table
steps seed whenever their table keys match, and novel steps can sit
anywhere in the scenario: seeded steps before, between, and after them
all replay free.
A scenario recorded again because its recording is stale (a shared
StepSet changed, a data value or a contract it depends on) seeds each
step it did not change, same words, keyword, table and doc string, from
its own committed recording wherever that still fits and the page shows
what it acts on, never from a newer recording of the same words in
another scenario. A StepSet accepted on its own (saffron accept <file> --step-set <name>) after that recording was made is the newer word on
those steps: it seeds them instead. A step that replayed another
scenario's recording is named in a seeded from another recording:
note, for the reviewer to read. --rerecord records afresh: it removes
the recording first, so nothing of it is seeded.
A request group's step seeds another scenario that sends the same
requests: the same table header, request names and rows, its cells free
to differ, as each request reads its own through <row:column>.
A step recorded more than one way, because the page was in another state
(I enter promo SAVE once opening the promo field first, once finding it
open), keeps each recording. Before seeding it, Saffron looks at the page:
the recording whose controls the page shows now wins, the shorter one when
several fit, so a field already open is filled, not toggled shut. When the
page shows none of them fully, the newest whose first control is there
replays, and failing that the newest.
No cache exists yet, so the agent executes each scenario in a real browser. Expect real token spend per scenario (typically $0.15–$2.00 depending on length and seeding: the report shows exact numbers, including cache traffic: prompt-cache reads/writes are the dominant cost of agent sessions, and the CLI and report break them out so the small "in+out tokens" figure is never mistaken for the bill). Scenarios finish yellow with a cache proposal each, or red if an assertion genuinely doesn't hold.
Before a recording is accepted as a pass, Saffron checks it once and sends the agent back with what is missing:
- A step with no browser action. Announcing a step (
saffron_step) performs nothing; only a browser call fills a field or clicks a button. An action step that recorded nothing, including one whose action ended up under the next step, is named. A step that genuinely needs nothing (the cookie banner never showed) passes on the second try, and aGiventhe page already satisfies is never asked. A step that names an Examples column (When I book for "<mode>") is asked, aGiventoo: the agent picks this row's value even where the page already shows it, or every row would replay this row's page. - A wait that waits for nothing. "I wait until the prices have loaded" records the condition it waits for, so replay waits for it too.
- A section the target ignores. A step that says where it acts
(
in "Your contact information") records a target scoped to that container. Replay acts on the first match, so an unscoped field would be the wrong form on a page that shows two with the same labels. The agent performs the step again, and the scoped action replaces the old one. A quote counts as a container afterinsideorwithin, before a word such as section, form, card or dialog (time-selection dialogtoo), after the thing the step acts on (I replace "E-mail" in "Contact"), or after a word for an element (the country selector in "Guest"), or with no "in" when a container word follows (the "Double" room card should show "Selected");hotels in "Oslo"andfill in "Email"do not. In aThen, a section named in plain words counts too, by its distinctive word:the confirmation Summary shows ...asks for checks scoped to the Summary,the order card shows ...to the order card; a position (the first row) does not, nor a tab or a menu, whose content stands outside them. When the section comes from an Examples column (inside the "<room>" room card) and the recording still is not scoped to it, the other rows are refused rather than acting in row 1's section. Each action, capture and check on an element must name the section itself, in an assertion step too; only a dropdown's option after its scoped trigger, a button of a dialog it opened (scoped to the dialog,role=dialog >> ...), and a wait may stand outside it. A quoted value the step types (I search for "<item>" in "<city>") is no section, unless the step says it is one: inin the "<room>" room card, a value equal to the room's name still needs the scope. When the agent performs flagged actions again, each new one replaces one flagged action of its kind, in its place; one not performed again, and the step's other actions, stay. - A check found by its own text. A check finds its element by the
field the step names, never by the text it checks: an amount found as the text
SEK 495.00, orORD-1checked on the element found byOrder ORD-1 confirmed, would hold wherever that text shows, so it is refused while recording. A step that only says a text is shown gets a visible check of that text.
If the agent's session fails because its provider reports a usage limit ("You've hit your usage limit", an exhausted quota or credit balance, a Claude subscription limit), no further AI session starts in that run; a throttle that clears in seconds ("tokens per min", "try again in 2s") does not stop it: the scenarios that need one fail at once with the provider's message, and replays carry on at zero AI. Run them again once the limit resets.
The first line a run prints says what produced it, and the report keeps
the same under provenance (a chip in the HTML report's header):
saffron-ai 1.3.3 · Codex agent on @openai/codex-sdk 0.158.0 · model gpt-5 (--model) · heal model gpt-5-mini (saffron.config.json)
That is the provider whose agent records and heals, the version of the
vendor package installed here (Antigravity has none: Saffron drives its
agy CLI), and each model you asked for with where it came from. When you
asked for none, the line says the vendor's default model and names none:
which model answered is the vendor's to report, and the report lists the
models that did under totals.models. A run without the agent says
no agent (--no-agent).
Press Ctrl-C to stop a run. Saffron stops starting scenarios, closes the
browsers, runs teardown (§17) and writes the report of what finished
before it exits with code 130. A SIGTERM (a cancelled CI job, kill)
stops it the same way and exits with code 143. That report replaces the last one and says
it is partial: latest.json has "interrupted": true and notRun, the
number of scenarios the run selected that did not finish, and the HTML
report shows interrupted · 3 not run in its header. Those scenarios
count as neither passed nor failed, including one the stop cut off
halfway, and the run's history line is marked partial, so trends leave it
out. A run of named scenarios afterwards replaces an interrupted report
whole rather than keeping its rows.
Try it on the example suite
If saffron init installed the Saucedemo examples, this is the whole
first run, credentials included (they are the public demo ones):
cp .env.example .env # SAUCE_USER / SAUCE_PASSWORD, resolved at replay only
npx playwright install chromium
npx saffron run --filter @smoke # records "Successful login" and "Add a product to the cart"
Both scenarios come back yellow with verified ✓ proposals (the agent
recorded them, then a zero-AI proof replay confirmed the recordings).
Measured on the real site: 35 AI calls and about $3 of AI usage for the
two, most of it prompt-cache traffic, which the report breaks out
honestly. Open the narratives, then:
npx saffron accept --all
npx saffron run --filter @smoke # green, 0 AI calls, 0 tokens
Now run everything: npx saffron run (a few dollars more: the remaining
scenarios seed most of their steps from what is already recorded). The
login-errors Scenario Outline records once and replays both example rows;
"Recover from a locked-out login" invokes the login StepSet in the middle
of the scenario; the checkout invokes a second set mid-scenario that
carries a data table, recorded as <table:firstName> references. Every
set comes from shared.steps.saffron, so later callers record mostly
seeded. Delete a cache and run again to watch a re-record; edit a table
value and watch it replay free.
Review and accept proposals
npx saffron diff # what the proposals would change, action by action, and the step rewrites they carry
npx saffron diff --html # the same as a review page: .saffron/reports/review.html
npx saffron accept # no args: list pending proposals with narratives
npx saffron accept --all # promote every VERIFIED proposal made here; UNVERIFIED, imported and held ones (the notes listed below) are skipped
npx saffron accept --all --include-unverified # promote UNVERIFIED ones too (deliberately)
npx saffron accept --all --include-imported # and the ones imported from a CI run (§19)
npx saffron accept <file...> # promote one or more (an UNVERIFIED file is accepted with a warning)
npx saffron reject <file...> # discard: the agent retries next run
npx saffron accept <file>#<revision> # only if it is still the proposal status --json listed (same for reject)
npx saffron accept <file> --step-set "Sign in" # only that StepSet's steps, for other scenarios to reuse; the proposal stays pending
One StepSet from a proposal. A proposal whose own check failed its
proof can still hold a sign-in, a seed or a checkout set its proof replayed
green. saffron accept <file> --step-set "Sign in" files just that set's
steps under .saffron/step-sets/, and every other scenario reuses them at
zero AI from the next run, as it reuses any committed step. The proposal,
its failing check and the rest of its steps stay pending for review. A set
the proof did not get all the way through is refused, naming the step it
failed on. A scenario that reuses the set still proves its own recording
before it is filed. Named with #<revision>, the set is taken only from
the proposal that was listed. Delete the file under .saffron/step-sets/
to stop the reuse.
Each proposal is stamped by a zero-AI proof replay that ran before it
was filed: verified ✓ means the recording already replayed
deterministically once; UNVERIFIED ✗ means it did not (the listing shows
the reason). Once is one fresh execution: with "stabilityProofs": 3 (or
--stability-proofs 3) a recording that passed its proof is replayed three
more times, each in a fresh execution with its own unique values, before it
is filed. The proposal records how many passed, the run summary counts
them apart (proof passed 2; stability proofs passed by 1 of 2), and one
that failed any gets an unstable recording: note naming the first
failure, a sign of a selector or value tied to the recording's session.
Three more checks run with the proof, at zero AI:
- Every row an Outline's recording serves. The recording is made on
one Examples row and replayed on each other row it does not refuse
(and that this environment can run: not one whose
@requires:capability is false), in a fresh execution each (theresetcommands run before each, §17). One that fails on a row is filed unverified, its proof error naming the row, with arow not proved:note, so no row is filed as served on another row's data. One bound to its own row's values, which other rows refuse, gets the same note: accepted, those rows stay red until they are recorded again.--plancounts these proofs, and the Outline as one recording: its other rows replay the proposal the first row files. The review (saffron diff, the review page, the report's review tab and the proposal list) shows each row as recorded, passed, failed, refused or not run, and "proved on 2 of 2 rows (1 refused)";saffron diffand the review page also say when the rows were proved, and each row's latest result in the last run beside its proof, so a proof from last week is not read as today's; a recording that failed a row is never shown as verified: in the run's totals, the report, history and the dashboard, the CI run view,saffron traceand both editors. As data,saffron diff --jsonand the report JSON give each row asrows(row,proof,why), andsaffron status --json, the CI run view and the MCPverifytool name the rows it failed on asfailedRows. - A moved clock. Every
{date}token in the recording is resolved for tomorrow, the month's last day and the next month's first, and the year's last day and the next year's first, and what stays as text beside a token must still match it: a weekday left inThursday, {date+1:MMMM D, YYYY}names the wrong day on most of them. A whole date near today left as text is named too, unless the scenario writes that date itself, in any of the ways apps spell one ("November 6, 2026", "Nov 6 2026", "6. November 2026", "06.11.2026", "2026/11/06", "11/6/2026" in the United States, and in the format the scenario's own tokens write, such as20261111for{date+7:YYYYMMDD}, also right before an iCalendar time,20261111T140000Z). Either gets adate not following the clock:note naming the step and the text. - A check weakened. A
Thenrecorded again under the same words, table and doc string, whose check is looser than the committed one, gets acheck weakened:note naming what changed: a value it no longer reads (a<column>,{data:...},{captured:...},{date}or{env:...}; the same value written another way,<table:amount>for<amount>, still counts), a pattern or name pattern that lost its^or$, a text it checks less of ("Order" for "Order BK-42 confirmed"), a kind of check it no longer makes or covers (a text check become a "shown" check; a text check where a "shown" one was is stricter), a JSON field it no longer compares, or an element it no longer names. This holds for--rerecordtoo: the recording it removes is kept for the comparison. Today's proof passes either way, so a person decides. - Data kept as written. A step that names a
{data:...}value in its text, table or doc string, while its recording neither writes the reference nor reads it yet holds the value as text ("Table for 2 people" for{data:party.size}, or a list's items one by one), gets adata not followed:note: today's data passes, a changed data file would not change what the step does. Recording writes a value the step names back as its reference first, a short one such as2too where it stands alone once in a check's or fill's value or a text pattern (never in a selector, URL or path, where a digit is an address, nor in a time, a date or regex syntax), so the note is for what it could not tell. Check a list in a downloaded file one item at a time, each by its own reference ({data:markers.0}); a list on the page with one check of the whole list. - A write cut off. A proof that passed while a write the page started
(a POST, PUT, PATCH or DELETE to the app's own site, never a third
party's beacon) was cut off by its frame moving on, before it was
answered, gets a
write not finished:note naming the request: a later check of what it saves passes or fails by timing. Wait for its response before leaving the page. A replay that fails after such a write names it in its error.
A step that fails after the app reported a failure names that response
beside its own error, in the console, the report and the heal prompt:
(meanwhile, POST /api/book answered 200 reporting failure: "The room is overbooked"). It counts the app's own fetch and XHR answers in that step
and the one before it: an error status, or a JSON body with
success: false, ok: false or an error. So a timeout reaching checkout
reads as the booking it was, not as a locator problem.
Before anything is recorded, each browser check the agent writes is run
on its page by replay's own engine, with the values, captures and clock
replay will use, for up to 3 seconds. One that does not hold there (a
value not whole in its text, BK-4 in "BK-42", or an element that is
not shown) is refused with replay's reason, so the agent writes the check
replay will pass instead of learning it from a failed proof. The proof
still runs.
Before any proof, a check whose CSS selector matches nothing on the page
the agent is looking at is refused while recording, with no cost: a
selector naming the wrong tag (p:has(button) where the page shows a link
styled as a button) would only have failed the proof. A "not shown" check,
which means to match nothing, and a selector holding a token are left to
the proof.
The proof also notes, for the reviewer, without holding the proposal: a
text check that passed on an element showing far more than the value (a
card or a section, broad check:), which would pass with the field itself
empty, unless the step says it reads a whole page, email or message ("the email body", "the whole page"; a card's body is still one part of the page); and a choice opened through an element named with the row's
<column> (choice opener:), which shows its current value before the
choice and names the row's value only on the row it was recorded on; and
a capture the proof read with more around what the agent typed (capture differs:, a label read with the value), with both values, which a
pattern on the capture fixes.
A note about a step names where the step is written, after the note's
kind (behavior change: features/pay.saffron:11 step 3 ...), from the
file as it is now, so nobody has to open the recording to find it. A
stale reason and a row refusal start with it too.
accept --all skips UNVERIFIED proposals, heals with a behavior
change (§7) or a requirement not kept (§7), recordings with an insensitive
check (§7), a volatile locator (§3), an unstable recording, a row not
proved, a date not following the clock, data not followed, a check
weakened, a write not finished or a check that changed the page, and ones no
proof replay checked (filed with --no-verify or "verifyProposals": false), so a bad recording never lands
by bulk-accepting; accept it by file if you have read it and
still want it, or better, re-record it (§12). A proposal is also stamped
UNVERIFIED when the recording cannot be right on its face, for example a
fill recorded with an empty value on a step that passes a value. If a recording fails its proof
replay, Saffron automatically runs one refinement pass, the agent is told
"the app works, your recording doesn't replay", before stamping. Opt out
with --no-verify or "verifyProposals": false if a scenario has
non-idempotent side effects (verification re-executes it).
Read the narrative and the recorded steps before accepting, the proposal
is the contract every future replay will execute. Then commit
.saffron/cache/ to git like any other reviewed artifact.
A proposal is bound to what you reviewed
What you approve is a diff: the proposed recording against the committed
one, for the scenario as it is written today. Between the moment a
proposal is filed and the moment someone accepts it, any of those three
can move: you edit a step, a colleague's heal of the same scenario is
accepted, --propagate rewrites a locator or a request, a pull brings in someone
else's recording, or a hand edit changes the proposal file. Accepting
after that would promote something nobody read.
So every proposal carries three short digests of what it was made
against, and saffron accept recomputes them first:
| Digest of | Changed when |
|---|---|
| The scenario's steps (keyword kind, text, data tables and doc strings) | A step, a table cell or a doc string was edited, a step added or removed, or the scenario renamed or deleted |
| The committed recording (or "none" for a first recording) | A heal was accepted, a locator or request fix propagated, a pull landed, or a first recording is no longer first |
| The proposal's own recording and its feature edits | The proposal file was edited by hand, including the step rewrites --with-feature-edit would apply |
A proposal whose digests no longer match is stale. saffron accept
refuses it, exit 1, naming what moved ("step 3 now reads ... and was
..."), the proposal stays where it is, and there is no override: run the
scenario again and a fresh proposal is filed against what is there now, or
saffron reject it. saffron accept with no arguments, saffron diff,
saffron status and both IDE panels show the same STALE mark and reason
before you get anywhere near the button. A run that finds a stale pending
proposal records afresh instead of proving it again.
Page fingerprints are advisory and are not part of the digests, so a recording whose fingerprints refreshed is not stale. Examples values are not part of them either: outline rows share one recording and resolve their values at replay, so a changed cell is judged by the cache's own staleness rule. The narrative and adaptations are for reading, not for applying, and are not bound. Proposals filed by a runner before 0.8.5 carry no digests: they are accepted as they are, with a note saying changes could not be checked.
A proposal can also be replaced after you read it: a later run of the
same scenario files a new one at the same path. saffron status --json
lists each proposal's revision, and saffron accept <file>#<revision>
or saffron reject <file>#<revision> decides that proposal only if it is
still the one listed; otherwise it says the file changed since it was
listed, exit 1, and leaves it in place. Both IDE panels decide this way.
A pending proposal on the next run
Until you decide on it, a proposal this machine filed is what the next run uses for its scenario, at zero AI:
- No committed recording yet. The run replays the proposal instead of recording again: yellow, "replayed the pending proposal", until you accept it. This is how a Scenario Outline records once: the first Examples row records, the later rows replay its proposal.
- The committed recording fails, and a heal is pending. Before the
agent is called, the run replays the pending heal in a browser of its
own. If it passes, the scenario is yellow, covered by the pending
proposal: the heal is paid for once, not again on every run or for each
of the outline's Examples rows. This holds under
--no-agenttoo. If it fails, the agent heals from the page where the committed recording stopped, as usual, and the new heal replaces the old one. - A stale proposal is recorded or healed afresh instead.
--rerecordrecords afresh over a proposal from an earlier run, without rejecting it first. Rows of an outline in the same run then replay the new one.
The run's summary names the invocation it describes, and keeps apart what could be read as one thing:
this run (`saffron run features --no-agent`, 12 scenarios, 10:04): 8 passed, 3 pending review (proof passed 2, failed 1), 1 failed (0 in the app or a check, 1 blocked by the recorder)
Pending review is not passing, and a proposal whose proof replay failed is
counted apart from one that replayed (and from one never proved, as with
--no-verify). A failure is the app's or a check's unless the run could
not test it: the recorder (a recording that cannot serve this row, or one
that needs recording with no agent), the agent's provider (a session that
could not run) or the environment (the app, API or a fixture was not
there). The report's JSON carries that as blocked on each red row, and
the command as command. A run of named scenarios is folded into the last
report, so a second line gives the combined report's own counts; its
command is this run's, and invocation holds what this run itself ran
(scenarios and totals), apart from the rows kept from earlier runs,
whose ranAt says when they ran.
A proposal this machine did not file is someone else's recording: one
imported from a CI run (§19), or one on a heal branch you checked out
(§21). A run never replays it with your secrets, and never records or
heals over it. While the committed recording passes, the scenario is green
as usual. When there is no committed recording, or the committed recording
fails, the scenario reports yellow, pending review: it says where the
committed recording failed, how to review the proposal (saffron diff <file>, then saffron accept or saffron reject) and, for an imported
one, which CI run it came from. --strict fails on it like on any yellow. saffron run --rerecord is the deliberate
way to record over it. The proposals this machine filed are noted by
revision in .saffron/reports/filed.json, git-ignored with the rest of
.saffron/reports/. One filed by an earlier version is not in it, so the
first run after upgrading reports it pending review.
Recordings are checked on the way in
A cache or proposal file is JSON that people commit, merge and now and
then edit. Saffron validates the shape every time it reads one, including
the operands each action needs (a click needs a target it can find, a
goto a URL, an expectText both), and names the first thing wrong, by
path:
The recording cannot be read: steps[2].actions[0].action "expectSomething" is not an
action this runner knows. Recorded by a newer saffron-ai? Update, or re-record with --rerecord.
An unreadable recording makes its scenario red with that message; it
is neither replayed as it is nor silently recorded over, and the rest of
the run goes on. saffron status marks it invalid with the same
reason, and an unreadable proposal is reported and left out of the
listing rather than blanking it. Recordings from a newer runner say so:
a file with a schema version this runner does not know asks you to update
saffron-ai, and one with an action type it does not know says the same.
Fields added within the schema version are optional and ignored by older
runners, so a newer runner's cache still reads on an older one.
A recording is written as version 2 only when it needs saffron-ai 1.0.0
or later to replay correctly: it has several people (As "buyer"
steps); one of its actions is a double, right or modified click, a
multi-select, or a cleared selection; it is an Outline recording bound to
its row or keeping the recorded row's value as typed; or a response
status, method or attribute name in it follows a column or holds a
{data:...}, {env:...}, {date...} or {unique:...} token. Every
other recording stays
version 1 and still reads on older runners. An older runner refuses a
version 2 file by name ("update saffron-ai") instead of replaying every
person in one browser, or a double click as a single one.
A recording is written as version 3 when it holds a date token that
saffron-ai 1.0.0 would resolve wrong: a range, a locale, or a month or
weekday name or two-digit year in its format. It is written as version 4
when a click it records starts several downloads at once (a batch export):
an earlier runner would check the first file instead of the latest.
JSON response field checks use version 5, so an older runner cannot skip
the fields and pass on status alone. So does a row or data value inside a
single-quoted selector text (p:has-text('Order <id>')), which 1.0.1
would fill in without a row boundary. Version 1 to 4 files read as before.
A version 1 or 2 recording whose
scenario gains a date token only version 3 resolves right (an Examples
row added, a data file or a table cell edited) is recorded again once, as
version 3. A recording that ties its rows to the section a column names
(see section 4) is version 3 too. So is one with a {data:...} value
that holds a date token (saffron-ai 1.0.0 resolves dates before data, so
it would type the token itself), and an Outline recording bound to its
row because a step in a column's As "<who>" session types a value the
step does not write, or because a step names a column its recording never
writes (see "A recording bound to its row").
A recording is written as version 8 when an element it acts on or checks lives in an iframe inside another iframe, or in one of several alike iframes picked by position (see the iframes note in section 3). saffron-ai 1.2.0 and earlier read a frame as a single iframe, so they ask to be updated rather than replay it. A recording with a single iframe keeps its version.
A recording is written as version 9 when it checks that an element is enabled or disabled. saffron-ai 1.2.0 and earlier do not know the check, so they ask to be updated rather than replay it.
A recording is written as version 10 when a request's body, or a part of one, is an Examples column's whole JSON value. saffron-ai 1.2.0 and earlier refuse it as malformed.
A recording is written as version 11 when a Scenario Outline's date
column holds a token and a step keeps a day the page spells out
({date+3:dddd, MMMM D, YYYY}). saffron-ai 1.2.0 and earlier would replay
another row on that day, so they ask to be updated rather than replay it.
A recording is written as version 12 when a request is sent several times
at once (concurrent). saffron-ai 1.2.1 and earlier would send it once and
pass, so they ask to be updated rather than replay it.
A recording is written as version 13 when a capture keeps only part of
its element's text (captureText with a pattern, such as a booking
number beside its Copy button's label). saffron-ai 1.2.2 and earlier would
keep the whole text, so they ask to be updated rather than replay it.
Recording file names. A recording is stored under
.saffron/cache/<feature>/<scenario>.json, the scenario name spelled in
lower-case ASCII. A name with letters outside ASCII (Login Успешный,
Café ouvert) gets a short fixed suffix from the full name
(login-1a2b3c4d.json), so two names that differ only in those letters
never share a file, or a folder of failure screenshots. Recordings saved
under the earlier name (caf-ouvert.json) are still found and used; the
next recording of that scenario is saved under the new name.
<feature> is the file name with the folders between the features
folder and the file (admin/login.saffron is admin-login-saffron
plus a short suffix from its path), so two files of one name in
different folders never share recordings. With "features": ".", or
for a file outside the features folder, the folders count from the
project. Files directly in the features folder are just their name.
Recordings saved before 1.0.0, when a "features": "." project put
admin/login.feature and shop/login.feature in one folder, are still
found by the feature they name.
Every run after that: free
npx saffron run # cached scenarios replay: 0 AI calls, 0 tokens, ~1-3s each
npx saffron run --no-agent # guarantee zero AI: failures go red instead of healing
npx saffron report # open the latest HTML report
The report opens on the run's first failure. On the left, every scenario
of the run: failed first, then pending review, then not run, then
passed, with a search box and a filter per status. On the right, the
scenario chosen: its steps, the error or the proposal's change, the
agent's account, screenshots and requests. A failure whose error other
scenarios share (numbers aside) names them, with one saffron run command
that runs them all again; a proposal whose change others propose too
names them, with one saffron accept command for their files.
Not run holds the reds the app did not cause: a recording that is stale
in a run with --no-agent (needs recording), a missing environment
(@requires), an agent that could not start, or a run over its budget.
They stay red, and the run still exits 1, but the report keeps them out of
failed: the failed count says how many more did not run and why, the run
map marks them ○ and counts them per feature file, the recurring failure
themes leave them out, and every scenario that needs recording names the
others with one saffron run command for their feature files, which
records them with the agent and replays the rest. After an edit to a
shared StepSet or Background, the scenarios using it show up there
together, apart from the failures worth debugging. The run map under it has a cell
per scenario, a row per feature file, coloured by status, AI cost or
duration; a click opens that scenario in the inspector under the map,
its steps as cards beside the failure or change and the scenarios that
share it, and leaves the list and its scenario as they were.
Every run also appends to .saffron/history.jsonl (which keeps its newest
half once it passes 64 MB, and is read from its end), so reports show deltas
vs the previous run and 20-run sparklines, and the CLI warns about
chronic scenarios (healing repeatedly = re-record it, don't keep
paying) and surfaces recurring failure themes. Chronic counts heals and the
reds that cost an agent session (a red no session could fix, such as a
server that was down, is no reason to re-record), and lists only scenarios
the run ran. The "vs previous run" line also names how many scenarios
turned green, which a first acceptance does without moving the pass rate.
The quality dashboard
npx saffron dashboard # writes .saffron/reports/dashboard.html and opens it
Where the report shows one run, the dashboard shows the suite over its
history, in the report's design, read from .saffron/history.jsonl:
- At a glance: how many tests, the share passing and how many are
failing (each scenario's latest result), flaky and never-run counts, the
last run's time, how much of it replayed without AI, and how much of the
suite is recorded. A bar shows every scenario's latest result.
Recorded counts recordings that still match their scenario, the same
ones
saffron statussays replay from cache: edit a scenario's wording and it drops out until it is recorded again. Passing does not count a heal whose zero-AI proof failed: that one shows as "healed, unverified", with its own stripe in the bar, because an unproven heal is not evidence the scenario works. - Scenarios that need a look: a grid with one cell per run (the last 20, oldest to newest) for each scenario that is failing, with how many runs in a row; flaky, passing and failing by turns over those runs against the same base URL (a suite run against staging and a preview is not flaky for differing between them, and a first recording that failed does not count); healed again and again, with how many of those heals went unproven (the page keeps changing under them: review the heals, or fix the step); or failed recently and passing now. Click a row for its error, and a scenario's name to open it in the editor. A scenario that fits several groups is listed once, under the most urgent, and the chips above the grid (failing, flaky, healed, passing now) filter by every group it fits. A chip with nothing under it cannot be pressed.
- Slowest: the scenarios that take longest on average, with their last time.
- Coverage: results per tag, scenarios that never ran, and ones left out of the last run by tags or paths.
--no-open writes the page without opening a browser; --stdout prints
it instead of writing it, which is what both IDE panels show (§11). The
numbers are the ones saffron status --json gives as quality, so the
page, the editors and your own tools agree. Commit history.jsonl and
the dashboard keeps its history across clones.