saffron-ai 0.7.0 → 0.7.1

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 CHANGED
@@ -4,6 +4,77 @@ All notable changes to Saffron, in one place: the `saffron-ai` runner (npm),
4
4
  the VS Code extension (`ChathurangaJayasinghe.saffron-vscode`) and the
5
5
  JetBrains plugin (`ai.saffron.jetbrains`). Dates are publish dates.
6
6
 
7
+ ## 0.7.1
8
+
9
+ Released 2026-09-20.
10
+
11
+ Runner
12
+
13
+ - **`expectUrl` checks the active page, not any open tab.** A URL sitting in
14
+ a leftover tab could satisfy the assertion for the wrong reason. *A
15
+ recording made before tab openers were marked, with a slow popup and
16
+ `retries: 0`, can now fail where it used to pass: re-record it, or leave
17
+ the default retry, which re-follows the newest tab.*
18
+ - **A changed table value only replays if the recording really uses it.**
19
+ Matching key sets used to be enough, so a recording that still carried the
20
+ old value as a literal, or never referenced the cell, stayed "valid" and
21
+ typed stale data. The old value is looked for only where a value can live
22
+ (typed, matched or named text, with tokens removed), so an empty cell or a
23
+ short value like "on" does not force a needless re-record.
24
+ - **Propagation never rewrites an assertion**, on either side: assertion
25
+ steps and assertion actions are skipped when a fix is extracted and when
26
+ it is applied, the step wording must match, and a locator inside a frame
27
+ is a different locator. When one heal sends the same locator to more than
28
+ one place, nothing propagates and `saffron accept` says so.
29
+ - **`saffron prune` and `saffron status` respect features outside the
30
+ configured directory.** A scenario run as `saffron run e2e/login.feature`
31
+ had its recordings listed as orphans. Feature files named by existing
32
+ recordings are now inspected too, and one that fails to parse blocks the
33
+ prune like any other.
34
+ - **Step reuse no longer brings back a stale table value.** Changing `alice`
35
+ to `bob` correctly invalidated the scenario's cache, and step reuse then
36
+ seeded the same actions straight back, matching on table keys alone: a
37
+ verified proposal at zero AI calls whose table said `bob` while its
38
+ actions still typed and asserted `alice`. Seeds now pass the same rule as
39
+ cache validation, and a step with no table of its own is refused when it
40
+ carries another step's old value as a literal. The old value is looked
41
+ for in locators built from data as well (a test id like `user-alice`,
42
+ fallback selectors, a frame, a response URL pattern), not only in typed
43
+ and named text: a fill can be parameterized while the assertion beside it
44
+ still targets the old user.
45
+ - **A feature edit cannot land on the wrong step.** Edits address steps by
46
+ index, so a step inserted while a proposal waited shifted every index
47
+ after it, and an edit meant for "I click login" rewrote the new step.
48
+ `saffron accept --with-feature-edit` now compares the scenario with the
49
+ steps the proposal was recorded against and refuses before anything is
50
+ consumed: the file, the cache and the proposal stay as they were.
51
+ - **A skipped feature edit no longer reaches the cache.** When one edit
52
+ applied, the accepted cache was given the new text of every edit,
53
+ including a Background edit that was correctly skipped, so the feature and
54
+ its cache disagreed at once. Only edits that landed are synced. Two edits
55
+ that reach the same StepSet line through two invocations are applied once
56
+ when they agree, and both refused when they do not, naming the two
57
+ wordings: the line can only say one thing.
58
+ - `saffron status` parses each feature file once. The vocabulary and the
59
+ orphan scan each parsed the whole project again; both now reuse what
60
+ status already read. IDE panels call status on every refresh.
61
+
62
+ Editors (VS Code extension 0.2.5)
63
+
64
+ - **Completion badges and duplicate-step diagnostics follow cache creation
65
+ and deletion.** Only changes to existing JSON files were watched, so
66
+ accepting a first recording or pruning one left the vocabulary stale until
67
+ something else triggered a rebuild. Recordings are watched for all three
68
+ events (and only recordings, not every JSON file), and the watcher is
69
+ disposed with the extension.
70
+ - **Vocabulary is per project in a multi-root workspace.** Step sets,
71
+ wording and recorded badges were built once for the whole workspace, so
72
+ project B's `Login` step set could answer go-to-definition in project A,
73
+ and a step recorded in A showed as recorded in B.
74
+ - **The inline diff button on a proposal row works.** An inline action
75
+ passes the tree item, not the file path the row click passes; both forms
76
+ are accepted, and the project guard applies to both.
77
+
7
78
  ## 0.7.0
8
79
 
9
80
  Released 2026-09-19.
package/dist-pkg/cli.js CHANGED
@@ -1,48 +1,48 @@
1
1
  #!/usr/bin/env node
