saffron-ai 0.2.0 → 0.3.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/README.md CHANGED
@@ -104,7 +104,7 @@ cached replays *and* the agent start authenticated. Generate one with
104
104
  The full execution-model diagram is on the site: https://saffron-ai.lovable.app/docs
105
105
 
106
106
  - Caches store per-step action lists with semantic targets (`role` + accessible name, `nameRegex` for volatile labels, CSS fallbacks) and `<param>` placeholders, so one cache serves every Examples row of a Scenario Outline.
107
- - Dynamic-content vocabulary for real-world apps: `captureText` stores displayed values (prices, counters) under a name; `expectDiffers` compares against captured values; `expectMatches`/`expectAttribute` assert patterns instead of literals; `{date±N}` templates keep date-picker locators valid across days; the replayer follows links that open new tabs; native dialogs, file uploads, drag-and-drop, and iframe-scoped elements replay on all three engines.
107
+ - Dynamic-content vocabulary for real-world apps: `captureText` stores displayed values (prices, counters) under a name; `expectDiffers` compares against captured values; `expectMatches`/`expectAttribute` assert patterns instead of literals; `{date±N}` templates keep date-picker locators valid across days; the replayer follows links that open new tabs; native dialogs, file uploads, drag-and-drop, and iframe-scoped elements replay on all three engines; network-aware steps wait on or assert backend API responses (`waitForResponse` / sacred `expectResponse`) with URL/method/status/body matchers — "wait for the order API to return 201" instead of a sleep.
108
108
  - The agent and the replayer share one browser over CDP, so healing continues from the exact page state where replay failed.
109
109
  - Every recorded action carries a page fingerprint (hash of the normalized aria snapshot) for token-free drift detection.
110
110
 
package/dist-pkg/cli.js CHANGED
@@ -1,33 +1,33 @@
1
1
  #!/usr/bin/env node
