saffron-ai 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +252 -0
- package/README.md +13 -2
- package/dist-pkg/cli.js +180 -148
- package/package.json +16 -2
- package/skills/saffron/references/config.md +18 -9
package/dist-pkg/cli.js
CHANGED
|
@@ -1,69 +1,71 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import ee from"node:fs";import D from"node:path";import Yf from"node:readline";import{Command as Zf}from"commander";import Hc from"node:fs";import Wc from"node:path";import{AstBuilder as Vc,GherkinClassicTokenMatcher as Gc,Parser as Jc}from"@cucumber/gherkin";import{IdGenerator as Kc,StepKeywordType as Rr}from"@cucumber/messages";import oo from"node:fs";import _c from"node:path";import ht from"node:fs";import Ue from"node:path";var Bt=/\{data:([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\}/g,mt=Ue.resolve("data"),Se,yn=new Map;function Ke(e,t="data",n){mt=Ue.isAbsolute(t)?t:Ue.join(e,t),Se=n?.trim()||void 0,yn.clear()}var Be=()=>({dir:mt,env:Se});function ro(e,t){let n=s=>s!==null&&typeof s=="object"&&!Array.isArray(s);if(!n(e)||!n(t))return t;let r={...e};for(let[s,o]of Object.entries(t))r[s]=s in e?ro(e[s],o):o;return r}function no(e,t){try{return JSON.parse(ht.readFileSync(e,"utf8"))}catch(n){throw new Error(`data file ${t} is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}}function Ec(e){if(yn.has(e))return yn.get(e);let t=Ue.join(mt,`${e}.json`),n=Se?Ue.join(mt,`${e}.${Se}.json`):void 0,r=ht.existsSync(t),s=n!==void 0&&ht.existsSync(n);if(!r&&!s)throw new Error(`data file ${Ue.relative(process.cwd(),t)||t} does not exist${Se?` (nor ${e}.${Se}.json for the "${Se}" environment)`:""}`);let o=r?no(t,`${e}.json`):void 0;if(s){let a=no(n,`${e}.${Se}.json`);o=r?ro(o,a):a}return yn.set(e,o),o}function Ye(e){let[t,...n]=e.split("."),r=Ec(t),s=[t];for(let o of n){let a=r,i=a&&typeof a=="object"?Array.isArray(a)?a[Number(o)]:a[o]:void 0;if(i===void 0)throw new Error(`"${o}" does not exist under ${s.join(".")} (in ${t}.json)`);r=i,s.push(o)}return r}function yt(e){let t;try{t=Ye(e)}catch(n){throw new Error(`{data:${e}}: ${n instanceof Error?n.message:String(n)}`)}if(t===null||typeof t=="object")throw new Error(`{data:${e}} is ${Array.isArray(t)?"a list":"an object"}, not a single value. Point at one entry, for example {data:${e}.${Array.isArray(t)?"0":Object.keys(t??{})[0]??"key"}}`);return String(t)}function ze(e){let t;try{t=Ye(e)}catch(s){throw new Error(`{data:${e}}: ${s instanceof Error?s.message:String(s)}`)}let n=Array.isArray(t)?t:[t];if(n.length===0)throw new Error(`{data:${e}} is an empty list: there is nothing to check`);let r=n.find(s=>s===null||typeof s=="object");if(r!==void 0)throw new Error(`{data:${e}} must be a list of plain values (text, numbers); it contains ${Array.isArray(r)?"a list":"an object"}`);return n.map(String)}function wn(e){ze(e)}function so(e){return e.replace(Bt,(t,n)=>{let r=ze(n);return Array.isArray(Ye(n))?`${t} (a data list, currently: ${r.map(o=>`"${o}"`).join(", ")})`:r[0]})}function Ac(e){let t=[],n=[],r="",s=!1;for(let o=0;o<e.length;o++){let a=e[o];s?a==='"'&&e[o+1]==='"'?(r+='"',o++):a==='"'?s=!1:r+=a:a==='"'&&r===""?s=!0:a===","?(n.push(r),r=""):a===`
|
|
3
3
|
`||a==="\r"?(a==="\r"&&e[o+1]===`
|
|
4
|
-
`&&o++,n.push(r),r="",n.some(i=>i!=="")&&t.push(n),n=[]):r+=a}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(a=>a.trim()))}function
|
|
5
|
-
`)
|
|
6
|
-
`),
|
|
7
|
-
`).
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
`)}
|
|
11
|
-
`}
|
|
12
|
-
`
|
|
13
|
-
`);return
|
|
4
|
+
`&&o++,n.push(r),r="",n.some(i=>i!=="")&&t.push(n),n=[]):r+=a}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(a=>a.trim()))}function bn(e){let t=i=>{throw new Error(`Examples: {data:${e}}: ${i}`)},n=Se?Ue.join(mt,`${e}.${Se}.csv`):void 0,r=n&&ht.existsSync(n)?n:Ue.join(mt,`${e}.csv`);if(!e.includes(".")&&ht.existsSync(r)){let[i,...c]=Ac(ht.readFileSync(r,"utf8"));return(!i||c.length===0)&&t(`${e}.csv needs a header row and at least one data row`),{headers:i,rows:c.map(l=>Object.fromEntries(i.map((d,p)=>[d,l[p]??""])))}}let s;try{s=Ye(e)}catch(i){return t(`${i instanceof Error?i.message:String(i)} (looked for ${e.split(".")[0]}.csv and ${e.split(".")[0]}.json in the data folder)`)}(!Array.isArray(s)||s.length===0||s.some(i=>i===null||typeof i!="object"||Array.isArray(i)))&&t('must be a non-empty list of records, like [{ "role": "Admin", "menu": "Users" }]');let o=s,a=[...new Set(o.flatMap(i=>Object.keys(i)))];return{headers:a,rows:o.map(i=>Object.fromEntries(a.map(c=>[c,i[c]===void 0||i[c]===null?"":String(i[c])])))}}function zt(e){return e.replace(Bt,(t,n)=>yt(n))}function Ze(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Bt))t.add(r[1]);return t}function xn(e){let t=new Map;for(let n of Ze(e))try{t.set(n,yt(n))}catch{}return t}function wr(e,t,n=new Set){let r=new Map;for(let[a,i]of t)r.set(i,[...r.get(i)??[],a]);let s=[];for(let[a,i]of r){let c=i.length===1?i:i.filter(l=>n.has(l));c.length===1&&s.push([c[0],a])}s.sort((a,i)=>i[1].length-a[1].length);for(let[a,i]of s)if(i!==""&&e===i)return`{data:${a}}`;let o=e;for(let[a,i]of s)i.length<4||(o=o.split(i).join(`{data:${a}}`));return o}var Sn=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function qt(e){return e.replace(Sn,(t,n)=>{let r=process.env[n];if(r===void 0)throw new Error(`environment variable ${n} is not set (referenced as {env:${n}}) \u2014 export it or add it to .env`);return r})}function xr(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(Sn))t.add(r[1]);return t}function ne(e){let t=[];for(let n of e.steps){t.push(n.text,n.resolvedText),n.docString&&t.push(n.docString),n.resolvedDocString&&t.push(n.resolvedDocString);for(let r of n.table??[])t.push(...r);for(let r of n.resolvedTable??[])t.push(...r)}return t}function vr(e){let t=[];for(let n of Ze(e))try{t.push(...ze(n))}catch{}return t}function Ie(e){let t=new Map,n=ne(e);for(let r of xr([...n,...vr(n)])){let s=process.env[r];s!==void 0&&t.set(r,s)}return t}var Qe=3,br=String.raw`\{(?:env:[A-Za-z_][A-Za-z0-9_]*|data:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+|unique:[A-Za-z0-9_-]+(?::digits)?)\}`,vn={'"':'\\"',"\\":"\\\\","\b":"\\b","\f":"\\f","\n":"\\n","\r":"\\r"," ":"\\t"};function Pc(e,t,n,r){let s="";for(let o of e.split("")){let a=o.charCodeAt(0);if(t?.test(o)||a<32&&!vn[o]){let i=a.toString(16).padStart(4,"0");s+="\\u"+(n?i.toUpperCase():i)}else vn[o]?s+=vn[o]:o==="/"&&r?s+="\\/":s+=o}return s}function Ic(e){return[e,e.replace(/%[0-9A-F]{2}/g,t=>t.toLowerCase())]}function ao(e,t,n){return e.replace(/[&<>"'+]|[^\x00-\x7f]/gu,r=>t[r]??(n&&r>"\x7F"?n(r.codePointAt(0)):r))}function Cc(e,t){let n="";for(let r of e){let s=r<=" "||r>="\x7F"||t.test(r);n+=s?[...Buffer.from(r,"utf8")].map(o=>"%"+o.toString(16).toUpperCase().padStart(2,"0")).join(""):r}return n}var jc=[/["#<>']/,/["#<>?^`{}]/,/["#<>?^`{|}]/,/["#<>?^`{}/:;=@[\\\]|]/,/["#<>?^`{}/:;=@[\\\]|']/];function Oc(e){let t=/[^\x00-\x7f]/,n=new Set([e]);for(let[s,o,a]of[[void 0,!1,!1],[void 0,!1,!0],[t,!1,!1],[t,!0,!1],[t,!1,!0],[/[<>&\u2028\u2029]/,!1,!1],[/[^\x00-\x7f]|[<>&'+`"]/,!0,!1]])n.add(Pc(e,s,o,a));let r=s=>{try{for(let o of Ic(s()))n.add(o)}catch{}};r(()=>encodeURIComponent(e)),r(()=>encodeURIComponent(e).replaceAll("'","%27")),r(()=>encodeURI(e)),r(()=>new URLSearchParams([["",e]]).toString().slice(1));for(let s of jc)r(()=>Cc(e,s));for(let s of[{"&":"&","<":"<",">":">"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'","+":"+"}])n.add(ao(e,s));return n.add(ao(e,{"&":"&","<":"<",">":">",'"':""","'":"'","+":"+"},s=>`&#x${s.toString(16).toUpperCase()};`)),[...n]}var Ht=e=>e.map(t=>`{env:${t}}`).join(""),Pe=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),Fc=e=>Buffer.from(e,"utf8").toString("latin1");function Xe(e,t={}){let n=[...e.entries()].filter(([,f])=>f.length>=Qe).sort((f,y)=>y[1].length-f[1].length),r=new Map,s=new Set;for(let[f,y]of n){t.standaloneBelow!==void 0&&y.length<t.standaloneBelow&&s.add(y);let $=t.encodings?Oc(y):[y];for(let T of $){let v=t.bytes?[Fc(T),.../^[\x00-\xff]*$/.test(T)?[T]:[]]:[T];for(let S of v)r.has(S)||r.set(S,f)}}let o=new Set(n.map(([,f])=>f)),a=new Set(["u"]);for(let[,f]of n)for(let y of f){let $=vn[y]??(y==="/"?"\\/":void 0);$&&a.add($.slice(1))}let i=new RegExp(`\\\\[${[...a].map(Pe).join("")}]`);if(r.size===0)return{empty:!0,mask:f=>f,matches:()=>[],occursIn:()=>!1,isValue:()=>!1,hiddenByEscape:i};let c=[...r.keys()].sort((f,y)=>y.length-f.length).map(f=>s.has(f)?`(?<![A-Za-z0-9])${Pe(f)}(?![A-Za-z0-9])`:Pe(f)).join("|"),l=new RegExp(`(${br})|${c}`,"g"),d=new RegExp(`(${br})|${c}`,"y"),p=new RegExp(c),g=f=>{let y=[];l.lastIndex=0;for(let $=l.exec(f);$!==null;$=l.exec(f)){if($[1]!==void 0)continue;let T=$.index,v=T+$[0].length,S=[r.get($[0])];for(let h=T+1;h<v;h++){d.lastIndex=h;let w=d.exec(f);w&&w[1]===void 0&&h+w[0].length>v&&(v=h+w[0].length,S.push(r.get(w[0])))}y.push({start:T,end:v,names:[...new Set(S)]}),l.lastIndex=v}return y};return{empty:!1,hiddenByEscape:i,occursIn:f=>p.test(f),isValue:f=>o.has(f),matches:g,mask(f){let y="",$=0;for(let T of g(f))y+=f.slice($,T.start)+Ht(T.names),$=T.end;return $===0?f:y+f.slice($)}}}function Sr(e,t,n=r=>r){let r=new Map;for(let a of e)for(let i of a.split(/[\s"'`]+/)){let c=i.replace(/^[.,;:!?()[\]]+|[.,;:!?()[\]]+$/g,""),l=[...c.matchAll(Sn)].map(g=>g[1]);if(l.length===0||l.some(g=>!t.has(g)))continue;let d=c.replace(Sn,(g,f)=>t.get(f));if(d===c||d.length<Qe||c===`{env:${l[0]}}`)continue;let p=n(d);r.has(p)||r.set(p,c.split(/(\{env:[A-Za-z_][A-Za-z0-9_]*\})/).map((g,f)=>f%2?g:n(g)).join(""))}if(r.size===0)return a=>a;let s=[...r.keys()].sort((a,i)=>i.length-a.length).map(Pe).join("|"),o=new RegExp(`(${br})|${s}`,"g");return a=>a.replace(o,(i,c)=>c!==void 0?i:r.get(i))}var io=new WeakMap;function Ce(e,t){let n=io.get(t);return n||(n=Xe(t,{encodings:!0}),io.set(t,n)),n.mask(e)}function co(e){let t=_c.join(e,".env");if(oo.existsSync(t))for(let n of oo.readFileSync(t,"utf8").split(`
|
|
5
|
+
`)){let r=n.trim();if(!r||r.startsWith("#"))continue;let s=r.indexOf("=");if(s<=0)continue;let o=r.slice(0,s).trim();if(!/^[A-Za-z_][A-Za-z0-9_]*$/.test(o))continue;let a=r.slice(s+1).trim();(a.startsWith('"')&&a.endsWith('"')||a.startsWith("'")&&a.endsWith("'"))&&(a=a.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=a)}}function et(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function ae(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((a,i)=>{s[i]!==void 0&&(t[`table:${o+1}:${a}`]=s[i])})})}return t}function tt(e){return e!==void 0?{docstring:e}:{}}function qe(e){let t=new Map;for(let r of e){let s={...ae(r.resolvedTable??r.table),...tt(r.resolvedDocString??r.docString)};for(let[o,a]of Object.entries(s)){let i=t.get(o);i||t.set(o,i=new Set),i.add(a)}}let n={};for(let[r,s]of t)s.size===1&&(n[r]=[...s][0]);return n}function kr(e){return e.replace(/\\.|["'`]|>>/g,t=>t[0]==="\\"?t:t===">>"?"\\>\\>":`\\${t}`)}var X={quoted:e=>JSON.stringify(e).slice(1,-1),regex:e=>kr(Pe(e).replaceAll("/","\\/")),pattern:e=>Pe(e)};function wt(e,t,n=r=>r){let r="",s=0;for(let o of e.matchAll(/"((?:[^"\\]|\\.)*)"|=\/((?:[^/\\]|\\.)*)\/([a-z]*)/g)){let[a,i,c,l]=o;i===void 0&&/[uv]/.test(l)||(r+=n(e.slice(s,o.index))+(i!==void 0?`"${t(i,"quoted")}"`:`=/${t(c,"regex")}/${l}`),s=o.index+a.length)}return r+n(e.slice(s))}import $r from"node:fs";import kn from"node:path";import{AstBuilder as Nc,GherkinClassicTokenMatcher as Mc,Parser as Dc}from"@cucumber/gherkin";import{IdGenerator as Lc}from"@cucumber/messages";var re=class extends Error{},Uc=/^(\s*)StepSet:\s*(.+?)\s*$/,Bc=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function Tr(e){let t=e.split(`
|
|
6
|
+
`),n=new Map,r=new Map;return{source:t.map((o,a)=>{let i=o.match(Uc);if(i)return n.set(a+1,i[2]),`${i[1]}Scenario: ${i[2]}`;let c=o.match(Bc);return c?(r.set(a+1,c[2]),`${c[1]}* StepSet ${c[2]}`):o}).join(`
|
|
7
|
+
`),definitions:n,invocations:r}}function zc(e){return new Dc(new Nc(Lc.uuid()),new Mc).parse(e)}function lo(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&lo(n.rule.children,t)}function je(e,t){let n=new Map;for(let r of e){let s=kn.relative(t,r),o=Tr($r.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let a=zc(o.source);a.feature&&lo(a.feature.children,i=>{let c=o.definitions.get(i.location.line);if(c===void 0)return;let l=n.get(c);if(l)throw new re(`StepSet "${c}" is defined twice: ${l.file}:${l.line} and ${s}:${i.location.line}. Set names are unique across the project. If line ${i.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${c}".`);if(i.examples.length>0)throw new re(`StepSet "${c}" (${s}:${i.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let d of i.steps)if(o.invocations.has(d.location.line))throw new re(`StepSet "${c}" (${s}:${i.location.line}) invokes another step set at line ${d.location.line} \u2014 step sets cannot be nested.`);n.set(c,{name:c,file:s,line:i.location.line,steps:[...i.steps]})})}return n}var qc=[".feature",".saffron"];function Oe(e){return qc.some(t=>e.endsWith(t))}function Fe(e,t,n){let r=kn.isAbsolute(t.features??"features")?t.features:kn.join(e,t.features??"features"),s=new Set(nt(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return je([...s].sort(),e)}function nt(e){if(!$r.existsSync(e))return[];let t=[];for(let n of $r.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(kn.join(e,r))}return t.sort()}function po(e,t,n){return e.map(({step:r,source:s})=>{let o;switch(r.keywordType){case Rr.OUTCOME:o="assertion";break;case Rr.CONTEXT:case Rr.ACTION:o="action";break;default:o=n.value}n.value=o;let a=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(l=>l.value)):void 0,i=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:et(r.text,t),table:a,resolvedTable:a?.map(c=>c.map(l=>et(l,t))),docString:i,resolvedDocString:i!==void 0?et(i,t):void 0,source:s}})}function fo(e,t,n,r,s){let o=[];for(let a of e){let i=r.get(a.location.line);if(i===void 0){o.push({step:a,source:{file:t,line:a.location.line,fromBackground:n}});continue}let c=s?.get(i);if(!c)throw new re(`${t}:${a.location.line} invokes StepSet "${i}", which is not defined anywhere in the project.`);for(let l of c.steps)o.push({step:l,source:{file:c.file,line:l.location.line,fromStepSet:c.name,fromBackground:n}})}return o}function Yc(e,t,n,r,s,o=!1){let a=e.tags.map(c=>c.name),i=[];if(e.examples.length===0)i.push({params:{},label:""});else{let c=0;for(let l of e.examples){let d=l.name.trim().match(/^\{data:([A-Za-z0-9_.-]+)\}$/);if(d&&!l.tableHeader){let g=`${t}:${l.location.line}`;if(!o)throw new re(`${g} reads Examples from a data file, which only .saffron files understand. Rename the file to ${t.replace(/\.feature$/,".saffron")}: nothing else needs to change.`);let f;try{f=bn(d[1])}catch(T){throw new re(`${g} ${T instanceof Error?T.message:String(T)}`)}let $=[...new Set([...r,...s].flatMap(T=>[T.step.text,T.step.docString?.content??"",...T.step.dataTable?.rows.flatMap(v=>v.cells.map(S=>S.value))??[]].flatMap(v=>[...v.matchAll(/<([^<>]+)>/g)].map(S=>S[1]))))].filter(T=>!f.headers.includes(T)&&!T.startsWith("table:")&&T!=="docstring");if($.length>0)throw new re(`${g} the scenario uses <${$.join(">, <")}>, but {data:${d[1]}} has the columns ${f.headers.map(T=>`"${T}"`).join(", ")}.`);for(let T of f.rows)c+=1,i.push({params:T,label:` (example ${c})`});continue}let p=l.tableHeader?.cells.map(g=>g.value)??[];for(let g of l.tableBody){c+=1;let f={};g.cells.forEach((y,$)=>{f[p[$]]=y.value}),i.push({params:f,label:` (example ${c})`})}}}return i.map(({params:c,label:l})=>{let d={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${l}`,tags:a,steps:[...po(r,c,d),...po(s,c,d)],params:c}})}function de(e,t,n){let r=Hc.readFileSync(e,"utf8"),s=e.endsWith(".saffron"),o=s?Tr(r):{source:r,definitions:new Map,invocations:new Map},a=new Jc(new Vc(Kc.uuid()),new Gc),i=Wc.relative(t,e),c;try{c=a.parse(o.source)}catch(g){let f=(g instanceof Error?g.message:String(g)).split(`
|
|
8
|
+
`).filter($=>/^\(\d+:\d+\)/.test($)).map($=>`${i}${$.replace(/^\((\d+):(\d+)\)/,":$1:$2")}`),y=!s&&/^\s*StepSet\b/m.test(r);throw new re(y?`${i} uses StepSet, which only .saffron files understand. Rename it to ${i.replace(/\.feature$/,".saffron")}: nothing else needs to change (a .saffron file accepts everything a .feature file does).`:`${i} is not valid Gherkin:
|
|
9
|
+
${f.join(`
|
|
10
|
+
`)||(g instanceof Error?g.message:String(g))}`)}if(!c.feature)return[];let l=c.feature.name,d=[],p=(g,f)=>{let y=f;for(let $ of g)if($.background)y=[...f,...fo($.background.steps,i,!0,o.invocations,n)];else if($.scenario){if(o.definitions.has($.scenario.location.line))continue;d.push(...Yc($.scenario,i,l,y,fo($.scenario.steps,i,!1,o.invocations,n),s))}else"rule"in $&&$.rule&&p($.rule.children,y)};return p(c.feature.children,[]),d}import vo from"node:fs";import Pr from"node:path";import mo from"node:fs";import yo from"node:path";import{createHash as ol}from"node:crypto";import Tn from"node:fs";import bt from"node:path";var ue=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse","expectEach"]);var $n=1,Zc=new Set(["goto","click","fill","press","selectOption","check","uncheck","hover","waitFor","captureText","handleDialog","uploadFile","dragTo","waitForResponse","switchTab","closeTab","newTab",...ue]),Qc=["role","name","nameRegex","label","text","testId","placeholder","frame"],Xc=["value","url","saveAs","compareTo","each","pattern","attribute","promptText","urlPattern","method","status","pageFingerprint"],J=class extends Error{constructor(n,r){super(`${n}: ${r}`);this.file=n;this.problem=r}file;problem},we=e=>e!==null&&typeof e=="object"&&!Array.isArray(e);function L(e,t,n){if(!e)throw new J(t,n)}function uo(e,t,n){L(we(e),t,`${n} must be an object`);for(let r of Qc)L(e[r]===void 0||typeof e[r]=="string",t,`${n}.${r} must be a string`);e.fallbackSelectors!==void 0&&L(Array.isArray(e.fallbackSelectors)&&e.fallbackSelectors.every(r=>typeof r=="string"),t,`${n}.fallbackSelectors must be a list of selectors`)}var el=e=>["role","label","text","testId","placeholder"].some(t=>typeof e[t]=="string"&&e[t]!=="")||Array.isArray(e.fallbackSelectors)&&e.fallbackSelectors.some(t=>typeof t=="string"&&t.trim().length>0),tl={goto:["url"],click:["target"],fill:["target","value"],press:["value"],selectOption:["target","value"],check:["target"],uncheck:["target"],hover:["target"],waitFor:["targetOrValue"],captureText:["target","saveAs"],handleDialog:[],uploadFile:["value"],dragTo:["target","toTarget"],waitForResponse:["urlPattern"],switchTab:["value"],closeTab:[],newTab:[],expectVisible:["target"],expectNotVisible:["target"],expectText:["target","value"],expectUrl:["urlOrValue"],expectValue:["target","value"],expectDiffers:["compareTo","targetOrValue"],expectMatches:["target","pattern"],expectAttribute:["target","attribute","pattern"],expectResponse:["urlPattern"],expectEach:["target","each"]};function nl(e,t,n){let r=o=>typeof e[o]=="string",s=o=>we(e[o])&&el(e[o]);for(let o of tl[e.action])switch(o){case"target":case"toTarget":L(s(o),t,`${n} (${e.action}) needs a ${o} with a role, name, label, text, test id, placeholder or selector`);break;case"urlOrValue":L(r("url")||r("value"),t,`${n} (${e.action}) needs a url`);break;case"targetOrValue":L(s("target")||r("value"),t,`${n} (${e.action}) needs a target or a value`);break;default:L(r(o),t,`${n} (${e.action}) needs ${o}`)}for(let o of["target","toTarget"])e[o]!==void 0&&L(s(o),t,`${n}.${o} has no usable locator`);if(e.action==="expectUrl"){let o=e.url??e.value;L(typeof o=="string"&&o.length>0,t,`${n} (expectUrl) needs a non-empty url or value`)}for(let o of["url","saveAs","compareTo","each","pattern","attribute","urlPattern"])e[o]!==void 0&&L(e[o].length>0,t,`${n}.${o} must not be empty`);["press","uploadFile","switchTab","expectText"].includes(e.action)&&L(typeof e.value=="string"&&e.value.length>0,t,`${n} (${e.action}) needs a non-empty value`)}function rl(e,t,n){L(we(e),t,`${n} must be an object`),L(typeof e.action=="string",t,`${n}.action is missing`),L(Zc.has(e.action),t,`${n}.action "${e.action}" is not an action this runner knows. Recorded by a newer saffron-ai? Update, or re-record with --rerecord`);for(let r of Xc)L(e[r]===void 0||typeof e[r]=="string",t,`${n}.${r} must be a string`);L(e.opensTab===void 0||typeof e.opensTab=="boolean",t,`${n}.opensTab must be true or false`),e.target!==void 0&&uo(e.target,t,`${n}.target`),e.toTarget!==void 0&&uo(e.toTarget,t,`${n}.toTarget`),nl(e,t,n)}function sl(e,t,n){L(we(e),t,`${n} must be an object`),L(typeof e.gherkin=="string"&&e.gherkin.length>0,t,`${n}.gherkin (the step text) is missing`),L(typeof e.keyword=="string",t,`${n}.keyword is missing`),L(e.kind==="action"||e.kind==="assertion",t,`${n}.kind must be "action" or "assertion"`),L(Array.isArray(e.actions),t,`${n}.actions must be a list (empty for a recorded no-op)`),e.actions.forEach((r,s)=>rl(r,t,`${n}.actions[${s}]`)),e.table!==void 0&&L(Array.isArray(e.table)&&e.table.every(r=>Array.isArray(r)&&r.every(s=>typeof s=="string")),t,`${n}.table must be rows of strings`),L(e.docString===void 0||typeof e.docString=="string",t,`${n}.docString must be a string`),L(e.fromStepSet===void 0||typeof e.fromStepSet=="string",t,`${n}.fromStepSet must be a string`)}function Er(e,t){return L(we(e),t,"is not a JSON object"),L(e.version!==void 0,t,"has no version: not a Saffron recording"),L(e.version===$n,t,typeof e.version=="number"&&e.version>$n?`is schema version ${e.version}; this saffron-ai reads version ${$n}. Update saffron-ai to the release that wrote it`:`has schema version ${JSON.stringify(e.version)}; this saffron-ai reads version ${$n}`),L(typeof e.feature=="string"&&e.feature.length>0,t,"feature (the feature file path) is missing"),L(typeof e.scenario=="string"&&e.scenario.length>0,t,"scenario (the scenario name) is missing"),L(e.recordedAt===void 0||typeof e.recordedAt=="string",t,"recordedAt must be a string"),L(e.recordedBy===void 0||e.recordedBy==="agent"||e.recordedBy==="manual",t,'recordedBy must be "agent" or "manual"'),L(Array.isArray(e.steps),t,"steps must be a list"),e.steps.forEach((n,r)=>sl(n,t,`steps[${r}]`)),e.data!==void 0&&L(we(e.data)&&Object.values(e.data).every(n=>typeof n=="string"),t,"data must map references to strings"),e}function go(e,t){L(we(e),t,"is not a JSON object"),L(we(e.meta),t,"meta is missing: not a Saffron proposal");let n=e.meta;L(n.mode==="record"||n.mode==="heal",t,'meta.mode must be "record" or "heal"'),L(typeof n.narrative=="string",t,"meta.narrative must be a string"),L(Array.isArray(n.adaptations)&&n.adaptations.every(r=>typeof r=="string"),t,"meta.adaptations must be a list of strings"),L(n.verified===void 0||typeof n.verified=="boolean",t,"meta.verified must be true or false"),L(we(n.usage),t,"meta.usage is missing"),n.featureEdits!==void 0&&L(Array.isArray(n.featureEdits)&&n.featureEdits.every(r=>we(r)&&typeof r.index=="number"&&typeof r.text=="string"),t,"meta.featureEdits must be a list of { index, text }"),n.bound!==void 0&&L(we(n.bound)&&["scenario","committed","recording"].every(r=>typeof n.bound[r]=="string"),t,"meta.bound must hold the scenario, committed and recording digests"),Er(e.cache,`${t} (cache)`)}var Ar=".saffron";function Rn(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function _r(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),s=Rn([...r,bt.basename(n,".feature")].join("-"));if(r.length===0)return s;let o=ol("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${s}-${o}`}function K(e,t,n){return bt.join(e,Ar,"cache",_r(t),`${Rn(n)}.json`)}function ho(e,t){let n=new Map;for(let r of t){let s=K(e,r.featurePath,r.name),o=n.get(s)??new Set;o.add(`${r.featurePath} \u203A ${r.name}`),n.set(s,o)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,s])=>`${[...s].join(" and ")} both map to ${bt.relative(e,r)}. Rename one scenario or one file.`)}var En="owner.json";function ke(e,t,n){return bt.join(e,Ar,"artifacts",_r(t),Rn(n))}function be(e,t,n){return bt.join(e,Ar,"proposals",_r(t),`${Rn(n)}.json`)}function xt(e,t){try{return pe(e)}catch(n){if(!(n instanceof J))throw n;t?.(e,n.problem);return}}function pe(e){if(!Tn.existsSync(e))return;let t;try{t=JSON.parse(Tn.readFileSync(e,"utf8"))}catch(n){throw new J(e,`is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}return Er(t,e)}function vt(e,t){Tn.mkdirSync(bt.dirname(e),{recursive:!0}),Tn.writeFileSync(e,`${JSON.stringify(t,null,2)}
|
|
11
|
+
`)}function Wt(e,t){let n=r=>typeof r=="string"?r.includes(t):Array.isArray(r)?r.some(n):r&&typeof r=="object"?Object.entries(r).some(([s,o])=>s!=="pageFingerprint"&&n(o)):!1;return e.some(n)}function St(e,t){if(t.trim()==="")return!1;let n=s=>(s??"").replace(/<[^<>]+>/g,""),r=new Set([t,...Object.values(X).map(s=>s(t))]);return e.some(s=>[s.value,s.url,s.urlPattern,s.pattern,s.promptText,s.compareTo,...[s.target,s.toTarget].flatMap(o=>[o?.name,o?.nameRegex,o?.label,o?.text,o?.placeholder,o?.testId,o?.frame,...o?.fallbackSelectors??[]])].some(o=>[...r].some(a=>n(o).includes(a))))}function An(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(t.push(s),`"\xAB${t.length-1}\xBB"`)),args:t}}function al(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(e)===n(t)}function il(e){let t=new Set,n=r=>{if(r)for(let s of r.matchAll(/<([^<>]+)>/g))t.add(s[1])};for(let r of e.actions)n(r.value),n(r.url),n(r.pattern),n(r.target?.name),n(r.target?.nameRegex),n(r.target?.label),n(r.target?.text),n(r.target?.placeholder);return n(e.gherkin),t}function $e(e){let t=yo.join(e,".saffron","cache"),n=new Map,r=new Map,s=0;if(mo.existsSync(t))for(let i of mo.readdirSync(t,{recursive:!0})){let c=String(i);if(!c.endsWith(".json"))continue;let l=xt(yo.join(t,c));if(l){s++;for(let d of l.steps){let p=d.gherkin,g=JSON.stringify(d.actions.map(({pageFingerprint:$,...T})=>T)),f=r.get(p)??[];f.includes(g)||(f.push(g),r.set(p,f));let y=n.get(p);!y||l.recordedAt>y.recordedAt?n.set(p,{gherkin:d.gherkin,table:d.table,docString:d.docString,actions:d.actions,sourceParams:qe(l.steps),sourceData:l.data,feature:l.feature,scenario:l.scenario,recordedAt:l.recordedAt,variants:f.length}):y.variants=f.length}}}let o=[...r.entries()].filter(([,i])=>i.length>1).map(([i])=>i),a=new Map;for(let i of n.values()){let{pattern:c,args:l}=An(i.gherkin);if(l.length===0)continue;let d=a.get(c);(!d||i.recordedAt>d.recordedAt)&&a.set(c,{...i,args:l})}return{steps:n,patterns:a,divergent:o,scannedCaches:s}}function wo(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let o=Object.keys(ae(e.table)),a=Object.keys(ae(t.table));if(o.length>0&&a.length>0){if(JSON.stringify(o)!==JSON.stringify(a))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(o=>[o.value,o.url,o.pattern].some(a=>a?.includes("<docstring>"))))return!1;if(e.table&&t.table){let o=ae(e.table),a=ae(t.table);for(let i of Object.keys(o))if(o[i]!==a[i]&&(!Wt(e.actions,`<${i}>`)||St(e.actions,o[i])))return!1}for(let[o,a]of Object.entries(e.sourceParams??{}))if(o in n&&n[o]!==a&&St(e.actions,a))return!1;for(let[o,a]of Object.entries(e.sourceData??{})){let i;try{i=yt(o)}catch{continue}if(i!==a&&(St(e.actions,a)||e.gherkin.includes(`{data:${o}}`)&&!Wt(e.actions,`{data:${o}}`)))return!1}let r={...n,...ae(t.resolvedTable??t.table),...tt(t.resolvedDocString??t.docString)},s=il({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let o of s)if(!(o in r))return!1;return!0}function bo(e,t,n){let r=e.steps.get(t.text);if(r)return wo(r,t,n)?r:void 0;let{pattern:s,args:o}=An(t.text);if(o.length===0)return;let a=e.patterns.get(s);if(!a)return;let i=new Map;for(let p=0;p<o.length;p++){let g=a.args[p],f=o[p];if(g===f)continue;let y=i.get(g);if(y!==void 0&&y!==f)return;i.set(g,f)}let c=p=>[p?.name,p?.nameRegex,p?.label,p?.text,p?.placeholder];for(let p of i.keys())if(!a.actions.some(f=>[f.value,f.url,f.pattern,...c(f.target)].includes(p)))return;let l=p=>p!==void 0&&i.has(p)?i.get(p):p,d={...a,gherkin:t.text,actions:a.actions.map(({pageFingerprint:p,...g})=>({...g,value:l(g.value),url:l(g.url),pattern:l(g.pattern),target:g.target?{...g.target,name:l(g.target.name),nameRegex:l(g.target.nameRegex),label:l(g.target.label),text:l(g.target.text),placeholder:l(g.target.placeholder)}:void 0}))};return wo(d,t,n)?d:void 0}function xo(e,t){let n=[];return e.forEach((r,s)=>{if(r.actions.length!==0&&!t.steps.has(r.gherkin)){for(let o of t.steps.values())if(!(o.variants>1)&&o.actions.length!==0&&al(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function Ir(e){if(!vo.existsSync(e))return[];let t=[];for(let n of vo.readdirSync(e,{recursive:!0})){let r=String(n);Oe(r)&&t.push(Pr.join(e,r))}return t.sort()}function Te(e,t,n,r){let s=Pr.isAbsolute(t)?t:Pr.join(e,t),o=Ir(s),a=n??je(o.filter(f=>f.endsWith(".saffron")),e),i=new Map,c=new Map;for(let f of o)for(let y of r?.get(f)??de(f,e,a)){let $=`${y.featurePath}::${y.name}`;for(let T of y.steps){let v=i.get(T.text);if(v||(v={text:T.text,keywords:new Map,kind:T.kind,scenarios:new Set,files:new Set,fromStepSet:T.source?.fromStepSet},i.set(T.text,v)),v.keywords.set(T.keyword,(v.keywords.get(T.keyword)??0)+1),v.scenarios.add($),T.source&&v.files.add(T.source.file),T.source?.fromStepSet){let S=c.get(T.source.fromStepSet);S||c.set(T.source.fromStepSet,S=new Set),S.add($)}}}let l=$e(e),d=new Set(l.divergent),p=[...i.values()].map(f=>({text:f.text,keyword:[...f.keywords.entries()].sort((y,$)=>$[1]-y[1])[0][0],kind:f.kind,status:d.has(f.text)?"divergent":l.steps.has(f.text)?"recorded":"unrecorded",usage:f.scenarios.size,files:[...f.files].sort(),fromStepSet:f.fromStepSet})).sort((f,y)=>y.usage-f.usage||f.text.localeCompare(y.text)),g=[...a.values()].map(f=>({name:f.name,file:f.file,line:f.line,steps:f.steps.map(y=>y.text),usage:c.get(f.name)?.size??0})).sort((f,y)=>y.usage-f.usage||f.name.localeCompare(y.name));return{steps:p,stepSets:g,scannedFiles:o.length,scannedCaches:l.scannedCaches}}function _n(e,t){let n=t.toLowerCase();return{...e,steps:e.steps.filter(r=>r.text.toLowerCase().includes(n)),stepSets:e.stepSets.filter(r=>r.name.toLowerCase().includes(n)||r.steps.some(s=>s.toLowerCase().includes(n)))}}function So(e){let t={},n="saffron,feature,gherkin,cucumber";for(let r of e.steps)t[`step: ${r.text}`]={scope:n,prefix:r.text,body:[r.text.replace(/\$/g,"\\$")],description:`${r.status} \xB7 used in ${r.usage} scenario(s)`};for(let r of e.stepSets)t[`StepSet: ${r.name}`]={scope:n,prefix:`StepSet ${r.name}`,body:[`StepSet ${r.name}`],description:`step set (${r.steps.length} steps) \u2014 ${r.file}`};return JSON.stringify(t,null,2)+`
|
|
12
|
+
`}import{Server as Ed}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as Ad}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as _d,ListToolsRequestSchema as Pd}from"@modelcontextprotocol/sdk/types.js";import ye from"node:fs";import Ee from"node:path";import ko from"node:fs";import cl from"node:path";function ie(e){co(e);let t=cl.join(e,"saffron.config.json"),n=ko.existsSync(t)?JSON.parse(ko.readFileSync(t,"utf8")):{};return Ke(e,n.dataDir,process.env.SAFFRON_ENV??n.env),n}var Cr={provider:"claude",features:"features",dataDir:"data",actionTimeoutMs:5e3,pollIntervalMs:100,retries:1,screenshot:"viewport",maxTurns:100,strict:!1,verifyProposals:!0,snapshotMode:"none",reuseSteps:!0,browser:"chromium",workers:1,assertionPolicy:"strict",hookTimeoutMs:3e5,trace:"off"};function $o(e){return{...Cr,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import jr from"node:fs";import Pn from"node:path";import{fileURLToPath as ll}from"node:url";function In(){let e=Pn.dirname(ll(import.meta.url));for(let t=0;t<6;t++){let n=Pn.join(e,"package.json");if(jr.existsSync(n))try{if(JSON.parse(jr.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=Pn.dirname(e)}}function He(){let e=In();if(!e)return"unknown";try{return JSON.parse(jr.readFileSync(Pn.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import ce from"node:fs";import Z from"node:path";import se from"node:fs";import{createHash as fl}from"node:crypto";import To from"node:os";import Cn from"node:path";import Ne from"node:fs";import kt from"node:path";import{createHash as dl,randomBytes as pl}from"node:crypto";function $t(e,t){try{if(!Ne.statSync(t).isFile())return!1;let n=kt.relative(Ne.realpathSync(e),Ne.realpathSync(t));return n!==""&&n!==".."&&!n.startsWith(".."+kt.sep)&&!kt.isAbsolute(n)}catch{return!1}}function rt(e){return dl("sha256").update(e).digest("hex")}function We(e,t,n){Ne.mkdirSync(kt.dirname(e),{recursive:!0});let r=`${e}.${process.pid}-${pl(4).toString("hex")}.partial`;try{Ne.writeFileSync(r,t),n!==void 0&&Ne.chmodSync(r,n),Ne.renameSync(r,e)}catch(s){throw Ne.rmSync(r,{force:!0}),s}}function Tt(e,t){let n=kt.join(e,t.file);if($t(kt.join(e,".saffron","artifacts"),n))try{let r=Ne.readFileSync(n);return t.sha256&&rt(r)!==t.sha256?void 0:r}catch{return}}function Ro(e,t){se.mkdirSync(Cn.dirname(e),{recursive:!0});let n=Buffer.from(`${JSON.stringify(t,null,2)}
|
|
13
|
+
`);return jn(e,()=>We(e,n)),Nr(n)}var ul=6e4,gl=5e3,hl=60*6e4,ml=new Int32Array(new SharedArrayBuffer(4)),Vt=class extends Error{};function jn(e,t,n=1e4){let r=`${e}.lock`,s=Date.now()+n,o;for(;;){o=JSON.stringify({pid:process.pid,host:To.hostname(),at:new Date().toISOString()});try{se.writeFileSync(r,o,{flag:"wx"});break}catch(a){let i=a.code,c=i==="EPERM"&&process.platform==="win32";if(i!=="EEXIST"&&!c)throw a;if(Date.now()>=s)throw c?a:new Vt(`${e} is being decided by another process. Try again.`);yl(r)||Atomics.wait(ml,0,0,25)}}try{return t()}finally{Or(r)===o&&se.rmSync(r,{force:!0})}}function Or(e){try{return se.readFileSync(e,"utf8")}catch{return}}function yl(e){let t=Or(e);if(t===void 0)return!1;let n={};try{n=JSON.parse(t)||{}}catch{}let r=Date.parse(n.at??""),s=ul;if(Number.isNaN(r)){try{r=se.statSync(e).mtimeMs}catch{return!1}s=gl}let o=n.host===To.hostname()&&typeof n.pid=="number";return o&&wl(n.pid)&&Date.now()-r<hl||!o&&Date.now()-r<s||Or(e)!==t?!1:(se.rmSync(e,{force:!0}),!0)}function wl(e){try{return process.kill(e,0),!0}catch(t){return t.code!=="ESRCH"}}function On(e,t,n){return se.existsSync(e)?jn(e,()=>se.existsSync(e)?t():n()):n()}function Fr(e){let t=/^(.+)#([0-9a-f]{64})$/i.exec(e);return t?{file:t[1],revision:t[2].toLowerCase()}:{file:e}}function Gt(e){return Nr(se.readFileSync(e))}var Nr=e=>fl("sha256").update(e).digest("hex");function Rt(e){return Fn(e).proposal}function Fn(e){let t=se.readFileSync(e),n;try{n=JSON.parse(t.toString("utf8"))}catch(r){throw new J(e,`is not valid JSON: ${r instanceof Error?r.message:String(r)}`)}return go(n,e),{proposal:n,revision:Nr(t)}}function Ve(e,t){let n=Cn.join(e,".saffron","proposals");if(!se.existsSync(n))return[];let r=[];for(let s of se.readdirSync(n)){let o=Cn.join(n,s);if(se.statSync(o).isDirectory())for(let a of se.readdirSync(o)){if(!a.endsWith(".json"))continue;let i=Cn.join(o,a);try{r.push({file:i,...Fn(i)})}catch(c){if(!t||!(c instanceof J))throw c;t(i,c.problem)}}}return r}function Nn(e,t){let n=Rt(t),r=K(e,n.cache.feature,n.cache.scenario);return vt(r,n.cache),se.rmSync(t),r}function Mn(e){se.rmSync(e)}var Mr=(e,t)=>Z.relative(e,t).split(Z.sep).join("/");function Eo(e){return ce.existsSync(e)?[...ce.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>Z.join(e,t)).sort():[]}function Ao(e){try{let t=JSON.parse(ce.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function _o(e){try{let t=JSON.parse(ce.readFileSync(Z.join(e,En),"utf8"));return{feature:typeof t.feature=="string"?t.feature:void 0,scenario:typeof t.scenario=="string"?t.scenario:void 0}}catch{return{}}}function Ln(e,t,n){let r=Z.isAbsolute(t)?t:Z.join(e,t),s=new Set(ce.existsSync(r)?[...ce.readdirSync(r,{recursive:!0})].map(String).filter(Oe).map(l=>Z.join(r,l)):[]);for(let l of["cache","proposals"])for(let d of Eo(Z.join(e,".saffron",l))){let{feature:p}=Ao(d);if(typeof p=="string"){let g=Z.resolve(e,p);ce.existsSync(g)&&s.add(g)}}for(let l of Dn(Z.join(e,".saffron","artifacts")))for(let d of Dn(l)){let p=_o(d).feature;if(p){let g=Z.resolve(e,p);ce.existsSync(g)&&s.add(g)}}let o=[...n?.unreadable??[]],a=[...s].filter(l=>!n?.parsedFiles.has(l)),i=new Map;if(a.length>0)try{i=je([...new Set([...nt(r),...s].filter(l=>l.endsWith(".saffron")))],e)}catch(l){o.push(l instanceof Error?l.message:String(l))}let c=new Set(n?.owned??[]);for(let l of a){let d;try{d=de(l,e,i)}catch(p){o.push(`${Mr(e,l)}: ${p instanceof Error?p.message:String(p)}`);continue}for(let p of d)c.add(K(e,p.featurePath,p.name)),c.add(be(e,p.featurePath,p.name)),c.add(ke(e,p.featurePath,p.displayName))}return{...bl(e,c),unreadable:o}}function bl(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let a=Z.join(e,".saffron",o==="cache"?"cache":"proposals");for(let i of Eo(a)){if(t.has(i)){r++;continue}let{feature:c,scenario:l}=Ao(i),d=c?Z.join(e,c):void 0;n.push({file:Mr(e,i),kind:o,feature:c,scenario:l,reason:c?ce.existsSync(d)?"scenario-gone":"feature-gone":"unknown"})}}let s=Z.join(e,".saffron","artifacts");for(let o of Dn(s))for(let a of Dn(o)){if(t.has(a)){r++;continue}let{feature:i,scenario:c}=_o(a);n.push({file:Mr(e,a),kind:"artifact",feature:i,scenario:c,reason:i?ce.existsSync(Z.join(e,i))?"scenario-gone":"feature-gone":"unknown"})}return{orphans:n,kept:r}}function Dn(e){try{return ce.readdirSync(e,{withFileTypes:!0}).filter(t=>t.isDirectory()).map(t=>Z.join(e,t.name)).sort()}catch{return[]}}function Po(e,t){let n=[],r=new Set;for(let s of t){let o=Z.join(e,s.file);try{s.kind==="proposal"?jn(o,()=>ce.rmSync(o)):ce.rmSync(o,{recursive:s.kind==="artifact"}),n.push(s.file),r.add(Z.dirname(o))}catch{}}for(let s of r)try{ce.readdirSync(s).length===0&&ce.rmdirSync(s)}catch{}return n}function Io(e){if(e.kind==="artifact")return`${e.file}/
|
|
14
14
|
failure screenshots of a scenario that no longer exists`;let t=e.feature&&e.scenario?`${e.feature} \u203A ${e.scenario}`:"an unreadable recording",n=e.reason==="feature-gone"?"the feature file is gone":e.reason==="scenario-gone"?"the scenario is gone from that file":"it names no scenario";return`${e.file}
|
|
15
|
-
${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`)
|
|
19
|
-
`),r=
|
|
20
|
-
`)
|
|
21
|
-
|
|
22
|
-
`)}var
|
|
23
|
-
|
|
24
|
-
`)
|
|
25
|
-
`)
|
|
26
|
-
`)[0]}));return{
|
|
27
|
-
`)}function
|
|
28
|
-
`).
|
|
15
|
+
${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import sa from"node:path";var xl=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function vl(e,t){let n=String(e.getFullYear()),r=String(e.getMonth()+1).padStart(2,"0"),s=String(e.getDate()).padStart(2,"0");return t.replaceAll("YYYY",n).replaceAll("MM",r).replaceAll("DD",s).replaceAll("M",String(e.getMonth()+1)).replaceAll("D",String(e.getDate()))}function Dr(e,t=new Date){return e.replace(xl,(n,r,s)=>{let o=new Date(t);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),vl(o,s??"YYYY-MM-DD")})}var Sl=/\b(\d{4})-(\d{2})-(\d{2})\b/g,kl=400;function Co(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(Sl,(r,s,o,a)=>{let i=new Date(Number(s),Number(o)-1,Number(a));if(Number.isNaN(i.getTime()))return r;let c=Math.round((i.getTime()-n.getTime())/864e5);return Math.abs(c)>kl?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as $l,randomBytes as Tl}from"node:crypto";var jo=/\{unique:([A-Za-z0-9_-]+)(?::(digits))?\}/g,me="__saffron_unique_seed",st=()=>`${Date.now().toString(36)}${Tl(4).toString("hex")}`;function Oo(e,t,n=!1){let r=$l("sha1").update(`${e}:${t}`).digest();if(n){let o=r.readUInt32BE(0)*65536+r.readUInt16BE(4);return String(1e8+o%9e8)}let s=BigInt(`0x${r.subarray(0,8).toString("hex")}`).toString(36).padStart(9,"0").slice(-9);return`${"abcdefghijklmnopqrstuvwxyz"[r[8]%26]}${s}`}function Et(e,t){return e.replace(jo,(n,r,s)=>Oo(t,r,s==="digits"))}function At(e,t){let n=new Map;for(let r of e)if(r)for(let s of r.matchAll(jo))n.set(s[0],Oo(t,s[1],s[2]==="digits"));return n}function Fo(e,t){let n=e;for(let[r,s]of t)n=n.split(s).join(r);return n}import{createHash as Rl}from"node:crypto";async function ot(e){let t;try{t=await e.locator("body").ariaSnapshot({timeout:3e3})}catch{return"sha256:unavailable"}let n=t.replace(/\d+/g,"#").replace(/\s+/g," ").trim();return`sha256:${Rl("sha256").update(n).digest("hex")}`}var El=/^[a-z][a-z\d+.-]*:|^\/\//i,No=e=>e.pathname.replace(/\/+$/,"");function Jt(e,t){if(!t)return e;let n,r;try{n=new URL(e),r=new URL(t)}catch{return e}if(n.origin!==r.origin)return e;let s=No(r),o=n.pathname,a;if(o===r.pathname||o===s||o===`${s}/`)a="/";else if(o.startsWith(`${s}/`))a=o.slice(s.length);else return e;let i=n.search===r.search?"":n.search||"?",c=a!=="/"?n.hash:n.hash===r.hash?"":n.hash||(r.hash?"#":"");return`${a}${i}${c}`}function Lr(e,t){if(El.test(e))return e;if(!t)throw new Error(`relative navigation "${e}" needs a base URL (set baseURL or pass --base-url)`);let n=new URL(t),r=e.indexOf("#"),s=r>=0?e.slice(r):void 0,o=r>=0?e.slice(0,r):e,a=o.indexOf("?"),i=a>=0?o.slice(a):void 0,c=a>=0?o.slice(0,a):o,l=c===""||c==="/",d=new URL(n.href);return d.pathname=l?n.pathname:`${No(n)}${c.startsWith("/")?c:`/${c}`}`,d.search=i===void 0?n.search:i==="?"?"":i,d.hash=s==="#"?"":s!==void 0?s:l?n.hash:"",d.toString()}import Mo from"node:fs";import Al from"node:os";import xe from"node:path";import{Client as _l}from"@modelcontextprotocol/sdk/client/index.js";import{StdioClientTransport as Pl}from"@modelcontextprotocol/sdk/client/stdio.js";import{McpServer as Il}from"@modelcontextprotocol/sdk/server/mcp.js";import{CallToolRequestSchema as Cl,ListToolsRequestSchema as jl}from"@modelcontextprotocol/sdk/types.js";var Ur=6e4;function Do(){return Mo.mkdtempSync(xe.join(Al.tmpdir(),"saffron-mcp-"))}var Br=1e8,Lo=new Set(["browser_navigate","browser_click","browser_type","browser_press_sequentially","browser_fill_form","browser_select_option","browser_check","browser_uncheck","browser_press_key","browser_hover","browser_drag","browser_file_upload","browser_handle_dialog","browser_tabs","browser_snapshot","browser_find","browser_wait_for","browser_take_screenshot","browser_console_messages","browser_network_requests","browser_network_request","browser_evaluate"]);function Ol(e){return{...e,tools:e.tools.filter(t=>Lo.has(t.name))}}function Fl(e){return Lo.has(e)?void 0:{isError:!0,content:[{type:"text",text:`${e} is not available in a Saffron session.`}]}}var Nl=e=>({isError:!0,content:[{type:"text",text:e}]}),Ml=new Set(["http:","https:","about:","data:"]);function Kt(e){if(typeof e!="string"||/^localhost(:\d+)?(\/|$)/i.test(e))return;let t;try{t=new URL(e).protocol}catch{return}return Ml.has(t)?void 0:`${e} is not a web page; a Saffron session only opens http(s) URLs.`}function zr(e,t){if(!Array.isArray(e))return;let n=a=>{try{return Mo.realpathSync(a)}catch{return}},r=n(t)??xe.resolve(t),s=a=>{let i=xe.relative(r,a);return i.startsWith("..")||xe.isAbsolute(i)?void 0:i},o=a=>a.split(xe.sep).some(i=>i.startsWith(".")&&i!=="..");for(let a of e){if(typeof a!="string")continue;let i=xe.resolve(a),c=n(i),l=n(xe.dirname(i)),d=s(c??(l?xe.join(l,xe.basename(i)):i));if(d===void 0)return`${a} is outside the project; uploads come from files inside it.`;if(o(d)||o(xe.relative(xe.resolve(t),i)))return`${a} is a hidden file; a Saffron session does not upload dotfiles.`;if(!c)return`${a} does not exist; uploads come from files inside the project.`}}function Dl(e,t,n=process.cwd()){if(!t)return{args:t};let r=e==="browser_navigate"||e==="browser_tabs"?Kt(t.url):e==="browser_file_upload"?zr(t.paths,n):void 0;if(r)return{args:t,refused:Nl(r)};if("filename"in t){let{filename:s,...o}=t;return{args:o}}return{args:t}}function Ll(e,t=Ur){if(!Array.isArray(e.content))return e;let n=!1,r=e.content.map(s=>{if(s.type!=="text"||typeof s.text!="string"||s.text.length<=t)return s;n=!0;let o=`
|
|
16
|
+
|
|
17
|
+
[Saffron cut this result at ${t} of ${s.text.length} characters: whole, it would not fit the agent's context. Ask for less: a snapshot of one region, a filtered request list, or a saffron_assert on the field you need instead of the whole body.]`,a=/[\uD800-\uDBFF]/.test(s.text[t-1])?t-1:t;return{...s,text:s.text.slice(0,a)+o}});return n?{...e,content:r}:e}async function Uo(e,t){let n=new _l({name:"saffron-cap",version:"1"}),r=Object.fromEntries(Object.entries(process.env).filter(o=>o[1]!==void 0));await n.connect(new Pl({...e,env:r,stderr:"ignore"}));let s=qr(n,void 0,t);return{config:{type:"sdk",name:"playwright",instance:s},close:async()=>{await Promise.allSettled([n.close(),s.close()])}}}function qr(e,t=(r,s,o)=>o(),n){let r=new Il({name:"playwright",version:"1"},{capabilities:{tools:{}}});return r.server.setRequestHandler(jl,async()=>Ol(await e.listTools())),r.server.setRequestHandler(Cl,async(s,o)=>{let a=Fl(s.params.name);if(a)return a;let i=Dl(s.params.name,s.params.arguments,n);if(i.refused)return i.refused;let c={...s.params,arguments:i.args};return Ll(await t(c.name,c.arguments,()=>e.callTool(c,void 0,{timeout:Br,signal:o.signal})))}),r}import Zt from"node:fs";import zl from"node:os";import Wr from"node:path";import Hr from"node:zlib";var Bo=67324752,zo=33639248,qo=101010256;function Ho(e){return Yt(e).map(t=>({name:t.name,data:t.read()}))}function Yt(e){let t=-1;for(let o=e.length-22;o>=Math.max(0,e.length-22-65535);o--)if(e.readUInt32LE(o)===qo){t=o;break}if(t<0)throw new Error("not a zip file: no central directory");let n=e.readUInt16LE(t+10),r=e.readUInt32LE(t+16),s=[];for(let o=0;o<n;o++){if(e.readUInt32LE(r)!==zo)throw new Error("not a zip file: bad central directory entry");let a=e.readUInt16LE(r+10),i=e.readUInt32LE(r+16),c=e.readUInt32LE(r+20),l=e.readUInt32LE(r+24),d=e.readUInt16LE(r+28),p=e.readUInt16LE(r+30),g=e.readUInt16LE(r+32),f=e.readUInt32LE(r+42),y=e.subarray(r+46,r+46+d).toString("utf8");if(r+=46+d+p+g,e.readUInt32LE(f)!==Bo)throw new Error(`not a zip file: bad local header for ${y}`);let $=e.readUInt16LE(f+26),T=e.readUInt16LE(f+28),v=f+30+$+T,S=e.subarray(v,v+c);if(!y.endsWith("/"))if(a===0)s.push({name:y,method:a,crc:i,size:l,raw:S,read:()=>Buffer.from(S)});else if(a===8)s.push({name:y,method:a,crc:i,size:l,raw:S,read:()=>Hr.inflateRawSync(S)});else throw new Error(`zip entry ${y} uses compression method ${a}, which is not supported`)}return s}var Ul=(()=>{let e=new Uint32Array(256);for(let t=0;t<256;t++){let n=t;for(let r=0;r<8;r++)n=n&1?3988292384^n>>>1:n>>>1;e[t]=n>>>0}return e})();function Bl(e){let t=Hr.crc32;if(t)return t(e)>>>0;let n=4294967295;for(let r of e)n=Ul[(n^r)&255]^n>>>8;return(n^4294967295)>>>0}function Wo(e){let t=[],n=[],r=0;for(let a of e){let{name:i}=a,c="raw"in a,l=c?a.method:8,d=c?a.raw:Hr.deflateRawSync(a.data),p=c?a.crc:Bl(a.data),g=c?a.size:a.data.length,f=Buffer.from(i,"utf8"),y=Buffer.alloc(30);y.writeUInt32LE(Bo,0),y.writeUInt16LE(20,4),y.writeUInt16LE(2048,6),y.writeUInt16LE(l,8),y.writeUInt32LE(0,10),y.writeUInt32LE(p,14),y.writeUInt32LE(d.length,18),y.writeUInt32LE(g,22),y.writeUInt16LE(f.length,26),y.writeUInt16LE(0,28);let $=Buffer.alloc(46);$.writeUInt32LE(zo,0),$.writeUInt16LE(20,4),$.writeUInt16LE(20,6),$.writeUInt16LE(2048,8),$.writeUInt16LE(l,10),$.writeUInt32LE(0,12),$.writeUInt32LE(p,16),$.writeUInt32LE(d.length,20),$.writeUInt32LE(g,24),$.writeUInt16LE(f.length,28),$.writeUInt16LE(0,30),$.writeUInt16LE(0,32),$.writeUInt16LE(0,34),$.writeUInt16LE(0,36),$.writeUInt32LE(0,38),$.writeUInt32LE(r,42),t.push(y,f,d),n.push($,f),r+=y.length+f.length+d.length}let s=n.reduce((a,i)=>a+i.length,0),o=Buffer.alloc(22);return o.writeUInt32LE(qo,0),o.writeUInt16LE(0,4),o.writeUInt16LE(0,6),o.writeUInt16LE(e.length,8),o.writeUInt16LE(e.length,10),o.writeUInt32LE(s,12),o.writeUInt32LE(r,16),o.writeUInt16LE(0,20),Buffer.concat([...t,...n,o])}var Vr=["off","on","retain-on-failure"],Gr="trace.zip";async function Jr(e,t){await e.tracing.start({screenshots:!0,snapshots:!0,sources:!1,title:t})}async function Kr(e,t){t&&Zt.mkdirSync(Wr.dirname(t),{recursive:!0}),await e.tracing.stop(t?{path:t}:void 0)}async function Yr(e,t,n){let r;try{r=Zt.mkdtempSync(Wr.join(zl.tmpdir(),"saffron-trace-"));let s=Wr.join(r,Gr);await e.tracing.stop({path:s});let o=ed(Zt.readFileSync(s),n);return We(t,o),rt(o)}catch{try{Zt.rmSync(t,{force:!0})}catch{console.warn("Could not remove the execution trace artifact.")}console.warn("Could not save the execution trace; no trace was attached to this run.");return}finally{if(r)try{Zt.rmSync(r,{recursive:!0,force:!0})}catch{console.warn("Could not remove the private temporary trace directory.")}}}async function Go(e,t,n,r){if(!t)return r();let s=e.context().tracing;await s.group(n).catch(()=>{});try{return await r()}finally{await s.groupEnd().catch(()=>{})}}var ql=new Set(["callId","parentId","pageId","frameId","contextId","pageref","_frameref","snapshotName","beforeSnapshot","afterSnapshot","inputSnapshot","sha1","_sha1"]),Hl=new Set(["before","action","after"]),Wl=/^\[saffron:(?:agent|replay|pending|action):-?\d+\] /,Vl=new Set(["type","class","method"]),Gl=new Set(["__playwright_bounding_rect__","__playwright_scroll_top_","__playwright_scroll_left_","__playwright_target__","__playwright_checked_","__playwright_selected_","__playwright_popover_open_","__playwright_dialog_open_","__playwright_shadow_root_","__playwright_custom_elements__"]),Jl=new Set(["style","__playwright_style_sheet_"]),Kl=new Set(["srcset","imagesrcset"]),Yl=/^(?:image\/(?!svg)|font\/|audio\/|video\/)/i,Zl=/\.(?:jpe?g|png|gif|webp|avif|bmp|ico|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|webm)$/i,Ql=/^text\/css\b/i,Xl=/\.css$/i;function ed(e,t){let n=Xe(t,{encodings:!0});if(n.empty)return e;let r=Xe(t,{encodings:!0,bytes:!0}),s=Yt(e),o=new Set(s.filter(f=>f.name.includes("/")).map(f=>f.name));for(let f of s)f.name.startsWith("resources/")&&o.add(f.name.slice(10));let a={names:o,pieces:pd(t)},i=new Set,c=new Set,l=f=>{for(let y of[f.snapshot?.response?.content,f.snapshot?.request?.postData]){if(!y)continue;let $=y._file??(y._sha1&&`resources/${y._sha1}`);if(!$)continue;let T=String(y.mimeType??"");Yl.test(T)?i.add($):Ql.test(T)&&c.add($)}},d=[...s],p=!1,g=(f,y)=>{y&&(d[f]={name:s[f].name,data:y},p=!0)};return s.forEach((f,y)=>{/\.(?:trace|network)$/.test(f.name)?g(y,td(f.read(),n,a,f.name.endsWith(".network")?l:void 0)):f.name.endsWith(".stacks")&&g(y,nd(f.read(),n,a))}),s.forEach((f,y)=>{/\.(?:trace|network|stacks)$/.test(f.name)||i.has(f.name)||Zl.test(f.name)||g(y,rd(f.read(),n,r,c.has(f.name)||Xl.test(f.name)))}),p?Wo(d):e}function td(e,t,n,r){let s=e.toString("utf8").split(`
|
|
18
|
+
`),o=!1;for(let a=0;a<s.length;a++){let i=s[a];if(!i.trim())continue;let c=JSON.parse(i);if(r?.(c),!t.occursIn(i)&&!t.hiddenByEscape.test(i))continue;let l=Qr(i,t,{keys:!1,numbers:!1,playwright:n});l!==i&&(s[a]=l,o=!0)}return o?Buffer.from(s.join(`
|
|
19
|
+
`),"utf8"):void 0}function nd(e,t,n){let r=e.toString("utf8");if(JSON.parse(r),!t.occursIn(r)&&!t.hiddenByEscape.test(r))return;let s=Qr(r,t,{keys:!1,numbers:!1,playwright:n});return s===r?void 0:Buffer.from(s,"utf8")}function rd(e,t,n,r=!1){if(r){let d=e.toString("latin1"),p=Zo(d,n);return p===d?void 0:Buffer.from(p,"latin1")}let s=e.toString("utf8"),o=s.startsWith("\uFEFF")?"\uFEFF":"",a=s.slice(o.length);if(!s.includes("\uFFFD")||Buffer.from(s,"utf8").equals(e)){let d=/^\s*[[{"]/.test(a)?Jo(a,t):void 0,p=d===void 0&&/^\s*(?:[[{"\d-]|true|false|null)/.test(a)&&a.trim().includes(`
|
|
20
|
+
`)?sd(a,t):void 0,g=d??p;if(g!==void 0)return g===a?void 0:Buffer.from(o+g,"utf8")}let c=e.toString("latin1"),l=n.mask(c);return l===c?void 0:Buffer.from(l,"latin1")}function Jo(e,t){try{JSON.parse(e)}catch(n){if(n instanceof SyntaxError)return;throw n}return!t.occursIn(e)&&!t.hiddenByEscape.test(e)?e:Qr(e,t,{keys:!0,numbers:!0})}function sd(e,t){let n=e.split(`
|
|
21
|
+
`),r=[];for(let s of n){if(!s.trim()){r.push(s);continue}let a=Jo(s,t);if(a===void 0)return;r.push(a)}return r.join(`
|
|
22
|
+
`)}var Ko=/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*@)?([^/?#]*)/i;function Yo(e,t=0,n=e.length){let r=Ko.exec(e.slice(t,n));if(!r)return;let[s,o,a=""]=r,i=t+o.length+a.length;return c=>c>=t&&c<t+o.length||c>=i-(a?1:0)&&c<=t+s.length&&c<n}function od(e,t,n){let r="",s=0;for(let o of t.matches(e)){let{token:a,kept:i=()=>!1}=n(o),c="";for(let l=o.start;l<o.end;){if(i(l)){c+=e[l++];continue}for(;l<o.end&&!i(l);)l++;c+=a(o.names)}c!==e.slice(o.start,o.end)&&(r+=e.slice(s,o.start)+c,s=o.end)}return s===0?e:r+e.slice(s)}var ad=e=>e.map(t=>`%7Benv:${t}%7D`).join(""),id=e=>e.map(t=>`\\{env\\:${t}\\}`).join("");function Zr(e,t,n,r){let s=n.map(([a,i])=>({start:a,end:i,kept:Yo(e,a,i)})),o=0;return od(e,t,a=>{for(;o<s.length&&s[o].end<a.end;)o++;let i=s[o];return i&&i.start<=a.start&&a.end<=i.end?{token:ad,kept:i.kept}:{token:r}})}function cd(e,t){return Yo(e)?Zr(e,t,[[0,e.length]],Ht):t.mask(e)}var ld=/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s'"]*))|@import\s+(?:"([^"]*)"|'([^']*)')/dgi;function Zo(e,t){if(!t.occursIn(e))return e;let n=[...e.matchAll(ld)].map(r=>r.indices.slice(1).find(s=>s!==void 0));return Zr(e,t,n,id)}function dd(e,t){let n=[];for(let r=0;r<e.length;){for(;r<e.length&&/[\s,]/.test(e[r]);)r++;let s=r;for(;s<e.length&&!/\s/.test(e[s]);)s++;let o=s;for(;o>r&&e[o-1]===",";)o--;if(o>r&&n.push([r,o]),r=s,o===s)for(;r<e.length&&e[r]!==",";)r++}return Zr(e,t,n,Ht)}function pd(e){let t=new Map;for(let[n,r]of e)if(!(r.length<Qe||!Ko.test(r)||!URL.canParse(r)))for(let s of new URL(r).searchParams.values())s.length>=Qe&&!t.has(s)&&t.set(s,n);return t}var Vo=e=>e===" "||e===" "||e===`
|
|
23
|
+
`||e==="\r";function Qr(e,t,n){let r=[],s=0,o,a=[],i=c=>{let l=c-1;for(;l>=0&&Vo(e[l]);)l--;return e[l]};for(let c=0;c<e.length;c++){let l=e[c];if(l==="{"||l==="["){let d=a.at(-1),p=i(c)===":"?o:void 0;a.push({dom:(d?.dom??!1)||p==="html",logged:(d?.logged??!1)||p==="value"});continue}if(l==="}"||l==="]"){a.pop();continue}if(l==='"'){let d=c++,p=!1;for(;c<e.length&&e[c]!=='"';c++)e[c]==="\\"&&(p=!0,c++);let g=c+1,f=g;for(;f<e.length&&Vo(e[f]);)f++;let y=e[f]===":",$=p?JSON.parse(e.slice(d,g)):e.slice(d+1,c),T=a.at(-1),v=!y&&i(d)===":"?o:void 0,S=x=>t.mask(x);if(y){if(o=$,!n.keys)continue}else if(n.playwright){if(a.length===1&&v!==void 0&&Vl.has(v)||n.playwright.names.has($))continue;let{pieces:x}=n.playwright;if(S=b=>x.has(b)?Ht([x.get(b)]):cd(b,t),T?.dom){if(i(d)==="["){T.tag=$.toUpperCase();continue}let b=v===void 0?T.tag:a.at(-2)?.tag;if(v!==void 0&&Gl.has(v)||v==="src"&&(b==="IFRAME"||b==="FRAME"))continue;v===void 0&&b==="STYLE"||v!==void 0&&Jl.has(v)?S=u=>Zo(u,t):v!==void 0&&Kl.has(v)&&(S=u=>dd(u,t))}else if(v!==void 0&&ql.has(v)||v==="phase"&&Hl.has($))continue}let h=n.playwright&&a.length===1&&v==="title"?Wl.exec($)?.[0]??"":"",w=h+S($.slice(h.length));w!==$&&(r.push(e.slice(s,d),JSON.stringify(w)),s=g)}else if(l==="-"||l>="0"&&l<="9"){let d=c+1;for(;d<e.length&&/[0-9eE.+-]/.test(e[d]);)d++;(n.numbers||(a.at(-1)?.logged??!1)||i(c)===":"&&o==="value")&&t.isValue(e.slice(c,d))&&(r.push(e.slice(s,c),JSON.stringify(t.mask(e.slice(c,d)))),s=d),c=d-1}}return r.length?r.join("")+e.slice(s):e}function Xt(e){return Array.isArray(e)?e.map(Xt):e&&typeof e=="object"?Object.fromEntries(Object.entries(e).filter(([t,n])=>n!==void 0&&t!=="pageFingerprint").sort(([t],[n])=>t.localeCompare(n)).map(([t,n])=>[t,Xt(n)])):e}var Xr=e=>JSON.stringify(Xt(e));function es(e,t="",n={}){if(Array.isArray(e))n[t]=e.map(r=>String(r)).join(", ");else if(e&&typeof e=="object")for(let[r,s]of Object.entries(e))es(s,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function fd(e,t){let n=es(Xt(e)),r=es(Xt(t)),s=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),o=[];for(let a of s){if(a==="action"||n[a]===r[a])continue;let i=a.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[a]===void 0?o.push(`${i}: added "${r[a]}"`):r[a]===void 0?o.push(`${i}: removed "${n[a]}"`):o.push(`${i}: "${n[a]}" \u2192 "${r[a]}"`)}return o}function ta(e,t,n){let r=e.map(n),s=t.map(n),o=Array.from({length:r.length+1},()=>new Array(s.length+1).fill(0));for(let l=r.length-1;l>=0;l--)for(let d=s.length-1;d>=0;d--)o[l][d]=r[l]===s[d]?o[l+1][d+1]+1:Math.max(o[l+1][d],o[l][d+1]);let a=[],i=0,c=0;for(;i<r.length&&c<s.length;)r[i]===s[c]?(a.push({before:e[i],after:t[c]}),i++,c++):o[i+1][c]>=o[i][c+1]?(a.push({before:e[i]}),i++):(a.push({after:t[c]}),c++);for(;i<r.length;i++)a.push({before:e[i]});for(;c<s.length;c++)a.push({after:t[c]});return a}function Qo(e){if(!e)return"";let t=[];if(e.role){let n=e.name?` "${e.name}"`:e.nameRegex?` matching /${e.nameRegex}/`:"";t.push(`${e.role}${n}`)}else e.label?t.push(`label "${e.label}"`):e.text?t.push(`text "${e.text}"`):e.placeholder?t.push(`placeholder "${e.placeholder}"`):e.testId&&t.push(`test id ${e.testId}`);return e.fallbackSelectors?.length&&t.push(`[${e.fallbackSelectors.join(", ")}]`),e.frame&&t.push(`inside frame ${e.frame}`),t.join(" ")}var Xo=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function ge(e){let t=Qo(e.target),n=e.value??"",r=(()=>{switch(e.action){case"goto":return`go to ${e.url}`;case"click":return`click ${t}`;case"fill":return`fill ${t} with "${n}"`;case"press":return t?`press ${n} on ${t}`:`press ${n}`;case"selectOption":return`select "${n}" in ${t}`;case"check":return`check ${t}`;case"uncheck":return`uncheck ${t}`;case"hover":return`hover ${t}`;case"waitFor":return t?`wait for ${t}`:`wait ${n}ms`;case"captureText":return`remember ${t} as "${e.saveAs}"`;case"handleDialog":return`${n} the dialog${e.promptText?` with "${e.promptText}"`:""}`;case"uploadFile":return`upload ${n}`;case"dragTo":return`drag ${t} onto ${Qo(e.toTarget)}`;case"waitForResponse":return`wait for ${Xo(e)}`;case"switchTab":return`switch to tab ${n}`;case"closeTab":return`close tab ${n||"(the current one)"}`;case"newTab":return`open a new tab${e.url?` at ${e.url}`:""}`;case"expectVisible":return`expect ${t} to be visible`;case"expectNotVisible":return`expect ${t} to be gone`;case"expectText":return`expect ${t} to contain "${n}"`;case"expectUrl":return`expect the URL to contain "${e.url??n}"`;case"expectValue":return`expect ${t} to hold "${n}"`;case"expectDiffers":return`expect ${t||`"${n}"`} to differ from "${e.compareTo}"`;case"expectMatches":return`expect ${t} to match /${e.pattern}/`;case"expectAttribute":return`expect ${t} ${e.attribute} to match /${e.pattern}/`;case"expectResponse":return`expect a response ${Xo(e)}`;case"expectEach":return`expect ${t} to show every value of ${e.each}`;default:return e.action}})();return e.opensTab?`${r}, opening a tab`:r}var Qt=e=>ue.has(e.action);function ud(e,t){let n=ta(e,t,Xr),r=[];for(let s=0;s<n.length;s++){let o=n[s];if(o.before&&o.after){r.push({change:"same",before:ge(o.before),after:ge(o.after),assertion:Qt(o.after)});continue}let a=n[s+1];if(o.before&&a?.after&&!a.before){r.push({change:"changed",before:ge(o.before),after:ge(a.after),details:fd(o.before,a.after),assertion:Qt(o.before)||Qt(a.after)}),s++;continue}o.before&&r.push({change:"removed",before:ge(o.before),assertion:Qt(o.before)}),o.after&&r.push({change:"added",after:ge(o.after),assertion:Qt(o.after)})}return r}var gd=e=>`${e.kind}\0${e.gherkin}`;function _t(e,t){let n=ta(e?.steps??[],t.steps,gd),r=[],s={added:0,removed:0,changed:0,assertions:0};for(let o of n){let a=o.after??o.before,i=ud(o.before?.actions??[],o.after?.actions??[]);for(let l of i)l.change==="added"?s.added++:l.change==="removed"?s.removed++:l.change==="changed"&&s.changed++,l.change!=="same"&&l.assertion&&s.assertions++;let c=e&&o.before?o.after?i.some(l=>l.change!=="same")?"changed":"same":"removed":"added";r.push({gherkin:a.gherkin,kind:a.kind,fromStepSet:a.fromStepSet,change:c,actions:i})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:r,summary:s}}var ea=new Set(["goto","click","press","newTab","switchTab","closeTab"]);function na(e,t){let n=[],r=s=>`${s} navigation action${s===1?"":"s"}`;for(let s=0;s<Math.min(e.length,t.length);s++){let o=e[s].actions.filter(p=>ea.has(p.action)),i=[...t[s].actions.filter(p=>ea.has(p.action))],c=[];for(let p of o){let g=i.findIndex(f=>Xr(f)===Xr(p));g>=0?i.splice(g,1):c.push(p)}let l=i;for(let p=c.length-1;p>=0;p--){let g=l.findIndex(f=>f.action===c[p].action);g>=0&&(l.splice(g,1),c.splice(p,1))}let d=`step ${s+1} ("${e[s].gherkin}")`;c.length>0&&n.push(`flow change: ${d} no longer performs ${r(c.length)} from the committed recording: ${c.map(ge).join("; ")}. A page or navigation step may have been removed or merged.`),l.length>0&&n.push(`flow change: ${d} now performs ${r(l.length)} the committed recording did not: ${l.map(ge).join("; ")}. A page or navigation step may have been added or reordered.`)}return n}function ra(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let a of e.steps){let i=a.change==="removed"?"- ":a.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${i}${a.kind==="assertion"?"[assertion] ":""}${a.gherkin}`+(a.fromStepSet?` (from step set "${a.fromStepSet}")`:""));for(let c of a.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let l of c.details??[])t.push(` ${l}`)}t.push("")}let{added:n,removed:r,changed:s,assertions:o}=e.summary;return t.push(n+r+s===0?"No action changed.":`${s} changed, ${n} added, ${r} removed.`+(o>0?` ${o} of them ${o===1?"is an assertion":"are assertions"}: read those first.`:"")),t.join(`
|
|
24
|
+
`)}var ss=e=>new Promise(t=>setTimeout(t,e)),ns=class{entries=[];seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(a=>{s.body=a.length>262144?a.slice(0,262144):a}).catch(()=>{})}},t.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function hd(e,t){if(!t)return e>=200&&e<300;let n=/^([1-5])xx$/i.exec(t.trim());if(n){let r=Number(n[1])*100;return e>=r&&e<r+100}return e===Number(t.trim())}function oa(e,t,n,r,s){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!hd(e.status,r)||s&&!s.test(e.body??""))}var md=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,aa=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]),yd=/(?<=(?:^|[^\\])(?:\\\\)*)(\\?)(<[^<>]+>|\{(?:env|data|date)[^{}]*\})/g;function rs(e,t,n,r=!1){return e.replace(yd,(s,o,a,i)=>{let c=e.slice(Math.max(0,i-2),i);if(a.startsWith("<")&&(c==="(?"||c==="\\k"))return s;let l=t(a);return l===a?s:(r?"":o)+n(l)})}function ia(e,t,n,r=!1,s=!0){let o=c=>qt(zt(Dr(et(c,n)))),a=[],i=c=>{if(t.role){let l=t.nameRegex?new RegExp(rs(t.nameRegex,o,X.pattern,!0),"i"):t.name?o(t.name):void 0,d=t.role;l===void 0&&!!(t.label||t.placeholder||t.text||t.testId||t.fallbackSelectors?.length)?t.text&&a.push(c.getByRole(d).filter({hasText:o(t.text)})):typeof l=="string"?(a.push(c.getByRole(d,{name:l,exact:!0})),s&&a.push(c.getByRole(d,{name:l,exact:!1}))):a.push(c.getByRole(d,{name:l,exact:!1}))}t.label&&a.push(c.getByLabel(o(t.label))),t.placeholder&&a.push(c.getByPlaceholder(o(t.placeholder))),t.text&&!t.role&&a.push(c.getByText(o(t.text))),t.testId&&a.push(c.getByTestId(o(t.testId)));for(let l of t.fallbackSelectors??[])a.push(c.locator(wt(l,(d,p)=>rs(d,o,X[p],p==="regex"),o)))};if(t.frame)i(e.frameLocator(o(t.frame)));else if(i(e),r)for(let c of e.frames())c!==e.mainFrame()&&i(c);if(a.length===0)throw new Error("Action target has neither role nor fallback selectors");return a}var la=100;async function wd(e,t,n,r){let s=Date.now()+t,o;for(;Date.now()<s;){try{if(await e())return}catch(a){o=a}await ss(r)}throw new Error(`Timed out waiting for ${n}${o?` (${String(o)})`:""}`)}async function Re(e,t,n=!1){let r;for(let s of e)try{await t(s.first());return}catch(o){if(r=o,n&&await s.first().count().catch(()=>0)>0)throw o}throw r}var ts=e=>e.replace(/\s+/g," ").trim(),en=new WeakMap;async function Bn(e,t,n,r,s,o=new Map,a,i=la){let c=(x,b,u)=>wd(x,b,u,i),l=en.get(e);if(l&&aa.has(t.action)&&t.action!=="uploadFile"){en.delete(e);let x,b=m=>{x??=m};e.on("filechooser",b);let u;try{if(u=await Bn(e,t,n,r,s,o,a,i),x||await e.waitForEvent("filechooser",{timeout:r}).then(b,()=>{}),!x)throw new Error(`uploadFile failed: the ${t.action} after it opened no file chooser within ${r} ms`);try{await x.setFiles(l,{timeout:r})}catch(m){throw new Error(`uploadFile failed: ${m instanceof Error?m.message:String(m)}`)}}catch(m){throw en.set(e,l),m}finally{e.off("filechooser",b)}return u}o.has(me)||o.set(me,st());let d=o.get(me),p=x=>typeof x=="string"?Et(x,d):Array.isArray(x)?x.map(p):x&&typeof x=="object"?Object.fromEntries(Object.entries(x).map(([b,u])=>[b,p(u)])):x;t=p(t),n=p(n);let g=x=>qt(zt(Dr(et(x,n)))),f=x=>rs(x,g,X.pattern,!0),y=t.value!==void 0?g(t.value):void 0,$=t.url!==void 0?g(t.url):void 0,T=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="expectEach"||t.action==="captureText",v=t.target?ia(e,t.target,n,T,!aa.has(t.action)):[],S={timeout:r},h={timeout:Math.min(1e3,r)},w=x=>async()=>{for(let b of v)try{if(await x(b.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!$)throw new Error("goto requires url");let x=Kt($);if(x)throw new Error(x);await e.goto(Lr($,s),S);return}case"click":return Re(v,x=>x.click(S),!0);case"fill":return Re(v,x=>x.fill(y??"",S),!0);case"press":return v.length>0?Re(v,x=>x.press(y??"",S),!0):e.keyboard.press(y??"");case"selectOption":return Re(v,async x=>{await x.selectOption(y??"",S)},!0);case"check":return Re(v,x=>x.check(S),!0);case"uncheck":return Re(v,x=>x.uncheck(S),!0);case"hover":return Re(v,x=>x.hover(S),!0);case"waitFor":return v.length>0?Re(v,x=>x.waitFor({state:"visible",timeout:r})):ss(Math.min(Number(y??"0"),r));case"expectVisible":return c(w(x=>x.isVisible()),r,"target to be visible");case"expectNotVisible":return c(async()=>{for(let x of v){let b=await x.count();for(let u=0;u<b;u++)if(await x.nth(u).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${v.length} locator(s))`);case"expectText":return c(w(async x=>(await x.textContent(h)??"").includes(y??"")),r,`text "${y}"`);case"expectEach":{let x=t.each?.match(/^\{data:([^}]+)\}$/)?.[1];if(!x)throw new Error(`expectEach needs a {data:...} list reference, got ${JSON.stringify(t.each)}`);let b=ze(x),u=async E=>await E.isVisible()?new Set(await E.evaluate(R=>{let _=I=>(I??"").replace(/\s+/g," ").trim(),A=I=>typeof I.checkVisibility=="function"?I.checkVisibility({visibilityProperty:!0,contentVisibilityAuto:!0}):!!(I.offsetWidth||I.offsetHeight||I.getClientRects().length),C=I=>{let U=I.ownerDocument.createTreeWalker(I,4),N="";for(let k=U.nextNode();k;k=U.nextNode())k.parentElement&&A(k.parentElement)&&(N+=k.nodeValue??"");return N},P=new Set,O=I=>{for(let U of Array.from(I.options))U.hidden||P.add(_(U.textContent))};R.tagName==="SELECT"&&O(R);for(let I of Array.from(R.querySelectorAll("*")))A(I)&&(I.tagName==="SELECT"?O(I):I.tagName!=="OPTION"&&P.add(_(C(I))));P.add(_(C(R)));for(let I of String(R.innerText??"").split(`
|
|
25
|
+
`))P.add(_(I));return[...P]},void 0,h)):new Set,m=b;return c(async()=>{for(let E of v)try{let R=await u(E.first()),_=b.filter(A=>!R.has(A.replace(/\s+/g," ").trim()));if(_.length<m.length&&(m=_),_.length===0)return!0}catch{}return!1},r,`every value of ${t.each} to be shown`).catch(()=>{throw new Error(`${m.map(E=>`"${E}"`).join(", ")} from ${t.each} ${m.length===1?"is":"are"} not shown in the target (${b.length-m.length} of ${b.length} found)`)})}case"expectValue":return c(w(async x=>await x.inputValue(h)===(y??"")),r,`input value "${y}"`);case"expectUrl":{let x=$??y??"";return c(async()=>e.url().includes(x),r,`URL containing "${x}" on the active page (${e.url()})`)}case"handleDialog":{let x=(y??"accept")!=="dismiss",b=t.promptText?g(t.promptText):void 0;e.once("dialog",u=>{x?u.accept(b):u.dismiss()});return}case"uploadFile":{let x=(y??"").split(`
|
|
26
|
+
`).filter(Boolean).map(u=>sa.isAbsolute(u)?u:sa.resolve(u));if(x.length===0)throw new Error("uploadFile requires a path");let b=zr(x,process.cwd());if(b)throw new Error(b);if(v.length>0)return Re(v,u=>u.setInputFiles(x,S),!0);en.set(e,x);return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let x=ia(e,t.toTarget,n,!1,!1);return Re(v,b=>b.dragTo(x[0].first(),S),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return c(w(async x=>{let b=ts(await x.textContent(h)??"");return b===""?!1:(o.set(t.saveAs,b),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let x=o.get(t.compareTo);if(x===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(v.length>0)return c(w(async m=>ts(await m.textContent(h)??"")!==x),r,`target text to differ from "${t.compareTo}" (${x})`);let b=y??"",u=o.get(b);if(u===void 0)throw new Error(`expectDiffers: no captured value named "${b}"`);if(u===x)throw new Error(`expectDiffers: "${b}" (${u}) equals "${t.compareTo}" (${x})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let x=new RegExp(f(t.pattern),"i");return c(w(async b=>x.test(await b.getAttribute(t.attribute,h)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let x=new RegExp(f(t.pattern),"i");return c(w(async b=>x.test(ts(await b.textContent(h)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let x=Number.parseInt(y??"",10),b=()=>e.context().pages().filter(m=>!m.isClosed());await c(async()=>b().length>x,r,`tab ${y} to exist (${b().length} open: ${b().map(m=>m.url()).join(", ")})`);let u=b()[x];return await u.bringToFront(),u}case"closeTab":{let x=e.context().pages().filter(m=>!m.isClosed()),b=y===void 0?e:x[Number.parseInt(y,10)];if(!b)throw new Error(`tab ${y} does not exist (${x.length} open)`);await b.close();let u=e.context().pages().filter(m=>!m.isClosed());if(u.length===0)throw new Error("closed the last tab; nothing left to drive");return u[u.length-1]}case"newTab":{let x=$?Kt($):void 0;if(x)throw new Error(x);let b=await e.context().newPage();return $&&await b.goto(Lr($,s),S),b}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let x=new RegExp(f(t.urlPattern)),b=t.pattern?new RegExp(f(t.pattern)):void 0;if(!a){await e.waitForResponse(async m=>{let E={seq:0,url:m.url(),method:m.request().method(),status:m.status()};return oa(E,x,t.method,t.status,void 0)?b?b.test(await m.text().catch(()=>"")):!0:!1},S);return}let u=()=>t.action==="expectResponse"?a.all():a.sincePreviousStep();return c(async()=>u().some(m=>oa(m,x,t.method,t.status,b)),r,md(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}var Un=(e,t)=>Wt(e.actions,t),ca=(e,t)=>St(e.actions,t);function os(e,t){if(t.steps.length!==e.steps.length)return!1;for(let[n,r]of Object.entries(t.data??{})){let s;try{s=yt(n)}catch{continue}if(s===r)continue;let o=t.steps.some(i=>Un(i,`{data:${n}}`)),a=t.steps.some(i=>ca(i,r));if(!o||a)return!1}return t.steps.every((n,r)=>{let s=e.steps[r];if(n.gherkin!==s.text||n.kind!==s.kind)return!1;let o=n.table,a=s.table;if(!!o!=!!a)return!1;if(o&&a){let i=Object.keys(ae(o)),c=Object.keys(ae(a));if(i.length>0&&c.length>0){if(JSON.stringify(i)!==JSON.stringify(c))return!1;let l=ae(o),d=ae(a);for(let p of i){if(l[p]===d[p])continue;let g=`<${p}>`,f=t.steps.some($=>Un($,g)),y=t.steps.some($=>ca($,l[p]));if(!f||y)return!1}}else if(JSON.stringify(o)!==JSON.stringify(a))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let i=t.steps.some(l=>Un(l,"<docstring>")),c=t.steps.some(l=>Un(l,n.docString));if(!i||c)return!1}return!0})}function as(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...qe(e.steps),...ae(r),...tt(n?.resolvedDocString??n?.docString)}}async function tn(e,t,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,a=Math.max(10,r.pollIntervalMs??la),i=[],c=r.vars??new Map;r.uniqueSeed&&c.set(me,r.uniqueSeed);for(let u of e.context().pages())en.delete(u);let l=()=>Object.fromEntries(c),d=n.steps.some(u=>u.actions.some(m=>(m.action==="waitForResponse"||m.action==="expectResponse")&&m.pattern!==void 0)),p=new ns;p.attach(e.context(),d);let g=!1,f=()=>(g=r.keepResponseLog===!0,g?{responses:p}:{}),y=e,$=new Set(e.context().pages()),T=[],v=u=>T.push(u);e.context().on("page",v);let S=u=>new Promise(m=>{let E=()=>{clearTimeout(R),e.context().off("page",E),m()},R=setTimeout(E,u);e.context().on("page",E)}),h=[],x=!n.steps.some(u=>u.actions.some(m=>m.opensTab))&&n.steps.some(u=>u.actions.some(m=>m.action==="switchTab"||m.action==="closeTab"||m.action==="newTab")),b=()=>{let u=e.context().pages();for(let m of u)$.has(m)||($.add(m),m.isClosed()||(y=m));if(y.isClosed()){let m=u.filter(E=>!E.isClosed());m.length>0&&(y=m[m.length-1])}};if(!os(t,n))return p.detach(),{status:"stale",stepResults:i,captured:l()};try{for(let u=0;u<n.steps.length;u++){let m=n.steps[u],E=as(t,u);p.markStep();let R=!1,_=0;r.trace&&await e.context().tracing.group(`[saffron:${r.tracePhase??"replay"}:${u+(r.traceStepOffset??0)}] ${m.keyword} ${m.gherkin}`).catch(()=>{});let A=async()=>{r.trace&&await e.context().tracing.groupEnd().catch(()=>{})};try{if(m.kind==="assertion"&&!m.actions.some(C=>ue.has(C.action))){let C=`Assertion step "${m.gherkin}" recorded ${m.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;i.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"failed",drift:R,error:C});for(let P=u+1;P<n.steps.length;P++)i.push({gherkin:n.steps[P].gherkin,kind:n.steps[P].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:u,actionIndex:0,stepText:m.gherkin,kind:m.kind,error:C},stepResults:i,captured:l(),...f()}}for(let C=0;C<m.actions.length;C++){let P=m.actions[C];b(),P.pageFingerprint&&await ot(y)!==P.pageFingerprint&&(R=!0);let O,I=!1;for(let U=0;U<=o&&!I;U++){U>0&&(_++,await ss(250*U),b());try{let N=U>0?`${ge(P)} (attempt ${U+1})`:ge(P),k=await Go(y,r.trace,`[saffron:action:${C}] ${N}`,()=>Bn(y,P,E,s,r.baseURL,c,p,a));k&&(y=k,$.add(k));let j=P.action==="click"||P.action==="press";if(T.length===0&&P.opensTab?await e.context().waitForEvent("page",{timeout:s}).catch(()=>{}):T.length===0&&j&&x&&await S(150),P.opensTab&&T.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for(T.length>0&&h.push({stepIndex:u,actionIndex:C});T.length>0;)await T.shift().waitForLoadState("domcontentloaded",{timeout:s}).catch(()=>{});I=!0}catch(N){O=N}}if(!I){let U=O instanceof Error?O.message:String(O);i.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"failed",drift:R,..._?{retries:_}:{},failedActionIndex:C,error:U});for(let k=u+1;k<n.steps.length;k++)i.push({gherkin:n.steps[k].gherkin,kind:n.steps[k].kind,status:"skipped"});let N={stepIndex:u,actionIndex:C,stepText:m.gherkin,kind:m.kind,error:U,pageUrl:y.isClosed()?void 0:y.url()};return await r.onFailure?.(y,N).catch(()=>{}),{status:"failed",failure:N,stepResults:i,captured:l(),tabOpeners:h,activePage:y.isClosed()?void 0:y,...f()}}}i.push({gherkin:m.gherkin,fromStepSet:m.fromStepSet,kind:m.kind,status:"passed",drift:R,..._?{retries:_}:{}})}finally{await A()}}return{status:"passed",stepResults:i,captured:l(),tabOpeners:h,activePage:y.isClosed()?void 0:y}}finally{e.context().off("page",v),g||p.detach()}}import{createHash as bd}from"node:crypto";import xd from"node:path";var is=e=>bd("sha1").update(JSON.stringify(e)).digest("hex").slice(0,16);function da(e){return{steps:e.steps.map(t=>({...t,actions:t.actions.map(({pageFingerprint:n,...r})=>r)})),data:e.data??null}}var pa=e=>is(e.map(t=>[t.kind,t.text,t.table??null,t.docString??null])),fa=e=>e?is(da(e)):"none",ua=(e,t)=>is({contract:da(e),featureEdits:t??null});function ga(e,t,n,r){return{scenario:pa(e.steps),committed:fa(t),recording:ua(n,r)}}function Me(e,t,n){let r=t.meta.bound;if(!r)return;let s=[],{feature:o,scenario:a}=t.cache,i;if(n&&!(n instanceof Map))i=n;else try{i=de(xd.join(e,o),e,n).find(l=>l.name===a)}catch(l){s.push(`${o} no longer parses: ${l instanceof Error?l.message.split(`
|
|
27
|
+
`)[0]:String(l)}`)}s.length||(i?pa(i.steps)!==r.scenario&&s.push(vd(i,t.cache)):s.push(`scenario "${a}" is no longer in ${o}`));let c;try{c=pe(K(e,o,a))}catch(l){if(!(l instanceof J))throw l;return s.push(`the committed recording cannot be read (${l.problem})`),s.join("; ")}return fa(c)!==r.committed&&s.push(r.committed==="none"?"a recording was committed for this scenario after the proposal was filed, so the diff you would review is not the one that was made":c?"the committed recording changed after the proposal was filed (an accepted heal, a propagated locator fix or a pull), so the diff you would review is not the one that was made":"the committed recording the proposal was diffed against is gone"),ua(t.cache,t.meta.featureEdits)!==r.recording&&s.push("the proposal's recording or feature edits were edited after it was filed"),s.length>0?s.join("; "):void 0}function vd(e,t){let n=e.steps,r=t.steps;if(n.length!==r.length)return`the scenario has ${n.length} steps now and had ${r.length} when the proposal was filed`;let s=n.findIndex((o,a)=>o.text!==r[a].gherkin||o.kind!==r[a].kind);return s<0?"the scenario's steps changed since the proposal was filed":`step ${s+1} now reads "${n[s].text}" and was "${r[s].gherkin}" when the proposal was filed`}var zn="not bound: filed by a runner older than 0.8.5, so changes since cannot be detected";import at from"node:fs";import ya from"node:path";function qn(e){return`$${e.toFixed(e>=1?2:4)}`}function cs(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function he(e){return e.costUsd>0||e.aiCalls===0}function ls(e){return he(e)?e.plan?` \xB7 5-hour window ${cs(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${qn(e.costUsd)} at API rates`:` \xB7 ${qn(e.costUsd)}`:" \xB7 cost not reported by the provider"}function ha(e,t){return e?t?`\u2248 ${qn(e)}`:qn(e):""}function ma(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}function ds(e){let t=e.costUsd===void 0?"":` \xB7 cost ${e.costUsd>=0?"+":"-"}$${Math.abs(e.costUsd).toFixed(4)}`;return`vs previous run: pass rate ${e.passRatePp>=0?"+":""}${e.passRatePp}pp${t} \xB7 AI calls ${e.aiCalls>=0?"+":""}${e.aiCalls}`}function fs(e){return ya.join(e,".saffron","history.jsonl")}function us(e,t,n,r){let s=e.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(a=>a.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(`
|
|
28
|
+
`)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,a)=>o+a.aiCalls,0),costUsd:s.reduce((o,a)=>o+a.costUsd,0),divergentSteps:n,scenarios:s}}function gs(e,t){let n=fs(e);at.mkdirSync(ya.dirname(n),{recursive:!0}),at.appendFileSync(n,`${JSON.stringify(t)}
|
|
29
|
+
`)}function wa(e,t){let n=fs(e);if(!at.existsSync(n))return gs(e,t);let r=at.readFileSync(n,"utf8").split(`
|
|
30
|
+
`).filter(a=>a.trim()),s=!1,o=r.map(a=>{try{if(JSON.parse(a).runId===t.runId)return s=!0,JSON.stringify(t)}catch{}return a});s||o.push(JSON.stringify(t)),at.writeFileSync(n,`${o.join(`
|
|
29
31
|
`)}
|
|
30
|
-
`)}function
|
|
31
|
-
`).filter(Boolean),s=[];for(let o of r.slice(-t))try{s.push(JSON.parse(o))}catch{}return s}var
|
|
32
|
-
`),R=new Map,_=0;
|
|
33
|
-
`)[0]:void 0,failedStep:(
|
|
34
|
-
`)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(o=>`${o.tag} (${o.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(o=>o.file).join(", ")}`:"no proposals pending review",...e.unreadableProposals.map(o=>`\u26A0 proposal ${o.file} cannot be read: ${o.problem}`),...e.features.flatMap(o=>o.scenarios.filter(a=>a.cacheProblem).map(a=>`\u26A0 recording of ${o.path} \u203A ${a.name} cannot be read: ${a.cacheProblem}`))),e.lastRun){let o=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${o.green} passed \xB7 ${o.yellow} pending review \xB7 ${o.red} failed \xB7
|
|
32
|
+
`)}function nn(e,t=50){let n=fs(e);if(!at.existsSync(n))return[];let r=at.readFileSync(n,"utf8").trim().split(`
|
|
33
|
+
`).filter(Boolean),s=[];for(let o of r.slice(-t))try{s.push(JSON.parse(o))}catch{}return s}var ps=e=>{let t=e.green+e.yellow+e.red;return t===0?0:Math.round((e.green+e.yellow)/t*100)};function hs(e,t){let n=[...e,t],r=n.slice(-20),s=n.slice(-10),o=e.at(-1),a=o?{passRatePp:ps(t)-ps(o),costUsd:he(t)&&he(o)?t.costUsd-o.costUsd:void 0,aiCalls:t.aiCalls-o.aiCalls,red:t.red-o.red}:void 0,i=new Map;for(let p of s)for(let g of p.scenarios){let f=`${g.feature}::${g.scenario}`,y=i.get(f)??{feature:g.feature,scenario:g.scenario,heals:0,reds:0,runs:0};y.runs++,g.source==="agent-heal"&&g.status==="yellow"&&y.heals++,g.status==="red"&&y.reds++,i.set(f,y)}let c=[...i.values()].filter(p=>p.heals>=2||p.reds>=3).sort((p,g)=>g.heals+g.reds-(p.heals+p.reds)),l=new Map;for(let p of r)for(let g of p.scenarios)g.status==="red"&&g.error&&l.set(g.error,(l.get(g.error)??0)+1);let d=[...l.entries()].sort((p,g)=>g[1]-p[1]).slice(0,3).map(([p,g])=>({message:p,count:g}));return{prev:a,series:{passRate:r.map(ps),costUsd:r.map(p=>he(p)?Number(p.costUsd.toFixed(4)):null),aiCalls:r.map(p=>p.aiCalls)},chronic:c,divergentSteps:t.divergentSteps,topIssues:d}}var Sd=/^\s*(?:Scenario(?: Outline| Template)?|Example):\s*(.+?)\s*$/,kd=/^\s*StepSet:/;function Pt(e,t){return Ee.relative(e,t).split(Ee.sep).join("/")}function $d(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function ba(e,t){return`${e} :: ${t}`}function Hn(e,t,n={}){let r=t.features??"features",s=Ee.isAbsolute(r)?r:Ee.join(e,r),o=Ee.join(e,".saffron","reports","latest.json"),a=Ee.join(e,".saffron","reports","latest.html"),i;try{i=JSON.parse(ye.readFileSync(o,"utf8"))}catch{i=void 0}let c=new Map;for(let b of i?.scenarios??[]){let u=b.scenario.replace(/ \(example \d+\)$/,""),m=ba(b.feature,u);c.set(m,$d(c.get(m),b.status))}let l=new Map,d;try{l=je(nt(s),e)}catch(b){d=b instanceof Error?b.message:String(b)}let p=ye.existsSync(s)?[...ye.readdirSync(s,{recursive:!0})].map(String).filter(Oe).map(b=>Ee.join(s,b)).sort():[],g=[],f=new Map,y=new Map,$={parsedFiles:new Set,owned:new Set,unreadable:d?[d]:[]};for(let b of p){let u=Pt(e,b),m="";try{m=ye.readFileSync(b,"utf8")}catch(I){g.push({path:u,name:u,scenarios:[],stepSets:0,error:String(I)}),$.unreadable.push(`${u}: ${String(I)}`);continue}let E=m.split(`
|
|
34
|
+
`),R=new Map,_=0;E.forEach((I,U)=>{let N=I.match(Sd);N&&!R.has(N[1])?R.set(N[1],U+1):kd.test(I)&&_++});let A=E.find(I=>/^\s*Feature:/.test(I)),C=A?A.replace(/^\s*Feature:\s*/,"").trim():u,P;try{P=de(b,e,l)}catch(I){g.push({path:u,name:C,scenarios:[],stepSets:_,error:d??(I instanceof Error?I.message:String(I))}),$.unreadable.push(`${u}: ${I instanceof Error?I.message:String(I)}`);continue}$.parsedFiles.add(b),y.set(b,P);let O=new Map;for(let I of P){$.owned.add(ke(e,I.featurePath,I.displayName));let U=O.get(I.name);if(U){U.rows+=1;continue}let N=K(e,I.featurePath,I.name);$.owned.add(N),$.owned.add(be(e,I.featurePath,I.name));let k="missing",j;if(ye.existsSync(N))try{let F=pe(N);k=F&&os(I,F)?"valid":"stale"}catch(F){k="invalid",j=F instanceof J?F.problem:F instanceof Error?F.message:String(F)}O.set(I.name,{name:I.name,line:R.get(I.name),tags:[...I.tags],outline:Object.keys(I.params).length>0,rows:1,cached:k!=="missing",cacheState:k,...j?{cacheProblem:j}:{},proposal:ye.existsSync(be(e,I.featurePath,I.name)),lastStatus:c.get(ba(I.featurePath,I.name))});for(let F of I.tags)f.set(F,(f.get(F)??0)+1)}g.push({path:u,name:C,scenarios:[...O.values()],stepSets:_})}let T=[],v=Ve(e,(b,u)=>T.push({file:Pt(e,b),problem:u})).map(({file:b,proposal:u,revision:m})=>({file:Pt(e,b),feature:u.cache.feature,scenario:u.cache.scenario,mode:u.meta.mode,createdAt:u.meta.createdAt,verified:u.meta.verified,proofError:u.meta.proofError,stale:u.meta.bound?Me(e,u,l):void 0,unbound:u.meta.bound?void 0:!0,revision:m,adaptations:u.meta.adaptations??[],narrative:u.meta.narrative,suggestedFeatureEdit:u.meta.suggestedFeatureEdit,recordedFor:u.meta.recordedFor,aiCalls:u.meta.usage?.aiCalls??0,costUsd:u.meta.usage&&he({costUsd:u.meta.usage.costUsd??0,aiCalls:u.meta.usage.aiCalls})?u.meta.usage.costUsd:void 0})).sort((b,u)=>b.file.localeCompare(u.file)),S;try{S=Te(e,r,l,y)}catch{S={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let h=Rd(e),w=nn(e,n.historyLimit??30).map(b=>({startedAt:b.startedAt,green:b.green,yellow:b.yellow,red:b.red,aiCalls:b.aiCalls,costUsd:he(b)?b.costUsd:void 0})),x=Ee.join(e,"saffron.config.json");return{tool:"saffron",version:He(),projectRoot:e,packageInstalled:ye.existsSync(Ee.join(e,"node_modules","saffron-ai")),config:{file:ye.existsSync(x)?"saffron.config.json":void 0,effective:$o(t)},features:g,tags:[...f.entries()].map(([b,u])=>({tag:b,scenarios:u})).sort((b,u)=>b.tag.localeCompare(u.tag)),proposals:v,unreadableProposals:T,lastRun:i?{startedAt:i.startedAt,finishedAt:i.finishedAt,totals:{...i.totals,costUsd:he(i.totals)?i.totals.costUsd:void 0},reportHtml:ye.existsSync(a)?Pt(e,a):void 0,attention:i.scenarios.filter(b=>b.status!=="green").map(b=>({feature:b.feature,scenario:b.scenario,baseScenario:b.scenario.replace(/ \(example \d+\)$/,""),status:b.status,error:b.status==="red"?b.error?.split(`
|
|
35
|
+
`)[0]:void 0,failedStep:(b.steps??[]).find(u=>u.status==="failed")?.gherkin??b.evidence?.find(u=>u.step)?.step,evidence:(b.evidence??[]).filter(u=>u.kind==="trace"?ye.existsSync(Ee.join(e,u.file)):Tt(e,u)!==void 0).map(u=>({kind:u.kind,file:u.file,...u.step?{step:u.step}:{},...u.sha256?{sha256:u.sha256}:{}}))})).sort((b,u)=>b.status===u.status?0:b.status==="red"?-1:1)}:void 0,history:w,vocabulary:{steps:S.steps.length,recorded:S.steps.filter(b=>b.status==="recorded").length,unrecorded:S.steps.filter(b=>b.status==="unrecorded").length,stepSets:S.stepSets.length,divergent:S.steps.filter(b=>b.status==="divergent").map(b=>b.text),duplicateWordings:h.slice(0,50)},data:(()=>{let b=Be(),u=[],m=new Set;for(let[E,R]of y)for(let _ of R)for(let A of Ze(ne(_)))if(!m.has(A)){m.add(A);try{wn(A)}catch(C){u.push({ref:A,reason:C instanceof Error?C.message:String(C),feature:Pt(e,E),scenario:_.name})}}return{dir:Pt(e,b.dir),env:b.env,files:ye.existsSync(b.dir)?ye.readdirSync(b.dir).filter(E=>/\.(json|csv)$/.test(E)).sort():[],unresolved:u}})(),orphans:(()=>{let b=Ln(e,r,$);return b.unreadable.length?[]:b.orphans})()}}function va(e){let t=e.features.reduce((o,a)=>o+a.scenarios.length,0),n=e.features.reduce((o,a)=>o+a.scenarios.filter(i=>i.cacheState==="valid").length,0),r=e.features.reduce((o,a)=>o+a.scenarios.filter(i=>i.cacheState==="stale"||i.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let o of e.features){let a=o.scenarios.map(i=>i.proposal?"\u25D0":i.cacheState==="valid"?"\u25CF":i.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${o.path} ${a}${o.error?` \u2717 ${o.error.split(`
|
|
36
|
+
`)[0]}`:""}`)}if(e.tags.length&&console.log(`tags: ${e.tags.map(o=>`${o.tag} (${o.scenarios})`).join(", ")}`),console.log(e.proposals.length?`${e.proposals.length} proposal(s) pending review: ${e.proposals.map(o=>o.file).join(", ")}`:"no proposals pending review",...e.unreadableProposals.map(o=>`\u26A0 proposal ${o.file} cannot be read: ${o.problem}`),...e.features.flatMap(o=>o.scenarios.filter(a=>a.cacheProblem).map(a=>`\u26A0 recording of ${o.path} \u203A ${a.name} cannot be read: ${a.cacheProblem}`))),e.lastRun){let o=e.lastRun.totals;console.log(`last run ${e.lastRun.finishedAt}: ${o.green} passed \xB7 ${o.yellow} pending review \xB7 ${o.red} failed \xB7 ${o.costUsd===void 0?"cost not reported":`$${o.costUsd.toFixed(2)}`}`)}let s=e.vocabulary;console.log(`vocabulary: ${s.steps} steps (${s.recorded} recorded, ${s.unrecorded} unrecorded), ${s.stepSets} step sets, ${s.divergent.length} divergent, ${s.duplicateWordings.length} duplicate wording group(s)`),e.orphans.length&&console.log(`${e.orphans.length} recording(s) belong to no scenario: run \`saffron prune\` to see them`),(e.data.files.length||e.data.unresolved.length)&&console.log(`test data: ${e.data.files.length} file(s) in ${e.data.dir}/${e.data.env?` \xB7 environment "${e.data.env}"`:""}`+(e.data.unresolved.length?` \xB7 ${e.data.unresolved.length} reference(s) do not resolve: ${e.data.unresolved.map(o=>`{data:${o.ref}}`).join(", ")}`:"")),console.log(`config: ${e.config.file??"(defaults, no saffron.config.json)"}`)}var Td=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function xa(e){return e?e.role?`${e.role}${e.name?` "${e.name}"`:e.nameRegex?` /${e.nameRegex}/`:""}`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testid ${e.testId}`:e.placeholder?`placeholder "${e.placeholder}"`:e.fallbackSelectors?.length?e.fallbackSelectors[0]:"":""}function Rd(e){let t;try{t=$e(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let s=An(r.gherkin).pattern,o=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(p=>p[1]),a=p=>p!==void 0&&(o.includes(p)||Td.test(p))?"\xABarg\xBB":p,i=r.actions.map(p=>{let{pageFingerprint:g,value:f,promptText:y,...$}=p;return{...$,value:a(f),promptText:a(y)}}),c=JSON.stringify(i),l=r.actions.map(p=>`${p.action}${xa(p.target)?` ${xa(p.target)}`:p.url?` ${p.url}`:""}`).join(" \xB7 "),d=n.get(c)??{steps:new Map,actions:l};d.steps.has(s)||d.steps.set(s,r.gherkin),n.set(c,d)}return[...n.values()].filter(r=>r.steps.size>1).map(r=>({steps:[...r.steps.values()].sort(),actions:r.actions})).sort((r,s)=>s.steps.length-r.steps.length||r.steps[0].localeCompare(s.steps[0]))}async function Sa(e,t){let n=new Ed({name:"saffron",version:He()},{capabilities:{tools:{}}});n.setRequestHandler(Pd,async()=>({tools:[{name:"search_steps",description:"Search the project's step vocabulary. Returns known Gherkin steps with status (recorded = replays at zero tokens, divergent, unrecorded), usage counts, and source files. ALWAYS reuse an existing wording exactly instead of inventing a near-duplicate: exact step text is the replay-cache identity, so a new wording costs a fresh AI recording.",inputSchema:{type:"object",properties:{query:{type:"string",description:"Case-insensitive substring filter over step text; omit to list everything."}}}},{name:"list_step_sets",description:"List the project's StepSet definitions (named reusable step sequences from .saffron files) with their steps, defining file, and usage. Invoke one inside a scenario with a line reading exactly: StepSet <name>",inputSchema:{type:"object",properties:{}}},{name:"project_status",description:"The project overview (same data as `saffron status --json`): every feature file with its scenarios (header line, tags, outline rows, cached / proposal pending / last run status), the tag list, pending proposals with narrative and proof-replay verdict, the last run's totals, run history, vocabulary health (divergent steps, duplicate wordings proven by identical recordings) and the effective config. Call it before editing tests to see what is already recorded and what awaits review.",inputSchema:{type:"object",properties:{}}}]})),n.setRequestHandler(_d,async r=>Id(r.params.name,r.params.arguments,e,t)),await n.connect(new Ad)}function Id(e,t,n,r){let s=o=>({content:[{type:"text",text:JSON.stringify(o,null,2)}]});if(e==="search_steps"){let o=Te(n,r),a=typeof t?.query=="string"?t.query:void 0,i=a?_n(o,a):o;return s({steps:i.steps,scannedFiles:i.scannedFiles,scannedCaches:i.scannedCaches})}if(e==="list_step_sets"){let o=Te(n,r);return s({stepSets:o.stepSets})}return e==="project_status"?s(Hn(n,ie(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as Cd}from"@anthropic-ai/claude-agent-sdk";function ka(e,t){let n=t.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
|
|
35
37
|
`),r=t.stepSets.map(s=>`- StepSet ${s.name}
|
|
36
38
|
${s.steps.map(o=>` ${o}`).join(`
|
|
37
39
|
`)}`).join(`
|
|
38
40
|
`);return["Draft a Saffron feature file from the requirements below.","","EXISTING STEP VOCABULARY \u2014 reuse these wordings EXACTLY (character for character) wherever they express what a step needs to do. Steps marked [recorded] or [divergent] already have replay recordings: reusing them makes the new file replay almost for free. Only invent a new wording when nothing in the vocabulary expresses the intent, and then follow the same style:",n.length>0?n:"(none yet \u2014 this is the project's first file)","",t.stepSets.length>0?`EXISTING STEP SETS \u2014 invoke with a line reading exactly \`StepSet <name>\` instead of repeating their steps:
|
|
39
41
|
${r}`:void 0,"","RULES:","1. Output ONLY the feature file content \u2014 no prose, no code fences, no explanations.","2. Gherkin/.saffron syntax: Feature:, Scenario:, Scenario Outline: + Examples, Given/When/Then/And/But. Prefer concrete scenarios; use an Outline only when the requirements list variations of the same flow.","3. Then steps are assertions \u2014 state exactly what must be true, one condition per step. End every scenario with at least one Then.","4. If the same 3+ step sequence would appear in two or more scenarios, define it once as a StepSet (first step a guard, last step an exit assertion) and invoke it. Definition uses a colon (StepSet: Name), invocation does not (StepSet Name).",'5. Use 2-column data tables for key/value input, header tables for lists of records, and """ doc strings for free text.',"6. Steps are natural language for an AI browser agent \u2014 never CSS selectors or code.","","REQUIREMENTS:",e.trim()].filter(s=>s!==void 0).join(`
|
|
40
|
-
`)}async function
|
|
41
|
-
|
|
42
|
-
`),
|
|
43
|
-
`);return{contents:{kind:
|
|
42
|
+
`)}var $a="You are Saffron's feature-file author. You write clean, assertive Gherkin that reuses the project's exact step vocabulary. You output only file content.";async function Ra(e,t,n,r){let s={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},o="";if(r)return await r({systemPrompt:$a,prompt:ka(e,t),model:n,maxTurns:1,mcpServers:{},mcpToken:"",usage:s,lastOutput:i=>{o=i},finished:()=>!1}),{content:Ta(o),usage:s};let a=Cd({prompt:ka(e,t),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],strictMcpConfig:!0,settings:{disableClaudeAiConnectors:!0},allowedTools:[],systemPrompt:$a}});for await(let i of a)if(i.type==="assistant")for(let c of i.message.content)c.type==="text"&&(o+=c.text);else i.type==="result"&&(s.aiCalls=i.num_turns,s.costUsd=i.total_cost_usd,"usage"in i&&i.usage&&(s.inputTokens=i.usage.input_tokens??0,s.outputTokens=i.usage.output_tokens??0,s.cacheReadTokens=i.usage.cache_read_input_tokens??0,s.cacheCreationTokens=i.usage.cache_creation_input_tokens??0));return{content:Ta(o),usage:s}}function Ta(e){return e.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim()+`
|
|
43
|
+
`}import ve from"node:fs";import{fileURLToPath as it,pathToFileURL as ms}from"node:url";import le from"node:path";import{createConnection as jd,ProposedFeatures as Od,TextDocuments as Fd,TextDocumentSyncKind as Nd,DidChangeWatchedFilesNotification as Md,WatchKind as Dd,CompletionItemKind as Ge,InsertTextFormat as Ea,DiagnosticSeverity as sn,MarkupKind as Aa}from"vscode-languageserver/node";import{TextDocument as Ld}from"vscode-languageserver-textdocument";var _a=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,Ud=/^\s*StepSet:\s*(.+?)\s*$/,ys=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,ws={recorded:"\u25CF recorded \u2014 replays at zero tokens",divergent:"\u25CF divergent \u2014 same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function Bd(e,t){let n=a=>new Set(a.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),s=n(t);if(r.size===0||s.size===0)return 0;let o=0;for(let a of r)s.has(a)&&o++;return o/(r.size+s.size-o)}function Pa(e){try{return JSON.parse(ve.readFileSync(le.join(e,"saffron.config.json"),"utf8"))}catch{return{}}}function Ia(e){try{let t=JSON.parse(ve.readFileSync(le.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function rn(e,t){let n=le.dirname(e),r=le.resolve(t);for(;;){if(ve.existsSync(le.join(n,"saffron.config.json")))return n;if(n===r||le.dirname(n)===n)return t;n=le.dirname(n)}}function Fa(e,t,n={}){let r=e,s=t,o=e,a=!1,i=u=>{if(!u.startsWith("file:"))return;let m=rn(it(u),o);if(m===r)return;v(m);let E=$.get(m);if(!E||!a||T.has(m))return h();({vocab:g,registry:f,files:y}=E);for(let R of p.all())l(R)&&b(R)},c=u=>{if(!u.startsWith("file:"))return;let m=it(u),E=rn(m,o);$.get(E)?.files.has(m)||T.add(E)},l=u=>!u.uri.startsWith("file:")||rn(it(u.uri),o)===r,d=jd(Od.all,process.stdin,process.stdout),p=new Fd(Ld),g={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},f=new Map,y=new Set,$=new Map,T=new Set,v=u=>{r=u,s=Ia(u)??(ve.existsSync(le.join(u,"saffron.config.json"))?"features":t);try{let m=Pa(u);Ke(u,m.dataDir,process.env.SAFFRON_ENV??m.env)}catch(m){d.console.warn(`saffron vocabulary rebuild: ${String(m)}`)}},S,h=()=>{try{let u=le.isAbsolute(s)?s:le.join(r,s),m=Pa(r);Ke(r,m.dataDir,process.env.SAFFRON_ENV??m.env),f=je(nt(u),r),g=Te(r,s,f),y=new Set(Ir(u)),$.set(r,{vocab:g,registry:f,files:y}),T.delete(r)}catch(u){d.console.warn(`saffron vocabulary rebuild: ${String(u)}`),{vocab:g,registry:f,files:y}=$.get(r)??{vocab:{steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},registry:new Map,files:new Set}}for(let u of p.all())l(u)&&b(u)},w=()=>{clearTimeout(S),S=setTimeout(()=>{let u=r,m=new Set(p.all().filter(E=>E.uri.startsWith("file:")).map(E=>rn(it(E.uri),o)));for(let E of $.keys())!m.has(E)&&E!==u&&$.delete(E);m.delete(u);for(let E of[...m,u])v(E),h()},300)};function x(u){return g.steps.find(m=>m.text===u)?.status}function b(u){let m=u.getText().split(`
|
|
44
|
+
`),E=Hd(m);if(!u.uri.endsWith(".saffron")){d.sendDiagnostics({uri:u.uri,diagnostics:E});return}let R=new Map;for(let _=0;_<m.length;_++){let A=m[_],C={start:{line:_,character:0},end:{line:_,character:A.length}},P=A.match(ys);P&&!f.has(P[3])&&E.push({range:C,severity:sn.Error,source:"saffron",message:`StepSet "${P[3]}" is not defined anywhere in the project.`});let O=A.match(Ud);if(O){let N=f.get(O[1]),k=le.relative(r,it(u.uri)).replace(/\\/g,"/");(R.has(O[1])||N&&N.file.replace(/\\/g,"/")!==k)&&E.push({range:C,severity:sn.Error,source:"saffron",message:`StepSet "${O[1]}" is defined twice \u2014 set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${O[1]}".`}),R.set(O[1],_)}let I=A.trim();if(I&&!P&&!O){for(let[N]of f)if(I.toLowerCase()===N.toLowerCase()){E.push({range:C,severity:sn.Warning,source:"saffron",message:`Did you mean "StepSet ${N}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let U=A.match(_a);if(U&&x(U[3])!=="recorded"){for(let N of g.steps)if(!(N.text===U[3]||N.status==="unrecorded")&&Bd(N.text,U[3])>=.7){E.push({range:{start:{line:_,character:A.indexOf(U[3])},end:{line:_,character:A.length}},severity:sn.Information,source:"saffron",message:`Similar to the ${N.status} step "${N.text}" (\xD7${N.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}d.sendDiagnostics({uri:u.uri,diagnostics:E})}d.onInitialize(u=>{if(n.rootFromClient){let m=u.workspaceFolders?.[0]?.uri??u.rootUri??void 0;if(m&&m.startsWith("file:")){let E=it(m);ve.existsSync(E)&&(o=r=E,s=Ia(E)??t)}}return a=u.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration===!0,h(),{capabilities:{textDocumentSync:Nd.Incremental,completionProvider:{triggerCharacters:[" ",":","."]},definitionProvider:!0,hoverProvider:!0}}}),d.onInitialized(()=>{if(!a)return;let u=[...["**/*.saffron","**/*.feature","**/saffron.config.json","**/.saffron/cache/**","**/data/**"].map(m=>({globPattern:m})),{globPattern:"**",kind:Dd.Delete}];d.client.register(Md.type,{watchers:u}).catch(()=>{a=!1})}),d.onCompletion(u=>{let m=p.get(u.textDocument.uri);if(!m)return[];i(m.uri);let E=m.getText({start:{line:u.position.line,character:0},end:u.position}),R=Wd(E);return R||Vd(m.uri,E,u.position.line,g)}),d.onDefinition(u=>{let m=p.get(u.textDocument.uri);if(!m)return;i(m.uri);let E=m.getText({start:{line:u.position.line,character:0},end:{line:u.position.line+1,character:0}}).replace(/\n$/,""),R=Ca(E,u.position.character);if(R){let O=qd(R.ref);return O?{uri:ms(O.file).href,range:{start:{line:O.line,character:0},end:{line:O.line,character:0}}}:void 0}let _=ja(E,u.position.character);if(_){let O=Oa(r,_.name);return O?{uri:ms(O.file).href,range:{start:{line:O.line,character:0},end:{line:O.line,character:0}}}:void 0}let A=E.match(ys);if(!A)return;let C=f.get(A[3]);return C?{uri:ms(le.join(r,C.file)).href,range:{start:{line:C.line-1,character:0},end:{line:C.line-1,character:0}}}:void 0}),d.onHover(u=>{let m=p.get(u.textDocument.uri);if(!m)return;i(m.uri);{let A=m.getText({start:{line:u.position.line,character:0},end:{line:u.position.line+1,character:0}}).replace(/\n$/,""),C=Ca(A,u.position.character);if(C)return{contents:{kind:"markdown",value:Na(C.ref)}};let P=ja(A,u.position.character);if(P){let O=Oa(r,P.name),I=O?`defined in ${le.relative(r,O.file)} (line ${O.line+1}); the value is resolved at replay and never shown here`:"not found in .env or .env.example; export it in the shell or add it to a git-ignored .env";return{contents:{kind:"markdown",value:`**{env:${P.name}}** ${I}.`}}}}let E=m.getText({start:{line:u.position.line,character:0},end:{line:u.position.line+1,character:0}}).replace(/\n$/,""),R=E.match(ys);if(R){let A=g.stepSets.find(P=>P.name===R[3]);if(!A)return;let C=A.steps.map(P=>`- ${ws[x(P)??"unrecorded"][0]} ${P}`).join(`
|
|
45
|
+
`);return{contents:{kind:Aa.Markdown,value:`**StepSet ${A.name}** \u2014 expands to:
|
|
44
46
|
|
|
45
|
-
${
|
|
47
|
+
${C}`}}}let _=E.match(_a);if(_){let A=g.steps.find(C=>C.text===_[3]);return A?{contents:{kind:Aa.Markdown,value:`${ws[A.status]}
|
|
46
48
|
|
|
47
|
-
Used in ${
|
|
49
|
+
Used in ${A.usage} place(s): ${A.files.join(", ")}${A.fromStepSet?`
|
|
48
50
|
|
|
49
|
-
Defined in StepSet **${
|
|
50
|
-
`),
|
|
51
|
-
`).findIndex(a=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(a));if(o!==-1)return{file:r,line:o}}}function
|
|
52
|
-
`)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((i,c)=>({label:i.text,kind:
|
|
51
|
+
Defined in StepSet **${A.fromStepSet}**`:""}`}}:void 0}}),p.onDidOpen(u=>{if(c(u.document.uri),u.document.uri.startsWith("file:")){let m=it(u.document.uri);rn(m,o)===r&&!y.has(m)&&w()}i(u.document.uri),b(u.document)}),p.onDidClose(u=>c(u.document.uri)),p.onDidChangeContent(u=>{i(u.document.uri),b(u.document)}),p.onDidSave(()=>w()),d.onDidChangeWatchedFiles(()=>w()),p.listen(d),d.listen()}function Ca(e,t){let n=/\{data:([A-Za-z0-9_.-]+)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{ref:r[1],start:r.index,end:r.index+r[0].length}}var zd=e=>/^\s*Examples:/.test(e);function Na(e,t=!1){let{env:n}=Be(),r=n?` (environment "${n}")`:"";try{if(t||!e.includes(".")){let{headers:a,rows:i}=bn(e);return`**{data:${e}}** ${i.length} Examples row(s), columns ${a.map(c=>`\`${c}\``).join(", ")}${r}.`}let s=ze(e);return Array.isArray(Ye(e))?`**{data:${e}}** a list of ${s.length}: ${s.map(a=>`"${a}"`).join(", ")}${r}. Use it with "every": replay checks each value, so adding one needs no re-record.`:`**{data:${e}}** = "${s[0]}"${r}. Resolved at replay: change the file and the next run uses the new value at zero tokens.`}catch(s){return`**{data:${e}}** does not resolve: ${s instanceof Error?s.message:String(s)}`}}function qd(e){let{dir:t,env:n}=Be(),[r,...s]=e.split("."),o=[n&&`${r}.${n}.json`,`${r}.json`,n&&`${r}.${n}.csv`,`${r}.csv`].filter(Boolean),a=[...s].reverse().find(c=>!/^\d+$/.test(c)),i;for(let c of o){let l=le.join(t,c);if(!ve.existsSync(l))continue;let d=ve.readFileSync(l,"utf8").split(`
|
|
52
|
+
`),p=a?d.findIndex(g=>g.includes(`"${a}"`)):0;if(p>=0)return{file:l,line:p};i??={file:l,line:0}}return i}function Hd(e){let t=[];return e.forEach((n,r)=>{for(let s of n.matchAll(/\{data:([A-Za-z0-9_.-]+)\}/g)){let o=Na(s[1],zd(n));o.includes("does not resolve")&&t.push({range:{start:{line:r,character:s.index??0},end:{line:r,character:(s.index??0)+s[0].length}},severity:sn.Error,source:"saffron",message:o.replace(/\*\*/g,"")})}}),t}function Wd(e){let t=e.match(/\{data:([A-Za-z0-9_.-]*)$/);if(!t)return;let n=t[1],{dir:r}=Be(),s=n.split(".");if(s.length===1)return(ve.existsSync(r)?[...new Set(ve.readdirSync(r).filter(a=>/\.(json|csv)$/.test(a)).map(a=>a.split(".")[0]))].sort():[]).map(a=>({label:a,kind:Ge.File,detail:"data file",insertText:a}));try{let o=Ye(s.slice(0,-1).join("."));return o===null||typeof o!="object"?[]:Object.entries(o).map(([a,i])=>({label:a,kind:i!==null&&typeof i=="object"?Ge.Module:Ge.Value,detail:Array.isArray(i)?`list of ${i.length}`:i!==null&&typeof i=="object"?"object":JSON.stringify(i),insertText:a}))}catch{return[]}}function ja(e,t){let n=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g,r;for(;r=n.exec(e);)if(t>=r.index&&t<=r.index+r[0].length)return{name:r[1],start:r.index,end:r.index+r[0].length}}function Oa(e,t){for(let n of[".env",".env.example"]){let r=le.join(e,n);if(!ve.existsSync(r))continue;let o=ve.readFileSync(r,"utf8").split(`
|
|
53
|
+
`).findIndex(a=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(a));if(o!==-1)return{file:r,line:o}}}function Vd(e,t,n,r){let s=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&s)return r.stepSets.map(i=>({label:i.name,kind:Ge.Function,detail:`step set \xB7 ${i.steps.length} step(s) \xB7 \xD7${i.usage}`,documentation:i.steps.join(`
|
|
54
|
+
`)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((i,c)=>({label:i.text,kind:Ge.Text,detail:`${ws[i.status]} \xB7 \xD7${i.usage}`,sortText:String(c).padStart(5,"0"),filterText:i.text}));let a=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(a.length>0&&("stepset:".startsWith(a)||a.startsWith("stepset"))&&s){let i={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:Ge.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
|
|
53
55
|
Given \u2026
|
|
54
56
|
When \u2026
|
|
55
57
|
|
|
56
|
-
Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:
|
|
57
|
-
`),filterText:`step set ${l.name}`,sortText:`1${l.name}`,textEdit:{range:i,newText:`StepSet ${l.name}`}});return c}return[]}import
|
|
58
|
-
`)
|
|
59
|
-
`)}}}function
|
|
60
|
-
`)
|
|
61
|
-
|
|
62
|
-
`)
|
|
63
|
-
`)[0]})`:"")+"
|
|
64
|
-
`)[0]}
|
|
65
|
-
`)[0]}
|
|
66
|
-
`);if(!A.includes(aa))throw new Error(A.replace(/^### Error\s*/,"").trim()||"the browser backend did not start")}catch(g){throw await Promise.allSettled([k.close(),x?.close()]),y(!1),g}let w=new Fl({name:"playwright",version:"1"},{capabilities:{tools:{}}});w.server.setRequestHandler(Dl,()=>k.listTools());let v=Promise.resolve(),u=(g,S,T=t())=>{let A=v.then(()=>Mr(e,T,g,S));return v=A.catch(()=>{}),A};w.server.setRequestHandler(Ml,(g,S)=>u(ql(g.params.name,g.params.arguments),()=>k.callTool(g.params,void 0,{timeout:Ll,signal:S.signal})));let p=async()=>{let g=[...f];if(g.length===0)return;let S=Promise.all(g.map(A=>A.path().then(()=>!0,()=>!0))),T=!await Promise.race([S,An(Bl).then(()=>!1)]);T&&Nr++;try{T&&(await Promise.allSettled(g.map(A=>A.cancel())),await Promise.allSettled(g.map(A=>A.failure()))),await An(0),await An(0)}finally{T&&Nr--}};return{config:{type:"sdk",name:"playwright",instance:w},close:async()=>{await Promise.race([v,An(Ul)]),await p().catch(()=>{}),await Promise.allSettled([k.close()]),await Promise.allSettled([x.close()]),await Promise.allSettled([w.close()]),y(!0)},grouped:(g,S)=>u(g,S)}}import{createRequire as Zl}from"node:module";import{query as Xl}from"@anthropic-ai/claude-agent-sdk";import{createSdkMcpServer as Hl,tool as bt}from"@anthropic-ai/claude-agent-sdk";import{z as B}from"zod";var te=e=>({content:[{type:"text",text:e}]});function la(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var Wl=new Set(["main","banner","contentinfo","status","alert","alertdialog","dialog","search"]);function da(e){if(!e?.role)return;let{role:t,frame:n,...r}=e;if(!(Object.keys(r).length>0||Wl.has(t)))return`ERROR: role="${t}" alone matches every ${t} on the page, and replay would use the first one. Add a name (or nameRegex), text, label or selector that picks exactly the element you checked, then call again.`}function Vl(e,t){return t.index>=e.steps.length?`ERROR: there is no step ${t.index}: this scenario's steps are numbered from 0 to ${e.steps.length-1}. Call saffron_step again with the zero-based index of the step you are about to perform.`:(e.announce(t.index,t.text,t.adaptation),t.index<e.firstStep?`Step ${t.index} already ran before you took over and keeps its recording; nothing you do under it is recorded. Do not redo it: continue from step ${e.firstStep} on the page as it is.`:`Recording step ${t.index}: ${t.text}`)}var pa={role:B.string().optional().describe("ARIA role of the element"),name:B.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:B.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:B.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:B.string().optional().describe("getByText locator text"),selector:B.string().optional().describe("CSS fallback selector"),frame:B.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 fa(e){return Hl({name:"saffron",version:"0.1.0",tools:[bt("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:B.number().int().min(0),text:B.string(),adaptation:B.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence: the kind of change (a locator or label change on the same page, a page removed or merged, a page added or flow reordered) and the page it happened on.")},async t=>te(Vl(e,t))),bt("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:B.string().describe("Name to store the captured text under"),...pa},async t=>{let n=la(t);if(!n)return te("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).");let r=da(n);return r?te(r):(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),te(`Capture recorded as "${t.saveAs}".`))}),bt("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:B.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:B.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),te(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),bt("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:B.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:B.string().optional().describe("HTTP method to match (any if omitted)"),status:B.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:B.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),te("Network wait recorded."))),bt("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:B.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...pa,expected:B.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:B.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:B.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:B.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:B.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:B.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:B.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:B.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:B.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:B.string().optional().describe("kind=each: the data-list token exactly as the step writes it, e.g. '{data:enums.OrderStatus}'. The target is the CONTAINER (dropdown, table, list) that must show every value of that list. Never pass the values themselves."),compareTo:B.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=la(t),r=da(n);if(r)return te(r);let s;switch(t.kind){case"visible":s={action:"expectVisible",target:n};break;case"notVisible":s={action:"expectNotVisible",target:n};break;case"text":s={action:"expectText",target:n,value:t.expected??""};break;case"value":s={action:"expectValue",target:n,value:t.expected??""};break;case"url":s={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return te("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return te("ERROR: kind=differs needs either capture (a stored name) or a target element.");s={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return te("ERROR: kind=matches requires pattern.");s={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return te("ERROR: kind=attribute requires attribute and pattern.");s={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"each":if(!t.each||!/^\{data:[^}]+\}$/.test(t.each))return te("ERROR: kind=each requires each='{data:file.list}', the token exactly as the step writes it (not the values).");s={action:"expectEach",target:n,each:t.each};break;case"response":if(!t.urlPattern)return te("ERROR: kind=response requires urlPattern.");s={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?te("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([s]),te("Assertion recorded."))}),bt("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:B.boolean(),narrative:B.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did. After a heal, say what kind of change the app went through (a locator change, or a page removed, merged, added or reordered) and on which page."),suggestedFeatureEdit:B.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:B.array(B.object({index:B.number().int().min(0).describe("zero-based step index"),text:B.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async t=>(e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],te("Run finalized.")))]})}function ga(e={}){let t=e.deliberateSnapshots?`
|
|
58
|
+
Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:Ea.Snippet,textEdit:{range:i,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:Ge.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:Ea.Snippet,textEdit:{range:i,newText:"StepSet ${1:Name}"}});for(let l of r.stepSets)c.push({label:`StepSet ${l.name}`,kind:Ge.Function,detail:`invoke step set \xB7 ${l.steps.length} step(s)`,documentation:l.steps.join(`
|
|
59
|
+
`),filterText:`step set ${l.name}`,sortText:`1${l.name}`,textEdit:{range:i,newText:`StepSet ${l.name}`}});return c}return[]}import It from"node:fs";import rp from"node:net";import fe from"node:path";import{chromium as sp,firefox as op,webkit as ap}from"playwright";var Gd=5e3,Jd=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]);function Ua(e){return e.mode==="heal"||e.mode==="refine"}function Ba(e){return e.mode==="heal"||e.mode==="refine"?e.failedStep?.stepIndex??e.completedSteps?.length??0:e.recordOnly?e.recordOnly.from:e.completedSteps?.length??0}var Ma=e=>`${e} action${e===1?"":"s"}`,Da=e=>e.map(ge).join("; "),Wn=class{firstStep;discardBeforeAnnounce;preAnnounce=[];discarded=[];carried;pendingTabOpen=!1;pendingTabFallback;lastOpener;lastOpenerAt=0;inFlight=new Set;steps;currentStepIndex=-1;done=!1;success=!1;narrative="";adaptations=[];suggestedFeatureEdit;featureEdits=[];announced=new Set;pendingFingerprint;constructor(t,n={}){this.firstStep=Math.max(n.firstStep??0,0),this.discardBeforeAnnounce=n.discardBeforeAnnounce??!1,this.steps=t.steps.map(r=>({gherkin:r.text,keyword:r.keyword,kind:r.kind,table:r.table,docString:r.docString,fromStepSet:r.source?.fromStepSet,actions:[]}))}addArmedAction(t){if(this.done)return;let n=this.destination();if(!n){this.discard([t],this.refusalReason());return}if(n.some(o=>o.action===t.action&&o.value===t.value&&o.promptText===t.promptText))return;let s=Math.max(n.length-1,0);n.splice(s,0,t)}destination(){if(this.currentStepIndex<0)return this.discardBeforeAnnounce?void 0:this.firstStep===0?this.steps[0]?.actions:this.preAnnounce;if(!(this.currentStepIndex<this.firstStep))return this.steps[this.currentStepIndex]?.actions}refusalReason(){let t=this.currentStepIndex;return t<0?`before announcing the failed step (step ${this.firstStep+1}); a heal records nothing until it announces that step, so this exploration was not filed under any step`:`under step ${t+1} ("${this.steps[t]?.gherkin??""}"), which had already run before the agent took over, so its recording was kept`}discard(t,n){if(t.length===0)return;let r=this.discarded.find(s=>s.why===n);r?r.actions.push(...t):this.discarded.push({actions:[...t],why:n})}announce(t,n,r){if(this.currentStepIndex=t,this.announced.add(t),r){let o=`Step ${t+1} (`;this.adaptations=this.adaptations.filter(a=>!a.startsWith(o)),this.adaptations.push(`${o}"${n}"): ${r}`)}let s=this.steps[t];this.preAnnounce.length>0&&t>=this.firstStep&&s&&(s.actions.unshift(...this.preAnnounce),this.carried={index:t,actions:this.preAnnounce},this.preAnnounce=[])}attributionNotes(){let t=[];if(this.carried){let{index:r,actions:s}=this.carried;t.push(`recording: ${Ma(s.length)} the agent performed before announcing a step ${s.length===1?"was":"were"} filed under step ${r+1} ("${this.steps[r]?.gherkin??""}"), the first step it recorded: ${Da(s)}.`)}let n=this.preAnnounce.length>0?[{actions:this.preAnnounce,why:"before announcing a step this session records"}]:[];for(let{actions:r,why:s}of[...this.discarded,...n])t.push(`recording: discarded ${Ma(r.length)} the agent performed ${s}: ${Da(r)}.`);return t}beginToolCall(t){this.inFlight.add(t)}endToolCall(t,n){this.inFlight.delete(t)&&(this.pendingTabOpen&&n&&(this.pendingTabFallback=void 0),this.inFlight.size===0&&this.pendingTabOpen&&(this.pendingTabFallback&&(this.pendingTabFallback.opensTab=!0),this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}markTabOpened(){let t=this.inFlight.size===0,n=Date.now()-this.lastOpenerAt<=Gd;if(t){this.lastOpener&&n&&(this.lastOpener.opensTab=!0);return}this.pendingTabOpen=!0,this.pendingTabFallback=n?this.lastOpener:void 0}addActions(t){if(this.done||t.length===0)return;if(this.currentStepIndex<0&&this.firstStep>0&&!this.discardBeforeAnnounce){let s=t.filter(o=>o.action==="switchTab");if(this.discard(s,"before announcing its first step (replay is already on the live page when the agent takes over)"),t=t.filter(o=>o.action!=="switchTab"),t.length===0)return}let n=this.destination();if(!n){this.pendingFingerprint=void 0,this.discard(t,this.refusalReason());return}this.pendingFingerprint&&t[0]&&(t[0].pageFingerprint=this.pendingFingerprint,this.pendingFingerprint=void 0),n.push(...t);let r=[...t].reverse().find(s=>Jd.has(s.action));r&&(this.lastOpener=r,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(r.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},Ae=e=>e.replace(/\\(['"\\])/g,"$1");function Kd(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?Ae(t[1]):void 0}function Yd(e){let t=[],n=0;for(;n<e.length;){let r=/^\.?([A-Za-z]+)\(/.exec(e.slice(n));if(!r)return;let s=n+r[0].length,o=s,a=1,i;for(;s<e.length&&a>0;s++){let c=e[s];if(i)c==="\\"?s++:c===i&&(i=void 0);else if(c==="'"||c==='"'||c==="`")i=c;else if(c==="/"&&/[(,:]\s*$/.test(e.slice(o-1,s)))for(s++;s<e.length&&e[s]!=="/";s++)e[s]==="\\"&&s++;else c==="("||c==="{"||c==="["?a++:(c===")"||c==="}"||c==="]")&&a--}if(a!==0)return;t.push({method:r[1],args:e.slice(o,s-1)}),n=s}return t}function bs(e,t){let n=/^'((?:[^'\\]|\\.)*)'$/.exec(e.trim());if(n)return`${JSON.stringify(Ae(n[1]))}${t?"s":"i"}`;let r=/^\/((?:[^/\\]|\\.)*)\/([a-z]*)$/.exec(e.trim());if(!r)return;let s=kr(r[1]);return s!==r[1]&&/[uv]/.test(r[2])?void 0:`/${s}/${r[2]}`}var Zd=new Set(["checked","disabled","selected","expanded","includeHidden","level","pressed","busy"]);function La(e,t){let n=/exact:\s*true/.test(t),r=/^\s*('(?:[^'\\]|\\.)*'|\/(?:[^/\\]|\\.)*\/[a-z]*)/.exec(t)?.[1];switch(e){case"locator":{let s=/^\s*'((?:[^'\\]|\\.)*)'\s*$/.exec(t);return s?Ae(s[1]):void 0}case"getByRole":{let s=/^\s*'([^']+)'/.exec(t)?.[1];if(!s)return;let o="";for(let[,a,i]of t.matchAll(/(\w+):\s*('(?:[^'\\]|\\.)*'|\/(?:[^/\\]|\\.)*\/[a-z]*|[^,}\s]+)/g))if(a==="name"||a==="description"){let c=bs(i,n);if(c===void 0)return;o+=`[${a}=${c}]`}else if(Zd.has(a))o+=`[${a==="includeHidden"?"include-hidden":a}=${i.replace(/^'(.*)'$/,"$1")}]`;else if(a!=="exact")return;return`internal:role=${s}${o}`}case"getByText":case"getByLabel":case"getByPlaceholder":case"getByAltText":case"getByTitle":{let s=r===void 0?void 0:bs(r,n);return s===void 0?void 0:e==="getByText"?`internal:text=${s}`:e==="getByLabel"?`internal:label=${s}`:`internal:attr=[${{getByPlaceholder:"placeholder",getByAltText:"alt",getByTitle:"title"}[e]}=${s}]`}case"getByTestId":{let s=r&&/^'/.test(r)?Ae(r.slice(1,-1)):void 0;return s===void 0?void 0:`internal:testid=[data-testid=${JSON.stringify(s)}s]`}case"first":return"nth=0";case"last":return"nth=-1";case"nth":return/^\s*-?\d+\s*$/.test(t)?`nth=${t.trim()}`:void 0;case"filter":{let s=/hasText:\s*('(?:[^'\\]|\\.)*'|\/(?:[^/\\]|\\.)*\/[a-z]*)/.exec(t);if(!s||!/^\s*\{\s*\}\s*$/.test(t.replace(s[0],"")))return;let o=bs(s[1],!1);return o===void 0?void 0:`internal:has-text=${o}`}default:return}}function Qd({method:e,args:t}){let n=String.raw`'(?:[^'\\]|\\.)*'`;return e==="getByRole"?new RegExp(String.raw`^\s*'[^']+'\s*(?:,\s*\{\s*(?:name:\s*${n}\s*(?:,\s*exact:\s*true\s*)?)?\}\s*)?$`).test(t):["getByText","getByLabel","getByPlaceholder","getByTestId","locator"].includes(e)&&new RegExp(String.raw`^\s*${n}\s*$`).test(t)}function xs(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=Ae(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=Yd(e.replace(/^\./,"")),s=r?.filter((c,l)=>!(c.method==="first"&&l===r.length-1));if(s&&s.length>1){let c=s.map(l=>La(l.method,l.args));return c.some(l=>l===void 0)?void 0:{...t,fallbackSelectors:[c.join(" >> ")]}}if(s?.length===1&&!Qd(s[0])){let c=La(s[0].method,s[0].args);return c===void 0?void 0:{...t,fallbackSelectors:[c]}}let o=e.match(/getByRole\('([^']+)'(?:,\s*\{([^}]*)\})?\)/);if(o){t.role=o[1];let c=(o[2]??"").match(/name:\s*'((?:[^'\\]|\\.)*)'/);c&&(t.name=Ae(c[1]))}let a=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[c,l]of a){let d=e.match(c);d&&(t[l]=Ae(d[1]))}let i=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return i&&(t.fallbackSelectors=[Ae(i[1])]),Object.keys(t).length>0?t:void 0}var Xd={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function ep(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Jt(Ae(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:Ae(r[1])};let s=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let g=xs(s[1]),f=xs(s[2]);if(g&&f)return{action:"dragTo",target:g,toTarget:f}}let o=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,a,i,c]=o,l=xs(a);if(!l)return;let d={action:Xd[i],target:l},p=Kd(c);return p!==void 0&&i!=="click"&&i!=="dblclick"&&(d.value=p),d}function za(e){let t=Array.isArray(e)?e:e?.content,n=typeof e=="string"?[e]:Array.isArray(t)?t.flatMap(r=>typeof r?.text=="string"?[r.text]:[]):[];for(let r of n){let s=r.split(/^### Open tabs\r?$/m)[1]?.split(/^### /m)[0];if(!s)continue;let o=/^- (\d+): \(current\) /m.exec(s)?.[1];if(o!==void 0)return Number(o)}}function qa(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let o of r){let a=o?.text;typeof a=="string"&&n.push(a)}else typeof e=="string"&&n.push(e);let s=[];for(let o of n){let a=o.split(/^### Ran Playwright code[ \t\r]*$/m).slice(1).map(i=>i.split(/^### /m)[0]).join(`
|
|
60
|
+
`);for(let i of a.split(`
|
|
61
|
+
`)){let c=ep(i.trim(),t);c&&s.push(c)}}return tp(s)}function tp(e){return e.filter((t,n)=>{let r=e[n+1];return!(t.action==="fill"&&(t.value??"")===""&&r?.action==="fill"&&JSON.stringify(r.target)===JSON.stringify(t.target))})}function Ha(e){let t=[];for(let n of e){if(n.kind==="assertion"&&n.actions.length===0){t.push(`Step "${n.gherkin}" is an assertion but recorded no check, so nothing would be verified. Re-record with: saffron run --rerecord`);continue}if(/"[^"]+"|<[^<>]+>/.test(n.gherkin)){for(let s of n.actions)if(s.action==="fill"&&(s.value??"")===""){t.push(`Step "${n.gherkin}" recorded a fill with an empty value although the step passes one; the typed value was lost. Re-record with: saffron run --rerecord`);break}}}return t}function ct(e,t,n=s=>s,r=!1){let s=e;for(let[o,a]of Object.entries(t).filter(([,i])=>i.length>=3).sort((i,c)=>c[1].length-i[1].length)){let i=n(a);if(!r){s=s.split(i).join(`<${o}>`);continue}if(a.length<5&&!/\p{N}/u.test(a)){s===i&&(s=`<${o}>`);continue}let c=i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");s=s.replace(new RegExp(`(?<![\\p{L}\\p{N}])${c}(?![\\p{L}\\p{N}])`,"gu"),()=>`<${o}>`)}return s}function Wa(e,t){let n=Object.entries(t).filter(([,l])=>l.length>=3);if(n.length===0)return e;let r=l=>l===void 0?void 0:ct(l,t),s=[...n].sort((l,d)=>d[1].length-l[1].length),o=l=>{if(l!==void 0){for(let[d,p]of s)if(l.includes(p))return`<${d}>`;return l}},a=l=>l===void 0?void 0:ct(l,t,X.pattern),i=l=>l===void 0?void 0:ct(l,t,void 0,!0),c=(l,d)=>l?{...l,name:d?o(l.name):r(l.name),nameRegex:a(l.nameRegex),label:r(l.label),text:d?o(l.text):r(l.text),placeholder:r(l.placeholder),testId:i(l.testId),frame:i(l.frame),fallbackSelectors:l.fallbackSelectors?.map(p=>wt(p,(g,f)=>ct(g,t,X[f])))}:void 0;return e.map(l=>({...l,actions:l.actions.map(d=>{let p=ue.has(d.action)&&d.action!=="expectValue";return{...d,value:p?o(d.value):r(d.value),url:r(d.url),pattern:a(d.pattern),urlPattern:a(d.urlPattern),promptText:r(d.promptText),target:c(d.target,p),toTarget:c(d.toTarget,!1)}})}))}function Va(e,t,n=r=>r){let r=qe(t.steps);return e.map((s,o)=>{let a=t.steps[o],i=a?.resolvedTable??a?.table,c=a?.resolvedDocString??a?.docString,l=Object.entries({...r,...ae(i),...tt(c)}).map(([g,f])=>[g,n(f)]).filter(([,g])=>g.length>=3).sort((g,f)=>f[1].length-g[1].length);if(l.length===0)return s;let d=(g,f=$=>$,y=!1)=>g===void 0?void 0:ct(g,Object.fromEntries(l),f,y),p=g=>g?{...g,name:d(g.name),nameRegex:d(g.nameRegex,X.pattern),label:d(g.label),text:d(g.text),placeholder:d(g.placeholder),testId:d(g.testId,void 0,!0),frame:d(g.frame,void 0,!0),fallbackSelectors:g.fallbackSelectors?.map(f=>wt(f,(y,$)=>d(y,X[$])??y))}:void 0;return{...s,actions:s.actions.map(g=>({...g,value:d(g.value),url:d(g.url),pattern:d(g.pattern,X.pattern),urlPattern:d(g.urlPattern,X.pattern),promptText:d(g.promptText),target:p(g.target),toTarget:p(g.toTarget)}))}})}function Ga(e,t=new Date){return e.map(n=>({...n,actions:n.actions.map(r=>vs(r,s=>Co(s,t)))}))}function vs(e,t,n){let r=(i,c,l=t)=>{let d={...i};for(let p of c)typeof i[p]=="string"&&(d[p]=l(i[p]));return d},s=i=>n?n(i,"pattern"):t(i),o=i=>{let c=r(r(i,["name","label","text","placeholder","testId","frame"]),["nameRegex"],s);return i.fallbackSelectors&&(c.fallbackSelectors=i.fallbackSelectors.map(l=>n?wt(l,n,t):t(l))),c},a=r(r(e,["value","url","promptText"]),["pattern","urlPattern"],s);return e.target&&(a.target=o(e.target)),e.toTarget&&(a.toTarget=o(e.toTarget)),a}function np(e,t){if(t.length===0)return e;let n=new Map(t.map(([o,a])=>[a,o])),r=[...n.keys()].sort((o,a)=>a.length-o.length).map(Pe).join("|"),s=new RegExp(String.raw`(?<=^|[^A-Za-z0-9\\]|\\[A-Za-z])(?:${r})(?![A-Za-z0-9])`,"g");return e.split(/(\{(?:env|data|unique|date)[^{}]*\})/).map((o,a)=>a%2?o:o.replace(s,i=>`{env:${n.get(i)}}`)).join("")}function Ja(e,t,n=[]){if(t.size===0)return e;let r=Sr(n,t),s=Xe(t,{standaloneBelow:6}),o=l=>s.mask(r(l)),a=[...t].filter(([,l])=>l.length>=Qe),i=l=>{let d=Sr(n,t,X[l]),p=Xe(new Map(a.filter(([,f])=>f.length>=6).map(([f,y])=>[f,X[l](y)]))),g=a.filter(([,f])=>f.length<6).map(([f,y])=>[f,X[l](y)]);return f=>o(np(p.mask(d(f)),g))},c={quoted:i("quoted"),regex:i("regex"),pattern:i("pattern")};return e.map(l=>({...l,actions:l.actions.map(d=>vs(d,o,(p,g)=>c[g](p)))}))}function Ka(e,t){if(t.size===0)return e;let n=r=>typeof r=="string"?Fo(r,t):Array.isArray(r)?r.map(n):r&&typeof r=="object"?Object.fromEntries(Object.entries(r).map(([s,o])=>[s,s==="pageFingerprint"?o:n(o)])):r;return e.map(r=>({...r,actions:r.actions.map(s=>n(s))}))}function Ya(e,t){return t.size===0?e:e.map(n=>{let r=new Set([...n.gherkin.matchAll(Bt)].map(l=>l[1])),s=l=>wr(l,t,r),o=l=>new Map([...t].filter(([,d])=>d.length>=4).map(([d,p])=>[d,X[l](p)])),a={quoted:o("quoted"),regex:o("regex"),pattern:o("pattern")},i=l=>l.split(/(\{(?:env|data|unique|date)[^{}]*\})/).map((d,p)=>p%2?d:s(d)).join(""),c=(l,d)=>i(wr(l,a[d],r));return{...n,actions:n.actions.map(l=>vs(l,s,c))}})}function Za(e,t,n){if(e!=="mcp__playwright__browser_tabs")return;let r=t??{},s=typeof r.index=="number"?String(r.index):void 0;switch(r.action){case"select":return s===void 0?void 0:{action:"switchTab",value:s};case"close":return s===void 0?{action:"closeTab"}:{action:"closeTab",value:s};case"new":return typeof r.url=="string"&&r.url?{action:"newTab",url:Jt(r.url,n)}:{action:"newTab"};default:return}}function Qa(e,t){let n=t??{};if(e==="mcp__playwright__browser_handle_dialog"){let r={action:"handleDialog",value:n.accept?"accept":"dismiss"};return typeof n.promptText=="string"&&n.promptText&&(r.promptText=n.promptText),r}if(e==="mcp__playwright__browser_file_upload"){let r=Array.isArray(n.paths)?n.paths:[];if(r.length===0)return;let s=process.cwd()+"/";return{action:"uploadFile",value:r.map(a=>typeof a=="string"&&a.startsWith(s)?a.slice(s.length):a).join(`
|
|
62
|
+
`)}}}function on(e,t){let n=e.pages().filter(s=>!s.isClosed()),r=s=>[...n].reverse().find(s);return(t?r(s=>s.url()===t):void 0)??r(s=>s.url()!=="about:blank")??n.at(-1)}async function Vn(e,t){let n=t.context();for(let s of e.contexts())if(s!==n)for(let o of s.pages())await o.close().catch(()=>{});await t.bringToFront().catch(()=>{});let r=n.pages().filter(s=>!s.isClosed());return{index:Math.max(r.indexOf(t),0),count:r.length,url:t.url()}}var ks=["chromium","chrome","msedge","firefox","webkit"],Ss={chrome:{label:"Google Chrome"},msedge:{label:"Microsoft Edge"}};function si(e){return ks.includes(e)}var an=class extends Error{},lt={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function ip(){return new Promise((e,t)=>{let n=rp.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function Xa(e){let t=xn(ne(e));return t.size>0?{data:Object.fromEntries([...t].sort(([n],[r])=>n.localeCompare(r)))}:{}}function Ct(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function oi(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function ei(e,t){return e[t]?.kind==="assertion"&&t>=oi(e)}function ti(e,t,n=!1,r){let s=oi(e);return e.map((o,a)=>{if(r&&a<r.stepIndex)return o;let i=t[a],c=i&&i.actions.length>0;return o.kind==="assertion"?n&&a<s&&c?i:o:c?r&&a===r.stepIndex&&r.actionIndex>0?{...i,actions:[...o.actions.slice(0,r.actionIndex),...i.actions]}:i:o})}function cp(e,t,n,r){let s=na(e,t);return s.length===0||!n?.pageUrl?s:[`page at failure: replay stopped at step ${n.stepIndex+1} ("${n.stepText}") on ${Jt(n.pageUrl,r)}, where the heal started.`,...s]}function lp(e,t,n=0){return e.map((r,s)=>{if(s<n)return r;let o=t[s];return o&&o.actions.length>0?o:r})}function ni(e,t){let n=[];for(let r=0;r<Math.min(t,e.length);r++){let s=e[r].actions.length;s!==0&&n.push(`recording: kept the existing recording of step ${r+1} ("${e[r].gherkin}"), which ran before the agent took over; ignored ${s} action${s===1?"":"s"} the agent's session held for it.`)}return n}function ai(e,t,n,r){let s=t.filter(c=>c>=0&&c<e.length),o=s.length>0?Math.max(...s):0,a=Math.min(n&&n.stepIndex>=o?n.stepIndex:o,e.length-1),i=n&&a===n.stepIndex?n.error:r;return e.map((c,l)=>({gherkin:c.gherkin,kind:c.kind,status:l<a?"passed":l===a?"failed":"skipped",...l===a&&i?{error:i}:{}}))}function dp(e,t,n){let r=n.success?e.map(s=>({gherkin:s.gherkin,kind:s.kind,status:"passed"})):ai(e,n.announcedSteps??[],t,n.narrative);return e.map((s,o)=>o<t.stepIndex?s:r[o])}function ri(e,t){return{aiCalls:e.aiCalls+t.aiCalls,inputTokens:e.inputTokens+t.inputTokens,outputTokens:e.outputTokens+t.outputTokens,cacheReadTokens:e.cacheReadTokens+t.cacheReadTokens,cacheCreationTokens:e.cacheCreationTokens+t.cacheCreationTokens,costUsd:(e.costUsd??0)+(t.costUsd??0),models:e.models||t.models?[...new Set([...e.models??[],...t.models??[]])]:void 0}}var Gn=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=$e(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${t}`)}return this.stepIndex}async recordWithSeeding(t,n,r){let s=this.getStepIndex(),o=t.steps.length,a={...t.params,...qe(t.steps)},i=t.steps.map(w=>{let x=s.steps.size>0?bo(s,w,a):void 0;return x?{gherkin:w.text,keyword:w.keyword,kind:w.kind,table:w.table,docString:w.docString,fromStepSet:w.source?.fromStepSet,actions:x.actions}:void 0}),c=t.steps.map(w=>({gherkin:w.text,keyword:w.keyword,kind:w.kind,table:w.table,docString:w.docString,fromStepSet:w.source?.fromStepSet,actions:[]})),l=new Map;l.set(me,r);let d=[],p=[],g=[],f=[],y={...lt},$=n,T=0,v=0,S=0,h=(w,x)=>({success:w,narrative:p.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:f.length>0?f.join(`
|
|
63
|
+
`):void 0,featureEdits:g,recordedSteps:c,announcedSteps:x===void 0?[]:Array.from({length:x+1},(b,u)=>u),usage:y,seededCount:T});for(;S<o;){if(i[S]){let R=S;for(;R<o&&i[R];)R++;let _=i.slice(S,R),A={...t,steps:t.steps.slice(S,R)},C=await tn($.isClosed()?on(n.context())??n:$,A,this.buildCandidateCache(t,_),{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,vars:l,trace:this.options.trace!==void 0&&this.options.trace!=="off",traceStepOffset:S});$=C.activePage??$;let P=C.status==="passed"?R-S:C.failure?.stepIndex??0;for(let I=0;I<P;I++)c[S+I]=_[I];if(T+=P,C.status==="passed"){S=R;continue}let O=S+P;d.push(`Seeded recording for "${t.steps[O].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),i[O]=void 0,S=O;continue}if(!this.options.agent)return h(!1,S);v++;let w=S;for(;w<o&&!i[w];)w++;v>=3&&(w=o);let x=await Vn(this.browser,$.isClosed()?on(n.context())??n:$),b=R=>{$=R};n.context().on("page",b);let u;try{u=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,traceContext:this.options.trace&&this.options.trace!=="off"?n.context():void 0,mode:"record",cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:S>0?t.steps.slice(0,S).map(R=>R.resolvedText):void 0,recordOnly:w<o?{from:S,to:w-1}:void 0,liveTab:x,onActiveTab:R=>{let _=n.context().pages().filter(A=>!A.isClosed())[R];_&&($=_)},uniqueSeed:l.get(me),onTabOpened:this.tabWatcher(),takeFingerprint:async()=>ot(Ct(this.browser)??n)}))}finally{n.context().off("page",b)}if(y=ri(y,u.usage),p.push(u.narrative),d.push(...u.adaptations),g.push(...u.featureEdits),u.suggestedFeatureEdit&&f.push(u.suggestedFeatureEdit),!u.success){let R=u.announcedSteps.filter(_=>_>=S&&_<o);return h(!1,R.length>0?Math.max(...R):S)}let m=u.announcedSteps.filter(R=>R>=S&&R<o),E=Math.min(o-1,Math.max(m.length>0?Math.max(...m):w-1,S));for(let R=S;R<=E;R++)c[R]=u.recordedSteps[R];S=E+1}return h(!0)}async withDialogsVisible(t){let n=()=>{},r=[],s=[],o=a=>{a.on("dialog",n),r.push(a)};for(let a of this.browser?.contexts()??[]){a.on("page",o),s.push(a);for(let i of a.pages())o(i)}try{return await t()}finally{for(let a of s)a.off("page",o);for(let a of r)try{a.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(t==="chromium"||Ss[t]){let n=await ip();try{this.browser=await sp.launch({headless:!this.options.headed,channel:Ss[t]?t:void 0,args:[`--remote-debugging-port=${n}`]})}catch(r){let s=r instanceof Error?r.message:String(r),o=Ss[t];throw o&&/is not found at|distribution .* is not found/i.test(s)?new an(`${o.label} is not installed on this machine, so --browser ${t} cannot run.
|
|
64
|
+
Install it (npx playwright install ${t} does that), or use --browser chromium, which Saffron ships with.`):r}this.cdpEndpoint=`http://127.0.0.1:${n}`}else this.browser=await(t==="firefox"?op:ap).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let t=fe.isAbsolute(this.options.storageState)?this.options.storageState:fe.join(this.options.projectRoot,this.options.storageState);if(!It.existsSync(t))throw new Error(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);return t}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n,...Xa(t)}}tabWatcher(){return t=>{let n=this.browser?.contexts()??[],r=()=>t();for(let s of n)s.on("page",r);return()=>{for(let s of n)s.off("page",r)}}}async proofReplay(t,n,r){let s=await this.browser.newContext({storageState:this.resolveStorageState()}),o=this.options.trace!==void 0&&this.options.trace!=="off";o&&await Jr(s,`${t.displayName} (proof replay)`);let a=await s.newPage();return{result:await tn(a,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,uniqueSeed:r,trace:o}),context:s,page:a}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),s=Ie(t);if(s.size>0){let o=a=>a===void 0?a:Ce(a,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(a=>Ce(a,s));for(let a of r.stepResults)a.error=o(a.error)}return r}async screenshot(t,n,r,s){let o=this.options.screenshot??"viewport";if(o!=="off")try{let a=ke(this.options.projectRoot,n.featurePath,n.displayName);this.claimArtifactDir(n);let i=fe.join(a,`${r}.jpg`),c=await t.screenshot({path:i,type:"jpeg",quality:80,fullPage:o==="full-page",timeout:5e3});return{kind:r,file:fe.relative(this.options.projectRoot,i).split(fe.sep).join("/"),step:s,sha256:rt(c)}}catch{return}}claimArtifactDir(t){let n=ke(this.options.projectRoot,t.featurePath,t.displayName);It.mkdirSync(n,{recursive:!0}),It.writeFileSync(fe.join(n,En),JSON.stringify({feature:t.featurePath,scenario:t.displayName},null,2))}async runScenarioInner(t,n={}){if(!this.browser)throw new Error("Orchestrator not started");let r=Date.now(),{projectRoot:s,baseURL:o,actionTimeoutMs:a,retries:i,pollIntervalMs:c}=this.options,l=K(s,t.featurePath,t.name),d;try{d=pe(l)}catch(b){if(!(b instanceof J))throw b;return{scenario:t,status:"red",source:"cache",stepResults:[],adaptations:[],usage:lt,durationMs:Date.now()-r,error:`The recording cannot be read: ${b.problem}. Fix the file, or re-record it with saffron run --rerecord.`}}let p=be(s,t.featurePath,t.name),g,f;if(!d&&It.existsSync(p))try{({proposal:g,revision:f}=Fn(p))}catch(b){if(!(b instanceof J)&&b.code!=="ENOENT")throw b;g=void 0}g&&Me(s,g,t)&&(g=void 0);let y=await this.browser.newContext({storageState:this.resolveStorageState()}),$=this.options.trace??"off",T=$!=="off";T&&await Jr(y,t.displayName);let v=!T,S=await y.newPage(),h=st(),w=[];It.rmSync(ke(s,t.featurePath,t.displayName),{recursive:!0,force:!0});let x=(b,u)=>this.screenshot(b,t,"before-heal",u.stepText).then(m=>{m&&w.push(m)});try{let b=await(async()=>{let m;if(g){let P=await tn(S,t,g.cache,{baseURL:o,actionTimeoutMs:a,retries:i,pollIntervalMs:c,uniqueSeed:h,trace:T,tracePhase:"pending"}),O=g.meta.recordedFor??t.name;if(P.status==="passed")return{scenario:t,status:"yellow",source:"agent-record",proposalReplay:!0,stepResults:P.stepResults,narrative:`Replayed the pending proposal recorded for "${O}" at zero AI; accept it to go green.`,adaptations:[],verified:!0,proposalFile:p,proposalRevision:f,usage:lt,durationMs:Date.now()-r};if(Object.keys(t.params).length>0&&O!==t.displayName){let U=P.failure;return{scenario:t,status:"red",source:"agent-record",proposalReplay:!0,stepResults:P.stepResults,adaptations:[],proposalFile:p,proposalRevision:f,usage:lt,durationMs:Date.now()-r,error:`The pending proposal recorded for "${O}" does not replay for this Examples row`+(U?` (step "${U.stepText}": ${U.error.split(`
|
|
65
|
+
`)[0]})`:"")+". A row value is probably baked in as a literal instead of a <placeholder>; inspect the proposal, reject it, and re-record with saffron run --rerecord."}}console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+(P.failure?` (step "${P.failure.stepText}": ${P.failure.error.split(`
|
|
66
|
+
`)[0]})`:"")+"; recording again"),await S.goto("about:blank")}if(d&&(m=await tn(S,t,d,{baseURL:o,actionTimeoutMs:a,retries:i,pollIntervalMs:c,uniqueSeed:h,trace:T,onFailure:x,keepResponseLog:!0}),m.status==="passed"))return{scenario:t,status:"green",source:"cache",stepResults:m.stepResults,adaptations:[],usage:lt,durationMs:Date.now()-r};if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let P=this.options.agent!==void 0&&this.cdpEndpoint===void 0&&!n.agentDisabled?`recording/healing needs a Chromium-family browser (the agent attaches over CDP). Record with --browser chromium (the default), chrome or msedge, then replay with --browser ${this.options.browser}.`:"no agent is configured.";return m?.responses?.detach(),{scenario:t,status:"red",source:"cache",stepResults:m?.stepResults??[],adaptations:[],usage:lt,durationMs:Date.now()-r,error:d?m?.status==="stale"?`Cache is stale (feature file changed) and ${P}`:`Replay failed and ${P} ${m?.failure?.error??""}`.trimEnd():`No cache exists and ${P}`}}let R=d!==void 0&&m!==void 0&&m.status==="failed",_=0,A,C;try{if(R){C=m.responses;let P=await Vn(this.browser,m.activePage??on(S.context(),m.failure.pageUrl)??S);A=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,traceContext:T?S.context():void 0,mode:"heal",liveTab:P,model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:m.stepResults.filter(O=>O.status==="passed").map(O=>O.gherkin),failedStep:{text:m.failure.stepText,kind:m.failure.kind,error:m.failure.error,pageUrl:m.failure.pageUrl,stepIndex:m.failure.stepIndex},uniqueSeed:h,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let O=Ct(this.browser)??S;return ot(O)}}))}else if(await S.goto("about:blank"),this.options.reuseSteps!==!1){let P=await this.recordWithSeeding(t,S,h);_=P.seededCount,A=P}else A=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,traceContext:T?S.context():void 0,mode:"record",cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,uniqueSeed:h,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let P=Ct(this.browser)??S;return ot(P)}}))}catch(P){C?.detach();let O=P instanceof Error?P.message:String(P);return{scenario:t,status:"red",source:R?"agent-heal":"agent-record",stepResults:m?.stepResults??[],adaptations:[],usage:lt,durationMs:Date.now()-r,error:`Agent invocation failed: ${O.split(`
|
|
67
|
+
`)[0]}`}}if(A.success&&R&&m.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||ei(d.steps,m.failure.stepIndex))){let P=d.steps[m.failure.stepIndex],O=Ct(this.browser)??S,I=new Map(Object.entries(m.captured)),U=as(t,m.failure.stepIndex);try{for(let N of P.actions)await Bn(O,N,U,this.options.actionTimeoutMs??5e3,o,I,C)}catch(N){let k=N instanceof Error?N.message:String(N),j=`Assertion failed as written: "${P.gherkin}". Saffron never adapts assertions. If this change is intended, update the .feature file or re-record the scenario (saffron run --rerecord --filter @tag). If the recording itself looks wrong (an empty value, a wrong target), re-record it the same way. Verification error: ${k.split(`
|
|
68
|
+
`)[0]}`,F=m.failure.stepIndex,q=m.stepResults.map((H,te)=>te===F?{...H,status:"failed",error:j}:H);return{scenario:t,status:"red",source:"agent-heal",stepResults:q,executionStepResults:q.map((H,te)=>te>F&&H.status==="skipped"?{...H,status:"passed"}:H),narrative:A.narrative,adaptations:A.adaptations,suggestedFeatureEdit:A.suggestedFeatureEdit,usage:A.usage,durationMs:Date.now()-r,error:j}}finally{C?.detach()}}return C?.detach(),await this.finalizeAgentRun(t,A,R,d,r,_,R?m?.failure:void 0,h)})();if(b.status==="red"){let m=w.find(E=>E.kind==="before-heal");if(b.source==="cache"&&m){let E=m.file.replace(/before-heal\.jpg$/,"failure.jpg");try{It.renameSync(fe.join(s,m.file),fe.join(s,E)),m.file=E}catch{}m.kind="failure"}else{let E=b.stepResults.find(_=>_.status==="failed")?.gherkin,R=await this.screenshot(Ct(this.browser)??S,t,"failure",E);R&&w.push(R)}}let u=At(ne(t),h);if(u.size>0&&(b.uniques=Object.fromEntries(u)),w.push(...b.evidence??[]),T){let m=$==="on"||b.status!=="green",E=ke(s,t.featurePath,t.displayName),R=m?fe.join(E,Gr):void 0;v=!0,R||await Kr(y).catch(()=>{});let _=R?await Yr(y,R,Ie(t)):void 0;if(R&&_){this.claimArtifactDir(t);let A=Ie(t);w.unshift({kind:"trace",file:fe.relative(s,R).split(fe.sep).join("/"),sha256:_,traceSteps:(b.executionStepResults??b.stepResults).map(C=>({...C,error:C.error&&Ce(C.error,A)}))})}}return w.length>0&&(b.status!=="green"||w.some(m=>m.kind==="trace"))&&(b.evidence=w),b}finally{v||await Kr(y).catch(()=>{}),await y.close()}}async finalizeAgentRun(t,n,r,s,o,a=0,i,c=st()){let l=r?"agent-heal":"agent-record",d=n.success?n.recordedSteps.map(k=>({gherkin:k.gherkin,kind:k.kind,status:"passed"})):ai(n.recordedSteps,n.announcedSteps??[],i&&{stepIndex:i.stepIndex,error:i.error},n.narrative);if(!n.success)return{scenario:t,status:"red",source:l,stepResults:d,narrative:n.narrative,adaptations:n.adaptations,usage:n.usage,durationMs:Date.now()-o,error:n.narrative};let p=r?ti(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",i?{stepIndex:i.stepIndex,actionIndex:i.actionIndex}:void 0):n.recordedSteps,g=[...n.adaptations,...r&&i?ni(n.recordedSteps,i.stepIndex):[]],f={...n.usage},y,$,T,v,S=[],h=0,w=new Set,x,b=async(k,j,F=j.stepResults)=>{if(!w.has(k)){w.add(k);try{if(this.options.trace&&this.options.trace!=="off"){let q=fe.join(ke(this.options.projectRoot,t.featurePath,t.displayName),`proof-${++h}.zip`),H=Ie(t),te=await Yr(k,q,H);if(te){this.claimArtifactDir(t);let ut=j.captured[me],gt=ut?At(ne(t),ut):new Map;S.push({kind:"trace",proof:!0,file:fe.relative(this.options.projectRoot,q).split(fe.sep).join("/"),sha256:te,traceSteps:F.map(Ut=>({...Ut,error:Ut.error&&Ce(Ut.error,H)})),traceUniques:gt.size?Object.fromEntries(gt):void 0})}}}finally{await k.close()}}};if(this.options.verifyProposals!==!1){let k=await this.proofReplay(t,this.buildCandidateCache(t,p),st());v=k.result.captured[me];try{if(k.result.status!=="passed"&&k.result.failure){let j=k.result.failure;if(this.options.agent&&!(r&&j.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||ei(s.steps,j.stepIndex))))try{let q=await Vn(this.browser,k.result.activePage??on(k.page.context(),j.pageUrl)??k.page),H=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,traceContext:this.options.trace&&this.options.trace!=="off"?k.context:void 0,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:k.result.stepResults.filter(te=>te.status==="passed").map(te=>te.gherkin),failedStep:{text:j.stepText,kind:j.kind,error:j.error,pageUrl:j.pageUrl,stepIndex:j.stepIndex},liveTab:q,uniqueSeed:k.result.captured[me],onTabOpened:this.tabWatcher(),takeFingerprint:async()=>ot(Ct(this.browser)??k.page)}));f=ri(f,H.usage),x=dp(k.result.stepResults,j,H),H.success&&(g.push(...H.adaptations),g.push(...ni(H.recordedSteps,j.stepIndex)),p=r?ti(p,H.recordedSteps,this.options.assertionPolicy==="adaptable-mid",{stepIndex:j.stepIndex,actionIndex:0}):lp(p,H.recordedSteps,j.stepIndex),await b(k.context,k.result,x),x=void 0,k=await this.proofReplay(t,this.buildCandidateCache(t,p),st()),v=k.result.captured[me])}catch{}}for(let j of k.result.tabOpeners??[]){let F=p[j.stepIndex]?.actions[j.actionIndex];F&&(F.opensTab=!0)}y=k.result.status==="passed",y||($=k.result.failure?.error??`proof replay ${k.result.status}`,T=k.result.stepResults)}finally{await b(k.context,k.result,x).catch(()=>{})}}r&&s&&g.push(...cp(s.steps,p,i,this.options.baseURL));let u=[...n.featureEdits];for(let k of xo(p,$e(this.options.projectRoot)))g.push(`duplicate wording: "${k.from}" recorded exactly the same actions as the existing step "${k.to}" (${k.feature} \u203A ${k.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),u.some(j=>j.index===k.index)||u.push({index:k.index,text:k.to});let m=Ha(p);m.length>0&&(y=!1,$=[$,...m].filter(Boolean).join(" "));let E=Ie(t),R=k=>E.size>0?Ce(k,E):k;g=g.map(R);let _=u.map(k=>({index:k.index,text:R(k.text)})),A=R(n.narrative),C=n.suggestedFeatureEdit?R(n.suggestedFeatureEdit):void 0,P={version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:p,...Xa(t)},O={meta:{createdAt:P.recordedAt,mode:r?"heal":"record",narrative:A,adaptations:g,suggestedFeatureEdit:C,featureEdits:_.length>0?_:void 0,verified:y,proofError:$?R($):void 0,seededSteps:a>0?a:void 0,recordedFor:t.displayName,usage:f,bound:ga(t,s,P,_.length>0?_:void 0)},cache:P},I=be(this.options.projectRoot,t.featurePath,t.name),U=Ro(I,O),N=pe(K(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:_t(N,O.cache),scenario:t,status:"yellow",source:l,stepResults:T??d,executionStepResults:d,evidence:S,proofUniques:(()=>{let k=v?At(ne(t),v):void 0;return k&&k.size>0?Object.fromEntries(k):void 0})(),narrative:A,adaptations:g,suggestedFeatureEdit:C,featureEdits:_.length>0?_:void 0,verified:y,proofError:$,seededSteps:a>0?a:void 0,proposalFile:I,proposalRevision:U,usage:f,durationMs:Date.now()-o}}};import ir from"node:fs";import df from"node:os";import Oi from"node:path";import{query as Op}from"@anthropic-ai/claude-agent-sdk";import{McpServer as pp}from"@modelcontextprotocol/sdk/server/mcp.js";import{z}from"zod";var oe=e=>({content:[{type:"text",text:e}]});function jt(e,t,n,r){let s=async o=>{let a=await r(o);return a.content[0]?.text.startsWith("ERROR:")?{...a,isError:!0}:a};return o=>{o.registerTool(e,{description:t,inputSchema:n},s)}}function fp(e){let t=new pp({name:e.name,version:e.version});for(let n of e.tools)n(t);return{type:"sdk",name:e.name,instance:t}}function ii(e){let t={};return e.role&&(t.role=e.role),e.name&&(t.name=e.name),e.nameRegex&&(t.nameRegex=e.nameRegex),e.label&&(t.label=e.label),e.text&&(t.text=e.text),e.selector&&(t.fallbackSelectors=[e.selector]),e.frame&&(t.frame=e.frame),Object.keys(t).length>0?t:void 0}var up=new Set(["main","banner","contentinfo","status","alert","alertdialog","dialog","search"]);function ci(e){if(!e?.role)return;let{role:t,frame:n,...r}=e;if(!(Object.keys(r).length>0||up.has(t)))return`ERROR: role="${t}" alone matches every ${t} on the page, and replay would use the first one. Add a name (or nameRegex), text, label or selector that picks exactly the element you checked, then call again.`}function gp(e,t){return t.index>=e.steps.length?`ERROR: there is no step ${t.index}: this scenario's steps are numbered from 0 to ${e.steps.length-1}. Call saffron_step again with the zero-based index of the step you are about to perform.`:(e.announce(t.index,t.text,t.adaptation),t.index<e.firstStep?`Step ${t.index} already ran before you took over and keeps its recording; nothing you do under it is recorded. Do not redo it: continue from step ${e.firstStep} on the page as it is.`:`Recording step ${t.index}: ${t.text}`)}var li=new WeakSet;function hp(e,t){if(t.success&&!li.has(e)){let n=[...e.announced].filter(r=>r>=e.firstStep&&e.steps[r]?.kind==="assertion"&&!e.steps[r].actions.some(s=>ue.has(s.action))).sort((r,s)=>r-s);if(n.length>0)return li.add(e),`ERROR: not finalized. Assertion step ${n.join(", ")} recorded no check, so replay would verify nothing. Record what you verified with saffron_assert under that step (saffron_step first if you moved on), then call saffron_done again. If the condition does not hold, call saffron_done with success=false.`}return e.done=!0,e.success=t.success,e.narrative=t.narrative,e.suggestedFeatureEdit=t.suggestedFeatureEdit,e.featureEdits=t.featureEdits??[],"Run finalized."}var di={role:z.string().optional().describe("ARIA role of the element"),name:z.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:z.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:z.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:z.string().optional().describe("getByText locator text"),selector:z.string().optional().describe("CSS fallback selector"),frame:z.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 Jn(e){return fp({name:"saffron",version:"0.1.0",tools:[jt("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:z.number().int().min(0),text:z.string(),adaptation:z.string().optional().describe("If the written step does not match the real UI and you are deviating from it, describe the deviation in one sentence: the kind of change (a locator or label change on the same page, a page removed or merged, a page added or flow reordered) and the page it happened on.")},async t=>oe(gp(e,t))),jt("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:z.string().describe("Name to store the captured text under"),...di},async t=>{let n=ii(t);if(!n)return oe("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).");let r=ci(n);return r?oe(r):(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),oe(`Capture recorded as "${t.saveAs}".`))}),jt("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:z.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:z.string().optional().describe("Text typed into a prompt() before accepting")},async t=>(e.addArmedAction({action:"handleDialog",value:t.accept?"accept":"dismiss",...t.promptText?{promptText:t.promptText}:{}}),oe(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),jt("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:z.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:z.string().optional().describe("HTTP method to match (any if omitted)"),status:z.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:z.string().optional().describe(`Regex the response body must match, e.g. '"status"\\s*:\\s*"READY"' for polling waits`)},async t=>(e.addActions([{action:"waitForResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}}]),oe("Network wait recorded."))),jt("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:z.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...di,expected:z.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:z.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:z.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:z.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:z.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:z.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:z.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:z.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:z.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:z.string().optional().describe("kind=each: the data-list token exactly as the step writes it, e.g. '{data:enums.OrderStatus}'. The target is the CONTAINER (dropdown, table, list) that must show every value of that list. Never pass the values themselves."),compareTo:z.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=ii(t),r=ci(n);if(r)return oe(r);let s;switch(t.kind){case"visible":s={action:"expectVisible",target:n};break;case"notVisible":s={action:"expectNotVisible",target:n};break;case"text":s={action:"expectText",target:n,value:t.expected??""};break;case"value":s={action:"expectValue",target:n,value:t.expected??""};break;case"url":s={action:"expectUrl",url:t.url??""};break;case"differs":if(!t.compareTo)return oe("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return oe("ERROR: kind=differs needs either capture (a stored name) or a target element.");s={action:"expectDiffers",target:n,value:t.capture,compareTo:t.compareTo};break;case"matches":if(!t.pattern)return oe("ERROR: kind=matches requires pattern.");s={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return oe("ERROR: kind=attribute requires attribute and pattern.");s={action:"expectAttribute",target:n,attribute:t.attribute,pattern:t.pattern};break;case"each":if(!t.each||!/^\{data:[^}]+\}$/.test(t.each))return oe("ERROR: kind=each requires each='{data:file.list}', the token exactly as the step writes it (not the values).");s={action:"expectEach",target:n,each:t.each};break;case"response":if(!t.urlPattern)return oe("ERROR: kind=response requires urlPattern.");s={action:"expectResponse",urlPattern:t.urlPattern,...t.method?{method:t.method}:{},...t.status?{status:t.status}:{},...t.bodyPattern?{pattern:t.bodyPattern}:{}};break}return!["url","differs","response"].includes(t.kind)&&!n?oe("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([s]),oe("Assertion recorded."))}),jt("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:z.boolean(),narrative:z.string().describe("2-4 sentence summary of the run: what happened, and why it failed if it did. After a heal, say what kind of change the app went through (a locator change, or a page removed, merged, added or reordered) and on which page."),suggestedFeatureEdit:z.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:z.array(z.object({index:z.number().int().min(0).describe("zero-based step index"),text:z.string().describe("replacement step text WITHOUT the Given/When/Then keyword")})).optional().describe("Machine-applicable version of the suggested edits: one entry per adapted step. Provide this whenever you adapted a step.")},async t=>oe(hp(e,t)))]})}function Yn(e={}){let t=e.deliberateSnapshots?`
|
|
67
69
|
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.
|
|
68
70
|
9. Minimize round-trips: batch tool calls in a single response whenever they don't depend on each other's results \u2014 saffron_step together with that step's browser actions, several fills in one turn, bookkeeping calls (saffron_capture, saffron_assert) alongside the actions they belong to. Only stop and wait when you genuinely need a result before deciding your next call. Do not write narration between tool calls.`:"";return`You are Saffron's test execution agent. You execute Gherkin scenarios in a real browser using the playwright MCP tools, while Saffron records your actions into a deterministic replay cache.
|
|
69
71
|
|
|
@@ -81,84 +83,113 @@ Non-negotiable rules:
|
|
|
81
83
|
5a3. DATA LISTS: when a step shows a token followed by '(a data list, currently: \u2026)', e.g. "should list every {data:enums.OrderStatus} (a data list, currently: "New", "Paid")", verify from a snapshot that EVERY listed value is shown inside one container (the dropdown, table, list or menu the step is about), then record ONE assertion: saffron_assert kind=each, each='{data:enums.OrderStatus}' (the token exactly as written, never the values), targeting that container. Do NOT record one assertion per value: replay reads the list from the data file, so values added to it later are checked without recording again. If any listed value is missing, the step FAILS.
|
|
82
84
|
5c. Real-world page furniture is supported: elements inside IFRAMES record and replay fine (interact normally) \u2014 but when a saffron_assert targets an element INSIDE an iframe, you MUST pass the iframe's CSS selector as the 'frame' argument (e.g. frame='iframe[title="Feedback widget"]'); replay cannot see into frames otherwise. File uploads: use browser_file_upload with the file path. Drag-and-drop: use browser_drag. All of these are recorded and replay deterministically.
|
|
83
85
|
5d. NATIVE DIALOGS (alert/confirm/prompt): when an action opens one, the page blocks and the snapshot reports a modal state \u2014 resolve it with browser_handle_dialog (accept true/false; promptText for prompt()). That call is recorded automatically as an armed handler replay places BEFORE the triggering action, so do NOT also call saffron_dialog. After handling, verify the post-dialog state as usual. If a step's wording says to confirm/accept, accept; if it says cancel/dismiss, dismiss.
|
|
84
|
-
5e. NETWORK WAITS: when a step says to wait for an API call ("wait for the order API to succeed", "until the job API reports READY"), do it verify-first: perform the trigger, check via browser_network_requests that the matching response actually occurred with the expected status/body, then record it with saffron_wait_api. For Then steps about API contracts ("the request should have returned 201") verify the same way and record with saffron_assert kind=response. urlPattern is a path-only regex \u2014 NEVER a literal volatile ID (/api/orders/\\d+, not /api/orders/8842) and never the host.
|
|
86
|
+
5e. NETWORK WAITS: when a step says to wait for an API call ("wait for the order API to succeed", "until the job API reports READY"), do it verify-first: perform the trigger, check via browser_network_requests that the matching response actually occurred with the expected status/body, then record it with saffron_wait_api. For Then steps about API contracts ("the request should have returned 201") verify the same way and record with saffron_assert kind=response. urlPattern is a path-only regex \u2014 NEVER a literal volatile ID (/api/orders/\\d+, not /api/orders/8842) and never the host. browser_network_request returns a whole response body, which Saffron cuts at ${Ur.toLocaleString("en-US")} characters: fetch a body only when the step is about its content, and prefer a saffron_assert kind=response on the field you need.
|
|
85
87
|
5f. TABS: a click that opens a new tab needs nothing extra, replay follows the new tab by itself. When a step says to switch to another tab, go back to the first tab, or close a tab, do it with browser_tabs (list first if unsure of the index); select, close and new are recorded and replay in the same order. Steps that assert on the other tab's content are ordinary assertions after the switch.
|
|
86
|
-
7. Never ask the user questions; you are unattended.${t}`}function
|
|
87
|
-
${c.map(
|
|
88
|
-
`)}`),l!==void 0&&(
|
|
88
|
+
7. Never ask the user questions; you are unattended.${t}`}function mp(e,t){if(e[t].kind!=="assertion")return"action";let n=e.length;for(let r=e.length-1;r>=0&&e[r].kind==="assertion";r--)n=r;return t>=n?"assertion:final":"assertion"}var yp=`When you heal, tell the reviewer WHAT KIND of change you adapted to, in saffron_step's adaptation and again in saffron_done's narrative, and name the page (its URL path) where it happened. Pick the kind that fits: (1) locator or label change: the same control on the same page, found differently (renamed button, new test id, native select instead of a custom one); (2) page removed or merged: a page no longer exists and its fields or controls now live on another page, so a navigation step (e.g. a 'Continue' click) is no longer needed; (3) page added or flow reordered: a new intermediate page, dialog or step order; (4) timing only: nothing changed, the element was slow. When you skip or add a click, key press or navigation the step used to perform, say so explicitly, e.g. "The guest information page is gone: its fields are now on /checkout, so the 'Continue to payment' click was dropped." Never describe a flow change as a locator change.`;function Kn(e){let t=e.liveTab;if(t)return t.count<=1?`Live page: the only open tab, at ${t.url}. Work on it; do not open or select another tab to start over.`:`Live page: tab ${t.index} of ${t.count} open tabs, at ${t.url}. This is where replay left off. If browser_tabs shows another tab as current, select tab ${t.index} before anything else (Saffron does not record that selection); never start over on another tab.`}function pi(e){let t=e.failedStep?.stepIndex;return`ANNOUNCE FIRST: call saffron_step for ${t===void 0?"the failed step":`the failed step (index ${t})`} BEFORE any state-changing browser action (click, type, select, key press, navigation, tab change). To diagnose, look with browser_snapshot, never with clicks or navigation: Saffron discards every action made before that announcement, and going back through earlier steps changes the state replay reached.`}function Zn(e){let{scenario:t}=e,n=t.steps.map((s,o)=>{let a=p=>qt(so(Et(p,e.uniqueSeed??""))),i=`${o}. [${mp(t.steps,o)}] ${s.keyword} ${a(s.resolvedText)}`,c=s.resolvedTable??s.table,l=s.resolvedDocString??s.docString,d=i;return c&&(d+=`
|
|
89
|
+
${c.map(p=>` | ${p.map(a).join(" | ")} |`).join(`
|
|
90
|
+
`)}`),l!==void 0&&(d+=`
|
|
89
91
|
"""
|
|
90
92
|
${a(l).split(`
|
|
91
|
-
`).map(
|
|
93
|
+
`).map(p=>` ${p}`).join(`
|
|
92
94
|
`)}
|
|
93
|
-
"""`),
|
|
94
|
-
`),r=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(e.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.",
|
|
95
|
+
"""`),d}).join(`
|
|
96
|
+
`),r=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",n,""];if(e.mode==="refine")r.push("MODE: REFINE. You (or a previous agent session) just recorded this scenario and it completed successfully \u2014 but the deterministic zero-AI replay of that recording FAILED at the step below. The application is working; the RECORDING is not replayable.","The browser tab shows the state where the proof replay stopped.",Kn(e),"",`Steps that replayed fine:
|
|
95
97
|
${(e.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
|
|
96
|
-
`)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Replay failure: ${e.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.",
|
|
98
|
+
`)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Replay failure: ${e.failedStep?.error}`,"","Typical causes: a locator that only matched during your session (volatile accessible name \u2014 use nameRegex or a shorter stable name), a literal dynamic value baked into an assertion (use kind=matches/differs/captureText), or an assertion that cannot be checked without navigation (use kind=attribute for link targets).","Re-perform and re-record the failed step and every step after it in a REPLAYABLE form via saffron_step + browser tools / saffron_capture / saffron_assert. Express the SAME conditions the steps state \u2014 do not weaken them.","If a step genuinely cannot be expressed replayably, call saffron_done success=false and explain why.",pi(e));else if(e.mode==="heal")r.push("MODE: HEAL. A cached deterministic replay of this scenario failed mid-run.","The browser tab already holds the page state reached so far \u2014 do NOT restart from step 0; verify where you are with a snapshot and continue from the failed step.",Kn(e),"",`Steps already completed by replay:
|
|
97
99
|
${(e.completedSteps??[]).map(s=>` \u2713 ${s}`).join(`
|
|
98
|
-
`)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Failure: ${e.failedStep?.error}`,e.failedStep?.pageUrl?`Page when replay failed: ${e.failedStep.pageUrl}`:void 0,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",
|
|
100
|
+
`)||" (none)"}`,"",`Failed step: [${e.failedStep?.kind}] ${e.failedStep?.text}`,`Failure: ${e.failedStep?.error}`,e.failedStep?.pageUrl?`Page when replay failed: ${e.failedStep.pageUrl}`:void 0,"","First diagnose: did the UI legitimately change (heal: adapt the actions, record via saffron_step + browser tools), or is the application broken (fail: saffron_done success=false with your diagnosis)?",yp,e.assertionPolicy==="adaptable-mid"?"If the failed step is an [assertion:final]: you may re-verify the SAME condition once against the live page (timing), but if it does not hold AS WRITTEN, the run FAILS \u2014 call saffron_done success=false; never substitute it. If the failed step is a mid-scenario [assertion]: this project's policy allows adapting it \u2014 verify the equivalent condition on the legitimately-changed UI, record it via saffron_assert, and report the deviation via saffron_step's adaptation field plus featureEdits. An application defect still fails.":"If the failed step is an [assertion] or [assertion:final]: you may re-verify the SAME condition once against the live page (it may have been a timing issue). If the condition AS WRITTEN does not hold, the run FAILS \u2014 call saffron_done success=false. Do NOT substitute a different element, text, or URL that 'means the same thing'; suggesting a corrected assertion belongs in suggestedFeatureEdit of your failure report, never in saffron_assert.","Announce (saffron_step) and perform only the failed step and the steps after it. Earlier steps keep their recordings.",pi(e));else if(e.mode==="record"&&e.recordOnly){let{from:s,to:o}=e.recordOnly;r.push("MODE: RECORD (segment). This scenario is being composed from existing recordings plus your work.",e.completedSteps&&e.completedSteps.length>0?`Already executed against the live browser (do NOT redo):
|
|
99
101
|
${e.completedSteps.map(a=>` \u2713 ${a}`).join(`
|
|
100
|
-
`)}`:"You are starting from a blank page.",
|
|
101
|
-
`),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,
|
|
102
|
-
`)}import{query as
|
|
103
|
-
`)),{calls:r,order:s,events:o}=ad(n),a=new Map;for(let u of o){let p=u.snapshot;if(u.type!=="frame-snapshot"||!p?.isMainFrame||!p.pageId||!p.frameUrl)continue;let b=p.snapshotName??(p.callId&&p.phase?`${p.phase}@${p.callId}`:void 0);b&&a.set(`${p.pageId}/${b}`,p.frameUrl)}for(let u of r.values())for(let p of[u.before,u.input,u.after])p&&(p.url=a.get(`${p.pageId}/${p.name}`));let i=[];for(let u of s){let p=r.get(u),b=p.parentId?r.get(p.parentId):void 0;b?b.calls.push(p):i.push(u)}let c=u=>{let{parentId:p,isGroup:b,title:g,isWait:S,...T}=u;return{...T,calls:T.calls.map(A=>c(A))}},l=u=>u.find(p=>p.error&&!p.isWait)?.error,f=[],d=[];for(let u of i){let p=r.get(u);if(!p.isGroup){d.push(c(p));continue}let b={title:p.title??"",startTime:p.startTime,endTime:p.endTime,error:p.error,actions:[],calls:[]},g=/^\[saffron:(agent|replay|pending):(-?\d+)\] (.*)$/.exec(b.title);g&&(b.phase=g[1],b.stepIndex=Number(g[2]),b.title=g[3]);let S=[];for(let T of p.calls)if(T.isGroup){let A={title:T.title??"",startTime:T.startTime,endTime:T.endTime,error:T.error??l(T.calls),calls:T.calls.map(_=>c(_))},R=/^\[saffron:action:(\d+)\] ([\s\S]*)$/.exec(A.title);R&&(A.actionIndex=Number(R[1]),A.title=R[2]),b.actions.push(A)}else S.push(T),b.calls.push(c(T));b.error||(b.error=b.actions.find(T=>T.error)?.error??l(S)),f.push(b)}let m=[],h=new Map,y,x,k,w=Number.POSITIVE_INFINITY,v=0;for(let u of o)if(u.type==="context-options")y=W(u.title),x=W(u.browserName),k=Number(u.version)||void 0,w=Math.min(w,Ue(u.monotonicTime,w));else if(u.type==="screencast-frame"){let p=W(u.file)??(W(u.sha1)?`resources/${W(u.sha1)}`:""),b={pageId:W(u.pageId)??"",file:p,sha1:p.slice(p.lastIndexOf("/")+1),timestamp:Ue(u.timestamp),width:Ue(u.width),height:Ue(u.height)};m.push(b),h.has(b.pageId)||h.set(b.pageId,{pageId:b.pageId})}else if(u.type==="event"&&u.class==="BrowserContext"&&u.method==="page"){let p=W(u.params?.pageId);p&&h.set(p,{...h.get(p),pageId:p,openedAt:Ue(u.time)})}else if(u.type==="event"&&u.class==="BrowserContext"&&u.method==="pageClosed"){let p=W(u.params?.pageId);p&&h.set(p,{...h.get(p),pageId:p,closedAt:Ue(u.time)})}else if(u.type==="frame-snapshot"){let p=u.snapshot;if(p?.pageId){let b=h.get(p.pageId)??{pageId:p.pageId};p.isMainFrame&&p.frameUrl&&(b.url=p.frameUrl),h.set(p.pageId,b)}}for(let u of r.values())w=Math.min(w,u.startTime),v=Math.max(v,u.endTime??u.startTime);for(let u of m)v=Math.max(v,u.timestamp);return Number.isFinite(w)||(w=0),{format:k,title:y,browserName:x,startTime:w,endTime:v,steps:f,calls:d,frames:m,requests:id(e),pages:[...h.values()],resources:e.filter(u=>u.name.startsWith("resources/")).map(u=>u.name.slice(10))}}function id(e){let t=[];for(let n of e.filter(r=>r.name.endsWith(".network")))for(let r of n.data.toString("utf8").split(`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
`)}`:"You are starting from a blank page.",Kn(e),"",`Record ONLY steps ${s} through ${o} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${o} already have recordings and will be replayed automatically the moment you stop. After completing step ${o}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(e.completedSteps&&e.completedSteps.length>0){let s=e.completedSteps.length;r.push("MODE: RECORD (seeded). No cache exists for this scenario yet, but its first steps matched existing recordings and were ALREADY EXECUTED against the live browser by zero-AI replay:",e.completedSteps.map(o=>` \u2713 ${o}`).join(`
|
|
103
|
+
`),"",`The browser currently shows the state after those ${s} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,Kn(e),`Start with saffron_step for step index ${s} and record from there to the end.`)}else r.push("MODE: RECORD. No cache exists for this scenario yet.","Execute every step in order, starting at step 0. Navigate to the base URL first if the scenario implies a starting page.");return r.filter(s=>s!==void 0).join(`
|
|
104
|
+
`)}import{query as wp}from"@anthropic-ai/claude-agent-sdk";var bp="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function $s(e,t=8e3){let n=e?.[bp];if(typeof n!="function")return;let r;try{let s=await Promise.race([n.call(e),new Promise(o=>{r=setTimeout(()=>o(void 0),t),r.unref?.()})]);return xp(s)}catch{return}finally{r&&clearTimeout(r)}}function xp(e){if(!e||!e.rate_limits_available||!e.subscription_type)return;let t=e.rate_limits?.five_hour;return{subscriptionType:e.subscription_type,utilization:typeof t?.utilization=="number"?t.utilization:void 0,resetsAt:t?.resets_at??void 0}}async function fi(){let e=()=>{},t=new Promise(s=>e=s);async function*n(){await t}let r;try{let s=wp({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=s;let o=(async()=>{try{for await(let i of s);}catch{}})(),a=await $s(s);return e(),r.close?.(),await o,a}catch{e(),r?.close?.();return}}import Ap from"node:fs";import yi from"node:path";import{createRequire as _p}from"node:module";import{createRequire as ui}from"node:module";var vp=ui(import.meta.url),Qn=ui(vp.resolve("playwright/package.json"));import{Client as Sp}from"@modelcontextprotocol/sdk/client/index.js";import{InMemoryTransport as kp}from"@modelcontextprotocol/sdk/inMemory.js";var $p=1e4,Tp=5e3,gi="saffron_connection_check",Xn=e=>new Promise(t=>{setTimeout(t,e).unref()}),cn=new Set,Ts=0,hi=e=>{Ts===0&&[...cn].at(-1)?.(e)};function Rp(e){let t=n=>e(n);return cn.size===0&&process.on("unhandledRejection",hi),cn.add(t),()=>{cn.delete(t)&&cn.size===0&&process.off("unhandledRejection",hi)}}async function Rs(e,t,n,r){await e.tracing.group(`[saffron:agent:${t.index}] ${t.title}`).catch(()=>{});try{await e.tracing.group(n).catch(()=>{});try{return await r()}finally{await e.tracing.groupEnd().catch(()=>{})}}finally{await e.tracing.groupEnd().catch(()=>{})}}function Ep(e,t={}){let n=o=>typeof o=="string"&&o.trim()?o.trim():void 0,r=n(t.element)??"an element",s=(()=>{switch(e){case"browser_snapshot":return"read the page";case"browser_find":return n(t.text)?`find "${n(t.text)}" on the page`:n(t.regex)?`find ${n(t.regex)} on the page`:"search the page";case"browser_click":return`${t.doubleClick?"double-click":"click"} ${r}`;case"browser_hover":return`hover ${r}`;case"browser_type":return`type into ${r}`;case"browser_fill_form":return"fill in the form";case"browser_select_option":return`select in ${r}`;case"browser_press_key":return n(t.key)?`press ${n(t.key)}`:"press a key";case"browser_navigate":return n(t.url)?`go to ${n(t.url)}`:"navigate";case"browser_navigate_back":return"go back";case"browser_wait_for":return n(t.text)?`wait for "${n(t.text)}"`:n(t.textGone)?`wait for "${n(t.textGone)}" to go`:`wait ${t.time??""}s`.replace(" s","");case"browser_tabs":return n(t.action)?`tabs: ${n(t.action)}`:"list the tabs";case"browser_drag":return`drag ${n(t.startElement)??"an element"} to ${n(t.endElement)??"an element"}`;case"browser_file_upload":return"upload files";case"browser_handle_dialog":return`${t.accept?"accept":"dismiss"} the dialog`;case"browser_evaluate":case"browser_run_code":case"browser_run_code_unsafe":return"run a script in the page";case"browser_take_screenshot":return"take a screenshot";case"browser_console_messages":return"read the console";case"browser_network_requests":return"read the network log";default:return}})();return s?`${s} \xB7 ${e}`:e}async function mi(e,t,n="none",r=()=>{},s={}){let{createConnection:o,resolveCLIConfigForMCP:a}=Qn("playwright-core/lib/coreBundle").tools,i=await a?.({},process.env)??{},c={...i,browser:{...i.browser,isolated:!1},sharedBrowserContext:!0,imageResponses:"omit",...s.outputDir?{outputDir:s.outputDir}:{},snapshot:{...i.snapshot,mode:n}},l=e.browser(),d=new Set(l?.listeners("disconnected")??[]),p=new Set,g=u=>{p.add(u)},f=u=>{u.on("download",g)};for(let u of e.pages())f(u);e.on("page",f);let y=Rp(r),$=u=>{e.off("page",f);for(let _ of e.pages())_.off("download",g);for(let _ of l?.listeners("disconnected")??[])d.has(_)||l.off("disconnected",_);if(!u)return y();let m=()=>{clearTimeout(R),e.off("close",E),y()},E=()=>{setTimeout(m,1e3).unref()},R=setTimeout(m,3e5);R.unref(),e.on("close",E)},T,v=new Sp({name:"saffron-trace",version:"1"});try{T=await o(c,async()=>e);let[u,m]=kp.createLinkedPair();await T.connect(m),await v.connect(u);let R=((await v.callTool({name:gi,arguments:{}})).content??[]).map(_=>_.text??"").join(`
|
|
105
|
+
`);if(!R.includes(gi))throw new Error(R.replace(/^### Error\s*/,"").trim()||"the browser backend did not start")}catch(u){throw await Promise.allSettled([v.close(),T?.close()]),$(!1),u}let S=Promise.resolve(),h=(u,m,E=t())=>{let R=S.then(()=>Rs(e,E,u,m));return S=R.catch(()=>{}),R},w=qr(v,(u,m,E)=>h(Ep(u,m),E),s.projectRoot),x=async()=>{let u=[...p];if(u.length===0)return;let m=Promise.all(u.map(R=>R.path().then(()=>!0,()=>!0))),E=!await Promise.race([m,Xn(Tp).then(()=>!1)]);E&&Ts++;try{E&&(await Promise.allSettled(u.map(R=>R.cancel())),await Promise.allSettled(u.map(R=>R.failure()))),await Xn(0),await Xn(0)}finally{E&&Ts--}};return{config:{type:"sdk",name:"playwright",instance:w},close:async()=>{await Promise.race([S,Xn($p)]),await x().catch(()=>{}),await Promise.allSettled([v.close()]),await Promise.allSettled([T.close()]),await Promise.allSettled([w.close()]),$(!0)},grouped:(u,m)=>h(u,m)}}var Pp=_p(import.meta.url);function Ip(){let e=Pp.resolve("@playwright/mcp/package.json");return yi.join(yi.dirname(e),"cli.js")}var Cp=new Set(["mcp__playwright__browser_navigate","mcp__playwright__browser_click","mcp__playwright__browser_type","mcp__playwright__browser_fill_form","mcp__playwright__browser_select_option","mcp__playwright__browser_press_key"]),jp=new Set(["mcp__playwright__browser_snapshot","mcp__playwright__browser_find","mcp__playwright__browser_wait_for","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_console_messages","mcp__playwright__browser_network_requests","mcp__playwright__browser_network_request","mcp__saffron__saffron_step","mcp__saffron__saffron_capture","mcp__saffron__saffron_dialog","mcp__saffron__saffron_wait_api","mcp__saffron__saffron_assert","mcp__saffron__saffron_done"]);function Es(e,t){if(jp.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Ot=class e{constructor(t,n){this.ctx=t;this.snapshotMode=n;this.state=new Wn(t.scenario,{firstStep:Ba(t),discardBeforeAnnounce:Ua(t)})}ctx;snapshotMode;state;usage={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0};resultText="";traced;capped;outputDir=Do();get projectRoot(){return this.ctx.projectRoot??process.cwd()}stopTabWatch;static async open(t,n,r){let s=new e(t,n);if(t.traceContext&&!r.traceBridgeUnavailable){let o=Ie(t.scenario);try{s.traced=await mi(t.traceContext,()=>s.currentStep(),n,a=>{let i=a instanceof Error?a.message:String(a);console.warn(`A promise rejected during the agent session and nothing handled it: ${Ce(i,o)}`)},{outputDir:s.outputDir,projectRoot:s.projectRoot})}catch(a){r.traceBridgeUnavailable=!0;let i=Ce(a instanceof Error?a.message:String(a),o);console.warn(`Agent tracing could not start (${i}). Continuing through the standard browser connection; agent calls will be absent from these traces.`)}}return s}currentStep(){let t=this.state.currentStepIndex,n=this.ctx.scenario.steps[t];return{index:t,title:n?`${n.keyword} ${n.text}`:"Agent exploration"}}async playwrightServer(){return this.traced?this.traced.config:(this.capped??=await Uo(this.playwrightStdio(),this.projectRoot),this.capped.config)}playwrightStdio(){return{command:process.execPath,args:[Ip(),"--cdp-endpoint",this.ctx.cdpEndpoint,"--snapshot-mode",this.snapshotMode,"--image-responses","omit","--output-dir",this.outputDir]}}noteThePage(t){let n="note the page for the recording";return this.traced?this.traced.grouped(n,t):this.ctx.traceContext?Rs(this.ctx.traceContext,this.currentStep(),n,t):t()}async beforeTool(t,n){if(this.state.beginToolCall(t),Cp.has(n)&&this.ctx.takeFingerprint&&!this.state.pendingFingerprint)try{this.state.pendingFingerprint=await this.noteThePage(this.ctx.takeFingerprint)}catch{}}afterTool(t,n,r,s){let o=Qa(n,r);o&&this.state.addArmedAction(o);let a=Za(n,r,this.ctx.baseURL);if(a&&this.state.addActions([a]),n.startsWith("mcp__playwright__")){let i=za(s);i!==void 0&&this.ctx.onActiveTab?.(i),this.state.addActions(qa(s,this.ctx.baseURL)),this.state.pendingFingerprint=void 0}this.state.endToolCall(t,Es(n,r))}failedTool(t,n,r){n.startsWith("mcp__playwright__")&&(this.state.pendingFingerprint=void 0),this.state.endToolCall(t,Es(n,r))}watchTabs(){this.stopTabWatch=this.ctx.onTabOpened?.(()=>this.state.markTabOpened())}sessionError(t){if(this.state.done)return;let n=t instanceof Error?t.message:String(t);this.state.done=!0,this.state.success=!1,this.state.narrative=`Agent session error: ${n}`}async close(){this.stopTabWatch?.(),this.stopTabWatch=void 0,await this.traced?.close(),await this.capped?.close(),Ap.rmSync(this.outputDir,{recursive:!0,force:!0})}result(){let{ctx:t,state:n}=this;return n.done||(n.success=!1,n.narrative=n.narrative||`Agent session ended without finalizing the run. Last output: ${this.resultText.slice(0,500)}`),{success:n.success,narrative:n.narrative,adaptations:[...n.adaptations,...n.attributionNotes()],suggestedFeatureEdit:n.suggestedFeatureEdit,featureEdits:n.featureEdits.map(r=>({index:r.index,text:ct(r.text,t.scenario.params)})),recordedSteps:Ja(Ka(Ya(Ga(Wa(Va(n.steps,t.scenario,r=>zt(Et(r,t.uniqueSeed??""))),t.scenario.params)),xn(ne(t.scenario))),At(ne(t.scenario),t.uniqueSeed??"")),Ie(t.scenario),ne(t.scenario)),announcedSteps:[...n.announced].sort((r,s)=>r-s),usage:this.usage}}};var er=class{constructor(t={}){this.opts=t}opts;planBefore;traceBridgeUnavailable=!1;async planUsage(){if(!this.planBefore)return;let t=await this.planBefore;if(!t)return;let n=await fi();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=await Ot.open(t,this.opts.snapshotMode??"none",this),r;try{r=Op({prompt:Zn(t),options:{systemPrompt:Yn({deliberateSnapshots:n.snapshotMode==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),model:t.model??this.opts.model,maxTurns:this.opts.maxTurns??100,tools:[],permissionMode:"bypassPermissions",allowDangerouslySkipPermissions:!0,persistSession:!1,settingSources:[],strictMcpConfig:!0,settings:{disableClaudeAiConnectors:!0},mcpServers:{playwright:await n.playwrightServer(),saffron:Jn(n.state)},hooks:{PreToolUse:[{hooks:[async s=>{let o=s;return await n.beforeTool(o.tool_use_id,o.tool_name),{continue:!0}}]}],PostToolUse:[{hooks:[async s=>{let o=s;return n.afterTool(o.tool_use_id,o.tool_name,o.tool_input,o.tool_response),{continue:!0}}]}],PostToolUseFailure:[{hooks:[async s=>{let o=s;return n.failedTool(o.tool_use_id,o.tool_name,o.tool_input),{continue:!0}}]}]}}})}catch(s){throw await n.close(),s}this.planBefore||(this.planBefore=$s(r)),n.watchTabs();try{for await(let s of r)s.type==="result"&&(n.usage.aiCalls=s.num_turns,n.usage.costUsd=s.total_cost_usd,"modelUsage"in s&&s.modelUsage&&(n.usage.models=Object.keys(s.modelUsage)),"usage"in s&&s.usage&&(n.usage.inputTokens=s.usage.input_tokens??0,n.usage.outputTokens=s.usage.output_tokens??0,n.usage.cacheReadTokens=s.usage.cache_read_input_tokens??0,n.usage.cacheCreationTokens=s.usage.cache_creation_input_tokens??0),s.subtype==="success"&&(n.resultText=s.result))}catch(s){n.sessionError(s)}finally{await n.close()}return n.result()}};import Fp from"node:http";import{randomBytes as Np,randomUUID as Mp,timingSafeEqual as Dp}from"node:crypto";import{Client as Lp}from"@modelcontextprotocol/sdk/client/index.js";import{InMemoryTransport as Up}from"@modelcontextprotocol/sdk/inMemory.js";import{Server as Bp}from"@modelcontextprotocol/sdk/server/index.js";import{StreamableHTTPServerTransport as zp}from"@modelcontextprotocol/sdk/server/streamableHttp.js";import{CallToolRequestSchema as qp,ListToolsRequestSchema as Hp}from"@modelcontextprotocol/sdk/types.js";async function Wp(e,t){let n=Np(16).toString("hex"),r=Buffer.from(`Bearer ${n}`),s=c=>{let l=Buffer.from(c??"");return l.length===r.length&&Dp(l,r)},o=(c,l)=>{let d=new Bp({name:c,version:"1"},{capabilities:{tools:{}}});return d.setRequestHandler(Hp,p=>l.listTools(p.params)),d.setRequestHandler(qp,async p=>{let g=Mp(),f=`mcp__${c}__${p.params.name}`,y=p.params.arguments??{};await t.beforeTool(g,f);try{let $=await l.callTool(p.params,void 0,{timeout:Br});return $.isError?t.failedTool(g,f,y):t.afterTool(g,f,y,$),$}catch($){throw t.failedTool(g,f,y),$}}),d},a=Fp.createServer(async(c,l)=>{let d=(c.url??"").split("?")[0].slice(1),p=s(c.headers.authorization)?e[d]:void 0;if(!p)return void l.writeHead(404).end();if(c.method!=="POST")return void l.writeHead(405,{Allow:"POST"}).end();let g=o(d,p),f=new zp({sessionIdGenerator:void 0,enableJsonResponse:!0});l.on("close",()=>{Promise.allSettled([f.close(),g.close()])});try{await g.connect(f),await f.handleRequest(c,l)}catch{l.headersSent||l.writeHead(500).end()}});a.requestTimeout=0,await new Promise((c,l)=>{a.once("error",l),a.listen(0,"127.0.0.1",()=>c())});let{port:i}=a.address();return{urls:Object.fromEntries(Object.keys(e).map(c=>[c,`http://127.0.0.1:${i}/${c}`])),token:n,close:async()=>{a.closeAllConnections(),await new Promise(c=>a.close(()=>c()))}}}async function wi(e){let[t,n]=Up.createLinkedPair();await e.connect(n);let r=new Lp({name:"saffron-gateway",version:"1"});return await r.connect(t),r}async function bi(e){let t=[];try{let n=await wi((await e.playwrightServer()).instance);t.push(n);let r=await wi(Jn(e.state).instance);t.push(r);let s=await Wp({playwright:n,saffron:r},e);return{urls:s.urls,token:s.token,close:async()=>{await s.close(),await Promise.allSettled(t.map(o=>o.close()))}}}catch(n){throw await Promise.allSettled(t.map(r=>r.close())),n}}var tr=class{constructor(t,n={},r=()=>{}){this.drive=t;this.opts=n;this.blocker=r}drive;opts;blocker;traceBridgeUnavailable=!1;async run(t){let n=this.blocker();if(n)throw new Error(n);let r=await Ot.open(t,this.opts.snapshotMode??"none",this),s;try{s=await bi(r)}catch(o){throw await r.close(),o}r.watchTabs();try{await this.drive({systemPrompt:Yn({deliberateSnapshots:r.snapshotMode==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),prompt:Zn(t),model:t.model??this.opts.model,maxTurns:this.opts.maxTurns??100,mcpServers:s.urls,mcpToken:s.token,usage:r.usage,lastOutput:o=>{r.resultText=o},finished:()=>r.state.done})}catch(o){r.sessionError(o)}finally{await s.close(),await r.close()}return r.result()}};async function ln(e,t){try{return await import(e)}catch(n){let r=n.code;throw(r==="ERR_MODULE_NOT_FOUND"||r==="MODULE_NOT_FOUND")&&String(n.message).includes(`'${e}'`)?new Error(`the ${t} provider needs ${e}: npm install --save-dev ${e}`):n}}function Ft(e){return`stopped after ${e} tool calls, the session's turn limit: raise maxTurns in saffron.config.json, or look at what the agent kept retrying`}import Nt from"node:fs";import As from"node:os";import _e from"node:path";import{createHash as Vp}from"node:crypto";import{createRequire as xi}from"node:module";function Gp(){try{let e=xi(import.meta.resolve("@openai/codex-sdk")).resolve("@openai/codex/package.json"),t=xi(e).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`),n=_e.join(_e.dirname(t),"vendor");for(let r of Nt.readdirSync(n))for(let s of[_e.join(n,r,"bin","codex"),_e.join(n,r,"codex","codex")])if(Nt.existsSync(s))return s}catch{}}function nr(e=process.platform){return e==="win32"?"the codex provider is not available on Windows yet: Saffron must run Codex without your ~/.codex/config.toml (your own MCP servers, plugins and hooks), and cannot on Windows":void 0}function Jp(e){return`'${e.replaceAll("'","'\\''")}'`}function Kp(){let e=nr();if(e)throw new Error(e);let t=Gp();if(!t)throw new Error("the Codex binary is missing from @openai/codex-sdk's install: reinstall it with npm install --save-dev @openai/codex-sdk");let n=_e.join(process.env.XDG_CACHE_HOME||_e.join(As.homedir(),".cache"),"saffron");Nt.mkdirSync(n,{recursive:!0});let r=_e.join(n,`codex-${Vp("sha256").update(t).digest("hex").slice(0,12)}`);return We(r,`#!/bin/sh
|
|
106
|
+
sub="$1"; shift
|
|
107
|
+
exec ${Jp(t)} "$sub" --ignore-user-config --ephemeral "$@"
|
|
108
|
+
`,493),r}var vi="SAFFRON_MCP_TOKEN",Yp=["cli_auth_credentials_store","forced_login_method","forced_chatgpt_workspace_id"];function Zp(e){let t=e.split(/^\s*\[/m)[0],n={};for(let r of Yp){let s=new RegExp(`^\\s*${r}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,"m").exec(t);s&&(n[r]=s[1]??s[2])}return n}function _s(){try{return Zp(Nt.readFileSync(_e.join(process.env.CODEX_HOME??_e.join(As.homedir(),".codex"),"config.toml"),"utf8"))}catch{return{}}}var Si=async e=>{let{Codex:t}=await ln("@openai/codex-sdk","codex"),n=Nt.mkdtempSync(_e.join(As.tmpdir(),"saffron-codex-"));try{let r=Object.fromEntries(Object.entries(process.env).filter(l=>l[1]!==void 0)),o=new t({codexPathOverride:Kp(),env:{...r,[vi]:e.mcpToken},config:{..._s(),developer_instructions:e.systemPrompt,include_permissions_instructions:!1,include_apps_instructions:!1,include_collaboration_mode_instructions:!1,include_environment_context:!1,project_doc_max_bytes:0,skills:{include_instructions:!1},mcp_servers:Object.fromEntries(Object.entries(e.mcpServers).map(([l,d])=>[l,{url:d,bearer_token_env_var:vi,required:!0,default_tools_approval_mode:"approve",startup_timeout_sec:30,tool_timeout_sec:600}])),features:{shell_tool:!1,view_image:!1,apps:!1,plugins:!1,hooks:!1,memories:!1,goals:!1,image_generation:!1,tool_suggest:!1,browser_use:!1,computer_use:!1,unified_exec:!1,multi_agent:!1,code_mode_host:!1,in_app_browser:!1,browser_use_external:!1}}}).startThread({model:e.model,workingDirectory:n,skipGitRepoCheck:!0,sandboxMode:"read-only",approvalPolicy:"never",webSearchMode:"disabled"}),a=new AbortController,i=0,c;try{let{events:l}=await o.runStreamed(e.prompt,{signal:a.signal});for await(let d of l)if(d.type==="item.started"&&d.item.type==="mcp_tool_call")++i>e.maxTurns&&!a.signal.aborted&&(c=Ft(e.maxTurns),a.abort());else if(d.type==="item.completed")(d.item.type==="mcp_tool_call"||d.item.type==="agent_message")&&e.usage.aiCalls++,d.item.type==="agent_message"&&e.lastOutput(d.item.text);else if(d.type==="turn.completed"){let p=d.usage;e.usage.inputTokens=p.input_tokens-p.cached_input_tokens,e.usage.cacheReadTokens=p.cached_input_tokens,e.usage.cacheCreationTokens=p.cache_write_input_tokens??0,e.usage.outputTokens=p.output_tokens}else d.type==="turn.failed"&&!a.signal.aborted?c=d.error.message:d.type==="error"&&!a.signal.aborted&&(c??=d.message)}catch(l){if(!a.signal.aborted&&!c)throw l}if(e.usage.models=[`codex/${e.model??"default"}`],c&&!e.finished()){let l=/auth|log ?in|401|unauthori[sz]ed/i.test(c)?". Sign in with your ChatGPT plan: npx @openai/codex login":"";throw new Error(`Codex: ${c}${l}`)}}finally{Nt.rmSync(n,{recursive:!0,force:!0})}};import{spawn as Qp,spawnSync as Xp}from"node:child_process";import Y from"node:fs";import sr from"node:os";import Q from"node:path";import ef from"node:readline";var rr="install the Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash), then run `agy` once to sign in with your Google account";function or(){for(let e of[...(process.env.PATH??"").split(Q.delimiter),Q.join(sr.homedir(),".local","bin")]){let t=Q.join(e,process.platform==="win32"?"agy.exe":"agy");if(e&&Y.existsSync(t))return t}}var Mt={playwright:"saffron_browser",saffron:"saffron_tools"},ki=Object.values(Mt).map(e=>`mcp(${e}/*)`),Ps=()=>Q.join(sr.homedir(),".gemini","antigravity-cli","settings.json");function $i(e){if(!Y.existsSync(e))return{};try{let t=JSON.parse(Y.readFileSync(e,"utf8"));if(t&&typeof t=="object"&&!Array.isArray(t))return t}catch{}throw new Error(`${e} is not a JSON object Saffron can add to: fix it (or ask agy to rewrite it), then run npx saffron login antigravity again`)}function ar(e=Ps()){let t;try{t=$i(e).permissions?.allow}catch{return ki}return ki.filter(n=>!(Array.isArray(t)&&t.includes(n)))}function tf(e=Ps()){let t=$i(e),n=ar(e);if(n.length===0)return[];let r=t.permissions&&typeof t.permissions=="object"?t.permissions:{},s=Array.isArray(r.allow)?r.allow:[],o={...t,permissions:{...r,allow:[...s,...n]}},a=Y.lstatSync(e,{throwIfNoEntry:!1})?.isSymbolicLink()??!1,i=nf(e),c=Y.existsSync(i)?Y.statSync(i).mode&4095:void 0;try{We(i,JSON.stringify(o,null,2)+`
|
|
109
|
+
`,c)}catch(l){let d=l.code;throw!a||!["EACCES","EPERM","EROFS"].includes(d??"")?l:new Error(`${e} links to ${i}, which Saffron cannot write: add ${n.join(" and ")} to permissions.allow there, then run npx saffron login antigravity again`)}return n}function nf(e){let t=e;for(let n=0;n<40&&Y.lstatSync(t,{throwIfNoEntry:!1})?.isSymbolicLink();n++)t=Q.resolve(Y.realpathSync(Q.dirname(t)),Y.readlinkSync(t));return Y.existsSync(t)?Y.realpathSync(t):t}async function Ti(){let e=or();if(!e)throw new Error(`the antigravity provider needs the agy command: ${rr}`);let t=Xp(e,["models"],{encoding:"utf8",timeout:6e4});if(t.status!==0){let r=`${t.stderr??""}${t.stdout??""}`.trim().split(`
|
|
110
|
+
`).at(-1)||(t.error?.message??"no answer");throw new Error(`agy could not list your models (${r}): run agy once and sign in with your Google AI Pro or Ultra account`)}let n=tf();console.log(n.length>0?`Antigravity: allowed Saffron's tools in ${Ps()}: ${n.join(", ")}. Remove those lines to take it back.`:"Antigravity: Saffron's tools are already allowed.")}var Ri=Q.join(sr.homedir(),".gemini","antigravity-cli","mcp"),rf=Object.values(Mt).map(e=>Q.join(Ri,e)),sf=`const path=require("path");const DIRS=${JSON.stringify(rf)};const SERVERS=${JSON.stringify(Object.values(Mt))};
|
|
111
|
+
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let t={};try{t=JSON.parse(s).toolCall||{}}catch{}const a=t.args||{};
|
|
112
|
+
const file=typeof a.AbsolutePath==="string"?path.resolve(a.AbsolutePath):"";
|
|
113
|
+
const dir=typeof a.DirectoryPath==="string"?path.resolve(a.DirectoryPath):"";
|
|
114
|
+
const ok=(t.name==="call_mcp_tool"&&SERVERS.includes(a.ServerName))||(t.name==="view_file"&&DIRS.includes(path.dirname(file)))||(t.name==="list_dir"&&DIRS.includes(dir));
|
|
115
|
+
process.stdout.write(JSON.stringify(ok?{decision:"allow"}:{decision:"deny",reason:"Only Saffron's browser and saffron tools are available in a Saffron session."}))})`,of=(e,t)=>`let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let i=0;try{i=JSON.parse(s).invocationNum}catch{}
|
|
116
|
+
const stop=i+1>=${e};if(stop)require("fs").writeFileSync(${JSON.stringify(t)},"");
|
|
117
|
+
process.stdout.write(JSON.stringify(stop?{terminationBehavior:"terminate"}:{}))})`,Ei=async e=>{let t=or();if(!t)throw new Error(`the antigravity provider needs the agy command: ${rr}`);if(Object.keys(e.mcpServers).length>0&&ar().length>0)throw new Error("agy does not allow Saffron's tools yet: run npx saffron login antigravity once");let n=Y.mkdtempSync(Q.join(sr.tmpdir(),"saffron-agy-"));try{let r=Q.join(n,".agents");Y.mkdirSync(Q.join(r,"agents"),{recursive:!0}),Y.writeFileSync(Q.join(r,"agents","saffron.md"),`---
|
|
118
|
+
name: saffron
|
|
119
|
+
description: Saffron's browser test agent.
|
|
120
|
+
mainAgent: true
|
|
121
|
+
subagent: false
|
|
122
|
+
commandExecutionPolicy: off
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
${e.systemPrompt}
|
|
126
|
+
|
|
127
|
+
Your tools are MCP tools, called with call_mcp_tool: the browser_* tools are on the ${Mt.playwright} server and the saffron_* tools on the ${Mt.saffron} server. Before a tool's first call, read its parameters with view_file from ${Q.join(Ri,"<server>","<tool>.json")}. No other tool is available.
|
|
128
|
+
`),Y.writeFileSync(Q.join(r,"mcp_config.json"),JSON.stringify({mcpServers:Object.fromEntries(Object.entries(e.mcpServers).map(([T,v])=>[Mt[T]??T,{serverUrl:v,headers:{Authorization:`Bearer ${e.mcpToken}`}}]))}));let s=(T,v)=>{let S=Q.join(n,T);return Y.writeFileSync(S,v),`${JSON.stringify(process.execPath)} ${JSON.stringify(S)}`};Y.writeFileSync(Q.join(r,"hooks.json"),JSON.stringify({"saffron-gate":{PreToolUse:[{matcher:"",hooks:[{type:"command",command:s("gate.cjs",sf)}]}]},"saffron-turns":{PostInvocation:[{type:"command",command:s("turns.cjs",of(e.maxTurns,Q.join(n,"turn-limit")))}]}}));let a=["--output-format","stream-json","--agent","saffron","--print-timeout",`${Math.max(30,2*e.maxTurns)}m`,"--sandbox"];e.model&&a.push("--model",e.model);let i=Qp(t,a,{cwd:n,stdio:["pipe","pipe","pipe"]});i.stdin.on("error",()=>{}),i.stdin.end(e.prompt);let c="";i.stderr.on("data",T=>{c=(c+T).slice(-4e3)});let l=new Promise((T,v)=>i.once("error",S=>v(new Error(`Antigravity: could not start ${t}: ${S.message}. ${rr}`)))),d=new Promise(T=>i.once("close",T)),p="",g,f,y=(async()=>{for await(let T of ef.createInterface({input:i.stdout})){let v;try{v=JSON.parse(T)}catch{continue}let S=v.step_update;S?(S.state==="DONE"&&S.usage&&e.usage.aiCalls++,S.step_type==="agent_response"&&typeof S.text_delta=="string"&&(S.step_index!==g&&([p,g]=["",S.step_index]),p+=S.text_delta,e.lastOutput(p))):v.event==="result"&&(f=v.result)}})();await Promise.race([y,l]);let $=await Promise.race([d,l]);if(f?.usage&&(e.usage.inputTokens+=Math.max(0,(f.usage.input_tokens??0)-(f.usage.cache_read_tokens??0)),e.usage.outputTokens+=f.usage.output_tokens??0,e.usage.cacheReadTokens+=f.usage.cache_read_tokens??0),e.usage.models=[`antigravity/${e.model??"default"}`],f?.response&&e.lastOutput(f.response),!e.finished()&&Y.existsSync(Q.join(n,"turn-limit")))throw new Error(`Antigravity: ${Ft(e.maxTurns)}`);if((f?.status!=="SUCCESS"||$!==0)&&!e.finished()){let T=typeof f?.error=="string"?f.error:c.trim().split(`
|
|
129
|
+
`).at(-1)||`agy exited with ${$}`;throw new Error(`Antigravity: ${T}${/auth/i.test(T)?`. ${rr}`:""}`)}}finally{Y.rmSync(n,{recursive:!0,force:!0})}};import Ai from"node:fs";import af from"node:os";import cf from"node:path";var Pi="@cursor/sdk";function dn(e=process.versions.node){let[t,n]=e.split(".").map(Number);return t>22||t===22&&n>=13?void 0:`the cursor provider needs Node.js 22.13 or newer (this is ${e})`}var Ii=async e=>{let t=dn();if(t)throw new Error(t);let{Agent:n,JsonlLocalAgentStore:r}=await ln(Pi,"cursor"),s=Ai.mkdtempSync(cf.join(af.tmpdir(),"saffron-cursor-")),o=a=>n.create({model:{id:e.model??"auto"},...a?{systemPrompt:a}:{},tools:["mcp"],mcpServers:Object.fromEntries(Object.entries(e.mcpServers).map(([i,c])=>[i,{type:"http",url:c,headers:{Authorization:`Bearer ${e.mcpToken}`}}])),local:{cwd:s,settingSources:[],store:new r(s)}});try{let a=await _i(await o(e.systemPrompt),e.prompt,e);if(a.status==="error"&&/system.?prompt/i.test(a.error?.message??"")&&(a=await _i(await o(void 0),`${e.systemPrompt}
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
${e.prompt}`,e)),a.status==="error"&&!e.finished())throw new Error(`Cursor: ${a.error?.message??"the run failed"}${lf(a.error?.message)}`)}finally{Ai.rmSync(s,{recursive:!0,force:!0})}};async function _i(e,t,n){try{let r=await e.send(t),s="",o=new Set,a=!1;for await(let c of r.stream())if(c.type==="usage")n.usage.aiCalls++;else if(c.type==="assistant"){for(let l of c.message.content)l.type==="text"&&(s+=l.text);n.lastOutput(s)}else c.type==="tool_call"&&(s="",c.status==="running"&&o.add(c.call_id??`#${o.size}`),o.size>n.maxTurns&&!a&&(a=!0,await r.cancel()));let i=await r.wait();if(i.usage&&(n.usage.inputTokens+=i.usage.inputTokens,n.usage.outputTokens+=i.usage.outputTokens,n.usage.cacheReadTokens+=i.usage.cacheReadTokens,n.usage.cacheCreationTokens+=i.usage.cacheWriteTokens),i.model&&(n.usage.models=[...new Set([...n.usage.models??[],`cursor/${i.model.id}`])]),i.result&&n.lastOutput(i.result),a&&!n.finished())throw new Error(`Cursor: ${Ft(n.maxTurns)}`);return i}finally{e.close()}}function lf(e=""){return/api key|unauthenticated|unauthori[sz]ed|logged.?out/i.test(e)?". Sign in once with: npx saffron login cursor":""}async function Ci(){let e=dn();if(e)throw new Error(e);let{Cursor:t}=await ln(Pi,"cursor"),n=await t.auth.status();if(n.status==="logged-in"){console.log(`Cursor: already signed in${n.email?` as ${n.email}`:""}.`);return}await t.auth.login({onLoginUrl:r=>console.log(`Opening ${r}`)}),console.log("Cursor: signed in. Saffron runs will use your Cursor plan.")}var Is=["claude","codex","antigravity","cursor"];function Fi(e){return Is.includes(e)}var Cs={codex:Si,antigravity:Ei,cursor:Ii};function Ni(e,t){return e==="claude"?new er(t):new tr(Cs[e],t,()=>{let n=Mi(e);return n.length>0?`${dt[e]} cannot start here: ${n.join("; ")}`:void 0})}var cr=(...e)=>Oi.join(df.homedir(),...e);function ji(e){try{return import.meta.resolve(e),!0}catch{return!1}}function Mi(e){switch(e){case"claude":return[];case"codex":{let t=nr();return t?[t]:ji("@openai/codex-sdk")?[]:["npm install --save-dev @openai/codex-sdk"]}case"antigravity":return or()?ar().length>0?["Saffron's tools allowed in agy, once: npx saffron login antigravity"]:[]:["the Antigravity CLI: curl -fsSL https://antigravity.google/cli/install.sh | bash","then run `agy` once to sign in with your Google AI Pro or Ultra account"];case"cursor":{let t=dn();return t?[t]:ji("@cursor/sdk")?[]:["npm install --save-dev @cursor/sdk"]}}}function js(e){let t=process.env,n=Mi(e);if(e==="claude")t.ANTHROPIC_API_KEY||t.CLAUDE_CODE_OAUTH_TOKEN||t.CLAUDE_CODE_USE_BEDROCK||t.CLAUDE_CODE_USE_VERTEX||ir.existsSync(cr(".claude",".credentials.json"))||ir.existsSync(cr(".claude.json"))||n.push("ANTHROPIC_API_KEY=sk-ant-... (console.anthropic.com \u2192 API keys)","or a Claude Code login on this machine");else if(e==="codex"&&!nr()){let r=Oi.join(t.CODEX_HOME??cr(".codex"),"auth.json");!["keyring","auto"].includes(_s().cli_auth_credentials_store??"")&&!ir.existsSync(r)&&n.push("a Codex login with your ChatGPT plan: npx @openai/codex login")}else e==="cursor"&&!dn()&&!t.CURSOR_API_KEY&&!ir.existsSync(cr(".cursor","sdk","auth.json"))&&n.push("a Cursor sign-in for Saffron (once per machine): npx saffron login cursor");return n}var dt={claude:"Claude",codex:"Codex",antigravity:"Antigravity",cursor:"Cursor"};var Je=(e,t=0)=>typeof e=="number"?e:t,G=e=>typeof e=="string"?e:void 0;function pf(e,t){if(!t)return;let n=G(t.selector);if(n)return ff(n);let r=G(t.url);if(r)return r;if(e==="press"||e==="keyboardPress")return G(t.key)}function ff(e){return e.replace(/internal:role=([a-z]+)\[name="((?:[^"\\]|\\.)*)"[is]?\]/g,(t,n,r)=>`${n} "${r}"`).replace(/internal:role=([a-z]+)/g,"$1").replace(/internal:label="((?:[^"\\]|\\.)*)"[is]?/g,'label "$1"').replace(/internal:text="((?:[^"\\]|\\.)*)"[is]?/g,'text "$1"').replace(/internal:attr=\[placeholder="((?:[^"\\]|\\.)*)"[is]?\]/g,'placeholder "$1"').replace(/internal:testid=\[data-testid="((?:[^"\\]|\\.)*)"s?\]/g,'test id "$1"').replace(/internal:has-text="((?:[^"\\]|\\.)*)"[is]?/g,'with text "$1"').replace(/ >> nth=0$/,"").replace(/ >> /g," \u203A ").replace(/nth=(\d+)/g,"#$1")}function uf(e){let t=new Map,n=[],r=[];for(let s of e){if(!s.trim())continue;let o;try{o=JSON.parse(s)}catch{continue}r.push(o);let a=G(o.callId);switch(o.type){case"before":{if(!a)break;let i=G(o.method)??"",c=o.class==="Tracing"&&i==="tracingGroup",l=i==="__waitInfo__";t.set(a,{callId:a,method:l?G(o.title)??"wait":i,subject:pf(i,o.params),selector:G(o.params?.selector),startTime:Je(o.startTime),log:[],pageId:G(o.pageId),before:Os(o,"beforeSnapshot"),calls:[],parentId:G(o.parentId),isGroup:c,title:G(o.title),isWait:l}),n.push(a);break}case"input":{let i=a?t.get(a):void 0;if(i){i.input=Os({...o,pageId:i.pageId},"inputSnapshot");let c=o.point,l=o.box,d=c??(l?{x:l.x+l.width/2,y:l.y+l.height/2}:void 0);i.input&&d?i.input.point=d:d&&(i.inputPoint=d)}break}case"frame-snapshot":{let i=o.snapshot,c=i?.callId?t.get(i.callId):void 0;if(c&&i?.phase&&i.pageId&&i.isMainFrame!==!1){let l=i.phase,d={pageId:i.pageId,name:`${l}@${i.callId}`,callId:i.callId,phase:l};if(c.pageId||(c.pageId=i.pageId),l==="before"&&!c.before)c.before=d;else if(l==="after")c.after=d;else if(l==="action"&&!c.input){let p=c.inputPoint;c.input=p?{...d,point:p}:d}}break}case"log":{let i=a?t.get(a):void 0,c=G(o.message);i&&c&&i.log.push(c);break}case"after":{let i=a?t.get(a):void 0;if(!i)break;i.endTime=Je(o.endTime,i.startTime);let c=o.error;c?.message&&(i.error=c.message);let l=Os({...o,pageId:i.pageId},"afterSnapshot");l&&(i.after=l);break}default:break}}return{calls:t,order:n,events:r}}function Os(e,t){let n=G(e[t]),r=G(e.pageId);return n&&r?{pageId:r,name:n}:void 0}function Di(e){return Fs(Ho(e))}function Fs(e){let n=e.filter(h=>h.name.endsWith(".trace")).flatMap(h=>h.data.toString("utf8").split(`
|
|
134
|
+
`)),{calls:r,order:s,events:o}=uf(n),a=new Map;for(let h of o){let w=h.snapshot;if(h.type!=="frame-snapshot"||!w?.isMainFrame||!w.pageId||!w.frameUrl)continue;let x=w.snapshotName??(w.callId&&w.phase?`${w.phase}@${w.callId}`:void 0);x&&a.set(`${w.pageId}/${x}`,w.frameUrl)}for(let h of r.values())for(let w of[h.before,h.input,h.after])w&&(w.url=a.get(`${w.pageId}/${w.name}`));let i=[];for(let h of s){let w=r.get(h),x=w.parentId?r.get(w.parentId):void 0;x?x.calls.push(w):i.push(h)}let c=h=>{let{parentId:w,isGroup:x,title:b,isWait:u,...m}=h;return{...m,calls:m.calls.map(E=>c(E))}},l=h=>h.find(w=>w.error&&!w.isWait)?.error,d=[],p=[];for(let h of i){let w=r.get(h);if(!w.isGroup){p.push(c(w));continue}let x={title:w.title??"",startTime:w.startTime,endTime:w.endTime,error:w.error,actions:[],calls:[]},b=/^\[saffron:(agent|replay|pending):(-?\d+)\] (.*)$/.exec(x.title);b&&(x.phase=b[1],x.stepIndex=Number(b[2]),x.title=b[3]);let u=[];for(let m of w.calls)if(m.isGroup){let E={title:m.title??"",startTime:m.startTime,endTime:m.endTime,error:m.error??l(m.calls),calls:m.calls.map(_=>c(_))},R=/^\[saffron:action:(\d+)\] ([\s\S]*)$/.exec(E.title);R&&(E.actionIndex=Number(R[1]),E.title=R[2]),x.actions.push(E)}else u.push(m),x.calls.push(c(m));x.error||(x.error=x.actions.find(m=>m.error)?.error??l(u)),d.push(x)}let g=[],f=new Map,y,$,T,v=Number.POSITIVE_INFINITY,S=0;for(let h of o)if(h.type==="context-options")y=G(h.title),$=G(h.browserName),T=Number(h.version)||void 0,v=Math.min(v,Je(h.monotonicTime,v));else if(h.type==="screencast-frame"){let w=G(h.file)??(G(h.sha1)?`resources/${G(h.sha1)}`:""),x={pageId:G(h.pageId)??"",file:w,sha1:w.slice(w.lastIndexOf("/")+1),timestamp:Je(h.timestamp),width:Je(h.width),height:Je(h.height)};g.push(x),f.has(x.pageId)||f.set(x.pageId,{pageId:x.pageId})}else if(h.type==="event"&&h.class==="BrowserContext"&&h.method==="page"){let w=G(h.params?.pageId);w&&f.set(w,{...f.get(w),pageId:w,openedAt:Je(h.time)})}else if(h.type==="event"&&h.class==="BrowserContext"&&h.method==="pageClosed"){let w=G(h.params?.pageId);w&&f.set(w,{...f.get(w),pageId:w,closedAt:Je(h.time)})}else if(h.type==="frame-snapshot"){let w=h.snapshot;if(w?.pageId){let x=f.get(w.pageId)??{pageId:w.pageId};w.isMainFrame&&w.frameUrl&&(x.url=w.frameUrl),f.set(w.pageId,x)}}for(let h of r.values())v=Math.min(v,h.startTime),S=Math.max(S,h.endTime??h.startTime);for(let h of g)S=Math.max(S,h.timestamp);return Number.isFinite(v)||(v=0),{format:T,title:y,browserName:$,startTime:v,endTime:S,steps:d,calls:p,frames:g,requests:gf(e),pages:[...f.values()],resources:e.filter(h=>h.name.startsWith("resources/")).map(h=>h.name.slice(10))}}function gf(e){let t=[];for(let n of e.filter(r=>r.name.endsWith(".network")))for(let r of n.data.toString("utf8").split(`
|
|
135
|
+
`)){if(!r.trim())continue;let s;try{s=JSON.parse(r)}catch{continue}if(s.type!=="resource-snapshot"||!s.snapshot)continue;let o=s.snapshot,a=o.request,i=o.response,c=o._monotonicTime;t.push({pageId:G(o.pageref),method:a?.method??"GET",url:a?.url??"",status:i?.status,mimeType:i?.content?.mimeType,startedAt:Date.parse(G(o.startedDateTime)??"")||0,time:typeof o.time=="number"?o.time:void 0,timestamp:typeof c=="number"?c:void 0})}return t}import Lt from"node:fs";import{randomBytes as Yi}from"node:crypto";import Li from"node:fs";import Dt from"node:path";var Ui="This run has no reviewable proposal identity. Run the scenario again to review from replay.",Ns="The proposal does not belong to this replay.",Bi=e=>e instanceof Error?e.message:String(e),zi=(e,t)=>`No pending proposal at ${Dt.relative(e,t)}: it has been accepted, rejected or removed since this run.`;function qi(e,t){let n=/ \(example \d+\)$/.exec(t.scenario),r=n?[t.scenario,t.scenario.slice(0,n.index)]:[t.scenario],s=o=>o.split(/[\\/]/).slice(-2).join("/");return r.map(o=>be(e,t.feature,o)).find(o=>s(o)===s(t.proposalFile))}function Hi(e,t,n){if(Gt(n)!==t.proposalRevision)return{available:!1,reason:"The proposal changed since this run. Open the latest run before reviewing it."};let r=i=>({available:!1,reason:i,rejectable:!0}),s;try{s=Rt(n)}catch(i){if(i instanceof J)return r(`The proposal cannot be read: ${Dt.relative(e,n)}: ${i.problem}`);throw i}let{feature:o,scenario:a}=s.cache;if(Dt.resolve(be(e,o,a))!==Dt.resolve(n)||o!==t.feature||!(t.scenario===a||t.scenario===s.meta.recordedFor||t.scenario.startsWith(a+" (example ")))return{available:!1,reason:Ns};try{if(!Li.existsSync(Dt.resolve(e,o)))return r(`STALE: ${o} no longer exists`);let i=Fe(e,ie(e),[Dt.resolve(e,o)]),c=Me(e,s,i);if(c)return r(`STALE: ${c}`);let l=t.status==="red",d=s.meta.verified===!0&&!l,p=l?t.error??"This run replayed the proposal and it failed.":s.meta.proofError;return{available:!0,verified:d,...!d&&p?{proofError:p}:{},unbound:!s.meta.bound,diff:_t(pe(K(e,o,a)),s.cache),featureEdits:s.meta.featureEdits??[]}}catch(i){return r(Bi(i))}}function Wi(e,t){if(!t.proposalFile||!t.proposalRevision)return{available:!1,reason:Ui};try{let n=qi(e,{...t,proposalFile:t.proposalFile});return n?Li.existsSync(n)?Hi(e,t,n):{available:!1,reason:zi(e,n)}:{available:!1,reason:Ns}}catch(n){return{available:!1,reason:Bi(n)}}}function Vi(e,t,n,r=!1){if(!t.proposalFile||!t.proposalRevision)throw new Error(Ui);let s=qi(e,{...t,proposalFile:t.proposalFile});if(!s)throw new Error(Ns);On(s,()=>{let o=Hi(e,t,s);if(n==="reject"){if(!o.available&&!o.rejectable)throw new Error(o.reason);Mn(s)}else{if(!o.available)throw new Error(o.reason);if(!o.verified&&!r)throw new Error("Confirm that you want to accept a proposal without successful proof replay.");Nn(e,s)}},()=>{throw new Error(zi(e,s))})}import Af from"node:http";import ft from"node:path";var M=e=>e.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',"""),Ji="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",hf="M24.085 45.646L24.085 45.646Q20.719 45.646 18.407 44.575Q16.095 43.504 14.905 41.651Q13.715 39.798 13.715 37.384L13.715 37.384L19.121 37.384Q19.121 38.336 19.648 39.152Q20.175 39.968 21.263 40.461Q22.351 40.954 24.085 40.954L24.085 40.954Q25.649 40.954 26.703 40.546Q27.757 40.138 28.301 39.407Q28.845 38.676 28.845 37.724L28.845 37.724Q28.845 36.534 27.825 35.837Q26.805 35.140 24.527 34.936L24.527 34.936L22.657 34.766Q18.951 34.460 16.741 32.454Q14.531 30.448 14.531 27.150L14.531 27.150Q14.531 24.770 15.670 23.036Q16.809 21.302 18.849 20.367Q20.889 19.432 23.677 19.432L23.677 19.432Q26.601 19.432 28.692 20.435Q30.783 21.438 31.905 23.257Q33.027 25.076 33.027 27.524L33.027 27.524L27.587 27.524Q27.587 26.606 27.145 25.841Q26.703 25.076 25.836 24.600Q24.969 24.124 23.677 24.124L23.677 24.124Q22.453 24.124 21.620 24.532Q20.787 24.940 20.379 25.637Q19.971 26.334 19.971 27.150L19.971 27.150Q19.971 28.204 20.719 28.986Q21.467 29.768 23.167 29.904L23.167 29.904L25.071 30.074Q27.791 30.312 29.865 31.247Q31.939 32.182 33.112 33.797Q34.285 35.412 34.285 37.724L34.285 37.724Q34.285 40.104 33.044 41.889Q31.803 43.674 29.525 44.660Q27.247 45.646 24.085 45.646Z";function Ds(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
|
|
136
|
+
<path d="${Ji}" fill="#F4A300"/>
|
|
137
|
+
<path d="${hf}" fill="#E7E9EC"/>
|
|
138
|
+
</svg>`}function mf(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${Ji}" fill="#F4A300"/></svg>`}var yf={green:"passed",yellow:"pending review",red:"failed"};function wf(e){return e.hooks?.length?`<div class="panel"><div class="panel-title">setup and teardown</div>${e.hooks.map(n=>{let r=n.exitCode===0,s=r?"ok":n.timedOut?"timed out":n.exitCode===null?"could not start":`exit ${n.exitCode}`;return`<div class="mono">${r?"\u2713":"\u2717"} ${M(String(n.phase))} \xB7 ${M(n.command)} \xB7 ${M(s)} \xB7 ${M(fn(n.durationMs))}</div>`}).join("")}</div>`:""}function fn(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function pn(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}function De(e,t,n,r="",s="",o=""){return`<div class="kpi">
|
|
108
139
|
<div class="kpi-label">${e}</div>
|
|
109
140
|
<div class="kpi-value ${r}">${t}</div>
|
|
110
141
|
<div class="kpi-sub ${s}">${n}</div>${o}
|
|
111
|
-
</div>`}function
|
|
112
|
-
${
|
|
113
|
-
${
|
|
114
|
-
${
|
|
115
|
-
${
|
|
116
|
-
${
|
|
117
|
-
${
|
|
118
|
-
${n.plan?
|
|
119
|
-
</section>`}function
|
|
120
|
-
`)}</section>`:""}function
|
|
142
|
+
</div>`}function Ms(e,t){let n=e.filter(l=>l!==null);if(n.length<2)return"";let r=108,s=22,o=Math.min(...n),i=Math.max(...n)-o||1,c=e.map((l,d)=>{if(l===null)return"";let p=`${(d/(e.length-1)*r).toFixed(1)},${(s-2-(l-o)/i*(s-4)).toFixed(1)}`;return d===0||e[d-1]===null?`M${p}h0`:`L${p}`}).join("");return`<svg class="spark" viewBox="0 0 ${r} ${s}" width="${r}" height="${s}" aria-hidden="true"><path d="${c}" fill="none" stroke="${t}" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/></svg>`}var Gi=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function bf(e,t){let n=e.totals,r=e.trends,s=n.scenarios===0?0:Math.round((n.green+n.yellow)/n.scenarios*100),o=n.red>0?"red":n.yellow>0?"gold":"green",a=n.inputTokens+n.outputTokens,i=r?.prev?`${Gi(r.prev.passRatePp,"pp")} \xB7 ${n.green+n.yellow} of ${n.scenarios}`:`${n.green+n.yellow} of ${n.scenarios} scenarios`,c=r?.prev?.costUsd!==void 0?`${Gi(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${fn(t)}`:`${fn(t)} total runtime`;return`<section class="kpis">
|
|
143
|
+
${De("pass rate",`${s}<span class="unit">%</span>`,i,o,r?.prev?r.prev.passRatePp>=0?"green":"red":"",Ms(r?.series.passRate??[],"var(--green)"))}
|
|
144
|
+
${De("passed",String(n.green),"deterministic replay","green",n.green>0?"green":"")}
|
|
145
|
+
${De("pending",String(n.yellow),n.yellow>0?"\u25B2 proposals to review":"nothing to review",n.yellow>0?"gold":"",n.yellow>0?"gold":"")}
|
|
146
|
+
${De("failed",String(n.red),n.red>0?"\u25BC needs attention":"all clear",n.red>0?"red":"",n.red>0?"red":"green")}
|
|
147
|
+
${De("ai calls",String(n.aiCalls),`${pn(a)} in+out tokens`,n.aiCalls>0?"":"green")}
|
|
148
|
+
${De("cache traffic",pn(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${pn(n.cacheReadTokens)} read \xB7 ${pn(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
|
|
149
|
+
${n.plan?De("plan usage",`${cs(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${n.plan.subscriptionType} plan`,ma(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+De("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${c}`,"","",Ms(r?.series.costUsd??[],"var(--gold)")):De("ai cost",he(n)?`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`:"n/a",he(n)?c:"not reported by the provider","","",he(n)?Ms(r?.series.costUsd??[],"var(--gold)"):"")}
|
|
150
|
+
</section>`}function xf(e){let t=e.trends;if(!t)return"";let n=[];return t.chronic.length>0&&n.push(`<div class="panel accent-gold"><div class="panel-title gold">chronic scenarios: stop paying for heals</div><ul>${t.chronic.map(r=>`<li>${M(r.feature)} \u203A ${M(r.scenario)}: ${r.heals} heal(s), ${r.reds} red(s) in the last ${r.runs} run(s). Re-record it or fix the feature text.</li>`).join("")}</ul></div>`),t.divergentSteps>0&&n.push(`<div class="panel"><div class="panel-title">step divergence (level-2 signal)</div><p>${t.divergentSteps} step text(s) currently have divergent recordings across caches.</p></div>`),t.topIssues.length>0&&n.push(`<div class="panel accent-red"><div class="panel-title red">recurring failure themes (last 20 runs)</div><ul>${t.topIssues.map(r=>`<li>${r.count}\xD7: ${M(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
|
|
151
|
+
`)}</section>`:""}function vf(e){return`<ol class="steps">${e.steps.map(n=>{let r=n.status==="passed"?'<span class="dot green"></span>':n.status==="failed"?'<span class="dot red"></span>':'<span class="dot idle"></span>';return`<li class="step ${n.status}">
|
|
121
152
|
${r}
|
|
122
153
|
<div class="step-body">
|
|
123
|
-
<span class="step-text">${
|
|
154
|
+
<span class="step-text">${M(n.gherkin)}</span>
|
|
124
155
|
${n.kind==="assertion"?'<span class="tag">assert</span>':""}
|
|
125
|
-
${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${
|
|
156
|
+
${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${M(n.fromStepSet)}</span>`:""}
|
|
126
157
|
${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
|
|
127
158
|
${n.retries?`<span class="tag gold" title="an action failed and was re-attempted with backoff before the step settled">retried \xD7${n.retries}</span>`:""}
|
|
128
|
-
${n.error?`<div class="step-error">${
|
|
159
|
+
${n.error?`<div class="step-error">${M(n.error)}</div>`:""}
|
|
129
160
|
</div>
|
|
130
161
|
</li>`}).join(`
|
|
131
|
-
`)}</ol>`}function
|
|
162
|
+
`)}</ol>`}function Sf(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
|
|
132
163
|
<div class="panel-title gold">suggested .feature edit</div>
|
|
133
|
-
<div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${
|
|
164
|
+
<div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${M(r)}</div><div class="diff-line add">+ ${M(n.text)}</div>`}).join("")}</div>
|
|
134
165
|
<div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
|
|
135
|
-
</div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${
|
|
166
|
+
</div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${M(e.suggestedFeatureEdit)}</pre></div>`:""}function kf(e){let t=e.proposalDiff;if(!t)return"";let n=t.steps.filter(c=>c.change!=="same"||!t.hasCommitted).map(c=>{let l=`<div class="diff-step">${c.kind==="assertion"?'<span class="pill gold">assertion</span> ':""}${M(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${M(c.fromStepSet)}")</span>`:""}</div>`,d=c.actions.flatMap(p=>p.change==="same"?[`<div class="diff-line"> ${M(p.before??"")}</div>`]:p.change==="added"?[`<div class="diff-line add">+ ${M(p.after??"")}</div>`]:p.change==="removed"?[`<div class="diff-line del">\u2212 ${M(p.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${M(p.before??"")}</div>`,`<div class="diff-line add">+ ${M(p.after??"")}</div>`,...(p.details??[]).map(g=>`<div class="diff-line detail">${M(g)}</div>`)]).join("");return`${l}<div class="diff">${d}</div>`}).join(""),{added:r,removed:s,changed:o,assertions:a}=t.summary,i=r+s+o===0?"No action changed.":`${o} changed, ${r} added, ${s} removed.${a>0?` ${a} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${M(i)}</div></div>`}function $f(e,t){let n=e.usage.inputTokens+e.usage.outputTokens;if(e.usage.aiCalls===0)return'<span class="pill green">0 tokens</span>';let r=ha(e.usage.costUsd,t),s=r?` \xB7 ${r}`:"";return`<span class="pill gold">${e.usage.aiCalls} ai \xB7 ${pn(n)} tok${s}</span>`}function Tf(e,t,n){return`<details class="card" ${e.status!=="green"?"open":""}>
|
|
136
167
|
<summary>
|
|
137
|
-
<span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${
|
|
138
|
-
<span class="card-title">${
|
|
168
|
+
<span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${yf[e.status]}</span>
|
|
169
|
+
<span class="card-title">${M(e.scenario)}</span>
|
|
139
170
|
<span class="card-meta">
|
|
140
|
-
<span class="m">${
|
|
171
|
+
<span class="m">${M(e.feature)}</span>
|
|
141
172
|
<span class="m-sep">\xB7</span>
|
|
142
|
-
<span class="m">${
|
|
173
|
+
<span class="m">${M(e.source)}</span>
|
|
143
174
|
<span class="m-sep">\xB7</span>
|
|
144
|
-
<span class="m">${
|
|
145
|
-
${
|
|
175
|
+
<span class="m">${fn(e.durationMs)}</span>
|
|
176
|
+
${$f(e,t)}
|
|
146
177
|
</span>
|
|
147
178
|
</summary>
|
|
148
179
|
<div class="card-body">
|
|
149
|
-
${
|
|
150
|
-
${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${
|
|
151
|
-
${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(r=>`<li>${
|
|
152
|
-
${
|
|
153
|
-
${
|
|
154
|
-
${e.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${e.verified===!0?' <span class="pill green">verified</span>':e.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${
|
|
180
|
+
${vf(e)}
|
|
181
|
+
${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${M(e.narrative)}</p></div>`:""}
|
|
182
|
+
${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(r=>`<li>${M(r)}</li>`).join("")}</ul></div>`:""}
|
|
183
|
+
${Sf(e)}
|
|
184
|
+
${kf(e)}
|
|
185
|
+
${e.proposalFile?`<div class="panel"><div class="panel-title">cache proposal pending${e.verified===!0?' <span class="pill green">verified</span>':e.verified===!1?' <span class="pill red">unverified</span>':""}</div><p class="mono">${M(e.proposalFile)}</p>${e.verified===!1&&e.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${M(e.proofError.split(`
|
|
155
186
|
`)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
|
|
156
|
-
${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${
|
|
157
|
-
${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([r,s])=>`<div class="mono">${
|
|
158
|
-
${e.proofUniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in the proof replay</div>${Object.entries(e.proofUniques).map(([r,s])=>`<div class="mono">${
|
|
159
|
-
${
|
|
187
|
+
${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${M(e.error)}</p></div>`:""}
|
|
188
|
+
${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([r,s])=>`<div class="mono">${M(r)} = ${M(s)}</div>`).join("")}</div>`:""}
|
|
189
|
+
${e.proofUniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in the proof replay</div>${Object.entries(e.proofUniques).map(([r,s])=>`<div class="mono">${M(r)} = ${M(s)}</div>`).join("")}</div>`:""}
|
|
190
|
+
${Rf(e,n)}
|
|
160
191
|
</div>
|
|
161
|
-
</details>`}function
|
|
192
|
+
</details>`}function Rf(e,t){let n=(e.evidence??[]).find(o=>o.kind==="trace"),r=n?`<div class="panel accent-gold"><div class="panel-title gold">execution replay</div><div>Step through this run with the page inspectable at every action:</div><div class="mono" style="margin-top:6px">npx saffron trace ${M(Ef(`${e.feature}:${e.scenario}`))}</div><div class="panel-hint">${M(n.file)}</div></div>`:"",s=(e.evidence??[]).flatMap(o=>{if(!t||o.kind!=="failure"&&o.kind!=="before-heal")return[];let a=Tt(t,o);if(!a)return[];try{let i=a.toString("base64"),c=o.kind==="failure"?"the page when it failed":"the page when the cached step stopped working, before the agent took over";return[`<figure class="shot"><figcaption>${M(c)}${o.step?`: <span class="mono">${M(o.step)}</span>`:""}</figcaption><a href="data:image/jpeg;base64,${i}" target="_blank" rel="noopener"><img alt="${M(c)}" src="data:image/jpeg;base64,${i}"/></a><div class="panel-hint">${M(o.file)}</div></figure>`]}catch{return[]}});return s.length===0?r:`<div class="panel ${e.status==="red"?"accent-red":"accent-gold"}"><div class="panel-title ${e.status==="red"?"red":"gold"}">screenshot</div>${s.join("")}</div>${r}`}var Ef=e=>/[^A-Za-z0-9_./:-]/.test(e)?`'${e.replace(/'/g,"'\\''")}'`:e;function lr(e,t){let n=e.totals,r=new Date(e.startedAt),s=new Date(e.finishedAt).getTime()-r.getTime(),o=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
|
|
162
193
|
<html lang="en">
|
|
163
194
|
<head>
|
|
164
195
|
<meta charset="utf-8"/>
|
|
@@ -283,37 +314,37 @@ ${e.completedSteps.map(a=>` \u2713 ${a}`).join(`
|
|
|
283
314
|
<div class="wrap">
|
|
284
315
|
<header class="top">
|
|
285
316
|
<div class="brand">
|
|
286
|
-
${
|
|
317
|
+
${Ds(38)}
|
|
287
318
|
<div>
|
|
288
319
|
<div class="brand-name">saffron</div>
|
|
289
320
|
<div class="brand-sub">test run report</div>
|
|
290
321
|
</div>
|
|
291
322
|
</div>
|
|
292
323
|
<div class="run-meta">
|
|
293
|
-
<span>${
|
|
324
|
+
<span>${M(o)}</span>
|
|
294
325
|
<span>\xB7</span>
|
|
295
|
-
<span>${
|
|
296
|
-
${e.baseURL?`<span class="chip base">${
|
|
326
|
+
<span>${fn(s)}</span>
|
|
327
|
+
${e.baseURL?`<span class="chip base">${M(e.baseURL)}</span>`:""}
|
|
297
328
|
${e.shards?`<span class="chip">merged from ${e.shards} shards</span>`:""}
|
|
298
329
|
</div>
|
|
299
330
|
</header>
|
|
300
331
|
|
|
301
|
-
${
|
|
302
|
-
${
|
|
303
|
-
${
|
|
332
|
+
${bf(e,s)}
|
|
333
|
+
${xf(e)}
|
|
334
|
+
${wf(e)}
|
|
304
335
|
|
|
305
336
|
<div class="section-head">
|
|
306
337
|
<span class="section-title">scenarios \xB7 ${n.scenarios}</span>
|
|
307
|
-
<span class="section-title">saffron v${
|
|
338
|
+
<span class="section-title">saffron v${M(e.version)}</span>
|
|
308
339
|
</div>
|
|
309
|
-
${e.scenarios.map(a=>
|
|
340
|
+
${e.scenarios.map(a=>Tf(a,!!e.totals.plan,t)).join(`
|
|
310
341
|
`)}
|
|
311
342
|
|
|
312
|
-
<footer>${
|
|
343
|
+
<footer>${mf(11)} generated by saffron v${M(e.version)} \xB7 ${M(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
|
|
313
344
|
</div>
|
|
314
345
|
</body>
|
|
315
346
|
</html>
|
|
316
|
-
`}function
|
|
347
|
+
`}function Ki(){return`<!DOCTYPE html>
|
|
317
348
|
<html lang="en">
|
|
318
349
|
<head>
|
|
319
350
|
<meta charset="utf-8">
|
|
@@ -400,7 +431,7 @@ ${e.completedSteps.map(a=>` \u2713 ${a}`).join(`
|
|
|
400
431
|
</head>
|
|
401
432
|
<body>
|
|
402
433
|
<header>
|
|
403
|
-
${
|
|
434
|
+
${Ds(26)}
|
|
404
435
|
<div><div class="name" id="title">Saffron replay</div><div class="sub" id="subtitle"></div></div>
|
|
405
436
|
<span class="status" id="status"></span>
|
|
406
437
|
<label id="scenarioPicker" hidden>Scenario <select id="scenarioSelect" aria-label="Scenario"></select></label>
|
|
@@ -1011,47 +1042,48 @@ ${e.completedSteps.map(a=>` \u2713 ${a}`).join(`
|
|
|
1011
1042
|
})();
|
|
1012
1043
|
</script>
|
|
1013
1044
|
</body>
|
|
1014
|
-
</html>`}var
|
|
1015
|
-
`),l.set(h
|
|
1016
|
-
`));return a}import
|
|
1017
|
-
`),
|
|
1018
|
-
`)).digest("hex").slice(0,12);return{scenarios:o.map(i=>e[i]),info:{index:t.index,total:t.total,by:n,suite:a,suiteSize:e.length,positions:o}}}function
|
|
1019
|
-
`),r}var
|
|
1020
|
-
`));let l=e.sort((
|
|
1021
|
-
`)}function
|
|
1045
|
+
</html>`}var pt=class extends Error{},Zi="This trace is gone, or a later run replaced it (one that stopped before writing its report). Run the scenario again to replay it.",_f=4;function Us(e,t){let n=ft.join(e,t.file);try{Lt.accessSync(n,Lt.constants.R_OK)}catch(s){let o=s.code;if(o==="ENOENT"||o==="ENOTDIR")return;throw s}if(!$t(ft.join(e,".saffron","artifacts"),n))throw new Error(`${t.file} is not a file under .saffron/artifacts`);let r=Lt.readFileSync(n);return t.sha256&&rt(r)!==t.sha256?void 0:r}function Pf(){let e=Qn.resolve("playwright-core/package.json");return ft.join(ft.dirname(e),"lib","vite","traceViewer")}var Le={".html":"text/html; charset=utf-8",".js":"text/javascript; charset=utf-8",".css":"text/css; charset=utf-8",".json":"application/json; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".jpeg":"image/jpeg",".jpg":"image/jpeg",".ttf":"font/ttf",".webmanifest":"application/manifest+json",".zip":"application/zip"},If=new Set([".png",".jpeg",".jpg"]),Ls={"Content-Security-Policy":"sandbox","X-Content-Type-Options":"nosniff"};async function Qi(e){let t=e.entries?[...e.entries]:[{scenario:e.scenario,traces:[{file:e.traceFile,data:e.traceData,label:e.label??"Execution",proof:e.proof,steps:e.scenario.steps,uniques:e.scenario.uniques},...(e.additionalTraces??[]).map(v=>({...v,proof:v.proof??!0}))]}],n=e.start??0;if(!t[n])throw new Error("No scenario to replay");let r=v=>{let S=Yt(v),h=Fs(S.map(w=>({name:w.name,get data(){return w.read()}})));return{zip:v,model:h,resources:new Map(S.filter(w=>w.name.includes("/")).map(w=>[w.name,w.read]))}},s=v=>{try{return v.data?v.data:!v.read&&!Lt.existsSync(v.file)?new pt(Zi):(v.read?v.read():Lt.readFileSync(v.file))??new pt(Zi)}catch(S){return S instanceof pt?S:new pt(`This trace cannot be read: ${S instanceof Error?S.message:String(S)}`)}},o=[];{let v=t[n],S=v.traces.map(s),h=v.traces.map((w,x)=>({t:w,data:S[x]})).filter(({data:w},x)=>x===0||Buffer.isBuffer(w));for(let[w,x]of v.traces.entries()){let b=S[w];w>0&&!Buffer.isBuffer(b)&&o.push({file:x.file,reason:b.message})}t[n]={...v,traces:h.map(({t:w,data:x})=>({...w,data:Buffer.isBuffer(x)?x:void 0,read:Buffer.isBuffer(x)?void 0:()=>{throw x}}))}}let a=new Map,i=(v,S)=>{let h=`${v}:${S}`,w=a.get(h);if(w)a.delete(h);else{let x=s(t[v].traces[S]);if(!Buffer.isBuffer(x))throw x;w=r(x);let b=[...a.keys()].filter(u=>{let[m,E]=u.split(":").map(Number),R=t[m].traces[E];return m!==n&&(R.data!==void 0||R.checked===!0)});v!==n&&b.length>=_f&&a.delete(b[0])}return a.set(h,w),w},c=new Map,l=(v,S)=>{let h=t[v].scenario.screenshots?.find(b=>b.file===S);if(!h)return;let w=`${v}:${S}`;if(c.has(w))return c.get(w);let x=Tt(e.projectRoot,h);return(v===n||!h.sha256)&&c.set(w,x),x};try{i(n,0)}catch(v){if(t.length===1)throw v}for(let v of t[n].scenario.screenshots??[])l(n,v.file);let d=Pf(),p=Ki(),g=Yi(32).toString("hex"),f=Yi(8).toString("hex"),y=t.map(v=>({feature:v.scenario.feature,scenario:v.scenario.scenario,status:v.scenario.status})),$=Af.createServer((v,S)=>{let h=(w,x,b,u={})=>{S.writeHead(w,{"Content-Type":b,"Cache-Control":"no-store",...u}),S.end(x)};try{let w=v.headers.host??"";if(!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/i.test(w))return h(403,"Invalid host","text/plain");let x=new URL(v.url??"/",`http://${w}`),b=Number(x.searchParams.get("scenario")??n),u=Number.isInteger(b)?t[b]:void 0,m=Number(x.searchParams.get("session")??"0"),E=u&&Number.isInteger(m)?u.traces[m]:void 0,R=()=>h(404,u?"No such trace session":"No such scenario","text/plain");if(x.pathname.startsWith("/api/review/")){if(v.method!=="POST")return h(405,"Use POST","text/plain");if(v.headers.origin!==`http://${w}`||v.headers["x-saffron-review"]!==g)return h(403,"Review must come from this replay page","text/plain");if(!u||!E)return R();let _=x.pathname.slice(12);if(_!=="accept"&&_!=="reject")return h(404,"Unknown decision","text/plain");try{return Vi(e.projectRoot,u.scenario,_,v.headers["x-saffron-unverified"]==="yes"),h(200,JSON.stringify({decision:_}),Le[".json"])}catch(A){return h(409,JSON.stringify({error:A instanceof Error?A.message:String(A)}),Le[".json"])}}if(v.method!=="GET"&&v.method!=="HEAD")return h(405,"Use GET","text/plain");if(x.pathname==="/"||x.pathname==="/index.html")return h(200,p,Le[".html"]);if(x.pathname==="/api/replay"){let _=u?b:n,A=E?m:0,C=t[_],P=C.traces[A],O={scenarios:y,scenarioIndex:_,sessions:C.traces.map(N=>N.label),session:A,proof:P.proof===!0,instance:f},I;try{I=i(_,A).model}catch(N){let k=N instanceof pt?N.message:`This trace cannot be read: ${N instanceof Error?N.message:String(N)}`;return h(200,JSON.stringify({...O,scenario:C.scenario,unavailable:k}),Le[".json"])}let U=Wi(e.projectRoot,C.scenario);return h(200,JSON.stringify({...O,scenario:{...C.scenario,steps:P.steps,uniques:P.uniques},trace:I,review:U,reviewToken:g}),Le[".json"])}if(x.pathname.startsWith("/artifact/")){let _=x.searchParams.get("v");if(_!==null&&_!==f)return h(410,"This replay was restarted. Reload the page.","text/plain");if(!u||!E)return R();if(x.pathname==="/artifact/trace.zip")return h(200,i(b,m).zip,Le[".zip"],Ls);if(x.pathname.startsWith("/artifact/resource/")){let A=decodeURIComponent(x.pathname.slice(19)),C=i(b,m).resources,P=(C.get(A)??(A.includes("/")?void 0:C.get(`resources/${A}`)))?.();if(!P)return h(404,"no such resource","text/plain");let O=ft.extname(x.pathname).toLowerCase();return h(200,P,If.has(O)?Le[O]:"application/octet-stream",Ls)}if(x.pathname.startsWith("/artifact/screenshot/")){let A=l(b,decodeURIComponent(x.pathname.slice(21)));return A?h(200,A,Le[".jpg"],Ls):h(404,"no such file","text/plain")}}if(x.pathname==="/trace/ping")return h(200,"ok","text/plain");if(x.pathname.startsWith("/trace/")){let _=x.pathname.slice(7)||"index.html",A=ft.join(d,_);if(!$t(d,A))return h(404,"not found","text/plain");let C=ft.extname(A).toLowerCase(),P=Lt.readFileSync(A);return S.writeHead(200,{"Content-Type":Le[C]??"application/octet-stream","Service-Worker-Allowed":"/trace/"}),S.end(P)}h(404,"not found","text/plain")}catch{S.headersSent?S.destroy():h(400,"Invalid request","text/plain")}});return await new Promise((v,S)=>{$.once("error",S),$.listen(e.port??0,"127.0.0.1",()=>v())}),{url:`http://127.0.0.1:${$.address().port}/`,skipped:o,close:()=>new Promise(v=>$.close(()=>v()))}}import Xi from"node:fs";import dr from"node:path";function pr(e){return JSON.stringify([e.role??null,e.name??null,e.nameRegex??null,e.label??null,e.text??null,e.testId??null,e.placeholder??null,e.frame??null])}function ec(e,t){let n=new Map,r=new Set,s=new Map,o=Math.min(e.steps.length,t.steps.length);for(let i=0;i<o;i++){if(e.steps[i].kind==="assertion"||t.steps[i].kind==="assertion"||e.steps[i].gherkin!==t.steps[i].gherkin)continue;let c=e.steps[i].actions,l=t.steps[i].actions;if(c.length===l.length)for(let d=0;d<c.length;d++){let p=c[d],g=l[d];if(p.action!==g.action||ue.has(p.action)||!p.target||!g.target)continue;let f=pr(p.target),y=JSON.stringify(g.target);s.has(f)&&s.get(f)!==y&&r.add(f),s.set(f,y),f!==pr(g.target)&&n.set(f,{from:p.target,to:g.target})}}let a=[...n.entries()];return{changes:a.filter(([i])=>!r.has(i)).map(([,i])=>i),ambiguous:a.filter(([i])=>r.has(i)).map(([,i])=>i.from)}}function Cf(e){let t=dr.join(e,".saffron","cache");if(!Xi.existsSync(t))return[];let n=[];for(let r of Xi.readdirSync(t,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(dr.join(t,s))}return n}function tc(e,t,n,r){if(t.length===0)return[];let s=new Map,o=new Set;for(let i of t){let c=pr(i.from),l=s.get(c);l&&JSON.stringify(l.to)!==JSON.stringify(i.to)&&o.add(c),s.set(c,i)}for(let i of o)s.delete(i);let a=[];for(let i of Cf(e)){if(dr.resolve(i)===dr.resolve(n))continue;let c=xt(i);if(!c)continue;let l=[],d=!1;for(let p of c.steps){if(p.kind==="assertion")continue;let g=0;for(let f of p.actions){if(!f.target||ue.has(f.action))continue;let y=s.get(pr(f.target));y&&(g++,r||(f.target=structuredClone(y.to),d=!0))}g>0&&l.push({gherkin:p.gherkin,count:g})}l.length>0&&(d&&vt(i,c),a.push({file:i,scenario:c.scenario,feature:c.feature,steps:l}))}return a}function fr(e){return e.role?`${e.role} "${e.name??e.nameRegex??""}"`:e.label?`label "${e.label}"`:e.text?`text "${e.text}"`:e.testId?`testId "${e.testId}"`:(e.fallbackSelectors??[]).join(",")||"(empty target)"}import nc from"node:fs";import ur from"node:path";function rc(e,t,n,r,s){let o=de(ur.join(e,t),e,s).find(c=>c.name===n);if(!o)return`scenario "${n}" is no longer in ${t}`;let a=o.steps.map(c=>c.text);if(a.length!==r.length)return`the scenario has ${a.length} steps now and had ${r.length} when this was recorded`;let i=a.findIndex((c,l)=>c!==r[l]);return i<0?void 0:`step ${i+1} now reads "${a[i]}" and was recorded as "${r[i]}"`}function sc(e,t,n,r,s,o){let a={applied:[],skipped:[],appliedIndices:[]},i=de(ur.join(e,t),e,s).find(p=>p.name===n);if(!i){for(let p of r)a.skipped.push({index:p.index,reason:`scenario "${n}" not found in ${t}`});return a}let c=new Map;for(let p of r){let g=i.steps[p.index]?.source;if(!g||g.fromBackground)continue;let f=`${g.file}:${g.line}`;c.set(f,(c.get(f)??new Set).add(p.text))}let l=new Map,d=new Set;for(let p of r){let g=i.steps[p.index];if(!g?.source){a.skipped.push({index:p.index,reason:`no step at index ${p.index}`});continue}if(o&&o[p.index]!==g.text){a.skipped.push({index:p.index,reason:`step ${p.index+1} now reads "${g.text}", not the "${o[p.index]??"(nothing)"}" this edit was written for`});continue}let{file:f,line:y,fromStepSet:$,fromBackground:T}=g.source;if(T){a.skipped.push({index:p.index,reason:`"${g.text}" is a Background step shared across scenarios; edit it manually`});continue}let v=`${f}:${y}`,S=c.get(v);if(S.size>1){a.skipped.push({index:p.index,reason:`${v}${$?` (StepSet "${$}")`:""} is reached by several steps and the edits disagree: ${[...S].map(u=>`"${u}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(d.has(v)){a.appliedIndices.push(p.index);continue}let h=l.get(f);h||(h=nc.readFileSync(ur.join(e,f),"utf8").split(`
|
|
1046
|
+
`),l.set(f,h));let w=h[y-1],x=w?.match(/^(\s*)(\S+)\s+(.*)$/);if(!x||x[3]!==g.text){a.skipped.push({index:p.index,reason:`${f}:${y} no longer matches the recorded step text`});continue}let b=`${x[1]}${x[2]} ${p.text}`;h[y-1]=b,d.add(v),a.appliedIndices.push(p.index),a.applied.push({file:f,line:y,from:w.trim(),to:b.trim(),fromStepSet:$})}for(let[p,g]of l)a.applied.some(f=>f.file===p)&&nc.writeFileSync(ur.join(e,p),g.join(`
|
|
1047
|
+
`));return a}import Bs from"node:fs";import zs from"node:path";function qs(e){let t={scenarios:e.length,green:0,yellow:0,red:0,aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0},n=new Set;for(let r of e){for(let s of r.usage.models??[])n.add(s);t[r.status]+=1,t.aiCalls+=r.usage.aiCalls,t.inputTokens+=r.usage.inputTokens,t.outputTokens+=r.usage.outputTokens,t.cacheReadTokens+=r.usage.cacheReadTokens,t.cacheCreationTokens+=r.usage.cacheCreationTokens??0,t.costUsd+=r.usage.costUsd??0}return n.size>0&&(t.models=[...n].sort()),t}function Hs(e,t,n){return{tool:"saffron",version:He(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:qs(e),scenarios:e.map(r=>({feature:r.scenario.featurePath,scenario:r.scenario.displayName,tags:[...r.scenario.tags],status:r.status,source:r.source,proposalReplay:r.proposalReplay,durationMs:r.durationMs,usage:r.usage,steps:r.stepResults,narrative:r.narrative,adaptations:r.adaptations,suggestedFeatureEdit:r.suggestedFeatureEdit,featureEdits:r.featureEdits,verified:r.verified,proofError:r.proofError,proposalFile:r.proposalFile,proposalRevision:r.proposalRevision,proposalDiff:r.proposalDiff,error:r.error,evidence:r.evidence,uniques:r.uniques,proofUniques:r.proofUniques}))}}function Ws(e,t){let n=zs.join(e,".saffron","reports");Bs.mkdirSync(n,{recursive:!0});let r=zs.join(n,"latest.json"),s=zs.join(n,"latest.html");return Bs.writeFileSync(r,`${JSON.stringify(t,null,2)}
|
|
1048
|
+
`),Bs.writeFileSync(s,lr(t,e)),{json:r,html:s}}import jf from"node:crypto";import un from"node:fs";import gr from"node:path";var oc=["scenario","file"];function ac(e){let t=/^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(e),n=t?Number(t[1]):NaN,r=t?Number(t[2]):NaN;if(!t||r<1||n<1||n>r)throw new Error(`invalid --shard "${e}": use <index>/<total>, e.g. --shard 2/4 (index from 1 to total)`);return{index:n,total:r}}function ic(e){if(!oc.includes(e))throw new Error(`invalid --shard-by "${e}": use ${oc.map(t=>`"${t}"`).join(" or ")}`);return e}function Of(e){return`shard-${e.index}-of-${e.total}`}var Ff=e=>`${e.featurePath}${e.displayName}`;function cc(e,t){let n=t.by??"scenario",r=e.map(Ff),s=new Set;if(n==="file"){let i=new Map;e.forEach((d,p)=>i.set(d.featurePath,[...i.get(d.featurePath)??[],p]));let c=new Array(t.total).fill(0),l=[...i].sort((d,p)=>p[1].length-d[1].length||(d[0]<p[0]?-1:d[0]>p[0]?1:0));for(let[,d]of l){let p=c.indexOf(Math.min(...c));if(c[p]+=d.length,p===t.index-1)for(let g of d)s.add(g)}}else e.map((c,l)=>l).sort((c,l)=>r[c]<r[l]?-1:r[c]>r[l]?1:0).forEach((c,l)=>{l%t.total===t.index-1&&s.add(c)});let o=e.map((i,c)=>c).filter(i=>s.has(i)),a=jf.createHash("sha256").update([...r].sort().join(`
|
|
1049
|
+
`)).digest("hex").slice(0,12);return{scenarios:o.map(i=>e[i]),info:{index:t.index,total:t.total,by:n,suite:a,suiteSize:e.length,positions:o}}}function lc(e=process.env){return e.SAFFRON_RUN_ID||e.GITHUB_RUN_ID&&`github:${e.GITHUB_RUN_ID}`||e.CI_PIPELINE_ID&&`gitlab:${e.CI_PIPELINE_ID}`||e.BUILD_BUILDID&&`azure:${e.BUILD_BUILDID}`||e.CIRCLE_WORKFLOW_ID&&`circleci:${e.CIRCLE_WORKFLOW_ID}`||void 0}function Vs(e,t){let n=gr.join(e,".saffron","reports");un.mkdirSync(n,{recursive:!0});let r=gr.join(n,`${Of(t.shard)}.json`);return un.writeFileSync(r,`${JSON.stringify(t,null,2)}
|
|
1050
|
+
`),r}var Nf=e=>{let t=e;return!!t&&t.tool==="saffron"&&!!t.shard&&typeof t.shard.index=="number"&&typeof t.shard.total=="number"&&Array.isArray(t.shard.positions)&&Array.isArray(t.scenarios)&&!!t.history&&Array.isArray(t.history.scenarios)},Mf=/^shard-\d+-of-\d+\.json$/;function dc(e){let t=[],n=(r,s)=>{let o=un.statSync(r,{throwIfNoEntry:!1});if(!o)throw new Error(`not found: ${r}`);if(o.isDirectory())for(let a of un.readdirSync(r).sort())n(gr.join(r,a),!1);else(s||Mf.test(gr.basename(r)))&&t.push(r)};for(let r of e)n(r,!0);return t.map(r=>{let s;try{s=JSON.parse(un.readFileSync(r,"utf8"))}catch(o){throw new Error(`cannot read shard report ${r}: ${o instanceof Error?o.message:String(o)}`)}if(!Nf(s))throw new Error(`${r} is not a shard report (written by saffron run --shard)`);return{file:r,report:s}})}var hr=class extends Error{};function pc(e){if(e.length===0)throw new hr("no shard reports found. Shards write .saffron/reports/shard-<i>-of-<n>.json");let t=e[0].report.shard,n=[],r=h=>h.map(w=>w.file).join(", "),s=new Set(e.map(h=>h.report.shard.total));s.size>1&&n.push(`shards disagree on the shard count (${[...s].sort().join(", ")}): ${r(e)}`),new Set(e.map(h=>`${h.report.shard.suite}/${h.report.shard.suiteSize}`)).size>1&&n.push("shards ran different suites (different scenarios or filters). Every shard must run the same paths and --filter");let a=new Set(e.map(h=>h.report.shard.by??"scenario"));a.size>1&&n.push(`shards split the suite in different ways (--shard-by ${[...a].sort().join(" and ")}): every shard must use the same`);let i=new Set(e.map(h=>h.report.shard.ciRun??""));i.size>1&&n.push(`shards come from different CI runs (${[...i].map(h=>h||"none recorded").join(", ")})`);let c=new Map;for(let h of e)c.set(h.report.shard.index,[...c.get(h.report.shard.index)??[],h]);for(let[h,w]of[...c].sort((x,b)=>x[0]-b[0]))w.length>1&&n.push(`shard ${h}/${t.total} appears ${w.length} times: ${r(w)}`);if(s.size===1){let h=Array.from({length:t.total},(w,x)=>x+1).filter(w=>!c.has(w));h.length>0&&n.push(`missing ${h.length===1?"shard":"shards"} ${h.map(w=>`${w}/${t.total}`).join(", ")}. A merge without ${h.length===1?"it":"them"} would under-count the run`)}if(n.length===0){let h=new Set,w=0;for(let b of e){let{positions:u}=b.report.shard;(u.length!==b.report.scenarios.length||u.length!==b.report.history.scenarios.length)&&n.push(`${b.file} is inconsistent: ${u.length} positions for ${b.report.scenarios.length} scenarios`);for(let m of u)h.has(m)&&n.push(`two shards both ran scenario ${m+1} of the suite`),h.add(m);w+=u.length}let x=[...new Set(e.map(b=>b.report.baseURL??"(no base URL)"))];x.length>1&&n.push(`the shards ran against different base URLs: ${x.join(", ")}. Merge the shards of one environment at a time`),n.length===0&&w!==t.suiteSize&&n.push(`the shards hold ${w} scenarios but the suite has ${t.suiteSize}`)}if(n.length>0)throw new hr(n.join(`
|
|
1051
|
+
`));let l=e.sort((h,w)=>h.report.shard.index-w.report.shard.index).flatMap(({report:h})=>h.shard.positions.map((w,x)=>({position:w,scenario:h.scenarios[x],history:h.history.scenarios[x]}))).sort((h,w)=>h.position-w.position),d=e.map(h=>h.report),p=d.map(h=>h.startedAt).sort()[0],g=d.map(h=>h.finishedAt).sort().at(-1),f=l.map(h=>h.scenario),y=d.flatMap(h=>h.hooks??[]),$={tool:"saffron",version:d[0].version,startedAt:p,finishedAt:g,baseURL:d.find(h=>h.baseURL)?.baseURL,totals:qs(f),scenarios:f,shards:t.total};y.length>0&&($.hooks=y);let T=l.map(h=>h.history),S={runId:t.ciRun?`${t.ciRun}:${t.suite}:${$.baseURL??""}`:p,startedAt:p,finishedAt:g,baseURL:$.baseURL,green:T.filter(h=>h.status==="green").length,yellow:T.filter(h=>h.status==="yellow").length,red:T.filter(h=>h.status==="red").length,aiCalls:T.reduce((h,w)=>h+w.aiCalls,0),costUsd:T.reduce((h,w)=>h+w.costUsd,0),divergentSteps:Math.max(...d.map(h=>h.history.divergentSteps)),scenarios:T};return{report:$,history:S}}var gn=class extends Error{},Gs=new Set(["and","or","not","(",")"]);function Df(e){let t=[...e.matchAll(/[(),|]|[^\s(),|]+(?:\([^\s(),|]*\))*/g)].flatMap(([s])=>{let o=/^(and|or|not)\((.*)\)$/i.exec(s);return o?[o[1],"(",o[2],")"].filter(Boolean):[s]}),n=s=>s===","||s==="|",r=[];return t.forEach((s,o)=>{let a=r.at(-1),i=t[o+1];n(s)&&(a===void 0||n(a)||a==="("||i===void 0||n(i)||i===")")||r.push(s)}),r.map(s=>n(s)?"or":Gs.has(s.toLowerCase())?s.toLowerCase():s.startsWith("@")?s:`@${s}`)}function Lf(e){let t=Df(e),n=0,r=()=>t[n],s=d=>{throw new gn(`--filter "${e}": ${d}`)},o=!t.some(d=>d==="and"||d==="not"||d==="("||d===")"),a=()=>{let d=i();for(;r()==="or"||o&&r()!==void 0&&r()!==")";){r()==="or"&&n++;let p=i(),g=d;d=f=>g(f)||p(f)}return d},i=()=>{let d=c();for(;r()==="and";){n++;let p=c(),g=d;d=f=>g(f)&&p(f)}return d},c=()=>{let d=r();if(d===void 0)return s("expected a tag at the end");if(d==="not"){n++;let p=c();return g=>!p(g)}if(d==="("){n++;let p=a();return r()!==")"?s("missing a closing parenthesis"):(n++,p)}return Gs.has(d)?s(`expected a tag, found "${d}"`):(n++,p=>p.has(d))},l=a();return n<t.length?s(Gs.has(t[n])?`unexpected "${t[n]}"`:`unexpected "${t[n]}": with and, not or parentheses, join every tag with and/or`):l}function fc(e){let t=(e===void 0?[]:Array.isArray(e)?e:[e]).map(r=>r.trim()).filter(Boolean);if(t.length===0)return;let n=t.map(Lf);return{text:t.length===1?t[0]:t.map(r=>`(${r})`).join(" or "),matches:r=>{let s=new Set(r);return n.some(o=>o(s))}}}function uc(e,t){return t?e.filter(n=>t.matches(n.tags)):e}function gc(e,t){return[...t??[],e]}import{spawn as Uf}from"node:child_process";function Js(e,t){if(e==null)return[];let n=Array.isArray(e)?e:[e];for(let r of n)if(typeof r!="string"||r.trim()==="")throw new Error(`"${t}" in saffron.config.json must be a command, or a list of commands, e.g. "${t}": "npm run seed"`);return n}function Bf(e,t,n){let r=Date.now();return new Promise(s=>{let o=process.platform!=="win32",a=Uf(t,{cwd:n.cwd,shell:!0,detached:o,env:{...process.env,...n.env},stdio:["ignore",process.stderr,process.stderr]}),i=!1,c=!1,l=()=>{try{o&&a.pid?process.kill(-a.pid,"SIGKILL"):a.kill("SIGKILL")}catch{}},d=setTimeout(()=>{i=!0,l()},n.timeoutMs),p=()=>{c=!0,l()};n.signal?.addEventListener("abort",p,{once:!0});let g=f=>{clearTimeout(d),n.signal?.removeEventListener("abort",p),s({phase:e,command:t,exitCode:f,durationMs:Date.now()-r,...i?{timedOut:i}:{},...c?{cancelled:c}:{}})};a.on("error",()=>g(null)),a.on("exit",f=>g(i||c?null:f))})}var mr=e=>e.exitCode!==0;async function Ks(e,t,n){let r=[];for(let s of t){if(n.signal?.aborted)break;console.error(`saffron ${e}: ${s}`);let o=await Bf(e,s,n);if(r.push(o),o.cancelled||mr(o)&&(console.error(`saffron ${e} failed: ${Ys(o,n.timeoutMs)}`),e==="setup"))break}return r}function Ys(e,t){return e.cancelled?`"${e.command}" was interrupted`:e.timedOut?`"${e.command}" was stopped after ${Math.round(t/1e3)}s (hookTimeoutMs)`:e.exitCode===null?`"${e.command}" could not be started`:`"${e.command}" exited with code ${e.exitCode}`}import B from"node:fs";import V from"node:path";var hn={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},zf=["claude","agents"],yc="<!-- saffron:start -->",Qs="<!-- saffron:end -->";function qf(){return[yc,"## Saffron end-to-end tests","","UI tests are plain-Gherkin `.feature` / `.saffron` files under `features/`,","run by Saffron (`npx saffron run`). No step definitions exist: an AI agent","records each scenario once; later runs replay at zero tokens.","","- Load the `saffron` skill before writing or editing scenarios.","- Exact step text is the cache identity: run `npx saffron steps` and reuse"," recorded wordings instead of inventing synonyms.","- `Then` steps are never healed: write them as the verdict, last.","- `npx saffron status` shows what is cached, what awaits review, the tags"," and vocabulary health; editing a cached scenario's text re-records it.","- Never edit `.saffron/cache/` or `.saffron/proposals/` by hand; change the"," scenario text and re-run. Review proposals with `npx saffron accept`"," (`--all`, or chosen files) and `npx saffron reject`.","- Secrets go in `{env:VAR}` tokens, never in files.","- Shared test data (accounts, names, enum values) goes in `data/*.json`,",' referenced as `{data:file.path.key}`; check a whole list with "every',' {data:file.list}"; read Examples rows from a file with'," `Examples: {data:file}` (.saffron only). Use `{unique:name}` for a value"," that must be new on every run. Never put secrets in `data/`.",Qs,""].join(`
|
|
1052
|
+
`)}function hc(e,t){if(!B.existsSync(e))return B.writeFileSync(e,t),"created";let n=B.readFileSync(e,"utf8"),r=n.indexOf(yc),s=n.indexOf(Qs);if(r!==-1&&s!==-1&&s>r){let a=n.slice(0,r)+t.trimEnd()+n.slice(s+Qs.length);return a===n?"unchanged":(B.writeFileSync(e,a),"updated")}let o=n.endsWith(`
|
|
1022
1053
|
`)?`
|
|
1023
1054
|
`:`
|
|
1024
1055
|
|
|
1025
|
-
`;return
|
|
1026
|
-
`),s?"updated":"created")}function
|
|
1027
|
-
`),n.scaffolded.push("saffron.config.json"));let o=
|
|
1028
|
-
`).some(
|
|
1056
|
+
`;return B.writeFileSync(e,n+o+t),"updated"}var Zs={command:"npx",args:["saffron","mcp"]},Hf={claude:{file:".mcp.json",key:"mcpServers",entry:Zs},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:Zs},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...Zs}}},mc={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function Wf(e,t){let n=V.join(e,t.file),r={},s=B.existsSync(n);if(s)try{r=JSON.parse(B.readFileSync(n,"utf8"))}catch{throw new Error(`${t.file} is not valid JSON: fix it or remove it, then re-run`)}let o=r[t.key]??{};return JSON.stringify(o.saffron)===JSON.stringify(t.entry)?"unchanged":(o.saffron=t.entry,r[t.key]=o,B.mkdirSync(V.dirname(n),{recursive:!0}),B.writeFileSync(n,JSON.stringify(r,null,2)+`
|
|
1057
|
+
`),s?"updated":"created")}function wc(e,t){B.mkdirSync(t,{recursive:!0});for(let n of B.readdirSync(e,{withFileTypes:!0})){let r=V.join(e,n.name),s=V.join(t,n.name);n.isDirectory()?wc(r,s):B.copyFileSync(r,s)}}function bc(e){let t=V.join(e.packageRoot,"skills","saffron");if(!B.existsSync(V.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??zf;for(let s of r){let o=V.join(e.projectRoot,hn[s],"saffron");B.existsSync(o)&&B.rmSync(o,{recursive:!0,force:!0}),wc(t,o),n.skillDirs.push(V.relative(e.projectRoot,o))}if(e.mcp!==!1)for(let s of r){let o=Hf[s];o?n.mcpFiles.push({file:o.file,outcome:Wf(e.projectRoot,o)}):mc[s]&&n.mcpHints.push(mc[s])}if(e.instructions!==!1){let s=qf(),o=V.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:hc(o,s)});let a=V.join(e.projectRoot,"CLAUDE.md");B.existsSync(a)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:hc(a,s)})}if(e.examples){n.examples=Vf(e.projectRoot,e.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(e.scaffold!==!1){let s=V.join(e.projectRoot,"saffron.config.json");B.existsSync(s)||(B.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
|
|
1058
|
+
`),n.scaffolded.push("saffron.config.json"));let o=V.join(e.projectRoot,"features");B.existsSync(o)||(B.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let a=V.join(e.projectRoot,".gitignore"),i=e.examples?[".saffron/reports/",".saffron/artifacts/",".env"]:[".saffron/reports/",".saffron/artifacts/"],c=B.existsSync(a)?B.readFileSync(a,"utf8"):"",l=[];for(let d of i)c.split(`
|
|
1059
|
+
`).some(p=>p.trim()===d)||(c+=(c&&!c.endsWith(`
|
|
1029
1060
|
`)?`
|
|
1030
|
-
`:"")+
|
|
1031
|
-
`,l.push(
|
|
1032
|
-
`)}import{spawn as
|
|
1033
|
-
`))}async function
|
|
1034
|
-
`)[0]}`)
|
|
1035
|
-
interrupted: stopping, then running teardown`),
|
|
1036
|
-
${
|
|
1037
|
-
${j}`}let
|
|
1038
|
-
${
|
|
1039
|
-
`)[0]}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);return}console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${
|
|
1040
|
-
`)[0]}): expect a heal or red on the next run.`)}let v
|
|
1041
|
-
${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`)});
|
|
1042
|
-
`).map(
|
|
1043
|
-
`))});
|
|
1061
|
+
`:"")+d+`
|
|
1062
|
+
`,l.push(d));l.length>0&&(B.writeFileSync(a,c),n.scaffolded.push(`.gitignore (+ ${l.join(", ")})`))}return n}function Vf(e,t){let n=V.join(t,"templates","saucedemo");if(!B.existsSync(V.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=V.join(e,"features");if(B.existsSync(r)&&B.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];B.mkdirSync(r,{recursive:!0});for(let c of B.readdirSync(V.join(n,"features")))B.copyFileSync(V.join(n,"features",c),V.join(r,c)),s.push(`features/${c}`);let o=V.join(n,"data");if(B.existsSync(o)){B.mkdirSync(V.join(e,"data"),{recursive:!0});for(let c of B.readdirSync(o)){let l=V.join(e,"data",c);B.existsSync(l)||(B.copyFileSync(V.join(o,c),l),s.push(`data/${c}`))}}let a=V.join(e,"saffron.config.json");B.existsSync(a)||(B.copyFileSync(V.join(n,"saffron.config.json"),a),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let i=V.join(e,".env.example");return B.existsSync(i)||(B.copyFileSync(V.join(n,".env.example"),i),s.push(".env.example")),{installed:!0,files:s}}import Gf from"node:path";function xc(e){return["saffron lsp is a language server: it speaks LSP over stdio and is meant","to be launched by your editor, not run by hand (nothing happens here).","","JetBrains (IntelliJ, WebStorm, PyCharm, Rider, incl. Community):"," Easiest: install the Saffron plugin from the JetBrains Marketplace"," (https://plugins.jetbrains.com/plugin/34240-saffron): highlighting,"," this language server, run configurations and the Saffron tool window,"," in one click. Manual setup instead:"," 1. Completion, diagnostics, hover, go-to-definition: install the free"," LSP4IJ plugin, then Settings \u2192 Languages & Frameworks \u2192 Language"," Servers \u2192 +: command `npx saffron lsp`, working dir = project root,"," file patterns *.saffron and *.feature."," 2. Syntax highlighting comes from a TextMate bundle, not the LSP:"," Settings \u2192 Editor \u2192 TextMate Bundles \u2192 + and pick this folder:",` ${e?Gf.join(e,"textmate","saffron"):"node_modules/saffron-ai/textmate/saffron"}`,"","Neovim / other LSP editors: cmd = { 'npx', 'saffron', 'lsp' },","filetypes = { 'saffron', 'feature' }.","","VS Code: install the extension instead:"," code --install-extension ChathurangaJayasinghe.saffron-vscode"].join(`
|
|
1063
|
+
`)}import{spawn as Jf}from"node:child_process";function Kf(e,t=process.platform){return t==="win32"?{command:'start "" "%SAFFRON_BROWSER_TARGET%"',args:[],options:{detached:!0,stdio:"ignore",shell:!0,windowsHide:!0,env:{...process.env,SAFFRON_BROWSER_TARGET:e}}}:{command:t==="darwin"?"open":"xdg-open",args:[e],options:{detached:!0,stdio:"ignore"}}}function Xs(e){let{command:t,args:n,options:r}=Kf(e),s=t.split(" ")[0],o=Jf(t,n,r);o.on("error",()=>console.error(`Could not open a browser (${s} is not available here). Open the address above yourself.`)),o.unref()}function vc(e,t){let n=t.length>0?t:["features"],r=[];for(let s of n){let o=D.isAbsolute(s)?s:D.join(e,s);if(ee.existsSync(o))if(ee.statSync(o).isDirectory())for(let a of ee.readdirSync(o,{recursive:!0})){let i=String(a);Oe(i)&&r.push(D.join(o,i))}else Oe(o)&&r.push(o)}return r.sort()}function Qf(e){let t=js(e);t.length!==0&&console.error([`note: the ${dt[e]} agent is not set up here. Cached scenarios replay fine`,"without AI, but recording and healing need:",...t.map(n=>` \xB7 ${n}`),"Replay-only runs: add --no-agent to silence this note.",""].join(`
|
|
1064
|
+
`))}function Sc(e,t){let n=e.provider??"claude";return t===n?{model:e.model,healModel:e.healModel}:((e.model||e.healModel)&&console.error(`note: model and healModel in saffron.config.json name ${dt[n]??n} models; this ${dt[t]} run uses its default models (--model and --heal-model pick others).`),{})}function to(e,t){let n=e??t??"claude";return Fi(n)||(console.error(`invalid provider "${n}": use ${Is.map(r=>`"${r}"`).join(", ")}`),process.exit(2)),n}async function Xf(e){let t=Yf.createInterface({input:process.stdin,output:process.stdout});try{let n=await new Promise(r=>t.question(e,r));return/^y(es)?$/i.test(n.trim())}finally{t.close()}}function yr(e){console.error(`saffron: ${e.message}`),process.exit(2)}function eo(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var eu={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},tu="\x1B[0m",W=new Zf().name("saffron").description("AI-fallback test runner for end-to-end tests written in plain language: zero-token cached replay, runtime AI healing.").version(He()).option("-p, --project-root <dir>","project root",process.cwd());W.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--env <name>","data environment: data/<file>.<name>.json is laid over data/<file>.json (also SAFFRON_ENV, or env in the config)").option("--headed","run the browser headed").option("--filter <tags>",'only run scenarios whose @tags match: a list ("@smoke,@WA-TC1" runs either) or an expression ("@e2e and not @wip", parentheses allowed); repeat the flag for "or"',gc).option("--no-agent","disable AI fallback (cache misses/failures go red)").option("--rerecord","discard the committed caches of the selected scenarios and let the agent record them fresh (the honest fix for a wrong recording)").option("--provider <name>",'whose agent records and heals: "claude" (default), "codex", "antigravity" or "cursor", on its login on this machine').option("--model <model>","model for the agent").option("--storage-state <file>","Playwright storageState JSON for authenticated sessions").option("--strict","treat passed-with-adaptation (yellow) as failure until its proposal is reviewed").option("--no-verify","skip the zero-AI proof replay of agent recordings before filing proposals").option("--no-reuse","disable Level-1 step reuse (seeding new recordings from existing step recordings)").option("--browser <name>",'browser: "chromium" (default), "chrome", "msedge", "firefox" or "webkit". Recording and healing need one of the first three').option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers (agent recording/healing stays sequential)").option("--shard <index/total>","run one part of the suite, e.g. 2/4 on the second of four CI machines; combine the parts with `saffron report --merge`").option("--shard-by <mode>",'how --shard splits the suite: "scenario" (default, spreads every file) or "file" (each feature file stays on one machine)').option("--no-hooks","skip the setup and teardown commands from the config").option("--trace <mode>",'execution trace per scenario: "off" (default), "on" or "retain-on-failure"; open one with saffron trace').option("--assertion-policy <policy>",'assertion adaptability: "strict" (default) or "adaptable-mid" (mid-scenario assertions may be adapted; the final assertion block never is)').action(async(e,t)=>{let n=D.resolve(W.opts().projectRoot),r=ie(n);t.env&&Ke(n,r.dataDir,t.env);let s=t.baseUrl??r.baseURL,o=vc(n,e.length>0?e:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2));let a=to(t.provider,r.provider),i=Sc(r,a);t.agent!==!1&&Qf(a);let c;try{let k=Fe(n,r,o);c=o.flatMap(j=>de(j,n,k))}catch(k){throw k instanceof re&&yr(k),k}let l;try{l=fc(t.filter)}catch(k){if(!(k instanceof gn))throw k;console.error(`${k.message}. Write tags joined with and, or, not and parentheses: "@smoke and not @wip".`),process.exit(2)}l&&(c=uc(c,l),c.length===0&&(console.error(`No scenarios match --filter "${l.text}" in ${o.length} feature file(s).`),process.exit(2)));let d;if(t.shardBy!==void 0&&t.shard===void 0&&(console.error("saffron: --shard-by needs --shard, e.g. --shard 2/4 --shard-by file"),process.exit(2)),t.shard!==void 0){try{let k=ic(String(t.shardBy??r.shardBy??"scenario")),j=cc(c,{...ac(String(t.shard)),by:k});c=j.scenarios,d={...j.info,ciRun:lc()}}catch(k){console.error(k instanceof Error?k.message:String(k)),process.exit(2)}if(console.log(`shard ${d.index}/${d.total}${d.by==="file"?" (by file)":""}: ${c.length} of ${d.suiteSize} scenario(s)`),c.length===0){let k=new Date,j=Hs([],k,s),F=us([],k,$e(n).divergent.length,s);console.log(`report: ${Vs(n,{...j,shard:d,history:F})}`),process.exit(0)}}if(t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let k=0;for(let j of c){let F=K(n,j.featurePath,j.name);ee.existsSync(F)&&(ee.rmSync(F),k++)}console.log(`rerecord: removed ${k} cache file(s) for ${c.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let k=c;try{let F=vc(n,[]),q=Fe(n,r,F);k=F.flatMap(H=>{try{return de(H,n,q)}catch{return[]}})}catch{}let j=ho(n,k);if(j.length>0){console.error("cache path collision:");for(let F of j)console.error(` ${F}`);process.exit(2)}}{let k=new Map;for(let F of c){let q=ne(F),H=xt(K(n,F.featurePath,F.name));H&&q.push(JSON.stringify(H.steps));for(let te of xr([...q,...vr(ne(F))]))process.env[te]===void 0&&!k.has(te)&&k.set(te,F.displayName)}let j=new Map;for(let F of c)for(let q of Ze(ne(F)))if(!j.has(q))try{wn(q)}catch(H){j.set(q,`${H instanceof Error?H.message:String(H)} (used by "${F.displayName}")`)}if(j.size>0){for(let F of j.values())console.error(F);process.exit(2)}if(k.size>0){for(let[F,q]of k)console.error(`missing environment variable ${F} (referenced as {env:${F}} by "${q}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let p=[],g=[];if(t.hooks!==!1)try{p=Js(r.setup,"setup"),g=Js(r.teardown,"teardown")}catch(k){console.error(k instanceof Error?k.message:String(k)),process.exit(2)}let f=new Gn({projectRoot:n,baseURL:s,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,screenshot:r.screenshot,trace:(()=>{let k=t.trace??r.trace??"off";return Vr.includes(k)||(console.error(`invalid --trace "${k}": use ${Vr.map(j=>`"${j}"`).join(", ")}`),process.exit(2)),k})(),retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let k=t.browser??r.browser??"chromium";return si(k)||(console.error(`invalid --browser "${k}": use ${ks.map(j=>`"${j}"`).join(", ")}`),process.exit(2)),k})(),healModel:t.healModel??i.healModel,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let k=t.assertionPolicy??r.assertionPolicy??"strict";return k!=="strict"&&k!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${k}": use "strict" or "adaptable-mid"`),process.exit(2)),k})(),agent:t.agent?Ni(a,{model:t.model??i.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),y=new Date,$=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),T=new Array(c.length),v=k=>{let j=k.scenario,F=eu[k.status],q=k.usage.inputTokens+k.usage.outputTokens,H=k.usage.cacheReadTokens+k.usage.cacheCreationTokens,te=H>0?`, ${eo(H)} cache traffic`:"";console.log(`${F}${k.status.toUpperCase().padEnd(7)}${tu}${j.displayName} (${k.source}, ${k.durationMs}ms, ${k.usage.aiCalls} AI calls, ${q} tokens${te})`);for(let gt of k.adaptations)console.log(` \u26A0 ${gt}`);k.seededSteps&&console.log(` \u26A1 ${k.seededSteps}/${k.stepResults.length} steps seeded from existing recordings (zero AI)`);let ut=k.stepResults.reduce((gt,Ut)=>gt+(Ut.retries??0),0);ut&&console.log(` \u21BB ${ut} action ${ut===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),k.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):k.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${k.proofError?.split(`
|
|
1065
|
+
`)[0]}`),k.error&&console.log(` ${k.error}`)},S=new AbortController,h={cwd:n,timeoutMs:r.hookTimeoutMs??Cr.hookTimeoutMs,env:{SAFFRON_BASE_URL:s,SAFFRON_ENV:Be().env,SAFFRON_SHARD:d?`${d.index}/${d.total}`:void 0}},w=[],x,b=()=>x??=Ks("teardown",g,h).then(k=>{w.push(...k)}),u=Promise.resolve([]);p.length+g.length>0&&process.on("SIGINT",()=>{if(S.signal.aborted){console.error("still cleaning up: teardown finishes, then Saffron exits");return}console.error(`
|
|
1066
|
+
interrupted: stopping, then running teardown`),S.abort(),u.then(()=>f.stop()).catch(()=>{}).then(b).finally(()=>process.exit(130))}),p.length>0&&(u=Ks("setup",p,{...h,signal:S.signal}),w.push(...await u),S.signal.aborted&&await new Promise(()=>{}),w.some(mr)&&(console.error("No scenario was run: the application is not in the state the suite expects."),await b(),process.exit(2)));try{await f.start()}catch(k){throw await b(),k instanceof an&&(console.error(k.message),process.exit(2)),k}try{if($>1&&c.length>1){let k=c.map((F,q)=>({s:F,i:q})),j=[];await Promise.all(Array.from({length:Math.min($,k.length)},async()=>{for(;;){let F=k.shift();if(!F)return;let q=await f.runScenario(F.s,{agentDisabled:!0});q.status==="red"?j.push(F):(T[F.i]=q,v(q))}}));for(let F of j.sort((q,H)=>q.i-H.i)){let q=await f.runScenario(F.s);T[F.i]=q,v(q)}}else for(let k=0;k<c.length;k++){let j=await f.runScenario(c[k]);T[k]=j,v(j)}}finally{await f.stop(),await b()}let m=Hs(T,y,s);w.length>0&&(m.hooks=w);let E=$e(n).divergent.length,R=us(T,y,E,s);if(!d){let k=nn(n);m.trends=hs(k,R),gs(n,R)}m.totals.aiCalls>0&&(m.totals.plan=await f.agent?.planUsage?.());let _;if(d){let k=Vs(n,{...m,shard:d,history:R}),j=k.replace(/\.json$/,".html");ee.writeFileSync(j,lr(m,n)),_=`${j}
|
|
1067
|
+
${k}`}else{let{html:k,json:j}=Ws(n,m);_=`${k}
|
|
1068
|
+
${j}`}let A=m.totals,C=A.cacheReadTokens+A.cacheCreationTokens;console.log(`
|
|
1069
|
+
${A.green} passed, ${A.yellow} pending review, ${A.red} failed \xB7 ${A.aiCalls} AI calls \xB7 ${(A.inputTokens+A.outputTokens).toLocaleString()} tokens in+out${C>0?` \xB7 ${eo(A.cacheReadTokens)} cache read \xB7 ${eo(A.cacheCreationTokens)} cache written`:""}${ls(A)}${A.models?.length?` \xB7 ${A.models.join(", ")}`:""}`);let P=m.trends;P?.prev&&console.log(ds(P.prev));for(let k of P?.chronic??[])console.log(`\u26A0 chronic: ${k.feature} \u203A ${k.scenario}: ${k.heals} heal(s), ${k.reds} red(s) in last ${k.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${_}`),d&&console.log(`shard report, not a whole run: history and trends wait for \`saffron report --merge\` over all ${d.total} shard-*-of-${d.total}.json files`);let O=Ve(D.resolve(W.opts().projectRoot),mn);O.length>0&&console.log(`${O.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let I=t.strict??r.strict??!1;I&&A.yellow>0&&console.log(`strict mode: ${A.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`);let U=A.red>0||I&&A.yellow>0,N=w.filter(k=>k.phase==="teardown"&&mr(k));N.length>0&&console.log(`teardown failed: ${N.map(k=>Ys(k,h.timeoutMs)).join("; ")}. The environment may hold data from this run.`),process.exit(U?1:N.length>0?2:0)});W.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (default with --all: every proposal)").option("--all","accept all pending proposals").option("--with-feature-edit","also apply the proposal's suggested .feature step rewrites").option("--propagate","apply the same locator fixes to every other cache using the same locator").option("--include-unverified","with --all: also accept proposals whose proof replay failed").action((e,t)=>{let n=D.resolve(W.opts().projectRoot),r=ie(n),s=e.length>0?e.map(Fr).map(o=>({...o,file:D.resolve(o.file)})):t.all?Ve(n,mn).map(o=>({file:o.file,revision:void 0})):[];if(s.length===0){Rc(n);return}try{for(let{file:o,revision:a}of s){let i=v=>{console.log(`not accepted: ${D.basename(o)} ${v}`),process.exitCode=1},c=Tc(n,o,a!==void 0,()=>{if(a&&Gt(o)!==a)return i($c);let v;try{v=Rt(o)}catch(w){if(!(w instanceof J))throw w;return i(`cannot be read: ${w.problem}`)}if(v.meta.bound){let w=Me(n,v,Fe(n,r,[]));if(w)return i(`is stale: ${w}. Run the scenario again for a proposal made against what is there now, or reject this one.`)}else console.log(`\u26A0 ${D.basename(o)} is ${zn}; accepting as it is.`);if(v.meta.verified===!1){if(t.all&&!t.includeUnverified){console.log(`skipped UNVERIFIED ${D.basename(o)} (${v.meta.proofError?.split(`
|
|
1070
|
+
`)[0]}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);return}console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${v.meta.proofError?.split(`
|
|
1071
|
+
`)[0]}): expect a heal or red on the next run.`)}let S=v.cache.steps.map(w=>w.gherkin);if(t.withFeatureEdit&&(v.meta.featureEdits??[]).length>0){let w=rc(n,v.cache.feature,v.cache.scenario,S,Fe(n,r,[]));if(w)return i(`carries feature edits, and ${w}. Run the scenario again for a fresh proposal, or accept this one without --with-feature-edit.`)}let h=pe(K(n,v.cache.feature,v.cache.scenario));return{proposal:v,recordedSteps:S,oldCache:h,cacheFile:Nn(n,o)}},i);if(!c)continue;let{proposal:l,recordedSteps:d,oldCache:p,cacheFile:g}=c;console.log(`accepted \u2192 ${g}`);let f=p?ec(p,l.cache):{changes:[],ambiguous:[]},y=f.changes;for(let v of f.ambiguous)console.log(` locator fix for ${fr(v)} not propagated: this heal sent it to more than one place, so other caches are left alone.`);if(y.length>0){let v=tc(n,y,g,!t.propagate);for(let S of y)console.log(` locator fix: ${fr(S.from)} \u2192 ${fr(S.to)}`);if(v.length===0)console.log(" no other caches use this locator.");else if(t.propagate)for(let S of v)console.log(` propagated \u2192 ${S.feature} \u203A ${S.scenario} (${S.steps.map(h=>`"${h.gherkin}"`).join(", ")})`);else{console.log(` ${v.length} other cache(s) use the same locator: re-run with --propagate to fix them too:`);for(let S of v)console.log(` \xB7 ${S.feature} \u203A ${S.scenario}`)}}let $=l.meta.featureEdits??[];if(!t.withFeatureEdit||$.length===0)continue;let T=sc(n,l.cache.feature,l.cache.scenario,$,Fe(n,r,[]),d);for(let v of T.applied){let S=v.fromStepSet?`${v.file}:${v.line} (StepSet "${v.fromStepSet}", fixes every invoking scenario)`:`${v.file}:${v.line}`;console.log(` feature edit ${S}:`),console.log(` - ${v.from}`),console.log(` + ${v.to}`)}for(let v of T.skipped)console.log(` skipped edit [${v.index}]: ${v.reason}`);if(T.appliedIndices.length>0){let v=new Set(T.appliedIndices),S=pe(g);for(let h of $)v.has(h.index)&&S.steps[h.index]&&(S.steps[h.index].gherkin=h.text);vt(g,S)}}}catch(o){throw o instanceof re&&yr(o),o}});W.command("steps").description("List/search the project's step vocabulary (files + caches): what exists, what's recorded, what to reuse").argument("[search]","case-insensitive substring filter").option("--json","machine-readable vocabulary").option("--snippets","write .vscode/saffron.code-snippets for native VS Code completion").action((e,t)=>{let n=D.resolve(W.opts().projectRoot),r=ie(n),s;try{s=Te(n,r.features??"features")}catch(a){throw a instanceof re&&yr(a),a}if(e&&(s=_n(s,e)),t.json){console.log(JSON.stringify(s,null,2));return}if(t.snippets){let a=D.join(n,".vscode","saffron.code-snippets");ee.mkdirSync(D.dirname(a),{recursive:!0}),ee.writeFileSync(a,So(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${a}`),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 a of s.stepSets)console.log(` ${a.name} (${a.steps.length} steps, ${a.usage} scenario(s)), ${a.file}:${a.line}`);console.log("")}for(let a of s.steps){let i=a.fromStepSet?` [${a.fromStepSet}]`:"";console.log(`${o[a.status]} ${a.keyword} ${a.text} \xD7${a.usage}${i}`)}console.log(`
|
|
1072
|
+
${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`)});W.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("--provider <name>",'whose agent writes the draft: "claude" (default), "codex", "antigravity" or "cursor"').option("--model <model>","model for authoring").action(async(e,t)=>{let n=D.resolve(W.opts().projectRoot),r=ie(n),s=to(t.provider,r.provider),o=ee.readFileSync(D.resolve(e),"utf8"),a;try{a=Te(n,r.features??"features")}catch(d){throw d instanceof re&&yr(d),d}console.log(`authoring with ${a.steps.length} known step(s) and ${a.stepSets.length} step set(s) as vocabulary\u2026`);let i=await Ra(o,a,t.model??Sc(r,s).model,s==="claude"?void 0:Cs[s]),c=t.out?D.resolve(t.out):D.join(n,r.features??"features",`${D.basename(e).replace(/\.[^.]+$/,"")}.saffron`);ee.existsSync(c)&&(console.error(`refusing to overwrite ${c}: pass --out for a new path`),process.exit(1)),ee.mkdirSync(D.dirname(c),{recursive:!0}),ee.writeFileSync(c,i.content);let l=i.content.split(`
|
|
1073
|
+
`).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>a.steps.some(p=>p.text===d)).length;console.log(`wrote ${c}`),console.log(`${l} step line(s) reuse the existing vocabulary (seedable)${i.usage.costUsd?` \xB7 $${i.usage.costUsd.toFixed(4)}`:""}`),console.log("review the draft, then record it: saffron run "+D.relative(process.cwd(),c))});W.command("login").description("Check or set up an AI provider's login on this machine (Cursor: a one-time sign-in for Saffron; Antigravity: allows Saffron's tools in agy)").argument("[provider]",'"claude", "codex", "antigravity" or "cursor" (default: the configured provider)').action(async e=>{let t=D.resolve(W.opts().projectRoot),n=to(e,ie(t).provider),r=n==="cursor"?Ci:n==="antigravity"?Ti:void 0;if(r)try{await r()}catch(o){console.error(o instanceof Error?o.message:String(o)),process.exit(1)}let s=js(n);if(s.length===0){console.log(`${dt[n]}: ready to record and heal.`);return}console.log([`${dt[n]} still needs:`,...s.map(o=>` \xB7 ${o}`)].join(`
|
|
1074
|
+
`)),process.exit(1)});W.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=D.resolve(W.opts().projectRoot),t=ie(e);await Sa(e,t.features??"features")});W.command("lsp").description("Run the Saffron language server over stdio (completion, go-to-definition, hover, diagnostics). For JetBrains (LSP4IJ / Ultimate LSP API), Neovim, and any LSP-capable editor").action(()=>{process.stdin.isTTY&&(console.error(xc(In())),process.exit(2));let e=D.resolve(W.opts().projectRoot),t=ie(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));Fa(e,t.features??"features",{rootFromClient:!n})});W.command("reject").description("Discard pending cache proposals (AI will retry next run)").argument("[files...]","proposal file(s)").option("--all","reject all pending proposals").action((e,t)=>{let n=D.resolve(W.opts().projectRoot),r=e.length>0?e.map(Fr).map(s=>({...s,file:D.resolve(s.file)})):t.all?Ve(n,mn).map(s=>({file:s.file,revision:void 0})):[];if(r.length===0){Rc(n);return}for(let{file:s,revision:o}of r){let a=Tc(n,s,o!==void 0,()=>{if(o&&Gt(s)!==o)return $c;Mn(s)},i=>i);if(a){console.log(`not rejected: ${D.basename(s)} ${a}`),process.exitCode=1;continue}console.log(`rejected ${s}`)}});W.command("init").description("Make the project agent-ready: install the bundled Saffron skill into agent directories, add instructions to AGENTS.md, scaffold config").option("--agents <list>",`comma-separated targets: ${Object.keys(hn).join(", ")} (default: claude,agents)`).option("--no-instructions","do not touch AGENTS.md / CLAUDE.md").option("--no-scaffold","do not create saffron.config.json, features/, .gitignore entry").option("--no-mcp","do not register `saffron mcp` in project MCP configs (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)").option("--examples","install the Saucedemo example suite (features, config, .env.example) for a guided first run").option("--no-examples","never install the example suite (skips the prompt)").action(async e=>{let t=D.resolve(W.opts().projectRoot),n=In();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(a=>a.trim()).filter(Boolean):void 0;for(let a of r??[])a in hn||(console.error(`saffron: unknown agent target "${a}" (expected one of ${Object.keys(hn).join(", ")})`),process.exit(2));let s=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await Xf("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=bc({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:s});for(let a of o.skillDirs)console.log(`skill \u2192 ${a}/`);for(let a of o.mcpFiles)console.log(`${a.outcome.padEnd(9)} \u2192 ${a.file} (saffron mcp server)`);for(let a of o.mcpHints)console.log(`mcp hint \u2192 ${a}`);for(let a of o.instructionFiles)console.log(`${a.outcome.padEnd(9)} \u2192 ${a.file} (managed Saffron block)`);for(let a of o.scaffolded)console.log(`created \u2192 ${a}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
|
|
1075
|
+
`))});W.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=D.resolve(W.opts().projectRoot),r=e.length>0?e.map(o=>D.resolve(o)):Ve(n,mn).map(o=>o.file);if(r.length===0){console.log("No pending proposals.");return}let s=r.map(o=>{let a=Rt(o),i=pe(K(n,a.cache.feature,a.cache.scenario));return{file:D.relative(n,o),verified:a.meta.verified,stale:a.meta.bound?kc(n,a):zn,..._t(i,a.cache)}});if(t.json){console.log(JSON.stringify(s.length===1?s[0]:s,null,2));return}console.log(s.map(o=>`${ra(o)}${o.stale?`
|
|
1044
1076
|
|
|
1045
1077
|
STALE: ${o.stale}`:""}`).join(`
|
|
1046
1078
|
|
|
1047
|
-
`))});
|
|
1048
|
-
${
|
|
1049
|
-
Press Ctrl-C to stop.`),t.open!==!1&&
|
|
1050
|
-
`)),process.exit(2));let s=e.yes&&!e.check?
|
|
1051
|
-
`))}e.check&&r.orphans.length>0&&process.exit(1)});
|
|
1052
|
-
${
|
|
1053
|
-
`)[0]:String(n)}`}}var
|
|
1054
|
-
`);for(let{file:n,proposal:r}of t){console.log(` ${n}`);let s=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${s})`);let o=r.meta.bound?
|
|
1079
|
+
`))});W.command("trace").description("Open the execution replay of a scenario from the last run: its Given / When / Then, the page at every action, the network alongside, and what the agent changed").argument("[scenario]","scenario name, feature path, or feature:scenario; default: every traced scenario, opening on a red one, else a yellow one").option("--port <n>","listen on a fixed port (default: a free one)").option("--no-open","print the URL without opening a browser").option("--json","print the parsed trace as JSON instead of serving it").option("--run <startedAt>","only if the last run is this one (its startedAt): an editor's row names the run it lists").action(async(e,t)=>{let n=t.port===void 0?void 0:Number(t.port);n!==void 0&&(!Number.isInteger(n)||n<0||n>65535)&&(console.error(`invalid --port "${t.port}": use a whole number from 0 to 65535`),process.exit(2));let r=D.resolve(W.opts().projectRoot),s=D.join(r,".saffron","reports","latest.json");ee.existsSync(s)||(console.error("No report found. Run `saffron run` first."),process.exit(2));let o=JSON.parse(ee.readFileSync(s,"utf8"));t.run!==void 0&&o.startedAt!==t.run&&(console.error(`The last run changed since this was listed: it is now the run of ${o.startedAt}. Refresh the panel to replay it.`),process.exit(2));let a=o.scenarios.filter(h=>h.evidence?.some(w=>w.kind==="trace"));a.length===0&&(console.error('The last run kept no execution trace. Set "trace": "retain-on-failure" (or "on") in saffron.config.json, or run with --trace retain-on-failure, then run again.'),process.exit(2));let i=e?.trim(),c=a;if(i){let h=a.filter(w=>w.scenario===i||w.feature===i||`${w.feature}:${w.scenario}`===i);c=h.length>0?h:a.filter(w=>w.scenario.toLowerCase().includes(i.toLowerCase()))}if(c.length===0||t.json&&i&&c.length>1){console.error(c.length===0?`No traced scenario matches "${i}". Traced in the last run:`:"--json prints one trace. Traced in the last run:");for(let h of a)console.error(` ${h.status.padEnd(6)} ${h.feature}:${h.scenario}`);console.error('Name one: saffron trace "<scenario name>", or "<feature>:<scenario name>" as listed'),process.exit(2)}let l=c.findIndex(h=>h.status==="red"),d=c.findIndex(h=>h.status==="yellow"),p=l>=0?l:d>=0?d:0,g=c[p],f=h=>D.basename(h,".zip").replace("proof-","Proof replay "),y=h=>{let w=h.evidence.filter(b=>b.kind==="trace"),x=w.find(b=>!b.proof)??w[0];return[x,...w.filter(b=>b!==x)]},[$]=y(g),T;if(c.length===1||t.json){let h=D.join(r,$.file);try{T=Us(r,$)}catch(w){console.error(`The trace cannot be read: ${w instanceof Error?w.message:String(w)}`),process.exit(2)}if(T||(console.error(ee.existsSync(h)?"The trace on disk is from a later run than the last report (a run that stopped before writing its report). Run the scenario again to replay it.":`The trace file is gone: ${h}. Run the scenario again.`),process.exit(2)),t.json){console.log(JSON.stringify(Di(T),null,2));return}}let v=c.map((h,w)=>{let[x,...b]=y(h);return{scenario:{feature:h.feature,scenario:h.scenario,status:h.status,error:h.error,adaptations:h.adaptations,narrative:h.narrative,proposalDiff:h.proposalDiff,proposalFile:h.proposalFile,proposalRevision:h.proposalRevision,screenshots:h.evidence?.filter(u=>u.kind!=="trace"),ranAt:o.startedAt,source:["cache","agent-record","agent-heal"].find(u=>u===h.source),proposalReplay:h.proposalReplay===!0?!0:void 0},traces:[x,...b].map((u,m)=>({file:D.join(r,u.file),data:w===p&&m===0?T:void 0,read:()=>Us(r,u),checked:u.sha256!==void 0,label:u.proof?f(u.file):"Execution",proof:u.proof===!0,steps:u.proof?u.traceSteps:u.traceSteps??h.steps,uniques:u.proof?u.traceUniques:h.uniques}))}}),S;try{S=await Qi({entries:v,start:p,projectRoot:r,port:n})}catch(h){console.error(`Could not start the replay: ${h instanceof Error?h.message:String(h)}`),process.exit(2)}for(let{file:h,reason:w}of S.skipped)console.error(`Skipping ${D.relative(r,h)}. ${w}`);console.log(`Saffron replay: ${c.length===1?`${g.feature} \u203A ${g.scenario}`:`${c.length} scenarios, opening on ${g.scenario}`}
|
|
1080
|
+
${S.url}
|
|
1081
|
+
Press Ctrl-C to stop.`),t.open!==!1&&Xs(S.url),await new Promise(h=>{process.once("SIGINT",()=>{S.close().finally(h)})}),process.exit(0)});W.command("prune").description("List (and with --yes remove) recordings whose scenario no longer exists: caches and pending proposals left behind by a deleted scenario or feature file").option("--json","machine-readable output").option("--yes","actually delete the files listed").option("--check","exit 1 when anything is orphaned, delete nothing (for CI)").action(e=>{let t=D.resolve(W.opts().projectRoot),n=ie(t),r=Ln(t,n.features??"features");r.unreadable.length>0&&(e.json?console.log(JSON.stringify({...r,removed:[]},null,2)):console.error(["saffron: cannot tell what is orphaned while a feature file fails to parse.",...r.unreadable.map(o=>` ${o}`),"Fix these first: a syntax error hides every scenario in the file."].join(`
|
|
1082
|
+
`)),process.exit(2));let s=e.yes&&!e.check?Po(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:s},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let o=r.orphans.filter(c=>c.kind==="cache").length,a=r.orphans.filter(c=>c.kind==="proposal").length,i=r.orphans.length-o-a;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${o} cache, ${a} proposal, ${i} screenshot ${i===1?"folder":"folders"}), ${r.kept} still owned:`,"",...r.orphans.map(c=>` ${s.length>0?"removed ":""}${Io(c)}`),"",s.length>0?"Deleted. Re-record by running the scenarios again if you deleted something by mistake.":"Nothing was deleted. Run `saffron prune --yes` to remove them."].join(`
|
|
1083
|
+
`))}e.check&&r.orphans.length>0&&process.exit(1)});W.command("status").description("Project overview for dashboards and IDE panels: feature files and scenarios with cache state, tags, pending proposals, last run, history, vocabulary health, effective config").option("--json","machine-readable output").action(e=>{let t=D.resolve(W.opts().projectRoot),n=ie(t),r=Hn(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}va(r)});W.command("report").description("Open the latest HTML report, or merge shard reports into it").argument("[paths...]","with --merge: shard report files or folders to search (default: .saffron)").option("--open","open in the default browser",!0).option("--merge","combine the shard-<i>-of-<n>.json reports of one sharded run into latest.json, latest.html and one history line").action((e,t)=>{let n=D.resolve(W.opts().projectRoot);if(t.merge){nu(n,e);return}e.length>0&&(console.error("saffron report: paths are only read with --merge"),process.exit(2));let r=D.join(n,".saffron","reports","latest.html");ee.existsSync(r)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(r),Xs(r)});function nu(e,t){let n=t.length>0?t.map(y=>D.resolve(y)):[D.join(e,".saffron")],r,s;try{let y=dc(n);s=y.length,r=pc(y)}catch(y){console.error(`saffron report --merge: ${y instanceof Error?y.message:String(y)}`),process.exit(2)}let{report:o,history:a}=r,i=nn(e),c=i.find(y=>y.runId===a.runId),l=c?i.filter(y=>y.runId!==a.runId):i;o.trends=hs(l,a);let d=c!==void 0&&JSON.stringify(c)===JSON.stringify(a);d||wa(e,a);let{html:p,json:g}=Ws(e,o),f=o.totals;console.log(`merged ${s} shard(s): ${f.scenarios} scenario(s) \xB7 ${f.green} passed, ${f.yellow} pending review, ${f.red} failed \xB7 ${f.aiCalls} AI calls${ls(f)}`),d?console.log("history already holds this run: not appended again"):c&&console.log("history already held this run: its line is replaced with these results"),o.trends.prev&&console.log(ds(o.trends.prev));for(let y of o.trends.chronic)console.log(`\u26A0 chronic: ${y.feature} \u203A ${y.scenario}: ${y.heals} heal(s), ${y.reds} red(s) in last ${y.runs} run(s)`);console.log(`report: ${p}
|
|
1084
|
+
${g}`)}function kc(e,t){try{return Me(e,t,Fe(e,ie(e),[]))}catch(n){return`cannot check: ${n instanceof Error?n.message.split(`
|
|
1085
|
+
`)[0]:String(n)}`}}var mn=(e,t)=>console.error(`\u26A0 proposal ${e} cannot be read: ${t}`),ru="is no longer pending: it was accepted, rejected or removed since it was listed.",su="is not a pending proposal.",ou="is not a proposal file under .saffron/proposals.",$c="changed since it was listed. Refresh and review it again.",au="is being decided by another process. Try again.";function Tc(e,t,n,r,s){if(!t.endsWith(".json")||ee.existsSync(t)&&!$t(D.join(e,".saffron","proposals"),t))return s(ou);try{return On(t,r,()=>s(n?ru:su))}catch(o){if(!(o instanceof Vt))throw o;return s(au)}}function Rc(e){let t=Ve(e,mn);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
|
|
1086
|
+
`);for(let{file:n,proposal:r}of t){console.log(` ${n}`);let s=r.meta.verified===!0?" \xB7 verified \u2713":r.meta.verified===!1?" \xB7 UNVERIFIED \u2717":"";console.log(` ${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}, ${r.meta.createdAt}${s})`);let o=r.meta.bound?kc(e,r):zn;o&&console.log(` \u2717 STALE: ${o}`),console.log(` ${r.meta.narrative}`);for(let a of r.meta.adaptations)console.log(` \u26A0 ${a}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
|
|
1055
1087
|
${r.meta.suggestedFeatureEdit.split(`
|
|
1056
1088
|
`).map(a=>` ${a}`).join(`
|
|
1057
|
-
`)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}
|
|
1089
|
+
`)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}W.parseAsync();
|