2
- import L from"node:fs";import P from"node:path";import{spawn as bo}from"node:child_process";import xo from"node:readline";import{Command as vo}from"commander";import jr from"node:fs";import Ir from"node:path";import{AstBuilder as Or,GherkinClassicTokenMatcher as Dr,Parser as Mr}from"@cucumber/gherkin";import{IdGenerator as Nr,StepKeywordType as tt}from"@cucumber/messages";function ce(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function V(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(o=>!o))return{};r.forEach((o,s)=>{n.forEach((i,a)=>{o[a]!==void 0&&(t[`table:${s+1}:${i}`]=o[a])})})}return t}function le(e){return e!==void 0?{docstring:e}:{}}function ge(e){let t=new Map;for(let r of e){let o={...V(r.resolvedTable??r.table),...le(r.resolvedDocString??r.docString)};for(let[s,i]of Object.entries(o)){let a=t.get(s);a||t.set(s,a=new Set),a.add(i)}}let n={};for(let[r,o]of t)o.size===1&&(n[r]=[...o][0]);return n}import Ze from"node:fs";import jt from"node:path";import{AstBuilder as Tr,GherkinClassicTokenMatcher as Rr,Parser as Ar}from"@cucumber/gherkin";import{IdGenerator as Er}from"@cucumber/messages";var B=class extends Error{},Pr=/^(\s*)StepSet:\s*(.+?)\s*$/,Cr=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function et(e){let t=e.split(`
3
- `),n=new Map,r=new Map;return{source:t.map((s,i)=>{let a=s.match(Pr);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let c=s.match(Cr);return c?(r.set(i+1,c[2]),`${c[1]}* StepSet ${c[2]}`):s}).join(`
4
- `),definitions:n,invocations:r}}function _r(e){return new Ar(new Tr(Er.uuid()),new Rr).parse(e)}function It(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&It(n.rule.children,t)}function J(e,t){let n=new Map;for(let r of e){let o=jt.relative(t,r),s=et(Ze.readFileSync(r,"utf8"));if(s.definitions.size===0)continue;let i=_r(s.source);i.feature&&It(i.feature.children,a=>{let c=s.definitions.get(a.location.line);if(c===void 0)return;let l=n.get(c);if(l)throw new B(`StepSet "${c}" is defined twice: ${l.file}:${l.line} and ${o}:${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 ${c}".`);if(a.examples.length>0)throw new B(`StepSet "${c}" (${o}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let p of a.steps)if(s.invocations.has(p.location.line))throw new B(`StepSet "${c}" (${o}:${a.location.line}) invokes another step set at line ${p.location.line} \u2014 step sets cannot be nested.`);n.set(c,{name:c,file:o,line:a.location.line,steps:[...a.steps]})})}return n}var Fr=[".feature",".saffron"];function Q(e){return Fr.some(t=>e.endsWith(t))}function se(e){if(!Ze.existsSync(e))return[];let t=[];for(let n of Ze.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(jt.join(e,r))}return t.sort()}function Ot(e,t,n){return e.map(({step:r,source:o})=>{let s;switch(r.keywordType){case tt.OUTCOME:s="assertion";break;case tt.CONTEXT:case tt.ACTION:s="action";break;default:s=n.value}n.value=s;let i=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(l=>l.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:s,text:r.text,resolvedText:ce(r.text,t),table:i,resolvedTable:i?.map(c=>c.map(l=>ce(l,t))),docString:a,resolvedDocString:a!==void 0?ce(a,t):void 0,source:o}})}function Dt(e,t,n,r,o){let s=[];for(let i of e){let a=r.get(i.location.line);if(a===void 0){s.push({step:i,source:{file:t,line:i.location.line,fromBackground:n}});continue}let c=o?.get(a);if(!c)throw new B(`${t}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let l of c.steps)s.push({step:l,source:{file:c.file,line:l.location.line,fromStepSet:c.name,fromBackground:n}})}return s}function Lr(e,t,n,r,o){let s=e.tags.map(a=>a.name),i=[];if(e.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let c of e.examples){let l=c.tableHeader?.cells.map(p=>p.value)??[];for(let p of c.tableBody){a+=1;let d={};p.cells.forEach((f,g)=>{d[l[g]]=f.value}),i.push({params:d,label:` (example ${a})`})}}}return i.map(({params:a,label:c})=>{let l={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${c}`,tags:s,steps:[...Ot(r,a,l),...Ot(o,a,l)],params:a}})}function z(e,t,n){let r=jr.readFileSync(e,"utf8"),s=e.endsWith(".saffron")?et(r):{source:r,definitions:new Map,invocations:new Map},a=new Mr(new Or(Nr.uuid()),new Dr).parse(s.source);if(!a.feature)return[];let c=Ir.relative(t,e),l=a.feature.name,p=[],d=(f,g)=>{let h=g;for(let x of f)if(x.background)h=[...g,...Dt(x.background.steps,c,!0,s.invocations,n)];else if(x.scenario){if(s.definitions.has(x.scenario.location.line))continue;p.push(...Lr(x.scenario,c,l,h,Dt(x.scenario.steps,c,!1,s.invocations,n)))}else"rule"in x&&x.rule&&d(x.rule.children,h)};return d(a.feature.children,[]),p}import qt from"node:fs";import rt from"node:path";import Ut from"node:fs";import Bt from"node:path";import{createHash as Ur}from"node:crypto";import je from"node:fs";import we from"node:path";var Mt=".saffron";function nt(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function Nt(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),o=nt([...r,we.basename(n,".feature")].join("-"));if(r.length===0)return o;let s=Ur("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${o}-${s}`}function M(e,t,n){return we.join(e,Mt,"cache",Nt(t),`${nt(n)}.json`)}function Lt(e,t){let n=new Map;for(let r of t){let o=M(e,r.featurePath,r.name),s=n.get(o)??new Set;s.add(`${r.featurePath} \u203A ${r.name}`),n.set(o,s)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,o])=>`${[...o].join(" and ")} both map to ${we.relative(e,r)}. Rename one scenario or one file.`)}function oe(e,t,n){return we.join(e,Mt,"proposals",Nt(t),`${nt(n)}.json`)}function N(e){if(!je.existsSync(e))return;let t=JSON.parse(je.readFileSync(e,"utf8"));if(t.version!==1)throw new Error(`Unsupported cache version ${t.version} in ${e}`);return t}function he(e,t){je.mkdirSync(we.dirname(e),{recursive:!0}),je.writeFileSync(e,`${JSON.stringify(t,null,2)}
5
- `)}function Ie(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,o)=>(t.push(o),`"\xAB${t.length-1}\xBB"`)),args:t}}function Br(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:o,...s})=>s));return n(e)===n(t)}function Vr(e){let t=new Set,n=r=>{if(r)for(let o of r.matchAll(/<([^<>]+)>/g))t.add(o[1])};for(let r of e.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(e.gherkin),t}function X(e){let t=Bt.join(e,".saffron","cache"),n=new Map,r=new Map,o=0;if(Ut.existsSync(t))for(let a of Ut.readdirSync(t,{recursive:!0})){let c=String(a);if(!c.endsWith(".json"))continue;let l=N(Bt.join(t,c));if(l){o++;for(let p of l.steps){let d=p.gherkin,f=JSON.stringify(p.actions.map(({pageFingerprint:x,...k})=>k)),g=r.get(d)??[];g.includes(f)||(g.push(f),r.set(d,g));let h=n.get(d);!h||l.recordedAt>h.recordedAt?n.set(d,{gherkin:p.gherkin,table:p.table,docString:p.docString,actions:p.actions,feature:l.feature,scenario:l.scenario,recordedAt:l.recordedAt,variants:g.length}):h.variants=g.length}}}let s=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:c,args:l}=Ie(a.gherkin);if(l.length===0)continue;let p=i.get(c);(!p||a.recordedAt>p.recordedAt)&&i.set(c,{...a,args:l})}return{steps:n,patterns:i,divergent:s,scannedCaches:o}}function Vt(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let s=Object.keys(V(e.table)),i=Object.keys(V(t.table));if(s.length>0&&i.length>0){if(JSON.stringify(s)!==JSON.stringify(i))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(s=>[s.value,s.url,s.pattern].some(i=>i?.includes("<docstring>"))))return!1;let r={...n,...V(t.resolvedTable??t.table),...le(t.resolvedDocString??t.docString)},o=Vr({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let s of o)if(!(s in r))return!1;return!0}function zt(e,t,n){let r=e.steps.get(t.text);if(r)return Vt(r,t,n)?r:void 0;let{pattern:o,args:s}=Ie(t.text);if(s.length===0)return;let i=e.patterns.get(o);if(!i)return;let a=new Map;for(let d=0;d<s.length;d++){let f=i.args[d],g=s[d];if(f===g)continue;let h=a.get(f);if(h!==void 0&&h!==g)return;a.set(f,g)}let c=d=>[d?.name,d?.nameRegex,d?.label,d?.text,d?.placeholder];for(let d of a.keys())if(!i.actions.some(g=>[g.value,g.url,g.pattern,...c(g.target)].includes(d)))return;let l=d=>d!==void 0&&a.has(d)?a.get(d):d,p={...i,gherkin:t.text,actions:i.actions.map(({pageFingerprint:d,...f})=>({...f,value:l(f.value),url:l(f.url),pattern:l(f.pattern),target:f.target?{...f.target,name:l(f.target.name),nameRegex:l(f.target.nameRegex),label:l(f.target.label),text:l(f.target.text),placeholder:l(f.target.placeholder)}:void 0}))};return Vt(p,t,n)?p:void 0}function Wt(e,t){let n=[];return e.forEach((r,o)=>{if(r.actions.length!==0&&!t.steps.has(r.gherkin)){for(let s of t.steps.values())if(!(s.variants>1)&&s.actions.length!==0&&Br(r.actions,s.actions)){n.push({index:o,from:r.gherkin,to:s.gherkin,feature:s.feature,scenario:s.scenario});break}}}),n}function zr(e){if(!qt.existsSync(e))return[];let t=[];for(let n of qt.readdirSync(e,{recursive:!0})){let r=String(n);Q(r)&&t.push(rt.join(e,r))}return t.sort()}function G(e,t,n){let r=rt.isAbsolute(t)?t:rt.join(e,t),o=zr(r),s=n??J(o.filter(f=>f.endsWith(".saffron")),e),i=new Map,a=new Map;for(let f of o)for(let g of z(f,e,s)){let h=`${g.featurePath}::${g.name}`;for(let x of g.steps){let k=i.get(x.text);if(k||(k={text:x.text,keywords:new Map,kind:x.kind,scenarios:new Set,files:new Set,fromStepSet:x.source?.fromStepSet},i.set(x.text,k)),k.keywords.set(x.keyword,(k.keywords.get(x.keyword)??0)+1),k.scenarios.add(h),x.source&&k.files.add(x.source.file),x.source?.fromStepSet){let y=a.get(x.source.fromStepSet);y||a.set(x.source.fromStepSet,y=new Set),y.add(h)}}}let c=X(e),l=new Set(c.divergent),p=[...i.values()].map(f=>({text:f.text,keyword:[...f.keywords.entries()].sort((g,h)=>h[1]-g[1])[0][0],kind:f.kind,status:l.has(f.text)?"divergent":c.steps.has(f.text)?"recorded":"unrecorded",usage:f.scenarios.size,files:[...f.files].sort(),fromStepSet:f.fromStepSet})).sort((f,g)=>g.usage-f.usage||f.text.localeCompare(g.text)),d=[...s.values()].map(f=>({name:f.name,file:f.file,line:f.line,steps:f.steps.map(g=>g.text),usage:a.get(f.name)?.size??0})).sort((f,g)=>g.usage-f.usage||f.name.localeCompare(g.name));return{steps:p,stepSets:d,scannedFiles:o.length,scannedCaches:c.scannedCaches}}function Oe(e,t){let n=t.toLowerCase();return{...e,steps:e.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:e.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(o=>o.toLowerCase().includes(n)))}}function Ht(e){let t={},n="saffron,feature,gherkin,cucumber";for(let r of e.steps)t[`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 e.stepSets)t[`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(t,null,2)+`
6
- `}import{Server as ds}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as ps}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as fs,ListToolsRequestSchema as us}from"@modelcontextprotocol/sdk/types.js";import te from"node:fs";import ne from"node:path";import Kt from"node:fs";import qr from"node:path";import Jt from"node:fs";import Wr from"node:path";var Gt=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function ke(e){return e.replace(Gt,(t,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 st(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Gt))t.add(r[1]);return t}function ot(e){let t=[];for(let n of e.steps){t.push(n.text,n.resolvedText),n.docString&&t.push(n.docString),n.resolvedDocString&&t.push(n.resolvedDocString);for(let r of n.table??[])t.push(...r);for(let r of n.resolvedTable??[])t.push(...r)}return t}function $e(e){let t=new Map;for(let n of st(ot(e))){let r=process.env[n];r!==void 0&&t.set(n,r)}return t}function me(e,t){let n=e;for(let[r,o]of[...t.entries()].sort((s,i)=>i[1].length-s[1].length))o.length<4||(n=n.split(o).join(`{env:${r}}`));return n}function Yt(e){let t=Wr.join(e,".env");if(Jt.existsSync(t))for(let n of Jt.readFileSync(t,"utf8").split(`
7
- `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let o=r.indexOf("=");if(o<=0)continue;let s=r.slice(0,o).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))continue;let i=r.slice(o+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[s]===void 0&&(process.env[s]=i)}}function W(e){Yt(e);let t=qr.join(e,"saffron.config.json");return Kt.existsSync(t)?JSON.parse(Kt.readFileSync(t,"utf8")):{}}var Hr={features:"features",actionTimeoutMs:5e3,pollIntervalMs:100,retries:1,maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict"};function Qt(e){return{...Hr,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import it from"node:fs";import De from"node:path";import{fileURLToPath as Jr}from"node:url";function Me(){let e=De.dirname(Jr(import.meta.url));for(let t=0;t<6;t++){let n=De.join(e,"package.json");if(it.existsSync(n))try{if(JSON.parse(it.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=De.dirname(e)}}function ie(){let e=Me();if(!e)return"unknown";try{return JSON.parse(it.readFileSync(De.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import Z from"node:fs";import Y from"node:path";var Xt=(e,t)=>Y.relative(e,t).split(Y.sep).join("/");function Gr(e){return Z.existsSync(e)?[...Z.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>Y.join(e,t)).sort():[]}function Yr(e){try{let t=JSON.parse(Z.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function Zt(e,t){let n=Y.isAbsolute(t)?t:Y.join(e,t),r=new Map,o=[];try{r=J(se(n),e)}catch(a){o.push(a instanceof Error?a.message:String(a))}let s=Z.existsSync(n)?[...Z.readdirSync(n,{recursive:!0})].map(String).filter(Q).map(a=>Y.join(n,a)).sort():[],i=new Set;for(let a of s){let c;try{c=z(a,e,r)}catch(l){o.push(`${Xt(e,a)}: ${l instanceof Error?l.message:String(l)}`);continue}for(let l of c)i.add(M(e,l.featurePath,l.name)),i.add(oe(e,l.featurePath,l.name))}return{...at(e,i),unreadable:o}}function at(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let s=Y.join(e,".saffron",o==="cache"?"cache":"proposals");for(let i of Gr(s)){if(t.has(i)){r++;continue}let{feature:a,scenario:c}=Yr(i),l=a?Y.join(e,a):void 0;n.push({file:Xt(e,i),kind:o,feature:a,scenario:c,reason:a?Z.existsSync(l)?"scenario-gone":"feature-gone":"unknown"})}}return{orphans:n,kept:r}}function en(e,t){let n=[],r=new Set;for(let o of t){let s=Y.join(e,o.file);try{Z.rmSync(s),n.push(o.file),r.add(Y.dirname(s))}catch{}}for(let o of r)try{Z.readdirSync(o).length===0&&Z.rmdirSync(o)}catch{}return n}function tn(e){let t=e.feature&&e.scenario?`${e.feature} \u203A ${e.scenario}`:"an unreadable recording",n=e.reason==="feature-gone"?"the feature file is gone":e.reason==="scenario-gone"?"the scenario is gone from that file":"it names no scenario";return`${e.file}
8
- ${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import rn from"node:path";var Se=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse"]);var Kr=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Qr(e,t){let n=String(e.getFullYear()),r=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return t.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",o).replaceAll("M",String(e.getMonth()+1)).replaceAll("D",String(e.getDate()))}function ct(e,t=new Date){return e.replace(Kr,(n,r,o)=>{let s=new Date(t);return s.setDate(s.getDate()+(r?parseInt(r,10):0)),Qr(s,o??"YYYY-MM-DD")})}var Xr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Zr=400;function nn(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(Xr,(r,o,s,i)=>{let a=new Date(Number(o),Number(s)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let c=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(c)>Zr?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as es}from"node:crypto";async function de(e){let t;try{t=await e.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=t.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${es("sha256").update(n).digest("hex")}`}var pt=e=>new Promise(t=>setTimeout(t,e)),dt=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let o={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(o),n){let s=r.headers()["content-type"]??"";/json|text|xml/i.test(s)&&r.text().then(i=>{o.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},t.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(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function ts(e,t){if(!t)return e>=200&&e<300;let n=/^([1-5])xx$/i.exec(t.trim());if(n){let r=Number(n[1])*100;return e>=r&&e<r+100}return e===Number(t.trim())}function sn(e,t,n,r,o){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!ts(e.status,r)||o&&!o.test(e.body??""))}var ns=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,rs=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]);function on(e,t,n,r=!1,o=!0){let s=c=>ke(ct(ce(c,n))),i=[],a=c=>{if(t.role){let l=t.nameRegex?new RegExp(s(t.nameRegex),"i"):t.name?s(t.name):void 0,p=t.role;typeof l=="string"?(i.push(c.getByRole(p,{name:l,exact:!0})),o&&i.push(c.getByRole(p,{name:l,exact:!1}))):i.push(c.getByRole(p,{name:l,exact:!1}))}t.label&&i.push(c.getByLabel(s(t.label))),t.placeholder&&i.push(c.getByPlaceholder(s(t.placeholder))),t.text&&i.push(c.getByText(s(t.text))),t.testId&&i.push(c.getByTestId(s(t.testId)));for(let l of t.fallbackSelectors??[])i.push(c.locator(l))};if(t.frame)a(e.frameLocator(s(t.frame)));else if(a(e),r)for(let c of e.frames())c!==e.mainFrame()&&a(c);if(i.length===0)throw new Error("Action target has neither role nor fallback selectors");return i}var cn=100;async function ss(e,t,n,r){let o=Date.now()+t,s;for(;Date.now()<o;){try{if(await e())return}catch(i){s=i}await pt(r)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function K(e,t,n=!1){let r;for(let o of e)try{await t(o.first());return}catch(s){if(r=s,n&&await o.first().count().catch(()=>0)>0)throw s}throw r}var lt=e=>e.replace(/\s+/g," ").trim();async function ft(e,t,n,r,o,s=new Map,i,a=cn){let c=(y,b,S)=>ss(y,b,S,a),l=y=>ke(ct(ce(y,n))),p=t.value!==void 0?l(t.value):void 0,d=t.url!==void 0?l(t.url):void 0,f=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="captureText",g=t.target?on(e,t.target,n,f,!rs.has(t.action)):[],h={timeout:r},x={timeout:Math.min(1e3,r)},k=y=>async()=>{for(let b of g)try{if(await y(b.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!d)throw new Error("goto requires url");let y=/^https?:\/\//.test(d)?d:new URL(d,o).toString();await e.goto(y,h);return}case"click":return K(g,y=>y.click(h),!0);case"fill":return K(g,y=>y.fill(p??"",h),!0);case"press":return g.length>0?K(g,y=>y.press(p??"",h),!0):e.keyboard.press(p??"");case"selectOption":return K(g,async y=>{await y.selectOption(p??"",h)},!0);case"check":return K(g,y=>y.check(h),!0);case"uncheck":return K(g,y=>y.uncheck(h),!0);case"hover":return K(g,y=>y.hover(h),!0);case"waitFor":return g.length>0?K(g,y=>y.waitFor({state:"visible",timeout:r})):pt(Math.min(Number(p??"0"),r));case"expectVisible":return c(k(y=>y.isVisible()),r,"target to be visible");case"expectNotVisible":return c(async()=>{for(let y of g){let b=await y.count();for(let S=0;S<b;S++)if(await y.nth(S).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${g.length} locator(s))`);case"expectText":return c(k(async y=>(await y.textContent(x)??"").includes(p??"")),r,`text "${p}"`);case"expectValue":return c(k(async y=>await y.inputValue(x)===(p??"")),r,`input value "${p}"`);case"expectUrl":{let y=d??p??"";return c(async()=>e.context().pages().some(b=>b.url().includes(y)),r,`URL containing "${y}" in any tab (current: ${e.context().pages().map(b=>b.url()).join(", ")})`)}case"handleDialog":{let y=(p??"accept")!=="dismiss",b=t.promptText?l(t.promptText):void 0;e.once("dialog",S=>{y?S.accept(b):S.dismiss()});return}case"uploadFile":{let y=(p??"").split(`
9
- `).filter(Boolean).map(b=>rn.isAbsolute(b)?b:rn.resolve(b));if(y.length===0)throw new Error("uploadFile requires a path");if(g.length>0)return K(g,b=>b.setInputFiles(y,h),!0);e.once("filechooser",b=>{b.setFiles(y)});return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let y=on(e,t.toTarget,n,!1,!1);return K(g,b=>b.dragTo(y[0].first(),h),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return c(k(async y=>{let b=lt(await y.textContent(x)??"");return b===""?!1:(s.set(t.saveAs,b),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let y=s.get(t.compareTo);if(y===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(g.length>0)return c(k(async u=>lt(await u.textContent(x)??"")!==y),r,`target text to differ from "${t.compareTo}" (${y})`);let b=p??"",S=s.get(b);if(S===void 0)throw new Error(`expectDiffers: no captured value named "${b}"`);if(S===y)throw new Error(`expectDiffers: "${b}" (${S}) equals "${t.compareTo}" (${y})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let y=new RegExp(t.pattern,"i");return c(k(async b=>y.test(await b.getAttribute(t.attribute,x)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let y=new RegExp(t.pattern,"i");return c(k(async b=>y.test(lt(await b.textContent(x)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let y=Number.parseInt(p??"",10),b=()=>e.context().pages().filter(u=>!u.isClosed());await c(async()=>b().length>y,r,`tab ${p} to exist (${b().length} open: ${b().map(u=>u.url()).join(", ")})`);let S=b()[y];return await S.bringToFront(),S}case"closeTab":{let y=e.context().pages().filter(u=>!u.isClosed()),b=p===void 0?e:y[Number.parseInt(p,10)];if(!b)throw new Error(`tab ${p} does not exist (${y.length} open)`);await b.close();let S=e.context().pages().filter(u=>!u.isClosed());if(S.length===0)throw new Error("closed the last tab; nothing left to drive");return S[S.length-1]}case"newTab":{let y=await e.context().newPage();if(d){let b=/^https?:\/\//.test(d)?d:new URL(d,o).toString();await y.goto(b,h)}return y}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let y=new RegExp(l(t.urlPattern)),b=t.pattern?new RegExp(l(t.pattern)):void 0;if(!i){await e.waitForResponse(u=>sn({seq:0,url:u.url(),method:u.request().method(),status:u.status()},y,t.method,t.status,void 0),h);return}let S=()=>t.action==="expectResponse"?i.all():i.sincePreviousStep();return c(async()=>S().some(u=>sn(u,y,t.method,t.status,b)),r,ns(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}function an(e,t){return e.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(t)))}function ut(e,t){return t.steps.length!==e.steps.length?!1:t.steps.every((n,r)=>{let o=e.steps[r];if(n.gherkin!==o.text||n.kind!==o.kind)return!1;let s=n.table,i=o.table;if(!!s!=!!i)return!1;if(s&&i){let a=Object.keys(V(s)),c=Object.keys(V(i));if(a.length>0&&c.length>0){if(JSON.stringify(a)!==JSON.stringify(c))return!1}else if(JSON.stringify(s)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(o.docString!==void 0))return!1;if(n.docString!==void 0&&o.docString!==void 0&&n.docString!==o.docString){let a=t.steps.some(l=>an(l,"<docstring>")),c=t.steps.some(l=>an(l,n.docString));if(!a||c)return!1}return!0})}function gt(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...ge(e.steps),...V(r),...le(n?.resolvedDocString??n?.docString)}}async function Te(e,t,n,r={}){let o=r.actionTimeoutMs??5e3,s=r.retries??1,i=Math.max(10,r.pollIntervalMs??cn),a=[],c=r.vars??new Map,l=()=>Object.fromEntries(c),p=n.steps.some(u=>u.actions.some(m=>(m.action==="waitForResponse"||m.action==="expectResponse")&&m.pattern!==void 0)),d=new dt;d.attach(e.context(),p);let f=e,g=new Set(e.context().pages()),h=[],x=u=>h.push(u);e.context().on("page",x);let k=[],b=!n.steps.some(u=>u.actions.some(m=>m.opensTab))&&n.steps.some(u=>u.actions.some(m=>m.action==="switchTab"||m.action==="closeTab"||m.action==="newTab")),S=()=>{let u=e.context().pages();for(let m of u)g.has(m)||(g.add(m),m.isClosed()||(f=m));if(f.isClosed()){let m=u.filter(T=>!T.isClosed());m.length>0&&(f=m[m.length-1])}};if(!ut(t,n))return d.detach(),{status:"stale",stepResults:a,captured:l()};try{for(let u=0;u<n.steps.length;u++){let m=n.steps[u],T=gt(t,u);d.markStep();let I=!1,v=0;if(m.kind==="assertion"&&!m.actions.some($=>Se.has($.action))){let $=`Assertion step "${m.gherkin}" recorded ${m.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;a.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"failed",drift:I,error:$});for(let w=u+1;w<n.steps.length;w++)a.push({gherkin:n.steps[w].gherkin,kind:n.steps[w].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:0,stepText:m.gherkin,kind:m.kind,error:$},stepResults:a,captured:l()}}for(let $=0;$<m.actions.length;$++){let w=m.actions[$];S(),w.pageFingerprint&&await de(f)!==w.pageFingerprint&&(I=!0);let A,F=!1;for(let D=0;D<=s&&!F;D++){D>0&&(v++,await pt(250*D),S());try{let R=await ft(f,w,T,o,r.baseURL,c,d,i);R&&(f=R,g.add(R));let q=w.action==="click"||w.action==="press";if(h.length===0&&(w.opensTab||q&&b)&&await e.context().waitForEvent("page",{timeout:w.opensTab?o:150}).catch(()=>{}),w.opensTab&&h.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for(h.length>0&&k.push({stepIndex:u,actionIndex:$});h.length>0;)await h.shift().waitForLoadState("domcontentloaded",{timeout:o}).catch(()=>{});F=!0}catch(R){A=R}}if(!F){let D=A instanceof Error?A.message:String(A);a.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"failed",drift:I,...v?{retries:v}:{},error:D});for(let R=u+1;R<n.steps.length;R++)a.push({gherkin:n.steps[R].gherkin,kind:n.steps[R].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:$,stepText:m.gherkin,kind:m.kind,error:D},stepResults:a,captured:l(),tabOpeners:k}}}a.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"passed",drift:I,...v?{retries:v}:{}})}return{status:"passed",stepResults:a,captured:l(),tabOpeners:k}}finally{e.context().off("page",x),d.detach()}}import ee from"node:fs";import Ne from"node:path";function ln(e,t){ee.mkdirSync(Ne.dirname(e),{recursive:!0}),ee.writeFileSync(e,`${JSON.stringify(t,null,2)}
10
- `)}function pe(e){return JSON.parse(ee.readFileSync(e,"utf8"))}function ae(e){let t=Ne.join(e,".saffron","proposals");if(!ee.existsSync(t))return[];let n=[];for(let r of ee.readdirSync(t)){let o=Ne.join(t,r);if(ee.statSync(o).isDirectory())for(let s of ee.readdirSync(o)){if(!s.endsWith(".json"))continue;let i=Ne.join(o,s);n.push({file:i,proposal:pe(i)})}}return n}function dn(e,t){let n=pe(t),r=M(e,n.cache.feature,n.cache.scenario);return he(r,n.cache),ee.rmSync(t),r}function pn(e){ee.rmSync(e)}import Le from"node:fs";import fn from"node:path";function un(e){return fn.join(e,".saffron","history.jsonl")}function gn(e,t,n,r){let o=e.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,verified:s.verified,seededSteps:s.seededSteps,adaptations:s.adaptations.length,driftSteps:s.stepResults.filter(i=>i.drift).length,aiCalls:s.usage.aiCalls,tokensInOut:s.usage.inputTokens+s.usage.outputTokens,cacheRead:s.usage.cacheReadTokens,cacheWrite:s.usage.cacheCreationTokens,costUsd:s.usage.costUsd??0,error:s.error?.split(`
11
- `)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:o.filter(s=>s.status==="green").length,yellow:o.filter(s=>s.status==="yellow").length,red:o.filter(s=>s.status==="red").length,aiCalls:o.reduce((s,i)=>s+i.aiCalls,0),costUsd:o.reduce((s,i)=>s+i.costUsd,0),divergentSteps:n,scenarios:o}}function hn(e,t){let n=un(e);Le.mkdirSync(fn.dirname(n),{recursive:!0}),Le.appendFileSync(n,`${JSON.stringify(t)}
12
- `)}function Ue(e,t=50){let n=un(e);if(!Le.existsSync(n))return[];let r=Le.readFileSync(n,"utf8").trim().split(`
13
- `).filter(Boolean),o=[];for(let s of r.slice(-t))try{o.push(JSON.parse(s))}catch{}return o}var ht=e=>{let t=e.green+e.yellow+e.red;return t===0?0:Math.round((e.green+e.yellow)/t*100)};function mn(e,t){let n=[...e,t],r=n.slice(-20),o=n.slice(-10),s=e.at(-1),i=s?{passRatePp:ht(t)-ht(s),costUsd:t.costUsd-s.costUsd,aiCalls:t.aiCalls-s.aiCalls,red:t.red-s.red}:void 0,a=new Map;for(let d of o)for(let f of d.scenarios){let g=`${f.feature}::${f.scenario}`,h=a.get(g)??{feature:f.feature,scenario:f.scenario,heals:0,reds:0,runs:0};h.runs++,f.source==="agent-heal"&&f.status==="yellow"&&h.heals++,f.status==="red"&&h.reds++,a.set(g,h)}let c=[...a.values()].filter(d=>d.heals>=2||d.reds>=3).sort((d,f)=>f.heals+f.reds-(d.heals+d.reds)),l=new Map;for(let d of r)for(let f of d.scenarios)f.status==="red"&&f.error&&l.set(f.error,(l.get(f.error)??0)+1);let p=[...l.entries()].sort((d,f)=>f[1]-d[1]).slice(0,3).map(([d,f])=>({message:d,count:f}));return{prev:i,series:{passRate:r.map(ht),costUsd:r.map(d=>Number(d.costUsd.toFixed(4))),aiCalls:r.map(d=>d.aiCalls)},chronic:c,divergentSteps:t.divergentSteps,topIssues:p}}var os=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,is=/^\s*StepSet:/;function mt(e,t){return ne.relative(e,t).split(ne.sep).join("/")}function as(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function Sn(e,t){return`${e} :: ${t}`}function Be(e,t,n={}){let r=t.features??"features",o=ne.isAbsolute(r)?r:ne.join(e,r),s=ne.join(e,".saffron","reports","latest.json"),i=ne.join(e,".saffron","reports","latest.html"),a;try{a=JSON.parse(te.readFileSync(s,"utf8"))}catch{a=void 0}let c=new Map;for(let u of a?.scenarios??[]){let m=u.scenario.replace(/ \(example \d+\)$/,""),T=Sn(u.feature,m);c.set(T,as(c.get(T),u.status))}let l=new Map,p;try{l=J(se(o),e)}catch(u){p=u instanceof Error?u.message:String(u)}let d=te.existsSync(o)?[...te.readdirSync(o,{recursive:!0})].map(String).filter(Q).map(u=>ne.join(o,u)).sort():[],f=[],g=new Map,h=new Set;for(let u of d){let m=mt(e,u),T="";try{T=te.readFileSync(u,"utf8")}catch(R){f.push({path:m,name:m,scenarios:[],stepSets:0,error:String(R)});continue}let I=T.split(`
14
- `),v=new Map,$=0;I.forEach((R,q)=>{let H=R.match(os);H&&!v.has(H[1])?v.set(H[1],q+1):is.test(R)&&$++});let w=I.find(R=>/^\s*Feature:/.test(R)),A=w?w.replace(/^\s*Feature:\s*/,"").trim():m,F;try{F=z(u,e,l)}catch(R){f.push({path:m,name:A,scenarios:[],stepSets:$,error:p??(R instanceof Error?R.message:String(R))});continue}let D=new Map;for(let R of F){let q=D.get(R.name);if(q){q.rows+=1;continue}let H=M(e,R.featurePath,R.name);h.add(H),h.add(oe(e,R.featurePath,R.name));let Fe="missing";if(te.existsSync(H))try{let ve=N(H);Fe=ve&&ut(R,ve)?"valid":"stale"}catch{Fe="invalid"}D.set(R.name,{name:R.name,line:v.get(R.name),tags:[...R.tags],outline:Object.keys(R.params).length>0,rows:1,cached:Fe!=="missing",cacheState:Fe,proposal:te.existsSync(oe(e,R.featurePath,R.name)),lastStatus:c.get(Sn(R.featurePath,R.name))});for(let ve of R.tags)g.set(ve,(g.get(ve)??0)+1)}f.push({path:m,name:A,scenarios:[...D.values()],stepSets:$})}let x=ae(e).map(({file:u,proposal:m})=>({file:mt(e,u),feature:m.cache.feature,scenario:m.cache.scenario,mode:m.meta.mode,createdAt:m.meta.createdAt,verified:m.meta.verified,proofError:m.meta.proofError,adaptations:m.meta.adaptations??[],narrative:m.meta.narrative,suggestedFeatureEdit:m.meta.suggestedFeatureEdit,recordedFor:m.meta.recordedFor,aiCalls:m.meta.usage?.aiCalls??0,costUsd:m.meta.usage?.costUsd})).sort((u,m)=>u.file.localeCompare(m.file)),k;try{k=G(e,r,l)}catch{k={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let y=ls(e),b=Ue(e,n.historyLimit??30).map(u=>({startedAt:u.startedAt,green:u.green,yellow:u.yellow,red:u.red,aiCalls:u.aiCalls,costUsd:u.costUsd})),S=ne.join(e,"saffron.config.json");return{tool:"saffron",version:ie(),projectRoot:e,packageInstalled:te.existsSync(ne.join(e,"node_modules","saffron-ai")),config:{file:te.existsSync(S)?"saffron.config.json":void 0,effective:Qt(t)},features:f,tags:[...g.entries()].map(([u,m])=>({tag:u,scenarios:m})).sort((u,m)=>u.tag.localeCompare(m.tag)),proposals:x,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:a.totals,reportHtml:te.existsSync(i)?mt(e,i):void 0}:void 0,history:b,vocabulary:{steps:k.steps.length,recorded:k.steps.filter(u=>u.status==="recorded").length,unrecorded:k.steps.filter(u=>u.status==="unrecorded").length,stepSets:k.stepSets.length,divergent:k.steps.filter(u=>u.status==="divergent").map(u=>u.text),duplicateWordings:y.slice(0,50)},orphans:f.some(u=>u.error)?[]:at(e,h).orphans}}function bn(e){let t=e.features.reduce((s,i)=>s+i.scenarios.length,0),n=e.features.reduce((s,i)=>s+i.scenarios.filter(a=>a.cacheState==="valid").length,0),r=e.features.reduce((s,i)=>s+i.scenarios.filter(a=>a.cacheState==="stale"||a.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let s of e.features){let i=s.scenarios.map(a=>a.proposal?"\u25D0":a.cacheState==="valid"?"\u25CF":a.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${s.path} ${i}${s.error?` \u2717 ${s.error.split(`
15
- `)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(s=>`${s.tag} (${s.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(s=>s.file).join(", ")}`:"no proposals pending review"),e.lastRun){let s=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${s.green} passed \xB7 ${s.yellow} pending review \xB7 ${s.red} failed \xB7 $${s.costUsd.toFixed(2)}`)}let o=e.vocabulary;console.log(`vocabulary: ${o.steps} steps (${o.recorded} recorded, ${o.unrecorded} unrecorded), ${o.stepSets} step sets, ${o.divergent.length} divergent, ${o.duplicateWordings.length} duplicate wording group(s)`),e.orphans.length&&console.log(`${e.orphans.length} recording(s) belong to no scenario: run \`saffron prune\` to see them`),console.log(`config: ${e.config.file??"(defaults, no saffron.config.json)"}`)}var cs=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function yn(e){return e?e.role?`${e.role}${e.name?` "${e.name}"`:e.nameRegex?` /${e.nameRegex}/`:""}`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testid ${e.testId}`:e.placeholder?`placeholder "${e.placeholder}"`:e.fallbackSelectors?.length?e.fallbackSelectors[0]:"":""}function ls(e){let t;try{t=X(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let o=Ie(r.gherkin).pattern,s=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(d=>d[1]),i=d=>d!==void 0&&(s.includes(d)||cs.test(d))?"\xABarg\xBB":d,a=r.actions.map(d=>{let{pageFingerprint:f,value:g,promptText:h,...x}=d;return{...x,value:i(g),promptText:i(h)}}),c=JSON.stringify(a),l=r.actions.map(d=>`${d.action}${yn(d.target)?` ${yn(d.target)}`:d.url?` ${d.url}`:""}`).join(" \xB7 "),p=n.get(c)??{steps:new Map,actions:l};p.steps.has(o)||p.steps.set(o,r.gherkin),n.set(c,p)}return[...n.values()].filter(r=>r.steps.size>1).map(r=>({steps:[...r.steps.values()].sort(),actions:r.actions})).sort((r,o)=>o.steps.length-r.steps.length||r.steps[0].localeCompare(o.steps[0]))}async function xn(e,t){let n=new ds({name:"saffron",version:ie()},{capabilities:{tools:{}}});n.setRequestHandler(us,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: 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:{}}},{name:"project_status",description:"The project overview (same data as `saffron status --json`): every feature file with its scenarios (header line, tags, outline rows, cached / proposal pending / last run status), the tag list, pending proposals with narrative and proof-replay verdict, the last run's totals, run history, vocabulary health (divergent steps, duplicate wordings proven by identical recordings) and the effective config. Call it before editing tests to see what is already recorded and what awaits review.",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(fs,async r=>gs(r.params.name,r.params.arguments,e,t)),await n.connect(new ps)}function gs(e,t,n,r){let o=s=>({content:[{type:"text",text:JSON.stringify(s,null,2)}]});if(e==="search_steps"){let s=G(n,r),i=typeof t?.query=="string"?t.query:void 0,a=i?Oe(s,i):s;return o({steps:a.steps,scannedFiles:a.scannedFiles,scannedCaches:a.scannedCaches})}if(e==="list_step_sets"){let s=G(n,r);return o({stepSets:s.stepSets})}return e==="project_status"?o(Be(n,W(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as hs}from"@anthropic-ai/claude-agent-sdk";function ms(e,t){let n=t.steps.map(o=>`- [${o.status}] ${o.keyword} ${o.text}`).join(`
2
+ import B from"node:fs";import P from"node:path";import{spawn as $o}from"node:child_process";import To from"node:readline";import{Command as Ro}from"commander";import Nr from"node:fs";import Lr from"node:path";import{AstBuilder as Ur,GherkinClassicTokenMatcher as Br,Parser as zr}from"@cucumber/gherkin";import{IdGenerator as Vr,StepKeywordType as ct}from"@cucumber/messages";function pe(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function N(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(o=>!o))return{};r.forEach((o,s)=>{n.forEach((i,a)=>{o[a]!==void 0&&(t[`table:${s+1}:${i}`]=o[a])})})}return t}function fe(e){return e!==void 0?{docstring:e}:{}}function se(e){let t=new Map;for(let r of e){let o={...N(r.resolvedTable??r.table),...fe(r.resolvedDocString??r.docString)};for(let[s,i]of Object.entries(o)){let a=t.get(s);a||t.set(s,a=new Set),a.add(i)}}let n={};for(let[r,o]of t)o.size===1&&(n[r]=[...o][0]);return n}import it from"node:fs";import Mt from"node:path";import{AstBuilder as Cr,GherkinClassicTokenMatcher as _r,Parser as Fr}from"@cucumber/gherkin";import{IdGenerator as jr}from"@cucumber/messages";var z=class extends Error{},Ir=/^(\s*)StepSet:\s*(.+?)\s*$/,Or=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function at(e){let t=e.split(`
3
+ `),n=new Map,r=new Map;return{source:t.map((s,i)=>{let a=s.match(Ir);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let c=s.match(Or);return c?(r.set(i+1,c[2]),`${c[1]}* StepSet ${c[2]}`):s}).join(`
4
+ `),definitions:n,invocations:r}}function Dr(e){return new Fr(new Cr(jr.uuid()),new _r).parse(e)}function Nt(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&Nt(n.rule.children,t)}function J(e,t){let n=new Map;for(let r of e){let o=Mt.relative(t,r),s=at(it.readFileSync(r,"utf8"));if(s.definitions.size===0)continue;let i=Dr(s.source);i.feature&&Nt(i.feature.children,a=>{let c=s.definitions.get(a.location.line);if(c===void 0)return;let d=n.get(c);if(d)throw new z(`StepSet "${c}" is defined twice: ${d.file}:${d.line} and ${o}:${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 ${c}".`);if(a.examples.length>0)throw new z(`StepSet "${c}" (${o}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let f of a.steps)if(s.invocations.has(f.location.line))throw new z(`StepSet "${c}" (${o}:${a.location.line}) invokes another step set at line ${f.location.line} \u2014 step sets cannot be nested.`);n.set(c,{name:c,file:o,line:a.location.line,steps:[...a.steps]})})}return n}var Mr=[".feature",".saffron"];function Q(e){return Mr.some(t=>e.endsWith(t))}function oe(e){if(!it.existsSync(e))return[];let t=[];for(let n of it.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(Mt.join(e,r))}return t.sort()}function Lt(e,t,n){return e.map(({step:r,source:o})=>{let s;switch(r.keywordType){case ct.OUTCOME:s="assertion";break;case ct.CONTEXT:case ct.ACTION:s="action";break;default:s=n.value}n.value=s;let i=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(d=>d.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:s,text:r.text,resolvedText:pe(r.text,t),table:i,resolvedTable:i?.map(c=>c.map(d=>pe(d,t))),docString:a,resolvedDocString:a!==void 0?pe(a,t):void 0,source:o}})}function Ut(e,t,n,r,o){let s=[];for(let i of e){let a=r.get(i.location.line);if(a===void 0){s.push({step:i,source:{file:t,line:i.location.line,fromBackground:n}});continue}let c=o?.get(a);if(!c)throw new z(`${t}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let d of c.steps)s.push({step:d,source:{file:c.file,line:d.location.line,fromStepSet:c.name,fromBackground:n}})}return s}function Wr(e,t,n,r,o){let s=e.tags.map(a=>a.name),i=[];if(e.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let c of e.examples){let d=c.tableHeader?.cells.map(f=>f.value)??[];for(let f of c.tableBody){a+=1;let l={};f.cells.forEach((h,u)=>{l[d[u]]=h.value}),i.push({params:l,label:` (example ${a})`})}}}return i.map(({params:a,label:c})=>{let d={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${c}`,tags:s,steps:[...Lt(r,a,d),...Lt(o,a,d)],params:a}})}function W(e,t,n){let r=Nr.readFileSync(e,"utf8"),s=e.endsWith(".saffron")?at(r):{source:r,definitions:new Map,invocations:new Map},a=new zr(new Ur(Vr.uuid()),new Br).parse(s.source);if(!a.feature)return[];let c=Lr.relative(t,e),d=a.feature.name,f=[],l=(h,u)=>{let p=u;for(let m of h)if(m.background)p=[...u,...Ut(m.background.steps,c,!0,s.invocations,n)];else if(m.scenario){if(s.definitions.has(m.scenario.location.line))continue;f.push(...Wr(m.scenario,c,d,p,Ut(m.scenario.steps,c,!1,s.invocations,n)))}else"rule"in m&&m.rule&&l(m.rule.children,p)};return l(a.feature.children,[]),f}import Kt from"node:fs";import dt from"node:path";import Wt from"node:fs";import qt from"node:path";import{createHash as qr}from"node:crypto";import De from"node:fs";import ke from"node:path";var Bt=".saffron";function lt(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function zt(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),o=lt([...r,ke.basename(n,".feature")].join("-"));if(r.length===0)return o;let s=qr("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${o}-${s}`}function L(e,t,n){return ke.join(e,Bt,"cache",zt(t),`${lt(n)}.json`)}function Vt(e,t){let n=new Map;for(let r of t){let o=L(e,r.featurePath,r.name),s=n.get(o)??new Set;s.add(`${r.featurePath} \u203A ${r.name}`),n.set(o,s)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,o])=>`${[...o].join(" and ")} both map to ${ke.relative(e,r)}. Rename one scenario or one file.`)}function ie(e,t,n){return ke.join(e,Bt,"proposals",zt(t),`${lt(n)}.json`)}function U(e){if(!De.existsSync(e))return;let t=JSON.parse(De.readFileSync(e,"utf8"));if(t.version!==1)throw new Error(`Unsupported cache version ${t.version} in ${e}`);return t}function ye(e,t){De.mkdirSync(ke.dirname(e),{recursive:!0}),De.writeFileSync(e,`${JSON.stringify(t,null,2)}
5
+ `)}function Me(e,t){let n=r=>typeof r=="string"?r.includes(t):Array.isArray(r)?r.some(n):r&&typeof r=="object"?Object.entries(r).some(([o,s])=>o!=="pageFingerprint"&&n(s)):!1;return e.some(n)}function $e(e,t){if(t.trim()==="")return!1;let n=r=>(r??"").replace(/<[^<>]+>/g,"");return e.some(r=>[r.value,r.url,r.urlPattern,r.pattern,r.promptText,r.compareTo,...[r.target,r.toTarget].flatMap(o=>[o?.name,o?.nameRegex,o?.label,o?.text,o?.placeholder,o?.testId,o?.frame,...o?.fallbackSelectors??[]])].some(o=>n(o).includes(t)))}function Ne(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,o)=>(t.push(o),`"\xAB${t.length-1}\xBB"`)),args:t}}function Hr(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:o,...s})=>s));return n(e)===n(t)}function Jr(e){let t=new Set,n=r=>{if(r)for(let o of r.matchAll(/<([^<>]+)>/g))t.add(o[1])};for(let r of e.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(e.gherkin),t}function X(e){let t=qt.join(e,".saffron","cache"),n=new Map,r=new Map,o=0;if(Wt.existsSync(t))for(let a of Wt.readdirSync(t,{recursive:!0})){let c=String(a);if(!c.endsWith(".json"))continue;let d=U(qt.join(t,c));if(d){o++;for(let f of d.steps){let l=f.gherkin,h=JSON.stringify(f.actions.map(({pageFingerprint:m,...k})=>k)),u=r.get(l)??[];u.includes(h)||(u.push(h),r.set(l,u));let p=n.get(l);!p||d.recordedAt>p.recordedAt?n.set(l,{gherkin:f.gherkin,table:f.table,docString:f.docString,actions:f.actions,sourceParams:se(d.steps),feature:d.feature,scenario:d.scenario,recordedAt:d.recordedAt,variants:u.length}):p.variants=u.length}}}let s=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:c,args:d}=Ne(a.gherkin);if(d.length===0)continue;let f=i.get(c);(!f||a.recordedAt>f.recordedAt)&&i.set(c,{...a,args:d})}return{steps:n,patterns:i,divergent:s,scannedCaches:o}}function Ht(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let s=Object.keys(N(e.table)),i=Object.keys(N(t.table));if(s.length>0&&i.length>0){if(JSON.stringify(s)!==JSON.stringify(i))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(s=>[s.value,s.url,s.pattern].some(i=>i?.includes("<docstring>"))))return!1;if(e.table&&t.table){let s=N(e.table),i=N(t.table);for(let a of Object.keys(s))if(s[a]!==i[a]&&(!Me(e.actions,`<${a}>`)||$e(e.actions,s[a])))return!1}for(let[s,i]of Object.entries(e.sourceParams??{}))if(s in n&&n[s]!==i&&$e(e.actions,i))return!1;let r={...n,...N(t.resolvedTable??t.table),...fe(t.resolvedDocString??t.docString)},o=Jr({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let s of o)if(!(s in r))return!1;return!0}function Jt(e,t,n){let r=e.steps.get(t.text);if(r)return Ht(r,t,n)?r:void 0;let{pattern:o,args:s}=Ne(t.text);if(s.length===0)return;let i=e.patterns.get(o);if(!i)return;let a=new Map;for(let l=0;l<s.length;l++){let h=i.args[l],u=s[l];if(h===u)continue;let p=a.get(h);if(p!==void 0&&p!==u)return;a.set(h,u)}let c=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(u=>[u.value,u.url,u.pattern,...c(u.target)].includes(l)))return;let d=l=>l!==void 0&&a.has(l)?a.get(l):l,f={...i,gherkin:t.text,actions:i.actions.map(({pageFingerprint:l,...h})=>({...h,value:d(h.value),url:d(h.url),pattern:d(h.pattern),target:h.target?{...h.target,name:d(h.target.name),nameRegex:d(h.target.nameRegex),label:d(h.target.label),text:d(h.target.text),placeholder:d(h.target.placeholder)}:void 0}))};return Ht(f,t,n)?f:void 0}function Gt(e,t){let n=[];return e.forEach((r,o)=>{if(r.actions.length!==0&&!t.steps.has(r.gherkin)){for(let s of t.steps.values())if(!(s.variants>1)&&s.actions.length!==0&&Hr(r.actions,s.actions)){n.push({index:o,from:r.gherkin,to:s.gherkin,feature:s.feature,scenario:s.scenario});break}}}),n}function Gr(e){if(!Kt.existsSync(e))return[];let t=[];for(let n of Kt.readdirSync(e,{recursive:!0})){let r=String(n);Q(r)&&t.push(dt.join(e,r))}return t.sort()}function G(e,t,n,r){let o=dt.isAbsolute(t)?t:dt.join(e,t),s=Gr(o),i=n??J(s.filter(u=>u.endsWith(".saffron")),e),a=new Map,c=new Map;for(let u of s)for(let p of r?.get(u)??W(u,e,i)){let m=`${p.featurePath}::${p.name}`;for(let k of p.steps){let y=a.get(k.text);if(y||(y={text:k.text,keywords:new Map,kind:k.kind,scenarios:new Set,files:new Set,fromStepSet:k.source?.fromStepSet},a.set(k.text,y)),y.keywords.set(k.keyword,(y.keywords.get(k.keyword)??0)+1),y.scenarios.add(m),k.source&&y.files.add(k.source.file),k.source?.fromStepSet){let b=c.get(k.source.fromStepSet);b||c.set(k.source.fromStepSet,b=new Set),b.add(m)}}}let d=X(e),f=new Set(d.divergent),l=[...a.values()].map(u=>({text:u.text,keyword:[...u.keywords.entries()].sort((p,m)=>m[1]-p[1])[0][0],kind:u.kind,status:f.has(u.text)?"divergent":d.steps.has(u.text)?"recorded":"unrecorded",usage:u.scenarios.size,files:[...u.files].sort(),fromStepSet:u.fromStepSet})).sort((u,p)=>p.usage-u.usage||u.text.localeCompare(p.text)),h=[...i.values()].map(u=>({name:u.name,file:u.file,line:u.line,steps:u.steps.map(p=>p.text),usage:c.get(u.name)?.size??0})).sort((u,p)=>p.usage-u.usage||u.name.localeCompare(p.name));return{steps:l,stepSets:h,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function Le(e,t){let n=t.toLowerCase();return{...e,steps:e.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:e.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(o=>o.toLowerCase().includes(n)))}}function Yt(e){let t={},n="saffron,feature,gherkin,cucumber";for(let r of e.steps)t[`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 e.stepSets)t[`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(t,null,2)+`
6
+ `}import{Server as hs}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as ms}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as ys,ListToolsRequestSchema as Ss}from"@modelcontextprotocol/sdk/types.js";import te from"node:fs";import ne from"node:path";import en from"node:fs";import Yr from"node:path";import Qt from"node:fs";import Kr from"node:path";var Xt=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function Te(e){return e.replace(Xt,(t,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 pt(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Xt))t.add(r[1]);return t}function ft(e){let t=[];for(let n of e.steps){t.push(n.text,n.resolvedText),n.docString&&t.push(n.docString),n.resolvedDocString&&t.push(n.resolvedDocString);for(let r of n.table??[])t.push(...r);for(let r of n.resolvedTable??[])t.push(...r)}return t}function Re(e){let t=new Map;for(let n of pt(ft(e))){let r=process.env[n];r!==void 0&&t.set(n,r)}return t}function Se(e,t){let n=e;for(let[r,o]of[...t.entries()].sort((s,i)=>i[1].length-s[1].length))o.length<4||(n=n.split(o).join(`{env:${r}}`));return n}function Zt(e){let t=Kr.join(e,".env");if(Qt.existsSync(t))for(let n of Qt.readFileSync(t,"utf8").split(`
7
+ `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let o=r.indexOf("=");if(o<=0)continue;let s=r.slice(0,o).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s))continue;let i=r.slice(o+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[s]===void 0&&(process.env[s]=i)}}function H(e){Zt(e);let t=Yr.join(e,"saffron.config.json");return en.existsSync(t)?JSON.parse(en.readFileSync(t,"utf8")):{}}var Qr={features:"features",actionTimeoutMs:5e3,pollIntervalMs:100,retries:1,maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict"};function tn(e){return{...Qr,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import ut from"node:fs";import Ue from"node:path";import{fileURLToPath as Xr}from"node:url";function Be(){let e=Ue.dirname(Xr(import.meta.url));for(let t=0;t<6;t++){let n=Ue.join(e,"package.json");if(ut.existsSync(n))try{if(JSON.parse(ut.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=Ue.dirname(e)}}function ae(){let e=Be();if(!e)return"unknown";try{return JSON.parse(ut.readFileSync(Ue.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import K from"node:fs";import q from"node:path";var nn=(e,t)=>q.relative(e,t).split(q.sep).join("/");function rn(e){return K.existsSync(e)?[...K.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>q.join(e,t)).sort():[]}function sn(e){try{let t=JSON.parse(K.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function ze(e,t,n){let r=q.isAbsolute(t)?t:q.join(e,t),o=new Set(K.existsSync(r)?[...K.readdirSync(r,{recursive:!0})].map(String).filter(Q).map(d=>q.join(r,d)):[]);for(let d of["cache","proposals"])for(let f of rn(q.join(e,".saffron",d))){let{feature:l}=sn(f);if(typeof l=="string"){let h=q.resolve(e,l);K.existsSync(h)&&o.add(h)}}let s=[...n?.unreadable??[]],i=[...o].filter(d=>!n?.parsedFiles.has(d)),a=new Map;if(i.length>0)try{a=J([...new Set([...oe(r),...o].filter(d=>d.endsWith(".saffron")))],e)}catch(d){s.push(d instanceof Error?d.message:String(d))}let c=new Set(n?.owned??[]);for(let d of i){let f;try{f=W(d,e,a)}catch(l){s.push(`${nn(e,d)}: ${l instanceof Error?l.message:String(l)}`);continue}for(let l of f)c.add(L(e,l.featurePath,l.name)),c.add(ie(e,l.featurePath,l.name))}return{...Zr(e,c),unreadable:s}}function Zr(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let s=q.join(e,".saffron",o==="cache"?"cache":"proposals");for(let i of rn(s)){if(t.has(i)){r++;continue}let{feature:a,scenario:c}=sn(i),d=a?q.join(e,a):void 0;n.push({file:nn(e,i),kind:o,feature:a,scenario:c,reason:a?K.existsSync(d)?"scenario-gone":"feature-gone":"unknown"})}}return{orphans:n,kept:r}}function on(e,t){let n=[],r=new Set;for(let o of t){let s=q.join(e,o.file);try{K.rmSync(s),n.push(o.file),r.add(q.dirname(s))}catch{}}for(let o of r)try{K.readdirSync(o).length===0&&K.rmdirSync(o)}catch{}return n}function an(e){let t=e.feature&&e.scenario?`${e.feature} \u203A ${e.scenario}`:"an unreadable recording",n=e.reason==="feature-gone"?"the feature file is gone":e.reason==="scenario-gone"?"the scenario is gone from that file":"it names no scenario";return`${e.file}
8
+ ${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import ln from"node:path";var Z=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse"]);var es=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function ts(e,t){let n=String(e.getFullYear()),r=String(e.getMonth()+1).padStart(2,"0"),o=String(e.getDate()).padStart(2,"0");return t.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",o).replaceAll("M",String(e.getMonth()+1)).replaceAll("D",String(e.getDate()))}function gt(e,t=new Date){return e.replace(es,(n,r,o)=>{let s=new Date(t);return s.setDate(s.getDate()+(r?parseInt(r,10):0)),ts(s,o??"YYYY-MM-DD")})}var ns=/\b(\d{4})-(\d{2})-(\d{2})\b/g,rs=400;function cn(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(ns,(r,o,s,i)=>{let a=new Date(Number(o),Number(s)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let c=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(c)>rs?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as ss}from"node:crypto";async function ue(e){let t;try{t=await e.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=t.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${ss("sha256").update(n).digest("hex")}`}var St=e=>new Promise(t=>setTimeout(t,e)),yt=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let o={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(o),n){let s=r.headers()["content-type"]??"";/json|text|xml/i.test(s)&&r.text().then(i=>{o.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},t.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(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function os(e,t){if(!t)return e>=200&&e<300;let n=/^([1-5])xx$/i.exec(t.trim());if(n){let r=Number(n[1])*100;return e>=r&&e<r+100}return e===Number(t.trim())}function dn(e,t,n,r,o){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!os(e.status,r)||o&&!o.test(e.body??""))}var is=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,as=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]);function pn(e,t,n,r=!1,o=!0){let s=c=>Te(gt(pe(c,n))),i=[],a=c=>{if(t.role){let d=t.nameRegex?new RegExp(s(t.nameRegex),"i"):t.name?s(t.name):void 0,f=t.role;typeof d=="string"?(i.push(c.getByRole(f,{name:d,exact:!0})),o&&i.push(c.getByRole(f,{name:d,exact:!1}))):i.push(c.getByRole(f,{name:d,exact:!1}))}t.label&&i.push(c.getByLabel(s(t.label))),t.placeholder&&i.push(c.getByPlaceholder(s(t.placeholder))),t.text&&i.push(c.getByText(s(t.text))),t.testId&&i.push(c.getByTestId(s(t.testId)));for(let d of t.fallbackSelectors??[])i.push(c.locator(d))};if(t.frame)a(e.frameLocator(s(t.frame)));else if(a(e),r)for(let c of e.frames())c!==e.mainFrame()&&a(c);if(i.length===0)throw new Error("Action target has neither role nor fallback selectors");return i}var fn=100;async function cs(e,t,n,r){let o=Date.now()+t,s;for(;Date.now()<o;){try{if(await e())return}catch(i){s=i}await St(r)}throw new Error(`Timed out waiting for ${n}${s?` (${String(s)})`:""}`)}async function Y(e,t,n=!1){let r;for(let o of e)try{await t(o.first());return}catch(s){if(r=s,n&&await o.first().count().catch(()=>0)>0)throw s}throw r}var ht=e=>e.replace(/\s+/g," ").trim();async function bt(e,t,n,r,o,s=new Map,i,a=fn){let c=(y,b,S)=>cs(y,b,S,a),d=y=>Te(gt(pe(y,n))),f=t.value!==void 0?d(t.value):void 0,l=t.url!==void 0?d(t.url):void 0,h=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="captureText",u=t.target?pn(e,t.target,n,h,!as.has(t.action)):[],p={timeout:r},m={timeout:Math.min(1e3,r)},k=y=>async()=>{for(let b of u)try{if(await y(b.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!l)throw new Error("goto requires url");let y=/^https?:\/\//.test(l)?l:new URL(l,o).toString();await e.goto(y,p);return}case"click":return Y(u,y=>y.click(p),!0);case"fill":return Y(u,y=>y.fill(f??"",p),!0);case"press":return u.length>0?Y(u,y=>y.press(f??"",p),!0):e.keyboard.press(f??"");case"selectOption":return Y(u,async y=>{await y.selectOption(f??"",p)},!0);case"check":return Y(u,y=>y.check(p),!0);case"uncheck":return Y(u,y=>y.uncheck(p),!0);case"hover":return Y(u,y=>y.hover(p),!0);case"waitFor":return u.length>0?Y(u,y=>y.waitFor({state:"visible",timeout:r})):St(Math.min(Number(f??"0"),r));case"expectVisible":return c(k(y=>y.isVisible()),r,"target to be visible");case"expectNotVisible":return c(async()=>{for(let y of u){let b=await y.count();for(let S=0;S<b;S++)if(await y.nth(S).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${u.length} locator(s))`);case"expectText":return c(k(async y=>(await y.textContent(m)??"").includes(f??"")),r,`text "${f}"`);case"expectValue":return c(k(async y=>await y.inputValue(m)===(f??"")),r,`input value "${f}"`);case"expectUrl":{let y=l??f??"";return c(async()=>e.url().includes(y),r,`URL containing "${y}" on the active page (${e.url()})`)}case"handleDialog":{let y=(f??"accept")!=="dismiss",b=t.promptText?d(t.promptText):void 0;e.once("dialog",S=>{y?S.accept(b):S.dismiss()});return}case"uploadFile":{let y=(f??"").split(`
9
+ `).filter(Boolean).map(b=>ln.isAbsolute(b)?b:ln.resolve(b));if(y.length===0)throw new Error("uploadFile requires a path");if(u.length>0)return Y(u,b=>b.setInputFiles(y,p),!0);e.once("filechooser",b=>{b.setFiles(y)});return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let y=pn(e,t.toTarget,n,!1,!1);return Y(u,b=>b.dragTo(y[0].first(),p),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return c(k(async y=>{let b=ht(await y.textContent(m)??"");return b===""?!1:(s.set(t.saveAs,b),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let y=s.get(t.compareTo);if(y===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(u.length>0)return c(k(async x=>ht(await x.textContent(m)??"")!==y),r,`target text to differ from "${t.compareTo}" (${y})`);let b=f??"",S=s.get(b);if(S===void 0)throw new Error(`expectDiffers: no captured value named "${b}"`);if(S===y)throw new Error(`expectDiffers: "${b}" (${S}) equals "${t.compareTo}" (${y})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let y=new RegExp(t.pattern,"i");return c(k(async b=>y.test(await b.getAttribute(t.attribute,m)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let y=new RegExp(t.pattern,"i");return c(k(async b=>y.test(ht(await b.textContent(m)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let y=Number.parseInt(f??"",10),b=()=>e.context().pages().filter(x=>!x.isClosed());await c(async()=>b().length>y,r,`tab ${f} to exist (${b().length} open: ${b().map(x=>x.url()).join(", ")})`);let S=b()[y];return await S.bringToFront(),S}case"closeTab":{let y=e.context().pages().filter(x=>!x.isClosed()),b=f===void 0?e:y[Number.parseInt(f,10)];if(!b)throw new Error(`tab ${f} does not exist (${y.length} open)`);await b.close();let S=e.context().pages().filter(x=>!x.isClosed());if(S.length===0)throw new Error("closed the last tab; nothing left to drive");return S[S.length-1]}case"newTab":{let y=await e.context().newPage();if(l){let b=/^https?:\/\//.test(l)?l:new URL(l,o).toString();await y.goto(b,p)}return y}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let y=new RegExp(d(t.urlPattern)),b=t.pattern?new RegExp(d(t.pattern)):void 0;if(!i){await e.waitForResponse(x=>dn({seq:0,url:x.url(),method:x.request().method(),status:x.status()},y,t.method,t.status,void 0),p);return}let S=()=>t.action==="expectResponse"?i.all():i.sincePreviousStep();return c(async()=>S().some(x=>dn(x,y,t.method,t.status,b)),r,is(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}var mt=(e,t)=>Me(e.actions,t),ls=(e,t)=>$e(e.actions,t);function xt(e,t){return t.steps.length!==e.steps.length?!1:t.steps.every((n,r)=>{let o=e.steps[r];if(n.gherkin!==o.text||n.kind!==o.kind)return!1;let s=n.table,i=o.table;if(!!s!=!!i)return!1;if(s&&i){let a=Object.keys(N(s)),c=Object.keys(N(i));if(a.length>0&&c.length>0){if(JSON.stringify(a)!==JSON.stringify(c))return!1;let d=N(s),f=N(i);for(let l of a){if(d[l]===f[l])continue;let h=`<${l}>`,u=t.steps.some(m=>mt(m,h)),p=t.steps.some(m=>ls(m,d[l]));if(!u||p)return!1}}else if(JSON.stringify(s)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(o.docString!==void 0))return!1;if(n.docString!==void 0&&o.docString!==void 0&&n.docString!==o.docString){let a=t.steps.some(d=>mt(d,"<docstring>")),c=t.steps.some(d=>mt(d,n.docString));if(!a||c)return!1}return!0})}function vt(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...se(e.steps),...N(r),...fe(n?.resolvedDocString??n?.docString)}}async function Ae(e,t,n,r={}){let o=r.actionTimeoutMs??5e3,s=r.retries??1,i=Math.max(10,r.pollIntervalMs??fn),a=[],c=r.vars??new Map,d=()=>Object.fromEntries(c),f=n.steps.some(x=>x.actions.some(g=>(g.action==="waitForResponse"||g.action==="expectResponse")&&g.pattern!==void 0)),l=new yt;l.attach(e.context(),f);let h=e,u=new Set(e.context().pages()),p=[],m=x=>p.push(x);e.context().on("page",m);let k=[],b=!n.steps.some(x=>x.actions.some(g=>g.opensTab))&&n.steps.some(x=>x.actions.some(g=>g.action==="switchTab"||g.action==="closeTab"||g.action==="newTab")),S=()=>{let x=e.context().pages();for(let g of x)u.has(g)||(u.add(g),g.isClosed()||(h=g));if(h.isClosed()){let g=x.filter($=>!$.isClosed());g.length>0&&(h=g[g.length-1])}};if(!xt(t,n))return l.detach(),{status:"stale",stepResults:a,captured:d()};try{for(let x=0;x<n.steps.length;x++){let g=n.steps[x],$=vt(t,x);l.markStep();let F=!1,v=0;if(g.kind==="assertion"&&!g.actions.some(T=>Z.has(T.action))){let T=`Assertion step "${g.gherkin}" recorded ${g.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"failed",drift:F,error:T});for(let w=x+1;w<n.steps.length;w++)a.push({gherkin:n.steps[w].gherkin,kind:n.steps[w].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:x,actionIndex:0,stepText:g.gherkin,kind:g.kind,error:T},stepResults:a,captured:d()}}for(let T=0;T<g.actions.length;T++){let w=g.actions[T];S(),w.pageFingerprint&&await ue(h)!==w.pageFingerprint&&(F=!0);let R,j=!1;for(let M=0;M<=s&&!j;M++){M>0&&(v++,await St(250*M),S());try{let D=await bt(h,w,$,o,r.baseURL,c,l,i);D&&(h=D,u.add(D));let A=w.action==="click"||w.action==="press";if(p.length===0&&(w.opensTab||A&&b)&&await e.context().waitForEvent("page",{timeout:w.opensTab?o:150}).catch(()=>{}),w.opensTab&&p.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for(p.length>0&&k.push({stepIndex:x,actionIndex:T});p.length>0;)await p.shift().waitForLoadState("domcontentloaded",{timeout:o}).catch(()=>{});j=!0}catch(D){R=D}}if(!j){let M=R instanceof Error?R.message:String(R);a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"failed",drift:F,...v?{retries:v}:{},error:M});for(let D=x+1;D<n.steps.length;D++)a.push({gherkin:n.steps[D].gherkin,kind:n.steps[D].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:x,actionIndex:T,stepText:g.gherkin,kind:g.kind,error:M},stepResults:a,captured:d(),tabOpeners:k}}}a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"passed",drift:F,...v?{retries:v}:{}})}return{status:"passed",stepResults:a,captured:d(),tabOpeners:k}}finally{e.context().off("page",m),l.detach()}}import ee from"node:fs";import Ve from"node:path";function un(e,t){ee.mkdirSync(Ve.dirname(e),{recursive:!0}),ee.writeFileSync(e,`${JSON.stringify(t,null,2)}
10
+ `)}function ge(e){return JSON.parse(ee.readFileSync(e,"utf8"))}function ce(e){let t=Ve.join(e,".saffron","proposals");if(!ee.existsSync(t))return[];let n=[];for(let r of ee.readdirSync(t)){let o=Ve.join(t,r);if(ee.statSync(o).isDirectory())for(let s of ee.readdirSync(o)){if(!s.endsWith(".json"))continue;let i=Ve.join(o,s);n.push({file:i,proposal:ge(i)})}}return n}function gn(e,t){let n=ge(t),r=L(e,n.cache.feature,n.cache.scenario);return ye(r,n.cache),ee.rmSync(t),r}function hn(e){ee.rmSync(e)}import We from"node:fs";import mn from"node:path";function yn(e){return mn.join(e,".saffron","history.jsonl")}function Sn(e,t,n,r){let o=e.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,tags:[...s.scenario.tags],status:s.status,source:s.source,durationMs:s.durationMs,verified:s.verified,seededSteps:s.seededSteps,adaptations:s.adaptations.length,driftSteps:s.stepResults.filter(i=>i.drift).length,aiCalls:s.usage.aiCalls,tokensInOut:s.usage.inputTokens+s.usage.outputTokens,cacheRead:s.usage.cacheReadTokens,cacheWrite:s.usage.cacheCreationTokens,costUsd:s.usage.costUsd??0,error:s.error?.split(`
11
+ `)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:o.filter(s=>s.status==="green").length,yellow:o.filter(s=>s.status==="yellow").length,red:o.filter(s=>s.status==="red").length,aiCalls:o.reduce((s,i)=>s+i.aiCalls,0),costUsd:o.reduce((s,i)=>s+i.costUsd,0),divergentSteps:n,scenarios:o}}function bn(e,t){let n=yn(e);We.mkdirSync(mn.dirname(n),{recursive:!0}),We.appendFileSync(n,`${JSON.stringify(t)}
12
+ `)}function qe(e,t=50){let n=yn(e);if(!We.existsSync(n))return[];let r=We.readFileSync(n,"utf8").trim().split(`
13
+ `).filter(Boolean),o=[];for(let s of r.slice(-t))try{o.push(JSON.parse(s))}catch{}return o}var wt=e=>{let t=e.green+e.yellow+e.red;return t===0?0:Math.round((e.green+e.yellow)/t*100)};function xn(e,t){let n=[...e,t],r=n.slice(-20),o=n.slice(-10),s=e.at(-1),i=s?{passRatePp:wt(t)-wt(s),costUsd:t.costUsd-s.costUsd,aiCalls:t.aiCalls-s.aiCalls,red:t.red-s.red}:void 0,a=new Map;for(let l of o)for(let h of l.scenarios){let u=`${h.feature}::${h.scenario}`,p=a.get(u)??{feature:h.feature,scenario:h.scenario,heals:0,reds:0,runs:0};p.runs++,h.source==="agent-heal"&&h.status==="yellow"&&p.heals++,h.status==="red"&&p.reds++,a.set(u,p)}let c=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,h)=>h.heals+h.reds-(l.heals+l.reds)),d=new Map;for(let l of r)for(let h of l.scenarios)h.status==="red"&&h.error&&d.set(h.error,(d.get(h.error)??0)+1);let f=[...d.entries()].sort((l,h)=>h[1]-l[1]).slice(0,3).map(([l,h])=>({message:l,count:h}));return{prev:i,series:{passRate:r.map(wt),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:c,divergentSteps:t.divergentSteps,topIssues:f}}var ds=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,ps=/^\s*StepSet:/;function kt(e,t){return ne.relative(e,t).split(ne.sep).join("/")}function fs(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function vn(e,t){return`${e} :: ${t}`}function He(e,t,n={}){let r=t.features??"features",o=ne.isAbsolute(r)?r:ne.join(e,r),s=ne.join(e,".saffron","reports","latest.json"),i=ne.join(e,".saffron","reports","latest.html"),a;try{a=JSON.parse(te.readFileSync(s,"utf8"))}catch{a=void 0}let c=new Map;for(let g of a?.scenarios??[]){let $=g.scenario.replace(/ \(example \d+\)$/,""),F=vn(g.feature,$);c.set(F,fs(c.get(F),g.status))}let d=new Map,f;try{d=J(oe(o),e)}catch(g){f=g instanceof Error?g.message:String(g)}let l=te.existsSync(o)?[...te.readdirSync(o,{recursive:!0})].map(String).filter(Q).map(g=>ne.join(o,g)).sort():[],h=[],u=new Map,p=new Map,m={parsedFiles:new Set,owned:new Set,unreadable:f?[f]:[]};for(let g of l){let $=kt(e,g),F="";try{F=te.readFileSync(g,"utf8")}catch(A){h.push({path:$,name:$,scenarios:[],stepSets:0,error:String(A)}),m.unreadable.push(`${$}: ${String(A)}`);continue}let v=F.split(`
14
+ `),T=new Map,w=0;v.forEach((A,le)=>{let de=A.match(ds);de&&!T.has(de[1])?T.set(de[1],le+1):ps.test(A)&&w++});let R=v.find(A=>/^\s*Feature:/.test(A)),j=R?R.replace(/^\s*Feature:\s*/,"").trim():$,M;try{M=W(g,e,d)}catch(A){h.push({path:$,name:j,scenarios:[],stepSets:w,error:f??(A instanceof Error?A.message:String(A))}),m.unreadable.push(`${$}: ${A instanceof Error?A.message:String(A)}`);continue}m.parsedFiles.add(g),p.set(g,M);let D=new Map;for(let A of M){let le=D.get(A.name);if(le){le.rows+=1;continue}let de=L(e,A.featurePath,A.name);m.owned.add(de),m.owned.add(ie(e,A.featurePath,A.name));let Oe="missing";if(te.existsSync(de))try{let we=U(de);Oe=we&&xt(A,we)?"valid":"stale"}catch{Oe="invalid"}D.set(A.name,{name:A.name,line:T.get(A.name),tags:[...A.tags],outline:Object.keys(A.params).length>0,rows:1,cached:Oe!=="missing",cacheState:Oe,proposal:te.existsSync(ie(e,A.featurePath,A.name)),lastStatus:c.get(vn(A.featurePath,A.name))});for(let we of A.tags)u.set(we,(u.get(we)??0)+1)}h.push({path:$,name:j,scenarios:[...D.values()],stepSets:w})}let k=ce(e).map(({file:g,proposal:$})=>({file:kt(e,g),feature:$.cache.feature,scenario:$.cache.scenario,mode:$.meta.mode,createdAt:$.meta.createdAt,verified:$.meta.verified,proofError:$.meta.proofError,adaptations:$.meta.adaptations??[],narrative:$.meta.narrative,suggestedFeatureEdit:$.meta.suggestedFeatureEdit,recordedFor:$.meta.recordedFor,aiCalls:$.meta.usage?.aiCalls??0,costUsd:$.meta.usage?.costUsd})).sort((g,$)=>g.file.localeCompare($.file)),y;try{y=G(e,r,d,p)}catch{y={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let b=gs(e),S=qe(e,n.historyLimit??30).map(g=>({startedAt:g.startedAt,green:g.green,yellow:g.yellow,red:g.red,aiCalls:g.aiCalls,costUsd:g.costUsd})),x=ne.join(e,"saffron.config.json");return{tool:"saffron",version:ae(),projectRoot:e,packageInstalled:te.existsSync(ne.join(e,"node_modules","saffron-ai")),config:{file:te.existsSync(x)?"saffron.config.json":void 0,effective:tn(t)},features:h,tags:[...u.entries()].map(([g,$])=>({tag:g,scenarios:$})).sort((g,$)=>g.tag.localeCompare($.tag)),proposals:k,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:a.totals,reportHtml:te.existsSync(i)?kt(e,i):void 0}:void 0,history:S,vocabulary:{steps:y.steps.length,recorded:y.steps.filter(g=>g.status==="recorded").length,unrecorded:y.steps.filter(g=>g.status==="unrecorded").length,stepSets:y.stepSets.length,divergent:y.steps.filter(g=>g.status==="divergent").map(g=>g.text),duplicateWordings:b.slice(0,50)},orphans:(()=>{let g=ze(e,r,m);return g.unreadable.length?[]:g.orphans})()}}function kn(e){let t=e.features.reduce((s,i)=>s+i.scenarios.length,0),n=e.features.reduce((s,i)=>s+i.scenarios.filter(a=>a.cacheState==="valid").length,0),r=e.features.reduce((s,i)=>s+i.scenarios.filter(a=>a.cacheState==="stale"||a.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let s of e.features){let i=s.scenarios.map(a=>a.proposal?"\u25D0":a.cacheState==="valid"?"\u25CF":a.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${s.path} ${i}${s.error?` \u2717 ${s.error.split(`
15
+ `)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(s=>`${s.tag} (${s.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(s=>s.file).join(", ")}`:"no proposals pending review"),e.lastRun){let s=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${s.green} passed \xB7 ${s.yellow} pending review \xB7 ${s.red} failed \xB7 $${s.costUsd.toFixed(2)}`)}let o=e.vocabulary;console.log(`vocabulary: ${o.steps} steps (${o.recorded} recorded, ${o.unrecorded} unrecorded), ${o.stepSets} step sets, ${o.divergent.length} divergent, ${o.duplicateWordings.length} duplicate wording group(s)`),e.orphans.length&&console.log(`${e.orphans.length} recording(s) belong to no scenario: run \`saffron prune\` to see them`),console.log(`config: ${e.config.file??"(defaults, no saffron.config.json)"}`)}var us=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function wn(e){return e?e.role?`${e.role}${e.name?` "${e.name}"`:e.nameRegex?` /${e.nameRegex}/`:""}`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testid ${e.testId}`:e.placeholder?`placeholder "${e.placeholder}"`:e.fallbackSelectors?.length?e.fallbackSelectors[0]:"":""}function gs(e){let t;try{t=X(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let o=Ne(r.gherkin).pattern,s=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(l=>l[1]),i=l=>l!==void 0&&(s.includes(l)||us.test(l))?"\xABarg\xBB":l,a=r.actions.map(l=>{let{pageFingerprint:h,value:u,promptText:p,...m}=l;return{...m,value:i(u),promptText:i(p)}}),c=JSON.stringify(a),d=r.actions.map(l=>`${l.action}${wn(l.target)?` ${wn(l.target)}`:l.url?` ${l.url}`:""}`).join(" \xB7 "),f=n.get(c)??{steps:new Map,actions:d};f.steps.has(o)||f.steps.set(o,r.gherkin),n.set(c,f)}return[...n.values()].filter(r=>r.steps.size>1).map(r=>({steps:[...r.steps.values()].sort(),actions:r.actions})).sort((r,o)=>o.steps.length-r.steps.length||r.steps[0].localeCompare(o.steps[0]))}async function $n(e,t){let n=new hs({name:"saffron",version:ae()},{capabilities:{tools:{}}});n.setRequestHandler(Ss,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: 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:{}}},{name:"project_status",description:"The project overview (same data as `saffron status --json`): every feature file with its scenarios (header line, tags, outline rows, cached / proposal pending / last run status), the tag list, pending proposals with narrative and proof-replay verdict, the last run's totals, run history, vocabulary health (divergent steps, duplicate wordings proven by identical recordings) and the effective config. Call it before editing tests to see what is already recorded and what awaits review.",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(ys,async r=>bs(r.params.name,r.params.arguments,e,t)),await n.connect(new ms)}function bs(e,t,n,r){let o=s=>({content:[{type:"text",text:JSON.stringify(s,null,2)}]});if(e==="search_steps"){let s=G(n,r),i=typeof t?.query=="string"?t.query:void 0,a=i?Le(s,i):s;return o({steps:a.steps,scannedFiles:a.scannedFiles,scannedCaches:a.scannedCaches})}if(e==="list_step_sets"){let s=G(n,r);return o({stepSets:s.stepSets})}return e==="project_status"?o(He(n,H(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as xs}from"@anthropic-ai/claude-agent-sdk";function vs(e,t){let n=t.steps.map(o=>`- [${o.status}] ${o.keyword} ${o.text}`).join(`
16
16
  `),r=t.stepSets.map(o=>`- StepSet ${o.name}
17
17
  ${o.steps.map(s=>` ${s}`).join(`
18
18
  `)}`).join(`
19
19
  `);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)","",t.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
20
20
  ${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:",e.trim()].filter(o=>o!==void 0).join(`
21
- `)}async function vn(e,t,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o="",s=hs({prompt:ms(e,t),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 s)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(o+=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 o=o.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:o+`
22
- `,usage:r}}import ze from"node:fs";import{fileURLToPath as wn}from"node:url";import fe from"node:path";import{createConnection as Ss,ProposedFeatures as ys,TextDocuments as bs,TextDocumentSyncKind as xs,CompletionItemKind as Re,InsertTextFormat as kn,DiagnosticSeverity as Ve,MarkupKind as $n}from"vscode-languageserver/node";import{TextDocument as vs}from"vscode-languageserver-textdocument";var Tn=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,ws=/^\s*StepSet:\s*(.+?)\s*$/,St=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,yt={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function ks(e,t){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),o=n(t);if(r.size===0||o.size===0)return 0;let s=0;for(let i of r)o.has(i)&&s++;return s/(r.size+o.size-s)}function $s(e){try{let t=JSON.parse(ze.readFileSync(fe.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function En(e,t,n={}){let r=e,o=t,s=Ss(ys.all,process.stdin,process.stdout),i=new bs(vs),a={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},c=new Map,l,p=()=>{try{let h=fe.isAbsolute(o)?o:fe.join(r,o);c=J(se(h),r),a=G(r,o,c)}catch(h){s.console.warn(`saffron vocabulary rebuild: ${String(h)}`)}for(let h of i.all())g(h)},d=()=>{clearTimeout(l),l=setTimeout(p,300)};function f(h){return a.steps.find(x=>x.text===h)?.status}function g(h){if(!h.uri.endsWith(".saffron"))return;let x=[],k=h.getText().split(`
23
- `),y=new Map;for(let b=0;b<k.length;b++){let S=k[b],u={start:{line:b,character:0},end:{line:b,character:S.length}},m=S.match(St);m&&!c.has(m[3])&&x.push({range:u,severity:Ve.Error,source:"saffron",message:`StepSet "${m[3]}" is not defined anywhere in the project.`});let T=S.match(ws);if(T){let $=c.get(T[1]),w=fe.relative(r,wn(h.uri)).replace(/\\/g,"/");(y.has(T[1])||$&&$.file!==w)&&x.push({range:u,severity:Ve.Error,source:"saffron",message:`StepSet "${T[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${T[1]}".`}),y.set(T[1],b)}let I=S.trim();if(I&&!m&&!T){for(let[$]of c)if(I.toLowerCase()===$.toLowerCase()){x.push({range:u,severity:Ve.Warning,source:"saffron",message:`Did you mean "StepSet ${$}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let v=S.match(Tn);if(v&&f(v[3])!=="recorded"){for(let $ of a.steps)if(!($.text===v[3]||$.status==="unrecorded")&&ks($.text,v[3])>=.7){x.push({range:{start:{line:b,character:S.indexOf(v[3])},end:{line:b,character:S.length}},severity:Ve.Information,source:"saffron",message:`Similar to the ${$.status} step "${$.text}" (\xD7${$.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}s.sendDiagnostics({uri:h.uri,diagnostics:x})}s.onInitialize(h=>{if(n.rootFromClient){let x=h.workspaceFolders?.[0]?.uri??h.rootUri??void 0;if(x&&x.startsWith("file:")){let k=wn(x);ze.existsSync(k)&&(r=k,o=$s(k)??t)}}return p(),{capabilities:{textDocumentSync:xs.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}}}),s.onCompletion(h=>{let x=i.get(h.textDocument.uri);if(!x)return[];let k=x.getText({start:{line:h.position.line,character:0},end:h.position});return Ts(x.uri,k,h.position.line,a)}),s.onDefinition(h=>{let x=i.get(h.textDocument.uri);if(!x)return;let k=x.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),y=Rn(k,h.position.character);if(y){let m=An(r,y.name);return m?{uri:`file://${m.file}`,range:{start:{line:m.line,character:0},end:{line:m.line,character:0}}}:void 0}let b=k.match(St);if(!b)return;let S=c.get(b[3]);return S?{uri:`file://${fe.join(r,S.file)}`,range:{start:{line:S.line-1,character:0},end:{line:S.line-1,character:0}}}:void 0}),s.onHover(h=>{let x=i.get(h.textDocument.uri);if(!x)return;{let S=x.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),u=Rn(S,h.position.character);if(u){let m=An(r,u.name),T=m?`defined in ${fe.relative(r,m.file)} (line ${m.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${u.name}}** ${T}.`}}}}let k=x.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),y=k.match(St);if(y){let S=a.stepSets.find(m=>m.name===y[3]);if(!S)return;let u=S.steps.map(m=>`- ${yt[f(m)??"unrecorded"][0]} ${m}`).join(`
24
- `);return{contents:{kind:$n.Markdown,value:`**StepSet ${S.name}** \u2014 expands to:
21
+ `)}async function Tn(e,t,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o="",s=xs({prompt:vs(e,t),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 s)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(o+=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 o=o.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:o+`
22
+ `,usage:r}}import Ge from"node:fs";import{fileURLToPath as Rn}from"node:url";import he from"node:path";import{createConnection as ws,ProposedFeatures as ks,TextDocuments as $s,TextDocumentSyncKind as Ts,CompletionItemKind as Ee,InsertTextFormat as An,DiagnosticSeverity as Je,MarkupKind as En}from"vscode-languageserver/node";import{TextDocument as Rs}from"vscode-languageserver-textdocument";var Pn=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,As=/^\s*StepSet:\s*(.+?)\s*$/,$t=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Tt={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Es(e,t){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),o=n(t);if(r.size===0||o.size===0)return 0;let s=0;for(let i of r)o.has(i)&&s++;return s/(r.size+o.size-s)}function Ps(e){try{let t=JSON.parse(Ge.readFileSync(he.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function Fn(e,t,n={}){let r=e,o=t,s=ws(ks.all,process.stdin,process.stdout),i=new $s(Rs),a={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},c=new Map,d,f=()=>{try{let p=he.isAbsolute(o)?o:he.join(r,o);c=J(oe(p),r),a=G(r,o,c)}catch(p){s.console.warn(`saffron vocabulary rebuild: ${String(p)}`)}for(let p of i.all())u(p)},l=()=>{clearTimeout(d),d=setTimeout(f,300)};function h(p){return a.steps.find(m=>m.text===p)?.status}function u(p){if(!p.uri.endsWith(".saffron"))return;let m=[],k=p.getText().split(`
23
+ `),y=new Map;for(let b=0;b<k.length;b++){let S=k[b],x={start:{line:b,character:0},end:{line:b,character:S.length}},g=S.match($t);g&&!c.has(g[3])&&m.push({range:x,severity:Je.Error,source:"saffron",message:`StepSet "${g[3]}" is not defined anywhere in the project.`});let $=S.match(As);if($){let T=c.get($[1]),w=he.relative(r,Rn(p.uri)).replace(/\\/g,"/");(y.has($[1])||T&&T.file!==w)&&m.push({range:x,severity:Je.Error,source:"saffron",message:`StepSet "${$[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${$[1]}".`}),y.set($[1],b)}let F=S.trim();if(F&&!g&&!$){for(let[T]of c)if(F.toLowerCase()===T.toLowerCase()){m.push({range:x,severity:Je.Warning,source:"saffron",message:`Did you mean "StepSet ${T}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let v=S.match(Pn);if(v&&h(v[3])!=="recorded"){for(let T of a.steps)if(!(T.text===v[3]||T.status==="unrecorded")&&Es(T.text,v[3])>=.7){m.push({range:{start:{line:b,character:S.indexOf(v[3])},end:{line:b,character:S.length}},severity:Je.Information,source:"saffron",message:`Similar to the ${T.status} step "${T.text}" (\xD7${T.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}s.sendDiagnostics({uri:p.uri,diagnostics:m})}s.onInitialize(p=>{if(n.rootFromClient){let m=p.workspaceFolders?.[0]?.uri??p.rootUri??void 0;if(m&&m.startsWith("file:")){let k=Rn(m);Ge.existsSync(k)&&(r=k,o=Ps(k)??t)}}return f(),{capabilities:{textDocumentSync:Ts.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}}}),s.onCompletion(p=>{let m=i.get(p.textDocument.uri);if(!m)return[];let k=m.getText({start:{line:p.position.line,character:0},end:p.position});return Cs(m.uri,k,p.position.line,a)}),s.onDefinition(p=>{let m=i.get(p.textDocument.uri);if(!m)return;let k=m.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),y=Cn(k,p.position.character);if(y){let g=_n(r,y.name);return g?{uri:`file://${g.file}`,range:{start:{line:g.line,character:0},end:{line:g.line,character:0}}}:void 0}let b=k.match($t);if(!b)return;let S=c.get(b[3]);return S?{uri:`file://${he.join(r,S.file)}`,range:{start:{line:S.line-1,character:0},end:{line:S.line-1,character:0}}}:void 0}),s.onHover(p=>{let m=i.get(p.textDocument.uri);if(!m)return;{let S=m.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),x=Cn(S,p.position.character);if(x){let g=_n(r,x.name),$=g?`defined in ${he.relative(r,g.file)} (line ${g.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${x.name}}** ${$}.`}}}}let k=m.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),y=k.match($t);if(y){let S=a.stepSets.find(g=>g.name===y[3]);if(!S)return;let x=S.steps.map(g=>`- ${Tt[h(g)??"unrecorded"][0]} ${g}`).join(`
24
+ `);return{contents:{kind:En.Markdown,value:`**StepSet ${S.name}** \u2014 expands to:
25
25
 
26
- ${u}`}}}let b=k.match(Tn);if(b){let S=a.steps.find(u=>u.text===b[3]);return S?{contents:{kind:$n.Markdown,value:`${yt[S.status]}
26
+ ${x}`}}}let b=k.match(Pn);if(b){let S=a.steps.find(x=>x.text===b[3]);return S?{contents:{kind:En.Markdown,value:`${Tt[S.status]}
27
27
 
28
28
  Used in ${S.usage} place(s): ${S.files.join(", ")}${S.fromStepSet?`
29
29
 
30
- Defined in StepSet **${S.fromStepSet}**`:""}`}}:void 0}}),i.onDidOpen(h=>g(h.document)),i.onDidChangeContent(h=>g(h.document)),i.onDidSave(()=>d()),s.onDidChangeWatchedFiles(()=>d()),i.listen(s),s.listen()}function Rn(e,t){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function An(e,t){for(let n of[".env",".env.example"]){let r=fe.join(e,n);if(!ze.existsSync(r))continue;let s=ze.readFileSync(r,"utf8").split(`
31
- `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(s!==-1)return{file:r,line:s}}}function Ts(e,t,n,r){let o=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&o)return r.stepSets.map(a=>({label:a.name,kind:Re.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
32
- `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:Re.Text,detail:`${yt[a.status]} \xB7 \xD7${a.usage}`,sortText:String(c).padStart(5,"0"),filterText:a.text}));let i=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&o){let a={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:Re.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
30
+ Defined in StepSet **${S.fromStepSet}**`:""}`}}:void 0}}),i.onDidOpen(p=>u(p.document)),i.onDidChangeContent(p=>u(p.document)),i.onDidSave(()=>l()),s.onDidChangeWatchedFiles(()=>l()),i.listen(s),s.listen()}function Cn(e,t){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function _n(e,t){for(let n of[".env",".env.example"]){let r=he.join(e,n);if(!Ge.existsSync(r))continue;let s=Ge.readFileSync(r,"utf8").split(`
31
+ `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(s!==-1)return{file:r,line:s}}}function Cs(e,t,n,r){let o=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&o)return r.stepSets.map(a=>({label:a.name,kind:Ee.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
32
+ `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:Ee.Text,detail:`${Tt[a.status]} \xB7 \xD7${a.usage}`,sortText:String(c).padStart(5,"0"),filterText:a.text}));let i=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&o){let a={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:Ee.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
33
33
  Given \u2026
34
34
  When \u2026
35
35
 
36
- Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:kn.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:Re.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:kn.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let l of r.stepSets)c.push({label:`StepSet ${l.name}`,kind:Re.Function,detail:`invoke step set \xB7 ${l.steps.length} step(s)`,documentation:l.steps.join(`
37
- `),filterText:`step set ${l.name}`,sortText:`1${l.name}`,textEdit:{range:a,newText:`StepSet ${l.name}`}});return c}return[]}import Bn from"node:fs";import Ms from"node:net";import Vn from"node:path";import{chromium as Ns,firefox as Ls,webkit as Us}from"playwright";var Rs=5e3,As=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]),We=class{pendingTabOpen=!1;pendingTabFallback;lastOpener;lastOpenerAt=0;inFlight=new Set;steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(t){this.steps=t.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(t){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===t.action&&i.value===t.value&&i.promptText===t.promptText))return;let s=Math.max(r.actions.length-1,0);r.actions.splice(s,0,t)}beginToolCall(t){this.inFlight.add(t)}endToolCall(t,n){this.inFlight.delete(t)&&(this.pendingTabOpen&&n&&(this.pendingTabFallback=void 0),this.inFlight.size===0&&this.pendingTabOpen&&(this.pendingTabFallback&&(this.pendingTabFallback.opensTab=!0),this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}markTabOpened(){let t=this.inFlight.size===0,n=Date.now()-this.lastOpenerAt<=Rs;if(t){this.lastOpener&&n&&(this.lastOpener.opensTab=!0);return}this.pendingTabOpen=!0,this.pendingTabFallback=n?this.lastOpener:void 0}addActions(t){if(this.done||t.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r)return;this.pendingFingerprint&&t[0]&&(t[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...t);let o=[...t].reverse().find(s=>As.has(s.action));o&&(this.lastOpener=o,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(o.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},ue=e=>e.replace(/\\(['"\\])/g,"$1");function Es(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?ue(t[1]):void 0}function bt(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=ue(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=e.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){t.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(t.name=ue(i[1]))}let o=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of o){let c=e.match(i);c&&(t[a]=ue(c[1]))}let s=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return s&&(t.fallbackSelectors=[ue(s[1])]),Object.keys(t).length>0?t:void 0}function Ps(e,t){if(t&&e.startsWith(t)){let n=e.slice(t.length);return n.startsWith("/")?n:`/${n}`}return e}var Cs={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function _s(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Ps(ue(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:ue(r[1])};let o=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(o){let f=bt(o[1]),g=bt(o[2]);if(f&&g)return{action:"dragTo",target:f,toTarget:g}}let s=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!s)return;let[,i,a,c]=s,l=bt(i);if(!l)return;let p={action:Cs[a],target:l},d=Es(c);return d!==void 0&&a!=="click"&&a!=="dblclick"&&(p.value=d),p}function Pn(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let s of r){let i=s?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let o=[];for(let s of n)for(let i of s.split(`
38
- `)){let a=_s(i.trim(),t);a&&o.push(a)}return Fs(o)}function Fs(e){return e.filter((t,n)=>{let r=e[n+1];return!(t.action==="fill"&&(t.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(t.target))})}function Cn(e){let t=[];for(let n of e){if(n.kind==="assertion"&&n.actions.length===0){t.push(`Step "${n.gherkin}" is an assertion but recorded no check, so nothing would be verified. Re-record with: saffron run --rerecord`);continue}if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let o of n.actions)if(o.action==="fill"&&(o.value??"")===""){t.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 t}function xt(e,t){let n=e;for(let[r,o]of Object.entries(t).filter(([,s])=>s.length>=3).sort((s,i)=>i[1].length-s[1].length))n=n.split(o).join(`<${r}>`);return n}function _n(e,t){let n=Object.entries(t).filter(([,i])=>i.length>=3);if(n.length===0)return e;let r=i=>i===void 0?void 0:xt(i,t),o=[...n].sort((i,a)=>a[1].length-i[1].length),s=i=>{if(i!==void 0){for(let[a,c]of o)if(i.includes(c))return`<${a}>`;return i}};return e.map(i=>({...i,actions:i.actions.map(a=>{let c=Se.has(a.action)&&a.action!=="expectValue";return{...a,value:c?s(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:c?s(a.target.name):r(a.target.name),label:r(a.target.label),text:c?s(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function Fn(e,t){let n=ge(t.steps);return e.map((r,o)=>{let s=t.steps[o],i=s?.resolvedTable??s?.table,a=s?.resolvedDocString??s?.docString,c=Object.entries({...n,...V(i),...le(a)}).filter(([,p])=>p.length>=3).sort((p,d)=>d[1].length-p[1].length);if(c.length===0)return r;let l=p=>{if(p===void 0)return;let d=p;for(let[f,g]of c)d=d.split(g).join(`<${f}>`);return d};return{...r,actions:r.actions.map(p=>({...p,value:l(p.value),url:l(p.url),target:p.target?{...p.target,name:l(p.target.name),label:l(p.target.label),text:l(p.target.text),placeholder:l(p.target.placeholder)}:void 0}))}})}function jn(e,t=new Date){let n=r=>r===void 0?void 0:nn(r,t);return e.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text)}:void 0}))}))}function In(e,t){if(t.size===0)return e;let n=r=>r===void 0?void 0:me(r,t);return e.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),pattern:n(o.pattern),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function On(e,t){if(e!=="mcp__playwright__browser_tabs")return;let n=t??{},r=typeof n.index=="number"?String(n.index):void 0;switch(n.action){case"select":return r===void 0?void 0:{action:"switchTab",value:r};case"close":return r===void 0?{action:"closeTab"}:{action:"closeTab",value:r};case"new":return typeof n.url=="string"&&n.url?{action:"newTab",url:n.url}:{action:"newTab"};default:return}}function Dn(e,t){let n=t??{};if(e==="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(e==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let o=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(o)?i.slice(o.length):i).join(`
39
- `)}}}function Ee(e){return Array.isArray(e)?e.map(Ee):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).filter(([t,n])=>n!==void 0&&t!=="pageFingerprint").sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>[t,Ee(n)])):e}var js=e=>JSON.stringify(Ee(e));function vt(e,t="",n={}){if(Array.isArray(e))n[t]=e.map(r=>String(r)).join(", ");else if(e&&typeof e=="object")for(let[r,o]of Object.entries(e))vt(o,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function Is(e,t){let n=vt(Ee(e)),r=vt(Ee(t)),o=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),s=[];for(let i of o){if(i==="action"||n[i]===r[i])continue;let a=i.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[i]===void 0?s.push(`${a}: added "${r[i]}"`):r[i]===void 0?s.push(`${a}: removed "${n[i]}"`):s.push(`${a}: "${n[i]}" \u2192 "${r[i]}"`)}return s}function Ln(e,t,n){let r=e.map(n),o=t.map(n),s=Array.from({length:r.length+1},()=>new Array(o.length+1).fill(0));for(let l=r.length-1;l>=0;l--)for(let p=o.length-1;p>=0;p--)s[l][p]=r[l]===o[p]?s[l+1][p+1]+1:Math.max(s[l+1][p],s[l][p+1]);let i=[],a=0,c=0;for(;a<r.length&&c<o.length;)r[a]===o[c]?(i.push({before:e[a],after:t[c]}),a++,c++):s[a+1][c]>=s[a][c+1]?(i.push({before:e[a]}),a++):(i.push({after:t[c]}),c++);for(;a<r.length;a++)i.push({before:e[a]});for(;c<o.length;c++)i.push({after:t[c]});return i}function Mn(e){if(!e)return"";let t=[];if(e.role){let n=e.name?` "${e.name}"`:e.nameRegex?` matching /${e.nameRegex}/`:"";t.push(`${e.role}${n}`)}else e.label?t.push(`label "${e.label}"`):e.text?t.push(`text "${e.text}"`):e.placeholder?t.push(`placeholder "${e.placeholder}"`):e.testId&&t.push(`test id ${e.testId}`);return e.fallbackSelectors?.length&&t.push(`[${e.fallbackSelectors.join(", ")}]`),e.frame&&t.push(`inside frame ${e.frame}`),t.join(" ")}var Nn=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function ye(e){let t=Mn(e.target),n=e.value??"",r=(()=>{switch(e.action){case"goto":return`go to ${e.url}`;case"click":return`click ${t}`;case"fill":return`fill ${t} with "${n}"`;case"press":return t?`press ${n} on ${t}`:`press ${n}`;case"selectOption":return`select "${n}" in ${t}`;case"check":return`check ${t}`;case"uncheck":return`uncheck ${t}`;case"hover":return`hover ${t}`;case"waitFor":return t?`wait for ${t}`:`wait ${n}ms`;case"captureText":return`remember ${t} as "${e.saveAs}"`;case"handleDialog":return`${n} the dialog${e.promptText?` with "${e.promptText}"`:""}`;case"uploadFile":return`upload ${n}`;case"dragTo":return`drag ${t} onto ${Mn(e.toTarget)}`;case"waitForResponse":return`wait for ${Nn(e)}`;case"switchTab":return`switch to tab ${n}`;case"closeTab":return`close tab ${n||"(the current one)"}`;case"newTab":return`open a new tab${e.url?` at ${e.url}`:""}`;case"expectVisible":return`expect ${t} to be visible`;case"expectNotVisible":return`expect ${t} to be gone`;case"expectText":return`expect ${t} to contain "${n}"`;case"expectUrl":return`expect the URL to contain "${e.url??n}"`;case"expectValue":return`expect ${t} to hold "${n}"`;case"expectDiffers":return`expect ${t||`"${n}"`} to differ from "${e.compareTo}"`;case"expectMatches":return`expect ${t} to match /${e.pattern}/`;case"expectAttribute":return`expect ${t} ${e.attribute} to match /${e.pattern}/`;case"expectResponse":return`expect a response ${Nn(e)}`;default:return e.action}})();return e.opensTab?`${r}, opening a tab`:r}var Ae=e=>Se.has(e.action);function Os(e,t){let n=Ln(e,t,js),r=[];for(let o=0;o<n.length;o++){let s=n[o];if(s.before&&s.after){r.push({change:"same",before:ye(s.before),after:ye(s.after),assertion:Ae(s.after)});continue}let i=n[o+1];if(s.before&&i?.after&&!i.before){r.push({change:"changed",before:ye(s.before),after:ye(i.after),details:Is(s.before,i.after),assertion:Ae(s.before)||Ae(i.after)}),o++;continue}s.before&&r.push({change:"removed",before:ye(s.before),assertion:Ae(s.before)}),s.after&&r.push({change:"added",after:ye(s.after),assertion:Ae(s.after)})}return r}var Ds=e=>`${e.kind}\0${e.gherkin}`;function qe(e,t){let n=Ln(e?.steps??[],t.steps,Ds),r=[],o={added:0,removed:0,changed:0,assertions:0};for(let s of n){let i=s.after??s.before,a=Os(s.before?.actions??[],s.after?.actions??[]);for(let l of a)l.change==="added"?o.added++:l.change==="removed"?o.removed++:l.change==="changed"&&o.changed++,l.change!=="same"&&l.assertion&&o.assertions++;let c=e&&s.before?s.after?a.some(l=>l.change!=="same")?"changed":"same":"removed":"added";r.push({gherkin:i.gherkin,kind:i.kind,fromStepSet:i.fromStepSet,change:c,actions:a})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:r,summary:o}}function Un(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let i of e.steps){let a=i.change==="removed"?"- ":i.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${a}${i.kind==="assertion"?"[assertion] ":""}${i.gherkin}`+(i.fromStepSet?` (from step set "${i.fromStepSet}")`:""));for(let c of i.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let l of c.details??[])t.push(` ${l}`)}t.push("")}let{added:n,removed:r,changed:o,assertions:s}=e.summary;return t.push(n+r+o===0?"No action changed.":`${o} changed, ${n} added, ${r} removed.`+(s>0?` ${s} of them ${s===1?"is an assertion":"are assertions"}: read those first.`:"")),t.join(`
40
- `)}var be={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Bs(){return new Promise((e,t)=>{let n=Ms.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function Pe(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function Hn(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function zn(e,t){return e[t]?.kind==="assertion"&&t>=Hn(e)}function Wn(e,t,n=!1,r){let o=Hn(e);return e.map((s,i)=>{let a=t[i],c=a&&a.actions.length>0;return s.kind==="assertion"?n&&i<o&&c?a:s:c?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...s.actions.slice(0,r.actionIndex),...a.actions]}:a:s})}function Vs(e,t){return e.map((n,r)=>{let o=t[r];return o&&o.actions.length>0?o:n})}function qn(e,t){return{aiCalls:e.aiCalls+t.aiCalls,inputTokens:e.inputTokens+t.inputTokens,outputTokens:e.outputTokens+t.outputTokens,cacheReadTokens:e.cacheReadTokens+t.cacheReadTokens,cacheCreationTokens:e.cacheCreationTokens+t.cacheCreationTokens,costUsd:(e.costUsd??0)+(t.costUsd??0),models:e.models||t.models?[...new Set([...e.models??[],...t.models??[]])]:void 0}}var He=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=X(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=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)${t}`)}return this.stepIndex}async recordWithSeeding(t,n){let r=this.getStepIndex(),o=t.steps.length,s={...t.params,...ge(t.steps)},i=t.steps.map(b=>{let S=r.steps.size>0?zt(r,b,s):void 0;return S?{gherkin:b.text,keyword:b.keyword,kind:b.kind,table:b.table,docString:b.docString,fromStepSet:b.source?.fromStepSet,actions:S.actions}:void 0}),a=t.steps.map(b=>({gherkin:b.text,keyword:b.keyword,kind:b.kind,table:b.table,docString:b.docString,fromStepSet:b.source?.fromStepSet,actions:[]})),c=new Map,l=[],p=[],d=[],f=[],g={...be},h=0,x=0,k=0,y=b=>({success:b,narrative:p.join(" ")||"Composed entirely from seeded recordings.",adaptations:l,suggestedFeatureEdit:f.length>0?f.join(`
41
- `):void 0,featureEdits:d,recordedSteps:a,announcedSteps:[],usage:g,seededCount:h});for(;k<o;){if(i[k]){let T=k;for(;T<o&&i[T];)T++;let I=i.slice(k,T),v={...t,steps:t.steps.slice(k,T)},$=await Te(n,v,this.buildCandidateCache(t,I),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,vars:c}),w=$.status==="passed"?T-k:$.failure?.stepIndex??0;for(let F=0;F<w;F++)a[k+F]=I[F];if(h+=w,$.status==="passed"){k=T;continue}let A=k+w;l.push(`Seeded recording for "${t.steps[A].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[A]=void 0,k=A;continue}if(!this.options.agent)return y(!1);x++;let b=k;for(;b<o&&!i[b];)b++;x>=3&&(b=o);let S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:k>0?t.steps.slice(0,k).map(T=>T.resolvedText):void 0,recordOnly:b<o?{from:k,to:b-1}:void 0,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>de(Pe(this.browser)??n)}));if(g=qn(g,S.usage),p.push(S.narrative),l.push(...S.adaptations),d.push(...S.featureEdits),S.suggestedFeatureEdit&&f.push(S.suggestedFeatureEdit),!S.success)return y(!1);let u=S.announcedSteps.filter(T=>T>=k&&T<o),m=Math.min(o-1,Math.max(u.length>0?Math.max(...u):b-1,k));for(let T=k;T<=m;T++)a[T]=S.recordedSteps[T];k=m+1}return y(!0)}async withDialogsVisible(t){let n=()=>{},r=[],o=[],s=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",s),o.push(i);for(let a of i.pages())s(a)}try{return await t()}finally{for(let i of o)i.off("page",s);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(t==="chromium"){let n=await Bs();this.browser=await Ns.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(t==="firefox"?Ls:Us).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 t=Vn.isAbsolute(this.options.storageState)?this.options.storageState:Vn.join(this.options.projectRoot,this.options.storageState);if(!Bn.existsSync(t))throw new Error(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return t}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}tabWatcher(){return t=>{let n=this.browser?.contexts()??[],r=()=>t();for(let o of n)o.on("page",r);return()=>{for(let o of n)o.off("page",r)}}}async proofReplay(t,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),o=await r.newPage();return{result:await Te(o,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs}),context:r,page:o}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),o=$e(t);if(o.size>0){let s=i=>i===void 0?i:me(i,o);r.error=s(r.error),r.narrative=s(r.narrative),r.proofError=s(r.proofError),r.suggestedFeatureEdit=s(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>me(i,o));for(let i of r.stepResults)i.error=s(i.error)}return r}async runScenarioInner(t,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:o,baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}=this.options,l=M(o,t.featurePath,t.name),p=N(l),d=oe(o,t.featurePath,t.name),f=!p&&Bn.existsSync(d)?pe(d):void 0,g=await this.browser.newContext({storageState:this.resolveStorageState()}),h=await g.newPage();try{let x;if(f){let u=await Te(h,t,f.cache,{baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}),m=f.meta.recordedFor??t.name;if(u.status==="passed")return{scenario:t,status:"yellow",source:"agent-record",stepResults:u.stepResults,narrative:`Replayed the pending proposal recorded for "${m}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:d,usage:be,durationMs:Date.now()-r};if(Object.keys(t.params).length>0&&m!==t.displayName){let I=u.failure;return{scenario:t,status:"red",source:"agent-record",stepResults:u.stepResults,adaptations:[],proposalFile:d,usage:be,durationMs:Date.now()-r,error:`The pending proposal recorded for "${m}" does not replay for this Examples row`+(I?` (step "${I.stepText}": ${I.error.split(`
42
- `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+(u.failure?` (step "${u.failure.stepText}": ${u.failure.error.split(`
43
- `)[0]})`:"")+"; recording again"),await h.goto("about:blank")}if(p&&(x=await Te(h,t,p,{baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}),x.status==="passed"))return{scenario:t,status:"green",source:"cache",stepResults:x.stepResults,adaptations:[],usage:be,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let u=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:t,status:"red",source:"cache",stepResults:x?.stepResults??[],adaptations:[],usage:be,durationMs:Date.now()-r,error:p?x?.status==="stale"?`Cache is stale (feature file changed) and ${u}`:`Replay failed and ${u} ${x?.failure?.error??""}`.trimEnd():`No cache exists and ${u}`}}let y=p!==void 0&&x!==void 0&&x.status==="failed",b=0,S;try{if(y)S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:s,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:x.stepResults.filter(u=>u.status==="passed").map(u=>u.gherkin),failedStep:{text:x.failure.stepText,kind:x.failure.kind,error:x.failure.error},onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let u=Pe(this.browser)??h;return de(u)}}));else if(await h.goto("about:blank"),this.options.reuseSteps!==!1){let u=await this.recordWithSeeding(t,h);b=u.seededCount,S=u}else S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:s,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let u=Pe(this.browser)??h;return de(u)}}))}catch(u){let m=u instanceof Error?u.message:String(u);return{scenario:t,status:"red",source:y?"agent-heal":"agent-record",stepResults:x?.stepResults??[],adaptations:[],usage:be,durationMs:Date.now()-r,error:`Agent invocation failed: ${m.split(`
44
- `)[0]}`}}if(S.success&&y&&x.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||zn(p.steps,x.failure.stepIndex))){let u=p.steps[x.failure.stepIndex],m=Pe(this.browser)??h,T=new Map(Object.entries(x.captured)),I=gt(t,x.failure.stepIndex);try{for(let v of u.actions)await ft(m,v,I,this.options.actionTimeoutMs??5e3,s,T)}catch(v){let $=v instanceof Error?v.message:String(v);return{scenario:t,status:"red",source:"agent-heal",stepResults:x.stepResults,narrative:S.narrative,adaptations:S.adaptations,suggestedFeatureEdit:S.suggestedFeatureEdit,usage:S.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${u.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: ${$.split(`
45
- `)[0]}`}}}return await this.finalizeAgentRun(t,S,y,p,r,b,y?x?.failure:void 0)}finally{await g.close()}}async finalizeAgentRun(t,n,r,o,s,i=0,a){let c=r?"agent-heal":"agent-record",l=n.recordedSteps.map(w=>({gherkin:w.gherkin,kind:w.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:t,status:"red",source:c,stepResults:l,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-s,error:n.narrative};let p=r?Wn(o.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,d=[...n.adaptations],f={...n.usage},g,h,x;if(this.options.verifyProposals!==!1){let w=await this.proofReplay(t,this.buildCandidateCache(t,p));try{if(w.result.status!=="passed"&&w.result.failure){let A=w.result.failure;if(this.options.agent&&!(r&&A.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||zn(o.steps,A.stepIndex))))try{let D=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,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(R=>R.status==="passed").map(R=>R.gherkin),failedStep:{text:A.stepText,kind:A.kind,error:A.error},onTabOpened:this.tabWatcher(),takeFingerprint:async()=>de(Pe(this.browser)??w.page)}));f=qn(f,D.usage),D.success&&(d.push(...D.adaptations),p=r?Wn(p,D.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Vs(p,D.recordedSteps),await w.context.close(),w=await this.proofReplay(t,this.buildCandidateCache(t,p)))}catch{}}for(let A of w.result.tabOpeners??[]){let F=p[A.stepIndex]?.actions[A.actionIndex];F&&(F.opensTab=!0)}g=w.result.status==="passed",g||(h=w.result.failure?.error??`proof replay ${w.result.status}`,x=w.result.stepResults)}finally{await w.context.close().catch(()=>{})}}let k=[...n.featureEdits];for(let w of Wt(p,X(this.options.projectRoot)))d.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.`),k.some(A=>A.index===w.index)||k.push({index:w.index,text:w.to});let y=Cn(p);y.length>0&&(g=!1,h=[h,...y].filter(Boolean).join(" "));let b=$e(t),S=w=>b.size>0?me(w,b):w;d=d.map(S);let u=k.map(w=>({index:w.index,text:S(w.text)})),m=S(n.narrative),T=n.suggestedFeatureEdit?S(n.suggestedFeatureEdit):void 0,I={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:m,adaptations:d,suggestedFeatureEdit:T,featureEdits:u.length>0?u:void 0,verified:g,proofError:h?S(h):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:f},cache:{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:p}},v=oe(this.options.projectRoot,t.featurePath,t.name);ln(v,I);let $=N(M(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:qe($,I.cache),scenario:t,status:"yellow",source:c,stepResults:x??l,narrative:m,adaptations:d,suggestedFeatureEdit:T,featureEdits:u.length>0?u:void 0,verified:g,proofError:h,seededSteps:i>0?i:void 0,proposalFile:v,usage:f,durationMs:Date.now()-s}}};import Zn from"node:path";import{createRequire as Gs}from"node:module";import{query as Ys}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as zs,tool as xe}from"@anthropic-ai/claude-agent-sdk";import{z as E}from"zod";var U=e=>({content:[{type:"text",text:e}]});function Jn(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var Gn={role:E.string().optional().describe("ARIA role of the element"),name:E.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:E.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:E.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:E.string().optional().describe("getByText locator text"),selector:E.string().optional().describe("CSS fallback selector"),frame:E.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 Yn(e){return zs({name:"saffron",version:"0.1.0",tools:[xe("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:E.number().int().min(0),text:E.string(),adaptation:E.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 t=>(e.currentStepIndex=t.index,e.announced.add(t.index),t.adaptation&&e.adaptations.push(`Step ${t.index+1} ("${t.text}"): ${t.adaptation}`),U(`Recording step ${t.index}: ${t.text}`))),xe("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:E.string().describe("Name to store the captured text under"),...Gn},async t=>{let n=Jn(t);return n?(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),U(`Capture recorded as "${t.saveAs}".`)):U("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),xe("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:E.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:E.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),U(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),xe("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:E.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:E.string().optional().describe("HTTP method to match (any if omitted)"),status:E.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:E.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),U("Network wait recorded."))),xe("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:E.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Gn,expected:E.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:E.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:E.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:E.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:E.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:E.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:E.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:E.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:E.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:E.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=Jn(t),r;switch(t.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:t.expected??""};break;case"value":r={action:"expectValue",target:n,value:t.expected??""};break;case"url":r={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return U("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return U("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return U("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return U("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"response":if(!t.urlPattern)return U("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?U("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([r]),U("Assertion recorded."))}),xe("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:E.boolean(),narrative:E.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:E.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:E.array(E.object({index:E.number().int().min(0).describe("zero-based step index"),text:E.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 t=>(e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],U("Run finalized.")))]})}function Kn(e={}){let t=e.deliberateSnapshots?`
36
+ Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:An.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:Ee.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:An.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let d of r.stepSets)c.push({label:`StepSet ${d.name}`,kind:Ee.Function,detail:`invoke step set \xB7 ${d.steps.length} step(s)`,documentation:d.steps.join(`
37
+ `),filterText:`step set ${d.name}`,sortText:`1${d.name}`,textEdit:{range:a,newText:`StepSet ${d.name}`}});return c}return[]}import qn from"node:fs";import zs from"node:net";import Hn from"node:path";import{chromium as Vs,firefox as Ws,webkit as qs}from"playwright";var _s=5e3,Fs=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]),Ke=class{pendingTabOpen=!1;pendingTabFallback;lastOpener;lastOpenerAt=0;inFlight=new Set;steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(t){this.steps=t.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(t){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===t.action&&i.value===t.value&&i.promptText===t.promptText))return;let s=Math.max(r.actions.length-1,0);r.actions.splice(s,0,t)}beginToolCall(t){this.inFlight.add(t)}endToolCall(t,n){this.inFlight.delete(t)&&(this.pendingTabOpen&&n&&(this.pendingTabFallback=void 0),this.inFlight.size===0&&this.pendingTabOpen&&(this.pendingTabFallback&&(this.pendingTabFallback.opensTab=!0),this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}markTabOpened(){let t=this.inFlight.size===0,n=Date.now()-this.lastOpenerAt<=_s;if(t){this.lastOpener&&n&&(this.lastOpener.opensTab=!0);return}this.pendingTabOpen=!0,this.pendingTabFallback=n?this.lastOpener:void 0}addActions(t){if(this.done||t.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r)return;this.pendingFingerprint&&t[0]&&(t[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...t);let o=[...t].reverse().find(s=>Fs.has(s.action));o&&(this.lastOpener=o,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(o.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},me=e=>e.replace(/\\(['"\\])/g,"$1");function js(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?me(t[1]):void 0}function Rt(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=me(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=e.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){t.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(t.name=me(i[1]))}let o=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of o){let c=e.match(i);c&&(t[a]=me(c[1]))}let s=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return s&&(t.fallbackSelectors=[me(s[1])]),Object.keys(t).length>0?t:void 0}function Is(e,t){if(t&&e.startsWith(t)){let n=e.slice(t.length);return n.startsWith("/")?n:`/${n}`}return e}var Os={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function Ds(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Is(me(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:me(r[1])};let o=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(o){let h=Rt(o[1]),u=Rt(o[2]);if(h&&u)return{action:"dragTo",target:h,toTarget:u}}let s=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!s)return;let[,i,a,c]=s,d=Rt(i);if(!d)return;let f={action:Os[a],target:d},l=js(c);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(f.value=l),f}function jn(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let s of r){let i=s?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let o=[];for(let s of n)for(let i of s.split(`
38
+ `)){let a=Ds(i.trim(),t);a&&o.push(a)}return Ms(o)}function Ms(e){return e.filter((t,n)=>{let r=e[n+1];return!(t.action==="fill"&&(t.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(t.target))})}function In(e){let t=[];for(let n of e){if(n.kind==="assertion"&&n.actions.length===0){t.push(`Step "${n.gherkin}" is an assertion but recorded no check, so nothing would be verified. Re-record with: saffron run --rerecord`);continue}if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let o of n.actions)if(o.action==="fill"&&(o.value??"")===""){t.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 t}function At(e,t){let n=e;for(let[r,o]of Object.entries(t).filter(([,s])=>s.length>=3).sort((s,i)=>i[1].length-s[1].length))n=n.split(o).join(`<${r}>`);return n}function On(e,t){let n=Object.entries(t).filter(([,i])=>i.length>=3);if(n.length===0)return e;let r=i=>i===void 0?void 0:At(i,t),o=[...n].sort((i,a)=>a[1].length-i[1].length),s=i=>{if(i!==void 0){for(let[a,c]of o)if(i.includes(c))return`<${a}>`;return i}};return e.map(i=>({...i,actions:i.actions.map(a=>{let c=Z.has(a.action)&&a.action!=="expectValue";return{...a,value:c?s(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:c?s(a.target.name):r(a.target.name),label:r(a.target.label),text:c?s(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function Dn(e,t){let n=se(t.steps);return e.map((r,o)=>{let s=t.steps[o],i=s?.resolvedTable??s?.table,a=s?.resolvedDocString??s?.docString,c=Object.entries({...n,...N(i),...fe(a)}).filter(([,f])=>f.length>=3).sort((f,l)=>l[1].length-f[1].length);if(c.length===0)return r;let d=f=>{if(f===void 0)return;let l=f;for(let[h,u]of c)l=l.split(u).join(`<${h}>`);return l};return{...r,actions:r.actions.map(f=>({...f,value:d(f.value),url:d(f.url),target:f.target?{...f.target,name:d(f.target.name),label:d(f.target.label),text:d(f.target.text),placeholder:d(f.target.placeholder)}:void 0}))}})}function Mn(e,t=new Date){let n=r=>r===void 0?void 0:cn(r,t);return e.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text)}:void 0}))}))}function Nn(e,t){if(t.size===0)return e;let n=r=>r===void 0?void 0:Se(r,t);return e.map(r=>({...r,actions:r.actions.map(o=>({...o,value:n(o.value),url:n(o.url),pattern:n(o.pattern),target:o.target?{...o.target,name:n(o.target.name),nameRegex:n(o.target.nameRegex),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function Ln(e,t){if(e!=="mcp__playwright__browser_tabs")return;let n=t??{},r=typeof n.index=="number"?String(n.index):void 0;switch(n.action){case"select":return r===void 0?void 0:{action:"switchTab",value:r};case"close":return r===void 0?{action:"closeTab"}:{action:"closeTab",value:r};case"new":return typeof n.url=="string"&&n.url?{action:"newTab",url:n.url}:{action:"newTab"};default:return}}function Un(e,t){let n=t??{};if(e==="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(e==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let o=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(o)?i.slice(o.length):i).join(`
39
+ `)}}}function Ce(e){return Array.isArray(e)?e.map(Ce):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).filter(([t,n])=>n!==void 0&&t!=="pageFingerprint").sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>[t,Ce(n)])):e}var Ns=e=>JSON.stringify(Ce(e));function Et(e,t="",n={}){if(Array.isArray(e))n[t]=e.map(r=>String(r)).join(", ");else if(e&&typeof e=="object")for(let[r,o]of Object.entries(e))Et(o,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function Ls(e,t){let n=Et(Ce(e)),r=Et(Ce(t)),o=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),s=[];for(let i of o){if(i==="action"||n[i]===r[i])continue;let a=i.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[i]===void 0?s.push(`${a}: added "${r[i]}"`):r[i]===void 0?s.push(`${a}: removed "${n[i]}"`):s.push(`${a}: "${n[i]}" \u2192 "${r[i]}"`)}return s}function Vn(e,t,n){let r=e.map(n),o=t.map(n),s=Array.from({length:r.length+1},()=>new Array(o.length+1).fill(0));for(let d=r.length-1;d>=0;d--)for(let f=o.length-1;f>=0;f--)s[d][f]=r[d]===o[f]?s[d+1][f+1]+1:Math.max(s[d+1][f],s[d][f+1]);let i=[],a=0,c=0;for(;a<r.length&&c<o.length;)r[a]===o[c]?(i.push({before:e[a],after:t[c]}),a++,c++):s[a+1][c]>=s[a][c+1]?(i.push({before:e[a]}),a++):(i.push({after:t[c]}),c++);for(;a<r.length;a++)i.push({before:e[a]});for(;c<o.length;c++)i.push({after:t[c]});return i}function Bn(e){if(!e)return"";let t=[];if(e.role){let n=e.name?` "${e.name}"`:e.nameRegex?` matching /${e.nameRegex}/`:"";t.push(`${e.role}${n}`)}else e.label?t.push(`label "${e.label}"`):e.text?t.push(`text "${e.text}"`):e.placeholder?t.push(`placeholder "${e.placeholder}"`):e.testId&&t.push(`test id ${e.testId}`);return e.fallbackSelectors?.length&&t.push(`[${e.fallbackSelectors.join(", ")}]`),e.frame&&t.push(`inside frame ${e.frame}`),t.join(" ")}var zn=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function be(e){let t=Bn(e.target),n=e.value??"",r=(()=>{switch(e.action){case"goto":return`go to ${e.url}`;case"click":return`click ${t}`;case"fill":return`fill ${t} with "${n}"`;case"press":return t?`press ${n} on ${t}`:`press ${n}`;case"selectOption":return`select "${n}" in ${t}`;case"check":return`check ${t}`;case"uncheck":return`uncheck ${t}`;case"hover":return`hover ${t}`;case"waitFor":return t?`wait for ${t}`:`wait ${n}ms`;case"captureText":return`remember ${t} as "${e.saveAs}"`;case"handleDialog":return`${n} the dialog${e.promptText?` with "${e.promptText}"`:""}`;case"uploadFile":return`upload ${n}`;case"dragTo":return`drag ${t} onto ${Bn(e.toTarget)}`;case"waitForResponse":return`wait for ${zn(e)}`;case"switchTab":return`switch to tab ${n}`;case"closeTab":return`close tab ${n||"(the current one)"}`;case"newTab":return`open a new tab${e.url?` at ${e.url}`:""}`;case"expectVisible":return`expect ${t} to be visible`;case"expectNotVisible":return`expect ${t} to be gone`;case"expectText":return`expect ${t} to contain "${n}"`;case"expectUrl":return`expect the URL to contain "${e.url??n}"`;case"expectValue":return`expect ${t} to hold "${n}"`;case"expectDiffers":return`expect ${t||`"${n}"`} to differ from "${e.compareTo}"`;case"expectMatches":return`expect ${t} to match /${e.pattern}/`;case"expectAttribute":return`expect ${t} ${e.attribute} to match /${e.pattern}/`;case"expectResponse":return`expect a response ${zn(e)}`;default:return e.action}})();return e.opensTab?`${r}, opening a tab`:r}var Pe=e=>Z.has(e.action);function Us(e,t){let n=Vn(e,t,Ns),r=[];for(let o=0;o<n.length;o++){let s=n[o];if(s.before&&s.after){r.push({change:"same",before:be(s.before),after:be(s.after),assertion:Pe(s.after)});continue}let i=n[o+1];if(s.before&&i?.after&&!i.before){r.push({change:"changed",before:be(s.before),after:be(i.after),details:Ls(s.before,i.after),assertion:Pe(s.before)||Pe(i.after)}),o++;continue}s.before&&r.push({change:"removed",before:be(s.before),assertion:Pe(s.before)}),s.after&&r.push({change:"added",after:be(s.after),assertion:Pe(s.after)})}return r}var Bs=e=>`${e.kind}\0${e.gherkin}`;function Ye(e,t){let n=Vn(e?.steps??[],t.steps,Bs),r=[],o={added:0,removed:0,changed:0,assertions:0};for(let s of n){let i=s.after??s.before,a=Us(s.before?.actions??[],s.after?.actions??[]);for(let d of a)d.change==="added"?o.added++:d.change==="removed"?o.removed++:d.change==="changed"&&o.changed++,d.change!=="same"&&d.assertion&&o.assertions++;let c=e&&s.before?s.after?a.some(d=>d.change!=="same")?"changed":"same":"removed":"added";r.push({gherkin:i.gherkin,kind:i.kind,fromStepSet:i.fromStepSet,change:c,actions:a})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:r,summary:o}}function Wn(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let i of e.steps){let a=i.change==="removed"?"- ":i.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${a}${i.kind==="assertion"?"[assertion] ":""}${i.gherkin}`+(i.fromStepSet?` (from step set "${i.fromStepSet}")`:""));for(let c of i.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let d of c.details??[])t.push(` ${d}`)}t.push("")}let{added:n,removed:r,changed:o,assertions:s}=e.summary;return t.push(n+r+o===0?"No action changed.":`${o} changed, ${n} added, ${r} removed.`+(s>0?` ${s} of them ${s===1?"is an assertion":"are assertions"}: read those first.`:"")),t.join(`
40
+ `)}var xe={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Hs(){return new Promise((e,t)=>{let n=zs.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function _e(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function Yn(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function Jn(e,t){return e[t]?.kind==="assertion"&&t>=Yn(e)}function Gn(e,t,n=!1,r){let o=Yn(e);return e.map((s,i)=>{let a=t[i],c=a&&a.actions.length>0;return s.kind==="assertion"?n&&i<o&&c?a:s:c?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...s.actions.slice(0,r.actionIndex),...a.actions]}:a:s})}function Js(e,t){return e.map((n,r)=>{let o=t[r];return o&&o.actions.length>0?o:n})}function Kn(e,t){return{aiCalls:e.aiCalls+t.aiCalls,inputTokens:e.inputTokens+t.inputTokens,outputTokens:e.outputTokens+t.outputTokens,cacheReadTokens:e.cacheReadTokens+t.cacheReadTokens,cacheCreationTokens:e.cacheCreationTokens+t.cacheCreationTokens,costUsd:(e.costUsd??0)+(t.costUsd??0),models:e.models||t.models?[...new Set([...e.models??[],...t.models??[]])]:void 0}}var Qe=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=X(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=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)${t}`)}return this.stepIndex}async recordWithSeeding(t,n){let r=this.getStepIndex(),o=t.steps.length,s={...t.params,...se(t.steps)},i=t.steps.map(b=>{let S=r.steps.size>0?Jt(r,b,s):void 0;return S?{gherkin:b.text,keyword:b.keyword,kind:b.kind,table:b.table,docString:b.docString,fromStepSet:b.source?.fromStepSet,actions:S.actions}:void 0}),a=t.steps.map(b=>({gherkin:b.text,keyword:b.keyword,kind:b.kind,table:b.table,docString:b.docString,fromStepSet:b.source?.fromStepSet,actions:[]})),c=new Map,d=[],f=[],l=[],h=[],u={...xe},p=0,m=0,k=0,y=b=>({success:b,narrative:f.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:h.length>0?h.join(`
41
+ `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:u,seededCount:p});for(;k<o;){if(i[k]){let $=k;for(;$<o&&i[$];)$++;let F=i.slice(k,$),v={...t,steps:t.steps.slice(k,$)},T=await Ae(n,v,this.buildCandidateCache(t,F),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,vars:c}),w=T.status==="passed"?$-k:T.failure?.stepIndex??0;for(let j=0;j<w;j++)a[k+j]=F[j];if(p+=w,T.status==="passed"){k=$;continue}let R=k+w;d.push(`Seeded recording for "${t.steps[R].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[R]=void 0,k=R;continue}if(!this.options.agent)return y(!1);m++;let b=k;for(;b<o&&!i[b];)b++;m>=3&&(b=o);let S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:k>0?t.steps.slice(0,k).map($=>$.resolvedText):void 0,recordOnly:b<o?{from:k,to:b-1}:void 0,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>ue(_e(this.browser)??n)}));if(u=Kn(u,S.usage),f.push(S.narrative),d.push(...S.adaptations),l.push(...S.featureEdits),S.suggestedFeatureEdit&&h.push(S.suggestedFeatureEdit),!S.success)return y(!1);let x=S.announcedSteps.filter($=>$>=k&&$<o),g=Math.min(o-1,Math.max(x.length>0?Math.max(...x):b-1,k));for(let $=k;$<=g;$++)a[$]=S.recordedSteps[$];k=g+1}return y(!0)}async withDialogsVisible(t){let n=()=>{},r=[],o=[],s=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",s),o.push(i);for(let a of i.pages())s(a)}try{return await t()}finally{for(let i of o)i.off("page",s);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(t==="chromium"){let n=await Hs();this.browser=await Vs.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${n}`]}),this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(t==="firefox"?Ws:qs).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 t=Hn.isAbsolute(this.options.storageState)?this.options.storageState:Hn.join(this.options.projectRoot,this.options.storageState);if(!qn.existsSync(t))throw new Error(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return t}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n}}tabWatcher(){return t=>{let n=this.browser?.contexts()??[],r=()=>t();for(let o of n)o.on("page",r);return()=>{for(let o of n)o.off("page",r)}}}async proofReplay(t,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),o=await r.newPage();return{result:await Ae(o,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs}),context:r,page:o}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),o=Re(t);if(o.size>0){let s=i=>i===void 0?i:Se(i,o);r.error=s(r.error),r.narrative=s(r.narrative),r.proofError=s(r.proofError),r.suggestedFeatureEdit=s(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>Se(i,o));for(let i of r.stepResults)i.error=s(i.error)}return r}async runScenarioInner(t,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:o,baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}=this.options,d=L(o,t.featurePath,t.name),f=U(d),l=ie(o,t.featurePath,t.name),h=!f&&qn.existsSync(l)?ge(l):void 0,u=await this.browser.newContext({storageState:this.resolveStorageState()}),p=await u.newPage();try{let m;if(h){let x=await Ae(p,t,h.cache,{baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}),g=h.meta.recordedFor??t.name;if(x.status==="passed")return{scenario:t,status:"yellow",source:"agent-record",stepResults:x.stepResults,narrative:`Replayed the pending proposal recorded for "${g}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:l,usage:xe,durationMs:Date.now()-r};if(Object.keys(t.params).length>0&&g!==t.displayName){let F=x.failure;return{scenario:t,status:"red",source:"agent-record",stepResults:x.stepResults,adaptations:[],proposalFile:l,usage:xe,durationMs:Date.now()-r,error:`The pending proposal recorded for "${g}" does not replay for this Examples row`+(F?` (step "${F.stepText}": ${F.error.split(`
42
+ `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+(x.failure?` (step "${x.failure.stepText}": ${x.failure.error.split(`
43
+ `)[0]})`:"")+"; recording again"),await p.goto("about:blank")}if(f&&(m=await Ae(p,t,f,{baseURL:s,actionTimeoutMs:i,retries:a,pollIntervalMs:c}),m.status==="passed"))return{scenario:t,status:"green",source:"cache",stepResults:m.stepResults,adaptations:[],usage:xe,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let x=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:t,status:"red",source:"cache",stepResults:m?.stepResults??[],adaptations:[],usage:xe,durationMs:Date.now()-r,error:f?m?.status==="stale"?`Cache is stale (feature file changed) and ${x}`:`Replay failed and ${x} ${m?.failure?.error??""}`.trimEnd():`No cache exists and ${x}`}}let y=f!==void 0&&m!==void 0&&m.status==="failed",b=0,S;try{if(y)S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:s,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:m.stepResults.filter(x=>x.status==="passed").map(x=>x.gherkin),failedStep:{text:m.failure.stepText,kind:m.failure.kind,error:m.failure.error},onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let x=_e(this.browser)??p;return ue(x)}}));else if(await p.goto("about:blank"),this.options.reuseSteps!==!1){let x=await this.recordWithSeeding(t,p);b=x.seededCount,S=x}else S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:s,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let x=_e(this.browser)??p;return ue(x)}}))}catch(x){let g=x instanceof Error?x.message:String(x);return{scenario:t,status:"red",source:y?"agent-heal":"agent-record",stepResults:m?.stepResults??[],adaptations:[],usage:xe,durationMs:Date.now()-r,error:`Agent invocation failed: ${g.split(`
44
+ `)[0]}`}}if(S.success&&y&&m.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Jn(f.steps,m.failure.stepIndex))){let x=f.steps[m.failure.stepIndex],g=_e(this.browser)??p,$=new Map(Object.entries(m.captured)),F=vt(t,m.failure.stepIndex);try{for(let v of x.actions)await bt(g,v,F,this.options.actionTimeoutMs??5e3,s,$)}catch(v){let T=v instanceof Error?v.message:String(v);return{scenario:t,status:"red",source:"agent-heal",stepResults:m.stepResults,narrative:S.narrative,adaptations:S.adaptations,suggestedFeatureEdit:S.suggestedFeatureEdit,usage:S.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${x.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: ${T.split(`
45
+ `)[0]}`}}}return await this.finalizeAgentRun(t,S,y,f,r,b,y?m?.failure:void 0)}finally{await u.close()}}async finalizeAgentRun(t,n,r,o,s,i=0,a){let c=r?"agent-heal":"agent-record",d=n.recordedSteps.map(w=>({gherkin:w.gherkin,kind:w.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:t,status:"red",source:c,stepResults:d,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-s,error:n.narrative};let f=r?Gn(o.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],h={...n.usage},u,p,m;if(this.options.verifyProposals!==!1){let w=await this.proofReplay(t,this.buildCandidateCache(t,f));try{if(w.result.status!=="passed"&&w.result.failure){let R=w.result.failure;if(this.options.agent&&!(r&&R.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Jn(o.steps,R.stepIndex))))try{let M=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,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(D=>D.status==="passed").map(D=>D.gherkin),failedStep:{text:R.stepText,kind:R.kind,error:R.error},onTabOpened:this.tabWatcher(),takeFingerprint:async()=>ue(_e(this.browser)??w.page)}));h=Kn(h,M.usage),M.success&&(l.push(...M.adaptations),f=r?Gn(f,M.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Js(f,M.recordedSteps),await w.context.close(),w=await this.proofReplay(t,this.buildCandidateCache(t,f)))}catch{}}for(let R of w.result.tabOpeners??[]){let j=f[R.stepIndex]?.actions[R.actionIndex];j&&(j.opensTab=!0)}u=w.result.status==="passed",u||(p=w.result.failure?.error??`proof replay ${w.result.status}`,m=w.result.stepResults)}finally{await w.context.close().catch(()=>{})}}let k=[...n.featureEdits];for(let w of Gt(f,X(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.`),k.some(R=>R.index===w.index)||k.push({index:w.index,text:w.to});let y=In(f);y.length>0&&(u=!1,p=[p,...y].filter(Boolean).join(" "));let b=Re(t),S=w=>b.size>0?Se(w,b):w;l=l.map(S);let x=k.map(w=>({index:w.index,text:S(w.text)})),g=S(n.narrative),$=n.suggestedFeatureEdit?S(n.suggestedFeatureEdit):void 0,F={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:g,adaptations:l,suggestedFeatureEdit:$,featureEdits:x.length>0?x:void 0,verified:u,proofError:p?S(p):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:h},cache:{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:f}},v=ie(this.options.projectRoot,t.featurePath,t.name);un(v,F);let T=U(L(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:Ye(T,F.cache),scenario:t,status:"yellow",source:c,stepResults:m??d,narrative:g,adaptations:l,suggestedFeatureEdit:$,featureEdits:x.length>0?x:void 0,verified:u,proofError:p,seededSteps:i>0?i:void 0,proposalFile:v,usage:h,durationMs:Date.now()-s}}};import rr from"node:path";import{createRequire as Zs}from"node:module";import{query as eo}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Gs,tool as ve}from"@anthropic-ai/claude-agent-sdk";import{z as E}from"zod";var V=e=>({content:[{type:"text",text:e}]});function Qn(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var Xn={role:E.string().optional().describe("ARIA role of the element"),name:E.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:E.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:E.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:E.string().optional().describe("getByText locator text"),selector:E.string().optional().describe("CSS fallback selector"),frame:E.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 Zn(e){return Gs({name:"saffron",version:"0.1.0",tools:[ve("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:E.number().int().min(0),text:E.string(),adaptation:E.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 t=>(e.currentStepIndex=t.index,e.announced.add(t.index),t.adaptation&&e.adaptations.push(`Step ${t.index+1} ("${t.text}"): ${t.adaptation}`),V(`Recording step ${t.index}: ${t.text}`))),ve("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:E.string().describe("Name to store the captured text under"),...Xn},async t=>{let n=Qn(t);return n?(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),V(`Capture recorded as "${t.saveAs}".`)):V("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ve("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:E.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:E.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),V(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),ve("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:E.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:E.string().optional().describe("HTTP method to match (any if omitted)"),status:E.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:E.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),V("Network wait recorded."))),ve("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:E.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Xn,expected:E.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:E.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:E.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:E.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:E.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:E.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:E.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:E.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:E.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:E.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=Qn(t),r;switch(t.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:t.expected??""};break;case"value":r={action:"expectValue",target:n,value:t.expected??""};break;case"url":r={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return V("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return V("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return V("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return V("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"response":if(!t.urlPattern)return V("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?V("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([r]),V("Assertion recorded."))}),ve("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:E.boolean(),narrative:E.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:E.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:E.array(E.object({index:E.number().int().min(0).describe("zero-based step index"),text:E.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 t=>(e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],V("Run finalized.")))]})}function er(e={}){let t=e.deliberateSnapshots?`
46
46
  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.
47
47
  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.
48
48
 
@@ -61,14 +61,14 @@ Non-negotiable rules:
61
61
  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.
62
62
  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.
63
63
  5f. TABS: a click that opens a new tab needs nothing extra, replay follows the new tab by itself. When a step says to switch to another tab, go back to the first tab, or close a tab, do it with browser_tabs (list first if unsure of the index); select, close and new are recorded and replay in the same order. Steps that assert on the other tab's content are ordinary assertions after the switch.
64
- 7. Never ask the user questions; you are unattended.${t}`}function Ws(e,t){if(e[t].kind!=="assertion")return"action";let n=e.length;for(let r=e.length-1;r>=0&&e[r].kind==="assertion";r--)n=r;return t>=n?"assertion:final":"assertion"}function Qn(e){let{scenario:t}=e,n=t.steps.map((o,s)=>{let i=d=>ke(d),a=`${s}. [${Ws(t.steps,s)}] ${o.keyword} ${i(o.resolvedText)}`,c=o.resolvedTable??o.table,l=o.resolvedDocString??o.docString,p=a;return c&&(p+=`
65
- ${c.map(d=>` | ${d.map(i).join(" | ")} |`).join(`
66
- `)}`),l!==void 0&&(p+=`
64
+ 7. Never ask the user questions; you are unattended.${t}`}function Ks(e,t){if(e[t].kind!=="assertion")return"action";let n=e.length;for(let r=e.length-1;r>=0&&e[r].kind==="assertion";r--)n=r;return t>=n?"assertion:final":"assertion"}function tr(e){let{scenario:t}=e,n=t.steps.map((o,s)=>{let i=l=>Te(l),a=`${s}. [${Ks(t.steps,s)}] ${o.keyword} ${i(o.resolvedText)}`,c=o.resolvedTable??o.table,d=o.resolvedDocString??o.docString,f=a;return c&&(f+=`
65
+ ${c.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
66
+ `)}`),d!==void 0&&(f+=`
67
67
  """
68
- ${i(l).split(`
69
- `).map(d=>` ${d}`).join(`
68
+ ${i(d).split(`
69
+ `).map(l=>` ${l}`).join(`
70
70
  `)}
71
- """`),p}).join(`
71
+ """`),f}).join(`
72
72
  `),r=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(e.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.","",`Steps that replayed fine:
73
73
  ${(e.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
74
74
  `)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Replay failure: ${e.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.");else if(e.mode==="heal")r.push("MODE: HEAL. A cached deterministic replay of this scenario failed mid-run.","The browser tab already holds the page state reached so far \u2014 do NOT restart from step 0; verify where you are with a snapshot and continue from the failed step.","",`Steps already completed by replay:
@@ -77,25 +77,25 @@ ${(e.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
77
77
  ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
78
78
  `)}`:"You are starting from a blank page.","",`Record ONLY steps ${o} through ${s} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${s} already have recordings and will be replayed automatically the moment you stop. After completing step ${s}, 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(e.completedSteps&&e.completedSteps.length>0){let o=e.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:",e.completedSteps.map(s=>` \u2713 ${s}`).join(`
79
79
  `),"",`The browser currently shows the state after those ${o} 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 ${o} 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(o=>o!==void 0).join(`
80
- `)}import{query as qs}from"@anthropic-ai/claude-agent-sdk";var Hs="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function wt(e,t=8e3){let n=e?.[Hs];if(typeof n!="function")return;let r;try{let o=await Promise.race([n.call(e),new Promise(s=>{r=setTimeout(()=>s(void 0),t),r.unref?.()})]);return Js(o)}catch{return}finally{r&&clearTimeout(r)}}function Js(e){if(!e||!e.rate_limits_available||!e.subscription_type)return;let t=e.rate_limits?.five_hour;return{subscriptionType:e.subscription_type,utilization:typeof t?.utilization=="number"?t.utilization:void 0,resetsAt:t?.resets_at??void 0}}async function Xn(){let e=()=>{},t=new Promise(o=>e=o);async function*n(){await t}let r;try{let o=qs({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=o;let s=(async()=>{try{for await(let a of o);}catch{}})(),i=await wt(o);return e(),r.close?.(),await s,i}catch{e(),r?.close?.();return}}var Ks=Gs(import.meta.url);function Qs(){let e=Ks.resolve("@playwright/mcp/package.json");return Zn.join(Zn.dirname(e),"cli.js")}var Xs=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"]),Zs=new Set(["mcp__playwright__browser_snapshot","mcp__playwright__browser_wait_for","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_console_messages","mcp__playwright__browser_network_requests","mcp__saffron__saffron_step","mcp__saffron__saffron_capture","mcp__saffron__saffron_dialog","mcp__saffron__saffron_wait_api","mcp__saffron__saffron_assert","mcp__saffron__saffron_done"]);function er(e,t){if(Zs.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Je=class{constructor(t={}){this.opts=t}opts;planBefore;async planUsage(){if(!this.planBefore)return;let t=await this.planBefore;if(!t)return;let n=await Xn();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=new We(t.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},o="",s=Ys({prompt:Qn(t),options:{systemPrompt:Kn({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),model:t.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:[Qs(),"--cdp-endpoint",t.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Yn(n)},hooks:{PreToolUse:[{hooks:[async a=>{let c=a;if(n.beginToolCall(c.tool_use_id),Xs.has(c.tool_name)&&t.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await t.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async a=>{let c=a,l=Dn(c.tool_name,c.tool_input);l&&n.addArmedAction(l);let p=On(c.tool_name,c.tool_input);return p&&n.addActions([p]),c.tool_name.startsWith("mcp__playwright__")&&(n.addActions(Pn(c.tool_response,t.baseURL)),n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,er(c.tool_name,c.tool_input)),{continue:!0}}]}],PostToolUseFailure:[{hooks:[async a=>{let c=a;return c.tool_name.startsWith("mcp__playwright__")&&(n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,er(c.tool_name,c.tool_input)),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=wt(s));let i=t.onTabOpened?.(()=>n.markTabOpened());try{for await(let a of s)a.type==="result"&&(r.aiCalls=a.num_turns,r.costUsd=a.total_cost_usd,"modelUsage"in a&&a.modelUsage&&(r.models=Object.keys(a.modelUsage)),"usage"in a&&a.usage&&(r.inputTokens=a.usage.input_tokens??0,r.outputTokens=a.usage.output_tokens??0,r.cacheReadTokens=a.usage.cache_read_input_tokens??0,r.cacheCreationTokens=a.usage.cache_creation_input_tokens??0),a.subtype==="success"&&(o=a.result))}catch(a){let c=a instanceof Error?a.message:String(a);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${c}`}finally{i?.()}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${o.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(a=>({index:a.index,text:xt(a.text,t.scenario.params)})),recordedSteps:In(jn(_n(Fn(n.steps,t.scenario),t.scenario.params)),$e(t.scenario)),announcedSteps:[...n.announced].sort((a,c)=>a-c),usage:r}}};function Ge(e){return`$${e.toFixed(e>=1?2:4)}`}function kt(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function tr(e){return e.plan?` \xB7 5-hour window ${kt(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${Ge(e.costUsd)} at API rates`:` \xB7 ${Ge(e.costUsd)}`}function nr(e,t){return e?t?`\u2248 ${Ge(e)}`:Ge(e):""}function rr(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import sr from"node:fs";import Ye from"node:path";function Ke(e){return JSON.stringify([e.role??null,e.name??null,e.nameRegex??null,e.label??null,e.text??null,e.testId??null,e.placeholder??null])}function or(e,t){let n=new Map,r=Math.min(e.steps.length,t.steps.length);for(let o=0;o<r;o++){let s=e.steps[o].actions,i=t.steps[o].actions;if(s.length===i.length)for(let a=0;a<s.length;a++){let c=s[a],l=i[a];if(c.action!==l.action||!c.target||!l.target)continue;let p=Ke(c.target);p!==Ke(l.target)&&n.set(p,{from:c.target,to:l.target})}}return[...n.values()]}function eo(e){let t=Ye.join(e,".saffron","cache");if(!sr.existsSync(t))return[];let n=[];for(let r of sr.readdirSync(t,{recursive:!0})){let o=String(r);o.endsWith(".json")&&n.push(Ye.join(t,o))}return n}function ir(e,t,n,r){if(t.length===0)return[];let o=new Map(t.map(i=>[Ke(i.from),i])),s=[];for(let i of eo(e)){if(Ye.resolve(i)===Ye.resolve(n))continue;let a=N(i);if(!a)continue;let c=[],l=!1;for(let p of a.steps){let d=0;for(let f of p.actions){if(!f.target)continue;let g=o.get(Ke(f.target));g&&(d++,r||(f.target=structuredClone(g.to),l=!0))}d>0&&c.push({gherkin:p.gherkin,count:d})}c.length>0&&(l&&he(i,a),s.push({file:i,scenario:a.scenario,feature:a.feature,steps:c}))}return s}function $t(e){return e.role?`${e.role} "${e.name??e.nameRegex??""}"`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testId "${e.testId}"`:(e.fallbackSelectors??[]).join(",")||"(empty target)"}import ar from"node:fs";import Tt from"node:path";function cr(e,t,n,r,o){let s={applied:[],skipped:[]},i=z(Tt.join(e,t),e,o).find(l=>l.name===n);if(!i){for(let l of r)s.skipped.push({index:l.index,reason:`scenario "${n}" not found in ${t}`});return s}let a=new Map,c=new Set;for(let l of r){let p=i.steps[l.index];if(!p?.source){s.skipped.push({index:l.index,reason:`no step at index ${l.index}`});continue}let{file:d,line:f,fromStepSet:g,fromBackground:h}=p.source;if(h){s.skipped.push({index:l.index,reason:`"${p.text}" is a Background step shared across scenarios; edit it manually`});continue}let x=`${d}:${f}`;if(c.has(x))continue;let k=a.get(d);k||(k=ar.readFileSync(Tt.join(e,d),"utf8").split(`
81
- `),a.set(d,k));let y=k[f-1],b=y?.match(/^(\s*)(\S+)\s+(.*)$/);if(!b||b[3]!==p.text){s.skipped.push({index:l.index,reason:`${d}:${f} no longer matches the recorded step text`});continue}let S=`${b[1]}${b[2]} ${l.text}`;k[f-1]=S,c.add(x),s.applied.push({file:d,line:f,from:y.trim(),to:S.trim(),fromStepSet:g})}for(let[l,p]of a)s.applied.some(d=>d.file===l)&&ar.writeFileSync(Tt.join(e,l),p.join(`
82
- `));return s}import At from"node:fs";import Et from"node:path";var _=e=>e.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),dr="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",to="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 no(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
83
- <path d="${dr}" fill="#F4A300"/>
84
- <path d="${to}" fill="#E7E9EC"/>
85
- </svg>`}function ro(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${dr}" fill="#F4A300"/></svg>`}var so={green:"passed",yellow:"pending review",red:"failed"};function Qe(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function Ce(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}function re(e,t,n,r="",o="",s=""){return`<div class="kpi">
80
+ `)}import{query as Ys}from"@anthropic-ai/claude-agent-sdk";var Qs="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function Pt(e,t=8e3){let n=e?.[Qs];if(typeof n!="function")return;let r;try{let o=await Promise.race([n.call(e),new Promise(s=>{r=setTimeout(()=>s(void 0),t),r.unref?.()})]);return Xs(o)}catch{return}finally{r&&clearTimeout(r)}}function Xs(e){if(!e||!e.rate_limits_available||!e.subscription_type)return;let t=e.rate_limits?.five_hour;return{subscriptionType:e.subscription_type,utilization:typeof t?.utilization=="number"?t.utilization:void 0,resetsAt:t?.resets_at??void 0}}async function nr(){let e=()=>{},t=new Promise(o=>e=o);async function*n(){await t}let r;try{let o=Ys({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=o;let s=(async()=>{try{for await(let a of o);}catch{}})(),i=await Pt(o);return e(),r.close?.(),await s,i}catch{e(),r?.close?.();return}}var to=Zs(import.meta.url);function no(){let e=to.resolve("@playwright/mcp/package.json");return rr.join(rr.dirname(e),"cli.js")}var ro=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"]),so=new Set(["mcp__playwright__browser_snapshot","mcp__playwright__browser_wait_for","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_console_messages","mcp__playwright__browser_network_requests","mcp__saffron__saffron_step","mcp__saffron__saffron_capture","mcp__saffron__saffron_dialog","mcp__saffron__saffron_wait_api","mcp__saffron__saffron_assert","mcp__saffron__saffron_done"]);function sr(e,t){if(so.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Xe=class{constructor(t={}){this.opts=t}opts;planBefore;async planUsage(){if(!this.planBefore)return;let t=await this.planBefore;if(!t)return;let n=await nr();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=new Ke(t.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},o="",s=eo({prompt:tr(t),options:{systemPrompt:er({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),model:t.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:[no(),"--cdp-endpoint",t.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Zn(n)},hooks:{PreToolUse:[{hooks:[async a=>{let c=a;if(n.beginToolCall(c.tool_use_id),ro.has(c.tool_name)&&t.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await t.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async a=>{let c=a,d=Un(c.tool_name,c.tool_input);d&&n.addArmedAction(d);let f=Ln(c.tool_name,c.tool_input);return f&&n.addActions([f]),c.tool_name.startsWith("mcp__playwright__")&&(n.addActions(jn(c.tool_response,t.baseURL)),n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,sr(c.tool_name,c.tool_input)),{continue:!0}}]}],PostToolUseFailure:[{hooks:[async a=>{let c=a;return c.tool_name.startsWith("mcp__playwright__")&&(n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,sr(c.tool_name,c.tool_input)),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=Pt(s));let i=t.onTabOpened?.(()=>n.markTabOpened());try{for await(let a of s)a.type==="result"&&(r.aiCalls=a.num_turns,r.costUsd=a.total_cost_usd,"modelUsage"in a&&a.modelUsage&&(r.models=Object.keys(a.modelUsage)),"usage"in a&&a.usage&&(r.inputTokens=a.usage.input_tokens??0,r.outputTokens=a.usage.output_tokens??0,r.cacheReadTokens=a.usage.cache_read_input_tokens??0,r.cacheCreationTokens=a.usage.cache_creation_input_tokens??0),a.subtype==="success"&&(o=a.result))}catch(a){let c=a instanceof Error?a.message:String(a);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${c}`}finally{i?.()}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${o.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(a=>({index:a.index,text:At(a.text,t.scenario.params)})),recordedSteps:Nn(Mn(On(Dn(n.steps,t.scenario),t.scenario.params)),Re(t.scenario)),announcedSteps:[...n.announced].sort((a,c)=>a-c),usage:r}}};function Ze(e){return`$${e.toFixed(e>=1?2:4)}`}function Ct(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function or(e){return e.plan?` \xB7 5-hour window ${Ct(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${Ze(e.costUsd)} at API rates`:` \xB7 ${Ze(e.costUsd)}`}function ir(e,t){return e?t?`\u2248 ${Ze(e)}`:Ze(e):""}function ar(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import cr from"node:fs";import et from"node:path";function tt(e){return JSON.stringify([e.role??null,e.name??null,e.nameRegex??null,e.label??null,e.text??null,e.testId??null,e.placeholder??null,e.frame??null])}function lr(e,t){let n=new Map,r=new Set,o=new Map,s=Math.min(e.steps.length,t.steps.length);for(let a=0;a<s;a++){if(e.steps[a].kind==="assertion"||t.steps[a].kind==="assertion"||e.steps[a].gherkin!==t.steps[a].gherkin)continue;let c=e.steps[a].actions,d=t.steps[a].actions;if(c.length===d.length)for(let f=0;f<c.length;f++){let l=c[f],h=d[f];if(l.action!==h.action||Z.has(l.action)||!l.target||!h.target)continue;let u=tt(l.target),p=JSON.stringify(h.target);o.has(u)&&o.get(u)!==p&&r.add(u),o.set(u,p),u!==tt(h.target)&&n.set(u,{from:l.target,to:h.target})}}let i=[...n.entries()];return{changes:i.filter(([a])=>!r.has(a)).map(([,a])=>a),ambiguous:i.filter(([a])=>r.has(a)).map(([,a])=>a.from)}}function oo(e){let t=et.join(e,".saffron","cache");if(!cr.existsSync(t))return[];let n=[];for(let r of cr.readdirSync(t,{recursive:!0})){let o=String(r);o.endsWith(".json")&&n.push(et.join(t,o))}return n}function dr(e,t,n,r){if(t.length===0)return[];let o=new Map,s=new Set;for(let a of t){let c=tt(a.from),d=o.get(c);d&&JSON.stringify(d.to)!==JSON.stringify(a.to)&&s.add(c),o.set(c,a)}for(let a of s)o.delete(a);let i=[];for(let a of oo(e)){if(et.resolve(a)===et.resolve(n))continue;let c=U(a);if(!c)continue;let d=[],f=!1;for(let l of c.steps){if(l.kind==="assertion")continue;let h=0;for(let u of l.actions){if(!u.target||Z.has(u.action))continue;let p=o.get(tt(u.target));p&&(h++,r||(u.target=structuredClone(p.to),f=!0))}h>0&&d.push({gherkin:l.gherkin,count:h})}d.length>0&&(f&&ye(a,c),i.push({file:a,scenario:c.scenario,feature:c.feature,steps:d}))}return i}function nt(e){return e.role?`${e.role} "${e.name??e.nameRegex??""}"`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testId "${e.testId}"`:(e.fallbackSelectors??[]).join(",")||"(empty target)"}import pr from"node:fs";import rt from"node:path";function fr(e,t,n,r,o){let s=W(rt.join(e,t),e,o).find(c=>c.name===n);if(!s)return`scenario "${n}" is no longer in ${t}`;let i=s.steps.map(c=>c.text);if(i.length!==r.length)return`the scenario has ${i.length} steps now and had ${r.length} when this was recorded`;let a=i.findIndex((c,d)=>c!==r[d]);return a<0?void 0:`step ${a+1} now reads "${i[a]}" and was recorded as "${r[a]}"`}function ur(e,t,n,r,o,s){let i={applied:[],skipped:[],appliedIndices:[]},a=W(rt.join(e,t),e,o).find(l=>l.name===n);if(!a){for(let l of r)i.skipped.push({index:l.index,reason:`scenario "${n}" not found in ${t}`});return i}let c=new Map;for(let l of r){let h=a.steps[l.index]?.source;if(!h||h.fromBackground)continue;let u=`${h.file}:${h.line}`;c.set(u,(c.get(u)??new Set).add(l.text))}let d=new Map,f=new Set;for(let l of r){let h=a.steps[l.index];if(!h?.source){i.skipped.push({index:l.index,reason:`no step at index ${l.index}`});continue}if(s&&s[l.index]!==h.text){i.skipped.push({index:l.index,reason:`step ${l.index+1} now reads "${h.text}", not the "${s[l.index]??"(nothing)"}" this edit was written for`});continue}let{file:u,line:p,fromStepSet:m,fromBackground:k}=h.source;if(k){i.skipped.push({index:l.index,reason:`"${h.text}" is a Background step shared across scenarios; edit it manually`});continue}let y=`${u}:${p}`,b=c.get(y);if(b.size>1){i.skipped.push({index:l.index,reason:`${y}${m?` (StepSet "${m}")`:""} is reached by several steps and the edits disagree: ${[...b].map(F=>`"${F}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(f.has(y)){i.appliedIndices.push(l.index);continue}let S=d.get(u);S||(S=pr.readFileSync(rt.join(e,u),"utf8").split(`
81
+ `),d.set(u,S));let x=S[p-1],g=x?.match(/^(\s*)(\S+)\s+(.*)$/);if(!g||g[3]!==h.text){i.skipped.push({index:l.index,reason:`${u}:${p} no longer matches the recorded step text`});continue}let $=`${g[1]}${g[2]} ${l.text}`;S[p-1]=$,f.add(y),i.appliedIndices.push(l.index),i.applied.push({file:u,line:p,from:x.trim(),to:$.trim(),fromStepSet:m})}for(let[l,h]of d)i.applied.some(u=>u.file===l)&&pr.writeFileSync(rt.join(e,l),h.join(`
82
+ `));return i}import Ft from"node:fs";import jt from"node:path";var _=e=>e.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),hr="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",io="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 ao(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
83
+ <path d="${hr}" fill="#F4A300"/>
84
+ <path d="${io}" fill="#E7E9EC"/>
85
+ </svg>`}function co(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${hr}" fill="#F4A300"/></svg>`}var lo={green:"passed",yellow:"pending review",red:"failed"};function st(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function Fe(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}function re(e,t,n,r="",o="",s=""){return`<div class="kpi">
86
86
  <div class="kpi-label">${e}</div>
87
87
  <div class="kpi-value ${r}">${t}</div>
88
88
  <div class="kpi-sub ${o}">${n}</div>${s}
89
- </div>`}function Rt(e,t){if(e.length<2)return"";let n=108,r=22,o=Math.min(...e),i=Math.max(...e)-o||1,a=e.map((c,l)=>`${(l/(e.length-1)*n).toFixed(1)},${(r-2-(c-o)/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="${t}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var lr=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function oo(e,t){let n=e.totals,r=e.trends,o=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),s=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${lr(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,c=r?.prev?`${lr(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${Qe(t)}`:`${Qe(t)} total runtime`;return`<section class="kpis">
90
- ${re("pass rate",`${o}<span class="unit">%</span>`,a,s,r?.prev?r.prev.passRatePp>=0?"green":"red":"",Rt(r?.series.passRate??[],"var(--green)"))}
89
+ </div>`}function _t(e,t){if(e.length<2)return"";let n=108,r=22,o=Math.min(...e),i=Math.max(...e)-o||1,a=e.map((c,d)=>`${(d/(e.length-1)*n).toFixed(1)},${(r-2-(c-o)/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="${t}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var gr=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function po(e,t){let n=e.totals,r=e.trends,o=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),s=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${gr(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,c=r?.prev?`${gr(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${st(t)}`:`${st(t)} total runtime`;return`<section class="kpis">
90
+ ${re("pass rate",`${o}<span class="unit">%</span>`,a,s,r?.prev?r.prev.passRatePp>=0?"green":"red":"",_t(r?.series.passRate??[],"var(--green)"))}
91
91
  ${re("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
92
92
  ${re("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
93
93
  ${re("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
94
- ${re("ai calls",String(n.aiCalls),`${Ce(i)} in+out tokens`,n.aiCalls>0?"":"green")}
95
- ${re("cache traffic",Ce(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${Ce(n.cacheReadTokens)} read \xB7 ${Ce(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
96
- ${n.plan?re("plan usage",`${kt(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,rr(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+re("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${c}`,"","",Rt(r?.series.costUsd??[],"var(--gold)")):re("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,c,"","",Rt(r?.series.costUsd??[],"var(--gold)"))}
97
- </section>`}function io(e){let t=e.trends;if(!t)return"";let n=[];return t.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${t.chronic.map(r=>`<li>${_(r.feature)} \u203A ${_(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>`),t.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${t.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),t.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${t.topIssues.map(r=>`<li>${r.count}\xD7: ${_(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
98
- `)}</section>`:""}function ao(e){return`<ol class="steps">${e.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}">
94
+ ${re("ai calls",String(n.aiCalls),`${Fe(i)} in+out tokens`,n.aiCalls>0?"":"green")}
95
+ ${re("cache traffic",Fe(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${Fe(n.cacheReadTokens)} read \xB7 ${Fe(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
96
+ ${n.plan?re("plan usage",`${Ct(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,ar(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+re("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${c}`,"","",_t(r?.series.costUsd??[],"var(--gold)")):re("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,c,"","",_t(r?.series.costUsd??[],"var(--gold)"))}
97
+ </section>`}function fo(e){let t=e.trends;if(!t)return"";let n=[];return t.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${t.chronic.map(r=>`<li>${_(r.feature)} \u203A ${_(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>`),t.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${t.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),t.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${t.topIssues.map(r=>`<li>${r.count}\xD7: ${_(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
98
+ `)}</section>`:""}function uo(e){return`<ol class="steps">${e.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}">
99
99
  ${r}
100
100
  <div class="step-body">
101
101
  <span class="step-text">${_(n.gherkin)}</span>
@@ -106,34 +106,34 @@ ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
106
106
  ${n.error?`<div class="step-error">${_(n.error)}</div>`:""}
107
107
  </div>
108
108
  </li>`}).join(`
109
- `)}</ol>`}function co(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
109
+ `)}</ol>`}function go(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
110
110
  <div class="panel-title gold">suggested .feature edit</div>
111
111
  <div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${_(r)}</div><div class="diff-line add">+ ${_(n.text)}</div>`}).join("")}</div>
112
112
  <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
113
- </div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${_(e.suggestedFeatureEdit)}</pre></div>`:""}function lo(e){let t=e.proposalDiff;if(!t)return"";let n=t.steps.filter(c=>c.change!=="same"||!t.hasCommitted).map(c=>{let l=`<div class="diff-step">${c.kind==="assertion"?'<span class="pill gold">assertion</span> ':""}${_(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${_(c.fromStepSet)}")</span>`:""}</div>`,p=c.actions.flatMap(d=>d.change==="same"?[`<div class="diff-line"> ${_(d.before??"")}</div>`]:d.change==="added"?[`<div class="diff-line add">+ ${_(d.after??"")}</div>`]:d.change==="removed"?[`<div class="diff-line del">\u2212 ${_(d.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${_(d.before??"")}</div>`,`<div class="diff-line add">+ ${_(d.after??"")}</div>`,...(d.details??[]).map(f=>`<div class="diff-line detail">${_(f)}</div>`)]).join("");return`${l}<div class="diff">${p}</div>`}).join(""),{added:r,removed:o,changed:s,assertions:i}=t.summary,a=r+o+s===0?"No action changed.":`${s} changed, ${r} added, ${o} removed.${i>0?` ${i} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${_(a)}</div></div>`}function po(e,t){let n=e.usage.inputTokens+e.usage.outputTokens;if(e.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=nr(e.usage.costUsd,t),o=r?` \xB7 ${r}`:"";return`<span class="pill gold">${e.usage.aiCalls} ai \xB7 ${Ce(n)} tok${o}</span>`}function fo(e,t){return`<details class="card" ${e.status!=="green"?"open":""}>
113
+ </div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${_(e.suggestedFeatureEdit)}</pre></div>`:""}function ho(e){let t=e.proposalDiff;if(!t)return"";let n=t.steps.filter(c=>c.change!=="same"||!t.hasCommitted).map(c=>{let d=`<div class="diff-step">${c.kind==="assertion"?'<span class="pill gold">assertion</span> ':""}${_(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${_(c.fromStepSet)}")</span>`:""}</div>`,f=c.actions.flatMap(l=>l.change==="same"?[`<div class="diff-line"> ${_(l.before??"")}</div>`]:l.change==="added"?[`<div class="diff-line add">+ ${_(l.after??"")}</div>`]:l.change==="removed"?[`<div class="diff-line del">\u2212 ${_(l.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${_(l.before??"")}</div>`,`<div class="diff-line add">+ ${_(l.after??"")}</div>`,...(l.details??[]).map(h=>`<div class="diff-line detail">${_(h)}</div>`)]).join("");return`${d}<div class="diff">${f}</div>`}).join(""),{added:r,removed:o,changed:s,assertions:i}=t.summary,a=r+o+s===0?"No action changed.":`${s} changed, ${r} added, ${o} removed.${i>0?` ${i} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${_(a)}</div></div>`}function mo(e,t){let n=e.usage.inputTokens+e.usage.outputTokens;if(e.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=ir(e.usage.costUsd,t),o=r?` \xB7 ${r}`:"";return`<span class="pill gold">${e.usage.aiCalls} ai \xB7 ${Fe(n)} tok${o}</span>`}function yo(e,t){return`<details class="card" ${e.status!=="green"?"open":""}>
114
114
  <summary>
115
- <span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${so[e.status]}</span>
115
+ <span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${lo[e.status]}</span>
116
116
  <span class="card-title">${_(e.scenario)}</span>
117
117
  <span class="card-meta">
118
118
  <span class="m">${_(e.feature)}</span>
119
119
  <span class="m-sep">\xB7</span>
120
120
  <span class="m">${_(e.source)}</span>
121
121
  <span class="m-sep">\xB7</span>
122
- <span class="m">${Qe(e.durationMs)}</span>
123
- ${po(e,t)}
122
+ <span class="m">${st(e.durationMs)}</span>
123
+ ${mo(e,t)}
124
124
  </span>
125
125
  </summary>
126
126
  <div class="card-body">
127
- ${ao(e)}
127
+ ${uo(e)}
128
128
  ${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${_(e.narrative)}</p></div>`:""}
129
129
  ${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(n=>`<li>${_(n)}</li>`).join("")}</ul></div>`:""}
130
- ${co(e)}
131
- ${lo(e)}
130
+ ${go(e)}
131
+ ${ho(e)}
132
132
  ${e.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${e.verified===!0?' <span class="pill green">verified</span>':e.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${_(e.proposalFile)}</p>${e.verified===!1&&e.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${_(e.proofError.split(`
133
133
  `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
134
134
  ${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${_(e.error)}</p></div>`:""}
135
135
  </div>
136
- </details>`}function pr(e){let t=e.totals,n=new Date(e.startedAt),r=new Date(e.finishedAt).getTime()-n.getTime(),o=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
136
+ </details>`}function mr(e){let t=e.totals,n=new Date(e.startedAt),r=new Date(e.finishedAt).getTime()-n.getTime(),o=n.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
137
137
  <html lang="en">
138
138
  <head>
139
139
  <meta charset="utf-8"/>
@@ -255,7 +255,7 @@ ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
255
255
  <div class="wrap">
256
256
  <header class="top">
257
257
  <div class="brand">
258
- ${no(38)}
258
+ ${ao(38)}
259
259
  <div>
260
260
  <div class="brand-name">saffron</div>
261
261
  <div class="brand-sub">test run report</div>
@@ -264,53 +264,53 @@ ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
264
264
  <div class="run-meta">
265
265
  <span>${_(o)}</span>
266
266
  <span>\xB7</span>
267
- <span>${Qe(r)}</span>
267
+ <span>${st(r)}</span>
268
268
  ${e.baseURL?`<span class="chip base">${_(e.baseURL)}</span>`:""}
269
269
  </div>
270
270
  </header>
271
271
 
272
- ${oo(e,r)}
273
- ${io(e)}
272
+ ${po(e,r)}
273
+ ${fo(e)}
274
274
 
275
275
  <div class="section-head">
276
276
  <span class="section-title">scenarios \xB7 ${t.scenarios}</span>
277
277
  <span class="section-title">saffron v${_(e.version)}</span>
278
278
  </div>
279
- ${e.scenarios.map(s=>fo(s,!!e.totals.plan)).join(`
279
+ ${e.scenarios.map(s=>yo(s,!!e.totals.plan)).join(`
280
280
  `)}
281
281
 
282
- <footer>${ro(11)} generated by saffron v${_(e.version)} \xB7 ${_(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
282
+ <footer>${co(11)} generated by saffron v${_(e.version)} \xB7 ${_(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
283
283
  </div>
284
284
  </body>
285
285
  </html>
286
- `}function fr(e,t,n){let r={scenarios:e.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o=new Set;for(let s of e){for(let i of s.usage.models??[])o.add(i);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 o.size>0&&(r.models=[...o].sort()),{tool:"saffron",version:ie(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:e.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,proposalDiff:s.proposalDiff,error:s.error}))}}function ur(e,t){let n=Et.join(e,".saffron","reports");At.mkdirSync(n,{recursive:!0});let r=Et.join(n,"latest.json"),o=Et.join(n,"latest.html");return At.writeFileSync(r,`${JSON.stringify(t,null,2)}
287
- `),At.writeFileSync(o,pr(t)),{json:r,html:o}}function gr(e){let t=e===void 0?[]:Array.isArray(e)?e:[e],n=new Set;for(let r of t)for(let o of r.split(/[\s,|]+/)){let s=o.trim();s&&n.add(s.startsWith("@")?s:`@${s}`)}return[...n]}function hr(e,t){return t.length===0?e:e.filter(n=>n.tags.some(r=>t.includes(r)))}function mr(e,t){return[...t??[],e]}import C from"node:fs";import O from"node:path";var _e={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},uo=["claude","agents"],br="<!-- saffron:start -->",Ct="<!-- saffron:end -->";function go(){return[br,"## 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.","- `npx saffron status` shows what is cached, what awaits review, the tags"," and vocabulary health; editing a cached scenario's text re-records it.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`"," (`--all`, or chosen files) and `npx saffron reject`.","- Secrets go in `{env:VAR}` tokens, never in files.",Ct,""].join(`
288
- `)}function Sr(e,t){if(!C.existsSync(e))return C.writeFileSync(e,t),"created";let n=C.readFileSync(e,"utf8"),r=n.indexOf(br),o=n.indexOf(Ct);if(r!==-1&&o!==-1&&o>r){let i=n.slice(0,r)+t.trimEnd()+n.slice(o+Ct.length);return i===n?"unchanged":(C.writeFileSync(e,i),"updated")}let s=n.endsWith(`
286
+ `}function yr(e,t,n){let r={scenarios:e.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o=new Set;for(let s of e){for(let i of s.usage.models??[])o.add(i);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 o.size>0&&(r.models=[...o].sort()),{tool:"saffron",version:ae(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:r,scenarios:e.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,proposalDiff:s.proposalDiff,error:s.error}))}}function Sr(e,t){let n=jt.join(e,".saffron","reports");Ft.mkdirSync(n,{recursive:!0});let r=jt.join(n,"latest.json"),o=jt.join(n,"latest.html");return Ft.writeFileSync(r,`${JSON.stringify(t,null,2)}
287
+ `),Ft.writeFileSync(o,mr(t)),{json:r,html:o}}function br(e){let t=e===void 0?[]:Array.isArray(e)?e:[e],n=new Set;for(let r of t)for(let o of r.split(/[\s,|]+/)){let s=o.trim();s&&n.add(s.startsWith("@")?s:`@${s}`)}return[...n]}function xr(e,t){return t.length===0?e:e.filter(n=>n.tags.some(r=>t.includes(r)))}function vr(e,t){return[...t??[],e]}import C from"node:fs";import O from"node:path";var je={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},So=["claude","agents"],$r="<!-- saffron:start -->",Ot="<!-- saffron:end -->";function bo(){return[$r,"## 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.","- `npx saffron status` shows what is cached, what awaits review, the tags"," and vocabulary health; editing a cached scenario's text re-records it.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`"," (`--all`, or chosen files) and `npx saffron reject`.","- Secrets go in `{env:VAR}` tokens, never in files.",Ot,""].join(`
288
+ `)}function wr(e,t){if(!C.existsSync(e))return C.writeFileSync(e,t),"created";let n=C.readFileSync(e,"utf8"),r=n.indexOf($r),o=n.indexOf(Ot);if(r!==-1&&o!==-1&&o>r){let i=n.slice(0,r)+t.trimEnd()+n.slice(o+Ot.length);return i===n?"unchanged":(C.writeFileSync(e,i),"updated")}let s=n.endsWith(`
289
289
  `)?`
290
290
  `:`
291
291
 
292
- `;return C.writeFileSync(e,n+s+t),"updated"}var Pt={command:"npx",args:["saffron","mcp"]},ho={claude:{file:".mcp.json",key:"mcpServers",entry:Pt},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:Pt},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...Pt}}},yr={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function mo(e,t){let n=O.join(e,t.file),r={},o=C.existsSync(n);if(o)try{r=JSON.parse(C.readFileSync(n,"utf8"))}catch{throw new Error(`${t.file} is not valid JSON: fix it or remove it, then re-run`)}let s=r[t.key]??{};return JSON.stringify(s.saffron)===JSON.stringify(t.entry)?"unchanged":(s.saffron=t.entry,r[t.key]=s,C.mkdirSync(O.dirname(n),{recursive:!0}),C.writeFileSync(n,JSON.stringify(r,null,2)+`
293
- `),o?"updated":"created")}function xr(e,t){C.mkdirSync(t,{recursive:!0});for(let n of C.readdirSync(e,{withFileTypes:!0})){let r=O.join(e,n.name),o=O.join(t,n.name);n.isDirectory()?xr(r,o):C.copyFileSync(r,o)}}function vr(e){let t=O.join(e.packageRoot,"skills","saffron");if(!C.existsSync(O.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??uo;for(let o of r){let s=O.join(e.projectRoot,_e[o],"saffron");C.existsSync(s)&&C.rmSync(s,{recursive:!0,force:!0}),xr(t,s),n.skillDirs.push(O.relative(e.projectRoot,s))}if(e.mcp!==!1)for(let o of r){let s=ho[o];s?n.mcpFiles.push({file:s.file,outcome:mo(e.projectRoot,s)}):yr[o]&&n.mcpHints.push(yr[o])}if(e.instructions!==!1){let o=go(),s=O.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:Sr(s,o)});let i=O.join(e.projectRoot,"CLAUDE.md");C.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:Sr(i,o)})}if(e.examples){n.examples=So(e.projectRoot,e.packageRoot);for(let o of n.examples.files)n.scaffolded.push(o)}if(e.scaffold!==!1){let o=O.join(e.projectRoot,"saffron.config.json");C.existsSync(o)||(C.writeFileSync(o,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
294
- `),n.scaffolded.push("saffron.config.json"));let s=O.join(e.projectRoot,"features");C.existsSync(s)||(C.mkdirSync(s,{recursive:!0}),n.scaffolded.push("features/"));let i=O.join(e.projectRoot,".gitignore"),a=e.examples?[".saffron/reports/",".env"]:[".saffron/reports/"],c=C.existsSync(i)?C.readFileSync(i,"utf8"):"",l=[];for(let p of a)c.split(`
295
- `).some(d=>d.trim()===p)||(c+=(c&&!c.endsWith(`
292
+ `;return C.writeFileSync(e,n+s+t),"updated"}var It={command:"npx",args:["saffron","mcp"]},xo={claude:{file:".mcp.json",key:"mcpServers",entry:It},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:It},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...It}}},kr={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function vo(e,t){let n=O.join(e,t.file),r={},o=C.existsSync(n);if(o)try{r=JSON.parse(C.readFileSync(n,"utf8"))}catch{throw new Error(`${t.file} is not valid JSON: fix it or remove it, then re-run`)}let s=r[t.key]??{};return JSON.stringify(s.saffron)===JSON.stringify(t.entry)?"unchanged":(s.saffron=t.entry,r[t.key]=s,C.mkdirSync(O.dirname(n),{recursive:!0}),C.writeFileSync(n,JSON.stringify(r,null,2)+`
293
+ `),o?"updated":"created")}function Tr(e,t){C.mkdirSync(t,{recursive:!0});for(let n of C.readdirSync(e,{withFileTypes:!0})){let r=O.join(e,n.name),o=O.join(t,n.name);n.isDirectory()?Tr(r,o):C.copyFileSync(r,o)}}function Rr(e){let t=O.join(e.packageRoot,"skills","saffron");if(!C.existsSync(O.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??So;for(let o of r){let s=O.join(e.projectRoot,je[o],"saffron");C.existsSync(s)&&C.rmSync(s,{recursive:!0,force:!0}),Tr(t,s),n.skillDirs.push(O.relative(e.projectRoot,s))}if(e.mcp!==!1)for(let o of r){let s=xo[o];s?n.mcpFiles.push({file:s.file,outcome:vo(e.projectRoot,s)}):kr[o]&&n.mcpHints.push(kr[o])}if(e.instructions!==!1){let o=bo(),s=O.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:wr(s,o)});let i=O.join(e.projectRoot,"CLAUDE.md");C.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:wr(i,o)})}if(e.examples){n.examples=wo(e.projectRoot,e.packageRoot);for(let o of n.examples.files)n.scaffolded.push(o)}if(e.scaffold!==!1){let o=O.join(e.projectRoot,"saffron.config.json");C.existsSync(o)||(C.writeFileSync(o,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
294
+ `),n.scaffolded.push("saffron.config.json"));let s=O.join(e.projectRoot,"features");C.existsSync(s)||(C.mkdirSync(s,{recursive:!0}),n.scaffolded.push("features/"));let i=O.join(e.projectRoot,".gitignore"),a=e.examples?[".saffron/reports/",".env"]:[".saffron/reports/"],c=C.existsSync(i)?C.readFileSync(i,"utf8"):"",d=[];for(let f of a)c.split(`
295
+ `).some(l=>l.trim()===f)||(c+=(c&&!c.endsWith(`
296
296
  `)?`
297
- `:"")+p+`
298
- `,l.push(p));l.length>0&&(C.writeFileSync(i,c),n.scaffolded.push(`.gitignore (+ ${l.join(", ")})`))}return n}function So(e,t){let n=O.join(t,"templates","saucedemo");if(!C.existsSync(O.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=O.join(e,"features");if(C.existsSync(r)&&C.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let o=[];C.mkdirSync(r,{recursive:!0});for(let a of C.readdirSync(O.join(n,"features")))C.copyFileSync(O.join(n,"features",a),O.join(r,a)),o.push(`features/${a}`);let s=O.join(e,"saffron.config.json");C.existsSync(s)||(C.copyFileSync(O.join(n,"saffron.config.json"),s),o.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=O.join(e,".env.example");return C.existsSync(i)||(C.copyFileSync(O.join(n,".env.example"),i),o.push(".env.example")),{installed:!0,files:o}}import yo from"node:path";function wr(e){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):"," Easiest: install the Saffron plugin from the JetBrains Marketplace"," (https://plugins.jetbrains.com/plugin/34240-saffron): highlighting,"," this language server, run configurations and the Saffron tool window,"," in one click. Manual setup instead:"," 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:",` ${e?yo.join(e,"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(`
299
- `)}function kr(e,t){let n=t.length>0?t:["features"],r=[];for(let o of n){let s=P.isAbsolute(o)?o:P.join(e,o);if(L.existsSync(s))if(L.statSync(s).isDirectory())for(let i of L.readdirSync(s,{recursive:!0})){let a=String(i);Q(a)&&r.push(P.join(s,a))}else Q(s)&&r.push(s)}return r.sort()}function Ft(e,t,n){let r=P.isAbsolute(t.features??"features")?t.features:P.join(e,t.features??"features"),o=new Set(se(r));for(let s of n)s.endsWith(".saffron")&&o.add(s);return J([...o].sort(),e)}function wo(){let e=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,t=process.env.HOME??"",n=t&&(L.existsSync(P.join(t,".claude",".credentials.json"))||L.existsSync(P.join(t,".claude.json")));e||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(`
300
- `))}async function ko(e){let t=xo.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>t.question(e,r));return/^y(es)?$/i.test(n.trim())}finally{t.close()}}function Xe(e){console.error(`saffron: ${e.message}`),process.exit(2)}function _t(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var $o={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},To="\x1B[0m",j=new vo().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(ie()).option("-p, --project-root <dir>","project root",process.cwd());j.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',mr).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(e,t)=>{let n=P.resolve(j.opts().projectRoot),r=W(n),o=t.baseUrl??r.baseURL,s=kr(n,e.length>0?e:r.features?[r.features]:[]);s.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),t.agent!==!1&&wo();let i;try{let v=Ft(n,r,s);i=s.flatMap($=>z($,n,v))}catch(v){throw v instanceof B&&Xe(v),v}let a=gr(t.filter);if(a.length>0&&(i=hr(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${s.length} feature file(s).`),process.exit(2))),t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let v=0;for(let $ of i){let w=M(n,$.featurePath,$.name);L.existsSync(w)&&(L.rmSync(w),v++)}console.log(`rerecord: removed ${v} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let v=i;try{let w=kr(n,[]),A=Ft(n,r,w);v=w.flatMap(F=>{try{return z(F,n,A)}catch{return[]}})}catch{}let $=Lt(n,v);if($.length>0){console.error("cache path collision:");for(let w of $)console.error(` ${w}`);process.exit(2)}}{let v=new Map;for(let $ of i){let w=ot($),A=N(M(n,$.featurePath,$.name));A&&w.push(JSON.stringify(A.steps));for(let F of st(w))process.env[F]===void 0&&!v.has(F)&&v.set(F,$.displayName)}if(v.size>0){for(let[$,w]of v)console.error(`missing environment variable ${$} (referenced as {env:${$}} by "${w}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let c=new He({projectRoot:n,baseURL:o,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let v=t.browser??r.browser??"chromium";return v!=="chromium"&&v!=="firefox"&&v!=="webkit"&&(console.error(`invalid --browser "${v}": use "chromium", "firefox", or "webkit"`),process.exit(2)),v})(),healModel:t.healModel??r.healModel,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let v=t.assertionPolicy??r.assertionPolicy??"strict";return v!=="strict"&&v!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${v}": use "strict" or "adaptable-mid"`),process.exit(2)),v})(),agent:t.agent?new Je({model:t.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),l=new Date,p=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),d=new Array(i.length),f=v=>{let $=v.scenario,w=$o[v.status],A=v.usage.inputTokens+v.usage.outputTokens,F=v.usage.cacheReadTokens+v.usage.cacheCreationTokens,D=F>0?`, ${_t(F)} cache traffic`:"";console.log(`${w}${v.status.toUpperCase().padEnd(7)}${To}${$.displayName} (${v.source}, ${v.durationMs}ms, ${v.usage.aiCalls} AI calls, ${A} tokens${D})`);for(let q of v.adaptations)console.log(` \u26A0 ${q}`);v.seededSteps&&console.log(` \u26A1 ${v.seededSteps}/${v.stepResults.length} steps seeded from existing recordings (zero AI)`);let R=v.stepResults.reduce((q,H)=>q+(H.retries??0),0);R&&console.log(` \u21BB ${R} action ${R===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),v.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):v.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${v.proofError?.split(`
301
- `)[0]}`),v.error&&console.log(` ${v.error}`)};await c.start();try{if(p>1&&i.length>1){let v=i.map((w,A)=>({s:w,i:A})),$=[];await Promise.all(Array.from({length:Math.min(p,v.length)},async()=>{for(;;){let w=v.shift();if(!w)return;let A=await c.runScenario(w.s,{agentDisabled:!0});A.status==="red"?$.push(w):(d[w.i]=A,f(A))}}));for(let w of $.sort((A,F)=>A.i-F.i)){let A=await c.runScenario(w.s);d[w.i]=A,f(A)}}else for(let v=0;v<i.length;v++){let $=await c.runScenario(i[v]);d[v]=$,f($)}}finally{await c.stop()}let g=fr(d,l,o),h=X(n).divergent.length,x=gn(d,l,h,o),k=Ue(n);g.trends=mn(k,x),hn(n,x),g.totals.aiCalls>0&&(g.totals.plan=await c.agent?.planUsage?.());let{html:y,json:b}=ur(n,g),S=g.totals,u=S.cacheReadTokens+S.cacheCreationTokens;console.log(`
302
- ${S.green} passed, ${S.yellow} pending review, ${S.red} failed \xB7 ${S.aiCalls} AI calls \xB7 ${(S.inputTokens+S.outputTokens).toLocaleString()} tokens in+out${u>0?` \xB7 ${_t(S.cacheReadTokens)} cache read \xB7 ${_t(S.cacheCreationTokens)} cache written`:""}${tr(S)}${S.models?.length?` \xB7 ${S.models.join(", ")}`:""}`);let m=g.trends;if(m?.prev){let v=m.prev.passRatePp,$=m.prev.costUsd;console.log(`vs previous run: pass rate ${v>=0?"+":""}${v}pp \xB7 cost ${$>=0?"+":"-"}$${Math.abs($).toFixed(4)} \xB7 AI calls ${m.prev.aiCalls>=0?"+":""}${m.prev.aiCalls}`)}for(let v of m?.chronic??[])console.log(`\u26A0 chronic: ${v.feature} \u203A ${v.scenario}: ${v.heals} heal(s), ${v.reds} red(s) in last ${v.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${y}
303
- ${b}`);let T=ae(P.resolve(j.opts().projectRoot));T.length>0&&console.log(`${T.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let I=t.strict??r.strict??!1;I&&S.yellow>0&&console.log(`strict mode: ${S.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`),process.exit(S.red>0||I&&S.yellow>0?1:0)});j.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (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((e,t)=>{let n=P.resolve(j.opts().projectRoot),r=W(n),o=e.length>0?e.map(s=>P.resolve(s)):t.all?ae(n).map(s=>s.file):[];if(o.length===0){$r(n);return}for(let s of o){let i=pe(s);if(i.meta.verified===!1){if(t.all&&!t.includeUnverified){console.log(`skipped UNVERIFIED ${P.basename(s)} (${i.meta.proofError?.split(`
297
+ `:"")+f+`
298
+ `,d.push(f));d.length>0&&(C.writeFileSync(i,c),n.scaffolded.push(`.gitignore (+ ${d.join(", ")})`))}return n}function wo(e,t){let n=O.join(t,"templates","saucedemo");if(!C.existsSync(O.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=O.join(e,"features");if(C.existsSync(r)&&C.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let o=[];C.mkdirSync(r,{recursive:!0});for(let a of C.readdirSync(O.join(n,"features")))C.copyFileSync(O.join(n,"features",a),O.join(r,a)),o.push(`features/${a}`);let s=O.join(e,"saffron.config.json");C.existsSync(s)||(C.copyFileSync(O.join(n,"saffron.config.json"),s),o.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=O.join(e,".env.example");return C.existsSync(i)||(C.copyFileSync(O.join(n,".env.example"),i),o.push(".env.example")),{installed:!0,files:o}}import ko from"node:path";function Ar(e){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):"," Easiest: install the Saffron plugin from the JetBrains Marketplace"," (https://plugins.jetbrains.com/plugin/34240-saffron): highlighting,"," this language server, run configurations and the Saffron tool window,"," in one click. Manual setup instead:"," 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:",` ${e?ko.join(e,"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(`
299
+ `)}function Er(e,t){let n=t.length>0?t:["features"],r=[];for(let o of n){let s=P.isAbsolute(o)?o:P.join(e,o);if(B.existsSync(s))if(B.statSync(s).isDirectory())for(let i of B.readdirSync(s,{recursive:!0})){let a=String(i);Q(a)&&r.push(P.join(s,a))}else Q(s)&&r.push(s)}return r.sort()}function ot(e,t,n){let r=P.isAbsolute(t.features??"features")?t.features:P.join(e,t.features??"features"),o=new Set(oe(r));for(let s of n)s.endsWith(".saffron")&&o.add(s);return J([...o].sort(),e)}function Ao(){let e=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,t=process.env.HOME??"",n=t&&(B.existsSync(P.join(t,".claude",".credentials.json"))||B.existsSync(P.join(t,".claude.json")));e||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(`
300
+ `))}async function Eo(e){let t=To.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>t.question(e,r));return/^y(es)?$/i.test(n.trim())}finally{t.close()}}function Ie(e){console.error(`saffron: ${e.message}`),process.exit(2)}function Dt(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var Po={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Co="\x1B[0m",I=new Ro().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(ae()).option("-p, --project-root <dir>","project root",process.cwd());I.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',vr).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(e,t)=>{let n=P.resolve(I.opts().projectRoot),r=H(n),o=t.baseUrl??r.baseURL,s=Er(n,e.length>0?e:r.features?[r.features]:[]);s.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),t.agent!==!1&&Ao();let i;try{let v=ot(n,r,s);i=s.flatMap(T=>W(T,n,v))}catch(v){throw v instanceof z&&Ie(v),v}let a=br(t.filter);if(a.length>0&&(i=xr(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${s.length} feature file(s).`),process.exit(2))),t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let v=0;for(let T of i){let w=L(n,T.featurePath,T.name);B.existsSync(w)&&(B.rmSync(w),v++)}console.log(`rerecord: removed ${v} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let v=i;try{let w=Er(n,[]),R=ot(n,r,w);v=w.flatMap(j=>{try{return W(j,n,R)}catch{return[]}})}catch{}let T=Vt(n,v);if(T.length>0){console.error("cache path collision:");for(let w of T)console.error(` ${w}`);process.exit(2)}}{let v=new Map;for(let T of i){let w=ft(T),R=U(L(n,T.featurePath,T.name));R&&w.push(JSON.stringify(R.steps));for(let j of pt(w))process.env[j]===void 0&&!v.has(j)&&v.set(j,T.displayName)}if(v.size>0){for(let[T,w]of v)console.error(`missing environment variable ${T} (referenced as {env:${T}} by "${w}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let c=new Qe({projectRoot:n,baseURL:o,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let v=t.browser??r.browser??"chromium";return v!=="chromium"&&v!=="firefox"&&v!=="webkit"&&(console.error(`invalid --browser "${v}": use "chromium", "firefox", or "webkit"`),process.exit(2)),v})(),healModel:t.healModel??r.healModel,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let v=t.assertionPolicy??r.assertionPolicy??"strict";return v!=="strict"&&v!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${v}": use "strict" or "adaptable-mid"`),process.exit(2)),v})(),agent:t.agent?new Xe({model:t.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),d=new Date,f=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),l=new Array(i.length),h=v=>{let T=v.scenario,w=Po[v.status],R=v.usage.inputTokens+v.usage.outputTokens,j=v.usage.cacheReadTokens+v.usage.cacheCreationTokens,M=j>0?`, ${Dt(j)} cache traffic`:"";console.log(`${w}${v.status.toUpperCase().padEnd(7)}${Co}${T.displayName} (${v.source}, ${v.durationMs}ms, ${v.usage.aiCalls} AI calls, ${R} tokens${M})`);for(let A of v.adaptations)console.log(` \u26A0 ${A}`);v.seededSteps&&console.log(` \u26A1 ${v.seededSteps}/${v.stepResults.length} steps seeded from existing recordings (zero AI)`);let D=v.stepResults.reduce((A,le)=>A+(le.retries??0),0);D&&console.log(` \u21BB ${D} action ${D===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),v.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):v.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${v.proofError?.split(`
301
+ `)[0]}`),v.error&&console.log(` ${v.error}`)};await c.start();try{if(f>1&&i.length>1){let v=i.map((w,R)=>({s:w,i:R})),T=[];await Promise.all(Array.from({length:Math.min(f,v.length)},async()=>{for(;;){let w=v.shift();if(!w)return;let R=await c.runScenario(w.s,{agentDisabled:!0});R.status==="red"?T.push(w):(l[w.i]=R,h(R))}}));for(let w of T.sort((R,j)=>R.i-j.i)){let R=await c.runScenario(w.s);l[w.i]=R,h(R)}}else for(let v=0;v<i.length;v++){let T=await c.runScenario(i[v]);l[v]=T,h(T)}}finally{await c.stop()}let u=yr(l,d,o),p=X(n).divergent.length,m=Sn(l,d,p,o),k=qe(n);u.trends=xn(k,m),bn(n,m),u.totals.aiCalls>0&&(u.totals.plan=await c.agent?.planUsage?.());let{html:y,json:b}=Sr(n,u),S=u.totals,x=S.cacheReadTokens+S.cacheCreationTokens;console.log(`
302
+ ${S.green} passed, ${S.yellow} pending review, ${S.red} failed \xB7 ${S.aiCalls} AI calls \xB7 ${(S.inputTokens+S.outputTokens).toLocaleString()} tokens in+out${x>0?` \xB7 ${Dt(S.cacheReadTokens)} cache read \xB7 ${Dt(S.cacheCreationTokens)} cache written`:""}${or(S)}${S.models?.length?` \xB7 ${S.models.join(", ")}`:""}`);let g=u.trends;if(g?.prev){let v=g.prev.passRatePp,T=g.prev.costUsd;console.log(`vs previous run: pass rate ${v>=0?"+":""}${v}pp \xB7 cost ${T>=0?"+":"-"}$${Math.abs(T).toFixed(4)} \xB7 AI calls ${g.prev.aiCalls>=0?"+":""}${g.prev.aiCalls}`)}for(let v of g?.chronic??[])console.log(`\u26A0 chronic: ${v.feature} \u203A ${v.scenario}: ${v.heals} heal(s), ${v.reds} red(s) in last ${v.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${y}
303
+ ${b}`);let $=ce(P.resolve(I.opts().projectRoot));$.length>0&&console.log(`${$.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let F=t.strict??r.strict??!1;F&&S.yellow>0&&console.log(`strict mode: ${S.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`),process.exit(S.red>0||F&&S.yellow>0?1:0)});I.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (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((e,t)=>{let n=P.resolve(I.opts().projectRoot),r=H(n),o=e.length>0?e.map(s=>P.resolve(s)):t.all?ce(n).map(s=>s.file):[];if(o.length===0){Pr(n);return}for(let s of o){let i=ge(s);if(i.meta.verified===!1){if(t.all&&!t.includeUnverified){console.log(`skipped UNVERIFIED ${P.basename(s)} (${i.meta.proofError?.split(`
304
304
  `)[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(`
305
- `)[0]}): expect a heal or red on the next run.`)}let a=N(M(n,i.cache.feature,i.cache.scenario)),c=dn(n,s);console.log(`accepted \u2192 ${c}`);let l=a?or(a,i.cache):[];if(l.length>0){let f=ir(n,l,c,!t.propagate);for(let g of l)console.log(` locator fix: ${$t(g.from)} \u2192 ${$t(g.to)}`);if(f.length===0)console.log(" no other caches use this locator.");else if(t.propagate)for(let g of f)console.log(` propagated \u2192 ${g.feature} \u203A ${g.scenario} (${g.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${f.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let g of f)console.log(` \xB7 ${g.feature} \u203A ${g.scenario}`)}}let p=i.meta.featureEdits??[];if(!t.withFeatureEdit||p.length===0)continue;let d;try{d=cr(n,i.cache.feature,i.cache.scenario,p,Ft(n,r,[]))}catch(f){throw f instanceof B&&Xe(f),f}for(let f of d.applied){let g=f.fromStepSet?`${f.file}:${f.line} (StepSet "${f.fromStepSet}", fixes every invoking scenario)`:`${f.file}:${f.line}`;console.log(` feature edit ${g}:`),console.log(` - ${f.from}`),console.log(` + ${f.to}`)}for(let f of d.skipped)console.log(` skipped edit [${f.index}]: ${f.reason}`);if(d.applied.length>0){let f=N(c);for(let g of p)f.steps[g.index]&&(f.steps[g.index].gherkin=g.text);he(c,f)}}});j.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((e,t)=>{let n=P.resolve(j.opts().projectRoot),r=W(n),o;try{o=G(n,r.features??"features")}catch(i){throw i instanceof B&&Xe(i),i}if(e&&(o=Oe(o,e)),t.json){console.log(JSON.stringify(o,null,2));return}if(t.snippets){let i=P.join(n,".vscode","saffron.code-snippets");L.mkdirSync(P.dirname(i),{recursive:!0}),L.writeFileSync(i,Ht(o)),console.log(`wrote ${o.steps.length} step + ${o.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let s={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(o.stepSets.length>0){console.log("step sets:");for(let i of o.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of o.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${s[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
306
- ${o.steps.length} step(s) across ${o.scannedFiles} file(s) \xB7 ${o.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});j.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(e,t)=>{let n=P.resolve(j.opts().projectRoot),r=W(n),o=L.readFileSync(P.resolve(e),"utf8"),s;try{s=G(n,r.features??"features")}catch(l){throw l instanceof B&&Xe(l),l}console.log(`authoring with ${s.steps.length} known step(s) and ${s.stepSets.length} step set(s) as vocabulary\u2026`);let i=await vn(o,s,t.model??r.model),a=t.out?P.resolve(t.out):P.join(n,r.features??"features",`${P.basename(e).replace(/\.[^.]+$/,"")}.saffron`);L.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),L.mkdirSync(P.dirname(a),{recursive:!0}),L.writeFileSync(a,i.content);let c=i.content.split(`
307
- `).map(l=>l.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(l=>s.steps.some(p=>p.text===l)).length;console.log(`wrote ${a}`),console.log(`${c} 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 "+P.relative(process.cwd(),a))});j.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=P.resolve(j.opts().projectRoot),t=W(e);await xn(e,t.features??"features")});j.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(wr(Me())),process.exit(2));let e=P.resolve(j.opts().projectRoot),t=W(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));En(e,t.features??"features",{rootFromClient:!n})});j.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((e,t)=>{let n=P.resolve(j.opts().projectRoot),r=e.length>0?e.map(o=>P.resolve(o)):t.all?ae(n).map(o=>o.file):[];if(r.length===0){$r(n);return}for(let o of r)pn(o),console.log(`rejected ${o}`)});j.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(_e).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 e=>{let t=P.resolve(j.opts().projectRoot),n=Me();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in _e||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(_e).join(", ")})`),process.exit(2));let o=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(o=await ko("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let s=vr({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:o});for(let i of s.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of s.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of s.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of s.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of s.scaffolded)console.log(`created \u2192 ${i}`);s.examples&&!s.examples.installed&&console.log(`examples \u2192 not installed: ${s.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),s.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(`
308
- `))});j.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=P.resolve(j.opts().projectRoot),r=e.length>0?e.map(s=>P.resolve(s)):ae(n).map(s=>s.file);if(r.length===0){console.log("No pending proposals.");return}let o=r.map(s=>{let i=pe(s),a=N(M(n,i.cache.feature,i.cache.scenario));return{file:P.relative(n,s),verified:i.meta.verified,...qe(a,i.cache)}});if(t.json){console.log(JSON.stringify(o.length===1?o[0]:o,null,2));return}console.log(o.map(s=>Un(s)).join(`
305
+ `)[0]}): expect a heal or red on the next run.`)}let a=i.cache.steps.map(p=>p.gherkin);if(t.withFeatureEdit&&(i.meta.featureEdits??[]).length>0){let p;try{p=fr(n,i.cache.feature,i.cache.scenario,a,ot(n,r,[]))}catch(m){throw m instanceof z&&Ie(m),m}if(p){console.log(`not accepted: ${P.basename(s)} carries feature edits, and ${p}. Run the scenario again for a fresh proposal, or accept this one without --with-feature-edit.`),process.exitCode=1;continue}}let c=U(L(n,i.cache.feature,i.cache.scenario)),d=gn(n,s);console.log(`accepted \u2192 ${d}`);let f=c?lr(c,i.cache):{changes:[],ambiguous:[]},l=f.changes;for(let p of f.ambiguous)console.log(` locator fix for ${nt(p)} not propagated: this heal sent it to more than one place, so other caches are left alone.`);if(l.length>0){let p=dr(n,l,d,!t.propagate);for(let m of l)console.log(` locator fix: ${nt(m.from)} \u2192 ${nt(m.to)}`);if(p.length===0)console.log(" no other caches use this locator.");else if(t.propagate)for(let m of p)console.log(` propagated \u2192 ${m.feature} \u203A ${m.scenario} (${m.steps.map(k=>`"${k.gherkin}"`).join(", ")})`);else{console.log(` ${p.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let m of p)console.log(` \xB7 ${m.feature} \u203A ${m.scenario}`)}}let h=i.meta.featureEdits??[];if(!t.withFeatureEdit||h.length===0)continue;let u;try{u=ur(n,i.cache.feature,i.cache.scenario,h,ot(n,r,[]),a)}catch(p){throw p instanceof z&&Ie(p),p}for(let p of u.applied){let m=p.fromStepSet?`${p.file}:${p.line} (StepSet "${p.fromStepSet}", fixes every invoking scenario)`:`${p.file}:${p.line}`;console.log(` feature edit ${m}:`),console.log(` - ${p.from}`),console.log(` + ${p.to}`)}for(let p of u.skipped)console.log(` skipped edit [${p.index}]: ${p.reason}`);if(u.appliedIndices.length>0){let p=new Set(u.appliedIndices),m=U(d);for(let k of h)p.has(k.index)&&m.steps[k.index]&&(m.steps[k.index].gherkin=k.text);ye(d,m)}}});I.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((e,t)=>{let n=P.resolve(I.opts().projectRoot),r=H(n),o;try{o=G(n,r.features??"features")}catch(i){throw i instanceof z&&Ie(i),i}if(e&&(o=Le(o,e)),t.json){console.log(JSON.stringify(o,null,2));return}if(t.snippets){let i=P.join(n,".vscode","saffron.code-snippets");B.mkdirSync(P.dirname(i),{recursive:!0}),B.writeFileSync(i,Yt(o)),console.log(`wrote ${o.steps.length} step + ${o.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let s={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(o.stepSets.length>0){console.log("step sets:");for(let i of o.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of o.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${s[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
306
+ ${o.steps.length} step(s) across ${o.scannedFiles} file(s) \xB7 ${o.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});I.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(e,t)=>{let n=P.resolve(I.opts().projectRoot),r=H(n),o=B.readFileSync(P.resolve(e),"utf8"),s;try{s=G(n,r.features??"features")}catch(d){throw d instanceof z&&Ie(d),d}console.log(`authoring with ${s.steps.length} known step(s) and ${s.stepSets.length} step set(s) as vocabulary\u2026`);let i=await Tn(o,s,t.model??r.model),a=t.out?P.resolve(t.out):P.join(n,r.features??"features",`${P.basename(e).replace(/\.[^.]+$/,"")}.saffron`);B.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),B.mkdirSync(P.dirname(a),{recursive:!0}),B.writeFileSync(a,i.content);let c=i.content.split(`
307
+ `).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>s.steps.some(f=>f.text===d)).length;console.log(`wrote ${a}`),console.log(`${c} 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 "+P.relative(process.cwd(),a))});I.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=P.resolve(I.opts().projectRoot),t=H(e);await $n(e,t.features??"features")});I.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(Ar(Be())),process.exit(2));let e=P.resolve(I.opts().projectRoot),t=H(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));Fn(e,t.features??"features",{rootFromClient:!n})});I.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((e,t)=>{let n=P.resolve(I.opts().projectRoot),r=e.length>0?e.map(o=>P.resolve(o)):t.all?ce(n).map(o=>o.file):[];if(r.length===0){Pr(n);return}for(let o of r)hn(o),console.log(`rejected ${o}`)});I.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(je).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 e=>{let t=P.resolve(I.opts().projectRoot),n=Be();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in je||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(je).join(", ")})`),process.exit(2));let o=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(o=await Eo("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let s=Rr({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:o});for(let i of s.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of s.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of s.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of s.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of s.scaffolded)console.log(`created \u2192 ${i}`);s.examples&&!s.examples.installed&&console.log(`examples \u2192 not installed: ${s.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),s.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(`
308
+ `))});I.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=P.resolve(I.opts().projectRoot),r=e.length>0?e.map(s=>P.resolve(s)):ce(n).map(s=>s.file);if(r.length===0){console.log("No pending proposals.");return}let o=r.map(s=>{let i=ge(s),a=U(L(n,i.cache.feature,i.cache.scenario));return{file:P.relative(n,s),verified:i.meta.verified,...Ye(a,i.cache)}});if(t.json){console.log(JSON.stringify(o.length===1?o[0]:o,null,2));return}console.log(o.map(s=>Wn(s)).join(`
309
309
 
310
- `))});j.command("prune").description("List (and with --yes remove) recordings whose scenario no longer exists: caches and pending proposals left behind by a deleted scenario or feature file").option("--json","machine-readable output").option("--yes","actually delete the files listed").option("--check","exit 1 when anything is orphaned, delete nothing (for CI)").action(e=>{let t=P.resolve(j.opts().projectRoot),n=W(t),r=Zt(t,n.features??"features");r.unreadable.length>0&&(e.json?console.log(JSON.stringify({...r,removed:[]},null,2)):console.error(["saffron: cannot tell what is orphaned while a feature file fails to parse.",...r.unreadable.map(s=>` ${s}`),"Fix these first: a syntax error hides every scenario in the file."].join(`
311
- `)),process.exit(2));let o=e.yes&&!e.check?en(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:o},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let s=r.orphans.filter(a=>a.kind==="cache").length,i=r.orphans.length-s;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${s} cache, ${i} proposal), ${r.kept} still owned:`,"",...r.orphans.map(a=>` ${o.length>0?"removed ":""}${tn(a)}`),"",o.length>0?"Deleted. Re-record by running the scenarios again if you deleted something by mistake.":"Nothing was deleted. Run `saffron prune --yes` to remove them."].join(`
312
- `))}e.check&&r.orphans.length>0&&process.exit(1)});j.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(e=>{let t=P.resolve(j.opts().projectRoot),n=W(t),r=Be(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}bn(r)});j.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let e=P.resolve(j.opts().projectRoot),t=P.join(e,".saffron","reports","latest.html");L.existsSync(t)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(t);let n=process.platform==="darwin"?"open":"xdg-open";bo(n,[t],{detached:!0,stdio:"ignore"}).unref()});function $r(e){let t=ae(e);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
310
+ `))});I.command("prune").description("List (and with --yes remove) recordings whose scenario no longer exists: caches and pending proposals left behind by a deleted scenario or feature file").option("--json","machine-readable output").option("--yes","actually delete the files listed").option("--check","exit 1 when anything is orphaned, delete nothing (for CI)").action(e=>{let t=P.resolve(I.opts().projectRoot),n=H(t),r=ze(t,n.features??"features");r.unreadable.length>0&&(e.json?console.log(JSON.stringify({...r,removed:[]},null,2)):console.error(["saffron: cannot tell what is orphaned while a feature file fails to parse.",...r.unreadable.map(s=>` ${s}`),"Fix these first: a syntax error hides every scenario in the file."].join(`
311
+ `)),process.exit(2));let o=e.yes&&!e.check?on(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:o},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let s=r.orphans.filter(a=>a.kind==="cache").length,i=r.orphans.length-s;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${s} cache, ${i} proposal), ${r.kept} still owned:`,"",...r.orphans.map(a=>` ${o.length>0?"removed ":""}${an(a)}`),"",o.length>0?"Deleted. Re-record by running the scenarios again if you deleted something by mistake.":"Nothing was deleted. Run `saffron prune --yes` to remove them."].join(`
312
+ `))}e.check&&r.orphans.length>0&&process.exit(1)});I.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(e=>{let t=P.resolve(I.opts().projectRoot),n=H(t),r=He(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}kn(r)});I.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let e=P.resolve(I.opts().projectRoot),t=P.join(e,".saffron","reports","latest.html");B.existsSync(t)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(t);let n=process.platform==="darwin"?"open":"xdg-open";$o(n,[t],{detached:!0,stdio:"ignore"}).unref()});function Pr(e){let t=ce(e);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
313
313
  `);for(let{file:n,proposal:r}of t){console.log(` ${n}`);let o=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}${o})`),console.log(` ${r.meta.narrative}`);for(let s of r.meta.adaptations)console.log(` \u26A0 ${s}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
314
314
  ${r.meta.suggestedFeatureEdit.split(`
315
315
  `).map(s=>` ${s}`).join(`
316
- `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}j.parseAsync();
316
+ `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}I.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saffron-ai",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",