saffron-ai 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist-pkg/cli.js +11 -11
  2. package/package.json +1 -1
package/dist-pkg/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import F from"node:fs";import T from"node:path";import{spawn as Rn}from"node:child_process";import{Command as Tn}from"commander";import Sr from"node:fs";import yr from"node:path";import{AstBuilder as br,GherkinClassicTokenMatcher as xr,Parser as vr}from"@cucumber/gherkin";import{IdGenerator as wr,StepKeywordType as Ae}from"@cucumber/messages";function N(t,e){return t.replace(/<([^<>]+)>/g,(r,n)=>n in e?e[n]:r)}function D(t){if(!t||t.length===0)return{};let e={};if(t.every(r=>r.length===2)){for(let[r,n]of t)e[`table:${r}`]=n;return e}if(t.length>=2){let[r,...n]=t;if(r.some(s=>!s))return{};n.forEach((s,o)=>{r.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function z(t){return t!==void 0?{docstring:t}:{}}function Q(t){let e=new Map;for(let n of t){let s={...D(n.resolvedTable??n.table),...z(n.resolvedDocString??n.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let r={};for(let[n,s]of e)s.size===1&&(r[n]=[...s][0]);return r}import Te from"node:fs";import Ke from"node:path";import{AstBuilder as lr,GherkinClassicTokenMatcher as dr,Parser as pr}from"@cucumber/gherkin";import{IdGenerator as ur}from"@cucumber/messages";var j=class extends Error{},fr=/^(\s*)StepSet:\s*(.+?)\s*$/,gr=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Ee(t){let e=t.split(`
2
+ import F from"node:fs";import R from"node:path";import{spawn as Rn}from"node:child_process";import{fileURLToPath as Tn}from"node:url";import{Command as En}from"commander";import Sr from"node:fs";import yr from"node:path";import{AstBuilder as br,GherkinClassicTokenMatcher as xr,Parser as vr}from"@cucumber/gherkin";import{IdGenerator as wr,StepKeywordType as Ae}from"@cucumber/messages";function N(t,e){return t.replace(/<([^<>]+)>/g,(r,n)=>n in e?e[n]:r)}function D(t){if(!t||t.length===0)return{};let e={};if(t.every(r=>r.length===2)){for(let[r,n]of t)e[`table:${r}`]=n;return e}if(t.length>=2){let[r,...n]=t;if(r.some(s=>!s))return{};n.forEach((s,o)=>{r.forEach((i,a)=>{s[a]!==void 0&&(e[`table:${o+1}:${i}`]=s[a])})})}return e}function z(t){return t!==void 0?{docstring:t}:{}}function Q(t){let e=new Map;for(let n of t){let s={...D(n.resolvedTable??n.table),...z(n.resolvedDocString??n.docString)};for(let[o,i]of Object.entries(s)){let a=e.get(o);a||e.set(o,a=new Set),a.add(i)}}let r={};for(let[n,s]of e)s.size===1&&(r[n]=[...s][0]);return r}import Te from"node:fs";import Ke from"node:path";import{AstBuilder as lr,GherkinClassicTokenMatcher as dr,Parser as pr}from"@cucumber/gherkin";import{IdGenerator as ur}from"@cucumber/messages";var j=class extends Error{},fr=/^(\s*)StepSet:\s*(.+?)\s*$/,gr=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Ee(t){let e=t.split(`
3
3
  `),r=new Map,n=new Map;return{source:e.map((o,i)=>{let a=o.match(fr);if(a)return r.set(i+1,a[2]),`${a[1]}Scenario: ${a[2]}`;let d=o.match(gr);return d?(n.set(i+1,d[2]),`${d[1]}* StepSet ${d[2]}`):o}).join(`
4
4
  `),definitions:r,invocations:n}}function mr(t){return new pr(new lr(ur.uuid()),new dr).parse(t)}function Je(t,e){for(let r of t)r.scenario?e(r.scenario):"rule"in r&&r.rule&&Je(r.rule.children,e)}function K(t,e){let r=new Map;for(let n of t){let s=Ke.relative(e,n),o=Ee(Te.readFileSync(n,"utf8"));if(o.definitions.size===0)continue;let i=mr(o.source);i.feature&&Je(i.feature.children,a=>{let d=o.definitions.get(a.location.line);if(d===void 0)return;let p=r.get(d);if(p)throw new j(`StepSet "${d}" is defined twice: ${p.file}:${p.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${d}".`);if(a.examples.length>0)throw new j(`StepSet "${d}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let m of a.steps)if(o.invocations.has(m.location.line))throw new j(`StepSet "${d}" (${s}:${a.location.line}) invokes another step set at line ${m.location.line} \u2014 step sets cannot be nested.`);r.set(d,{name:d,file:s,line:a.location.line,steps:[...a.steps]})})}return r}var hr=[".feature",".saffron"];function re(t){return hr.some(e=>t.endsWith(e))}function le(t){if(!Te.existsSync(t))return[];let e=[];for(let r of Te.readdirSync(t,{recursive:!0})){let n=String(r);n.endsWith(".saffron")&&e.push(Ke.join(t,n))}return e.sort()}function Xe(t,e,r){return t.map(({step:n,source:s})=>{let o;switch(n.keywordType){case Ae.OUTCOME:o="assertion";break;case Ae.CONTEXT:case Ae.ACTION:o="action";break;default:o=r.value}r.value=o;let i=n.dataTable?n.dataTable.rows.map(d=>d.cells.map(p=>p.value)):void 0,a=n.docString?.content;return{keyword:n.keyword.trim(),kind:o,text:n.text,resolvedText:N(n.text,e),table:i,resolvedTable:i?.map(d=>d.map(p=>N(p,e))),docString:a,resolvedDocString:a!==void 0?N(a,e):void 0,source:s}})}function Ze(t,e,r,n,s){let o=[];for(let i of t){let a=n.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:e,line:i.location.line,fromBackground:r}});continue}let d=s?.get(a);if(!d)throw new j(`${e}:${i.location.line} invokes StepSet "${a}", which is not defined anywhere in the project.`);for(let p of d.steps)o.push({step:p,source:{file:d.file,line:p.location.line,fromStepSet:d.name,fromBackground:r}})}return o}function kr(t,e,r,n,s){let o=t.tags.map(a=>a.name),i=[];if(t.examples.length===0)i.push({params:{},label:""});else{let a=0;for(let d of t.examples){let p=d.tableHeader?.cells.map(m=>m.value)??[];for(let m of d.tableBody){a+=1;let l={};m.cells.forEach((c,f)=>{l[p[f]]=c.value}),i.push({params:l,label:` \u2014 example ${a}`})}}}return i.map(({params:a,label:d})=>{let p={value:"action"};return{featurePath:e,featureName:r,name:t.name,displayName:`${t.name}${d}`,tags:o,steps:[...Xe(n,a,p),...Xe(s,a,p)],params:a}})}function J(t,e,r){let n=Sr.readFileSync(t,"utf8"),o=t.endsWith(".saffron")?Ee(n):{source:n,definitions:new Map,invocations:new Map},a=new vr(new br(wr.uuid()),new xr).parse(o.source);if(!a.feature)return[];let d=yr.relative(e,t),p=a.feature.name,m=[],l=(c,f)=>{let h=f;for(let u of c)if(u.background)h=[...f,...Ze(u.background.steps,d,!0,o.invocations,r)];else if(u.scenario){if(o.definitions.has(u.scenario.location.line))continue;m.push(...kr(u.scenario,d,p,h,Ze(u.scenario.steps,d,!1,o.invocations,r)))}else"rule"in u&&u.rule&&l(u.rule.children,h)};return l(a.feature.children,[]),m}import ct from"node:fs";import Pe from"node:path";import rt from"node:fs";import nt from"node:path";import de from"node:fs";import ne from"node:path";var et=".saffron";function pe(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function B(t,e,r){let n=pe(ne.basename(e,".feature"));return ne.join(t,et,"cache",n,`${pe(r)}.json`)}function tt(t,e,r){let n=pe(ne.basename(e,".feature"));return ne.join(t,et,"proposals",n,`${pe(r)}.json`)}function M(t){if(!de.existsSync(t))return;let e=JSON.parse(de.readFileSync(t,"utf8"));if(e.version!==1)throw new Error(`Unsupported cache version ${e.version} in ${t}`);return e}function X(t,e){de.mkdirSync(ne.dirname(t),{recursive:!0}),de.writeFileSync(t,`${JSON.stringify(e,null,2)}
5
5
  `)}function ot(t){let e=[];return{pattern:t.replace(/"((?:[^"\\]|\\.)*)"/g,(n,s)=>(e.push(s),`"\xAB${e.length-1}\xBB"`)),args:e}}function $r(t,e){let r=n=>JSON.stringify(n.map(({pageFingerprint:s,...o})=>o));return r(t)===r(e)}function Rr(t){let e=new Set,r=n=>{if(n)for(let s of n.matchAll(/<([^<>]+)>/g))e.add(s[1])};for(let n of t.actions)r(n.value),r(n.url),r(n.pattern),r(n.target?.name),r(n.target?.nameRegex),r(n.target?.label),r(n.target?.text),r(n.target?.placeholder);return r(t.gherkin),e}function V(t){let e=nt.join(t,".saffron","cache"),r=new Map,n=new Map,s=0;if(rt.existsSync(e))for(let a of rt.readdirSync(e,{recursive:!0})){let d=String(a);if(!d.endsWith(".json"))continue;let p=M(nt.join(e,d));if(p){s++;for(let m of p.steps){let l=m.gherkin,c=JSON.stringify(m.actions.map(({pageFingerprint:u,...g})=>g)),f=n.get(l)??[];f.includes(c)||(f.push(c),n.set(l,f));let h=r.get(l);!h||p.recordedAt>h.recordedAt?r.set(l,{gherkin:m.gherkin,table:m.table,docString:m.docString,actions:m.actions,feature:p.feature,scenario:p.scenario,recordedAt:p.recordedAt,variants:f.length}):h.variants=f.length}}}let o=[...n.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of r.values()){let{pattern:d,args:p}=ot(a.gherkin);if(p.length===0)continue;let m=i.get(d);(!m||a.recordedAt>m.recordedAt)&&i.set(d,{...a,args:p})}return{steps:r,patterns:i,divergent:o,scannedCaches:s}}function st(t,e,r){if(!!t.table!=!!e.table)return!1;if(t.table&&e.table){let o=Object.keys(D(t.table)),i=Object.keys(D(e.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(t.table)!==JSON.stringify(e.table))return!1}if(t.docString!==void 0!=(e.docString!==void 0)||t.docString!==void 0&&e.docString!==void 0&&t.docString!==e.docString&&!t.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;let n={...r,...D(e.resolvedTable??e.table),...z(e.resolvedDocString??e.docString)},s=Rr({gherkin:t.gherkin,keyword:"",kind:"action",actions:t.actions});for(let o of s)if(!(o in n))return!1;return!0}function it(t,e,r){let n=t.steps.get(e.text);if(n)return st(n,e,r)?n:void 0;let{pattern:s,args:o}=ot(e.text);if(o.length===0)return;let i=t.patterns.get(s);if(!i)return;let a=new Map;for(let l=0;l<o.length;l++){let c=i.args[l],f=o[l];if(c===f)continue;let h=a.get(c);if(h!==void 0&&h!==f)return;a.set(c,f)}let d=l=>[l?.name,l?.nameRegex,l?.label,l?.text,l?.placeholder];for(let l of a.keys())if(!i.actions.some(f=>[f.value,f.url,f.pattern,...d(f.target)].includes(l)))return;let p=l=>l!==void 0&&a.has(l)?a.get(l):l,m={...i,gherkin:e.text,actions:i.actions.map(({pageFingerprint:l,...c})=>({...c,value:p(c.value),url:p(c.url),pattern:p(c.pattern),target:c.target?{...c.target,name:p(c.target.name),nameRegex:p(c.target.nameRegex),label:p(c.target.label),text:p(c.target.text),placeholder:p(c.target.placeholder)}:void 0}))};return st(m,e,r)?m:void 0}function at(t,e){let r=[];return t.forEach((n,s)=>{if(n.actions.length!==0&&!e.steps.has(n.gherkin)){for(let o of e.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&$r(n.actions,o.actions)){r.push({index:s,from:n.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),r}function Tr(t){if(!ct.existsSync(t))return[];let e=[];for(let r of ct.readdirSync(t,{recursive:!0})){let n=String(r);re(n)&&e.push(Pe.join(t,n))}return e.sort()}function q(t,e,r){let n=Pe.isAbsolute(e)?e:Pe.join(t,e),s=Tr(n),o=r??K(s.filter(c=>c.endsWith(".saffron")),t),i=new Map,a=new Map;for(let c of s)for(let f of J(c,t,o)){let h=`${f.featurePath}::${f.name}`;for(let u of f.steps){let g=i.get(u.text);if(g||(g={text:u.text,keywords:new Map,kind:u.kind,scenarios:new Set,files:new Set,fromStepSet:u.source?.fromStepSet},i.set(u.text,g)),g.keywords.set(u.keyword,(g.keywords.get(u.keyword)??0)+1),g.scenarios.add(h),u.source&&g.files.add(u.source.file),u.source?.fromStepSet){let b=a.get(u.source.fromStepSet);b||a.set(u.source.fromStepSet,b=new Set),b.add(h)}}}let d=V(t),p=new Set(d.divergent),m=[...i.values()].map(c=>({text:c.text,keyword:[...c.keywords.entries()].sort((f,h)=>h[1]-f[1])[0][0],kind:c.kind,status:p.has(c.text)?"divergent":d.steps.has(c.text)?"recorded":"unrecorded",usage:c.scenarios.size,files:[...c.files].sort(),fromStepSet:c.fromStepSet})).sort((c,f)=>f.usage-c.usage||c.text.localeCompare(f.text)),l=[...o.values()].map(c=>({name:c.name,file:c.file,line:c.line,steps:c.steps.map(f=>f.text),usage:a.get(c.name)?.size??0})).sort((c,f)=>f.usage-c.usage||c.name.localeCompare(f.name));return{steps:m,stepSets:l,scannedFiles:s.length,scannedCaches:d.scannedCaches}}function ue(t,e){let r=e.toLowerCase();return{...t,steps:t.steps.filter(n=>n.text.toLowerCase().includes(r)),stepSets:t.stepSets.filter(n=>n.name.toLowerCase().includes(r)||n.steps.some(s=>s.toLowerCase().includes(r)))}}function lt(t){let e={},r="saffron,feature,gherkin,cucumber";for(let n of t.steps)e[`step: ${n.text}`]={scope:r,prefix:n.text,body:[n.text.replace(/\$/g,"\\$")],description:`${n.status} \xB7 used in ${n.usage} scenario(s)`};for(let n of t.stepSets)e[`StepSet: ${n.name}`]={scope:r,prefix:`StepSet ${n.name}`,body:[`StepSet ${n.name}`],description:`step set (${n.steps.length} steps) \u2014 ${n.file}`};return JSON.stringify(e,null,2)+`
@@ -23,9 +23,9 @@ Defined in StepSet **${g.fromStepSet}**`:""}`}}:void 0}}),n.onDidOpen(l=>m(l.doc
23
23
  `)){let n=r.trim();if(!n||n.startsWith("#"))continue;let s=n.indexOf("=");if(s<=0)continue;let o=n.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let i=n.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}import Qr from"node:fs";import Kr from"node:net";import Tt from"node:path";import{chromium as Jr,firefox as Xr,webkit as Zr}from"playwright";import U from"node:fs";import ge from"node:path";function yt(t,e){U.mkdirSync(ge.dirname(t),{recursive:!0}),U.writeFileSync(t,`${JSON.stringify(e,null,2)}
24
24
  `)}function me(t){return JSON.parse(U.readFileSync(t,"utf8"))}function ie(t){let e=ge.join(t,".saffron","proposals");if(!U.existsSync(e))return[];let r=[];for(let n of U.readdirSync(e)){let s=ge.join(e,n);if(U.statSync(s).isDirectory())for(let o of U.readdirSync(s)){if(!o.endsWith(".json"))continue;let i=ge.join(s,o);r.push({file:i,proposal:me(i)})}}return r}function bt(t,e){let r=me(e),n=B(t,r.cache.feature,r.cache.scenario);return X(n,r.cache),U.rmSync(e),n}function xt(t){U.rmSync(t)}import wt from"node:path";var zr=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Br(t,e){let r=String(t.getFullYear()),n=String(t.getMonth()+1).padStart(2,"0"),s=String(t.getDate()).padStart(2,"0");return e.replaceAll("YYYY",r).replaceAll("MM",n).replaceAll("DD",s).replaceAll("M",String(t.getMonth()+1)).replaceAll("D",String(t.getDate()))}function Oe(t,e=new Date){return t.replace(zr,(r,n,s)=>{let o=new Date(e);return o.setDate(o.getDate()+(n?parseInt(n,10):0)),Br(o,s??"YYYY-MM-DD")})}var Vr=/\b(\d{4})-(\d{2})-(\d{2})\b/g,qr=400;function vt(t,e=new Date){let r=new Date(e.getFullYear(),e.getMonth(),e.getDate());return t.replace(Vr,(n,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return n;let d=Math.round((a.getTime()-r.getTime())/864e5);return Math.abs(d)>qr?n:d===0?"{date}":`{date${d>0?"+":""}${d}}`})}import{createHash as Hr}from"node:crypto";async function H(t){let e;try{e=await t.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let r=e.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Hr("sha256").update(r).digest("hex")}`}var Le=t=>new Promise(e=>setTimeout(e,t)),Ue=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(e,r){this.context=e,this.listener=n=>{let s={seq:++this.seq,url:n.url(),method:n.request().method(),status:n.status()};if(this.entries.push(s),r){let o=n.headers()["content-type"]??"";/json|text|xml/i.test(o)&&n.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},e.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(e=>e.seq>this.prevStepSeq)}all(){return this.entries}};function Wr(t,e){if(!e)return t>=200&&t<300;let r=/^([1-5])xx$/i.exec(e.trim());if(r){let n=Number(r[1])*100;return t>=n&&t<n+100}return t===Number(e.trim())}function kt(t,e,r,n,s){return!(!e.test(t.url)||r&&t.method.toUpperCase()!==r.toUpperCase()||!Wr(t.status,n)||s&&!s.test(t.body??""))}var Yr=(t,e)=>`response matching ${t.method??"any-method"} /${e}/ status ${t.status??"2xx"}${t.pattern?` body /${t.pattern}/`:""}`;function $t(t,e,r,n=!1){let s=a=>se(Oe(N(a,r))),o=[],i=a=>{if(e.role){let d=e.nameRegex?new RegExp(s(e.nameRegex),"i"):e.name?s(e.name):void 0;o.push(a.getByRole(e.role,{name:d,exact:!1}))}e.label&&o.push(a.getByLabel(s(e.label))),e.placeholder&&o.push(a.getByPlaceholder(s(e.placeholder))),e.text&&o.push(a.getByText(s(e.text))),e.testId&&o.push(a.getByTestId(s(e.testId)));for(let d of e.fallbackSelectors??[])o.push(a.locator(d))};if(e.frame)i(t.frameLocator(s(e.frame)));else if(i(t),n)for(let a of t.frames())a!==t.mainFrame()&&i(a);if(o.length===0)throw new Error("Action target has neither role nor fallback selectors");return o}async function L(t,e,r){let n=Date.now()+e,s;for(;Date.now()<n;){try{if(await t())return}catch(o){s=o}await Le(100)}throw new Error(`Timed out waiting for ${r}${s?` (${String(s)})`:""}`)}async function O(t,e){let r;for(let n of t)try{await e(n.first());return}catch(s){r=s}throw r}var Me=t=>t.replace(/\s+/g," ").trim();async function Ne(t,e,r,n,s,o=new Map,i){let a=u=>se(Oe(N(u,r))),d=e.value!==void 0?a(e.value):void 0,p=e.url!==void 0?a(e.url):void 0,m=e.action==="expectVisible"||e.action==="expectText"||e.action==="expectValue"||e.action==="expectMatches"||e.action==="expectAttribute"||e.action==="captureText",l=e.target?$t(t,e.target,r,m):[],c={timeout:n},f={timeout:Math.min(1e3,n)},h=u=>async()=>{for(let g of l)try{if(await u(g.first()))return!0}catch{}return!1};switch(e.action){case"goto":{if(!p)throw new Error("goto requires url");let u=/^https?:\/\//.test(p)?p:new URL(p,s).toString();await t.goto(u,c);return}case"click":return O(l,u=>u.click(c));case"fill":return O(l,u=>u.fill(d??"",c));case"press":return l.length>0?O(l,u=>u.press(d??"",c)):t.keyboard.press(d??"");case"selectOption":return O(l,async u=>{await u.selectOption(d??"",c)});case"check":return O(l,u=>u.check(c));case"uncheck":return O(l,u=>u.uncheck(c));case"hover":return O(l,u=>u.hover(c));case"waitFor":return l.length>0?O(l,u=>u.waitFor({state:"visible",timeout:n})):Le(Math.min(Number(d??"0"),n));case"expectVisible":return L(h(u=>u.isVisible()),n,"target to be visible");case"expectNotVisible":return O(l,u=>u.waitFor({state:"hidden",timeout:n}));case"expectText":return L(h(async u=>(await u.textContent(f)??"").includes(d??"")),n,`text "${d}"`);case"expectValue":return L(h(async u=>await u.inputValue(f)===(d??"")),n,`input value "${d}"`);case"expectUrl":{let u=p??d??"";return L(async()=>t.context().pages().some(g=>g.url().includes(u)),n,`URL containing "${u}" in any tab (current: ${t.context().pages().map(g=>g.url()).join(", ")})`)}case"handleDialog":{let u=(d??"accept")!=="dismiss",g=e.promptText?a(e.promptText):void 0;t.once("dialog",b=>{u?b.accept(g):b.dismiss()});return}case"uploadFile":{let u=(d??"").split(`
25
25
  `).filter(Boolean).map(g=>wt.isAbsolute(g)?g:wt.resolve(g));if(u.length===0)throw new Error("uploadFile requires a path");if(l.length>0)return O(l,g=>g.setInputFiles(u,c));t.once("filechooser",g=>{g.setFiles(u)});return}case"dragTo":{if(!e.toTarget)throw new Error("dragTo requires toTarget");let u=$t(t,e.toTarget,r);return O(l,g=>g.dragTo(u[0].first(),c))}case"captureText":{if(!e.saveAs)throw new Error("captureText requires saveAs");return L(h(async u=>{let g=Me(await u.textContent(f)??"");return g===""?!1:(o.set(e.saveAs,g),!0)}),n,`non-empty text to capture as "${e.saveAs}"`)}case"expectDiffers":{if(!e.compareTo)throw new Error("expectDiffers requires compareTo");let u=o.get(e.compareTo);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${e.compareTo}"`);if(l.length>0)return L(h(async S=>Me(await S.textContent(f)??"")!==u),n,`target text to differ from "${e.compareTo}" (${u})`);let g=d??"",b=o.get(g);if(b===void 0)throw new Error(`expectDiffers: no captured value named "${g}"`);if(b===u)throw new Error(`expectDiffers: "${g}" (${b}) equals "${e.compareTo}" (${u})`);return}case"expectAttribute":{if(!e.attribute)throw new Error("expectAttribute requires attribute");if(!e.pattern)throw new Error("expectAttribute requires pattern");let u=new RegExp(e.pattern,"i");return L(h(async g=>u.test(await g.getAttribute(e.attribute,f)??"")),n,`attribute ${e.attribute} to match /${e.pattern}/i`)}case"expectMatches":{if(!e.pattern)throw new Error("expectMatches requires pattern");let u=new RegExp(e.pattern,"i");return L(h(async g=>u.test(Me(await g.textContent(f)??""))),n,`target text to match /${e.pattern}/i`)}case"waitForResponse":case"expectResponse":{if(!e.urlPattern)throw new Error(`${e.action} requires urlPattern`);let u=new RegExp(a(e.urlPattern)),g=e.pattern?new RegExp(a(e.pattern)):void 0;if(!i){await t.waitForResponse(S=>kt({seq:0,url:S.url(),method:S.request().method(),status:S.status()},u,e.method,e.status,void 0),c);return}let b=()=>e.action==="expectResponse"?i.all():i.sincePreviousStep();return L(async()=>b().some(S=>kt(S,u,e.method,e.status,g)),n,Yr(e,e.urlPattern))}default:throw new Error(`Unknown action type: ${e.action}`)}}function Rt(t,e){return t.actions.some(r=>[r.value,r.url,r.pattern,r.target?.name,r.target?.nameRegex,r.target?.label,r.target?.text,r.target?.placeholder].some(n=>n?.includes(e)))}function Gr(t,e){return e.steps.length!==t.steps.length?!1:e.steps.every((r,n)=>{let s=t.steps[n];if(r.gherkin!==s.text)return!1;let o=r.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(D(o)),d=Object.keys(D(i));if(a.length>0&&d.length>0){if(JSON.stringify(a)!==JSON.stringify(d))return!1}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(r.docString!==void 0!=(s.docString!==void 0))return!1;if(r.docString!==void 0&&s.docString!==void 0&&r.docString!==s.docString){let a=e.steps.some(p=>Rt(p,"<docstring>")),d=e.steps.some(p=>Rt(p,r.docString));if(!a||d)return!1}return!0})}function ze(t,e){let r=t.steps[e],n=r?.resolvedTable??r?.table;return{...t.params,...Q(t.steps),...D(n),...z(r?.resolvedDocString??r?.docString)}}async function he(t,e,r,n={}){let s=n.actionTimeoutMs??5e3,o=n.retries??1,i=[],a=n.vars??new Map,d=()=>Object.fromEntries(a),p=r.steps.some(f=>f.actions.some(h=>(h.action==="waitForResponse"||h.action==="expectResponse")&&h.pattern!==void 0)),m=new Ue;m.attach(t.context(),p);let l=t,c=()=>{let f=t.context().pages(),h=f[f.length-1];h&&h!==l&&!h.isClosed()&&(l=h)};if(!Gr(e,r))return m.detach(),{status:"stale",stepResults:i,captured:d()};try{for(let f=0;f<r.steps.length;f++){let h=r.steps[f],u=ze(e,f);m.markStep();let g=!1;for(let b=0;b<h.actions.length;b++){let S=h.actions[b];c(),S.pageFingerprint&&await H(l)!==S.pageFingerprint&&(g=!0);let k,x=!1;for(let v=0;v<=o&&!x;v++){v>0&&(await Le(250*v),c());try{await Ne(l,S,u,s,n.baseURL,a,m),x=!0}catch($){k=$}}if(!x){let v=k instanceof Error?k.message:String(k);i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"failed",drift:g,error:v});for(let $=f+1;$<r.steps.length;$++)i.push({gherkin:r.steps[$].gherkin,kind:r.steps[$].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:f,actionIndex:b,stepText:h.gherkin,kind:h.kind,error:v},stepResults:i,captured:d()}}}i.push({gherkin:h.gherkin,fromStepSet:h.fromStepSet,kind:h.kind,status:"passed",drift:g})}return{status:"passed",stepResults:i,captured:d()}}finally{m.detach()}}var Se={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function en(){return new Promise((t,e)=>{let r=Kr.createServer();r.listen(0,"127.0.0.1",()=>{let{port:n}=r.address();r.close(()=>t(n))}),r.on("error",e)})}function ae(t){let e=t.contexts().flatMap(r=>r.pages());return[...e].reverse().find(r=>r.url()!=="about:blank")??e.at(-1)}function Ct(t){let e=t.length;for(let r=t.length-1;r>=0&&t[r].kind==="assertion";r--)e=r;return e}function Et(t,e){return t[e]?.kind==="assertion"&&e>=Ct(t)}function At(t,e,r=!1,n){let s=Ct(t);return t.map((o,i)=>{let a=e[i],d=a&&a.actions.length>0;return o.kind==="assertion"?r&&i<s&&d?a:o:d?n&&i===n.stepIndex&&n.actionIndex>0?{...a,actions:[...o.actions.slice(0,n.actionIndex),...a.actions]}:a:o})}function tn(t,e){return t.map((r,n)=>{let s=e[n];return s&&s.actions.length>0?s:r})}function Pt(t,e){return{aiCalls:t.aiCalls+e.aiCalls,inputTokens:t.inputTokens+e.inputTokens,outputTokens:t.outputTokens+e.outputTokens,cacheReadTokens:t.cacheReadTokens+e.cacheReadTokens,cacheCreationTokens:t.cacheCreationTokens+e.cacheCreationTokens,costUsd:(t.costUsd??0)+(e.costUsd??0)}}var ye=class{constructor(e){this.options=e}options;browser;cdpEndpoint;stepIndex;getStepIndex(){if(!this.stepIndex&&(this.stepIndex=V(this.options.projectRoot),this.stepIndex.steps.size>0)){let e=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${e}`)}return this.stepIndex}async recordWithSeeding(e,r){let n=this.getStepIndex(),s=e.steps.length,o={...e.params,...Q(e.steps)},i=e.steps.map(S=>{let k=n.steps.size>0?it(n,S,o):void 0;return k?{gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:k.actions}:void 0}),a=e.steps.map(S=>({gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:[]})),d=new Map,p=[],m=[],l=[],c=[],f={...Se},h=0,u=0,g=0,b=S=>({success:S,narrative:m.join(" ")||"Composed entirely from seeded recordings.",adaptations:p,suggestedFeatureEdit:c.length>0?c.join(`
26
- `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:f,seededCount:h});for(;g<s;){if(i[g]){let $=g;for(;$<s&&i[$];)$++;let y=i.slice(g,$),R={...e,steps:e.steps.slice(g,$)},A=await he(r,R,this.buildCandidateCache(e,y),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),P=A.status==="passed"?$-g:A.failure?.stepIndex??0;for(let G=0;G<P;G++)a[g+G]=y[G];if(h+=P,A.status==="passed"){g=$;continue}let _=g+P;p.push(`Seeded recording for "${e.steps[_].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[_]=void 0,g=_;continue}if(!this.options.agent)return b(!1);u++;let S=g;for(;S<s&&!i[S];)S++;u>=3&&(S=s);let k=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:g>0?e.steps.slice(0,g).map($=>$.resolvedText):void 0,recordOnly:S<s?{from:g,to:S-1}:void 0,takeFingerprint:async()=>H(ae(this.browser)??r)}));if(f=Pt(f,k.usage),m.push(k.narrative),p.push(...k.adaptations),l.push(...k.featureEdits),k.suggestedFeatureEdit&&c.push(k.suggestedFeatureEdit),!k.success)return b(!1);let x=k.announcedSteps.filter($=>$>=g&&$<s),v=Math.min(s-1,Math.max(x.length>0?Math.max(...x):S-1,g));for(let $=g;$<=v;$++)a[$]=k.recordedSteps[$];g=v+1}return b(!0)}async withDialogsVisible(e){let r=()=>{},n=[],s=[],o=i=>{i.on("dialog",r),n.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of n)try{i.off("dialog",r)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let r=await en();this.browser=await Jr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${r}`]}),this.cdpEndpoint=`http://127.0.0.1:${r}`}else this.browser=await(e==="firefox"?Xr:Zr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=Tt.isAbsolute(this.options.storageState)?this.options.storageState:Tt.join(this.options.projectRoot,this.options.storageState);if(!Qr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,r){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:r}}async proofReplay(e,r){let n=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await n.newPage();return{result:await he(s,e,r,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:n,page:s}}async runScenario(e,r={}){let n=await this.runScenarioInner(e,r),s=oe(e);if(s.size>0){let o=i=>i===void 0?i:Z(i,s);n.error=o(n.error),n.narrative=o(n.narrative),n.proofError=o(n.proofError),n.suggestedFeatureEdit=o(n.suggestedFeatureEdit),n.adaptations=n.adaptations.map(i=>Z(i,s));for(let i of n.stepResults)i.error=o(i.error)}return n}async runScenarioInner(e,r={}){if(!this.browser)throw new Error("Orchestrator not started");let n=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=B(s,e.featurePath,e.name),p=M(d),m=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await m.newPage();try{let c;if(p&&(c=await he(l,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:Se,durationMs:Date.now()-n};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!r.agentDisabled)){let b=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!r.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:p?c?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let h=p!==void 0&&c!==void 0&&c.status==="failed",u=0,g;try{if(h)g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,l);u=b.seededCount,g=b}else g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}))}catch(b){let S=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:`Agent invocation failed: ${S.split(`
26
+ `):void 0,featureEdits:l,recordedSteps:a,announcedSteps:[],usage:f,seededCount:h});for(;g<s;){if(i[g]){let $=g;for(;$<s&&i[$];)$++;let y=i.slice(g,$),T={...e,steps:e.steps.slice(g,$)},A=await he(r,T,this.buildCandidateCache(e,y),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,vars:d}),P=A.status==="passed"?$-g:A.failure?.stepIndex??0;for(let G=0;G<P;G++)a[g+G]=y[G];if(h+=P,A.status==="passed"){g=$;continue}let _=g+P;p.push(`Seeded recording for "${e.steps[_].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[_]=void 0,g=_;continue}if(!this.options.agent)return b(!1);u++;let S=g;for(;S<s&&!i[S];)S++;u>=3&&(S=s);let k=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:g>0?e.steps.slice(0,g).map($=>$.resolvedText):void 0,recordOnly:S<s?{from:g,to:S-1}:void 0,takeFingerprint:async()=>H(ae(this.browser)??r)}));if(f=Pt(f,k.usage),m.push(k.narrative),p.push(...k.adaptations),l.push(...k.featureEdits),k.suggestedFeatureEdit&&c.push(k.suggestedFeatureEdit),!k.success)return b(!1);let x=k.announcedSteps.filter($=>$>=g&&$<s),v=Math.min(s-1,Math.max(x.length>0?Math.max(...x):S-1,g));for(let $=g;$<=v;$++)a[$]=k.recordedSteps[$];g=v+1}return b(!0)}async withDialogsVisible(e){let r=()=>{},n=[],s=[],o=i=>{i.on("dialog",r),n.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await e()}finally{for(let i of s)i.off("page",o);for(let i of n)try{i.off("dialog",r)}catch{}}}async start(){let e=this.options.browser??"chromium";if(e==="chromium"){let r=await en();this.browser=await Jr.launch({headless:!this.options.headed,args:[`--remote-debugging-port=${r}`]}),this.cdpEndpoint=`http://127.0.0.1:${r}`}else this.browser=await(e==="firefox"?Xr:Zr).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let e=Tt.isAbsolute(this.options.storageState)?this.options.storageState:Tt.join(this.options.projectRoot,this.options.storageState);if(!Qr.existsSync(e))throw new Error(`storageState file not found: ${e}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return e}buildCandidateCache(e,r){return{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:r}}async proofReplay(e,r){let n=await this.browser.newContext({storageState:this.resolveStorageState()}),s=await n.newPage();return{result:await he(s,e,r,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries}),context:n,page:s}}async runScenario(e,r={}){let n=await this.runScenarioInner(e,r),s=oe(e);if(s.size>0){let o=i=>i===void 0?i:Z(i,s);n.error=o(n.error),n.narrative=o(n.narrative),n.proofError=o(n.proofError),n.suggestedFeatureEdit=o(n.suggestedFeatureEdit),n.adaptations=n.adaptations.map(i=>Z(i,s));for(let i of n.stepResults)i.error=o(i.error)}return n}async runScenarioInner(e,r={}){if(!this.browser)throw new Error("Orchestrator not started");let n=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:i,retries:a}=this.options,d=B(s,e.featurePath,e.name),p=M(d),m=await this.browser.newContext({storageState:this.resolveStorageState()}),l=await m.newPage();try{let c;if(p&&(c=await he(l,e,p,{baseURL:o,actionTimeoutMs:i,retries:a}),c.status==="passed"))return{scenario:e,status:"green",source:"cache",stepResults:c.stepResults,adaptations:[],usage:Se,durationMs:Date.now()-n};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!r.agentDisabled)){let b=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!r.agentDisabled?`recording/healing requires Chromium (the agent attaches over CDP). Record with --browser chromium (the default), then replay with --browser ${this.options.browser}.`:"no agent is configured.";return{scenario:e,status:"red",source:"cache",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:p?c?.status==="stale"?`Cache is stale (feature file changed) and ${b}`:`Replay failed and ${b} ${c?.failure?.error??""}`.trimEnd():`No cache exists and ${b}`}}let h=p!==void 0&&c!==void 0&&c.status==="failed",u=0,g;try{if(h)g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"heal",model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:c.stepResults.filter(b=>b.status==="passed").map(b=>b.gherkin),failedStep:{text:c.failure.stepText,kind:c.failure.kind,error:c.failure.error},takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}));else if(await l.goto("about:blank"),this.options.reuseSteps!==!1){let b=await this.recordWithSeeding(e,l);u=b.seededCount,g=b}else g=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:o,mode:"record",cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,takeFingerprint:async()=>{let b=ae(this.browser)??l;return H(b)}}))}catch(b){let S=b instanceof Error?b.message:String(b);return{scenario:e,status:"red",source:h?"agent-heal":"agent-record",stepResults:c?.stepResults??[],adaptations:[],usage:Se,durationMs:Date.now()-n,error:`Agent invocation failed: ${S.split(`
27
27
  `)[0]}`}}if(g.success&&h&&c.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Et(p.steps,c.failure.stepIndex))){let b=p.steps[c.failure.stepIndex],S=ae(this.browser)??l,k=new Map(Object.entries(c.captured)),x=ze(e,c.failure.stepIndex);try{for(let v of b.actions)await Ne(S,v,x,this.options.actionTimeoutMs??5e3,o,k)}catch(v){let $=v instanceof Error?v.message:String(v);return{scenario:e,status:"red",source:"agent-heal",stepResults:c.stepResults,narrative:g.narrative,adaptations:g.adaptations,suggestedFeatureEdit:g.suggestedFeatureEdit,usage:g.usage,durationMs:Date.now()-n,error:`Assertion failed as written: "${b.gherkin}". Saffron never adapts assertions \u2014 if this change is intended, update the .feature file or re-record the scenario. Verification error: ${$.split(`
28
- `)[0]}`}}}return await this.finalizeAgentRun(e,g,h,p,n,u,h?c?.failure:void 0)}finally{await m.close()}}async finalizeAgentRun(e,r,n,s,o,i=0,a){let d=n?"agent-heal":"agent-record",p=r.recordedSteps.map(x=>({gherkin:x.gherkin,kind:x.kind,status:r.success?"passed":"failed"}));if(!r.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:r.narrative,adaptations:r.adaptations,usage:r.usage,durationMs:Date.now()-o,error:r.narrative};let m=n?At(s.steps,r.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):r.recordedSteps,l=[...r.adaptations],c={...r.usage},f,h;if(this.options.verifyProposals!==!1){let x=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(x.result.status!=="passed"&&x.result.failure){let v=x.result.failure;if(this.options.agent&&!(n&&v.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Et(s.steps,v.stepIndex))))try{let y=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:n?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:x.result.stepResults.filter(R=>R.status==="passed").map(R=>R.gherkin),failedStep:{text:v.stepText,kind:v.kind,error:v.error},takeFingerprint:async()=>H(ae(this.browser)??x.page)}));c=Pt(c,y.usage),y.success&&(l.push(...y.adaptations),m=n?At(m,y.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):tn(m,y.recordedSteps),await x.context.close(),x=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}f=x.result.status==="passed",f||(h=x.result.failure?.error??`proof replay ${x.result.status}`)}finally{await x.context.close().catch(()=>{})}}let u=[...r.featureEdits];for(let x of at(m,V(this.options.projectRoot)))l.push(`duplicate wording: "${x.from}" recorded exactly the same actions as the existing step "${x.to}" (${x.feature} \u203A ${x.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(v=>v.index===x.index)||u.push({index:x.index,text:x.to});let g=oe(e),b=x=>g.size>0?Z(x,g):x;l=l.map(b);let S={meta:{createdAt:new Date().toISOString(),mode:n?"heal":"record",narrative:b(r.narrative),adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit?b(r.suggestedFeatureEdit):void 0,featureEdits:u.length>0?u:void 0,verified:f,proofError:h?b(h):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},k=tt(this.options.projectRoot,e.featurePath,e.name);return yt(k,S),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:r.narrative,adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:u.length>0?u:void 0,verified:f,proofError:h,seededSteps:i>0?i:void 0,proposalFile:k,usage:c,durationMs:Date.now()-o}}};import Bt from"node:path";import{createRequire as ln}from"node:module";import{query as dn}from"@anthropic-ai/claude-agent-sdk";var be=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];if(!n||n.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(n.actions.length-1,0);n.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];n&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.actions.push(...e))}},W=t=>t.replace(/\\(['"\\])/g,"$1");function rn(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?W(e[1]):void 0}function Be(t){let e={},r=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);r&&(e.frame=W(r[1]??r[2]),t=t.slice((r.index??0)+r[0].length));let n=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(n){e.role=n[1];let i=(n[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=W(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=W(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[W(o[1])]),Object.keys(e).length>0?e:void 0}function nn(t,e){if(e&&t.startsWith(e)){let r=t.slice(e.length);return r.startsWith("/")?r:`/${r}`}return t}var sn={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function on(t,e){let r=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"goto",url:nn(W(r[1]),e)};let n=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"press",value:W(n[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=Be(s[1]),f=Be(s[2]);if(c&&f)return{action:"dragTo",target:c,toTarget:f}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=Be(i);if(!p)return;let m={action:sn[a],target:p},l=rn(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function Ft(t,e){let r=[],n=Array.isArray(t)?t:t?.content;if(Array.isArray(n))for(let o of n){let i=o?.text;typeof i=="string"&&r.push(i)}else typeof t=="string"&&r.push(t);let s=[];for(let o of r)for(let i of o.split(`
28
+ `)[0]}`}}}return await this.finalizeAgentRun(e,g,h,p,n,u,h?c?.failure:void 0)}finally{await m.close()}}async finalizeAgentRun(e,r,n,s,o,i=0,a){let d=n?"agent-heal":"agent-record",p=r.recordedSteps.map(x=>({gherkin:x.gherkin,kind:x.kind,status:r.success?"passed":"failed"}));if(!r.success)return{scenario:e,status:"red",source:d,stepResults:p,narrative:r.narrative,adaptations:r.adaptations,usage:r.usage,durationMs:Date.now()-o,error:r.narrative};let m=n?At(s.steps,r.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):r.recordedSteps,l=[...r.adaptations],c={...r.usage},f,h;if(this.options.verifyProposals!==!1){let x=await this.proofReplay(e,this.buildCandidateCache(e,m));try{if(x.result.status!=="passed"&&x.result.failure){let v=x.result.failure;if(this.options.agent&&!(n&&v.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||Et(s.steps,v.stepIndex))))try{let y=await this.withDialogsVisible(()=>this.options.agent.run({scenario:e,baseURL:this.options.baseURL,mode:"refine",model:n?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,assertionPolicy:this.options.assertionPolicy,completedSteps:x.result.stepResults.filter(T=>T.status==="passed").map(T=>T.gherkin),failedStep:{text:v.stepText,kind:v.kind,error:v.error},takeFingerprint:async()=>H(ae(this.browser)??x.page)}));c=Pt(c,y.usage),y.success&&(l.push(...y.adaptations),m=n?At(m,y.recordedSteps,this.options.assertionPolicy==="adaptable-mid"):tn(m,y.recordedSteps),await x.context.close(),x=await this.proofReplay(e,this.buildCandidateCache(e,m)))}catch{}}f=x.result.status==="passed",f||(h=x.result.failure?.error??`proof replay ${x.result.status}`)}finally{await x.context.close().catch(()=>{})}}let u=[...r.featureEdits];for(let x of at(m,V(this.options.projectRoot)))l.push(`duplicate wording: "${x.from}" recorded exactly the same actions as the existing step "${x.to}" (${x.feature} \u203A ${x.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(v=>v.index===x.index)||u.push({index:x.index,text:x.to});let g=oe(e),b=x=>g.size>0?Z(x,g):x;l=l.map(b);let S={meta:{createdAt:new Date().toISOString(),mode:n?"heal":"record",narrative:b(r.narrative),adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit?b(r.suggestedFeatureEdit):void 0,featureEdits:u.length>0?u:void 0,verified:f,proofError:h?b(h):void 0,seededSteps:i>0?i:void 0,usage:c},cache:{version:1,feature:e.featurePath,scenario:e.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:m}},k=tt(this.options.projectRoot,e.featurePath,e.name);return yt(k,S),{scenario:e,status:"yellow",source:d,stepResults:p,narrative:r.narrative,adaptations:l,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:u.length>0?u:void 0,verified:f,proofError:h,seededSteps:i>0?i:void 0,proposalFile:k,usage:c,durationMs:Date.now()-o}}};import Bt from"node:path";import{createRequire as ln}from"node:module";import{query as dn}from"@anthropic-ai/claude-agent-sdk";var be=class{steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(e){this.steps=e.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(e){if(this.done)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];if(!n||n.actions.some(i=>i.action===e.action&&i.value===e.value&&i.promptText===e.promptText))return;let o=Math.max(n.actions.length-1,0);n.actions.splice(o,0,e)}addActions(e){if(this.done||e.length===0)return;let r=Math.max(this.currentStepIndex,0),n=this.steps[r];n&&(this.pendingFingerprint&&e[0]&&(e[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.actions.push(...e))}},W=t=>t.replace(/\\(['"\\])/g,"$1");function rn(t){let e=t.match(/'((?:[^'\\]|\\.)*)'/);return e?W(e[1]):void 0}function Be(t){let e={},r=t.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);r&&(e.frame=W(r[1]??r[2]),t=t.slice((r.index??0)+r[0].length));let n=t.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(n){e.role=n[1];let i=(n[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);i&&(e.name=W(i[1]))}let s=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[i,a]of s){let d=t.match(i);d&&(e[a]=W(d[1]))}let o=t.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return o&&(e.fallbackSelectors=[W(o[1])]),Object.keys(e).length>0?e:void 0}function nn(t,e){if(e&&t.startsWith(e)){let r=t.slice(e.length);return r.startsWith("/")?r:`/${r}`}return t}var sn={click:"click",dblclick:"click",fill:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function on(t,e){let r=t.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"goto",url:nn(W(r[1]),e)};let n=t.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"press",value:W(n[1])};let s=t.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let c=Be(s[1]),f=Be(s[2]);if(c&&f)return{action:"dragTo",target:c,toTarget:f}}let o=t.match(/await\s+page\.(.+)\.(click|dblclick|fill|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,d]=o,p=Be(i);if(!p)return;let m={action:sn[a],target:p},l=rn(d);return l!==void 0&&a!=="click"&&a!=="dblclick"&&(m.value=l),m}function Ft(t,e){let r=[],n=Array.isArray(t)?t:t?.content;if(Array.isArray(n))for(let o of n){let i=o?.text;typeof i=="string"&&r.push(i)}else typeof t=="string"&&r.push(t);let s=[];for(let o of r)for(let i of o.split(`
29
29
  `)){let a=on(i.trim(),e);a&&s.push(a)}return s}function Ve(t,e){let r=t;for(let[n,s]of Object.entries(e).filter(([,o])=>o.length>=3).sort((o,i)=>i[1].length-o[1].length))r=r.split(s).join(`<${n}>`);return r}function It(t,e){if(Object.entries(e).filter(([,s])=>s.length>=3).length===0)return t;let n=s=>s===void 0?void 0:Ve(s,e);return t.map(s=>({...s,actions:s.actions.map(o=>({...o,value:n(o.value),url:n(o.url),target:o.target?{...o.target,name:n(o.target.name),label:n(o.target.label),text:n(o.target.text),placeholder:n(o.target.placeholder)}:void 0}))}))}function _t(t,e){let r=Q(e.steps);return t.map((n,s)=>{let o=e.steps[s],i=o?.resolvedTable??o?.table,a=o?.resolvedDocString??o?.docString,d=Object.entries({...r,...D(i),...z(a)}).filter(([,m])=>m.length>=3).sort((m,l)=>l[1].length-m[1].length);if(d.length===0)return n;let p=m=>{if(m===void 0)return;let l=m;for(let[c,f]of d)l=l.split(f).join(`<${c}>`);return l};return{...n,actions:n.actions.map(m=>({...m,value:p(m.value),url:p(m.url),target:m.target?{...m.target,name:p(m.target.name),label:p(m.target.label),text:p(m.target.text),placeholder:p(m.target.placeholder)}:void 0}))}})}function jt(t,e=new Date){let r=n=>n===void 0?void 0:vt(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text)}:void 0}))}))}function Dt(t,e){if(e.size===0)return t;let r=n=>n===void 0?void 0:Z(n,e);return t.map(n=>({...n,actions:n.actions.map(s=>({...s,value:r(s.value),url:r(s.url),pattern:r(s.pattern),target:s.target?{...s.target,name:r(s.target.name),nameRegex:r(s.target.nameRegex),label:r(s.target.label),text:r(s.target.text),placeholder:r(s.target.placeholder)}:void 0}))}))}function Ot(t,e){let r=e??{};if(t==="mcp__playwright__browser_handle_dialog"){let n={action:"handleDialog",value:r.accept?"accept":"dismiss"};return typeof r.promptText=="string"&&r.promptText&&(n.promptText=r.promptText),n}if(t==="mcp__playwright__browser_file_upload"){let n=Array.isArray(r.paths)?r.paths:[];if(n.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:n.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
30
30
  `)}}}import{createSdkMcpServer as an,tool as ee}from"@anthropic-ai/claude-agent-sdk";import{z as w}from"zod";var I=t=>({content:[{type:"text",text:t}]});function Mt(t){let e={};return t.role&&(e.role=t.role),t.name&&(e.name=t.name),t.nameRegex&&(e.nameRegex=t.nameRegex),t.label&&(e.label=t.label),t.text&&(e.text=t.text),t.selector&&(e.fallbackSelectors=[t.selector]),t.frame&&(e.frame=t.frame),Object.keys(e).length>0?e:void 0}var Ut={role:w.string().optional().describe("ARIA role of the element"),name:w.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:w.string().optional().describe("Regex for the accessible name when it embeds volatile content, e.g. 'Select \\\\{date\\\\+1\\\\} as checkin' cannot work \u2014 instead use nameRegex 'Select .* as checkin'"),label:w.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:w.string().optional().describe("getByText locator text"),selector:w.string().optional().describe("CSS fallback selector"),frame:w.string().optional().describe(`CSS selector of the iframe hosting the element, e.g. 'iframe[title="Feedback widget"]' \u2014 REQUIRED whenever the element lives inside an iframe; without it replay looks only at the top-level page`)};function Lt(t){return an({name:"saffron",version:"0.1.0",tools:[ee("saffron_step","Announce the Gherkin step you are about to perform. MUST be called before executing browser actions for each step, with the step's zero-based index and exact text.",{index:w.number().int().min(0),text:w.string(),adaptation:w.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence.")},async e=>(t.currentStepIndex=e.index,t.announced.add(e.index),e.adaptation&&t.adaptations.push(`Step ${e.index+1} ("${e.text}"): ${e.adaptation}`),I(`Recording step ${e.index}: ${e.text}`))),ee("saffron_capture",`Record a captureText action for the current step: at replay time, the target element's text is read and stored under saveAs for later expectDiffers comparisons. Use for steps like 'I record the displayed price as "X"'. The target must select exactly the element whose text is the value.`,{saveAs:w.string().describe("Name to store the captured text under"),...Ut},async e=>{let r=Mt(e);return r?(t.addActions([{action:"captureText",target:r,saveAs:e.saveAs}]),I(`Capture recorded as "${e.saveAs}".`)):I("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).")}),ee("saffron_dialog","Record how a native dialog (alert/confirm/prompt) must be handled at replay. Call this IMMEDIATELY whenever an action triggers a native dialog \u2014 whether you handled it via browser_handle_dialog or the browser reported it as already handled. At replay Saffron arms this handler BEFORE the triggering action, so without this call the replay will auto-dismiss the dialog and diverge from what you saw.",{accept:w.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:w.string().optional().describe("Text typed into a prompt() before accepting")},async e=>(t.addArmedAction({action:"handleDialog",value:e.accept?"accept":"dismiss",...e.promptText?{promptText:e.promptText}:{}}),I(`Dialog handling recorded (${e.accept?"accept":"dismiss"}).`))),ee("saffron_wait_api","Record a network wait for the current step: replay will block until a matching API response is observed (responses from the triggering step count). Call AFTER you verified via browser_network_requests that the response actually occurred with the expected status/body. Use a path-only urlPattern regex and never literal volatile IDs (/api/orders/\\d+, not /api/orders/8842).",{urlPattern:w.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:w.string().optional().describe("HTTP method to match (any if omitted)"),status:w.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:w.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async e=>(t.addActions([{action:"waitForResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}}]),I("Network wait recorded."))),ee("saffron_assert","Record a verified assertion for the current Then step. Call ONLY after you have confirmed the condition holds via a page snapshot. If the condition does NOT hold, do not call this \u2014 call saffron_done with success=false instead. NEVER record volatile content (prices, dates, counters) as literal expected text \u2014 use kind=matches with a pattern, or kind=differs against captured values.",{kind:w.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response"]),...Ut,expected:w.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:w.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:w.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:w.string().optional().describe("kind=attribute: attribute name to check, e.g. 'href' for link-target assertions \u2014 use this for 'X should link to Y' instead of clicking through"),capture:w.string().optional().describe("kind=differs: name of the captured value on the LEFT of the comparison (omit when comparing the target element's live text)"),urlPattern:w.string().optional().describe("kind=response: regex matched against the response URL \u2014 path-only for portability, never literal volatile IDs (use /api/orders/\\d+)"),method:w.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:w.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:w.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),compareTo:w.string().optional().describe("kind=differs: name of the captured value to compare against")},async e=>{let r=Mt(e),n;switch(e.kind){case"visible":n={action:"expectVisible",target:r};break;case"notVisible":n={action:"expectNotVisible",target:r};break;case"text":n={action:"expectText",target:r,value:e.expected??""};break;case"value":n={action:"expectValue",target:r,value:e.expected??""};break;case"url":n={action:"expectUrl",url:e.url??""};break;case"differs":if(!e.compareTo)return I("ERROR: kind=differs requires compareTo.");if(!e.capture&&!r)return I("ERROR: kind=differs needs either capture (a stored name) or a target element.");n={action:"expectDiffers",target:r,value:e.capture,compareTo:e.compareTo};break;case"matches":if(!e.pattern)return I("ERROR: kind=matches requires pattern.");n={action:"expectMatches",target:r,pattern:e.pattern};break;case"attribute":if(!e.attribute||!e.pattern)return I("ERROR: kind=attribute requires attribute and pattern.");n={action:"expectAttribute",target:r,attribute:e.attribute,pattern:e.pattern};break;case"response":if(!e.urlPattern)return I("ERROR: kind=response requires urlPattern.");n={action:"expectResponse",urlPattern:e.urlPattern,...e.method?{method:e.method}:{},...e.status?{status:e.status}:{},...e.bodyPattern?{pattern:e.bodyPattern}:{}};break}return!["url","differs","response"].includes(e.kind)&&!r?I("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(t.addActions([n]),I("Assertion recorded."))}),ee("saffron_done","Finalize the run. Call exactly once, after the final step: success=true only if every step's intent was achieved and every assertion verified.",{success:w.boolean(),narrative:w.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did."),suggestedFeatureEdit:w.string().optional().describe("If you adapted any steps: the corrected Gherkin for those steps, as you would rewrite them in the .feature file (free text, for the human report)."),featureEdits:w.array(w.object({index:w.number().int().min(0).describe("zero-based step index"),text:w.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async e=>(t.done=!0,t.success=e.success,t.narrative=e.narrative,t.suggestedFeatureEdit=e.suggestedFeatureEdit,t.featureEdits=e.featureEdits??[],I("Run finalized.")))]})}function Nt(t={}){let e=t.deliberateSnapshots?`
31
31
  8. Tool responses do NOT include a page snapshot. Take snapshots deliberately with browser_snapshot, and only when you actually need to see the page: after navigation, before locating an element you haven't seen yet, and when verifying an assertion. Do NOT snapshot after actions whose outcome is predictable (filling a field you just located, pressing Enter, a click whose result you will verify later anyway). Every snapshot is expensive.
@@ -265,14 +265,14 @@ ${t.completedSteps.map(i=>` \u2713 ${i}`).join(`
265
265
  `),We.writeFileSync(s,Jt(e)),{json:n,html:s}}import $e from"node:fs";import er from"node:path";function tr(t){return er.join(t,".saffron","history.jsonl")}function rr(t,e,r,n){let s=t.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
266
266
  `)[0]}));return{runId:e.toISOString(),startedAt:e.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:r,scenarios:s}}function nr(t,e){let r=tr(t);$e.mkdirSync(er.dirname(r),{recursive:!0}),$e.appendFileSync(r,`${JSON.stringify(e)}
267
267
  `)}function sr(t,e=50){let r=tr(t);if(!$e.existsSync(r))return[];let n=$e.readFileSync(r,"utf8").trim().split(`
268
- `).filter(Boolean),s=[];for(let o of n.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var Ge=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function or(t,e){let r=[...t,e],n=r.slice(-20),s=r.slice(-10),o=t.at(-1),i=o?{passRatePp:Ge(e)-Ge(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let f=`${c.feature}::${c.scenario}`,h=a.get(f)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(f,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of n)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let m=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:n.map(Ge),costUsd:n.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:n.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}function te(t){St(t);let e=T.join(t,"saffron.config.json");return F.existsSync(e)?JSON.parse(F.readFileSync(e,"utf8")):{}}function En(t,e){let r=e.length>0?e:["features"],n=[];for(let s of r){let o=T.isAbsolute(s)?s:T.join(t,s);if(F.existsSync(o))if(F.statSync(o).isDirectory())for(let i of F.readdirSync(o,{recursive:!0})){let a=String(i);re(a)&&n.push(T.join(o,a))}else re(o)&&n.push(o)}return n.sort()}function ir(t,e,r){let n=T.isAbsolute(e.features??"features")?e.features:T.join(t,e.features??"features"),s=new Set(le(n));for(let o of r)o.endsWith(".saffron")&&s.add(o);return K([...s].sort(),t)}function An(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",r=e&&(F.existsSync(T.join(e,".claude",".credentials.json"))||F.existsSync(T.join(e,".claude.json")));t||r||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
269
- `))}function Re(t){console.error(`saffron: ${t.message}`),process.exit(2)}function Qe(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var Pn={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Cn="\x1B[0m",C=new Tn().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version("0.1.0").option("-p, --project-root <dir>","project root",process.cwd());C.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tag>","only run scenarios with this @tag").option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit" \u2014 recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let r=T.resolve(C.opts().projectRoot),n=te(r),s=e.baseUrl??n.baseURL,o=En(r,t.length>0?t:n.features?[n.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&An();let i;try{let y=ir(r,n,o);i=o.flatMap(R=>J(R,r,y))}catch(y){throw y instanceof j&&Re(y),y}if(e.filter){let y=e.filter.startsWith("@")?e.filter:`@${e.filter}`;i=i.filter(R=>R.tags.includes(y))}{let y=new Map;for(let R of i){let A=De(R),P=M(B(r,R.featurePath,R.name));P&&A.push(JSON.stringify(P.steps));for(let _ of je(A))process.env[_]===void 0&&!y.has(_)&&y.set(_,R.displayName)}if(y.size>0){for(let[R,A]of y)console.error(`missing environment variable ${R} (referenced as {env:${R}} by "${A}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let a=new ye({projectRoot:r,baseURL:s,headed:e.headed,actionTimeoutMs:n.actionTimeoutMs,retries:n.retries,storageState:e.storageState??n.storageState,browser:(()=>{let y=e.browser??n.browser??"chromium";return y!=="chromium"&&y!=="firefox"&&y!=="webkit"&&(console.error(`invalid --browser "${y}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),y})(),healModel:e.healModel??n.healModel,verifyProposals:e.verify===!1?!1:n.verifyProposals,reuseSteps:e.reuse===!1?!1:n.reuseSteps,assertionPolicy:(()=>{let y=e.assertionPolicy??n.assertionPolicy??"strict";return y!=="strict"&&y!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${y}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),y})(),agent:e.agent?new xe({model:e.model??n.model,maxTurns:n.maxTurns,snapshotMode:n.snapshotMode}):void 0}),d=new Date,p=Math.max(1,Number.parseInt(String(e.workers??n.workers??1),10)||1),m=new Array(i.length),l=y=>{let R=y.scenario,A=Pn[y.status],P=y.usage.inputTokens+y.usage.outputTokens,_=y.usage.cacheReadTokens+y.usage.cacheCreationTokens,G=_>0?`, ${Qe(_)} cache traffic`:"";console.log(`${A}${y.status.toUpperCase().padEnd(7)}${Cn}${R.displayName} (${y.source}, ${y.durationMs}ms, ${y.usage.aiCalls} AI calls, ${P} tokens${G})`);for(let cr of y.adaptations)console.log(` \u26A0 ${cr}`);y.seededSteps&&console.log(` \u26A1 ${y.seededSteps}/${y.stepResults.length} steps seeded from existing recordings (zero AI)`),y.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):y.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${y.proofError?.split(`
270
- `)[0]}`),y.error&&console.log(` ${y.error}`)};await a.start();try{if(p>1&&i.length>1){let y=i.map((A,P)=>({s:A,i:P})),R=[];await Promise.all(Array.from({length:Math.min(p,y.length)},async()=>{for(;;){let A=y.shift();if(!A)return;let P=await a.runScenario(A.s,{agentDisabled:!0});P.status==="red"?R.push(A):(m[A.i]=P,l(P))}}));for(let A of R.sort((P,_)=>P.i-_.i)){let P=await a.runScenario(A.s);m[A.i]=P,l(P)}}else for(let y=0;y<i.length;y++){let R=await a.runScenario(i[y]);m[y]=R,l(R)}}finally{await a.stop()}let c=Xt(m,d,s),f=V(r).divergent.length,h=rr(m,d,f,s),u=sr(r);c.trends=or(u,h),nr(r,h);let{html:g,json:b}=Zt(r,c),S=c.totals,k=S.cacheReadTokens+S.cacheCreationTokens;console.log(`
271
- ${S.green} passed, ${S.yellow} adapted, ${S.red} failed \xB7 ${S.aiCalls} AI calls \xB7 ${(S.inputTokens+S.outputTokens).toLocaleString()} tokens in+out${k>0?` \xB7 ${Qe(S.cacheReadTokens)} cache read \xB7 ${Qe(S.cacheCreationTokens)} cache written`:""} \xB7 $${S.costUsd.toFixed(4)}`);let x=c.trends;if(x?.prev){let y=x.prev.passRatePp,R=x.prev.costUsd;console.log(`vs previous run: pass rate ${y>=0?"+":""}${y}pp \xB7 cost ${R>=0?"+":"-"}$${Math.abs(R).toFixed(4)} \xB7 AI calls ${x.prev.aiCalls>=0?"+":""}${x.prev.aiCalls}`)}for(let y of x?.chronic??[])console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario} \u2014 ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${g}
272
- ${b}`);let v=ie(T.resolve(C.opts().projectRoot));v.length>0&&console.log(`${v.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let $=e.strict??n.strict??!1;$&&S.yellow>0&&console.log(`strict mode: ${S.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(S.red>0||$&&S.yellow>0?1:0)});C.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").action((t,e)=>{let r=T.resolve(C.opts().projectRoot),n=te(r),s=t?[T.resolve(t)]:e.all?ie(r).map(o=>o.file):[];if(s.length===0){ar(r);return}for(let o of s){let i=me(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
273
- `)[0]}) \u2014 expect a heal or red on the next run.`);let a=M(B(r,i.cache.feature,i.cache.scenario)),d=bt(r,o);console.log(`accepted \u2192 ${d}`);let p=a?qt(a,i.cache):[];if(p.length>0){let c=Ht(r,p,d,!e.propagate);for(let f of p)console.log(` locator fix: ${qe(f.from)} \u2192 ${qe(f.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let f of c)console.log(` propagated \u2192 ${f.feature} \u203A ${f.scenario} (${f.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let f of c)console.log(` \xB7 ${f.feature} \u203A ${f.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let l;try{l=Yt(r,i.cache.feature,i.cache.scenario,m,ir(r,n,[]))}catch(c){throw c instanceof j&&Re(c),c}for(let c of l.applied){let f=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}" \u2014 fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${f}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=M(d);for(let f of m)c.steps[f.index]&&(c.steps[f.index].gherkin=f.text);X(d,c)}}});C.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let r=T.resolve(C.opts().projectRoot),n=te(r),s;try{s=q(r,n.features??"features")}catch(i){throw i instanceof j&&Re(i),i}if(t&&(s=ue(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=T.join(r,".vscode","saffron.code-snippets");F.mkdirSync(T.dirname(i),{recursive:!0}),F.writeFileSync(i,lt(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
274
- ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});C.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let r=T.resolve(C.opts().projectRoot),n=te(r),s=F.readFileSync(T.resolve(t),"utf8"),o;try{o=q(r,n.features??"features")}catch(p){throw p instanceof j&&Re(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await pt(s,o,e.model??n.model),a=e.out?T.resolve(e.out):T.join(r,n.features??"features",`${T.basename(t).replace(/\.[^.]+$/,"")}.saffron`);F.existsSync(a)&&(console.error(`refusing to overwrite ${a} \u2014 pass --out for a new path`),process.exit(1)),F.mkdirSync(T.dirname(a),{recursive:!0}),F.writeFileSync(a,i.content);let d=i.content.split(`
275
- `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+T.relative(process.cwd(),a))});C.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=T.resolve(C.opts().projectRoot),e=te(t);await dt(t,e.features??"features")});C.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{let t=T.resolve(C.opts().projectRoot),e=te(t);gt(t,e.features??"features")});C.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let r=T.resolve(C.opts().projectRoot),n=t?[T.resolve(t)]:e.all?ie(r).map(s=>s.file):[];if(n.length===0){ar(r);return}for(let s of n)xt(s),console.log(`rejected ${s}`)});C.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=T.resolve(C.opts().projectRoot),e=T.join(t,".saffron","reports","latest.html");F.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let r=process.platform==="darwin"?"open":"xdg-open";Rn(r,[e],{detached:!0,stdio:"ignore"}).unref()});function ar(t){let e=ie(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
268
+ `).filter(Boolean),s=[];for(let o of n.slice(-e))try{s.push(JSON.parse(o))}catch{}return s}var Ge=t=>{let e=t.green+t.yellow+t.red;return e===0?0:Math.round((t.green+t.yellow)/e*100)};function or(t,e){let r=[...t,e],n=r.slice(-20),s=r.slice(-10),o=t.at(-1),i=o?{passRatePp:Ge(e)-Ge(o),costUsd:e.costUsd-o.costUsd,aiCalls:e.aiCalls-o.aiCalls,red:e.red-o.red}:void 0,a=new Map;for(let l of s)for(let c of l.scenarios){let f=`${c.feature}::${c.scenario}`,h=a.get(f)??{feature:c.feature,scenario:c.scenario,heals:0,reds:0,runs:0};h.runs++,c.source==="agent-heal"&&c.status==="yellow"&&h.heals++,c.status==="red"&&h.reds++,a.set(f,h)}let d=[...a.values()].filter(l=>l.heals>=2||l.reds>=3).sort((l,c)=>c.heals+c.reds-(l.heals+l.reds)),p=new Map;for(let l of n)for(let c of l.scenarios)c.status==="red"&&c.error&&p.set(c.error,(p.get(c.error)??0)+1);let m=[...p.entries()].sort((l,c)=>c[1]-l[1]).slice(0,3).map(([l,c])=>({message:l,count:c}));return{prev:i,series:{passRate:n.map(Ge),costUsd:n.map(l=>Number(l.costUsd.toFixed(4))),aiCalls:n.map(l=>l.aiCalls)},chronic:d,divergentSteps:e.divergentSteps,topIssues:m}}function te(t){St(t);let e=R.join(t,"saffron.config.json");return F.existsSync(e)?JSON.parse(F.readFileSync(e,"utf8")):{}}function An(t,e){let r=e.length>0?e:["features"],n=[];for(let s of r){let o=R.isAbsolute(s)?s:R.join(t,s);if(F.existsSync(o))if(F.statSync(o).isDirectory())for(let i of F.readdirSync(o,{recursive:!0})){let a=String(i);re(a)&&n.push(R.join(o,a))}else re(o)&&n.push(o)}return n.sort()}function ir(t,e,r){let n=R.isAbsolute(e.features??"features")?e.features:R.join(t,e.features??"features"),s=new Set(le(n));for(let o of r)o.endsWith(".saffron")&&s.add(o);return K([...s].sort(),t)}function Pn(){let t=process.env.ANTHROPIC_API_KEY||process.env.CLAUDE_CODE_OAUTH_TOKEN||process.env.CLAUDE_CODE_USE_BEDROCK||process.env.CLAUDE_CODE_USE_VERTEX,e=process.env.HOME??"",r=e&&(F.existsSync(R.join(e,".claude",".credentials.json"))||F.existsSync(R.join(e,".claude.json")));t||r||console.error(["note: no Claude credentials detected. Cached scenarios replay fine","without AI, but recording and healing need one of:"," \xB7 ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)"," \xB7 a Claude Code login on this machine","Replay-only runs: add --no-agent to silence this note.",""].join(`
269
+ `))}function Re(t){console.error(`saffron: ${t.message}`),process.exit(2)}function Qe(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e4?`${(t/1e3).toFixed(1)}k`:t.toLocaleString()}var Cn={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Fn="\x1B[0m";function In(){let t=R.dirname(Tn(import.meta.url));for(let e=0;e<5;e++){let r=R.join(t,"package.json");if(F.existsSync(r))try{let n=JSON.parse(F.readFileSync(r,"utf8"));if(n.name==="saffron-ai"&&n.version)return n.version}catch{}t=R.dirname(t)}return"unknown"}var C=new En().name("saffron").description("Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing.").version(In()).option("-p, --project-root <dir>","project root",process.cwd());C.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--headed","run the browser headed").option("--filter <tag>","only run scenarios with this @tag").option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'replay browser: "chromium" (default), "firefox", or "webkit" \u2014 recording/healing always use Chromium').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=e.baseUrl??n.baseURL,o=An(r,t.length>0?t:n.features?[n.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2)),e.agent!==!1&&Pn();let i;try{let y=ir(r,n,o);i=o.flatMap(T=>J(T,r,y))}catch(y){throw y instanceof j&&Re(y),y}if(e.filter){let y=e.filter.startsWith("@")?e.filter:`@${e.filter}`;i=i.filter(T=>T.tags.includes(y))}{let y=new Map;for(let T of i){let A=De(T),P=M(B(r,T.featurePath,T.name));P&&A.push(JSON.stringify(P.steps));for(let _ of je(A))process.env[_]===void 0&&!y.has(_)&&y.set(_,T.displayName)}if(y.size>0){for(let[T,A]of y)console.error(`missing environment variable ${T} (referenced as {env:${T}} by "${A}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let a=new ye({projectRoot:r,baseURL:s,headed:e.headed,actionTimeoutMs:n.actionTimeoutMs,retries:n.retries,storageState:e.storageState??n.storageState,browser:(()=>{let y=e.browser??n.browser??"chromium";return y!=="chromium"&&y!=="firefox"&&y!=="webkit"&&(console.error(`invalid --browser "${y}" \u2014 use "chromium", "firefox", or "webkit"`),process.exit(2)),y})(),healModel:e.healModel??n.healModel,verifyProposals:e.verify===!1?!1:n.verifyProposals,reuseSteps:e.reuse===!1?!1:n.reuseSteps,assertionPolicy:(()=>{let y=e.assertionPolicy??n.assertionPolicy??"strict";return y!=="strict"&&y!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${y}" \u2014 use "strict" or "adaptable-mid"`),process.exit(2)),y})(),agent:e.agent?new xe({model:e.model??n.model,maxTurns:n.maxTurns,snapshotMode:n.snapshotMode}):void 0}),d=new Date,p=Math.max(1,Number.parseInt(String(e.workers??n.workers??1),10)||1),m=new Array(i.length),l=y=>{let T=y.scenario,A=Cn[y.status],P=y.usage.inputTokens+y.usage.outputTokens,_=y.usage.cacheReadTokens+y.usage.cacheCreationTokens,G=_>0?`, ${Qe(_)} cache traffic`:"";console.log(`${A}${y.status.toUpperCase().padEnd(7)}${Fn}${T.displayName} (${y.source}, ${y.durationMs}ms, ${y.usage.aiCalls} AI calls, ${P} tokens${G})`);for(let cr of y.adaptations)console.log(` \u26A0 ${cr}`);y.seededSteps&&console.log(` \u26A1 ${y.seededSteps}/${y.stepResults.length} steps seeded from existing recordings (zero AI)`),y.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):y.verified===!1&&console.log(` \u2717 proposal UNVERIFIED \u2014 proof replay failed: ${y.proofError?.split(`
270
+ `)[0]}`),y.error&&console.log(` ${y.error}`)};await a.start();try{if(p>1&&i.length>1){let y=i.map((A,P)=>({s:A,i:P})),T=[];await Promise.all(Array.from({length:Math.min(p,y.length)},async()=>{for(;;){let A=y.shift();if(!A)return;let P=await a.runScenario(A.s,{agentDisabled:!0});P.status==="red"?T.push(A):(m[A.i]=P,l(P))}}));for(let A of T.sort((P,_)=>P.i-_.i)){let P=await a.runScenario(A.s);m[A.i]=P,l(P)}}else for(let y=0;y<i.length;y++){let T=await a.runScenario(i[y]);m[y]=T,l(T)}}finally{await a.stop()}let c=Xt(m,d,s),f=V(r).divergent.length,h=rr(m,d,f,s),u=sr(r);c.trends=or(u,h),nr(r,h);let{html:g,json:b}=Zt(r,c),S=c.totals,k=S.cacheReadTokens+S.cacheCreationTokens;console.log(`
271
+ ${S.green} passed, ${S.yellow} adapted, ${S.red} failed \xB7 ${S.aiCalls} AI calls \xB7 ${(S.inputTokens+S.outputTokens).toLocaleString()} tokens in+out${k>0?` \xB7 ${Qe(S.cacheReadTokens)} cache read \xB7 ${Qe(S.cacheCreationTokens)} cache written`:""} \xB7 $${S.costUsd.toFixed(4)}`);let x=c.trends;if(x?.prev){let y=x.prev.passRatePp,T=x.prev.costUsd;console.log(`vs previous run: pass rate ${y>=0?"+":""}${y}pp \xB7 cost ${T>=0?"+":"-"}$${Math.abs(T).toFixed(4)} \xB7 AI calls ${x.prev.aiCalls>=0?"+":""}${x.prev.aiCalls}`)}for(let y of x?.chronic??[])console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario} \u2014 ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${g}
272
+ ${b}`);let v=ie(R.resolve(C.opts().projectRoot));v.length>0&&console.log(`${v.length} cache proposal(s) pending \u2014 review with: saffron accept | saffron reject`);let $=e.strict??n.strict??!1;$&&S.yellow>0&&console.log(`strict mode: ${S.yellow} adapted scenario(s) treated as failure \u2014 review proposals to go green.`),process.exit(S.red>0||$&&S.yellow>0?1:0)});C.command("accept").description("Promote pending cache proposals to committed caches").argument("[file]","proposal file (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=t?[R.resolve(t)]:e.all?ie(r).map(o=>o.file):[];if(s.length===0){ar(r);return}for(let o of s){let i=me(o);i.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${i.meta.proofError?.split(`
273
+ `)[0]}) \u2014 expect a heal or red on the next run.`);let a=M(B(r,i.cache.feature,i.cache.scenario)),d=bt(r,o);console.log(`accepted \u2192 ${d}`);let p=a?qt(a,i.cache):[];if(p.length>0){let c=Ht(r,p,d,!e.propagate);for(let f of p)console.log(` locator fix: ${qe(f.from)} \u2192 ${qe(f.to)}`);if(c.length===0)console.log(" no other caches use this locator.");else if(e.propagate)for(let f of c)console.log(` propagated \u2192 ${f.feature} \u203A ${f.scenario} (${f.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${c.length} other cache(s) use the same locator \u2014 re-run with --propagate to fix them too:`);for(let f of c)console.log(` \xB7 ${f.feature} \u203A ${f.scenario}`)}}let m=i.meta.featureEdits??[];if(!e.withFeatureEdit||m.length===0)continue;let l;try{l=Yt(r,i.cache.feature,i.cache.scenario,m,ir(r,n,[]))}catch(c){throw c instanceof j&&Re(c),c}for(let c of l.applied){let f=c.fromStepSet?`${c.file}:${c.line} (StepSet "${c.fromStepSet}" \u2014 fixes every invoking scenario)`:`${c.file}:${c.line}`;console.log(` feature edit ${f}:`),console.log(` - ${c.from}`),console.log(` + ${c.to}`)}for(let c of l.skipped)console.log(` skipped edit [${c.index}]: ${c.reason}`);if(l.applied.length>0){let c=M(d);for(let f of m)c.steps[f.index]&&(c.steps[f.index].gherkin=f.text);X(d,c)}}});C.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s;try{s=q(r,n.features??"features")}catch(i){throw i instanceof j&&Re(i),i}if(t&&(s=ue(s,t)),e.json){console.log(JSON.stringify(s,null,2));return}if(e.snippets){let i=R.join(r,".vscode","saffron.code-snippets");F.mkdirSync(R.dirname(i),{recursive:!0}),F.writeFileSync(i,lt(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)) \u2014 ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
274
+ ${s.steps.length} step(s) across ${s.scannedFiles} file(s) \xB7 ${s.scannedCaches} cache(s) \xB7 \x1B[32m\u25CF\x1B[0m recorded (replays free) \xB7 \x1B[33m\u25CF\x1B[0m divergent \xB7 \u25CB unrecorded`)});C.command("author").description("Draft a feature file from plain-paragraph requirements, reusing the project's step vocabulary (AI)").argument("<prose-file>","text/markdown file with the requirements").option("-o, --out <file>","output path (default: features/<name>.saffron)").option("--model <model>","model for authoring").action(async(t,e)=>{let r=R.resolve(C.opts().projectRoot),n=te(r),s=F.readFileSync(R.resolve(t),"utf8"),o;try{o=q(r,n.features??"features")}catch(p){throw p instanceof j&&Re(p),p}console.log(`authoring with ${o.steps.length} known step(s) and ${o.stepSets.length} step set(s) as vocabulary\u2026`);let i=await pt(s,o,e.model??n.model),a=e.out?R.resolve(e.out):R.join(r,n.features??"features",`${R.basename(t).replace(/\.[^.]+$/,"")}.saffron`);F.existsSync(a)&&(console.error(`refusing to overwrite ${a} \u2014 pass --out for a new path`),process.exit(1)),F.mkdirSync(R.dirname(a),{recursive:!0}),F.writeFileSync(a,i.content);let d=i.content.split(`
275
+ `).map(p=>p.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(p=>o.steps.some(m=>m.text===p)).length;console.log(`wrote ${a}`),console.log(`${d} step line(s) reuse the existing vocabulary (seedable) \xB7 $${(i.usage.costUsd??0).toFixed(4)}`),console.log("review the draft, then record it: saffron run "+R.relative(process.cwd(),a))});C.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let t=R.resolve(C.opts().projectRoot),e=te(t);await dt(t,e.features??"features")});C.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics) \u2014 for JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{let t=R.resolve(C.opts().projectRoot),e=te(t);gt(t,e.features??"features")});C.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[file]","proposal file").option("--all","reject all pending proposals").action((t,e)=>{let r=R.resolve(C.opts().projectRoot),n=t?[R.resolve(t)]:e.all?ie(r).map(s=>s.file):[];if(n.length===0){ar(r);return}for(let s of n)xt(s),console.log(`rejected ${s}`)});C.command("report").description("Open the latest HTML report").option("--open","open in the default browser",!0).action(()=>{let t=R.resolve(C.opts().projectRoot),e=R.join(t,".saffron","reports","latest.html");F.existsSync(e)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(e);let r=process.platform==="darwin"?"open":"xdg-open";Rn(r,[e],{detached:!0,stdio:"ignore"}).unref()});function ar(t){let e=ie(t);if(e.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
276
276
  `);for(let{file:r,proposal:n}of e){console.log(` ${r}`);let s=n.meta.verified===!0?" \xB7 verified \u2713":n.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${n.cache.feature} \u203A ${n.cache.scenario} (${n.meta.mode}, ${n.meta.createdAt}${s})`),console.log(` ${n.meta.narrative}`);for(let o of n.meta.adaptations)console.log(` \u26A0 ${o}`);n.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
277
277
  ${n.meta.suggestedFeatureEdit.split(`
278
278
  `).map(o=>` ${o}`).join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saffron-ai",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Gherkin-native AI-fallback test runner: zero-token cached replay, runtime AI healing, honest reports",
5
5
  "scripts": {
6
6
  "test": "vitest run",