saffron-ai 0.8.3 → 0.8.4

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist-pkg/cli.js +129 -125
  3. package/package.json +1 -1
package/dist-pkg/cli.js CHANGED
@@ -1,58 +1,61 @@
1
1
  #!/usr/bin/env node
2
- import W from"node:fs";import O from"node:path";import{spawn as Zi}from"node:child_process";import Xi from"node:readline";import{Command as ea}from"commander";import Ys from"node:fs";import Qs from"node:path";import{AstBuilder as Zs,GherkinClassicTokenMatcher as Xs,Parser as eo}from"@cucumber/gherkin";import{IdGenerator as to,StepKeywordType as Gt}from"@cucumber/messages";function $e(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function q(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(t[`table:${o+1}:${i}`]=s[a])})})}return t}function Re(e){return e!==void 0?{docstring:e}:{}}function he(e){let t=new Map;for(let r of e){let s={...q(r.resolvedTable??r.table),...Re(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=t.get(o);a||t.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of t)s.size===1&&(n[r]=[...s][0]);return n}import je from"node:fs";import me from"node:path";var Ht=/\{data:([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\}/g,Ce=me.resolve("data"),ne,dt=new Map;function Fe(e,t="data",n){Ce=me.isAbsolute(t)?t:me.join(e,t),ne=n?.trim()||void 0,dt.clear()}var ye=()=>({dir:Ce,env:ne});function Mn(e,t){let n=s=>s!==null&&typeof s=="object"&&!Array.isArray(s);if(!n(e)||!n(t))return t;let r={...e};for(let[s,o]of Object.entries(t))r[s]=s in e?Mn(e[s],o):o;return r}function Nn(e,t){try{return JSON.parse(je.readFileSync(e,"utf8"))}catch(n){throw new Error(`data file ${t} is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}}function Us(e){if(dt.has(e))return dt.get(e);let t=me.join(Ce,`${e}.json`),n=ne?me.join(Ce,`${e}.${ne}.json`):void 0,r=je.existsSync(t),s=n!==void 0&&je.existsSync(n);if(!r&&!s)throw new Error(`data file ${me.relative(process.cwd(),t)||t} does not exist${ne?` (nor ${e}.${ne}.json for the "${ne}" environment)`:""}`);let o=r?Nn(t,`${e}.json`):void 0;if(s){let i=Nn(n,`${e}.${ne}.json`);o=r?Mn(o,i):i}return dt.set(e,o),o}function Te(e){let[t,...n]=e.split("."),r=Us(t),s=[t];for(let o of n){let i=r,a=i&&typeof i=="object"?Array.isArray(i)?i[Number(o)]:i[o]:void 0;if(a===void 0)throw new Error(`"${o}" does not exist under ${s.join(".")} (in ${t}.json)`);r=a,s.push(o)}return r}function Ie(e){let t;try{t=Te(e)}catch(n){throw new Error(`{data:${e}}: ${n instanceof Error?n.message:String(n)}`)}if(t===null||typeof t=="object")throw new Error(`{data:${e}} is ${Array.isArray(t)?"a list":"an object"}, not a single value. Point at one entry, for example {data:${e}.${Array.isArray(t)?"0":Object.keys(t??{})[0]??"key"}}`);return String(t)}function Oe(e){let t;try{t=Te(e)}catch(s){throw new Error(`{data:${e}}: ${s instanceof Error?s.message:String(s)}`)}let n=Array.isArray(t)?t:[t];if(n.length===0)throw new Error(`{data:${e}} is an empty list: there is nothing to check`);let r=n.find(s=>s===null||typeof s=="object");if(r!==void 0)throw new Error(`{data:${e}} must be a list of plain values (text, numbers); it contains ${Array.isArray(r)?"a list":"an object"}`);return n.map(String)}function pt(e){Oe(e)}function Ln(e){return e.replace(Ht,(t,n)=>{let r=Oe(n);return Array.isArray(Te(n))?`${t} (a data list, currently: ${r.map(o=>`"${o}"`).join(", ")})`:r[0]})}function Bs(e){let t=[],n=[],r="",s=!1;for(let o=0;o<e.length;o++){let i=e[o];s?i==='"'&&e[o+1]==='"'?(r+='"',o++):i==='"'?s=!1:r+=i:i==='"'&&r===""?s=!0:i===","?(n.push(r),r=""):i===`
2
+ import G from"node:fs";import D from"node:path";import{spawn as Sa}from"node:child_process";import ba from"node:readline";import{Command as xa}from"commander";import ho from"node:fs";import mo from"node:path";import{AstBuilder as yo,GherkinClassicTokenMatcher as So,Parser as bo}from"@cucumber/gherkin";import{IdGenerator as xo,StepKeywordType as Zt}from"@cucumber/messages";function Re(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function W(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(t[`table:${o+1}:${i}`]=s[a])})})}return t}function Te(e){return e!==void 0?{docstring:e}:{}}function me(e){let t=new Map;for(let r of e){let s={...W(r.resolvedTable??r.table),...Te(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=t.get(o);a||t.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of t)s.size===1&&(n[r]=[...s][0]);return n}import Ie from"node:fs";import ye from"node:path";var Ke=/\{data:([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\}/g,Oe=ye.resolve("data"),re,mt=new Map;function De(e,t="data",n){Oe=ye.isAbsolute(t)?t:ye.join(e,t),re=n?.trim()||void 0,mt.clear()}var Se=()=>({dir:Oe,env:re});function Hn(e,t){let n=s=>s!==null&&typeof s=="object"&&!Array.isArray(s);if(!n(e)||!n(t))return t;let r={...e};for(let[s,o]of Object.entries(t))r[s]=s in e?Hn(e[s],o):o;return r}function Vn(e,t){try{return JSON.parse(Ie.readFileSync(e,"utf8"))}catch(n){throw new Error(`data file ${t} is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}}function so(e){if(mt.has(e))return mt.get(e);let t=ye.join(Oe,`${e}.json`),n=re?ye.join(Oe,`${e}.${re}.json`):void 0,r=Ie.existsSync(t),s=n!==void 0&&Ie.existsSync(n);if(!r&&!s)throw new Error(`data file ${ye.relative(process.cwd(),t)||t} does not exist${re?` (nor ${e}.${re}.json for the "${re}" environment)`:""}`);let o=r?Vn(t,`${e}.json`):void 0;if(s){let i=Vn(n,`${e}.${re}.json`);o=r?Hn(o,i):i}return mt.set(e,o),o}function Ae(e){let[t,...n]=e.split("."),r=so(t),s=[t];for(let o of n){let i=r,a=i&&typeof i=="object"?Array.isArray(i)?i[Number(o)]:i[o]:void 0;if(a===void 0)throw new Error(`"${o}" does not exist under ${s.join(".")} (in ${t}.json)`);r=a,s.push(o)}return r}function Ne(e){let t;try{t=Ae(e)}catch(n){throw new Error(`{data:${e}}: ${n instanceof Error?n.message:String(n)}`)}if(t===null||typeof t=="object")throw new Error(`{data:${e}} is ${Array.isArray(t)?"a list":"an object"}, not a single value. Point at one entry, for example {data:${e}.${Array.isArray(t)?"0":Object.keys(t??{})[0]??"key"}}`);return String(t)}function Me(e){let t;try{t=Ae(e)}catch(s){throw new Error(`{data:${e}}: ${s instanceof Error?s.message:String(s)}`)}let n=Array.isArray(t)?t:[t];if(n.length===0)throw new Error(`{data:${e}} is an empty list: there is nothing to check`);let r=n.find(s=>s===null||typeof s=="object");if(r!==void 0)throw new Error(`{data:${e}} must be a list of plain values (text, numbers); it contains ${Array.isArray(r)?"a list":"an object"}`);return n.map(String)}function yt(e){Me(e)}function Wn(e){return e.replace(Ke,(t,n)=>{let r=Me(n);return Array.isArray(Ae(n))?`${t} (a data list, currently: ${r.map(o=>`"${o}"`).join(", ")})`:r[0]})}function oo(e){let t=[],n=[],r="",s=!1;for(let o=0;o<e.length;o++){let i=e[o];s?i==='"'&&e[o+1]==='"'?(r+='"',o++):i==='"'?s=!1:r+=i:i==='"'&&r===""?s=!0:i===","?(n.push(r),r=""):i===`
3
3
  `||i==="\r"?(i==="\r"&&e[o+1]===`
4
- `&&o++,n.push(r),r="",n.some(a=>a!=="")&&t.push(n),n=[]):r+=i}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(i=>i.trim()))}function ft(e){let t=a=>{throw new Error(`Examples: {data:${e}}: ${a}`)},n=ne?me.join(Ce,`${e}.${ne}.csv`):void 0,r=n&&je.existsSync(n)?n:me.join(Ce,`${e}.csv`);if(!e.includes(".")&&je.existsSync(r)){let[a,...c]=Bs(je.readFileSync(r,"utf8"));return(!a||c.length===0)&&t(`${e}.csv needs a header row and at least one data row`),{headers:a,rows:c.map(d=>Object.fromEntries(a.map((h,l)=>[h,d[l]??""])))}}let s;try{s=Te(e)}catch(a){return t(`${a instanceof Error?a.message:String(a)} (looked for ${e.split(".")[0]}.csv and ${e.split(".")[0]}.json in the data folder)`)}(!Array.isArray(s)||s.length===0||s.some(a=>a===null||typeof a!="object"||Array.isArray(a)))&&t('must be a non-empty list of records, like [{ "role": "Admin", "menu": "Users" }]');let o=s,i=[...new Set(o.flatMap(a=>Object.keys(a)))];return{headers:i,rows:o.map(a=>Object.fromEntries(i.map(c=>[c,a[c]===void 0||a[c]===null?"":String(a[c])])))}}function Je(e){return e.replace(Ht,(t,n)=>Ie(n))}function Ge(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Ht))t.add(r[1]);return t}function ut(e){let t=new Map;for(let n of Ge(e))try{t.set(n,Ie(n))}catch{}return t}function Un(e,t){let n=[...t.entries()].sort((s,o)=>o[1].length-s[1].length);for(let[s,o]of n)if(o!==""&&e===o)return`{data:${s}}`;let r=e;for(let[s,o]of n)o.length<4||(r=r.split(o).join(`{data:${s}}`));return r}import Wt from"node:fs";import Bn from"node:path";import{AstBuilder as zs,GherkinClassicTokenMatcher as Vs,Parser as qs}from"@cucumber/gherkin";import{IdGenerator as Hs}from"@cucumber/messages";var V=class extends Error{},Ws=/^(\s*)StepSet:\s*(.+?)\s*$/,Js=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Jt(e){let t=e.split(`
5
- `),n=new Map,r=new Map;return{source:t.map((o,i)=>{let a=o.match(Ws);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let c=o.match(Js);return c?(r.set(i+1,c[2]),`${c[1]}* StepSet ${c[2]}`):o}).join(`
6
- `),definitions:n,invocations:r}}function Gs(e){return new qs(new zs(Hs.uuid()),new Vs).parse(e)}function zn(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&zn(n.rule.children,t)}function re(e,t){let n=new Map;for(let r of e){let s=Bn.relative(t,r),o=Jt(Wt.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=Gs(o.source);i.feature&&zn(i.feature.children,a=>{let c=o.definitions.get(a.location.line);if(c===void 0)return;let d=n.get(c);if(d)throw new V(`StepSet "${c}" 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 ${c}".`);if(a.examples.length>0)throw new V(`StepSet "${c}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let h of a.steps)if(o.invocations.has(h.location.line))throw new V(`StepSet "${c}" (${s}:${a.location.line}) invokes another step set at line ${h.location.line} \u2014 step sets cannot be nested.`);n.set(c,{name:c,file:s,line:a.location.line,steps:[...a.steps]})})}return n}var Ks=[".feature",".saffron"];function ce(e){return Ks.some(t=>e.endsWith(t))}function Se(e){if(!Wt.existsSync(e))return[];let t=[];for(let n of Wt.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(Bn.join(e,r))}return t.sort()}function Vn(e,t,n){return e.map(({step:r,source:s})=>{let o;switch(r.keywordType){case Gt.OUTCOME:o="assertion";break;case Gt.CONTEXT:case Gt.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(d=>d.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:$e(r.text,t),table:i,resolvedTable:i?.map(c=>c.map(d=>$e(d,t))),docString:a,resolvedDocString:a!==void 0?$e(a,t):void 0,source:s}})}function qn(e,t,n,r,s){let o=[];for(let i of e){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:t,line:i.location.line,fromBackground:n}});continue}let c=s?.get(a);if(!c)throw new V(`${t}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let d of c.steps)o.push({step:d,source:{file:c.file,line:d.location.line,fromStepSet:c.name,fromBackground:n}})}return o}function no(e,t,n,r,s,o=!1){let i=e.tags.map(c=>c.name),a=[];if(e.examples.length===0)a.push({params:{},label:""});else{let c=0;for(let d of e.examples){let h=d.name.trim().match(/^\{data:([A-Za-z0-9_.-]+)\}$/);if(h&&!d.tableHeader){let f=`${t}:${d.location.line}`;if(!o)throw new V(`${f} reads Examples from a data file, which only .saffron files understand. Rename the file to ${t.replace(/\.feature$/,".saffron")}: nothing else needs to change.`);let m;try{m=ft(h[1])}catch(w){throw new V(`${f} ${w instanceof Error?w.message:String(w)}`)}let x=[...new Set([...r,...s].flatMap(w=>[w.step.text,w.step.docString?.content??"",...w.step.dataTable?.rows.flatMap(R=>R.cells.map(S=>S.value))??[]].flatMap(R=>[...R.matchAll(/<([^<>]+)>/g)].map(S=>S[1]))))].filter(w=>!m.headers.includes(w)&&!w.startsWith("table:")&&w!=="docstring");if(x.length>0)throw new V(`${f} the scenario uses <${x.join(">, <")}>, but {data:${h[1]}} has the columns ${m.headers.map(w=>`"${w}"`).join(", ")}.`);for(let w of m.rows)c+=1,a.push({params:w,label:` (example ${c})`});continue}let l=d.tableHeader?.cells.map(f=>f.value)??[];for(let f of d.tableBody){c+=1;let m={};f.cells.forEach((p,x)=>{m[l[x]]=p.value}),a.push({params:m,label:` (example ${c})`})}}}return a.map(({params:c,label:d})=>{let h={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${d}`,tags:i,steps:[...Vn(r,c,h),...Vn(s,c,h)],params:c}})}function Z(e,t,n){let r=Ys.readFileSync(e,"utf8"),s=e.endsWith(".saffron"),o=s?Jt(r):{source:r,definitions:new Map,invocations:new Map},i=new eo(new Zs(to.uuid()),new Xs),a=Qs.relative(t,e),c;try{c=i.parse(o.source)}catch(f){let m=(f instanceof Error?f.message:String(f)).split(`
7
- `).filter(x=>/^\(\d+:\d+\)/.test(x)).map(x=>`${a}${x.replace(/^\((\d+):(\d+)\)/,":$1:$2")}`),p=!s&&/^\s*StepSet\b/m.test(r);throw new V(p?`${a} uses StepSet, which only .saffron files understand. Rename it to ${a.replace(/\.feature$/,".saffron")}: nothing else needs to change (a .saffron file accepts everything a .feature file does).`:`${a} is not valid Gherkin:
8
- ${m.join(`
9
- `)||(f instanceof Error?f.message:String(f))}`)}if(!c.feature)return[];let d=c.feature.name,h=[],l=(f,m)=>{let p=m;for(let x of f)if(x.background)p=[...m,...qn(x.background.steps,a,!0,o.invocations,n)];else if(x.scenario){if(o.definitions.has(x.scenario.location.line))continue;h.push(...no(x.scenario,a,d,p,qn(x.scenario.steps,a,!1,o.invocations,n),s))}else"rule"in x&&x.rule&&l(x.rule.children,p)};return l(c.feature.children,[]),h}import Qn from"node:fs";import Qt from"node:path";import Wn from"node:fs";import Jn from"node:path";import{createHash as ro}from"node:crypto";import gt from"node:fs";import De from"node:path";var Kt=".saffron";function ht(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function Yt(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),s=ht([...r,De.basename(n,".feature")].join("-"));if(r.length===0)return s;let o=ro("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${s}-${o}`}function H(e,t,n){return De.join(e,Kt,"cache",Yt(t),`${ht(n)}.json`)}function Hn(e,t){let n=new Map;for(let r of t){let s=H(e,r.featurePath,r.name),o=n.get(s)??new Set;o.add(`${r.featurePath} \u203A ${r.name}`),n.set(s,o)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,s])=>`${[...s].join(" and ")} both map to ${De.relative(e,r)}. Rename one scenario or one file.`)}var mt="owner.json";function Ae(e,t,n){return De.join(e,Kt,"artifacts",Yt(t),ht(n))}function be(e,t,n){return De.join(e,Kt,"proposals",Yt(t),`${ht(n)}.json`)}function J(e){if(!gt.existsSync(e))return;let t=JSON.parse(gt.readFileSync(e,"utf8"));if(t.version!==1)throw new Error(`Unsupported cache version ${t.version} in ${e}`);return t}function Ne(e,t){gt.mkdirSync(De.dirname(e),{recursive:!0}),gt.writeFileSync(e,`${JSON.stringify(t,null,2)}
10
- `)}function yt(e,t){let n=r=>typeof r=="string"?r.includes(t):Array.isArray(r)?r.some(n):r&&typeof r=="object"?Object.entries(r).some(([s,o])=>s!=="pageFingerprint"&&n(o)):!1;return e.some(n)}function Me(e,t){if(t.trim()==="")return!1;let n=r=>(r??"").replace(/<[^<>]+>/g,"");return e.some(r=>[r.value,r.url,r.urlPattern,r.pattern,r.promptText,r.compareTo,...[r.target,r.toTarget].flatMap(s=>[s?.name,s?.nameRegex,s?.label,s?.text,s?.placeholder,s?.testId,s?.frame,...s?.fallbackSelectors??[]])].some(s=>n(s).includes(t)))}function St(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(t.push(s),`"\xAB${t.length-1}\xBB"`)),args:t}}function so(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(e)===n(t)}function oo(e){let t=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))t.add(s[1])};for(let r of e.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(e.gherkin),t}function se(e){let t=Jn.join(e,".saffron","cache"),n=new Map,r=new Map,s=0;if(Wn.existsSync(t))for(let a of Wn.readdirSync(t,{recursive:!0})){let c=String(a);if(!c.endsWith(".json"))continue;let d=J(Jn.join(t,c));if(d){s++;for(let h of d.steps){let l=h.gherkin,f=JSON.stringify(h.actions.map(({pageFingerprint:x,...w})=>w)),m=r.get(l)??[];m.includes(f)||(m.push(f),r.set(l,m));let p=n.get(l);!p||d.recordedAt>p.recordedAt?n.set(l,{gherkin:h.gherkin,table:h.table,docString:h.docString,actions:h.actions,sourceParams:he(d.steps),sourceData:d.data,feature:d.feature,scenario:d.scenario,recordedAt:d.recordedAt,variants:m.length}):p.variants=m.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:c,args:d}=St(a.gherkin);if(d.length===0)continue;let h=i.get(c);(!h||a.recordedAt>h.recordedAt)&&i.set(c,{...a,args:d})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function Gn(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let o=Object.keys(q(e.table)),i=Object.keys(q(t.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;if(e.table&&t.table){let o=q(e.table),i=q(t.table);for(let a of Object.keys(o))if(o[a]!==i[a]&&(!yt(e.actions,`<${a}>`)||Me(e.actions,o[a])))return!1}for(let[o,i]of Object.entries(e.sourceParams??{}))if(o in n&&n[o]!==i&&Me(e.actions,i))return!1;for(let[o,i]of Object.entries(e.sourceData??{})){let a;try{a=Ie(o)}catch{continue}if(a!==i&&Me(e.actions,i))return!1}let r={...n,...q(t.resolvedTable??t.table),...Re(t.resolvedDocString??t.docString)},s=oo({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let o of s)if(!(o in r))return!1;return!0}function Kn(e,t,n){let r=e.steps.get(t.text);if(r)return Gn(r,t,n)?r:void 0;let{pattern:s,args:o}=St(t.text);if(o.length===0)return;let i=e.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let f=i.args[l],m=o[l];if(f===m)continue;let p=a.get(f);if(p!==void 0&&p!==m)return;a.set(f,m)}let c=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(m=>[m.value,m.url,m.pattern,...c(m.target)].includes(l)))return;let d=l=>l!==void 0&&a.has(l)?a.get(l):l,h={...i,gherkin:t.text,actions:i.actions.map(({pageFingerprint:l,...f})=>({...f,value:d(f.value),url:d(f.url),pattern:d(f.pattern),target:f.target?{...f.target,name:d(f.target.name),nameRegex:d(f.target.nameRegex),label:d(f.target.label),text:d(f.target.text),placeholder:d(f.target.placeholder)}:void 0}))};return Gn(h,t,n)?h:void 0}function Yn(e,t){let n=[];return e.forEach((r,s)=>{if(r.actions.length!==0&&!t.steps.has(r.gherkin)){for(let o of t.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&so(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function io(e){if(!Qn.existsSync(e))return[];let t=[];for(let n of Qn.readdirSync(e,{recursive:!0})){let r=String(n);ce(r)&&t.push(Qt.join(e,r))}return t.sort()}function oe(e,t,n,r){let s=Qt.isAbsolute(t)?t:Qt.join(e,t),o=io(s),i=n??re(o.filter(m=>m.endsWith(".saffron")),e),a=new Map,c=new Map;for(let m of o)for(let p of r?.get(m)??Z(m,e,i)){let x=`${p.featurePath}::${p.name}`;for(let w of p.steps){let R=a.get(w.text);if(R||(R={text:w.text,keywords:new Map,kind:w.kind,scenarios:new Set,files:new Set,fromStepSet:w.source?.fromStepSet},a.set(w.text,R)),R.keywords.set(w.keyword,(R.keywords.get(w.keyword)??0)+1),R.scenarios.add(x),w.source&&R.files.add(w.source.file),w.source?.fromStepSet){let S=c.get(w.source.fromStepSet);S||c.set(w.source.fromStepSet,S=new Set),S.add(x)}}}let d=se(e),h=new Set(d.divergent),l=[...a.values()].map(m=>({text:m.text,keyword:[...m.keywords.entries()].sort((p,x)=>x[1]-p[1])[0][0],kind:m.kind,status:h.has(m.text)?"divergent":d.steps.has(m.text)?"recorded":"unrecorded",usage:m.scenarios.size,files:[...m.files].sort(),fromStepSet:m.fromStepSet})).sort((m,p)=>p.usage-m.usage||m.text.localeCompare(p.text)),f=[...i.values()].map(m=>({name:m.name,file:m.file,line:m.line,steps:m.steps.map(p=>p.text),usage:c.get(m.name)?.size??0})).sort((m,p)=>p.usage-m.usage||m.name.localeCompare(p.name));return{steps:l,stepSets:f,scannedFiles:o.length,scannedCaches:d.scannedCaches}}function bt(e,t){let n=t.toLowerCase();return{...e,steps:e.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:e.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function Zn(e){let t={},n="saffron,feature,gherkin,cucumber";for(let r of e.steps)t[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of e.stepSets)t[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(t,null,2)+`
11
- `}import{Server as Eo}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Po}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as _o,ListToolsRequestSchema as jo}from"@modelcontextprotocol/sdk/types.js";import ee from"node:fs";import ae from"node:path";import nr from"node:fs";import co from"node:path";import Xn from"node:fs";import ao from"node:path";var er=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function Ke(e){return e.replace(er,(t,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function Zt(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(er))t.add(r[1]);return t}function X(e){let t=[];for(let n of e.steps){t.push(n.text,n.resolvedText),n.docString&&t.push(n.docString),n.resolvedDocString&&t.push(n.resolvedDocString);for(let r of n.table??[])t.push(...r);for(let r of n.resolvedTable??[])t.push(...r)}return t}function Ye(e){let t=new Map;for(let n of Zt(X(e))){let r=process.env[n];r!==void 0&&t.set(n,r)}return t}function Le(e,t){let n=e;for(let[r,s]of[...t.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(n=n.split(s).join(`{env:${r}}`));return n}function tr(e){let t=ao.join(e,".env");if(Xn.existsSync(t))for(let n of Xn.readFileSync(t,"utf8").split(`
12
- `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}function te(e){tr(e);let t=co.join(e,"saffron.config.json"),n=nr.existsSync(t)?JSON.parse(nr.readFileSync(t,"utf8")):{};return Fe(e,n.dataDir,process.env.SAFFRON_ENV??n.env),n}var Xt={features:"features",dataDir:"data",actionTimeoutMs:5e3,pollIntervalMs:100,retries:1,screenshot:"viewport",maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict",hookTimeoutMs:3e5};function rr(e){return{...Xt,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import en from"node:fs";import xt from"node:path";import{fileURLToPath as lo}from"node:url";function wt(){let e=xt.dirname(lo(import.meta.url));for(let t=0;t<6;t++){let n=xt.join(e,"package.json");if(en.existsSync(n))try{if(JSON.parse(en.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=xt.dirname(e)}}function xe(){let e=wt();if(!e)return"unknown";try{return JSON.parse(en.readFileSync(xt.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import G from"node:fs";import z from"node:path";var tn=(e,t)=>z.relative(e,t).split(z.sep).join("/");function sr(e){return G.existsSync(e)?[...G.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>z.join(e,t)).sort():[]}function or(e){try{let t=JSON.parse(G.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function ir(e){try{let t=JSON.parse(G.readFileSync(z.join(e,mt),"utf8"));return{feature:typeof t.feature=="string"?t.feature:void 0,scenario:typeof t.scenario=="string"?t.scenario:void 0}}catch{return{}}}function kt(e,t,n){let r=z.isAbsolute(t)?t:z.join(e,t),s=new Set(G.existsSync(r)?[...G.readdirSync(r,{recursive:!0})].map(String).filter(ce).map(d=>z.join(r,d)):[]);for(let d of["cache","proposals"])for(let h of sr(z.join(e,".saffron",d))){let{feature:l}=or(h);if(typeof l=="string"){let f=z.resolve(e,l);G.existsSync(f)&&s.add(f)}}for(let d of vt(z.join(e,".saffron","artifacts")))for(let h of vt(d)){let l=ir(h).feature;if(l){let f=z.resolve(e,l);G.existsSync(f)&&s.add(f)}}let o=[...n?.unreadable??[]],i=[...s].filter(d=>!n?.parsedFiles.has(d)),a=new Map;if(i.length>0)try{a=re([...new Set([...Se(r),...s].filter(d=>d.endsWith(".saffron")))],e)}catch(d){o.push(d instanceof Error?d.message:String(d))}let c=new Set(n?.owned??[]);for(let d of i){let h;try{h=Z(d,e,a)}catch(l){o.push(`${tn(e,d)}: ${l instanceof Error?l.message:String(l)}`);continue}for(let l of h)c.add(H(e,l.featurePath,l.name)),c.add(be(e,l.featurePath,l.name)),c.add(Ae(e,l.featurePath,l.displayName))}return{...po(e,c),unreadable:o}}function po(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let i=z.join(e,".saffron",o==="cache"?"cache":"proposals");for(let a of sr(i)){if(t.has(a)){r++;continue}let{feature:c,scenario:d}=or(a),h=c?z.join(e,c):void 0;n.push({file:tn(e,a),kind:o,feature:c,scenario:d,reason:c?G.existsSync(h)?"scenario-gone":"feature-gone":"unknown"})}}let s=z.join(e,".saffron","artifacts");for(let o of vt(s))for(let i of vt(o)){if(t.has(i)){r++;continue}let{feature:a,scenario:c}=ir(i);n.push({file:tn(e,i),kind:"artifact",feature:a,scenario:c,reason:a?G.existsSync(z.join(e,a))?"scenario-gone":"feature-gone":"unknown"})}return{orphans:n,kept:r}}function vt(e){try{return G.readdirSync(e,{withFileTypes:!0}).filter(t=>t.isDirectory()).map(t=>z.join(e,t.name)).sort()}catch{return[]}}function ar(e,t){let n=[],r=new Set;for(let s of t){let o=z.join(e,s.file);try{G.rmSync(o,{recursive:s.kind==="artifact"}),n.push(s.file),r.add(z.dirname(o))}catch{}}for(let s of r)try{G.readdirSync(s).length===0&&G.rmdirSync(s)}catch{}return n}function cr(e){if(e.kind==="artifact")return`${e.file}/
4
+ `&&o++,n.push(r),r="",n.some(a=>a!=="")&&t.push(n),n=[]):r+=i}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(i=>i.trim()))}function St(e){let t=a=>{throw new Error(`Examples: {data:${e}}: ${a}`)},n=re?ye.join(Oe,`${e}.${re}.csv`):void 0,r=n&&Ie.existsSync(n)?n:ye.join(Oe,`${e}.csv`);if(!e.includes(".")&&Ie.existsSync(r)){let[a,...c]=oo(Ie.readFileSync(r,"utf8"));return(!a||c.length===0)&&t(`${e}.csv needs a header row and at least one data row`),{headers:a,rows:c.map(d=>Object.fromEntries(a.map((m,l)=>[m,d[l]??""])))}}let s;try{s=Ae(e)}catch(a){return t(`${a instanceof Error?a.message:String(a)} (looked for ${e.split(".")[0]}.csv and ${e.split(".")[0]}.json in the data folder)`)}(!Array.isArray(s)||s.length===0||s.some(a=>a===null||typeof a!="object"||Array.isArray(a)))&&t('must be a non-empty list of records, like [{ "role": "Admin", "menu": "Users" }]');let o=s,i=[...new Set(o.flatMap(a=>Object.keys(a)))];return{headers:i,rows:o.map(a=>Object.fromEntries(i.map(c=>[c,a[c]===void 0||a[c]===null?"":String(a[c])])))}}function Ye(e){return e.replace(Ke,(t,n)=>Ne(n))}function Qe(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Ke))t.add(r[1]);return t}function bt(e){let t=new Map;for(let n of Qe(e))try{t.set(n,Ne(n))}catch{}return t}function Jn(e,t,n=new Set){let r=new Map;for(let[i,a]of t)r.set(a,[...r.get(a)??[],i]);let s=[];for(let[i,a]of r){let c=a.length===1?a:a.filter(d=>n.has(d));c.length===1&&s.push([c[0],i])}s.sort((i,a)=>a[1].length-i[1].length);for(let[i,a]of s)if(a!==""&&e===a)return`{data:${i}}`;let o=e;for(let[i,a]of s)a.length<4||(o=o.split(a).join(`{data:${i}}`));return o}import Yt from"node:fs";import Gn from"node:path";import{AstBuilder as io,GherkinClassicTokenMatcher as ao,Parser as co}from"@cucumber/gherkin";import{IdGenerator as lo}from"@cucumber/messages";var V=class extends Error{},po=/^(\s*)StepSet:\s*(.+?)\s*$/,fo=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Qt(e){let t=e.split(`
5
+ `),n=new Map,r=new Map;return{source:t.map((o,i)=>{let a=o.match(po);if(a)return n.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let c=o.match(fo);return c?(r.set(i+1,c[2]),`${c[1]}* StepSet ${c[2]}`):o}).join(`
6
+ `),definitions:n,invocations:r}}function uo(e){return new co(new io(lo.uuid()),new ao).parse(e)}function Kn(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&Kn(n.rule.children,t)}function se(e,t){let n=new Map;for(let r of e){let s=Gn.relative(t,r),o=Qt(Yt.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=uo(o.source);i.feature&&Kn(i.feature.children,a=>{let c=o.definitions.get(a.location.line);if(c===void 0)return;let d=n.get(c);if(d)throw new V(`StepSet "${c}" 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 ${c}".`);if(a.examples.length>0)throw new V(`StepSet "${c}" (${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 V(`StepSet "${c}" (${s}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);n.set(c,{name:c,file:s,line:a.location.line,steps:[...a.steps]})})}return n}var go=[".feature",".saffron"];function de(e){return go.some(t=>e.endsWith(t))}function be(e){if(!Yt.existsSync(e))return[];let t=[];for(let n of Yt.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(Gn.join(e,r))}return t.sort()}function Yn(e,t,n){return e.map(({step:r,source:s})=>{let o;switch(r.keywordType){case Zt.OUTCOME:o="assertion";break;case Zt.CONTEXT:case Zt.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(d=>d.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:Re(r.text,t),table:i,resolvedTable:i?.map(c=>c.map(d=>Re(d,t))),docString:a,resolvedDocString:a!==void 0?Re(a,t):void 0,source:s}})}function Qn(e,t,n,r,s){let o=[];for(let i of e){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:t,line:i.location.line,fromBackground:n}});continue}let c=s?.get(a);if(!c)throw new V(`${t}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let d of c.steps)o.push({step:d,source:{file:c.file,line:d.location.line,fromStepSet:c.name,fromBackground:n}})}return o}function wo(e,t,n,r,s,o=!1){let i=e.tags.map(c=>c.name),a=[];if(e.examples.length===0)a.push({params:{},label:""});else{let c=0;for(let d of e.examples){let m=d.name.trim().match(/^\{data:([A-Za-z0-9_.-]+)\}$/);if(m&&!d.tableHeader){let f=`${t}:${d.location.line}`;if(!o)throw new V(`${f} reads Examples from a data file, which only .saffron files understand. Rename the file to ${t.replace(/\.feature$/,".saffron")}: nothing else needs to change.`);let S;try{S=St(m[1])}catch(w){throw new V(`${f} ${w instanceof Error?w.message:String(w)}`)}let y=[...new Set([...r,...s].flatMap(w=>[w.step.text,w.step.docString?.content??"",...w.step.dataTable?.rows.flatMap(T=>T.cells.map($=>$.value))??[]].flatMap(T=>[...T.matchAll(/<([^<>]+)>/g)].map($=>$[1]))))].filter(w=>!S.headers.includes(w)&&!w.startsWith("table:")&&w!=="docstring");if(y.length>0)throw new V(`${f} the scenario uses <${y.join(">, <")}>, but {data:${m[1]}} has the columns ${S.headers.map(w=>`"${w}"`).join(", ")}.`);for(let w of S.rows)c+=1,a.push({params:w,label:` (example ${c})`});continue}let l=d.tableHeader?.cells.map(f=>f.value)??[];for(let f of d.tableBody){c+=1;let S={};f.cells.forEach((h,y)=>{S[l[y]]=h.value}),a.push({params:S,label:` (example ${c})`})}}}return a.map(({params:c,label:d})=>{let m={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${d}`,tags:i,steps:[...Yn(r,c,m),...Yn(s,c,m)],params:c}})}function X(e,t,n){let r=ho.readFileSync(e,"utf8"),s=e.endsWith(".saffron"),o=s?Qt(r):{source:r,definitions:new Map,invocations:new Map},i=new bo(new yo(xo.uuid()),new So),a=mo.relative(t,e),c;try{c=i.parse(o.source)}catch(f){let S=(f instanceof Error?f.message:String(f)).split(`
7
+ `).filter(y=>/^\(\d+:\d+\)/.test(y)).map(y=>`${a}${y.replace(/^\((\d+):(\d+)\)/,":$1:$2")}`),h=!s&&/^\s*StepSet\b/m.test(r);throw new V(h?`${a} uses StepSet, which only .saffron files understand. Rename it to ${a.replace(/\.feature$/,".saffron")}: nothing else needs to change (a .saffron file accepts everything a .feature file does).`:`${a} is not valid Gherkin:
8
+ ${S.join(`
9
+ `)||(f instanceof Error?f.message:String(f))}`)}if(!c.feature)return[];let d=c.feature.name,m=[],l=(f,S)=>{let h=S;for(let y of f)if(y.background)h=[...S,...Qn(y.background.steps,a,!0,o.invocations,n)];else if(y.scenario){if(o.definitions.has(y.scenario.location.line))continue;m.push(...wo(y.scenario,a,d,h,Qn(y.scenario.steps,a,!1,o.invocations,n),s))}else"rule"in y&&y.rule&&l(y.rule.children,h)};return l(c.feature.children,[]),m}import sr from"node:fs";import tn from"node:path";import Xn from"node:fs";import er from"node:path";import{createHash as vo}from"node:crypto";import xt from"node:fs";import Ue from"node:path";var Xt=".saffron";function wt(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function en(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),s=wt([...r,Ue.basename(n,".feature")].join("-"));if(r.length===0)return s;let o=vo("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${s}-${o}`}function J(e,t,n){return Ue.join(e,Xt,"cache",en(t),`${wt(n)}.json`)}function Zn(e,t){let n=new Map;for(let r of t){let s=J(e,r.featurePath,r.name),o=n.get(s)??new Set;o.add(`${r.featurePath} \u203A ${r.name}`),n.set(s,o)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,s])=>`${[...s].join(" and ")} both map to ${Ue.relative(e,r)}. Rename one scenario or one file.`)}var vt="owner.json";function Ee(e,t,n){return Ue.join(e,Xt,"artifacts",en(t),wt(n))}function xe(e,t,n){return Ue.join(e,Xt,"proposals",en(t),`${wt(n)}.json`)}function K(e){if(!xt.existsSync(e))return;let t=JSON.parse(xt.readFileSync(e,"utf8"));if(t.version!==1)throw new Error(`Unsupported cache version ${t.version} in ${e}`);return t}function Le(e,t){xt.mkdirSync(Ue.dirname(e),{recursive:!0}),xt.writeFileSync(e,`${JSON.stringify(t,null,2)}
10
+ `)}function Ze(e,t){let n=r=>typeof r=="string"?r.includes(t):Array.isArray(r)?r.some(n):r&&typeof r=="object"?Object.entries(r).some(([s,o])=>s!=="pageFingerprint"&&n(o)):!1;return e.some(n)}function Be(e,t){if(t.trim()==="")return!1;let n=r=>(r??"").replace(/<[^<>]+>/g,"");return e.some(r=>[r.value,r.url,r.urlPattern,r.pattern,r.promptText,r.compareTo,...[r.target,r.toTarget].flatMap(s=>[s?.name,s?.nameRegex,s?.label,s?.text,s?.placeholder,s?.testId,s?.frame,...s?.fallbackSelectors??[]])].some(s=>n(s).includes(t)))}function $t(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(t.push(s),`"\xAB${t.length-1}\xBB"`)),args:t}}function $o(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(e)===n(t)}function ko(e){let t=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))t.add(s[1])};for(let r of e.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(e.gherkin),t}function oe(e){let t=er.join(e,".saffron","cache"),n=new Map,r=new Map,s=0;if(Xn.existsSync(t))for(let a of Xn.readdirSync(t,{recursive:!0})){let c=String(a);if(!c.endsWith(".json"))continue;let d=K(er.join(t,c));if(d){s++;for(let m of d.steps){let l=m.gherkin,f=JSON.stringify(m.actions.map(({pageFingerprint:y,...w})=>w)),S=r.get(l)??[];S.includes(f)||(S.push(f),r.set(l,S));let h=n.get(l);!h||d.recordedAt>h.recordedAt?n.set(l,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,sourceParams:me(d.steps),sourceData:d.data,feature:d.feature,scenario:d.scenario,recordedAt:d.recordedAt,variants:S.length}):h.variants=S.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:c,args:d}=$t(a.gherkin);if(d.length===0)continue;let m=i.get(c);(!m||a.recordedAt>m.recordedAt)&&i.set(c,{...a,args:d})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function tr(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let o=Object.keys(W(e.table)),i=Object.keys(W(t.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;if(e.table&&t.table){let o=W(e.table),i=W(t.table);for(let a of Object.keys(o))if(o[a]!==i[a]&&(!Ze(e.actions,`<${a}>`)||Be(e.actions,o[a])))return!1}for(let[o,i]of Object.entries(e.sourceParams??{}))if(o in n&&n[o]!==i&&Be(e.actions,i))return!1;for(let[o,i]of Object.entries(e.sourceData??{})){let a;try{a=Ne(o)}catch{continue}if(a!==i&&(Be(e.actions,i)||e.gherkin.includes(`{data:${o}}`)&&!Ze(e.actions,`{data:${o}}`)))return!1}let r={...n,...W(t.resolvedTable??t.table),...Te(t.resolvedDocString??t.docString)},s=ko({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let o of s)if(!(o in r))return!1;return!0}function nr(e,t,n){let r=e.steps.get(t.text);if(r)return tr(r,t,n)?r:void 0;let{pattern:s,args:o}=$t(t.text);if(o.length===0)return;let i=e.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let f=i.args[l],S=o[l];if(f===S)continue;let h=a.get(f);if(h!==void 0&&h!==S)return;a.set(f,S)}let c=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(S=>[S.value,S.url,S.pattern,...c(S.target)].includes(l)))return;let d=l=>l!==void 0&&a.has(l)?a.get(l):l,m={...i,gherkin:t.text,actions:i.actions.map(({pageFingerprint:l,...f})=>({...f,value:d(f.value),url:d(f.url),pattern:d(f.pattern),target:f.target?{...f.target,name:d(f.target.name),nameRegex:d(f.target.nameRegex),label:d(f.target.label),text:d(f.target.text),placeholder:d(f.target.placeholder)}:void 0}))};return tr(m,t,n)?m:void 0}function rr(e,t){let n=[];return e.forEach((r,s)=>{if(r.actions.length!==0&&!t.steps.has(r.gherkin)){for(let o of t.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&$o(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function Ro(e){if(!sr.existsSync(e))return[];let t=[];for(let n of sr.readdirSync(e,{recursive:!0})){let r=String(n);de(r)&&t.push(tn.join(e,r))}return t.sort()}function ie(e,t,n,r){let s=tn.isAbsolute(t)?t:tn.join(e,t),o=Ro(s),i=n??se(o.filter(S=>S.endsWith(".saffron")),e),a=new Map,c=new Map;for(let S of o)for(let h of r?.get(S)??X(S,e,i)){let y=`${h.featurePath}::${h.name}`;for(let w of h.steps){let T=a.get(w.text);if(T||(T={text:w.text,keywords:new Map,kind:w.kind,scenarios:new Set,files:new Set,fromStepSet:w.source?.fromStepSet},a.set(w.text,T)),T.keywords.set(w.keyword,(T.keywords.get(w.keyword)??0)+1),T.scenarios.add(y),w.source&&T.files.add(w.source.file),w.source?.fromStepSet){let $=c.get(w.source.fromStepSet);$||c.set(w.source.fromStepSet,$=new Set),$.add(y)}}}let d=oe(e),m=new Set(d.divergent),l=[...a.values()].map(S=>({text:S.text,keyword:[...S.keywords.entries()].sort((h,y)=>y[1]-h[1])[0][0],kind:S.kind,status:m.has(S.text)?"divergent":d.steps.has(S.text)?"recorded":"unrecorded",usage:S.scenarios.size,files:[...S.files].sort(),fromStepSet:S.fromStepSet})).sort((S,h)=>h.usage-S.usage||S.text.localeCompare(h.text)),f=[...i.values()].map(S=>({name:S.name,file:S.file,line:S.line,steps:S.steps.map(h=>h.text),usage:c.get(S.name)?.size??0})).sort((S,h)=>h.usage-S.usage||S.name.localeCompare(h.name));return{steps:l,stepSets:f,scannedFiles:o.length,scannedCaches:d.scannedCaches}}function kt(e,t){let n=t.toLowerCase();return{...e,steps:e.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:e.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function or(e){let t={},n="saffron,feature,gherkin,cucumber";for(let r of e.steps)t[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of e.stepSets)t[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(t,null,2)+`
11
+ `}import{Server as Jo}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Go}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as Ko,ListToolsRequestSchema as Yo}from"@modelcontextprotocol/sdk/types.js";import ee from"node:fs";import ce from"node:path";import lr from"node:fs";import Ao from"node:path";import ir from"node:fs";import To from"node:path";var ar=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function Xe(e){return e.replace(ar,(t,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function nn(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(ar))t.add(r[1]);return t}function Z(e){let t=[];for(let n of e.steps){t.push(n.text,n.resolvedText),n.docString&&t.push(n.docString),n.resolvedDocString&&t.push(n.resolvedDocString);for(let r of n.table??[])t.push(...r);for(let r of n.resolvedTable??[])t.push(...r)}return t}function et(e){let t=new Map;for(let n of nn(Z(e))){let r=process.env[n];r!==void 0&&t.set(n,r)}return t}function ze(e,t){let n=e;for(let[r,s]of[...t.entries()].sort((o,i)=>i[1].length-o[1].length))s.length<4||(n=n.split(s).join(`{env:${r}}`));return n}function cr(e){let t=To.join(e,".env");if(ir.existsSync(t))for(let n of ir.readFileSync(t,"utf8").split(`
12
+ `)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}function te(e){cr(e);let t=Ao.join(e,"saffron.config.json"),n=lr.existsSync(t)?JSON.parse(lr.readFileSync(t,"utf8")):{};return De(e,n.dataDir,process.env.SAFFRON_ENV??n.env),n}var rn={features:"features",dataDir:"data",actionTimeoutMs:5e3,pollIntervalMs:100,retries:1,screenshot:"viewport",maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict",hookTimeoutMs:3e5};function dr(e){return{...rn,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import sn from"node:fs";import Rt from"node:path";import{fileURLToPath as Eo}from"node:url";function Tt(){let e=Rt.dirname(Eo(import.meta.url));for(let t=0;t<6;t++){let n=Rt.join(e,"package.json");if(sn.existsSync(n))try{if(JSON.parse(sn.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=Rt.dirname(e)}}function we(){let e=Tt();if(!e)return"unknown";try{return JSON.parse(sn.readFileSync(Rt.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import Y from"node:fs";import q from"node:path";var on=(e,t)=>q.relative(e,t).split(q.sep).join("/");function pr(e){return Y.existsSync(e)?[...Y.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>q.join(e,t)).sort():[]}function fr(e){try{let t=JSON.parse(Y.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function ur(e){try{let t=JSON.parse(Y.readFileSync(q.join(e,vt),"utf8"));return{feature:typeof t.feature=="string"?t.feature:void 0,scenario:typeof t.scenario=="string"?t.scenario:void 0}}catch{return{}}}function Et(e,t,n){let r=q.isAbsolute(t)?t:q.join(e,t),s=new Set(Y.existsSync(r)?[...Y.readdirSync(r,{recursive:!0})].map(String).filter(de).map(d=>q.join(r,d)):[]);for(let d of["cache","proposals"])for(let m of pr(q.join(e,".saffron",d))){let{feature:l}=fr(m);if(typeof l=="string"){let f=q.resolve(e,l);Y.existsSync(f)&&s.add(f)}}for(let d of At(q.join(e,".saffron","artifacts")))for(let m of At(d)){let l=ur(m).feature;if(l){let f=q.resolve(e,l);Y.existsSync(f)&&s.add(f)}}let o=[...n?.unreadable??[]],i=[...s].filter(d=>!n?.parsedFiles.has(d)),a=new Map;if(i.length>0)try{a=se([...new Set([...be(r),...s].filter(d=>d.endsWith(".saffron")))],e)}catch(d){o.push(d instanceof Error?d.message:String(d))}let c=new Set(n?.owned??[]);for(let d of i){let m;try{m=X(d,e,a)}catch(l){o.push(`${on(e,d)}: ${l instanceof Error?l.message:String(l)}`);continue}for(let l of m)c.add(J(e,l.featurePath,l.name)),c.add(xe(e,l.featurePath,l.name)),c.add(Ee(e,l.featurePath,l.displayName))}return{...Po(e,c),unreadable:o}}function Po(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let i=q.join(e,".saffron",o==="cache"?"cache":"proposals");for(let a of pr(i)){if(t.has(a)){r++;continue}let{feature:c,scenario:d}=fr(a),m=c?q.join(e,c):void 0;n.push({file:on(e,a),kind:o,feature:c,scenario:d,reason:c?Y.existsSync(m)?"scenario-gone":"feature-gone":"unknown"})}}let s=q.join(e,".saffron","artifacts");for(let o of At(s))for(let i of At(o)){if(t.has(i)){r++;continue}let{feature:a,scenario:c}=ur(i);n.push({file:on(e,i),kind:"artifact",feature:a,scenario:c,reason:a?Y.existsSync(q.join(e,a))?"scenario-gone":"feature-gone":"unknown"})}return{orphans:n,kept:r}}function At(e){try{return Y.readdirSync(e,{withFileTypes:!0}).filter(t=>t.isDirectory()).map(t=>q.join(e,t.name)).sort()}catch{return[]}}function gr(e,t){let n=[],r=new Set;for(let s of t){let o=q.join(e,s.file);try{Y.rmSync(o,{recursive:s.kind==="artifact"}),n.push(s.file),r.add(q.dirname(o))}catch{}}for(let s of r)try{Y.readdirSync(s).length===0&&Y.rmdirSync(s)}catch{}return n}function hr(e){if(e.kind==="artifact")return`${e.file}/
13
13
  failure screenshots of a scenario that no longer exists`;let t=e.feature&&e.scenario?`${e.feature} \u203A ${e.scenario}`:"an unreadable recording",n=e.reason==="feature-gone"?"the feature file is gone":e.reason==="scenario-gone"?"the scenario is gone from that file":"it names no scenario";return`${e.file}
14
- ${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import ur from"node:path";var le=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse","expectEach"]);var fo=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function uo(e,t){let n=String(e.getFullYear()),r=String(e.getMonth()+1).padStart(2,"0"),s=String(e.getDate()).padStart(2,"0");return t.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(e.getMonth()+1)).replaceAll("D",String(e.getDate()))}function nn(e,t=new Date){return e.replace(fo,(n,r,s)=>{let o=new Date(t);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),uo(o,s??"YYYY-MM-DD")})}var go=/\b(\d{4})-(\d{2})-(\d{2})\b/g,ho=400;function lr(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(go,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let c=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(c)>ho?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as mo,randomBytes as yo}from"node:crypto";var dr=/\{unique:([A-Za-z0-9_-]+)(?::(digits))?\}/g,de="__saffron_unique_seed",Qe=()=>`${Date.now().toString(36)}${yo(4).toString("hex")}`;function pr(e,t,n=!1){let r=mo("sha1").update(`${e}:${t}`).digest();if(n){let o=r.readUInt32BE(0)*65536+r.readUInt16BE(4);return String(1e8+o%9e8)}let s=BigInt(`0x${r.subarray(0,8).toString("hex")}`).toString(36).padStart(9,"0").slice(-9);return`${"abcdefghijklmnopqrstuvwxyz"[r[8]%26]}${s}`}function Ue(e,t){return e.replace(dr,(n,r,s)=>pr(t,r,s==="digits"))}function $t(e,t){let n=new Map;for(let r of e)if(r)for(let s of r.matchAll(dr))n.set(s[0],pr(t,s[1],s[2]==="digits"));return n}function fr(e,t){let n=e;for(let[r,s]of t)n=n.split(s).join(r);return n}import{createHash as So}from"node:crypto";async function Ee(e){let t;try{t=await e.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=t.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${So("sha256").update(n).digest("hex")}`}var on=e=>new Promise(t=>setTimeout(t,e)),sn=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},t.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function bo(e,t){if(!t)return e>=200&&e<300;let n=/^([1-5])xx$/i.exec(t.trim());if(n){let r=Number(n[1])*100;return e>=r&&e<r+100}return e===Number(t.trim())}function gr(e,t,n,r,s){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!bo(e.status,r)||s&&!s.test(e.body??""))}var xo=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,wo=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]);function hr(e,t,n,r=!1,s=!0){let o=c=>Ke(Je(nn($e(c,n)))),i=[],a=c=>{if(t.role){let d=t.nameRegex?new RegExp(o(t.nameRegex),"i"):t.name?o(t.name):void 0,h=t.role;typeof d=="string"?(i.push(c.getByRole(h,{name:d,exact:!0})),s&&i.push(c.getByRole(h,{name:d,exact:!1}))):i.push(c.getByRole(h,{name:d,exact:!1}))}t.label&&i.push(c.getByLabel(o(t.label))),t.placeholder&&i.push(c.getByPlaceholder(o(t.placeholder))),t.text&&i.push(c.getByText(o(t.text))),t.testId&&i.push(c.getByTestId(o(t.testId)));for(let d of t.fallbackSelectors??[])i.push(c.locator(d))};if(t.frame)a(e.frameLocator(o(t.frame)));else if(a(e),r)for(let c of e.frames())c!==e.mainFrame()&&a(c);if(i.length===0)throw new Error("Action target has neither role nor fallback selectors");return i}var yr=100;async function vo(e,t,n,r){let s=Date.now()+t,o;for(;Date.now()<s;){try{if(await e())return}catch(i){o=i}await on(r)}throw new Error(`Timed out waiting for ${n}${o?` (${String(o)})`:""}`)}async function ie(e,t,n=!1){let r;for(let s of e)try{await t(s.first());return}catch(o){if(r=o,n&&await s.first().count().catch(()=>0)>0)throw o}throw r}var rn=e=>e.replace(/\s+/g," ").trim();async function an(e,t,n,r,s,o=new Map,i,a=yr){let c=(g,y,u)=>vo(g,y,u,a);o.has(de)||o.set(de,Qe());let d=o.get(de),h=g=>typeof g=="string"?Ue(g,d):Array.isArray(g)?g.map(h):g&&typeof g=="object"?Object.fromEntries(Object.entries(g).map(([y,u])=>[y,h(u)])):g;t=h(t),n=h(n);let l=g=>Ke(Je(nn($e(g,n)))),f=t.value!==void 0?l(t.value):void 0,m=t.url!==void 0?l(t.url):void 0,p=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="expectEach"||t.action==="captureText",x=t.target?hr(e,t.target,n,p,!wo.has(t.action)):[],w={timeout:r},R={timeout:Math.min(1e3,r)},S=g=>async()=>{for(let y of x)try{if(await g(y.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!m)throw new Error("goto requires url");let g=/^https?:\/\//.test(m)?m:new URL(m,s).toString();await e.goto(g,w);return}case"click":return ie(x,g=>g.click(w),!0);case"fill":return ie(x,g=>g.fill(f??"",w),!0);case"press":return x.length>0?ie(x,g=>g.press(f??"",w),!0):e.keyboard.press(f??"");case"selectOption":return ie(x,async g=>{await g.selectOption(f??"",w)},!0);case"check":return ie(x,g=>g.check(w),!0);case"uncheck":return ie(x,g=>g.uncheck(w),!0);case"hover":return ie(x,g=>g.hover(w),!0);case"waitFor":return x.length>0?ie(x,g=>g.waitFor({state:"visible",timeout:r})):on(Math.min(Number(f??"0"),r));case"expectVisible":return c(S(g=>g.isVisible()),r,"target to be visible");case"expectNotVisible":return c(async()=>{for(let g of x){let y=await g.count();for(let u=0;u<y;u++)if(await g.nth(u).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${x.length} locator(s))`);case"expectText":return c(S(async g=>(await g.textContent(R)??"").includes(f??"")),r,`text "${f}"`);case"expectEach":{let g=t.each?.match(/^\{data:([^}]+)\}$/)?.[1];if(!g)throw new Error(`expectEach needs a {data:...} list reference, got ${JSON.stringify(t.each)}`);let y=Oe(g),u=async P=>await P.isVisible()?new Set(await P.evaluate(A=>{let $=E=>(E??"").replace(/\s+/g," ").trim(),k=E=>typeof E.checkVisibility=="function"?E.checkVisibility({visibilityProperty:!0,contentVisibilityAuto:!0}):!!(E.offsetWidth||E.offsetHeight||E.getClientRects().length),N=E=>{let T=E.ownerDocument.createTreeWalker(E,4),Q="";for(let v=T.nextNode();v;v=T.nextNode())v.parentElement&&k(v.parentElement)&&(Q+=v.nodeValue??"");return Q},C=new Set,M=E=>{for(let T of Array.from(E.options))T.hidden||C.add($(T.textContent))};A.tagName==="SELECT"&&M(A);for(let E of Array.from(A.querySelectorAll("*")))k(E)&&(E.tagName==="SELECT"?M(E):E.tagName!=="OPTION"&&C.add($(N(E))));C.add($(N(A)));for(let E of String(A.innerText??"").split(`
15
- `))C.add($(E));return[...C]},void 0,R)):new Set,b=y;return c(async()=>{for(let P of x)try{let A=await u(P.first()),$=y.filter(k=>!A.has(k.replace(/\s+/g," ").trim()));if($.length<b.length&&(b=$),$.length===0)return!0}catch{}return!1},r,`every value of ${t.each} to be shown`).catch(()=>{throw new Error(`${b.map(P=>`"${P}"`).join(", ")} from ${t.each} ${b.length===1?"is":"are"} not shown in the target (${y.length-b.length} of ${y.length} found)`)})}case"expectValue":return c(S(async g=>await g.inputValue(R)===(f??"")),r,`input value "${f}"`);case"expectUrl":{let g=m??f??"";return c(async()=>e.url().includes(g),r,`URL containing "${g}" on the active page (${e.url()})`)}case"handleDialog":{let g=(f??"accept")!=="dismiss",y=t.promptText?l(t.promptText):void 0;e.once("dialog",u=>{g?u.accept(y):u.dismiss()});return}case"uploadFile":{let g=(f??"").split(`
16
- `).filter(Boolean).map(y=>ur.isAbsolute(y)?y:ur.resolve(y));if(g.length===0)throw new Error("uploadFile requires a path");if(x.length>0)return ie(x,y=>y.setInputFiles(g,w),!0);e.once("filechooser",y=>{y.setFiles(g)});return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let g=hr(e,t.toTarget,n,!1,!1);return ie(x,y=>y.dragTo(g[0].first(),w),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return c(S(async g=>{let y=rn(await g.textContent(R)??"");return y===""?!1:(o.set(t.saveAs,y),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let g=o.get(t.compareTo);if(g===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(x.length>0)return c(S(async b=>rn(await b.textContent(R)??"")!==g),r,`target text to differ from "${t.compareTo}" (${g})`);let y=f??"",u=o.get(y);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${y}"`);if(u===g)throw new Error(`expectDiffers: "${y}" (${u}) equals "${t.compareTo}" (${g})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let g=new RegExp(t.pattern,"i");return c(S(async y=>g.test(await y.getAttribute(t.attribute,R)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let g=new RegExp(t.pattern,"i");return c(S(async y=>g.test(rn(await y.textContent(R)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let g=Number.parseInt(f??"",10),y=()=>e.context().pages().filter(b=>!b.isClosed());await c(async()=>y().length>g,r,`tab ${f} to exist (${y().length} open: ${y().map(b=>b.url()).join(", ")})`);let u=y()[g];return await u.bringToFront(),u}case"closeTab":{let g=e.context().pages().filter(b=>!b.isClosed()),y=f===void 0?e:g[Number.parseInt(f,10)];if(!y)throw new Error(`tab ${f} does not exist (${g.length} open)`);await y.close();let u=e.context().pages().filter(b=>!b.isClosed());if(u.length===0)throw new Error("closed the last tab; nothing left to drive");return u[u.length-1]}case"newTab":{let g=await e.context().newPage();if(m){let y=/^https?:\/\//.test(m)?m:new URL(m,s).toString();await g.goto(y,w)}return g}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let g=new RegExp(l(t.urlPattern)),y=t.pattern?new RegExp(l(t.pattern)):void 0;if(!i){await e.waitForResponse(b=>gr({seq:0,url:b.url(),method:b.request().method(),status:b.status()},g,t.method,t.status,void 0),w);return}let u=()=>t.action==="expectResponse"?i.all():i.sincePreviousStep();return c(async()=>u().some(b=>gr(b,g,t.method,t.status,y)),r,xo(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}var Rt=(e,t)=>yt(e.actions,t),mr=(e,t)=>Me(e.actions,t);function cn(e,t){if(t.steps.length!==e.steps.length)return!1;for(let[n,r]of Object.entries(t.data??{})){let s;try{s=Ie(n)}catch{continue}if(s===r)continue;let o=t.steps.some(a=>Rt(a,`{data:${n}}`)),i=t.steps.some(a=>mr(a,r));if(!o||i)return!1}return t.steps.every((n,r)=>{let s=e.steps[r];if(n.gherkin!==s.text||n.kind!==s.kind)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(q(o)),c=Object.keys(q(i));if(a.length>0&&c.length>0){if(JSON.stringify(a)!==JSON.stringify(c))return!1;let d=q(o),h=q(i);for(let l of a){if(d[l]===h[l])continue;let f=`<${l}>`,m=t.steps.some(x=>Rt(x,f)),p=t.steps.some(x=>mr(x,d[l]));if(!m||p)return!1}}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=t.steps.some(d=>Rt(d,"<docstring>")),c=t.steps.some(d=>Rt(d,n.docString));if(!a||c)return!1}return!0})}function ln(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...he(e.steps),...q(r),...Re(n?.resolvedDocString??n?.docString)}}async function Ze(e,t,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=Math.max(10,r.pollIntervalMs??yr),a=[],c=r.vars??new Map;r.uniqueSeed&&c.set(de,r.uniqueSeed);let d=()=>Object.fromEntries(c),h=n.steps.some(y=>y.actions.some(u=>(u.action==="waitForResponse"||u.action==="expectResponse")&&u.pattern!==void 0)),l=new sn;l.attach(e.context(),h);let f=e,m=new Set(e.context().pages()),p=[],x=y=>p.push(y);e.context().on("page",x);let w=[],S=!n.steps.some(y=>y.actions.some(u=>u.opensTab))&&n.steps.some(y=>y.actions.some(u=>u.action==="switchTab"||u.action==="closeTab"||u.action==="newTab")),g=()=>{let y=e.context().pages();for(let u of y)m.has(u)||(m.add(u),u.isClosed()||(f=u));if(f.isClosed()){let u=y.filter(b=>!b.isClosed());u.length>0&&(f=u[u.length-1])}};if(!cn(t,n))return l.detach(),{status:"stale",stepResults:a,captured:d()};try{for(let y=0;y<n.steps.length;y++){let u=n.steps[y],b=ln(t,y);l.markStep();let P=!1,A=0;if(u.kind==="assertion"&&!u.actions.some($=>le.has($.action))){let $=`Assertion step "${u.gherkin}" recorded ${u.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;a.push({gherkin:u.gherkin,fromStepSet:u.fromStepSet,kind:u.kind,status:"failed",drift:P,error:$});for(let k=y+1;k<n.steps.length;k++)a.push({gherkin:n.steps[k].gherkin,kind:n.steps[k].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:y,actionIndex:0,stepText:u.gherkin,kind:u.kind,error:$},stepResults:a,captured:d()}}for(let $=0;$<u.actions.length;$++){let k=u.actions[$];g(),k.pageFingerprint&&await Ee(f)!==k.pageFingerprint&&(P=!0);let N,C=!1;for(let M=0;M<=o&&!C;M++){M>0&&(A++,await on(250*M),g());try{let E=await an(f,k,b,s,r.baseURL,c,l,i);E&&(f=E,m.add(E));let T=k.action==="click"||k.action==="press";if(p.length===0&&(k.opensTab||T&&S)&&await e.context().waitForEvent("page",{timeout:k.opensTab?s:150}).catch(()=>{}),k.opensTab&&p.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for(p.length>0&&w.push({stepIndex:y,actionIndex:$});p.length>0;)await p.shift().waitForLoadState("domcontentloaded",{timeout:s}).catch(()=>{});C=!0}catch(E){N=E}}if(!C){let M=N instanceof Error?N.message:String(N);a.push({gherkin:u.gherkin,fromStepSet:u.fromStepSet,kind:u.kind,status:"failed",drift:P,...A?{retries:A}:{},error:M});for(let T=y+1;T<n.steps.length;T++)a.push({gherkin:n.steps[T].gherkin,kind:n.steps[T].kind,status:"skipped"});let E={stepIndex:y,actionIndex:$,stepText:u.gherkin,kind:u.kind,error:M};return await r.onFailure?.(f,E).catch(()=>{}),{status:"failed",failure:E,stepResults:a,captured:d(),tabOpeners:w}}}a.push({gherkin:u.gherkin,fromStepSet:u.fromStepSet,kind:u.kind,status:"passed",drift:P,...A?{retries:A}:{}})}return{status:"passed",stepResults:a,captured:d(),tabOpeners:w}}finally{e.context().off("page",x),l.detach()}}import pe from"node:fs";import Tt from"node:path";function Sr(e,t){pe.mkdirSync(Tt.dirname(e),{recursive:!0}),pe.writeFileSync(e,`${JSON.stringify(t,null,2)}
17
- `)}function Pe(e){return JSON.parse(pe.readFileSync(e,"utf8"))}function we(e){let t=Tt.join(e,".saffron","proposals");if(!pe.existsSync(t))return[];let n=[];for(let r of pe.readdirSync(t)){let s=Tt.join(t,r);if(pe.statSync(s).isDirectory())for(let o of pe.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=Tt.join(s,o);n.push({file:i,proposal:Pe(i)})}}return n}function br(e,t){let n=Pe(t),r=H(e,n.cache.feature,n.cache.scenario);return Ne(r,n.cache),pe.rmSync(t),r}function xr(e){pe.rmSync(e)}import At from"node:fs";import wr from"node:path";function vr(e){return wr.join(e,".saffron","history.jsonl")}function pn(e,t,n,r){let s=e.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
18
- `)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function fn(e,t){let n=vr(e);At.mkdirSync(wr.dirname(n),{recursive:!0}),At.appendFileSync(n,`${JSON.stringify(t)}
19
- `)}function Xe(e,t=50){let n=vr(e);if(!At.existsSync(n))return[];let r=At.readFileSync(n,"utf8").trim().split(`
20
- `).filter(Boolean),s=[];for(let o of r.slice(-t))try{s.push(JSON.parse(o))}catch{}return s}var dn=e=>{let t=e.green+e.yellow+e.red;return t===0?0:Math.round((e.green+e.yellow)/t*100)};function un(e,t){let n=[...e,t],r=n.slice(-20),s=n.slice(-10),o=e.at(-1),i=o?{passRatePp:dn(t)-dn(o),costUsd:t.costUsd-o.costUsd,aiCalls:t.aiCalls-o.aiCalls,red:t.red-o.red}:void 0,a=new Map;for(let l of s)for(let f of l.scenarios){let m=`${f.feature}::${f.scenario}`,p=a.get(m)??{feature:f.feature,scenario:f.scenario,heals:0,reds:0,runs:0};p.runs++,f.source==="agent-heal"&&f.status==="yellow"&&p.heals++,f.status==="red"&&p.reds++,a.set(m,p)}let c=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,f)=>f.heals+f.reds-(l.heals+l.reds)),d=new Map;for(let l of r)for(let f of l.scenarios)f.status==="red"&&f.error&&d.set(f.error,(d.get(f.error)??0)+1);let h=[...d.entries()].sort((l,f)=>f[1]-l[1]).slice(0,3).map(([l,f])=>({message:l,count:f}));return{prev:i,series:{passRate:r.map(dn),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:c,divergentSteps:t.divergentSteps,topIssues:h}}var ko=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,$o=/^\s*StepSet:/;function et(e,t){return ae.relative(e,t).split(ae.sep).join("/")}function Ro(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function kr(e,t){return`${e} :: ${t}`}function Et(e,t,n={}){let r=t.features??"features",s=ae.isAbsolute(r)?r:ae.join(e,r),o=ae.join(e,".saffron","reports","latest.json"),i=ae.join(e,".saffron","reports","latest.html"),a;try{a=JSON.parse(ee.readFileSync(o,"utf8"))}catch{a=void 0}let c=new Map;for(let u of a?.scenarios??[]){let b=u.scenario.replace(/ \(example \d+\)$/,""),P=kr(u.feature,b);c.set(P,Ro(c.get(P),u.status))}let d=new Map,h;try{d=re(Se(s),e)}catch(u){h=u instanceof Error?u.message:String(u)}let l=ee.existsSync(s)?[...ee.readdirSync(s,{recursive:!0})].map(String).filter(ce).map(u=>ae.join(s,u)).sort():[],f=[],m=new Map,p=new Map,x={parsedFiles:new Set,owned:new Set,unreadable:h?[h]:[]};for(let u of l){let b=et(e,u),P="";try{P=ee.readFileSync(u,"utf8")}catch(T){f.push({path:b,name:b,scenarios:[],stepSets:0,error:String(T)}),x.unreadable.push(`${b}: ${String(T)}`);continue}let A=P.split(`
21
- `),$=new Map,k=0;A.forEach((T,Q)=>{let v=T.match(ko);v&&!$.has(v[1])?$.set(v[1],Q+1):$o.test(T)&&k++});let N=A.find(T=>/^\s*Feature:/.test(T)),C=N?N.replace(/^\s*Feature:\s*/,"").trim():b,M;try{M=Z(u,e,d)}catch(T){f.push({path:b,name:C,scenarios:[],stepSets:k,error:h??(T instanceof Error?T.message:String(T))}),x.unreadable.push(`${b}: ${T instanceof Error?T.message:String(T)}`);continue}x.parsedFiles.add(u),p.set(u,M);let E=new Map;for(let T of M){x.owned.add(Ae(e,T.featurePath,T.displayName));let Q=E.get(T.name);if(Q){Q.rows+=1;continue}let v=H(e,T.featurePath,T.name);x.owned.add(v),x.owned.add(be(e,T.featurePath,T.name));let _="missing";if(ee.existsSync(v))try{let j=J(v);_=j&&cn(T,j)?"valid":"stale"}catch{_="invalid"}E.set(T.name,{name:T.name,line:$.get(T.name),tags:[...T.tags],outline:Object.keys(T.params).length>0,rows:1,cached:_!=="missing",cacheState:_,proposal:ee.existsSync(be(e,T.featurePath,T.name)),lastStatus:c.get(kr(T.featurePath,T.name))});for(let j of T.tags)m.set(j,(m.get(j)??0)+1)}f.push({path:b,name:C,scenarios:[...E.values()],stepSets:k})}let w=we(e).map(({file:u,proposal:b})=>({file:et(e,u),feature:b.cache.feature,scenario:b.cache.scenario,mode:b.meta.mode,createdAt:b.meta.createdAt,verified:b.meta.verified,proofError:b.meta.proofError,adaptations:b.meta.adaptations??[],narrative:b.meta.narrative,suggestedFeatureEdit:b.meta.suggestedFeatureEdit,recordedFor:b.meta.recordedFor,aiCalls:b.meta.usage?.aiCalls??0,costUsd:b.meta.usage?.costUsd})).sort((u,b)=>u.file.localeCompare(b.file)),R;try{R=oe(e,r,d,p)}catch{R={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let S=Ao(e),g=Xe(e,n.historyLimit??30).map(u=>({startedAt:u.startedAt,green:u.green,yellow:u.yellow,red:u.red,aiCalls:u.aiCalls,costUsd:u.costUsd})),y=ae.join(e,"saffron.config.json");return{tool:"saffron",version:xe(),projectRoot:e,packageInstalled:ee.existsSync(ae.join(e,"node_modules","saffron-ai")),config:{file:ee.existsSync(y)?"saffron.config.json":void 0,effective:rr(t)},features:f,tags:[...m.entries()].map(([u,b])=>({tag:u,scenarios:b})).sort((u,b)=>u.tag.localeCompare(b.tag)),proposals:w,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:a.totals,reportHtml:ee.existsSync(i)?et(e,i):void 0,attention:a.scenarios.filter(u=>u.status!=="green").map(u=>({feature:u.feature,scenario:u.scenario,baseScenario:u.scenario.replace(/ \(example \d+\)$/,""),status:u.status,error:u.status==="red"?u.error?.split(`
22
- `)[0]:void 0,failedStep:(u.steps??[]).find(b=>b.status==="failed")?.gherkin??u.evidence?.[0]?.step,evidence:(u.evidence??[]).filter(b=>ee.existsSync(ae.join(e,b.file)))})).sort((u,b)=>u.status===b.status?0:u.status==="red"?-1:1)}:void 0,history:g,vocabulary:{steps:R.steps.length,recorded:R.steps.filter(u=>u.status==="recorded").length,unrecorded:R.steps.filter(u=>u.status==="unrecorded").length,stepSets:R.stepSets.length,divergent:R.steps.filter(u=>u.status==="divergent").map(u=>u.text),duplicateWordings:S.slice(0,50)},data:(()=>{let u=ye(),b=[],P=new Set;for(let[A,$]of p)for(let k of $)for(let N of Ge(X(k)))if(!P.has(N)){P.add(N);try{pt(N)}catch(C){b.push({ref:N,reason:C instanceof Error?C.message:String(C),feature:et(e,A),scenario:k.name})}}return{dir:et(e,u.dir),env:u.env,files:ee.existsSync(u.dir)?ee.readdirSync(u.dir).filter(A=>/\.(json|csv)$/.test(A)).sort():[],unresolved:b}})(),orphans:(()=>{let u=kt(e,r,x);return u.unreadable.length?[]:u.orphans})()}}function Rr(e){let t=e.features.reduce((o,i)=>o+i.scenarios.length,0),n=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="valid").length,0),r=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="stale"||a.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let o of e.features){let i=o.scenarios.map(a=>a.proposal?"\u25D0":a.cacheState==="valid"?"\u25CF":a.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${o.path} ${i}${o.error?` \u2717 ${o.error.split(`
23
- `)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(o=>`${o.tag} (${o.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(o=>o.file).join(", ")}`:"no proposals pending review"),e.lastRun){let o=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${o.green} passed \xB7 ${o.yellow} pending review \xB7 ${o.red} failed \xB7 $${o.costUsd.toFixed(2)}`)}let s=e.vocabulary;console.log(`vocabulary: ${s.steps} steps (${s.recorded} recorded, ${s.unrecorded} unrecorded), ${s.stepSets} step sets, ${s.divergent.length} divergent, ${s.duplicateWordings.length} duplicate wording group(s)`),e.orphans.length&&console.log(`${e.orphans.length} recording(s) belong to no scenario: run \`saffron prune\` to see them`),(e.data.files.length||e.data.unresolved.length)&&console.log(`test data: ${e.data.files.length} file(s) in ${e.data.dir}/${e.data.env?` \xB7 environment "${e.data.env}"`:""}`+(e.data.unresolved.length?` \xB7 ${e.data.unresolved.length} reference(s) do not resolve: ${e.data.unresolved.map(o=>`{data:${o.ref}}`).join(", ")}`:"")),console.log(`config: ${e.config.file??"(defaults, no saffron.config.json)"}`)}var To=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function $r(e){return e?e.role?`${e.role}${e.name?` "${e.name}"`:e.nameRegex?` /${e.nameRegex}/`:""}`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testid ${e.testId}`:e.placeholder?`placeholder "${e.placeholder}"`:e.fallbackSelectors?.length?e.fallbackSelectors[0]:"":""}function Ao(e){let t;try{t=se(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let s=St(r.gherkin).pattern,o=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(l=>l[1]),i=l=>l!==void 0&&(o.includes(l)||To.test(l))?"\xABarg\xBB":l,a=r.actions.map(l=>{let{pageFingerprint:f,value:m,promptText:p,...x}=l;return{...x,value:i(m),promptText:i(p)}}),c=JSON.stringify(a),d=r.actions.map(l=>`${l.action}${$r(l.target)?` ${$r(l.target)}`:l.url?` ${l.url}`:""}`).join(" \xB7 "),h=n.get(c)??{steps:new Map,actions:d};h.steps.has(s)||h.steps.set(s,r.gherkin),n.set(c,h)}return[...n.values()].filter(r=>r.steps.size>1).map(r=>({steps:[...r.steps.values()].sort(),actions:r.actions})).sort((r,s)=>s.steps.length-r.steps.length||r.steps[0].localeCompare(s.steps[0]))}async function Tr(e,t){let n=new Eo({name:"saffron",version:xe()},{capabilities:{tools:{}}});n.setRequestHandler(jo,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate: exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}},{name:"project_status",description:"The project overview (same data as `saffron status --json`): every feature file with its scenarios (header line, tags, outline rows, cached / proposal pending / last run status), the tag list, pending proposals with narrative and proof-replay verdict, the last run's totals, run history, vocabulary health (divergent steps, duplicate wordings proven by identical recordings) and the effective config. Call it before editing tests to see what is already recorded and what awaits review.",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(_o,async r=>Co(r.params.name,r.params.arguments,e,t)),await n.connect(new Po)}function Co(e,t,n,r){let s=o=>({content:[{type:"text",text:JSON.stringify(o,null,2)}]});if(e==="search_steps"){let o=oe(n,r),i=typeof t?.query=="string"?t.query:void 0,a=i?bt(o,i):o;return s({steps:a.steps,scannedFiles:a.scannedFiles,scannedCaches:a.scannedCaches})}if(e==="list_step_sets"){let o=oe(n,r);return s({stepSets:o.stepSets})}return e==="project_status"?s(Et(n,te(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as Fo}from"@anthropic-ai/claude-agent-sdk";function Io(e,t){let n=t.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
14
+ ${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import wr from"node:path";var pe=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse","expectEach"]);var Co=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function _o(e,t){let n=String(e.getFullYear()),r=String(e.getMonth()+1).padStart(2,"0"),s=String(e.getDate()).padStart(2,"0");return t.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(e.getMonth()+1)).replaceAll("D",String(e.getDate()))}function an(e,t=new Date){return e.replace(Co,(n,r,s)=>{let o=new Date(t);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),_o(o,s??"YYYY-MM-DD")})}var jo=/\b(\d{4})-(\d{2})-(\d{2})\b/g,Fo=400;function mr(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(jo,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let c=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(c)>Fo?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as Io,randomBytes as Oo}from"node:crypto";var yr=/\{unique:([A-Za-z0-9_-]+)(?::(digits))?\}/g,ne="__saffron_unique_seed",Pe=()=>`${Date.now().toString(36)}${Oo(4).toString("hex")}`;function Sr(e,t,n=!1){let r=Io("sha1").update(`${e}:${t}`).digest();if(n){let o=r.readUInt32BE(0)*65536+r.readUInt16BE(4);return String(1e8+o%9e8)}let s=BigInt(`0x${r.subarray(0,8).toString("hex")}`).toString(36).padStart(9,"0").slice(-9);return`${"abcdefghijklmnopqrstuvwxyz"[r[8]%26]}${s}`}function qe(e,t){return e.replace(yr,(n,r,s)=>Sr(t,r,s==="digits"))}function tt(e,t){let n=new Map;for(let r of e)if(r)for(let s of r.matchAll(yr))n.set(s[0],Sr(t,s[1],s[2]==="digits"));return n}function br(e,t){let n=e;for(let[r,s]of t)n=n.split(s).join(r);return n}import{createHash as Do}from"node:crypto";async function Ce(e){let t;try{t=await e.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=t.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Do("sha256").update(n).digest("hex")}`}var No=/^[a-z][a-z\d+.-]*:|^\/\//i,xr=e=>e.pathname.replace(/\/+$/,"");function nt(e,t){if(!t)return e;let n,r;try{n=new URL(e),r=new URL(t)}catch{return e}if(n.origin!==r.origin)return e;let s=xr(r),o=n.pathname,i;if(o===r.pathname||o===s||o===`${s}/`)i="/";else if(o.startsWith(`${s}/`))i=o.slice(s.length);else return e;let a=n.search===r.search?"":n.search||"?",c=i!=="/"?n.hash:n.hash===r.hash?"":n.hash||(r.hash?"#":"");return`${i}${a}${c}`}function cn(e,t){if(No.test(e))return e;if(!t)throw new Error(`relative navigation "${e}" needs a base URL (set baseURL or pass --base-url)`);let n=new URL(t),r=e.indexOf("#"),s=r>=0?e.slice(r):void 0,o=r>=0?e.slice(0,r):e,i=o.indexOf("?"),a=i>=0?o.slice(i):void 0,c=i>=0?o.slice(0,i):o,d=c===""||c==="/",m=new URL(n.href);return m.pathname=d?n.pathname:`${xr(n)}${c.startsWith("/")?c:`/${c}`}`,m.search=a===void 0?n.search:a==="?"?"":a,m.hash=s==="#"?"":s!==void 0?s:d?n.hash:"",m.toString()}var pn=e=>new Promise(t=>setTimeout(t,e)),dn=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},t.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function Mo(e,t){if(!t)return e>=200&&e<300;let n=/^([1-5])xx$/i.exec(t.trim());if(n){let r=Number(n[1])*100;return e>=r&&e<r+100}return e===Number(t.trim())}function vr(e,t,n,r,s){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!Mo(e.status,r)||s&&!s.test(e.body??""))}var Uo=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,Lo=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]);function $r(e,t,n,r=!1,s=!0){let o=c=>Xe(Ye(an(Re(c,n)))),i=[],a=c=>{if(t.role){let d=t.nameRegex?new RegExp(o(t.nameRegex),"i"):t.name?o(t.name):void 0,m=t.role;d===void 0&&!!(t.label||t.placeholder||t.text||t.testId||t.fallbackSelectors?.length)?t.text&&i.push(c.getByRole(m).filter({hasText:o(t.text)})):typeof d=="string"?(i.push(c.getByRole(m,{name:d,exact:!0})),s&&i.push(c.getByRole(m,{name:d,exact:!1}))):i.push(c.getByRole(m,{name:d,exact:!1}))}t.label&&i.push(c.getByLabel(o(t.label))),t.placeholder&&i.push(c.getByPlaceholder(o(t.placeholder))),t.text&&!t.role&&i.push(c.getByText(o(t.text))),t.testId&&i.push(c.getByTestId(o(t.testId)));for(let d of t.fallbackSelectors??[])i.push(c.locator(d))};if(t.frame)a(e.frameLocator(o(t.frame)));else if(a(e),r)for(let c of e.frames())c!==e.mainFrame()&&a(c);if(i.length===0)throw new Error("Action target has neither role nor fallback selectors");return i}var Rr=100;async function Bo(e,t,n,r){let s=Date.now()+t,o;for(;Date.now()<s;){try{if(await e())return}catch(i){o=i}await pn(r)}throw new Error(`Timed out waiting for ${n}${o?` (${String(o)})`:""}`)}async function ae(e,t,n=!1){let r;for(let s of e)try{await t(s.first());return}catch(o){if(r=o,n&&await s.first().count().catch(()=>0)>0)throw o}throw r}var ln=e=>e.replace(/\s+/g," ").trim();async function fn(e,t,n,r,s,o=new Map,i,a=Rr){let c=(p,u,g)=>Bo(p,u,g,a);o.has(ne)||o.set(ne,Pe());let d=o.get(ne),m=p=>typeof p=="string"?qe(p,d):Array.isArray(p)?p.map(m):p&&typeof p=="object"?Object.fromEntries(Object.entries(p).map(([u,g])=>[u,m(g)])):p;t=m(t),n=m(n);let l=p=>Xe(Ye(an(Re(p,n)))),f=t.value!==void 0?l(t.value):void 0,S=t.url!==void 0?l(t.url):void 0,h=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="expectEach"||t.action==="captureText",y=t.target?$r(e,t.target,n,h,!Lo.has(t.action)):[],w={timeout:r},T={timeout:Math.min(1e3,r)},$=p=>async()=>{for(let u of y)try{if(await p(u.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!S)throw new Error("goto requires url");await e.goto(cn(S,s),w);return}case"click":return ae(y,p=>p.click(w),!0);case"fill":return ae(y,p=>p.fill(f??"",w),!0);case"press":return y.length>0?ae(y,p=>p.press(f??"",w),!0):e.keyboard.press(f??"");case"selectOption":return ae(y,async p=>{await p.selectOption(f??"",w)},!0);case"check":return ae(y,p=>p.check(w),!0);case"uncheck":return ae(y,p=>p.uncheck(w),!0);case"hover":return ae(y,p=>p.hover(w),!0);case"waitFor":return y.length>0?ae(y,p=>p.waitFor({state:"visible",timeout:r})):pn(Math.min(Number(f??"0"),r));case"expectVisible":return c($(p=>p.isVisible()),r,"target to be visible");case"expectNotVisible":return c(async()=>{for(let p of y){let u=await p.count();for(let g=0;g<u;g++)if(await p.nth(g).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${y.length} locator(s))`);case"expectText":return c($(async p=>(await p.textContent(T)??"").includes(f??"")),r,`text "${f}"`);case"expectEach":{let p=t.each?.match(/^\{data:([^}]+)\}$/)?.[1];if(!p)throw new Error(`expectEach needs a {data:...} list reference, got ${JSON.stringify(t.each)}`);let u=Me(p),g=async A=>await A.isVisible()?new Set(await A.evaluate(P=>{let k=_=>(_??"").replace(/\s+/g," ").trim(),v=_=>typeof _.checkVisibility=="function"?_.checkVisibility({visibilityProperty:!0,contentVisibilityAuto:!0}):!!(_.offsetWidth||_.offsetHeight||_.getClientRects().length),M=_=>{let E=_.ownerDocument.createTreeWalker(_,4),B="";for(let x=E.nextNode();x;x=E.nextNode())x.parentElement&&v(x.parentElement)&&(B+=x.nodeValue??"");return B},R=new Set,C=_=>{for(let E of Array.from(_.options))E.hidden||R.add(k(E.textContent))};P.tagName==="SELECT"&&C(P);for(let _ of Array.from(P.querySelectorAll("*")))v(_)&&(_.tagName==="SELECT"?C(_):_.tagName!=="OPTION"&&R.add(k(M(_))));R.add(k(M(P)));for(let _ of String(P.innerText??"").split(`
15
+ `))R.add(k(_));return[...R]},void 0,T)):new Set,b=u;return c(async()=>{for(let A of y)try{let P=await g(A.first()),k=u.filter(v=>!P.has(v.replace(/\s+/g," ").trim()));if(k.length<b.length&&(b=k),k.length===0)return!0}catch{}return!1},r,`every value of ${t.each} to be shown`).catch(()=>{throw new Error(`${b.map(A=>`"${A}"`).join(", ")} from ${t.each} ${b.length===1?"is":"are"} not shown in the target (${u.length-b.length} of ${u.length} found)`)})}case"expectValue":return c($(async p=>await p.inputValue(T)===(f??"")),r,`input value "${f}"`);case"expectUrl":{let p=S??f??"";return c(async()=>e.url().includes(p),r,`URL containing "${p}" on the active page (${e.url()})`)}case"handleDialog":{let p=(f??"accept")!=="dismiss",u=t.promptText?l(t.promptText):void 0;e.once("dialog",g=>{p?g.accept(u):g.dismiss()});return}case"uploadFile":{let p=(f??"").split(`
16
+ `).filter(Boolean).map(u=>wr.isAbsolute(u)?u:wr.resolve(u));if(p.length===0)throw new Error("uploadFile requires a path");if(y.length>0)return ae(y,u=>u.setInputFiles(p,w),!0);e.once("filechooser",u=>{u.setFiles(p)});return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let p=$r(e,t.toTarget,n,!1,!1);return ae(y,u=>u.dragTo(p[0].first(),w),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return c($(async p=>{let u=ln(await p.textContent(T)??"");return u===""?!1:(o.set(t.saveAs,u),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let p=o.get(t.compareTo);if(p===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(y.length>0)return c($(async b=>ln(await b.textContent(T)??"")!==p),r,`target text to differ from "${t.compareTo}" (${p})`);let u=f??"",g=o.get(u);if(g===void 0)throw new Error(`expectDiffers: no captured value named "${u}"`);if(g===p)throw new Error(`expectDiffers: "${u}" (${g}) equals "${t.compareTo}" (${p})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let p=new RegExp(t.pattern,"i");return c($(async u=>p.test(await u.getAttribute(t.attribute,T)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let p=new RegExp(t.pattern,"i");return c($(async u=>p.test(ln(await u.textContent(T)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let p=Number.parseInt(f??"",10),u=()=>e.context().pages().filter(b=>!b.isClosed());await c(async()=>u().length>p,r,`tab ${f} to exist (${u().length} open: ${u().map(b=>b.url()).join(", ")})`);let g=u()[p];return await g.bringToFront(),g}case"closeTab":{let p=e.context().pages().filter(b=>!b.isClosed()),u=f===void 0?e:p[Number.parseInt(f,10)];if(!u)throw new Error(`tab ${f} does not exist (${p.length} open)`);await u.close();let g=e.context().pages().filter(b=>!b.isClosed());if(g.length===0)throw new Error("closed the last tab; nothing left to drive");return g[g.length-1]}case"newTab":{let p=await e.context().newPage();return S&&await p.goto(cn(S,s),w),p}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let p=new RegExp(l(t.urlPattern)),u=t.pattern?new RegExp(l(t.pattern)):void 0;if(!i){await e.waitForResponse(b=>vr({seq:0,url:b.url(),method:b.request().method(),status:b.status()},p,t.method,t.status,void 0),w);return}let g=()=>t.action==="expectResponse"?i.all():i.sincePreviousStep();return c(async()=>g().some(b=>vr(b,p,t.method,t.status,u)),r,Uo(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}var Pt=(e,t)=>Ze(e.actions,t),kr=(e,t)=>Be(e.actions,t);function un(e,t){if(t.steps.length!==e.steps.length)return!1;for(let[n,r]of Object.entries(t.data??{})){let s;try{s=Ne(n)}catch{continue}if(s===r)continue;let o=t.steps.some(a=>Pt(a,`{data:${n}}`)),i=t.steps.some(a=>kr(a,r));if(!o||i)return!1}return t.steps.every((n,r)=>{let s=e.steps[r];if(n.gherkin!==s.text||n.kind!==s.kind)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(W(o)),c=Object.keys(W(i));if(a.length>0&&c.length>0){if(JSON.stringify(a)!==JSON.stringify(c))return!1;let d=W(o),m=W(i);for(let l of a){if(d[l]===m[l])continue;let f=`<${l}>`,S=t.steps.some(y=>Pt(y,f)),h=t.steps.some(y=>kr(y,d[l]));if(!S||h)return!1}}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=t.steps.some(d=>Pt(d,"<docstring>")),c=t.steps.some(d=>Pt(d,n.docString));if(!a||c)return!1}return!0})}function gn(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...me(e.steps),...W(r),...Te(n?.resolvedDocString??n?.docString)}}async function rt(e,t,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=Math.max(10,r.pollIntervalMs??Rr),a=[],c=r.vars??new Map;r.uniqueSeed&&c.set(ne,r.uniqueSeed);let d=()=>Object.fromEntries(c),m=n.steps.some(u=>u.actions.some(g=>(g.action==="waitForResponse"||g.action==="expectResponse")&&g.pattern!==void 0)),l=new dn;l.attach(e.context(),m);let f=e,S=new Set(e.context().pages()),h=[],y=u=>h.push(u);e.context().on("page",y);let w=[],$=!n.steps.some(u=>u.actions.some(g=>g.opensTab))&&n.steps.some(u=>u.actions.some(g=>g.action==="switchTab"||g.action==="closeTab"||g.action==="newTab")),p=()=>{let u=e.context().pages();for(let g of u)S.has(g)||(S.add(g),g.isClosed()||(f=g));if(f.isClosed()){let g=u.filter(b=>!b.isClosed());g.length>0&&(f=g[g.length-1])}};if(!un(t,n))return l.detach(),{status:"stale",stepResults:a,captured:d()};try{for(let u=0;u<n.steps.length;u++){let g=n.steps[u],b=gn(t,u);l.markStep();let A=!1,P=0;if(g.kind==="assertion"&&!g.actions.some(k=>pe.has(k.action))){let k=`Assertion step "${g.gherkin}" recorded ${g.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"failed",drift:A,error:k});for(let v=u+1;v<n.steps.length;v++)a.push({gherkin:n.steps[v].gherkin,kind:n.steps[v].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:0,stepText:g.gherkin,kind:g.kind,error:k},stepResults:a,captured:d()}}for(let k=0;k<g.actions.length;k++){let v=g.actions[k];p(),v.pageFingerprint&&await Ce(f)!==v.pageFingerprint&&(A=!0);let M,R=!1;for(let C=0;C<=o&&!R;C++){C>0&&(P++,await pn(250*C),p());try{let _=await fn(f,v,b,s,r.baseURL,c,l,i);_&&(f=_,S.add(_));let E=v.action==="click"||v.action==="press";if(h.length===0&&(v.opensTab||E&&$)&&await e.context().waitForEvent("page",{timeout:v.opensTab?s:150}).catch(()=>{}),v.opensTab&&h.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for(h.length>0&&w.push({stepIndex:u,actionIndex:k});h.length>0;)await h.shift().waitForLoadState("domcontentloaded",{timeout:s}).catch(()=>{});R=!0}catch(_){M=_}}if(!R){let C=M instanceof Error?M.message:String(M);a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"failed",drift:A,...P?{retries:P}:{},error:C});for(let E=u+1;E<n.steps.length;E++)a.push({gherkin:n.steps[E].gherkin,kind:n.steps[E].kind,status:"skipped"});let _={stepIndex:u,actionIndex:k,stepText:g.gherkin,kind:g.kind,error:C,pageUrl:f.isClosed()?void 0:f.url()};return await r.onFailure?.(f,_).catch(()=>{}),{status:"failed",failure:_,stepResults:a,captured:d(),tabOpeners:w,activePage:f.isClosed()?void 0:f}}}a.push({gherkin:g.gherkin,fromStepSet:g.fromStepSet,kind:g.kind,status:"passed",drift:A,...P?{retries:P}:{}})}return{status:"passed",stepResults:a,captured:d(),tabOpeners:w,activePage:f.isClosed()?void 0:f}}finally{e.context().off("page",y),l.detach()}}import fe from"node:fs";import Ct from"node:path";function Tr(e,t){fe.mkdirSync(Ct.dirname(e),{recursive:!0}),fe.writeFileSync(e,`${JSON.stringify(t,null,2)}
17
+ `)}function _e(e){return JSON.parse(fe.readFileSync(e,"utf8"))}function ve(e){let t=Ct.join(e,".saffron","proposals");if(!fe.existsSync(t))return[];let n=[];for(let r of fe.readdirSync(t)){let s=Ct.join(t,r);if(fe.statSync(s).isDirectory())for(let o of fe.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=Ct.join(s,o);n.push({file:i,proposal:_e(i)})}}return n}function Ar(e,t){let n=_e(t),r=J(e,n.cache.feature,n.cache.scenario);return Le(r,n.cache),fe.rmSync(t),r}function Er(e){fe.rmSync(e)}import je from"node:fs";import Pr from"node:path";function mn(e){return Pr.join(e,".saffron","history.jsonl")}function yn(e,t,n,r){let s=e.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
18
+ `)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function Sn(e,t){let n=mn(e);je.mkdirSync(Pr.dirname(n),{recursive:!0}),je.appendFileSync(n,`${JSON.stringify(t)}
19
+ `)}function Cr(e,t){let n=mn(e);if(!je.existsSync(n))return Sn(e,t);let r=je.readFileSync(n,"utf8").split(`
20
+ `).filter(i=>i.trim()),s=!1,o=r.map(i=>{try{if(JSON.parse(i).runId===t.runId)return s=!0,JSON.stringify(t)}catch{}return i});s||o.push(JSON.stringify(t)),je.writeFileSync(n,`${o.join(`
21
+ `)}
22
+ `)}function st(e,t=50){let n=mn(e);if(!je.existsSync(n))return[];let r=je.readFileSync(n,"utf8").trim().split(`
23
+ `).filter(Boolean),s=[];for(let o of r.slice(-t))try{s.push(JSON.parse(o))}catch{}return s}var hn=e=>{let t=e.green+e.yellow+e.red;return t===0?0:Math.round((e.green+e.yellow)/t*100)};function bn(e,t){let n=[...e,t],r=n.slice(-20),s=n.slice(-10),o=e.at(-1),i=o?{passRatePp:hn(t)-hn(o),costUsd:t.costUsd-o.costUsd,aiCalls:t.aiCalls-o.aiCalls,red:t.red-o.red}:void 0,a=new Map;for(let l of s)for(let f of l.scenarios){let S=`${f.feature}::${f.scenario}`,h=a.get(S)??{feature:f.feature,scenario:f.scenario,heals:0,reds:0,runs:0};h.runs++,f.source==="agent-heal"&&f.status==="yellow"&&h.heals++,f.status==="red"&&h.reds++,a.set(S,h)}let c=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,f)=>f.heals+f.reds-(l.heals+l.reds)),d=new Map;for(let l of r)for(let f of l.scenarios)f.status==="red"&&f.error&&d.set(f.error,(d.get(f.error)??0)+1);let m=[...d.entries()].sort((l,f)=>f[1]-l[1]).slice(0,3).map(([l,f])=>({message:l,count:f}));return{prev:i,series:{passRate:r.map(hn),costUsd:r.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:r.map(l=>l.aiCalls)},chronic:c,divergentSteps:t.divergentSteps,topIssues:m}}var zo=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,qo=/^\s*StepSet:/;function ot(e,t){return ce.relative(e,t).split(ce.sep).join("/")}function Vo(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function _r(e,t){return`${e} :: ${t}`}function _t(e,t,n={}){let r=t.features??"features",s=ce.isAbsolute(r)?r:ce.join(e,r),o=ce.join(e,".saffron","reports","latest.json"),i=ce.join(e,".saffron","reports","latest.html"),a;try{a=JSON.parse(ee.readFileSync(o,"utf8"))}catch{a=void 0}let c=new Map;for(let g of a?.scenarios??[]){let b=g.scenario.replace(/ \(example \d+\)$/,""),A=_r(g.feature,b);c.set(A,Vo(c.get(A),g.status))}let d=new Map,m;try{d=se(be(s),e)}catch(g){m=g instanceof Error?g.message:String(g)}let l=ee.existsSync(s)?[...ee.readdirSync(s,{recursive:!0})].map(String).filter(de).map(g=>ce.join(s,g)).sort():[],f=[],S=new Map,h=new Map,y={parsedFiles:new Set,owned:new Set,unreadable:m?[m]:[]};for(let g of l){let b=ot(e,g),A="";try{A=ee.readFileSync(g,"utf8")}catch(E){f.push({path:b,name:b,scenarios:[],stepSets:0,error:String(E)}),y.unreadable.push(`${b}: ${String(E)}`);continue}let P=A.split(`
24
+ `),k=new Map,v=0;P.forEach((E,B)=>{let x=E.match(zo);x&&!k.has(x[1])?k.set(x[1],B+1):qo.test(E)&&v++});let M=P.find(E=>/^\s*Feature:/.test(E)),R=M?M.replace(/^\s*Feature:\s*/,"").trim():b,C;try{C=X(g,e,d)}catch(E){f.push({path:b,name:R,scenarios:[],stepSets:v,error:m??(E instanceof Error?E.message:String(E))}),y.unreadable.push(`${b}: ${E instanceof Error?E.message:String(E)}`);continue}y.parsedFiles.add(g),h.set(g,C);let _=new Map;for(let E of C){y.owned.add(Ee(e,E.featurePath,E.displayName));let B=_.get(E.name);if(B){B.rows+=1;continue}let x=J(e,E.featurePath,E.name);y.owned.add(x),y.owned.add(xe(e,E.featurePath,E.name));let j="missing";if(ee.existsSync(x))try{let F=K(x);j=F&&un(E,F)?"valid":"stale"}catch{j="invalid"}_.set(E.name,{name:E.name,line:k.get(E.name),tags:[...E.tags],outline:Object.keys(E.params).length>0,rows:1,cached:j!=="missing",cacheState:j,proposal:ee.existsSync(xe(e,E.featurePath,E.name)),lastStatus:c.get(_r(E.featurePath,E.name))});for(let F of E.tags)S.set(F,(S.get(F)??0)+1)}f.push({path:b,name:R,scenarios:[..._.values()],stepSets:v})}let w=ve(e).map(({file:g,proposal:b})=>({file:ot(e,g),feature:b.cache.feature,scenario:b.cache.scenario,mode:b.meta.mode,createdAt:b.meta.createdAt,verified:b.meta.verified,proofError:b.meta.proofError,adaptations:b.meta.adaptations??[],narrative:b.meta.narrative,suggestedFeatureEdit:b.meta.suggestedFeatureEdit,recordedFor:b.meta.recordedFor,aiCalls:b.meta.usage?.aiCalls??0,costUsd:b.meta.usage?.costUsd})).sort((g,b)=>g.file.localeCompare(b.file)),T;try{T=ie(e,r,d,h)}catch{T={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let $=Wo(e),p=st(e,n.historyLimit??30).map(g=>({startedAt:g.startedAt,green:g.green,yellow:g.yellow,red:g.red,aiCalls:g.aiCalls,costUsd:g.costUsd})),u=ce.join(e,"saffron.config.json");return{tool:"saffron",version:we(),projectRoot:e,packageInstalled:ee.existsSync(ce.join(e,"node_modules","saffron-ai")),config:{file:ee.existsSync(u)?"saffron.config.json":void 0,effective:dr(t)},features:f,tags:[...S.entries()].map(([g,b])=>({tag:g,scenarios:b})).sort((g,b)=>g.tag.localeCompare(b.tag)),proposals:w,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:a.totals,reportHtml:ee.existsSync(i)?ot(e,i):void 0,attention:a.scenarios.filter(g=>g.status!=="green").map(g=>({feature:g.feature,scenario:g.scenario,baseScenario:g.scenario.replace(/ \(example \d+\)$/,""),status:g.status,error:g.status==="red"?g.error?.split(`
25
+ `)[0]:void 0,failedStep:(g.steps??[]).find(b=>b.status==="failed")?.gherkin??g.evidence?.[0]?.step,evidence:(g.evidence??[]).filter(b=>ee.existsSync(ce.join(e,b.file)))})).sort((g,b)=>g.status===b.status?0:g.status==="red"?-1:1)}:void 0,history:p,vocabulary:{steps:T.steps.length,recorded:T.steps.filter(g=>g.status==="recorded").length,unrecorded:T.steps.filter(g=>g.status==="unrecorded").length,stepSets:T.stepSets.length,divergent:T.steps.filter(g=>g.status==="divergent").map(g=>g.text),duplicateWordings:$.slice(0,50)},data:(()=>{let g=Se(),b=[],A=new Set;for(let[P,k]of h)for(let v of k)for(let M of Qe(Z(v)))if(!A.has(M)){A.add(M);try{yt(M)}catch(R){b.push({ref:M,reason:R instanceof Error?R.message:String(R),feature:ot(e,P),scenario:v.name})}}return{dir:ot(e,g.dir),env:g.env,files:ee.existsSync(g.dir)?ee.readdirSync(g.dir).filter(P=>/\.(json|csv)$/.test(P)).sort():[],unresolved:b}})(),orphans:(()=>{let g=Et(e,r,y);return g.unreadable.length?[]:g.orphans})()}}function Fr(e){let t=e.features.reduce((o,i)=>o+i.scenarios.length,0),n=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="valid").length,0),r=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="stale"||a.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let o of e.features){let i=o.scenarios.map(a=>a.proposal?"\u25D0":a.cacheState==="valid"?"\u25CF":a.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${o.path} ${i}${o.error?` \u2717 ${o.error.split(`
26
+ `)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(o=>`${o.tag} (${o.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(o=>o.file).join(", ")}`:"no proposals pending review"),e.lastRun){let o=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${o.green} passed \xB7 ${o.yellow} pending review \xB7 ${o.red} failed \xB7 $${o.costUsd.toFixed(2)}`)}let s=e.vocabulary;console.log(`vocabulary: ${s.steps} steps (${s.recorded} recorded, ${s.unrecorded} unrecorded), ${s.stepSets} step sets, ${s.divergent.length} divergent, ${s.duplicateWordings.length} duplicate wording group(s)`),e.orphans.length&&console.log(`${e.orphans.length} recording(s) belong to no scenario: run \`saffron prune\` to see them`),(e.data.files.length||e.data.unresolved.length)&&console.log(`test data: ${e.data.files.length} file(s) in ${e.data.dir}/${e.data.env?` \xB7 environment "${e.data.env}"`:""}`+(e.data.unresolved.length?` \xB7 ${e.data.unresolved.length} reference(s) do not resolve: ${e.data.unresolved.map(o=>`{data:${o.ref}}`).join(", ")}`:"")),console.log(`config: ${e.config.file??"(defaults, no saffron.config.json)"}`)}var Ho=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function jr(e){return e?e.role?`${e.role}${e.name?` "${e.name}"`:e.nameRegex?` /${e.nameRegex}/`:""}`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testid ${e.testId}`:e.placeholder?`placeholder "${e.placeholder}"`:e.fallbackSelectors?.length?e.fallbackSelectors[0]:"":""}function Wo(e){let t;try{t=oe(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let s=$t(r.gherkin).pattern,o=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(l=>l[1]),i=l=>l!==void 0&&(o.includes(l)||Ho.test(l))?"\xABarg\xBB":l,a=r.actions.map(l=>{let{pageFingerprint:f,value:S,promptText:h,...y}=l;return{...y,value:i(S),promptText:i(h)}}),c=JSON.stringify(a),d=r.actions.map(l=>`${l.action}${jr(l.target)?` ${jr(l.target)}`:l.url?` ${l.url}`:""}`).join(" \xB7 "),m=n.get(c)??{steps:new Map,actions:d};m.steps.has(s)||m.steps.set(s,r.gherkin),n.set(c,m)}return[...n.values()].filter(r=>r.steps.size>1).map(r=>({steps:[...r.steps.values()].sort(),actions:r.actions})).sort((r,s)=>s.steps.length-r.steps.length||r.steps[0].localeCompare(s.steps[0]))}async function Ir(e,t){let n=new Jo({name:"saffron",version:we()},{capabilities:{tools:{}}});n.setRequestHandler(Yo,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate: exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}},{name:"project_status",description:"The project overview (same data as `saffron status --json`): every feature file with its scenarios (header line, tags, outline rows, cached / proposal pending / last run status), the tag list, pending proposals with narrative and proof-replay verdict, the last run's totals, run history, vocabulary health (divergent steps, duplicate wordings proven by identical recordings) and the effective config. Call it before editing tests to see what is already recorded and what awaits review.",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(Ko,async r=>Qo(r.params.name,r.params.arguments,e,t)),await n.connect(new Go)}function Qo(e,t,n,r){let s=o=>({content:[{type:"text",text:JSON.stringify(o,null,2)}]});if(e==="search_steps"){let o=ie(n,r),i=typeof t?.query=="string"?t.query:void 0,a=i?kt(o,i):o;return s({steps:a.steps,scannedFiles:a.scannedFiles,scannedCaches:a.scannedCaches})}if(e==="list_step_sets"){let o=ie(n,r);return s({stepSets:o.stepSets})}return e==="project_status"?s(_t(n,te(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as Zo}from"@anthropic-ai/claude-agent-sdk";function Xo(e,t){let n=t.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
24
27
  `),r=t.stepSets.map(s=>`- StepSet ${s.name}
25
28
  ${s.steps.map(o=>` ${o}`).join(`
26
29
  `)}`).join(`
27
30
  `);return["Draft a Saffron feature file from the requirements below.","","EXISTING STEP VOCABULARY \u2014 reuse these wordings EXACTLY (character for character) wherever they express what a step needs to do. Steps marked [recorded] or [divergent] already have replay recordings: reusing them makes the new file replay almost for free. Only invent a new wording when nothing in the vocabulary expresses the intent, and then follow the same style:",n.length>0?n:"(none yet \u2014 this is the project's first file)","",t.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
28
31
  ${r}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",e.trim()].filter(s=>s!==void 0).join(`
29
- `)}async function Ar(e,t,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Fo({prompt:Io(e,t),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
30
- `,usage:r}}import ue from"node:fs";import{fileURLToPath as Er}from"node:url";import fe from"node:path";import{createConnection as Oo,ProposedFeatures as Do,TextDocuments as No,TextDocumentSyncKind as Mo,CompletionItemKind as ve,InsertTextFormat as Pr,DiagnosticSeverity as tt,MarkupKind as _r}from"vscode-languageserver/node";import{TextDocument as Lo}from"vscode-languageserver-textdocument";var jr=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,Uo=/^\s*StepSet:\s*(.+?)\s*$/,gn=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,hn={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Bo(e,t){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),s=n(t);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function zo(e){try{return JSON.parse(ue.readFileSync(fe.join(e,"saffron.config.json"),"utf8"))}catch{return{}}}function Vo(e){try{let t=JSON.parse(ue.readFileSync(fe.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function Or(e,t,n={}){let r=e,s=t,o=Oo(Do.all,process.stdin,process.stdout),i=new No(Lo),a={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},c=new Map,d,h=()=>{try{let p=fe.isAbsolute(s)?s:fe.join(r,s),x=zo(r);Fe(r,x.dataDir,process.env.SAFFRON_ENV??x.env),c=re(Se(p),r),a=oe(r,s,c)}catch(p){o.console.warn(`saffron vocabulary rebuild: ${String(p)}`)}for(let p of i.all())m(p)},l=()=>{clearTimeout(d),d=setTimeout(h,300)};function f(p){return a.steps.find(x=>x.text===p)?.status}function m(p){let x=p.getText().split(`
31
- `),w=Wo(x);if(!p.uri.endsWith(".saffron")){o.sendDiagnostics({uri:p.uri,diagnostics:w});return}let R=new Map;for(let S=0;S<x.length;S++){let g=x[S],y={start:{line:S,character:0},end:{line:S,character:g.length}},u=g.match(gn);u&&!c.has(u[3])&&w.push({range:y,severity:tt.Error,source:"saffron",message:`StepSet "${u[3]}" is not defined anywhere in the project.`});let b=g.match(Uo);if(b){let $=c.get(b[1]),k=fe.relative(r,Er(p.uri)).replace(/\\/g,"/");(R.has(b[1])||$&&$.file!==k)&&w.push({range:y,severity:tt.Error,source:"saffron",message:`StepSet "${b[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${b[1]}".`}),R.set(b[1],S)}let P=g.trim();if(P&&!u&&!b){for(let[$]of c)if(P.toLowerCase()===$.toLowerCase()){w.push({range:y,severity:tt.Warning,source:"saffron",message:`Did you mean "StepSet ${$}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let A=g.match(jr);if(A&&f(A[3])!=="recorded"){for(let $ of a.steps)if(!($.text===A[3]||$.status==="unrecorded")&&Bo($.text,A[3])>=.7){w.push({range:{start:{line:S,character:g.indexOf(A[3])},end:{line:S,character:g.length}},severity:tt.Information,source:"saffron",message:`Similar to the ${$.status} step "${$.text}" (\xD7${$.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}o.sendDiagnostics({uri:p.uri,diagnostics:w})}o.onInitialize(p=>{if(n.rootFromClient){let x=p.workspaceFolders?.[0]?.uri??p.rootUri??void 0;if(x&&x.startsWith("file:")){let w=Er(x);ue.existsSync(w)&&(r=w,s=Vo(w)??t)}}return h(),{capabilities:{textDocumentSync:Mo.Incremental,completionProvider:{triggerCharacters:[" ",":","."]},definitionProvider:!0,hoverProvider:!0}}}),o.onCompletion(p=>{let x=i.get(p.textDocument.uri);if(!x)return[];let w=x.getText({start:{line:p.position.line,character:0},end:p.position}),R=Jo(w);return R||Go(x.uri,w,p.position.line,a)}),o.onDefinition(p=>{let x=i.get(p.textDocument.uri);if(!x)return;let w=x.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),R=Cr(w,p.position.character);if(R){let b=Ho(R.ref);return b?{uri:`file://${b.file}`,range:{start:{line:b.line,character:0},end:{line:b.line,character:0}}}:void 0}let S=Fr(w,p.position.character);if(S){let b=Ir(r,S.name);return b?{uri:`file://${b.file}`,range:{start:{line:b.line,character:0},end:{line:b.line,character:0}}}:void 0}let g=w.match(gn);if(!g)return;let y=c.get(g[3]);return y?{uri:`file://${fe.join(r,y.file)}`,range:{start:{line:y.line-1,character:0},end:{line:y.line-1,character:0}}}:void 0}),o.onHover(p=>{let x=i.get(p.textDocument.uri);if(!x)return;{let g=x.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),y=Cr(g,p.position.character);if(y)return{contents:{kind:"markdown",value:Dr(y.ref)}};let u=Fr(g,p.position.character);if(u){let b=Ir(r,u.name),P=b?`defined in ${fe.relative(r,b.file)} (line ${b.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${u.name}}** ${P}.`}}}}let w=x.getText({start:{line:p.position.line,character:0},end:{line:p.position.line+1,character:0}}).replace(/\n$/,""),R=w.match(gn);if(R){let g=a.stepSets.find(u=>u.name===R[3]);if(!g)return;let y=g.steps.map(u=>`- ${hn[f(u)??"unrecorded"][0]} ${u}`).join(`
32
- `);return{contents:{kind:_r.Markdown,value:`**StepSet ${g.name}** \u2014 expands to:
32
+ `)}async function Or(e,t,n){let r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},s="",o=Zo({prompt:Xo(e,t),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],allowedTools:[],systemPrompt:"You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content."}});for await(let i of o)if(i.type==="assistant")for(let a of i.message.content)a.type==="text"&&(s+=a.text);else i.type==="result"&&(r.aiCalls=i.num_turns,r.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(r.inputTokens=i.usage.input_tokens??0,r.outputTokens=i.usage.output_tokens??0,r.cacheReadTokens=i.usage.cache_read_input_tokens??0,r.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return s=s.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim(),{content:s+`
33
+ `,usage:r}}import ge from"node:fs";import{fileURLToPath as Dr}from"node:url";import ue from"node:path";import{createConnection as ei,ProposedFeatures as ti,TextDocuments as ni,TextDocumentSyncKind as ri,CompletionItemKind as $e,InsertTextFormat as Nr,DiagnosticSeverity as it,MarkupKind as Mr}from"vscode-languageserver/node";import{TextDocument as si}from"vscode-languageserver-textdocument";var Ur=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,oi=/^\s*StepSet:\s*(.+?)\s*$/,xn=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,wn={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function ii(e,t){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),s=n(t);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function ai(e){try{return JSON.parse(ge.readFileSync(ue.join(e,"saffron.config.json"),"utf8"))}catch{return{}}}function ci(e){try{let t=JSON.parse(ge.readFileSync(ue.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function qr(e,t,n={}){let r=e,s=t,o=ei(ti.all,process.stdin,process.stdout),i=new ni(si),a={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},c=new Map,d,m=()=>{try{let h=ue.isAbsolute(s)?s:ue.join(r,s),y=ai(r);De(r,y.dataDir,process.env.SAFFRON_ENV??y.env),c=se(be(h),r),a=ie(r,s,c)}catch(h){o.console.warn(`saffron vocabulary rebuild: ${String(h)}`)}for(let h of i.all())S(h)},l=()=>{clearTimeout(d),d=setTimeout(m,300)};function f(h){return a.steps.find(y=>y.text===h)?.status}function S(h){let y=h.getText().split(`
34
+ `),w=pi(y);if(!h.uri.endsWith(".saffron")){o.sendDiagnostics({uri:h.uri,diagnostics:w});return}let T=new Map;for(let $=0;$<y.length;$++){let p=y[$],u={start:{line:$,character:0},end:{line:$,character:p.length}},g=p.match(xn);g&&!c.has(g[3])&&w.push({range:u,severity:it.Error,source:"saffron",message:`StepSet "${g[3]}" is not defined anywhere in the project.`});let b=p.match(oi);if(b){let k=c.get(b[1]),v=ue.relative(r,Dr(h.uri)).replace(/\\/g,"/");(T.has(b[1])||k&&k.file!==v)&&w.push({range:u,severity:it.Error,source:"saffron",message:`StepSet "${b[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${b[1]}".`}),T.set(b[1],$)}let A=p.trim();if(A&&!g&&!b){for(let[k]of c)if(A.toLowerCase()===k.toLowerCase()){w.push({range:u,severity:it.Warning,source:"saffron",message:`Did you mean "StepSet ${k}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let P=p.match(Ur);if(P&&f(P[3])!=="recorded"){for(let k of a.steps)if(!(k.text===P[3]||k.status==="unrecorded")&&ii(k.text,P[3])>=.7){w.push({range:{start:{line:$,character:p.indexOf(P[3])},end:{line:$,character:p.length}},severity:it.Information,source:"saffron",message:`Similar to the ${k.status} step "${k.text}" (\xD7${k.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}o.sendDiagnostics({uri:h.uri,diagnostics:w})}o.onInitialize(h=>{if(n.rootFromClient){let y=h.workspaceFolders?.[0]?.uri??h.rootUri??void 0;if(y&&y.startsWith("file:")){let w=Dr(y);ge.existsSync(w)&&(r=w,s=ci(w)??t)}}return m(),{capabilities:{textDocumentSync:ri.Incremental,completionProvider:{triggerCharacters:[" ",":","."]},definitionProvider:!0,hoverProvider:!0}}}),o.onCompletion(h=>{let y=i.get(h.textDocument.uri);if(!y)return[];let w=y.getText({start:{line:h.position.line,character:0},end:h.position}),T=fi(w);return T||ui(y.uri,w,h.position.line,a)}),o.onDefinition(h=>{let y=i.get(h.textDocument.uri);if(!y)return;let w=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),T=Lr(w,h.position.character);if(T){let b=di(T.ref);return b?{uri:`file://${b.file}`,range:{start:{line:b.line,character:0},end:{line:b.line,character:0}}}:void 0}let $=Br(w,h.position.character);if($){let b=zr(r,$.name);return b?{uri:`file://${b.file}`,range:{start:{line:b.line,character:0},end:{line:b.line,character:0}}}:void 0}let p=w.match(xn);if(!p)return;let u=c.get(p[3]);return u?{uri:`file://${ue.join(r,u.file)}`,range:{start:{line:u.line-1,character:0},end:{line:u.line-1,character:0}}}:void 0}),o.onHover(h=>{let y=i.get(h.textDocument.uri);if(!y)return;{let p=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),u=Lr(p,h.position.character);if(u)return{contents:{kind:"markdown",value:Vr(u.ref)}};let g=Br(p,h.position.character);if(g){let b=zr(r,g.name),A=b?`defined in ${ue.relative(r,b.file)} (line ${b.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${g.name}}** ${A}.`}}}}let w=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),T=w.match(xn);if(T){let p=a.stepSets.find(g=>g.name===T[3]);if(!p)return;let u=p.steps.map(g=>`- ${wn[f(g)??"unrecorded"][0]} ${g}`).join(`
35
+ `);return{contents:{kind:Mr.Markdown,value:`**StepSet ${p.name}** \u2014 expands to:
33
36
 
34
- ${y}`}}}let S=w.match(jr);if(S){let g=a.steps.find(y=>y.text===S[3]);return g?{contents:{kind:_r.Markdown,value:`${hn[g.status]}
37
+ ${u}`}}}let $=w.match(Ur);if($){let p=a.steps.find(u=>u.text===$[3]);return p?{contents:{kind:Mr.Markdown,value:`${wn[p.status]}
35
38
 
36
- Used in ${g.usage} place(s): ${g.files.join(", ")}${g.fromStepSet?`
39
+ Used in ${p.usage} place(s): ${p.files.join(", ")}${p.fromStepSet?`
37
40
 
38
- Defined in StepSet **${g.fromStepSet}**`:""}`}}:void 0}}),i.onDidOpen(p=>m(p.document)),i.onDidChangeContent(p=>m(p.document)),i.onDidSave(()=>l()),o.onDidChangeWatchedFiles(()=>l()),i.listen(o),o.listen()}function Cr(e,t){let n=/\{data:([A-Za-z0-9_.-]+)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{ref:r[1],start:r.index,end:r.index+r[0].length}}var qo=e=>/^\s*Examples:/.test(e);function Dr(e,t=!1){let{env:n}=ye(),r=n?` (environment "${n}")`:"";try{if(t||!e.includes(".")){let{headers:i,rows:a}=ft(e);return`**{data:${e}}** ${a.length} Examples row(s), columns ${i.map(c=>`\`${c}\``).join(", ")}${r}.`}let s=Oe(e);return Array.isArray(Te(e))?`**{data:${e}}** a list of ${s.length}: ${s.map(i=>`"${i}"`).join(", ")}${r}. Use it with "every": replay checks each value, so adding one needs no re-record.`:`**{data:${e}}** = "${s[0]}"${r}. Resolved at replay: change the file and the next run uses the new value at zero tokens.`}catch(s){return`**{data:${e}}** does not resolve: ${s instanceof Error?s.message:String(s)}`}}function Ho(e){let{dir:t,env:n}=ye(),[r,...s]=e.split("."),o=[n&&`${r}.${n}.json`,`${r}.json`,n&&`${r}.${n}.csv`,`${r}.csv`].filter(Boolean),i=[...s].reverse().find(c=>!/^\d+$/.test(c)),a;for(let c of o){let d=fe.join(t,c);if(!ue.existsSync(d))continue;let h=ue.readFileSync(d,"utf8").split(`
39
- `),l=i?h.findIndex(f=>f.includes(`"${i}"`)):0;if(l>=0)return{file:d,line:l};a??={file:d,line:0}}return a}function Wo(e){let t=[];return e.forEach((n,r)=>{for(let s of n.matchAll(/\{data:([A-Za-z0-9_.-]+)\}/g)){let o=Dr(s[1],qo(n));o.includes("does not resolve")&&t.push({range:{start:{line:r,character:s.index??0},end:{line:r,character:(s.index??0)+s[0].length}},severity:tt.Error,source:"saffron",message:o.replace(/\*\*/g,"")})}}),t}function Jo(e){let t=e.match(/\{data:([A-Za-z0-9_.-]*)$/);if(!t)return;let n=t[1],{dir:r}=ye(),s=n.split(".");if(s.length===1)return(ue.existsSync(r)?[...new Set(ue.readdirSync(r).filter(i=>/\.(json|csv)$/.test(i)).map(i=>i.split(".")[0]))].sort():[]).map(i=>({label:i,kind:ve.File,detail:"data file",insertText:i}));try{let o=Te(s.slice(0,-1).join("."));return o===null||typeof o!="object"?[]:Object.entries(o).map(([i,a])=>({label:i,kind:a!==null&&typeof a=="object"?ve.Module:ve.Value,detail:Array.isArray(a)?`list of ${a.length}`:a!==null&&typeof a=="object"?"object":JSON.stringify(a),insertText:i}))}catch{return[]}}function Fr(e,t){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function Ir(e,t){for(let n of[".env",".env.example"]){let r=fe.join(e,n);if(!ue.existsSync(r))continue;let o=ue.readFileSync(r,"utf8").split(`
40
- `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(o!==-1)return{file:r,line:o}}}function Go(e,t,n,r){let s=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&s)return r.stepSets.map(a=>({label:a.name,kind:ve.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
41
- `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:ve.Text,detail:`${hn[a.status]} \xB7 \xD7${a.usage}`,sortText:String(c).padStart(5,"0"),filterText:a.text}));let i=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&s){let a={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:ve.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
41
+ Defined in StepSet **${p.fromStepSet}**`:""}`}}:void 0}}),i.onDidOpen(h=>S(h.document)),i.onDidChangeContent(h=>S(h.document)),i.onDidSave(()=>l()),o.onDidChangeWatchedFiles(()=>l()),i.listen(o),o.listen()}function Lr(e,t){let n=/\{data:([A-Za-z0-9_.-]+)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{ref:r[1],start:r.index,end:r.index+r[0].length}}var li=e=>/^\s*Examples:/.test(e);function Vr(e,t=!1){let{env:n}=Se(),r=n?` (environment "${n}")`:"";try{if(t||!e.includes(".")){let{headers:i,rows:a}=St(e);return`**{data:${e}}** ${a.length} Examples row(s), columns ${i.map(c=>`\`${c}\``).join(", ")}${r}.`}let s=Me(e);return Array.isArray(Ae(e))?`**{data:${e}}** a list of ${s.length}: ${s.map(i=>`"${i}"`).join(", ")}${r}. Use it with "every": replay checks each value, so adding one needs no re-record.`:`**{data:${e}}** = "${s[0]}"${r}. Resolved at replay: change the file and the next run uses the new value at zero tokens.`}catch(s){return`**{data:${e}}** does not resolve: ${s instanceof Error?s.message:String(s)}`}}function di(e){let{dir:t,env:n}=Se(),[r,...s]=e.split("."),o=[n&&`${r}.${n}.json`,`${r}.json`,n&&`${r}.${n}.csv`,`${r}.csv`].filter(Boolean),i=[...s].reverse().find(c=>!/^\d+$/.test(c)),a;for(let c of o){let d=ue.join(t,c);if(!ge.existsSync(d))continue;let m=ge.readFileSync(d,"utf8").split(`
42
+ `),l=i?m.findIndex(f=>f.includes(`"${i}"`)):0;if(l>=0)return{file:d,line:l};a??={file:d,line:0}}return a}function pi(e){let t=[];return e.forEach((n,r)=>{for(let s of n.matchAll(/\{data:([A-Za-z0-9_.-]+)\}/g)){let o=Vr(s[1],li(n));o.includes("does not resolve")&&t.push({range:{start:{line:r,character:s.index??0},end:{line:r,character:(s.index??0)+s[0].length}},severity:it.Error,source:"saffron",message:o.replace(/\*\*/g,"")})}}),t}function fi(e){let t=e.match(/\{data:([A-Za-z0-9_.-]*)$/);if(!t)return;let n=t[1],{dir:r}=Se(),s=n.split(".");if(s.length===1)return(ge.existsSync(r)?[...new Set(ge.readdirSync(r).filter(i=>/\.(json|csv)$/.test(i)).map(i=>i.split(".")[0]))].sort():[]).map(i=>({label:i,kind:$e.File,detail:"data file",insertText:i}));try{let o=Ae(s.slice(0,-1).join("."));return o===null||typeof o!="object"?[]:Object.entries(o).map(([i,a])=>({label:i,kind:a!==null&&typeof a=="object"?$e.Module:$e.Value,detail:Array.isArray(a)?`list of ${a.length}`:a!==null&&typeof a=="object"?"object":JSON.stringify(a),insertText:i}))}catch{return[]}}function Br(e,t){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function zr(e,t){for(let n of[".env",".env.example"]){let r=ue.join(e,n);if(!ge.existsSync(r))continue;let o=ge.readFileSync(r,"utf8").split(`
43
+ `).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(o!==-1)return{file:r,line:o}}}function ui(e,t,n,r){let s=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&s)return r.stepSets.map(a=>({label:a.name,kind:$e.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
44
+ `)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:$e.Text,detail:`${wn[a.status]} \xB7 \xD7${a.usage}`,sortText:String(c).padStart(5,"0"),filterText:a.text}));let i=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&s){let a={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:$e.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
42
45
  Given \u2026
43
46
  When \u2026
44
47
 
45
- Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:Pr.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:ve.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:Pr.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let d of r.stepSets)c.push({label:`StepSet ${d.name}`,kind:ve.Function,detail:`invoke step set \xB7 ${d.steps.length} step(s)`,documentation:d.steps.join(`
46
- `),filterText:`step set ${d.name}`,sortText:`1${d.name}`,textEdit:{range:a,newText:`StepSet ${d.name}`}});return c}return[]}import ze from"node:fs";import ii from"node:net";import ke from"node:path";import{chromium as ai,firefox as ci,webkit as li}from"playwright";var Ko=5e3,Yo=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]),Pt=class{pendingTabOpen=!1;pendingTabFallback;lastOpener;lastOpenerAt=0;inFlight=new Set;steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(t){this.steps=t.steps.map(n=>({gherkin:n.text,keyword:n.keyword,kind:n.kind,table:n.table,docString:n.docString,fromStepSet:n.source?.fromStepSet,actions:[]}))}addArmedAction(t){if(this.done)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r||r.actions.some(i=>i.action===t.action&&i.value===t.value&&i.promptText===t.promptText))return;let o=Math.max(r.actions.length-1,0);r.actions.splice(o,0,t)}beginToolCall(t){this.inFlight.add(t)}endToolCall(t,n){this.inFlight.delete(t)&&(this.pendingTabOpen&&n&&(this.pendingTabFallback=void 0),this.inFlight.size===0&&this.pendingTabOpen&&(this.pendingTabFallback&&(this.pendingTabFallback.opensTab=!0),this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}markTabOpened(){let t=this.inFlight.size===0,n=Date.now()-this.lastOpenerAt<=Ko;if(t){this.lastOpener&&n&&(this.lastOpener.opensTab=!0);return}this.pendingTabOpen=!0,this.pendingTabFallback=n?this.lastOpener:void 0}addActions(t){if(this.done||t.length===0)return;let n=Math.max(this.currentStepIndex,0),r=this.steps[n];if(!r)return;this.pendingFingerprint&&t[0]&&(t[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),r.actions.push(...t);let s=[...t].reverse().find(o=>Yo.has(o.action));s&&(this.lastOpener=s,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(s.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},_e=e=>e.replace(/\\(['"\\])/g,"$1");function Qo(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?_e(t[1]):void 0}function mn(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=_e(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=e.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){t.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(t.name=_e(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let c=e.match(i);c&&(t[a]=_e(c[1]))}let o=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(t.fallbackSelectors=[_e(o[1])]),Object.keys(t).length>0?t:void 0}function Zo(e,t){if(t&&e.startsWith(t)){let n=e.slice(t.length);return n.startsWith("/")?n:`/${n}`}return e}var Xo={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function ei(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Zo(_e(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:_e(r[1])};let s=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let f=mn(s[1]),m=mn(s[2]);if(f&&m)return{action:"dragTo",target:f,toTarget:m}}let o=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,c]=o,d=mn(i);if(!d)return;let h={action:Xo[a],target:d},l=Qo(c);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(h.value=l),h}function Nr(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let s=[];for(let o of n)for(let i of o.split(`
47
- `)){let a=ei(i.trim(),t);a&&s.push(a)}return ti(s)}function ti(e){return e.filter((t,n)=>{let r=e[n+1];return!(t.action==="fill"&&(t.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(t.target))})}function Mr(e){let t=[];for(let n of e){if(n.kind==="assertion"&&n.actions.length===0){t.push(`Step "${n.gherkin}" is an assertion but recorded no check, so nothing would be verified. Re-record with: saffron run --rerecord`);continue}if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let s of n.actions)if(s.action==="fill"&&(s.value??"")===""){t.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}}return t}function yn(e,t){let n=e;for(let[r,s]of Object.entries(t).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))n=n.split(s).join(`<${r}>`);return n}function Lr(e,t){let n=Object.entries(t).filter(([,i])=>i.length>=3);if(n.length===0)return e;let r=i=>i===void 0?void 0:yn(i,t),s=[...n].sort((i,a)=>a[1].length-i[1].length),o=i=>{if(i!==void 0){for(let[a,c]of s)if(i.includes(c))return`<${a}>`;return i}};return e.map(i=>({...i,actions:i.actions.map(a=>{let c=le.has(a.action)&&a.action!=="expectValue";return{...a,value:c?o(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:c?o(a.target.name):r(a.target.name),label:r(a.target.label),text:c?o(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function Ur(e,t,n=r=>r){let r=he(t.steps);return e.map((s,o)=>{let i=t.steps[o],a=i?.resolvedTable??i?.table,c=i?.resolvedDocString??i?.docString,d=Object.entries({...r,...q(a),...Re(c)}).map(([l,f])=>[l,n(f)]).filter(([,l])=>l.length>=3).sort((l,f)=>f[1].length-l[1].length);if(d.length===0)return s;let h=l=>{if(l===void 0)return;let f=l;for(let[m,p]of d)f=f.split(p).join(`<${m}>`);return f};return{...s,actions:s.actions.map(l=>({...l,value:h(l.value),url:h(l.url),target:l.target?{...l.target,name:h(l.target.name),label:h(l.target.label),text:h(l.target.text),placeholder:h(l.target.placeholder)}:void 0}))}})}function Br(e,t=new Date){let n=r=>r===void 0?void 0:lr(r,t);return e.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text)}:void 0}))}))}function zr(e,t){if(t.size===0)return e;let n=r=>r===void 0?void 0:Le(r,t);return e.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function Vr(e,t){if(t.size===0)return e;let n=r=>typeof r=="string"?fr(r,t):Array.isArray(r)?r.map(n):r&&typeof r=="object"?Object.fromEntries(Object.entries(r).map(([s,o])=>[s,s==="pageFingerprint"?o:n(o)])):r;return e.map(r=>({...r,actions:r.actions.map(s=>n(s))}))}function qr(e,t){if(t.size===0)return e;let n=r=>r===void 0?void 0:Un(r,t);return e.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function Hr(e,t){if(e!=="mcp__playwright__browser_tabs")return;let n=t??{},r=typeof n.index=="number"?String(n.index):void 0;switch(n.action){case"select":return r===void 0?void 0:{action:"switchTab",value:r};case"close":return r===void 0?{action:"closeTab"}:{action:"closeTab",value:r};case"new":return typeof n.url=="string"&&n.url?{action:"newTab",url:n.url}:{action:"newTab"};default:return}}function Wr(e,t){let n=t??{};if(e==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(e==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
48
- `)}}}function rt(e){return Array.isArray(e)?e.map(rt):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).filter(([t,n])=>n!==void 0&&t!=="pageFingerprint").sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>[t,rt(n)])):e}var ni=e=>JSON.stringify(rt(e));function Sn(e,t="",n={}){if(Array.isArray(e))n[t]=e.map(r=>String(r)).join(", ");else if(e&&typeof e=="object")for(let[r,s]of Object.entries(e))Sn(s,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function ri(e,t){let n=Sn(rt(e)),r=Sn(rt(t)),s=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),o=[];for(let i of s){if(i==="action"||n[i]===r[i])continue;let a=i.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[i]===void 0?o.push(`${a}: added "${r[i]}"`):r[i]===void 0?o.push(`${a}: removed "${n[i]}"`):o.push(`${a}: "${n[i]}" \u2192 "${r[i]}"`)}return o}function Kr(e,t,n){let r=e.map(n),s=t.map(n),o=Array.from({length:r.length+1},()=>new Array(s.length+1).fill(0));for(let d=r.length-1;d>=0;d--)for(let h=s.length-1;h>=0;h--)o[d][h]=r[d]===s[h]?o[d+1][h+1]+1:Math.max(o[d+1][h],o[d][h+1]);let i=[],a=0,c=0;for(;a<r.length&&c<s.length;)r[a]===s[c]?(i.push({before:e[a],after:t[c]}),a++,c++):o[a+1][c]>=o[a][c+1]?(i.push({before:e[a]}),a++):(i.push({after:t[c]}),c++);for(;a<r.length;a++)i.push({before:e[a]});for(;c<s.length;c++)i.push({after:t[c]});return i}function Jr(e){if(!e)return"";let t=[];if(e.role){let n=e.name?` "${e.name}"`:e.nameRegex?` matching /${e.nameRegex}/`:"";t.push(`${e.role}${n}`)}else e.label?t.push(`label "${e.label}"`):e.text?t.push(`text "${e.text}"`):e.placeholder?t.push(`placeholder "${e.placeholder}"`):e.testId&&t.push(`test id ${e.testId}`);return e.fallbackSelectors?.length&&t.push(`[${e.fallbackSelectors.join(", ")}]`),e.frame&&t.push(`inside frame ${e.frame}`),t.join(" ")}var Gr=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function Be(e){let t=Jr(e.target),n=e.value??"",r=(()=>{switch(e.action){case"goto":return`go to ${e.url}`;case"click":return`click ${t}`;case"fill":return`fill ${t} with "${n}"`;case"press":return t?`press ${n} on ${t}`:`press ${n}`;case"selectOption":return`select "${n}" in ${t}`;case"check":return`check ${t}`;case"uncheck":return`uncheck ${t}`;case"hover":return`hover ${t}`;case"waitFor":return t?`wait for ${t}`:`wait ${n}ms`;case"captureText":return`remember ${t} as "${e.saveAs}"`;case"handleDialog":return`${n} the dialog${e.promptText?` with "${e.promptText}"`:""}`;case"uploadFile":return`upload ${n}`;case"dragTo":return`drag ${t} onto ${Jr(e.toTarget)}`;case"waitForResponse":return`wait for ${Gr(e)}`;case"switchTab":return`switch to tab ${n}`;case"closeTab":return`close tab ${n||"(the current one)"}`;case"newTab":return`open a new tab${e.url?` at ${e.url}`:""}`;case"expectVisible":return`expect ${t} to be visible`;case"expectNotVisible":return`expect ${t} to be gone`;case"expectText":return`expect ${t} to contain "${n}"`;case"expectUrl":return`expect the URL to contain "${e.url??n}"`;case"expectValue":return`expect ${t} to hold "${n}"`;case"expectDiffers":return`expect ${t||`"${n}"`} to differ from "${e.compareTo}"`;case"expectMatches":return`expect ${t} to match /${e.pattern}/`;case"expectAttribute":return`expect ${t} ${e.attribute} to match /${e.pattern}/`;case"expectResponse":return`expect a response ${Gr(e)}`;case"expectEach":return`expect ${t} to show every value of ${e.each}`;default:return e.action}})();return e.opensTab?`${r}, opening a tab`:r}var nt=e=>le.has(e.action);function si(e,t){let n=Kr(e,t,ni),r=[];for(let s=0;s<n.length;s++){let o=n[s];if(o.before&&o.after){r.push({change:"same",before:Be(o.before),after:Be(o.after),assertion:nt(o.after)});continue}let i=n[s+1];if(o.before&&i?.after&&!i.before){r.push({change:"changed",before:Be(o.before),after:Be(i.after),details:ri(o.before,i.after),assertion:nt(o.before)||nt(i.after)}),s++;continue}o.before&&r.push({change:"removed",before:Be(o.before),assertion:nt(o.before)}),o.after&&r.push({change:"added",after:Be(o.after),assertion:nt(o.after)})}return r}var oi=e=>`${e.kind}\0${e.gherkin}`;function _t(e,t){let n=Kr(e?.steps??[],t.steps,oi),r=[],s={added:0,removed:0,changed:0,assertions:0};for(let o of n){let i=o.after??o.before,a=si(o.before?.actions??[],o.after?.actions??[]);for(let d of a)d.change==="added"?s.added++:d.change==="removed"?s.removed++:d.change==="changed"&&s.changed++,d.change!=="same"&&d.assertion&&s.assertions++;let c=e&&o.before?o.after?a.some(d=>d.change!=="same")?"changed":"same":"removed":"added";r.push({gherkin:i.gherkin,kind:i.kind,fromStepSet:i.fromStepSet,change:c,actions:a})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:r,summary:s}}function Yr(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let i of e.steps){let a=i.change==="removed"?"- ":i.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${a}${i.kind==="assertion"?"[assertion] ":""}${i.gherkin}`+(i.fromStepSet?` (from step set "${i.fromStepSet}")`:""));for(let c of i.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let d of c.details??[])t.push(` ${d}`)}t.push("")}let{added:n,removed:r,changed:s,assertions:o}=e.summary;return t.push(n+r+s===0?"No action changed.":`${s} changed, ${n} added, ${r} removed.`+(o>0?` ${o} of them ${o===1?"is an assertion":"are assertions"}: read those first.`:"")),t.join(`
49
- `)}var xn=["chromium","chrome","msedge","firefox","webkit"],bn={chrome:{label:"Google Chrome"},msedge:{label:"Microsoft Edge"}};function ts(e){return xn.includes(e)}var st=class extends Error{},Ve={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function di(){return new Promise((e,t)=>{let n=ii.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function Qr(e){let t=ut(X(e));return t.size>0?{data:Object.fromEntries([...t].sort(([n],[r])=>n.localeCompare(r)))}:{}}function qe(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function ns(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function Zr(e,t){return e[t]?.kind==="assertion"&&t>=ns(e)}function Xr(e,t,n=!1,r){let s=ns(e);return e.map((o,i)=>{let a=t[i],c=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&c?a:o:c?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function pi(e,t){return e.map((n,r)=>{let s=t[r];return s&&s.actions.length>0?s:n})}function es(e,t){return{aiCalls:e.aiCalls+t.aiCalls,inputTokens:e.inputTokens+t.inputTokens,outputTokens:e.outputTokens+t.outputTokens,cacheReadTokens:e.cacheReadTokens+t.cacheReadTokens,cacheCreationTokens:e.cacheCreationTokens+t.cacheCreationTokens,costUsd:(e.costUsd??0)+(t.costUsd??0),models:e.models||t.models?[...new Set([...e.models??[],...t.models??[]])]:void 0}}var jt=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=se(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${t}`)}return this.stepIndex}async recordWithSeeding(t,n){let r=this.getStepIndex(),s=t.steps.length,o={...t.params,...he(t.steps)},i=t.steps.map(S=>{let g=r.steps.size>0?Kn(r,S,o):void 0;return g?{gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:g.actions}:void 0}),a=t.steps.map(S=>({gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:[]})),c=new Map;c.set(de,Qe());let d=[],h=[],l=[],f=[],m={...Ve},p=0,x=0,w=0,R=S=>({success:S,narrative:h.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:f.length>0?f.join(`
50
- `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:m,seededCount:p});for(;w<s;){if(i[w]){let b=w;for(;b<s&&i[b];)b++;let P=i.slice(w,b),A={...t,steps:t.steps.slice(w,b)},$=await Ze(n,A,this.buildCandidateCache(t,P),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,vars:c}),k=$.status==="passed"?b-w:$.failure?.stepIndex??0;for(let C=0;C<k;C++)a[w+C]=P[C];if(p+=k,$.status==="passed"){w=b;continue}let N=w+k;d.push(`Seeded recording for "${t.steps[N].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[N]=void 0,w=N;continue}if(!this.options.agent)return R(!1);x++;let S=w;for(;S<s&&!i[S];)S++;x>=3&&(S=s);let g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:w>0?t.steps.slice(0,w).map(b=>b.resolvedText):void 0,recordOnly:S<s?{from:w,to:S-1}:void 0,uniqueSeed:c.get(de),onTabOpened:this.tabWatcher(),takeFingerprint:async()=>Ee(qe(this.browser)??n)}));if(m=es(m,g.usage),h.push(g.narrative),d.push(...g.adaptations),l.push(...g.featureEdits),g.suggestedFeatureEdit&&f.push(g.suggestedFeatureEdit),!g.success)return R(!1);let y=g.announcedSteps.filter(b=>b>=w&&b<s),u=Math.min(s-1,Math.max(y.length>0?Math.max(...y):S-1,w));for(let b=w;b<=u;b++)a[b]=g.recordedSteps[b];w=u+1}return R(!0)}async withDialogsVisible(t){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await t()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(t==="chromium"||bn[t]){let n=await di();try{this.browser=await ai.launch({headless:!this.options.headed,channel:bn[t]?t:void 0,args:[`--remote-debugging-port=${n}`]})}catch(r){let s=r instanceof Error?r.message:String(r),o=bn[t];throw o&&/is not found at|distribution .* is not found/i.test(s)?new st(`${o.label} is not installed on this machine, so --browser ${t} cannot run.
51
- Install it (npx playwright install ${t} does that), or use --browser chromium, which Saffron ships with.`):r}this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(t==="firefox"?ci:li).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let t=ke.isAbsolute(this.options.storageState)?this.options.storageState:ke.join(this.options.projectRoot,this.options.storageState);if(!ze.existsSync(t))throw new Error(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return t}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n,...Qr(t)}}tabWatcher(){return t=>{let n=this.browser?.contexts()??[],r=()=>t();for(let s of n)s.on("page",r);return()=>{for(let s of n)s.off("page",r)}}}async proofReplay(t,n){let r=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await r.newPage();return{result:await Ze(s,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs}),context:r,page:s}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),s=Ye(t);if(s.size>0){let o=i=>i===void 0?i:Le(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>Le(i,s));for(let i of r.stepResults)i.error=o(i.error)}return r}async screenshot(t,n,r,s){let o=this.options.screenshot??"viewport";if(o!=="off")try{let i=Ae(this.options.projectRoot,n.featurePath,n.displayName);ze.mkdirSync(i,{recursive:!0}),ze.writeFileSync(ke.join(i,mt),JSON.stringify({feature:n.featurePath,scenario:n.displayName},null,2));let a=ke.join(i,`${r}.jpg`);return await t.screenshot({path:a,type:"jpeg",quality:80,fullPage:o==="full-page",timeout:5e3}),{kind:r,file:ke.relative(this.options.projectRoot,a).split(ke.sep).join("/"),step:s}}catch{return}}async runScenarioInner(t,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c}=this.options,d=H(s,t.featurePath,t.name),h=J(d),l=be(s,t.featurePath,t.name),f=!h&&ze.existsSync(l)?Pe(l):void 0,m=await this.browser.newContext({storageState:this.resolveStorageState()}),p=await m.newPage(),x=Qe(),w=[];ze.rmSync(Ae(s,t.featurePath,t.displayName),{recursive:!0,force:!0});let R=(S,g)=>this.screenshot(S,t,"before-heal",g.stepText).then(y=>{y&&w.push(y)});try{let S=await(async()=>{let y;if(f){let $=await Ze(p,t,f.cache,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c}),k=f.meta.recordedFor??t.name;if($.status==="passed")return{scenario:t,status:"yellow",source:"agent-record",stepResults:$.stepResults,narrative:`Replayed the pending proposal recorded for "${k}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:l,usage:Ve,durationMs:Date.now()-r};if(Object.keys(t.params).length>0&&k!==t.displayName){let C=$.failure;return{scenario:t,status:"red",source:"agent-record",stepResults:$.stepResults,adaptations:[],proposalFile:l,usage:Ve,durationMs:Date.now()-r,error:`The pending proposal recorded for "${k}" does not replay for this Examples row`+(C?` (step "${C.stepText}": ${C.error.split(`
52
- `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+($.failure?` (step "${$.failure.stepText}": ${$.failure.error.split(`
53
- `)[0]})`:"")+"; recording again"),await p.goto("about:blank")}if(h&&(y=await Ze(p,t,h,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c,uniqueSeed:x,onFailure:R}),y.status==="passed"))return{scenario:t,status:"green",source:"cache",stepResults:y.stepResults,adaptations:[],usage:Ve,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let $=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing needs a Chromium-family browser (the agent attaches over CDP). Record with --browser chromium (the default), chrome or msedge, then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:t,status:"red",source:"cache",stepResults:y?.stepResults??[],adaptations:[],usage:Ve,durationMs:Date.now()-r,error:h?y?.status==="stale"?`Cache is stale (feature file changed) and ${$}`:`Replay failed and ${$} ${y?.failure?.error??""}`.trimEnd():`No cache exists and ${$}`}}let b=h!==void 0&&y!==void 0&&y.status==="failed",P=0,A;try{if(b)A=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:y.stepResults.filter($=>$.status==="passed").map($=>$.gherkin),failedStep:{text:y.failure.stepText,kind:y.failure.kind,error:y.failure.error},uniqueSeed:x,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let $=qe(this.browser)??p;return Ee($)}}));else if(await p.goto("about:blank"),this.options.reuseSteps!==!1){let $=await this.recordWithSeeding(t,p);P=$.seededCount,A=$}else A=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,uniqueSeed:x,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let $=qe(this.browser)??p;return Ee($)}}))}catch($){let k=$ instanceof Error?$.message:String($);return{scenario:t,status:"red",source:b?"agent-heal":"agent-record",stepResults:y?.stepResults??[],adaptations:[],usage:Ve,durationMs:Date.now()-r,error:`Agent invocation failed: ${k.split(`
54
- `)[0]}`}}if(A.success&&b&&y.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Zr(h.steps,y.failure.stepIndex))){let $=h.steps[y.failure.stepIndex],k=qe(this.browser)??p,N=new Map(Object.entries(y.captured)),C=ln(t,y.failure.stepIndex);try{for(let M of $.actions)await an(k,M,C,this.options.actionTimeoutMs??5e3,o,N)}catch(M){let E=M instanceof Error?M.message:String(M);return{scenario:t,status:"red",source:"agent-heal",stepResults:y.stepResults,narrative:A.narrative,adaptations:A.adaptations,suggestedFeatureEdit:A.suggestedFeatureEdit,usage:A.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${$.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${E.split(`
55
- `)[0]}`}}}return await this.finalizeAgentRun(t,A,b,h,r,P,b?y?.failure:void 0)})();if(S.status==="red"){let y=w.find(u=>u.kind==="before-heal");if(S.source==="cache"&&y){let u=y.file.replace(/before-heal\.jpg$/,"failure.jpg");try{ze.renameSync(ke.join(s,y.file),ke.join(s,u)),y.file=u}catch{}y.kind="failure"}else{let u=S.stepResults.find(P=>P.status==="failed")?.gherkin,b=await this.screenshot(qe(this.browser)??p,t,"failure",u);b&&w.push(b)}}let g=$t(X(t),x);return g.size>0&&(S.uniques=Object.fromEntries(g)),w.length>0&&S.status!=="green"&&(S.evidence=w),S}finally{await m.close()}}async finalizeAgentRun(t,n,r,s,o,i=0,a){let c=r?"agent-heal":"agent-record",d=n.recordedSteps.map(k=>({gherkin:k.gherkin,kind:k.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:t,status:"red",source:c,stepResults:d,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let h=r?Xr(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,l=[...n.adaptations],f={...n.usage},m,p,x;if(this.options.verifyProposals!==!1){let k=await this.proofReplay(t,this.buildCandidateCache(t,h));try{if(k.result.status!=="passed"&&k.result.failure){let N=k.result.failure;if(this.options.agent&&!(r&&N.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Zr(s.steps,N.stepIndex))))try{let M=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:k.result.stepResults.filter(E=>E.status==="passed").map(E=>E.gherkin),failedStep:{text:N.stepText,kind:N.kind,error:N.error},uniqueSeed:k.result.captured[de],onTabOpened:this.tabWatcher(),takeFingerprint:async()=>Ee(qe(this.browser)??k.page)}));f=es(f,M.usage),M.success&&(l.push(...M.adaptations),h=r?Xr(h,M.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):pi(h,M.recordedSteps),await k.context.close(),k=await this.proofReplay(t,this.buildCandidateCache(t,h)))}catch{}}for(let N of k.result.tabOpeners??[]){let C=h[N.stepIndex]?.actions[N.actionIndex];C&&(C.opensTab=!0)}m=k.result.status==="passed",m||(p=k.result.failure?.error??`proof replay ${k.result.status}`,x=k.result.stepResults)}finally{await k.context.close().catch(()=>{})}}let w=[...n.featureEdits];for(let k of Yn(h,se(this.options.projectRoot)))l.push(`duplicate wording: "${k.from}" recorded exactly the same actions as the existing step "${k.to}" (${k.feature} \u203A ${k.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),w.some(N=>N.index===k.index)||w.push({index:k.index,text:k.to});let R=Mr(h);R.length>0&&(m=!1,p=[p,...R].filter(Boolean).join(" "));let S=Ye(t),g=k=>S.size>0?Le(k,S):k;l=l.map(g);let y=w.map(k=>({index:k.index,text:g(k.text)})),u=g(n.narrative),b=n.suggestedFeatureEdit?g(n.suggestedFeatureEdit):void 0,P={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:u,adaptations:l,suggestedFeatureEdit:b,featureEdits:y.length>0?y:void 0,verified:m,proofError:p?g(p):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:f},cache:{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:h,...Qr(t)}},A=be(this.options.projectRoot,t.featurePath,t.name);Sr(A,P);let $=J(H(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:_t($,P.cache),scenario:t,status:"yellow",source:c,stepResults:x??d,narrative:u,adaptations:l,suggestedFeatureEdit:b,featureEdits:y.length>0?y:void 0,verified:m,proofError:p,seededSteps:i>0?i:void 0,proposalFile:A,usage:f,durationMs:Date.now()-o}}};import ls from"node:path";import{createRequire as yi}from"node:module";import{query as Si}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as fi,tool as He}from"@anthropic-ai/claude-agent-sdk";import{z as D}from"zod";var K=e=>({content:[{type:"text",text:e}]});function rs(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var ss={role:D.string().optional().describe("ARIA role of the element"),name:D.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:D.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:D.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:D.string().optional().describe("getByText locator text"),selector:D.string().optional().describe("CSS fallback selector"),frame:D.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 os(e){return fi({name:"saffron",version:"0.1.0",tools:[He("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:D.number().int().min(0),text:D.string(),adaptation:D.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async t=>(e.currentStepIndex=t.index,e.announced.add(t.index),t.adaptation&&e.adaptations.push(`Step ${t.index+1} ("${t.text}"): ${t.adaptation}`),K(`Recording step ${t.index}: ${t.text}`))),He("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:D.string().describe("Name to store the captured text under"),...ss},async t=>{let n=rs(t);return n?(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),K(`Capture recorded as "${t.saveAs}".`)):K("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),He("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:D.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:D.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),K(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),He("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:D.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:D.string().optional().describe("HTTP method to match (any if omitted)"),status:D.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:D.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),K("Network wait recorded."))),He("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:D.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...ss,expected:D.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:D.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:D.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:D.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:D.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:D.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:D.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:D.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:D.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:D.string().optional().describe("kind=each: the data-list token exactly as the step writes it, e.g. '{data:enums.OrderStatus}'. The target is the CONTAINER (dropdown, table, list) that must show every value of that list. Never pass the values themselves."),compareTo:D.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=rs(t),r;switch(t.kind){case"visible":r={action:"expectVisible",target:n};break;case"notVisible":r={action:"expectNotVisible",target:n};break;case"text":r={action:"expectText",target:n,value:t.expected??""};break;case"value":r={action:"expectValue",target:n,value:t.expected??""};break;case"url":r={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return K("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return K("ERROR: kind=differs needs either capture (a stored name) or a target element.");r={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return K("ERROR: kind=matches requires pattern.");r={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return K("ERROR: kind=attribute requires attribute and pattern.");r={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"each":if(!t.each||!/^\{data:[^}]+\}$/.test(t.each))return K("ERROR: kind=each requires each='{data:file.list}', the token exactly as the step writes it (not the values).");r={action:"expectEach",target:n,each:t.each};break;case"response":if(!t.urlPattern)return K("ERROR: kind=response requires urlPattern.");r={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?K("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([r]),K("Assertion recorded."))}),He("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:D.boolean(),narrative:D.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:D.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:D.array(D.object({index:D.number().int().min(0).describe("zero-based step index"),text:D.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async t=>(e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],K("Run finalized.")))]})}function is(e={}){let t=e.deliberateSnapshots?`
48
+ Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:Nr.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:$e.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:Nr.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let d of r.stepSets)c.push({label:`StepSet ${d.name}`,kind:$e.Function,detail:`invoke step set \xB7 ${d.steps.length} step(s)`,documentation:d.steps.join(`
49
+ `),filterText:`step set ${d.name}`,sortText:`1${d.name}`,textEdit:{range:a,newText:`StepSet ${d.name}`}});return c}return[]}import Ve from"node:fs";import $i from"node:net";import ke from"node:path";import{chromium as ki,firefox as Ri,webkit as Ti}from"playwright";function ct(e){return Array.isArray(e)?e.map(ct):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).filter(([t,n])=>n!==void 0&&t!=="pageFingerprint").sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>[t,ct(n)])):e}var vn=e=>JSON.stringify(ct(e));function $n(e,t="",n={}){if(Array.isArray(e))n[t]=e.map(r=>String(r)).join(", ");else if(e&&typeof e=="object")for(let[r,s]of Object.entries(e))$n(s,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function gi(e,t){let n=$n(ct(e)),r=$n(ct(t)),s=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),o=[];for(let i of s){if(i==="action"||n[i]===r[i])continue;let a=i.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[i]===void 0?o.push(`${a}: added "${r[i]}"`):r[i]===void 0?o.push(`${a}: removed "${n[i]}"`):o.push(`${a}: "${n[i]}" \u2192 "${r[i]}"`)}return o}function Gr(e,t,n){let r=e.map(n),s=t.map(n),o=Array.from({length:r.length+1},()=>new Array(s.length+1).fill(0));for(let d=r.length-1;d>=0;d--)for(let m=s.length-1;m>=0;m--)o[d][m]=r[d]===s[m]?o[d+1][m+1]+1:Math.max(o[d+1][m],o[d][m+1]);let i=[],a=0,c=0;for(;a<r.length&&c<s.length;)r[a]===s[c]?(i.push({before:e[a],after:t[c]}),a++,c++):o[a+1][c]>=o[a][c+1]?(i.push({before:e[a]}),a++):(i.push({after:t[c]}),c++);for(;a<r.length;a++)i.push({before:e[a]});for(;c<s.length;c++)i.push({after:t[c]});return i}function Hr(e){if(!e)return"";let t=[];if(e.role){let n=e.name?` "${e.name}"`:e.nameRegex?` matching /${e.nameRegex}/`:"";t.push(`${e.role}${n}`)}else e.label?t.push(`label "${e.label}"`):e.text?t.push(`text "${e.text}"`):e.placeholder?t.push(`placeholder "${e.placeholder}"`):e.testId&&t.push(`test id ${e.testId}`);return e.fallbackSelectors?.length&&t.push(`[${e.fallbackSelectors.join(", ")}]`),e.frame&&t.push(`inside frame ${e.frame}`),t.join(" ")}var Wr=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function le(e){let t=Hr(e.target),n=e.value??"",r=(()=>{switch(e.action){case"goto":return`go to ${e.url}`;case"click":return`click ${t}`;case"fill":return`fill ${t} with "${n}"`;case"press":return t?`press ${n} on ${t}`:`press ${n}`;case"selectOption":return`select "${n}" in ${t}`;case"check":return`check ${t}`;case"uncheck":return`uncheck ${t}`;case"hover":return`hover ${t}`;case"waitFor":return t?`wait for ${t}`:`wait ${n}ms`;case"captureText":return`remember ${t} as "${e.saveAs}"`;case"handleDialog":return`${n} the dialog${e.promptText?` with "${e.promptText}"`:""}`;case"uploadFile":return`upload ${n}`;case"dragTo":return`drag ${t} onto ${Hr(e.toTarget)}`;case"waitForResponse":return`wait for ${Wr(e)}`;case"switchTab":return`switch to tab ${n}`;case"closeTab":return`close tab ${n||"(the current one)"}`;case"newTab":return`open a new tab${e.url?` at ${e.url}`:""}`;case"expectVisible":return`expect ${t} to be visible`;case"expectNotVisible":return`expect ${t} to be gone`;case"expectText":return`expect ${t} to contain "${n}"`;case"expectUrl":return`expect the URL to contain "${e.url??n}"`;case"expectValue":return`expect ${t} to hold "${n}"`;case"expectDiffers":return`expect ${t||`"${n}"`} to differ from "${e.compareTo}"`;case"expectMatches":return`expect ${t} to match /${e.pattern}/`;case"expectAttribute":return`expect ${t} ${e.attribute} to match /${e.pattern}/`;case"expectResponse":return`expect a response ${Wr(e)}`;case"expectEach":return`expect ${t} to show every value of ${e.each}`;default:return e.action}})();return e.opensTab?`${r}, opening a tab`:r}var at=e=>pe.has(e.action);function hi(e,t){let n=Gr(e,t,vn),r=[];for(let s=0;s<n.length;s++){let o=n[s];if(o.before&&o.after){r.push({change:"same",before:le(o.before),after:le(o.after),assertion:at(o.after)});continue}let i=n[s+1];if(o.before&&i?.after&&!i.before){r.push({change:"changed",before:le(o.before),after:le(i.after),details:gi(o.before,i.after),assertion:at(o.before)||at(i.after)}),s++;continue}o.before&&r.push({change:"removed",before:le(o.before),assertion:at(o.before)}),o.after&&r.push({change:"added",after:le(o.after),assertion:at(o.after)})}return r}var mi=e=>`${e.kind}\0${e.gherkin}`;function jt(e,t){let n=Gr(e?.steps??[],t.steps,mi),r=[],s={added:0,removed:0,changed:0,assertions:0};for(let o of n){let i=o.after??o.before,a=hi(o.before?.actions??[],o.after?.actions??[]);for(let d of a)d.change==="added"?s.added++:d.change==="removed"?s.removed++:d.change==="changed"&&s.changed++,d.change!=="same"&&d.assertion&&s.assertions++;let c=e&&o.before?o.after?a.some(d=>d.change!=="same")?"changed":"same":"removed":"added";r.push({gherkin:i.gherkin,kind:i.kind,fromStepSet:i.fromStepSet,change:c,actions:a})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:r,summary:s}}var Jr=new Set(["goto","click","press","newTab","switchTab","closeTab"]);function Kr(e,t){let n=[],r=s=>`${s} navigation action${s===1?"":"s"}`;for(let s=0;s<Math.min(e.length,t.length);s++){let o=e[s].actions.filter(l=>Jr.has(l.action)),a=[...t[s].actions.filter(l=>Jr.has(l.action))],c=[];for(let l of o){let f=a.findIndex(S=>vn(S)===vn(l));f>=0?a.splice(f,1):c.push(l)}let d=a;for(let l=c.length-1;l>=0;l--){let f=d.findIndex(S=>S.action===c[l].action);f>=0&&(d.splice(f,1),c.splice(l,1))}let m=`step ${s+1} ("${e[s].gherkin}")`;c.length>0&&n.push(`flow change: ${m} no longer performs ${r(c.length)} from the committed recording: ${c.map(le).join("; ")}. A page or navigation step may have been removed or merged.`),d.length>0&&n.push(`flow change: ${m} now performs ${r(d.length)} the committed recording did not: ${d.map(le).join("; ")}. A page or navigation step may have been added or reordered.`)}return n}function Yr(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let i of e.steps){let a=i.change==="removed"?"- ":i.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${a}${i.kind==="assertion"?"[assertion] ":""}${i.gherkin}`+(i.fromStepSet?` (from step set "${i.fromStepSet}")`:""));for(let c of i.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let d of c.details??[])t.push(` ${d}`)}t.push("")}let{added:n,removed:r,changed:s,assertions:o}=e.summary;return t.push(n+r+s===0?"No action changed.":`${s} changed, ${n} added, ${r} removed.`+(o>0?` ${o} of them ${o===1?"is an assertion":"are assertions"}: read those first.`:"")),t.join(`
50
+ `)}var yi=5e3,Si=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]);function Xr(e){return e.mode==="heal"||e.mode==="refine"}function es(e){return e.mode==="heal"||e.mode==="refine"?e.failedStep?.stepIndex??e.completedSteps?.length??0:e.recordOnly?e.recordOnly.from:e.completedSteps?.length??0}var Qr=e=>`${e} action${e===1?"":"s"}`,Zr=e=>e.map(le).join("; "),Ft=class{firstStep;discardBeforeAnnounce;preAnnounce=[];discarded=[];carried;pendingTabOpen=!1;pendingTabFallback;lastOpener;lastOpenerAt=0;inFlight=new Set;steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(t,n={}){this.firstStep=Math.max(n.firstStep??0,0),this.discardBeforeAnnounce=n.discardBeforeAnnounce??!1,this.steps=t.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(t){if(this.done)return;let n=this.destination();if(!n){this.discard([t],this.refusalReason());return}if(n.some(o=>o.action===t.action&&o.value===t.value&&o.promptText===t.promptText))return;let s=Math.max(n.length-1,0);n.splice(s,0,t)}destination(){if(this.currentStepIndex<0)return this.discardBeforeAnnounce?void 0:this.firstStep===0?this.steps[0]?.actions:this.preAnnounce;if(!(this.currentStepIndex<this.firstStep))return this.steps[this.currentStepIndex]?.actions}refusalReason(){let t=this.currentStepIndex;return t<0?`before announcing the failed step (step ${this.firstStep+1}); a heal records nothing until it announces that step, so this exploration was not filed under any step`:`under step ${t+1} ("${this.steps[t]?.gherkin??""}"), which had already run before the agent took over, so its recording was kept`}discard(t,n){if(t.length===0)return;let r=this.discarded.find(s=>s.why===n);r?r.actions.push(...t):this.discarded.push({actions:[...t],why:n})}announce(t,n,r){this.currentStepIndex=t,this.announced.add(t),r&&this.adaptations.push(`Step ${t+1} ("${n}"): ${r}`);let s=this.steps[t];this.preAnnounce.length>0&&t>=this.firstStep&&s&&(s.actions.unshift(...this.preAnnounce),this.carried={index:t,actions:this.preAnnounce},this.preAnnounce=[])}attributionNotes(){let t=[];if(this.carried){let{index:r,actions:s}=this.carried;t.push(`recording: ${Qr(s.length)} the agent performed before announcing a step ${s.length===1?"was":"were"} filed under step ${r+1} ("${this.steps[r]?.gherkin??""}"), the first step it recorded: ${Zr(s)}.`)}let n=this.preAnnounce.length>0?[{actions:this.preAnnounce,why:"before announcing a step this session records"}]:[];for(let{actions:r,why:s}of[...this.discarded,...n])t.push(`recording: discarded ${Qr(r.length)} the agent performed ${s}: ${Zr(r)}.`);return t}beginToolCall(t){this.inFlight.add(t)}endToolCall(t,n){this.inFlight.delete(t)&&(this.pendingTabOpen&&n&&(this.pendingTabFallback=void 0),this.inFlight.size===0&&this.pendingTabOpen&&(this.pendingTabFallback&&(this.pendingTabFallback.opensTab=!0),this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}markTabOpened(){let t=this.inFlight.size===0,n=Date.now()-this.lastOpenerAt<=yi;if(t){this.lastOpener&&n&&(this.lastOpener.opensTab=!0);return}this.pendingTabOpen=!0,this.pendingTabFallback=n?this.lastOpener:void 0}addActions(t){if(this.done||t.length===0)return;if(this.currentStepIndex<0&&this.firstStep>0&&!this.discardBeforeAnnounce){let s=t.filter(o=>o.action==="switchTab");if(this.discard(s,"before announcing its first step (replay is already on the live page when the agent takes over)"),t=t.filter(o=>o.action!=="switchTab"),t.length===0)return}let n=this.destination();if(!n){this.pendingFingerprint=void 0,this.discard(t,this.refusalReason());return}this.pendingFingerprint&&t[0]&&(t[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.push(...t);let r=[...t].reverse().find(s=>Si.has(s.action));r&&(this.lastOpener=r,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(r.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},Fe=e=>e.replace(/\\(['"\\])/g,"$1");function bi(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?Fe(t[1]):void 0}function kn(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=Fe(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=e.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(r){t.role=r[1];let i=(r[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(t.name=Fe(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let c=e.match(i);c&&(t[a]=Fe(c[1]))}let o=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(t.fallbackSelectors=[Fe(o[1])]),Object.keys(t).length>0?t:void 0}var xi={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function wi(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:nt(Fe(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:Fe(r[1])};let s=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let f=kn(s[1]),S=kn(s[2]);if(f&&S)return{action:"dragTo",target:f,toTarget:S}}let o=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,c]=o,d=kn(i);if(!d)return;let m={action:xi[a],target:d},l=bi(c);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function ts(e){let t=Array.isArray(e)?e:e?.content,n=typeof e=="string"?[e]:Array.isArray(t)?t.flatMap(r=>typeof r?.text=="string"?[r.text]:[]):[];for(let r of n){let s=r.split(/^### Open tabs\r?$/m)[1]?.split(/^### /m)[0];if(!s)continue;let o=/^- (\d+): \(current\) /m.exec(s)?.[1];if(o!==void 0)return Number(o)}}function ns(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let s=[];for(let o of n)for(let i of o.split(`
51
+ `)){let a=wi(i.trim(),t);a&&s.push(a)}return vi(s)}function vi(e){return e.filter((t,n)=>{let r=e[n+1];return!(t.action==="fill"&&(t.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(t.target))})}function rs(e){let t=[];for(let n of e){if(n.kind==="assertion"&&n.actions.length===0){t.push(`Step "${n.gherkin}" is an assertion but recorded no check, so nothing would be verified. Re-record with: saffron run --rerecord`);continue}if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let s of n.actions)if(s.action==="fill"&&(s.value??"")===""){t.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}}return t}function Rn(e,t){let n=e;for(let[r,s]of Object.entries(t).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))n=n.split(s).join(`<${r}>`);return n}function ss(e,t){let n=Object.entries(t).filter(([,i])=>i.length>=3);if(n.length===0)return e;let r=i=>i===void 0?void 0:Rn(i,t),s=[...n].sort((i,a)=>a[1].length-i[1].length),o=i=>{if(i!==void 0){for(let[a,c]of s)if(i.includes(c))return`<${a}>`;return i}};return e.map(i=>({...i,actions:i.actions.map(a=>{let c=pe.has(a.action)&&a.action!=="expectValue";return{...a,value:c?o(a.value):r(a.value),url:r(a.url),target:a.target?{...a.target,name:c?o(a.target.name):r(a.target.name),label:r(a.target.label),text:c?o(a.target.text):r(a.target.text),placeholder:r(a.target.placeholder)}:void 0}})}))}function os(e,t,n=r=>r){let r=me(t.steps);return e.map((s,o)=>{let i=t.steps[o],a=i?.resolvedTable??i?.table,c=i?.resolvedDocString??i?.docString,d=Object.entries({...r,...W(a),...Te(c)}).map(([l,f])=>[l,n(f)]).filter(([,l])=>l.length>=3).sort((l,f)=>f[1].length-l[1].length);if(d.length===0)return s;let m=l=>{if(l===void 0)return;let f=l;for(let[S,h]of d)f=f.split(h).join(`<${S}>`);return f};return{...s,actions:s.actions.map(l=>({...l,value:m(l.value),url:m(l.url),target:l.target?{...l.target,name:m(l.target.name),label:m(l.target.label),text:m(l.target.text),placeholder:m(l.target.placeholder)}:void 0}))}})}function is(e,t=new Date){let n=r=>r===void 0?void 0:mr(r,t);return e.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text)}:void 0}))}))}function as(e,t){if(t.size===0)return e;let n=r=>r===void 0?void 0:ze(r,t);return e.map(r=>({...r,actions:r.actions.map(s=>({...s,value:n(s.value),url:n(s.url),pattern:n(s.pattern),target:s.target?{...s.target,name:n(s.target.name),nameRegex:n(s.target.nameRegex),label:n(s.target.label),text:n(s.target.text),placeholder:n(s.target.placeholder)}:void 0}))}))}function cs(e,t){if(t.size===0)return e;let n=r=>typeof r=="string"?br(r,t):Array.isArray(r)?r.map(n):r&&typeof r=="object"?Object.fromEntries(Object.entries(r).map(([s,o])=>[s,s==="pageFingerprint"?o:n(o)])):r;return e.map(r=>({...r,actions:r.actions.map(s=>n(s))}))}function ls(e,t){return t.size===0?e:e.map(n=>{let r=new Set([...n.gherkin.matchAll(Ke)].map(o=>o[1])),s=o=>o===void 0?void 0:Jn(o,t,r);return{...n,actions:n.actions.map(o=>({...o,value:s(o.value),url:s(o.url),pattern:s(o.pattern),target:o.target?{...o.target,name:s(o.target.name),nameRegex:s(o.target.nameRegex),label:s(o.target.label),text:s(o.target.text),placeholder:s(o.target.placeholder)}:void 0}))}})}function ds(e,t,n){if(e!=="mcp__playwright__browser_tabs")return;let r=t??{},s=typeof r.index=="number"?String(r.index):void 0;switch(r.action){case"select":return s===void 0?void 0:{action:"switchTab",value:s};case"close":return s===void 0?{action:"closeTab"}:{action:"closeTab",value:s};case"new":return typeof r.url=="string"&&r.url?{action:"newTab",url:nt(r.url,n)}:{action:"newTab"};default:return}}function ps(e,t){let n=t??{};if(e==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(e==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
52
+ `)}}}function lt(e,t){let n=e.pages().filter(s=>!s.isClosed()),r=s=>[...n].reverse().find(s);return(t?r(s=>s.url()===t):void 0)??r(s=>s.url()!=="about:blank")??n.at(-1)}async function It(e,t){let n=t.context();for(let s of e.contexts())if(s!==n)for(let o of s.pages())await o.close().catch(()=>{});await t.bringToFront().catch(()=>{});let r=n.pages().filter(s=>!s.isClosed());return{index:Math.max(r.indexOf(t),0),count:r.length,url:t.url()}}var An=["chromium","chrome","msedge","firefox","webkit"],Tn={chrome:{label:"Google Chrome"},msedge:{label:"Microsoft Edge"}};function ys(e){return An.includes(e)}var dt=class extends Error{},He={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Ai(){return new Promise((e,t)=>{let n=$i.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function fs(e){let t=bt(Z(e));return t.size>0?{data:Object.fromEntries([...t].sort(([n],[r])=>n.localeCompare(r)))}:{}}function We(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function Ss(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function us(e,t){return e[t]?.kind==="assertion"&&t>=Ss(e)}function gs(e,t,n=!1,r){let s=Ss(e);return e.map((o,i)=>{if(r&&i<r.stepIndex)return o;let a=t[i],c=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&c?a:o:c?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function Ei(e,t,n,r){let s=Kr(e,t);return s.length===0||!n?.pageUrl?s:[`page at failure: replay stopped at step ${n.stepIndex+1} ("${n.stepText}") on ${nt(n.pageUrl,r)}, where the heal started.`,...s]}function Pi(e,t,n=0){return e.map((r,s)=>{if(s<n)return r;let o=t[s];return o&&o.actions.length>0?o:r})}function hs(e,t){let n=[];for(let r=0;r<Math.min(t,e.length);r++){let s=e[r].actions.length;s!==0&&n.push(`recording: kept the existing recording of step ${r+1} ("${e[r].gherkin}"), which ran before the agent took over; ignored ${s} action${s===1?"":"s"} the agent's session held for it.`)}return n}function ms(e,t){return{aiCalls:e.aiCalls+t.aiCalls,inputTokens:e.inputTokens+t.inputTokens,outputTokens:e.outputTokens+t.outputTokens,cacheReadTokens:e.cacheReadTokens+t.cacheReadTokens,cacheCreationTokens:e.cacheCreationTokens+t.cacheCreationTokens,costUsd:(e.costUsd??0)+(t.costUsd??0),models:e.models||t.models?[...new Set([...e.models??[],...t.models??[]])]:void 0}}var Ot=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=oe(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${t}`)}return this.stepIndex}async recordWithSeeding(t,n,r){let s=this.getStepIndex(),o=t.steps.length,i={...t.params,...me(t.steps)},a=t.steps.map(u=>{let g=s.steps.size>0?nr(s,u,i):void 0;return g?{gherkin:u.text,keyword:u.keyword,kind:u.kind,table:u.table,docString:u.docString,fromStepSet:u.source?.fromStepSet,actions:g.actions}:void 0}),c=t.steps.map(u=>({gherkin:u.text,keyword:u.keyword,kind:u.kind,table:u.table,docString:u.docString,fromStepSet:u.source?.fromStepSet,actions:[]})),d=new Map;d.set(ne,r);let m=[],l=[],f=[],S=[],h={...He},y=n,w=0,T=0,$=0,p=u=>({success:u,narrative:l.join(" ")||"Composed entirely from seeded recordings.",adaptations:m,suggestedFeatureEdit:S.length>0?S.join(`
53
+ `):void 0,featureEdits:f,recordedSteps:c,announcedSteps:[],usage:h,seededCount:w});for(;$<o;){if(a[$]){let v=$;for(;v<o&&a[v];)v++;let M=a.slice($,v),R={...t,steps:t.steps.slice($,v)},C=await rt(y.isClosed()?lt(n.context())??n:y,R,this.buildCandidateCache(t,M),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,vars:d});y=C.activePage??y;let _=C.status==="passed"?v-$:C.failure?.stepIndex??0;for(let B=0;B<_;B++)c[$+B]=M[B];if(w+=_,C.status==="passed"){$=v;continue}let E=$+_;m.push(`Seeded recording for "${t.steps[E].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),a[E]=void 0,$=E;continue}if(!this.options.agent)return p(!1);T++;let u=$;for(;u<o&&!a[u];)u++;T>=3&&(u=o);let g=await It(this.browser,y.isClosed()?lt(n.context())??n:y),b=v=>{y=v};n.context().on("page",b);let A;try{A=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:$>0?t.steps.slice(0,$).map(v=>v.resolvedText):void 0,recordOnly:u<o?{from:$,to:u-1}:void 0,liveTab:g,onActiveTab:v=>{let M=n.context().pages().filter(R=>!R.isClosed())[v];M&&(y=M)},uniqueSeed:d.get(ne),onTabOpened:this.tabWatcher(),takeFingerprint:async()=>Ce(We(this.browser)??n)}))}finally{n.context().off("page",b)}if(h=ms(h,A.usage),l.push(A.narrative),m.push(...A.adaptations),f.push(...A.featureEdits),A.suggestedFeatureEdit&&S.push(A.suggestedFeatureEdit),!A.success)return p(!1);let P=A.announcedSteps.filter(v=>v>=$&&v<o),k=Math.min(o-1,Math.max(P.length>0?Math.max(...P):u-1,$));for(let v=$;v<=k;v++)c[v]=A.recordedSteps[v];$=k+1}return p(!0)}async withDialogsVisible(t){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await t()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(t==="chromium"||Tn[t]){let n=await Ai();try{this.browser=await ki.launch({headless:!this.options.headed,channel:Tn[t]?t:void 0,args:[`--remote-debugging-port=${n}`]})}catch(r){let s=r instanceof Error?r.message:String(r),o=Tn[t];throw o&&/is not found at|distribution .* is not found/i.test(s)?new dt(`${o.label} is not installed on this machine, so --browser ${t} cannot run.
54
+ Install it (npx playwright install ${t} does that), or use --browser chromium, which Saffron ships with.`):r}this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(t==="firefox"?Ri:Ti).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let t=ke.isAbsolute(this.options.storageState)?this.options.storageState:ke.join(this.options.projectRoot,this.options.storageState);if(!Ve.existsSync(t))throw new Error(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return t}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n,...fs(t)}}tabWatcher(){return t=>{let n=this.browser?.contexts()??[],r=()=>t();for(let s of n)s.on("page",r);return()=>{for(let s of n)s.off("page",r)}}}async proofReplay(t,n,r){let s=await this.browser.newContext({storageState:this.resolveStorageState()}),o=await s.newPage();return{result:await rt(o,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,uniqueSeed:r}),context:s,page:o}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),s=et(t);if(s.size>0){let o=i=>i===void 0?i:ze(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>ze(i,s));for(let i of r.stepResults)i.error=o(i.error)}return r}async screenshot(t,n,r,s){let o=this.options.screenshot??"viewport";if(o!=="off")try{let i=Ee(this.options.projectRoot,n.featurePath,n.displayName);Ve.mkdirSync(i,{recursive:!0}),Ve.writeFileSync(ke.join(i,vt),JSON.stringify({feature:n.featurePath,scenario:n.displayName},null,2));let a=ke.join(i,`${r}.jpg`);return await t.screenshot({path:a,type:"jpeg",quality:80,fullPage:o==="full-page",timeout:5e3}),{kind:r,file:ke.relative(this.options.projectRoot,a).split(ke.sep).join("/"),step:s}}catch{return}}async runScenarioInner(t,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c}=this.options,d=J(s,t.featurePath,t.name),m=K(d),l=xe(s,t.featurePath,t.name),f=!m&&Ve.existsSync(l)?_e(l):void 0,S=await this.browser.newContext({storageState:this.resolveStorageState()}),h=await S.newPage(),y=Pe(),w=[];Ve.rmSync(Ee(s,t.featurePath,t.displayName),{recursive:!0,force:!0});let T=($,p)=>this.screenshot($,t,"before-heal",p.stepText).then(u=>{u&&w.push(u)});try{let $=await(async()=>{let u;if(f){let k=await rt(h,t,f.cache,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c,uniqueSeed:y}),v=f.meta.recordedFor??t.name;if(k.status==="passed")return{scenario:t,status:"yellow",source:"agent-record",stepResults:k.stepResults,narrative:`Replayed the pending proposal recorded for "${v}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:l,usage:He,durationMs:Date.now()-r};if(Object.keys(t.params).length>0&&v!==t.displayName){let R=k.failure;return{scenario:t,status:"red",source:"agent-record",stepResults:k.stepResults,adaptations:[],proposalFile:l,usage:He,durationMs:Date.now()-r,error:`The pending proposal recorded for "${v}" does not replay for this Examples row`+(R?` (step "${R.stepText}": ${R.error.split(`
55
+ `)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+(k.failure?` (step "${k.failure.stepText}": ${k.failure.error.split(`
56
+ `)[0]})`:"")+"; recording again"),await h.goto("about:blank")}if(m&&(u=await rt(h,t,m,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c,uniqueSeed:y,onFailure:T}),u.status==="passed"))return{scenario:t,status:"green",source:"cache",stepResults:u.stepResults,adaptations:[],usage:He,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let k=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing needs a Chromium-family browser (the agent attaches over CDP). Record with --browser chromium (the default), chrome or msedge, then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:t,status:"red",source:"cache",stepResults:u?.stepResults??[],adaptations:[],usage:He,durationMs:Date.now()-r,error:m?u?.status==="stale"?`Cache is stale (feature file changed) and ${k}`:`Replay failed and ${k} ${u?.failure?.error??""}`.trimEnd():`No cache exists and ${k}`}}let b=m!==void 0&&u!==void 0&&u.status==="failed",A=0,P;try{if(b){let k=await It(this.browser,u.activePage??lt(h.context(),u.failure.pageUrl)??h);P=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,mode:"heal",liveTab:k,model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:u.stepResults.filter(v=>v.status==="passed").map(v=>v.gherkin),failedStep:{text:u.failure.stepText,kind:u.failure.kind,error:u.failure.error,pageUrl:u.failure.pageUrl,stepIndex:u.failure.stepIndex},uniqueSeed:y,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let v=We(this.browser)??h;return Ce(v)}}))}else if(await h.goto("about:blank"),this.options.reuseSteps!==!1){let k=await this.recordWithSeeding(t,h,y);A=k.seededCount,P=k}else P=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,uniqueSeed:y,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let k=We(this.browser)??h;return Ce(k)}}))}catch(k){let v=k instanceof Error?k.message:String(k);return{scenario:t,status:"red",source:b?"agent-heal":"agent-record",stepResults:u?.stepResults??[],adaptations:[],usage:He,durationMs:Date.now()-r,error:`Agent invocation failed: ${v.split(`
57
+ `)[0]}`}}if(P.success&&b&&u.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||us(m.steps,u.failure.stepIndex))){let k=m.steps[u.failure.stepIndex],v=We(this.browser)??h,M=new Map(Object.entries(u.captured)),R=gn(t,u.failure.stepIndex);try{for(let C of k.actions)await fn(v,C,R,this.options.actionTimeoutMs??5e3,o,M)}catch(C){let _=C instanceof Error?C.message:String(C);return{scenario:t,status:"red",source:"agent-heal",stepResults:u.stepResults,narrative:P.narrative,adaptations:P.adaptations,suggestedFeatureEdit:P.suggestedFeatureEdit,usage:P.usage,durationMs:Date.now()-r,error:`Assertion failed as written: "${k.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${_.split(`
58
+ `)[0]}`}}}return await this.finalizeAgentRun(t,P,b,m,r,A,b?u?.failure:void 0,y)})();if($.status==="red"){let u=w.find(g=>g.kind==="before-heal");if($.source==="cache"&&u){let g=u.file.replace(/before-heal\.jpg$/,"failure.jpg");try{Ve.renameSync(ke.join(s,u.file),ke.join(s,g)),u.file=g}catch{}u.kind="failure"}else{let g=$.stepResults.find(A=>A.status==="failed")?.gherkin,b=await this.screenshot(We(this.browser)??h,t,"failure",g);b&&w.push(b)}}let p=tt(Z(t),y);return p.size>0&&($.uniques=Object.fromEntries(p)),w.length>0&&$.status!=="green"&&($.evidence=w),$}finally{await S.close()}}async finalizeAgentRun(t,n,r,s,o,i=0,a,c=Pe()){let d=r?"agent-heal":"agent-record",m=n.recordedSteps.map(R=>({gherkin:R.gherkin,kind:R.kind,status:n.success?"passed":"failed"}));if(!n.success)return{scenario:t,status:"red",source:d,stepResults:m,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let l=r?gs(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,f=[...n.adaptations,...r&&a?hs(n.recordedSteps,a.stepIndex):[]],S={...n.usage},h,y,w,T;if(this.options.verifyProposals!==!1){let R=await this.proofReplay(t,this.buildCandidateCache(t,l),Pe());T=R.result.captured[ne];try{if(R.result.status!=="passed"&&R.result.failure){let C=R.result.failure;if(this.options.agent&&!(r&&C.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||us(s.steps,C.stepIndex))))try{let E=await It(this.browser,R.result.activePage??lt(R.page.context(),C.pageUrl)??R.page),B=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:R.result.stepResults.filter(x=>x.status==="passed").map(x=>x.gherkin),failedStep:{text:C.stepText,kind:C.kind,error:C.error,pageUrl:C.pageUrl,stepIndex:C.stepIndex},liveTab:E,uniqueSeed:R.result.captured[ne],onTabOpened:this.tabWatcher(),takeFingerprint:async()=>Ce(We(this.browser)??R.page)}));S=ms(S,B.usage),B.success&&(f.push(...B.adaptations),f.push(...hs(B.recordedSteps,C.stepIndex)),l=r?gs(l,B.recordedSteps,this.options.assertionPolicy==="adaptable-mid",{stepIndex:C.stepIndex,actionIndex:0}):Pi(l,B.recordedSteps,C.stepIndex),await R.context.close(),R=await this.proofReplay(t,this.buildCandidateCache(t,l),Pe()),T=R.result.captured[ne])}catch{}}for(let C of R.result.tabOpeners??[]){let _=l[C.stepIndex]?.actions[C.actionIndex];_&&(_.opensTab=!0)}h=R.result.status==="passed",h||(y=R.result.failure?.error??`proof replay ${R.result.status}`,w=R.result.stepResults)}finally{await R.context.close().catch(()=>{})}}r&&s&&f.push(...Ei(s.steps,l,a,this.options.baseURL));let $=[...n.featureEdits];for(let R of rr(l,oe(this.options.projectRoot)))f.push(`duplicate wording: "${R.from}" recorded exactly the same actions as the existing step "${R.to}" (${R.feature} \u203A ${R.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),$.some(C=>C.index===R.index)||$.push({index:R.index,text:R.to});let p=rs(l);p.length>0&&(h=!1,y=[y,...p].filter(Boolean).join(" "));let u=et(t),g=R=>u.size>0?ze(R,u):R;f=f.map(g);let b=$.map(R=>({index:R.index,text:g(R.text)})),A=g(n.narrative),P=n.suggestedFeatureEdit?g(n.suggestedFeatureEdit):void 0,k={meta:{createdAt:new Date().toISOString(),mode:r?"heal":"record",narrative:A,adaptations:f,suggestedFeatureEdit:P,featureEdits:b.length>0?b:void 0,verified:h,proofError:y?g(y):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:S},cache:{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:l,...fs(t)}},v=xe(this.options.projectRoot,t.featurePath,t.name);Tr(v,k);let M=K(J(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:jt(M,k.cache),scenario:t,status:"yellow",source:d,stepResults:w??m,proofUniques:(()=>{let R=T?tt(Z(t),T):void 0;return R&&R.size>0?Object.fromEntries(R):void 0})(),narrative:A,adaptations:f,suggestedFeatureEdit:P,featureEdits:b.length>0?b:void 0,verified:h,proofError:y,seededSteps:i>0?i:void 0,proposalFile:v,usage:S,durationMs:Date.now()-o}}};import As from"node:path";import{createRequire as Ni}from"node:module";import{query as Mi}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Ci,tool as Je}from"@anthropic-ai/claude-agent-sdk";import{z as N}from"zod";var H=e=>({content:[{type:"text",text:e}]});function bs(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var _i=new Set(["main","banner","contentinfo","status","alert","alertdialog","dialog","search"]);function xs(e){if(!e?.role)return;let{role:t,frame:n,...r}=e;if(!(Object.keys(r).length>0||_i.has(t)))return`ERROR: role="${t}" alone matches every ${t} on the page, and replay would use the first one. Add a name (or nameRegex), text, label or selector that picks exactly the element you checked, then call again.`}var ws={role:N.string().optional().describe("ARIA role of the element"),name:N.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:N.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:N.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:N.string().optional().describe("getByText locator text"),selector:N.string().optional().describe("CSS fallback selector"),frame:N.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 vs(e){return Ci({name:"saffron",version:"0.1.0",tools:[Je("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:N.number().int().min(0),text:N.string(),adaptation:N.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: the kind of change (a locator or label change on the same page, a page removed or merged, a page added or flow reordered) and the page it happened on.")},async t=>(e.announce(t.index,t.text,t.adaptation),t.index<e.firstStep?H(`Step ${t.index} already ran before you took over and keeps its recording; nothing you do under it is recorded. Do not redo it: continue from step ${e.firstStep} on the page as it is.`):H(`Recording step ${t.index}: ${t.text}`))),Je("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:N.string().describe("Name to store the captured text under"),...ws},async t=>{let n=bs(t);if(!n)return H("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).");let r=xs(n);return r?H(r):(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),H(`Capture recorded as "${t.saveAs}".`))}),Je("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:N.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:N.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),H(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),Je("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:N.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:N.string().optional().describe("HTTP method to match (any if omitted)"),status:N.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:N.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),H("Network wait recorded."))),Je("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:N.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...ws,expected:N.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:N.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:N.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:N.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:N.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:N.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:N.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:N.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:N.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:N.string().optional().describe("kind=each: the data-list token exactly as the step writes it, e.g. '{data:enums.OrderStatus}'. The target is the CONTAINER (dropdown, table, list) that must show every value of that list. Never pass the values themselves."),compareTo:N.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=bs(t),r=xs(n);if(r)return H(r);let s;switch(t.kind){case"visible":s={action:"expectVisible",target:n};break;case"notVisible":s={action:"expectNotVisible",target:n};break;case"text":s={action:"expectText",target:n,value:t.expected??""};break;case"value":s={action:"expectValue",target:n,value:t.expected??""};break;case"url":s={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return H("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return H("ERROR: kind=differs needs either capture (a stored name) or a target element.");s={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return H("ERROR: kind=matches requires pattern.");s={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return H("ERROR: kind=attribute requires attribute and pattern.");s={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"each":if(!t.each||!/^\{data:[^}]+\}$/.test(t.each))return H("ERROR: kind=each requires each='{data:file.list}', the token exactly as the step writes it (not the values).");s={action:"expectEach",target:n,each:t.each};break;case"response":if(!t.urlPattern)return H("ERROR: kind=response requires urlPattern.");s={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?H("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([s]),H("Assertion recorded."))}),Je("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:N.boolean(),narrative:N.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did. After a heal, say what kind of change the app went through (a locator change, or a page removed, merged, added or reordered) and on which page."),suggestedFeatureEdit:N.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:N.array(N.object({index:N.number().int().min(0).describe("zero-based step index"),text:N.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async t=>(e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],H("Run finalized.")))]})}function ks(e={}){let t=e.deliberateSnapshots?`
56
59
  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.
57
60
  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.
58
61
 
@@ -72,81 +75,82 @@ Non-negotiable rules:
72
75
  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.
73
76
  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.
74
77
  5f. TABS: a click that opens a new tab needs nothing extra, replay follows the new tab by itself. When a step says to switch to another tab, go back to the first tab, or close a tab, do it with browser_tabs (list first if unsure of the index); select, close and new are recorded and replay in the same order. Steps that assert on the other tab's content are ordinary assertions after the switch.
75
- 7. Never ask the user questions; you are unattended.${t}`}function ui(e,t){if(e[t].kind!=="assertion")return"action";let n=e.length;for(let r=e.length-1;r>=0&&e[r].kind==="assertion";r--)n=r;return t>=n?"assertion:final":"assertion"}function as(e){let{scenario:t}=e,n=t.steps.map((s,o)=>{let i=l=>Ke(Ln(Ue(l,e.uniqueSeed??""))),a=`${o}. [${ui(t.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,c=s.resolvedTable??s.table,d=s.resolvedDocString??s.docString,h=a;return c&&(h+=`
78
+ 7. Never ask the user questions; you are unattended.${t}`}function ji(e,t){if(e[t].kind!=="assertion")return"action";let n=e.length;for(let r=e.length-1;r>=0&&e[r].kind==="assertion";r--)n=r;return t>=n?"assertion:final":"assertion"}var Fi=`When you heal, tell the reviewer WHAT KIND of change you adapted to, in saffron_step's adaptation and again in saffron_done's narrative, and name the page (its URL path) where it happened. Pick the kind that fits: (1) locator or label change: the same control on the same page, found differently (renamed button, new test id, native select instead of a custom one); (2) page removed or merged: a page no longer exists and its fields or controls now live on another page, so a navigation step (e.g. a 'Continue' click) is no longer needed; (3) page added or flow reordered: a new intermediate page, dialog or step order; (4) timing only: nothing changed, the element was slow. When you skip or add a click, key press or navigation the step used to perform, say so explicitly, e.g. "The guest information page is gone: its fields are now on /checkout, so the 'Continue to payment' click was dropped." Never describe a flow change as a locator change.`;function Dt(e){let t=e.liveTab;if(t)return t.count<=1?`Live page: the only open tab, at ${t.url}. Work on it; do not open or select another tab to start over.`:`Live page: tab ${t.index} of ${t.count} open tabs, at ${t.url}. This is where replay left off. If browser_tabs shows another tab as current, select tab ${t.index} before anything else (Saffron does not record that selection); never start over on another tab.`}function $s(e){let t=e.failedStep?.stepIndex;return`ANNOUNCE FIRST: call saffron_step for ${t===void 0?"the failed step":`the failed step (index ${t})`} BEFORE any state-changing browser action (click, type, select, key press, navigation, tab change). To diagnose, look with browser_snapshot, never with clicks or navigation: Saffron discards every action made before that announcement, and going back through earlier steps changes the state replay reached.`}function Rs(e){let{scenario:t}=e,n=t.steps.map((s,o)=>{let i=l=>Xe(Wn(qe(l,e.uniqueSeed??""))),a=`${o}. [${ji(t.steps,o)}] ${s.keyword} ${i(s.resolvedText)}`,c=s.resolvedTable??s.table,d=s.resolvedDocString??s.docString,m=a;return c&&(m+=`
76
79
  ${c.map(l=>` | ${l.map(i).join(" | ")} |`).join(`
77
- `)}`),d!==void 0&&(h+=`
80
+ `)}`),d!==void 0&&(m+=`
78
81
  """
79
82
  ${i(d).split(`
80
83
  `).map(l=>` ${l}`).join(`
81
84
  `)}
82
- """`),h}).join(`
83
- `),r=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(e.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.","",`Steps that replayed fine:
85
+ """`),m}).join(`
86
+ `),r=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(e.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.",Dt(e),"",`Steps that replayed fine:
84
87
  ${(e.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
85
- `)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Replay failure: ${e.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.");else if(e.mode==="heal")r.push("MODE: HEAL. A cached deterministic replay of this scenario failed mid-run.","The browser tab already holds the page state reached so far \u2014 do NOT restart from step 0; verify where you are with a snapshot and continue from the failed step.","",`Steps already completed by replay:
88
+ `)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Replay failure: ${e.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.",$s(e));else if(e.mode==="heal")r.push("MODE: HEAL. A cached deterministic replay of this scenario failed mid-run.","The browser tab already holds the page state reached so far \u2014 do NOT restart from step 0; verify where you are with a snapshot and continue from the failed step.",Dt(e),"",`Steps already completed by replay:
86
89
  ${(e.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
87
- `)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Failure: ${e.failedStep?.error}`,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",e.assertionPolicy==="adaptable-mid"?"If the failed step is an [assertion:final]: you may re-verify the SAME condition once against the live page (timing), but if it does not hold AS WRITTEN, the run FAILS \u2014 call saffron_done success=false; never substitute it. If the failed step is a mid-scenario [assertion]: this project's policy allows adapting it \u2014 verify the equivalent condition on the legitimately-changed UI, record it via saffron_assert, and report the deviation via saffron_step's adaptation field plus featureEdits. An application defect still fails.":"If the failed step is an [assertion] or [assertion:final]: you may re-verify the SAME condition once against the live page (it may have been a timing issue). If the condition AS WRITTEN does not hold, the run FAILS \u2014 call saffron_done success=false. Do NOT substitute a different element, text, or URL that 'means the same thing'; suggesting a corrected assertion belongs in suggestedFeatureEdit of your failure report, never in saffron_assert.","Announce (saffron_step) and perform only the failed step and the steps after it.");else if(e.mode==="record"&&e.recordOnly){let{from:s,to:o}=e.recordOnly;r.push("MODE: RECORD (segment). This scenario is being composed from existing recordings plus your work.",e.completedSteps&&e.completedSteps.length>0?`Already executed against the live browser (do NOT redo):
90
+ `)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Failure: ${e.failedStep?.error}`,e.failedStep?.pageUrl?`Page when replay failed: ${e.failedStep.pageUrl}`:void 0,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",Fi,e.assertionPolicy==="adaptable-mid"?"If the failed step is an [assertion:final]: you may re-verify the SAME condition once against the live page (timing), but if it does not hold AS WRITTEN, the run FAILS \u2014 call saffron_done success=false; never substitute it. If the failed step is a mid-scenario [assertion]: this project's policy allows adapting it \u2014 verify the equivalent condition on the legitimately-changed UI, record it via saffron_assert, and report the deviation via saffron_step's adaptation field plus featureEdits. An application defect still fails.":"If the failed step is an [assertion] or [assertion:final]: you may re-verify the SAME condition once against the live page (it may have been a timing issue). If the condition AS WRITTEN does not hold, the run FAILS \u2014 call saffron_done success=false. Do NOT substitute a different element, text, or URL that 'means the same thing'; suggesting a corrected assertion belongs in suggestedFeatureEdit of your failure report, never in saffron_assert.","Announce (saffron_step) and perform only the failed step and the steps after it. Earlier steps keep their recordings.",$s(e));else if(e.mode==="record"&&e.recordOnly){let{from:s,to:o}=e.recordOnly;r.push("MODE: RECORD (segment). This scenario is being composed from existing recordings plus your work.",e.completedSteps&&e.completedSteps.length>0?`Already executed against the live browser (do NOT redo):
88
91
  ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
89
- `)}`:"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(e.completedSteps&&e.completedSteps.length>0){let s=e.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",e.completedSteps.map(o=>` \u2713 ${o}`).join(`
90
- `),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,`Start with saffron_step for step index ${s} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(s=>s!==void 0).join(`
91
- `)}import{query as gi}from"@anthropic-ai/claude-agent-sdk";var hi="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function wn(e,t=8e3){let n=e?.[hi];if(typeof n!="function")return;let r;try{let s=await Promise.race([n.call(e),new Promise(o=>{r=setTimeout(()=>o(void 0),t),r.unref?.()})]);return mi(s)}catch{return}finally{r&&clearTimeout(r)}}function mi(e){if(!e||!e.rate_limits_available||!e.subscription_type)return;let t=e.rate_limits?.five_hour;return{subscriptionType:e.subscription_type,utilization:typeof t?.utilization=="number"?t.utilization:void 0,resetsAt:t?.resets_at??void 0}}async function cs(){let e=()=>{},t=new Promise(s=>e=s);async function*n(){await t}let r;try{let s=gi({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=s;let o=(async()=>{try{for await(let a of s);}catch{}})(),i=await wn(s);return e(),r.close?.(),await o,i}catch{e(),r?.close?.();return}}var bi=yi(import.meta.url);function xi(){let e=bi.resolve("@playwright/mcp/package.json");return ls.join(ls.dirname(e),"cli.js")}var wi=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"]),vi=new Set(["mcp__playwright__browser_snapshot","mcp__playwright__browser_wait_for","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_console_messages","mcp__playwright__browser_network_requests","mcp__saffron__saffron_step","mcp__saffron__saffron_capture","mcp__saffron__saffron_dialog","mcp__saffron__saffron_wait_api","mcp__saffron__saffron_assert","mcp__saffron__saffron_done"]);function ds(e,t){if(vi.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Ct=class{constructor(t={}){this.opts=t}opts;planBefore;async planUsage(){if(!this.planBefore)return;let t=await this.planBefore;if(!t)return;let n=await cs();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=new Pt(t.scenario),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=Si({prompt:as(t),options:{systemPrompt:is({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),model:t.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[xi(),"--cdp-endpoint",t.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:os(n)},hooks:{PreToolUse:[{hooks:[async a=>{let c=a;if(n.beginToolCall(c.tool_use_id),wi.has(c.tool_name)&&t.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await t.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async a=>{let c=a,d=Wr(c.tool_name,c.tool_input);d&&n.addArmedAction(d);let h=Hr(c.tool_name,c.tool_input);return h&&n.addActions([h]),c.tool_name.startsWith("mcp__playwright__")&&(n.addActions(Nr(c.tool_response,t.baseURL)),n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,ds(c.tool_name,c.tool_input)),{continue:!0}}]}],PostToolUseFailure:[{hooks:[async a=>{let c=a;return c.tool_name.startsWith("mcp__playwright__")&&(n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,ds(c.tool_name,c.tool_input)),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=wn(o));let i=t.onTabOpened?.(()=>n.markTabOpened());try{for await(let a of o)a.type==="result"&&(r.aiCalls=a.num_turns,r.costUsd=a.total_cost_usd,"modelUsage"in a&&a.modelUsage&&(r.models=Object.keys(a.modelUsage)),"usage"in a&&a.usage&&(r.inputTokens=a.usage.input_tokens??0,r.outputTokens=a.usage.output_tokens??0,r.cacheReadTokens=a.usage.cache_read_input_tokens??0,r.cacheCreationTokens=a.usage.cache_creation_input_tokens??0),a.subtype==="success"&&(s=a.result))}catch(a){let c=a instanceof Error?a.message:String(a);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${c}`}finally{i?.()}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:n.adaptations,suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(a=>({index:a.index,text:yn(a.text,t.scenario.params)})),recordedSteps:zr(Vr(qr(Br(Lr(Ur(n.steps,t.scenario,a=>Je(Ue(a,t.uniqueSeed??""))),t.scenario.params)),ut(X(t.scenario))),$t(X(t.scenario),t.uniqueSeed??"")),Ye(t.scenario)),announcedSteps:[...n.announced].sort((a,c)=>a-c),usage:r}}};function Ft(e){return`$${e.toFixed(e>=1?2:4)}`}function vn(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function kn(e){return e.plan?` \xB7 5-hour window ${vn(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${Ft(e.costUsd)} at API rates`:` \xB7 ${Ft(e.costUsd)}`}function ps(e,t){return e?t?`\u2248 ${Ft(e)}`:Ft(e):""}function fs(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import us from"node:fs";import It from"node:path";function Ot(e){return JSON.stringify([e.role??null,e.name??null,e.nameRegex??null,e.label??null,e.text??null,e.testId??null,e.placeholder??null,e.frame??null])}function gs(e,t){let n=new Map,r=new Set,s=new Map,o=Math.min(e.steps.length,t.steps.length);for(let a=0;a<o;a++){if(e.steps[a].kind==="assertion"||t.steps[a].kind==="assertion"||e.steps[a].gherkin!==t.steps[a].gherkin)continue;let c=e.steps[a].actions,d=t.steps[a].actions;if(c.length===d.length)for(let h=0;h<c.length;h++){let l=c[h],f=d[h];if(l.action!==f.action||le.has(l.action)||!l.target||!f.target)continue;let m=Ot(l.target),p=JSON.stringify(f.target);s.has(m)&&s.get(m)!==p&&r.add(m),s.set(m,p),m!==Ot(f.target)&&n.set(m,{from:l.target,to:f.target})}}let i=[...n.entries()];return{changes:i.filter(([a])=>!r.has(a)).map(([,a])=>a),ambiguous:i.filter(([a])=>r.has(a)).map(([,a])=>a.from)}}function ki(e){let t=It.join(e,".saffron","cache");if(!us.existsSync(t))return[];let n=[];for(let r of us.readdirSync(t,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(It.join(t,s))}return n}function hs(e,t,n,r){if(t.length===0)return[];let s=new Map,o=new Set;for(let a of t){let c=Ot(a.from),d=s.get(c);d&&JSON.stringify(d.to)!==JSON.stringify(a.to)&&o.add(c),s.set(c,a)}for(let a of o)s.delete(a);let i=[];for(let a of ki(e)){if(It.resolve(a)===It.resolve(n))continue;let c=J(a);if(!c)continue;let d=[],h=!1;for(let l of c.steps){if(l.kind==="assertion")continue;let f=0;for(let m of l.actions){if(!m.target||le.has(m.action))continue;let p=s.get(Ot(m.target));p&&(f++,r||(m.target=structuredClone(p.to),h=!0))}f>0&&d.push({gherkin:l.gherkin,count:f})}d.length>0&&(h&&Ne(a,c),i.push({file:a,scenario:c.scenario,feature:c.feature,steps:d}))}return i}function Dt(e){return e.role?`${e.role} "${e.name??e.nameRegex??""}"`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testId "${e.testId}"`:(e.fallbackSelectors??[]).join(",")||"(empty target)"}import ms from"node:fs";import Nt from"node:path";function ys(e,t,n,r,s){let o=Z(Nt.join(e,t),e,s).find(c=>c.name===n);if(!o)return`scenario "${n}" is no longer in ${t}`;let i=o.steps.map(c=>c.text);if(i.length!==r.length)return`the scenario has ${i.length} steps now and had ${r.length} when this was recorded`;let a=i.findIndex((c,d)=>c!==r[d]);return a<0?void 0:`step ${a+1} now reads "${i[a]}" and was recorded as "${r[a]}"`}function Ss(e,t,n,r,s,o){let i={applied:[],skipped:[],appliedIndices:[]},a=Z(Nt.join(e,t),e,s).find(l=>l.name===n);if(!a){for(let l of r)i.skipped.push({index:l.index,reason:`scenario "${n}" not found in ${t}`});return i}let c=new Map;for(let l of r){let f=a.steps[l.index]?.source;if(!f||f.fromBackground)continue;let m=`${f.file}:${f.line}`;c.set(m,(c.get(m)??new Set).add(l.text))}let d=new Map,h=new Set;for(let l of r){let f=a.steps[l.index];if(!f?.source){i.skipped.push({index:l.index,reason:`no step at index ${l.index}`});continue}if(o&&o[l.index]!==f.text){i.skipped.push({index:l.index,reason:`step ${l.index+1} now reads "${f.text}", not the "${o[l.index]??"(nothing)"}" this edit was written for`});continue}let{file:m,line:p,fromStepSet:x,fromBackground:w}=f.source;if(w){i.skipped.push({index:l.index,reason:`"${f.text}" is a Background step shared across scenarios; edit it manually`});continue}let R=`${m}:${p}`,S=c.get(R);if(S.size>1){i.skipped.push({index:l.index,reason:`${R}${x?` (StepSet "${x}")`:""} is reached by several steps and the edits disagree: ${[...S].map(P=>`"${P}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(h.has(R)){i.appliedIndices.push(l.index);continue}let g=d.get(m);g||(g=ms.readFileSync(Nt.join(e,m),"utf8").split(`
92
- `),d.set(m,g));let y=g[p-1],u=y?.match(/^(\s*)(\S+)\s+(.*)$/);if(!u||u[3]!==f.text){i.skipped.push({index:l.index,reason:`${m}:${p} no longer matches the recorded step text`});continue}let b=`${u[1]}${u[2]} ${l.text}`;g[p-1]=b,h.add(R),i.appliedIndices.push(l.index),i.applied.push({file:m,line:p,from:y.trim(),to:b.trim(),fromStepSet:x})}for(let[l,f]of d)i.applied.some(m=>m.file===l)&&ms.writeFileSync(Nt.join(e,l),f.join(`
93
- `));return i}import Rn from"node:fs";import Tn from"node:path";import $i from"node:fs";import Ri from"node:path";var F=e=>e.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),xs="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",Ti="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 Ai(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
94
- <path d="${xs}" fill="#F4A300"/>
95
- <path d="${Ti}" fill="#E7E9EC"/>
96
- </svg>`}function Ei(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${xs}" fill="#F4A300"/></svg>`}var Pi={green:"passed",yellow:"pending review",red:"failed"};function _i(e){return e.hooks?.length?`<div class="panel"><div class="panel-title">setup and teardown</div>${e.hooks.map(n=>{let r=n.exitCode===0,s=r?"ok":n.timedOut?"timed out":n.exitCode===null?"could not start":`exit ${n.exitCode}`;return`<div class="mono">${r?"\u2713":"\u2717"} ${n.phase} \xB7 ${F(n.command)} \xB7 ${s} \xB7 ${it(n.durationMs)}</div>`}).join("")}</div>`:""}function it(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function ot(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}function ge(e,t,n,r="",s="",o=""){return`<div class="kpi">
92
+ `)}`:"You are starting from a blank page.",Dt(e),"",`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(e.completedSteps&&e.completedSteps.length>0){let s=e.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",e.completedSteps.map(o=>` \u2713 ${o}`).join(`
93
+ `),"",`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.`,Dt(e),`Start with saffron_step for step index ${s} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(s=>s!==void 0).join(`
94
+ `)}import{query as Ii}from"@anthropic-ai/claude-agent-sdk";var Oi="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function En(e,t=8e3){let n=e?.[Oi];if(typeof n!="function")return;let r;try{let s=await Promise.race([n.call(e),new Promise(o=>{r=setTimeout(()=>o(void 0),t),r.unref?.()})]);return Di(s)}catch{return}finally{r&&clearTimeout(r)}}function Di(e){if(!e||!e.rate_limits_available||!e.subscription_type)return;let t=e.rate_limits?.five_hour;return{subscriptionType:e.subscription_type,utilization:typeof t?.utilization=="number"?t.utilization:void 0,resetsAt:t?.resets_at??void 0}}async function Ts(){let e=()=>{},t=new Promise(s=>e=s);async function*n(){await t}let r;try{let s=Ii({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=s;let o=(async()=>{try{for await(let a of s);}catch{}})(),i=await En(s);return e(),r.close?.(),await o,i}catch{e(),r?.close?.();return}}var Ui=Ni(import.meta.url);function Li(){let e=Ui.resolve("@playwright/mcp/package.json");return As.join(As.dirname(e),"cli.js")}var Bi=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"]),zi=new Set(["mcp__playwright__browser_snapshot","mcp__playwright__browser_wait_for","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_console_messages","mcp__playwright__browser_network_requests","mcp__saffron__saffron_step","mcp__saffron__saffron_capture","mcp__saffron__saffron_dialog","mcp__saffron__saffron_wait_api","mcp__saffron__saffron_assert","mcp__saffron__saffron_done"]);function Es(e,t){if(zi.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Nt=class{constructor(t={}){this.opts=t}opts;planBefore;async planUsage(){if(!this.planBefore)return;let t=await this.planBefore;if(!t)return;let n=await Ts();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=new Ft(t.scenario,{firstStep:es(t),discardBeforeAnnounce:Xr(t)}),r={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0},s="",o=Mi({prompt:Rs(t),options:{systemPrompt:ks({deliberateSnapshots:(this.opts.snapshotMode??"none")==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),model:t.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],mcpServers:{playwright:{type:"stdio",command:process.execPath,args:[Li(),"--cdp-endpoint",t.cdpEndpoint,"--snapshot-mode",this.opts.snapshotMode??"none","--image-responses","omit"]},saffron:vs(n)},hooks:{PreToolUse:[{hooks:[async a=>{let c=a;if(n.beginToolCall(c.tool_use_id),Bi.has(c.tool_name)&&t.takeFingerprint&&!n.pendingFingerprint)try{n.pendingFingerprint=await t.takeFingerprint()}catch{}return{continue:!0}}]}],PostToolUse:[{hooks:[async a=>{let c=a,d=ps(c.tool_name,c.tool_input);d&&n.addArmedAction(d);let m=ds(c.tool_name,c.tool_input,t.baseURL);if(m&&n.addActions([m]),c.tool_name.startsWith("mcp__playwright__")){let l=ts(c.tool_response);l!==void 0&&t.onActiveTab?.(l),n.addActions(ns(c.tool_response,t.baseURL)),n.pendingFingerprint=void 0}return n.endToolCall(c.tool_use_id,Es(c.tool_name,c.tool_input)),{continue:!0}}]}],PostToolUseFailure:[{hooks:[async a=>{let c=a;return c.tool_name.startsWith("mcp__playwright__")&&(n.pendingFingerprint=void 0),n.endToolCall(c.tool_use_id,Es(c.tool_name,c.tool_input)),{continue:!0}}]}]}}});this.planBefore||(this.planBefore=En(o));let i=t.onTabOpened?.(()=>n.markTabOpened());try{for await(let a of o)a.type==="result"&&(r.aiCalls=a.num_turns,r.costUsd=a.total_cost_usd,"modelUsage"in a&&a.modelUsage&&(r.models=Object.keys(a.modelUsage)),"usage"in a&&a.usage&&(r.inputTokens=a.usage.input_tokens??0,r.outputTokens=a.usage.output_tokens??0,r.cacheReadTokens=a.usage.cache_read_input_tokens??0,r.cacheCreationTokens=a.usage.cache_creation_input_tokens??0),a.subtype==="success"&&(s=a.result))}catch(a){let c=a instanceof Error?a.message:String(a);n.done=!0,n.success=!1,n.narrative=`Agent session error: ${c}`}finally{i?.()}return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${s.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:[...n.adaptations,...n.attributionNotes()],suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(a=>({index:a.index,text:Rn(a.text,t.scenario.params)})),recordedSteps:as(cs(ls(is(ss(os(n.steps,t.scenario,a=>Ye(qe(a,t.uniqueSeed??""))),t.scenario.params)),bt(Z(t.scenario))),tt(Z(t.scenario),t.uniqueSeed??"")),et(t.scenario)),announcedSteps:[...n.announced].sort((a,c)=>a-c),usage:r}}};function Mt(e){return`$${e.toFixed(e>=1?2:4)}`}function Pn(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function Cn(e){return e.plan?` \xB7 5-hour window ${Pn(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${Mt(e.costUsd)} at API rates`:` \xB7 ${Mt(e.costUsd)}`}function Ps(e,t){return e?t?`\u2248 ${Mt(e)}`:Mt(e):""}function Cs(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}import _s from"node:fs";import Ut from"node:path";function Lt(e){return JSON.stringify([e.role??null,e.name??null,e.nameRegex??null,e.label??null,e.text??null,e.testId??null,e.placeholder??null,e.frame??null])}function js(e,t){let n=new Map,r=new Set,s=new Map,o=Math.min(e.steps.length,t.steps.length);for(let a=0;a<o;a++){if(e.steps[a].kind==="assertion"||t.steps[a].kind==="assertion"||e.steps[a].gherkin!==t.steps[a].gherkin)continue;let c=e.steps[a].actions,d=t.steps[a].actions;if(c.length===d.length)for(let m=0;m<c.length;m++){let l=c[m],f=d[m];if(l.action!==f.action||pe.has(l.action)||!l.target||!f.target)continue;let S=Lt(l.target),h=JSON.stringify(f.target);s.has(S)&&s.get(S)!==h&&r.add(S),s.set(S,h),S!==Lt(f.target)&&n.set(S,{from:l.target,to:f.target})}}let i=[...n.entries()];return{changes:i.filter(([a])=>!r.has(a)).map(([,a])=>a),ambiguous:i.filter(([a])=>r.has(a)).map(([,a])=>a.from)}}function qi(e){let t=Ut.join(e,".saffron","cache");if(!_s.existsSync(t))return[];let n=[];for(let r of _s.readdirSync(t,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(Ut.join(t,s))}return n}function Fs(e,t,n,r){if(t.length===0)return[];let s=new Map,o=new Set;for(let a of t){let c=Lt(a.from),d=s.get(c);d&&JSON.stringify(d.to)!==JSON.stringify(a.to)&&o.add(c),s.set(c,a)}for(let a of o)s.delete(a);let i=[];for(let a of qi(e)){if(Ut.resolve(a)===Ut.resolve(n))continue;let c=K(a);if(!c)continue;let d=[],m=!1;for(let l of c.steps){if(l.kind==="assertion")continue;let f=0;for(let S of l.actions){if(!S.target||pe.has(S.action))continue;let h=s.get(Lt(S.target));h&&(f++,r||(S.target=structuredClone(h.to),m=!0))}f>0&&d.push({gherkin:l.gherkin,count:f})}d.length>0&&(m&&Le(a,c),i.push({file:a,scenario:c.scenario,feature:c.feature,steps:d}))}return i}function Bt(e){return e.role?`${e.role} "${e.name??e.nameRegex??""}"`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testId "${e.testId}"`:(e.fallbackSelectors??[]).join(",")||"(empty target)"}import Is from"node:fs";import zt from"node:path";function Os(e,t,n,r,s){let o=X(zt.join(e,t),e,s).find(c=>c.name===n);if(!o)return`scenario "${n}" is no longer in ${t}`;let i=o.steps.map(c=>c.text);if(i.length!==r.length)return`the scenario has ${i.length} steps now and had ${r.length} when this was recorded`;let a=i.findIndex((c,d)=>c!==r[d]);return a<0?void 0:`step ${a+1} now reads "${i[a]}" and was recorded as "${r[a]}"`}function Ds(e,t,n,r,s,o){let i={applied:[],skipped:[],appliedIndices:[]},a=X(zt.join(e,t),e,s).find(l=>l.name===n);if(!a){for(let l of r)i.skipped.push({index:l.index,reason:`scenario "${n}" not found in ${t}`});return i}let c=new Map;for(let l of r){let f=a.steps[l.index]?.source;if(!f||f.fromBackground)continue;let S=`${f.file}:${f.line}`;c.set(S,(c.get(S)??new Set).add(l.text))}let d=new Map,m=new Set;for(let l of r){let f=a.steps[l.index];if(!f?.source){i.skipped.push({index:l.index,reason:`no step at index ${l.index}`});continue}if(o&&o[l.index]!==f.text){i.skipped.push({index:l.index,reason:`step ${l.index+1} now reads "${f.text}", not the "${o[l.index]??"(nothing)"}" this edit was written for`});continue}let{file:S,line:h,fromStepSet:y,fromBackground:w}=f.source;if(w){i.skipped.push({index:l.index,reason:`"${f.text}" is a Background step shared across scenarios; edit it manually`});continue}let T=`${S}:${h}`,$=c.get(T);if($.size>1){i.skipped.push({index:l.index,reason:`${T}${y?` (StepSet "${y}")`:""} is reached by several steps and the edits disagree: ${[...$].map(A=>`"${A}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(m.has(T)){i.appliedIndices.push(l.index);continue}let p=d.get(S);p||(p=Is.readFileSync(zt.join(e,S),"utf8").split(`
95
+ `),d.set(S,p));let u=p[h-1],g=u?.match(/^(\s*)(\S+)\s+(.*)$/);if(!g||g[3]!==f.text){i.skipped.push({index:l.index,reason:`${S}:${h} no longer matches the recorded step text`});continue}let b=`${g[1]}${g[2]} ${l.text}`;p[h-1]=b,m.add(T),i.appliedIndices.push(l.index),i.applied.push({file:S,line:h,from:u.trim(),to:b.trim(),fromStepSet:y})}for(let[l,f]of d)i.applied.some(S=>S.file===l)&&Is.writeFileSync(zt.join(e,l),f.join(`
96
+ `));return i}import jn from"node:fs";import Fn from"node:path";import Vi from"node:fs";import Hi from"node:path";var I=e=>e.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;"),Ms="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",Wi="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 Ji(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
97
+ <path d="${Ms}" fill="#F4A300"/>
98
+ <path d="${Wi}" fill="#E7E9EC"/>
99
+ </svg>`}function Gi(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${Ms}" fill="#F4A300"/></svg>`}var Ki={green:"passed",yellow:"pending review",red:"failed"};function Yi(e){return e.hooks?.length?`<div class="panel"><div class="panel-title">setup and teardown</div>${e.hooks.map(n=>{let r=n.exitCode===0,s=r?"ok":n.timedOut?"timed out":n.exitCode===null?"could not start":`exit ${n.exitCode}`;return`<div class="mono">${r?"\u2713":"\u2717"} ${n.phase} \xB7 ${I(n.command)} \xB7 ${s} \xB7 ${ft(n.durationMs)}</div>`}).join("")}</div>`:""}function ft(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function pt(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}function he(e,t,n,r="",s="",o=""){return`<div class="kpi">
97
100
  <div class="kpi-label">${e}</div>
98
101
  <div class="kpi-value ${r}">${t}</div>
99
102
  <div class="kpi-sub ${s}">${n}</div>${o}
100
- </div>`}function $n(e,t){if(e.length<2)return"";let n=108,r=22,s=Math.min(...e),i=Math.max(...e)-s||1,a=e.map((c,d)=>`${(d/(e.length-1)*n).toFixed(1)},${(r-2-(c-s)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${t}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var bs=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function ji(e,t){let n=e.totals,r=e.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${bs(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,c=r?.prev?`${bs(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${it(t)}`:`${it(t)} total runtime`;return`<section class="kpis">
101
- ${ge("pass rate",`${s}<span class="unit">%</span>`,a,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",$n(r?.series.passRate??[],"var(--green)"))}
102
- ${ge("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
103
- ${ge("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
104
- ${ge("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
105
- ${ge("ai calls",String(n.aiCalls),`${ot(i)} in+out tokens`,n.aiCalls>0?"":"green")}
106
- ${ge("cache traffic",ot(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${ot(n.cacheReadTokens)} read \xB7 ${ot(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
107
- ${n.plan?ge("plan usage",`${vn(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,fs(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+ge("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${c}`,"","",$n(r?.series.costUsd??[],"var(--gold)")):ge("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,c,"","",$n(r?.series.costUsd??[],"var(--gold)"))}
108
- </section>`}function Ci(e){let t=e.trends;if(!t)return"";let n=[];return t.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${t.chronic.map(r=>`<li>${F(r.feature)} \u203A ${F(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),t.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${t.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),t.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${t.topIssues.map(r=>`<li>${r.count}\xD7: ${F(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
109
- `)}</section>`:""}function Fi(e){return`<ol class="steps">${e.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
103
+ </div>`}function _n(e,t){if(e.length<2)return"";let n=108,r=22,s=Math.min(...e),i=Math.max(...e)-s||1,a=e.map((c,d)=>`${(d/(e.length-1)*n).toFixed(1)},${(r-2-(c-s)/i*(r-4)).toFixed(1)}`).join(" ");return`<svg class="spark" viewBox="0 0 ${n} ${r}" width="${n}" height="${r}" aria-hidden="true"><polyline points="${a}" fill="none" stroke="${t}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var Ns=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function Qi(e,t){let n=e.totals,r=e.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",i=n.inputTokens+n.outputTokens,a=r?.prev?`${Ns(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,c=r?.prev?`${Ns(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${ft(t)}`:`${ft(t)} total runtime`;return`<section class="kpis">
104
+ ${he("pass rate",`${s}<span class="unit">%</span>`,a,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",_n(r?.series.passRate??[],"var(--green)"))}
105
+ ${he("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
106
+ ${he("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
107
+ ${he("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
108
+ ${he("ai calls",String(n.aiCalls),`${pt(i)} in+out tokens`,n.aiCalls>0?"":"green")}
109
+ ${he("cache traffic",pt(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${pt(n.cacheReadTokens)} read \xB7 ${pt(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
110
+ ${n.plan?he("plan usage",`${Pn(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,Cs(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+he("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${c}`,"","",_n(r?.series.costUsd??[],"var(--gold)")):he("ai cost",`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,c,"","",_n(r?.series.costUsd??[],"var(--gold)"))}
111
+ </section>`}function Zi(e){let t=e.trends;if(!t)return"";let n=[];return t.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${t.chronic.map(r=>`<li>${I(r.feature)} \u203A ${I(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),t.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${t.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),t.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${t.topIssues.map(r=>`<li>${r.count}\xD7: ${I(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
112
+ `)}</section>`:""}function Xi(e){return`<ol class="steps">${e.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
110
113
  ${r}
111
114
  <div class="step-body">
112
- <span class="step-text">${F(n.gherkin)}</span>
115
+ <span class="step-text">${I(n.gherkin)}</span>
113
116
  ${n.kind==="assertion"?'<span class="tag">assert</span>':""}
114
- ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${F(n.fromStepSet)}</span>`:""}
117
+ ${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${I(n.fromStepSet)}</span>`:""}
115
118
  ${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
116
119
  ${n.retries?`<span class="tag gold" title="an action failed and was re-attempted with backoff before the step settled">retried \xD7${n.retries}</span>`:""}
117
- ${n.error?`<div class="step-error">${F(n.error)}</div>`:""}
120
+ ${n.error?`<div class="step-error">${I(n.error)}</div>`:""}
118
121
  </div>
119
122
  </li>`}).join(`
120
- `)}</ol>`}function Ii(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
123
+ `)}</ol>`}function ea(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
121
124
  <div class="panel-title gold">suggested .feature edit</div>
122
- <div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${F(r)}</div><div class="diff-line add">+ ${F(n.text)}</div>`}).join("")}</div>
125
+ <div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${I(r)}</div><div class="diff-line add">+ ${I(n.text)}</div>`}).join("")}</div>
123
126
  <div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
124
- </div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${F(e.suggestedFeatureEdit)}</pre></div>`:""}function Oi(e){let t=e.proposalDiff;if(!t)return"";let n=t.steps.filter(c=>c.change!=="same"||!t.hasCommitted).map(c=>{let d=`<div class="diff-step">${c.kind==="assertion"?'<span class="pill gold">assertion</span> ':""}${F(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${F(c.fromStepSet)}")</span>`:""}</div>`,h=c.actions.flatMap(l=>l.change==="same"?[`<div class="diff-line"> ${F(l.before??"")}</div>`]:l.change==="added"?[`<div class="diff-line add">+ ${F(l.after??"")}</div>`]:l.change==="removed"?[`<div class="diff-line del">\u2212 ${F(l.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${F(l.before??"")}</div>`,`<div class="diff-line add">+ ${F(l.after??"")}</div>`,...(l.details??[]).map(f=>`<div class="diff-line detail">${F(f)}</div>`)]).join("");return`${d}<div class="diff">${h}</div>`}).join(""),{added:r,removed:s,changed:o,assertions:i}=t.summary,a=r+s+o===0?"No action changed.":`${o} changed, ${r} added, ${s} removed.${i>0?` ${i} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${F(a)}</div></div>`}function Di(e,t){let n=e.usage.inputTokens+e.usage.outputTokens;if(e.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=ps(e.usage.costUsd,t),s=r?` \xB7 ${r}`:"";return`<span class="pill gold">${e.usage.aiCalls} ai \xB7 ${ot(n)} tok${s}</span>`}function Ni(e,t,n){return`<details class="card" ${e.status!=="green"?"open":""}>
127
+ </div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${I(e.suggestedFeatureEdit)}</pre></div>`:""}function ta(e){let t=e.proposalDiff;if(!t)return"";let n=t.steps.filter(c=>c.change!=="same"||!t.hasCommitted).map(c=>{let d=`<div class="diff-step">${c.kind==="assertion"?'<span class="pill gold">assertion</span> ':""}${I(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${I(c.fromStepSet)}")</span>`:""}</div>`,m=c.actions.flatMap(l=>l.change==="same"?[`<div class="diff-line"> ${I(l.before??"")}</div>`]:l.change==="added"?[`<div class="diff-line add">+ ${I(l.after??"")}</div>`]:l.change==="removed"?[`<div class="diff-line del">\u2212 ${I(l.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${I(l.before??"")}</div>`,`<div class="diff-line add">+ ${I(l.after??"")}</div>`,...(l.details??[]).map(f=>`<div class="diff-line detail">${I(f)}</div>`)]).join("");return`${d}<div class="diff">${m}</div>`}).join(""),{added:r,removed:s,changed:o,assertions:i}=t.summary,a=r+s+o===0?"No action changed.":`${o} changed, ${r} added, ${s} removed.${i>0?` ${i} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${I(a)}</div></div>`}function na(e,t){let n=e.usage.inputTokens+e.usage.outputTokens;if(e.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=Ps(e.usage.costUsd,t),s=r?` \xB7 ${r}`:"";return`<span class="pill gold">${e.usage.aiCalls} ai \xB7 ${pt(n)} tok${s}</span>`}function ra(e,t,n){return`<details class="card" ${e.status!=="green"?"open":""}>
125
128
  <summary>
126
- <span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${Pi[e.status]}</span>
127
- <span class="card-title">${F(e.scenario)}</span>
129
+ <span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${Ki[e.status]}</span>
130
+ <span class="card-title">${I(e.scenario)}</span>
128
131
  <span class="card-meta">
129
- <span class="m">${F(e.feature)}</span>
132
+ <span class="m">${I(e.feature)}</span>
130
133
  <span class="m-sep">\xB7</span>
131
- <span class="m">${F(e.source)}</span>
134
+ <span class="m">${I(e.source)}</span>
132
135
  <span class="m-sep">\xB7</span>
133
- <span class="m">${it(e.durationMs)}</span>
134
- ${Di(e,t)}
136
+ <span class="m">${ft(e.durationMs)}</span>
137
+ ${na(e,t)}
135
138
  </span>
136
139
  </summary>
137
140
  <div class="card-body">
138
- ${Fi(e)}
139
- ${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${F(e.narrative)}</p></div>`:""}
140
- ${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(r=>`<li>${F(r)}</li>`).join("")}</ul></div>`:""}
141
- ${Ii(e)}
142
- ${Oi(e)}
143
- ${e.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${e.verified===!0?' <span class="pill green">verified</span>':e.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${F(e.proposalFile)}</p>${e.verified===!1&&e.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${F(e.proofError.split(`
141
+ ${Xi(e)}
142
+ ${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${I(e.narrative)}</p></div>`:""}
143
+ ${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(r=>`<li>${I(r)}</li>`).join("")}</ul></div>`:""}
144
+ ${ea(e)}
145
+ ${ta(e)}
146
+ ${e.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${e.verified===!0?' <span class="pill green">verified</span>':e.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${I(e.proposalFile)}</p>${e.verified===!1&&e.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${I(e.proofError.split(`
144
147
  `)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
145
- ${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${F(e.error)}</p></div>`:""}
146
- ${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([r,s])=>`<div class="mono">${F(r)} = ${F(s)}</div>`).join("")}</div>`:""}
147
- ${Mi(e,n)}
148
+ ${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${I(e.error)}</p></div>`:""}
149
+ ${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([r,s])=>`<div class="mono">${I(r)} = ${I(s)}</div>`).join("")}</div>`:""}
150
+ ${e.proofUniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in the proof replay</div>${Object.entries(e.proofUniques).map(([r,s])=>`<div class="mono">${I(r)} = ${I(s)}</div>`).join("")}</div>`:""}
151
+ ${sa(e,n)}
148
152
  </div>
149
- </details>`}function Mi(e,t){let n=(e.evidence??[]).flatMap(r=>{if(!t)return[];try{let s=$i.readFileSync(Ri.join(t,r.file)).toString("base64"),o=r.kind==="failure"?"the page when it failed":"the page when the cached step stopped working, before the agent took over";return[`<figure class="shot"><figcaption>${F(o)}${r.step?`: <span class="mono">${F(r.step)}</span>`:""}</figcaption><a href="data:image/jpeg;base64,${s}" target="_blank" rel="noopener"><img alt="${F(o)}" src="data:image/jpeg;base64,${s}"/></a><div class="panel-hint">${F(r.file)}</div></figure>`]}catch{return[]}});return n.length===0?"":`<div class="panel ${e.status==="red"?"accent-red":"accent-gold"}"><div class="panel-title ${e.status==="red"?"red":"gold"}">screenshot</div>${n.join("")}</div>`}function Mt(e,t){let n=e.totals,r=new Date(e.startedAt),s=new Date(e.finishedAt).getTime()-r.getTime(),o=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
153
+ </details>`}function sa(e,t){let n=(e.evidence??[]).flatMap(r=>{if(!t)return[];try{let s=Vi.readFileSync(Hi.join(t,r.file)).toString("base64"),o=r.kind==="failure"?"the page when it failed":"the page when the cached step stopped working, before the agent took over";return[`<figure class="shot"><figcaption>${I(o)}${r.step?`: <span class="mono">${I(r.step)}</span>`:""}</figcaption><a href="data:image/jpeg;base64,${s}" target="_blank" rel="noopener"><img alt="${I(o)}" src="data:image/jpeg;base64,${s}"/></a><div class="panel-hint">${I(r.file)}</div></figure>`]}catch{return[]}});return n.length===0?"":`<div class="panel ${e.status==="red"?"accent-red":"accent-gold"}"><div class="panel-title ${e.status==="red"?"red":"gold"}">screenshot</div>${n.join("")}</div>`}function qt(e,t){let n=e.totals,r=new Date(e.startedAt),s=new Date(e.finishedAt).getTime()-r.getTime(),o=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
150
154
  <html lang="en">
151
155
  <head>
152
156
  <meta charset="utf-8"/>
@@ -271,70 +275,70 @@ ${e.completedSteps.map(i=>` \u2713 ${i}`).join(`
271
275
  <div class="wrap">
272
276
  <header class="top">
273
277
  <div class="brand">
274
- ${Ai(38)}
278
+ ${Ji(38)}
275
279
  <div>
276
280
  <div class="brand-name">saffron</div>
277
281
  <div class="brand-sub">test run report</div>
278
282
  </div>
279
283
  </div>
280
284
  <div class="run-meta">
281
- <span>${F(o)}</span>
285
+ <span>${I(o)}</span>
282
286
  <span>\xB7</span>
283
- <span>${it(s)}</span>
284
- ${e.baseURL?`<span class="chip base">${F(e.baseURL)}</span>`:""}
287
+ <span>${ft(s)}</span>
288
+ ${e.baseURL?`<span class="chip base">${I(e.baseURL)}</span>`:""}
285
289
  ${e.shards?`<span class="chip">merged from ${e.shards} shards</span>`:""}
286
290
  </div>
287
291
  </header>
288
292
 
289
- ${ji(e,s)}
290
- ${Ci(e)}
291
- ${_i(e)}
293
+ ${Qi(e,s)}
294
+ ${Zi(e)}
295
+ ${Yi(e)}
292
296
 
293
297
  <div class="section-head">
294
298
  <span class="section-title">scenarios \xB7 ${n.scenarios}</span>
295
- <span class="section-title">saffron v${F(e.version)}</span>
299
+ <span class="section-title">saffron v${I(e.version)}</span>
296
300
  </div>
297
- ${e.scenarios.map(i=>Ni(i,!!e.totals.plan,t)).join(`
301
+ ${e.scenarios.map(i=>ra(i,!!e.totals.plan,t)).join(`
298
302
  `)}
299
303
 
300
- <footer>${Ei(11)} generated by saffron v${F(e.version)} \xB7 ${F(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
304
+ <footer>${Gi(11)} generated by saffron v${I(e.version)} \xB7 ${I(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
301
305
  </div>
302
306
  </body>
303
307
  </html>
304
- `}function An(e){let t={scenarios:e.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},n=new Set;for(let r of e){for(let s of r.usage.models??[])n.add(s);t[r.status]+=1,t.aiCalls+=r.usage.aiCalls,t.inputTokens+=r.usage.inputTokens,t.outputTokens+=r.usage.outputTokens,t.cacheReadTokens+=r.usage.cacheReadTokens,t.cacheCreationTokens+=r.usage.cacheCreationTokens??0,t.costUsd+=r.usage.costUsd??0}return n.size>0&&(t.models=[...n].sort()),t}function En(e,t,n){return{tool:"saffron",version:xe(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:An(e),scenarios:e.map(r=>({feature:r.scenario.featurePath,scenario:r.scenario.displayName,tags:[...r.scenario.tags],status:r.status,source:r.source,durationMs:r.durationMs,usage:r.usage,steps:r.stepResults,narrative:r.narrative,adaptations:r.adaptations,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:r.featureEdits,verified:r.verified,proofError:r.proofError,proposalFile:r.proposalFile,proposalDiff:r.proposalDiff,error:r.error,evidence:r.evidence,uniques:r.uniques}))}}function Pn(e,t){let n=Tn.join(e,".saffron","reports");Rn.mkdirSync(n,{recursive:!0});let r=Tn.join(n,"latest.json"),s=Tn.join(n,"latest.html");return Rn.writeFileSync(r,`${JSON.stringify(t,null,2)}
305
- `),Rn.writeFileSync(s,Mt(t,e)),{json:r,html:s}}import Li from"node:crypto";import at from"node:fs";import Lt from"node:path";var ws=["scenario","file"];function vs(e){let t=/^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(e),n=t?Number(t[1]):NaN,r=t?Number(t[2]):NaN;if(!t||r<1||n<1||n>r)throw new Error(`invalid --shard "${e}": use <index>/<total>, e.g. --shard 2/4 (index from 1 to total)`);return{index:n,total:r}}function ks(e){if(!ws.includes(e))throw new Error(`invalid --shard-by "${e}": use ${ws.map(t=>`"${t}"`).join(" or ")}`);return e}function Ui(e){return`shard-${e.index}-of-${e.total}`}var Bi=e=>`${e.featurePath}\0${e.displayName}`;function $s(e,t){let n=t.by??"scenario",r=e.map(Bi),s=new Set;if(n==="file"){let a=new Map;e.forEach((h,l)=>a.set(h.featurePath,[...a.get(h.featurePath)??[],l]));let c=new Array(t.total).fill(0),d=[...a].sort((h,l)=>l[1].length-h[1].length||(h[0]<l[0]?-1:h[0]>l[0]?1:0));for(let[,h]of d){let l=c.indexOf(Math.min(...c));if(c[l]+=h.length,l===t.index-1)for(let f of h)s.add(f)}}else e.map((c,d)=>d).sort((c,d)=>r[c]<r[d]?-1:r[c]>r[d]?1:0).forEach((c,d)=>{d%t.total===t.index-1&&s.add(c)});let o=e.map((a,c)=>c).filter(a=>s.has(a)),i=Li.createHash("sha256").update([...r].sort().join(`
306
- `)).digest("hex").slice(0,12);return{scenarios:o.map(a=>e[a]),info:{index:t.index,total:t.total,by:n,suite:i,suiteSize:e.length,positions:o}}}function Rs(e=process.env){return e.SAFFRON_RUN_ID||e.GITHUB_RUN_ID&&`github:${e.GITHUB_RUN_ID}`||e.CI_PIPELINE_ID&&`gitlab:${e.CI_PIPELINE_ID}`||e.BUILD_BUILDID&&`azure:${e.BUILD_BUILDID}`||e.CIRCLE_WORKFLOW_ID&&`circleci:${e.CIRCLE_WORKFLOW_ID}`||void 0}function _n(e,t){let n=Lt.join(e,".saffron","reports");at.mkdirSync(n,{recursive:!0});let r=Lt.join(n,`${Ui(t.shard)}.json`);return at.writeFileSync(r,`${JSON.stringify(t,null,2)}
307
- `),r}var zi=e=>{let t=e;return!!t&&t.tool==="saffron"&&!!t.shard&&typeof t.shard.index=="number"&&typeof t.shard.total=="number"&&Array.isArray(t.shard.positions)&&Array.isArray(t.scenarios)&&!!t.history&&Array.isArray(t.history.scenarios)},Vi=/^shard-\d+-of-\d+\.json$/;function Ts(e){let t=[],n=(r,s)=>{let o=at.statSync(r,{throwIfNoEntry:!1});if(!o)throw new Error(`not found: ${r}`);if(o.isDirectory())for(let i of at.readdirSync(r).sort())n(Lt.join(r,i),!1);else(s||Vi.test(Lt.basename(r)))&&t.push(r)};for(let r of e)n(r,!0);return t.map(r=>{let s;try{s=JSON.parse(at.readFileSync(r,"utf8"))}catch(o){throw new Error(`cannot read shard report ${r}: ${o instanceof Error?o.message:String(o)}`)}if(!zi(s))throw new Error(`${r} is not a shard report (written by saffron run --shard)`);return{file:r,report:s}})}var Ut=class extends Error{};function As(e){if(e.length===0)throw new Ut("no shard reports found. Shards write .saffron/reports/shard-<i>-of-<n>.json");let t=e[0].report.shard,n=[],r=S=>S.map(g=>g.file).join(", "),s=new Set(e.map(S=>S.report.shard.total));s.size>1&&n.push(`shards disagree on the shard count (${[...s].sort().join(", ")}): ${r(e)}`),new Set(e.map(S=>`${S.report.shard.suite}/${S.report.shard.suiteSize}`)).size>1&&n.push("shards ran different suites (different scenarios or filters). Every shard must run the same paths and --filter");let i=new Set(e.map(S=>S.report.shard.by??"scenario"));i.size>1&&n.push(`shards split the suite in different ways (--shard-by ${[...i].sort().join(" and ")}): every shard must use the same`);let a=new Set(e.map(S=>S.report.shard.ciRun??""));a.size>1&&n.push(`shards come from different CI runs (${[...a].map(S=>S||"none recorded").join(", ")})`);let c=new Map;for(let S of e)c.set(S.report.shard.index,[...c.get(S.report.shard.index)??[],S]);for(let[S,g]of[...c].sort((y,u)=>y[0]-u[0]))g.length>1&&n.push(`shard ${S}/${t.total} appears ${g.length} times: ${r(g)}`);if(s.size===1){let S=Array.from({length:t.total},(g,y)=>y+1).filter(g=>!c.has(g));S.length>0&&n.push(`missing ${S.length===1?"shard":"shards"} ${S.map(g=>`${g}/${t.total}`).join(", ")}. A merge without ${S.length===1?"it":"them"} would under-count the run`)}if(n.length===0){let S=new Set,g=0;for(let y of e){let{positions:u}=y.report.shard;(u.length!==y.report.scenarios.length||u.length!==y.report.history.scenarios.length)&&n.push(`${y.file} is inconsistent: ${u.length} positions for ${y.report.scenarios.length} scenarios`);for(let b of u)S.has(b)&&n.push(`two shards both ran scenario ${b+1} of the suite`),S.add(b);g+=u.length}n.length===0&&g!==t.suiteSize&&n.push(`the shards hold ${g} scenarios but the suite has ${t.suiteSize}`)}if(n.length>0)throw new Ut(n.join(`
308
- `));let d=e.sort((S,g)=>S.report.shard.index-g.report.shard.index).flatMap(({report:S})=>S.shard.positions.map((g,y)=>({position:g,scenario:S.scenarios[y],history:S.history.scenarios[y]}))).sort((S,g)=>S.position-g.position),h=e.map(S=>S.report),l=h.map(S=>S.startedAt).sort()[0],f=h.map(S=>S.finishedAt).sort().at(-1),m=d.map(S=>S.scenario),p=h.flatMap(S=>S.hooks??[]),x={tool:"saffron",version:h[0].version,startedAt:l,finishedAt:f,baseURL:h.find(S=>S.baseURL)?.baseURL,totals:An(m),scenarios:m,shards:t.total};p.length>0&&(x.hooks=p);let w=d.map(S=>S.history),R={runId:l,startedAt:l,finishedAt:f,baseURL:x.baseURL,green:w.filter(S=>S.status==="green").length,yellow:w.filter(S=>S.status==="yellow").length,red:w.filter(S=>S.status==="red").length,aiCalls:w.reduce((S,g)=>S+g.aiCalls,0),costUsd:w.reduce((S,g)=>S+g.costUsd,0),divergentSteps:Math.max(...h.map(S=>S.history.divergentSteps)),scenarios:w};return{report:x,history:R}}function Es(e){let t=e===void 0?[]:Array.isArray(e)?e:[e],n=new Set;for(let r of t)for(let s of r.split(/[\s,|]+/)){let o=s.trim();o&&n.add(o.startsWith("@")?o:`@${o}`)}return[...n]}function Ps(e,t){return t.length===0?e:e.filter(n=>n.tags.some(r=>t.includes(r)))}function _s(e,t){return[...t??[],e]}import{spawn as qi}from"node:child_process";function jn(e,t){if(e==null)return[];let n=Array.isArray(e)?e:[e];for(let r of n)if(typeof r!="string"||r.trim()==="")throw new Error(`"${t}" in saffron.config.json must be a command, or a list of commands, e.g. "${t}": "npm run seed"`);return n}function Hi(e,t,n){let r=Date.now();return new Promise(s=>{let o=process.platform!=="win32",i=qi(t,{cwd:n.cwd,shell:!0,detached:o,env:{...process.env,...n.env},stdio:["ignore",process.stderr,process.stderr]}),a=!1,c=!1,d=()=>{try{o&&i.pid?process.kill(-i.pid,"SIGKILL"):i.kill("SIGKILL")}catch{}},h=setTimeout(()=>{a=!0,d()},n.timeoutMs),l=()=>{c=!0,d()};n.signal?.addEventListener("abort",l,{once:!0});let f=m=>{clearTimeout(h),n.signal?.removeEventListener("abort",l),s({phase:e,command:t,exitCode:m,durationMs:Date.now()-r,...a?{timedOut:a}:{},...c?{cancelled:c}:{}})};i.on("error",()=>f(null)),i.on("exit",m=>f(a||c?null:m))})}var Bt=e=>e.exitCode!==0;async function Cn(e,t,n){let r=[];for(let s of t){if(n.signal?.aborted)break;console.error(`saffron ${e}: ${s}`);let o=await Hi(e,s,n);if(r.push(o),o.cancelled||Bt(o)&&(console.error(`saffron ${e} failed: ${Fn(o,n.timeoutMs)}`),e==="setup"))break}return r}function Fn(e,t){return e.cancelled?`"${e.command}" was interrupted`:e.timedOut?`"${e.command}" was stopped after ${Math.round(t/1e3)}s (hookTimeoutMs)`:e.exitCode===null?`"${e.command}" could not be started`:`"${e.command}" exited with code ${e.exitCode}`}import I from"node:fs";import L from"node:path";var ct={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},Wi=["claude","agents"],Fs="<!-- saffron:start -->",On="<!-- saffron:end -->";function Ji(){return[Fs,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- `npx saffron status` shows what is cached, what awaits review, the tags"," and vocabulary health; editing a cached scenario's text re-records it.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`"," (`--all`, or chosen files) and `npx saffron reject`.","- Secrets go in `{env:VAR}` tokens, never in files.","- Shared test data (accounts, names, enum values) goes in `data/*.json`,",' referenced as `{data:file.path.key}`; check a whole list with "every',' {data:file.list}"; read Examples rows from a file with'," `Examples: {data:file}` (.saffron only). Use `{unique:name}` for a value"," that must be new on every run. Never put secrets in `data/`.",On,""].join(`
309
- `)}function js(e,t){if(!I.existsSync(e))return I.writeFileSync(e,t),"created";let n=I.readFileSync(e,"utf8"),r=n.indexOf(Fs),s=n.indexOf(On);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+t.trimEnd()+n.slice(s+On.length);return i===n?"unchanged":(I.writeFileSync(e,i),"updated")}let o=n.endsWith(`
308
+ `}function In(e){let t={scenarios:e.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},n=new Set;for(let r of e){for(let s of r.usage.models??[])n.add(s);t[r.status]+=1,t.aiCalls+=r.usage.aiCalls,t.inputTokens+=r.usage.inputTokens,t.outputTokens+=r.usage.outputTokens,t.cacheReadTokens+=r.usage.cacheReadTokens,t.cacheCreationTokens+=r.usage.cacheCreationTokens??0,t.costUsd+=r.usage.costUsd??0}return n.size>0&&(t.models=[...n].sort()),t}function On(e,t,n){return{tool:"saffron",version:we(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:In(e),scenarios:e.map(r=>({feature:r.scenario.featurePath,scenario:r.scenario.displayName,tags:[...r.scenario.tags],status:r.status,source:r.source,durationMs:r.durationMs,usage:r.usage,steps:r.stepResults,narrative:r.narrative,adaptations:r.adaptations,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:r.featureEdits,verified:r.verified,proofError:r.proofError,proposalFile:r.proposalFile,proposalDiff:r.proposalDiff,error:r.error,evidence:r.evidence,uniques:r.uniques,proofUniques:r.proofUniques}))}}function Dn(e,t){let n=Fn.join(e,".saffron","reports");jn.mkdirSync(n,{recursive:!0});let r=Fn.join(n,"latest.json"),s=Fn.join(n,"latest.html");return jn.writeFileSync(r,`${JSON.stringify(t,null,2)}
309
+ `),jn.writeFileSync(s,qt(t,e)),{json:r,html:s}}import oa from"node:crypto";import ut from"node:fs";import Vt from"node:path";var Us=["scenario","file"];function Ls(e){let t=/^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(e),n=t?Number(t[1]):NaN,r=t?Number(t[2]):NaN;if(!t||r<1||n<1||n>r)throw new Error(`invalid --shard "${e}": use <index>/<total>, e.g. --shard 2/4 (index from 1 to total)`);return{index:n,total:r}}function Bs(e){if(!Us.includes(e))throw new Error(`invalid --shard-by "${e}": use ${Us.map(t=>`"${t}"`).join(" or ")}`);return e}function ia(e){return`shard-${e.index}-of-${e.total}`}var aa=e=>`${e.featurePath}${e.displayName}`;function zs(e,t){let n=t.by??"scenario",r=e.map(aa),s=new Set;if(n==="file"){let a=new Map;e.forEach((m,l)=>a.set(m.featurePath,[...a.get(m.featurePath)??[],l]));let c=new Array(t.total).fill(0),d=[...a].sort((m,l)=>l[1].length-m[1].length||(m[0]<l[0]?-1:m[0]>l[0]?1:0));for(let[,m]of d){let l=c.indexOf(Math.min(...c));if(c[l]+=m.length,l===t.index-1)for(let f of m)s.add(f)}}else e.map((c,d)=>d).sort((c,d)=>r[c]<r[d]?-1:r[c]>r[d]?1:0).forEach((c,d)=>{d%t.total===t.index-1&&s.add(c)});let o=e.map((a,c)=>c).filter(a=>s.has(a)),i=oa.createHash("sha256").update([...r].sort().join(`
310
+ `)).digest("hex").slice(0,12);return{scenarios:o.map(a=>e[a]),info:{index:t.index,total:t.total,by:n,suite:i,suiteSize:e.length,positions:o}}}function qs(e=process.env){return e.SAFFRON_RUN_ID||e.GITHUB_RUN_ID&&`github:${e.GITHUB_RUN_ID}`||e.CI_PIPELINE_ID&&`gitlab:${e.CI_PIPELINE_ID}`||e.BUILD_BUILDID&&`azure:${e.BUILD_BUILDID}`||e.CIRCLE_WORKFLOW_ID&&`circleci:${e.CIRCLE_WORKFLOW_ID}`||void 0}function Nn(e,t){let n=Vt.join(e,".saffron","reports");ut.mkdirSync(n,{recursive:!0});let r=Vt.join(n,`${ia(t.shard)}.json`);return ut.writeFileSync(r,`${JSON.stringify(t,null,2)}
311
+ `),r}var ca=e=>{let t=e;return!!t&&t.tool==="saffron"&&!!t.shard&&typeof t.shard.index=="number"&&typeof t.shard.total=="number"&&Array.isArray(t.shard.positions)&&Array.isArray(t.scenarios)&&!!t.history&&Array.isArray(t.history.scenarios)},la=/^shard-\d+-of-\d+\.json$/;function Vs(e){let t=[],n=(r,s)=>{let o=ut.statSync(r,{throwIfNoEntry:!1});if(!o)throw new Error(`not found: ${r}`);if(o.isDirectory())for(let i of ut.readdirSync(r).sort())n(Vt.join(r,i),!1);else(s||la.test(Vt.basename(r)))&&t.push(r)};for(let r of e)n(r,!0);return t.map(r=>{let s;try{s=JSON.parse(ut.readFileSync(r,"utf8"))}catch(o){throw new Error(`cannot read shard report ${r}: ${o instanceof Error?o.message:String(o)}`)}if(!ca(s))throw new Error(`${r} is not a shard report (written by saffron run --shard)`);return{file:r,report:s}})}var Ht=class extends Error{};function Hs(e){if(e.length===0)throw new Ht("no shard reports found. Shards write .saffron/reports/shard-<i>-of-<n>.json");let t=e[0].report.shard,n=[],r=p=>p.map(u=>u.file).join(", "),s=new Set(e.map(p=>p.report.shard.total));s.size>1&&n.push(`shards disagree on the shard count (${[...s].sort().join(", ")}): ${r(e)}`),new Set(e.map(p=>`${p.report.shard.suite}/${p.report.shard.suiteSize}`)).size>1&&n.push("shards ran different suites (different scenarios or filters). Every shard must run the same paths and --filter");let i=new Set(e.map(p=>p.report.shard.by??"scenario"));i.size>1&&n.push(`shards split the suite in different ways (--shard-by ${[...i].sort().join(" and ")}): every shard must use the same`);let a=new Set(e.map(p=>p.report.shard.ciRun??""));a.size>1&&n.push(`shards come from different CI runs (${[...a].map(p=>p||"none recorded").join(", ")})`);let c=new Map;for(let p of e)c.set(p.report.shard.index,[...c.get(p.report.shard.index)??[],p]);for(let[p,u]of[...c].sort((g,b)=>g[0]-b[0]))u.length>1&&n.push(`shard ${p}/${t.total} appears ${u.length} times: ${r(u)}`);if(s.size===1){let p=Array.from({length:t.total},(u,g)=>g+1).filter(u=>!c.has(u));p.length>0&&n.push(`missing ${p.length===1?"shard":"shards"} ${p.map(u=>`${u}/${t.total}`).join(", ")}. A merge without ${p.length===1?"it":"them"} would under-count the run`)}if(n.length===0){let p=new Set,u=0;for(let b of e){let{positions:A}=b.report.shard;(A.length!==b.report.scenarios.length||A.length!==b.report.history.scenarios.length)&&n.push(`${b.file} is inconsistent: ${A.length} positions for ${b.report.scenarios.length} scenarios`);for(let P of A)p.has(P)&&n.push(`two shards both ran scenario ${P+1} of the suite`),p.add(P);u+=A.length}let g=[...new Set(e.map(b=>b.report.baseURL??"(no base URL)"))];g.length>1&&n.push(`the shards ran against different base URLs: ${g.join(", ")}. Merge the shards of one environment at a time`),n.length===0&&u!==t.suiteSize&&n.push(`the shards hold ${u} scenarios but the suite has ${t.suiteSize}`)}if(n.length>0)throw new Ht(n.join(`
312
+ `));let d=e.sort((p,u)=>p.report.shard.index-u.report.shard.index).flatMap(({report:p})=>p.shard.positions.map((u,g)=>({position:u,scenario:p.scenarios[g],history:p.history.scenarios[g]}))).sort((p,u)=>p.position-u.position),m=e.map(p=>p.report),l=m.map(p=>p.startedAt).sort()[0],f=m.map(p=>p.finishedAt).sort().at(-1),S=d.map(p=>p.scenario),h=m.flatMap(p=>p.hooks??[]),y={tool:"saffron",version:m[0].version,startedAt:l,finishedAt:f,baseURL:m.find(p=>p.baseURL)?.baseURL,totals:In(S),scenarios:S,shards:t.total};h.length>0&&(y.hooks=h);let w=d.map(p=>p.history),$={runId:t.ciRun?`${t.ciRun}:${t.suite}:${y.baseURL??""}`:l,startedAt:l,finishedAt:f,baseURL:y.baseURL,green:w.filter(p=>p.status==="green").length,yellow:w.filter(p=>p.status==="yellow").length,red:w.filter(p=>p.status==="red").length,aiCalls:w.reduce((p,u)=>p+u.aiCalls,0),costUsd:w.reduce((p,u)=>p+u.costUsd,0),divergentSteps:Math.max(...m.map(p=>p.history.divergentSteps)),scenarios:w};return{report:y,history:$}}function Ws(e){let t=e===void 0?[]:Array.isArray(e)?e:[e],n=new Set;for(let r of t)for(let s of r.split(/[\s,|]+/)){let o=s.trim();o&&n.add(o.startsWith("@")?o:`@${o}`)}return[...n]}function Js(e,t){return t.length===0?e:e.filter(n=>n.tags.some(r=>t.includes(r)))}function Gs(e,t){return[...t??[],e]}import{spawn as da}from"node:child_process";function Mn(e,t){if(e==null)return[];let n=Array.isArray(e)?e:[e];for(let r of n)if(typeof r!="string"||r.trim()==="")throw new Error(`"${t}" in saffron.config.json must be a command, or a list of commands, e.g. "${t}": "npm run seed"`);return n}function pa(e,t,n){let r=Date.now();return new Promise(s=>{let o=process.platform!=="win32",i=da(t,{cwd:n.cwd,shell:!0,detached:o,env:{...process.env,...n.env},stdio:["ignore",process.stderr,process.stderr]}),a=!1,c=!1,d=()=>{try{o&&i.pid?process.kill(-i.pid,"SIGKILL"):i.kill("SIGKILL")}catch{}},m=setTimeout(()=>{a=!0,d()},n.timeoutMs),l=()=>{c=!0,d()};n.signal?.addEventListener("abort",l,{once:!0});let f=S=>{clearTimeout(m),n.signal?.removeEventListener("abort",l),s({phase:e,command:t,exitCode:S,durationMs:Date.now()-r,...a?{timedOut:a}:{},...c?{cancelled:c}:{}})};i.on("error",()=>f(null)),i.on("exit",S=>f(a||c?null:S))})}var Wt=e=>e.exitCode!==0;async function Un(e,t,n){let r=[];for(let s of t){if(n.signal?.aborted)break;console.error(`saffron ${e}: ${s}`);let o=await pa(e,s,n);if(r.push(o),o.cancelled||Wt(o)&&(console.error(`saffron ${e} failed: ${Ln(o,n.timeoutMs)}`),e==="setup"))break}return r}function Ln(e,t){return e.cancelled?`"${e.command}" was interrupted`:e.timedOut?`"${e.command}" was stopped after ${Math.round(t/1e3)}s (hookTimeoutMs)`:e.exitCode===null?`"${e.command}" could not be started`:`"${e.command}" exited with code ${e.exitCode}`}import O from"node:fs";import U from"node:path";var gt={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},fa=["claude","agents"],Qs="<!-- saffron:start -->",zn="<!-- saffron:end -->";function ua(){return[Qs,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- `npx saffron status` shows what is cached, what awaits review, the tags"," and vocabulary health; editing a cached scenario's text re-records it.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`"," (`--all`, or chosen files) and `npx saffron reject`.","- Secrets go in `{env:VAR}` tokens, never in files.","- Shared test data (accounts, names, enum values) goes in `data/*.json`,",' referenced as `{data:file.path.key}`; check a whole list with "every',' {data:file.list}"; read Examples rows from a file with'," `Examples: {data:file}` (.saffron only). Use `{unique:name}` for a value"," that must be new on every run. Never put secrets in `data/`.",zn,""].join(`
313
+ `)}function Ks(e,t){if(!O.existsSync(e))return O.writeFileSync(e,t),"created";let n=O.readFileSync(e,"utf8"),r=n.indexOf(Qs),s=n.indexOf(zn);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+t.trimEnd()+n.slice(s+zn.length);return i===n?"unchanged":(O.writeFileSync(e,i),"updated")}let o=n.endsWith(`
310
314
  `)?`
311
315
  `:`
312
316
 
313
- `;return I.writeFileSync(e,n+o+t),"updated"}var In={command:"npx",args:["saffron","mcp"]},Gi={claude:{file:".mcp.json",key:"mcpServers",entry:In},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:In},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...In}}},Cs={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function Ki(e,t){let n=L.join(e,t.file),r={},s=I.existsSync(n);if(s)try{r=JSON.parse(I.readFileSync(n,"utf8"))}catch{throw new Error(`${t.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[t.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(t.entry)?"unchanged":(o.saffron=t.entry,r[t.key]=o,I.mkdirSync(L.dirname(n),{recursive:!0}),I.writeFileSync(n,JSON.stringify(r,null,2)+`
314
- `),s?"updated":"created")}function Is(e,t){I.mkdirSync(t,{recursive:!0});for(let n of I.readdirSync(e,{withFileTypes:!0})){let r=L.join(e,n.name),s=L.join(t,n.name);n.isDirectory()?Is(r,s):I.copyFileSync(r,s)}}function Os(e){let t=L.join(e.packageRoot,"skills","saffron");if(!I.existsSync(L.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??Wi;for(let s of r){let o=L.join(e.projectRoot,ct[s],"saffron");I.existsSync(o)&&I.rmSync(o,{recursive:!0,force:!0}),Is(t,o),n.skillDirs.push(L.relative(e.projectRoot,o))}if(e.mcp!==!1)for(let s of r){let o=Gi[s];o?n.mcpFiles.push({file:o.file,outcome:Ki(e.projectRoot,o)}):Cs[s]&&n.mcpHints.push(Cs[s])}if(e.instructions!==!1){let s=Ji(),o=L.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:js(o,s)});let i=L.join(e.projectRoot,"CLAUDE.md");I.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:js(i,s)})}if(e.examples){n.examples=Yi(e.projectRoot,e.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(e.scaffold!==!1){let s=L.join(e.projectRoot,"saffron.config.json");I.existsSync(s)||(I.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
315
- `),n.scaffolded.push("saffron.config.json"));let o=L.join(e.projectRoot,"features");I.existsSync(o)||(I.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=L.join(e.projectRoot,".gitignore"),a=e.examples?[".saffron/reports/",".saffron/artifacts/",".env"]:[".saffron/reports/",".saffron/artifacts/"],c=I.existsSync(i)?I.readFileSync(i,"utf8"):"",d=[];for(let h of a)c.split(`
316
- `).some(l=>l.trim()===h)||(c+=(c&&!c.endsWith(`
317
+ `;return O.writeFileSync(e,n+o+t),"updated"}var Bn={command:"npx",args:["saffron","mcp"]},ga={claude:{file:".mcp.json",key:"mcpServers",entry:Bn},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:Bn},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...Bn}}},Ys={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function ha(e,t){let n=U.join(e,t.file),r={},s=O.existsSync(n);if(s)try{r=JSON.parse(O.readFileSync(n,"utf8"))}catch{throw new Error(`${t.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[t.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(t.entry)?"unchanged":(o.saffron=t.entry,r[t.key]=o,O.mkdirSync(U.dirname(n),{recursive:!0}),O.writeFileSync(n,JSON.stringify(r,null,2)+`
318
+ `),s?"updated":"created")}function Zs(e,t){O.mkdirSync(t,{recursive:!0});for(let n of O.readdirSync(e,{withFileTypes:!0})){let r=U.join(e,n.name),s=U.join(t,n.name);n.isDirectory()?Zs(r,s):O.copyFileSync(r,s)}}function Xs(e){let t=U.join(e.packageRoot,"skills","saffron");if(!O.existsSync(U.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??fa;for(let s of r){let o=U.join(e.projectRoot,gt[s],"saffron");O.existsSync(o)&&O.rmSync(o,{recursive:!0,force:!0}),Zs(t,o),n.skillDirs.push(U.relative(e.projectRoot,o))}if(e.mcp!==!1)for(let s of r){let o=ga[s];o?n.mcpFiles.push({file:o.file,outcome:ha(e.projectRoot,o)}):Ys[s]&&n.mcpHints.push(Ys[s])}if(e.instructions!==!1){let s=ua(),o=U.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:Ks(o,s)});let i=U.join(e.projectRoot,"CLAUDE.md");O.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:Ks(i,s)})}if(e.examples){n.examples=ma(e.projectRoot,e.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(e.scaffold!==!1){let s=U.join(e.projectRoot,"saffron.config.json");O.existsSync(s)||(O.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
319
+ `),n.scaffolded.push("saffron.config.json"));let o=U.join(e.projectRoot,"features");O.existsSync(o)||(O.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=U.join(e.projectRoot,".gitignore"),a=e.examples?[".saffron/reports/",".saffron/artifacts/",".env"]:[".saffron/reports/",".saffron/artifacts/"],c=O.existsSync(i)?O.readFileSync(i,"utf8"):"",d=[];for(let m of a)c.split(`
320
+ `).some(l=>l.trim()===m)||(c+=(c&&!c.endsWith(`
317
321
  `)?`
318
- `:"")+h+`
319
- `,d.push(h));d.length>0&&(I.writeFileSync(i,c),n.scaffolded.push(`.gitignore (+ ${d.join(", ")})`))}return n}function Yi(e,t){let n=L.join(t,"templates","saucedemo");if(!I.existsSync(L.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=L.join(e,"features");if(I.existsSync(r)&&I.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];I.mkdirSync(r,{recursive:!0});for(let c of I.readdirSync(L.join(n,"features")))I.copyFileSync(L.join(n,"features",c),L.join(r,c)),s.push(`features/${c}`);let o=L.join(n,"data");if(I.existsSync(o)){I.mkdirSync(L.join(e,"data"),{recursive:!0});for(let c of I.readdirSync(o)){let d=L.join(e,"data",c);I.existsSync(d)||(I.copyFileSync(L.join(o,c),d),s.push(`data/${c}`))}}let i=L.join(e,"saffron.config.json");I.existsSync(i)||(I.copyFileSync(L.join(n,"saffron.config.json"),i),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let a=L.join(e,".env.example");return I.existsSync(a)||(I.copyFileSync(L.join(n,".env.example"),a),s.push(".env.example")),{installed:!0,files:s}}import Qi from"node:path";function Ds(e){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," Easiest: install the Saffron plugin from the JetBrains Marketplace"," (https://plugins.jetbrains.com/plugin/34240-saffron): highlighting,"," this language server, run configurations and the Saffron tool window,"," in one click. Manual setup instead:"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${e?Qi.join(e,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
320
- `)}function Ns(e,t){let n=t.length>0?t:["features"],r=[];for(let s of n){let o=O.isAbsolute(s)?s:O.join(e,s);if(W.existsSync(o))if(W.statSync(o).isDirectory())for(let i of W.readdirSync(o,{recursive:!0})){let a=String(i);ce(a)&&r.push(O.join(o,a))}else ce(o)&&r.push(o)}return r.sort()}function zt(e,t,n){let r=O.isAbsolute(t.features??"features")?t.features:O.join(e,t.features??"features"),s=new Set(Se(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return re([...s].sort(),e)}function ta(){let e=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,t=process.env.HOME??"",n=t&&(W.existsSync(O.join(t,".claude",".credentials.json"))||W.existsSync(O.join(t,".claude.json")));e||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
321
- `))}async function na(e){let t=Xi.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>t.question(e,r));return/^y(es)?$/i.test(n.trim())}finally{t.close()}}function lt(e){console.error(`saffron: ${e.message}`),process.exit(2)}function Dn(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var ra={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},sa="\x1B[0m",U=new ea().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(xe()).option("-p, --project-root <dir>","project root",process.cwd());U.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("--env <name>","data environment: data/<file>.<name>.json is laid over data/<file>.json (also SAFFRON_ENV, or env in the config)").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags: comma-separated ("@smoke,@WA-TC1") or repeat the flag',_s).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'browser: "chromium" (default), "chrome", "msedge", "firefox" or "webkit". Recording and healing need one of the first three').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--shard <index/total>","run one part of the suite, e.g. 2/4 on the second of four CI machines; combine the parts with `saffron report --merge`").option("--shard-by <mode>",'how --shard splits the suite: "scenario" (default, spreads every file) or "file" (each feature file stays on one machine)').option("--no-hooks","skip the setup and teardown commands from the config").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(e,t)=>{let n=O.resolve(U.opts().projectRoot),r=te(n);t.env&&Fe(n,r.dataDir,t.env);let s=t.baseUrl??r.baseURL,o=Ns(n,e.length>0?e:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),t.agent!==!1&&ta();let i;try{let v=zt(n,r,o);i=o.flatMap(_=>Z(_,n,v))}catch(v){throw v instanceof V&&lt(v),v}let a=Es(t.filter);a.length>0&&(i=Ps(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${o.length} feature file(s).`),process.exit(2)));let c;if(t.shardBy!==void 0&&t.shard===void 0&&(console.error("saffron: --shard-by needs --shard, e.g. --shard 2/4 --shard-by file"),process.exit(2)),t.shard!==void 0){try{let v=ks(String(t.shardBy??r.shardBy??"scenario")),_=$s(i,{...vs(String(t.shard)),by:v});i=_.scenarios,c={..._.info,ciRun:Rs()}}catch(v){console.error(v instanceof Error?v.message:String(v)),process.exit(2)}if(console.log(`shard ${c.index}/${c.total}${c.by==="file"?" (by file)":""}: ${i.length} of ${c.suiteSize} scenario(s)`),i.length===0){let v=new Date,_=En([],v,s),j=pn([],v,se(n).divergent.length,s);console.log(`report: ${_n(n,{..._,shard:c,history:j})}`),process.exit(0)}}if(t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let v=0;for(let _ of i){let j=H(n,_.featurePath,_.name);W.existsSync(j)&&(W.rmSync(j),v++)}console.log(`rerecord: removed ${v} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let v=i;try{let j=Ns(n,[]),B=zt(n,r,j);v=j.flatMap(Y=>{try{return Z(Y,n,B)}catch{return[]}})}catch{}let _=Hn(n,v);if(_.length>0){console.error("cache path collision:");for(let j of _)console.error(` ${j}`);process.exit(2)}}{let v=new Map;for(let j of i){let B=X(j),Y=J(H(n,j.featurePath,j.name));Y&&B.push(JSON.stringify(Y.steps));for(let We of Zt(B))process.env[We]===void 0&&!v.has(We)&&v.set(We,j.displayName)}let _=new Map;for(let j of i)for(let B of Ge(X(j)))if(!_.has(B))try{pt(B)}catch(Y){_.set(B,`${Y instanceof Error?Y.message:String(Y)} (used by "${j.displayName}")`)}if(_.size>0){for(let j of _.values())console.error(j);process.exit(2)}if(v.size>0){for(let[j,B]of v)console.error(`missing environment variable ${j} (referenced as {env:${j}} by "${B}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=[],h=[];if(t.hooks!==!1)try{d=jn(r.setup,"setup"),h=jn(r.teardown,"teardown")}catch(v){console.error(v instanceof Error?v.message:String(v)),process.exit(2)}let l=new jt({projectRoot:n,baseURL:s,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,screenshot:r.screenshot,retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let v=t.browser??r.browser??"chromium";return ts(v)||(console.error(`invalid --browser "${v}": use ${xn.map(_=>`"${_}"`).join(", ")}`),process.exit(2)),v})(),healModel:t.healModel??r.healModel,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let v=t.assertionPolicy??r.assertionPolicy??"strict";return v!=="strict"&&v!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${v}": use "strict" or "adaptable-mid"`),process.exit(2)),v})(),agent:t.agent?new Ct({model:t.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),f=new Date,m=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),p=new Array(i.length),x=v=>{let _=v.scenario,j=ra[v.status],B=v.usage.inputTokens+v.usage.outputTokens,Y=v.usage.cacheReadTokens+v.usage.cacheCreationTokens,We=Y>0?`, ${Dn(Y)} cache traffic`:"";console.log(`${j}${v.status.toUpperCase().padEnd(7)}${sa}${_.displayName} (${v.source}, ${v.durationMs}ms, ${v.usage.aiCalls} AI calls, ${B} tokens${We})`);for(let qt of v.adaptations)console.log(` \u26A0 ${qt}`);v.seededSteps&&console.log(` \u26A1 ${v.seededSteps}/${v.stepResults.length} steps seeded from existing recordings (zero AI)`);let Vt=v.stepResults.reduce((qt,Ls)=>qt+(Ls.retries??0),0);Vt&&console.log(` \u21BB ${Vt} action ${Vt===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),v.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):v.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${v.proofError?.split(`
322
- `)[0]}`),v.error&&console.log(` ${v.error}`)},w=new AbortController,R={cwd:n,timeoutMs:r.hookTimeoutMs??Xt.hookTimeoutMs,env:{SAFFRON_BASE_URL:s,SAFFRON_ENV:ye().env,SAFFRON_SHARD:c?`${c.index}/${c.total}`:void 0}},S=[],g=!1,y=async()=>{g||(g=!0,S.push(...await Cn("teardown",h,R)))},u=Promise.resolve([]);d.length+h.length>0&&process.once("SIGINT",()=>{console.error(`
323
- interrupted: stopping, then running teardown`),w.abort(),u.then(()=>l.stop()).catch(()=>{}).then(y).finally(()=>process.exit(130))}),d.length>0&&(u=Cn("setup",d,{...R,signal:w.signal}),S.push(...await u),w.signal.aborted&&await new Promise(()=>{}),S.some(Bt)&&(console.error("No scenario was run: the application is not in the state the suite expects."),await y(),process.exit(2)));try{await l.start()}catch(v){throw await y(),v instanceof st&&(console.error(v.message),process.exit(2)),v}try{if(m>1&&i.length>1){let v=i.map((j,B)=>({s:j,i:B})),_=[];await Promise.all(Array.from({length:Math.min(m,v.length)},async()=>{for(;;){let j=v.shift();if(!j)return;let B=await l.runScenario(j.s,{agentDisabled:!0});B.status==="red"?_.push(j):(p[j.i]=B,x(B))}}));for(let j of _.sort((B,Y)=>B.i-Y.i)){let B=await l.runScenario(j.s);p[j.i]=B,x(B)}}else for(let v=0;v<i.length;v++){let _=await l.runScenario(i[v]);p[v]=_,x(_)}}finally{await l.stop(),await y()}let b=En(p,f,s);S.length>0&&(b.hooks=S);let P=se(n).divergent.length,A=pn(p,f,P,s);if(!c){let v=Xe(n);b.trends=un(v,A),fn(n,A)}b.totals.aiCalls>0&&(b.totals.plan=await l.agent?.planUsage?.());let $;if(c){let v=_n(n,{...b,shard:c,history:A}),_=v.replace(/\.json$/,".html");W.writeFileSync(_,Mt(b,n)),$=`${_}
324
- ${v}`}else{let{html:v,json:_}=Pn(n,b);$=`${v}
325
- ${_}`}let k=b.totals,N=k.cacheReadTokens+k.cacheCreationTokens;console.log(`
326
- ${k.green} passed, ${k.yellow} pending review, ${k.red} failed \xB7 ${k.aiCalls} AI calls \xB7 ${(k.inputTokens+k.outputTokens).toLocaleString()} tokens in+out${N>0?` \xB7 ${Dn(k.cacheReadTokens)} cache read \xB7 ${Dn(k.cacheCreationTokens)} cache written`:""}${kn(k)}${k.models?.length?` \xB7 ${k.models.join(", ")}`:""}`);let C=b.trends;if(C?.prev){let v=C.prev.passRatePp,_=C.prev.costUsd;console.log(`vs previous run: pass rate ${v>=0?"+":""}${v}pp \xB7 cost ${_>=0?"+":"-"}$${Math.abs(_).toFixed(4)} \xB7 AI calls ${C.prev.aiCalls>=0?"+":""}${C.prev.aiCalls}`)}for(let v of C?.chronic??[])console.log(`\u26A0 chronic: ${v.feature} \u203A ${v.scenario}: ${v.heals} heal(s), ${v.reds} red(s) in last ${v.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${$}`),c&&console.log(`shard report, not a whole run: history and trends wait for \`saffron report --merge\` over all ${c.total} shard-*-of-${c.total}.json files`);let M=we(O.resolve(U.opts().projectRoot));M.length>0&&console.log(`${M.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let E=t.strict??r.strict??!1;E&&k.yellow>0&&console.log(`strict mode: ${k.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`);let T=k.red>0||E&&k.yellow>0,Q=S.filter(v=>v.phase==="teardown"&&Bt(v));Q.length>0&&console.log(`teardown failed: ${Q.map(v=>Fn(v,R.timeoutMs)).join("; ")}. The environment may hold data from this run.`),process.exit(T?1:Q.length>0?2:0)});U.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((e,t)=>{let n=O.resolve(U.opts().projectRoot),r=te(n),s=e.length>0?e.map(o=>O.resolve(o)):t.all?we(n).map(o=>o.file):[];if(s.length===0){Ms(n);return}for(let o of s){let i=Pe(o);if(i.meta.verified===!1){if(t.all&&!t.includeUnverified){console.log(`skipped UNVERIFIED ${O.basename(o)} (${i.meta.proofError?.split(`
322
+ `:"")+m+`
323
+ `,d.push(m));d.length>0&&(O.writeFileSync(i,c),n.scaffolded.push(`.gitignore (+ ${d.join(", ")})`))}return n}function ma(e,t){let n=U.join(t,"templates","saucedemo");if(!O.existsSync(U.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=U.join(e,"features");if(O.existsSync(r)&&O.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];O.mkdirSync(r,{recursive:!0});for(let c of O.readdirSync(U.join(n,"features")))O.copyFileSync(U.join(n,"features",c),U.join(r,c)),s.push(`features/${c}`);let o=U.join(n,"data");if(O.existsSync(o)){O.mkdirSync(U.join(e,"data"),{recursive:!0});for(let c of O.readdirSync(o)){let d=U.join(e,"data",c);O.existsSync(d)||(O.copyFileSync(U.join(o,c),d),s.push(`data/${c}`))}}let i=U.join(e,"saffron.config.json");O.existsSync(i)||(O.copyFileSync(U.join(n,"saffron.config.json"),i),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let a=U.join(e,".env.example");return O.existsSync(a)||(O.copyFileSync(U.join(n,".env.example"),a),s.push(".env.example")),{installed:!0,files:s}}import ya from"node:path";function eo(e){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," Easiest: install the Saffron plugin from the JetBrains Marketplace"," (https://plugins.jetbrains.com/plugin/34240-saffron): highlighting,"," this language server, run configurations and the Saffron tool window,"," in one click. Manual setup instead:"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${e?ya.join(e,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
324
+ `)}function to(e,t){let n=t.length>0?t:["features"],r=[];for(let s of n){let o=D.isAbsolute(s)?s:D.join(e,s);if(G.existsSync(o))if(G.statSync(o).isDirectory())for(let i of G.readdirSync(o,{recursive:!0})){let a=String(i);de(a)&&r.push(D.join(o,a))}else de(o)&&r.push(o)}return r.sort()}function Jt(e,t,n){let r=D.isAbsolute(t.features??"features")?t.features:D.join(e,t.features??"features"),s=new Set(be(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return se([...s].sort(),e)}function wa(){let e=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,t=process.env.HOME??"",n=t&&(G.existsSync(D.join(t,".claude",".credentials.json"))||G.existsSync(D.join(t,".claude.json")));e||n||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
325
+ `))}async function va(e){let t=ba.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>t.question(e,r));return/^y(es)?$/i.test(n.trim())}finally{t.close()}}function ht(e){console.error(`saffron: ${e.message}`),process.exit(2)}function qn(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var $a={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},ka="\x1B[0m",L=new xa().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(we()).option("-p, --project-root <dir>","project root",process.cwd());L.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("--env <name>","data environment: data/<file>.<name>.json is laid over data/<file>.json (also SAFFRON_ENV, or env in the config)").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios carrying any of these @tags: comma-separated ("@smoke,@WA-TC1") or repeat the flag',Gs).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'browser: "chromium" (default), "chrome", "msedge", "firefox" or "webkit". Recording and healing need one of the first three').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--shard <index/total>","run one part of the suite, e.g. 2/4 on the second of four CI machines; combine the parts with `saffron report --merge`").option("--shard-by <mode>",'how --shard splits the suite: "scenario" (default, spreads every file) or "file" (each feature file stays on one machine)').option("--no-hooks","skip the setup and teardown commands from the config").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(e,t)=>{let n=D.resolve(L.opts().projectRoot),r=te(n);t.env&&De(n,r.dataDir,t.env);let s=t.baseUrl??r.baseURL,o=to(n,e.length>0?e:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),t.agent!==!1&&wa();let i;try{let x=Jt(n,r,o);i=o.flatMap(j=>X(j,n,x))}catch(x){throw x instanceof V&&ht(x),x}let a=Ws(t.filter);a.length>0&&(i=Js(i,a),i.length===0&&(console.error(`No scenarios match --filter ${a.join(",")} in ${o.length} feature file(s).`),process.exit(2)));let c;if(t.shardBy!==void 0&&t.shard===void 0&&(console.error("saffron: --shard-by needs --shard, e.g. --shard 2/4 --shard-by file"),process.exit(2)),t.shard!==void 0){try{let x=Bs(String(t.shardBy??r.shardBy??"scenario")),j=zs(i,{...Ls(String(t.shard)),by:x});i=j.scenarios,c={...j.info,ciRun:qs()}}catch(x){console.error(x instanceof Error?x.message:String(x)),process.exit(2)}if(console.log(`shard ${c.index}/${c.total}${c.by==="file"?" (by file)":""}: ${i.length} of ${c.suiteSize} scenario(s)`),i.length===0){let x=new Date,j=On([],x,s),F=yn([],x,oe(n).divergent.length,s);console.log(`report: ${Nn(n,{...j,shard:c,history:F})}`),process.exit(0)}}if(t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let x=0;for(let j of i){let F=J(n,j.featurePath,j.name);G.existsSync(F)&&(G.rmSync(F),x++)}console.log(`rerecord: removed ${x} cache file(s) for ${i.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let x=i;try{let F=to(n,[]),z=Jt(n,r,F);x=F.flatMap(Q=>{try{return X(Q,n,z)}catch{return[]}})}catch{}let j=Zn(n,x);if(j.length>0){console.error("cache path collision:");for(let F of j)console.error(` ${F}`);process.exit(2)}}{let x=new Map;for(let F of i){let z=Z(F),Q=K(J(n,F.featurePath,F.name));Q&&z.push(JSON.stringify(Q.steps));for(let Ge of nn(z))process.env[Ge]===void 0&&!x.has(Ge)&&x.set(Ge,F.displayName)}let j=new Map;for(let F of i)for(let z of Qe(Z(F)))if(!j.has(z))try{yt(z)}catch(Q){j.set(z,`${Q instanceof Error?Q.message:String(Q)} (used by "${F.displayName}")`)}if(j.size>0){for(let F of j.values())console.error(F);process.exit(2)}if(x.size>0){for(let[F,z]of x)console.error(`missing environment variable ${F} (referenced as {env:${F}} by "${z}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let d=[],m=[];if(t.hooks!==!1)try{d=Mn(r.setup,"setup"),m=Mn(r.teardown,"teardown")}catch(x){console.error(x instanceof Error?x.message:String(x)),process.exit(2)}let l=new Ot({projectRoot:n,baseURL:s,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,screenshot:r.screenshot,retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let x=t.browser??r.browser??"chromium";return ys(x)||(console.error(`invalid --browser "${x}": use ${An.map(j=>`"${j}"`).join(", ")}`),process.exit(2)),x})(),healModel:t.healModel??r.healModel,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let x=t.assertionPolicy??r.assertionPolicy??"strict";return x!=="strict"&&x!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${x}": use "strict" or "adaptable-mid"`),process.exit(2)),x})(),agent:t.agent?new Nt({model:t.model??r.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),f=new Date,S=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),h=new Array(i.length),y=x=>{let j=x.scenario,F=$a[x.status],z=x.usage.inputTokens+x.usage.outputTokens,Q=x.usage.cacheReadTokens+x.usage.cacheCreationTokens,Ge=Q>0?`, ${qn(Q)} cache traffic`:"";console.log(`${F}${x.status.toUpperCase().padEnd(7)}${ka}${j.displayName} (${x.source}, ${x.durationMs}ms, ${x.usage.aiCalls} AI calls, ${z} tokens${Ge})`);for(let Kt of x.adaptations)console.log(` \u26A0 ${Kt}`);x.seededSteps&&console.log(` \u26A1 ${x.seededSteps}/${x.stepResults.length} steps seeded from existing recordings (zero AI)`);let Gt=x.stepResults.reduce((Kt,ro)=>Kt+(ro.retries??0),0);Gt&&console.log(` \u21BB ${Gt} action ${Gt===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),x.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):x.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${x.proofError?.split(`
326
+ `)[0]}`),x.error&&console.log(` ${x.error}`)},w=new AbortController,T={cwd:n,timeoutMs:r.hookTimeoutMs??rn.hookTimeoutMs,env:{SAFFRON_BASE_URL:s,SAFFRON_ENV:Se().env,SAFFRON_SHARD:c?`${c.index}/${c.total}`:void 0}},$=[],p,u=()=>p??=Un("teardown",m,T).then(x=>{$.push(...x)}),g=Promise.resolve([]);d.length+m.length>0&&process.on("SIGINT",()=>{if(w.signal.aborted){console.error("still cleaning up: teardown finishes, then Saffron exits");return}console.error(`
327
+ interrupted: stopping, then running teardown`),w.abort(),g.then(()=>l.stop()).catch(()=>{}).then(u).finally(()=>process.exit(130))}),d.length>0&&(g=Un("setup",d,{...T,signal:w.signal}),$.push(...await g),w.signal.aborted&&await new Promise(()=>{}),$.some(Wt)&&(console.error("No scenario was run: the application is not in the state the suite expects."),await u(),process.exit(2)));try{await l.start()}catch(x){throw await u(),x instanceof dt&&(console.error(x.message),process.exit(2)),x}try{if(S>1&&i.length>1){let x=i.map((F,z)=>({s:F,i:z})),j=[];await Promise.all(Array.from({length:Math.min(S,x.length)},async()=>{for(;;){let F=x.shift();if(!F)return;let z=await l.runScenario(F.s,{agentDisabled:!0});z.status==="red"?j.push(F):(h[F.i]=z,y(z))}}));for(let F of j.sort((z,Q)=>z.i-Q.i)){let z=await l.runScenario(F.s);h[F.i]=z,y(z)}}else for(let x=0;x<i.length;x++){let j=await l.runScenario(i[x]);h[x]=j,y(j)}}finally{await l.stop(),await u()}let b=On(h,f,s);$.length>0&&(b.hooks=$);let A=oe(n).divergent.length,P=yn(h,f,A,s);if(!c){let x=st(n);b.trends=bn(x,P),Sn(n,P)}b.totals.aiCalls>0&&(b.totals.plan=await l.agent?.planUsage?.());let k;if(c){let x=Nn(n,{...b,shard:c,history:P}),j=x.replace(/\.json$/,".html");G.writeFileSync(j,qt(b,n)),k=`${j}
328
+ ${x}`}else{let{html:x,json:j}=Dn(n,b);k=`${x}
329
+ ${j}`}let v=b.totals,M=v.cacheReadTokens+v.cacheCreationTokens;console.log(`
330
+ ${v.green} passed, ${v.yellow} pending review, ${v.red} failed \xB7 ${v.aiCalls} AI calls \xB7 ${(v.inputTokens+v.outputTokens).toLocaleString()} tokens in+out${M>0?` \xB7 ${qn(v.cacheReadTokens)} cache read \xB7 ${qn(v.cacheCreationTokens)} cache written`:""}${Cn(v)}${v.models?.length?` \xB7 ${v.models.join(", ")}`:""}`);let R=b.trends;if(R?.prev){let x=R.prev.passRatePp,j=R.prev.costUsd;console.log(`vs previous run: pass rate ${x>=0?"+":""}${x}pp \xB7 cost ${j>=0?"+":"-"}$${Math.abs(j).toFixed(4)} \xB7 AI calls ${R.prev.aiCalls>=0?"+":""}${R.prev.aiCalls}`)}for(let x of R?.chronic??[])console.log(`\u26A0 chronic: ${x.feature} \u203A ${x.scenario}: ${x.heals} heal(s), ${x.reds} red(s) in last ${x.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${k}`),c&&console.log(`shard report, not a whole run: history and trends wait for \`saffron report --merge\` over all ${c.total} shard-*-of-${c.total}.json files`);let C=ve(D.resolve(L.opts().projectRoot));C.length>0&&console.log(`${C.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let _=t.strict??r.strict??!1;_&&v.yellow>0&&console.log(`strict mode: ${v.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`);let E=v.red>0||_&&v.yellow>0,B=$.filter(x=>x.phase==="teardown"&&Wt(x));B.length>0&&console.log(`teardown failed: ${B.map(x=>Ln(x,T.timeoutMs)).join("; ")}. The environment may hold data from this run.`),process.exit(E?1:B.length>0?2:0)});L.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((e,t)=>{let n=D.resolve(L.opts().projectRoot),r=te(n),s=e.length>0?e.map(o=>D.resolve(o)):t.all?ve(n).map(o=>o.file):[];if(s.length===0){no(n);return}for(let o of s){let i=_e(o);if(i.meta.verified===!1){if(t.all&&!t.includeUnverified){console.log(`skipped UNVERIFIED ${D.basename(o)} (${i.meta.proofError?.split(`
327
331
  `)[0]}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);continue}console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
328
- `)[0]}): expect a heal or red on the next run.`)}let a=i.cache.steps.map(p=>p.gherkin);if(t.withFeatureEdit&&(i.meta.featureEdits??[]).length>0){let p;try{p=ys(n,i.cache.feature,i.cache.scenario,a,zt(n,r,[]))}catch(x){throw x instanceof V&&lt(x),x}if(p){console.log(`not accepted: ${O.basename(o)} carries feature edits, and ${p}. Run the scenario again for a fresh proposal, or accept this one without --with-feature-edit.`),process.exitCode=1;continue}}let c=J(H(n,i.cache.feature,i.cache.scenario)),d=br(n,o);console.log(`accepted \u2192 ${d}`);let h=c?gs(c,i.cache):{changes:[],ambiguous:[]},l=h.changes;for(let p of h.ambiguous)console.log(` locator fix for ${Dt(p)} not propagated: this heal sent it to more than one place, so other caches are left alone.`);if(l.length>0){let p=hs(n,l,d,!t.propagate);for(let x of l)console.log(` locator fix: ${Dt(x.from)} \u2192 ${Dt(x.to)}`);if(p.length===0)console.log(" no other caches use this locator.");else if(t.propagate)for(let x of p)console.log(` propagated \u2192 ${x.feature} \u203A ${x.scenario} (${x.steps.map(w=>`"${w.gherkin}"`).join(", ")})`);else{console.log(` ${p.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let x of p)console.log(` \xB7 ${x.feature} \u203A ${x.scenario}`)}}let f=i.meta.featureEdits??[];if(!t.withFeatureEdit||f.length===0)continue;let m;try{m=Ss(n,i.cache.feature,i.cache.scenario,f,zt(n,r,[]),a)}catch(p){throw p instanceof V&&lt(p),p}for(let p of m.applied){let x=p.fromStepSet?`${p.file}:${p.line} (StepSet "${p.fromStepSet}", fixes every invoking scenario)`:`${p.file}:${p.line}`;console.log(` feature edit ${x}:`),console.log(` - ${p.from}`),console.log(` + ${p.to}`)}for(let p of m.skipped)console.log(` skipped edit [${p.index}]: ${p.reason}`);if(m.appliedIndices.length>0){let p=new Set(m.appliedIndices),x=J(d);for(let w of f)p.has(w.index)&&x.steps[w.index]&&(x.steps[w.index].gherkin=w.text);Ne(d,x)}}});U.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((e,t)=>{let n=O.resolve(U.opts().projectRoot),r=te(n),s;try{s=oe(n,r.features??"features")}catch(i){throw i instanceof V&&lt(i),i}if(e&&(s=bt(s,e)),t.json){console.log(JSON.stringify(s,null,2));return}if(t.snippets){let i=O.join(n,".vscode","saffron.code-snippets");W.mkdirSync(O.dirname(i),{recursive:!0}),W.writeFileSync(i,Zn(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
329
- ${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`)});U.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(e,t)=>{let n=O.resolve(U.opts().projectRoot),r=te(n),s=W.readFileSync(O.resolve(e),"utf8"),o;try{o=oe(n,r.features??"features")}catch(d){throw d instanceof V&&lt(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 Ar(s,o,t.model??r.model),a=t.out?O.resolve(t.out):O.join(n,r.features??"features",`${O.basename(e).replace(/\.[^.]+$/,"")}.saffron`);W.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),W.mkdirSync(O.dirname(a),{recursive:!0}),W.writeFileSync(a,i.content);let c=i.content.split(`
330
- `).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>o.steps.some(h=>h.text===d)).length;console.log(`wrote ${a}`),console.log(`${c} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+O.relative(process.cwd(),a))});U.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=O.resolve(U.opts().projectRoot),t=te(e);await Tr(e,t.features??"features")});U.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(Ds(wt())),process.exit(2));let e=O.resolve(U.opts().projectRoot),t=te(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));Or(e,t.features??"features",{rootFromClient:!n})});U.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((e,t)=>{let n=O.resolve(U.opts().projectRoot),r=e.length>0?e.map(s=>O.resolve(s)):t.all?we(n).map(s=>s.file):[];if(r.length===0){Ms(n);return}for(let s of r)xr(s),console.log(`rejected ${s}`)});U.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(ct).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async e=>{let t=O.resolve(U.opts().projectRoot),n=wt();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in ct||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(ct).join(", ")})`),process.exit(2));let s=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await na("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=Os({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:s});for(let i of o.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of o.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of o.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of o.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of o.scaffolded)console.log(`created \u2192 ${i}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
331
- `))});U.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=O.resolve(U.opts().projectRoot),r=e.length>0?e.map(o=>O.resolve(o)):we(n).map(o=>o.file);if(r.length===0){console.log("No pending proposals.");return}let s=r.map(o=>{let i=Pe(o),a=J(H(n,i.cache.feature,i.cache.scenario));return{file:O.relative(n,o),verified:i.meta.verified,..._t(a,i.cache)}});if(t.json){console.log(JSON.stringify(s.length===1?s[0]:s,null,2));return}console.log(s.map(o=>Yr(o)).join(`
332
+ `)[0]}): expect a heal or red on the next run.`)}let a=i.cache.steps.map(h=>h.gherkin);if(t.withFeatureEdit&&(i.meta.featureEdits??[]).length>0){let h;try{h=Os(n,i.cache.feature,i.cache.scenario,a,Jt(n,r,[]))}catch(y){throw y instanceof V&&ht(y),y}if(h){console.log(`not accepted: ${D.basename(o)} carries feature edits, and ${h}. Run the scenario again for a fresh proposal, or accept this one without --with-feature-edit.`),process.exitCode=1;continue}}let c=K(J(n,i.cache.feature,i.cache.scenario)),d=Ar(n,o);console.log(`accepted \u2192 ${d}`);let m=c?js(c,i.cache):{changes:[],ambiguous:[]},l=m.changes;for(let h of m.ambiguous)console.log(` locator fix for ${Bt(h)} not propagated: this heal sent it to more than one place, so other caches are left alone.`);if(l.length>0){let h=Fs(n,l,d,!t.propagate);for(let y of l)console.log(` locator fix: ${Bt(y.from)} \u2192 ${Bt(y.to)}`);if(h.length===0)console.log(" no other caches use this locator.");else if(t.propagate)for(let y of h)console.log(` propagated \u2192 ${y.feature} \u203A ${y.scenario} (${y.steps.map(w=>`"${w.gherkin}"`).join(", ")})`);else{console.log(` ${h.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let y of h)console.log(` \xB7 ${y.feature} \u203A ${y.scenario}`)}}let f=i.meta.featureEdits??[];if(!t.withFeatureEdit||f.length===0)continue;let S;try{S=Ds(n,i.cache.feature,i.cache.scenario,f,Jt(n,r,[]),a)}catch(h){throw h instanceof V&&ht(h),h}for(let h of S.applied){let y=h.fromStepSet?`${h.file}:${h.line} (StepSet "${h.fromStepSet}", fixes every invoking scenario)`:`${h.file}:${h.line}`;console.log(` feature edit ${y}:`),console.log(` - ${h.from}`),console.log(` + ${h.to}`)}for(let h of S.skipped)console.log(` skipped edit [${h.index}]: ${h.reason}`);if(S.appliedIndices.length>0){let h=new Set(S.appliedIndices),y=K(d);for(let w of f)h.has(w.index)&&y.steps[w.index]&&(y.steps[w.index].gherkin=w.text);Le(d,y)}}});L.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((e,t)=>{let n=D.resolve(L.opts().projectRoot),r=te(n),s;try{s=ie(n,r.features??"features")}catch(i){throw i instanceof V&&ht(i),i}if(e&&(s=kt(s,e)),t.json){console.log(JSON.stringify(s,null,2));return}if(t.snippets){let i=D.join(n,".vscode","saffron.code-snippets");G.mkdirSync(D.dirname(i),{recursive:!0}),G.writeFileSync(i,or(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
333
+ ${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`)});L.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(e,t)=>{let n=D.resolve(L.opts().projectRoot),r=te(n),s=G.readFileSync(D.resolve(e),"utf8"),o;try{o=ie(n,r.features??"features")}catch(d){throw d instanceof V&&ht(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 Or(s,o,t.model??r.model),a=t.out?D.resolve(t.out):D.join(n,r.features??"features",`${D.basename(e).replace(/\.[^.]+$/,"")}.saffron`);G.existsSync(a)&&(console.error(`refusing to overwrite ${a}: pass --out for a new path`),process.exit(1)),G.mkdirSync(D.dirname(a),{recursive:!0}),G.writeFileSync(a,i.content);let c=i.content.split(`
334
+ `).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>o.steps.some(m=>m.text===d)).length;console.log(`wrote ${a}`),console.log(`${c} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+D.relative(process.cwd(),a))});L.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=D.resolve(L.opts().projectRoot),t=te(e);await Ir(e,t.features??"features")});L.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(eo(Tt())),process.exit(2));let e=D.resolve(L.opts().projectRoot),t=te(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));qr(e,t.features??"features",{rootFromClient:!n})});L.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((e,t)=>{let n=D.resolve(L.opts().projectRoot),r=e.length>0?e.map(s=>D.resolve(s)):t.all?ve(n).map(s=>s.file):[];if(r.length===0){no(n);return}for(let s of r)Er(s),console.log(`rejected ${s}`)});L.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(gt).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async e=>{let t=D.resolve(L.opts().projectRoot),n=Tt();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in gt||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(gt).join(", ")})`),process.exit(2));let s=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await va("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=Xs({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:s});for(let i of o.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of o.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of o.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of o.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of o.scaffolded)console.log(`created \u2192 ${i}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
335
+ `))});L.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=D.resolve(L.opts().projectRoot),r=e.length>0?e.map(o=>D.resolve(o)):ve(n).map(o=>o.file);if(r.length===0){console.log("No pending proposals.");return}let s=r.map(o=>{let i=_e(o),a=K(J(n,i.cache.feature,i.cache.scenario));return{file:D.relative(n,o),verified:i.meta.verified,...jt(a,i.cache)}});if(t.json){console.log(JSON.stringify(s.length===1?s[0]:s,null,2));return}console.log(s.map(o=>Yr(o)).join(`
332
336
 
333
- `))});U.command("prune").description("List (and with --yes remove) recordings whose scenario no longer exists: caches and pending proposals left behind by a deleted scenario or feature file").option("--json","machine-readable output").option("--yes","actually delete the files listed").option("--check","exit 1 when anything is orphaned, delete nothing (for CI)").action(e=>{let t=O.resolve(U.opts().projectRoot),n=te(t),r=kt(t,n.features??"features");r.unreadable.length>0&&(e.json?console.log(JSON.stringify({...r,removed:[]},null,2)):console.error(["saffron: cannot tell what is orphaned while a feature file fails to parse.",...r.unreadable.map(o=>` ${o}`),"Fix these first: a syntax error hides every scenario in the file."].join(`
334
- `)),process.exit(2));let s=e.yes&&!e.check?ar(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:s},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let o=r.orphans.filter(c=>c.kind==="cache").length,i=r.orphans.filter(c=>c.kind==="proposal").length,a=r.orphans.length-o-i;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${o} cache, ${i} proposal, ${a} screenshot ${a===1?"folder":"folders"}), ${r.kept} still owned:`,"",...r.orphans.map(c=>` ${s.length>0?"removed ":""}${cr(c)}`),"",s.length>0?"Deleted. Re-record by running the scenarios again if you deleted something by mistake.":"Nothing was deleted. Run `saffron prune --yes` to remove them."].join(`
335
- `))}e.check&&r.orphans.length>0&&process.exit(1)});U.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(e=>{let t=O.resolve(U.opts().projectRoot),n=te(t),r=Et(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}Rr(r)});U.command("report").description("Open the latest HTML report, or merge shard reports into it").argument("[paths...]","with --merge: shard report files or folders to search (default: .saffron)").option("--open","open in the default browser",!0).option("--merge","combine the shard-<i>-of-<n>.json reports of one sharded run into latest.json, latest.html and one history line").action((e,t)=>{let n=O.resolve(U.opts().projectRoot);if(t.merge){oa(n,e);return}e.length>0&&(console.error("saffron report: paths are only read with --merge"),process.exit(2));let r=O.join(n,".saffron","reports","latest.html");W.existsSync(r)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(r);let s=process.platform==="darwin"?"open":"xdg-open";Zi(s,[r],{detached:!0,stdio:"ignore"}).unref()});function oa(e,t){let n=t.length>0?t.map(p=>O.resolve(p)):[O.join(e,".saffron")],r,s;try{let p=Ts(n);s=p.length,r=As(p)}catch(p){console.error(`saffron report --merge: ${p instanceof Error?p.message:String(p)}`),process.exit(2)}let{report:o,history:i}=r,a=Xe(e),c=a.some(p=>p.runId===i.runId),d=c?a.filter(p=>p.runId!==i.runId):a;o.trends=un(d,i),c||fn(e,i);let{html:h,json:l}=Pn(e,o),f=o.totals;console.log(`merged ${s} shard(s): ${f.scenarios} scenario(s) \xB7 ${f.green} passed, ${f.yellow} pending review, ${f.red} failed \xB7 ${f.aiCalls} AI calls${kn(f)}`),c&&console.log("history already holds this run: not appended again");let m=o.trends.prev;m&&console.log(`vs previous run: pass rate ${m.passRatePp>=0?"+":""}${m.passRatePp}pp \xB7 cost ${m.costUsd>=0?"+":"-"}$${Math.abs(m.costUsd).toFixed(4)} \xB7 AI calls ${m.aiCalls>=0?"+":""}${m.aiCalls}`);for(let p of o.trends.chronic)console.log(`\u26A0 chronic: ${p.feature} \u203A ${p.scenario}: ${p.heals} heal(s), ${p.reds} red(s) in last ${p.runs} run(s)`);console.log(`report: ${h}
336
- ${l}`)}function Ms(e){let t=we(e);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
337
+ `))});L.command("prune").description("List (and with --yes remove) recordings whose scenario no longer exists: caches and pending proposals left behind by a deleted scenario or feature file").option("--json","machine-readable output").option("--yes","actually delete the files listed").option("--check","exit 1 when anything is orphaned, delete nothing (for CI)").action(e=>{let t=D.resolve(L.opts().projectRoot),n=te(t),r=Et(t,n.features??"features");r.unreadable.length>0&&(e.json?console.log(JSON.stringify({...r,removed:[]},null,2)):console.error(["saffron: cannot tell what is orphaned while a feature file fails to parse.",...r.unreadable.map(o=>` ${o}`),"Fix these first: a syntax error hides every scenario in the file."].join(`
338
+ `)),process.exit(2));let s=e.yes&&!e.check?gr(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:s},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let o=r.orphans.filter(c=>c.kind==="cache").length,i=r.orphans.filter(c=>c.kind==="proposal").length,a=r.orphans.length-o-i;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${o} cache, ${i} proposal, ${a} screenshot ${a===1?"folder":"folders"}), ${r.kept} still owned:`,"",...r.orphans.map(c=>` ${s.length>0?"removed ":""}${hr(c)}`),"",s.length>0?"Deleted. Re-record by running the scenarios again if you deleted something by mistake.":"Nothing was deleted. Run `saffron prune --yes` to remove them."].join(`
339
+ `))}e.check&&r.orphans.length>0&&process.exit(1)});L.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(e=>{let t=D.resolve(L.opts().projectRoot),n=te(t),r=_t(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}Fr(r)});L.command("report").description("Open the latest HTML report, or merge shard reports into it").argument("[paths...]","with --merge: shard report files or folders to search (default: .saffron)").option("--open","open in the default browser",!0).option("--merge","combine the shard-<i>-of-<n>.json reports of one sharded run into latest.json, latest.html and one history line").action((e,t)=>{let n=D.resolve(L.opts().projectRoot);if(t.merge){Ra(n,e);return}e.length>0&&(console.error("saffron report: paths are only read with --merge"),process.exit(2));let r=D.join(n,".saffron","reports","latest.html");G.existsSync(r)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(r);let s=process.platform==="darwin"?"open":"xdg-open";Sa(s,[r],{detached:!0,stdio:"ignore"}).unref()});function Ra(e,t){let n=t.length>0?t.map(y=>D.resolve(y)):[D.join(e,".saffron")],r,s;try{let y=Vs(n);s=y.length,r=Hs(y)}catch(y){console.error(`saffron report --merge: ${y instanceof Error?y.message:String(y)}`),process.exit(2)}let{report:o,history:i}=r,a=st(e),c=a.find(y=>y.runId===i.runId),d=c?a.filter(y=>y.runId!==i.runId):a;o.trends=bn(d,i);let m=c!==void 0&&JSON.stringify(c)===JSON.stringify(i);m||Cr(e,i);let{html:l,json:f}=Dn(e,o),S=o.totals;console.log(`merged ${s} shard(s): ${S.scenarios} scenario(s) \xB7 ${S.green} passed, ${S.yellow} pending review, ${S.red} failed \xB7 ${S.aiCalls} AI calls${Cn(S)}`),m?console.log("history already holds this run: not appended again"):c&&console.log("history already held this run: its line is replaced with these results");let h=o.trends.prev;h&&console.log(`vs previous run: pass rate ${h.passRatePp>=0?"+":""}${h.passRatePp}pp \xB7 cost ${h.costUsd>=0?"+":"-"}$${Math.abs(h.costUsd).toFixed(4)} \xB7 AI calls ${h.aiCalls>=0?"+":""}${h.aiCalls}`);for(let y of o.trends.chronic)console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario}: ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s)`);console.log(`report: ${l}
340
+ ${f}`)}function no(e){let t=ve(e);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
337
341
  `);for(let{file:n,proposal:r}of t){console.log(` ${n}`);let s=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${s})`),console.log(` ${r.meta.narrative}`);for(let o of r.meta.adaptations)console.log(` \u26A0 ${o}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
338
342
  ${r.meta.suggestedFeatureEdit.split(`
339
343
  `).map(o=>` ${o}`).join(`
340
- `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}U.parseAsync();
344
+ `)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}L.parseAsync();