2
- import F from"node:fs";import $ from"node:path";import{spawn as vn}from"node:child_process";import{Command as wn}from"commander";import mr from"node:fs";import hr from"node:path";import{AstBuilder as Sr,GherkinClassicTokenMatcher as yr,Parser as br}from"@cucumber/gherkin";import{IdGenerator as xr,StepKeywordType as Ae}from"@cucumber/messages";function N(t,e){return t.replace(/<([^<>]+)>/g,(r,n)=>n in e?e[n]:r)}function j(t){if(!t||t.length===0)return{};let e={};if(t.every(r=>r.length===2)){for(let[r,n]of t)e[`table:${r}`]=n;return e}if(t.length>=2){let[r,...n]=t;if(r.some(s=>!s))return{};n.forEach((s,o)=>{r.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function z(t){return t!==void 0?{docstring:t}:{}}function Q(t){let e=new Map;for(let n of t){let s={...j(n.resolvedTable??n.table),...z(n.resolvedDocString??n.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let r={};for(let[n,s]of e)s.size===1&&(r[n]=[...s][0]);return r}import Te from"node:fs";import Qe from"node:path";import{AstBuilder as ar,GherkinClassicTokenMatcher as cr,Parser as lr}from"@cucumber/gherkin";import{IdGenerator as dr}from"@cucumber/messages";var _=class extends Error{},pr=/^(\s*)StepSet:\s*(.+?)\s*$/,ur=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Ee(t){let e=t.split(`
3
- `),r=new Map,n=new Map;return{source:e.map((o,i)=>{let a=o.match(pr);if(a)return r.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let p=o.match(ur);return p?(n.set(i+1,p[2]),`${p[1]}* StepSet ${p[2]}`):o}).join(`
4
- `),definitions:r,invocations:n}}function fr(t){return new lr(new ar(dr.uuid()),new cr).parse(t)}function Ke(t,e){for(let r of t)r.scenario?e(r.scenario):"rule"in r&&r.rule&&Ke(r.rule.children,e)}function K(t,e){let r=new Map;for(let n of t){let s=Qe.relative(e,n),o=Ee(Te.readFileSync(n,"utf8"));if(o.definitions.size===0)continue;let i=fr(o.source);i.feature&&Ke(i.feature.children,a=>{let p=o.definitions.get(a.location.line);if(p===void 0)return;let d=r.get(p);if(d)throw new _(`StepSet "${p}" is defined twice: ${d.file}:${d.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${p}".`);if(a.examples.length>0)throw new _(`StepSet "${p}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let f of a.steps)if(o.invocations.has(f.location.line))throw new _(`StepSet "${p}" (${s}:${a.location.line}) invokes another step set at line ${f.location.line} \u2014 step sets cannot be nested.`);r.set(p,{name:p,file:s,line:a.location.line,steps:[...a.steps]})})}return r}var gr=[".feature",".saffron"];function te(t){return gr.some(e=>t.endsWith(e))}function le(t){if(!Te.existsSync(t))return[];let e=[];for(let r of Te.readdirSync(t,{recursive:!0})){let n=String(r);n.endsWith(".saffron")&&e.push(Qe.join(t,n))}return e.sort()}function Je(t,e,r){return t.map(({step:n,source:s})=>{let o;switch(n.keywordType){case Ae.OUTCOME:o="assertion";break;case Ae.CONTEXT:case Ae.ACTION:o="action";break;default:o=r.value}r.value=o;let i=n.dataTable?n.dataTable.rows.map(p=>p.cells.map(d=>d.value)):void 0,a=n.docString?.content;return{keyword:n.keyword.trim(),kind:o,text:n.text,resolvedText:N(n.text,e),table:i,resolvedTable:i?.map(p=>p.map(d=>N(d,e))),docString:a,resolvedDocString:a!==void 0?N(a,e):void 0,source:s}})}function Xe(t,e,r,n,s){let o=[];for(let i of t){let a=n.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:r}});continue}let p=s?.get(a);if(!p)throw new _(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let d of p.steps)o.push({step:d,source:{file:p.file,line:d.location.line,fromStepSet:p.name,fromBackground:r}})}return o}function vr(t,e,r,n,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let p of t.examples){let d=p.tableHeader?.cells.map(f=>f.value)??[];for(let f of p.tableBody){a+=1;let l={};f.cells.forEach((c,g)=>{l[d[g]]=c.value}),i.push({params:l,label:` \u2014 example ${a}`})}}}return i.map(({params:a,label:p})=>{let d={value:"action"};return{featurePath:e,featureName:r,name:t.name,displayName:`${t.name}${p}`,tags:o,steps:[...Je(n,a,d),...Je(s,a,d)],params:a}})}function J(t,e,r){let n=mr.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?Ee(n):{source:n,definitions:new Map,invocations:new Map},a=new br(new Sr(xr.uuid()),new yr).parse(o.source);if(!a.feature)return[];let p=hr.relative(e,t),d=a.feature.name,f=[],l=(c,g)=>{let u=g;for(let m of c)if(m.background)u=[...g,...Xe(m.background.steps,p,!0,o.invocations,r)];else if(m.scenario){if(o.definitions.has(m.scenario.location.line))continue;f.push(...vr(m.scenario,p,d,u,Xe(m.scenario.steps,p,!1,o.invocations,r)))}else"rule"in m&&m.rule&&l(m.rule.children,u)};return l(a.feature.children,[]),f}import at from"node:fs";import Ce from"node:path";import tt from"node:fs";import rt from"node:path";import de from"node:fs";import re from"node:path";var Ze=".saffron";function pe(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function B(t,e,r){let n=pe(re.basename(e,".feature"));return re.join(t,Ze,"cache",n,`${pe(r)}.json`)}function et(t,e,r){let n=pe(re.basename(e,".feature"));return re.join(t,Ze,"proposals",n,`${pe(r)}.json`)}function O(t){if(!de.existsSync(t))return;let e=JSON.parse(de.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function X(t,e){de.mkdirSync(re.dirname(t),{recursive:!0}),de.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
- `)}function st(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(n,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function wr(t,e){let r=n=>JSON.stringify(n.map(({pageFingerprint:s,...o})=>o));return r(t)===r(e)}function kr(t){let e=new Set,r=n=>{if(n)for(let s of n.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let n of t.actions)r(n.value),r(n.url),r(n.pattern),r(n.target?.name),r(n.target?.nameRegex),r(n.target?.label),r(n.target?.text),r(n.target?.placeholder);return r(t.gherkin),e}function V(t){let e=rt.join(t,".saffron","cache"),r=new Map,n=new Map,s=0;if(tt.existsSync(e))for(let a of tt.readdirSync(e,{recursive:!0})){let p=String(a);if(!p.endsWith(".json"))continue;let d=O(rt.join(e,p));if(d){s++;for(let f of d.steps){let l=f.gherkin,c=JSON.stringify(f.actions.map(({pageFingerprint:m,...h})=>h)),g=n.get(l)??[];g.includes(c)||(g.push(c),n.set(l,g));let u=r.get(l);!u||d.recordedAt>u.recordedAt?r.set(l,{gherkin:f.gherkin,table:f.table,docString:f.docString,actions:f.actions,feature:d.feature,scenario:d.scenario,recordedAt:d.recordedAt,variants:g.length}):u.variants=g.length}}}let o=[...n.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of r.values()){let{pattern:p,args:d}=st(a.gherkin);if(d.length===0)continue;let f=i.get(p);(!f||a.recordedAt>f.recordedAt)&&i.set(p,{...a,args:d})}return{steps:r,patterns:i,divergent:o,scannedCaches:s}}function nt(t,e,r){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(j(t.table)),i=Object.keys(j(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let n={...r,...j(e.resolvedTable??e.table),...z(e.resolvedDocString??e.docString)},s=kr({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in n))return!1;return!0}function ot(t,e,r){let n=t.steps.get(e.text);if(n)return nt(n,e,r)?n:void 0;let{pattern:s,args:o}=st(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],g=o[l];if(c===g)continue;let u=a.get(c);if(u!==void 0&&u!==g)return;a.set(c,g)}let p=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(g=>[g.value,g.url,g.pattern,...p(g.target)].includes(l)))return;let d=l=>l!==void 0&&a.has(l)?a.get(l):l,f={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:d(c.value),url:d(c.url),pattern:d(c.pattern),target:c.target?{...c.target,name:d(c.target.name),nameRegex:d(c.target.nameRegex),label:d(c.target.label),text:d(c.target.text),placeholder:d(c.target.placeholder)}:void 0}))};return nt(f,e,r)?f:void 0}function it(t,e){let r=[];return t.forEach((n,s)=>{if(n.actions.length!==0&&!e.steps.has(n.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&wr(n.actions,o.actions)){r.push({index:s,from:n.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),r}function $r(t){if(!at.existsSync(t))return[];let e=[];for(let r of at.readdirSync(t,{recursive:!0})){let n=String(r);te(n)&&e.push(Ce.join(t,n))}return e.sort()}function H(t,e,r){let n=Ce.isAbsolute(e)?e:Ce.join(t,e),s=$r(n),o=r??K(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let g of J(c,t,o)){let u=`${g.featurePath}::${g.name}`;for(let m of g.steps){let h=i.get(m.text);if(h||(h={text:m.text,keywords:new Map,kind:m.kind,scenarios:new Set,files:new Set,fromStepSet:m.source?.fromStepSet},i.set(m.text,h)),h.keywords.set(m.keyword,(h.keywords.get(m.keyword)??0)+1),h.scenarios.add(u),m.source&&h.files.add(m.source.file),m.source?.fromStepSet){let b=a.get(m.source.fromStepSet);b||a.set(m.source.fromStepSet,b=new Set),b.add(u)}}}let p=V(t),d=new Set(p.divergent),f=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((g,u)=>u[1]-g[1])[0][0],kind:c.kind,status:d.has(c.text)?"divergent":p.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,g)=>g.usage-c.usage||c.text.localeCompare(g.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(g=>g.text),usage:a.get(c.name)?.size??0})).sort((c,g)=>g.usage-c.usage||c.name.localeCompare(g.name));return{steps:f,stepSets:l,scannedFiles:s.length,scannedCaches:p.scannedCaches}}function ue(t,e){let r=e.toLowerCase();return{...t,steps:t.steps.filter(n=>n.text.toLowerCase().includes(r)),stepSets:t.stepSets.filter(n=>n.name.toLowerCase().includes(r)||n.steps.some(s=>s.toLowerCase().includes(r)))}}function ct(t){let e={},r="saffron,feature,gherkin,cucumber";for(let n of t.steps)e[`step: ${n.text}`]={scope:r,prefix:n.text,body:[n.text.replace(/\$/g,"\\$")],description:`${n.status} \xB7 used in ${n.usage} scenario(s)`};for(let n of t.stepSets)e[`StepSet: ${n.name}`]={scope:r,prefix:`StepSet ${n.name}`,body:[`StepSet ${n.name}`],description:`step set (${n.steps.length} steps) \u2014 ${n.file}`};return JSON.stringify(e,null,2)+`
6
- `}import{Server as Rr}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Tr}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Er,ListToolsRequestSchema as Ar}from"@modelcontextprotocol/sdk/types.js";async function lt(t,e){let r=new Rr({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});r.setRequestHandler(Ar,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),r.setRequestHandler(Er,async n=>{let s=H(t,e);if(n.params.name==="search_steps"){let o=n.params.arguments?.query,i=o?ue(s,o):s;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return n.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:s.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${n.params.name}`}],isError:!0}}),await r.connect(new Tr)}import{query as Cr}from"@anthropic-ai/claude-agent-sdk";function Pr(t,e){let r=e.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
2
+ import F from"node:fs";import R from"node:path";import{spawn as Rn}from"node:child_process";import{fileURLToPath as Tn}from"node:url";import{Command as En}from"commander";import Sr from"node:fs";import yr from"node:path";import{AstBuilder as br,GherkinClassicTokenMatcher as xr,Parser as vr}from"@cucumber/gherkin";import{IdGenerator as wr,StepKeywordType as Ae}from"@cucumber/messages";function N(t,e){return t.replace(/<([^<>]+)>/g,(r,n)=>n in e?e[n]:r)}function D(t){if(!t||t.length===0)return{};let e={};if(t.every(r=>r.length===2)){for(let[r,n]of t)e[`table:${r}`]=n;return e}if(t.length>=2){let[r,...n]=t;if(r.some(s=>!s))return{};n.forEach((s,o)=>{r.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function z(t){return t!==void 0?{docstring:t}:{}}function Q(t){let e=new Map;for(let n of t){let s={...D(n.resolvedTable??n.table),...z(n.resolvedDocString??n.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let r={};for(let[n,s]of e)s.size===1&&(r[n]=[...s][0]);return r}import Te from"node:fs";import Ke from"node:path";import{AstBuilder as lr,GherkinClassicTokenMatcher as dr,Parser as pr}from"@cucumber/gherkin";import{IdGenerator as ur}from"@cucumber/messages";var j=class extends Error{},fr=/^(\s*)StepSet:\s*(.+?)\s*$/,gr=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Ee(t){let e=t.split(`
3
+ `),r=new Map,n=new Map;return{source:e.map((o,i)=>{let a=o.match(fr);if(a)return r.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=o.match(gr);return d?(n.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):o}).join(`
4
+ `),definitions:r,invocations:n}}function mr(t){return new pr(new lr(ur.uuid()),new dr).parse(t)}function Je(t,e){for(let r of t)r.scenario?e(r.scenario):"rule"in r&&r.rule&&Je(r.rule.children,e)}function K(t,e){let r=new Map;for(let n of t){let s=Ke.relative(e,n),o=Ee(Te.readFileSync(n,"utf8"));if(o.definitions.size===0)continue;let i=mr(o.source);i.feature&&Je(i.feature.children,a=>{let d=o.definitions.get(a.location.line);if(d===void 0)return;let p=r.get(d);if(p)throw new j(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new j(`StepSet "${d}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let m of a.steps)if(o.invocations.has(m.location.line))throw new j(`StepSet "${d}" (${s}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);r.set(d,{name:d,file:s,line:a.location.line,steps:[...a.steps]})})}return r}var hr=[".feature",".saffron"];function re(t){return hr.some(e=>t.endsWith(e))}function le(t){if(!Te.existsSync(t))return[];let e=[];for(let r of Te.readdirSync(t,{recursive:!0})){let n=String(r);n.endsWith(".saffron")&&e.push(Ke.join(t,n))}return e.sort()}function Xe(t,e,r){return t.map(({step:n,source:s})=>{let o;switch(n.keywordType){case Ae.OUTCOME:o="assertion";break;case Ae.CONTEXT:case Ae.ACTION:o="action";break;default:o=r.value}r.value=o;let i=n.dataTable?n.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=n.docString?.content;return{keyword:n.keyword.trim(),kind:o,text:n.text,resolvedText:N(n.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>N(p,e))),docString:a,resolvedDocString:a!==void 0?N(a,e):void 0,source:s}})}function Ze(t,e,r,n,s){let o=[];for(let i of t){let a=n.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:r}});continue}let d=s?.get(a);if(!d)throw new j(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)o.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:r}})}return o}function kr(t,e,r,n,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(m=>m.value)??[];for(let m of d.tableBody){a+=1;let l={};m.cells.forEach((c,f)=>{l[p[f]]=c.value}),i.push({params:l,label:` \u2014 example ${a}`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:r,name:t.name,displayName:`${t.name}${d}`,tags:o,steps:[...Xe(n,a,p),...Xe(s,a,p)],params:a}})}function J(t,e,r){let n=Sr.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?Ee(n):{source:n,definitions:new Map,invocations:new Map},a=new vr(new br(wr.uuid()),new xr).parse(o.source);if(!a.feature)return[];let d=yr.relative(e,t),p=a.feature.name,m=[],l=(c,f)=>{let h=f;for(let u of c)if(u.background)h=[...f,...Ze(u.background.steps,d,!0,o.invocations,r)];else if(u.scenario){if(o.definitions.has(u.scenario.location.line))continue;m.push(...kr(u.scenario,d,p,h,Ze(u.scenario.steps,d,!1,o.invocations,r)))}else"rule"in u&&u.rule&&l(u.rule.children,h)};return l(a.feature.children,[]),m}import ct from"node:fs";import Pe from"node:path";import rt from"node:fs";import nt from"node:path";import de from"node:fs";import ne from"node:path";var et=".saffron";function pe(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function B(t,e,r){let n=pe(ne.basename(e,".feature"));return ne.join(t,et,"cache",n,`${pe(r)}.json`)}function tt(t,e,r){let n=pe(ne.basename(e,".feature"));return ne.join(t,et,"proposals",n,`${pe(r)}.json`)}function M(t){if(!de.existsSync(t))return;let e=JSON.parse(de.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function X(t,e){de.mkdirSync(ne.dirname(t),{recursive:!0}),de.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
+ `)}function ot(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(n,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function $r(t,e){let r=n=>JSON.stringify(n.map(({pageFingerprint:s,...o})=>o));return r(t)===r(e)}function Rr(t){let e=new Set,r=n=>{if(n)for(let s of n.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let n of t.actions)r(n.value),r(n.url),r(n.pattern),r(n.target?.name),r(n.target?.nameRegex),r(n.target?.label),r(n.target?.text),r(n.target?.placeholder);return r(t.gherkin),e}function V(t){let e=nt.join(t,".saffron","cache"),r=new Map,n=new Map,s=0;if(rt.existsSync(e))for(let a of rt.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=M(nt.join(e,d));if(p){s++;for(let m of p.steps){let l=m.gherkin,c=JSON.stringify(m.actions.map(({pageFingerprint:u,...g})=>g)),f=n.get(l)??[];f.includes(c)||(f.push(c),n.set(l,f));let h=r.get(l);!h||p.recordedAt>h.recordedAt?r.set(l,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:f.length}):h.variants=f.length}}}let o=[...n.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of r.values()){let{pattern:d,args:p}=ot(a.gherkin);if(p.length===0)continue;let m=i.get(d);(!m||a.recordedAt>m.recordedAt)&&i.set(d,{...a,args:p})}return{steps:r,patterns:i,divergent:o,scannedCaches:s}}function st(t,e,r){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(D(t.table)),i=Object.keys(D(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let n={...r,...D(e.resolvedTable??e.table),...z(e.resolvedDocString??e.docString)},s=Rr({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in n))return!1;return!0}function it(t,e,r){let n=t.steps.get(e.text);if(n)return st(n,e,r)?n:void 0;let{pattern:s,args:o}=ot(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],f=o[l];if(c===f)continue;let h=a.get(c);if(h!==void 0&&h!==f)return;a.set(c,f)}let d=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(f=>[f.value,f.url,f.pattern,...d(f.target)].includes(l)))return;let p=l=>l!==void 0&&a.has(l)?a.get(l):l,m={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:p(c.value),url:p(c.url),pattern:p(c.pattern),target:c.target?{...c.target,name:p(c.target.name),nameRegex:p(c.target.nameRegex),label:p(c.target.label),text:p(c.target.text),placeholder:p(c.target.placeholder)}:void 0}))};return st(m,e,r)?m:void 0}function at(t,e){let r=[];return t.forEach((n,s)=>{if(n.actions.length!==0&&!e.steps.has(n.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&$r(n.actions,o.actions)){r.push({index:s,from:n.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),r}function Tr(t){if(!ct.existsSync(t))return[];let e=[];for(let r of ct.readdirSync(t,{recursive:!0})){let n=String(r);re(n)&&e.push(Pe.join(t,n))}return e.sort()}function q(t,e,r){let n=Pe.isAbsolute(e)?e:Pe.join(t,e),s=Tr(n),o=r??K(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let f of J(c,t,o)){let h=`${f.featurePath}::${f.name}`;for(let u of f.steps){let g=i.get(u.text);if(g||(g={text:u.text,keywords:new Map,kind:u.kind,scenarios:new Set,files:new Set,fromStepSet:u.source?.fromStepSet},i.set(u.text,g)),g.keywords.set(u.keyword,(g.keywords.get(u.keyword)??0)+1),g.scenarios.add(h),u.source&&g.files.add(u.source.file),u.source?.fromStepSet){let b=a.get(u.source.fromStepSet);b||a.set(u.source.fromStepSet,b=new Set),b.add(h)}}}let d=V(t),p=new Set(d.divergent),m=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((f,h)=>h[1]-f[1])[0][0],kind:c.kind,status:p.has(c.text)?"divergent":d.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,f)=>f.usage-c.usage||c.text.localeCompare(f.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(f=>f.text),usage:a.get(c.name)?.size??0})).sort((c,f)=>f.usage-c.usage||c.name.localeCompare(f.name));return{steps:m,stepSets:l,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function ue(t,e){let r=e.toLowerCase();return{...t,steps:t.steps.filter(n=>n.text.toLowerCase().includes(r)),stepSets:t.stepSets.filter(n=>n.name.toLowerCase().includes(r)||n.steps.some(s=>s.toLowerCase().includes(r)))}}function lt(t){let e={},r="saffron,feature,gherkin,cucumber";for(let n of t.steps)e[`step: ${n.text}`]={scope:r,prefix:n.text,body:[n.text.replace(/\$/g,"\\$")],description:`${n.status} \xB7 used in ${n.usage} scenario(s)`};for(let n of t.stepSets)e[`StepSet: ${n.name}`]={scope:r,prefix:`StepSet ${n.name}`,body:[`StepSet ${n.name}`],description:`step set (${n.steps.length} steps) \u2014 ${n.file}`};return JSON.stringify(e,null,2)+`
6
+ `}import{Server as Er}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Ar}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Pr,ListToolsRequestSchema as Cr}from"@modelcontextprotocol/sdk/types.js";async function dt(t,e){let r=new Er({name:"saffron",version:"0.1.0"},{capabilities:{tools:{}}});r.setRequestHandler(Cr,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate \u2014 exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}}]})),r.setRequestHandler(Pr,async n=>{let s=q(t,e);if(n.params.name==="search_steps"){let o=n.params.arguments?.query,i=o?ue(s,o):s;return{content:[{type:"text",text:JSON.stringify({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches},null,2)}]}}return n.params.name==="list_step_sets"?{content:[{type:"text",text:JSON.stringify({stepSets:s.stepSets},null,2)}]}:{content:[{type:"text",text:`Unknown tool: ${n.params.name}`}],isError:!0}}),await r.connect(new Ar)}import{query as Fr}from"@anthropic-ai/claude-agent-sdk";function Ir(t,e){let r=e.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
7
7
  `),n=e.stepSets.map(s=>`- StepSet ${s.name}
8
8
  ${s.steps.map(o=>` ${o}`).join(`
9
9
  `)}`).join(`
10
10
  `);return["Draft a Saffron feature file from the requirements below.","","EXISTING STEP VOCABULARY \u2014 reuse these wordings EXACTLY (character for character) wherever they express what a step needs to do. Steps marked [recorded] or [divergent] already have replay recordings: reusing them makes the new file replay almost for free. Only invent a new wording when nothing in the vocabulary expresses the intent, and then follow the same style:",r.length>0?r:"(none yet \u2014 this is the project's first file)","",e.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
11
11
  ${n}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",t.trim()].filter(s=>s!==void 0).join(`
12
- `)}async function dt(t,e,r){let n={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Cr({prompt:Pr(t,e),options:{model:r,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(n.aiCalls=i.num_turns,n.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(n.inputTokens=i.usage.input_tokens??0,n.outputTokens=i.usage.output_tokens??0,n.cacheReadTokens=i.usage.cache_read_input_tokens??0,n.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
13
- `,usage:n}}import fe from"node:path";import{createConnection as Fr,ProposedFeatures as Ir,TextDocuments as _r,TextDocumentSyncKind as jr,CompletionItemKind as Pe,DiagnosticSeverity as Fe,MarkupKind as pt}from"vscode-languageserver/node";import{TextDocument as Dr}from"vscode-languageserver-textdocument";var ut=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,Mr=/^\s*StepSet:\s*(.+?)\s*$/,Ie=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,_e={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Or(t,e){let r=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),n=r(t),s=r(e);if(n.size===0||s.size===0)return 0;let o=0;for(let i of n)s.has(i)&&o++;return o/(n.size+s.size-o)}function ft(t,e){let r=Fr(Ir.all,process.stdin,process.stdout),n=new _r(Dr),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,a=()=>{try{let l=fe.isAbsolute(e)?e:fe.join(t,e);o=K(le(l),t),s=H(t,e,o)}catch(l){r.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of n.all())f(l)},p=()=>{clearTimeout(i),i=setTimeout(a,300)};function d(l){return s.steps.find(c=>c.text===l)?.status}function f(l){if(!l.uri.endsWith(".saffron"))return;let c=[],g=l.getText().split(`
14
- `),u=new Map;for(let m=0;m<g.length;m++){let h=g[m],b={start:{line:m,character:0},end:{line:m,character:h.length}},y=h.match(Ie);y&&!o.has(y[3])&&c.push({range:b,severity:Fe.Error,source:"saffron",message:`StepSet "${y[3]}" is not defined anywhere in the project.`});let v=h.match(Mr);if(v){let w=o.get(v[1]),T=fe.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(u.has(v[1])||w&&w.file!==T)&&c.push({range:b,severity:Fe.Error,source:"saffron",message:`StepSet "${v[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${v[1]}".`}),u.set(v[1],m)}let x=h.match(ut);if(x&&d(x[3])!=="recorded"){for(let w of s.steps)if(!(w.text===x[3]||w.status==="unrecorded")&&Or(w.text,x[3])>=.7){c.push({range:{start:{line:m,character:h.indexOf(x[3])},end:{line:m,character:h.length}},severity:Fe.Information,source:"saffron",message:`Similar to the ${w.status} step "${w.text}" (\xD7${w.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}r.sendDiagnostics({uri:l.uri,diagnostics:c})}r.onInitialize(()=>(a(),{capabilities:{textDocumentSync:jr.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),r.onCompletion(l=>{let c=n.get(l.textDocument.uri);if(!c)return[];let g=c.getText({start:{line:l.position.line,character:0},end:l.position});return/^\s*StepSet\s+/.test(g)&&c.uri.endsWith(".saffron")?s.stepSets.map(u=>({label:u.name,kind:Pe.Function,detail:`step set \xB7 ${u.steps.length} step(s) \xB7 \xD7${u.usage}`,documentation:u.steps.join(`
15
- `)})):/^\s*(Given|When|Then|And|But|\*)\s+/.test(g)?s.steps.map((u,m)=>({label:u.text,kind:Pe.Text,detail:`${_e[u.status]} \xB7 \xD7${u.usage}`,sortText:String(m).padStart(5,"0"),filterText:u.text})):/^\s*S\w*$/.test(g.trimEnd())&&c.uri.endsWith(".saffron")?s.stepSets.map(u=>({label:`StepSet ${u.name}`,kind:Pe.Function})):[]}),r.onDefinition(l=>{let c=n.get(l.textDocument.uri);if(!c)return;let u=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,"").match(Ie);if(!u)return;let m=o.get(u[3]);return m?{uri:`file://${fe.join(t,m.file)}`,range:{start:{line:m.line-1,character:0},end:{line:m.line-1,character:0}}}:void 0}),r.onHover(l=>{let c=n.get(l.textDocument.uri);if(!c)return;let g=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),u=g.match(Ie);if(u){let h=s.stepSets.find(y=>y.name===u[3]);if(!h)return;let b=h.steps.map(y=>`- ${_e[d(y)??"unrecorded"][0]} ${y}`).join(`
16
- `);return{contents:{kind:pt.Markdown,value:`**StepSet ${h.name}** \u2014 expands to:
12
+ `)}async function pt(t,e,r){let n={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Fr({prompt:Ir(t,e),options:{model:r,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(n.aiCalls=i.num_turns,n.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(n.inputTokens=i.usage.input_tokens??0,n.outputTokens=i.usage.output_tokens??0,n.cacheReadTokens=i.usage.cache_read_input_tokens??0,n.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
13
+ `,usage:n}}import fe from"node:path";import{createConnection as _r,ProposedFeatures as jr,TextDocuments as Dr,TextDocumentSyncKind as Or,CompletionItemKind as Ce,DiagnosticSeverity as Fe,MarkupKind as ut}from"vscode-languageserver/node";import{TextDocument as Mr}from"vscode-languageserver-textdocument";var ft=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,Ur=/^\s*StepSet:\s*(.+?)\s*$/,Ie=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,_e={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Lr(t,e){let r=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),n=r(t),s=r(e);if(n.size===0||s.size===0)return 0;let o=0;for(let i of n)s.has(i)&&o++;return o/(n.size+s.size-o)}function gt(t,e){let r=_r(jr.all,process.stdin,process.stdout),n=new Dr(Mr),s={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},o=new Map,i,a=()=>{try{let l=fe.isAbsolute(e)?e:fe.join(t,e);o=K(le(l),t),s=q(t,e,o)}catch(l){r.console.warn(`saffron vocabulary rebuild: ${String(l)}`)}for(let l of n.all())m(l)},d=()=>{clearTimeout(i),i=setTimeout(a,300)};function p(l){return s.steps.find(c=>c.text===l)?.status}function m(l){if(!l.uri.endsWith(".saffron"))return;let c=[],f=l.getText().split(`
14
+ `),h=new Map;for(let u=0;u<f.length;u++){let g=f[u],b={start:{line:u,character:0},end:{line:u,character:g.length}},S=g.match(Ie);S&&!o.has(S[3])&&c.push({range:b,severity:Fe.Error,source:"saffron",message:`StepSet "${S[3]}" is not defined anywhere in the project.`});let k=g.match(Ur);if(k){let v=o.get(k[1]),$=fe.relative(t,new URL(l.uri).pathname).replace(/\\/g,"/");(h.has(k[1])||v&&v.file!==$)&&c.push({range:b,severity:Fe.Error,source:"saffron",message:`StepSet "${k[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${k[1]}".`}),h.set(k[1],u)}let x=g.match(ft);if(x&&p(x[3])!=="recorded"){for(let v of s.steps)if(!(v.text===x[3]||v.status==="unrecorded")&&Lr(v.text,x[3])>=.7){c.push({range:{start:{line:u,character:g.indexOf(x[3])},end:{line:u,character:g.length}},severity:Fe.Information,source:"saffron",message:`Similar to the ${v.status} step "${v.text}" (\xD7${v.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}r.sendDiagnostics({uri:l.uri,diagnostics:c})}r.onInitialize(()=>(a(),{capabilities:{textDocumentSync:Or.Incremental,completionProvider:{triggerCharacters:[" "]},definitionProvider:!0,hoverProvider:!0}})),r.onCompletion(l=>{let c=n.get(l.textDocument.uri);if(!c)return[];let f=c.getText({start:{line:l.position.line,character:0},end:l.position});return/^\s*StepSet\s+/.test(f)&&c.uri.endsWith(".saffron")?s.stepSets.map(h=>({label:h.name,kind:Ce.Function,detail:`step set \xB7 ${h.steps.length} step(s) \xB7 \xD7${h.usage}`,documentation:h.steps.join(`
15
+ `)})):/^\s*(Given|When|Then|And|But|\*)\s+/.test(f)?s.steps.map((h,u)=>({label:h.text,kind:Ce.Text,detail:`${_e[h.status]} \xB7 \xD7${h.usage}`,sortText:String(u).padStart(5,"0"),filterText:h.text})):/^\s*S\w*$/.test(f.trimEnd())&&c.uri.endsWith(".saffron")?s.stepSets.map(h=>({label:`StepSet ${h.name}`,kind:Ce.Function})):[]}),r.onDefinition(l=>{let c=n.get(l.textDocument.uri);if(!c)return;let h=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,"").match(Ie);if(!h)return;let u=o.get(h[3]);return u?{uri:`file://${fe.join(t,u.file)}`,range:{start:{line:u.line-1,character:0},end:{line:u.line-1,character:0}}}:void 0}),r.onHover(l=>{let c=n.get(l.textDocument.uri);if(!c)return;let f=c.getText({start:{line:l.position.line,character:0},end:{line:l.position.line+1,character:0}}).replace(/\n$/,""),h=f.match(Ie);if(h){let g=s.stepSets.find(S=>S.name===h[3]);if(!g)return;let b=g.steps.map(S=>`- ${_e[p(S)??"unrecorded"][0]} ${S}`).join(`
16
+ `);return{contents:{kind:ut.Markdown,value:`**StepSet ${g.name}** \u2014 expands to:
17
17
 
18
- ${b}`}}}let m=g.match(ut);if(m){let h=s.steps.find(b=>b.text===m[3]);return h?{contents:{kind:pt.Markdown,value:`${_e[h.status]}
18
+ ${b}`}}}let u=f.match(ft);if(u){let g=s.steps.find(b=>b.text===u[3]);return g?{contents:{kind:ut.Markdown,value:`${_e[g.status]}
19
19
 
20
- Used in ${h.usage} place(s): ${h.files.join(", ")}${h.fromStepSet?`
20
+ Used in ${g.usage} place(s): ${g.files.join(", ")}${g.fromStepSet?`
21
21
 
22
- Defined in StepSet **${h.fromStepSet}**`:""}`}}:void 0}}),n.onDidOpen(l=>f(l.document)),n.onDidChangeContent(l=>f(l.document)),n.onDidSave(()=>p()),r.onDidChangeWatchedFiles(()=>p()),n.listen(r),r.listen()}import gt from"node:fs";import Lr from"node:path";var mt=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function ne(t){return t.replace(mt,(e,r)=>{let n=process.env[r];if(n===void 0)throw new Error(`environment variable ${r} is not set (referenced as {env:${r}}) \u2014 export it or add it to .env`);return n})}function je(t){let e=new Set;for(let r of t)if(r)for(let n of r.matchAll(mt))e.add(n[1]);return e}function De(t){let e=[];for(let r of t.steps){e.push(r.text,r.resolvedText),r.docString&&e.push(r.docString);for(let n of r.table??[])e.push(...n)}return e}function se(t){let e=new Map;for(let r of je(De(t))){let n=process.env[r];n!==void 0&&e.set(r,n)}return e}function Z(t,e){let r=t;for(let[n,s]of[...e.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(r=r.split(s).join(`{env:${n}}`));return r}function ht(t){let e=Lr.join(t,".env");if(gt.existsSync(e))for(let r of gt.readFileSync(e,"utf8").split(`
23
- `)){let n=r.trim();if(!n||n.startsWith("#"))continue;let s=n.indexOf("=");if(s<=0)continue;let o=n.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=n.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import Wr from"node:fs";import qr from"node:net";import $t from"node:path";import{chromium as Yr,firefox as Gr,webkit as Qr}from"playwright";import L from"node:fs";import ge from"node:path";function St(t,e){L.mkdirSync(ge.dirname(t),{recursive:!0}),L.writeFileSync(t,`${JSON.stringify(e,null,2)}
24
- `)}function me(t){return JSON.parse(L.readFileSync(t,"utf8"))}function oe(t){let e=ge.join(t,".saffron","proposals");if(!L.existsSync(e))return[];let r=[];for(let n of L.readdirSync(e)){let s=ge.join(e,n);if(L.statSync(s).isDirectory())for(let o of L.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=ge.join(s,o);r.push({file:i,proposal:me(i)})}}return r}function yt(t,e){let r=me(e),n=B(t,r.cache.feature,r.cache.scenario);return X(n,r.cache),L.rmSync(e),n}function bt(t){L.rmSync(t)}import vt from"node:path";var Ur=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Nr(t,e){let r=String(t.getFullYear()),n=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",r).replaceAll("MM",n).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function Me(t,e=new Date){return t.replace(Ur,(r,n,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(n?parseInt(n,10):0)),Nr(o,s??"YYYY-MM-DD")})}var zr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Br=400;function xt(t,e=new Date){let r=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(zr,(n,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return n;let p=Math.round((a.getTime()-r.getTime())/864e5);return Math.abs(p)>Br?n:p===0?"{date}":`{date${p>0?"+":""}${p}}`})}import{createHash as Vr}from"node:crypto";async function W(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let r=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Vr("sha256").update(r).digest("hex")}`}var Le=t=>new Promise(e=>setTimeout(e,t));function wt(t,e,r,n=!1){let s=a=>ne(Me(N(a,r))),o=[],i=a=>{if(e.role){let p=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:p,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let p of e.fallbackSelectors??[])o.push(a.locator(p))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),n)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function U(t,e,r){let n=Date.now()+e,s;for(;Date.now()<n;){try{if(await t())return}catch(o){s=o}await Le(100)}throw new Error(`Timed out waiting for ${r}${s?` (${String(s)})`:""}`)}async function D(t,e){let r;for(let n of t)try{await e(n.first());return}catch(s){r=s}throw r}var Oe=t=>t.replace(/\s+/g," ").trim();async function Ue(t,e,r,n,s,o=new Map){let i=u=>ne(Me(N(u,r))),a=e.value!==void 0?i(e.value):void 0,p=e.url!==void 0?i(e.url):void 0,d=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",f=e.target?wt(t,e.target,r,d):[],l={timeout:n},c={timeout:Math.min(1e3,n)},g=u=>async()=>{for(let m of f)try{if(await u(m.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let u=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(u,l);return}case"click":return D(f,u=>u.click(l));case"fill":return D(f,u=>u.fill(a??"",l));case"press":return f.length>0?D(f,u=>u.press(a??"",l)):t.keyboard.press(a??"");case"selectOption":return D(f,async u=>{await u.selectOption(a??"",l)});case"check":return D(f,u=>u.check(l));case"uncheck":return D(f,u=>u.uncheck(l));case"hover":return D(f,u=>u.hover(l));case"waitFor":return f.length>0?D(f,u=>u.waitFor({state:"visible",timeout:n})):Le(Math.min(Number(a??"0"),n));case"expectVisible":return U(g(u=>u.isVisible()),n,"target to be visible");case"expectNotVisible":return D(f,u=>u.waitFor({state:"hidden",timeout:n}));case"expectText":return U(g(async u=>(await u.textContent(c)??"").includes(a??"")),n,`text "${a}"`);case"expectValue":return U(g(async u=>await u.inputValue(c)===(a??"")),n,`input value "${a}"`);case"expectUrl":{let u=p??a??"";return U(async()=>t.context().pages().some(m=>m.url().includes(u)),n,`URL containing "${u}" in any tab (current: ${t.context().pages().map(m=>m.url()).join(", ")})`)}case"handleDialog":{let u=(a??"accept")!=="dismiss",m=e.promptText?i(e.promptText):void 0;t.once("dialog",h=>{u?h.accept(m):h.dismiss()});return}case"uploadFile":{let u=(a??"").split(`
25
- `).filter(Boolean).map(m=>vt.isAbsolute(m)?m:vt.resolve(m));if(u.length===0)throw new Error("uploadFile requires a path");if(f.length>0)return D(f,m=>m.setInputFiles(u,l));t.once("filechooser",m=>{m.setFiles(u)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let u=wt(t,e.toTarget,r);return D(f,m=>m.dragTo(u[0].first(),l))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return U(g(async u=>{let m=Oe(await u.textContent(c)??"");return m===""?!1:(o.set(e.saveAs,m),!0)}),n,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let u=o.get(e.compareTo);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(f.length>0)return U(g(async b=>Oe(await b.textContent(c)??"")!==u),n,`target text to differ from "${e.compareTo}" (${u})`);let m=a??"",h=o.get(m);if(h===void 0)throw new Error(`expectDiffers: no captured value named "${m}"`);if(h===u)throw new Error(`expectDiffers: "${m}" (${h}) equals "${e.compareTo}" (${u})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let u=new RegExp(e.pattern,"i");return U(g(async m=>u.test(await m.getAttribute(e.attribute,c)??"")),n,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let u=new RegExp(e.pattern,"i");return U(g(async m=>u.test(Oe(await m.textContent(c)??""))),n,`target text to match /${e.pattern}/i`)}default:throw new Error(`Unknown action type: ${e.action}`)}}function kt(t,e){return t.actions.some(r=>[r.value,r.url,r.pattern,r.target?.name,r.target?.nameRegex,r.target?.label,r.target?.text,r.target?.placeholder].some(n=>n?.includes(e)))}function Hr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((r,n)=>{let s=t.steps[n];if(r.gherkin!==s.text)return!1;let o=r.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(j(o)),p=Object.keys(j(i));if(a.length>0&&p.length>0){if(JSON.stringify(a)!==JSON.stringify(p))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(r.docString!==void 0!=(s.docString!==void 0))return!1;if(r.docString!==void 0&&s.docString!==void 0&&r.docString!==s.docString){let a=e.steps.some(d=>kt(d,"<docstring>")),p=e.steps.some(d=>kt(d,r.docString));if(!a||p)return!1}return!0})}function Ne(t,e){let r=t.steps[e],n=r?.resolvedTable??r?.table;return{...t.params,...Q(t.steps),...j(n),...z(r?.resolvedDocString??r?.docString)}}async function he(t,e,r,n={}){let s=n.actionTimeoutMs??5e3,o=n.retries??1,i=[],a=n.vars??new Map,p=()=>Object.fromEntries(a),d=t,f=()=>{let l=t.context().pages(),c=l[l.length-1];c&&c!==d&&!c.isClosed()&&(d=c)};if(!Hr(e,r))return{status:"stale",stepResults:i,captured:p()};for(let l=0;l<r.steps.length;l++){let c=r.steps[l],g=Ne(e,l),u=!1;for(let m=0;m<c.actions.length;m++){let h=c.actions[m];f(),h.pageFingerprint&&await W(d)!==h.pageFingerprint&&(u=!0);let b,y=!1;for(let v=0;v<=o&&!y;v++){v>0&&(await Le(250*v),f());try{await Ue(d,h,g,s,n.baseURL,a),y=!0}catch(x){b=x}}if(!y){let v=b instanceof Error?b.message:String(b);i.push({gherkin:c.gherkin,fromStepSet:c.fromStepSet,kind:c.kind,status:"failed",drift:u,error:v});for(let x=l+1;x<r.steps.length;x++)i.push({gherkin:r.steps[x].gherkin,kind:r.steps[x].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:l,actionIndex:m,stepText:c.gherkin,kind:c.kind,error:v},stepResults:i,captured:p()}}}i.push({gherkin:c.gherkin,fromStepSet:c.fromStepSet,kind:c.kind,status:"passed",drift:u})}return{status:"passed",stepResults:i,captured:p()}}var Se={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Kr(){return new Promise((t,e)=>{let r=qr.createServer();r.listen(0,"127.0.0.1",()=>{let{port:n}=r.address();r.close(()=>t(n))}),r.on("error",e)})}function ie(t){let e=t.contexts().flatMap(r=>r.pages());return[...e].reverse().find(r=>r.url()!=="about:blank")??e.at(-1)}function At(t){let e=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)e=r;return e}function Rt(t,e){return t[e]?.kind==="assertion"&&e>=At(t)}function Tt(t,e,r=!1,n){let s=At(t);return t.map((o,i)=>{let a=e[i],p=a&&a.actions.length>0;return o.kind==="assertion"?r&&i<s&&p?a:o:p?n&&i===n.stepIndex&&n.actionIndex>0?{...a,actions:[...o.actions.slice(0,n.actionIndex),...a.actions]}:a:o})}function Jr(t,e){return t.map((r,n)=>{let s=e[n];return s&&s.actions.length>0?s:r})}function Et(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0)}}var ye=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=V(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,r){let n=this.getStepIndex(),s=e.steps.length,o={...e.params,...Q(e.steps)},i=e.steps.map(y=>{let v=n.steps.size>0?ot(n,y,o):void 0;return v?{gherkin:y.text,keyword:y.keyword,kind:y.kind,table:y.table,docString:y.docString,fromStepSet:y.source?.fromStepSet,actions:v.actions}:void 0}),a=e.steps.map(y=>({gherkin:y.text,keyword:y.keyword,kind:y.kind,table:y.table,docString:y.docString,fromStepSet:y.source?.fromStepSet,actions:[]})),p=new Map,d=[],f=[],l=[],c=[],g={...Se},u=0,m=0,h=0,b=y=>({success:y,narrative:f.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:c.length>0?c.join(`
26
- `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:g,seededCount:u});for(;h<s;){if(i[h]){let T=h;for(;T<s&&i[T];)T++;let S=i.slice(h,T),k={...e,steps:e.steps.slice(h,T)},A=await he(r,k,this.buildCandidateCache(e,S),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:p}),C=A.status==="passed"?T-h:A.failure?.stepIndex??0;for(let G=0;G<C;G++)a[h+G]=S[G];if(u+=C,A.status==="passed"){h=T;continue}let I=h+C;d.push(`Seeded recording for "${e.steps[I].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[I]=void 0,h=I;continue}if(!this.options.agent)return b(!1);m++;let y=h;for(;y<s&&!i[y];)y++;m>=3&&(y=s);let v=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:h>0?e.steps.slice(0,h).map(T=>T.resolvedText):void 0,recordOnly:y<s?{from:h,to:y-1}:void 0,takeFingerprint:async()=>W(ie(this.browser)??r)}));if(g=Et(g,v.usage),f.push(v.narrative),d.push(...v.adaptations),l.push(...v.featureEdits),v.suggestedFeatureEdit&&c.push(v.suggestedFeatureEdit),!v.success)return b(!1);let x=v.announcedSteps.filter(T=>T>=h&&T<s),w=Math.min(s-1,Math.max(x.length>0?Math.max(...x):y-1,h));for(let T=h;T<=w;T++)a[T]=v.recordedSteps[T];h=w+1}return b(!0)}async withDialogsVisible(e){let r=()=>{},n=[],s=[],o=i=>{i.on("dialog",r),n.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of n)try{i.off("dialog",r)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let r=await Kr();this.browser=await Yr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${r}`]}),this.cdpEndpoint=`http://127.0.0.1:${r}`}else this.browser=await(e==="firefox"?Gr:Qr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=$t.isAbsolute(this.options.storageState)?this.options.storageState:$t.join(this.options.projectRoot,this.options.storageState);if(!Wr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,r){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:r}}async proofReplay(e,r){let n=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await n.newPage();return{result:await he(s,e,r,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:n,page:s}}async runScenario(e,r={}){let n=await this.runScenarioInner(e,r),s=se(e);if(s.size>0){let o=i=>i===void 0?i:Z(i,s);n.error=o(n.error),n.narrative=o(n.narrative),n.proofError=o(n.proofError),n.suggestedFeatureEdit=o(n.suggestedFeatureEdit),n.adaptations=n.adaptations.map(i=>Z(i,s));for(let i of n.stepResults)i.error=o(i.error)}return n}async runScenarioInner(e,r={}){if(!this.browser)throw new Error("Orchestrator not started");let n=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,p=B(s,e.featurePath,e.name),d=O(p),f=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await f.newPage();try{let c;if(d&&(c=await he(l,e,d,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:Se,durationMs:Date.now()-n};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!r.agentDisabled)){let b=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!r.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:d?c?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let u=d!==void 0&&c!==void 0&&c.status==="failed",m=0,h;try{if(u)h=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let b=ie(this.browser)??l;return W(b)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,l);m=b.seededCount,h=b}else h=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=ie(this.browser)??l;return W(b)}}))}catch(b){let y=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:u?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:`Agent invocation failed: ${y.split(`
27
- `)[0]}`}}if(h.success&&u&&c.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Rt(d.steps,c.failure.stepIndex))){let b=d.steps[c.failure.stepIndex],y=ie(this.browser)??l,v=new Map(Object.entries(c.captured)),x=Ne(e,c.failure.stepIndex);try{for(let w of b.actions)await Ue(y,w,x,this.options.actionTimeoutMs??5e3,o,v)}catch(w){let T=w instanceof Error?w.message:String(w);return{scenario:e,status:"red",source:"agent-heal",stepResults:c.stepResults,narrative:h.narrative,adaptations:h.adaptations,suggestedFeatureEdit:h.suggestedFeatureEdit,usage:h.usage,durationMs:Date.now()-n,error:`Assertion failed as written: "${b.gherkin}". Saffron never adapts assertions \u2014 if this change is intended, update the .feature file or re-record the scenario. Verification error: ${T.split(`
28
- `)[0]}`}}}return await this.finalizeAgentRun(e,h,u,d,n,m,u?c?.failure:void 0)}finally{await f.close()}}async finalizeAgentRun(e,r,n,s,o,i=0,a){let p=n?"agent-heal":"agent-record",d=r.recordedSteps.map(x=>({gherkin:x.gherkin,kind:x.kind,status:r.success?"passed":"failed"}));if(!r.success)return{scenario:e,status:"red",source:p,stepResults:d,narrative:r.narrative,adaptations:r.adaptations,usage:r.usage,durationMs:Date.now()-o,error:r.narrative};let f=n?Tt(s.steps,r.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):r.recordedSteps,l=[...r.adaptations],c={...r.usage},g,u;if(this.options.verifyProposals!==!1){let x=await this.proofReplay(e,this.buildCandidateCache(e,f));try{if(x.result.status!=="passed"&&x.result.failure){let w=x.result.failure;if(this.options.agent&&!(n&&w.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Rt(s.steps,w.stepIndex))))try{let S=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:n?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:x.result.stepResults.filter(k=>k.status==="passed").map(k=>k.gherkin),failedStep:{text:w.stepText,kind:w.kind,error:w.error},takeFingerprint:async()=>W(ie(this.browser)??x.page)}));c=Et(c,S.usage),S.success&&(l.push(...S.adaptations),f=n?Tt(f,S.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):Jr(f,S.recordedSteps),await x.context.close(),x=await this.proofReplay(e,this.buildCandidateCache(e,f)))}catch{}}g=x.result.status==="passed",g||(u=x.result.failure?.error??`proof replay ${x.result.status}`)}finally{await x.context.close().catch(()=>{})}}let m=[...r.featureEdits];for(let x of it(f,V(this.options.projectRoot)))l.push(`duplicate wording: "${x.from}" recorded exactly the same actions as the existing step "${x.to}" (${x.feature} \u203A ${x.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),m.some(w=>w.index===x.index)||m.push({index:x.index,text:x.to});let h=se(e),b=x=>h.size>0?Z(x,h):x;l=l.map(b);let y={meta:{createdAt:new Date().toISOString(),mode:n?"heal":"record",narrative:b(r.narrative),adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit?b(r.suggestedFeatureEdit):void 0,featureEdits:m.length>0?m:void 0,verified:g,proofError:u?b(u):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:f}},v=et(this.options.projectRoot,e.featurePath,e.name);return St(v,y),{scenario:e,status:"yellow",source:p,stepResults:d,narrative:r.narrative,adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:m.length>0?m:void 0,verified:g,proofError:u,seededSteps:i>0?i:void 0,proposalFile:v,usage:c,durationMs:Date.now()-o}}};import Nt from"node:path";import{createRequire as sn}from"node:module";import{query as on}from"@anthropic-ai/claude-agent-sdk";var be=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];if(!n||n.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(n.actions.length-1,0);n.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];n&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.actions.push(...e))}},q=t=>t.replace(/\\(['"\\])/g,"$1");function Xr(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?q(e[1]):void 0}function ze(t){let e={},r=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);r&&(e.frame=q(r[1]??r[2]),t=t.slice((r.index??0)+r[0].length));let n=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(n){e.role=n[1];let i=(n[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=q(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let p=t.match(i);p&&(e[a]=q(p[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[q(o[1])]),Object.keys(e).length>0?e:void 0}function Zr(t,e){if(e&&t.startsWith(e)){let r=t.slice(e.length);return r.startsWith("/")?r:`/${r}`}return t}var en={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function tn(t,e){let r=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"goto",url:Zr(q(r[1]),e)};let n=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"press",value:q(n[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=ze(s[1]),g=ze(s[2]);if(c&&g)return{action:"dragTo",target:c,toTarget:g}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,p]=o,d=ze(i);if(!d)return;let f={action:en[a],target:d},l=Xr(p);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(f.value=l),f}function Ct(t,e){let r=[],n=Array.isArray(t)?t:t?.content;if(Array.isArray(n))for(let o of n){let i=o?.text;typeof i=="string"&&r.push(i)}else typeof t=="string"&&r.push(t);let s=[];for(let o of r)for(let i of o.split(`
29
- `)){let a=tn(i.trim(),e);a&&s.push(a)}return s}function Be(t,e){let r=t;for(let[n,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))r=r.split(s).join(`<${n}>`);return r}function Pt(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let n=s=>s===void 0?void 0:Be(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function Ft(t,e){let r=Q(e.steps);return t.map((n,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,p=Object.entries({...r,...j(i),...z(a)}).filter(([,f])=>f.length>=3).sort((f,l)=>l[1].length-f[1].length);if(p.length===0)return n;let d=f=>{if(f===void 0)return;let l=f;for(let[c,g]of p)l=l.split(g).join(`<${c}>`);return l};return{...n,actions:n.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 It(t,e=new Date){let r=n=>n===void 0?void 0:xt(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text)}:void 0}))}))}function _t(t,e){if(e.size===0)return t;let r=n=>n===void 0?void 0:Z(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),pattern:r(s.pattern),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text),placeholder:r(s.target.placeholder)}:void 0}))}))}function jt(t,e){let r=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let n={action:"handleDialog",value:r.accept?"accept":"dismiss"};return typeof r.promptText=="string"&&r.promptText&&(n.promptText=r.promptText),n}if(t==="mcp__playwright__browser_file_upload"){let n=Array.isArray(r.paths)?r.paths:[];if(n.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:n.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
30
- `)}}}import{createSdkMcpServer as rn,tool as ae}from"@anthropic-ai/claude-agent-sdk";import{z as R}from"zod";var M=t=>({content:[{type:"text",text:t}]});function Dt(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var Mt={role:R.string().optional().describe("ARIA role of the element"),name:R.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:R.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:R.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:R.string().optional().describe("getByText locator text"),selector:R.string().optional().describe("CSS fallback selector"),frame:R.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function Ot(t){return rn({name:"saffron",version:"0.1.0",tools:[ae("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:R.number().int().min(0),text:R.string(),adaptation:R.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),M(`Recording step ${e.index}: ${e.text}`))),ae("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:R.string().describe("Name to store the captured text under"),...Mt},async e=>{let r=Dt(e);return r?(t.addActions([{action:"captureText",target:r,saveAs:e.saveAs}]),M(`Capture recorded as "${e.saveAs}".`)):M("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ae("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:R.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:R.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),M(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),ae("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:R.enum(["visible","notVisible","text","url","value","differs","matches","attribute"]),...Mt,expected:R.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:R.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:R.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:R.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:R.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),compareTo:R.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let r=Dt(e),n;switch(e.kind){case"visible":n={action:"expectVisible",target:r};break;case"notVisible":n={action:"expectNotVisible",target:r};break;case"text":n={action:"expectText",target:r,value:e.expected??""};break;case"value":n={action:"expectValue",target:r,value:e.expected??""};break;case"url":n={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return M("ERROR: kind=differs requires compareTo.");if(!e.capture&&!r)return M("ERROR: kind=differs needs either capture (a stored name) or a target element.");n={action:"expectDiffers",target:r,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return M("ERROR: kind=matches requires pattern.");n={action:"expectMatches",target:r,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return M("ERROR: kind=attribute requires attribute and pattern.");n={action:"expectAttribute",target:r,attribute:e.attribute,pattern:e.pattern};break}return!["url","differs"].includes(e.kind)&&!r?M("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([n]),M("Assertion recorded."))}),ae("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:R.boolean(),narrative:R.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:R.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:R.array(R.object({index:R.number().int().min(0).describe("zero-based step index"),text:R.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],M("Run finalized.")))]})}function Lt(t={}){let e=t.deliberateSnapshots?`
22
+ Defined in StepSet **${g.fromStepSet}**`:""}`}}:void 0}}),n.onDidOpen(l=>m(l.document)),n.onDidChangeContent(l=>m(l.document)),n.onDidSave(()=>d()),r.onDidChangeWatchedFiles(()=>d()),n.listen(r),r.listen()}import mt from"node:fs";import Nr from"node:path";var ht=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function se(t){return t.replace(ht,(e,r)=>{let n=process.env[r];if(n===void 0)throw new Error(`environment variable ${r} is not set (referenced as {env:${r}}) \u2014 export it or add it to .env`);return n})}function je(t){let e=new Set;for(let r of t)if(r)for(let n of r.matchAll(ht))e.add(n[1]);return e}function De(t){let e=[];for(let r of t.steps){e.push(r.text,r.resolvedText),r.docString&&e.push(r.docString);for(let n of r.table??[])e.push(...n)}return e}function oe(t){let e=new Map;for(let r of je(De(t))){let n=process.env[r];n!==void 0&&e.set(r,n)}return e}function Z(t,e){let r=t;for(let[n,s]of[...e.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(r=r.split(s).join(`{env:${n}}`));return r}function St(t){let e=Nr.join(t,".env");if(mt.existsSync(e))for(let r of mt.readFileSync(e,"utf8").split(`
23
+ `)){let n=r.trim();if(!n||n.startsWith("#"))continue;let s=n.indexOf("=");if(s<=0)continue;let o=n.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=n.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import Qr from"node:fs";import Kr from"node:net";import Tt from"node:path";import{chromium as Jr,firefox as Xr,webkit as Zr}from"playwright";import U from"node:fs";import ge from"node:path";function yt(t,e){U.mkdirSync(ge.dirname(t),{recursive:!0}),U.writeFileSync(t,`${JSON.stringify(e,null,2)}
24
+ `)}function me(t){return JSON.parse(U.readFileSync(t,"utf8"))}function ie(t){let e=ge.join(t,".saffron","proposals");if(!U.existsSync(e))return[];let r=[];for(let n of U.readdirSync(e)){let s=ge.join(e,n);if(U.statSync(s).isDirectory())for(let o of U.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=ge.join(s,o);r.push({file:i,proposal:me(i)})}}return r}function bt(t,e){let r=me(e),n=B(t,r.cache.feature,r.cache.scenario);return X(n,r.cache),U.rmSync(e),n}function xt(t){U.rmSync(t)}import wt from"node:path";var zr=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Br(t,e){let r=String(t.getFullYear()),n=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",r).replaceAll("MM",n).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function Oe(t,e=new Date){return t.replace(zr,(r,n,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(n?parseInt(n,10):0)),Br(o,s??"YYYY-MM-DD")})}var Vr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,qr=400;function vt(t,e=new Date){let r=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(Vr,(n,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return n;let d=Math.round((a.getTime()-r.getTime())/864e5);return Math.abs(d)>qr?n:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as Hr}from"node:crypto";async function H(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let r=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Hr("sha256").update(r).digest("hex")}`}var Le=t=>new Promise(e=>setTimeout(e,t)),Ue=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,r){this.context=e,this.listener=n=>{let s={seq:++this.seq,url:n.url(),method:n.request().method(),status:n.status()};if(this.entries.push(s),r){let o=n.headers()["content-type"]??"";/json|text|xml/i.test(o)&&n.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function Wr(t,e){if(!e)return t>=200&&t<300;let r=/^([1-5])xx$/i.exec(e.trim());if(r){let n=Number(r[1])*100;return t>=n&&t<n+100}return t===Number(e.trim())}function kt(t,e,r,n,s){return!(!e.test(t.url)||r&&t.method.toUpperCase()!==r.toUpperCase()||!Wr(t.status,n)||s&&!s.test(t.body??""))}var Yr=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function $t(t,e,r,n=!1){let s=a=>se(Oe(N(a,r))),o=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let d of e.fallbackSelectors??[])o.push(a.locator(d))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),n)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function L(t,e,r){let n=Date.now()+e,s;for(;Date.now()<n;){try{if(await t())return}catch(o){s=o}await Le(100)}throw new Error(`Timed out waiting for ${r}${s?` (${String(s)})`:""}`)}async function O(t,e){let r;for(let n of t)try{await e(n.first());return}catch(s){r=s}throw r}var Me=t=>t.replace(/\s+/g," ").trim();async function Ne(t,e,r,n,s,o=new Map,i){let a=u=>se(Oe(N(u,r))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,m=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",l=e.target?$t(t,e.target,r,m):[],c={timeout:n},f={timeout:Math.min(1e3,n)},h=u=>async()=>{for(let g of l)try{if(await u(g.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let u=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(u,c);return}case"click":return O(l,u=>u.click(c));case"fill":return O(l,u=>u.fill(d??"",c));case"press":return l.length>0?O(l,u=>u.press(d??"",c)):t.keyboard.press(d??"");case"selectOption":return O(l,async u=>{await u.selectOption(d??"",c)});case"check":return O(l,u=>u.check(c));case"uncheck":return O(l,u=>u.uncheck(c));case"hover":return O(l,u=>u.hover(c));case"waitFor":return l.length>0?O(l,u=>u.waitFor({state:"visible",timeout:n})):Le(Math.min(Number(d??"0"),n));case"expectVisible":return L(h(u=>u.isVisible()),n,"target to be visible");case"expectNotVisible":return O(l,u=>u.waitFor({state:"hidden",timeout:n}));case"expectText":return L(h(async u=>(await u.textContent(f)??"").includes(d??"")),n,`text "${d}"`);case"expectValue":return L(h(async u=>await u.inputValue(f)===(d??"")),n,`input value "${d}"`);case"expectUrl":{let u=p??d??"";return L(async()=>t.context().pages().some(g=>g.url().includes(u)),n,`URL containing "${u}" in any tab (current: ${t.context().pages().map(g=>g.url()).join(", ")})`)}case"handleDialog":{let u=(d??"accept")!=="dismiss",g=e.promptText?a(e.promptText):void 0;t.once("dialog",b=>{u?b.accept(g):b.dismiss()});return}case"uploadFile":{let u=(d??"").split(`
25
+ `).filter(Boolean).map(g=>wt.isAbsolute(g)?g:wt.resolve(g));if(u.length===0)throw new Error("uploadFile requires a path");if(l.length>0)return O(l,g=>g.setInputFiles(u,c));t.once("filechooser",g=>{g.setFiles(u)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let u=$t(t,e.toTarget,r);return O(l,g=>g.dragTo(u[0].first(),c))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return L(h(async u=>{let g=Me(await u.textContent(f)??"");return g===""?!1:(o.set(e.saveAs,g),!0)}),n,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let u=o.get(e.compareTo);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(l.length>0)return L(h(async S=>Me(await S.textContent(f)??"")!==u),n,`target text to differ from "${e.compareTo}" (${u})`);let g=d??"",b=o.get(g);if(b===void 0)throw new Error(`expectDiffers: no captured value named "${g}"`);if(b===u)throw new Error(`expectDiffers: "${g}" (${b}) equals "${e.compareTo}" (${u})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let u=new RegExp(e.pattern,"i");return L(h(async g=>u.test(await g.getAttribute(e.attribute,f)??"")),n,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let u=new RegExp(e.pattern,"i");return L(h(async g=>u.test(Me(await g.textContent(f)??""))),n,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let u=new RegExp(a(e.urlPattern)),g=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(S=>kt({seq:0,url:S.url(),method:S.request().method(),status:S.status()},u,e.method,e.status,void 0),c);return}let b=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return L(async()=>b().some(S=>kt(S,u,e.method,e.status,g)),n,Yr(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function Rt(t,e){return t.actions.some(r=>[r.value,r.url,r.pattern,r.target?.name,r.target?.nameRegex,r.target?.label,r.target?.text,r.target?.placeholder].some(n=>n?.includes(e)))}function Gr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((r,n)=>{let s=t.steps[n];if(r.gherkin!==s.text)return!1;let o=r.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(D(o)),d=Object.keys(D(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(r.docString!==void 0!=(s.docString!==void 0))return!1;if(r.docString!==void 0&&s.docString!==void 0&&r.docString!==s.docString){let a=e.steps.some(p=>Rt(p,"<docstring>")),d=e.steps.some(p=>Rt(p,r.docString));if(!a||d)return!1}return!0})}function ze(t,e){let r=t.steps[e],n=r?.resolvedTable??r?.table;return{...t.params,...Q(t.steps),...D(n),...z(r?.resolvedDocString??r?.docString)}}async function he(t,e,r,n={}){let s=n.actionTimeoutMs??5e3,o=n.retries??1,i=[],a=n.vars??new Map,d=()=>Object.fromEntries(a),p=r.steps.some(f=>f.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),m=new Ue;m.attach(t.context(),p);let l=t,c=()=>{let f=t.context().pages(),h=f[f.length-1];h&&h!==l&&!h.isClosed()&&(l=h)};if(!Gr(e,r))return m.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let f=0;f<r.steps.length;f++){let h=r.steps[f],u=ze(e,f);m.markStep();let g=!1;for(let b=0;b<h.actions.length;b++){let S=h.actions[b];c(),S.pageFingerprint&&await H(l)!==S.pageFingerprint&&(g=!0);let k,x=!1;for(let v=0;v<=o&&!x;v++){v>0&&(await Le(250*v),c());try{await Ne(l,S,u,s,n.baseURL,a,m),x=!0}catch($){k=$}}if(!x){let v=k instanceof Error?k.message:String(k);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:g,error:v});for(let $=f+1;$<r.steps.length;$++)i.push({gherkin:r.steps[$].gherkin,kind:r.steps[$].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:f,actionIndex:b,stepText:h.gherkin,kind:h.kind,error:v},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:g})}return{status:"passed",stepResults:i,captured:d()}}finally{m.detach()}}var Se={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function en(){return new Promise((t,e)=>{let r=Kr.createServer();r.listen(0,"127.0.0.1",()=>{let{port:n}=r.address();r.close(()=>t(n))}),r.on("error",e)})}function ae(t){let e=t.contexts().flatMap(r=>r.pages());return[...e].reverse().find(r=>r.url()!=="about:blank")??e.at(-1)}function Ct(t){let e=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)e=r;return e}function Et(t,e){return t[e]?.kind==="assertion"&&e>=Ct(t)}function At(t,e,r=!1,n){let s=Ct(t);return t.map((o,i)=>{let a=e[i],d=a&&a.actions.length>0;return o.kind==="assertion"?r&&i<s&&d?a:o:d?n&&i===n.stepIndex&&n.actionIndex>0?{...a,actions:[...o.actions.slice(0,n.actionIndex),...a.actions]}:a:o})}function tn(t,e){return t.map((r,n)=>{let s=e[n];return s&&s.actions.length>0?s:r})}function Pt(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0)}}var ye=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=V(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,r){let n=this.getStepIndex(),s=e.steps.length,o={...e.params,...Q(e.steps)},i=e.steps.map(S=>{let k=n.steps.size>0?it(n,S,o):void 0;return k?{gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:k.actions}:void 0}),a=e.steps.map(S=>({gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:[]})),d=new Map,p=[],m=[],l=[],c=[],f={...Se},h=0,u=0,g=0,b=S=>({success:S,narrative:m.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:c.length>0?c.join(`
26
+ `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:f,seededCount:h});for(;g<s;){if(i[g]){let $=g;for(;$<s&&i[$];)$++;let y=i.slice(g,$),T={...e,steps:e.steps.slice(g,$)},A=await he(r,T,this.buildCandidateCache(e,y),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),P=A.status==="passed"?$-g:A.failure?.stepIndex??0;for(let G=0;G<P;G++)a[g+G]=y[G];if(h+=P,A.status==="passed"){g=$;continue}let _=g+P;p.push(`Seeded recording for "${e.steps[_].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[_]=void 0,g=_;continue}if(!this.options.agent)return b(!1);u++;let S=g;for(;S<s&&!i[S];)S++;u>=3&&(S=s);let k=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:g>0?e.steps.slice(0,g).map($=>$.resolvedText):void 0,recordOnly:S<s?{from:g,to:S-1}:void 0,takeFingerprint:async()=>H(ae(this.browser)??r)}));if(f=Pt(f,k.usage),m.push(k.narrative),p.push(...k.adaptations),l.push(...k.featureEdits),k.suggestedFeatureEdit&&c.push(k.suggestedFeatureEdit),!k.success)return b(!1);let x=k.announcedSteps.filter($=>$>=g&&$<s),v=Math.min(s-1,Math.max(x.length>0?Math.max(...x):S-1,g));for(let $=g;$<=v;$++)a[$]=k.recordedSteps[$];g=v+1}return b(!0)}async withDialogsVisible(e){let r=()=>{},n=[],s=[],o=i=>{i.on("dialog",r),n.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of n)try{i.off("dialog",r)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let r=await en();this.browser=await Jr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${r}`]}),this.cdpEndpoint=`http://127.0.0.1:${r}`}else this.browser=await(e==="firefox"?Xr:Zr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=Tt.isAbsolute(this.options.storageState)?this.options.storageState:Tt.join(this.options.projectRoot,this.options.storageState);if(!Qr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,r){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:r}}async proofReplay(e,r){let n=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await n.newPage();return{result:await he(s,e,r,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:n,page:s}}async runScenario(e,r={}){let n=await this.runScenarioInner(e,r),s=oe(e);if(s.size>0){let o=i=>i===void 0?i:Z(i,s);n.error=o(n.error),n.narrative=o(n.narrative),n.proofError=o(n.proofError),n.suggestedFeatureEdit=o(n.suggestedFeatureEdit),n.adaptations=n.adaptations.map(i=>Z(i,s));for(let i of n.stepResults)i.error=o(i.error)}return n}async runScenarioInner(e,r={}){if(!this.browser)throw new Error("Orchestrator not started");let n=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=B(s,e.featurePath,e.name),p=M(d),m=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await m.newPage();try{let c;if(p&&(c=await he(l,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:Se,durationMs:Date.now()-n};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!r.agentDisabled)){let b=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!r.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:p?c?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let h=p!==void 0&&c!==void 0&&c.status==="failed",u=0,g;try{if(h)g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,l);u=b.seededCount,g=b}else g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}))}catch(b){let S=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:`Agent invocation failed: ${S.split(`
27
+ `)[0]}`}}if(g.success&&h&&c.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Et(p.steps,c.failure.stepIndex))){let b=p.steps[c.failure.stepIndex],S=ae(this.browser)??l,k=new Map(Object.entries(c.captured)),x=ze(e,c.failure.stepIndex);try{for(let v of b.actions)await Ne(S,v,x,this.options.actionTimeoutMs??5e3,o,k)}catch(v){let $=v instanceof Error?v.message:String(v);return{scenario:e,status:"red",source:"agent-heal",stepResults:c.stepResults,narrative:g.narrative,adaptations:g.adaptations,suggestedFeatureEdit:g.suggestedFeatureEdit,usage:g.usage,durationMs:Date.now()-n,error:`Assertion failed as written: "${b.gherkin}". Saffron never adapts assertions \u2014 if this change is intended, update the .feature file or re-record the scenario. Verification error: ${$.split(`
28
+ `)[0]}`}}}return await this.finalizeAgentRun(e,g,h,p,n,u,h?c?.failure:void 0)}finally{await m.close()}}async finalizeAgentRun(e,r,n,s,o,i=0,a){let d=n?"agent-heal":"agent-record",p=r.recordedSteps.map(x=>({gherkin:x.gherkin,kind:x.kind,status:r.success?"passed":"failed"}));if(!r.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:r.narrative,adaptations:r.adaptations,usage:r.usage,durationMs:Date.now()-o,error:r.narrative};let m=n?At(s.steps,r.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):r.recordedSteps,l=[...r.adaptations],c={...r.usage},f,h;if(this.options.verifyProposals!==!1){let x=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(x.result.status!=="passed"&&x.result.failure){let v=x.result.failure;if(this.options.agent&&!(n&&v.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Et(s.steps,v.stepIndex))))try{let y=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:n?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:x.result.stepResults.filter(T=>T.status==="passed").map(T=>T.gherkin),failedStep:{text:v.stepText,kind:v.kind,error:v.error},takeFingerprint:async()=>H(ae(this.browser)??x.page)}));c=Pt(c,y.usage),y.success&&(l.push(...y.adaptations),m=n?At(m,y.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):tn(m,y.recordedSteps),await x.context.close(),x=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}f=x.result.status==="passed",f||(h=x.result.failure?.error??`proof replay ${x.result.status}`)}finally{await x.context.close().catch(()=>{})}}let u=[...r.featureEdits];for(let x of at(m,V(this.options.projectRoot)))l.push(`duplicate wording: "${x.from}" recorded exactly the same actions as the existing step "${x.to}" (${x.feature} \u203A ${x.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(v=>v.index===x.index)||u.push({index:x.index,text:x.to});let g=oe(e),b=x=>g.size>0?Z(x,g):x;l=l.map(b);let S={meta:{createdAt:new Date().toISOString(),mode:n?"heal":"record",narrative:b(r.narrative),adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit?b(r.suggestedFeatureEdit):void 0,featureEdits:u.length>0?u:void 0,verified:f,proofError:h?b(h):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},k=tt(this.options.projectRoot,e.featurePath,e.name);return yt(k,S),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:r.narrative,adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:u.length>0?u:void 0,verified:f,proofError:h,seededSteps:i>0?i:void 0,proposalFile:k,usage:c,durationMs:Date.now()-o}}};import Bt from"node:path";import{createRequire as ln}from"node:module";import{query as dn}from"@anthropic-ai/claude-agent-sdk";var be=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];if(!n||n.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(n.actions.length-1,0);n.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];n&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.actions.push(...e))}},W=t=>t.replace(/\\(['"\\])/g,"$1");function rn(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?W(e[1]):void 0}function Be(t){let e={},r=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);r&&(e.frame=W(r[1]??r[2]),t=t.slice((r.index??0)+r[0].length));let n=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(n){e.role=n[1];let i=(n[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=W(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=W(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[W(o[1])]),Object.keys(e).length>0?e:void 0}function nn(t,e){if(e&&t.startsWith(e)){let r=t.slice(e.length);return r.startsWith("/")?r:`/${r}`}return t}var sn={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function on(t,e){let r=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"goto",url:nn(W(r[1]),e)};let n=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"press",value:W(n[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=Be(s[1]),f=Be(s[2]);if(c&&f)return{action:"dragTo",target:c,toTarget:f}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=Be(i);if(!p)return;let m={action:sn[a],target:p},l=rn(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function Ft(t,e){let r=[],n=Array.isArray(t)?t:t?.content;if(Array.isArray(n))for(let o of n){let i=o?.text;typeof i=="string"&&r.push(i)}else typeof t=="string"&&r.push(t);let s=[];for(let o of r)for(let i of o.split(`
29
+ `)){let a=on(i.trim(),e);a&&s.push(a)}return s}function Ve(t,e){let r=t;for(let[n,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))r=r.split(s).join(`<${n}>`);return r}function It(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let n=s=>s===void 0?void 0:Ve(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function _t(t,e){let r=Q(e.steps);return t.map((n,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,d=Object.entries({...r,...D(i),...z(a)}).filter(([,m])=>m.length>=3).sort((m,l)=>l[1].length-m[1].length);if(d.length===0)return n;let p=m=>{if(m===void 0)return;let l=m;for(let[c,f]of d)l=l.split(f).join(`<${c}>`);return l};return{...n,actions:n.actions.map(m=>({...m,value:p(m.value),url:p(m.url),target:m.target?{...m.target,name:p(m.target.name),label:p(m.target.label),text:p(m.target.text),placeholder:p(m.target.placeholder)}:void 0}))}})}function jt(t,e=new Date){let r=n=>n===void 0?void 0:vt(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text)}:void 0}))}))}function Dt(t,e){if(e.size===0)return t;let r=n=>n===void 0?void 0:Z(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),pattern:r(s.pattern),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text),placeholder:r(s.target.placeholder)}:void 0}))}))}function Ot(t,e){let r=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let n={action:"handleDialog",value:r.accept?"accept":"dismiss"};return typeof r.promptText=="string"&&r.promptText&&(n.promptText=r.promptText),n}if(t==="mcp__playwright__browser_file_upload"){let n=Array.isArray(r.paths)?r.paths:[];if(n.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:n.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
30
+ `)}}}import{createSdkMcpServer as an,tool as ee}from"@anthropic-ai/claude-agent-sdk";import{z as w}from"zod";var I=t=>({content:[{type:"text",text:t}]});function Mt(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var Ut={role:w.string().optional().describe("ARIA role of the element"),name:w.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:w.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:w.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:w.string().optional().describe("getByText locator text"),selector:w.string().optional().describe("CSS fallback selector"),frame:w.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 Lt(t){return an({name:"saffron",version:"0.1.0",tools:[ee("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:w.number().int().min(0),text:w.string(),adaptation:w.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),I(`Recording step ${e.index}: ${e.text}`))),ee("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:w.string().describe("Name to store the captured text under"),...Ut},async e=>{let r=Mt(e);return r?(t.addActions([{action:"captureText",target:r,saveAs:e.saveAs}]),I(`Capture recorded as "${e.saveAs}".`)):I("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ee("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:w.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:w.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),I(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),ee("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:w.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:w.string().optional().describe("HTTP method to match (any if omitted)"),status:w.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:w.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),I("Network wait recorded."))),ee("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:w.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Ut,expected:w.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:w.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:w.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:w.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:w.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:w.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:w.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:w.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:w.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:w.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let r=Mt(e),n;switch(e.kind){case"visible":n={action:"expectVisible",target:r};break;case"notVisible":n={action:"expectNotVisible",target:r};break;case"text":n={action:"expectText",target:r,value:e.expected??""};break;case"value":n={action:"expectValue",target:r,value:e.expected??""};break;case"url":n={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return I("ERROR: kind=differs requires compareTo.");if(!e.capture&&!r)return I("ERROR: kind=differs needs either capture (a stored name) or a target element.");n={action:"expectDiffers",target:r,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return I("ERROR: kind=matches requires pattern.");n={action:"expectMatches",target:r,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return I("ERROR: kind=attribute requires attribute and pattern.");n={action:"expectAttribute",target:r,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return I("ERROR: kind=response requires urlPattern.");n={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!r?I("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([n]),I("Assertion recorded."))}),ee("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:w.boolean(),narrative:w.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:w.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:w.array(w.object({index:w.number().int().min(0).describe("zero-based step index"),text:w.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],I("Run finalized.")))]})}function Nt(t={}){let e=t.deliberateSnapshots?`
31
31
  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.
32
32
  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.
33
33
 
@@ -42,14 +42,15 @@ Non-negotiable rules:
42
42
  6. When all steps are done, call saffron_done exactly once. If you adapted steps, include BOTH suggestedFeatureEdit (human-readable corrected Gherkin) AND featureEdits (one {index, text} entry per adapted step, text without the keyword) so the correction can be applied automatically.
43
43
  5c. Real-world page furniture is supported: elements inside IFRAMES record and replay fine (interact normally) \u2014 but when a saffron_assert targets an element INSIDE an iframe, you MUST pass the iframe's CSS selector as the 'frame' argument (e.g. frame='iframe[title="Feedback widget"]'); replay cannot see into frames otherwise. File uploads: use browser_file_upload with the file path. Drag-and-drop: use browser_drag. All of these are recorded and replay deterministically.
44
44
  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.
45
- 7. Never ask the user questions; you are unattended.${e}`}function nn(t,e){if(t[e].kind!=="assertion")return"action";let r=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)r=n;return e>=r?"assertion:final":"assertion"}function Ut(t){let{scenario:e}=t,r=e.steps.map((s,o)=>{let i=l=>ne(l),a=`${o}. [${nn(e.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,p=s.resolvedTable??s.table,d=s.resolvedDocString??s.docString,f=a;return p&&(f+=`
46
- ${p.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
47
- `)}`),d!==void 0&&(f+=`
45
+ 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.
46
+ 7. Never ask the user questions; you are unattended.${e}`}function cn(t,e){if(t[e].kind!=="assertion")return"action";let r=t.length;for(let n=t.length-1;n>=0&&t[n].kind==="assertion";n--)r=n;return e>=r?"assertion:final":"assertion"}function zt(t){let{scenario:e}=t,r=e.steps.map((s,o)=>{let i=l=>se(l),a=`${o}. [${cn(e.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,d=s.resolvedTable??s.table,p=s.resolvedDocString??s.docString,m=a;return d&&(m+=`
47
+ ${d.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
48
+ `)}`),p!==void 0&&(m+=`
48
49
  """
49
- ${i(d).split(`
50
+ ${i(p).split(`
50
51
  `).map(l=>` ${l}`).join(`
51
52
  `)}
52
- """`),f}).join(`
53
+ """`),m}).join(`
53
54
  `),n=[`Feature: ${e.featureName} (${e.featurePath})`,`Scenario: ${e.displayName}`,t.baseURL?`Base URL: ${t.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",r,""];if(t.mode==="refine")n.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:
54
55
  ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
55
56
  `)||" (none)"}`,"",`Failed step: [${t.failedStep?.kind}] ${t.failedStep?.text}`,`Replay failure: ${t.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(t.mode==="heal")n.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:
@@ -58,25 +59,25 @@ ${(t.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
58
59
  ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
59
60
  `)}`:"You are starting from a blank page.","",`Record ONLY steps ${s} through ${o} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${o} already have recordings and will be replayed automatically the moment you stop. After completing step ${o}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(t.completedSteps&&t.completedSteps.length>0){let s=t.completedSteps.length;n.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",t.completedSteps.map(o=>` \u2713 ${o}`).join(`
60
61
  `),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,`Start with saffron_step for step index ${s} and record from there to the end.`)}else n.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 n.filter(s=>s!==void 0).join(`
61
- `)}var an=sn(import.meta.url);function cn(){let t=an.resolve("@playwright/mcp/package.json");return Nt.join(Nt.dirname(t),"cli.js")}var ln=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"]),xe=class{constructor(e={}){this.opts=e}opts;async run(e){let r=new be(e.scenario),n={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=on({prompt:Ut(e),options:{systemPrompt:Lt({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[cn(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Ot(r)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(ln.has(a.tool_name)&&e.takeFingerprint&&!r.pendingFingerprint)try{r.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,p=jt(a.tool_name,a.tool_input);return p&&r.addArmedAction(p),a.tool_name.startsWith("mcp__playwright__")&&(r.addActions(Ct(a.tool_response,e.baseURL)),r.pendingFingerprint=void 0),{continue:!0}}]}]}}});try{for await(let i of o)i.type==="result"&&(n.aiCalls=i.num_turns,n.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(n.inputTokens=i.usage.input_tokens??0,n.outputTokens=i.usage.output_tokens??0,n.cacheReadTokens=i.usage.cache_read_input_tokens??0,n.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(s=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);r.done=!0,r.success=!1,r.narrative=`Agent session error: ${a}`}return r.done||(r.success=!1,r.narrative=r.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:r.success,narrative:r.narrative,adaptations:r.adaptations,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:r.featureEdits.map(i=>({index:i.index,text:Be(i.text,e.scenario.params)})),recordedSteps:_t(It(Pt(Ft(r.steps,e.scenario),e.scenario.params)),se(e.scenario)),announcedSteps:[...r.announced].sort((i,a)=>i-a),usage:n}}};import zt from"node:fs";import ve from"node:path";function we(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function Bt(t,e){let r=new Map,n=Math.min(t.steps.length,e.steps.length);for(let s=0;s<n;s++){let o=t.steps[s].actions,i=e.steps[s].actions;if(o.length===i.length)for(let a=0;a<o.length;a++){let p=o[a],d=i[a];if(p.action!==d.action||!p.target||!d.target)continue;let f=we(p.target);f!==we(d.target)&&r.set(f,{from:p.target,to:d.target})}}return[...r.values()]}function dn(t){let e=ve.join(t,".saffron","cache");if(!zt.existsSync(e))return[];let r=[];for(let n of zt.readdirSync(e,{recursive:!0})){let s=String(n);s.endsWith(".json")&&r.push(ve.join(e,s))}return r}function Vt(t,e,r,n){if(e.length===0)return[];let s=new Map(e.map(i=>[we(i.from),i])),o=[];for(let i of dn(t)){if(ve.resolve(i)===ve.resolve(r))continue;let a=O(i);if(!a)continue;let p=[],d=!1;for(let f of a.steps){let l=0;for(let c of f.actions){if(!c.target)continue;let g=s.get(we(c.target));g&&(l++,n||(c.target=structuredClone(g.to),d=!0))}l>0&&p.push({gherkin:f.gherkin,count:l})}p.length>0&&(d&&X(i,a),o.push({file:i,scenario:a.scenario,feature:a.feature,steps:p}))}return o}function Ve(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import Ht from"node:fs";import He from"node:path";function Wt(t,e,r,n,s){let o={applied:[],skipped:[]},i=J(He.join(t,e),t,s).find(d=>d.name===r);if(!i){for(let d of n)o.skipped.push({index:d.index,reason:`scenario "${r}" not found in ${e}`});return o}let a=new Map,p=new Set;for(let d of n){let f=i.steps[d.index];if(!f?.source){o.skipped.push({index:d.index,reason:`no step at index ${d.index}`});continue}let{file:l,line:c,fromStepSet:g,fromBackground:u}=f.source;if(u){o.skipped.push({index:d.index,reason:`"${f.text}" is a Background step shared across scenarios \u2014 edit it manually`});continue}let m=`${l}:${c}`;if(p.has(m))continue;let h=a.get(l);h||(h=Ht.readFileSync(He.join(t,l),"utf8").split(`
62
- `),a.set(l,h));let b=h[c-1],y=b?.match(/^(\s*)(\S+)\s+(.*)$/);if(!y||y[3]!==f.text){o.skipped.push({index:d.index,reason:`${l}:${c} no longer matches the recorded step text`});continue}let v=`${y[1]}${y[2]} ${d.text}`;h[c-1]=v,p.add(m),o.applied.push({file:l,line:c,from:b.trim(),to:v.trim(),fromStepSet:g})}for(let[d,f]of a)o.applied.some(l=>l.file===d)&&Ht.writeFileSync(He.join(t,d),f.join(`
63
- `));return o}import We from"node:fs";import qe from"node:path";var E=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),Gt="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",pn="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 un(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
64
- <path d="${Gt}" fill="#F4A300"/>
65
- <path d="${pn}" fill="#E7E9EC"/>
66
- </svg>`}function fn(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${Gt}" fill="#F4A300"/></svg>`}var gn={green:"passed",yellow:"adapted",red:"failed"};function ke(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function ce(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function Y(t,e,r,n="",s="",o=""){return`<div class="kpi">
62
+ `)}var pn=ln(import.meta.url);function un(){let t=pn.resolve("@playwright/mcp/package.json");return Bt.join(Bt.dirname(t),"cli.js")}var fn=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"]),xe=class{constructor(e={}){this.opts=e}opts;async run(e){let r=new be(e.scenario),n={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=dn({prompt:zt(e),options:{systemPrompt:Nt({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:e.assertionPolicy==="adaptable-mid"}),model:e.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[un(),"--cdp-endpoint",e.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:Lt(r)},hooks:{PreToolUse:[{hooks:[async i=>{let a=i;if(fn.has(a.tool_name)&&e.takeFingerprint&&!r.pendingFingerprint)try{r.pendingFingerprint=await e.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async i=>{let a=i,d=Ot(a.tool_name,a.tool_input);return d&&r.addArmedAction(d),a.tool_name.startsWith("mcp__playwright__")&&(r.addActions(Ft(a.tool_response,e.baseURL)),r.pendingFingerprint=void 0),{continue:!0}}]}]}}});try{for await(let i of o)i.type==="result"&&(n.aiCalls=i.num_turns,n.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(n.inputTokens=i.usage.input_tokens??0,n.outputTokens=i.usage.output_tokens??0,n.cacheReadTokens=i.usage.cache_read_input_tokens??0,n.cacheCreationTokens=i.usage.cache_creation_input_tokens??0),i.subtype==="success"&&(s=i.result))}catch(i){let a=i instanceof Error?i.message:String(i);r.done=!0,r.success=!1,r.narrative=`Agent session error: ${a}`}return r.done||(r.success=!1,r.narrative=r.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:r.success,narrative:r.narrative,adaptations:r.adaptations,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:r.featureEdits.map(i=>({index:i.index,text:Ve(i.text,e.scenario.params)})),recordedSteps:Dt(jt(It(_t(r.steps,e.scenario),e.scenario.params)),oe(e.scenario)),announcedSteps:[...r.announced].sort((i,a)=>i-a),usage:n}}};import Vt from"node:fs";import ve from"node:path";function we(t){return JSON.stringify([t.role??null,t.name??null,t.nameRegex??null,t.label??null,t.text??null,t.testId??null,t.placeholder??null])}function qt(t,e){let r=new Map,n=Math.min(t.steps.length,e.steps.length);for(let s=0;s<n;s++){let o=t.steps[s].actions,i=e.steps[s].actions;if(o.length===i.length)for(let a=0;a<o.length;a++){let d=o[a],p=i[a];if(d.action!==p.action||!d.target||!p.target)continue;let m=we(d.target);m!==we(p.target)&&r.set(m,{from:d.target,to:p.target})}}return[...r.values()]}function gn(t){let e=ve.join(t,".saffron","cache");if(!Vt.existsSync(e))return[];let r=[];for(let n of Vt.readdirSync(e,{recursive:!0})){let s=String(n);s.endsWith(".json")&&r.push(ve.join(e,s))}return r}function Ht(t,e,r,n){if(e.length===0)return[];let s=new Map(e.map(i=>[we(i.from),i])),o=[];for(let i of gn(t)){if(ve.resolve(i)===ve.resolve(r))continue;let a=M(i);if(!a)continue;let d=[],p=!1;for(let m of a.steps){let l=0;for(let c of m.actions){if(!c.target)continue;let f=s.get(we(c.target));f&&(l++,n||(c.target=structuredClone(f.to),p=!0))}l>0&&d.push({gherkin:m.gherkin,count:l})}d.length>0&&(p&&X(i,a),o.push({file:i,scenario:a.scenario,feature:a.feature,steps:d}))}return o}function qe(t){return t.role?`${t.role} "${t.name??t.nameRegex??""}"`:t.label?`label "${t.label}"`:t.text?`text "${t.text}"`:t.testId?`testId "${t.testId}"`:(t.fallbackSelectors??[]).join(",")||"(empty target)"}import Wt from"node:fs";import He from"node:path";function Yt(t,e,r,n,s){let o={applied:[],skipped:[]},i=J(He.join(t,e),t,s).find(p=>p.name===r);if(!i){for(let p of n)o.skipped.push({index:p.index,reason:`scenario "${r}" not found in ${e}`});return o}let a=new Map,d=new Set;for(let p of n){let m=i.steps[p.index];if(!m?.source){o.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}let{file:l,line:c,fromStepSet:f,fromBackground:h}=m.source;if(h){o.skipped.push({index:p.index,reason:`"${m.text}" is a Background step shared across scenarios \u2014 edit it manually`});continue}let u=`${l}:${c}`;if(d.has(u))continue;let g=a.get(l);g||(g=Wt.readFileSync(He.join(t,l),"utf8").split(`
63
+ `),a.set(l,g));let b=g[c-1],S=b?.match(/^(\s*)(\S+)\s+(.*)$/);if(!S||S[3]!==m.text){o.skipped.push({index:p.index,reason:`${l}:${c} no longer matches the recorded step text`});continue}let k=`${S[1]}${S[2]} ${p.text}`;g[c-1]=k,d.add(u),o.applied.push({file:l,line:c,from:b.trim(),to:k.trim(),fromStepSet:f})}for(let[p,m]of a)o.applied.some(l=>l.file===p)&&Wt.writeFileSync(He.join(t,p),m.join(`
64
+ `));return o}import We from"node:fs";import Ye from"node:path";var E=t=>t.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),Kt="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",mn="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 hn(t){return`<svg viewBox="0 0 48 48" width="${t}" height="${t}" aria-hidden="true">
65
+ <path d="${Kt}" fill="#F4A300"/>
66
+ <path d="${mn}" fill="#E7E9EC"/>
67
+ </svg>`}function Sn(t){return`<svg viewBox="8 1 32 15" height="${t}" aria-hidden="true"><path d="${Kt}" fill="#F4A300"/></svg>`}var yn={green:"passed",yellow:"adapted",red:"failed"};function ke(t){return t>=1e3?`${(t/1e3).toFixed(1)}s`:`${t}ms`}function ce(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}function Y(t,e,r,n="",s="",o=""){return`<div class="kpi">
67
68
  <div class="kpi-label">${t}</div>
68
69
  <div class="kpi-value ${n}">${e}</div>
69
70
  <div class="kpi-sub ${s}">${r}</div>${o}
70
- </div>`}function qt(t,e){if(t.length<2)return"";let r=108,n=22,s=Math.min(...t),i=Math.max(...t)-s||1,a=t.map((p,d)=>`${(d/(t.length-1)*r).toFixed(1)},${(n-2-(p-s)/i*(n-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${r} ${n}" width="${r}" height="${n}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var Yt=(t,e="",r=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(r)}${e} vs prev`;function mn(t,e){let r=t.totals,n=t.trends,s=r.scenarios===0?0:Math.round((r.green+r.yellow)/r.scenarios*100),o=r.red>0?"red":r.yellow>0?"gold":"green",i=r.inputTokens+r.outputTokens,a=n?.prev?`${Yt(n.prev.passRatePp,"pp")} \xB7 ${r.green+r.yellow} of ${r.scenarios}`:`${r.green+r.yellow} of ${r.scenarios} scenarios`,p=n?.prev?`${Yt(n.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${ke(e)}`:`${ke(e)} total runtime`;return`<section class="kpis">
71
- ${Y("pass rate",`${s}<span class="unit">%</span>`,a,o,n?.prev?n.prev.passRatePp>=0?"green":"red":"",qt(n?.series.passRate??[],"var(--green)"))}
71
+ </div>`}function Gt(t,e){if(t.length<2)return"";let r=108,n=22,s=Math.min(...t),i=Math.max(...t)-s||1,a=t.map((d,p)=>`${(p/(t.length-1)*r).toFixed(1)},${(n-2-(d-s)/i*(n-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${r} ${n}" width="${r}" height="${n}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${e}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var Qt=(t,e="",r=0)=>`${t>=0?"+":"\u2212"}${Math.abs(t).toFixed(r)}${e} vs prev`;function bn(t,e){let r=t.totals,n=t.trends,s=r.scenarios===0?0:Math.round((r.green+r.yellow)/r.scenarios*100),o=r.red>0?"red":r.yellow>0?"gold":"green",i=r.inputTokens+r.outputTokens,a=n?.prev?`${Qt(n.prev.passRatePp,"pp")} \xB7 ${r.green+r.yellow} of ${r.scenarios}`:`${r.green+r.yellow} of ${r.scenarios} scenarios`,d=n?.prev?`${Qt(n.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${ke(e)}`:`${ke(e)} total runtime`;return`<section class="kpis">
72
+ ${Y("pass rate",`${s}<span class="unit">%</span>`,a,o,n?.prev?n.prev.passRatePp>=0?"green":"red":"",Gt(n?.series.passRate??[],"var(--green)"))}
72
73
  ${Y("passed",String(r.green),"deterministic replay","green",r.green>0?"green":"")}
73
74
  ${Y("adapted",String(r.yellow),r.yellow>0?"\u25B2 proposals pending review":"no adaptations",r.yellow>0?"gold":"",r.yellow>0?"gold":"")}
74
75
  ${Y("failed",String(r.red),r.red>0?"\u25BC needs attention":"all clear",r.red>0?"red":"",r.red>0?"red":"green")}
75
76
  ${Y("ai calls",String(r.aiCalls),`${ce(i)} in+out tokens`,r.aiCalls>0?"":"green")}
76
77
  ${Y("cache traffic",ce(r.cacheReadTokens+r.cacheCreationTokens),r.cacheReadTokens+r.cacheCreationTokens>0?`${ce(r.cacheReadTokens)} read \xB7 ${ce(r.cacheCreationTokens)} written`:"zero \u2014 fully cached run","",r.cacheReadTokens+r.cacheCreationTokens>0?"":"green")}
77
- ${Y("ai cost",`<span class="unit">$</span>${r.costUsd.toFixed(r.costUsd>=1?2:4)}`,p,"","",qt(n?.series.costUsd??[],"var(--gold)"))}
78
- </section>`}function hn(t){let e=t.trends;if(!e)return"";let r=[];return e.chronic.length>0&&r.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios \u2014 stop paying for heals</div><ul>${e.chronic.map(n=>`<li>${E(n.feature)} \u203A ${E(n.scenario)} \u2014 ${n.heals} heal(s), ${n.reds} red(s) in the last ${n.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&r.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&r.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(n=>`<li>${n.count}\xD7 \u2014 ${E(n.message)}</li>`).join("")}</ul></div>`),r.length>0?`<section class="trends">${r.join(`
79
- `)}</section>`:""}function Sn(t){return`<ol class="steps">${t.steps.map(r=>{let n=r.status==="passed"?'<span class="dot green"></span>':r.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${r.status}">
78
+ ${Y("ai cost",`<span class="unit">$</span>${r.costUsd.toFixed(r.costUsd>=1?2:4)}`,d,"","",Gt(n?.series.costUsd??[],"var(--gold)"))}
79
+ </section>`}function xn(t){let e=t.trends;if(!e)return"";let r=[];return e.chronic.length>0&&r.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios \u2014 stop paying for heals</div><ul>${e.chronic.map(n=>`<li>${E(n.feature)} \u203A ${E(n.scenario)} \u2014 ${n.heals} heal(s), ${n.reds} red(s) in the last ${n.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),e.divergentSteps>0&&r.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${e.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),e.topIssues.length>0&&r.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${e.topIssues.map(n=>`<li>${n.count}\xD7 \u2014 ${E(n.message)}</li>`).join("")}</ul></div>`),r.length>0?`<section class="trends">${r.join(`
80
+ `)}</section>`:""}function vn(t){return`<ol class="steps">${t.steps.map(r=>{let n=r.status==="passed"?'<span class="dot green"></span>':r.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${r.status}">
80
81
  ${n}
81
82
  <div class="step-body">
82
83
  <span class="step-text">${E(r.gherkin)}</span>
@@ -86,13 +87,13 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
86
87
  ${r.error?`<div class="step-error">${E(r.error)}</div>`:""}
87
88
  </div>
88
89
  </li>`}).join(`
89
- `)}</ol>`}function yn(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
90
+ `)}</ol>`}function wn(t){return t.featureEdits&&t.featureEdits.length>0?`<div class="panel">
90
91
  <div class="panel-title gold">suggested .feature edit</div>
91
92
  <div class="diff">${t.featureEdits.map(r=>{let n=t.steps[r.index]?.gherkin??`step ${r.index}`;return`<div class="diff-line del">\u2212 ${E(n)}</div><div class="diff-line add">+ ${E(r.text)}</div>`}).join("")}</div>
92
93
  <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
93
- </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${E(t.suggestedFeatureEdit)}</pre></div>`:""}function bn(t){let e=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=t.usage.costUsd?` \xB7 $${t.usage.costUsd.toFixed(2)}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${ce(e)} tok${r}</span>`}function xn(t){return`<details class="card" ${t.status!=="green"?"open":""}>
94
+ </div>`:t.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${E(t.suggestedFeatureEdit)}</pre></div>`:""}function kn(t){let e=t.usage.inputTokens+t.usage.outputTokens;if(t.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=t.usage.costUsd?` \xB7 $${t.usage.costUsd.toFixed(2)}`:"";return`<span class="pill gold">${t.usage.aiCalls} ai \xB7 ${ce(e)} tok${r}</span>`}function $n(t){return`<details class="card" ${t.status!=="green"?"open":""}>
94
95
  <summary>
95
- <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${gn[t.status]}</span>
96
+ <span class="pill ${t.status==="green"?"green":t.status==="yellow"?"gold":"red"}">${yn[t.status]}</span>
96
97
  <span class="card-title">${E(t.scenario)}</span>
97
98
  <span class="card-meta">
98
99
  <span class="m">${E(t.feature)}</span>
@@ -100,19 +101,19 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
100
101
  <span class="m">${E(t.source)}</span>
101
102
  <span class="m-sep">\xB7</span>
102
103
  <span class="m">${ke(t.durationMs)}</span>
103
- ${bn(t)}
104
+ ${kn(t)}
104
105
  </span>
105
106
  </summary>
106
107
  <div class="card-body">
107
- ${Sn(t)}
108
+ ${vn(t)}
108
109
  ${t.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${E(t.narrative)}</p></div>`:""}
109
110
  ${t.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${t.adaptations.map(e=>`<li>${E(e)}</li>`).join("")}</ul></div>`:""}
110
- ${yn(t)}
111
+ ${wn(t)}
111
112
  ${t.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${t.verified===!0?' <span class="pill green">verified</span>':t.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${E(t.proposalFile)}</p>${t.verified===!1&&t.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${E(t.proofError.split(`
112
113
  `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
113
114
  ${t.error&&t.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${E(t.error)}</p></div>`:""}
114
115
  </div>
115
- </details>`}function Qt(t){let e=t.totals,r=new Date(t.startedAt),n=new Date(t.finishedAt).getTime()-r.getTime(),s=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
116
+ </details>`}function Jt(t){let e=t.totals,r=new Date(t.startedAt),n=new Date(t.finishedAt).getTime()-r.getTime(),s=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
116
117
  <html lang="en">
117
118
  <head>
118
119
  <meta charset="utf-8"/>
@@ -232,7 +233,7 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
232
233
  <div class="wrap">
233
234
  <header class="top">
234
235
  <div class="brand">
235
- ${un(38)}
236
+ ${hn(38)}
236
237
  <div>
237
238
  <div class="brand-name">saffron</div>
238
239
  <div class="brand-sub">test run report</div>
@@ -246,33 +247,33 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
246
247
  </div>
247
248
  </header>
248
249
 
249
- ${mn(t,n)}
250
- ${hn(t)}
250
+ ${bn(t,n)}
251
+ ${xn(t)}
251
252
 
252
253
  <div class="section-head">
253
254
  <span class="section-title">scenarios \xB7 ${e.scenarios}</span>
254
255
  <span class="section-title">saffron v${E(t.version)}</span>
255
256
  </div>
256
- ${t.scenarios.map(xn).join(`
257
+ ${t.scenarios.map($n).join(`
257
258
  `)}
258
259
 
259
- <footer>${fn(11)} generated by saffron v${E(t.version)} \xB7 ${E(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
260
+ <footer>${Sn(11)} generated by saffron v${E(t.version)} \xB7 ${E(new Date(t.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
260
261
  </div>
261
262
  </body>
262
263
  </html>
263
- `}function Kt(t,e,r){let n={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};for(let s of t)n[s.status]+=1,n.aiCalls+=s.usage.aiCalls,n.inputTokens+=s.usage.inputTokens,n.outputTokens+=s.usage.outputTokens,n.cacheReadTokens+=s.usage.cacheReadTokens,n.cacheCreationTokens+=s.usage.cacheCreationTokens??0,n.costUsd+=s.usage.costUsd??0;return{tool:"saffron",version:"0.1.0",startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,totals:n,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,status:s.status,source:s.source,durationMs:s.durationMs,usage:s.usage,steps:s.stepResults,narrative:s.narrative,adaptations:s.adaptations,suggestedFeatureEdit:s.suggestedFeatureEdit,featureEdits:s.featureEdits,verified:s.verified,proofError:s.proofError,proposalFile:s.proposalFile,error:s.error}))}}function Jt(t,e){let r=qe.join(t,".saffron","reports");We.mkdirSync(r,{recursive:!0});let n=qe.join(r,"latest.json"),s=qe.join(r,"latest.html");return We.writeFileSync(n,`${JSON.stringify(e,null,2)}
264
- `),We.writeFileSync(s,Qt(e)),{json:n,html:s}}import $e from"node:fs";import Xt from"node:path";function Zt(t){return Xt.join(t,".saffron","history.jsonl")}function er(t,e,r,n){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
265
- `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:r,scenarios:s}}function tr(t,e){let r=Zt(t);$e.mkdirSync(Xt.dirname(r),{recursive:!0}),$e.appendFileSync(r,`${JSON.stringify(e)}
266
- `)}function rr(t,e=50){let r=Zt(t);if(!$e.existsSync(r))return[];let n=$e.readFileSync(r,"utf8").trim().split(`
267
- `).filter(Boolean),s=[];for(let o of n.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var Ye=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function nr(t,e){let r=[...t,e],n=r.slice(-20),s=r.slice(-10),o=t.at(-1),i=o?{passRatePp:Ye(e)-Ye(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let g=`${c.feature}::${c.scenario}`,u=a.get(g)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};u.runs++,c.source==="agent-heal"&&c.status==="yellow"&&u.heals++,c.status==="red"&&u.reds++,a.set(g,u)}let p=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),d=new Map;for(let l of n)for(let c of l.scenarios)c.status==="red"&&c.error&&d.set(c.error,(d.get(c.error)??0)+1);let f=[...d.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:n.map(Ye),costUsd:n.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:n.map(l=>l.aiCalls)},chronic:p,divergentSteps:e.divergentSteps,topIssues:f}}function ee(t){ht(t);let e=$.join(t,"saffron.config.json");return F.existsSync(e)?JSON.parse(F.readFileSync(e,"utf8")):{}}function kn(t,e){let r=e.length>0?e:["features"],n=[];for(let s of r){let o=$.isAbsolute(s)?s:$.join(t,s);if(F.existsSync(o))if(F.statSync(o).isDirectory())for(let i of F.readdirSync(o,{recursive:!0})){let a=String(i);te(a)&&n.push($.join(o,a))}else te(o)&&n.push(o)}return n.sort()}function sr(t,e,r){let n=$.isAbsolute(e.features??"features")?e.features:$.join(t,e.features??"features"),s=new Set(le(n));for(let o of r)o.endsWith(".saffron")&&s.add(o);return K([...s].sort(),t)}function $n(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",r=e&&(F.existsSync($.join(e,".claude",".credentials.json"))||F.existsSync($.join(e,".claude.json")));t||r||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(`
268
- `))}function Re(t){console.error(`saffron: ${t.message}`),process.exit(2)}function Ge(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var Rn={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Tn="\x1B[0m",P=new wn().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version("0.1.0").option("-p, --project-root <dir>","project root",process.cwd());P.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tag>","only run scenarios with this @tag").option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit" \u2014 recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let r=$.resolve(P.opts().projectRoot),n=ee(r),s=e.baseUrl??n.baseURL,o=kn(r,t.length>0?t:n.features?[n.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&$n();let i;try{let S=sr(r,n,o);i=o.flatMap(k=>J(k,r,S))}catch(S){throw S instanceof _&&Re(S),S}if(e.filter){let S=e.filter.startsWith("@")?e.filter:`@${e.filter}`;i=i.filter(k=>k.tags.includes(S))}{let S=new Map;for(let k of i){let A=De(k),C=O(B(r,k.featurePath,k.name));C&&A.push(JSON.stringify(C.steps));for(let I of je(A))process.env[I]===void 0&&!S.has(I)&&S.set(I,k.displayName)}if(S.size>0){for(let[k,A]of S)console.error(`missing environment variable ${k} (referenced as {env:${k}} by "${A}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let a=new ye({projectRoot:r,baseURL:s,headed:e.headed,actionTimeoutMs:n.actionTimeoutMs,retries:n.retries,storageState:e.storageState??n.storageState,browser:(()=>{let S=e.browser??n.browser??"chromium";return S!=="chromium"&&S!=="firefox"&&S!=="webkit"&&(console.error(`invalid --browser "${S}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),S})(),healModel:e.healModel??n.healModel,verifyProposals:e.verify===!1?!1:n.verifyProposals,reuseSteps:e.reuse===!1?!1:n.reuseSteps,assertionPolicy:(()=>{let S=e.assertionPolicy??n.assertionPolicy??"strict";return S!=="strict"&&S!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${S}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),S})(),agent:e.agent?new xe({model:e.model??n.model,maxTurns:n.maxTurns,snapshotMode:n.snapshotMode}):void 0}),p=new Date,d=Math.max(1,Number.parseInt(String(e.workers??n.workers??1),10)||1),f=new Array(i.length),l=S=>{let k=S.scenario,A=Rn[S.status],C=S.usage.inputTokens+S.usage.outputTokens,I=S.usage.cacheReadTokens+S.usage.cacheCreationTokens,G=I>0?`, ${Ge(I)} cache traffic`:"";console.log(`${A}${S.status.toUpperCase().padEnd(7)}${Tn}${k.displayName} (${S.source}, ${S.durationMs}ms, ${S.usage.aiCalls} AI calls, ${C} tokens${G})`);for(let ir of S.adaptations)console.log(` \u26A0 ${ir}`);S.seededSteps&&console.log(` \u26A1 ${S.seededSteps}/${S.stepResults.length} steps seeded from existing recordings (zero AI)`),S.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):S.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${S.proofError?.split(`
269
- `)[0]}`),S.error&&console.log(` ${S.error}`)};await a.start();try{if(d>1&&i.length>1){let S=i.map((A,C)=>({s:A,i:C})),k=[];await Promise.all(Array.from({length:Math.min(d,S.length)},async()=>{for(;;){let A=S.shift();if(!A)return;let C=await a.runScenario(A.s,{agentDisabled:!0});C.status==="red"?k.push(A):(f[A.i]=C,l(C))}}));for(let A of k.sort((C,I)=>C.i-I.i)){let C=await a.runScenario(A.s);f[A.i]=C,l(C)}}else for(let S=0;S<i.length;S++){let k=await a.runScenario(i[S]);f[S]=k,l(k)}}finally{await a.stop()}let c=Kt(f,p,s),g=V(r).divergent.length,u=er(f,p,g,s),m=rr(r);c.trends=nr(m,u),tr(r,u);let{html:h,json:b}=Jt(r,c),y=c.totals,v=y.cacheReadTokens+y.cacheCreationTokens;console.log(`
270
- ${y.green} passed, ${y.yellow} adapted, ${y.red} failed \xB7 ${y.aiCalls} AI calls \xB7 ${(y.inputTokens+y.outputTokens).toLocaleString()} tokens in+out${v>0?` \xB7 ${Ge(y.cacheReadTokens)} cache read \xB7 ${Ge(y.cacheCreationTokens)} cache written`:""} \xB7 $${y.costUsd.toFixed(4)}`);let x=c.trends;if(x?.prev){let S=x.prev.passRatePp,k=x.prev.costUsd;console.log(`vs previous run: pass rate ${S>=0?"+":""}${S}pp \xB7 cost ${k>=0?"+":"-"}$${Math.abs(k).toFixed(4)} \xB7 AI calls ${x.prev.aiCalls>=0?"+":""}${x.prev.aiCalls}`)}for(let S of x?.chronic??[])console.log(`\u26A0 chronic: ${S.feature} \u203A ${S.scenario} \u2014 ${S.heals} heal(s), ${S.reds} red(s) in last ${S.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${h}
271
- ${b}`);let w=oe($.resolve(P.opts().projectRoot));w.length>0&&console.log(`${w.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let T=e.strict??n.strict??!1;T&&y.yellow>0&&console.log(`strict mode: ${y.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(y.red>0||T&&y.yellow>0?1:0)});P.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").action((t,e)=>{let r=$.resolve(P.opts().projectRoot),n=ee(r),s=t?[$.resolve(t)]:e.all?oe(r).map(o=>o.file):[];if(s.length===0){or(r);return}for(let o of s){let i=me(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
272
- `)[0]}) \u2014 expect a heal or red on the next run.`);let a=O(B(r,i.cache.feature,i.cache.scenario)),p=yt(r,o);console.log(`accepted \u2192 ${p}`);let d=a?Bt(a,i.cache):[];if(d.length>0){let c=Vt(r,d,p,!e.propagate);for(let g of d)console.log(` locator fix: ${Ve(g.from)} \u2192 ${Ve(g.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let g of c)console.log(` propagated \u2192 ${g.feature} \u203A ${g.scenario} (${g.steps.map(u=>`"${u.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let g of c)console.log(` \xB7 ${g.feature} \u203A ${g.scenario}`)}}let f=i.meta.featureEdits??[];if(!e.withFeatureEdit||f.length===0)continue;let l;try{l=Wt(r,i.cache.feature,i.cache.scenario,f,sr(r,n,[]))}catch(c){throw c instanceof _&&Re(c),c}for(let c of l.applied){let g=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}" \u2014 fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${g}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=O(p);for(let g of f)c.steps[g.index]&&(c.steps[g.index].gherkin=g.text);X(p,c)}}});P.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let r=$.resolve(P.opts().projectRoot),n=ee(r),s;try{s=H(r,n.features??"features")}catch(i){throw i instanceof _&&Re(i),i}if(t&&(s=ue(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=$.join(r,".vscode","saffron.code-snippets");F.mkdirSync($.dirname(i),{recursive:!0}),F.writeFileSync(i,ct(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
273
- ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});P.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let r=$.resolve(P.opts().projectRoot),n=ee(r),s=F.readFileSync($.resolve(t),"utf8"),o;try{o=H(r,n.features??"features")}catch(d){throw d instanceof _&&Re(d),d}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await dt(s,o,e.model??n.model),a=e.out?$.resolve(e.out):$.join(r,n.features??"features",`${$.basename(t).replace(/\.[^.]+$/,"")}.saffron`);F.existsSync(a)&&(console.error(`refusing to overwrite ${a} \u2014 pass --out for a new path`),process.exit(1)),F.mkdirSync($.dirname(a),{recursive:!0}),F.writeFileSync(a,i.content);let p=i.content.split(`
274
- `).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>o.steps.some(f=>f.text===d)).length;console.log(`wrote ${a}`),console.log(`${p} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+$.relative(process.cwd(),a))});P.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=$.resolve(P.opts().projectRoot),e=ee(t);await lt(t,e.features??"features")});P.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{let t=$.resolve(P.opts().projectRoot),e=ee(t);ft(t,e.features??"features")});P.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let r=$.resolve(P.opts().projectRoot),n=t?[$.resolve(t)]:e.all?oe(r).map(s=>s.file):[];if(n.length===0){or(r);return}for(let s of n)bt(s),console.log(`rejected ${s}`)});P.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=$.resolve(P.opts().projectRoot),e=$.join(t,".saffron","reports","latest.html");F.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let r=process.platform==="darwin"?"open":"xdg-open";vn(r,[e],{detached:!0,stdio:"ignore"}).unref()});function or(t){let e=oe(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
264
+ `}function Xt(t,e,r){let n={scenarios:t.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};for(let s of t)n[s.status]+=1,n.aiCalls+=s.usage.aiCalls,n.inputTokens+=s.usage.inputTokens,n.outputTokens+=s.usage.outputTokens,n.cacheReadTokens+=s.usage.cacheReadTokens,n.cacheCreationTokens+=s.usage.cacheCreationTokens??0,n.costUsd+=s.usage.costUsd??0;return{tool:"saffron",version:"0.1.0",startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,totals:n,scenarios:t.map(s=>({feature:s.scenario.featurePath,scenario:s.scenario.displayName,status:s.status,source:s.source,durationMs:s.durationMs,usage:s.usage,steps:s.stepResults,narrative:s.narrative,adaptations:s.adaptations,suggestedFeatureEdit:s.suggestedFeatureEdit,featureEdits:s.featureEdits,verified:s.verified,proofError:s.proofError,proposalFile:s.proposalFile,error:s.error}))}}function Zt(t,e){let r=Ye.join(t,".saffron","reports");We.mkdirSync(r,{recursive:!0});let n=Ye.join(r,"latest.json"),s=Ye.join(r,"latest.html");return We.writeFileSync(n,`${JSON.stringify(e,null,2)}
265
+ `),We.writeFileSync(s,Jt(e)),{json:n,html:s}}import $e from"node:fs";import er from"node:path";function tr(t){return er.join(t,".saffron","history.jsonl")}function rr(t,e,r,n){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
266
+ `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:r,scenarios:s}}function nr(t,e){let r=tr(t);$e.mkdirSync(er.dirname(r),{recursive:!0}),$e.appendFileSync(r,`${JSON.stringify(e)}
267
+ `)}function sr(t,e=50){let r=tr(t);if(!$e.existsSync(r))return[];let n=$e.readFileSync(r,"utf8").trim().split(`
268
+ `).filter(Boolean),s=[];for(let o of n.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var Ge=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function or(t,e){let r=[...t,e],n=r.slice(-20),s=r.slice(-10),o=t.at(-1),i=o?{passRatePp:Ge(e)-Ge(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let f=`${c.feature}::${c.scenario}`,h=a.get(f)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(f,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of n)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let m=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:n.map(Ge),costUsd:n.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:n.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}function te(t){St(t);let e=R.join(t,"saffron.config.json");return F.existsSync(e)?JSON.parse(F.readFileSync(e,"utf8")):{}}function An(t,e){let r=e.length>0?e:["features"],n=[];for(let s of r){let o=R.isAbsolute(s)?s:R.join(t,s);if(F.existsSync(o))if(F.statSync(o).isDirectory())for(let i of F.readdirSync(o,{recursive:!0})){let a=String(i);re(a)&&n.push(R.join(o,a))}else re(o)&&n.push(o)}return n.sort()}function ir(t,e,r){let n=R.isAbsolute(e.features??"features")?e.features:R.join(t,e.features??"features"),s=new Set(le(n));for(let o of r)o.endsWith(".saffron")&&s.add(o);return K([...s].sort(),t)}function Pn(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",r=e&&(F.existsSync(R.join(e,".claude",".credentials.json"))||F.existsSync(R.join(e,".claude.json")));t||r||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(`
269
+ `))}function Re(t){console.error(`saffron: ${t.message}`),process.exit(2)}function Qe(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var Cn={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Fn="\x1B[0m";function In(){let t=R.dirname(Tn(import.meta.url));for(let e=0;e<5;e++){let r=R.join(t,"package.json");if(F.existsSync(r))try{let n=JSON.parse(F.readFileSync(r,"utf8"));if(n.name==="saffron-ai"&&n.version)return n.version}catch{}t=R.dirname(t)}return"unknown"}var C=new En().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(In()).option("-p, --project-root <dir>","project root",process.cwd());C.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 <tag>","only run scenarios with this @tag").option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit" \u2014 recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=e.baseUrl??n.baseURL,o=An(r,t.length>0?t:n.features?[n.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&Pn();let i;try{let y=ir(r,n,o);i=o.flatMap(T=>J(T,r,y))}catch(y){throw y instanceof j&&Re(y),y}if(e.filter){let y=e.filter.startsWith("@")?e.filter:`@${e.filter}`;i=i.filter(T=>T.tags.includes(y))}{let y=new Map;for(let T of i){let A=De(T),P=M(B(r,T.featurePath,T.name));P&&A.push(JSON.stringify(P.steps));for(let _ of je(A))process.env[_]===void 0&&!y.has(_)&&y.set(_,T.displayName)}if(y.size>0){for(let[T,A]of y)console.error(`missing environment variable ${T} (referenced as {env:${T}} by "${A}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let a=new ye({projectRoot:r,baseURL:s,headed:e.headed,actionTimeoutMs:n.actionTimeoutMs,retries:n.retries,storageState:e.storageState??n.storageState,browser:(()=>{let y=e.browser??n.browser??"chromium";return y!=="chromium"&&y!=="firefox"&&y!=="webkit"&&(console.error(`invalid --browser "${y}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),y})(),healModel:e.healModel??n.healModel,verifyProposals:e.verify===!1?!1:n.verifyProposals,reuseSteps:e.reuse===!1?!1:n.reuseSteps,assertionPolicy:(()=>{let y=e.assertionPolicy??n.assertionPolicy??"strict";return y!=="strict"&&y!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${y}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),y})(),agent:e.agent?new xe({model:e.model??n.model,maxTurns:n.maxTurns,snapshotMode:n.snapshotMode}):void 0}),d=new Date,p=Math.max(1,Number.parseInt(String(e.workers??n.workers??1),10)||1),m=new Array(i.length),l=y=>{let T=y.scenario,A=Cn[y.status],P=y.usage.inputTokens+y.usage.outputTokens,_=y.usage.cacheReadTokens+y.usage.cacheCreationTokens,G=_>0?`, ${Qe(_)} cache traffic`:"";console.log(`${A}${y.status.toUpperCase().padEnd(7)}${Fn}${T.displayName} (${y.source}, ${y.durationMs}ms, ${y.usage.aiCalls} AI calls, ${P} tokens${G})`);for(let cr of y.adaptations)console.log(` \u26A0 ${cr}`);y.seededSteps&&console.log(` \u26A1 ${y.seededSteps}/${y.stepResults.length} steps seeded from existing recordings (zero AI)`),y.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):y.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${y.proofError?.split(`
270
+ `)[0]}`),y.error&&console.log(` ${y.error}`)};await a.start();try{if(p>1&&i.length>1){let y=i.map((A,P)=>({s:A,i:P})),T=[];await Promise.all(Array.from({length:Math.min(p,y.length)},async()=>{for(;;){let A=y.shift();if(!A)return;let P=await a.runScenario(A.s,{agentDisabled:!0});P.status==="red"?T.push(A):(m[A.i]=P,l(P))}}));for(let A of T.sort((P,_)=>P.i-_.i)){let P=await a.runScenario(A.s);m[A.i]=P,l(P)}}else for(let y=0;y<i.length;y++){let T=await a.runScenario(i[y]);m[y]=T,l(T)}}finally{await a.stop()}let c=Xt(m,d,s),f=V(r).divergent.length,h=rr(m,d,f,s),u=sr(r);c.trends=or(u,h),nr(r,h);let{html:g,json:b}=Zt(r,c),S=c.totals,k=S.cacheReadTokens+S.cacheCreationTokens;console.log(`
271
+ ${S.green} passed, ${S.yellow} adapted, ${S.red} failed \xB7 ${S.aiCalls} AI calls \xB7 ${(S.inputTokens+S.outputTokens).toLocaleString()} tokens in+out${k>0?` \xB7 ${Qe(S.cacheReadTokens)} cache read \xB7 ${Qe(S.cacheCreationTokens)} cache written`:""} \xB7 $${S.costUsd.toFixed(4)}`);let x=c.trends;if(x?.prev){let y=x.prev.passRatePp,T=x.prev.costUsd;console.log(`vs previous run: pass rate ${y>=0?"+":""}${y}pp \xB7 cost ${T>=0?"+":"-"}$${Math.abs(T).toFixed(4)} \xB7 AI calls ${x.prev.aiCalls>=0?"+":""}${x.prev.aiCalls}`)}for(let y of x?.chronic??[])console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario} \u2014 ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${g}
272
+ ${b}`);let v=ie(R.resolve(C.opts().projectRoot));v.length>0&&console.log(`${v.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let $=e.strict??n.strict??!1;$&&S.yellow>0&&console.log(`strict mode: ${S.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(S.red>0||$&&S.yellow>0?1:0)});C.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=t?[R.resolve(t)]:e.all?ie(r).map(o=>o.file):[];if(s.length===0){ar(r);return}for(let o of s){let i=me(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
273
+ `)[0]}) \u2014 expect a heal or red on the next run.`);let a=M(B(r,i.cache.feature,i.cache.scenario)),d=bt(r,o);console.log(`accepted \u2192 ${d}`);let p=a?qt(a,i.cache):[];if(p.length>0){let c=Ht(r,p,d,!e.propagate);for(let f of p)console.log(` locator fix: ${qe(f.from)} \u2192 ${qe(f.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let f of c)console.log(` propagated \u2192 ${f.feature} \u203A ${f.scenario} (${f.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let f of c)console.log(` \xB7 ${f.feature} \u203A ${f.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let l;try{l=Yt(r,i.cache.feature,i.cache.scenario,m,ir(r,n,[]))}catch(c){throw c instanceof j&&Re(c),c}for(let c of l.applied){let f=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}" \u2014 fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${f}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=M(d);for(let f of m)c.steps[f.index]&&(c.steps[f.index].gherkin=f.text);X(d,c)}}});C.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s;try{s=q(r,n.features??"features")}catch(i){throw i instanceof j&&Re(i),i}if(t&&(s=ue(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=R.join(r,".vscode","saffron.code-snippets");F.mkdirSync(R.dirname(i),{recursive:!0}),F.writeFileSync(i,lt(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
274
+ ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});C.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=F.readFileSync(R.resolve(t),"utf8"),o;try{o=q(r,n.features??"features")}catch(p){throw p instanceof j&&Re(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await pt(s,o,e.model??n.model),a=e.out?R.resolve(e.out):R.join(r,n.features??"features",`${R.basename(t).replace(/\.[^.]+$/,"")}.saffron`);F.existsSync(a)&&(console.error(`refusing to overwrite ${a} \u2014 pass --out for a new path`),process.exit(1)),F.mkdirSync(R.dirname(a),{recursive:!0}),F.writeFileSync(a,i.content);let d=i.content.split(`
275
+ `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+R.relative(process.cwd(),a))});C.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=R.resolve(C.opts().projectRoot),e=te(t);await dt(t,e.features??"features")});C.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{let t=R.resolve(C.opts().projectRoot),e=te(t);gt(t,e.features??"features")});C.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=t?[R.resolve(t)]:e.all?ie(r).map(s=>s.file):[];if(n.length===0){ar(r);return}for(let s of n)xt(s),console.log(`rejected ${s}`)});C.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=R.resolve(C.opts().projectRoot),e=R.join(t,".saffron","reports","latest.html");F.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let r=process.platform==="darwin"?"open":"xdg-open";Rn(r,[e],{detached:!0,stdio:"ignore"}).unref()});function ar(t){let e=ie(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
275
276
  `);for(let{file:r,proposal:n}of e){console.log(` ${r}`);let s=n.meta.verified===!0?" \xB7 verified \u2713":n.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${n.cache.feature} \u203A ${n.cache.scenario} (${n.meta.mode}, ${n.meta.createdAt}${s})`),console.log(` ${n.meta.narrative}`);for(let o of n.meta.adaptations)console.log(` \u26A0 ${o}`);n.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
276
277
  ${n.meta.suggestedFeatureEdit.split(`
277
278
  `).map(o=>` ${o}`).join(`
278
- `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}P.parseAsync();
279
+ `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}C.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saffron-ai",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",