saffron-ai 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,164 @@
1
+ # Changelog
2
+
3
+ All notable changes to Saffron, in one place: the `saffron-ai` runner (npm),
4
+ the VS Code extension (`ChathurangaJayasinghe.saffron-vscode`) and the
5
+ JetBrains plugin (`ai.saffron.jetbrains`). Dates are publish dates.
6
+
7
+ ## 0.5.0
8
+
9
+ Released 2026-09-14.
10
+
11
+ Runner
12
+
13
+ - Guided first run: `saffron init` offers to install the Saucedemo example
14
+ suite (`--examples` / `--no-examples`): five short scenarios against the
15
+ public demo shop, `{env:VAR}` credentials with a `.env.example`, a
16
+ Scenario Outline, a data table, and a StepSet in a library file, plus a
17
+ matching `saffron.config.json` with `retries: 1`. Only installs into an
18
+ empty `features/` directory. Documented as "Your first run".
19
+
20
+ ## 0.4.2
21
+
22
+ Released 2026-09-13.
23
+
24
+ Runner
25
+
26
+ - `saffron run --rerecord`: discard the selected scenarios' caches and let
27
+ the agent record them fresh, seeded from the other recordings. The
28
+ sanctioned fix for a wrong recording instead of editing cache JSON.
29
+ - The recorder understands every way the agent types (`fill`,
30
+ `pressSequentially`, `type`) and drops the clearing `fill('')` that
31
+ precedes typing into the same field. This was the cause of caches with an
32
+ empty value for a step that passes one.
33
+ - A fill recorded with an empty value on a step that passes a value marks
34
+ the proposal UNVERIFIED with the reason, even if the proof replay passed.
35
+ - `saffron accept --all` skips UNVERIFIED proposals; `--include-unverified`
36
+ overrides, and accepting a single file still works with a warning.
37
+ - Retries are visible: a `retried ×N` chip per step in the report and a
38
+ `↻` line in the terminal. Retries stay per action with backoff; they are
39
+ not scenario re-runs.
40
+ - Scenario Outline rows display as `(example N)`; report, terminal and docs
41
+ use plain punctuation throughout.
42
+ - Reports and history carry the real package version.
43
+
44
+ Docs and site
45
+
46
+ - Documentation moved to the site's Docs section, generated from the
47
+ runner's markdown; the previous docs page became Features; the landing
48
+ page was trimmed.
49
+ - Assertion policy documented on the Features page; cache lifecycle gained
50
+ the staleness and wrong-recording stages.
51
+
52
+ ## 0.4.1
53
+
54
+ Released 2026-09-13.
55
+
56
+ Runner
57
+
58
+ - JetBrains highlighting without the plugin: the package ships the TextMate
59
+ grammar at `textmate/saffron` for Settings → Editor → TextMate Bundles.
60
+ - `saffron lsp` run from a terminal prints the editor setup steps instead
61
+ of waiting silently for a client.
62
+
63
+ Editors
64
+
65
+ - JetBrains plugin 0.1.0 (Marketplace listing 34240): `.saffron` file type,
66
+ bundled grammar, `saffron lsp` wired through LSP4IJ automatically.
67
+ - JetBrains plugin 0.1.1: the no-package fallback runs
68
+ `npx -p saffron-ai saffron lsp` (the bare `saffron` name resolves an
69
+ unrelated npm package); the install hint shows once per project; dark
70
+ theme file icon; verified on 2024.2 through 2026.2.
71
+ - VS Code extension 0.1.3: plain punctuation in README, hover and
72
+ diagnostic messages.
73
+
74
+ ## 0.4.0
75
+
76
+ Released 2026-09-13.
77
+
78
+ Runner
79
+
80
+ - Agent skill shipped in the package (`skills/saffron/SKILL.md` plus
81
+ reference sheets) following the Agent Skills specification.
82
+ - `saffron init`: installs the skill into `.claude/skills` and
83
+ `.agents/skills` (Copilot and Cursor on request), registers the
84
+ `saffron mcp` server in `.mcp.json`, `.cursor/mcp.json` and
85
+ `.vscode/mcp.json`, appends a managed block to `AGENTS.md` (and
86
+ `CLAUDE.md` when present), and scaffolds `saffron.config.json`,
87
+ `features/` and the `.gitignore` entry.
88
+ - Multi-tag `--filter` (OR semantics, comma-separated or repeated); scenario
89
+ tags are written to the report JSON and `history.jsonl` so external tools
90
+ can key results by a test-case tag.
91
+ - `saffron --version` reports the real package version.
92
+
93
+ Editors
94
+
95
+ - VS Code extension 0.1.1: case- and spacing-tolerant `StepSet` keyword
96
+ completion plus a definition snippet.
97
+ - VS Code extension 0.1.2: warning and quick fix when a set name is written
98
+ without the `StepSet` keyword.
99
+
100
+ ## 0.3.1
101
+
102
+ Released 2026-08-15.
103
+
104
+ - `saffron --version` no longer reports a hardcoded value.
105
+
106
+ ## 0.3.0
107
+
108
+ Released 2026-08-12.
109
+
110
+ Runner
111
+
112
+ - Network-aware steps in plain prose: `waitForResponse` (healable) blocks
113
+ until a matching backend response, `expectResponse` (assertion, never
114
+ healed) audits that one occurred. Matchers: URL pattern, method, status
115
+ (exact or class), optional body pattern for polling waits. Waits match
116
+ from the triggering step onward, so "click and wait" never races.
117
+ - Recording ergonomics: native dialogs, drag-and-drop and iframes record
118
+ live. The dialog auto-dismiss was the orchestrator's own launching
119
+ Playwright client, not the MCP; agent sessions now keep dialogs visible.
120
+ - Positive assertions recorded without frame scoping fall back to searching
121
+ child frames; poll-based assertions sweep all candidates under one
122
+ deadline.
123
+
124
+ Editors
125
+
126
+ - VS Code extension 0.1.0 published to the Marketplace.
127
+
128
+ ## 0.2.0
129
+
130
+ Released 2026-07-19.
131
+
132
+ Runner
133
+
134
+ - Secrets: `{env:VAR}` tokens resolve from the environment (or a
135
+ git-ignored `.env`) at replay; caches, proposals, reports and history are
136
+ masked back to the token; missing variables fail fast by name.
137
+ - Action vocabulary for real-world pages: `handleDialog`, `uploadFile`,
138
+ `dragTo`, and iframe-scoped targets, replaying on Chromium, Firefox and
139
+ WebKit.
140
+ - Cross-browser replay (`--browser firefox|webkit`), parallel replay
141
+ workers (`--workers N`), and a cheaper model for heal sessions
142
+ (`--heal-model`).
143
+
144
+ ## 0.1.0
145
+
146
+ Released 2026-07-10.
147
+
148
+ First public release, free for any use under the Saffron Free Use License.
149
+
150
+ - Gherkin-native runner: an AI agent records each scenario once; later runs
151
+ replay with plain Playwright at zero tokens; runtime healing files
152
+ reviewable proposals; assertions are never healed.
153
+ - Verified proposals (zero-AI proof replay), honest cost reporting, run
154
+ history and trends, chronic-scenario detection.
155
+ - Level-1 step reuse: new recordings seed from every step already
156
+ recorded.
157
+ - Data vocabulary: `<param>` placeholders, key/value and record tables,
158
+ doc strings, `{date±N}` templates, captured values.
159
+ - Assertion policy: `strict` (default) or `adaptable-mid`; the final
160
+ assertion block is always strict.
161
+ - Step sets and the `.saffron` dialect (`StepSet:`), project-wide library
162
+ files, parse-time expansion.
163
+ - Step vocabulary tooling: `saffron steps`, `saffron mcp`, `saffron lsp`,
164
+ `saffron author`; behavior-proven duplicate wording detection.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Like a maturing Cucumber suite, most of a new feature file is steps you already
24
24
 
25
25
  **Step sets** (shipped): `.saffron` files, a superset dialect of Gherkin, add the `StepSet:` keyword for named, reusable step sequences invoked with `StepSet <name>` inside any scenario. Sets expand at parse time, so their steps cache and seed like ordinary steps; editing a set makes every invoking scenario honestly stale (re-recorded mostly seeded), and heal edits route to the set definition, one fix, every caller follows. Sets are **project-wide**: keep application-wide flows in a sets-only library file (convention: `features/shared.steps.saffron`) and invoke them from any feature.
26
26
 
27
- **IDE integration & authoring** (shipped): `saffron steps` lists the project vocabulary with recorded/divergent/unrecorded badges (`--snippets` for native VS Code completion), `saffron mcp` serves it to AI assistants, `saffron lsp` brings completion/navigation/diagnostics to **JetBrains** (incl. Community editions via LSP4IJ) and Neovim, `saffron author` drafts feature files from prose in your own vocabulary, and duplicate wordings that record identical actions get **behavior-proven rename proposals**. The **Saffron VS Code extension** is on the [Marketplace](https://marketplace.visualstudio.com/items?itemName=ChathurangaJayasinghe.saffron-vscode) (`code --install-extension ChathurangaJayasinghe.saffron-vscode`). JetBrains users get the same in one click from the **Saffron JetBrains plugin** (sibling repo `saffron-jetbrains`: `.saffron` file type, bundled grammar, LSP4IJ wiring; Marketplace listing pending).
27
+ **IDE integration & authoring** (shipped): `saffron steps` lists the project vocabulary with recorded/divergent/unrecorded badges (`--snippets` for native VS Code completion), `saffron mcp` serves it to AI assistants, `saffron lsp` brings completion/navigation/diagnostics to **JetBrains** (incl. Community editions via LSP4IJ) and Neovim, `saffron author` drafts feature files from prose in your own vocabulary, and duplicate wordings that record identical actions get **behavior-proven rename proposals**. The **Saffron VS Code extension** is on the [Marketplace](https://marketplace.visualstudio.com/items?itemName=ChathurangaJayasinghe.saffron-vscode) (`code --install-extension ChathurangaJayasinghe.saffron-vscode`). JetBrains users get the same in one click from the **Saffron JetBrains plugin** ([source](https://github.com/s-chathuranga-j/saffron-jetbrains-plugin): `.saffron` file type, bundled grammar, LSP4IJ wiring; Marketplace listing pending).
28
28
 
29
29
  Data tables and doc strings are first-class: 2-column key/value tables parameterize the recording (`<table:username>`), multi-row record tables parameterize per cell (`<table:1:firstName>`), and `"""` doc strings record as `<docstring>`, so editing *values* or *content* replays at zero tokens, while structural changes (keys, headers, row counts) honestly re-record. Unambiguous params are scenario-wide, so a later assertion on a note's text follows content edits too. **Secrets** stay out of everything: `{env:VAR}` resolves from the environment (or a git-ignored `.env`) at replay, recordings and reports are masked back to the token, and missing variables fail fast by name.
30
30
 
@@ -33,10 +33,15 @@ Data tables and doc strings are first-class: 2-column key/value tables parameter
33
33
  ```bash
34
34
  npm install --save-dev saffron-ai
35
35
  npx playwright install chromium
36
- npx saffron init # config + the Saffron agent skill for your AI assistants
36
+ npx saffron init # config + agent skill; offers the Saucedemo example suite
37
37
  npx saffron run
38
38
  ```
39
39
 
40
+ No app of your own yet? `npx saffron init --examples` installs five short
41
+ scenarios against the public demo shop at saucedemo.com (login, cart,
42
+ checkout, a Scenario Outline, a StepSet) so the first run records and
43
+ replays something real.
44
+
40
45
  **Built for the AI era:** the package ships an [Agent Skill](https://agentskills.io) that teaches Claude Code, Codex, Cursor, Copilot and any skill-aware agent to write Saffron tests efficiently. Reuse recorded wordings, keep assertions sacred, secrets as `{env:VAR}`, so the tests your agents write replay at zero tokens instead of paying for near-duplicate recordings.
41
46
 
42
47
  CI tip: `saffron run --strict` treats yellow (passed-with-adaptation) as a
@@ -66,14 +71,14 @@ node dist/cli/index.js -p examples accept --all
66
71
 
67
72
  | Command | What it does |
68
73
  |---|---|
69
- | `saffron run [paths] [--headed] [--filter @tags] [--no-agent] [--strict] [--browser b] [--workers n] [--heal-model m] [--no-verify] [--no-reuse] [--model m] [--storage-state f]` | Run features. Cached replays are deterministic; misses/failures escalate to the agent (unless `--no-agent`). Replay cross-browser with `--browser firefox\|webkit`, parallelize with `--workers N`, heal on a cheaper model with `--heal-model`. Exit 1 on red (and on yellow with `--strict`). |
70
- | `saffron accept [file \| --all] [--with-feature-edit] [--propagate]` | Promote cache proposals to committed caches; `--with-feature-edit` also rewrites the adapted steps in the `.feature` file (and keeps the cache in sync); `--propagate` applies the heal's locator fixes to every other cache using the same locator. One heal repairs N scenarios before they ever fail. No args: list pending proposals. |
74
+ | `saffron run [paths] [--headed] [--filter @tags] [--rerecord] [--no-agent] [--strict] [--browser b] [--workers n] [--heal-model m] [--no-verify] [--no-reuse] [--model m] [--storage-state f]` | Run features. Cached replays are deterministic; misses/failures escalate to the agent (unless `--no-agent`). Replay cross-browser with `--browser firefox\|webkit`, parallelize with `--workers N`, heal on a cheaper model with `--heal-model`. Exit 1 on red (and on yellow with `--strict`). |
75
+ | `saffron accept [file \| --all] [--include-unverified] [--with-feature-edit] [--propagate]` | Promote cache proposals to committed caches (`--all` skips UNVERIFIED ones unless `--include-unverified`); `--with-feature-edit` also rewrites the adapted steps in the `.feature` file (and keeps the cache in sync); `--propagate` applies the heal's locator fixes to every other cache using the same locator. One heal repairs N scenarios before they ever fail. No args: list pending proposals. |
71
76
  | `saffron reject [file \| --all]` | Discard proposals; the agent will try again next run. |
72
77
  | `saffron steps [search] [--json] [--snippets]` | List/search the step vocabulary (files + caches) with recorded/divergent/unrecorded badges and usage; `--snippets` writes `.vscode/saffron.code-snippets` for native VS Code completion. |
73
78
  | `saffron mcp` | Serve the vocabulary to AI assistants over stdio MCP (`search_steps`, `list_step_sets`), e.g. `claude mcp add saffron -- npx saffron mcp`. |
74
79
  | `saffron author <prose-file> [-o out]` | Draft a `.saffron` feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI; reports how many lines are seedable). |
75
80
  | `saffron lsp` | Run the Saffron language server over stdio for JetBrains (LSP4IJ/Ultimate), Neovim, any LSP editor: badge completion, StepSet go-to-definition, hover, diagnostics. Highlighting for JetBrains comes from the bundled TextMate grammar (`textmate/saffron` in the package); run `npx saffron lsp` in a terminal for setup steps. |
76
- | `saffron init [--agents list]` | Make the project agent-ready: installs the bundled **Saffron agent skill** into `.claude/skills` and `.agents/skills` (Copilot/Cursor via `--agents`), registers the `saffron mcp` server in `.mcp.json` / `.cursor/mcp.json` / `.vscode/mcp.json`, adds a managed block to `AGENTS.md`/`CLAUDE.md`, scaffolds config. |
81
+ | `saffron init [--agents list] [--examples]` | Make the project agent-ready (`--examples` installs the Saucedemo example suite): installs the bundled **Saffron agent skill** into `.claude/skills` and `.agents/skills` (Copilot/Cursor via `--agents`), registers the `saffron mcp` server in `.mcp.json` / `.cursor/mcp.json` / `.vscode/mcp.json`, adds a managed block to `AGENTS.md`/`CLAUDE.md`, scaffolds config. |
77
82
  | `saffron report` | Open the latest HTML report. |
78
83
 
79
84
  Configuration lives in `saffron.config.json` at your project root:
@@ -137,6 +142,7 @@ far is in the roadmap's "Where we are" table.
137
142
 
138
143
  - [Website](https://saffron-ai.lovable.app): what Saffron is, the honesty rules, economics, quickstart
139
144
  - [Documentation](https://saffron-ai.lovable.app/docs): the cache lifecycle, step sets & the `.saffron` dialect, the step vocabulary, and the guarantees, with diagrams
145
+ - [Changelog](CHANGELOG.md), also at https://saffron-ai.lovable.app/docs/changelog
140
146
  - [Issues & questions](https://github.com/s-chathuranga-j/saffron-ai/issues)
141
147
  - **Docs site**: `npm run docs` builds a self-contained page at
142
148
  ## License
package/dist-pkg/cli.js CHANGED
@@ -1,34 +1,34 @@
1
1
  #!/usr/bin/env node
2
- import I from"node:fs";import $ from"node:path";import{spawn as Qr}from"node:child_process";import{Command as Kr}from"commander";import Mn from"node:fs";import Ln from"node:path";import{AstBuilder as Un,GherkinClassicTokenMatcher as Nn,Parser as Bn}from"@cucumber/gherkin";import{IdGenerator as zn,StepKeywordType as De}from"@cucumber/messages";function V(t,e){return t.replace(/<([^<>]+)>/g,(n,r)=>r in e?e[r]:n)}function L(t){if(!t||t.length===0)return{};let e={};if(t.every(n=>n.length===2)){for(let[n,r]of t)e[`table:${n}`]=r;return e}if(t.length>=2){let[n,...r]=t;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function q(t){return t!==void 0?{docstring:t}:{}}function K(t){let e=new Map;for(let r of t){let s={...L(r.resolvedTable??r.table),...q(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of e)s.size===1&&(n[r]=[...s][0]);return n}import je from"node:fs";import ot from"node:path";import{AstBuilder as Cn,GherkinClassicTokenMatcher as Pn,Parser as Fn}from"@cucumber/gherkin";import{IdGenerator as In}from"@cucumber/messages";var M=class extends Error{},jn=/^(\s*)StepSet:\s*(.+?)\s*$/,_n=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function _e(t){let e=t.split(`
3
- `),n=new Map,r=new Map;return{source:e.map((o,i)=>{let a=o.match(jn);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=o.match(_n);return d?(r.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):o}).join(`
4
- `),definitions:n,invocations:r}}function Dn(t){return new Fn(new Cn(In.uuid()),new Pn).parse(t)}function it(t,e){for(let n of t)n.scenario?e(n.scenario):"rule"in n&&n.rule&&it(n.rule.children,e)}function X(t,e){let n=new Map;for(let r of t){let s=ot.relative(e,r),o=_e(je.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=Dn(o.source);i.feature&&it(i.feature.children,a=>{let d=o.definitions.get(a.location.line);if(d===void 0)return;let p=n.get(d);if(p)throw new M(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new M(`StepSet "${d}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let m of a.steps)if(o.invocations.has(m.location.line))throw new M(`StepSet "${d}" (${s}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);n.set(d,{name:d,file:s,line:a.location.line,steps:[...a.steps]})})}return n}var On=[".feature",".saffron"];function se(t){return On.some(e=>t.endsWith(e))}function ue(t){if(!je.existsSync(t))return[];let e=[];for(let n of je.readdirSync(t,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&e.push(ot.join(t,r))}return e.sort()}function at(t,e,n){return t.map(({step:r,source:s})=>{let o;switch(r.keywordType){case De.OUTCOME:o="assertion";break;case De.CONTEXT:case De.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:V(r.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>V(p,e))),docString:a,resolvedDocString:a!==void 0?V(a,e):void 0,source:s}})}function ct(t,e,n,r,s){let o=[];for(let i of t){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:n}});continue}let d=s?.get(a);if(!d)throw new M(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)o.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:n}})}return o}function Vn(t,e,n,r,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(m=>m.value)??[];for(let m of d.tableBody){a+=1;let l={};m.cells.forEach((c,g)=>{l[p[g]]=c.value}),i.push({params:l,label:` \u2014 example ${a}`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:n,name:t.name,displayName:`${t.name}${d}`,tags:o,steps:[...at(r,a,p),...at(s,a,p)],params:a}})}function Z(t,e,n){let r=Mn.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?_e(r):{source:r,definitions:new Map,invocations:new Map},a=new Bn(new Un(zn.uuid()),new Nn).parse(o.source);if(!a.feature)return[];let d=Ln.relative(e,t),p=a.feature.name,m=[],l=(c,g)=>{let h=g;for(let u of c)if(u.background)h=[...g,...ct(u.background.steps,d,!0,o.invocations,n)];else if(u.scenario){if(o.definitions.has(u.scenario.location.line))continue;m.push(...Vn(u.scenario,d,p,h,ct(u.scenario.steps,d,!1,o.invocations,n)))}else"rule"in u&&u.rule&&l(u.rule.children,h)};return l(a.feature.children,[]),m}import yt from"node:fs";import Oe from"node:path";import pt from"node:fs";import ut from"node:path";import fe from"node:fs";import oe from"node:path";var lt=".saffron";function ge(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function W(t,e,n){let r=ge(oe.basename(e,".feature"));return oe.join(t,lt,"cache",r,`${ge(n)}.json`)}function dt(t,e,n){let r=ge(oe.basename(e,".feature"));return oe.join(t,lt,"proposals",r,`${ge(n)}.json`)}function N(t){if(!fe.existsSync(t))return;let e=JSON.parse(fe.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function ee(t,e){fe.mkdirSync(oe.dirname(t),{recursive:!0}),fe.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
- `)}function gt(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function qn(t,e){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(t)===n(e)}function Wn(t){let e=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let r of t.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(t.gherkin),e}function H(t){let e=ut.join(t,".saffron","cache"),n=new Map,r=new Map,s=0;if(pt.existsSync(e))for(let a of pt.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=N(ut.join(e,d));if(p){s++;for(let m of p.steps){let l=m.gherkin,c=JSON.stringify(m.actions.map(({pageFingerprint:u,...f})=>f)),g=r.get(l)??[];g.includes(c)||(g.push(c),r.set(l,g));let h=n.get(l);!h||p.recordedAt>h.recordedAt?n.set(l,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:g.length}):h.variants=g.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:d,args:p}=gt(a.gherkin);if(p.length===0)continue;let m=i.get(d);(!m||a.recordedAt>m.recordedAt)&&i.set(d,{...a,args:p})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function ft(t,e,n){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(L(t.table)),i=Object.keys(L(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let r={...n,...L(e.resolvedTable??e.table),...q(e.resolvedDocString??e.docString)},s=Wn({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in r))return!1;return!0}function mt(t,e,n){let r=t.steps.get(e.text);if(r)return ft(r,e,n)?r:void 0;let{pattern:s,args:o}=gt(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],g=o[l];if(c===g)continue;let h=a.get(c);if(h!==void 0&&h!==g)return;a.set(c,g)}let d=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(g=>[g.value,g.url,g.pattern,...d(g.target)].includes(l)))return;let p=l=>l!==void 0&&a.has(l)?a.get(l):l,m={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:p(c.value),url:p(c.url),pattern:p(c.pattern),target:c.target?{...c.target,name:p(c.target.name),nameRegex:p(c.target.nameRegex),label:p(c.target.label),text:p(c.target.text),placeholder:p(c.target.placeholder)}:void 0}))};return ft(m,e,n)?m:void 0}function ht(t,e){let n=[];return t.forEach((r,s)=>{if(r.actions.length!==0&&!e.steps.has(r.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&qn(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function Hn(t){if(!yt.existsSync(t))return[];let e=[];for(let n of yt.readdirSync(t,{recursive:!0})){let r=String(n);se(r)&&e.push(Oe.join(t,r))}return e.sort()}function G(t,e,n){let r=Oe.isAbsolute(e)?e:Oe.join(t,e),s=Hn(r),o=n??X(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let g of Z(c,t,o)){let h=`${g.featurePath}::${g.name}`;for(let u of g.steps){let f=i.get(u.text);if(f||(f={text:u.text,keywords:new Map,kind:u.kind,scenarios:new Set,files:new Set,fromStepSet:u.source?.fromStepSet},i.set(u.text,f)),f.keywords.set(u.keyword,(f.keywords.get(u.keyword)??0)+1),f.scenarios.add(h),u.source&&f.files.add(u.source.file),u.source?.fromStepSet){let y=a.get(u.source.fromStepSet);y||a.set(u.source.fromStepSet,y=new Set),y.add(h)}}}let d=H(t),p=new Set(d.divergent),m=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((g,h)=>h[1]-g[1])[0][0],kind:c.kind,status:p.has(c.text)?"divergent":d.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,g)=>g.usage-c.usage||c.text.localeCompare(g.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(g=>g.text),usage:a.get(c.name)?.size??0})).sort((c,g)=>g.usage-c.usage||c.name.localeCompare(g.name));return{steps:m,stepSets:l,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function me(t,e){let n=e.toLowerCase();return{...t,steps:t.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:t.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function St(t){let e={},n="saffron,feature,gherkin,cucumber";for(let r of t.steps)e[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of t.stepSets)e[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(e,null,2)+`
6
- `}import{Server as Gn}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Jn}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Yn,ListToolsRequestSchema as Qn}from"@modelcontextprotocol/sdk/types.js";async function xt(t,e){let n=new Gn({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});n.setRequestHandler(Qn,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(Yn,async r=>{let s=G(t,e);if(r.params.name==="search_steps"){let o=r.params.arguments?.query,i=o?me(s,o):s;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return r.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:s.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${r.params.name}`}],isError:!0}}),await n.connect(new Jn)}import{query as Kn}from"@anthropic-ai/claude-agent-sdk";function Xn(t,e){let n=e.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
2
+ import I from"node:fs";import T from"node:path";import{spawn as ts}from"node:child_process";import ns from"node:readline";import{Command as rs}from"commander";import Un from"node:fs";import Bn from"node:path";import{AstBuilder as Vn,GherkinClassicTokenMatcher as zn,Parser as qn}from"@cucumber/gherkin";import{IdGenerator as Wn,StepKeywordType as Me}from"@cucumber/messages";function q(t,e){return t.replace(/<([^<>]+)>/g,(n,r)=>r in e?e[r]:n)}function L(t){if(!t||t.length===0)return{};let e={};if(t.every(n=>n.length===2)){for(let[n,r]of t)e[`table:${n}`]=r;return e}if(t.length>=2){let[n,...r]=t;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function W(t){return t!==void 0?{docstring:t}:{}}function K(t){let e=new Map;for(let r of t){let s={...L(r.resolvedTable??r.table),...W(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of e)s.size===1&&(n[r]=[...s][0]);return n}import De from"node:fs";import at from"node:path";import{AstBuilder as jn,GherkinClassicTokenMatcher as In,Parser as _n}from"@cucumber/gherkin";import{IdGenerator as Dn}from"@cucumber/messages";var M=class extends Error{},On=/^(\s*)StepSet:\s*(.+?)\s*$/,Mn=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Oe(t){let e=t.split(`
3
+ `),n=new Map,r=new Map;return{source:e.map((o,i)=>{let a=o.match(On);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=o.match(Mn);return d?(r.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):o}).join(`
4
+ `),definitions:n,invocations:r}}function Ln(t){return new _n(new jn(Dn.uuid()),new In).parse(t)}function ct(t,e){for(let n of t)n.scenario?e(n.scenario):"rule"in n&&n.rule&&ct(n.rule.children,e)}function X(t,e){let n=new Map;for(let r of t){let s=at.relative(e,r),o=Oe(De.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=Ln(o.source);i.feature&&ct(i.feature.children,a=>{let d=o.definitions.get(a.location.line);if(d===void 0)return;let p=n.get(d);if(p)throw new M(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new M(`StepSet "${d}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let m of a.steps)if(o.invocations.has(m.location.line))throw new M(`StepSet "${d}" (${s}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);n.set(d,{name:d,file:s,line:a.location.line,steps:[...a.steps]})})}return n}var Nn=[".feature",".saffron"];function se(t){return Nn.some(e=>t.endsWith(e))}function ue(t){if(!De.existsSync(t))return[];let e=[];for(let n of De.readdirSync(t,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&e.push(at.join(t,r))}return e.sort()}function lt(t,e,n){return t.map(({step:r,source:s})=>{let o;switch(r.keywordType){case Me.OUTCOME:o="assertion";break;case Me.CONTEXT:case Me.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:q(r.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>q(p,e))),docString:a,resolvedDocString:a!==void 0?q(a,e):void 0,source:s}})}function dt(t,e,n,r,s){let o=[];for(let i of t){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:n}});continue}let d=s?.get(a);if(!d)throw new M(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)o.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:n}})}return o}function Hn(t,e,n,r,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(m=>m.value)??[];for(let m of d.tableBody){a+=1;let l={};m.cells.forEach((c,g)=>{l[p[g]]=c.value}),i.push({params:l,label:` (example ${a})`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:n,name:t.name,displayName:`${t.name}${d}`,tags:o,steps:[...lt(r,a,p),...lt(s,a,p)],params:a}})}function Z(t,e,n){let r=Un.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?Oe(r):{source:r,definitions:new Map,invocations:new Map},a=new qn(new Vn(Wn.uuid()),new zn).parse(o.source);if(!a.feature)return[];let d=Bn.relative(e,t),p=a.feature.name,m=[],l=(c,g)=>{let h=g;for(let u of c)if(u.background)h=[...g,...dt(u.background.steps,d,!0,o.invocations,n)];else if(u.scenario){if(o.definitions.has(u.scenario.location.line))continue;m.push(...Hn(u.scenario,d,p,h,dt(u.scenario.steps,d,!1,o.invocations,n)))}else"rule"in u&&u.rule&&l(u.rule.children,h)};return l(a.feature.children,[]),m}import xt from"node:fs";import Le from"node:path";import ft from"node:fs";import gt from"node:path";import fe from"node:fs";import oe from"node:path";var pt=".saffron";function ge(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function z(t,e,n){let r=ge(oe.basename(e,".feature"));return oe.join(t,pt,"cache",r,`${ge(n)}.json`)}function ut(t,e,n){let r=ge(oe.basename(e,".feature"));return oe.join(t,pt,"proposals",r,`${ge(n)}.json`)}function U(t){if(!fe.existsSync(t))return;let e=JSON.parse(fe.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function ee(t,e){fe.mkdirSync(oe.dirname(t),{recursive:!0}),fe.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
+ `)}function ht(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function Gn(t,e){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(t)===n(e)}function Yn(t){let e=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let r of t.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(t.gherkin),e}function H(t){let e=gt.join(t,".saffron","cache"),n=new Map,r=new Map,s=0;if(ft.existsSync(e))for(let a of ft.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=U(gt.join(e,d));if(p){s++;for(let m of p.steps){let l=m.gherkin,c=JSON.stringify(m.actions.map(({pageFingerprint:u,...f})=>f)),g=r.get(l)??[];g.includes(c)||(g.push(c),r.set(l,g));let h=n.get(l);!h||p.recordedAt>h.recordedAt?n.set(l,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:g.length}):h.variants=g.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:d,args:p}=ht(a.gherkin);if(p.length===0)continue;let m=i.get(d);(!m||a.recordedAt>m.recordedAt)&&i.set(d,{...a,args:p})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function mt(t,e,n){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(L(t.table)),i=Object.keys(L(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let r={...n,...L(e.resolvedTable??e.table),...W(e.resolvedDocString??e.docString)},s=Yn({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in r))return!1;return!0}function yt(t,e,n){let r=t.steps.get(e.text);if(r)return mt(r,e,n)?r:void 0;let{pattern:s,args:o}=ht(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],g=o[l];if(c===g)continue;let h=a.get(c);if(h!==void 0&&h!==g)return;a.set(c,g)}let d=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(g=>[g.value,g.url,g.pattern,...d(g.target)].includes(l)))return;let p=l=>l!==void 0&&a.has(l)?a.get(l):l,m={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:p(c.value),url:p(c.url),pattern:p(c.pattern),target:c.target?{...c.target,name:p(c.target.name),nameRegex:p(c.target.nameRegex),label:p(c.target.label),text:p(c.target.text),placeholder:p(c.target.placeholder)}:void 0}))};return mt(m,e,n)?m:void 0}function St(t,e){let n=[];return t.forEach((r,s)=>{if(r.actions.length!==0&&!e.steps.has(r.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&Gn(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function Jn(t){if(!xt.existsSync(t))return[];let e=[];for(let n of xt.readdirSync(t,{recursive:!0})){let r=String(n);se(r)&&e.push(Le.join(t,r))}return e.sort()}function G(t,e,n){let r=Le.isAbsolute(e)?e:Le.join(t,e),s=Jn(r),o=n??X(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let g of Z(c,t,o)){let h=`${g.featurePath}::${g.name}`;for(let u of g.steps){let f=i.get(u.text);if(f||(f={text:u.text,keywords:new Map,kind:u.kind,scenarios:new Set,files:new Set,fromStepSet:u.source?.fromStepSet},i.set(u.text,f)),f.keywords.set(u.keyword,(f.keywords.get(u.keyword)??0)+1),f.scenarios.add(h),u.source&&f.files.add(u.source.file),u.source?.fromStepSet){let y=a.get(u.source.fromStepSet);y||a.set(u.source.fromStepSet,y=new Set),y.add(h)}}}let d=H(t),p=new Set(d.divergent),m=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((g,h)=>h[1]-g[1])[0][0],kind:c.kind,status:p.has(c.text)?"divergent":d.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,g)=>g.usage-c.usage||c.text.localeCompare(g.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(g=>g.text),usage:a.get(c.name)?.size??0})).sort((c,g)=>g.usage-c.usage||c.name.localeCompare(g.name));return{steps:m,stepSets:l,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function me(t,e){let n=e.toLowerCase();return{...t,steps:t.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:t.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function bt(t){let e={},n="saffron,feature,gherkin,cucumber";for(let r of t.steps)e[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of t.stepSets)e[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(e,null,2)+`
6
+ `}import{Server as Qn}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Kn}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Xn,ListToolsRequestSchema as Zn}from"@modelcontextprotocol/sdk/types.js";async function vt(t,e){let n=new Qn({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});n.setRequestHandler(Zn,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(Xn,async r=>{let s=G(t,e);if(r.params.name==="search_steps"){let o=r.params.arguments?.query,i=o?me(s,o):s;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return r.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:s.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${r.params.name}`}],isError:!0}}),await n.connect(new Kn)}import{query as er}from"@anthropic-ai/claude-agent-sdk";function tr(t,e){let n=e.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
7
7
  `),r=e.stepSets.map(s=>`- StepSet ${s.name}
8
8
  ${s.steps.map(o=>` ${o}`).join(`
9
9
  `)}`).join(`
10
10
  `);return["Draft a Saffron feature file from the requirements below.","","EXISTING STEP VOCABULARY \u2014 reuse these wordings EXACTLY (character for character) wherever they express what a step needs to do. Steps marked [recorded] or [divergent] already have replay recordings: reusing them makes the new file replay almost for free. Only invent a new wording when nothing in the vocabulary expresses the intent, and then follow the same style:",n.length>0?n:"(none yet \u2014 this is the project's first file)","",e.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
11
11
  ${r}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",t.trim()].filter(s=>s!==void 0).join(`
12
- `)}async function bt(t,e,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Kn({prompt:Xn(t,e),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
13
- `,usage:r}}import he from"node:path";import{createConnection as Zn,ProposedFeatures as er,TextDocuments as tr,TextDocumentSyncKind as nr,CompletionItemKind as Me,DiagnosticSeverity as ye,MarkupKind as vt}from"vscode-languageserver/node";import{TextDocument as rr}from"vscode-languageserver-textdocument";var wt=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,sr=/^\s*StepSet:\s*(.+?)\s*$/,Le=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Ue={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function or(t,e){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(t),s=n(e);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function kt(t,e){let n=Zn(er.all,process.stdin,process.stdout),r=new tr(rr),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,a=()=>{try{let l=he.isAbsolute(e)?e:he.join(t,e);o=X(ue(l),t),s=G(t,e,o)}catch(l){n.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of r.all())m(l)},d=()=>{clearTimeout(i),i=setTimeout(a,300)};function p(l){return s.steps.find(c=>c.text===l)?.status}function m(l){if(!l.uri.endsWith(".saffron"))return;let c=[],g=l.getText().split(`
14
- `),h=new Map;for(let u=0;u<g.length;u++){let f=g[u],y={start:{line:u,character:0},end:{line:u,character:f.length}},x=f.match(Le);x&&!o.has(x[3])&&c.push({range:y,severity:ye.Error,source:"saffron",message:`StepSet "${x[3]}" is not defined anywhere in the project.`});let b=f.match(sr);if(b){let v=o.get(b[1]),j=he.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(h.has(b[1])||v&&v.file!==j)&&c.push({range:y,severity:ye.Error,source:"saffron",message:`StepSet "${b[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${b[1]}".`}),h.set(b[1],u)}let w=f.trim();if(w&&!x&&!b){for(let[v]of o)if(w.toLowerCase()===v.toLowerCase()){c.push({range:y,severity:ye.Warning,source:"saffron",message:`Did you mean "StepSet ${v}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let k=f.match(wt);if(k&&p(k[3])!=="recorded"){for(let v of s.steps)if(!(v.text===k[3]||v.status==="unrecorded")&&or(v.text,k[3])>=.7){c.push({range:{start:{line:u,character:f.indexOf(k[3])},end:{line:u,character:f.length}},severity:ye.Information,source:"saffron",message:`Similar to the ${v.status} step "${v.text}" (\xD7${v.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}n.sendDiagnostics({uri:l.uri,diagnostics:c})}n.onInitialize(()=>(a(),{capabilities:{textDocumentSync:nr.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),n.onCompletion(l=>{let c=r.get(l.textDocument.uri);if(!c)return[];let g=c.getText({start:{line:l.position.line,character:0},end:l.position});if(/^\s*StepSet\s+/.test(g)&&c.uri.endsWith(".saffron"))return s.stepSets.map(f=>({label:f.name,kind:Me.Function,detail:`step set \xB7 ${f.steps.length} step(s) \xB7 \xD7${f.usage}`,documentation:f.steps.join(`
15
- `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(g))return s.steps.map((f,y)=>({label:f.text,kind:Me.Text,detail:`${Ue[f.status]} \xB7 \xD7${f.usage}`,sortText:String(y).padStart(5,"0"),filterText:f.text}));let u=g.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(u.length>0&&("stepset".startsWith(u)||u.startsWith("stepset"))&&c.uri.endsWith(".saffron")){let f={start:{line:l.position.line,character:g.length-g.replace(/^\s*/,"").length},end:l.position};return s.stepSets.map(y=>({label:`StepSet ${y.name}`,kind:Me.Function,detail:`invoke step set \xB7 ${y.steps.length} step(s)`,documentation:y.steps.join(`
16
- `),filterText:`step set ${y.name}`,textEdit:{range:f,newText:`StepSet ${y.name}`}}))}return[]}),n.onDefinition(l=>{let c=r.get(l.textDocument.uri);if(!c)return;let h=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,"").match(Le);if(!h)return;let u=o.get(h[3]);return u?{uri:`file://${he.join(t,u.file)}`,range:{start:{line:u.line-1,character:0},end:{line:u.line-1,character:0}}}:void 0}),n.onHover(l=>{let c=r.get(l.textDocument.uri);if(!c)return;let g=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=g.match(Le);if(h){let f=s.stepSets.find(x=>x.name===h[3]);if(!f)return;let y=f.steps.map(x=>`- ${Ue[p(x)??"unrecorded"][0]} ${x}`).join(`
17
- `);return{contents:{kind:vt.Markdown,value:`**StepSet ${f.name}** \u2014 expands to:
12
+ `)}async function wt(t,e,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=er({prompt:tr(t,e),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
13
+ `,usage:r}}import he from"node:path";import{createConnection as nr,ProposedFeatures as rr,TextDocuments as sr,TextDocumentSyncKind as or,CompletionItemKind as Ne,DiagnosticSeverity as ye,MarkupKind as kt}from"vscode-languageserver/node";import{TextDocument as ir}from"vscode-languageserver-textdocument";var $t=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,ar=/^\s*StepSet:\s*(.+?)\s*$/,Ue=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Be={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function cr(t,e){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(t),s=n(e);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function Rt(t,e){let n=nr(rr.all,process.stdin,process.stdout),r=new sr(ir),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,a=()=>{try{let l=he.isAbsolute(e)?e:he.join(t,e);o=X(ue(l),t),s=G(t,e,o)}catch(l){n.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of r.all())m(l)},d=()=>{clearTimeout(i),i=setTimeout(a,300)};function p(l){return s.steps.find(c=>c.text===l)?.status}function m(l){if(!l.uri.endsWith(".saffron"))return;let c=[],g=l.getText().split(`
14
+ `),h=new Map;for(let u=0;u<g.length;u++){let f=g[u],y={start:{line:u,character:0},end:{line:u,character:f.length}},x=f.match(Ue);x&&!o.has(x[3])&&c.push({range:y,severity:ye.Error,source:"saffron",message:`StepSet "${x[3]}" is not defined anywhere in the project.`});let w=f.match(ar);if(w){let b=o.get(w[1]),_=he.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(h.has(w[1])||b&&b.file!==_)&&c.push({range:y,severity:ye.Error,source:"saffron",message:`StepSet "${w[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${w[1]}".`}),h.set(w[1],u)}let j=f.trim();if(j&&!x&&!w){for(let[b]of o)if(j.toLowerCase()===b.toLowerCase()){c.push({range:y,severity:ye.Warning,source:"saffron",message:`Did you mean "StepSet ${b}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let v=f.match($t);if(v&&p(v[3])!=="recorded"){for(let b of s.steps)if(!(b.text===v[3]||b.status==="unrecorded")&&cr(b.text,v[3])>=.7){c.push({range:{start:{line:u,character:f.indexOf(v[3])},end:{line:u,character:f.length}},severity:ye.Information,source:"saffron",message:`Similar to the ${b.status} step "${b.text}" (\xD7${b.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}n.sendDiagnostics({uri:l.uri,diagnostics:c})}n.onInitialize(()=>(a(),{capabilities:{textDocumentSync:or.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),n.onCompletion(l=>{let c=r.get(l.textDocument.uri);if(!c)return[];let g=c.getText({start:{line:l.position.line,character:0},end:l.position});if(/^\s*StepSet\s+/.test(g)&&c.uri.endsWith(".saffron"))return s.stepSets.map(f=>({label:f.name,kind:Ne.Function,detail:`step set \xB7 ${f.steps.length} step(s) \xB7 \xD7${f.usage}`,documentation:f.steps.join(`
15
+ `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(g))return s.steps.map((f,y)=>({label:f.text,kind:Ne.Text,detail:`${Be[f.status]} \xB7 \xD7${f.usage}`,sortText:String(y).padStart(5,"0"),filterText:f.text}));let u=g.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(u.length>0&&("stepset".startsWith(u)||u.startsWith("stepset"))&&c.uri.endsWith(".saffron")){let f={start:{line:l.position.line,character:g.length-g.replace(/^\s*/,"").length},end:l.position};return s.stepSets.map(y=>({label:`StepSet ${y.name}`,kind:Ne.Function,detail:`invoke step set \xB7 ${y.steps.length} step(s)`,documentation:y.steps.join(`
16
+ `),filterText:`step set ${y.name}`,textEdit:{range:f,newText:`StepSet ${y.name}`}}))}return[]}),n.onDefinition(l=>{let c=r.get(l.textDocument.uri);if(!c)return;let h=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,"").match(Ue);if(!h)return;let u=o.get(h[3]);return u?{uri:`file://${he.join(t,u.file)}`,range:{start:{line:u.line-1,character:0},end:{line:u.line-1,character:0}}}:void 0}),n.onHover(l=>{let c=r.get(l.textDocument.uri);if(!c)return;let g=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=g.match(Ue);if(h){let f=s.stepSets.find(x=>x.name===h[3]);if(!f)return;let y=f.steps.map(x=>`- ${Be[p(x)??"unrecorded"][0]} ${x}`).join(`
17
+ `);return{contents:{kind:kt.Markdown,value:`**StepSet ${f.name}** \u2014 expands to:
18
18
 
19
- ${y}`}}}let u=g.match(wt);if(u){let f=s.steps.find(y=>y.text===u[3]);return f?{contents:{kind:vt.Markdown,value:`${Ue[f.status]}
19
+ ${y}`}}}let u=g.match($t);if(u){let f=s.steps.find(y=>y.text===u[3]);return f?{contents:{kind:kt.Markdown,value:`${Be[f.status]}
20
20
 
21
21
  Used in ${f.usage} place(s): ${f.files.join(", ")}${f.fromStepSet?`
22
22
 
23
- Defined in StepSet **${f.fromStepSet}**`:""}`}}:void 0}}),r.onDidOpen(l=>m(l.document)),r.onDidChangeContent(l=>m(l.document)),r.onDidSave(()=>d()),n.onDidChangeWatchedFiles(()=>d()),r.listen(n),n.listen()}import Rt from"node:fs";import ir from"node:path";var $t=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function ie(t){return t.replace($t,(e,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function Ne(t){let e=new Set;for(let n of t)if(n)for(let r of n.matchAll($t))e.add(r[1]);return e}function Be(t){let e=[];for(let n of t.steps){e.push(n.text,n.resolvedText),n.docString&&e.push(n.docString);for(let r of n.table??[])e.push(...r)}return e}function ae(t){let e=new Map;for(let n of Ne(Be(t))){let r=process.env[n];r!==void 0&&e.set(n,r)}return e}function te(t,e){let n=t;for(let[r,s]of[...e.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(n=n.split(s).join(`{env:${r}}`));return n}function Tt(t){let e=ir.join(t,".env");if(Rt.existsSync(e))for(let n of Rt.readFileSync(e,"utf8").split(`
24
- `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import mr from"node:fs";import hr from"node:net";import Dt from"node:path";import{chromium as yr,firefox as Sr,webkit as xr}from"playwright";import B from"node:fs";import Se from"node:path";function Et(t,e){B.mkdirSync(Se.dirname(t),{recursive:!0}),B.writeFileSync(t,`${JSON.stringify(e,null,2)}
25
- `)}function xe(t){return JSON.parse(B.readFileSync(t,"utf8"))}function ce(t){let e=Se.join(t,".saffron","proposals");if(!B.existsSync(e))return[];let n=[];for(let r of B.readdirSync(e)){let s=Se.join(e,r);if(B.statSync(s).isDirectory())for(let o of B.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=Se.join(s,o);n.push({file:i,proposal:xe(i)})}}return n}function At(t,e){let n=xe(e),r=W(t,n.cache.feature,n.cache.scenario);return ee(r,n.cache),B.rmSync(e),r}function Ct(t){B.rmSync(t)}import Ft from"node:path";var ar=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function cr(t,e){let n=String(t.getFullYear()),r=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function ze(t,e=new Date){return t.replace(ar,(n,r,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),cr(o,s??"YYYY-MM-DD")})}var lr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,dr=400;function Pt(t,e=new Date){let n=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(lr,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let d=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(d)>dr?r:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as pr}from"node:crypto";async function J(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${pr("sha256").update(n).digest("hex")}`}var We=t=>new Promise(e=>setTimeout(e,t)),qe=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,n){this.context=e,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function ur(t,e){if(!e)return t>=200&&t<300;let n=/^([1-5])xx$/i.exec(e.trim());if(n){let r=Number(n[1])*100;return t>=r&&t<r+100}return t===Number(e.trim())}function It(t,e,n,r,s){return!(!e.test(t.url)||n&&t.method.toUpperCase()!==n.toUpperCase()||!ur(t.status,r)||s&&!s.test(t.body??""))}var fr=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function jt(t,e,n,r=!1){let s=a=>ie(ze(V(a,n))),o=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let d of e.fallbackSelectors??[])o.push(a.locator(d))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),r)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function z(t,e,n){let r=Date.now()+e,s;for(;Date.now()<r;){try{if(await t())return}catch(o){s=o}await We(100)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function U(t,e){let n;for(let r of t)try{await e(r.first());return}catch(s){n=s}throw n}var Ve=t=>t.replace(/\s+/g," ").trim();async function He(t,e,n,r,s,o=new Map,i){let a=u=>ie(ze(V(u,n))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,m=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",l=e.target?jt(t,e.target,n,m):[],c={timeout:r},g={timeout:Math.min(1e3,r)},h=u=>async()=>{for(let f of l)try{if(await u(f.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let u=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(u,c);return}case"click":return U(l,u=>u.click(c));case"fill":return U(l,u=>u.fill(d??"",c));case"press":return l.length>0?U(l,u=>u.press(d??"",c)):t.keyboard.press(d??"");case"selectOption":return U(l,async u=>{await u.selectOption(d??"",c)});case"check":return U(l,u=>u.check(c));case"uncheck":return U(l,u=>u.uncheck(c));case"hover":return U(l,u=>u.hover(c));case"waitFor":return l.length>0?U(l,u=>u.waitFor({state:"visible",timeout:r})):We(Math.min(Number(d??"0"),r));case"expectVisible":return z(h(u=>u.isVisible()),r,"target to be visible");case"expectNotVisible":return U(l,u=>u.waitFor({state:"hidden",timeout:r}));case"expectText":return z(h(async u=>(await u.textContent(g)??"").includes(d??"")),r,`text "${d}"`);case"expectValue":return z(h(async u=>await u.inputValue(g)===(d??"")),r,`input value "${d}"`);case"expectUrl":{let u=p??d??"";return z(async()=>t.context().pages().some(f=>f.url().includes(u)),r,`URL containing "${u}" in any tab (current: ${t.context().pages().map(f=>f.url()).join(", ")})`)}case"handleDialog":{let u=(d??"accept")!=="dismiss",f=e.promptText?a(e.promptText):void 0;t.once("dialog",y=>{u?y.accept(f):y.dismiss()});return}case"uploadFile":{let u=(d??"").split(`
26
- `).filter(Boolean).map(f=>Ft.isAbsolute(f)?f:Ft.resolve(f));if(u.length===0)throw new Error("uploadFile requires a path");if(l.length>0)return U(l,f=>f.setInputFiles(u,c));t.once("filechooser",f=>{f.setFiles(u)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let u=jt(t,e.toTarget,n);return U(l,f=>f.dragTo(u[0].first(),c))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return z(h(async u=>{let f=Ve(await u.textContent(g)??"");return f===""?!1:(o.set(e.saveAs,f),!0)}),r,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let u=o.get(e.compareTo);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(l.length>0)return z(h(async x=>Ve(await x.textContent(g)??"")!==u),r,`target text to differ from "${e.compareTo}" (${u})`);let f=d??"",y=o.get(f);if(y===void 0)throw new Error(`expectDiffers: no captured value named "${f}"`);if(y===u)throw new Error(`expectDiffers: "${f}" (${y}) equals "${e.compareTo}" (${u})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let u=new RegExp(e.pattern,"i");return z(h(async f=>u.test(await f.getAttribute(e.attribute,g)??"")),r,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let u=new RegExp(e.pattern,"i");return z(h(async f=>u.test(Ve(await f.textContent(g)??""))),r,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let u=new RegExp(a(e.urlPattern)),f=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(x=>It({seq:0,url:x.url(),method:x.request().method(),status:x.status()},u,e.method,e.status,void 0),c);return}let y=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return z(async()=>y().some(x=>It(x,u,e.method,e.status,f)),r,fr(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function _t(t,e){return t.actions.some(n=>[n.value,n.url,n.pattern,n.target?.name,n.target?.nameRegex,n.target?.label,n.target?.text,n.target?.placeholder].some(r=>r?.includes(e)))}function gr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((n,r)=>{let s=t.steps[r];if(n.gherkin!==s.text)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(L(o)),d=Object.keys(L(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=e.steps.some(p=>_t(p,"<docstring>")),d=e.steps.some(p=>_t(p,n.docString));if(!a||d)return!1}return!0})}function Ge(t,e){let n=t.steps[e],r=n?.resolvedTable??n?.table;return{...t.params,...K(t.steps),...L(r),...q(n?.resolvedDocString??n?.docString)}}async function be(t,e,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=[],a=r.vars??new Map,d=()=>Object.fromEntries(a),p=n.steps.some(g=>g.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),m=new qe;m.attach(t.context(),p);let l=t,c=()=>{let g=t.context().pages(),h=g[g.length-1];h&&h!==l&&!h.isClosed()&&(l=h)};if(!gr(e,n))return m.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let g=0;g<n.steps.length;g++){let h=n.steps[g],u=Ge(e,g);m.markStep();let f=!1;for(let y=0;y<h.actions.length;y++){let x=h.actions[y];c(),x.pageFingerprint&&await J(l)!==x.pageFingerprint&&(f=!0);let b,w=!1;for(let k=0;k<=o&&!w;k++){k>0&&(await We(250*k),c());try{await He(l,x,u,s,r.baseURL,a,m),w=!0}catch(v){b=v}}if(!w){let k=b instanceof Error?b.message:String(b);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:f,error:k});for(let v=g+1;v<n.steps.length;v++)i.push({gherkin:n.steps[v].gherkin,kind:n.steps[v].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:g,actionIndex:y,stepText:h.gherkin,kind:h.kind,error:k},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:f})}return{status:"passed",stepResults:i,captured:d()}}finally{m.detach()}}var ve={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function br(){return new Promise((t,e)=>{let n=hr.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>t(r))}),n.on("error",e)})}function le(t){let e=t.contexts().flatMap(n=>n.pages());return[...e].reverse().find(n=>n.url()!=="about:blank")??e.at(-1)}function Ut(t){let e=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)e=n;return e}function Ot(t,e){return t[e]?.kind==="assertion"&&e>=Ut(t)}function Mt(t,e,n=!1,r){let s=Ut(t);return t.map((o,i)=>{let a=e[i],d=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&d?a:o:d?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function vr(t,e){return t.map((n,r)=>{let s=e[r];return s&&s.actions.length>0?s:n})}function Lt(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0)}}var we=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=H(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,n){let r=this.getStepIndex(),s=e.steps.length,o={...e.params,...K(e.steps)},i=e.steps.map(x=>{let b=r.steps.size>0?mt(r,x,o):void 0;return b?{gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:b.actions}:void 0}),a=e.steps.map(x=>({gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:[]})),d=new Map,p=[],m=[],l=[],c=[],g={...ve},h=0,u=0,f=0,y=x=>({success:x,narrative:m.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:c.length>0?c.join(`
27
- `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:g,seededCount:h});for(;f<s;){if(i[f]){let v=f;for(;v<s&&i[v];)v++;let j=i.slice(f,v),S={...e,steps:e.steps.slice(f,v)},T=await be(n,S,this.buildCandidateCache(e,j),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),C=T.status==="passed"?v-f:T.failure?.stepIndex??0;for(let _=0;_<C;_++)a[f+_]=j[_];if(h+=C,T.status==="passed"){f=v;continue}let F=f+C;p.push(`Seeded recording for "${e.steps[F].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[F]=void 0,f=F;continue}if(!this.options.agent)return y(!1);u++;let x=f;for(;x<s&&!i[x];)x++;u>=3&&(x=s);let b=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:f>0?e.steps.slice(0,f).map(v=>v.resolvedText):void 0,recordOnly:x<s?{from:f,to:x-1}:void 0,takeFingerprint:async()=>J(le(this.browser)??n)}));if(g=Lt(g,b.usage),m.push(b.narrative),p.push(...b.adaptations),l.push(...b.featureEdits),b.suggestedFeatureEdit&&c.push(b.suggestedFeatureEdit),!b.success)return y(!1);let w=b.announcedSteps.filter(v=>v>=f&&v<s),k=Math.min(s-1,Math.max(w.length>0?Math.max(...w):x-1,f));for(let v=f;v<=k;v++)a[v]=b.recordedSteps[v];f=k+1}return y(!0)}async withDialogsVisible(e){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let n=await br();this.browser=await yr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(e==="firefox"?Sr:xr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=Dt.isAbsolute(this.options.storageState)?this.options.storageState:Dt.join(this.options.projectRoot,this.options.storageState);if(!mr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,n){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}async proofReplay(e,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await r.newPage();return{result:await be(s,e,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:r,page:s}}async runScenario(e,n={}){let r=await this.runScenarioInner(e,n),s=ae(e);if(s.size>0){let o=i=>i===void 0?i:te(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>te(i,s));for(let i of r.stepResults)i.error=o(i.error)}return r}async runScenarioInner(e,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=W(s,e.featurePath,e.name),p=N(d),m=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await m.newPage();try{let c;if(p&&(c=await be(l,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:ve,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let y=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:ve,durationMs:Date.now()-r,error:p?c?.status==="stale"?`Cache is stale (feature file changed) and ${y}`:`Replay failed and ${y} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${y}`}}let h=p!==void 0&&c!==void 0&&c.status==="failed",u=0,f;try{if(h)f=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(y=>y.status==="passed").map(y=>y.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let y=le(this.browser)??l;return J(y)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let y=await this.recordWithSeeding(e,l);u=y.seededCount,f=y}else f=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let y=le(this.browser)??l;return J(y)}}))}catch(y){let x=y instanceof Error?y.message:String(y);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:ve,durationMs:Date.now()-r,error:`Agent invocation failed: ${x.split(`
28
- `)[0]}`}}if(f.success&&h&&c.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Ot(p.steps,c.failure.stepIndex))){let y=p.steps[c.failure.stepIndex],x=le(this.browser)??l,b=new Map(Object.entries(c.captured)),w=Ge(e,c.failure.stepIndex);try{for(let k of y.actions)await He(x,k,w,this.options.actionTimeoutMs??5e3,o,b)}catch(k){let v=k instanceof Error?k.message:String(k);return{scenario:e,status:"red",source:"agent-heal",stepResults:c.stepResults,narrative:f.narrative,adaptations:f.adaptations,suggestedFeatureEdit:f.suggestedFeatureEdit,usage:f.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${y.gherkin}". Saffron never adapts assertions \u2014 if this change is intended, update the .feature file or re-record the scenario. Verification error: ${v.split(`
29
- `)[0]}`}}}return await this.finalizeAgentRun(e,f,h,p,r,u,h?c?.failure:void 0)}finally{await m.close()}}async finalizeAgentRun(e,n,r,s,o,i=0,a){let d=r?"agent-heal":"agent-record",p=n.recordedSteps.map(w=>({gherkin:w.gherkin,kind:w.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let m=r?Mt(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],c={...n.usage},g,h;if(this.options.verifyProposals!==!1){let w=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(w.result.status!=="passed"&&w.result.failure){let k=w.result.failure;if(this.options.agent&&!(r&&k.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Ot(s.steps,k.stepIndex))))try{let j=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:w.result.stepResults.filter(S=>S.status==="passed").map(S=>S.gherkin),failedStep:{text:k.stepText,kind:k.kind,error:k.error},takeFingerprint:async()=>J(le(this.browser)??w.page)}));c=Lt(c,j.usage),j.success&&(l.push(...j.adaptations),m=r?Mt(m,j.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):vr(m,j.recordedSteps),await w.context.close(),w=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}g=w.result.status==="passed",g||(h=w.result.failure?.error??`proof replay ${w.result.status}`)}finally{await w.context.close().catch(()=>{})}}let u=[...n.featureEdits];for(let w of ht(m,H(this.options.projectRoot)))l.push(`duplicate wording: "${w.from}" recorded exactly the same actions as the existing step "${w.to}" (${w.feature} \u203A ${w.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(k=>k.index===w.index)||u.push({index:w.index,text:w.to});let f=ae(e),y=w=>f.size>0?te(w,f):w;l=l.map(y);let x={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:y(n.narrative),adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit?y(n.suggestedFeatureEdit):void 0,featureEdits:u.length>0?u:void 0,verified:g,proofError:h?y(h):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},b=dt(this.options.projectRoot,e.featurePath,e.name);return Et(b,x),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:n.narrative,adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:u.length>0?u:void 0,verified:g,proofError:h,seededSteps:i>0?i:void 0,proposalFile:b,usage:c,durationMs:Date.now()-o}}};import Kt from"node:path";import{createRequire as Ar}from"node:module";import{query as Cr}from"@anthropic-ai/claude-agent-sdk";var ke=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(r.actions.length-1,0);r.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];r&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...e))}},Y=t=>t.replace(/\\(['"\\])/g,"$1");function wr(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?Y(e[1]):void 0}function Je(t){let e={},n=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(e.frame=Y(n[1]??n[2]),t=t.slice((n.index??0)+n[0].length));let r=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){e.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=Y(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=Y(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[Y(o[1])]),Object.keys(e).length>0?e:void 0}function kr(t,e){if(e&&t.startsWith(e)){let n=t.slice(e.length);return n.startsWith("/")?n:`/${n}`}return t}var Rr={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function $r(t,e){let n=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:kr(Y(n[1]),e)};let r=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:Y(r[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=Je(s[1]),g=Je(s[2]);if(c&&g)return{action:"dragTo",target:c,toTarget:g}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=Je(i);if(!p)return;let m={action:Rr[a],target:p},l=wr(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function Nt(t,e){let n=[],r=Array.isArray(t)?t:t?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof t=="string"&&n.push(t);let s=[];for(let o of n)for(let i of o.split(`
30
- `)){let a=$r(i.trim(),e);a&&s.push(a)}return s}function Ye(t,e){let n=t;for(let[r,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))n=n.split(s).join(`<${r}>`);return n}function Bt(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let r=s=>s===void 0?void 0:Ye(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:r(o.value),url:r(o.url),target:o.target?{...o.target,name:r(o.target.name),label:r(o.target.label),text:r(o.target.text),placeholder:r(o.target.placeholder)}:void 0}))}))}function zt(t,e){let n=K(e.steps);return t.map((r,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,d=Object.entries({...n,...L(i),...q(a)}).filter(([,m])=>m.length>=3).sort((m,l)=>l[1].length-m[1].length);if(d.length===0)return r;let p=m=>{if(m===void 0)return;let l=m;for(let[c,g]of d)l=l.split(g).join(`<${c}>`);return l};return{...r,actions:r.actions.map(m=>({...m,value:p(m.value),url:p(m.url),target:m.target?{...m.target,name:p(m.target.name),label:p(m.target.label),text:p(m.target.text),placeholder:p(m.target.placeholder)}:void 0}))}})}function Vt(t,e=new Date){let n=r=>r===void 0?void 0:Pt(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text)}:void 0}))}))}function qt(t,e){if(e.size===0)return t;let n=r=>r===void 0?void 0:te(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function Wt(t,e){let n=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(t==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
31
- `)}}}import{createSdkMcpServer as Tr,tool as ne}from"@anthropic-ai/claude-agent-sdk";import{z as R}from"zod";var D=t=>({content:[{type:"text",text:t}]});function Ht(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var Gt={role:R.string().optional().describe("ARIA role of the element"),name:R.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:R.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:R.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:R.string().optional().describe("getByText locator text"),selector:R.string().optional().describe("CSS fallback selector"),frame:R.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function Jt(t){return Tr({name:"saffron",version:"0.1.0",tools:[ne("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:R.number().int().min(0),text:R.string(),adaptation:R.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),D(`Recording step ${e.index}: ${e.text}`))),ne("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:R.string().describe("Name to store the captured text under"),...Gt},async e=>{let n=Ht(e);return n?(t.addActions([{action:"captureText",target:n,saveAs:e.saveAs}]),D(`Capture recorded as "${e.saveAs}".`)):D("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ne("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:R.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:R.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),D(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),ne("saffron_wait_api","Record a network wait for the current step: replay will block until a matching API response is observed (responses from the triggering step count). Call AFTER you verified via browser_network_requests that the response actually occurred with the expected status/body. Use a path-only urlPattern regex and never literal volatile IDs (/api/orders/\\d+, not /api/orders/8842).",{urlPattern:R.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:R.string().optional().describe("HTTP method to match (any if omitted)"),status:R.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:R.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),D("Network wait recorded."))),ne("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:R.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Gt,expected:R.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:R.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:R.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:R.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:R.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),urlPattern:R.string().optional().describe("kind=response: regex matched against the response URL \u2014 path-only for portability, never literal volatile IDs (use /api/orders/\\d+)"),method:R.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:R.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:R.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:R.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let n=Ht(e),r;switch(e.kind){case"visible":r={action:"expectVisible",target:n};break;case"notVisible":r={action:"expectNotVisible",target:n};break;case"text":r={action:"expectText",target:n,value:e.expected??""};break;case"value":r={action:"expectValue",target:n,value:e.expected??""};break;case"url":r={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return D("ERROR: kind=differs requires compareTo.");if(!e.capture&&!n)return D("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return D("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return D("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return D("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!n?D("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([r]),D("Assertion recorded."))}),ne("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:R.boolean(),narrative:R.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:R.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:R.array(R.object({index:R.number().int().min(0).describe("zero-based step index"),text:R.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],D("Run finalized.")))]})}function Yt(t={}){let e=t.deliberateSnapshots?`
23
+ Defined in StepSet **${f.fromStepSet}**`:""}`}}:void 0}}),r.onDidOpen(l=>m(l.document)),r.onDidChangeContent(l=>m(l.document)),r.onDidSave(()=>d()),n.onDidChangeWatchedFiles(()=>d()),r.listen(n),n.listen()}import Tt from"node:fs";import lr from"node:path";var Et=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function ie(t){return t.replace(Et,(e,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function Ve(t){let e=new Set;for(let n of t)if(n)for(let r of n.matchAll(Et))e.add(r[1]);return e}function ze(t){let e=[];for(let n of t.steps){e.push(n.text,n.resolvedText),n.docString&&e.push(n.docString);for(let r of n.table??[])e.push(...r)}return e}function ae(t){let e=new Map;for(let n of Ve(ze(t))){let r=process.env[n];r!==void 0&&e.set(n,r)}return e}function te(t,e){let n=t;for(let[r,s]of[...e.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(n=n.split(s).join(`{env:${r}}`));return n}function At(t){let e=lr.join(t,".env");if(Tt.existsSync(e))for(let n of Tt.readFileSync(e,"utf8").split(`
24
+ `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import kr from"node:fs";import $r from"node:net";import qt from"node:path";import{chromium as Rr,firefox as Tr,webkit as Er}from"playwright";import B from"node:fs";import Se from"node:path";function Ct(t,e){B.mkdirSync(Se.dirname(t),{recursive:!0}),B.writeFileSync(t,`${JSON.stringify(e,null,2)}
25
+ `)}function xe(t){return JSON.parse(B.readFileSync(t,"utf8"))}function ce(t){let e=Se.join(t,".saffron","proposals");if(!B.existsSync(e))return[];let n=[];for(let r of B.readdirSync(e)){let s=Se.join(e,r);if(B.statSync(s).isDirectory())for(let o of B.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=Se.join(s,o);n.push({file:i,proposal:xe(i)})}}return n}function Pt(t,e){let n=xe(e),r=z(t,n.cache.feature,n.cache.scenario);return ee(r,n.cache),B.rmSync(e),r}function Ft(t){B.rmSync(t)}import It from"node:path";var dr=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function pr(t,e){let n=String(t.getFullYear()),r=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function qe(t,e=new Date){return t.replace(dr,(n,r,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),pr(o,s??"YYYY-MM-DD")})}var ur=/\b(\d{4})-(\d{2})-(\d{2})\b/g,fr=400;function jt(t,e=new Date){let n=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(ur,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let d=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(d)>fr?r:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as gr}from"node:crypto";async function Y(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${gr("sha256").update(n).digest("hex")}`}var Ge=t=>new Promise(e=>setTimeout(e,t)),He=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,n){this.context=e,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function mr(t,e){if(!e)return t>=200&&t<300;let n=/^([1-5])xx$/i.exec(e.trim());if(n){let r=Number(n[1])*100;return t>=r&&t<r+100}return t===Number(e.trim())}function _t(t,e,n,r,s){return!(!e.test(t.url)||n&&t.method.toUpperCase()!==n.toUpperCase()||!mr(t.status,r)||s&&!s.test(t.body??""))}var hr=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function Dt(t,e,n,r=!1){let s=a=>ie(qe(q(a,n))),o=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let d of e.fallbackSelectors??[])o.push(a.locator(d))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),r)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function V(t,e,n){let r=Date.now()+e,s;for(;Date.now()<r;){try{if(await t())return}catch(o){s=o}await Ge(100)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function N(t,e){let n;for(let r of t)try{await e(r.first());return}catch(s){n=s}throw n}var We=t=>t.replace(/\s+/g," ").trim();async function Ye(t,e,n,r,s,o=new Map,i){let a=u=>ie(qe(q(u,n))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,m=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",l=e.target?Dt(t,e.target,n,m):[],c={timeout:r},g={timeout:Math.min(1e3,r)},h=u=>async()=>{for(let f of l)try{if(await u(f.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let u=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(u,c);return}case"click":return N(l,u=>u.click(c));case"fill":return N(l,u=>u.fill(d??"",c));case"press":return l.length>0?N(l,u=>u.press(d??"",c)):t.keyboard.press(d??"");case"selectOption":return N(l,async u=>{await u.selectOption(d??"",c)});case"check":return N(l,u=>u.check(c));case"uncheck":return N(l,u=>u.uncheck(c));case"hover":return N(l,u=>u.hover(c));case"waitFor":return l.length>0?N(l,u=>u.waitFor({state:"visible",timeout:r})):Ge(Math.min(Number(d??"0"),r));case"expectVisible":return V(h(u=>u.isVisible()),r,"target to be visible");case"expectNotVisible":return N(l,u=>u.waitFor({state:"hidden",timeout:r}));case"expectText":return V(h(async u=>(await u.textContent(g)??"").includes(d??"")),r,`text "${d}"`);case"expectValue":return V(h(async u=>await u.inputValue(g)===(d??"")),r,`input value "${d}"`);case"expectUrl":{let u=p??d??"";return V(async()=>t.context().pages().some(f=>f.url().includes(u)),r,`URL containing "${u}" in any tab (current: ${t.context().pages().map(f=>f.url()).join(", ")})`)}case"handleDialog":{let u=(d??"accept")!=="dismiss",f=e.promptText?a(e.promptText):void 0;t.once("dialog",y=>{u?y.accept(f):y.dismiss()});return}case"uploadFile":{let u=(d??"").split(`
26
+ `).filter(Boolean).map(f=>It.isAbsolute(f)?f:It.resolve(f));if(u.length===0)throw new Error("uploadFile requires a path");if(l.length>0)return N(l,f=>f.setInputFiles(u,c));t.once("filechooser",f=>{f.setFiles(u)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let u=Dt(t,e.toTarget,n);return N(l,f=>f.dragTo(u[0].first(),c))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return V(h(async u=>{let f=We(await u.textContent(g)??"");return f===""?!1:(o.set(e.saveAs,f),!0)}),r,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let u=o.get(e.compareTo);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(l.length>0)return V(h(async x=>We(await x.textContent(g)??"")!==u),r,`target text to differ from "${e.compareTo}" (${u})`);let f=d??"",y=o.get(f);if(y===void 0)throw new Error(`expectDiffers: no captured value named "${f}"`);if(y===u)throw new Error(`expectDiffers: "${f}" (${y}) equals "${e.compareTo}" (${u})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let u=new RegExp(e.pattern,"i");return V(h(async f=>u.test(await f.getAttribute(e.attribute,g)??"")),r,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let u=new RegExp(e.pattern,"i");return V(h(async f=>u.test(We(await f.textContent(g)??""))),r,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let u=new RegExp(a(e.urlPattern)),f=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(x=>_t({seq:0,url:x.url(),method:x.request().method(),status:x.status()},u,e.method,e.status,void 0),c);return}let y=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return V(async()=>y().some(x=>_t(x,u,e.method,e.status,f)),r,hr(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function Ot(t,e){return t.actions.some(n=>[n.value,n.url,n.pattern,n.target?.name,n.target?.nameRegex,n.target?.label,n.target?.text,n.target?.placeholder].some(r=>r?.includes(e)))}function yr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((n,r)=>{let s=t.steps[r];if(n.gherkin!==s.text)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(L(o)),d=Object.keys(L(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=e.steps.some(p=>Ot(p,"<docstring>")),d=e.steps.some(p=>Ot(p,n.docString));if(!a||d)return!1}return!0})}function Je(t,e){let n=t.steps[e],r=n?.resolvedTable??n?.table;return{...t.params,...K(t.steps),...L(r),...W(n?.resolvedDocString??n?.docString)}}async function be(t,e,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=[],a=r.vars??new Map,d=()=>Object.fromEntries(a),p=n.steps.some(g=>g.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),m=new He;m.attach(t.context(),p);let l=t,c=()=>{let g=t.context().pages(),h=g[g.length-1];h&&h!==l&&!h.isClosed()&&(l=h)};if(!yr(e,n))return m.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let g=0;g<n.steps.length;g++){let h=n.steps[g],u=Je(e,g);m.markStep();let f=!1,y=0;for(let x=0;x<h.actions.length;x++){let w=h.actions[x];c(),w.pageFingerprint&&await Y(l)!==w.pageFingerprint&&(f=!0);let j,v=!1;for(let b=0;b<=o&&!v;b++){b>0&&(y++,await Ge(250*b),c());try{await Ye(l,w,u,s,r.baseURL,a,m),v=!0}catch(_){j=_}}if(!v){let b=j instanceof Error?j.message:String(j);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:f,...y?{retries:y}:{},error:b});for(let _=g+1;_<n.steps.length;_++)i.push({gherkin:n.steps[_].gherkin,kind:n.steps[_].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:g,actionIndex:x,stepText:h.gherkin,kind:h.kind,error:b},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:f,...y?{retries:y}:{}})}return{status:"passed",stepResults:i,captured:d()}}finally{m.detach()}}var ve=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(r.actions.length-1,0);r.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];r&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...e))}},J=t=>t.replace(/\\(['"\\])/g,"$1");function Sr(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?J(e[1]):void 0}function Qe(t){let e={},n=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(e.frame=J(n[1]??n[2]),t=t.slice((n.index??0)+n[0].length));let r=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){e.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=J(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=J(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[J(o[1])]),Object.keys(e).length>0?e:void 0}function xr(t,e){if(e&&t.startsWith(e)){let n=t.slice(e.length);return n.startsWith("/")?n:`/${n}`}return t}var br={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function vr(t,e){let n=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:xr(J(n[1]),e)};let r=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:J(r[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=Qe(s[1]),g=Qe(s[2]);if(c&&g)return{action:"dragTo",target:c,toTarget:g}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=Qe(i);if(!p)return;let m={action:br[a],target:p},l=Sr(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function Mt(t,e){let n=[],r=Array.isArray(t)?t:t?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof t=="string"&&n.push(t);let s=[];for(let o of n)for(let i of o.split(`
27
+ `)){let a=vr(i.trim(),e);a&&s.push(a)}return wr(s)}function wr(t){return t.filter((e,n)=>{let r=t[n+1];return!(e.action==="fill"&&(e.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(e.target))})}function Lt(t){let e=[];for(let n of t)if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let s of n.actions)if(s.action==="fill"&&(s.value??"")===""){e.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}return e}function Ke(t,e){let n=t;for(let[r,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))n=n.split(s).join(`<${r}>`);return n}function Nt(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let r=s=>s===void 0?void 0:Ke(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:r(o.value),url:r(o.url),target:o.target?{...o.target,name:r(o.target.name),label:r(o.target.label),text:r(o.target.text),placeholder:r(o.target.placeholder)}:void 0}))}))}function Ut(t,e){let n=K(e.steps);return t.map((r,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,d=Object.entries({...n,...L(i),...W(a)}).filter(([,m])=>m.length>=3).sort((m,l)=>l[1].length-m[1].length);if(d.length===0)return r;let p=m=>{if(m===void 0)return;let l=m;for(let[c,g]of d)l=l.split(g).join(`<${c}>`);return l};return{...r,actions:r.actions.map(m=>({...m,value:p(m.value),url:p(m.url),target:m.target?{...m.target,name:p(m.target.name),label:p(m.target.label),text:p(m.target.text),placeholder:p(m.target.placeholder)}:void 0}))}})}function Bt(t,e=new Date){let n=r=>r===void 0?void 0:jt(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text)}:void 0}))}))}function Vt(t,e){if(e.size===0)return t;let n=r=>r===void 0?void 0:te(r,e);return t.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function zt(t,e){let n=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(t==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
28
+ `)}}}var we={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Ar(){return new Promise((t,e)=>{let n=$r.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>t(r))}),n.on("error",e)})}function le(t){let e=t.contexts().flatMap(n=>n.pages());return[...e].reverse().find(n=>n.url()!=="about:blank")??e.at(-1)}function Yt(t){let e=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)e=n;return e}function Wt(t,e){return t[e]?.kind==="assertion"&&e>=Yt(t)}function Ht(t,e,n=!1,r){let s=Yt(t);return t.map((o,i)=>{let a=e[i],d=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&d?a:o:d?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function Cr(t,e){return t.map((n,r)=>{let s=e[r];return s&&s.actions.length>0?s:n})}function Gt(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0)}}var ke=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=H(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,n){let r=this.getStepIndex(),s=e.steps.length,o={...e.params,...K(e.steps)},i=e.steps.map(x=>{let w=r.steps.size>0?yt(r,x,o):void 0;return w?{gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:w.actions}:void 0}),a=e.steps.map(x=>({gherkin:x.text,keyword:x.keyword,kind:x.kind,table:x.table,docString:x.docString,fromStepSet:x.source?.fromStepSet,actions:[]})),d=new Map,p=[],m=[],l=[],c=[],g={...we},h=0,u=0,f=0,y=x=>({success:x,narrative:m.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:c.length>0?c.join(`
29
+ `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:g,seededCount:h});for(;f<s;){if(i[f]){let b=f;for(;b<s&&i[b];)b++;let _=i.slice(f,b),S={...e,steps:e.steps.slice(f,b)},R=await be(n,S,this.buildCandidateCache(e,_),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),E=R.status==="passed"?b-f:R.failure?.stepIndex??0;for(let D=0;D<E;D++)a[f+D]=_[D];if(h+=E,R.status==="passed"){f=b;continue}let F=f+E;p.push(`Seeded recording for "${e.steps[F].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[F]=void 0,f=F;continue}if(!this.options.agent)return y(!1);u++;let x=f;for(;x<s&&!i[x];)x++;u>=3&&(x=s);let w=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:f>0?e.steps.slice(0,f).map(b=>b.resolvedText):void 0,recordOnly:x<s?{from:f,to:x-1}:void 0,takeFingerprint:async()=>Y(le(this.browser)??n)}));if(g=Gt(g,w.usage),m.push(w.narrative),p.push(...w.adaptations),l.push(...w.featureEdits),w.suggestedFeatureEdit&&c.push(w.suggestedFeatureEdit),!w.success)return y(!1);let j=w.announcedSteps.filter(b=>b>=f&&b<s),v=Math.min(s-1,Math.max(j.length>0?Math.max(...j):x-1,f));for(let b=f;b<=v;b++)a[b]=w.recordedSteps[b];f=v+1}return y(!0)}async withDialogsVisible(e){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let n=await Ar();this.browser=await Rr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(e==="firefox"?Tr:Er).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=qt.isAbsolute(this.options.storageState)?this.options.storageState:qt.join(this.options.projectRoot,this.options.storageState);if(!kr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,n){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}async proofReplay(e,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await r.newPage();return{result:await be(s,e,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:r,page:s}}async runScenario(e,n={}){let r=await this.runScenarioInner(e,n),s=ae(e);if(s.size>0){let o=i=>i===void 0?i:te(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>te(i,s));for(let i of r.stepResults)i.error=o(i.error)}return r}async runScenarioInner(e,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=z(s,e.featurePath,e.name),p=U(d),m=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await m.newPage();try{let c;if(p&&(c=await be(l,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:we,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let y=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:we,durationMs:Date.now()-r,error:p?c?.status==="stale"?`Cache is stale (feature file changed) and ${y}`:`Replay failed and ${y} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${y}`}}let h=p!==void 0&&c!==void 0&&c.status==="failed",u=0,f;try{if(h)f=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(y=>y.status==="passed").map(y=>y.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let y=le(this.browser)??l;return Y(y)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let y=await this.recordWithSeeding(e,l);u=y.seededCount,f=y}else f=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let y=le(this.browser)??l;return Y(y)}}))}catch(y){let x=y instanceof Error?y.message:String(y);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:we,durationMs:Date.now()-r,error:`Agent invocation failed: ${x.split(`
30
+ `)[0]}`}}if(f.success&&h&&c.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Wt(p.steps,c.failure.stepIndex))){let y=p.steps[c.failure.stepIndex],x=le(this.browser)??l,w=new Map(Object.entries(c.captured)),j=Je(e,c.failure.stepIndex);try{for(let v of y.actions)await Ye(x,v,j,this.options.actionTimeoutMs??5e3,o,w)}catch(v){let b=v instanceof Error?v.message:String(v);return{scenario:e,status:"red",source:"agent-heal",stepResults:c.stepResults,narrative:f.narrative,adaptations:f.adaptations,suggestedFeatureEdit:f.suggestedFeatureEdit,usage:f.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${y.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${b.split(`
31
+ `)[0]}`}}}return await this.finalizeAgentRun(e,f,h,p,r,u,h?c?.failure:void 0)}finally{await m.close()}}async finalizeAgentRun(e,n,r,s,o,i=0,a){let d=r?"agent-heal":"agent-record",p=n.recordedSteps.map(v=>({gherkin:v.gherkin,kind:v.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let m=r?Ht(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],c={...n.usage},g,h;if(this.options.verifyProposals!==!1){let v=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(v.result.status!=="passed"&&v.result.failure){let b=v.result.failure;if(this.options.agent&&!(r&&b.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Wt(s.steps,b.stepIndex))))try{let S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:v.result.stepResults.filter(R=>R.status==="passed").map(R=>R.gherkin),failedStep:{text:b.stepText,kind:b.kind,error:b.error},takeFingerprint:async()=>Y(le(this.browser)??v.page)}));c=Gt(c,S.usage),S.success&&(l.push(...S.adaptations),m=r?Ht(m,S.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Cr(m,S.recordedSteps),await v.context.close(),v=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}g=v.result.status==="passed",g||(h=v.result.failure?.error??`proof replay ${v.result.status}`)}finally{await v.context.close().catch(()=>{})}}let u=[...n.featureEdits];for(let v of St(m,H(this.options.projectRoot)))l.push(`duplicate wording: "${v.from}" recorded exactly the same actions as the existing step "${v.to}" (${v.feature} \u203A ${v.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(b=>b.index===v.index)||u.push({index:v.index,text:v.to});let f=ae(e),y=v=>f.size>0?te(v,f):v;l=l.map(y);let x={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:y(n.narrative),adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit?y(n.suggestedFeatureEdit):void 0,featureEdits:u.length>0?u:void 0,verified:g,proofError:h?y(h):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},w=Lt(m);w.length>0&&(g=!1,h=[h,...w].filter(Boolean).join(" "));let j=ut(this.options.projectRoot,e.featurePath,e.name);return Ct(j,x),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:n.narrative,adaptations:l,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:u.length>0?u:void 0,verified:g,proofError:h,seededSteps:i>0?i:void 0,proposalFile:j,usage:c,durationMs:Date.now()-o}}};import en from"node:path";import{createRequire as jr}from"node:module";import{query as Ir}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Pr,tool as ne}from"@anthropic-ai/claude-agent-sdk";import{z as k}from"zod";var O=t=>({content:[{type:"text",text:t}]});function Jt(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var Qt={role:k.string().optional().describe("ARIA role of the element"),name:k.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:k.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:k.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:k.string().optional().describe("getByText locator text"),selector:k.string().optional().describe("CSS fallback selector"),frame:k.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function Kt(t){return Pr({name:"saffron",version:"0.1.0",tools:[ne("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:k.number().int().min(0),text:k.string(),adaptation:k.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),O(`Recording step ${e.index}: ${e.text}`))),ne("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:k.string().describe("Name to store the captured text under"),...Qt},async e=>{let n=Jt(e);return n?(t.addActions([{action:"captureText",target:n,saveAs:e.saveAs}]),O(`Capture recorded as "${e.saveAs}".`)):O("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ne("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:k.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:k.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),O(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),ne("saffron_wait_api","Record a network wait for the current step: replay will block until a matching API response is observed (responses from the triggering step count). Call AFTER you verified via browser_network_requests that the response actually occurred with the expected status/body. Use a path-only urlPattern regex and never literal volatile IDs (/api/orders/\\d+, not /api/orders/8842).",{urlPattern:k.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:k.string().optional().describe("HTTP method to match (any if omitted)"),status:k.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:k.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),O("Network wait recorded."))),ne("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:k.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Qt,expected:k.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:k.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:k.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:k.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:k.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),urlPattern:k.string().optional().describe("kind=response: regex matched against the response URL \u2014 path-only for portability, never literal volatile IDs (use /api/orders/\\d+)"),method:k.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:k.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:k.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:k.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let n=Jt(e),r;switch(e.kind){case"visible":r={action:"expectVisible",target:n};break;case"notVisible":r={action:"expectNotVisible",target:n};break;case"text":r={action:"expectText",target:n,value:e.expected??""};break;case"value":r={action:"expectValue",target:n,value:e.expected??""};break;case"url":r={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return O("ERROR: kind=differs requires compareTo.");if(!e.capture&&!n)return O("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return O("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return O("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return O("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!n?O("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([r]),O("Assertion recorded."))}),ne("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:k.boolean(),narrative:k.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:k.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:k.array(k.object({index:k.number().int().min(0).describe("zero-based step index"),text:k.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],O("Run finalized.")))]})}function Xt(t={}){let e=t.deliberateSnapshots?`
32
32
  8. Tool responses do NOT include a page snapshot. Take snapshots deliberately with browser_snapshot, and only when you actually need to see the page: after navigation, before locating an element you haven't seen yet, and when verifying an assertion. Do NOT snapshot after actions whose outcome is predictable (filling a field you just located, pressing Enter, a click whose result you will verify later anyway). Every snapshot is expensive.
33
33
  9. Minimize round-trips: batch tool calls in a single response whenever they don't depend on each other's results \u2014 saffron_step together with that step's browser actions, several fills in one turn, bookkeeping calls (saffron_capture, saffron_assert) alongside the actions they belong to. Only stop and wait when you genuinely need a result before deciding your next call. Do not write narration between tool calls.`:"";return`You are Saffron's test execution agent. You execute Gherkin scenarios in a real browser using the playwright MCP tools, while Saffron records your actions into a deterministic replay cache.
34
34
 
@@ -44,7 +44,7 @@ Non-negotiable rules:
44
44
  5c. Real-world page furniture is supported: elements inside IFRAMES record and replay fine (interact normally) \u2014 but when a saffron_assert targets an element INSIDE an iframe, you MUST pass the iframe's CSS selector as the 'frame' argument (e.g. frame='iframe[title="Feedback widget"]'); replay cannot see into frames otherwise. File uploads: use browser_file_upload with the file path. Drag-and-drop: use browser_drag. All of these are recorded and replay deterministically.
45
45
  5d. NATIVE DIALOGS (alert/confirm/prompt): when an action opens one, the page blocks and the snapshot reports a modal state \u2014 resolve it with browser_handle_dialog (accept true/false; promptText for prompt()). That call is recorded automatically as an armed handler replay places BEFORE the triggering action, so do NOT also call saffron_dialog. After handling, verify the post-dialog state as usual. If a step's wording says to confirm/accept, accept; if it says cancel/dismiss, dismiss.
46
46
  5e. NETWORK WAITS: when a step says to wait for an API call ("wait for the order API to succeed", "until the job API reports READY"), do it verify-first: perform the trigger, check via browser_network_requests that the matching response actually occurred with the expected status/body, then record it with saffron_wait_api. For Then steps about API contracts ("the request should have returned 201") verify the same way and record with saffron_assert kind=response. urlPattern is a path-only regex \u2014 NEVER a literal volatile ID (/api/orders/\\d+, not /api/orders/8842) and never the host.
47
- 7. Never ask the user questions; you are unattended.${e}`}function Er(t,e){if(t[e].kind!=="assertion")return"action";let n=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)n=r;return e>=n?"assertion:final":"assertion"}function Qt(t){let{scenario:e}=t,n=e.steps.map((s,o)=>{let i=l=>ie(l),a=`${o}. [${Er(e.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,d=s.resolvedTable??s.table,p=s.resolvedDocString??s.docString,m=a;return d&&(m+=`
47
+ 7. Never ask the user questions; you are unattended.${e}`}function Fr(t,e){if(t[e].kind!=="assertion")return"action";let n=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)n=r;return e>=n?"assertion:final":"assertion"}function Zt(t){let{scenario:e}=t,n=e.steps.map((s,o)=>{let i=l=>ie(l),a=`${o}. [${Fr(e.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,d=s.resolvedTable??s.table,p=s.resolvedDocString??s.docString,m=a;return d&&(m+=`
48
48
  ${d.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
49
49
  `)}`),p!==void 0&&(m+=`
50
50
  """
@@ -60,66 +60,67 @@ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
60
60
  ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
61
61
  `)}`:"You are starting from a blank page.","",`Record ONLY steps ${s} through ${o} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${o} already have recordings and will be replayed automatically the moment you stop. After completing step ${o}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(t.completedSteps&&t.completedSteps.length>0){let s=t.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",t.completedSteps.map(o=>` \u2713 ${o}`).join(`
62
62
  `),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,`Start with saffron_step for step index ${s} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(s=>s!==void 0).join(`
63
- `)}var Pr=Ar(import.meta.url);function Fr(){let t=Pr.resolve("@playwright/mcp/package.json");return Kt.join(Kt.dirname(t),"cli.js")}var Ir=new Set(["mcp__playwright__browser_navigate","mcp__playwright__browser_click","mcp__playwright__browser_type","mcp__playwright__browser_fill_form","mcp__playwright__browser_select_option","mcp__playwright__browser_press_key"]),Re=class{constructor(e={}){this.opts=e}opts;async run(e){let n=new ke(e.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=Cr({prompt:Qt(e),options:{systemPrompt:Yt({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[Fr(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Jt(n)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(Ir.has(a.tool_name)&&e.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,d=Wt(a.tool_name,a.tool_input);return d&&n.addArmedAction(d),a.tool_name.startsWith("mcp__playwright__")&&(n.addActions(Nt(a.tool_response,e.baseURL)),n.pendingFingerprint=void 0),{continue:!0}}]}]}}});try{for await(let i of o)i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(s=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${a}`}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(i=>({index:i.index,text:Ye(i.text,e.scenario.params)})),recordedSteps:qt(Vt(Bt(zt(n.steps,e.scenario),e.scenario.params)),ae(e.scenario)),announcedSteps:[...n.announced].sort((i,a)=>i-a),usage:r}}};import Xt from"node:fs";import $e from"node:path";function Te(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function Zt(t,e){let n=new Map,r=Math.min(t.steps.length,e.steps.length);for(let s=0;s<r;s++){let o=t.steps[s].actions,i=e.steps[s].actions;if(o.length===i.length)for(let a=0;a<o.length;a++){let d=o[a],p=i[a];if(d.action!==p.action||!d.target||!p.target)continue;let m=Te(d.target);m!==Te(p.target)&&n.set(m,{from:d.target,to:p.target})}}return[...n.values()]}function jr(t){let e=$e.join(t,".saffron","cache");if(!Xt.existsSync(e))return[];let n=[];for(let r of Xt.readdirSync(e,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push($e.join(e,s))}return n}function en(t,e,n,r){if(e.length===0)return[];let s=new Map(e.map(i=>[Te(i.from),i])),o=[];for(let i of jr(t)){if($e.resolve(i)===$e.resolve(n))continue;let a=N(i);if(!a)continue;let d=[],p=!1;for(let m of a.steps){let l=0;for(let c of m.actions){if(!c.target)continue;let g=s.get(Te(c.target));g&&(l++,r||(c.target=structuredClone(g.to),p=!0))}l>0&&d.push({gherkin:m.gherkin,count:l})}d.length>0&&(p&&ee(i,a),o.push({file:i,scenario:a.scenario,feature:a.feature,steps:d}))}return o}function Qe(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import tn from"node:fs";import Ke from"node:path";function nn(t,e,n,r,s){let o={applied:[],skipped:[]},i=Z(Ke.join(t,e),t,s).find(p=>p.name===n);if(!i){for(let p of r)o.skipped.push({index:p.index,reason:`scenario "${n}" not found in ${e}`});return o}let a=new Map,d=new Set;for(let p of r){let m=i.steps[p.index];if(!m?.source){o.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}let{file:l,line:c,fromStepSet:g,fromBackground:h}=m.source;if(h){o.skipped.push({index:p.index,reason:`"${m.text}" is a Background step shared across scenarios \u2014 edit it manually`});continue}let u=`${l}:${c}`;if(d.has(u))continue;let f=a.get(l);f||(f=tn.readFileSync(Ke.join(t,l),"utf8").split(`
64
- `),a.set(l,f));let y=f[c-1],x=y?.match(/^(\s*)(\S+)\s+(.*)$/);if(!x||x[3]!==m.text){o.skipped.push({index:p.index,reason:`${l}:${c} no longer matches the recorded step text`});continue}let b=`${x[1]}${x[2]} ${p.text}`;f[c-1]=b,d.add(u),o.applied.push({file:l,line:c,from:y.trim(),to:b.trim(),fromStepSet:g})}for(let[p,m]of a)o.applied.some(l=>l.file===p)&&tn.writeFileSync(Ke.join(t,p),m.join(`
65
- `));return o}import Ze from"node:fs";import Xe from"node:fs";import Ee from"node:path";import{fileURLToPath as _r}from"node:url";function Ae(){let t=Ee.dirname(_r(import.meta.url));for(let e=0;e<6;e++){let n=Ee.join(t,"package.json");if(Xe.existsSync(n))try{if(JSON.parse(Xe.readFileSync(n,"utf8")).name==="saffron-ai")return t}catch{}t=Ee.dirname(t)}}function Ce(){let t=Ae();if(!t)return"unknown";try{return JSON.parse(Xe.readFileSync(Ee.join(t,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import et from"node:path";var A=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),on="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",Dr="M24.085 45.646L24.085 45.646Q20.719 45.646 18.407 44.575Q16.095 43.504 14.905 41.651Q13.715 39.798 13.715 37.384L13.715 37.384L19.121 37.384Q19.121 38.336 19.648 39.152Q20.175 39.968 21.263 40.461Q22.351 40.954 24.085 40.954L24.085 40.954Q25.649 40.954 26.703 40.546Q27.757 40.138 28.301 39.407Q28.845 38.676 28.845 37.724L28.845 37.724Q28.845 36.534 27.825 35.837Q26.805 35.140 24.527 34.936L24.527 34.936L22.657 34.766Q18.951 34.460 16.741 32.454Q14.531 30.448 14.531 27.150L14.531 27.150Q14.531 24.770 15.670 23.036Q16.809 21.302 18.849 20.367Q20.889 19.432 23.677 19.432L23.677 19.432Q26.601 19.432 28.692 20.435Q30.783 21.438 31.905 23.257Q33.027 25.076 33.027 27.524L33.027 27.524L27.587 27.524Q27.587 26.606 27.145 25.841Q26.703 25.076 25.836 24.600Q24.969 24.124 23.677 24.124L23.677 24.124Q22.453 24.124 21.620 24.532Q20.787 24.940 20.379 25.637Q19.971 26.334 19.971 27.150L19.971 27.150Q19.971 28.204 20.719 28.986Q21.467 29.768 23.167 29.904L23.167 29.904L25.071 30.074Q27.791 30.312 29.865 31.247Q31.939 32.182 33.112 33.797Q34.285 35.412 34.285 37.724L34.285 37.724Q34.285 40.104 33.044 41.889Q31.803 43.674 29.525 44.660Q27.247 45.646 24.085 45.646Z";function Or(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
66
- <path d="${on}" fill="#F4A300"/>
67
- <path d="${Dr}" fill="#E7E9EC"/>
68
- </svg>`}function Mr(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${on}" fill="#F4A300"/></svg>`}var Lr={green:"passed",yellow:"adapted",red:"failed"};function Pe(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function de(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function Q(t,e,n,r="",s="",o=""){return`<div class="kpi">
63
+ `)}var _r=jr(import.meta.url);function Dr(){let t=_r.resolve("@playwright/mcp/package.json");return en.join(en.dirname(t),"cli.js")}var Or=new Set(["mcp__playwright__browser_navigate","mcp__playwright__browser_click","mcp__playwright__browser_type","mcp__playwright__browser_fill_form","mcp__playwright__browser_select_option","mcp__playwright__browser_press_key"]),$e=class{constructor(e={}){this.opts=e}opts;async run(e){let n=new ve(e.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=Ir({prompt:Zt(e),options:{systemPrompt:Xt({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[Dr(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Kt(n)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(Or.has(a.tool_name)&&e.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,d=zt(a.tool_name,a.tool_input);return d&&n.addArmedAction(d),a.tool_name.startsWith("mcp__playwright__")&&(n.addActions(Mt(a.tool_response,e.baseURL)),n.pendingFingerprint=void 0),{continue:!0}}]}]}}});try{for await(let i of o)i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(s=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${a}`}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(i=>({index:i.index,text:Ke(i.text,e.scenario.params)})),recordedSteps:Vt(Bt(Nt(Ut(n.steps,e.scenario),e.scenario.params)),ae(e.scenario)),announcedSteps:[...n.announced].sort((i,a)=>i-a),usage:r}}};import tn from"node:fs";import Re from"node:path";function Te(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function nn(t,e){let n=new Map,r=Math.min(t.steps.length,e.steps.length);for(let s=0;s<r;s++){let o=t.steps[s].actions,i=e.steps[s].actions;if(o.length===i.length)for(let a=0;a<o.length;a++){let d=o[a],p=i[a];if(d.action!==p.action||!d.target||!p.target)continue;let m=Te(d.target);m!==Te(p.target)&&n.set(m,{from:d.target,to:p.target})}}return[...n.values()]}function Mr(t){let e=Re.join(t,".saffron","cache");if(!tn.existsSync(e))return[];let n=[];for(let r of tn.readdirSync(e,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(Re.join(e,s))}return n}function rn(t,e,n,r){if(e.length===0)return[];let s=new Map(e.map(i=>[Te(i.from),i])),o=[];for(let i of Mr(t)){if(Re.resolve(i)===Re.resolve(n))continue;let a=U(i);if(!a)continue;let d=[],p=!1;for(let m of a.steps){let l=0;for(let c of m.actions){if(!c.target)continue;let g=s.get(Te(c.target));g&&(l++,r||(c.target=structuredClone(g.to),p=!0))}l>0&&d.push({gherkin:m.gherkin,count:l})}d.length>0&&(p&&ee(i,a),o.push({file:i,scenario:a.scenario,feature:a.feature,steps:d}))}return o}function Xe(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import sn from"node:fs";import Ze from"node:path";function on(t,e,n,r,s){let o={applied:[],skipped:[]},i=Z(Ze.join(t,e),t,s).find(p=>p.name===n);if(!i){for(let p of r)o.skipped.push({index:p.index,reason:`scenario "${n}" not found in ${e}`});return o}let a=new Map,d=new Set;for(let p of r){let m=i.steps[p.index];if(!m?.source){o.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}let{file:l,line:c,fromStepSet:g,fromBackground:h}=m.source;if(h){o.skipped.push({index:p.index,reason:`"${m.text}" is a Background step shared across scenarios; edit it manually`});continue}let u=`${l}:${c}`;if(d.has(u))continue;let f=a.get(l);f||(f=sn.readFileSync(Ze.join(t,l),"utf8").split(`
64
+ `),a.set(l,f));let y=f[c-1],x=y?.match(/^(\s*)(\S+)\s+(.*)$/);if(!x||x[3]!==m.text){o.skipped.push({index:p.index,reason:`${l}:${c} no longer matches the recorded step text`});continue}let w=`${x[1]}${x[2]} ${p.text}`;f[c-1]=w,d.add(u),o.applied.push({file:l,line:c,from:y.trim(),to:w.trim(),fromStepSet:g})}for(let[p,m]of a)o.applied.some(l=>l.file===p)&&sn.writeFileSync(Ze.join(t,p),m.join(`
65
+ `));return o}import tt from"node:fs";import et from"node:fs";import Ee from"node:path";import{fileURLToPath as Lr}from"node:url";function Ae(){let t=Ee.dirname(Lr(import.meta.url));for(let e=0;e<6;e++){let n=Ee.join(t,"package.json");if(et.existsSync(n))try{if(JSON.parse(et.readFileSync(n,"utf8")).name==="saffron-ai")return t}catch{}t=Ee.dirname(t)}}function Ce(){let t=Ae();if(!t)return"unknown";try{return JSON.parse(et.readFileSync(Ee.join(t,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import nt from"node:path";var C=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),ln="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",Nr="M24.085 45.646L24.085 45.646Q20.719 45.646 18.407 44.575Q16.095 43.504 14.905 41.651Q13.715 39.798 13.715 37.384L13.715 37.384L19.121 37.384Q19.121 38.336 19.648 39.152Q20.175 39.968 21.263 40.461Q22.351 40.954 24.085 40.954L24.085 40.954Q25.649 40.954 26.703 40.546Q27.757 40.138 28.301 39.407Q28.845 38.676 28.845 37.724L28.845 37.724Q28.845 36.534 27.825 35.837Q26.805 35.140 24.527 34.936L24.527 34.936L22.657 34.766Q18.951 34.460 16.741 32.454Q14.531 30.448 14.531 27.150L14.531 27.150Q14.531 24.770 15.670 23.036Q16.809 21.302 18.849 20.367Q20.889 19.432 23.677 19.432L23.677 19.432Q26.601 19.432 28.692 20.435Q30.783 21.438 31.905 23.257Q33.027 25.076 33.027 27.524L33.027 27.524L27.587 27.524Q27.587 26.606 27.145 25.841Q26.703 25.076 25.836 24.600Q24.969 24.124 23.677 24.124L23.677 24.124Q22.453 24.124 21.620 24.532Q20.787 24.940 20.379 25.637Q19.971 26.334 19.971 27.150L19.971 27.150Q19.971 28.204 20.719 28.986Q21.467 29.768 23.167 29.904L23.167 29.904L25.071 30.074Q27.791 30.312 29.865 31.247Q31.939 32.182 33.112 33.797Q34.285 35.412 34.285 37.724L34.285 37.724Q34.285 40.104 33.044 41.889Q31.803 43.674 29.525 44.660Q27.247 45.646 24.085 45.646Z";function Ur(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
66
+ <path d="${ln}" fill="#F4A300"/>
67
+ <path d="${Nr}" fill="#E7E9EC"/>
68
+ </svg>`}function Br(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${ln}" fill="#F4A300"/></svg>`}var Vr={green:"passed",yellow:"adapted",red:"failed"};function Pe(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function de(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function Q(t,e,n,r="",s="",o=""){return`<div class="kpi">
69
69
  <div class="kpi-label">${t}</div>
70
70
  <div class="kpi-value ${r}">${e}</div>
71
71
  <div class="kpi-sub ${s}">${n}</div>${o}
72
- </div>`}function rn(t,e){if(t.length<2)return"";let n=108,r=22,s=Math.min(...t),i=Math.max(...t)-s||1,a=t.map((d,p)=>`${(p/(t.length-1)*n).toFixed(1)},${(r-2-(d-s)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var sn=(t,e="",n=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(n)}${e} vs prev`;function Ur(t,e){let n=t.totals,r=t.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${sn(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,d=r?.prev?`${sn(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${Pe(e)}`:`${Pe(e)} total runtime`;return`<section class="kpis">
73
- ${Q("pass rate",`${s}<span class="unit">%</span>`,a,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",rn(r?.series.passRate??[],"var(--green)"))}
72
+ </div>`}function an(t,e){if(t.length<2)return"";let n=108,r=22,s=Math.min(...t),i=Math.max(...t)-s||1,a=t.map((d,p)=>`${(p/(t.length-1)*n).toFixed(1)},${(r-2-(d-s)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var cn=(t,e="",n=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(n)}${e} vs prev`;function zr(t,e){let n=t.totals,r=t.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${cn(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,d=r?.prev?`${cn(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${Pe(e)}`:`${Pe(e)} total runtime`;return`<section class="kpis">
73
+ ${Q("pass rate",`${s}<span class="unit">%</span>`,a,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",an(r?.series.passRate??[],"var(--green)"))}
74
74
  ${Q("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
75
75
  ${Q("adapted",String(n.yellow),n.yellow>0?"\u25B2 proposals pending review":"no adaptations",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
76
76
  ${Q("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
77
77
  ${Q("ai calls",String(n.aiCalls),`${de(i)} in+out tokens`,n.aiCalls>0?"":"green")}
78
- ${Q("cache traffic",de(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${de(n.cacheReadTokens)} read \xB7 ${de(n.cacheCreationTokens)} written`:"zero \u2014 fully cached run","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
79
- ${Q("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,d,"","",rn(r?.series.costUsd??[],"var(--gold)"))}
80
- </section>`}function Nr(t){let e=t.trends;if(!e)return"";let n=[];return e.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios \u2014 stop paying for heals</div><ul>${e.chronic.map(r=>`<li>${A(r.feature)} \u203A ${A(r.scenario)} \u2014 ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(r=>`<li>${r.count}\xD7 \u2014 ${A(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
81
- `)}</section>`:""}function Br(t){return`<ol class="steps">${t.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
78
+ ${Q("cache traffic",de(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${de(n.cacheReadTokens)} read \xB7 ${de(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
79
+ ${Q("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,d,"","",an(r?.series.costUsd??[],"var(--gold)"))}
80
+ </section>`}function qr(t){let e=t.trends;if(!e)return"";let n=[];return e.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${e.chronic.map(r=>`<li>${C(r.feature)} \u203A ${C(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(r=>`<li>${r.count}\xD7: ${C(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
81
+ `)}</section>`:""}function Wr(t){return`<ol class="steps">${t.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
82
82
  ${r}
83
83
  <div class="step-body">
84
- <span class="step-text">${A(n.gherkin)}</span>
84
+ <span class="step-text">${C(n.gherkin)}</span>
85
85
  ${n.kind==="assertion"?'<span class="tag">assert</span>':""}
86
- ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition \u2014 edit once, every invoking scenario follows">from StepSet: ${A(n.fromStepSet)}</span>`:""}
86
+ ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${C(n.fromStepSet)}</span>`:""}
87
87
  ${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
88
- ${n.error?`<div class="step-error">${A(n.error)}</div>`:""}
88
+ ${n.retries?`<span class="tag gold" title="an action failed and was re-attempted with backoff before the step settled">retried \xD7${n.retries}</span>`:""}
89
+ ${n.error?`<div class="step-error">${C(n.error)}</div>`:""}
89
90
  </div>
90
91
  </li>`}).join(`
91
- `)}</ol>`}function zr(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
92
+ `)}</ol>`}function Hr(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
92
93
  <div class="panel-title gold">suggested .feature edit</div>
93
- <div class="diff">${t.featureEdits.map(n=>{let r=t.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${A(r)}</div><div class="diff-line add">+ ${A(n.text)}</div>`}).join("")}</div>
94
+ <div class="diff">${t.featureEdits.map(n=>{let r=t.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${C(r)}</div><div class="diff-line add">+ ${C(n.text)}</div>`}).join("")}</div>
94
95
  <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
95
- </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${A(t.suggestedFeatureEdit)}</pre></div>`:""}function Vr(t){let e=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let n=t.usage.costUsd?` \xB7 $${t.usage.costUsd.toFixed(2)}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${de(e)} tok${n}</span>`}function qr(t){return`<details class="card" ${t.status!=="green"?"open":""}>
96
+ </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${C(t.suggestedFeatureEdit)}</pre></div>`:""}function Gr(t){let e=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let n=t.usage.costUsd?` \xB7 $${t.usage.costUsd.toFixed(2)}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${de(e)} tok${n}</span>`}function Yr(t){return`<details class="card" ${t.status!=="green"?"open":""}>
96
97
  <summary>
97
- <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${Lr[t.status]}</span>
98
- <span class="card-title">${A(t.scenario)}</span>
98
+ <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${Vr[t.status]}</span>
99
+ <span class="card-title">${C(t.scenario)}</span>
99
100
  <span class="card-meta">
100
- <span class="m">${A(t.feature)}</span>
101
+ <span class="m">${C(t.feature)}</span>
101
102
  <span class="m-sep">\xB7</span>
102
- <span class="m">${A(t.source)}</span>
103
+ <span class="m">${C(t.source)}</span>
103
104
  <span class="m-sep">\xB7</span>
104
105
  <span class="m">${Pe(t.durationMs)}</span>
105
- ${Vr(t)}
106
+ ${Gr(t)}
106
107
  </span>
107
108
  </summary>
108
109
  <div class="card-body">
109
- ${Br(t)}
110
- ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${A(t.narrative)}</p></div>`:""}
111
- ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(e=>`<li>${A(e)}</li>`).join("")}</ul></div>`:""}
112
- ${zr(t)}
113
- ${t.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${t.verified===!0?' <span class="pill green">verified</span>':t.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${A(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${A(t.proofError.split(`
110
+ ${Wr(t)}
111
+ ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${C(t.narrative)}</p></div>`:""}
112
+ ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(e=>`<li>${C(e)}</li>`).join("")}</ul></div>`:""}
113
+ ${Hr(t)}
114
+ ${t.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${t.verified===!0?' <span class="pill green">verified</span>':t.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${C(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${C(t.proofError.split(`
114
115
  `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
115
- ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${A(t.error)}</p></div>`:""}
116
+ ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${C(t.error)}</p></div>`:""}
116
117
  </div>
117
- </details>`}function an(t){let e=t.totals,n=new Date(t.startedAt),r=new Date(t.finishedAt).getTime()-n.getTime(),s=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
118
+ </details>`}function dn(t){let e=t.totals,n=new Date(t.startedAt),r=new Date(t.finishedAt).getTime()-n.getTime(),s=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
118
119
  <html lang="en">
119
120
  <head>
120
121
  <meta charset="utf-8"/>
121
122
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
122
- <title>Saffron \u2014 run report</title>
123
+ <title>Saffron run report</title>
123
124
  <link rel="preconnect" href="https://fonts.googleapis.com">
124
125
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
125
126
  <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
@@ -234,58 +235,60 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
234
235
  <div class="wrap">
235
236
  <header class="top">
236
237
  <div class="brand">
237
- ${Or(38)}
238
+ ${Ur(38)}
238
239
  <div>
239
240
  <div class="brand-name">saffron</div>
240
241
  <div class="brand-sub">test run report</div>
241
242
  </div>
242
243
  </div>
243
244
  <div class="run-meta">
244
- <span>${A(s)}</span>
245
+ <span>${C(s)}</span>
245
246
  <span>\xB7</span>
246
247
  <span>${Pe(r)}</span>
247
- ${t.baseURL?`<span class="chip base">${A(t.baseURL)}</span>`:""}
248
+ ${t.baseURL?`<span class="chip base">${C(t.baseURL)}</span>`:""}
248
249
  </div>
249
250
  </header>
250
251
 
251
- ${Ur(t,r)}
252
- ${Nr(t)}
252
+ ${zr(t,r)}
253
+ ${qr(t)}
253
254
 
254
255
  <div class="section-head">
255
256
  <span class="section-title">scenarios \xB7 ${e.scenarios}</span>
256
- <span class="section-title">saffron v${A(t.version)}</span>
257
+ <span class="section-title">saffron v${C(t.version)}</span>
257
258
  </div>
258
- ${t.scenarios.map(qr).join(`
259
+ ${t.scenarios.map(Yr).join(`
259
260
  `)}
260
261
 
261
- <footer>${Mr(11)} generated by saffron v${A(t.version)} \xB7 ${A(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
262
+ <footer>${Br(11)} generated by saffron v${C(t.version)} \xB7 ${C(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
262
263
  </div>
263
264
  </body>
264
265
  </html>
265
- `}function cn(t,e,n){let r={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};for(let s of t)r[s.status]+=1,r.aiCalls+=s.usage.aiCalls,r.inputTokens+=s.usage.inputTokens,r.outputTokens+=s.usage.outputTokens,r.cacheReadTokens+=s.usage.cacheReadTokens,r.cacheCreationTokens+=s.usage.cacheCreationTokens??0,r.costUsd+=s.usage.costUsd??0;return{tool:"saffron",version:Ce(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,usage:s.usage,steps:s.stepResults,narrative:s.narrative,adaptations:s.adaptations,suggestedFeatureEdit:s.suggestedFeatureEdit,featureEdits:s.featureEdits,verified:s.verified,proofError:s.proofError,proposalFile:s.proposalFile,error:s.error}))}}function ln(t,e){let n=et.join(t,".saffron","reports");Ze.mkdirSync(n,{recursive:!0});let r=et.join(n,"latest.json"),s=et.join(n,"latest.html");return Ze.writeFileSync(r,`${JSON.stringify(e,null,2)}
266
- `),Ze.writeFileSync(s,an(e)),{json:r,html:s}}function dn(t){let e=t===void 0?[]:Array.isArray(t)?t:[t],n=new Set;for(let r of e)for(let s of r.split(/[\s,|]+/)){let o=s.trim();o&&n.add(o.startsWith("@")?o:`@${o}`)}return[...n]}function pn(t,e){return e.length===0?t:t.filter(n=>n.tags.some(r=>e.includes(r)))}function un(t,e){return[...e??[],t]}import Fe from"node:fs";import fn from"node:path";function gn(t){return fn.join(t,".saffron","history.jsonl")}function mn(t,e,n,r){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
267
- `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function hn(t,e){let n=gn(t);Fe.mkdirSync(fn.dirname(n),{recursive:!0}),Fe.appendFileSync(n,`${JSON.stringify(e)}
268
- `)}function yn(t,e=50){let n=gn(t);if(!Fe.existsSync(n))return[];let r=Fe.readFileSync(n,"utf8").trim().split(`
269
- `).filter(Boolean),s=[];for(let o of r.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var tt=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function Sn(t,e){let n=[...t,e],r=n.slice(-20),s=n.slice(-10),o=t.at(-1),i=o?{passRatePp:tt(e)-tt(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let g=`${c.feature}::${c.scenario}`,h=a.get(g)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(g,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of r)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let m=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:r.map(tt),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}import E from"node:fs";import O from"node:path";var pe={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},Wr=["claude","agents"],vn="<!-- saffron:start -->",rt="<!-- saffron:end -->";function Hr(){return[vn,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`.","- Secrets go in `{env:VAR}` tokens, never in files.",rt,""].join(`
270
- `)}function xn(t,e){if(!E.existsSync(t))return E.writeFileSync(t,e),"created";let n=E.readFileSync(t,"utf8"),r=n.indexOf(vn),s=n.indexOf(rt);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+e.trimEnd()+n.slice(s+rt.length);return i===n?"unchanged":(E.writeFileSync(t,i),"updated")}let o=n.endsWith(`
266
+ `}function pn(t,e,n){let r={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};for(let s of t)r[s.status]+=1,r.aiCalls+=s.usage.aiCalls,r.inputTokens+=s.usage.inputTokens,r.outputTokens+=s.usage.outputTokens,r.cacheReadTokens+=s.usage.cacheReadTokens,r.cacheCreationTokens+=s.usage.cacheCreationTokens??0,r.costUsd+=s.usage.costUsd??0;return{tool:"saffron",version:Ce(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,usage:s.usage,steps:s.stepResults,narrative:s.narrative,adaptations:s.adaptations,suggestedFeatureEdit:s.suggestedFeatureEdit,featureEdits:s.featureEdits,verified:s.verified,proofError:s.proofError,proposalFile:s.proposalFile,error:s.error}))}}function un(t,e){let n=nt.join(t,".saffron","reports");tt.mkdirSync(n,{recursive:!0});let r=nt.join(n,"latest.json"),s=nt.join(n,"latest.html");return tt.writeFileSync(r,`${JSON.stringify(e,null,2)}
267
+ `),tt.writeFileSync(s,dn(e)),{json:r,html:s}}function fn(t){let e=t===void 0?[]:Array.isArray(t)?t:[t],n=new Set;for(let r of e)for(let s of r.split(/[\s,|]+/)){let o=s.trim();o&&n.add(o.startsWith("@")?o:`@${o}`)}return[...n]}function gn(t,e){return e.length===0?t:t.filter(n=>n.tags.some(r=>e.includes(r)))}function mn(t,e){return[...e??[],t]}import Fe from"node:fs";import hn from"node:path";function yn(t){return hn.join(t,".saffron","history.jsonl")}function Sn(t,e,n,r){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
268
+ `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function xn(t,e){let n=yn(t);Fe.mkdirSync(hn.dirname(n),{recursive:!0}),Fe.appendFileSync(n,`${JSON.stringify(e)}
269
+ `)}function bn(t,e=50){let n=yn(t);if(!Fe.existsSync(n))return[];let r=Fe.readFileSync(n,"utf8").trim().split(`
270
+ `).filter(Boolean),s=[];for(let o of r.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var rt=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function vn(t,e){let n=[...t,e],r=n.slice(-20),s=n.slice(-10),o=t.at(-1),i=o?{passRatePp:rt(e)-rt(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let g=`${c.feature}::${c.scenario}`,h=a.get(g)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(g,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of r)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let m=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:r.map(rt),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}import $ from"node:fs";import A from"node:path";var pe={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},Jr=["claude","agents"],$n="<!-- saffron:start -->",ot="<!-- saffron:end -->";function Qr(){return[$n,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`.","- Secrets go in `{env:VAR}` tokens, never in files.",ot,""].join(`
271
+ `)}function wn(t,e){if(!$.existsSync(t))return $.writeFileSync(t,e),"created";let n=$.readFileSync(t,"utf8"),r=n.indexOf($n),s=n.indexOf(ot);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+e.trimEnd()+n.slice(s+ot.length);return i===n?"unchanged":($.writeFileSync(t,i),"updated")}let o=n.endsWith(`
271
272
  `)?`
272
273
  `:`
273
274
 
274
- `;return E.writeFileSync(t,n+o+e),"updated"}var nt={command:"npx",args:["saffron","mcp"]},Gr={claude:{file:".mcp.json",key:"mcpServers",entry:nt},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:nt},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...nt}}},bn={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function Jr(t,e){let n=O.join(t,e.file),r={},s=E.existsSync(n);if(s)try{r=JSON.parse(E.readFileSync(n,"utf8"))}catch{throw new Error(`${e.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[e.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(e.entry)?"unchanged":(o.saffron=e.entry,r[e.key]=o,E.mkdirSync(O.dirname(n),{recursive:!0}),E.writeFileSync(n,JSON.stringify(r,null,2)+`
275
- `),s?"updated":"created")}function wn(t,e){E.mkdirSync(e,{recursive:!0});for(let n of E.readdirSync(t,{withFileTypes:!0})){let r=O.join(t,n.name),s=O.join(e,n.name);n.isDirectory()?wn(r,s):E.copyFileSync(r,s)}}function kn(t){let e=O.join(t.packageRoot,"skills","saffron");if(!E.existsSync(O.join(e,"SKILL.md")))throw new Error(`bundled skill not found at ${e}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=t.targets??Wr;for(let s of r){let o=O.join(t.projectRoot,pe[s],"saffron");E.existsSync(o)&&E.rmSync(o,{recursive:!0,force:!0}),wn(e,o),n.skillDirs.push(O.relative(t.projectRoot,o))}if(t.mcp!==!1)for(let s of r){let o=Gr[s];o?n.mcpFiles.push({file:o.file,outcome:Jr(t.projectRoot,o)}):bn[s]&&n.mcpHints.push(bn[s])}if(t.instructions!==!1){let s=Hr(),o=O.join(t.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:xn(o,s)});let i=O.join(t.projectRoot,"CLAUDE.md");E.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:xn(i,s)})}if(t.scaffold!==!1){let s=O.join(t.projectRoot,"saffron.config.json");E.existsSync(s)||(E.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
276
- `),n.scaffolded.push("saffron.config.json"));let o=O.join(t.projectRoot,"features");E.existsSync(o)||(E.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=O.join(t.projectRoot,".gitignore"),a=".saffron/reports/",d=E.existsSync(i)?E.readFileSync(i,"utf8"):"";d.split(`
277
- `).some(p=>p.trim()===a)||(E.writeFileSync(i,d+(d&&!d.endsWith(`
275
+ `;return $.writeFileSync(t,n+o+e),"updated"}var st={command:"npx",args:["saffron","mcp"]},Kr={claude:{file:".mcp.json",key:"mcpServers",entry:st},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:st},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...st}}},kn={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function Xr(t,e){let n=A.join(t,e.file),r={},s=$.existsSync(n);if(s)try{r=JSON.parse($.readFileSync(n,"utf8"))}catch{throw new Error(`${e.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[e.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(e.entry)?"unchanged":(o.saffron=e.entry,r[e.key]=o,$.mkdirSync(A.dirname(n),{recursive:!0}),$.writeFileSync(n,JSON.stringify(r,null,2)+`
276
+ `),s?"updated":"created")}function Rn(t,e){$.mkdirSync(e,{recursive:!0});for(let n of $.readdirSync(t,{withFileTypes:!0})){let r=A.join(t,n.name),s=A.join(e,n.name);n.isDirectory()?Rn(r,s):$.copyFileSync(r,s)}}function Tn(t){let e=A.join(t.packageRoot,"skills","saffron");if(!$.existsSync(A.join(e,"SKILL.md")))throw new Error(`bundled skill not found at ${e}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=t.targets??Jr;for(let s of r){let o=A.join(t.projectRoot,pe[s],"saffron");$.existsSync(o)&&$.rmSync(o,{recursive:!0,force:!0}),Rn(e,o),n.skillDirs.push(A.relative(t.projectRoot,o))}if(t.mcp!==!1)for(let s of r){let o=Kr[s];o?n.mcpFiles.push({file:o.file,outcome:Xr(t.projectRoot,o)}):kn[s]&&n.mcpHints.push(kn[s])}if(t.instructions!==!1){let s=Qr(),o=A.join(t.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:wn(o,s)});let i=A.join(t.projectRoot,"CLAUDE.md");$.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:wn(i,s)})}if(t.examples){n.examples=Zr(t.projectRoot,t.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(t.scaffold!==!1){let s=A.join(t.projectRoot,"saffron.config.json");$.existsSync(s)||($.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
277
+ `),n.scaffolded.push("saffron.config.json"));let o=A.join(t.projectRoot,"features");$.existsSync(o)||($.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=A.join(t.projectRoot,".gitignore"),a=t.examples?[".saffron/reports/",".env"]:[".saffron/reports/"],d=$.existsSync(i)?$.readFileSync(i,"utf8"):"",p=[];for(let m of a)d.split(`
278
+ `).some(l=>l.trim()===m)||(d+=(d&&!d.endsWith(`
278
279
  `)?`
279
- `:"")+a+`
280
- `),n.scaffolded.push(".gitignore (+ .saffron/reports/)"))}return n}import Yr from"node:path";function Rn(t){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${t?Yr.join(t,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
281
- `)}function re(t){Tt(t);let e=$.join(t,"saffron.config.json");return I.existsSync(e)?JSON.parse(I.readFileSync(e,"utf8")):{}}function Xr(t,e){let n=e.length>0?e:["features"],r=[];for(let s of n){let o=$.isAbsolute(s)?s:$.join(t,s);if(I.existsSync(o))if(I.statSync(o).isDirectory())for(let i of I.readdirSync(o,{recursive:!0})){let a=String(i);se(a)&&r.push($.join(o,a))}else se(o)&&r.push(o)}return r.sort()}function $n(t,e,n){let r=$.isAbsolute(e.features??"features")?e.features:$.join(t,e.features??"features"),s=new Set(ue(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return X([...s].sort(),t)}function Zr(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",n=e&&(I.existsSync($.join(e,".claude",".credentials.json"))||I.existsSync($.join(e,".claude.json")));t||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
282
- `))}function Ie(t){console.error(`saffron: ${t.message}`),process.exit(2)}function st(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var es={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},ts="\x1B[0m",P=new Kr().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(Ce()).option("-p, --project-root <dir>","project root",process.cwd());P.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags \u2014 comma-separated ("@smoke,@WA-TC1") or repeat the flag',un).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit" \u2014 recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let n=$.resolve(P.opts().projectRoot),r=re(n),s=e.baseUrl??r.baseURL,o=Xr(n,t.length>0?t:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&Zr();let i;try{let S=$n(n,r,o);i=o.flatMap(T=>Z(T,n,S))}catch(S){throw S instanceof M&&Ie(S),S}let a=dn(e.filter);a.length>0&&(i=pn(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${o.length} feature file(s).`),process.exit(2)));{let S=new Map;for(let T of i){let C=Be(T),F=N(W(n,T.featurePath,T.name));F&&C.push(JSON.stringify(F.steps));for(let _ of Ne(C))process.env[_]===void 0&&!S.has(_)&&S.set(_,T.displayName)}if(S.size>0){for(let[T,C]of S)console.error(`missing environment variable ${T} (referenced as {env:${T}} by "${C}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=new we({projectRoot:n,baseURL:s,headed:e.headed,actionTimeoutMs:r.actionTimeoutMs,retries:r.retries,storageState:e.storageState??r.storageState,browser:(()=>{let S=e.browser??r.browser??"chromium";return S!=="chromium"&&S!=="firefox"&&S!=="webkit"&&(console.error(`invalid --browser "${S}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),S})(),healModel:e.healModel??r.healModel,verifyProposals:e.verify===!1?!1:r.verifyProposals,reuseSteps:e.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let S=e.assertionPolicy??r.assertionPolicy??"strict";return S!=="strict"&&S!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${S}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),S})(),agent:e.agent?new Re({model:e.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),p=new Date,m=Math.max(1,Number.parseInt(String(e.workers??r.workers??1),10)||1),l=new Array(i.length),c=S=>{let T=S.scenario,C=es[S.status],F=S.usage.inputTokens+S.usage.outputTokens,_=S.usage.cacheReadTokens+S.usage.cacheCreationTokens,En=_>0?`, ${st(_)} cache traffic`:"";console.log(`${C}${S.status.toUpperCase().padEnd(7)}${ts}${T.displayName} (${S.source}, ${S.durationMs}ms, ${S.usage.aiCalls} AI calls, ${F} tokens${En})`);for(let An of S.adaptations)console.log(` \u26A0 ${An}`);S.seededSteps&&console.log(` \u26A1 ${S.seededSteps}/${S.stepResults.length} steps seeded from existing recordings (zero AI)`),S.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):S.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${S.proofError?.split(`
283
- `)[0]}`),S.error&&console.log(` ${S.error}`)};await d.start();try{if(m>1&&i.length>1){let S=i.map((C,F)=>({s:C,i:F})),T=[];await Promise.all(Array.from({length:Math.min(m,S.length)},async()=>{for(;;){let C=S.shift();if(!C)return;let F=await d.runScenario(C.s,{agentDisabled:!0});F.status==="red"?T.push(C):(l[C.i]=F,c(F))}}));for(let C of T.sort((F,_)=>F.i-_.i)){let F=await d.runScenario(C.s);l[C.i]=F,c(F)}}else for(let S=0;S<i.length;S++){let T=await d.runScenario(i[S]);l[S]=T,c(T)}}finally{await d.stop()}let g=cn(l,p,s),h=H(n).divergent.length,u=mn(l,p,h,s),f=yn(n);g.trends=Sn(f,u),hn(n,u);let{html:y,json:x}=ln(n,g),b=g.totals,w=b.cacheReadTokens+b.cacheCreationTokens;console.log(`
284
- ${b.green} passed, ${b.yellow} adapted, ${b.red} failed \xB7 ${b.aiCalls} AI calls \xB7 ${(b.inputTokens+b.outputTokens).toLocaleString()} tokens in+out${w>0?` \xB7 ${st(b.cacheReadTokens)} cache read \xB7 ${st(b.cacheCreationTokens)} cache written`:""} \xB7 $${b.costUsd.toFixed(4)}`);let k=g.trends;if(k?.prev){let S=k.prev.passRatePp,T=k.prev.costUsd;console.log(`vs previous run: pass rate ${S>=0?"+":""}${S}pp \xB7 cost ${T>=0?"+":"-"}$${Math.abs(T).toFixed(4)} \xB7 AI calls ${k.prev.aiCalls>=0?"+":""}${k.prev.aiCalls}`)}for(let S of k?.chronic??[])console.log(`\u26A0 chronic: ${S.feature} \u203A ${S.scenario} \u2014 ${S.heals} heal(s), ${S.reds} red(s) in last ${S.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${y}
285
- ${x}`);let v=ce($.resolve(P.opts().projectRoot));v.length>0&&console.log(`${v.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let j=e.strict??r.strict??!1;j&&b.yellow>0&&console.log(`strict mode: ${b.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(b.red>0||j&&b.yellow>0?1:0)});P.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").action((t,e)=>{let n=$.resolve(P.opts().projectRoot),r=re(n),s=t?[$.resolve(t)]:e.all?ce(n).map(o=>o.file):[];if(s.length===0){Tn(n);return}for(let o of s){let i=xe(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
286
- `)[0]}) \u2014 expect a heal or red on the next run.`);let a=N(W(n,i.cache.feature,i.cache.scenario)),d=At(n,o);console.log(`accepted \u2192 ${d}`);let p=a?Zt(a,i.cache):[];if(p.length>0){let c=en(n,p,d,!e.propagate);for(let g of p)console.log(` locator fix: ${Qe(g.from)} \u2192 ${Qe(g.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let g of c)console.log(` propagated \u2192 ${g.feature} \u203A ${g.scenario} (${g.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let g of c)console.log(` \xB7 ${g.feature} \u203A ${g.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let l;try{l=nn(n,i.cache.feature,i.cache.scenario,m,$n(n,r,[]))}catch(c){throw c instanceof M&&Ie(c),c}for(let c of l.applied){let g=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}" \u2014 fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${g}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=N(d);for(let g of m)c.steps[g.index]&&(c.steps[g.index].gherkin=g.text);ee(d,c)}}});P.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let n=$.resolve(P.opts().projectRoot),r=re(n),s;try{s=G(n,r.features??"features")}catch(i){throw i instanceof M&&Ie(i),i}if(t&&(s=me(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=$.join(n,".vscode","saffron.code-snippets");I.mkdirSync($.dirname(i),{recursive:!0}),I.writeFileSync(i,St(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
287
- ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});P.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let n=$.resolve(P.opts().projectRoot),r=re(n),s=I.readFileSync($.resolve(t),"utf8"),o;try{o=G(n,r.features??"features")}catch(p){throw p instanceof M&&Ie(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await bt(s,o,e.model??r.model),a=e.out?$.resolve(e.out):$.join(n,r.features??"features",`${$.basename(t).replace(/\.[^.]+$/,"")}.saffron`);I.existsSync(a)&&(console.error(`refusing to overwrite ${a} \u2014 pass --out for a new path`),process.exit(1)),I.mkdirSync($.dirname(a),{recursive:!0}),I.writeFileSync(a,i.content);let d=i.content.split(`
288
- `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+$.relative(process.cwd(),a))});P.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=$.resolve(P.opts().projectRoot),e=re(t);await xt(t,e.features??"features")});P.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(Rn(Ae())),process.exit(2));let t=$.resolve(P.opts().projectRoot),e=re(t);kt(t,e.features??"features")});P.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let n=$.resolve(P.opts().projectRoot),r=t?[$.resolve(t)]:e.all?ce(n).map(s=>s.file):[];if(r.length===0){Tn(n);return}for(let s of r)Ct(s),console.log(`rejected ${s}`)});P.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(pe).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").action(t=>{let e=$.resolve(P.opts().projectRoot),n=Ae();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=t.agents?String(t.agents).split(",").map(o=>o.trim()).filter(Boolean):void 0;for(let o of r??[])o in pe||(console.error(`saffron: unknown agent target "${o}" (expected one of ${Object.keys(pe).join(", ")})`),process.exit(2));let s=kn({projectRoot:e,packageRoot:n,targets:r,instructions:t.instructions,scaffold:t.scaffold,mcp:t.mcp});for(let o of s.skillDirs)console.log(`skill \u2192 ${o}/`);for(let o of s.mcpFiles)console.log(`${o.outcome.padEnd(9)} \u2192 ${o.file} (saffron mcp server)`);for(let o of s.mcpHints)console.log(`mcp hint \u2192 ${o}`);for(let o of s.instructionFiles)console.log(`${o.outcome.padEnd(9)} \u2192 ${o.file} (managed Saffron block)`);for(let o of s.scaffolded)console.log(`created \u2192 ${o}`);console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it.")});P.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=$.resolve(P.opts().projectRoot),e=$.join(t,".saffron","reports","latest.html");I.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let n=process.platform==="darwin"?"open":"xdg-open";Qr(n,[e],{detached:!0,stdio:"ignore"}).unref()});function Tn(t){let e=ce(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
280
+ `:"")+m+`
281
+ `,p.push(m));p.length>0&&($.writeFileSync(i,d),n.scaffolded.push(`.gitignore (+ ${p.join(", ")})`))}return n}function Zr(t,e){let n=A.join(e,"templates","saucedemo");if(!$.existsSync(A.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=A.join(t,"features");if($.existsSync(r)&&$.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];$.mkdirSync(r,{recursive:!0});for(let a of $.readdirSync(A.join(n,"features")))$.copyFileSync(A.join(n,"features",a),A.join(r,a)),s.push(`features/${a}`);let o=A.join(t,"saffron.config.json");$.existsSync(o)||($.copyFileSync(A.join(n,"saffron.config.json"),o),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=A.join(t,".env.example");return $.existsSync(i)||($.copyFileSync(A.join(n,".env.example"),i),s.push(".env.example")),{installed:!0,files:s}}import es from"node:path";function En(t){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${t?es.join(t,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
282
+ `)}function re(t){At(t);let e=T.join(t,"saffron.config.json");return I.existsSync(e)?JSON.parse(I.readFileSync(e,"utf8")):{}}function ss(t,e){let n=e.length>0?e:["features"],r=[];for(let s of n){let o=T.isAbsolute(s)?s:T.join(t,s);if(I.existsSync(o))if(I.statSync(o).isDirectory())for(let i of I.readdirSync(o,{recursive:!0})){let a=String(i);se(a)&&r.push(T.join(o,a))}else se(o)&&r.push(o)}return r.sort()}function An(t,e,n){let r=T.isAbsolute(e.features??"features")?e.features:T.join(t,e.features??"features"),s=new Set(ue(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return X([...s].sort(),t)}function os(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",n=e&&(I.existsSync(T.join(e,".claude",".credentials.json"))||I.existsSync(T.join(e,".claude.json")));t||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
283
+ `))}async function is(t){let e=ns.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>e.question(t,r));return/^y(es)?$/i.test(n.trim())}finally{e.close()}}function je(t){console.error(`saffron: ${t.message}`),process.exit(2)}function it(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var as={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},cs="\x1B[0m",P=new rs().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(Ce()).option("-p, --project-root <dir>","project root",process.cwd());P.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags: comma-separated ("@smoke,@WA-TC1") or repeat the flag',mn).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit", recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let n=T.resolve(P.opts().projectRoot),r=re(n),s=e.baseUrl??r.baseURL,o=ss(n,t.length>0?t:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&os();let i;try{let S=An(n,r,o);i=o.flatMap(R=>Z(R,n,S))}catch(S){throw S instanceof M&&je(S),S}let a=fn(e.filter);if(a.length>0&&(i=gn(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${o.length} feature file(s).`),process.exit(2))),e.rerecord){e.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let S=0;for(let R of i){let E=z(n,R.featurePath,R.name);I.existsSync(E)&&(I.rmSync(E),S++)}console.log(`rerecord: removed ${S} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let S=new Map;for(let R of i){let E=ze(R),F=U(z(n,R.featurePath,R.name));F&&E.push(JSON.stringify(F.steps));for(let D of Ve(E))process.env[D]===void 0&&!S.has(D)&&S.set(D,R.displayName)}if(S.size>0){for(let[R,E]of S)console.error(`missing environment variable ${R} (referenced as {env:${R}} by "${E}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=new ke({projectRoot:n,baseURL:s,headed:e.headed,actionTimeoutMs:r.actionTimeoutMs,retries:r.retries,storageState:e.storageState??r.storageState,browser:(()=>{let S=e.browser??r.browser??"chromium";return S!=="chromium"&&S!=="firefox"&&S!=="webkit"&&(console.error(`invalid --browser "${S}": use "chromium", "firefox", or "webkit"`),process.exit(2)),S})(),healModel:e.healModel??r.healModel,verifyProposals:e.verify===!1?!1:r.verifyProposals,reuseSteps:e.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let S=e.assertionPolicy??r.assertionPolicy??"strict";return S!=="strict"&&S!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${S}": use "strict" or "adaptable-mid"`),process.exit(2)),S})(),agent:e.agent?new $e({model:e.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),p=new Date,m=Math.max(1,Number.parseInt(String(e.workers??r.workers??1),10)||1),l=new Array(i.length),c=S=>{let R=S.scenario,E=as[S.status],F=S.usage.inputTokens+S.usage.outputTokens,D=S.usage.cacheReadTokens+S.usage.cacheCreationTokens,Pn=D>0?`, ${it(D)} cache traffic`:"";console.log(`${E}${S.status.toUpperCase().padEnd(7)}${cs}${R.displayName} (${S.source}, ${S.durationMs}ms, ${S.usage.aiCalls} AI calls, ${F} tokens${Pn})`);for(let _e of S.adaptations)console.log(` \u26A0 ${_e}`);S.seededSteps&&console.log(` \u26A1 ${S.seededSteps}/${S.stepResults.length} steps seeded from existing recordings (zero AI)`);let Ie=S.stepResults.reduce((_e,Fn)=>_e+(Fn.retries??0),0);Ie&&console.log(` \u21BB ${Ie} action ${Ie===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),S.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):S.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${S.proofError?.split(`
284
+ `)[0]}`),S.error&&console.log(` ${S.error}`)};await d.start();try{if(m>1&&i.length>1){let S=i.map((E,F)=>({s:E,i:F})),R=[];await Promise.all(Array.from({length:Math.min(m,S.length)},async()=>{for(;;){let E=S.shift();if(!E)return;let F=await d.runScenario(E.s,{agentDisabled:!0});F.status==="red"?R.push(E):(l[E.i]=F,c(F))}}));for(let E of R.sort((F,D)=>F.i-D.i)){let F=await d.runScenario(E.s);l[E.i]=F,c(F)}}else for(let S=0;S<i.length;S++){let R=await d.runScenario(i[S]);l[S]=R,c(R)}}finally{await d.stop()}let g=pn(l,p,s),h=H(n).divergent.length,u=Sn(l,p,h,s),f=bn(n);g.trends=vn(f,u),xn(n,u);let{html:y,json:x}=un(n,g),w=g.totals,j=w.cacheReadTokens+w.cacheCreationTokens;console.log(`
285
+ ${w.green} passed, ${w.yellow} adapted, ${w.red} failed \xB7 ${w.aiCalls} AI calls \xB7 ${(w.inputTokens+w.outputTokens).toLocaleString()} tokens in+out${j>0?` \xB7 ${it(w.cacheReadTokens)} cache read \xB7 ${it(w.cacheCreationTokens)} cache written`:""} \xB7 $${w.costUsd.toFixed(4)}`);let v=g.trends;if(v?.prev){let S=v.prev.passRatePp,R=v.prev.costUsd;console.log(`vs previous run: pass rate ${S>=0?"+":""}${S}pp \xB7 cost ${R>=0?"+":"-"}$${Math.abs(R).toFixed(4)} \xB7 AI calls ${v.prev.aiCalls>=0?"+":""}${v.prev.aiCalls}`)}for(let S of v?.chronic??[])console.log(`\u26A0 chronic: ${S.feature} \u203A ${S.scenario}: ${S.heals} heal(s), ${S.reds} red(s) in last ${S.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${y}
286
+ ${x}`);let b=ce(T.resolve(P.opts().projectRoot));b.length>0&&console.log(`${b.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let _=e.strict??r.strict??!1;_&&w.yellow>0&&console.log(`strict mode: ${w.yellow} adapted scenario(s) treated as failure. Review proposals to go green.`),process.exit(w.red>0||_&&w.yellow>0?1:0)});P.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((t,e)=>{let n=T.resolve(P.opts().projectRoot),r=re(n),s=t?[T.resolve(t)]:e.all?ce(n).map(o=>o.file):[];if(s.length===0){Cn(n);return}for(let o of s){let i=xe(o);if(i.meta.verified===!1){if(e.all&&!e.includeUnverified){console.log(`skipped UNVERIFIED ${T.basename(o)} (${i.meta.proofError?.split(`
287
+ `)[0]}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);continue}console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
288
+ `)[0]}): expect a heal or red on the next run.`)}let a=U(z(n,i.cache.feature,i.cache.scenario)),d=Pt(n,o);console.log(`accepted \u2192 ${d}`);let p=a?nn(a,i.cache):[];if(p.length>0){let c=rn(n,p,d,!e.propagate);for(let g of p)console.log(` locator fix: ${Xe(g.from)} \u2192 ${Xe(g.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let g of c)console.log(` propagated \u2192 ${g.feature} \u203A ${g.scenario} (${g.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let g of c)console.log(` \xB7 ${g.feature} \u203A ${g.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let l;try{l=on(n,i.cache.feature,i.cache.scenario,m,An(n,r,[]))}catch(c){throw c instanceof M&&je(c),c}for(let c of l.applied){let g=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}", fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${g}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=U(d);for(let g of m)c.steps[g.index]&&(c.steps[g.index].gherkin=g.text);ee(d,c)}}});P.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let n=T.resolve(P.opts().projectRoot),r=re(n),s;try{s=G(n,r.features??"features")}catch(i){throw i instanceof M&&je(i),i}if(t&&(s=me(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=T.join(n,".vscode","saffron.code-snippets");I.mkdirSync(T.dirname(i),{recursive:!0}),I.writeFileSync(i,bt(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
289
+ ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});P.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let n=T.resolve(P.opts().projectRoot),r=re(n),s=I.readFileSync(T.resolve(t),"utf8"),o;try{o=G(n,r.features??"features")}catch(p){throw p instanceof M&&je(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await wt(s,o,e.model??r.model),a=e.out?T.resolve(e.out):T.join(n,r.features??"features",`${T.basename(t).replace(/\.[^.]+$/,"")}.saffron`);I.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),I.mkdirSync(T.dirname(a),{recursive:!0}),I.writeFileSync(a,i.content);let d=i.content.split(`
290
+ `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+T.relative(process.cwd(),a))});P.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=T.resolve(P.opts().projectRoot),e=re(t);await vt(t,e.features??"features")});P.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(En(Ae())),process.exit(2));let t=T.resolve(P.opts().projectRoot),e=re(t);Rt(t,e.features??"features")});P.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let n=T.resolve(P.opts().projectRoot),r=t?[T.resolve(t)]:e.all?ce(n).map(s=>s.file):[];if(r.length===0){Cn(n);return}for(let s of r)Ft(s),console.log(`rejected ${s}`)});P.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(pe).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async t=>{let e=T.resolve(P.opts().projectRoot),n=Ae();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=t.agents?String(t.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in pe||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(pe).join(", ")})`),process.exit(2));let s=t.examples===!0;t.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await is("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=Tn({projectRoot:e,packageRoot:n,targets:r,instructions:t.instructions,scaffold:t.scaffold,mcp:t.mcp,examples:s});for(let i of o.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of o.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of o.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of o.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of o.scaffolded)console.log(`created \u2192 ${i}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
291
+ `))});P.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=T.resolve(P.opts().projectRoot),e=T.join(t,".saffron","reports","latest.html");I.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let n=process.platform==="darwin"?"open":"xdg-open";ts(n,[e],{detached:!0,stdio:"ignore"}).unref()});function Cn(t){let e=ce(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
289
292
  `);for(let{file:n,proposal:r}of e){console.log(` ${n}`);let s=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${s})`),console.log(` ${r.meta.narrative}`);for(let o of r.meta.adaptations)console.log(` \u26A0 ${o}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
290
293
  ${r.meta.suggestedFeatureEdit.split(`
291
294
  `).map(o=>` ${o}`).join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saffron-ai",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing, honest reports",
5
5
  "scripts": {
6
6
  "test": "vitest run",
@@ -8,7 +8,7 @@
8
8
  "dev": "tsc --watch",
9
9
  "e2e": "node scripts/e2e.mjs",
10
10
  "prepublishOnly": "npm run build && npm test && npm run bundle",
11
- "docs": "node scripts/build-docs.mjs",
11
+ "docs": "node scripts/build-docs.mjs && node scripts/build-site-docs.mjs",
12
12
  "bundle": "esbuild src/cli/index.ts --bundle --platform=node --format=esm --packages=external --minify --legal-comments=none --outfile=dist-pkg/cli.js"
13
13
  },
14
14
  "keywords": [
@@ -56,7 +56,9 @@
56
56
  "dist-pkg",
57
57
  "skills",
58
58
  "textmate",
59
+ "templates",
59
60
  "README.md",
61
+ "CHANGELOG.md",
60
62
  "LICENSE"
61
63
  ]
62
64
  }
@@ -119,6 +119,7 @@ npx saffron run --no-agent # replay only (CI without AI access)
119
119
  npx saffron accept # list pending proposals
120
120
  npx saffron accept --all # promote verified proposals to caches
121
121
  npx saffron report # open the HTML report
122
+ npx saffron run --rerecord --filter @t # a recording is wrong: record it fresh
122
123
  ```
123
124
 
124
125
  Results: **green** = cached pass · **yellow** = passed with AI recording
@@ -142,7 +143,7 @@ re-record (mostly seeded from existing step recordings).
142
143
  | Put a password literal in a step | `{env:VAR}` |
143
144
  | Sleep/wait "for 3 seconds" | Wait for a UI signal or an API response |
144
145
  | One 30-step scenario | Several 5–10 step scenarios + StepSets |
145
- | Edit cache JSON to fix a test | Edit the scenario text; re-run |
146
+ | Edit cache JSON to fix a test | Edit the scenario text and re-run, or `saffron run --rerecord` if the recording itself is wrong |
146
147
 
147
148
  ## References
148
149
 
@@ -39,6 +39,7 @@ your-project/
39
39
  |---|---|
40
40
  | `baseURL` | App under test; steps say "the login page", not full URLs |
41
41
  | `storageState` | Playwright storage-state JSON so replays and the agent start authenticated (`npx playwright open --save-storage=.auth/state.json <url>`) |
42
+ | `retries` | Extra attempts per action with backoff before a step fails and the agent heals (default 1). Not a scenario re-run; reports show a `retried ×N` chip |
42
43
  | `strict` | Yellow (passed-with-adaptation) exits 1 until reviewed: cached-green-only CI |
43
44
  | `assertionPolicy` | `strict` (default) or `adaptable-mid`; the final assertion block is always strict |
44
45
  | `verifyProposals` | Proof-replay every recording zero-AI before filing (default true) |
@@ -58,7 +59,7 @@ your-project/
58
59
  | `saffron steps [search] [--json] [--snippets]` | The step vocabulary with recorded/divergent/unrecorded badges |
59
60
  | `saffron author <prose-file>` | Draft a `.saffron` file from plain-paragraph requirements using the project vocabulary |
60
61
  | `saffron mcp` / `saffron lsp` | Vocabulary for AI assistants / language server for editors |
61
- | `saffron init` | Install this skill into the project's agent directories and scaffold config |
62
+ | `saffron init [--examples]` | Install this skill into the project's agent directories, register MCP, scaffold config; `--examples` adds the Saucedemo example suite |
62
63
  | `saffron report` | Open the latest HTML report |
63
64
 
64
65
  Exit codes: 0 green/yellow, 1 red (or yellow with `--strict`), 2 usage /
@@ -0,0 +1,5 @@
1
+ # Saucedemo's public demo credentials. Copy this file to .env (git-ignored)
2
+ # or export the variables in your shell. Saffron resolves {env:VAR} tokens
3
+ # at replay and never writes the values into caches, proposals or reports.
4
+ SAUCE_USER=standard_user
5
+ SAUCE_PASSWORD=secret_sauce
@@ -0,0 +1,25 @@
1
+ Feature: Login
2
+
3
+ # Credentials come from the environment ({env:VAR}); they never land in
4
+ # caches, proposals or reports. See .env.example.
5
+
6
+ @smoke @login
7
+ Scenario: Successful login
8
+ Given I am on the login page
9
+ When I enter "{env:SAUCE_USER}" in the username field
10
+ And I enter "{env:SAUCE_PASSWORD}" in the password field
11
+ And I click the "Login" button
12
+ Then I should see the products page
13
+
14
+ @login
15
+ Scenario Outline: Login errors
16
+ Given I am on the login page
17
+ When I enter "<username>" in the username field
18
+ And I enter "<password>" in the password field
19
+ And I click the "Login" button
20
+ Then I should see the error message "<message>"
21
+
22
+ Examples:
23
+ | username | password | message |
24
+ | locked_out_user | secret_sauce | Epic sadface: Sorry, this user has been locked out. |
25
+ | standard_user | wrong | Epic sadface: Username and password do not match |
@@ -0,0 +1,11 @@
1
+ Feature: Shared step sets for the Saucedemo example suite
2
+
3
+ # A sets-only library file: no scenarios, only StepSet definitions that any
4
+ # .saffron file in the project can invoke with "StepSet <name>".
5
+
6
+ StepSet: Log in as the standard user
7
+ Given I am on the login page
8
+ When I enter "{env:SAUCE_USER}" in the username field
9
+ And I enter "{env:SAUCE_PASSWORD}" in the password field
10
+ And I click the "Login" button
11
+ Then I should see the products page
@@ -0,0 +1,25 @@
1
+ Feature: Shopping
2
+
3
+ # .saffron is Gherkin plus the StepSet keyword. The login flow is defined
4
+ # once in shared.steps.saffron and invoked here; its steps are inlined at
5
+ # parse time, so they record once and replay everywhere.
6
+
7
+ @smoke @cart
8
+ Scenario: Add a product to the cart
9
+ StepSet Log in as the standard user
10
+ When I add "Sauce Labs Backpack" to the cart
11
+ Then the cart badge should show 1
12
+
13
+ @checkout
14
+ Scenario: Complete a checkout
15
+ StepSet Log in as the standard user
16
+ When I add "Sauce Labs Bike Light" to the cart
17
+ And I open the cart
18
+ And I click the "Checkout" button
19
+ And I enter the following checkout information
20
+ | firstName | Ada |
21
+ | lastName | Lovelace |
22
+ | postalCode | 10117 |
23
+ And I click the "Continue" button
24
+ And I click the "Finish" button
25
+ Then I should see "Thank you for your order!"
@@ -0,0 +1,6 @@
1
+ {
2
+ "baseURL": "https://www.saucedemo.com",
3
+ "features": "features",
4
+ "retries": 1,
5
+ "strict": false
6
+ }