saffron-ai 0.9.7 → 0.9.9
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 +217 -0
- package/README.md +2 -3
- package/dist-pkg/cli.js +239 -215
- package/package.json +2 -2
- package/skills/saffron/SKILL.md +13 -1
- package/skills/saffron/references/config.md +6 -4
package/dist-pkg/cli.js
CHANGED
|
@@ -1,42 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import ue from"node:fs";import U from"node:path";import Uw from"node:readline";import{Command as zw}from"commander";import Sp from"node:fs";import xp from"node:path";import{AstBuilder as $p,dialects as Ta,GherkinClassicTokenMatcher as kp,Parser as Rp}from"@cucumber/gherkin";import{IdGenerator as Tp,StepKeywordType as io}from"@cucumber/messages";import ba from"node:fs";import ip from"node:path";import yn from"node:fs";import xt from"node:path";var vn=/\{data:([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\}/g,bn=xt.resolve("data"),it,ma=process.cwd(),Or=new Map;function at(e,t="data",n){ma=xt.resolve(e),bn=xt.isAbsolute(t)?t:xt.join(e,t),it=n?.trim()||void 0,Or.clear()}var Me=()=>({dir:bn,env:it,root:ma});function wa(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?wa(e[s],o):o;return r}function ha(e,t){try{return JSON.parse(yn.readFileSync(e,"utf8"))}catch(n){throw new Error(`data file ${t} is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}}function sp(e){if(Or.has(e))return Or.get(e);let t=xt.join(bn,`${e}.json`),n=it?xt.join(bn,`${e}.${it}.json`):void 0,r=yn.existsSync(t),s=n!==void 0&&yn.existsSync(n);if(!r&&!s)throw new Error(`data file ${xt.relative(process.cwd(),t)||t} does not exist${it?` (nor ${e}.${it}.json for the "${it}" environment)`:""}`);let o=r?ha(t,`${e}.json`):void 0;if(s){let i=ha(n,`${e}.${it}.json`);o=r?wa(o,i):i}return Or.set(e,o),o}function Zt(e){let[t,...n]=e.split("."),r=sp(t),s=[t];for(let o of n){let i=r,a=i&&typeof i=="object"?Array.isArray(i)?i[Number(o)]:i[o]:void 0;if(a===void 0)throw new Error(`"${o}" does not exist under ${s.join(".")} (in ${t}.json)`);r=a,s.push(o)}return r}function Sn(e){let t;try{t=Zt(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 Nt(e){let t;try{t=Zt(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 jr(e){Nt(e)}function ya(e){return e.replace(vn,(t,n)=>{let r=Nt(n);return Array.isArray(Zt(n))?`${t} (a data list, currently: ${r.map(o=>`"${o}"`).join(", ")})`:r[0]})}function op(e){let t=[],n=[],r="",s=!1;for(let o=0;o<e.length;o++){let i=e[o];s?i==='"'&&e[o+1]==='"'?(r+='"',o++):i==='"'?s=!1:r+=i:i==='"'&&r===""?s=!0:i===","?(n.push(r),r=""):i===`
|
|
3
3
|
`||i==="\r"?(i==="\r"&&e[o+1]===`
|
|
4
|
-
`&&o++,n.push(r),r="",n.some(a=>a!=="")&&t.push(n),n=[]):r+=i}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(i=>i.trim()))}function
|
|
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 i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}function
|
|
6
|
-
`),n=new Map,r=new Map;return{source:t.map((
|
|
7
|
-
`),definitions:n,invocations:r}}function
|
|
8
|
-
`).filter(
|
|
9
|
-
${
|
|
10
|
-
`)||(
|
|
11
|
-
`)
|
|
12
|
-
`}
|
|
13
|
-
`)
|
|
14
|
-
`
|
|
4
|
+
`&&o++,n.push(r),r="",n.some(a=>a!=="")&&t.push(n),n=[]):r+=i}return n.push(r),n.some(o=>o!=="")&&t.push(n),t.map(o=>o.map(i=>i.trim()))}function Nr(e){let t=a=>{throw new Error(`Examples: {data:${e}}: ${a}`)},n=it?xt.join(bn,`${e}.${it}.csv`):void 0,r=n&&yn.existsSync(n)?n:xt.join(bn,`${e}.csv`);if(!e.includes(".")&&yn.existsSync(r)){let[a,...c]=op(yn.readFileSync(r,"utf8"));return(!a||c.length===0)&&t(`${e}.csv needs a header row and at least one data row`),{headers:a,rows:c.map(l=>Object.fromEntries(a.map((d,p)=>[d,l[p]??""])))}}let s;try{s=Zt(e)}catch(a){return t(`${a instanceof Error?a.message:String(a)} (looked for ${e.split(".")[0]}.csv and ${e.split(".")[0]}.json in the data folder)`)}(!Array.isArray(s)||s.length===0||s.some(a=>a===null||typeof a!="object"||Array.isArray(a)))&&t('must be a non-empty list of records, like [{ "role": "Admin", "menu": "Users" }]');let o=s,i=[...new Set(o.flatMap(a=>Object.keys(a)))];return{headers:i,rows:o.map(a=>Object.fromEntries(i.map(c=>[c,a[c]===void 0||a[c]===null?"":String(a[c])])))}}function Mr(e){return e.replace(vn,(t,n)=>Sn(n))}function $t(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(vn))t.add(r[1]);return t}function Fr(e){let t=new Map;for(let n of $t(e))try{t.set(n,Sn(n))}catch{}return t}function Zs(e,t,n=new Set){let r=new Map;for(let[i,a]of t)r.set(a,[...r.get(a)??[],i]);let s=[];for(let[i,a]of r){let c=a.length===1?a:a.filter(l=>n.has(l));c.length===1&&s.push([c[0],i])}s.sort((i,a)=>a[1].length-i[1].length);for(let[i,a]of s)if(a!==""&&e===a)return`{data:${i}}`;let o=e;for(let[i,a]of s)a.length<4||(o=o.split(a).join(`{data:${i}}`));return o}var xn=/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;function Br(e,t){return e.replace(xn,(n,r)=>{if(!t.has(r))throw new Error(`{env:${r}} is not referenced by this scenario: a recording can only use the environment variables its scenario names`);let s=process.env[r];if(s===void 0)throw new Error(`environment variable ${r} is not set (referenced as {env:${r}}): export it or add it to .env`);return s})}function Xs(e){let t=new Set;for(let n of e)if(n)for(let r of n.matchAll(xn))t.add(r[1]);return t}function ze(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 eo(e){let t=[];for(let n of $t(e))try{t.push(...Nt(n))}catch{}return t}function Fe(e){let t=ze(e);return Xs([...t,...eo(t)])}function ct(e){let t=new Map;for(let n of Fe(e)){let r=process.env[n];r!==void 0&&t.set(n,r)}return t}var Qt=3,Qs=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)?)\\?\}`,ap=/\{(env:[A-Za-z_][A-Za-z0-9_]*|data:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\\\}/g,xa=e=>`${e.slice(0,-1)}\\}`,$a=e=>e.replace(ap,"{$1}"),Lr={'"':'\\"',"\\":"\\\\","\b":"\\b","\f":"\\f","\n":"\\n","\r":"\\r"," ":"\\t"};function cp(e,t,n,r){let s="";for(let o of e.split("")){let i=o.charCodeAt(0);if(t?.test(o)||i<32&&!Lr[o]){let a=i.toString(16).padStart(4,"0");s+="\\u"+(n?a.toUpperCase():a)}else Lr[o]?s+=Lr[o]:o==="/"&&r?s+="\\/":s+=o}return s}function lp(e){return[e,e.replace(/%[0-9A-F]{2}/g,t=>t.toLowerCase())]}function va(e,t,n){return e.replace(/[&<>"'+]|[^\x00-\x7f]/gu,r=>t[r]??(n&&r>"\x7F"?n(r.codePointAt(0)):r))}function dp(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 fp=[/["#<>']/,/["#<>?^`{}]/,/["#<>?^`{|}]/,/["#<>?^`{}/:;=@[\\\]|]/,/["#<>?^`{}/:;=@[\\\]|']/];function pp(e){let t=/[^\x00-\x7f]/,n=new Set([e]);for(let[s,o,i]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(cp(e,s,o,i));let r=s=>{try{for(let o of lp(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 fp)r(()=>dp(e,s));for(let s of[{"&":"&","<":"<",">":">"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'"},{"&":"&","<":"<",">":">",'"':""","'":"'","+":"+"}])n.add(va(e,s));return n.add(va(e,{"&":"&","<":"<",">":">",'"':""","'":"'","+":"+"},s=>`&#x${s.toString(16).toUpperCase()};`)),[...n]}var Kn=e=>e.map(t=>`{env:${t}}`).join(""),kt=e=>e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),up=e=>Buffer.from(e,"utf8").toString("latin1");function Xt(e,t={}){let n=[...e.entries()].filter(([,f])=>f.length>=Qt).sort((f,g)=>g[1].length-f[1].length),r=new Map,s=new Set;for(let[f,g]of n){t.standaloneBelow!==void 0&&g.length<t.standaloneBelow&&s.add(g);let w=t.encodings?pp(g):[g];for(let R of w){let $=t.bytes?[up(R),.../^[\x00-\xff]*$/.test(R)?[R]:[]]:[R];for(let m of $)r.has(m)||r.set(m,f)}}let o=new Set(n.map(([,f])=>f)),i=new Set(["u"]);for(let[,f]of n)for(let g of f){let w=Lr[g]??(g==="/"?"\\/":void 0);w&&i.add(w.slice(1))}let a=new RegExp(`\\\\[${[...i].map(kt).join("")}]`);if(r.size===0)return{empty:!0,mask:f=>f,matches:()=>[],occursIn:()=>!1,isValue:()=>!1,hiddenByEscape:a};let c=[...r.keys()].sort((f,g)=>g.length-f.length).map(f=>s.has(f)?`(?<![A-Za-z0-9])${kt(f)}(?![A-Za-z0-9])`:kt(f)).join("|"),l=new RegExp(`(${Qs})|${c}`,"g"),d=new RegExp(`(${Qs})|${c}`,"y"),p=new RegExp(c),u=f=>{let g=[];l.lastIndex=0;for(let w=l.exec(f);w!==null;w=l.exec(f)){if(w[1]!==void 0)continue;let R=w.index,$=R+w[0].length,m=[r.get(w[0])];for(let v=R+1;v<$;v++){d.lastIndex=v;let S=d.exec(f);S&&S[1]===void 0&&v+S[0].length>$&&($=v+S[0].length,m.push(r.get(S[0])))}g.push({start:R,end:$,names:[...new Set(m)]}),l.lastIndex=$}return g};return{empty:!1,hiddenByEscape:a,occursIn:f=>p.test(f),isValue:f=>o.has(f),matches:u,mask(f){let g="",w=0;for(let R of u(f))g+=f.slice(w,R.start)+Kn(R.names),w=R.end;return w===0?f:g+f.slice(w)}}}function to(e,t,n=r=>r){let r=new Map;for(let i of e)for(let a of i.split(/[\s"'`]+/)){let c=a.replace(/^[.,;:!?()[\]]+|[.,;:!?()[\]]+$/g,""),l=[...c.matchAll(xn)].map(u=>u[1]);if(l.length===0||l.some(u=>!t.has(u)))continue;let d=c.replace(xn,(u,f)=>t.get(f));if(d===c||d.length<Qt||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((u,f)=>f%2?u:n(u)).join(""))}if(r.size===0)return i=>i;let s=[...r.keys()].sort((i,a)=>a.length-i.length).map(kt).join("|"),o=new RegExp(`(${Qs})|${s}`,"g");return i=>i.replace(o,(a,c)=>c!==void 0?a:r.get(a))}var Sa=new WeakMap;function et(e,t){let n=Sa.get(t);return n||(n=Xt(t,{encodings:!0}),Sa.set(t,n)),n.mask(e)}function ka(e){let t=ip.join(e,".env");if(ba.existsSync(t))for(let n of ba.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 i=r.slice(s+1).trim();(i.startsWith('"')&&i.endsWith('"')||i.startsWith("'")&&i.endsWith("'"))&&(i=i.slice(1,-1)),process.env[o]===void 0&&(process.env[o]=i)}}function $n(e,t){return e.replace(/<([^<>]+)>/g,(n,r)=>r in t?t[r]:n)}function Ce(e){if(!e||e.length===0)return{};let t={};if(e.every(n=>n.length===2)){for(let[n,r]of e)t[`table:${n}`]=r;return t}if(e.length>=2){let[n,...r]=e;if(n.some(s=>!s))return{};r.forEach((s,o)=>{n.forEach((i,a)=>{s[a]!==void 0&&(t[`table:${o+1}:${i}`]=s[a])})})}return t}function en(e){return e!==void 0?{docstring:e}:{}}function Mt(e){let t=new Map;for(let r of e){let s={...Ce(r.resolvedTable??r.table),...en(r.resolvedDocString??r.docString)};for(let[o,i]of Object.entries(s)){let a=t.get(o);a||t.set(o,a=new Set),a.add(i)}}let n={};for(let[r,s]of t)s.size===1&&(n[r]=[...s][0]);return n}function no(e){return e.replace(/\\.|["'`]|>>/g,t=>t[0]==="\\"?t:t===">>"?"\\>\\>":`\\${t}`)}var xe={quoted:e=>JSON.stringify(e).slice(1,-1),regex:e=>no(kt(e).replaceAll("/","\\/")),pattern:e=>kt(e)};function kn(e,t,n=r=>r){let r="",s=0;for(let o of e.matchAll(/"((?:[^"\\]|\\.)*)"|=\/((?:[^/\\]|\\.)*)\/([a-z]*)/g)){let[i,a,c,l]=o;a===void 0&&/[uv]/.test(l)||(r+=n(e.slice(s,o.index))+(a!==void 0?`"${t(a,"quoted")}"`:`=/${t(c,"regex")}/${l}`),s=o.index+i.length)}return r+n(e.slice(s))}import ro from"node:fs";import Dr from"node:path";import{AstBuilder as gp,GherkinClassicTokenMatcher as hp,Parser as mp}from"@cucumber/gherkin";import{IdGenerator as wp}from"@cucumber/messages";var $e=class extends Error{},yp=/^(\s*)StepSet:\s*(.+?)\s*$/,bp=/^(\s*)StepSet\s+([^\s:].*?)\s*$/;function so(e){let t=e.split(`
|
|
6
|
+
`),n=new Map,r=new Map,s;return{source:t.map((i,a)=>{let c=/^\s*("""|```)/.exec(i)?.[1];if(s)return c===s&&(s=void 0),i;if(c)return s=c,i;let l=i.match(yp);if(l)return n.set(a+1,l[2]),`${l[1]}Scenario: ${l[2]}`;let d=i.match(bp);return d?(r.set(a+1,d[2]),`${d[1]}* StepSet ${d[2]}`):i}).join(`
|
|
7
|
+
`),definitions:n,invocations:r}}function vp(e){return new mp(new gp(wp.uuid()),new hp).parse(e)}function Ra(e,t){for(let n of e)n.scenario?t(n.scenario):"rule"in n&&n.rule&&Ra(n.rule.children,t)}function Rt(e,t){let n=new Map;for(let r of e){let s=Dr.relative(t,r),o=so(ro.readFileSync(r,"utf8"));if(o.definitions.size===0)continue;let i=vp(o.source);i.feature&&Ra(i.feature.children,a=>{let c=o.definitions.get(a.location.line);if(c===void 0)return;let l=n.get(c);if(l)throw new $e(`StepSet "${c}" is defined twice: ${l.file}:${l.line} and ${s}:${a.location.line}. Set names are unique across the project. If line ${a.location.line} was meant to INVOKE the set inside a scenario, remove the colon: "StepSet ${c}".`);if(a.examples.length>0)throw new $e(`StepSet "${c}" (${s}:${a.location.line}) has an Examples table \u2014 step sets take their values from the invoking scenario.`);for(let d of a.steps)if(o.invocations.has(d.location.line))throw new $e(`StepSet "${c}" (${s}:${a.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:a.location.line,steps:[...a.steps]})})}return n}var oo=[".feature",".saffron"];function Tt(e){return oo.some(t=>e.endsWith(t))}function Le(e,t,n){let r=Dr.isAbsolute(t.features??"features")?t.features:Dr.join(e,t.features??"features"),s=new Set(tn(r));for(let o of n)o.endsWith(".saffron")&&s.add(o);return Rt([...s].sort(),e)}function tn(e){if(!ro.existsSync(e))return[];let t=[];for(let n of ro.readdirSync(e,{recursive:!0})){let r=String(n);r.endsWith(".saffron")&&t.push(Dr.join(e,r))}return t.sort()}function Ea(e,t,n){return e.map(({step:r,source:s})=>{let o;switch(r.keywordType){case io.OUTCOME:o="assertion";break;case io.CONTEXT:case io.ACTION:o="action";break;default:o=n.value}n.value=o;let i=r.dataTable?r.dataTable.rows.map(c=>c.cells.map(l=>l.value)):void 0,a=r.docString?.content;return{keyword:r.keyword.trim(),kind:o,text:r.text,resolvedText:$n(r.text,t),table:i,resolvedTable:i?.map(c=>c.map(l=>$n(l,t))),docString:a,resolvedDocString:a!==void 0?$n(a,t):void 0,source:s}})}function Aa(e,t,n,r,s){let o=[];for(let i of e){let a=r.get(i.location.line);if(a===void 0){o.push({step:i,source:{file:t,line:i.location.line,fromBackground:n}});continue}let c=s?.get(a);if(!c)throw new $e(`${t}:${i.location.line} invokes StepSet "${a}", 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 Ep(e,t,n,r,s,o=!1,i=[]){let a=[...new Set([...i,...e.tags.map(l=>l.name)])],c=[];if(e.examples.length===0)c.push({params:{},label:"",tags:a});else{let l=0;for(let d of e.examples){let p=[...new Set([...a,...d.tags.map(g=>g.name)])],u=d.name.trim().match(/^\{data:([A-Za-z0-9_.-]+)\}$/);if(u&&!d.tableHeader){let g=`${t}:${d.location.line}`;if(!o)throw new $e(`${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 w;try{w=Nr(u[1])}catch(m){throw new $e(`${g} ${m instanceof Error?m.message:String(m)}`)}let $=[...new Set([...r,...s].flatMap(m=>[m.step.text,m.step.docString?.content??"",...m.step.dataTable?.rows.flatMap(v=>v.cells.map(S=>S.value))??[]].flatMap(v=>[...v.matchAll(/<([^<>]+)>/g)].map(S=>S[1]))))].filter(m=>!w.headers.includes(m)&&!m.startsWith("table:")&&m!=="docstring");if($.length>0)throw new $e(`${g} the scenario uses <${$.join(">, <")}>, but {data:${u[1]}} has the columns ${w.headers.map(m=>`"${m}"`).join(", ")}.`);for(let m of w.rows)l+=1,c.push({params:m,label:` (example ${l})`,tags:p});continue}let f=d.tableHeader?.cells.map(g=>g.value)??[];for(let g of d.tableBody){l+=1;let w={};g.cells.forEach((R,$)=>{w[f[$]]=R.value}),c.push({params:w,label:` (example ${l})`,line:g.location.line,tags:p})}}}return c.map(({params:l,label:d,line:p,tags:u})=>{let f={value:"action"};return{featurePath:t,featureName:n,name:e.name,displayName:`${e.name}${d}`,tags:u,steps:[...Ea(r,l,f),...Ea(s,l,f)],params:l,line:e.location.line,...p!==void 0?{exampleLine:p}:{}}})}function _a(e,t){let n=Sp.readFileSync(e,"utf8"),r=e.endsWith(".saffron"),s=r?so(n):{source:n,definitions:new Map,invocations:new Map},o=new Rp(new $p(Tp.uuid()),new kp),i=xp.relative(t,e),a;try{a=o.parse(s.source)}catch(c){let l=(c instanceof Error?c.message:String(c)).split(`
|
|
8
|
+
`).filter(p=>/^\(\d+:\d+\)/.test(p)).map(p=>`${i}${p.replace(/^\((\d+):(\d+)\)/,":$1:$2")}`),d=!r&&/^\s*StepSet\b/m.test(n);throw new $e(d?`${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
|
+
${l.join(`
|
|
10
|
+
`)||(c instanceof Error?c.message:String(c))}`)}return{doc:a,pre:s,relPath:i,isSaffron:r,lineCount:n.split(`
|
|
11
|
+
`).length-(n.endsWith(`
|
|
12
|
+
`)?1:0)}}function Ia(e,t){let{doc:n,pre:r,lineCount:s}=_a(e,t),o=[],i=(c,l)=>Math.min(l,...c.map(d=>d.location.line)),a=c=>{for(let l of c)if(l.background){let{name:d,location:p}=l.background;o.push({kind:"background",name:d,line:p.line,start:p.line,rows:[]})}else if(l.scenario){let{name:d,location:p,tags:u,examples:f}=l.scenario,g=r.definitions.has(p.line)?"stepSet":"scenario",w=f.flatMap(R=>R.tableBody.map($=>$.location.line));o.push({kind:g,name:d,line:p.line,start:i(u,p.line),rows:w})}else if("rule"in l&&l.rule){let{name:d,location:p,tags:u}=l.rule;o.push({kind:"rule",name:d,line:p.line,start:i(u,p.line),rows:[]}),a(l.rule.children)}};return n.feature&&a(n.feature.children),{blocks:o,lineCount:s}}function Ca(e){let t=e.split(/\r?\n/),n=t.findIndex(c=>c.trim()!==""&&!c.trim().startsWith("#")),r=(n<0?t:t.slice(0,n)).map(c=>/^\s*#\s*language\s*:\s*([\w-]+)/.exec(c)?.[1]).find(Boolean),s=r&&Ta[r]||Ta.en,o=[...s.scenario,...s.scenarioOutline].map(c=>`${c.trim()}:`),i=[],a;return t.forEach((c,l)=>{let d=c.trim(),p=['"""',"```"].find(f=>d.startsWith(f));if(p){a?a===p&&(a=void 0):a=p;return}if(a)return;let u=o.find(f=>d.startsWith(f));u&&i.push({line:l+1,name:d.slice(u.length).trim()})}),i}function Be(e,t,n){let{doc:r,pre:s,relPath:o,isSaffron:i}=_a(e,t);if(!r.feature)return[];let a=r.feature.name,c=[],l=(d,p,u)=>{let f=p;for(let g of d)if(g.background)f=[...p,...Aa(g.background.steps,o,!0,s.invocations,n)];else if(g.scenario){if(s.definitions.has(g.scenario.location.line))continue;c.push(...Ep(g.scenario,o,a,f,Aa(g.scenario.steps,o,!1,s.invocations,n),i,u))}else"rule"in g&&g.rule&&l(g.rule.children,f,[...u,...g.rule.tags.map(w=>w.name)])};return l(r.feature.children,[],r.feature.tags.map(d=>d.name)),c}import Ua from"node:fs";import po from"node:path";import Ma from"node:fs";import Fa from"node:path";import{createHash as Mp}from"node:crypto";import zr from"node:fs";import Rn from"node:path";import Pa from"node:path";var He=new Set(["expectVisible","expectNotVisible","expectText","expectUrl","expectValue","expectDiffers","expectMatches","expectAttribute","expectResponse","expectEach"]);var Ur=1,Ap=new Set(["goto","click","fill","press","selectOption","check","uncheck","hover","waitFor","captureText","handleDialog","uploadFile","dragTo","waitForResponse","switchTab","closeTab","newTab",...He]),_p=["role","name","nameRegex","label","text","testId","placeholder","frame"],Ip=["value","url","saveAs","compareTo","each","pattern","attribute","promptText","urlPattern","method","status","pageFingerprint"],ao=/[\u0000-\u001f\u007f-\u009f]/,le=class extends Error{constructor(n,r){super(`${n}: ${r}`);this.file=n;this.problem=r}file;problem},tt=e=>e!==null&&typeof e=="object"&&!Array.isArray(e);function J(e,t,n){if(!e)throw new le(t,n)}function Oa(e,t,n){J(tt(e),t,`${n} must be an object`);for(let r of _p)J(e[r]===void 0||typeof e[r]=="string",t,`${n}.${r} must be a string`);e.fallbackSelectors!==void 0&&J(Array.isArray(e.fallbackSelectors)&&e.fallbackSelectors.every(r=>typeof r=="string"),t,`${n}.fallbackSelectors must be a list of selectors`)}var Cp=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),Pp={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 Op(e,t,n){let r=o=>typeof e[o]=="string",s=o=>tt(e[o])&&Cp(e[o]);for(let o of Pp[e.action])switch(o){case"target":case"toTarget":J(s(o),t,`${n} (${e.action}) needs a ${o} with a role, name, label, text, test id, placeholder or selector`);break;case"urlOrValue":J(r("url")||r("value"),t,`${n} (${e.action}) needs a url`);break;case"targetOrValue":J(s("target")||r("value"),t,`${n} (${e.action}) needs a target or a value`);break;default:J(r(o),t,`${n} (${e.action}) needs ${o}`)}for(let o of["target","toTarget"])e[o]!==void 0&&J(s(o),t,`${n}.${o} has no usable locator`);if(e.action==="expectUrl"){let o=e.url??e.value;J(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&&J(e[o].length>0,t,`${n}.${o} must not be empty`);["press","uploadFile","switchTab","expectText"].includes(e.action)&&J(typeof e.value=="string"&&e.value.length>0,t,`${n} (${e.action}) needs a non-empty value`)}function jp(e,t,n){J(tt(e),t,`${n} must be an object`),J(typeof e.action=="string",t,`${n}.action is missing`),J(Ap.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 Ip)J(e[r]===void 0||typeof e[r]=="string",t,`${n}.${r} must be a string`);J(e.opensTab===void 0||typeof e.opensTab=="boolean",t,`${n}.opensTab must be true or false`),e.target!==void 0&&Oa(e.target,t,`${n}.target`),e.toTarget!==void 0&&Oa(e.toTarget,t,`${n}.toTarget`),Op(e,t,n)}function Np(e,t,n){J(tt(e),t,`${n} must be an object`),J(typeof e.gherkin=="string"&&e.gherkin.length>0,t,`${n}.gherkin (the step text) is missing`),J(typeof e.keyword=="string",t,`${n}.keyword is missing`),J(e.kind==="action"||e.kind==="assertion",t,`${n}.kind must be "action" or "assertion"`),J(Array.isArray(e.actions),t,`${n}.actions must be a list (empty for a recorded no-op)`),e.actions.forEach((r,s)=>jp(r,t,`${n}.actions[${s}]`)),e.table!==void 0&&J(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`),J(e.docString===void 0||typeof e.docString=="string",t,`${n}.docString must be a string`),J(e.fromStepSet===void 0||typeof e.fromStepSet=="string",t,`${n}.fromStepSet must be a string`)}function co(e,t){return J(tt(e),t,"is not a JSON object"),J(e.version!==void 0,t,"has no version: not a Saffron recording"),J(e.version===Ur,t,typeof e.version=="number"&&e.version>Ur?`is schema version ${e.version}; this saffron-ai reads version ${Ur}. Update saffron-ai to the release that wrote it`:`has schema version ${JSON.stringify(e.version)}; this saffron-ai reads version ${Ur}`),J(typeof e.feature=="string"&&e.feature.length>0,t,"feature (the feature file path) is missing"),J(!Pa.isAbsolute(e.feature)&&!Pa.win32.isAbsolute(e.feature)&&!e.feature.split(/[\\/]/).includes(".."),t,`names a feature file outside the project: ${JSON.stringify(e.feature)}`),J(typeof e.scenario=="string"&&e.scenario.length>0,t,"scenario (the scenario name) is missing"),J(e.recordedAt===void 0||typeof e.recordedAt=="string",t,"recordedAt must be a string"),J(e.recordedBy===void 0||e.recordedBy==="agent"||e.recordedBy==="manual",t,'recordedBy must be "agent" or "manual"'),J(Array.isArray(e.steps),t,"steps must be a list"),e.steps.forEach((n,r)=>Np(n,t,`steps[${r}]`)),e.data!==void 0&&J(tt(e.data)&&Object.values(e.data).every(n=>typeof n=="string"),t,"data must map references to strings"),e}function ja(e,t){J(tt(e),t,"is not a JSON object"),J(tt(e.meta),t,"meta is missing: not a Saffron proposal");let n=e.meta;J(n.mode==="record"||n.mode==="heal",t,'meta.mode must be "record" or "heal"'),J(typeof n.narrative=="string",t,"meta.narrative must be a string"),J(Array.isArray(n.adaptations)&&n.adaptations.every(r=>typeof r=="string"),t,"meta.adaptations must be a list of strings"),J(n.verified===void 0||typeof n.verified=="boolean",t,"meta.verified must be true or false"),J(tt(n.usage),t,"meta.usage is missing"),n.featureEdits!==void 0&&(J(Array.isArray(n.featureEdits)&&n.featureEdits.every(r=>tt(r)&&typeof r.index=="number"&&typeof r.text=="string"),t,"meta.featureEdits must be a list of { index, text }"),n.featureEdits.forEach((r,s)=>J(!ao.test(r.text),t,`meta.featureEdits[${s}].text holds a line break or another control character`))),n.bound!==void 0&&J(tt(n.bound)&&["scenario","committed","recording"].every(r=>typeof n.bound[r]=="string"),t,"meta.bound must hold the scenario, committed and recording digests"),co(e.cache,`${t} (cache)`)}var lo=".saffron";function Hr(e){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"unnamed"}function fo(e){let t=e.split(/[\\/]/).filter(Boolean),n=t.pop()??"unnamed",r=t.slice(1),s=Hr([...r,Rn.basename(n,".feature")].join("-"));if(r.length===0)return s;let o=Mp("sha1").update(e.replace(/\\/g,"/")).digest("hex").slice(0,6);return`${s}-${o}`}function se(e,t,n){return Rn.join(e,lo,"cache",fo(t),`${Hr(n)}.json`)}function Na(e,t){let n=new Map;for(let r of t){let s=se(e,r.featurePath,r.name),o=n.get(s)??new Set;o.add(`${r.featurePath} \u203A ${r.name}${r.line?` (line ${r.line})`:""}`),n.set(s,o)}return[...n.entries()].filter(([,r])=>r.size>1).map(([r,s])=>`${[...s].join(" and ")} both map to ${Rn.relative(e,r)}. Rename one scenario or one file.`)}var nn="owner.json";function Ft(e,t,n){return Rn.join(e,lo,"artifacts",fo(t),Hr(n))}function qe(e,t,n){return Rn.join(e,lo,"proposals",fo(t),`${Hr(n)}.json`)}function Lt(e,t){try{return Pe(e)}catch(n){if(!(n instanceof le))throw n;t?.(e,n.problem);return}}function Pe(e){if(!zr.existsSync(e))return;let t;try{t=JSON.parse(zr.readFileSync(e,"utf8"))}catch(n){throw new le(e,`is not valid JSON: ${n instanceof Error?n.message:String(n)}`)}return co(t,e)}function Tn(e,t){zr.mkdirSync(Rn.dirname(e),{recursive:!0}),zr.writeFileSync(e,`${JSON.stringify(t,null,2)}
|
|
13
|
+
`)}function Yn(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 En(e,t){if(t.trim()==="")return!1;let n=s=>(s??"").replace(/<[^<>]+>/g,""),r=new Set([t,...Object.values(xe).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(i=>n(o).includes(i))))}function qr(e){let t=[];return{pattern:e.replace(/"((?:[^"\\]|\\.)*)"/g,(r,s)=>(t.push(s),`"\xAB${t.length-1}\xBB"`)),args:t}}function Fp(e,t){let n=r=>JSON.stringify(r.map(({pageFingerprint:s,...o})=>o));return n(e)===n(t)}function Lp(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 lt(e){let t=Fa.join(e,".saffron","cache"),n=new Map,r=new Map,s=0;if(Ma.existsSync(t))for(let a of Ma.readdirSync(t,{recursive:!0})){let c=String(a);if(!c.endsWith(".json"))continue;let l=Lt(Fa.join(t,c));if(l){s++;for(let d of l.steps){let p=d.gherkin,u=JSON.stringify(d.actions.map(({pageFingerprint:w,...R})=>R)),f=r.get(p)??[];f.includes(u)||(f.push(u),r.set(p,f));let g=n.get(p);!g||l.recordedAt>g.recordedAt?n.set(p,{gherkin:d.gherkin,table:d.table,docString:d.docString,actions:d.actions,sourceParams:Mt(l.steps),sourceData:l.data,feature:l.feature,scenario:l.scenario,recordedAt:l.recordedAt,variants:f.length}):g.variants=f.length}}}let o=[...r.entries()].filter(([,a])=>a.length>1).map(([a])=>a),i=new Map;for(let a of n.values()){let{pattern:c,args:l}=qr(a.gherkin);if(l.length===0)continue;let d=i.get(c);(!d||a.recordedAt>d.recordedAt)&&i.set(c,{...a,args:l})}return{steps:n,patterns:i,divergent:o,scannedCaches:s}}function La(e,t,n){if(!!e.table!=!!t.table)return!1;if(e.table&&t.table){let o=Object.keys(Ce(e.table)),i=Object.keys(Ce(t.table));if(o.length>0&&i.length>0){if(JSON.stringify(o)!==JSON.stringify(i))return!1}else if(JSON.stringify(e.table)!==JSON.stringify(t.table))return!1}if(e.docString!==void 0!=(t.docString!==void 0)||e.docString!==void 0&&t.docString!==void 0&&e.docString!==t.docString&&!e.actions.some(o=>[o.value,o.url,o.pattern].some(i=>i?.includes("<docstring>"))))return!1;if(e.table&&t.table){let o=Ce(e.table),i=Ce(t.table);for(let a of Object.keys(o))if(o[a]!==i[a]&&(!Yn(e.actions,`<${a}>`)||En(e.actions,o[a])))return!1}for(let[o,i]of Object.entries(e.sourceParams??{}))if(o in n&&n[o]!==i&&En(e.actions,i))return!1;for(let[o,i]of Object.entries(e.sourceData??{})){let a;try{a=Sn(o)}catch{continue}if(a!==i&&(En(e.actions,i)||e.gherkin.includes(`{data:${o}}`)&&!Yn(e.actions,`{data:${o}}`)))return!1}let r={...n,...Ce(t.resolvedTable??t.table),...en(t.resolvedDocString??t.docString)},s=Lp({gherkin:e.gherkin,keyword:"",kind:"action",actions:e.actions});for(let o of s)if(!(o in r))return!1;return!0}function Ba(e,t,n){let r=e.steps.get(t.text);if(r)return La(r,t,n)?r:void 0;let{pattern:s,args:o}=qr(t.text);if(o.length===0)return;let i=e.patterns.get(s);if(!i)return;let a=new Map;for(let p=0;p<o.length;p++){let u=i.args[p],f=o[p];if(u===f)continue;let g=a.get(u);if(g!==void 0&&g!==f)return;a.set(u,f)}let c=p=>[p?.name,p?.nameRegex,p?.label,p?.text,p?.placeholder];for(let p of a.keys())if(!i.actions.some(f=>[f.value,f.url,f.pattern,...c(f.target)].includes(p)))return;let l=p=>p!==void 0&&a.has(p)?a.get(p):p,d={...i,gherkin:t.text,actions:i.actions.map(({pageFingerprint:p,...u})=>({...u,value:l(u.value),url:l(u.url),pattern:l(u.pattern),target:u.target?{...u.target,name:l(u.target.name),nameRegex:l(u.target.nameRegex),label:l(u.target.label),text:l(u.target.text),placeholder:l(u.target.placeholder)}:void 0}))};return La(d,t,n)?d:void 0}function Da(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&&Fp(r.actions,o.actions)){n.push({index:s,from:r.gherkin,to:o.gherkin,feature:o.feature,scenario:o.scenario});break}}}),n}function uo(e){if(!Ua.existsSync(e))return[];let t=[];for(let n of Ua.readdirSync(e,{recursive:!0})){let r=String(n);Tt(r)&&t.push(po.join(e,r))}return t.sort()}function dt(e,t,n,r){let s=po.isAbsolute(t)?t:po.join(e,t),o=uo(s),i=n??Rt(o.filter(f=>f.endsWith(".saffron")),e),a=new Map,c=new Map;for(let f of o)for(let g of r?.get(f)??Be(f,e,i)){let w=`${g.featurePath}::${g.name}`;for(let R of g.steps){let $=a.get(R.text);if($||($={text:R.text,keywords:new Map,kind:R.kind,scenarios:new Set,files:new Set,fromStepSet:R.source?.fromStepSet},a.set(R.text,$)),$.keywords.set(R.keyword,($.keywords.get(R.keyword)??0)+1),$.scenarios.add(w),R.source&&$.files.add(R.source.file),R.source?.fromStepSet){let m=c.get(R.source.fromStepSet);m||c.set(R.source.fromStepSet,m=new Set),m.add(w)}}}let l=lt(e),d=new Set(l.divergent),p=[...a.values()].map(f=>({text:f.text,keyword:[...f.keywords.entries()].sort((g,w)=>w[1]-g[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,g)=>g.usage-f.usage||f.text.localeCompare(g.text)),u=[...i.values()].map(f=>({name:f.name,file:f.file,line:f.line,steps:f.steps.map(g=>g.text),usage:c.get(f.name)?.size??0})).sort((f,g)=>g.usage-f.usage||f.name.localeCompare(g.name));return{steps:p,stepSets:u,scannedFiles:o.length,scannedCaches:l.scannedCaches}}function Jr(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 za(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)+`
|
|
14
|
+
`}import{Server as Xg}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as eh}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as th,ListToolsRequestSchema as nh}from"@modelcontextprotocol/sdk/types.js";import Ye from"node:fs";import gt from"node:path";import Ha from"node:fs";import Bp from"node:path";function pe(e){ka(e);let t=Bp.join(e,"saffron.config.json"),n=Ha.existsSync(t)?JSON.parse(Ha.readFileSync(t,"utf8")):{};return at(e,n.dataDir,process.env.SAFFRON_ENV??n.env),n}var go={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,agentWorkers:1,assertionPolicy:"strict",hookTimeoutMs:3e5,trace:"off"};function qa(e){return{...go,...Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}}import ho from"node:fs";import Wr from"node:path";import{fileURLToPath as Dp}from"node:url";function Vr(){let e=Wr.dirname(Dp(import.meta.url));for(let t=0;t<6;t++){let n=Wr.join(e,"package.json");if(ho.existsSync(n))try{if(JSON.parse(ho.readFileSync(n,"utf8")).name==="saffron-ai")return e}catch{}e=Wr.dirname(e)}}function nt(){let e=Vr();if(!e)return"unknown";try{return JSON.parse(ho.readFileSync(Wr.join(e,"package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}import je from"node:fs";import we from"node:path";import ke from"node:fs";import{createHash as Hp}from"node:crypto";import Ja from"node:os";import _n from"node:path";import Et from"node:fs";import Bt from"node:path";import{createHash as Up,randomBytes as zp}from"node:crypto";function Dt(e,t){try{if(!Et.statSync(t).isFile())return!1;let n=Bt.relative(Et.realpathSync(e),Et.realpathSync(t));return n!==""&&n!==".."&&!n.startsWith(".."+Bt.sep)&&!Bt.isAbsolute(n)}catch{return!1}}var Ae=(e,t)=>Bt.relative(e,t).split(Bt.sep).join("/");function rt(e){return Up("sha256").update(e).digest("hex")}function Oe(e,t,n){Et.mkdirSync(Bt.dirname(e),{recursive:!0});let r=`${e}.${process.pid}-${zp(4).toString("hex")}.partial`;try{Et.writeFileSync(r,t),n!==void 0&&Et.chmodSync(r,n),Et.renameSync(r,e)}catch(s){throw Et.rmSync(r,{force:!0}),s}}function An(e,t){let n=Bt.join(e,t.file);if(Dt(Bt.join(e,".saffron","artifacts"),n))try{let r=Et.readFileSync(n);return t.sha256&&rt(r)!==t.sha256?void 0:r}catch{return}}function Wa(e,t){ke.mkdirSync(_n.dirname(e),{recursive:!0});let n=Buffer.from(`${JSON.stringify(t,null,2)}
|
|
15
|
+
`);return In(e,()=>Oe(e,n)),yo(n)}var qp=6e4,Jp=5e3,Wp=60*6e4,Vp=new Int32Array(new SharedArrayBuffer(4)),rn=class extends Error{};function In(e,t,n=1e4){let r=`${e}.lock`,s=Date.now()+n,o;for(;;){o=JSON.stringify({pid:process.pid,host:Ja.hostname(),at:new Date().toISOString()});try{ke.writeFileSync(r,o,{flag:"wx"});break}catch(i){let a=i.code,c=a==="EPERM"&&process.platform==="win32";if(a!=="EEXIST"&&!c)throw i;if(Date.now()>=s)throw c?i:new rn(`${e} is being decided by another process. Try again.`);Gp(r)||Atomics.wait(Vp,0,0,25)}}try{return t()}finally{mo(r)===o&&ke.rmSync(r,{force:!0})}}function mo(e){try{return ke.readFileSync(e,"utf8")}catch{return}}function Gp(e){let t=mo(e);if(t===void 0)return!1;let n={};try{n=JSON.parse(t)||{}}catch{}let r=Date.parse(n.at??""),s=qp;if(Number.isNaN(r)){try{r=ke.statSync(e).mtimeMs}catch{return!1}s=Jp}let o=n.host===Ja.hostname()&&typeof n.pid=="number";return o&&Kp(n.pid)&&Date.now()-r<Wp||!o&&Date.now()-r<s||mo(e)!==t?!1:(ke.rmSync(e,{force:!0}),!0)}function Kp(e){try{return process.kill(e,0),!0}catch(t){return t.code!=="ESRCH"}}function Gr(e,t,n){return ke.existsSync(e)?In(e,()=>ke.existsSync(e)?t():n()):n()}function wo(e){let t=/^(.+)#([0-9a-f]{64})$/i.exec(e);return t?{file:t[1],revision:t[2].toLowerCase()}:{file:e}}function Ge(e){return yo(ke.readFileSync(e))}var yo=e=>Hp("sha256").update(e).digest("hex"),Va=e=>_n.join(e,".saffron","reports","filed.json");function Ga(e){let t;try{t=JSON.parse(ke.readFileSync(Va(e),"utf8"))}catch{return{}}return!t||typeof t!="object"||Array.isArray(t)?{}:Object.fromEntries(Object.entries(t).filter(([,n])=>typeof n=="string"))}function Ka(e,t,n){return Ga(e)[Ae(e,t)]===n}function Ya(e,t,n){let r=Ga(e);r[Ae(e,t)]=n;for(let[s,o]of Object.entries(r)){try{if(Ge(_n.join(e,s))===o)continue}catch{}delete r[s]}Oe(Va(e),`${JSON.stringify(r,null,2)}
|
|
16
|
+
`)}function Cn(e){return Kr(e).proposal}function Kr(e){return bo(ke.readFileSync(e),e)}function bo(e,t){let n;try{n=JSON.parse(e.toString("utf8"))}catch(r){throw new le(t,`is not valid JSON: ${r instanceof Error?r.message:String(r)}`)}return ja(n,t),{proposal:n,revision:yo(e),bytes:e}}function ft(e,t){let n=_n.join(e,".saffron","proposals");if(!ke.existsSync(n))return[];let r=[];for(let s of ke.readdirSync(n)){let o=_n.join(n,s);if(ke.statSync(o).isDirectory())for(let i of ke.readdirSync(o)){if(!i.endsWith(".json"))continue;let a=_n.join(o,i);try{r.push({file:a,...Kr(a)})}catch(c){if(!t||!(c instanceof le))throw c;t(a,c.problem)}}}return r}function Yr(e,t){let n=Cn(t),r=se(e,n.cache.feature,n.cache.scenario);return Tn(r,n.cache),ke.rmSync(t),r}function Zr(e){ke.rmSync(e)}var vo=(e,t)=>we.relative(e,t).split(we.sep).join("/");function Za(e){return je.existsSync(e)?[...je.readdirSync(e,{recursive:!0})].map(String).filter(t=>t.endsWith(".json")).map(t=>we.join(e,t)).sort():[]}function Qa(e){try{let t=JSON.parse(je.readFileSync(e,"utf8")),n="cache"in t&&t.cache?t.cache:t;return{feature:n.feature,scenario:n.scenario}}catch{return{}}}function Xa(e){try{let t=JSON.parse(je.readFileSync(we.join(e,nn),"utf8"));return{feature:typeof t.feature=="string"?t.feature:void 0,scenario:typeof t.scenario=="string"?t.scenario:void 0}}catch{return{}}}function Xr(e,t,n){let r=we.isAbsolute(t)?t:we.join(e,t),s=new Set(je.existsSync(r)?[...je.readdirSync(r,{recursive:!0})].map(String).filter(Tt).map(l=>we.join(r,l)):[]);for(let l of["cache","proposals"])for(let d of Za(we.join(e,".saffron",l))){let{feature:p}=Qa(d);if(typeof p=="string"){let u=we.resolve(e,p);je.existsSync(u)&&s.add(u)}}for(let l of Qr(we.join(e,".saffron","artifacts")))for(let d of Qr(l)){let p=Xa(d).feature;if(p){let u=we.resolve(e,p);je.existsSync(u)&&s.add(u)}}let o=[...n?.unreadable??[]],i=[...s].filter(l=>!n?.parsedFiles.has(l)),a=new Map;if(i.length>0)try{a=Rt([...new Set([...tn(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 i){let d;try{d=Be(l,e,a)}catch(p){o.push(`${vo(e,l)}: ${p instanceof Error?p.message:String(p)}`);continue}for(let p of d)c.add(se(e,p.featurePath,p.name)),c.add(qe(e,p.featurePath,p.name)),c.add(Ft(e,p.featurePath,p.displayName))}return{...Yp(e,c),unreadable:o}}function Yp(e,t){let n=[],r=0;for(let o of["cache","proposal"]){let i=we.join(e,".saffron",o==="cache"?"cache":"proposals");for(let a of Za(i)){if(t.has(a)){r++;continue}let{feature:c,scenario:l}=Qa(a),d=c?we.join(e,c):void 0;n.push({file:vo(e,a),kind:o,feature:c,scenario:l,reason:c?je.existsSync(d)?"scenario-gone":"feature-gone":"unknown"})}}let s=we.join(e,".saffron","artifacts");for(let o of Qr(s))for(let i of Qr(o)){if(t.has(i)){r++;continue}let{feature:a,scenario:c}=Xa(i);n.push({file:vo(e,i),kind:"artifact",feature:a,scenario:c,reason:a?je.existsSync(we.join(e,a))?"scenario-gone":"feature-gone":"unknown"})}return{orphans:n,kept:r}}function Qr(e){try{return je.readdirSync(e,{withFileTypes:!0}).filter(t=>t.isDirectory()).map(t=>we.join(e,t.name)).sort()}catch{return[]}}function ec(e,t){let n=[],r=new Set;for(let s of t){let o=we.join(e,s.file);try{s.kind==="proposal"?In(o,()=>je.rmSync(o)):je.rmSync(o,{recursive:s.kind==="artifact"}),n.push(s.file),r.add(we.dirname(o))}catch{}}for(let s of r)try{je.readdirSync(s).length===0&&je.rmdirSync(s)}catch{}return n}function tc(e){if(e.kind==="artifact")return`${e.file}/
|
|
15
17
|
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}
|
|
16
|
-
${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import
|
|
18
|
+
${e.kind==="proposal"?"pending proposal for ":""}${t}: ${n}`}import Ku from"node:path";var Zp=/\{date([+-]\d+)?(?::([^}]+))?\}/g;function Qp(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 nc(e,t=new Date){return e.replace(Zp,(n,r,s)=>{let o=new Date(t);return o.setDate(o.getDate()+(r?parseInt(r,10):0)),Qp(o,s??"YYYY-MM-DD")})}var Xp=/\b(\d{4})-(\d{2})-(\d{2})\b/g,eu=400;function rc(e,t=new Date){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate());return e.replace(Xp,(r,s,o,i)=>{let a=new Date(Number(s),Number(o)-1,Number(i));if(Number.isNaN(a.getTime()))return r;let c=Math.round((a.getTime()-n.getTime())/864e5);return Math.abs(c)>eu?r:c===0?"{date}":`{date${c>0?"+":""}${c}}`})}import{createHash as tu,randomBytes as nu}from"node:crypto";var sc=/\{unique:([A-Za-z0-9_-]+)(?::(digits))?\}/g,Ke="__saffron_unique_seed",Ut=()=>`${Date.now().toString(36)}${nu(4).toString("hex")}`;function oc(e,t,n=!1){let r=tu("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 Pn(e,t){return e.replace(sc,(n,r,s)=>oc(t,r,s==="digits"))}function On(e,t){let n=new Map;for(let r of e)if(r)for(let s of r.matchAll(sc))n.set(s[0],oc(t,s[1],s[2]==="digits"));return n}function ic(e,t){let n=e;for(let[r,s]of t)n=n.split(s).join(r);return n}import{createHash as ru}from"node:crypto";async function sn(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:${ru("sha256").update(n).digest("hex")}`}var su=/^[a-z][a-z\d+.-]*:|^\/\//i,ac=e=>e.pathname.replace(/\/+$/,"");function Zn(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=ac(r),o=n.pathname,i;if(o===r.pathname||o===s||o===`${s}/`)i="/";else if(o.startsWith(`${s}/`))i=o.slice(s.length);else return e;let a=n.search===r.search?"":n.search||"?",c=i!=="/"?n.hash:n.hash===r.hash?"":n.hash||(r.hash?"#":"");return`${i}${a}${c}`}function So(e,t){if(su.test(e))return e;if(!t)throw new Error(`relative navigation "${e}" needs a base URL (set baseURL or pass --base-url)`);let n=new URL(t),r=e.indexOf("#"),s=r>=0?e.slice(r):void 0,o=r>=0?e.slice(0,r):e,i=o.indexOf("?"),a=i>=0?o.slice(i):void 0,c=i>=0?o.slice(0,i):o,l=c===""||c==="/",d=new URL(n.href);return d.pathname=l?n.pathname:`${ac(n)}${c.startsWith("/")?c:`/${c}`}`,d.search=a===void 0?n.search:a==="?"?"":a,d.hash=s==="#"?"":s!==void 0?s:l?n.hash:"",d.toString()}import xo from"node:fs";import ou from"node:os";import De from"node:path";import{Client as iu}from"@modelcontextprotocol/sdk/client/index.js";import{StdioClientTransport as au}from"@modelcontextprotocol/sdk/client/stdio.js";import{McpServer as cu}from"@modelcontextprotocol/sdk/server/mcp.js";import{CallToolRequestSchema as lu,ListToolsRequestSchema as du}from"@modelcontextprotocol/sdk/types.js";var $o=6e4;function cc(){return xo.mkdtempSync(De.join(ou.tmpdir(),"saffron-mcp-"))}var ko=1e8,lc=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 fu(e){return{...e,tools:e.tools.filter(t=>lc.has(t.name))}}function pu(e){return lc.has(e)?void 0:{isError:!0,content:[{type:"text",text:`${e} is not available in a Saffron session.`}]}}var uu=e=>({isError:!0,content:[{type:"text",text:e}]}),gu=new Set(["http:","https:"]);function Qn(e){if(typeof e!="string"||/^localhost(:\d+)?(\/|$)/i.test(e))return;let t=`${e} is not a web page; a Saffron session only opens http(s) URLs.`,n;try{n=new URL(e)}catch{return/^[a-z][a-z\d+.-]*:/i.test(e.replace(/^[\u0000- ]+/,"").replace(/[\t\n\r]/g,""))?t:void 0}return gu.has(n.protocol)||n.href==="about:blank"?void 0:t}function Ro(e,t){if(!Array.isArray(e))return;let n=i=>{try{return xo.realpathSync(i)}catch{return}},r=n(t)??De.resolve(t),s=i=>{let a=De.relative(r,i);return a.startsWith("..")||De.isAbsolute(a)?void 0:a},o=i=>i.split(De.sep).some(a=>a.startsWith(".")&&a!=="..");for(let i of e){if(typeof i!="string")continue;let a=De.resolve(i),c=n(a),l=n(De.dirname(a)),d=s(c??(l?De.join(l,De.basename(a)):a));if(d===void 0&&!c&&!l){let p=De.relative(De.resolve(t),a);!p.startsWith("..")&&!De.isAbsolute(p)&&(d=p)}if(d===void 0)return`${i} is outside the project; uploads come from files inside it.`;if(o(d)||o(De.relative(De.resolve(t),a)))return`${i} is a hidden file; a Saffron session does not upload dotfiles.`;if(!c)return`${i} does not exist; uploads come from files inside the project.`;if(xo.statSync(c).isDirectory())return`${i} is a folder; a Saffron session uploads single files only.`}}function hu(e,t,n=process.cwd()){if(!t)return{args:t};let r=e==="browser_navigate"||e==="browser_tabs"?Qn(t.url):e==="browser_file_upload"?Ro(t.paths,n):void 0;if(r)return{args:t,refused:uu(r)};if("filename"in t){let{filename:s,...o}=t;return{args:o}}return{args:t}}function mu(e,t=$o){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=`
|
|
17
19
|
|
|
18
|
-
[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.]`,i=/[\uD800-\uDBFF]/.test(s.text[t-1])?t-1:t;return{...s,text:s.text.slice(0,i)+o}});return n?{...e,content:r}:e}async function
|
|
19
|
-
`),o=!1;for(let i=0;i<s.length;i++){let a=s[i];if(!a.trim())continue;let c=JSON.parse(a);if(r?.(c),!t.occursIn(a)&&!t.hiddenByEscape.test(a))continue;let l=
|
|
20
|
-
`),"utf8"):void 0}function
|
|
21
|
-
`)?
|
|
22
|
-
`),r=[];for(let s of n){if(!s.trim()){r.push(s);continue}let i=
|
|
23
|
-
`)}var
|
|
24
|
-
`||e==="\r";function
|
|
25
|
-
`)}var
|
|
26
|
-
`))
|
|
27
|
-
`).filter(Boolean).map(
|
|
28
|
-
`)[0]:String(l)}`)}s.length||(a?
|
|
29
|
-
`)[0]}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function
|
|
30
|
-
`)}function
|
|
31
|
-
`).filter(i=>i.trim()),s=!1,o=r.map(i=>{try{if(JSON.parse(i).runId===t.runId)return s=!0,JSON.stringify(t)}catch{}return i});s||o.push(JSON.stringify(t)),
|
|
20
|
+
[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.]`,i=/[\uD800-\uDBFF]/.test(s.text[t-1])?t-1:t;return{...s,text:s.text.slice(0,i)+o}});return n?{...e,content:r}:e}async function dc(e,t){let n=new iu({name:"saffron-cap",version:"1"}),r=Object.fromEntries(Object.entries(process.env).filter(o=>o[1]!==void 0));await n.connect(new au({...e,env:r,stderr:"ignore"}));let s=To(n,void 0,t);return{config:{type:"sdk",name:"playwright",instance:s},close:async()=>{await Promise.allSettled([n.close(),s.close()])}}}function To(e,t=(r,s,o)=>o(),n){let r=new cu({name:"playwright",version:"1"},{capabilities:{tools:{}}});return r.server.setRequestHandler(du,async()=>fu(await e.listTools())),r.server.setRequestHandler(lu,async(s,o)=>{let i=pu(s.params.name);if(i)return i;let a=hu(s.params.name,s.params.arguments,n);if(a.refused)return a.refused;let c={...s.params,arguments:a.args};return mu(await t(c.name,c.arguments,()=>e.callTool(c,void 0,{timeout:ko,signal:o.signal})))}),r}import er from"node:fs";import xu from"node:os";import Eo from"node:path";import Ue from"node:fs";import wu from"node:path";import{createHash as yu,randomBytes as bu}from"node:crypto";import ts from"node:zlib";var zt=4294967295,pc=67324752,uc=33639248,gc=101010256;function hc(e){return Xn(e).map(t=>({name:t.name,data:t.read()}))}function mc(e,t){let n=Math.max(0,t-22-65535),r=e(n,t-n),s=-1;for(let l=r.length-22;l>=0;l--)if(r.readUInt32LE(l)===gc){s=l;break}if(s<0)throw new Error("not a zip file: no central directory");let o=r.readUInt16LE(s+10),i=e(r.readUInt32LE(s+16),r.readUInt32LE(s+12)),a=0,c=[];for(let l=0;l<o;l++){if(i.readUInt32LE(a)!==uc)throw new Error("not a zip file: bad central directory entry");let d=i.readUInt16LE(a+10),p=i.readUInt32LE(a+16),u=i.readUInt32LE(a+20),f=i.readUInt32LE(a+24),g=i.readUInt16LE(a+28),w=i.readUInt16LE(a+30),R=i.readUInt16LE(a+32),$=i.readUInt32LE(a+42),m=i.subarray(a+46,a+46+g).toString("utf8");a+=46+g+w+R;let v=e($,30);if(v.length<30||v.readUInt32LE(0)!==pc)throw new Error(`not a zip file: bad local header for ${m}`);let S=$+30+v.readUInt16LE(26)+v.readUInt16LE(28);if(!m.endsWith("/")){if(d!==0&&d!==8)throw new Error(`zip entry ${m} uses compression method ${d}, which is not supported`);c.push({name:m,method:d,crc:p,size:f,compressedSize:u,start:S})}}return c}function Xn(e){return mc((t,n)=>e.subarray(t,t+n),e.length).map(({name:t,method:n,crc:r,size:s,compressedSize:o,start:i})=>{let a=e.subarray(i,i+o);return{name:t,method:n,crc:r,size:s,raw:a,read:()=>n===0?Buffer.from(a):ts.inflateRawSync(a)}})}function vu(e,t,n){let r=Ue.openSync(e,"r");try{let s=Buffer.alloc(n),o=0;for(;o<n;){let i=Ue.readSync(r,s,o,n-o,t+o);if(i===0)break;o+=i}return s.subarray(0,o)}finally{Ue.closeSync(r)}}function wc(e){let t=Ue.statSync(e).size,n=(r,s)=>vu(e,r,Math.max(0,Math.min(s,t-r)));return mc(n,t).map(r=>({...r,raw:()=>n(r.start,r.compressedSize)}))}var Su=(()=>{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 es(e,t=0){let n=ts.crc32;if(n)return n(e,t)>>>0;let r=(t^4294967295)>>>0;for(let s of e)r=Su[(r^s)&255]^r>>>8;return(r^4294967295)>>>0}var fc=1024*1024;function yc(e,t){let n=Ue.openSync(e,"r");try{let r=Buffer.alloc(fc);for(let s;(s=Ue.readSync(n,r,0,fc,null))>0;)t(r.subarray(0,s))}finally{Ue.closeSync(n)}}function ns(e){let t=yu("sha256"),n=0,r=0;return yc(e,s=>{n=es(s,n),r+=s.length,t.update(s)}),{crc:n,size:r,sha256:t.digest("hex")}}function bc(e,t,n,r,s,o){let i=Buffer.alloc(30);i.writeUInt32LE(pc,0),i.writeUInt16LE(20,4),i.writeUInt16LE(2048,6),i.writeUInt16LE(t,8),i.writeUInt32LE(0,10),i.writeUInt32LE(n,14),i.writeUInt32LE(r,18),i.writeUInt32LE(s,22),i.writeUInt16LE(e.length,26),i.writeUInt16LE(0,28);let a=Buffer.alloc(46);return a.writeUInt32LE(uc,0),a.writeUInt16LE(20,4),a.writeUInt16LE(20,6),a.writeUInt16LE(2048,8),a.writeUInt16LE(t,10),a.writeUInt32LE(0,12),a.writeUInt32LE(n,16),a.writeUInt32LE(r,20),a.writeUInt32LE(s,24),a.writeUInt16LE(e.length,28),a.writeUInt16LE(0,30),a.writeUInt16LE(0,32),a.writeUInt16LE(0,34),a.writeUInt16LE(0,36),a.writeUInt32LE(0,38),a.writeUInt32LE(o,42),{local:i,central:a}}function vc(e,t,n){let r=Buffer.alloc(22);return r.writeUInt32LE(gc,0),r.writeUInt16LE(0,4),r.writeUInt16LE(0,6),r.writeUInt16LE(e,8),r.writeUInt16LE(e,10),r.writeUInt32LE(t,12),r.writeUInt32LE(n,16),r.writeUInt16LE(0,20),r}var Sc=e=>new Error(`cannot write a zip of ${e} files: one holds at most 65,535`),xc=e=>new Error(`cannot write ${e} into a zip: a zip holds at most 4 GB, and this one would be larger`),$c=()=>new Error("cannot write the zip: a zip holds at most 4 GB, and this one would be larger");function kc(e){if(e.length>65535)throw Sc(e.length);let t=[],n=[],r=0;for(let o of e){let i="raw"in o,a=i?o.raw:ts.deflateRawSync(o.data),c=i?o.size:o.data.length,l=Buffer.from(o.name,"utf8");if(c>zt||r+30+l.length+a.length>zt)throw xc(o.name);let{local:d,central:p}=bc(l,i?o.method:8,i?o.crc:es(o.data),a.length,c,r);t.push(d,l,a),n.push(p,l),r+=d.length+l.length+a.length}let s=n.reduce((o,i)=>o+i.length,0);if(r+s>zt)throw $c();return Buffer.concat([...t,...n,vc(e.length,s,r)])}function jn(e,t){for(let n=0;n<t.length;)n+=Ue.writeSync(e,t,n,t.length-n)}function Rc(e,t){if(t.length>65535)throw Sc(t.length);Ue.mkdirSync(wu.dirname(e),{recursive:!0});let n=`${e}.${process.pid}-${bu(4).toString("hex")}.partial`,r=Ue.openSync(n,"w");try{let s=[],o=0;for(let a of t){let c=Buffer.from(a.name,"utf8"),l="file"in a,d=l&&(a.crc===void 0||a.size===void 0)?ns(a.file):void 0,p=l?void 0:ts.deflateRawSync(a.data),u=l?d?.crc??a.crc:es(a.data),f=l?d?.size??a.size:a.data.length,g=p?.length??f;if(f>zt||o+30+c.length+g>zt)throw xc(a.name);let{local:w,central:R}=bc(c,l?0:8,u,g,f,o);if(jn(r,w),jn(r,c),p)jn(r,p);else{let $=0,m=0;if(yc(a.file,v=>{$+v.length>f||(jn(r,v),$+=v.length,m=es(v,m))}),$!==f||m!==u)throw new Error(`${a.name} changed while it was being packed`)}s.push(R,c),o+=w.length+c.length+g}let i=s.reduce((a,c)=>a+c.length,0);if(o+i>zt)throw $c();for(let a of s)jn(r,a);jn(r,vc(t.length,i,o)),Ue.closeSync(r),Ue.renameSync(n,e)}catch(s){try{Ue.closeSync(r)}catch{}throw Ue.rmSync(n,{force:!0}),s}}var Ao=["off","on","retain-on-failure"],_o="trace.zip";async function Io(e,t){await e.tracing.start({screenshots:!0,snapshots:!0,sources:!1,title:t})}async function Co(e,t){t&&er.mkdirSync(Eo.dirname(t),{recursive:!0}),await e.tracing.stop(t?{path:t}:void 0)}async function Po(e,t,n){let r;try{r=er.mkdtempSync(Eo.join(xu.tmpdir(),"saffron-trace-"));let s=Eo.join(r,_o);await e.tracing.stop({path:s});let o=ju(er.readFileSync(s),n);return Oe(t,o),rt(o)}catch{try{er.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{er.rmSync(r,{recursive:!0,force:!0})}catch{console.warn("Could not remove the private temporary trace directory.")}}}async function Ec(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 $u=new Set(["callId","parentId","pageId","frameId","contextId","pageref","_frameref","snapshotName","beforeSnapshot","afterSnapshot","inputSnapshot","sha1","_sha1"]),ku=new Set(["before","action","after"]),Ru=/^\[saffron:(?:agent|replay|pending|action):-?\d+\] /,Tu=new Set(["type","class","method"]),Eu=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__"]),Au=new Set(["style","__playwright_style_sheet_"]),_u=new Set(["srcset","imagesrcset"]),Iu=/^(?:image\/(?!svg)|font\/|audio\/|video\/)/i,Cu=/\.(?:jpe?g|png|gif|webp|avif|bmp|ico|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|webm)$/i,Pu=/^text\/css\b/i,Ou=/\.css$/i;function ju(e,t){let n=Xt(t,{encodings:!0});if(n.empty)return e;let r=Xt(t,{encodings:!0,bytes:!0}),s=Xn(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 i={names:o,pieces:Ju(t)},a=new Set,c=new Set,l=f=>{for(let g of[f.snapshot?.response?.content,f.snapshot?.request?.postData]){if(!g)continue;let w=g._file??(g._sha1&&`resources/${g._sha1}`);if(!w)continue;let R=String(g.mimeType??"");Iu.test(R)?a.add(w):Pu.test(R)&&c.add(w)}},d=[...s],p=!1,u=(f,g)=>{g&&(d[f]={name:s[f].name,data:g},p=!0)};return s.forEach((f,g)=>{/\.(?:trace|network)$/.test(f.name)?u(g,Nu(f.read(),n,i,f.name.endsWith(".network")?l:void 0)):f.name.endsWith(".stacks")&&u(g,Mu(f.read(),n,i))}),s.forEach((f,g)=>{/\.(?:trace|network|stacks)$/.test(f.name)||a.has(f.name)||Cu.test(f.name)||u(g,Fu(f.read(),n,r,c.has(f.name)||Ou.test(f.name)))}),p?kc(d):e}function Nu(e,t,n,r){let s=e.toString("utf8").split(`
|
|
21
|
+
`),o=!1;for(let i=0;i<s.length;i++){let a=s[i];if(!a.trim())continue;let c=JSON.parse(a);if(r?.(c),!t.occursIn(a)&&!t.hiddenByEscape.test(a))continue;let l=jo(a,t,{keys:!1,numbers:!1,playwright:n});l!==a&&(s[i]=l,o=!0)}return o?Buffer.from(s.join(`
|
|
22
|
+
`),"utf8"):void 0}function Mu(e,t,n){let r=e.toString("utf8");if(JSON.parse(r),!t.occursIn(r)&&!t.hiddenByEscape.test(r))return;let s=jo(r,t,{keys:!1,numbers:!1,playwright:n});return s===r?void 0:Buffer.from(s,"utf8")}function Fu(e,t,n,r=!1){if(r){let d=e.toString("latin1"),p=Cc(d,n);return p===d?void 0:Buffer.from(p,"latin1")}let s=e.toString("utf8"),o=s.startsWith("\uFEFF")?"\uFEFF":"",i=s.slice(o.length);if(!s.includes("\uFFFD")||Buffer.from(s,"utf8").equals(e)){let d=/^\s*[[{"]/.test(i)?Ac(i,t):void 0,p=d===void 0&&/^\s*(?:[[{"\d-]|true|false|null)/.test(i)&&i.trim().includes(`
|
|
23
|
+
`)?Lu(i,t):void 0,u=d??p;if(u!==void 0)return u===i?void 0:Buffer.from(o+u,"utf8")}let c=e.toString("latin1"),l=n.mask(c);return l===c?void 0:Buffer.from(l,"latin1")}function Ac(e,t){try{JSON.parse(e)}catch(n){if(n instanceof SyntaxError)return;throw n}return!t.occursIn(e)&&!t.hiddenByEscape.test(e)?e:jo(e,t,{keys:!0,numbers:!0})}function Lu(e,t){let n=e.split(`
|
|
24
|
+
`),r=[];for(let s of n){if(!s.trim()){r.push(s);continue}let i=Ac(s,t);if(i===void 0)return;r.push(i)}return r.join(`
|
|
25
|
+
`)}var _c=/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*@)?([^/?#]*)/i;function Ic(e,t=0,n=e.length){let r=_c.exec(e.slice(t,n));if(!r)return;let[s,o,i=""]=r,a=t+o.length+i.length;return c=>c>=t&&c<t+o.length||c>=a-(i?1:0)&&c<=t+s.length&&c<n}function Bu(e,t,n){let r="",s=0;for(let o of t.matches(e)){let{token:i,kept:a=()=>!1}=n(o),c="";for(let l=o.start;l<o.end;){if(a(l)){c+=e[l++];continue}for(;l<o.end&&!a(l);)l++;c+=i(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 Du=e=>e.map(t=>`%7Benv:${t}%7D`).join(""),Uu=e=>e.map(t=>`\\{env\\:${t}\\}`).join("");function Oo(e,t,n,r){let s=n.map(([i,a])=>({start:i,end:a,kept:Ic(e,i,a)})),o=0;return Bu(e,t,i=>{for(;o<s.length&&s[o].end<i.end;)o++;let a=s[o];return a&&a.start<=i.start&&i.end<=a.end?{token:Du,kept:a.kept}:{token:r}})}function zu(e,t){return Ic(e)?Oo(e,t,[[0,e.length]],Kn):t.mask(e)}var Hu=/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s'"]*))|@import\s+(?:"([^"]*)"|'([^']*)')/dgi;function Cc(e,t){if(!t.occursIn(e))return e;let n=[...e.matchAll(Hu)].map(r=>r.indices.slice(1).find(s=>s!==void 0));return Oo(e,t,n,Uu)}function qu(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 Oo(e,t,n,Kn)}function Ju(e){let t=new Map;for(let[n,r]of e)if(!(r.length<Qt||!_c.test(r)||!URL.canParse(r)))for(let s of new URL(r).searchParams.values())s.length>=Qt&&!t.has(s)&&t.set(s,n);return t}var Tc=e=>e===" "||e===" "||e===`
|
|
26
|
+
`||e==="\r";function jo(e,t,n){let r=[],s=0,o,i=[],a=c=>{let l=c-1;for(;l>=0&&Tc(e[l]);)l--;return e[l]};for(let c=0;c<e.length;c++){let l=e[c];if(l==="{"||l==="["){let d=i.at(-1),p=a(c)===":"?o:void 0;i.push({dom:(d?.dom??!1)||p==="html",logged:(d?.logged??!1)||p==="value"});continue}if(l==="}"||l==="]"){i.pop();continue}if(l==='"'){let d=c++,p=!1;for(;c<e.length&&e[c]!=='"';c++)e[c]==="\\"&&(p=!0,c++);let u=c+1,f=u;for(;f<e.length&&Tc(e[f]);)f++;let g=e[f]===":",w=p?JSON.parse(e.slice(d,u)):e.slice(d+1,c),R=i.at(-1),$=!g&&a(d)===":"?o:void 0,m=x=>t.mask(x);if(g){if(o=w,!n.keys)continue}else if(n.playwright){if(i.length===1&&$!==void 0&&Tu.has($)||n.playwright.names.has(w))continue;let{pieces:x}=n.playwright;if(m=b=>x.has(b)?Kn([x.get(b)]):zu(b,t),R?.dom){if(a(d)==="["){R.tag=w.toUpperCase();continue}let b=$===void 0?R.tag:i.at(-2)?.tag;if($!==void 0&&Eu.has($)||$==="src"&&(b==="IFRAME"||b==="FRAME"))continue;$===void 0&&b==="STYLE"||$!==void 0&&Au.has($)?m=k=>Cc(k,t):$!==void 0&&_u.has($)&&(m=k=>qu(k,t))}else if($!==void 0&&$u.has($)||$==="phase"&&ku.has(w))continue}let v=n.playwright&&i.length===1&&$==="title"?Ru.exec(w)?.[0]??"":"",S=v+m(w.slice(v.length));S!==w&&(r.push(e.slice(s,d),JSON.stringify(S)),s=u)}else if(l==="-"||l>="0"&&l<="9"){let d=c+1;for(;d<e.length&&/[0-9eE.+-]/.test(e[d]);)d++;(n.numbers||(i.at(-1)?.logged??!1)||a(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 ie(e,t=200){let n=e.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-_]?/g,"").replace(/[\x00-\x1f\x7f-\x9f]/g,"");return n.length>t?`${n.slice(0,t-3)}...`:n}function nr(e){return Array.isArray(e)?e.map(nr):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,nr(n)])):e}var No=e=>JSON.stringify(nr(e));function Mo(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))Mo(s,t?`${t}.${r}`:r,n);else t&&(n[t]=String(e));return n}function Wu(e,t){let n=Mo(nr(e)),r=Mo(nr(t)),s=[...new Set([...Object.keys(n),...Object.keys(r)])].sort(),o=[];for(let i of s){if(i==="action"||n[i]===r[i])continue;let a=i.replace(/^target\./,"").replace(/^toTarget\./,"destination ");n[i]===void 0?o.push(`${a}: added "${r[i]}"`):r[i]===void 0?o.push(`${a}: removed "${n[i]}"`):o.push(`${a}: "${n[i]}" \u2192 "${r[i]}"`)}return o}function Nc(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 i=[],a=0,c=0;for(;a<r.length&&c<s.length;)r[a]===s[c]?(i.push({before:e[a],after:t[c]}),a++,c++):o[a+1][c]>=o[a][c+1]?(i.push({before:e[a]}),a++):(i.push({after:t[c]}),c++);for(;a<r.length;a++)i.push({before:e[a]});for(;c<s.length;c++)i.push({after:t[c]});return i}function Pc(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 Oc=e=>`${e.method??"any"} /${e.urlPattern}/ ${e.status??"2xx"}`+(e.pattern?` with a body matching /${e.pattern}/`:"");function Je(e){let t=Pc(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 ${Pc(e.toTarget)}`;case"waitForResponse":return`wait for ${Oc(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 ${Oc(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 tr=e=>He.has(e.action);function Vu(e,t){let n=Nc(e,t,No),r=[];for(let s=0;s<n.length;s++){let o=n[s];if(o.before&&o.after){r.push({change:"same",before:Je(o.before),after:Je(o.after),assertion:tr(o.after)});continue}let i=n[s+1];if(o.before&&i?.after&&!i.before){r.push({change:"changed",before:Je(o.before),after:Je(i.after),details:Wu(o.before,i.after),assertion:tr(o.before)||tr(i.after)}),s++;continue}o.before&&r.push({change:"removed",before:Je(o.before),assertion:tr(o.before)}),o.after&&r.push({change:"added",after:Je(o.after),assertion:tr(o.after)})}return r}var Gu=e=>`${e.kind}\0${e.gherkin}`;function Ht(e,t,n=[]){let r=Nc(e?.steps??[],t.steps,Gu),s=[],o={added:0,removed:0,changed:0,assertions:0};for(let i of r){let a=i.after??i.before,c=Vu(i.before?.actions??[],i.after?.actions??[]);for(let d of c)d.change==="added"?o.added++:d.change==="removed"?o.removed++:d.change==="changed"&&o.changed++,d.change!=="same"&&d.assertion&&o.assertions++;let l=e&&i.before?i.after?c.some(d=>d.change!=="same")?"changed":"same":"removed":"added";s.push({gherkin:a.gherkin,kind:a.kind,fromStepSet:a.fromStepSet,change:l,actions:c})}return{feature:t.feature,scenario:t.scenario,hasCommitted:e!==void 0,steps:s,summary:o,...n.length>0?{featureEdits:n.map(i=>({index:i.index,from:t.steps[i.index]?.gherkin??`step ${i.index+1}`,to:i.text,fromStepSet:t.steps[i.index]?.fromStepSet}))}:{}}}var jc=new Set(["goto","click","press","newTab","switchTab","closeTab"]);function Mc(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=>jc.has(p.action)),a=[...t[s].actions.filter(p=>jc.has(p.action))],c=[];for(let p of o){let u=a.findIndex(f=>No(f)===No(p));u>=0?a.splice(u,1):c.push(p)}let l=a;for(let p=c.length-1;p>=0;p--){let u=l.findIndex(f=>f.action===c[p].action);u>=0&&(l.splice(u,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(Je).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(Je).join("; ")}. A page or navigation step may have been added or reordered.`)}return n}function rs(e){let t=[];t.push(`${e.feature} \u203A ${e.scenario}`),t.push(e.hasCommitted?"Comparing the committed recording with the proposed one.":"First recording for this scenario: there is nothing to compare against."),t.push("");for(let i of e.steps){let a=i.change==="removed"?"- ":i.change==="added"&&e.hasCommitted?"+ ":" ";t.push(`${a}${i.kind==="assertion"?"[assertion] ":""}${i.gherkin}`+(i.fromStepSet?` (from step set "${i.fromStepSet}")`:""));for(let c of i.actions)if(c.change==="same")t.push(` ${c.before}`);else if(c.change==="added")t.push(` + ${c.after}`);else if(c.change==="removed")t.push(` - ${c.before}`);else{t.push(` - ${c.before}`),t.push(` + ${c.after}`);for(let l of c.details??[])t.push(` ${l}`)}t.push("")}if(e.featureEdits?.length){t.push("Feature file edits, applied only by saffron accept --with-feature-edit:");for(let i of e.featureEdits){let a=i.fromStepSet?` (step set "${i.fromStepSet}": every scenario that uses it)`:"";t.push(` - ${i.from}${a}`,` + ${i.to}`)}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.map(i=>ie(i,1/0)).join(`
|
|
27
|
+
`)}var Do=e=>new Promise(t=>setTimeout(t,e)),Lo=class{entries=[];lastMatch;seq=0;prevStepSeq=0;curStepSeq=0;listener;context;attach(t,n){this.context=t,this.listener=r=>{let s={seq:++this.seq,url:r.url(),method:r.request().method(),status:r.status()};if(this.entries.push(s),n){let o=r.headers()["content-type"]??"";/json|text|xml/i.test(o)&&r.text().then(i=>{s.body=i.length>262144?i.slice(0,262144):i}).catch(()=>{})}},t.on("response",this.listener)}detach(){this.context&&this.listener&&this.context.off("response",this.listener)}markStep(){this.prevStepSeq=this.curStepSeq,this.curStepSeq=this.seq}sincePreviousStep(){return this.entries.filter(t=>t.seq>this.prevStepSeq)}all(){return this.entries}};function Yu(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 Fc(e,t,n,r,s){return!(!t.test(e.url)||n&&e.method.toUpperCase()!==n.toUpperCase()||!Yu(e.status,r)||s&&!s.test(e.body??""))}var Zu=(e,t)=>`response matching ${e.method??"any-method"} /${t}/ status ${e.status??"2xx"}${e.pattern?` body /${e.pattern}/`:""}`,Lc=new Set(["click","fill","press","selectOption","check","uncheck","hover","dragTo","uploadFile"]),Qu=/(?<=(?:^|[^\\])(?:\\\\)*)(\\?)(<[^<>]+>|\{(?:env|data|date)[^{}]*\})/g;function Uc(e,t,n){return $a(Br(Mr(nc($n(e,t))),n))}function Bo(e,t,n,r=!1){return e.replace(Qu,(s,o,i,a)=>{let c=e.slice(Math.max(0,a-2),a);if(i.startsWith("<")&&(c==="(?"||c==="\\k"))return s;let l=t(i);return l===i?s:(r?"":o)+n(l)})}function Bc(e,t,n,r=!1,s=!0,o=new Set){let i=l=>Uc(l,n,o),a=[],c=l=>{if(t.role){let d=t.nameRegex?new RegExp(Bo(t.nameRegex,i,xe.pattern,!0),"i"):t.name?i(t.name):void 0,p=t.role;d===void 0&&!!(t.label||t.placeholder||t.text||t.testId||t.fallbackSelectors?.length)?t.text&&a.push(l.getByRole(p).filter({hasText:i(t.text)})):typeof d=="string"?(a.push(l.getByRole(p,{name:d,exact:!0})),s&&a.push(l.getByRole(p,{name:d,exact:!1}))):a.push(l.getByRole(p,{name:d,exact:!1}))}t.label&&a.push(l.getByLabel(i(t.label))),t.placeholder&&a.push(l.getByPlaceholder(i(t.placeholder))),t.text&&!t.role&&a.push(l.getByText(i(t.text))),t.testId&&a.push(l.getByTestId(i(t.testId)));for(let d of t.fallbackSelectors??[])a.push(l.locator(kn(d,(p,u)=>Bo(p,i,xe[u],u==="regex"),i)))};if(t.frame)c(e.frameLocator(i(t.frame)));else if(c(e),r)for(let l of e.frames())l!==e.mainFrame()&&c(l);if(a.length===0)throw new Error("Action target has neither role nor fallback selectors");return a}var zc=100;async function Xu(e,t,n,r){let s=Date.now()+t,o;for(;Date.now()<s;){try{if(await e())return}catch(i){o=i}await Do(r)}throw new Error(`Timed out waiting for ${n}${o?` (${String(o)})`:""}`)}async function pt(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 Fo=e=>e.replace(/\s+/g," ").trim(),rr=new WeakMap;async function os(e,t,n,r,s,o=new Map,i,a=zc,c=new Set){let l=(b,k,h)=>Xu(b,k,h,a),d=rr.get(e);if(d&&Lc.has(t.action)&&t.action!=="uploadFile"){rr.delete(e);let b,k=y=>{b??=y};e.on("filechooser",k);let h;try{if(h=await os(e,t,n,r,s,o,i,a,c),b||await e.waitForEvent("filechooser",{timeout:r}).then(k,()=>{}),!b)throw new Error(`uploadFile failed: the ${t.action} after it opened no file chooser within ${r} ms`);try{await b.setFiles(d,{timeout:r})}catch(y){throw new Error(`uploadFile failed: ${y instanceof Error?y.message:String(y)}`)}}catch(y){throw rr.set(e,d),y}finally{e.off("filechooser",k)}return h}o.has(Ke)||o.set(Ke,Ut());let p=o.get(Ke),u=b=>typeof b=="string"?Pn(b,p):Array.isArray(b)?b.map(u):b&&typeof b=="object"?Object.fromEntries(Object.entries(b).map(([k,h])=>[k,u(h)])):b;t=u(t),n=u(n);let f=b=>Uc(b,n,c),g=b=>Bo(b,f,xe.pattern,!0),w=t.value!==void 0?f(t.value):void 0,R=t.url!==void 0?f(t.url):void 0,$=t.action==="expectVisible"||t.action==="expectText"||t.action==="expectValue"||t.action==="expectMatches"||t.action==="expectAttribute"||t.action==="expectEach"||t.action==="captureText",m=t.target?Bc(e,t.target,n,$,!Lc.has(t.action),c):[],v={timeout:r},S={timeout:Math.min(1e3,r)},x=b=>async()=>{for(let k of m)try{if(await b(k.first()))return!0}catch{}return!1};switch(t.action){case"goto":{if(!R)throw new Error("goto requires url");let b=Qn(R);if(b)throw new Error(b);await e.goto(So(R,s),v);return}case"click":return pt(m,b=>b.click(v),!0);case"fill":return pt(m,b=>b.fill(w??"",v),!0);case"press":return m.length>0?pt(m,b=>b.press(w??"",v),!0):e.keyboard.press(w??"");case"selectOption":return pt(m,async b=>{await b.selectOption(w??"",v)},!0);case"check":return pt(m,b=>b.check(v),!0);case"uncheck":return pt(m,b=>b.uncheck(v),!0);case"hover":return pt(m,b=>b.hover(v),!0);case"waitFor":return m.length>0?pt(m,b=>b.waitFor({state:"visible",timeout:r})):Do(Math.min(Number(w??"0"),r));case"expectVisible":return l(x(b=>b.isVisible()),r,"target to be visible");case"expectNotVisible":return l(async()=>{for(let b of m){let k=await b.count();for(let h=0;h<k;h++)if(await b.nth(h).isVisible())return!1}return!0},r,`every match of every recorded locator to be hidden (${m.length} locator(s))`);case"expectText":return l(x(async b=>(await b.textContent(S)??"").includes(w??"")),r,`text "${w}"`);case"expectEach":{let b=t.each?.match(/^\{data:([^}]+)\}$/)?.[1];if(!b)throw new Error(`expectEach needs a {data:...} list reference, got ${JSON.stringify(t.each)}`);let k=Nt(b),h=async A=>await A.isVisible()?new Set(await A.evaluate(E=>{let P=j=>(j??"").replace(/\s+/g," ").trim(),O=j=>typeof j.checkVisibility=="function"?j.checkVisibility({visibilityProperty:!0,contentVisibilityAuto:!0}):!!(j.offsetWidth||j.offsetHeight||j.getClientRects().length),M=j=>{let L=j.ownerDocument.createTreeWalker(j,4),_="";for(let F=L.nextNode();F;F=L.nextNode())F.parentElement&&O(F.parentElement)&&(_+=F.nodeValue??"");return _},T=new Set,C=j=>{for(let L of Array.from(j.options))L.hidden||T.add(P(L.textContent))};E.tagName==="SELECT"&&C(E);for(let j of Array.from(E.querySelectorAll("*")))O(j)&&(j.tagName==="SELECT"?C(j):j.tagName!=="OPTION"&&T.add(P(M(j))));T.add(P(M(E)));for(let j of String(E.innerText??"").split(`
|
|
28
|
+
`))T.add(P(j));return[...T]},void 0,S)):new Set,y=k;return l(async()=>{for(let A of m)try{let E=await h(A.first()),P=k.filter(O=>!E.has(O.replace(/\s+/g," ").trim()));if(P.length<y.length&&(y=P),P.length===0)return!0}catch{}return!1},r,`every value of ${t.each} to be shown`).catch(()=>{throw new Error(`${y.map(A=>`"${A}"`).join(", ")} from ${t.each} ${y.length===1?"is":"are"} not shown in the target (${k.length-y.length} of ${k.length} found)`)})}case"expectValue":return l(x(async b=>await b.inputValue(S)===(w??"")),r,`input value "${w}"`);case"expectUrl":{let b=R??w??"";return l(async()=>e.url().includes(b),r,`URL containing "${b}" on the active page (${e.url()})`)}case"handleDialog":{let b=(w??"accept")!=="dismiss",k=t.promptText?f(t.promptText):void 0;e.once("dialog",h=>{b?h.accept(k):h.dismiss()});return}case"uploadFile":{let b=Me().root,k=(w??"").split(`
|
|
29
|
+
`).filter(Boolean).map(y=>Ku.resolve(b,y));if(k.length===0)throw new Error("uploadFile requires a path");let h=Ro(k,b);if(h)throw new Error(h);if(m.length>0)return pt(m,y=>y.setInputFiles(k,v),!0);rr.set(e,k);return}case"dragTo":{if(!t.toTarget)throw new Error("dragTo requires toTarget");let b=Bc(e,t.toTarget,n,!1,!1,c);return pt(m,k=>k.dragTo(b[0].first(),v),!0)}case"captureText":{if(!t.saveAs)throw new Error("captureText requires saveAs");return l(x(async b=>{let k=Fo(await b.textContent(S)??"");return k===""?!1:(o.set(t.saveAs,k),!0)}),r,`non-empty text to capture as "${t.saveAs}"`)}case"expectDiffers":{if(!t.compareTo)throw new Error("expectDiffers requires compareTo");let b=o.get(t.compareTo);if(b===void 0)throw new Error(`expectDiffers: no captured value named "${t.compareTo}"`);if(m.length>0)return l(x(async y=>Fo(await y.textContent(S)??"")!==b),r,`target text to differ from "${t.compareTo}" (${b})`);let k=w??"",h=o.get(k);if(h===void 0)throw new Error(`expectDiffers: no captured value named "${k}"`);if(h===b)throw new Error(`expectDiffers: "${k}" (${h}) equals "${t.compareTo}" (${b})`);return}case"expectAttribute":{if(!t.attribute)throw new Error("expectAttribute requires attribute");if(!t.pattern)throw new Error("expectAttribute requires pattern");let b=new RegExp(g(t.pattern),"i");return l(x(async k=>b.test(await k.getAttribute(t.attribute,S)??"")),r,`attribute ${t.attribute} to match /${t.pattern}/i`)}case"expectMatches":{if(!t.pattern)throw new Error("expectMatches requires pattern");let b=new RegExp(g(t.pattern),"i");return l(x(async k=>b.test(Fo(await k.textContent(S)??""))),r,`target text to match /${t.pattern}/i`)}case"switchTab":{let b=Number.parseInt(w??"",10),k=()=>e.context().pages().filter(y=>!y.isClosed());await l(async()=>k().length>b,r,`tab ${w} to exist (${k().length} open: ${k().map(y=>y.url()).join(", ")})`);let h=k()[b];return await h.bringToFront(),h}case"closeTab":{let b=e.context().pages().filter(y=>!y.isClosed()),k=w===void 0?e:b[Number.parseInt(w,10)];if(!k)throw new Error(`tab ${w} does not exist (${b.length} open)`);await k.close();let h=e.context().pages().filter(y=>!y.isClosed());if(h.length===0)throw new Error("closed the last tab; nothing left to drive");return h[h.length-1]}case"newTab":{let b=R?Qn(R):void 0;if(b)throw new Error(b);let k=await e.context().newPage();return R&&await k.goto(So(R,s),v),k}case"waitForResponse":case"expectResponse":{if(!t.urlPattern)throw new Error(`${t.action} requires urlPattern`);let b=new RegExp(g(t.urlPattern)),k=t.pattern?new RegExp(g(t.pattern)):void 0;if(!i){await e.waitForResponse(async y=>{let A={seq:0,url:y.url(),method:y.request().method(),status:y.status()};return Fc(A,b,t.method,t.status,void 0)?k?k.test(await y.text().catch(()=>"")):!0:!1},v);return}let h=()=>t.action==="expectResponse"?i.all():i.sincePreviousStep();return l(async()=>{let y=h().find(A=>Fc(A,b,t.method,t.status,k));return y&&(i.lastMatch=y),y!==void 0},r,Zu(t,t.urlPattern))}default:throw new Error(`Unknown action type: ${t.action}`)}}var ss=(e,t)=>Yn(e.actions,t),Dc=(e,t)=>En(e.actions,t);function sr(e,t){if(t.steps.length!==e.steps.length)return!1;for(let[n,r]of Object.entries(t.data??{})){let s;try{s=Sn(n)}catch{continue}if(s===r)continue;let o=t.steps.some(a=>ss(a,`{data:${n}}`)),i=t.steps.some(a=>Dc(a,r));if(!o||i)return!1}return t.steps.every((n,r)=>{let s=e.steps[r];if(n.gherkin!==s.text||n.kind!==s.kind)return!1;let o=n.table,i=s.table;if(!!o!=!!i)return!1;if(o&&i){let a=Object.keys(Ce(o)),c=Object.keys(Ce(i));if(a.length>0&&c.length>0){if(JSON.stringify(a)!==JSON.stringify(c))return!1;let l=Ce(o),d=Ce(i);for(let p of a){if(l[p]===d[p])continue;let u=`<${p}>`,f=t.steps.some(w=>ss(w,u)),g=t.steps.some(w=>Dc(w,l[p]));if(!f||g)return!1}}else if(JSON.stringify(o)!==JSON.stringify(i))return!1}if(n.docString!==void 0!=(s.docString!==void 0))return!1;if(n.docString!==void 0&&s.docString!==void 0&&n.docString!==s.docString){let a=t.steps.some(l=>ss(l,"<docstring>")),c=t.steps.some(l=>ss(l,n.docString));if(!a||c)return!1}return!0})}function Uo(e,t){let n=e.steps[t],r=n?.resolvedTable??n?.table;return{...e.params,...Mt(e.steps),...Ce(r),...en(n?.resolvedDocString??n?.docString)}}async function or(e,t,n,r={}){let s=r.actionTimeoutMs??5e3,o=r.retries??1,i=Math.max(10,r.pollIntervalMs??zc),a=[],c=r.envNames??Fe(t),l=r.vars??new Map;r.uniqueSeed&&l.set(Ke,r.uniqueSeed);for(let h of e.context().pages())rr.delete(h);let d=()=>Object.fromEntries(l),p=n.steps.some(h=>h.actions.some(y=>(y.action==="waitForResponse"||y.action==="expectResponse")&&y.pattern!==void 0)),u=new Lo;u.attach(e.context(),p);let f=!1,g=()=>(f=r.keepResponseLog===!0,f?{responses:u}:{}),w=e,R=new Set(e.context().pages()),$=[],m=h=>$.push(h);e.context().on("page",m);let v=h=>new Promise(y=>{let A=()=>{clearTimeout(E),e.context().off("page",A),y()},E=setTimeout(A,h);e.context().on("page",A)}),S=[],b=!n.steps.some(h=>h.actions.some(y=>y.opensTab))&&n.steps.some(h=>h.actions.some(y=>y.action==="switchTab"||y.action==="closeTab"||y.action==="newTab")),k=()=>{let h=e.context().pages();for(let y of h)R.has(y)||(R.add(y),y.isClosed()||(w=y));if(w.isClosed()){let y=h.filter(A=>!A.isClosed());y.length>0&&(w=y[y.length-1])}};if(!sr(t,n))return u.detach(),e.context().off("page",m),{status:"stale",stepResults:a,captured:d()};try{for(let h=0;h<n.steps.length;h++){let y=n.steps[h],A=Uo(t,h);u.markStep();let E=!1,P=0,O=[],M=[],T=(j,L)=>{let _=e.context().pages().filter(V=>!V.isClosed()),F=_.filter(V=>!L.has(V)).at(-1),D=F??w;if(D===j||D.isClosed())return;let H=F?"opened":j.isClosed()?"closed":"switched";O.push({how:H,tab:_.indexOf(D)+1,url:D.url()})};r.trace&&await e.context().tracing.group(`[saffron:${r.tracePhase??"replay"}:${h+(r.traceStepOffset??0)}] ${y.keyword} ${y.gherkin}`).catch(()=>{});let C=async()=>{r.trace&&await e.context().tracing.groupEnd().catch(()=>{})};try{if(y.kind==="assertion"&&!y.actions.some(j=>He.has(j.action))){let j=`Assertion step "${y.gherkin}" recorded ${y.actions.length===0?"nothing":"only setup actions"}, so nothing was checked. Re-record the scenario (saffron run --rerecord) so the agent records what must be true.`;a.push({gherkin:y.gherkin,fromStepSet:y.fromStepSet,kind:y.kind,status:"failed",drift:E,error:j});for(let L=h+1;L<n.steps.length;L++)a.push({gherkin:n.steps[L].gherkin,kind:n.steps[L].kind,status:"skipped"});return{status:"failed",failure:{stepIndex:h,actionIndex:0,stepText:y.gherkin,kind:y.kind,error:j},stepResults:a,captured:d(),...g()}}for(let j=0;j<y.actions.length;j++){let L=y.actions[j];k();let _=w,F=new Set(e.context().pages());u.lastMatch=void 0,L.pageFingerprint&&await sn(w)!==L.pageFingerprint&&(E=!0);let D,H=!1;for(let V=0;V<=o&&!H;V++){V>0&&(P++,await Do(250*V),k());try{let W=V>0?`${Je(L)} (attempt ${V+1})`:Je(L),ce=await Ec(w,r.trace,`[saffron:action:${j}] ${W}`,()=>os(w,L,A,s,r.baseURL,l,u,i,c));ce&&(w=ce,R.add(ce));let st=L.action==="click"||L.action==="press";if($.length===0&&L.opensTab?await e.context().waitForEvent("page",{timeout:s}).catch(()=>{}):$.length===0&&st&&b&&await v(150),L.opensTab&&$.length===0)throw new Error("this action opened a new tab when it was recorded, and none opened now; the flow has changed");for($.length>0&&S.push({stepIndex:h,actionIndex:j});$.length>0;)await $.shift().waitForLoadState("domcontentloaded",{timeout:s}).catch(()=>{});if(H=!0,T(_,F),u.lastMatch&&(L.action==="waitForResponse"||L.action==="expectResponse")){let{method:Te,url:wn,status:bt}=u.lastMatch;M.push({action:L.action,method:Te,url:wn,status:bt})}}catch(W){D=W}}if(!H){let V=D instanceof Error?D.message:String(D);a.push({gherkin:y.gherkin,fromStepSet:y.fromStepSet,kind:y.kind,status:"failed",drift:E,...P?{retries:P}:{},...O.length?{tabs:O}:{},...M.length?{responses:M}:{},failedActionIndex:j,error:V});for(let ce=h+1;ce<n.steps.length;ce++)a.push({gherkin:n.steps[ce].gherkin,kind:n.steps[ce].kind,status:"skipped"});let W={stepIndex:h,actionIndex:j,stepText:y.gherkin,kind:y.kind,error:V,pageUrl:w.isClosed()?void 0:w.url()};return await r.onFailure?.(w,W).catch(()=>{}),{status:"failed",failure:W,stepResults:a,captured:d(),tabOpeners:S,activePage:w.isClosed()?void 0:w,...g()}}}a.push({gherkin:y.gherkin,fromStepSet:y.fromStepSet,kind:y.kind,status:"passed",drift:E,...P?{retries:P}:{},...O.length?{tabs:O}:{},...M.length?{responses:M}:{}})}finally{await C()}}return{status:"passed",stepResults:a,captured:d(),tabOpeners:S,activePage:w.isClosed()?void 0:w}}finally{e.context().off("page",m),f||u.detach()}}import{createHash as eg}from"node:crypto";import tg from"node:path";var zo=e=>eg("sha1").update(JSON.stringify(e)).digest("hex").slice(0,16);function Hc(e){return{steps:e.steps.map(t=>({...t,actions:t.actions.map(({pageFingerprint:n,...r})=>r)})),data:e.data??null}}var qc=e=>zo(e.map(t=>[t.kind,t.text,t.table??null,t.docString??null])),ir=e=>e?zo(Hc(e)):"none",Jc=(e,t)=>zo({contract:Hc(e),featureEdits:t??null});function Wc(e,t,n,r){return{scenario:qc(e.steps),committed:ir(t),recording:Jc(n,r)}}function We(e,t,n){let r=t.meta.bound;if(!r)return;let s=[],{feature:o,scenario:i}=t.cache,a;if(n&&!(n instanceof Map))a=n;else try{a=Be(tg.join(e,o),e,n).find(l=>l.name===i)}catch(l){s.push(`${o} no longer parses: ${l instanceof Error?l.message.split(`
|
|
30
|
+
`)[0]:String(l)}`)}s.length||(a?qc(a.steps)!==r.scenario&&s.push(ng(a,t.cache)):s.push(`scenario "${i}" is no longer in ${o}`));let c;try{c=Pe(se(e,o,i))}catch(l){if(!(l instanceof le))throw l;return s.push(`the committed recording cannot be read (${l.problem})`),s.join("; ")}return ir(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"),Jc(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 ng(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,i)=>o.text!==r[i].gherkin||o.kind!==r[i].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 is="not bound: filed by a runner older than 0.8.5, so changes since cannot be detected";import cn from"node:fs";import Kc from"node:path";function as(e){return`$${e.toFixed(e>=1?2:4)}`}function Ho(e,t){let n=e===void 0?"?":`${Math.round(e)}%`,r=t===void 0?"?":`${Math.round(t)}%`;return`${n} \u2192 ${r}`}function Ve(e){return e.costUsd>0||e.aiCalls===0}function qo(e){return Ve(e)?e.plan?` \xB7 5-hour window ${Ho(e.plan.fiveHourBefore,e.plan.fiveHourAfter)} (${e.plan.subscriptionType} plan) \xB7 \u2248 ${as(e.costUsd)} at API rates`:` \xB7 ${as(e.costUsd)}`:" \xB7 cost not reported by the provider"}function Vc(e,t){return e?t?`\u2248 ${as(e)}`:as(e):""}function Gc(e){if(!e)return"";let t=new Date(e);return Number.isNaN(t.getTime())?"":`resets ${t.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}`}function Jo(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}`}var ar=e=>e.status==="yellow"&&e.verified===!1,Wo=(e,t)=>e.green+e.yellow-t.filter(ar).length;function on(e,t){let n=e.green+e.yellow+e.red;return n===0?0:Math.round(Wo(e,t)/n*100)}function an(e,t){if(e.name)return e.name;let n=e.scenario.replace(/ \(example \d+\)$/,"");return t&&(t(e.scenario)||!t(n))?e.scenario:n}function Yc(e,t){return e.feature===t.feature&&e.scenario===t.scenario&&(e.browser===void 0||t.browser===void 0||e.browser===t.browser)}function Go(e){let t=r=>`${r.feature}\0${r.scenario}`,n=new Map;for(let r of e)r.browser&&n.set(t(r),(n.get(t(r))??new Set).add(r.browser));return r=>{let s=[...n.get(t(r))??[]];return s.length<2?[{key:t(r)}]:(r.browser?[r.browser]:s).map(o=>({key:`${t(r)}\0${o}`,browser:o}))}}function Ko(e){return Kc.join(e,".saffron","history.jsonl")}function Yo(e,t,n,r){let s=e.map(o=>({feature:o.scenario.featurePath,scenario:o.scenario.displayName,name:o.scenario.name,tags:[...o.scenario.tags],status:o.status,source:o.source,durationMs:o.durationMs,verified:o.verified,seededSteps:o.seededSteps,adaptations:o.adaptations.length,driftSteps:o.stepResults.filter(i=>i.drift).length,aiCalls:o.usage.aiCalls,tokensInOut:o.usage.inputTokens+o.usage.outputTokens,cacheRead:o.usage.cacheReadTokens,cacheWrite:o.usage.cacheCreationTokens,costUsd:o.usage.costUsd??0,error:o.error?.split(`
|
|
31
|
+
`)[0],browser:o.browser}));return{runId:t.toISOString(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:r,green:s.filter(o=>o.status==="green").length,yellow:s.filter(o=>o.status==="yellow").length,red:s.filter(o=>o.status==="red").length,aiCalls:s.reduce((o,i)=>o+i.aiCalls,0),costUsd:s.reduce((o,i)=>o+i.costUsd,0),divergentSteps:n,scenarios:s}}function Zo(e,t){let n=Ko(e);cn.mkdirSync(Kc.dirname(n),{recursive:!0}),cn.appendFileSync(n,`${JSON.stringify(t)}
|
|
32
|
+
`)}function Zc(e,t){let n=Ko(e);if(!cn.existsSync(n))return Zo(e,t);let r=cn.readFileSync(n,"utf8").split(`
|
|
33
|
+
`).filter(i=>i.trim()),s=!1,o=r.map(i=>{try{if(JSON.parse(i).runId===t.runId)return s=!0,JSON.stringify(t)}catch{}return i});s||o.push(JSON.stringify(t)),cn.writeFileSync(n,`${o.join(`
|
|
32
34
|
`)}
|
|
33
|
-
`)}function
|
|
34
|
-
`).filter(Boolean),s=[];for(let
|
|
35
|
-
<path d="${
|
|
36
|
-
<path d="${
|
|
37
|
-
</svg>`}function
|
|
35
|
+
`)}function cr(e,t=50){let n=Ko(e);if(!cn.existsSync(n))return[];let r=cn.readFileSync(n,"utf8").trim().split(`
|
|
36
|
+
`).filter(Boolean),s=[],o=0;for(let i=r.length-1;i>=0&&o<t;i--)try{let a=JSON.parse(r[i]);s.push(a),a.partial||o++}catch{}return s.reverse()}var Vo=e=>on(e,e.scenarios);function Qo(e,t){let n=e.filter(f=>!f.partial),r=[...n,t],s=r.slice(-20),o=r.slice(-10),i=n.at(-1),a=i?{passRatePp:Vo(t)-Vo(i),costUsd:Ve(t)&&Ve(i)?t.costUsd-i.costUsd:void 0,aiCalls:t.aiCalls-i.aiCalls,red:t.red-i.red}:void 0,c=new Map,l=Go(o.flatMap(f=>f.scenarios));for(let f of o)for(let g of f.scenarios)for(let{key:w,browser:R}of l(g)){let $=c.get(w)??{feature:g.feature,scenario:R?`${g.scenario} [${R}]`:g.scenario,heals:0,reds:0,runs:0};$.runs++,g.source==="agent-heal"&&g.status==="yellow"&&$.heals++,g.status==="red"&&$.reds++,c.set(w,$)}let d=[...c.values()].filter(f=>f.heals>=2||f.reds>=3).sort((f,g)=>g.heals+g.reds-(f.heals+f.reds)),p=new Map;for(let f of s)for(let g of f.scenarios)g.status==="red"&&g.error&&p.set(g.error,(p.get(g.error)??0)+1);let u=[...p.entries()].sort((f,g)=>g[1]-f[1]).slice(0,3).map(([f,g])=>({message:f,count:g}));return{prev:a,series:{passRate:s.map(Vo),costUsd:s.map(f=>Ve(f)?Number(f.costUsd.toFixed(4)):null),aiCalls:s.map(f=>f.aiCalls)},chronic:d,divergentSteps:t.divergentSteps,topIssues:u}}var Qc=20,rg=10,sg=e=>e.scenarios.filter(ar).length,el=e=>e.replace(/\\/g,"/"),Nn=(e,t)=>`${el(e)}\0${t}`,og=(e,t)=>t===0?0:Math.round(e/t*100),Xc=e=>Math.max(0,Date.parse(e.finishedAt)-Date.parse(e.startedAt))||0;function tl(e,t){let n=new Map;for(let T of e)for(let C of T.scenarios)n.set(Nn(T.path,C.name),{feature:T.path,scenario:C.name,tags:C.tags});let r=T=>an(T,C=>n.has(Nn(T.feature,C))),s=T=>n.has(Nn(T.feature,r(T))),o=e.flatMap(T=>T.scenarios),i=t.filter(T=>!T.partial),a=T=>i.at(-T)?.startedAt??"",c=i.slice(-Qc),l=i.at(-1),d=new Set((l?.scenarios??[]).flatMap(T=>T.browser?[T.browser]:[])),p=T=>T===""||d.size===0||d.has(T),u=new Map,f=t.filter(T=>T.startedAt>=a(Qc)),g=Go(f.flatMap(T=>T.scenarios));for(let T of f)for(let C of T.scenarios){if(!s(C))continue;let j=r(C);for(let{key:L,browser:_}of g(C)){let F=u.get(L)??{ref:{feature:el(C.feature),scenario:C.scenario,...j!==C.scenario?{name:j}:{},..._?{browser:_}:{}},results:[]};F.results.push({at:T.startedAt,s:C,target:T.baseURL??""}),u.set(L,F)}}let w=[],R=[],$=[];for(let{ref:T,results:C}of u.values()){if(T.browser&&!p(T.browser))continue;let j=new Map,L=0,_=0,F=0;for(let H of C){if(H.s.source==="agent-record")continue;let V=H.s.status==="red";F++,V&&_++;let W=j.get(H.target);W!==void 0&&W!==V&&L++,j.set(H.target,V)}L>=2&&w.push({...T,runs:F,reds:_,flips:L,last:C.at(-1).s.status});let D=C.filter(H=>H.s.source==="agent-heal"&&H.s.status==="yellow");D.length>=2&&R.push({...T,runs:C.length,heals:D.length,unverified:D.filter(H=>ar(H.s)).length}),$.push({...T,avgMs:Math.round(C.reduce((H,V)=>H+V.s.durationMs,0)/C.length),lastMs:C.at(-1).s.durationMs,runs:C.length})}w.sort((T,C)=>C.flips-T.flips||C.reds-T.reds),R.sort((T,C)=>C.heals-T.heals),$.sort((T,C)=>C.avgMs-T.avgMs);let m=[],v=new Set(t.filter(T=>T.startedAt>=a(rg)).map(T=>T.startedAt));for(let{ref:T,results:C}of u.values()){if(T.browser&&!p(T.browser))continue;let j=C.findLast(F=>F.s.status==="red");if(!j||!v.has(j.at))continue;let L=C.at(-1).s.status==="red",_=0;for(let F=C.length-1;F>=0&&C[F].s.status==="red";F--)_++;m.push({...T,at:j.at,error:j.s.error,stillFailing:L,streak:_})}m.sort((T,C)=>Number(C.stillFailing)-Number(T.stillFailing)||C.at.localeCompare(T.at));let S={green:0,yellow:1,unverified:2,red:3},x=new Map;for(let T of t)for(let C of T.scenarios){let j=Nn(C.feature,r(C)),L=x.get(j)??new Map,_=L.get(C.scenario)??new Map;C.browser?_.delete(""):_.clear(),_.set(C.browser??"",ar(C)?"unverified":C.status),L.set(C.scenario,_),x.set(j,L)}let b=new Map;for(let T of e)for(let C of T.scenarios){let j=Array.from({length:C.rows},(L,_)=>`${C.name} (example ${_+1})`);b.set(Nn(T.path,C.name),new Set(C.outline===!0?j:C.outline===!1?[C.name]:[C.name,...j]))}let k=new Map;for(let[T,C]of x){let j=b.get(T),L=[...C].filter(([_])=>!j||j.has(_)).flatMap(([,_])=>{let F=[..._],D=F.filter(([H])=>p(H));return(D.length>0?D:F).map(([,H])=>H)});L.length>0&&k.set(T,L.reduce((_,F)=>S[F]>S[_]?F:_))}let h=new Set(k.keys()),y=new Set((l?.scenarios??[]).map(T=>Nn(T.feature,r(T)))),A=[],E=[];for(let[T,C]of n){let j={feature:C.feature,scenario:C.scenario};h.has(T)?l&&!y.has(T)&&E.push(j):A.push(j)}let P=l?(()=>{let T=l.scenarios.length,C=sg(l);return{startedAt:l.startedAt,durationMs:Xc(l),ran:T,green:l.green,yellow:l.yellow,red:l.red,unverified:C,passRate:on(l,l.scenarios),replayRate:og(l.scenarios.filter(j=>j.source==="cache"&&j.status==="green").length,T)}})():void 0,O={green:0,yellow:0,unverified:0,red:0,neverRun:0},M=new Map;for(let[T,C]of n){let j=k.get(T)??"neverRun";O[j]++;for(let L of C.tags){let _=M.get(L)??{tag:L,scenarios:0,green:0,yellow:0,unverified:0,red:0,neverRun:0};_.scenarios++,_[j]++,M.set(L,_)}}return{historyRuns:t.length,suite:{features:e.length,scenarios:o.length,tests:o.reduce((T,C)=>T+C.rows,0),recorded:o.filter(T=>T.cacheState===void 0?T.cached:T.cacheState==="valid").length,...O},lastRun:P,trend:c.map(T=>({startedAt:T.startedAt,passRate:on(T,T.scenarios),durationMs:Xc(T),red:T.red,tests:T.green+T.yellow+T.red})),flaky:w,healing:R,recentFailures:m,notInLastRun:E,neverRun:A,slowest:$.slice(0,10),tags:[...M.values()].sort((T,C)=>C.red-T.red||C.scenarios-T.scenarios||T.tag.localeCompare(C.tag))}}import{execFileSync as Rg}from"node:child_process";import{createHash as Tg,randomBytes as ui}from"node:crypto";import Q from"node:fs";import te from"node:path";import Eg from"node:zlib";var N=e=>String(e).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',"""),rl="M11 14 L10 4.5 L17.5 9.5 L24 2.5 L30.5 9.5 L38 4.5 L37 14 Z",ig="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 dr(e){return`<svg viewBox="0 0 48 48" width="${e}" height="${e}" aria-hidden="true">
|
|
37
|
+
<path d="${rl}" fill="#F4A300"/>
|
|
38
|
+
<path d="${ig}" fill="#E7E9EC"/>
|
|
39
|
+
</svg>`}function Xo(e){return`<svg viewBox="8 1 32 15" height="${e}" aria-hidden="true"><path d="${rl}" fill="#F4A300"/></svg>`}function ag(e){let t=e.imported;if(!t)return"";let n=t.shards?` \xB7 shard${t.shards.imported.length===1?"":"s"} ${t.shards.imported.join(", ")} of ${t.shards.total}`:"",r=t.headCommit??t.commit,s=`imported from ${t.run?`run ${t.run}`:"another machine"}${t.branch?` \xB7 ${t.branch}`:""}${r?` @ ${r.slice(0,7)}`:""}${n}`;return t.url&&/^https?:\/\//i.test(t.url)?`<a class="chip" href="${N(t.url)}" target="_blank" rel="noopener noreferrer">${N(s)}</a>`:`<span class="chip">${N(s)}</span>`}var ei=`<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
38
40
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
39
|
-
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">`,
|
|
41
|
+
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">`,ti=` :root {
|
|
40
42
|
--bg: #131416;
|
|
41
43
|
--card: #1A1B1E;
|
|
42
44
|
--card-2: #16171A;
|
|
@@ -146,155 +148,161 @@ import ce from"node:fs";import L from"node:path";import Pm from"node:readline";i
|
|
|
146
148
|
code { font-family: var(--mono); font-size: 11px; background: var(--bg); border: 1px solid var(--border-2); padding: 1px 6px; border-radius: 4px; color: var(--text-dim); }
|
|
147
149
|
|
|
148
150
|
footer { margin-top: 42px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--faint); font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; }
|
|
149
|
-
`,
|
|
151
|
+
`,cg={green:"passed",yellow:"pending review",red:"failed"};function lg(e){return e.hooks?.length?`<div class="panel"><div class="panel-title">setup and teardown</div>${e.hooks.map(n=>{let r=n.exitCode===0,s=r?"ok":n.timedOut?"timed out":n.exitCode===null?"could not start":`exit ${n.exitCode}`;return`<div class="mono">${r?"\u2713":"\u2717"} ${N(String(n.phase))} \xB7 ${N(n.command)} \xB7 ${N(s)} \xB7 ${N(_e(n.durationMs))}</div>`}).join("")}</div>`:""}function ni(e,t=5){return[...e.scenarios].sort((n,r)=>Number(r.durationMs)-Number(n.durationMs)).slice(0,t)}function ri(e,t=0){let n=Number(e.spanMs??t);return n>0?`${Math.min(100,Math.round(Number(e.busyMs)/n*100))}%`:"\u2013"}function dg(e){let t=e.timing?.workers??[],n=Number(e.timing?.wallMs??0),r=t.length>1&&n>0?t.map(o=>`<div class="mono">worker ${N(o.worker)} \xB7 ${Number(o.runs)} run${Number(o.runs)===1?"":"s"} \xB7 ${N(_e(Number(o.busyMs)))} busy \xB7 ${N(ri(o,n))}</div>`).join(""):"",s=e.scenarios.length>1?ni(e).map(o=>`<div class="mono">${N(_e(Number(o.durationMs)))} \xB7 ${N(o.feature)} \u203A ${N(o.scenario)}${e.browsers&&o.browser?` \xB7 ${N(o.browser)}`:""}${o.worker&&t.length>1?` \xB7 worker ${N(o.worker)}`:""}</div>`).join(""):"";return!r&&!s?"":`<div class="panel"><div class="panel-title">timing${n>0?` \xB7 ${N(_e(n))} from the first scenario to the last`:""}</div>${r}${r&&s?'<div class="panel-hint">slowest scenarios</div>':""}${s}</div>`}function _e(e){return e>=1e3?`${(e/1e3).toFixed(1)}s`:`${e}ms`}function lr(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:Number(e).toLocaleString()}function ye(e,t,n,r="",s="",o=""){return`<div class="kpi">
|
|
150
152
|
<div class="kpi-label">${e}</div>
|
|
151
153
|
<div class="kpi-value ${r}">${t}</div>
|
|
152
154
|
<div class="kpi-sub ${s}">${n}</div>${o}
|
|
153
|
-
</div>`}function
|
|
154
|
-
${
|
|
155
|
-
${
|
|
156
|
-
${
|
|
157
|
-
${
|
|
158
|
-
${
|
|
159
|
-
${
|
|
160
|
-
${n.plan?
|
|
161
|
-
</section>`}function
|
|
162
|
-
`)}</section>`:""}function
|
|
155
|
+
</div>`}function Mn(e,t){let n=e.filter(l=>l!==null);if(n.length<2)return"";let r=108,s=22,o=Math.min(...n),a=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)/a*(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 nl=(e,t="",n=0)=>`${e>=0?"+":"\u2212"}${Math.abs(e).toFixed(n)}${t} vs prev`;function fg(e,t){let n=e.totals,r=e.trends,[s,o,i,a]=[n.scenarios,n.green,n.yellow,n.red].map(Number),c=Wo({green:o,yellow:i},e.scenarios),l=s===0?0:Math.round(c/s*100),d=n.red>0?"red":n.yellow>0?"gold":"green",p=n.inputTokens+n.outputTokens,u=r?.prev?`${nl(r.prev.passRatePp,"pp")} \xB7 ${c} of ${s}`:`${c} of ${s} scenarios`,f=r?.prev?.costUsd!==void 0?`${nl(r.prev.costUsd,"",4).replace("+","+$").replace("\u2212","\u2212$")} \xB7 ${_e(t)}`:`${_e(t)} total runtime`;return`<section class="kpis">
|
|
156
|
+
${ye("pass rate",`${l}<span class="unit">%</span>`,u,d,r?.prev?r.prev.passRatePp>=0?"green":"red":"",Mn(r?.series.passRate??[],"var(--green)"))}
|
|
157
|
+
${ye("passed",String(o),"deterministic replay","green",o>0?"green":"")}
|
|
158
|
+
${ye("pending",String(i),i>0?"\u25B2 proposals to review":"nothing to review",i>0?"gold":"",i>0?"gold":"")}
|
|
159
|
+
${ye("failed",String(a),a>0?"\u25BC needs attention":"all clear",a>0?"red":"",a>0?"red":"green")}
|
|
160
|
+
${ye("ai calls",String(Number(n.aiCalls)),`${lr(p)} in+out tokens`,n.aiCalls>0?"":"green")}
|
|
161
|
+
${ye("cache traffic",lr(n.cacheReadTokens+n.cacheCreationTokens),n.cacheReadTokens+n.cacheCreationTokens>0?`${lr(n.cacheReadTokens)} read \xB7 ${lr(n.cacheCreationTokens)} written`:"zero (fully cached run)","",n.cacheReadTokens+n.cacheCreationTokens>0?"":"green")}
|
|
162
|
+
${n.plan?ye("plan usage",`${Ho(n.plan.fiveHourBefore,n.plan.fiveHourAfter)}`,[`5-hour window \xB7 ${N(n.plan.subscriptionType)} plan`,Gc(n.plan.resetsAt)].filter(Boolean).join(" \xB7 "),"gold","")+ye("api-equivalent",`<span class="unit">\u2248 $</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`,`what this run costs on an API key \xB7 ${f}`,"","",Mn(r?.series.costUsd??[],"var(--gold)")):ye("ai cost",Ve(n)?`<span class="unit">$</span>${n.costUsd.toFixed(n.costUsd>=1?2:4)}`:"n/a",Ve(n)?f:"not reported by the provider","","",Ve(n)?Mn(r?.series.costUsd??[],"var(--gold)"):"")}
|
|
163
|
+
</section>`}function pg(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>${N(r.feature)} \u203A ${N(r.scenario)}: ${Number(r.heals)} heal(s), ${Number(r.reds)} red(s) in the last ${Number(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>${Number(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>${Number(r.count)}\xD7: ${N(r.message)}</li>`).join("")}</ul></div>`),n.length>0?`<section class="trends">${n.join(`
|
|
164
|
+
`)}</section>`:""}function ug(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(n.status)}">
|
|
163
165
|
${r}
|
|
164
166
|
<div class="step-body">
|
|
165
|
-
<span class="step-text">${
|
|
167
|
+
<span class="step-text">${N(n.gherkin)}</span>
|
|
166
168
|
${n.kind==="assertion"?'<span class="tag">assert</span>':""}
|
|
167
|
-
${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${
|
|
169
|
+
${n.fromStepSet?`<span class="tag indigo" title="inlined from a StepSet definition: edit once, every invoking scenario follows">from StepSet: ${N(n.fromStepSet)}</span>`:""}
|
|
168
170
|
${n.drift?'<span class="tag gold" title="page fingerprint no longer matches the recording">drift</span>':""}
|
|
169
171
|
${n.retries?`<span class="tag gold" title="an action failed and was re-attempted with backoff before the step settled">retried \xD7${Number(n.retries)}</span>`:""}
|
|
170
|
-
${n.error?`<div class="step-error">${
|
|
172
|
+
${n.error?`<div class="step-error">${N(n.error)}</div>`:""}
|
|
171
173
|
</div>
|
|
172
174
|
</li>`}).join(`
|
|
173
|
-
`)}</ol>`}function
|
|
175
|
+
`)}</ol>`}function gg(e){return e.featureEdits&&e.featureEdits.length>0?`<div class="panel">
|
|
174
176
|
<div class="panel-title gold">suggested .feature edit</div>
|
|
175
|
-
<div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${
|
|
177
|
+
<div class="diff">${e.featureEdits.map(n=>{let r=e.steps[n.index]?.gherkin??`step ${n.index}`;return`<div class="diff-line del">\u2212 ${N(r)}</div><div class="diff-line add">+ ${N(n.text)}</div>`}).join("")}</div>
|
|
176
178
|
<div class="panel-hint">apply with <code>saffron accept --with-feature-edit</code></div>
|
|
177
|
-
</div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${
|
|
179
|
+
</div>`:e.suggestedFeatureEdit?`<div class="panel"><div class="panel-title gold">suggested .feature edit</div><pre>${N(e.suggestedFeatureEdit)}</pre></div>`:""}function hg(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> ':""}${N(c.gherkin)}${c.fromStepSet?` <span class="mono">(step set "${N(c.fromStepSet)}")</span>`:""}</div>`,d=c.actions.flatMap(p=>p.change==="same"?[`<div class="diff-line"> ${N(p.before??"")}</div>`]:p.change==="added"?[`<div class="diff-line add">+ ${N(p.after??"")}</div>`]:p.change==="removed"?[`<div class="diff-line del">\u2212 ${N(p.before??"")}</div>`]:[`<div class="diff-line del">\u2212 ${N(p.before??"")}</div>`,`<div class="diff-line add">+ ${N(p.after??"")}</div>`,...(p.details??[]).map(u=>`<div class="diff-line detail">${N(u)}</div>`)]).join("");return`${l}<div class="diff">${d}</div>`}).join(""),{added:r,removed:s,changed:o,assertions:i}=t.summary,a=r+s+o===0?"No action changed.":`${o} changed, ${r} added, ${s} removed.${i>0?` ${i} touch the scenario's verdict.`:""}`;return`<div class="panel accent-gold"><div class="panel-title gold">what this proposal changes</div>${t.hasCommitted?"":'<p class="panel-hint">First recording: there is nothing to compare against.</p>'}${n}<div class="panel-hint">${N(a)}</div></div>`}function mg(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=Vc(e.usage.costUsd,t),s=r?` \xB7 ${r}`:"";return`<span class="pill gold">${Number(e.usage.aiCalls)} ai \xB7 ${lr(n)} tok${s}</span>`}function wg(e,t,n,r=!1){return`<details class="card" ${e.status!=="green"?"open":""}>
|
|
178
180
|
<summary>
|
|
179
|
-
<span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${
|
|
180
|
-
<span class="card-title">${
|
|
181
|
+
<span class="pill ${e.status==="green"?"green":e.status==="yellow"?"gold":"red"}">${cg[e.status]}</span>
|
|
182
|
+
<span class="card-title">${N(e.scenario)}</span>
|
|
181
183
|
<span class="card-meta">
|
|
182
|
-
<span class="m">${
|
|
184
|
+
<span class="m">${N(e.feature)}</span>
|
|
183
185
|
<span class="m-sep">\xB7</span>
|
|
184
|
-
<span class="m">${
|
|
186
|
+
<span class="m">${N(e.source)}</span>
|
|
185
187
|
<span class="m-sep">\xB7</span>
|
|
186
|
-
<span class="m">${
|
|
187
|
-
${
|
|
188
|
+
<span class="m">${_e(Number(e.durationMs))}</span>
|
|
189
|
+
${r&&e.browser?`<span class="m-sep">\xB7</span><span class="chip">${N(e.browser)}</span>`:""}
|
|
190
|
+
${mg(e,t)}
|
|
188
191
|
</span>
|
|
189
192
|
</summary>
|
|
190
193
|
<div class="card-body">
|
|
191
|
-
${
|
|
192
|
-
${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${
|
|
193
|
-
${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(
|
|
194
|
-
${
|
|
195
|
-
${
|
|
196
|
-
${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">${
|
|
194
|
+
${ug(e)}
|
|
195
|
+
${e.narrative?`<div class="panel"><div class="panel-title">agent narrative</div><p>${N(e.narrative)}</p></div>`:""}
|
|
196
|
+
${e.adaptations.length>0?`<div class="panel accent-gold"><div class="panel-title gold">adaptations</div><ul>${e.adaptations.map(s=>`<li>${N(s)}</li>`).join("")}</ul></div>`:""}
|
|
197
|
+
${gg(e)}
|
|
198
|
+
${hg(e)}
|
|
199
|
+
${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">${N(e.proposalFile)}</p>${e.verified===!1&&e.proofError?`<p class="mono" style="color:var(--red)">proof replay failed: ${N(e.proofError.split(`
|
|
197
200
|
`)[0])}</p>`:""}<div class="panel-hint">review with <code>saffron accept</code> / <code>saffron reject</code></div></div>`:""}
|
|
198
|
-
${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${
|
|
199
|
-
${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([
|
|
200
|
-
${e.proofUniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in the proof replay</div>${Object.entries(e.proofUniques).map(([
|
|
201
|
-
${
|
|
201
|
+
${e.error&&e.status==="red"?`<div class="panel accent-red"><div class="panel-title red">failure</div><p>${N(e.error)}</p></div>`:""}
|
|
202
|
+
${e.uniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in this run</div>${Object.entries(e.uniques).map(([s,o])=>`<div class="mono">${N(s)} = ${N(o)}</div>`).join("")}</div>`:""}
|
|
203
|
+
${e.proofUniques&&e.status!=="green"?`<div class="panel"><div class="panel-title">unique values in the proof replay</div>${Object.entries(e.proofUniques).map(([s,o])=>`<div class="mono">${N(s)} = ${N(o)}</div>`).join("")}</div>`:""}
|
|
204
|
+
${yg(e,n,r)}
|
|
202
205
|
</div>
|
|
203
|
-
</details>`}function
|
|
206
|
+
</details>`}function yg(e,t,n=!1){let r=(e.evidence??[]).find(i=>i.kind==="trace"),s=r?`<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 ${N(bg(`${e.feature}:${e.scenario}${n&&e.browser?`@${e.browser}`:""}`))}</div><div class="panel-hint">${N(r.file)}</div></div>`:"",o=(e.evidence??[]).flatMap(i=>{if(!t||i.kind!=="failure"&&i.kind!=="before-heal")return[];let a=An(t,i);if(!a)return[];try{let c=a.toString("base64"),l=i.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>${N(l)}${i.step?`: <span class="mono">${N(i.step)}</span>`:""}</figcaption><a href="data:image/jpeg;base64,${c}" target="_blank" rel="noopener"><img alt="${N(l)}" src="data:image/jpeg;base64,${c}"/></a><div class="panel-hint">${N(i.file)}</div></figure>`]}catch{return[]}});return o.length===0?s:`<div class="panel ${e.status==="red"?"accent-red":"accent-gold"}"><div class="panel-title ${e.status==="red"?"red":"gold"}">screenshot</div>${o.join("")}</div>${s}`}var bg=e=>/[^A-Za-z0-9_./:-]/.test(e)?`'${e.replace(/'/g,"'\\''")}'`:e;function qt(e,t){let n=e.totals,r=new Date(e.startedAt),s=new Date(e.finishedAt).getTime()-r.getTime(),o=r.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"});return`<!doctype html>
|
|
204
207
|
<html lang="en">
|
|
205
208
|
<head>
|
|
206
209
|
<meta charset="utf-8"/>
|
|
207
210
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
208
211
|
<title>Saffron run report</title>
|
|
209
|
-
${
|
|
212
|
+
${ei}
|
|
210
213
|
<style>
|
|
211
|
-
${
|
|
214
|
+
${ti}</style>
|
|
212
215
|
</head>
|
|
213
216
|
<body>
|
|
214
217
|
<div class="topline"></div>
|
|
215
218
|
<div class="wrap">
|
|
216
219
|
<header class="top">
|
|
217
220
|
<div class="brand">
|
|
218
|
-
${
|
|
221
|
+
${dr(38)}
|
|
219
222
|
<div>
|
|
220
223
|
<div class="brand-name">saffron</div>
|
|
221
224
|
<div class="brand-sub">test run report</div>
|
|
222
225
|
</div>
|
|
223
226
|
</div>
|
|
224
227
|
<div class="run-meta">
|
|
225
|
-
<span>${
|
|
228
|
+
<span>${N(o)}</span>
|
|
226
229
|
<span>\xB7</span>
|
|
227
|
-
<span>${
|
|
228
|
-
${e.baseURL?`<span class="chip base">${
|
|
230
|
+
<span>${_e(s)}</span>
|
|
231
|
+
${e.baseURL?`<span class="chip base">${N(e.baseURL)}</span>`:""}
|
|
229
232
|
${e.shards?`<span class="chip">merged from ${Number(e.shards)} shards</span>`:""}
|
|
230
|
-
${
|
|
233
|
+
${e.browsers?.length?`<span class="chip">${e.browsers.map(i=>N(i)).join(" \xB7 ")}</span>`:""}
|
|
234
|
+
${ag(e)}
|
|
231
235
|
</div>
|
|
232
236
|
</header>
|
|
233
237
|
|
|
234
|
-
${
|
|
235
|
-
${
|
|
236
|
-
${
|
|
238
|
+
${fg(e,s)}
|
|
239
|
+
${pg(e)}
|
|
240
|
+
${lg(e)}
|
|
241
|
+
${dg(e)}
|
|
237
242
|
|
|
238
243
|
<div class="section-head">
|
|
239
244
|
<span class="section-title">scenarios \xB7 ${Number(n.scenarios)}</span>
|
|
240
|
-
<span class="section-title">saffron v${
|
|
245
|
+
<span class="section-title">saffron v${N(e.version)}</span>
|
|
241
246
|
</div>
|
|
242
|
-
${e.scenarios.map(i=>
|
|
247
|
+
${e.scenarios.map(i=>wg(i,!!e.totals.plan,t,!!e.browsers)).join(`
|
|
243
248
|
`)}
|
|
244
249
|
|
|
245
|
-
<footer>${
|
|
250
|
+
<footer>${Xo(11)} generated by saffron v${N(e.version)} \xB7 ${N(new Date(e.finishedAt).toLocaleString("en-GB",{dateStyle:"medium",timeStyle:"medium"}))}</footer>
|
|
246
251
|
</div>
|
|
247
252
|
</body>
|
|
248
253
|
</html>
|
|
249
|
-
`}import
|
|
250
|
-
`),
|
|
251
|
-
${d.name}`;o.set(u,[...o.get(u)??[],p])});let i=new Array(t.total).fill(0),a=[...o].sort((d,p)=>p[1].length-d[1].length||(d[0]<p[0]?-1:d[0]>p[0]?1:0));for(let[,d]of a){let p=i.indexOf(Math.min(...i));if(i[p]+=d.length,p===t.index-1)for(let u of d)s.add(u)}let c=e.map((d,p)=>p).filter(d=>s.has(d)),l=
|
|
252
|
-
`)).digest("hex").slice(0,12);return{scenarios:c.map(d=>e[d]),info:{index:t.index,total:t.total,by:n,suite:l,suiteSize:e.length,positions:c}}}function
|
|
253
|
-
`),r}var
|
|
254
|
-
`));let
|
|
255
|
-
`)??[]).map(r=>r.split(/\s+/)[1]).filter(Boolean);return[...new Set(n.map(
|
|
254
|
+
`}import ds from"node:fs";import fs from"node:path";function ps(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 si(e,t,n){return{tool:"saffron",version:nt(),startedAt:t.toISOString(),finishedAt:new Date().toISOString(),baseURL:n,totals:ps(e),scenarios:e.map(r=>({feature:r.scenario.featurePath,scenario:r.scenario.displayName,name:r.scenario.name,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,worker:r.worker,browser:r.browser}))}}var ut=e=>!!e&&typeof e=="object"&&!Array.isArray(e),cs=(e,t)=>t.find(n=>typeof e[n]!="number"),ls=e=>e===void 0||typeof e=="number",vg=["scenarios","green","yellow","red","aiCalls","inputTokens","outputTokens","cacheReadTokens","costUsd"],Sg=["aiCalls","inputTokens","outputTokens","cacheReadTokens"],sl=e=>typeof e=="string"&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/.test(e)&&!Number.isNaN(Date.parse(e));function oi(e){if(!ut(e)||e.tool!=="saffron")return"is not a Saffron run report";if(!sl(e.startedAt)||!sl(e.finishedAt))return"has no start or finish time";if(!ut(e.totals))return"has no totals";let t=cs(e.totals,vg);if(t)return`has totals.${t} that is not a number`;if(!Array.isArray(e.scenarios))return"has no list of scenarios";for(let[o,i]of e.scenarios.entries()){let a=`scenarios[${o}]`;if(!ut(i)||typeof i.feature!="string"||typeof i.scenario!="string")return`has ${a} without a feature and scenario name`;if(!["green","yellow","red"].includes(i.status))return`has ${a}.status ${JSON.stringify(i.status)}, which this saffron-ai does not know`;if(!ut(i.usage)||cs(i.usage,Sg))return`has ${a}.usage without its token counts`;if(!Array.isArray(i.steps))return`has ${a}.steps that is not a list`;let c=i.steps.findIndex(d=>!ut(d)||typeof d.gherkin!="string"||typeof d.kind!="string"||typeof d.status!="string");if(c>=0)return`has ${a}.steps[${c}] without its gherkin, kind and status`;for(let[d,p]of i.steps.entries()){if(!["passed","failed","skipped"].includes(p.status))return`has ${a}.steps[${d}].status ${JSON.stringify(p.status)}, which this saffron-ai does not know`;let u=["retries","failedActionIndex"].find(f=>!ls(p[f]));if(u)return`has ${a}.steps[${d}].${u} that is not a number`}let l=["browser","worker"].find(d=>i[d]!==void 0&&typeof i[d]!="string");if(l)return`has ${a}.${l} that is not text`;if(i.evidence!==void 0&&!(Array.isArray(i.evidence)&&i.evidence.every(d=>ut(d)&&typeof d.file=="string"&&typeof d.kind=="string")))return`has ${a}.evidence that is not a list of files`}if(e.browsers!==void 0&&!(Array.isArray(e.browsers)&&e.browsers.every(o=>typeof o=="string")))return"has browsers that is not a list of names";let n=e.timing;if(n!==void 0&&!(ut(n)&&typeof n.wallMs=="number"&&Array.isArray(n.workers)&&n.workers.every(o=>ut(o)&&typeof o.worker=="string"&&!cs(o,["runs","busyMs"])&&ls(o.spanMs))))return"has timing whose workers are not a list of named counts";let r=e.trends,s=(o,i)=>Array.isArray(o)&&o.every(a=>ut(a)&&!cs(a,i));if(r!==void 0&&!(ut(r)&&ls(r.divergentSteps)&&s(r.chronic,["heals","reds","runs"])&&s(r.topIssues,["count"])&&(r.prev===void 0||s([r.prev],["passRatePp","aiCalls","red"])&&ls(r.prev.costUsd))))return"has trends whose counts are not numbers"}var ii=e=>oi(e)===void 0;function ai(e){try{let t=JSON.parse(ds.readFileSync(fs.join(e,".saffron","reports","latest.json"),"utf8"));return ii(t)?t:void 0}catch{return}}function ci(e,t){if(!e||e.imported||e.baseURL!==t.baseURL)return t;let n=new Set,r=e.scenarios.flatMap(g=>{let w=t.scenarios.find(R=>Yc(g,R));return w?n.has(w)?[]:(n.add(w),[w]):[g]}),s=r.filter(g=>!t.scenarios.includes(g)),o=t.totals.aiCalls>0;if(o&&s.some(g=>g.usage.aiCalls>0)&&!t.totals.plan!=!e.totals.plan)return t;r.push(...t.scenarios.filter(g=>!n.has(g)));let i=[...new Set(r.flatMap(g=>g.browser?[g.browser]:[]))],{trends:a,shards:c,hooks:l,timing:d,browsers:p,...u}=e,f=o?t.totals.plan:e.totals.plan;return{...u,version:t.version,startedAt:t.startedAt,finishedAt:t.finishedAt,totals:{...ps(r),...f?{plan:f}:{}},scenarios:r,...t.shards?{shards:t.shards}:{},...t.hooks?{hooks:t.hooks}:{},...t.timing?{timing:t.timing}:{},...i.length>1?{browsers:i}:{}}}function fr(e,t){let n=fs.join(e,".saffron","reports");ds.mkdirSync(n,{recursive:!0});let r=fs.join(n,"latest.json"),s=fs.join(n,"latest.html");return ds.writeFileSync(r,`${JSON.stringify(t,null,2)}
|
|
255
|
+
`),ds.writeFileSync(s,qt(t,e)),{json:r,html:s}}import xg from"node:crypto";import pr from"node:fs";import us from"node:path";var ol=["scenario","file"];function il(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 al(e){if(!ol.includes(e))throw new Error(`invalid --shard-by "${e}": use ${ol.map(t=>`"${t}"`).join(" or ")}`);return e}function li(e){return`shard-${e.index}-of-${e.total}`}var $g=e=>`${e.featurePath}${e.displayName}`;function cl(e,t){let n=t.by??"scenario",r=e.map($g),s=new Set,o=new Map;e.forEach((d,p)=>{let u=n==="file"?d.featurePath:`${d.featurePath}
|
|
256
|
+
${d.name}`;o.set(u,[...o.get(u)??[],p])});let i=new Array(t.total).fill(0),a=[...o].sort((d,p)=>p[1].length-d[1].length||(d[0]<p[0]?-1:d[0]>p[0]?1:0));for(let[,d]of a){let p=i.indexOf(Math.min(...i));if(i[p]+=d.length,p===t.index-1)for(let u of d)s.add(u)}let c=e.map((d,p)=>p).filter(d=>s.has(d)),l=xg.createHash("sha256").update([...r].sort().join(`
|
|
257
|
+
`)).digest("hex").slice(0,12);return{scenarios:c.map(d=>e[d]),info:{index:t.index,total:t.total,by:n,suite:l,suiteSize:e.length,positions:c}}}function gs(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}var hs=(e=process.env)=>e.GITHUB_RUN_ATTEMPT||e.SYSTEM_JOBATTEMPT||void 0;function di(e,t){let n=us.join(e,".saffron","reports");pr.mkdirSync(n,{recursive:!0});let r=us.join(n,`${li(t.shard)}.json`);return pr.writeFileSync(r,`${JSON.stringify(t,null,2)}
|
|
258
|
+
`),r}var ms=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)&&(t.shard.browser===void 0||typeof t.shard.browser=="string")&&Array.isArray(t.scenarios)&&!!t.history&&Array.isArray(t.history.scenarios)},kg=/^shard-\d+-of-\d+\.json$/;function ll(e){let t=[],n=(r,s)=>{let o=pr.statSync(r,{throwIfNoEntry:!1});if(!o)throw new Error(`not found: ${r}`);if(o.isDirectory())for(let i of pr.readdirSync(r).sort())n(us.join(r,i),!1);else(s||kg.test(us.basename(r)))&&t.push(r)};for(let r of e)n(r,!0);return t.map(r=>{let s;try{s=JSON.parse(pr.readFileSync(r,"utf8"))}catch(o){throw new Error(`cannot read shard report ${r}: ${o instanceof Error?o.message:String(o)}`)}if(!ms(s))throw new Error(`${r} is not a shard report (written by saffron run --shard)`);return{file:r,report:s}})}var ln=class extends Error{};function ws(e,t={}){if(e.length===0)throw new ln("no shard reports found. Shards write .saffron/reports/shard-<i>-of-<n>.json");let n=E=>Number(E.report.shard.attempt)||0,r=E=>{let{ciRun:P,index:O,total:M,suite:T,suiteSize:C,by:j,browser:L}=E.report.shard;return JSON.stringify([P??"",O,M,T,C,j??"scenario",E.report.baseURL??"",L??""])},s=new Map;for(let E of e)s.set(r(E),Math.max(s.get(r(E))??0,n(E)));let o=e.filter(E=>n(E)<s.get(r(E)));e=e.filter(E=>!o.includes(E));let i=e[0].report.shard,a=[],c=E=>E.map(P=>P.file).join(", "),l=new Set(e.map(E=>E.report.shard.total));l.size>1&&a.push(`shards disagree on the shard count (${[...l].sort().join(", ")}): ${c(e)}`),new Set(e.map(E=>`${E.report.shard.suite}/${E.report.shard.suiteSize}`)).size>1&&a.push("shards ran different suites (different scenarios or filters). Every shard must run the same paths and --filter");let p=new Set(e.map(E=>E.report.shard.by??"scenario"));p.size>1&&a.push(`shards split the suite in different ways (--shard-by ${[...p].sort().join(" and ")}): every shard must use the same`);let u=new Set(e.map(E=>E.report.shard.ciRun??""));u.size>1&&a.push(`shards come from different CI runs (${[...u].map(E=>E||"none recorded").join(", ")})`);let f=[...new Set(e.map(E=>E.report.baseURL??"(no base URL)"))];f.length>1&&a.push(`the shards ran against different base URLs: ${f.join(", ")}. Merge the shards of one environment at a time`);let g=[...new Set(e.map(E=>E.report.shard.browser??"(no browser recorded)"))];g.length>1&&a.push(`the shards ran in different browsers: ${g.join(", ")}. Merge the shards of one browser at a time`);let w=new Map;for(let E of e)w.set(E.report.shard.index,[...w.get(E.report.shard.index)??[],E]);for(let[E,P]of[...w].sort((O,M)=>O[0]-M[0]))P.length>1&&a.push(`shard ${E}/${i.total} appears ${P.length} times: ${c(P)}`);if(l.size===1&&!t.partial){let E=Array.from({length:i.total},(P,O)=>O+1).filter(P=>!w.has(P));E.length>0&&a.push(`missing ${E.length===1?"shard":"shards"} ${E.map(P=>`${P}/${i.total}`).join(", ")}. A merge without ${E.length===1?"it":"them"} would under-count the run`)}if(a.length===0){let E=new Set,P=0;for(let O of e){let{positions:M}=O.report.shard;(M.length!==O.report.scenarios.length||M.length!==O.report.history.scenarios.length)&&a.push(`${O.file} is inconsistent: ${M.length} positions for ${O.report.scenarios.length} scenarios`);for(let T of M)E.has(T)&&a.push(`two shards both ran scenario ${T+1} of the suite`),E.add(T);P+=M.length}a.length===0&&!t.partial&&P!==i.suiteSize&&a.push(`the shards hold ${P} scenarios but the suite has ${i.suiteSize}`)}if(a.length>0)throw new ln(a.join(`
|
|
259
|
+
`));let R=e.sort((E,P)=>E.report.shard.index-P.report.shard.index).flatMap(({report:E})=>E.shard.positions.map((P,O)=>({position:P,scenario:E.scenarios[O].worker?{...E.scenarios[O],worker:`${E.shard.index}/${E.shard.total} \xB7 ${E.scenarios[O].worker}`}:E.scenarios[O],history:E.history.scenarios[O]}))).sort((E,P)=>E.position-P.position),$=e.map(E=>E.report),m=$.map(E=>E.startedAt).sort()[0],v=$.map(E=>E.finishedAt).sort().at(-1),S=R.map(E=>E.scenario),x=$.flatMap(E=>E.hooks??[]),b={tool:"saffron",version:$[0].version,startedAt:m,finishedAt:v,baseURL:$.find(E=>E.baseURL)?.baseURL,totals:ps(S),scenarios:S,shards:e.length};x.length>0&&(b.hooks=x);let k=e.filter(E=>E.report.timing).sort((E,P)=>E.report.shard.index-P.report.shard.index);k.length>0&&(b.timing={wallMs:Math.max(...k.map(E=>E.report.timing.wallMs)),workers:k.flatMap(E=>E.report.timing.workers.map(P=>({...P,worker:`${E.report.shard.index}/${E.report.shard.total} \xB7 ${P.worker}`})))});let h=R.map(E=>E.history),A={runId:i.ciRun?`${i.ciRun}:${i.suite}:${b.baseURL??""}${i.browser?`:${i.browser}`:""}`:m,startedAt:m,finishedAt:v,baseURL:b.baseURL,green:h.filter(E=>E.status==="green").length,yellow:h.filter(E=>E.status==="yellow").length,red:h.filter(E=>E.status==="red").length,aiCalls:h.reduce((E,P)=>E+P.aiCalls,0),costUsd:h.reduce((E,P)=>E+P.costUsd,0),divergentSteps:Math.max(...$.map(E=>E.history.divergentSteps)),...$.some(E=>E.history.partial)?{partial:!0}:{},scenarios:h};return{report:b,history:A,replaced:o.map(E=>E.file)}}var ys=1,z=class extends Error{constructor(n,r){super(ie(n,1/0));this.kind=r}kind},dn=e=>e instanceof Error?e.message:String(e);function At(e,t){try{return Rg("git",t,{cwd:e,encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:1e4}).trim()||void 0}catch{return}}function ml(e){let t=e.trim();if(/^[a-z]:[\\/]/i.test(t))return;let n,r;if(/^[a-z][a-z0-9+.-]*:\/\//i.test(t)){let o;try{o=new URL(t)}catch{return}if(o.protocol==="file:")return;n=o.hostname,r=o.pathname}else{let o=/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(t);if(!o)return;n=o[1],r=o[2]}try{r=decodeURIComponent(r)}catch{}r=r.replace(/^\/+|\/+$/g,"").replace(/\.git$/i,""),n=n.toLowerCase();let s=/^v3\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(r);return s?(n="dev.azure.com",r=`${s[1]}/${s[2]}/_git/${s[3]}`):n.endsWith(".visualstudio.com")&&(r=`${n.slice(0,-17)}/${r.replace(/^DefaultCollection\//i,"")}`,n="dev.azure.com"),r?`${n}/${r}`.toLowerCase():void 0}function vs(e){let n=(At(e,["remote","-v"])?.split(`
|
|
260
|
+
`)??[]).map(r=>r.split(/\s+/)[1]).filter(Boolean);return[...new Set(n.map(ml).filter(r=>r!==void 0))]}var dl=e=>e?.replace(/^refs\/heads\//,"")||void 0,wl=e=>e.GITHUB_EVENT_NAME==="pull_request"||!!e.GITHUB_EVENT_NAME?.startsWith("pull_request_review");function Ag(e){if(!e.GITHUB_EVENT_PATH||!wl(e))return{};let t=n=>typeof n=="string"&&n?n:void 0;try{let n=JSON.parse(Q.readFileSync(e.GITHUB_EVENT_PATH,"utf8"))?.pull_request?.head;return{sha:t(n?.sha),ref:t(n?.ref)}}catch{return{}}}function gi(e,t){return[...(At(e,["cat-file","-p",t])??"").split(`
|
|
256
261
|
|
|
257
|
-
`)[0].matchAll(/^parent ([0-9a-f]+)$/gm)].map(r=>r[1])}function
|
|
258
|
-
`)??[],r=n.includes("origin")?"origin":n[0],s=t.GITHUB_SERVER_URL&&t.GITHUB_REPOSITORY?`${t.GITHUB_SERVER_URL}/${t.GITHUB_REPOSITORY}`:void 0,o=[r&&
|
|
259
|
-
`)},...r]),{file:f,manifest:u,proposals:o,missing:l,dropped:d,unreadable:n}}var Jr="[a-z0-9-]+",Ac=new RegExp(`^proposals/${Jr}/${Jr}\\.json$`),_u=new RegExp(`^artifacts/${Jr}/${Jr}/[A-Za-z0-9][A-Za-z0-9._-]*$`),No=1024**3,Lo=8*1024**3,Iu="the bundles to import add up to more than 8 GB unzipped",Sc=Math.min(Lo,xt),xc=100,Cu=1024**2,Pu=6e5;function Ou(e){try{return process.kill(e,0),!0}catch(t){return t.code==="EPERM"}}function Bo(e){let t=K.join(e,".saffron","reports");for(let o of W.existsSync(t)?W.readdirSync(t):[]){let i=Number(/^run-download-(\d+)-/.exec(o)?.[1]),a=K.join(t,o),c=i?W.statSync(a,{throwIfNoEntry:!1}):void 0;c&&Date.now()-c.mtimeMs>36e5&&!Ou(i)&&W.rmSync(a,{recursive:!0,force:!0})}let n=K.join(t,`run-download-${process.pid}-${Mo(4).toString("hex")}`),r=W.mkdirSync(n,{recursive:!0});return{dir:n,remove:()=>{W.rmSync(n,{recursive:!0,force:!0});for(let o=t;r&&o.startsWith(r);o=K.dirname(o))try{W.rmdirSync(o)}catch{break}}}}async function ju(e,t){let n=r=>new D(r?.name==="TimeoutError"?`cannot download ${e}: it did not finish within 10 minutes`:`cannot download ${e}: ${zt(r)}`);try{let r=await fetch(e,{redirect:"follow",signal:AbortSignal.timeout(Pu)});if(!r.ok||!r.body)throw new D(`cannot download ${e}: HTTP ${r.status}`);let s=W.openSync(t,"w");try{let o=0;for await(let i of r.body){if(o+=i.length,o>Lo)throw new D(`cannot download ${e}: it is over 8 GB`);W.writeSync(s,i)}}finally{W.closeSync(s)}}catch(r){throw r instanceof D?r:n(r)}}async function Nu(e,t){if(/^https?:\/\//i.test(e)){let s=K.join(t(),`url-${Mo(4).toString("hex")}.zip`);return await ju(e,s),[{source:e,file:s}]}let n=W.statSync(e,{throwIfNoEntry:!1});if(!n)throw new D(`not found: ${e}`);let r=n.isDirectory()?W.readdirSync(e,{recursive:!0}).map(String).filter(s=>/^saffron-bundle.*\.zip$/.test(K.basename(s))).sort().map(s=>K.join(e,s)):[e];if(r.length===0)throw new D(`no saffron-bundle*.zip under ${e}`);return r.map(s=>({source:s,file:s}))}function Fu(e,t){let n;try{n=Ea(t)}catch(s){throw new D(`${e} is not a Saffron bundle: ${zt(s)}`)}let r=new Map;for(let s of n){if(r.has(s.name))throw new D(`${e} is damaged: it holds ${ne(s.name)} twice`);r.set(s.name,s)}return{source:e,file:t,entries:r}}var Mu=1024*1024;function $c(e,t,n){let r=bu("sha256"),s=W.openSync(e,"r");try{let o=Buffer.alloc(Math.max(1,Math.min(Mu,t.size)));for(let i=0;i<t.size;){let a=W.readSync(s,o,0,Math.min(o.length,t.size-i),t.start+i);if(a===0)throw new Error("the file ends early");let c=o.subarray(0,a);r.update(c),n?.(c),i+=a}}finally{W.closeSync(s)}return r.digest("hex")}function Du(e){try{return Or(e).sha256}catch{return}}function Lu({source:e,file:t,entries:n}){let r=f=>new D(`${e} ${f}`);for(let f of n.values()){if(f.size>No||f.compressedSize>No)throw r(`is damaged: ${ne(f.name)} is too large`);if(f.method!==0&&f.size>Cu&&f.size>xc*f.compressedSize)throw r(`is damaged: ${ne(f.name)} claims to unzip to over ${xc} times its size`)}let s=f=>{let y=n.get(f);if(!y)throw r(`is damaged: ${f} is missing`);let b;try{let k=y.raw();if(k.length!==y.compressedSize)throw new Error("the file ends early");b=y.method===0?k:vu.inflateRawSync(k,{maxOutputLength:Math.max(y.size,1)})}catch(k){throw r(`is damaged: ${f} cannot be read (${zt(k)})`)}if(b.length!==y.size)throw r(`is damaged: ${f} is truncated`);return b},o=f=>{let y=n.get(f);if(!(!y||y.method!==0)){if(y.compressedSize!==y.size)throw r(`is damaged: ${f} is truncated`);return y}},i=f=>{let y=o(f);if(!y)return Ue(s(f));try{return $c(t,y)}catch(b){throw r(`is damaged: ${f} cannot be read (${zt(b)})`)}},a=(f,y,b)=>{let k=o(f),T=()=>new Error(ne(`${e} is damaged: ${f} changed while it was imported`,1/0));if(!k){let x=s(f);if(Ue(x)!==b)throw T();be(y,x);return}W.mkdirSync(K.dirname(y),{recursive:!0});let v=`${y}.${process.pid}-${Mo(4).toString("hex")}.partial`,S=W.openSync(v,"w");try{let x;try{x=$c(t,k,$=>{for(let h=0;h<$.length;)h+=W.writeSync(S,$,h,$.length-h)})}finally{W.closeSync(S)}if(x!==b)throw T();W.renameSync(v,y)}catch(x){throw W.rmSync(v,{force:!0}),x}},c=f=>{try{return JSON.parse(s(f).toString("utf8"))}catch(y){throw y instanceof D?y:r(`is damaged: ${f} is not JSON`)}};if(!n.has("manifest.json"))throw r("is not a Saffron bundle: it has no manifest.json");let l=c("manifest.json");if(!l||l.tool!=="saffron"||typeof l.format!="number")throw r("is not a Saffron bundle");for(let f of["runner","createdAt","repository","branch","commit","headCommit","run","attempt","url"])if(l[f]!==void 0){if(typeof l[f]!="string")throw r(`is damaged: its manifest's ${f} is not text`);if(l[f].length>2048)throw r(`is damaged: its manifest's ${f} is over 2048 characters`);l[f]=ne(l[f],2048)}if(l.format>qr)throw r(`is bundle format ${l.format}, made by saffron-ai ${l.runner}; this saffron-ai (${Be()}) reads format ${qr}. Update saffron-ai to import it`);if(l.format!==qr)throw r(`has bundle format ${l.format}, which this saffron-ai does not read`);if(!Array.isArray(l.proposals)||!Array.isArray(l.evidence))throw r("is damaged: its manifest has no proposal or evidence list");let{shard:d}=l;if(d!==void 0&&!(Number.isInteger(d?.index)&&Number.isInteger(d?.total)&&d.index>=1&&d.index<=d.total))throw r("is damaged: its manifest's shard is not <index> of <total>");let p=new Map;for(let f of l.proposals){if(!f||typeof f.entry!="string"||!Ac.test(f.entry)||typeof f.revision!="string")throw r(`is damaged: its manifest lists a proposal at ${ne(JSON.stringify(f?.entry)??"nothing")}`);typeof f.feature=="string"&&(f.feature=ne(f.feature)),typeof f.scenario=="string"&&(f.scenario=ne(f.scenario));let y=s(f.entry);if(Ue(y)!==f.revision)throw r(`is damaged: ${f.entry} is not the proposal its manifest names`);p.set(f.entry,y)}for(let f of l.evidence){if(!f||typeof f.entry!="string"||!_u.test(f.entry)||typeof f.sha256!="string")throw r(`is damaged: its manifest lists evidence at ${ne(JSON.stringify(f?.entry)??"nothing")}`);if(i(f.entry)!==f.sha256)throw r(`is damaged: ${f.entry} is not the file its manifest names`)}let u;if(l.report!==void 0){if(l.report!=="report.json")throw r(`is damaged: its manifest names the report ${JSON.stringify(l.report)}`);let f=c("report.json"),y=Rc(f);if(y)throw r(`is damaged: report.json ${y}`);try{kt(f)}catch(b){throw r(`is damaged: report.json cannot be shown (${zt(b)})`)}u=f}if(d&&!(u&&zr(u)))throw r("is damaged: it is a shard's bundle without the shard's report");return{source:e,manifest:l,report:u,files:p,read:s,write:a}}function Bu(e,t){let n=t.headCommit??t.commit,r=dt(e,["rev-parse","HEAD"]);if(!n||!r||r===n)return;let s=`${n.slice(0,7)}${t.branch?` on ${t.branch}`:""}`;if(!/^[0-9a-f]{7,64}$/i.test(n)||!dt(e,["rev-parse","--verify","--quiet",`${n}^{commit}`]))return`this checkout is at ${r.slice(0,7)}, and the bundle was made at ${s}, which this clone does not have: fetch it to review against the same files`;let[o,i]=(dt(e,["rev-list","--left-right","--count",`HEAD...${n}`,"--"])??"").split(/\s+/).map(Number),a=[o?`${o} commit${o===1?"":"s"} ahead of`:"",i?`${i} commit${i===1?"":"s"} behind`:""].filter(Boolean).join(" and ");return`this checkout (${r.slice(0,7)}) is ${a||"not at"} the commit the bundle was made at, ${s}`}function Fo(e){let{repository:t,branch:n,commit:r,headCommit:s,run:o,attempt:i,url:a}=e;return Object.fromEntries(Object.entries({repository:t,branch:n,commit:r,headCommit:s,run:o,attempt:i,url:a}).filter(([,c])=>typeof c=="string"))}var _c=e=>K.join(e,".saffron","reports","imported.json");function ft(e){let t;try{t=JSON.parse(W.readFileSync(_c(e),"utf8"))}catch{return{}}return!t||typeof t!="object"||Array.isArray(t)?{}:Object.fromEntries(Object.entries(t).filter(([,n])=>!!n&&typeof n.revision=="string"&&!!n.from&&typeof n.from=="object"))}function Uu(e,t){try{return W.readFileSync(e).equals(t)}catch{return!1}}function Ic(e,t,n,r,s){let o;try{o=Us(t,n)}catch(T){let v={file:`.saffron/${n}`,feature:"",scenario:""},S=`cannot be read: ${T instanceof te?T.problem:zt(T)}`;return{verdict:{...v,outcome:"refused",reason:S},shown:v,file:K.join(e,".saffron",n),revision:"",verified:!1}}let{proposal:i,revision:a}=o,{feature:c,scenario:l}=i.cache,d=Ce(e,c,l),p={file:me(e,d),feature:ne(c),scenario:ne(l)},u=T=>({verdict:T,shown:p,file:d,revision:a,verified:i.meta.verified===!0}),f=T=>u({...p,outcome:"refused",reason:T});if(!i.meta.bound)return f("carries no binding (a runner older than 0.8.5 filed it), so nothing can tell whether it fits this checkout");let y=s?`. ${s}`:"";if(!W.existsSync(K.join(e,c)))return f(`is for ${p.feature}, which is not in this checkout${y}`);let b=wt(ee(e,c,l));if(b&&Nn(b)===Nn(i.cache))return u({...p,outcome:"unchanged",reason:"already accepted here: the committed recording is this proposal's"});let k;try{k=Oe(e,i,r)}catch(T){k=`cannot be checked: ${zt(T)}`}return k?f(`does not fit this checkout: ${ne(k,1e3)}${y}`):u()}function Cc(e,t){if(W.existsSync(e.file)){if(Ne(e.file)===e.revision)return{...e.shown,outcome:"unchanged"};if(!t)return{...e.shown,outcome:"refused",reason:"differs from the proposal already pending here for this scenario: review that one first (saffron diff), or import with --overwrite to replace it"}}}function zu(e,t,n,r,s,o){let i=Ic(e,t,n,r,o);if(i.verdict)return i.verdict;W.mkdirSync(K.dirname(i.file),{recursive:!0});try{return ln(i.file,()=>{let a=Cc(i,s);return a||(be(i.file,t),{...i.shown,outcome:"imported"})})}catch(a){if(a instanceof Dt)return{...i.shown,outcome:"refused",reason:"is being decided by another process. Try again."};throw a}}async function Pc(e,t,n,r={}){let s;try{let o=await Oc(t,()=>(s??=Bo(e)).dir,r.describe);return qu(e,o,n,r)}finally{s?.remove()}}async function Oc(e,t,n){let r=[];for(let s of e)for(let o of await Nu(s,t))r.push(Fu(n?.(o.source)??o.source,o.file));if(r.reduce((s,o)=>s+[...o.entries.values()].reduce((i,a)=>i+a.size,0),0)>Lo)throw new D(Iu);return r.map(Lu)}function Hu(e){let t=[...new Set(e)].sort((n,r)=>n-r);return t.length>1?`${t.slice(0,-1).join(", ")} and ${t[t.length-1]}`:String(t[0]??"")}function jc(e,t,n){let r=t;if(r.length===0)throw new D("no bundle to import");let s=new Set,o=Vr(e);for(let u of r){if(n.run!==void 0&&u.manifest.run!==n.run)throw new D(`${u.source} was made by ${u.manifest.run?`run ${u.manifest.run}`:"no CI run"}, not by ${n.run}, the run it was downloaded from. If that is expected, download the artifact and import the file`);let f=u.manifest.repository;if(!f&&!n.allowUnknownRepository)throw new D(`${u.source} does not say which repository it comes from (it was made outside a git checkout). Import it with --allow-unknown-repository to check it by its proposals' bindings only`);if(!f)s.add(`${u.source} does not say which repository it comes from: it is checked by its proposals' bindings only`);else if(o.length===0)s.add(`this project has no git remote to compare ${f} with: the bundle is checked by its proposals' bindings only`);else if(!o.includes(f)){let y=k=>k.slice(k.indexOf("/")),b=o.find(k=>y(k)===y(f));if(!b)throw new D(`${u.source} comes from ${f}, and this checkout is ${o.join(", ")}. Import it into a checkout of ${f}`);s.add(`${r.length===1?"the bundle comes":"the bundles come"} from ${f}, and this checkout is ${b}: the same repository under another host name (an SSH host alias, or a mirror), so each proposal is checked by its binding`)}}if(r.length>1){let u=new Set(r.map(v=>v.manifest.run??""));if(u.size>1)throw new D(`these bundles come from different runs (${[...u].map(v=>v||"none recorded").join(", ")}). Import one run at a time`);let f=v=>Number(v.manifest.attempt)||0,y=v=>v.manifest.shard?.index??0,b=new Map;for(let v of r){let S=b.get(y(v));(!S||f(v)>f(S))&&b.set(y(v),v)}let k=r.filter(v=>f(v)<f(b.get(y(v))));for(let v of k){let S=v.manifest.shard?`shard ${v.manifest.shard.index}`:"the whole run";s.add(`${v.source} is ${S} of attempt ${f(v)}, which attempt ${f(b.get(y(v)))} ran again: left out`)}r=r.filter(v=>!k.includes(v));let T=r.filter(v=>!v.manifest.shard);if(T.length>0&&r.length>1)throw new D(`${T.map(v=>v.source).join(", ")} ${T.length===1?"holds a whole run, not a shard of one":"hold whole runs, not shards of one"}: import one run at a time`)}let i,a=r.filter(u=>u.report);if(r.length===1&&a.length===1){let{shard:u,history:f,...y}=a[0].report;i=y}else if(a.length>0)try{let u=Hr(a.map(f=>({file:f.source,report:f.report})),{partial:!0});if(u.replaced.length>0)throw new Ut(`${u.replaced.join(", ")} holds a shard that another bundle of the same attempt holds too`);i=u.report}catch(u){throw u instanceof Ut?new D(`these shard bundles do not belong together: ${u.message}`):u}let c=r[0].manifest,l=Bu(e,c);l&&s.add(l),c.headCommit&&c.commit&&s.add(`the run tested ${c.commit.slice(0,7)}, a pull request's merge commit, not the head of ${c.branch??"its branch"} (${c.headCommit.slice(0,7)}): each proposal is checked against this checkout by its binding`);let d=c.headCommit??c.commit,p=l&&d?`The bundle was made at ${d.slice(0,7)}${c.branch?` on ${c.branch}`:""}: check that out and import again`:void 0;if(i){let u=r.flatMap(f=>f.manifest.shard?[f.manifest.shard]:[]);i.imported={...Fo(c),...u.length>0?{shards:{imported:u.map(f=>f.index).sort((f,y)=>f-y),total:u[0].total}}:{}}}return{bundles:r,report:i,warnings:s,hint:p}}function Nc(e,t,n){let r={written:0,unchanged:0},s=new Map;for(let o of n?.scenarios??[])for(let i of o.evidence??[])s.set(K.posix.dirname(i.file),{feature:o.feature,scenario:o.scenario});for(let o of t)for(let{entry:i,sha256:a}of o.manifest.evidence){let c=K.join(e,".saffron",...i.split("/"));if(Du(c)===a){r.unchanged++;continue}o.write(i,c,a),r.written++;let l=s.get(`.saffron/${K.posix.dirname(i)}`);l&&W.writeFileSync(K.join(K.dirname(c),rn),JSON.stringify(l,null,2))}return r}function Fc(e){let t=new Map;for(let s of e)for(let o of s.manifest.proposals){let i=t.get(o.entry)??{revisions:new Set,shards:[]};i.revisions.add(o.revision),s.manifest.shard&&i.shards.push(s.manifest.shard.index),t.set(o.entry,i)}let n=[],r=new Set;for(let s of e)for(let o of s.manifest.proposals){let i=t.get(o.entry);if(i.revisions.size===1){n.push({b:s,p:o});continue}r.has(o.entry)||(r.add(o.entry),n.push({b:s,p:o,conflict:{file:`.saffron/${o.entry}`,feature:typeof o.feature=="string"?o.feature:"",scenario:typeof o.scenario=="string"?o.scenario:"",outcome:"refused",reason:`is a conflict between the run's shards: shards ${Hu(i.shards)} filed different proposals for this scenario (a Scenario Outline's rows split across shards), so no proposal for it was imported. Run the scenario again on one machine and import that run`}}))}return n}function qu(e,t,n,r){let{bundles:s,report:o,warnings:i,hint:a}=jc(e,t,r),c=Nc(e,s,o),l;if(o){for(let y of o.scenarios){let b=typeof y.proposalFile=="string"?y.proposalFile.split(/[\\/]/).slice(-2):[];Ac.test(`proposals/${b.join("/")}`)&&(y.proposalFile=`.saffron/proposals/${b.join("/")}`)}let f=K.join(e,".saffron","reports","latest.json");Uu(f,Buffer.from(`${JSON.stringify(o,null,2)}
|
|
260
|
-
`))?l="unchanged":(
|
|
261
|
-
`),{bundles:s.map(f=>{let{runner:
|
|
262
|
-
`),
|
|
263
|
-
`)[0]:void 0,failedStep:(
|
|
264
|
-
`)[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(i=>i.cacheProblem).map(i=>`\u26A0 recording of ${o.path} \u203A ${i.name} cannot be read: ${i.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
|
|
262
|
+
`)[0].matchAll(/^parent ([0-9a-f]+)$/gm)].map(r=>r[1])}function _g(e,t=process.env){let n=At(e,["remote"])?.split(`
|
|
263
|
+
`)??[],r=n.includes("origin")?"origin":n[0],s=t.GITHUB_SERVER_URL&&t.GITHUB_REPOSITORY?`${t.GITHUB_SERVER_URL}/${t.GITHUB_REPOSITORY}`:void 0,o=[r&&At(e,["remote","get-url",r]),s,t.BUILD_REPOSITORY_URI].map(g=>g?ml(g):void 0).find(Boolean),i=At(e,["rev-parse","--abbrev-ref","HEAD"]),a=Ag(t),c=(wl(t)?t.GITHUB_HEAD_REF||a.ref:void 0)||(t.GITHUB_EVENT_NAME==="pull_request_target"?t.GITHUB_BASE_REF:void 0)||(t.GITHUB_REF_TYPE==="tag"?void 0:t.GITHUB_REF_NAME)||dl(t.SYSTEM_PULLREQUEST_SOURCEBRANCH)||(t.BUILD_SOURCEBRANCH?.startsWith("refs/heads/")?dl(t.BUILD_SOURCEBRANCH):void 0)||(i!=="HEAD"?i:void 0),l=At(e,["rev-parse","HEAD"])||t.GITHUB_SHA||t.BUILD_SOURCEVERSION,d=a.sha||t.SYSTEM_PULLREQUEST_SOURCECOMMITID,p=d&&l&&gi(e,l)[1]===d?d:void 0,u=t.GITHUB_SERVER_URL&&t.GITHUB_REPOSITORY&&t.GITHUB_RUN_ID?`${t.GITHUB_SERVER_URL}/${t.GITHUB_REPOSITORY}/actions/runs/${t.GITHUB_RUN_ID}`:t.SYSTEM_COLLECTIONURI&&t.SYSTEM_TEAMPROJECT&&t.BUILD_BUILDID?`${t.SYSTEM_COLLECTIONURI.replace(/\/?$/,"/")}${encodeURIComponent(t.SYSTEM_TEAMPROJECT)}/_build/results?buildId=${t.BUILD_BUILDID}`:void 0,f={repository:o,branch:c,commit:l,headCommit:p&&p!==l?p:void 0,run:t.GITHUB_RUN_ID&&`github:${t.GITHUB_RUN_ID}`||t.BUILD_BUILDID&&`azure:${t.BUILD_BUILDID}`||gs(t),attempt:hs(t),url:u};return Object.fromEntries(Object.entries(f).filter(([,g])=>g!==void 0))}function ur(e){let t=e.headCommit??e.commit;return[e.run?`run ${e.run}${e.attempt&&e.attempt!=="1"?` (attempt ${e.attempt})`:""}`:"a local run",e.branch||t?`${e.branch??"detached"}${t?` @ ${t.slice(0,7)}`:""}`:void 0,e.repository].filter(Boolean).join(" \xB7 ")}var Ig=/^(latest|shard-\d+-of-\d+)\.json$/;function Cg(e){let t=te.join(e,".saffron","reports"),n;try{n=Q.readdirSync(t).filter(s=>Ig.test(s)).sort()}catch{return}let r;for(let s of n)try{let o=Q.readFileSync(te.join(t,s)),i=JSON.parse(o.toString("utf8"));ii(i)&&(!r||i.finishedAt>r.report.finishedAt)&&(r={bytes:o,report:i})}catch{}return r}function Pg(e,t){return te.join(e,".saffron","reports",t?`saffron-bundle-${li(t)}.zip`:"saffron-bundle.zip")}var fl=e=>`${(e/1024**3).toFixed(1)} GB`,pl='Keep fewer traces ("trace": "retain-on-failure" in saffron.config.json, or --trace retain-on-failure) and run again';function yl(e,t={}){let n=[],r=[],s=[],o=[],i=te.join(e,".saffron","proposals");for(let{file:g,proposal:w,revision:R,bytes:$}of ft(e,(m,v)=>n.push({file:Ae(e,m),problem:v}))){let m=`proposals/${Ae(i,g)}`;r.push({name:m,data:$}),s.push({entry:m,feature:w.cache.feature,scenario:w.cache.scenario,revision:R,bound:w.meta.bound}),o.push({file:g,proposal:w,bytes:$})}let a=Cg(e),c=[],l=[],d=[];if(a){r.push({name:"report.json",data:a.bytes});let g=[],w=new Set;for(let $ of a.report.scenarios)for(let m of $.evidence??[]){if(w.has(m.file))continue;w.add(m.file);let v=m.file.startsWith(".saffron/artifacts/")?Q.statSync(te.join(e,m.file),{throwIfNoEntry:!1}):void 0;if(!v?.isFile()){l.push(m.file);continue}let S=$.status==="green"&&m.kind==="trace";if(v.size>fi){if(S){d.push(m.file);continue}throw new z(`${m.file} is ${fl(v.size)}, over the 1 GB a bundle holds per file. ${pl}`)}g.push({e:m,size:v.size,greenTrace:S})}let R=()=>r.reduce(($,m)=>$+("data"in m?m.data.length:m.size??0)+1024,0)+g.reduce(($,m)=>$+m.size+1024,0);if(R()>ul)for(let $ of g.filter(m=>m.greenTrace))g.splice(g.indexOf($),1),d.push($.e.file);if(R()>ul)throw new z(`the bundle would hold ${fl(R())}${d.length?" even without the traces of green scenarios":""}, over the 4 GB one bundle holds. ${pl}, or split the run into shards (--shard)`);for(let{e:$}of g){let m=te.join(e,$.file),v;try{v=Dt(te.join(e,".saffron","artifacts"),m)?ns(m):void 0}catch{v=void 0}if(!v||$.sha256&&v.sha256!==$.sha256){l.push($.file);continue}let S=$.file.slice(9);r.push({name:S,file:m,crc:v.crc,size:v.size}),c.push({entry:S,sha256:v.sha256})}}if(!a&&s.length===0)throw new z("nothing to export: no run report and no pending proposals under .saffron. Run `saffron run` first.");let p=a&&ms(a.report)?{index:a.report.shard.index,total:a.report.shard.total}:void 0,u={tool:"saffron",format:ys,runner:nt(),createdAt:new Date().toISOString(),..._g(e,t.env),...p?{shard:p}:{},...a?{report:"report.json"}:{},proposals:s,evidence:c},f=t.out??Pg(e,p);if(Q.existsSync(f)&&Q.statSync(f).isDirectory())throw new z(`${f} is a folder: give --out a file name, such as ${te.join(f,"saffron-bundle.zip")}`);return Rc(f,[{name:"manifest.json",data:Buffer.from(`${JSON.stringify(u,null,2)}
|
|
264
|
+
`)},...r]),{file:f,manifest:u,proposals:o,missing:l,dropped:d,unreadable:n}}var bs="[a-z0-9-]+",bl=new RegExp(`^proposals/${bs}/${bs}\\.json$`),Og=new RegExp(`^artifacts/${bs}/${bs}/[A-Za-z0-9][A-Za-z0-9._-]*$`),fi=1024**3,hi=8*1024**3,jg="the bundles to import add up to more than 8 GB unzipped",ul=Math.min(hi,zt),gl=100,Ng=1024**2,Mg=6e5;function Fg(e){try{return process.kill(e,0),!0}catch(t){return t.code==="EPERM"}}function mi(e){let t=te.join(e,".saffron","reports");for(let o of Q.existsSync(t)?Q.readdirSync(t):[]){let i=Number(/^run-download-(\d+)-/.exec(o)?.[1]),a=te.join(t,o),c=i?Q.statSync(a,{throwIfNoEntry:!1}):void 0;c&&Date.now()-c.mtimeMs>36e5&&!Fg(i)&&Q.rmSync(a,{recursive:!0,force:!0})}let n=te.join(t,`run-download-${process.pid}-${ui(4).toString("hex")}`),r=Q.mkdirSync(n,{recursive:!0});return{dir:n,remove:()=>{Q.rmSync(n,{recursive:!0,force:!0});for(let o=t;r&&o.startsWith(r);o=te.dirname(o))try{Q.rmdirSync(o)}catch{break}}}}async function Lg(e,t){let n=r=>new z(r?.name==="TimeoutError"?`cannot download ${e}: it did not finish within 10 minutes`:`cannot download ${e}: ${dn(r)}`);try{let r=await fetch(e,{redirect:"follow",signal:AbortSignal.timeout(Mg)});if(!r.ok||!r.body)throw new z(`cannot download ${e}: HTTP ${r.status}`);let s=Q.openSync(t,"w");try{let o=0;for await(let i of r.body){if(o+=i.length,o>hi)throw new z(`cannot download ${e}: it is over 8 GB`);Q.writeSync(s,i)}}finally{Q.closeSync(s)}}catch(r){throw r instanceof z?r:n(r)}}async function Bg(e,t){if(/^https?:\/\//i.test(e)){let s=te.join(t(),`url-${ui(4).toString("hex")}.zip`);return await Lg(e,s),[{source:e,file:s}]}let n=Q.statSync(e,{throwIfNoEntry:!1});if(!n)throw new z(`not found: ${e}`);let r=n.isDirectory()?Q.readdirSync(e,{recursive:!0}).map(String).filter(s=>/^saffron-bundle.*\.zip$/.test(te.basename(s))).sort().map(s=>te.join(e,s)):[e];if(r.length===0)throw new z(`no saffron-bundle*.zip under ${e}`);return r.map(s=>({source:s,file:s}))}function Dg(e,t){let n;try{n=wc(t)}catch(s){throw new z(`${e} is not a Saffron bundle: ${dn(s)}`)}let r=new Map;for(let s of n){if(r.has(s.name))throw new z(`${e} is damaged: it holds ${ie(s.name)} twice`);r.set(s.name,s)}return{source:e,file:t,entries:r}}var Ug=1024*1024;function hl(e,t,n){let r=Tg("sha256"),s=Q.openSync(e,"r");try{let o=Buffer.alloc(Math.max(1,Math.min(Ug,t.size)));for(let i=0;i<t.size;){let a=Q.readSync(s,o,0,Math.min(o.length,t.size-i),t.start+i);if(a===0)throw new Error("the file ends early");let c=o.subarray(0,a);r.update(c),n?.(c),i+=a}}finally{Q.closeSync(s)}return r.digest("hex")}function zg(e){try{return ns(e).sha256}catch{return}}function Hg({source:e,file:t,entries:n}){let r=f=>new z(`${e} ${f}`);for(let f of n.values()){if(f.size>fi||f.compressedSize>fi)throw r(`is damaged: ${ie(f.name)} is too large`);if(f.method!==0&&f.size>Ng&&f.size>gl*f.compressedSize)throw r(`is damaged: ${ie(f.name)} claims to unzip to over ${gl} times its size`)}let s=f=>{let g=n.get(f);if(!g)throw r(`is damaged: ${f} is missing`);let w;try{let R=g.raw();if(R.length!==g.compressedSize)throw new Error("the file ends early");w=g.method===0?R:Eg.inflateRawSync(R,{maxOutputLength:Math.max(g.size,1)})}catch(R){throw r(`is damaged: ${f} cannot be read (${dn(R)})`)}if(w.length!==g.size)throw r(`is damaged: ${f} is truncated`);return w},o=f=>{let g=n.get(f);if(!(!g||g.method!==0)){if(g.compressedSize!==g.size)throw r(`is damaged: ${f} is truncated`);return g}},i=f=>{let g=o(f);if(!g)return rt(s(f));try{return hl(t,g)}catch(w){throw r(`is damaged: ${f} cannot be read (${dn(w)})`)}},a=(f,g,w)=>{let R=o(f),$=()=>new Error(ie(`${e} is damaged: ${f} changed while it was imported`,1/0));if(!R){let S=s(f);if(rt(S)!==w)throw $();Oe(g,S);return}Q.mkdirSync(te.dirname(g),{recursive:!0});let m=`${g}.${process.pid}-${ui(4).toString("hex")}.partial`,v=Q.openSync(m,"w");try{let S;try{S=hl(t,R,x=>{for(let b=0;b<x.length;)b+=Q.writeSync(v,x,b,x.length-b)})}finally{Q.closeSync(v)}if(S!==w)throw $();Q.renameSync(m,g)}catch(S){throw Q.rmSync(m,{force:!0}),S}},c=f=>{try{return JSON.parse(s(f).toString("utf8"))}catch(g){throw g instanceof z?g:r(`is damaged: ${f} is not JSON`)}};if(!n.has("manifest.json"))throw r("is not a Saffron bundle: it has no manifest.json");let l=c("manifest.json");if(!l||l.tool!=="saffron"||typeof l.format!="number")throw r("is not a Saffron bundle");for(let f of["runner","createdAt","repository","branch","commit","headCommit","run","attempt","url"])if(l[f]!==void 0){if(typeof l[f]!="string")throw r(`is damaged: its manifest's ${f} is not text`);if(l[f].length>2048)throw r(`is damaged: its manifest's ${f} is over 2048 characters`);l[f]=ie(l[f],2048)}if(l.format>ys)throw r(`is bundle format ${l.format}, made by saffron-ai ${l.runner}; this saffron-ai (${nt()}) reads format ${ys}. Update saffron-ai to import it`);if(l.format!==ys)throw r(`has bundle format ${l.format}, which this saffron-ai does not read`);if(!Array.isArray(l.proposals)||!Array.isArray(l.evidence))throw r("is damaged: its manifest has no proposal or evidence list");let{shard:d}=l;if(d!==void 0&&!(Number.isInteger(d?.index)&&Number.isInteger(d?.total)&&d.index>=1&&d.index<=d.total))throw r("is damaged: its manifest's shard is not <index> of <total>");let p=new Map;for(let f of l.proposals){if(!f||typeof f.entry!="string"||!bl.test(f.entry)||typeof f.revision!="string")throw r(`is damaged: its manifest lists a proposal at ${ie(JSON.stringify(f?.entry)??"nothing")}`);typeof f.feature=="string"&&(f.feature=ie(f.feature)),typeof f.scenario=="string"&&(f.scenario=ie(f.scenario));let g=s(f.entry);if(rt(g)!==f.revision)throw r(`is damaged: ${f.entry} is not the proposal its manifest names`);p.set(f.entry,g)}for(let f of l.evidence){if(!f||typeof f.entry!="string"||!Og.test(f.entry)||typeof f.sha256!="string")throw r(`is damaged: its manifest lists evidence at ${ie(JSON.stringify(f?.entry)??"nothing")}`);if(i(f.entry)!==f.sha256)throw r(`is damaged: ${f.entry} is not the file its manifest names`)}let u;if(l.report!==void 0){if(l.report!=="report.json")throw r(`is damaged: its manifest names the report ${JSON.stringify(l.report)}`);let f=c("report.json"),g=oi(f);if(g)throw r(`is damaged: report.json ${g}`);try{qt(f)}catch(w){throw r(`is damaged: report.json cannot be shown (${dn(w)})`)}u=f}if(d&&!(u&&ms(u)))throw r("is damaged: it is a shard's bundle without the shard's report");return{source:e,manifest:l,report:u,files:p,read:s,write:a}}function qg(e,t){let n=t.headCommit??t.commit,r=At(e,["rev-parse","HEAD"]);if(!n||!r||r===n)return;let s=`${n.slice(0,7)}${t.branch?` on ${t.branch}`:""}`;if(!/^[0-9a-f]{7,64}$/i.test(n)||!At(e,["rev-parse","--verify","--quiet",`${n}^{commit}`]))return`this checkout is at ${r.slice(0,7)}, and the bundle was made at ${s}, which this clone does not have: fetch it to review against the same files`;let[o,i]=(At(e,["rev-list","--left-right","--count",`HEAD...${n}`,"--"])??"").split(/\s+/).map(Number),a=[o?`${o} commit${o===1?"":"s"} ahead of`:"",i?`${i} commit${i===1?"":"s"} behind`:""].filter(Boolean).join(" and ");return`this checkout (${r.slice(0,7)}) is ${a||"not at"} the commit the bundle was made at, ${s}`}function pi(e){let{repository:t,branch:n,commit:r,headCommit:s,run:o,attempt:i,url:a}=e;return Object.fromEntries(Object.entries({repository:t,branch:n,commit:r,headCommit:s,run:o,attempt:i,url:a}).filter(([,c])=>typeof c=="string"))}var vl=e=>te.join(e,".saffron","reports","imported.json");function _t(e){let t;try{t=JSON.parse(Q.readFileSync(vl(e),"utf8"))}catch{return{}}return!t||typeof t!="object"||Array.isArray(t)?{}:Object.fromEntries(Object.entries(t).filter(([,n])=>!!n&&typeof n.revision=="string"&&!!n.from&&typeof n.from=="object"))}function Jg(e,t){try{return Q.readFileSync(e).equals(t)}catch{return!1}}function Sl(e,t,n,r,s){let o;try{o=bo(t,n)}catch($){let m={file:`.saffron/${n}`,feature:"",scenario:""},v=`cannot be read: ${$ instanceof le?$.problem:dn($)}`;return{verdict:{...m,outcome:"refused",reason:v},shown:m,file:te.join(e,".saffron",n),revision:"",verified:!1}}let{proposal:i,revision:a}=o,{feature:c,scenario:l}=i.cache,d=qe(e,c,l),p={file:Ae(e,d),feature:ie(c),scenario:ie(l)},u=$=>({verdict:$,shown:p,file:d,revision:a,verified:i.meta.verified===!0}),f=$=>u({...p,outcome:"refused",reason:$});if(!i.meta.bound)return f("carries no binding (a runner older than 0.8.5 filed it), so nothing can tell whether it fits this checkout");let g=s?`. ${s}`:"";if(!Q.existsSync(te.join(e,c)))return f(`is for ${p.feature}, which is not in this checkout${g}`);let w=Lt(se(e,c,l));if(w&&ir(w)===ir(i.cache))return u({...p,outcome:"unchanged",reason:"already accepted here: the committed recording is this proposal's"});let R;try{R=We(e,i,r)}catch($){R=`cannot be checked: ${dn($)}`}return R?f(`does not fit this checkout: ${ie(R,1e3)}${g}`):u()}function xl(e,t){if(Q.existsSync(e.file)){if(Ge(e.file)===e.revision)return{...e.shown,outcome:"unchanged"};if(!t)return{...e.shown,outcome:"refused",reason:"differs from the proposal already pending here for this scenario: review that one first (saffron diff), or import with --overwrite to replace it"}}}function Wg(e,t,n,r,s,o){let i=Sl(e,t,n,r,o);if(i.verdict)return i.verdict;Q.mkdirSync(te.dirname(i.file),{recursive:!0});try{return In(i.file,()=>{let a=xl(i,s);return a||(Oe(i.file,t),{...i.shown,outcome:"imported"})})}catch(a){if(a instanceof rn)return{...i.shown,outcome:"refused",reason:"is being decided by another process. Try again."};throw a}}async function $l(e,t,n,r={}){let s;try{let o=await kl(t,()=>(s??=mi(e)).dir,r.describe);return Gg(e,o,n,r)}finally{s?.remove()}}async function kl(e,t,n){let r=[];for(let s of e)for(let o of await Bg(s,t))r.push(Dg(n?.(o.source)??o.source,o.file));if(r.reduce((s,o)=>s+[...o.entries.values()].reduce((i,a)=>i+a.size,0),0)>hi)throw new z(jg);return r.map(Hg)}function Vg(e){let t=[...new Set(e)].sort((n,r)=>n-r);return t.length>1?`${t.slice(0,-1).join(", ")} and ${t[t.length-1]}`:String(t[0]??"")}function Rl(e,t,n){let r=t;if(r.length===0)throw new z("no bundle to import");let s=new Set,o=vs(e);for(let u of r){if(n.run!==void 0&&u.manifest.run!==n.run)throw new z(`${u.source} was made by ${u.manifest.run?`run ${u.manifest.run}`:"no CI run"}, not by ${n.run}, the run it was downloaded from. If that is expected, download the artifact and import the file`);let f=u.manifest.repository;if(!f&&!n.allowUnknownRepository)throw new z(`${u.source} does not say which repository it comes from (it was made outside a git checkout). Import it with --allow-unknown-repository to check it by its proposals' bindings only`);if(!f)s.add(`${u.source} does not say which repository it comes from: it is checked by its proposals' bindings only`);else if(o.length===0)s.add(`this project has no git remote to compare ${f} with: the bundle is checked by its proposals' bindings only`);else if(!o.includes(f)){let g=R=>R.slice(R.indexOf("/")),w=o.find(R=>g(R)===g(f));if(!w)throw new z(`${u.source} comes from ${f}, and this checkout is ${o.join(", ")}. Import it into a checkout of ${f}`);s.add(`${r.length===1?"the bundle comes":"the bundles come"} from ${f}, and this checkout is ${w}: the same repository under another host name (an SSH host alias, or a mirror), so each proposal is checked by its binding`)}}if(r.length>1){let u=new Set(r.map(m=>m.manifest.run??""));if(u.size>1)throw new z(`these bundles come from different runs (${[...u].map(m=>m||"none recorded").join(", ")}). Import one run at a time`);let f=m=>Number(m.manifest.attempt)||0,g=m=>m.manifest.shard?.index??0,w=new Map;for(let m of r){let v=w.get(g(m));(!v||f(m)>f(v))&&w.set(g(m),m)}let R=r.filter(m=>f(m)<f(w.get(g(m))));for(let m of R){let v=m.manifest.shard?`shard ${m.manifest.shard.index}`:"the whole run";s.add(`${m.source} is ${v} of attempt ${f(m)}, which attempt ${f(w.get(g(m)))} ran again: left out`)}r=r.filter(m=>!R.includes(m));let $=r.filter(m=>!m.manifest.shard);if($.length>0&&r.length>1)throw new z(`${$.map(m=>m.source).join(", ")} ${$.length===1?"holds a whole run, not a shard of one":"hold whole runs, not shards of one"}: import one run at a time`)}let i,a=r.filter(u=>u.report);if(r.length===1&&a.length===1){let{shard:u,history:f,...g}=a[0].report;i=g}else if(a.length>0)try{let u=ws(a.map(f=>({file:f.source,report:f.report})),{partial:!0});if(u.replaced.length>0)throw new ln(`${u.replaced.join(", ")} holds a shard that another bundle of the same attempt holds too`);i=u.report}catch(u){throw u instanceof ln?new z(`these shard bundles do not belong together: ${u.message}`):u}let c=r[0].manifest,l=qg(e,c);l&&s.add(l),c.headCommit&&c.commit&&s.add(`the run tested ${c.commit.slice(0,7)}, a pull request's merge commit, not the head of ${c.branch??"its branch"} (${c.headCommit.slice(0,7)}): each proposal is checked against this checkout by its binding`);let d=c.headCommit??c.commit,p=l&&d?`The bundle was made at ${d.slice(0,7)}${c.branch?` on ${c.branch}`:""}: check that out and import again`:void 0;if(i){let u=r.flatMap(f=>f.manifest.shard?[f.manifest.shard]:[]);i.imported={...pi(c),...u.length>0?{shards:{imported:u.map(f=>f.index).sort((f,g)=>f-g),total:u[0].total}}:{}}}return{bundles:r,report:i,warnings:s,hint:p}}function Tl(e,t,n){let r={written:0,unchanged:0},s=new Map;for(let o of n?.scenarios??[])for(let i of o.evidence??[])s.set(te.posix.dirname(i.file),{feature:o.feature,scenario:o.scenario});for(let o of t)for(let{entry:i,sha256:a}of o.manifest.evidence){let c=te.join(e,".saffron",...i.split("/"));if(zg(c)===a){r.unchanged++;continue}o.write(i,c,a),r.written++;let l=s.get(`.saffron/${te.posix.dirname(i)}`);l&&Q.writeFileSync(te.join(te.dirname(c),nn),JSON.stringify(l,null,2))}return r}function El(e){let t=new Map;for(let s of e)for(let o of s.manifest.proposals){let i=t.get(o.entry)??{revisions:new Set,shards:[]};i.revisions.add(o.revision),s.manifest.shard&&i.shards.push(s.manifest.shard.index),t.set(o.entry,i)}let n=[],r=new Set;for(let s of e)for(let o of s.manifest.proposals){let i=t.get(o.entry);if(i.revisions.size===1){n.push({b:s,p:o});continue}r.has(o.entry)||(r.add(o.entry),n.push({b:s,p:o,conflict:{file:`.saffron/${o.entry}`,feature:typeof o.feature=="string"?o.feature:"",scenario:typeof o.scenario=="string"?o.scenario:"",outcome:"refused",reason:`is a conflict between the run's shards: shards ${Vg(i.shards)} filed different proposals for this scenario (a Scenario Outline's rows split across shards), so no proposal for it was imported. Run the scenario again on one machine and import that run`}}))}return n}function Gg(e,t,n,r){let{bundles:s,report:o,warnings:i,hint:a}=Rl(e,t,r),c=Tl(e,s,o),l;if(o){for(let g of o.scenarios){let w=typeof g.proposalFile=="string"?g.proposalFile.split(/[\\/]/).slice(-2):[];bl.test(`proposals/${w.join("/")}`)&&(g.proposalFile=`.saffron/proposals/${w.join("/")}`)}let f=te.join(e,".saffron","reports","latest.json");Jg(f,Buffer.from(`${JSON.stringify(o,null,2)}
|
|
265
|
+
`))?l="unchanged":(fr(e,o),l="written")}let d=[],p=_t(e),u=JSON.stringify(p);for(let{b:f,p:g,conflict:w}of El(s)){if(w){d.push(w);continue}let R=Wg(e,f.files.get(g.entry),g.entry,n,r.overwrite===!0,a),$=R.reason===void 0?R:{...R,reason:ie(R.reason,1/0)};d.push($),($.outcome==="imported"||$.outcome==="unchanged"&&!$.reason)&&(p[$.file]={revision:g.revision,from:{...pi(f.manifest),...f.manifest.shard?{shard:f.manifest.shard}:{}}})}for(let[f,g]of Object.entries(p)){let w;try{w=Ge(te.join(e,f))}catch{w=void 0}w!==g.revision&&delete p[f]}return JSON.stringify(p)!==u&&Oe(vl(e),`${JSON.stringify(p,null,2)}
|
|
266
|
+
`),{bundles:s.map(f=>{let{runner:g,createdAt:w,shard:R}=f.manifest;return{source:f.source,runner:g,createdAt:w,origin:pi(f.manifest),...R?{shard:{index:R.index,total:R.total}}:{}}}),warnings:[...i],proposals:d,evidence:c,...l?{report:l}:{}}}async function Al(e,t,n,r,s={}){let o=await kl(n,()=>{throw new z("a viewed run is read from its downloaded folder")},s.describe),{bundles:i,report:a,warnings:c,hint:l}=Rl(e,o,{run:s.run});Tl(t,i,a);let d=El(i).map(({b:p,p:u,conflict:f})=>{if(f)return{feature:f.feature,scenario:f.scenario,verified:!1,importable:!1,reason:f.reason};let g=Sl(e,p.files.get(u.entry),u.entry,r,l),w=g.verdict??xl(g,!1),{feature:R,scenario:$}=g.shown,m={feature:R||(typeof u.feature=="string"?u.feature:""),scenario:$||(typeof u.scenario=="string"?u.scenario:""),verified:g.verified};return w?{...m,importable:!1,reason:ie(w.reason??"already pending here",1/0)}:{...m,importable:!0}});return{report:a,warnings:[...c],proposals:d}}var Kg=/^\s*StepSet:/;function Jt(e,t){return gt.relative(e,t).split(gt.sep).join("/")}function Yg(e,t){let n={green:0,yellow:1,red:2};return!e||n[t]>n[e]?t:e}function gr(e,t,n={}){let r=t.features??"features",s=gt.isAbsolute(r)?r:gt.join(e,r),o=gt.join(e,".saffron","reports","latest.json"),i=gt.join(e,".saffron","reports","latest.html"),a;try{a=JSON.parse(Ye.readFileSync(o,"utf8"))}catch{a=void 0}let c=new Map;for(let h of a?.scenarios??[])c.set(h.feature,[...c.get(h.feature)??[],h]);let l=new Map,d;try{l=Rt(tn(s),e)}catch(h){d=h instanceof Error?h.message:String(h)}let p=Ye.existsSync(s)?[...Ye.readdirSync(s,{recursive:!0})].map(String).filter(Tt).map(h=>gt.join(s,h)).sort():[],u=[],f=new Map,g=new Map,w={parsedFiles:new Set,owned:new Set,unreadable:d?[d]:[]};for(let h of p){let y=Jt(e,h),A="";try{A=Ye.readFileSync(h,"utf8")}catch(_){u.push({path:y,name:y,scenarios:[],stepSets:0,error:String(_)}),w.unreadable.push(`${y}: ${String(_)}`);continue}let E=A.split(`
|
|
267
|
+
`),P=E.filter(_=>Kg.test(_)).length,O=E.find(_=>/^\s*Feature:/.test(_)),M=O?O.replace(/^\s*Feature:\s*/,"").trim():y,T;try{T=Be(h,e,l)}catch(_){u.push({path:y,name:M,scenarios:[],stepSets:P,error:d??(_ instanceof Error?_.message:String(_))}),w.unreadable.push(`${y}: ${_ instanceof Error?_.message:String(_)}`);continue}w.parsedFiles.add(h),g.set(h,T);let C=new Set(T.map(_=>_.name)),j=new Map;for(let _ of(T[0]&&c.get(T[0].featurePath))??[]){let F=an(_,D=>C.has(D));j.set(F,Yg(j.get(F),_.status))}let L=new Map;for(let _ of T){w.owned.add(Ft(e,_.featurePath,_.displayName));let F=L.get(_.name);if(F){F.rows+=1;for(let W of _.tags)F.tags.includes(W)||(F.tags.push(W),f.set(W,(f.get(W)??0)+1));continue}let D=se(e,_.featurePath,_.name);w.owned.add(D),w.owned.add(qe(e,_.featurePath,_.name));let H="missing",V;if(Ye.existsSync(D))try{let W=Pe(D);H=W&&sr(_,W)?"valid":"stale"}catch(W){H="invalid",V=W instanceof le?W.problem:W instanceof Error?W.message:String(W)}L.set(_.name,{name:_.name,line:_.line,tags:[..._.tags],outline:Object.keys(_.params).length>0,rows:1,cached:H!=="missing",cacheState:H,...V?{cacheProblem:V}:{},proposal:Ye.existsSync(qe(e,_.featurePath,_.name)),lastStatus:j.get(_.name)});for(let W of _.tags)f.set(W,(f.get(W)??0)+1)}u.push({path:y,name:M,scenarios:[...L.values()],stepSets:P})}let R=[],$=_t(e),m=ft(e,(h,y)=>R.push({file:Jt(e,h),problem:y})).map(({file:h,proposal:y,revision:A})=>({file:Jt(e,h),feature:y.cache.feature,scenario:y.cache.scenario,mode:y.meta.mode,createdAt:y.meta.createdAt,verified:y.meta.verified,proofError:y.meta.proofError,stale:y.meta.bound?We(e,y,l):void 0,unbound:y.meta.bound?void 0:!0,revision:A,adaptations:y.meta.adaptations??[],narrative:y.meta.narrative,suggestedFeatureEdit:y.meta.suggestedFeatureEdit,recordedFor:y.meta.recordedFor,aiCalls:y.meta.usage?.aiCalls??0,costUsd:y.meta.usage&&Ve({costUsd:y.meta.usage.costUsd??0,aiCalls:y.meta.usage.aiCalls})?y.meta.usage.costUsd:void 0,importedFrom:$[Jt(e,h)]?.revision===A?$[Jt(e,h)].from:void 0})).sort((h,y)=>h.file.localeCompare(y.file)),v;try{v=dt(e,r,l,g)}catch{v={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0}}let S=Qg(e),x=cr(e,Math.max(100,n.historyLimit??30)),b=x.filter(h=>!h.partial).slice(-(n.historyLimit??30)).map(h=>({startedAt:h.startedAt,green:h.green,yellow:h.yellow,red:h.red,passRate:on(h,h.scenarios),aiCalls:h.aiCalls,costUsd:Ve(h)?h.costUsd:void 0})),k=gt.join(e,"saffron.config.json");return{tool:"saffron",version:nt(),projectRoot:e,packageInstalled:Ye.existsSync(gt.join(e,"node_modules","saffron-ai")),config:{file:Ye.existsSync(k)?"saffron.config.json":void 0,effective:qa(t)},features:u,tags:[...f.entries()].map(([h,y])=>({tag:h,scenarios:y})).sort((h,y)=>h.tag.localeCompare(y.tag)),proposals:m,unreadableProposals:R,lastRun:a?{startedAt:a.startedAt,finishedAt:a.finishedAt,totals:{...a.totals,costUsd:Ve(a.totals)?a.totals.costUsd:void 0},reportHtml:Ye.existsSync(i)?Jt(e,i):void 0,...a.browsers?.length?{browsers:a.browsers}:{},attention:a.scenarios.filter(h=>h.status!=="green").map(h=>({feature:h.feature,scenario:h.scenario,baseScenario:an(h,y=>u.some(A=>A.path===h.feature&&A.scenarios.some(E=>E.name===y))),status:h.status,...h.browser?{browser:h.browser}:{},error:h.status==="red"?h.error?.split(`
|
|
268
|
+
`)[0]:void 0,failedStep:(h.steps??[]).find(y=>y.status==="failed")?.gherkin??h.evidence?.find(y=>y.step)?.step,evidence:(h.evidence??[]).filter(y=>y.kind==="trace"?Ye.existsSync(gt.join(e,y.file)):An(e,y)!==void 0).map(y=>({kind:y.kind,file:y.file,...y.step?{step:y.step}:{},...y.sha256?{sha256:y.sha256}:{}}))})).sort((h,y)=>h.status===y.status?0:h.status==="red"?-1:1)}:void 0,history:b,quality:tl(u,x),vocabulary:{steps:v.steps.length,recorded:v.steps.filter(h=>h.status==="recorded").length,unrecorded:v.steps.filter(h=>h.status==="unrecorded").length,stepSets:v.stepSets.length,divergent:v.steps.filter(h=>h.status==="divergent").map(h=>h.text),duplicateWordings:S.slice(0,50)},data:(()=>{let h=Me(),y=[],A=new Set;for(let[E,P]of g)for(let O of P)for(let M of $t(ze(O)))if(!A.has(M)){A.add(M);try{jr(M)}catch(T){y.push({ref:M,reason:T instanceof Error?T.message:String(T),feature:Jt(e,E),scenario:O.name})}}return{dir:Jt(e,h.dir),env:h.env,files:Ye.existsSync(h.dir)?Ye.readdirSync(h.dir).filter(E=>/\.(json|csv)$/.test(E)).sort():[],unresolved:y}})(),orphans:(()=>{let h=Xr(e,r,w);return h.unreadable.length?[]:h.orphans})()}}function Il(e){let t=e.features.reduce((o,i)=>o+i.scenarios.length,0),n=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="valid").length,0),r=e.features.reduce((o,i)=>o+i.scenarios.filter(a=>a.cacheState==="stale"||a.cacheState==="invalid").length,0);console.log(`saffron ${e.version} \xB7 ${e.features.length} feature file(s) \xB7 ${t} scenario(s), ${n} replay from cache`+(r?`, ${r} stale (\u26A0) and will re-record`:""));for(let o of e.features){let i=o.scenarios.map(a=>a.proposal?"\u25D0":a.cacheState==="valid"?"\u25CF":a.cacheState==="missing"?"\u25CB":"\u26A0").join("");console.log(` ${o.path} ${i}${o.error?` \u2717 ${o.error.split(`
|
|
269
|
+
`)[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(i=>i.cacheProblem).map(i=>`\u26A0 recording of ${o.path} \u203A ${i.name} cannot be read: ${i.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 Zg=/^(<[^>]+>|\{env:[^}]+\}|\{date[^}]*\})$/;function _l(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 Qg(e){let t;try{t=lt(e)}catch{return[]}let n=new Map;for(let r of t.steps.values()){if(r.actions.length===0)continue;let s=qr(r.gherkin).pattern,o=[...r.gherkin.matchAll(/"([^"]*)"/g)].map(p=>p[1]),i=p=>p!==void 0&&(o.includes(p)||Zg.test(p))?"\xABarg\xBB":p,a=r.actions.map(p=>{let{pageFingerprint:u,value:f,promptText:g,...w}=p;return{...w,value:i(f),promptText:i(g)}}),c=JSON.stringify(a),l=r.actions.map(p=>`${p.action}${_l(p.target)?` ${_l(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 Cl(e,t){let n=new Xg({name:"saffron",version:nt()},{capabilities:{tools:{}}});n.setRequestHandler(nh,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 (and, for one imported from CI with `saffron import`, the run, branch and commit it came from, as importedFrom), 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(th,async r=>rh(r.params.name,r.params.arguments,e,t)),await n.connect(new eh)}function rh(e,t,n,r){let s=o=>({content:[{type:"text",text:JSON.stringify(o,null,2)}]});if(e==="search_steps"){let o=dt(n,r),i=typeof t?.query=="string"?t.query:void 0,a=i?Jr(o,i):o;return s({steps:a.steps,scannedFiles:a.scannedFiles,scannedCaches:a.scannedCaches})}if(e==="list_step_sets"){let o=dt(n,r);return s({stepSets:o.stepSets})}return e==="project_status"?s(gr(n,pe(n))):{content:[{type:"text",text:`Unknown tool: ${e}`}],isError:!0}}import{query as sh}from"@anthropic-ai/claude-agent-sdk";function Pl(e,t){let n=t.steps.map(s=>`- [${s.status}] ${s.keyword} ${s.text}`).join(`
|
|
265
270
|
`),r=t.stepSets.map(s=>`- StepSet ${s.name}
|
|
266
271
|
${s.steps.map(o=>` ${o}`).join(`
|
|
267
272
|
`)}`).join(`
|
|
268
273
|
`);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:
|
|
269
274
|
${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(`
|
|
270
|
-
`)}var
|
|
271
|
-
`}import
|
|
272
|
-
|
|
273
|
-
`);return{
|
|
275
|
+
`)}var Ol="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 Nl(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:Ol,prompt:Pl(e,t),model:n,maxTurns:1,mcpServers:{},mcpToken:"",usage:s,lastOutput:a=>{o=a},finished:()=>!1}),{content:jl(o),usage:s};let i=sh({prompt:Pl(e,t),options:{model:n,maxTurns:1,permissionMode:"bypassPermissions",persistSession:!1,settingSources:[],strictMcpConfig:!0,settings:{disableClaudeAiConnectors:!0},allowedTools:[],systemPrompt:Ol}});for await(let a of i)if(a.type==="assistant")for(let c of a.message.content)c.type==="text"&&(o+=c.text);else a.type==="result"&&(s.aiCalls=a.num_turns,s.costUsd=a.total_cost_usd,"usage"in a&&a.usage&&(s.inputTokens=a.usage.input_tokens??0,s.outputTokens=a.usage.output_tokens??0,s.cacheReadTokens=a.usage.cache_read_input_tokens??0,s.cacheCreationTokens=a.usage.cache_creation_input_tokens??0));return{content:jl(o),usage:s}}function jl(e){return e.replace(/^\s*```(?:gherkin|saffron)?\s*\n/,"").replace(/\n```\s*$/,"").trim()+`
|
|
276
|
+
`}import Ze from"node:fs";import{fileURLToPath as Wt,pathToFileURL as bi}from"node:url";import de from"node:path";import{createConnection as dh,ProposedFeatures as fh,TextDocuments as ph,TextDocumentSyncKind as uh,DidChangeWatchedFilesNotification as gh,WatchKind as hh,CompletionItemKind as Vt,InsertTextFormat as ql,DiagnosticSeverity as mr,MarkupKind as Jl}from"vscode-languageserver/node";import{TextDocument as mh}from"vscode-languageserver-textdocument";import Ml from"node:path";var hr=class extends Error{},wi=new Set(["and","or","not","(",")"]);function oh(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 i=r.at(-1),a=t[o+1];n(s)&&(i===void 0||n(i)||i==="("||a===void 0||n(a)||a===")")||r.push(s)}),r.map(s=>n(s)?"or":wi.has(s.toLowerCase())?s.toLowerCase():s.startsWith("@")?s:`@${s}`)}function ih(e){let t=oh(e),n=0,r=()=>t[n],s=d=>{throw new hr(`--filter "${e}": ${d}`)},o=!t.some(d=>d==="and"||d==="not"||d==="("||d===")"),i=()=>{let d=a();for(;r()==="or"||o&&r()!==void 0&&r()!==")";){r()==="or"&&n++;let p=a(),u=d;d=f=>u(f)||p(f)}return d},a=()=>{let d=c();for(;r()==="and";){n++;let p=c(),u=d;d=f=>u(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 u=>!p(u)}if(d==="("){n++;let p=i();return r()!==")"?s("missing a closing parenthesis"):(n++,p)}return wi.has(d)?s(`expected a tag, found "${d}"`):(n++,p=>p.has(d))},l=i();return n<t.length?s(wi.has(t[n])?`unexpected "${t[n]}"`:`unexpected "${t[n]}": with and, not or parentheses, join every tag with and/or`):l}function Fl(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(ih);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 Ll(e,t){return t?e.filter(n=>t.matches(n.tags)):e}function Bl(e,t){return[...t??[],e]}var ah=new RegExp(`^(.+?(?:${oo.map(e=>e.replace(".","\\.")).join("|")})):(.+)$`),yi=/^\d+(?::\d+)*$/;function Dl(e){let t=[],n=new Map;for(let r of e){let s=ah.exec(r);if(!s){t.push(r);continue}let o=yi.test(s[2])?s[2].split(":").map(Number):[s[2]];n.set(s[1],[...n.get(s[1])??[],...o])}return{plain:t,picks:n}}var fn=class extends Error{};function Ul(e,t){let n=o=>`${o.featurePath}
|
|
277
|
+
${o.name}`,r=new Set(e.map(n)),s=new Set(e);return[...e,...t.filter(o=>!s.has(o)&&r.has(n(o)))]}function zl(e){let t=e.length>10?[...e.slice(0,10),`\u2026 (${e.length} in all)`]:e;return t.length===1?t[0]:`${t.slice(0,-1).join(", ")} and ${t.at(-1)}`}function ch(e,t,n,r){let s=n.filter(c=>c.kind==="scenario").map(c=>String(c.line)),o=s.length===0?" It has no scenarios.":` Its scenario${s.length===1?" starts at line":"s start at lines"} ${zl(s)}.`,i=c=>{throw new fn(`${e}:${t} ${c}.${o}`)};t<1&&i("is not a line: lines count from 1"),t>r&&i(`is past the end of the file, which has ${r} lines`);let a=n.filter(c=>c.start<=t).at(-1);return a?(a.kind==="background"&&i("is in the Background, which runs before every scenario, not in a scenario"),a.kind==="stepSet"&&i(`is in StepSet "${a.name}", which runs where a scenario invokes it, not in a scenario`),a.kind==="rule"&&i(`is the header of Rule "${a.name}", not a scenario`),{line:a.rows.includes(t)?t:a.line,block:a}):i("is in the feature's header, not in a scenario")}function lh(e,t,n){let r=n.filter(i=>i.kind==="scenario"),s=r.find(i=>i.name===t);if(s)return{line:s.line,block:s};let o=r.map(i=>`"${i.name}"`);throw new fn(`${e}:${t} names no scenario.${o.length===0?" It has no scenarios.":` Its scenario${o.length===1?" is":"s are"} ${zl(o)}.`}`)}function Hl(e,t,n,r){let s=new Map,o=new Map;for(let[a,c]of n){let l=Ml.resolve(t,a);if(r.has(l))continue;let{blocks:d,lineCount:p}=Ia(l,t),u=Ml.relative(t,l),f=s.get(u)??new Map;for(let g of c){if(typeof g=="string"&&!d.some(m=>m.kind==="scenario"&&m.name===g)&&e.some(m=>m.featurePath===u&&m.displayName===g)){o.set(u,(o.get(u)??new Set).add(g));continue}let{line:R,block:$}=typeof g=="number"?ch(a,g,d,p):lh(a,g,d);f.set(R,`${a}:${g} is in Scenario Outline "${$.name}", whose Examples have no rows yet: nothing to run`)}s.set(u,f)}let i=e.filter(a=>{let c=s.get(a.featurePath);return!c||c.has(a.line??0)||c.has(a.exampleLine??0)||o.get(a.featurePath)?.has(a.displayName)===!0});for(let[a,c]of s)for(let[l,d]of c)if(!i.some(p=>p.featurePath===a&&(p.line===l||p.exampleLine===l)))throw new fn(`${d}.`);return i}var Wl=/^(\s*)(Given|When|Then|And|But|\*)\s+(.+?)\s*$/,wh=/^\s*StepSet:\s*(.+?)\s*$/,vi=/^(\s*)(StepSet)\s+([^\s:].*?)\s*$/,Si={recorded:"\u25CF recorded: replays at zero tokens",divergent:"\u25CF divergent: same text, different recordings",unrecorded:"\u25CB written, not recorded yet"};function yh(e,t){let n=i=>new Set(i.toLowerCase().replace(/"[^"]*"/g,'"x"').split(/[^a-z0-9"<>]+/).filter(Boolean)),r=n(e),s=n(t);if(r.size===0||s.size===0)return 0;let o=0;for(let i of r)s.has(i)&&o++;return o/(r.size+s.size-o)}function Vl(e){try{return JSON.parse(Ze.readFileSync(de.join(e,"saffron.config.json"),"utf8"))}catch{return{}}}function Gl(e){try{let t=JSON.parse(Ze.readFileSync(de.join(e,"saffron.config.json"),"utf8"));return typeof t.features=="string"?t.features:void 0}catch{return}}function Fn(e,t){let n=de.dirname(e),r=de.resolve(t);for(;;){if(Ze.existsSync(de.join(n,"saffron.config.json")))return n;if(n===r||de.dirname(n)===n)return t;n=de.dirname(n)}}function Ql(e,t,n={}){let r=e,s=t,o=e,i=!1,a=!1,c=h=>{if(!h.startsWith("file:"))return;let y=Fn(Wt(h),o);if(y===r)return;m(y);let A=R.get(y);if(!A||!i||$.has(y))return S();({vocab:f,registry:g,files:w}=A);for(let E of u.all())d(E)&&k(E)},l=h=>{if(!h.startsWith("file:"))return;let y=Wt(h),A=Fn(y,o);R.get(A)?.files.has(y)||$.add(A)},d=h=>!h.uri.startsWith("file:")||Fn(Wt(h.uri),o)===r,p=dh(fh.all,process.stdin,process.stdout),u=new ph(mh),f={steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},g=new Map,w=new Set,R=new Map,$=new Set,m=h=>{r=h,s=Gl(h)??(Ze.existsSync(de.join(h,"saffron.config.json"))?"features":t);try{let y=Vl(h);at(h,y.dataDir,process.env.SAFFRON_ENV??y.env)}catch(y){p.console.warn(`saffron vocabulary rebuild: ${String(y)}`)}},v,S=()=>{try{let h=de.isAbsolute(s)?s:de.join(r,s),y=Vl(r);at(r,y.dataDir,process.env.SAFFRON_ENV??y.env),g=Rt(tn(h),r),f=dt(r,s,g),w=new Set(uo(h)),R.set(r,{vocab:f,registry:g,files:w}),$.delete(r)}catch(h){p.console.warn(`saffron vocabulary rebuild: ${String(h)}`),{vocab:f,registry:g,files:w}=R.get(r)??{vocab:{steps:[],stepSets:[],scannedFiles:0,scannedCaches:0},registry:new Map,files:new Set}}for(let h of u.all())d(h)&&k(h)},x=()=>{clearTimeout(v),v=setTimeout(()=>{let h=r,y=new Set(u.all().filter(A=>A.uri.startsWith("file:")).map(A=>Fn(Wt(A.uri),o)));for(let A of R.keys())!y.has(A)&&A!==h&&R.delete(A);y.delete(h);for(let A of[...y,h])m(A),S()},300)};function b(h){return f.steps.find(y=>y.text===h)?.status}function k(h){let y=h.getText().split(`
|
|
278
|
+
`),A=kh(y);if(!h.uri.endsWith(".saffron")){p.sendDiagnostics({uri:h.uri,diagnostics:A});return}let E=new Map;for(let P=0;P<y.length;P++){let O=y[P],M={start:{line:P,character:0},end:{line:P,character:O.length}},T=O.match(vi);T&&!g.has(T[3])&&A.push({range:M,severity:mr.Error,source:"saffron",message:`StepSet "${T[3]}" is not defined anywhere in the project.`});let C=O.match(wh);if(C){let _=g.get(C[1]),F=de.relative(r,Wt(h.uri)).replace(/\\/g,"/");(E.has(C[1])||_&&_.file.replace(/\\/g,"/")!==F)&&A.push({range:M,severity:mr.Error,source:"saffron",message:`StepSet "${C[1]}" is defined twice. Set names are project-wide. If this line was meant to INVOKE the set, remove the colon: "StepSet ${C[1]}".`}),E.set(C[1],P)}let j=O.trim();if(j&&!T&&!C){for(let[_]of g)if(j.toLowerCase()===_.toLowerCase()){A.push({range:M,severity:mr.Warning,source:"saffron",message:`Did you mean "StepSet ${_}"? Invoking a step set needs the StepSet keyword, like a step needs Given/When/Then.`});break}}let L=O.match(Wl);if(L&&b(L[3])!=="recorded"){for(let _ of f.steps)if(!(_.text===L[3]||_.status==="unrecorded")&&yh(_.text,L[3])>=.7){A.push({range:{start:{line:P,character:O.indexOf(L[3])},end:{line:P,character:O.length}},severity:mr.Information,source:"saffron",message:`Similar to the ${_.status} step "${_.text}" (\xD7${_.usage}). Reusing the exact wording replays at zero tokens; a new wording costs a fresh AI recording.`});break}}}p.sendDiagnostics({uri:h.uri,diagnostics:A})}p.onInitialize(h=>{if(n.rootFromClient){let y=h.workspaceFolders?.[0]?.uri??h.rootUri??void 0;if(y&&y.startsWith("file:")){let A=Wt(y);Ze.existsSync(A)&&(o=r=A,s=Gl(A)??t)}}return i=h.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration===!0,a=h.initializationOptions?.runLenses===!0,S(),{capabilities:{textDocumentSync:uh.Incremental,completionProvider:{triggerCharacters:[" ",":","."]},definitionProvider:!0,hoverProvider:!0,...a?{codeLensProvider:{resolveProvider:!1}}:{}}}}),p.onCodeLens(h=>{let y=u.get(h.textDocument.uri);if(!a||!y||!y.uri.startsWith("file:"))return[];let A=Wt(y.uri),E=vh(A,o);return E?!A.endsWith(".saffron")&&!Ze.existsSync(de.join(E,"saffron.config.json"))?[]:Sh(A,y.getText(),E):[]}),p.onInitialized(()=>{if(!i)return;let h=[...["**/*.saffron","**/*.feature","**/saffron.config.json","**/.saffron/cache/**","**/data/**"].map(y=>({globPattern:y})),{globPattern:"**",kind:hh.Delete}];p.client.register(gh.type,{watchers:h}).catch(()=>{i=!1})}),p.onCompletion(h=>{let y=u.get(h.textDocument.uri);if(!y)return[];c(y.uri);let A=y.getText({start:{line:h.position.line,character:0},end:h.position}),E=Rh(A);return E||Th(y.uri,A,h.position.line,f)}),p.onDefinition(h=>{let y=u.get(h.textDocument.uri);if(!y)return;c(y.uri);let A=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),E=Kl(A,h.position.character);if(E){let C=$h(E.ref);return C?{uri:bi(C.file).href,range:{start:{line:C.line,character:0},end:{line:C.line,character:0}}}:void 0}let P=Yl(A,h.position.character);if(P){let C=Zl(r,P.name);return C?{uri:bi(C.file).href,range:{start:{line:C.line,character:0},end:{line:C.line,character:0}}}:void 0}let O=A.match(vi);if(!O)return;let M=g.get(O[3]);return M?{uri:bi(de.join(r,M.file)).href,range:{start:{line:M.line-1,character:0},end:{line:M.line-1,character:0}}}:void 0}),p.onHover(h=>{let y=u.get(h.textDocument.uri);if(!y)return;c(y.uri);{let O=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),M=Kl(O,h.position.character);if(M)return{contents:{kind:"markdown",value:Xl(M.ref)}};let T=Yl(O,h.position.character);if(T){let C=Zl(r,T.name),j=C?`defined in ${de.relative(r,C.file)} (line ${C.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:${T.name}}** ${j}.`}}}}let A=y.getText({start:{line:h.position.line,character:0},end:{line:h.position.line+1,character:0}}).replace(/\n$/,""),E=A.match(vi);if(E){let O=f.stepSets.find(T=>T.name===E[3]);if(!O)return;let M=O.steps.map(T=>`- ${Si[b(T)??"unrecorded"][0]} ${T}`).join(`
|
|
279
|
+
`);return{contents:{kind:Jl.Markdown,value:`**StepSet ${O.name}** expands to:
|
|
274
280
|
|
|
275
|
-
${
|
|
281
|
+
${M}`}}}let P=A.match(Wl);if(P){let O=f.steps.find(M=>M.text===P[3]);return O?{contents:{kind:Jl.Markdown,value:`${Si[O.status]}
|
|
276
282
|
|
|
277
|
-
Used in ${
|
|
283
|
+
Used in ${O.usage} place(s): ${O.files.join(", ")}${O.fromStepSet?`
|
|
278
284
|
|
|
279
|
-
Defined in StepSet **${
|
|
280
|
-
`),p=i?d.findIndex(u=>u.includes(`"${i}"`)):0;if(p>=0)return{file:l,line:p};a??={file:l,line:0}}return a}function
|
|
281
|
-
`).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(o!==-1)return{file:r,line:o}}}function
|
|
282
|
-
`)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:
|
|
285
|
+
Defined in StepSet **${O.fromStepSet}**`:""}`}}:void 0}}),u.onDidOpen(h=>{if(l(h.document.uri),h.document.uri.startsWith("file:")){let y=Wt(h.document.uri);Fn(y,o)===r&&!w.has(y)&&x()}c(h.document.uri),k(h.document)}),u.onDidClose(h=>l(h.document.uri)),u.onDidChangeContent(h=>{c(h.document.uri),k(h.document)}),u.onDidSave(()=>x()),p.onDidChangeWatchedFiles(()=>x()),u.listen(p),p.listen()}var bh="saffron.runScenario";function vh(e,t){let n=Fn(e,t),r=de.relative(de.resolve(t),n);return r.split(/[\\/]/)[0]===".."||de.isAbsolute(r)?void 0:n}function Sh(e,t,n){let r=de.relative(n,e).split(de.sep).join("/");return r.startsWith("../")||de.isAbsolute(r)?[]:Ca(t).flatMap(({line:s,name:o})=>{let i={start:{line:s-1,character:0},end:{line:s-1,character:0}},a=(c,l)=>{let d=o&&!yi.test(o)?`${r}:${o}`:`${r}:${s}`;return{range:i,command:{title:c,command:bh,arguments:[{root:n,target:d,scenario:o,mode:l}]}}};return[a("\u25B6 Run scenario","run"),a("Replay only","replay"),a("Headed","headed")]})}function Kl(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 xh=e=>/^\s*Examples:/.test(e);function Xl(e,t=!1){let{env:n}=Me(),r=n?` (environment "${n}")`:"";try{if(t||!e.includes(".")){let{headers:i,rows:a}=Nr(e);return`**{data:${e}}** ${a.length} Examples row(s), columns ${i.map(c=>`\`${c}\``).join(", ")}${r}.`}let s=Nt(e);return Array.isArray(Zt(e))?`**{data:${e}}** a list of ${s.length}: ${s.map(i=>`"${i}"`).join(", ")}${r}. Use it with "every": replay checks each value, so adding one needs no re-record.`:`**{data:${e}}** = "${s[0]}"${r}. Resolved at replay: change the file and the next run uses the new value at zero tokens.`}catch(s){return`**{data:${e}}** does not resolve: ${s instanceof Error?s.message:String(s)}`}}function $h(e){let{dir:t,env:n}=Me(),[r,...s]=e.split("."),o=[n&&`${r}.${n}.json`,`${r}.json`,n&&`${r}.${n}.csv`,`${r}.csv`].filter(Boolean),i=[...s].reverse().find(c=>!/^\d+$/.test(c)),a;for(let c of o){let l=de.join(t,c);if(!Ze.existsSync(l))continue;let d=Ze.readFileSync(l,"utf8").split(`
|
|
286
|
+
`),p=i?d.findIndex(u=>u.includes(`"${i}"`)):0;if(p>=0)return{file:l,line:p};a??={file:l,line:0}}return a}function kh(e){let t=[];return e.forEach((n,r)=>{for(let s of n.matchAll(/\{data:([A-Za-z0-9_.-]+)\}/g)){let o=Xl(s[1],xh(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:mr.Error,source:"saffron",message:o.replace(/\*\*/g,"")})}}),t}function Rh(e){let t=e.match(/\{data:([A-Za-z0-9_.-]*)$/);if(!t)return;let n=t[1],{dir:r}=Me(),s=n.split(".");if(s.length===1)return(Ze.existsSync(r)?[...new Set(Ze.readdirSync(r).filter(i=>/\.(json|csv)$/.test(i)).map(i=>i.split(".")[0]))].sort():[]).map(i=>({label:i,kind:Vt.File,detail:"data file",insertText:i}));try{let o=Zt(s.slice(0,-1).join("."));return o===null||typeof o!="object"?[]:Object.entries(o).map(([i,a])=>({label:i,kind:a!==null&&typeof a=="object"?Vt.Module:Vt.Value,detail:Array.isArray(a)?`list of ${a.length}`:a!==null&&typeof a=="object"?"object":JSON.stringify(a),insertText:i}))}catch{return[]}}function Yl(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 Zl(e,t){for(let n of[".env",".env.example"]){let r=de.join(e,n);if(!Ze.existsSync(r))continue;let o=Ze.readFileSync(r,"utf8").split(`
|
|
287
|
+
`).findIndex(i=>new RegExp(`^\\s*(?:export\\s+)?${t}\\s*=`).test(i));if(o!==-1)return{file:r,line:o}}}function Th(e,t,n,r){let s=e.endsWith(".saffron");if(/^\s*StepSet\s+/.test(t)&&s)return r.stepSets.map(a=>({label:a.name,kind:Vt.Function,detail:`step set \xB7 ${a.steps.length} step(s) \xB7 \xD7${a.usage}`,documentation:a.steps.join(`
|
|
288
|
+
`)}));if(/^\s*(Given|When|Then|And|But|\*)\s+/.test(t))return r.steps.map((a,c)=>({label:a.text,kind:Vt.Text,detail:`${Si[a.status]} \xB7 \xD7${a.usage}`,sortText:String(c).padStart(5,"0"),filterText:a.text}));let i=t.replace(/^\s*/,"").trimEnd().toLowerCase().replace(/\s+/g,"");if(i.length>0&&("stepset:".startsWith(i)||i.startsWith("stepset"))&&s){let a={start:{line:n,character:t.length-t.replace(/^\s*/,"").length},end:{line:n,character:t.length}},c=[{label:"StepSet: \u2026",kind:Vt.Keyword,detail:"define a reusable step set",documentation:`StepSet: Name
|
|
283
289
|
Given \u2026
|
|
284
290
|
When \u2026
|
|
285
291
|
|
|
286
|
-
Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:
|
|
287
|
-
`),filterText:`step set ${l.name}`,sortText:`1${l.name}`,textEdit:{range:a,newText:`StepSet ${l.name}`}});return c}return[]}import hn from"node:fs";import jg from"node:net";import _t from"node:path";import{chromium as Ng,firefox as Fg,webkit as Mg}from"playwright";import mg from"node:path";var yg=5e3,wg=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]);function ol(e){return e.mode==="heal"||e.mode==="refine"}function il(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 nl=e=>`${e} action${e===1?"":"s"}`,rl=e=>e.map(Pe).join("; "),Wr=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(i=>!i.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: ${nl(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: ${rl(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 ${nl(r.length)} the agent performed ${s}: ${rl(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<=yg;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=>wg.has(s.action));r&&(this.lastOpener=r,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(r.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},tt=e=>e.replace(/\\(['"\\])/g,"$1");function bg(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?tt(t[1]):void 0}function vg(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,i=1,a;for(;s<e.length&&i>0;s++){let c=e[s];if(a)c==="\\"?s++:c===a&&(a=void 0);else if(c==="'"||c==='"'||c==="`")a=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==="["?i++:(c===")"||c==="}"||c==="]")&&i--}if(i!==0)return;t.push({method:r[1],args:e.slice(o,s-1)}),n=s}return t}function qo(e,t){let n=/^'((?:[^'\\]|\\.)*)'$/.exec(e.trim());if(n)return`${JSON.stringify(tt(n[1]))}${t?"s":"i"}`;let r=/^\/((?:[^/\\]|\\.)*)\/([a-z]*)$/.exec(e.trim());if(!r)return;let s=Rs(r[1]);return s!==r[1]&&/[uv]/.test(r[2])?void 0:`/${s}/${r[2]}`}var Sg=new Set(["checked","disabled","selected","expanded","includeHidden","level","pressed","busy"]);function sl(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?tt(s[1]):void 0}case"getByRole":{let s=/^\s*'([^']+)'/.exec(t)?.[1];if(!s)return;let o="";for(let[,i,a]of t.matchAll(/(\w+):\s*('(?:[^'\\]|\\.)*'|\/(?:[^/\\]|\\.)*\/[a-z]*|[^,}\s]+)/g))if(i==="name"||i==="description"){let c=qo(a,n);if(c===void 0)return;o+=`[${i}=${c}]`}else if(Sg.has(i))o+=`[${i==="includeHidden"?"include-hidden":i}=${a.replace(/^'(.*)'$/,"$1")}]`;else if(i!=="exact")return;return`internal:role=${s}${o}`}case"getByText":case"getByLabel":case"getByPlaceholder":case"getByAltText":case"getByTitle":{let s=r===void 0?void 0:qo(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)?tt(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=qo(s[1],!1);return o===void 0?void 0:`internal:has-text=${o}`}default:return}}function xg({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 Jo(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=tt(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=vg(e.replace(/^\./,"")),s=r?.filter((c,l)=>!(c.method==="first"&&l===r.length-1));if(s&&s.length>1){let c=s.map(l=>sl(l.method,l.args));return c.some(l=>l===void 0)?void 0:{...t,fallbackSelectors:[c.join(" >> ")]}}if(s?.length===1&&!xg(s[0])){let c=sl(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=tt(c[1]))}let i=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[c,l]of i){let d=e.match(c);d&&(t[l]=tt(d[1]))}let a=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return a&&(t.fallbackSelectors=[tt(a[1])]),Object.keys(t).length>0?t:void 0}var $g={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function kg(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:En(tt(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:tt(r[1])};let s=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let u=Jo(s[1]),f=Jo(s[2]);if(u&&f)return{action:"dragTo",target:u,toTarget:f}}let o=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,c]=o,l=Jo(i);if(!l)return;let d={action:$g[a],target:l},p=bg(c);return p!==void 0&&a!=="click"&&a!=="dblclick"&&(d.value=p),d}function al(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 cl(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let s=[];for(let o of n){let i=o.split(/^### Ran Playwright code[ \t\r]*$/m).slice(1).map(a=>a.split(/^### /m)[0]).join(`
|
|
292
|
+
Invoke it anywhere with: StepSet Name`,filterText:"step set stepset:",sortText:"0",insertTextFormat:ql.Snippet,textEdit:{range:a,newText:"StepSet: ${1:Name}\n "}}];r.stepSets.length===0&&c.push({label:"StepSet \u2026",kind:Vt.Keyword,detail:"invoke a step set (none defined yet)",filterText:"step set stepset",sortText:"1",insertTextFormat:ql.Snippet,textEdit:{range:a,newText:"StepSet ${1:Name}"}});for(let l of r.stepSets)c.push({label:`StepSet ${l.name}`,kind:Vt.Function,detail:`invoke step set \xB7 ${l.steps.length} step(s)`,documentation:l.steps.join(`
|
|
293
|
+
`),filterText:`step set ${l.name}`,sortText:`1${l.name}`,textEdit:{range:a,newText:`StepSet ${l.name}`}});return c}return[]}async function xi(e,t,n){let r=[...e],s;if(await Promise.all(Array.from({length:Math.min(t,r.length)},async(o,i)=>{for(let a=r.shift();a&&!s;a=r.shift())for(let c of a){if(s)return;try{await n(c,i)}catch(l){s??={error:l};return}}})),s)throw s.error}function ed(e,t){let n=new Map;for(let r of e)n.set(t(r),[...n.get(t(r))??[],r]);return[...n.values()]}import mt from"node:fs";import Jh from"node:net";import wt from"node:path";import{chromium as Wh,devices as Ri,firefox as Vh,webkit as Gh}from"playwright";import Eh from"node:path";var Ah=5e3,_h=new Set(["click","press","selectOption","check","uncheck","hover","dragTo"]);function sd(e){return e.mode==="heal"||e.mode==="refine"}function od(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 td=e=>`${e} action${e===1?"":"s"}`,nd=e=>e.map(Je).join("; "),Ss=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(i=>!i.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: ${td(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: ${nd(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 ${td(r.length)} the agent performed ${s}: ${nd(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<=Ah;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=>_h.has(s.action));r&&(this.lastOpener=r,this.lastOpenerAt=Date.now(),this.pendingTabOpen&&(r.opensTab=!0,this.pendingTabOpen=!1,this.pendingTabFallback=void 0))}},ht=e=>e.replace(/\\(['"\\])/g,"$1");function Ih(e){let t=e.match(/'((?:[^'\\]|\\.)*)'/);return t?ht(t[1]):void 0}function Ch(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,i=1,a;for(;s<e.length&&i>0;s++){let c=e[s];if(a)c==="\\"?s++:c===a&&(a=void 0);else if(c==="'"||c==='"'||c==="`")a=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==="["?i++:(c===")"||c==="}"||c==="]")&&i--}if(i!==0)return;t.push({method:r[1],args:e.slice(o,s-1)}),n=s}return t}function $i(e,t){let n=/^'((?:[^'\\]|\\.)*)'$/.exec(e.trim());if(n)return`${JSON.stringify(ht(n[1]))}${t?"s":"i"}`;let r=/^\/((?:[^/\\]|\\.)*)\/([a-z]*)$/.exec(e.trim());if(!r)return;let s=no(r[1]);return s!==r[1]&&/[uv]/.test(r[2])?void 0:`/${s}/${r[2]}`}var Ph=new Set(["checked","disabled","selected","expanded","includeHidden","level","pressed","busy"]);function rd(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?ht(s[1]):void 0}case"getByRole":{let s=/^\s*'([^']+)'/.exec(t)?.[1];if(!s)return;let o="";for(let[,i,a]of t.matchAll(/(\w+):\s*('(?:[^'\\]|\\.)*'|\/(?:[^/\\]|\\.)*\/[a-z]*|[^,}\s]+)/g))if(i==="name"||i==="description"){let c=$i(a,n);if(c===void 0)return;o+=`[${i}=${c}]`}else if(Ph.has(i))o+=`[${i==="includeHidden"?"include-hidden":i}=${a.replace(/^'(.*)'$/,"$1")}]`;else if(i!=="exact")return;return`internal:role=${s}${o}`}case"getByText":case"getByLabel":case"getByPlaceholder":case"getByAltText":case"getByTitle":{let s=r===void 0?void 0:$i(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)?ht(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=$i(s[1],!1);return o===void 0?void 0:`internal:has-text=${o}`}default:return}}function Oh({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 ki(e){let t={},n=e.match(/(?:frameLocator\('((?:[^'\\]|\\.)*)'\)|locator\('((?:[^'\\]|\\.)*)'\)\.contentFrame\(\))/);n&&(t.frame=ht(n[1]??n[2]),e=e.slice((n.index??0)+n[0].length));let r=Ch(e.replace(/^\./,"")),s=r?.filter((c,l)=>!(c.method==="first"&&l===r.length-1));if(s&&s.length>1){let c=s.map(l=>rd(l.method,l.args));return c.some(l=>l===void 0)?void 0:{...t,fallbackSelectors:[c.join(" >> ")]}}if(s?.length===1&&!Oh(s[0])){let c=rd(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=ht(c[1]))}let i=[[/getByLabel\('((?:[^'\\]|\\.)*)'\)/,"label"],[/getByText\('((?:[^'\\]|\\.)*)'\)/,"text"],[/getByTestId\('((?:[^'\\]|\\.)*)'\)/,"testId"],[/getByPlaceholder\('((?:[^'\\]|\\.)*)'\)/,"placeholder"]];for(let[c,l]of i){let d=e.match(c);d&&(t[l]=ht(d[1]))}let a=e.match(/(?:^|\.)locator\('((?:[^'\\]|\\.)*)'\)/);return a&&(t.fallbackSelectors=[ht(a[1])]),Object.keys(t).length>0?t:void 0}var jh={click:"click",dblclick:"click",fill:"fill",pressSequentially:"fill",type:"fill",press:"press",check:"check",uncheck:"uncheck",hover:"hover",selectOption:"selectOption"};function Nh(e,t){let n=e.match(/page\.goto\('((?:[^'\\]|\\.)*)'\)/);if(n)return{action:"goto",url:Zn(ht(n[1]),t)};let r=e.match(/page\.keyboard\.press\('((?:[^'\\]|\\.)*)'\)/);if(r)return{action:"press",value:ht(r[1])};let s=e.match(/await\s+page\.(.+)\.dragTo\(page\.(.+?)\);?\s*$/);if(s){let u=ki(s[1]),f=ki(s[2]);if(u&&f)return{action:"dragTo",target:u,toTarget:f}}let o=e.match(/await\s+page\.(.+)\.(click|dblclick|fill|pressSequentially|type|press|check|uncheck|hover|selectOption)\((.*)\);?\s*$/);if(!o)return;let[,i,a,c]=o,l=ki(i);if(!l)return;let d={action:jh[a],target:l},p=Ih(c);return p!==void 0&&a!=="click"&&a!=="dblclick"&&(d.value=p),d}function id(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 ad(e,t){let n=[],r=Array.isArray(e)?e:e?.content;if(Array.isArray(r))for(let o of r){let i=o?.text;typeof i=="string"&&n.push(i)}else typeof e=="string"&&n.push(e);let s=[];for(let o of n){let i=o.split(/^### Ran Playwright code[ \t\r]*$/m).slice(1).map(a=>a.split(/^### /m)[0]).join(`
|
|
288
294
|
`);for(let a of i.split(`
|
|
289
|
-
`)){let c=
|
|
290
|
-
`)}}}function
|
|
291
|
-
`):void 0,featureEdits:u,recordedSteps:c,announcedSteps
|
|
292
|
-
Install it (npx playwright install ${t} does that), or use --browser chromium, which Saffron ships with.`):
|
|
293
|
-
`)[0]}
|
|
294
|
-
`)[0]})
|
|
295
|
-
`)[0]})`:"")+
|
|
296
|
-
`)[0]}
|
|
297
|
-
`)[0]}`,lr=A.failure.stepIndex,Ai=A.stepResults.map((Gt,vs)=>vs===lr?{...Gt,status:"failed",error:qe}:Gt);return{scenario:t,status:"red",source:"agent-heal",stepResults:Ai,executionStepResults:Ai.map((Gt,vs)=>vs>lr&&Gt.status==="skipped"?{...Gt,status:"passed"}:Gt),narrative:P.narrative,adaptations:P.adaptations,suggestedFeatureEdit:P.suggestedFeatureEdit,usage:P.usage,durationMs:Date.now()-r,error:qe}}finally{E?.detach()}}return E?.detach(),await this.finalizeAgentRun(t,P,B,d,r,N,B?A?.failure:void 0,g)})(),I=R.find(A=>A.kind==="before-heal");if(_.source==="cache"&&I){let A=I.file.replace(/before-heal\.jpg$/,"failure.jpg");try{hn.renameSync(_t.join(s,I.file),_t.join(s,A)),I.file=A}catch{}I.kind="failure"}else if(_.status==="red"){let A=_.stepResults.find(B=>B.status==="failed")?.gherkin,H=await this.screenshot(mn(this.browser)??w,t,"failure",A);H&&R.push(H)}let j=pn(_e(t),g);if(j.size>0&&(_.uniques=Object.fromEntries(j)),R.push(..._.evidence??[]),$){let A=x==="on"||_.status!=="green",H=Ge(s,t.featurePath,t.displayName),B=A?_t.join(H,Zs):void 0;h=!0;let N=m??S;B||await Xs(N).catch(()=>{});let P=B?await eo(N,B,We(t)):void 0;if(B&&P){this.claimArtifactDir(t);let E=We(t);R.unshift({kind:"trace",file:me(s,B),sha256:P,traceSteps:(_.executionStepResults??_.stepResults).map(O=>({...O,error:O.error&&it(O.error,E)}))})}}return R.length>0&&(_.status!=="green"||R.some(A=>A.kind==="trace"))&&(_.evidence=R),_}finally{h||await Xs(S).catch(()=>{}),await m?.close(),await S.close()}}async finalizeAgentRun(t,n,r,s,o,i=0,a,c=St()){let l=r?"agent-heal":"agent-record",d=n.success?n.recordedSteps.map(P=>({gherkin:P.gherkin,kind:P.kind,status:"passed"})):vl(n.recordedSteps,n.announcedSteps??[],a&&{stepIndex:a.stepIndex,error:a.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?hl(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,u=[...n.adaptations,...r&&a?ml(n.recordedSteps,a.stepIndex):[]],f={...n.usage},y,b,k,T,v=[],S=0,x=new Set,$,h=async(P,E,O=E.stepResults)=>{if(!x.has(P)){x.add(P);try{if(this.options.trace&&this.options.trace!=="off"){let M=_t.join(Ge(this.options.projectRoot,t.featurePath,t.displayName),`proof-${++S}.zip`),U=We(t),X=await eo(P,M,U);if(X){this.claimArtifactDir(t);let He=E.captured[Fe],ht=He?pn(_e(t),He):new Map;v.push({kind:"trace",proof:!0,file:me(this.options.projectRoot,M),sha256:X,traceSteps:O.map(qe=>({...qe,error:qe.error&&it(qe.error,U)})),traceUniques:ht.size?Object.fromEntries(ht):void 0})}}}finally{await P.close()}}};if(this.options.verifyProposals!==!1){let P=await this.proofReplay(t,this.buildCandidateCache(t,p),St());T=P.result.captured[Fe];try{if(P.result.status!=="passed"&&P.result.failure){let E=P.result.failure;if(this.options.agent&&!(r&&E.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||gl(s.steps,E.stepIndex))))try{let M=await Gr(this.browser,P.result.activePage??Wn(P.page.context(),E.pageUrl)??P.page),U=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,traceContext:this.options.trace&&this.options.trace!=="off"?P.context:void 0,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:P.result.stepResults.filter(X=>X.status==="passed").map(X=>X.gherkin),failedStep:{text:E.stepText,kind:E.kind,error:E.error,pageUrl:E.pageUrl,stepIndex:E.stepIndex},liveTab:M,uniqueSeed:P.result.captured[Fe],onTabOpened:this.tabWatcher(),takeFingerprint:async()=>Lt(mn(this.browser)??P.page)}));f=yl(f,U.usage),$=Ug(P.result.stepResults,E,U),U.success&&(u.push(...U.adaptations),u.push(...ml(U.recordedSteps,E.stepIndex)),p=r?hl(p,U.recordedSteps,this.options.assertionPolicy==="adaptable-mid",{stepIndex:E.stepIndex,actionIndex:0}):Bg(p,U.recordedSteps,E.stepIndex),await h(P.context,P.result,$),$=void 0,P=await this.proofReplay(t,this.buildCandidateCache(t,p),St()),T=P.result.captured[Fe])}catch{}}for(let E of P.result.tabOpeners??[]){let O=p[E.stepIndex]?.actions[E.actionIndex];O&&(O.opensTab=!0)}y=P.result.status==="passed",y||(b=P.result.failure?.error??`proof replay ${P.result.status}`,k=P.result.stepResults)}finally{await h(P.context,P.result,$).catch(()=>{})}}r&&s&&u.push(...Lg(s.steps,p,a,this.options.baseURL));let m=[...n.featureEdits];for(let P of Yi(p,Ke(this.options.projectRoot)))u.push(`duplicate wording: "${P.from}" recorded exactly the same actions as the existing step "${P.to}" (${P.feature} \u203A ${P.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),m.some(E=>E.index===P.index)||m.push({index:P.index,text:P.to});let w=ll(p);w.length>0&&(y=!1,b=[b,...w].filter(Boolean).join(" "));let g=We(t),R=P=>g.size>0?it(P,g):P;u=u.map(R);let C=m.map(P=>({index:P.index,text:R(P.text)})),_=R(n.narrative),I=n.suggestedFeatureEdit?R(n.suggestedFeatureEdit):void 0,j={version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:p,...ul(t)},A={meta:{createdAt:j.recordedAt,mode:r?"heal":"record",narrative:_,adaptations:u,suggestedFeatureEdit:I,featureEdits:C.length>0?C:void 0,verified:y,proofError:b?R(b):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:f,bound:nc(t,s,j,C.length>0?C:void 0)},cache:j},H=Ce(this.options.projectRoot,t.featurePath,t.name),B=na(H,A);ia(this.options.projectRoot,H,B);let N=Re(ee(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:$t(N,A.cache),scenario:t,status:"yellow",source:l,stepResults:k??d,executionStepResults:d,evidence:v,proofUniques:(()=>{let P=T?pn(_e(t),T):void 0;return P&&P.size>0?Object.fromEntries(P):void 0})(),narrative:_,adaptations:u,suggestedFeatureEdit:I,featureEdits:C.length>0?C:void 0,verified:y,proofError:b,seededSteps:i>0?i:void 0,proposalFile:me(this.options.projectRoot,H),proposalRevision:B,usage:f,durationMs:Date.now()-o}}};import ls from"node:fs";import Bh from"node:os";import Gl from"node:path";import{query as fh}from"@anthropic-ai/claude-agent-sdk";import{McpServer as zg}from"@modelcontextprotocol/sdk/server/mcp.js";import{z as V}from"zod";var ye=e=>({content:[{type:"text",text:e}]});function yn(e,t,n,r){let s=async o=>{let i=await r(o);return i.content[0]?.text.startsWith("ERROR:")?{...i,isError:!0}:i};return o=>{o.registerTool(e,{description:t,inputSchema:n},s)}}function Hg(e){let t=new zg({name:e.name,version:e.version});for(let n of e.tools)n(t);return{type:"sdk",name:e.name,instance:t}}function Sl(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 qg=new Set(["main","banner","contentinfo","status","alert","alertdialog","dialog","search"]);function xl(e){if(!e?.role)return;let{role:t,frame:n,...r}=e;if(!(Object.keys(r).length>0||qg.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 Jg(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 $l=new WeakSet;function Vg(e,t){if(t.success&&!$l.has(e)){let n=[...e.announced].filter(r=>r>=e.firstStep&&e.steps[r]?.kind==="assertion"&&!e.steps[r].actions.some(s=>Ie.has(s.action))).sort((r,s)=>r-s);if(n.length>0)return $l.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??[]).filter(n=>!Is.test(n.text)),"Run finalized."}var kl={role:V.string().optional().describe("ARIA role of the element"),name:V.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:V.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:V.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:V.string().optional().describe("getByText locator text"),selector:V.string().optional().describe("CSS fallback selector"),frame:V.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 Yr(e){return Hg({name:"saffron",version:"0.1.0",tools:[yn("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:V.number().int().min(0),text:V.string(),adaptation:V.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=>ye(Jg(e,t))),yn("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:V.string().describe("Name to store the captured text under"),...kl},async t=>{let n=Sl(t);if(!n)return ye("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).");let r=xl(n);return r?ye(r):(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),ye(`Capture recorded as "${t.saveAs}".`))}),yn("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:V.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:V.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}:{}}),ye(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),yn("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:V.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:V.string().optional().describe("HTTP method to match (any if omitted)"),status:V.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:V.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}:{}}]),ye("Network wait recorded."))),yn("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:V.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...kl,expected:V.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:V.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:V.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:V.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:V.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:V.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:V.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:V.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:V.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:V.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:V.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=Sl(t),r=xl(n);if(r)return ye(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 ye("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return ye("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 ye("ERROR: kind=matches requires pattern.");s={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return ye("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 ye("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 ye("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?ye("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([s]),ye("Assertion recorded."))}),yn("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:V.boolean(),narrative:V.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:V.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:V.array(V.object({index:V.number().int().min(0).describe("zero-based step index"),text:V.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=>ye(Vg(e,t)))]})}function Qr(e={}){let t=e.deliberateSnapshots?`
|
|
295
|
+
`)){let c=Nh(a.trim(),t);c&&s.push(c)}}return Mh(s)}function Mh(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 cd(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 Gt(e,t,n=s=>s,r=!1){let s=e;for(let[o,i]of Object.entries(t).filter(([,a])=>a.length>=3).sort((a,c)=>c[1].length-a[1].length)){let a=n(i);if(!r){s=s.split(a).join(`<${o}>`);continue}if(i.length<5&&!/\p{N}/u.test(i)){s===a&&(s=`<${o}>`);continue}let c=a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");s=s.replace(new RegExp(`(?<![\\p{L}\\p{N}])${c}(?![\\p{L}\\p{N}])`,"gu"),()=>`<${o}>`)}return s}function Fh(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:Gt(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}},i=l=>l===void 0?void 0:Gt(l,t,xe.pattern),a=l=>l===void 0?void 0:Gt(l,t,void 0,!0),c=(l,d)=>l?{...l,name:d?o(l.name):r(l.name),nameRegex:i(l.nameRegex),label:r(l.label),text:d?o(l.text):r(l.text),placeholder:r(l.placeholder),testId:a(l.testId),frame:a(l.frame),fallbackSelectors:l.fallbackSelectors?.map(p=>kn(p,(u,f)=>Gt(u,t,xe[f])))}:void 0;return e.map(l=>({...l,actions:l.actions.map(d=>{let p=He.has(d.action)&&d.action!=="expectValue";return{...d,value:p?o(d.value):r(d.value),url:r(d.url),pattern:i(d.pattern),urlPattern:i(d.urlPattern),promptText:r(d.promptText),target:c(d.target,p),toTarget:c(d.toTarget,!1)}})}))}function Lh(e,t,n=r=>r){let r=Mt(t.steps);return e.map((s,o)=>{let i=t.steps[o],a=i?.resolvedTable??i?.table,c=i?.resolvedDocString??i?.docString,l=Object.entries({...r,...Ce(a),...en(c)}).map(([u,f])=>[u,n(f)]).filter(([,u])=>u.length>=3).sort((u,f)=>f[1].length-u[1].length);if(l.length===0)return s;let d=(u,f=w=>w,g=!1)=>u===void 0?void 0:Gt(u,Object.fromEntries(l),f,g),p=u=>u?{...u,name:d(u.name),nameRegex:d(u.nameRegex,xe.pattern),label:d(u.label),text:d(u.text),placeholder:d(u.placeholder),testId:d(u.testId,void 0,!0),frame:d(u.frame,void 0,!0),fallbackSelectors:u.fallbackSelectors?.map(f=>kn(f,(g,w)=>d(g,xe[w])??g))}:void 0;return{...s,actions:s.actions.map(u=>({...u,value:d(u.value),url:d(u.url),pattern:d(u.pattern,xe.pattern),urlPattern:d(u.urlPattern,xe.pattern),promptText:d(u.promptText),target:p(u.target),toTarget:p(u.toTarget)}))}})}function Bh(e,t=new Date){return e.map(n=>({...n,actions:n.actions.map(r=>wr(r,s=>rc(s,t)))}))}function wr(e,t,n){let r=(a,c,l=t)=>{let d={...a};for(let p of c)typeof a[p]=="string"&&(d[p]=l(a[p]));return d},s=a=>n?n(a,"pattern"):t(a),o=a=>{let c=r(r(a,["name","label","text","placeholder","testId","frame"]),["nameRegex"],s);return a.fallbackSelectors&&(c.fallbackSelectors=a.fallbackSelectors.map(l=>n?kn(l,n,t):t(l))),c},i=r(r(e,["value","url","promptText"]),["pattern","urlPattern"],s);return e.target&&(i.target=o(e.target)),e.toTarget&&(i.toTarget=o(e.toTarget)),i}function Dh(e,t){if(t.length===0)return e;let n=new Map(t.map(([o,i])=>[i,o])),r=[...n.keys()].sort((o,i)=>i.length-o.length).map(kt).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,i)=>i%2?o:o.replace(s,a=>`{env:${n.get(a)}}`)).join("")}function Uh(e,t){let n=Fe(t),r=$t(ze(t)),s=new RegExp(`${xn.source}|${vn.source}`,"g"),o=i=>i.replace(s,(a,c,l)=>(c!==void 0?n.has(c):r.has(l))?a:xa(a));return e.map(i=>({...i,actions:i.actions.map(a=>wr(a,o))}))}function zh(e,t,n=[]){if(t.size===0)return e;let r=to(n,t),s=Xt(t,{standaloneBelow:6}),o=l=>s.mask(r(l)),i=[...t].filter(([,l])=>l.length>=Qt),a=l=>{let d=to(n,t,xe[l]),p=Xt(new Map(i.filter(([,f])=>f.length>=6).map(([f,g])=>[f,xe[l](g)]))),u=i.filter(([,f])=>f.length<6).map(([f,g])=>[f,xe[l](g)]);return f=>o(Dh(p.mask(d(f)),u))},c={quoted:a("quoted"),regex:a("regex"),pattern:a("pattern")};return e.map(l=>({...l,actions:l.actions.map(d=>wr(d,o,(p,u)=>c[u](p)))}))}function ld(e,t,n=""){let r=ze(t),s=zh(Hh(qh(Bh(Fh(Lh(Uh(e,t),t,a=>Mr(Pn(a,n))),t.params)),Fr(r)),On(r,n)),ct(t),r),o=Object.fromEntries(Object.entries(t.params).filter(([,a])=>a.includes("{env:")));if(Object.keys(o).length===0)return s;let i=a=>Gt(a,o);return s.map(a=>({...a,actions:a.actions.map(c=>wr(c,i))}))}function Hh(e,t){if(t.size===0)return e;let n=r=>typeof r=="string"?ic(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 qh(e,t){return t.size===0?e:e.map(n=>{let r=new Set([...n.gherkin.matchAll(vn)].map(l=>l[1])),s=l=>Zs(l,t,r),o=l=>new Map([...t].filter(([,d])=>d.length>=4).map(([d,p])=>[d,xe[l](p)])),i={quoted:o("quoted"),regex:o("regex"),pattern:o("pattern")},a=l=>l.split(/(\{(?:env|data|unique|date)[^{}]*\})/).map((d,p)=>p%2?d:s(d)).join(""),c=(l,d)=>a(Zs(l,i[d],r));return{...n,actions:n.actions.map(l=>wr(l,s,c))}})}function dd(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:Zn(r.url,n)}:{action:"newTab"};default:return}}function fd(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=Me().root+Eh.sep;return{action:"uploadFile",value:r.map(i=>typeof i=="string"&&i.startsWith(s)?i.slice(s.length):i).join(`
|
|
296
|
+
`)}}}function yr(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 xs(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 Ai=["chromium","chrome","msedge","firefox","webkit"],Ti={chrome:{label:"Google Chrome"},msedge:{label:"Microsoft Edge"}};function wd(e){return Ai.includes(e)}function Bn(e){return e==="chromium"||Ti[e]!==void 0}var It=class extends Error{},Ei=class extends It{},_i=Object.keys(Ri);function yd(e){let t=/^(\d{2,5})x(\d{2,5})$/.exec(e.trim());return t?{width:Number(t[1]),height:Number(t[2])}:void 0}var Kt={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0,costUsd:0};async function Kh(){return new Promise((e,t)=>{let n=Jh.createServer();n.listen(0,"127.0.0.1",()=>{let{port:r}=n.address();n.close(()=>e(r))}),n.on("error",t)})}function pd(e){let t=Fr(ze(e));return t.size>0?{data:Object.fromEntries([...t].sort(([n],[r])=>n.localeCompare(r)))}:{}}function Ln(e){let t=e.contexts().flatMap(n=>n.pages());return[...t].reverse().find(n=>n.url()!=="about:blank")??t.at(-1)}function bd(e){let t=e.length;for(let n=e.length-1;n>=0&&e[n].kind==="assertion";n--)t=n;return t}function ud(e,t){return e[t]?.kind==="assertion"&&t>=bd(e)}function gd(e,t,n=!1,r){let s=bd(e);return e.map((o,i)=>{if(r&&i<r.stepIndex)return o;let a=t[i],c=a&&a.actions.length>0;return o.kind==="assertion"?n&&i<s&&c?a:o:c?r&&i===r.stepIndex&&r.actionIndex>0?{...a,actions:[...o.actions.slice(0,r.actionIndex),...a.actions]}:a:o})}function Yh(e,t,n,r){let s=Mc(e,t);return s.length===0||!n?.pageUrl?s:[`page at failure: replay stopped at step ${n.stepIndex+1} ("${n.stepText}") on ${Zn(n.pageUrl,r)}, where the heal started.`,...s]}function Zh(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 hd(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 vd(e,t,n,r){let s=t.filter(c=>c>=0&&c<e.length),o=s.length>0?Math.max(...s):0,i=Math.min(n&&n.stepIndex>=o?n.stepIndex:o,e.length-1),a=n&&i===n.stepIndex?n.error:r;return e.map((c,l)=>({gherkin:c.gherkin,kind:c.kind,status:l<i?"passed":l===i?"failed":"skipped",...l===i&&a?{error:a}:{}}))}function Qh(e,t,n){let r=n.success?e.map(s=>({gherkin:s.gherkin,kind:s.kind,status:"passed"})):vd(e,n.announcedSteps??[],t,n.narrative);return e.map((s,o)=>o<t.stepIndex?s:r[o])}function md(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 $s=class{constructor(t){this.options=t}options;browser;cdpEndpoint;stepIndex;get agent(){return this.options.agent}getStepIndex(){if(!this.stepIndex&&(this.stepIndex=lt(this.options.projectRoot),this.stepIndex.steps.size>0)){let t=this.stepIndex.divergent.length>0?` \xB7 ${this.stepIndex.divergent.length} step(s) have divergent recordings (Level-2 signal)`:"";console.log(`step index: ${this.stepIndex.steps.size} reusable steps from ${this.stepIndex.scannedCaches} cache(s)${t}`)}return this.stepIndex}async recordWithSeeding(t,n,r){let s=this.getStepIndex(),o=t.steps.length,i={...t.params,...Mt(t.steps)},a=t.steps.map(S=>{let x=s.steps.size>0?Ba(s,S,i):void 0;return x?{gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:x.actions}:void 0}),c=t.steps.map(S=>({gherkin:S.text,keyword:S.keyword,kind:S.kind,table:S.table,docString:S.docString,fromStepSet:S.source?.fromStepSet,actions:[]})),l=new Map;l.set(Ke,r);let d=[],p=[],u=[],f=[],g={...Kt},w=n,R=0,$=0,m=0,v=(S,x)=>({success:S,narrative:p.join(" ")||"Composed entirely from seeded recordings.",adaptations:d,suggestedFeatureEdit:f.length>0?f.join(`
|
|
297
|
+
`):void 0,featureEdits:u,recordedSteps:c,announcedSteps:x===void 0?[]:Array.from({length:x+1},(b,k)=>k),usage:g,seededCount:R});for(;m<o;){if(a[m]){let A=m;for(;A<o&&a[A];)A++;let E=a.slice(m,A),P={...t,steps:t.steps.slice(m,A)},O=await or(w.isClosed()?yr(n.context())??n:w,P,this.buildCandidateCache(t,E),{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:m,envNames:Fe(t)});w=O.activePage??w;let M=O.status==="passed"?A-m:O.failure?.stepIndex??0;for(let C=0;C<M;C++)c[m+C]=E[C];if(R+=M,O.status==="passed"){m=A;continue}let T=m+M;d.push(`Seeded recording for "${t.steps[T].text}" did not replay in this scenario's context; the agent re-recorded it fresh. Divergence signal.`),a[T]=void 0,m=T;continue}if(!this.options.agent)return v(!1,m);$++;let S=m;for(;S<o&&!a[S];)S++;$>=3&&(S=o);let x=await xs(this.browser,w.isClosed()?yr(n.context())??n:w),b=A=>{w=A};n.context().on("page",b);let k;try{k=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:m>0?t.steps.slice(0,m).map(A=>A.resolvedText):void 0,recordOnly:S<o?{from:m,to:S-1}:void 0,liveTab:x,onActiveTab:A=>{let E=n.context().pages().filter(P=>!P.isClosed())[A];E&&(w=E)},uniqueSeed:l.get(Ke),onTabOpened:this.tabWatcher(),takeFingerprint:async()=>sn(Ln(this.browser)??n)}))}finally{n.context().off("page",b)}if(g=md(g,k.usage),p.push(k.narrative),d.push(...k.adaptations),u.push(...k.featureEdits),k.suggestedFeatureEdit&&f.push(k.suggestedFeatureEdit),!k.success){let A=k.announcedSteps.filter(E=>E>=m&&E<o);return v(!1,A.length>0?Math.max(...A):m)}let h=k.announcedSteps.filter(A=>A>=m&&A<o),y=Math.min(o-1,Math.max(h.length>0?Math.max(...h):S-1,m));for(let A=m;A<=y;A++)c[A]=k.recordedSteps[A];m=y+1}return v(!0)}async withDialogsVisible(t){let n=()=>{},r=[],s=[],o=i=>{i.on("dialog",n),r.push(i)};for(let i of this.browser?.contexts()??[]){i.on("page",o),s.push(i);for(let a of i.pages())o(a)}try{return await t()}finally{for(let i of s)i.off("page",o);for(let i of r)try{i.off("dialog",n)}catch{}}}async start(){let t=this.options.browser??"chromium";if(Bn(t)){let r=await Kh();try{this.browser=await Wh.launch({headless:!this.options.headed,channel:Ti[t]?t:void 0,args:[`--remote-debugging-port=${r}`]})}catch(s){let o=s instanceof Error?s.message:String(s),i=Ti[t];throw i&&/is not found at|distribution .* is not found/i.test(o)?new Ei(`${i.label} is not installed on this machine, so --browser ${t} cannot run.
|
|
298
|
+
Install it (npx playwright install ${t} does that), or use --browser chromium, which Saffron ships with.`):s}this.cdpEndpoint=`http://127.0.0.1:${r}`}else this.browser=await(t==="firefox"?Vh:Gh).launch({headless:!this.options.headed}),this.cdpEndpoint=void 0;let n;try{n=this.contextOptions()}catch(r){throw await this.stop(),r}if(this.options.device||this.options.viewport){if(t==="firefox"&&this.options.device&&Ri[this.options.device]?.isMobile)throw await this.stop(),new It(`--device "${this.options.device}" is a mobile profile, and Playwright does not emulate mobile devices in Firefox. Run it in chromium or webkit, or give Firefox a --viewport instead.`);try{await(await this.browser.newContext(n)).close()}catch(r){await this.stop();let s=(r instanceof Error?r.message:String(r)).split(`
|
|
299
|
+
`)[0];throw new It(`${this.options.device?`--device "${this.options.device}"`:"--viewport"} cannot run in ${t}: ${s}`)}}}async stop(){await this.browser?.close(),this.browser=void 0}resolveStorageState(){if(!this.options.storageState)return;let t=wt.isAbsolute(this.options.storageState)?this.options.storageState:wt.join(this.options.projectRoot,this.options.storageState);if(!mt.existsSync(t))throw new It(`storageState file not found: ${t}. Generate one with: npx playwright open --save-storage=${this.options.storageState} <url>`);try{let n=JSON.parse(mt.readFileSync(t,"utf8"));if(!n||typeof n!="object"||Array.isArray(n))throw new Error("it is not a JSON object")}catch(n){throw new It(`storageState file ${t} cannot be used: ${(n instanceof Error?n.message:String(n)).split(`
|
|
300
|
+
`)[0]}. Generate it again with: npx playwright open --save-storage=${this.options.storageState} <url>`)}return t}contextOptions(){let{defaultBrowserType:t,...n}=this.options.device?Ri[this.options.device]??{}:{};return{storageState:this.resolveStorageState(),...n,...this.options.viewport?{viewport:this.options.viewport}:{}}}artifacts(t){let n=Ft(this.options.projectRoot,t.featurePath,t.displayName);return this.options.artifactsPerBrowser?wt.join(n,this.options.browser??"chromium"):n}buildCandidateCache(t,n){return{version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:n,...pd(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,s){let o=await this.browser.newContext(this.contextOptions()),i=this.options.trace!==void 0&&this.options.trace!=="off";i&&await Io(o,s?t.displayName:`${t.displayName} (proof replay)`);let a=await o.newPage();return{result:await or(a,t,n,{baseURL:this.options.baseURL,actionTimeoutMs:this.options.actionTimeoutMs,retries:this.options.retries,pollIntervalMs:this.options.pollIntervalMs,uniqueSeed:r,trace:i,tracePhase:s}),context:o,page:a}}async runScenario(t,n={}){let r=await this.runScenarioInner(t,n),s=ct(t);if(s.size>0){let o=i=>i===void 0?i:et(i,s);r.error=o(r.error),r.narrative=o(r.narrative),r.proofError=o(r.proofError),r.suggestedFeatureEdit=o(r.suggestedFeatureEdit),r.adaptations=r.adaptations.map(i=>et(i,s));for(let i of r.stepResults){i.error=o(i.error);for(let a of i.tabs??[])a.url=et(a.url,s);for(let a of i.responses??[])a.url=et(a.url,s)}}return r}async screenshot(t,n,r,s){let o=this.options.screenshot??"viewport";if(o!=="off")try{let i=this.artifacts(n);this.claimArtifactDir(n);let a=wt.join(i,`${r}.jpg`),c=await t.screenshot({path:a,type:"jpeg",quality:80,fullPage:o==="full-page",timeout:5e3});return{kind:r,file:Ae(this.options.projectRoot,a),step:s,sha256:rt(c)}}catch{return}}claimArtifactDir(t){let n=Ft(this.options.projectRoot,t.featurePath,t.displayName);mt.mkdirSync(this.artifacts(t),{recursive:!0}),mt.writeFileSync(wt.join(n,nn),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:i,retries:a,pollIntervalMs:c}=this.options,l=se(s,t.featurePath,t.name),d;try{d=Pe(l)}catch(P){if(!(P instanceof le))throw P;return{scenario:t,status:"red",source:"cache",stepResults:[],adaptations:[],usage:Kt,durationMs:Date.now()-r,error:`The recording cannot be read: ${P.problem}. Fix the file, or re-record it with saffron run --rerecord.`}}let p=qe(s,t.featurePath,t.name),u=Ae(s,p),f,g;if(mt.existsSync(p))try{({proposal:f,revision:g}=Kr(p))}catch(P){if(!(P instanceof le)&&P.code!=="ENOENT")throw P;f=void 0}let w=!1;f&&!Ka(s,p,g)&&(w=!this.options.rerecord,f=void 0),f&&We(s,f,t)&&(f=void 0);let R=P=>P.status==="stale"?"the committed recording no longer matches the scenario":`the committed recording failed${P.failure?` at "${P.failure.stepText}"`:""}`,$=P=>{let O=_t(s)[u],M=O?.revision===g?` from ${ie(ur(O.from))}`:"",T=P?R(P):"";return{scenario:t,status:"yellow",source:d?"cache":"agent-record",stepResults:P?.stepResults??[],narrative:(T?`${T[0].toUpperCase()}${T.slice(1)}. `:"")+`A proposal${M} is pending review at ${u}. It was not filed on this machine, so this run does not replay it, heal or record over it. Review it with saffron diff ${u}, then saffron accept or saffron reject it.`,adaptations:[],proposalFile:u,proposalRevision:g,usage:Kt,durationMs:Date.now()-r}};if(mt.rmSync(this.artifacts(t),{recursive:!0,force:!0}),this.options.artifactsPerBrowser){let P=Ft(s,t.featurePath,t.displayName);for(let O of mt.existsSync(P)?mt.readdirSync(P,{withFileTypes:!0}):[])O.isFile()&&O.name!==nn&&mt.rmSync(wt.join(P,O.name),{force:!0})}if(w&&!d)return $();let m=(P,O)=>({scenario:t,status:"yellow",source:"agent-record",proposalReplay:!0,stepResults:P.stepResults,narrative:`Replayed the pending proposal recorded for "${f.meta.recordedFor??t.name}" at zero AI${O?` instead of ${O.status==="stale"?"recording":"healing"} again: ${R(O)}`:""}; accept it to go green.`,adaptations:[],verified:!0,proposalFile:u,proposalRevision:g,usage:Kt,durationMs:Date.now()-r}),v=await this.browser.newContext(this.contextOptions()),S=this.options.trace??"off",x=S!=="off";x&&await Io(v,t.displayName);let b=!x,k,h=await v.newPage(),y=Ut(),A=[],E=(P,O)=>this.screenshot(P,t,"before-heal",O.stepText).then(M=>{M&&A.push(M)});try{let P=await(async()=>{let T;if(f&&!d){let D=await or(h,t,f.cache,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c,uniqueSeed:y,trace:x,tracePhase:"pending"}),H=f.meta.recordedFor??t.name;if(D.status==="passed")return m(D);if(Object.keys(t.params).length>0&&H!==t.displayName){let W=D.failure;return{scenario:t,status:"red",source:"agent-record",proposalReplay:!0,stepResults:D.stepResults,adaptations:[],proposalFile:u,proposalRevision:g,usage:Kt,durationMs:Date.now()-r,error:`The pending proposal recorded for "${H}" does not replay for this Examples row`+(W?` (step "${W.stepText}": ${W.error.split(`
|
|
301
|
+
`)[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`+(D.failure?` (step "${D.failure.stepText}": ${D.failure.error.split(`
|
|
302
|
+
`)[0]})`:"")+"; recording again"),await h.goto("about:blank")}if(d){if(T=await or(h,t,d,{baseURL:o,actionTimeoutMs:i,retries:a,pollIntervalMs:c,uniqueSeed:y,trace:x,onFailure:E,keepResponseLog:!0}),T.status==="passed")return{scenario:t,status:"green",source:"cache",stepResults:T.stepResults,adaptations:[],usage:Kt,durationMs:Date.now()-r};if(w)return T.responses?.detach(),$(T);if(f){let D=Ut(),H=await this.proofReplay(t,f.cache,D,"pending");if(H.result.status==="passed")return T.responses?.detach(),k=H.context,y=D,m(H.result,T);await H.context.close(),console.log(` \u21BA pending proposal for "${t.displayName}" did not replay`+(H.result.failure?` (step "${H.result.failure.stepText}": ${H.result.failure.error.split(`
|
|
303
|
+
`)[0]})`:"")+(T.status==="failed"?"; healing":"; recording again"))}}if(!(this.options.agent!==void 0&&this.cdpEndpoint!==void 0&&!n.agentDisabled)){let D=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 T?.responses?.detach(),{scenario:t,status:"red",source:"cache",stepResults:T?.stepResults??[],adaptations:[],usage:Kt,durationMs:Date.now()-r,error:d?T?.status==="stale"?`Cache is stale (feature file changed) and ${D}`:`Replay failed and ${D} ${T?.failure?.error??""}`.trimEnd():`No cache exists and ${D}`}}let j=d!==void 0&&T!==void 0&&T.status==="failed",L=0,_,F;try{if(j){F=T.responses;let D=await xs(this.browser,T.activePage??yr(h.context(),T.failure.pageUrl)??h);_=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,traceContext:x?h.context():void 0,mode:"heal",liveTab:D,model:this.options.healModel,cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:T.stepResults.filter(H=>H.status==="passed").map(H=>H.gherkin),failedStep:{text:T.failure.stepText,kind:T.failure.kind,error:T.failure.error,pageUrl:T.failure.pageUrl,stepIndex:T.failure.stepIndex},uniqueSeed:y,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let H=Ln(this.browser)??h;return sn(H)}}))}else if(await h.goto("about:blank"),this.options.reuseSteps!==!1){let D=await this.recordWithSeeding(t,h,y);L=D.seededCount,_=D}else _=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:o,traceContext:x?h.context():void 0,mode:"record",cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,uniqueSeed:y,onTabOpened:this.tabWatcher(),takeFingerprint:async()=>{let D=Ln(this.browser)??h;return sn(D)}}))}catch(D){F?.detach();let H=D instanceof Error?D.message:String(D);return{scenario:t,status:"red",source:j?"agent-heal":"agent-record",stepResults:T?.stepResults??[],adaptations:[],usage:Kt,durationMs:Date.now()-r,error:`Agent invocation failed: ${H.split(`
|
|
304
|
+
`)[0]}`}}if(_.success&&j&&T.failure.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||ud(d.steps,T.failure.stepIndex))){let D=d.steps[T.failure.stepIndex],H=Ln(this.browser)??h,V=new Map(Object.entries(T.captured)),W=Uo(t,T.failure.stepIndex);try{for(let ce of D.actions)await os(H,ce,W,this.options.actionTimeoutMs??5e3,o,V,F,void 0,Fe(t))}catch(ce){let st=ce instanceof Error?ce.message:String(ce),Te=`Assertion failed as written: "${D.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: ${st.split(`
|
|
305
|
+
`)[0]}`,wn=T.failure.stepIndex,bt=T.stepResults.map((ot,jt)=>jt===wn?{...ot,status:"failed",error:Te}:ot);return{scenario:t,status:"red",source:"agent-heal",stepResults:bt,executionStepResults:bt.map((ot,jt)=>jt>wn&&ot.status==="skipped"?{...ot,status:"passed"}:ot),narrative:_.narrative,adaptations:_.adaptations,suggestedFeatureEdit:_.suggestedFeatureEdit,usage:_.usage,durationMs:Date.now()-r,error:Te}}finally{F?.detach()}}return F?.detach(),await this.finalizeAgentRun(t,_,j,d,r,L,j?T?.failure:void 0,y)})(),O=A.find(T=>T.kind==="before-heal");if(P.source==="cache"&&O){let T=O.file.replace(/before-heal\.jpg$/,"failure.jpg");try{mt.renameSync(wt.join(s,O.file),wt.join(s,T)),O.file=T}catch{}O.kind="failure"}else if(P.status==="red"){let T=P.stepResults.find(j=>j.status==="failed")?.gherkin,C=await this.screenshot(Ln(this.browser)??h,t,"failure",T);C&&A.push(C)}let M=On(ze(t),y);if(M.size>0&&(P.uniques=Object.fromEntries(M)),A.push(...P.evidence??[]),x){let C=S==="on"||P.status!=="green"?wt.join(this.artifacts(t),_o):void 0;b=!0;let j=k??v;C||await Co(j).catch(()=>{});let L=C?await Po(j,C,ct(t)):void 0;if(C&&L){this.claimArtifactDir(t);let _=ct(t);A.unshift({kind:"trace",file:Ae(s,C),sha256:L,traceSteps:(P.executionStepResults??P.stepResults).map(F=>({...F,error:F.error&&et(F.error,_)}))})}}return A.length>0&&(P.status!=="green"||A.some(T=>T.kind==="trace"))&&(P.evidence=A),P}finally{b||await Co(v).catch(()=>{}),await k?.close(),await v.close()}}async finalizeAgentRun(t,n,r,s,o,i=0,a,c=Ut()){let l=r?"agent-heal":"agent-record",d=n.success?n.recordedSteps.map(_=>({gherkin:_.gherkin,kind:_.kind,status:"passed"})):vd(n.recordedSteps,n.announcedSteps??[],a&&{stepIndex:a.stepIndex,error:a.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?gd(s.steps,n.recordedSteps,this.options.assertionPolicy==="adaptable-mid",a?{stepIndex:a.stepIndex,actionIndex:a.actionIndex}:void 0):n.recordedSteps,u=[...n.adaptations,...r&&a?hd(n.recordedSteps,a.stepIndex):[]],f={...n.usage},g,w,R,$,m=[],v=0,S=new Set,x,b=async(_,F,D=F.stepResults)=>{if(!S.has(_)){S.add(_);try{if(this.options.trace&&this.options.trace!=="off"){let H=wt.join(this.artifacts(t),`proof-${++v}.zip`),V=ct(t),W=await Po(_,H,V);if(W){this.claimArtifactDir(t);let ce=F.captured[Ke],st=ce?On(ze(t),ce):new Map;m.push({kind:"trace",proof:!0,file:Ae(this.options.projectRoot,H),sha256:W,traceSteps:D.map(Te=>({...Te,error:Te.error&&et(Te.error,V)})),traceUniques:st.size?Object.fromEntries(st):void 0})}}}finally{await _.close()}}};if(this.options.verifyProposals!==!1){let _=await this.proofReplay(t,this.buildCandidateCache(t,p),Ut());$=_.result.captured[Ke];try{if(_.result.status!=="passed"&&_.result.failure){let F=_.result.failure;if(this.options.agent&&!(r&&F.kind==="assertion"&&(this.options.assertionPolicy!=="adaptable-mid"||ud(s.steps,F.stepIndex))))try{let H=await xs(this.browser,_.result.activePage??yr(_.page.context(),F.pageUrl)??_.page),V=await this.withDialogsVisible(()=>this.options.agent.run({scenario:t,baseURL:this.options.baseURL,traceContext:this.options.trace&&this.options.trace!=="off"?_.context:void 0,mode:"refine",model:r?this.options.healModel:void 0,cdpEndpoint:this.cdpEndpoint,projectRoot:this.options.projectRoot,assertionPolicy:this.options.assertionPolicy,completedSteps:_.result.stepResults.filter(W=>W.status==="passed").map(W=>W.gherkin),failedStep:{text:F.stepText,kind:F.kind,error:F.error,pageUrl:F.pageUrl,stepIndex:F.stepIndex},liveTab:H,uniqueSeed:_.result.captured[Ke],onTabOpened:this.tabWatcher(),takeFingerprint:async()=>sn(Ln(this.browser)??_.page)}));f=md(f,V.usage),x=Qh(_.result.stepResults,F,V),V.success&&(u.push(...V.adaptations),u.push(...hd(V.recordedSteps,F.stepIndex)),p=r?gd(p,V.recordedSteps,this.options.assertionPolicy==="adaptable-mid",{stepIndex:F.stepIndex,actionIndex:0}):Zh(p,V.recordedSteps,F.stepIndex),await b(_.context,_.result,x),x=void 0,_=await this.proofReplay(t,this.buildCandidateCache(t,p),Ut()),$=_.result.captured[Ke])}catch{}}for(let F of _.result.tabOpeners??[]){let D=p[F.stepIndex]?.actions[F.actionIndex];D&&(D.opensTab=!0)}g=_.result.status==="passed",g||(w=_.result.failure?.error??`proof replay ${_.result.status}`,R=_.result.stepResults)}finally{await b(_.context,_.result,x).catch(()=>{})}}r&&s&&u.push(...Yh(s.steps,p,a,this.options.baseURL));let k=[...n.featureEdits];for(let _ of Da(p,lt(this.options.projectRoot)))u.push(`duplicate wording: "${_.from}" recorded exactly the same actions as the existing step "${_.to}" (${_.feature} \u203A ${_.scenario}). Accept with --with-feature-edit to converge on the canonical wording.`),k.some(F=>F.index===_.index)||k.push({index:_.index,text:_.to});let h=cd(p);h.length>0&&(g=!1,w=[w,...h].filter(Boolean).join(" "));let y=ct(t),A=_=>y.size>0?et(_,y):_;u=u.map(A);let E=k.map(_=>({index:_.index,text:A(_.text)})),P=A(n.narrative),O=n.suggestedFeatureEdit?A(n.suggestedFeatureEdit):void 0,M={version:1,feature:t.featurePath,scenario:t.name,recordedAt:new Date().toISOString(),recordedBy:"agent",steps:p,...pd(t)},T={meta:{createdAt:M.recordedAt,mode:r?"heal":"record",narrative:P,adaptations:u,suggestedFeatureEdit:O,featureEdits:E.length>0?E:void 0,verified:g,proofError:w?A(w):void 0,seededSteps:i>0?i:void 0,recordedFor:t.displayName,usage:f,bound:Wc(t,s,M,E.length>0?E:void 0)},cache:M},C=qe(this.options.projectRoot,t.featurePath,t.name),j=Wa(C,T);Ya(this.options.projectRoot,C,j);let L=Pe(se(this.options.projectRoot,t.featurePath,t.name));return{proposalDiff:Ht(L,T.cache),scenario:t,status:"yellow",source:l,stepResults:R??d,executionStepResults:d,evidence:m,proofUniques:(()=>{let _=$?On(ze(t),$):void 0;return _&&_.size>0?Object.fromEntries(_):void 0})(),narrative:P,adaptations:u,suggestedFeatureEdit:O,featureEdits:E.length>0?E:void 0,verified:g,proofError:w,seededSteps:i>0?i:void 0,proposalFile:Ae(this.options.projectRoot,C),proposalRevision:j,usage:f,durationMs:Date.now()-o}}};import Fs from"node:fs";import Zm from"node:os";import Gd from"node:path";import{query as xm}from"@anthropic-ai/claude-agent-sdk";import{McpServer as Xh}from"@modelcontextprotocol/sdk/server/mcp.js";import{z as Y}from"zod";var Ie=e=>({content:[{type:"text",text:e}]});function Dn(e,t,n,r){let s=async o=>{let i=await r(o);return i.content[0]?.text.startsWith("ERROR:")?{...i,isError:!0}:i};return o=>{o.registerTool(e,{description:t,inputSchema:n},s)}}function em(e){let t=new Xh({name:e.name,version:e.version});for(let n of e.tools)n(t);return{type:"sdk",name:e.name,instance:t}}function Sd(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 tm=new Set(["main","banner","contentinfo","status","alert","alertdialog","dialog","search"]);function xd(e){if(!e?.role)return;let{role:t,frame:n,...r}=e;if(!(Object.keys(r).length>0||tm.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 nm(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 $d=new WeakSet;function rm(e,t){if(t.success&&!$d.has(e)){let n=[...e.announced].filter(r=>r>=e.firstStep&&e.steps[r]?.kind==="assertion"&&!e.steps[r].actions.some(s=>He.has(s.action))).sort((r,s)=>r-s);if(n.length>0)return $d.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??[]).filter(n=>!ao.test(n.text)),"Run finalized."}var kd={role:Y.string().optional().describe("ARIA role of the element"),name:Y.string().optional().describe("Accessible name \u2014 use the SHORTEST stable substring; never include volatile content like prices or dates"),nameRegex:Y.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:Y.string().optional().describe("Form-label text for getByLabel \u2014 the literal <label> text only, never a free-text description"),text:Y.string().optional().describe("getByText locator text"),selector:Y.string().optional().describe("CSS fallback selector"),frame:Y.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 ks(e){return em({name:"saffron",version:"0.1.0",tools:[Dn("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:Y.number().int().min(0),text:Y.string(),adaptation:Y.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=>Ie(nm(e,t))),Dn("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:Y.string().describe("Name to store the captured text under"),...kd},async t=>{let n=Sd(t);if(!n)return Ie("ERROR: capture needs a target (role/name, nameRegex, label, text, or selector).");let r=xd(n);return r?Ie(r):(e.addActions([{action:"captureText",target:n,saveAs:t.saveAs}]),Ie(`Capture recorded as "${t.saveAs}".`))}),Dn("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:Y.boolean().describe("true = accept/OK the dialog, false = dismiss/cancel"),promptText:Y.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}:{}}),Ie(`Dialog handling recorded (${t.accept?"accept":"dismiss"}).`))),Dn("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:Y.string().describe("Regex matched against the response URL \u2014 path-only for portability"),method:Y.string().optional().describe("HTTP method to match (any if omitted)"),status:Y.string().optional().describe("Expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:Y.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}:{}}]),Ie("Network wait recorded."))),Dn("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:Y.enum(["visible","notVisible","text","url","value","differs","matches","attribute","response","each"]),...kd,expected:Y.string().optional().describe("Expected text/value for kind=text|value \u2014 stable text only, never volatile values"),url:Y.string().optional().describe("URL substring for kind=url (path only, no host)"),pattern:Y.string().optional().describe("kind=matches|attribute: regex the target's text (or attribute) must match, e.g. 'NOK [\\\\d,]+' for a price"),attribute:Y.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:Y.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:Y.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:Y.string().optional().describe("kind=response: HTTP method to match (any if omitted)"),status:Y.string().optional().describe("kind=response: expected status \u2014 exact ('201') or class ('2xx'); default any 2xx"),bodyPattern:Y.string().optional().describe("kind=response: regex the response body must match (verify via browser_network_requests first)"),each:Y.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:Y.string().optional().describe("kind=differs: name of the captured value to compare against")},async t=>{let n=Sd(t),r=xd(n);if(r)return Ie(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 Ie("ERROR: kind=differs requires compareTo.");if(!t.capture&&!n)return Ie("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 Ie("ERROR: kind=matches requires pattern.");s={action:"expectMatches",target:n,pattern:t.pattern};break;case"attribute":if(!t.attribute||!t.pattern)return Ie("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 Ie("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 Ie("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?Ie("ERROR: assertion needs a target (role/name, nameRegex, label, text, or selector)."):(e.addActions([s]),Ie("Assertion recorded."))}),Dn("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:Y.boolean(),narrative:Y.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:Y.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:Y.array(Y.object({index:Y.number().int().min(0).describe("zero-based step index"),text:Y.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=>Ie(rm(e,t)))]})}function Ts(e={}){let t=e.deliberateSnapshots?`
|
|
298
306
|
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.
|
|
299
307
|
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.
|
|
300
308
|
|
|
@@ -312,9 +320,9 @@ Non-negotiable rules:
|
|
|
312
320
|
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.
|
|
313
321
|
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.
|
|
314
322
|
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.
|
|
315
|
-
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 ${
|
|
323
|
+
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 ${$o.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.
|
|
316
324
|
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.
|
|
317
|
-
7. Never ask the user questions; you are unattended.${t}`}function
|
|
325
|
+
7. Never ask the user questions; you are unattended.${t}`}function sm(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 om=`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 Rs(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 Rd(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 Es(e){let{scenario:t}=e,n=Fe(t),r=t.steps.map((o,i)=>{let a=u=>Br(ya(Pn(u,e.uniqueSeed??"")),n),c=`${i}. [${sm(t.steps,i)}] ${o.keyword} ${a(o.resolvedText)}`,l=o.resolvedTable??o.table,d=o.resolvedDocString??o.docString,p=c;return l&&(p+=`
|
|
318
326
|
${l.map(u=>` | ${u.map(a).join(" | ")} |`).join(`
|
|
319
327
|
`)}`),d!==void 0&&(p+=`
|
|
320
328
|
"""
|
|
@@ -322,28 +330,28 @@ ${a(d).split(`
|
|
|
322
330
|
`).map(u=>` ${u}`).join(`
|
|
323
331
|
`)}
|
|
324
332
|
"""`),p}).join(`
|
|
325
|
-
`),s=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",r,""];if(e.mode==="refine")s.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.",
|
|
333
|
+
`),s=[`Feature: ${t.featureName} (${t.featurePath})`,`Scenario: ${t.displayName}`,e.baseURL?`Base URL: ${e.baseURL}`:void 0,"","Steps (index. [kind] keyword text):",r,""];if(e.mode==="refine")s.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.",Rs(e),"",`Steps that replayed fine:
|
|
326
334
|
${(e.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
|
|
327
|
-
`)||" (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.",
|
|
335
|
+
`)||" (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.",Rd(e));else if(e.mode==="heal")s.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.",Rs(e),"",`Steps already completed by replay:
|
|
328
336
|
${(e.completedSteps??[]).map(o=>` \u2713 ${o}`).join(`
|
|
329
|
-
`)||" (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)?",
|
|
337
|
+
`)||" (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)?",om,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.",Rd(e));else if(e.mode==="record"&&e.recordOnly){let{from:o,to:i}=e.recordOnly;s.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):
|
|
330
338
|
${e.completedSteps.map(a=>` \u2713 ${a}`).join(`
|
|
331
|
-
`)}`:"You are starting from a blank page.",
|
|
332
|
-
`),"",`The browser currently shows the state after those ${o} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,
|
|
333
|
-
`)}import{query as
|
|
334
|
-
`);if(!R.includes(Al))throw new Error(R.replace(/^### Error\s*/,"").trim()||"the browser backend did not start")}catch(m){throw await Promise.allSettled([T.close(),k?.close()]),b(!1),m}let v=Promise.resolve(),S=(m,w,g=t())=>{let R=v.then(()=>Yo(e,g,m,w));return v=R.catch(()=>{}),R},x=Gs(T,(m,w,g)=>S(sh(m,w),g),s.projectRoot),$=async()=>{let m=[...p];if(m.length===0)return;let w=Promise.all(m.map(R=>R.path().then(()=>!0,()=>!0))),g=!await Promise.race([w,ts(nh).then(()=>!1)]);g&&Ko++;try{g&&(await Promise.allSettled(m.map(R=>R.cancel())),await Promise.allSettled(m.map(R=>R.failure()))),await ts(0),await ts(0)}finally{g&&Ko--}};return{config:{type:"sdk",name:"playwright",instance:x},close:async()=>{await Promise.race([v,ts(th)]),await $().catch(()=>{}),await Promise.allSettled([T.close()]),await Promise.allSettled([k.close()]),await Promise.allSettled([x.close()]),b(!0)},grouped:(m,w)=>S(m,w)}}var ah=ih(import.meta.url);function ch(){let e=ah.resolve("@playwright/mcp/package.json");return Cl.join(Cl.dirname(e),"cli.js")}var lh=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"]),dh=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 Zo(e,t){if(dh.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var wn=class e{constructor(t,n){this.ctx=t;this.snapshotMode=n;this.state=new Wr(t.scenario,{firstStep:il(t),discardBeforeAnnounce:ol(t)})}ctx;snapshotMode;state;usage={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0};resultText="";traced;capped;outputDir=wa();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=We(t.scenario);try{s.traced=await Il(t.traceContext,()=>s.currentStep(),n,i=>{let a=i instanceof Error?i.message:String(i);console.warn(`A promise rejected during the agent session and nothing handled it: ${it(a,o)}`)},{outputDir:s.outputDir,projectRoot:s.projectRoot})}catch(i){r.traceBridgeUnavailable=!0;let a=it(i instanceof Error?i.message:String(i),o);console.warn(`Agent tracing could not start (${a}). 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 va(this.playwrightStdio(),this.projectRoot),this.capped.config)}playwrightStdio(){return{command:process.execPath,args:[ch(),"--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?Yo(this.ctx.traceContext,this.currentStep(),n,t):t()}async beforeTool(t,n){if(this.state.beginToolCall(t),lh.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=pl(n,r);o&&this.state.addArmedAction(o);let i=fl(n,r,this.ctx.baseURL);if(i&&this.state.addActions([i]),n.startsWith("mcp__playwright__")){let a=al(s);a!==void 0&&this.ctx.onActiveTab?.(a),this.state.addActions(cl(s,this.ctx.baseURL)),this.state.pendingFingerprint=void 0}this.state.endToolCall(t,Zo(n,r))}failedTool(t,n,r){n.startsWith("mcp__playwright__")&&(this.state.pendingFingerprint=void 0),this.state.endToolCall(t,Zo(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(),oh.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:At(r.text,t.scenario.params)})),recordedSteps:dl(n.steps,t.scenario,t.uniqueSeed),announcedSteps:[...n.announced].sort((r,s)=>r-s),usage:this.usage}}};var ns=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 Rl();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=await wn.open(t,this.opts.snapshotMode??"none",this),r;try{r=fh({prompt:Xr(t),options:{systemPrompt:Qr({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:Yr(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=Go(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 ph from"node:http";import{randomBytes as uh,randomUUID as gh,timingSafeEqual as hh}from"node:crypto";import{Client as mh}from"@modelcontextprotocol/sdk/client/index.js";import{InMemoryTransport as yh}from"@modelcontextprotocol/sdk/inMemory.js";import{Server as wh}from"@modelcontextprotocol/sdk/server/index.js";import{StreamableHTTPServerTransport as bh}from"@modelcontextprotocol/sdk/server/streamableHttp.js";import{CallToolRequestSchema as vh,ListToolsRequestSchema as Sh}from"@modelcontextprotocol/sdk/types.js";async function xh(e,t){let n=uh(16).toString("hex"),r=Buffer.from(`Bearer ${n}`),s=c=>{let l=Buffer.from(c??"");return l.length===r.length&&hh(l,r)},o=(c,l)=>{let d=new wh({name:c,version:"1"},{capabilities:{tools:{}}});return d.setRequestHandler(Sh,p=>l.listTools(p.params)),d.setRequestHandler(vh,async p=>{let u=gh(),f=`mcp__${c}__${p.params.name}`,y=p.params.arguments??{};await t.beforeTool(u,f);try{let b=await l.callTool(p.params,void 0,{timeout:Vs});return b.isError?t.failedTool(u,f,y):t.afterTool(u,f,y,b),b}catch(b){throw t.failedTool(u,f,y),b}}),d},i=ph.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 u=o(d,p),f=new bh({sessionIdGenerator:void 0,enableJsonResponse:!0});l.on("close",()=>{Promise.allSettled([f.close(),u.close()])});try{await u.connect(f),await f.handleRequest(c,l)}catch{l.headersSent||l.writeHead(500).end()}});i.requestTimeout=0,await new Promise((c,l)=>{i.once("error",l),i.listen(0,"127.0.0.1",()=>c())});let{port:a}=i.address();return{urls:Object.fromEntries(Object.keys(e).map(c=>[c,`http://127.0.0.1:${a}/${c}`])),token:n,close:async()=>{i.closeAllConnections(),await new Promise(c=>i.close(()=>c()))}}}async function Pl(e){let[t,n]=yh.createLinkedPair();await e.connect(n);let r=new mh({name:"saffron-gateway",version:"1"});return await r.connect(t),r}async function Ol(e){let t=[];try{let n=await Pl((await e.playwrightServer()).instance);t.push(n);let r=await Pl(Yr(e.state).instance);t.push(r);let s=await xh({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 rs=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 wn.open(t,this.opts.snapshotMode??"none",this),s;try{s=await Ol(r)}catch(o){throw await r.close(),o}r.watchTabs();try{await this.drive({systemPrompt:Qr({deliberateSnapshots:r.snapshotMode==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),prompt:Xr(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 Yn(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 bn(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 vn from"node:fs";import Qo from"node:os";import nt from"node:path";import{createHash as $h}from"node:crypto";import{createRequire as jl}from"node:module";function kh(){try{let e=jl(import.meta.resolve("@openai/codex-sdk")).resolve("@openai/codex/package.json"),t=jl(e).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`),n=nt.join(nt.dirname(t),"vendor");for(let r of vn.readdirSync(n))for(let s of[nt.join(n,r,"bin","codex"),nt.join(n,r,"codex","codex")])if(vn.existsSync(s))return s}catch{}}function ss(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 Th(e){return`'${e.replaceAll("'","'\\''")}'`}function Rh(){let e=ss();if(e)throw new Error(e);let t=kh();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=nt.join(process.env.XDG_CACHE_HOME||nt.join(Qo.homedir(),".cache"),"saffron");vn.mkdirSync(n,{recursive:!0});let r=nt.join(n,`codex-${$h("sha256").update(t).digest("hex").slice(0,12)}`);return be(r,`#!/bin/sh
|
|
339
|
+
`)}`:"You are starting from a blank page.",Rs(e),"",`Record ONLY steps ${o} through ${i} (inclusive).`,`CRITICAL STOP RULE: the steps AFTER step ${i} already have recordings and will be replayed automatically the moment you stop. After completing step ${i}, immediately call saffron_done \u2014 do NOT perform, verify, or navigate into any later step. Executing a later step yourself would make the automatic replay run it TWICE.`)}else if(e.completedSteps&&e.completedSteps.length>0){let o=e.completedSteps.length;s.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(i=>` \u2713 ${i}`).join(`
|
|
340
|
+
`),"",`The browser currently shows the state after those ${o} step(s) (verify with a snapshot if unsure). Do NOT redo them and do NOT navigate away from the current state.`,Rs(e),`Start with saffron_step for step index ${o} and record from there to the end.`)}else s.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 s.filter(o=>o!==void 0).join(`
|
|
341
|
+
`)}import{query as im}from"@anthropic-ai/claude-agent-sdk";var am="usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";async function Ii(e,t=8e3){let n=e?.[am];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 cm(s)}catch{return}finally{r&&clearTimeout(r)}}function cm(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 Td(){let e=()=>{},t=new Promise(s=>e=s);async function*n(){await t}let r;try{let s=im({prompt:n(),options:{maxTurns:1,tools:[],settingSources:[],persistSession:!1}});r=s;let o=(async()=>{try{for await(let a of s);}catch{}})(),i=await Ii(s);return e(),r.close?.(),await o,i}catch{e(),r?.close?.();return}}import mm from"node:fs";import Cd from"node:path";import{createRequire as wm}from"node:module";import{createRequire as Ed}from"node:module";var lm=Ed(import.meta.url),As=Ed(lm.resolve("playwright/package.json"));import{Client as dm}from"@modelcontextprotocol/sdk/client/index.js";import{InMemoryTransport as fm}from"@modelcontextprotocol/sdk/inMemory.js";var pm=1e4,um=5e3,Ad="saffron_connection_check",_s=e=>new Promise(t=>{setTimeout(t,e).unref()}),br=new Set,Ci=0,_d=e=>{Ci===0&&[...br].at(-1)?.(e)};function gm(e){let t=n=>e(n);return br.size===0&&process.on("unhandledRejection",_d),br.add(t),()=>{br.delete(t)&&br.size===0&&process.off("unhandledRejection",_d)}}async function Pi(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 hm(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 Id(e,t,n="none",r=()=>{},s={}){let{createConnection:o,resolveCLIConfigForMCP:i}=As("playwright-core/lib/coreBundle").tools,a=await i?.({},process.env)??{},c={...a,browser:{...a.browser,isolated:!1},sharedBrowserContext:!0,imageResponses:"omit",...s.outputDir?{outputDir:s.outputDir}:{},snapshot:{...a.snapshot,mode:n}},l=e.browser(),d=new Set(l?.listeners("disconnected")??[]),p=new Set,u=k=>{p.add(k)},f=k=>{k.on("download",u)};for(let k of e.pages())f(k);e.on("page",f);let g=gm(r),w=k=>{e.off("page",f);for(let E of e.pages())E.off("download",u);for(let E of l?.listeners("disconnected")??[])d.has(E)||l.off("disconnected",E);if(!k)return g();let h=()=>{clearTimeout(A),e.off("close",y),g()},y=()=>{setTimeout(h,1e3).unref()},A=setTimeout(h,3e5);A.unref(),e.on("close",y)},R,$=new dm({name:"saffron-trace",version:"1"});try{R=await o(c,async()=>e);let[k,h]=fm.createLinkedPair();await R.connect(h),await $.connect(k);let A=((await $.callTool({name:Ad,arguments:{}})).content??[]).map(E=>E.text??"").join(`
|
|
342
|
+
`);if(!A.includes(Ad))throw new Error(A.replace(/^### Error\s*/,"").trim()||"the browser backend did not start")}catch(k){throw await Promise.allSettled([$.close(),R?.close()]),w(!1),k}let m=Promise.resolve(),v=(k,h,y=t())=>{let A=m.then(()=>Pi(e,y,k,h));return m=A.catch(()=>{}),A},S=To($,(k,h,y)=>v(hm(k,h),y),s.projectRoot),x=async()=>{let k=[...p];if(k.length===0)return;let h=Promise.all(k.map(A=>A.path().then(()=>!0,()=>!0))),y=!await Promise.race([h,_s(um).then(()=>!1)]);y&&Ci++;try{y&&(await Promise.allSettled(k.map(A=>A.cancel())),await Promise.allSettled(k.map(A=>A.failure()))),await _s(0),await _s(0)}finally{y&&Ci--}};return{config:{type:"sdk",name:"playwright",instance:S},close:async()=>{await Promise.race([m,_s(pm)]),await x().catch(()=>{}),await Promise.allSettled([$.close()]),await Promise.allSettled([R.close()]),await Promise.allSettled([S.close()]),w(!0)},grouped:(k,h)=>v(k,h)}}var ym=wm(import.meta.url);function bm(){let e=ym.resolve("@playwright/mcp/package.json");return Cd.join(Cd.dirname(e),"cli.js")}var vm=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"]),Sm=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 Oi(e,t){if(Sm.has(e))return!1;if(e==="mcp__playwright__browser_tabs"){let n=t?.action;return!(n==="list"||n==="select"||n==="close")}return!0}var Un=class e{constructor(t,n){this.ctx=t;this.snapshotMode=n;this.state=new Ss(t.scenario,{firstStep:od(t),discardBeforeAnnounce:sd(t)})}ctx;snapshotMode;state;usage={aiCalls:0,inputTokens:0,outputTokens:0,cacheReadTokens:0,cacheCreationTokens:0};resultText="";traced;capped;outputDir=cc();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=ct(t.scenario);try{s.traced=await Id(t.traceContext,()=>s.currentStep(),n,i=>{let a=i instanceof Error?i.message:String(i);console.warn(`A promise rejected during the agent session and nothing handled it: ${et(a,o)}`)},{outputDir:s.outputDir,projectRoot:s.projectRoot})}catch(i){r.traceBridgeUnavailable=!0;let a=et(i instanceof Error?i.message:String(i),o);console.warn(`Agent tracing could not start (${a}). 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 dc(this.playwrightStdio(),this.projectRoot),this.capped.config)}playwrightStdio(){return{command:process.execPath,args:[bm(),"--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?Pi(this.ctx.traceContext,this.currentStep(),n,t):t()}async beforeTool(t,n){if(this.state.beginToolCall(t),vm.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=fd(n,r);o&&this.state.addArmedAction(o);let i=dd(n,r,this.ctx.baseURL);if(i&&this.state.addActions([i]),n.startsWith("mcp__playwright__")){let a=id(s);a!==void 0&&this.ctx.onActiveTab?.(a),this.state.addActions(ad(s,this.ctx.baseURL)),this.state.pendingFingerprint=void 0}this.state.endToolCall(t,Oi(n,r))}failedTool(t,n,r){n.startsWith("mcp__playwright__")&&(this.state.pendingFingerprint=void 0),this.state.endToolCall(t,Oi(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(),mm.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:Gt(r.text,t.scenario.params)})),recordedSteps:ld(n.steps,t.scenario,t.uniqueSeed),announcedSteps:[...n.announced].sort((r,s)=>r-s),usage:this.usage}}};var Is=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 Td();return{subscriptionType:t.subscriptionType,fiveHourBefore:t.utilization,fiveHourAfter:n?.utilization,resetsAt:n?.resetsAt??t.resetsAt}}async run(t){let n=await Un.open(t,this.opts.snapshotMode??"none",this),r;try{r=xm({prompt:Es(t),options:{systemPrompt:Ts({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:ks(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=Ii(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 $m from"node:http";import{randomBytes as km,randomUUID as Rm,timingSafeEqual as Tm}from"node:crypto";import{Client as Em}from"@modelcontextprotocol/sdk/client/index.js";import{InMemoryTransport as Am}from"@modelcontextprotocol/sdk/inMemory.js";import{Server as _m}from"@modelcontextprotocol/sdk/server/index.js";import{StreamableHTTPServerTransport as Im}from"@modelcontextprotocol/sdk/server/streamableHttp.js";import{CallToolRequestSchema as Cm,ListToolsRequestSchema as Pm}from"@modelcontextprotocol/sdk/types.js";async function Om(e,t){let n=km(16).toString("hex"),r=Buffer.from(`Bearer ${n}`),s=c=>{let l=Buffer.from(c??"");return l.length===r.length&&Tm(l,r)},o=(c,l)=>{let d=new _m({name:c,version:"1"},{capabilities:{tools:{}}});return d.setRequestHandler(Pm,p=>l.listTools(p.params)),d.setRequestHandler(Cm,async p=>{let u=Rm(),f=`mcp__${c}__${p.params.name}`,g=p.params.arguments??{};await t.beforeTool(u,f);try{let w=await l.callTool(p.params,void 0,{timeout:ko});return w.isError?t.failedTool(u,f,g):t.afterTool(u,f,g,w),w}catch(w){throw t.failedTool(u,f,g),w}}),d},i=$m.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 u=o(d,p),f=new Im({sessionIdGenerator:void 0,enableJsonResponse:!0});l.on("close",()=>{Promise.allSettled([f.close(),u.close()])});try{await u.connect(f),await f.handleRequest(c,l)}catch{l.headersSent||l.writeHead(500).end()}});i.requestTimeout=0,await new Promise((c,l)=>{i.once("error",l),i.listen(0,"127.0.0.1",()=>c())});let{port:a}=i.address();return{urls:Object.fromEntries(Object.keys(e).map(c=>[c,`http://127.0.0.1:${a}/${c}`])),token:n,close:async()=>{i.closeAllConnections(),await new Promise(c=>i.close(()=>c()))}}}async function Pd(e){let[t,n]=Am.createLinkedPair();await e.connect(n);let r=new Em({name:"saffron-gateway",version:"1"});return await r.connect(t),r}async function Od(e){let t=[];try{let n=await Pd((await e.playwrightServer()).instance);t.push(n);let r=await Pd(ks(e.state).instance);t.push(r);let s=await Om({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 Cs=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 Un.open(t,this.opts.snapshotMode??"none",this),s;try{s=await Od(r)}catch(o){throw await r.close(),o}r.watchTabs();try{await this.drive({systemPrompt:Ts({deliberateSnapshots:r.snapshotMode==="none",adaptableMidAssertions:t.assertionPolicy==="adaptable-mid"}),prompt:Es(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 vr(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 zn(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 Hn from"node:fs";import ji from"node:os";import yt from"node:path";import{createHash as jm}from"node:crypto";import{createRequire as jd}from"node:module";function Nm(){try{let e=jd(import.meta.resolve("@openai/codex-sdk")).resolve("@openai/codex/package.json"),t=jd(e).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`),n=yt.join(yt.dirname(t),"vendor");for(let r of Hn.readdirSync(n))for(let s of[yt.join(n,r,"bin","codex"),yt.join(n,r,"codex","codex")])if(Hn.existsSync(s))return s}catch{}}function Ps(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 Mm(e){return`'${e.replaceAll("'","'\\''")}'`}function Fm(){let e=Ps();if(e)throw new Error(e);let t=Nm();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=yt.join(process.env.XDG_CACHE_HOME||yt.join(ji.homedir(),".cache"),"saffron");Hn.mkdirSync(n,{recursive:!0});let r=yt.join(n,`codex-${jm("sha256").update(t).digest("hex").slice(0,12)}`);return Oe(r,`#!/bin/sh
|
|
335
343
|
sub="$1"; shift
|
|
336
|
-
exec ${
|
|
337
|
-
`,493),r}var
|
|
338
|
-
`,c)}catch(l){let d=l.code;throw!i||!["EACCES","EPERM","EROFS"].includes(d??"")?l:new Error(`${e} links to ${a}, which Saffron cannot write: add ${n.join(" and ")} to permissions.allow there, then run npx saffron login antigravity again`)}return n}function
|
|
339
|
-
`).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=
|
|
344
|
+
exec ${Mm(t)} "$sub" --ignore-user-config --ephemeral "$@"
|
|
345
|
+
`,493),r}var Nd="SAFFRON_MCP_TOKEN",Lm=["cli_auth_credentials_store","forced_login_method","forced_chatgpt_workspace_id"];function Bm(e){let t=e.split(/^\s*\[/m)[0],n={};for(let r of Lm){let s=new RegExp(`^\\s*${r}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,"m").exec(t);s&&(n[r]=s[1]??s[2])}return n}function Ni(){try{return Bm(Hn.readFileSync(yt.join(process.env.CODEX_HOME??yt.join(ji.homedir(),".codex"),"config.toml"),"utf8"))}catch{return{}}}var Md=async e=>{let{Codex:t}=await vr("@openai/codex-sdk","codex"),n=Hn.mkdtempSync(yt.join(ji.tmpdir(),"saffron-codex-"));try{let r=Object.fromEntries(Object.entries(process.env).filter(l=>l[1]!==void 0)),o=new t({codexPathOverride:Fm(),env:{...r,[Nd]:e.mcpToken},config:{...Ni(),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:Nd,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"}),i=new AbortController,a=0,c;try{let{events:l}=await o.runStreamed(e.prompt,{signal:i.signal});for await(let d of l)if(d.type==="item.started"&&d.item.type==="mcp_tool_call")++a>e.maxTurns&&!i.signal.aborted&&(c=zn(e.maxTurns),i.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"&&!i.signal.aborted?c=d.error.message:d.type==="error"&&!i.signal.aborted&&(c??=d.message)}catch(l){if(!i.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{Hn.rmSync(n,{recursive:!0,force:!0})}};import{spawn as Dm,spawnSync as Um}from"node:child_process";import ge from"node:fs";import js from"node:os";import be from"node:path";import zm from"node:readline";var Os="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 Ns(){for(let e of[...(process.env.PATH??"").split(be.delimiter),be.join(js.homedir(),".local","bin")]){let t=be.join(e,process.platform==="win32"?"agy.exe":"agy");if(e&&ge.existsSync(t))return t}}var qn={playwright:"saffron_browser",saffron:"saffron_tools"},Fd=Object.values(qn).map(e=>`mcp(${e}/*)`),Mi=()=>be.join(js.homedir(),".gemini","antigravity-cli","settings.json");function Ld(e){if(!ge.existsSync(e))return{};try{let t=JSON.parse(ge.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 Ms(e=Mi()){let t;try{t=Ld(e).permissions?.allow}catch{return Fd}return Fd.filter(n=>!(Array.isArray(t)&&t.includes(n)))}function Hm(e=Mi()){let t=Ld(e),n=Ms(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]}},i=ge.lstatSync(e,{throwIfNoEntry:!1})?.isSymbolicLink()??!1,a=qm(e),c=ge.existsSync(a)?ge.statSync(a).mode&4095:void 0;try{Oe(a,JSON.stringify(o,null,2)+`
|
|
346
|
+
`,c)}catch(l){let d=l.code;throw!i||!["EACCES","EPERM","EROFS"].includes(d??"")?l:new Error(`${e} links to ${a}, which Saffron cannot write: add ${n.join(" and ")} to permissions.allow there, then run npx saffron login antigravity again`)}return n}function qm(e){let t=e;for(let n=0;n<40&&ge.lstatSync(t,{throwIfNoEntry:!1})?.isSymbolicLink();n++)t=be.resolve(ge.realpathSync(be.dirname(t)),ge.readlinkSync(t));return ge.existsSync(t)?ge.realpathSync(t):t}async function Bd(){let e=Ns();if(!e)throw new Error(`the antigravity provider needs the agy command: ${Os}`);let t=Um(e,["models"],{encoding:"utf8",timeout:6e4});if(t.status!==0){let r=`${t.stderr??""}${t.stdout??""}`.trim().split(`
|
|
347
|
+
`).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=Hm();console.log(n.length>0?`Antigravity: allowed Saffron's tools in ${Mi()}: ${n.join(", ")}. Remove those lines to take it back.`:"Antigravity: Saffron's tools are already allowed.")}var Dd=be.join(js.homedir(),".gemini","antigravity-cli","mcp"),Jm=Object.values(qn).map(e=>be.join(Dd,e)),Wm=`const path=require("path");const DIRS=${JSON.stringify(Jm)};const SERVERS=${JSON.stringify(Object.values(qn))};
|
|
340
348
|
let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let t={};try{t=JSON.parse(s).toolCall||{}}catch{}const a=t.args||{};
|
|
341
349
|
const file=typeof a.AbsolutePath==="string"?path.resolve(a.AbsolutePath):"";
|
|
342
350
|
const dir=typeof a.DirectoryPath==="string"?path.resolve(a.DirectoryPath):"";
|
|
343
351
|
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));
|
|
344
|
-
process.stdout.write(JSON.stringify(ok?{decision:"allow"}:{decision:"deny",reason:"Only Saffron's browser and saffron tools are available in a Saffron session."}))})`,
|
|
352
|
+
process.stdout.write(JSON.stringify(ok?{decision:"allow"}:{decision:"deny",reason:"Only Saffron's browser and saffron tools are available in a Saffron session."}))})`,Vm=(e,t)=>`let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let i=0;try{i=JSON.parse(s).invocationNum}catch{}
|
|
345
353
|
const stop=i+1>=${e};if(stop)require("fs").writeFileSync(${JSON.stringify(t)},"");
|
|
346
|
-
process.stdout.write(JSON.stringify(stop?{terminationBehavior:"terminate"}:{}))})`,
|
|
354
|
+
process.stdout.write(JSON.stringify(stop?{terminationBehavior:"terminate"}:{}))})`,Ud=async e=>{let t=Ns();if(!t)throw new Error(`the antigravity provider needs the agy command: ${Os}`);if(Object.keys(e.mcpServers).length>0&&Ms().length>0)throw new Error("agy does not allow Saffron's tools yet: run npx saffron login antigravity once");let n=ge.mkdtempSync(be.join(js.tmpdir(),"saffron-agy-"));try{let r=be.join(n,".agents");ge.mkdirSync(be.join(r,"agents"),{recursive:!0}),ge.writeFileSync(be.join(r,"agents","saffron.md"),`---
|
|
347
355
|
name: saffron
|
|
348
356
|
description: Saffron's browser test agent.
|
|
349
357
|
mainAgent: true
|
|
@@ -353,15 +361,15 @@ commandExecutionPolicy: off
|
|
|
353
361
|
|
|
354
362
|
${e.systemPrompt}
|
|
355
363
|
|
|
356
|
-
Your tools are MCP tools, called with call_mcp_tool: the browser_* tools are on the ${
|
|
357
|
-
`),
|
|
358
|
-
`).at(-1)||`agy exited with ${
|
|
364
|
+
Your tools are MCP tools, called with call_mcp_tool: the browser_* tools are on the ${qn.playwright} server and the saffron_* tools on the ${qn.saffron} server. Before a tool's first call, read its parameters with view_file from ${be.join(Dd,"<server>","<tool>.json")}. No other tool is available.
|
|
365
|
+
`),ge.writeFileSync(be.join(r,"mcp_config.json"),JSON.stringify({mcpServers:Object.fromEntries(Object.entries(e.mcpServers).map(([R,$])=>[qn[R]??R,{serverUrl:$,headers:{Authorization:`Bearer ${e.mcpToken}`}}]))}));let s=(R,$)=>{let m=be.join(n,R);return ge.writeFileSync(m,$),`${JSON.stringify(process.execPath)} ${JSON.stringify(m)}`};ge.writeFileSync(be.join(r,"hooks.json"),JSON.stringify({"saffron-gate":{PreToolUse:[{matcher:"",hooks:[{type:"command",command:s("gate.cjs",Wm)}]}]},"saffron-turns":{PostInvocation:[{type:"command",command:s("turns.cjs",Vm(e.maxTurns,be.join(n,"turn-limit")))}]}}));let i=["--output-format","stream-json","--agent","saffron","--print-timeout",`${Math.max(30,2*e.maxTurns)}m`,"--sandbox"];e.model&&i.push("--model",e.model);let a=Dm(t,i,{cwd:n,stdio:["pipe","pipe","pipe"]});a.stdin.on("error",()=>{}),a.stdin.end(e.prompt);let c="";a.stderr.on("data",R=>{c=(c+R).slice(-4e3)});let l=new Promise((R,$)=>a.once("error",m=>$(new Error(`Antigravity: could not start ${t}: ${m.message}. ${Os}`)))),d=new Promise(R=>a.once("close",R)),p="",u,f,g=(async()=>{for await(let R of zm.createInterface({input:a.stdout})){let $;try{$=JSON.parse(R)}catch{continue}let m=$.step_update;m?(m.state==="DONE"&&m.usage&&e.usage.aiCalls++,m.step_type==="agent_response"&&typeof m.text_delta=="string"&&(m.step_index!==u&&([p,u]=["",m.step_index]),p+=m.text_delta,e.lastOutput(p))):$.event==="result"&&(f=$.result)}})();await Promise.race([g,l]);let w=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()&&ge.existsSync(be.join(n,"turn-limit")))throw new Error(`Antigravity: ${zn(e.maxTurns)}`);if((f?.status!=="SUCCESS"||w!==0)&&!e.finished()){let R=typeof f?.error=="string"?f.error:c.trim().split(`
|
|
366
|
+
`).at(-1)||`agy exited with ${w}`;throw new Error(`Antigravity: ${R}${/auth/i.test(R)?`. ${Os}`:""}`)}}finally{ge.rmSync(n,{recursive:!0,force:!0})}};import zd from"node:fs";import Gm from"node:os";import Km from"node:path";var qd="@cursor/sdk";function Sr(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 Jd=async e=>{let t=Sr();if(t)throw new Error(t);let{Agent:n,JsonlLocalAgentStore:r}=await vr(qd,"cursor"),s=zd.mkdtempSync(Km.join(Gm.tmpdir(),"saffron-cursor-")),o=i=>n.create({model:{id:e.model??"auto"},...i?{systemPrompt:i}:{},tools:["mcp"],mcpServers:Object.fromEntries(Object.entries(e.mcpServers).map(([a,c])=>[a,{type:"http",url:c,headers:{Authorization:`Bearer ${e.mcpToken}`}}])),local:{cwd:s,settingSources:[],store:new r(s)}});try{let i=await Hd(await o(e.systemPrompt),e.prompt,e);if(i.status==="error"&&/system.?prompt/i.test(i.error?.message??"")&&(i=await Hd(await o(void 0),`${e.systemPrompt}
|
|
359
367
|
|
|
360
368
|
---
|
|
361
369
|
|
|
362
|
-
${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.error?.message??"the run failed"}${
|
|
363
|
-
`)),{calls:r,order:s,events:o}=
|
|
364
|
-
`)){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,i=o.request,a=o.response,c=o._monotonicTime;t.push({pageId:
|
|
370
|
+
${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.error?.message??"the run failed"}${Ym(i.error?.message)}`)}finally{zd.rmSync(s,{recursive:!0,force:!0})}};async function Hd(e,t,n){try{let r=await e.send(t),s="",o=new Set,i=!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&&!i&&(i=!0,await r.cancel()));let a=await r.wait();if(a.usage&&(n.usage.inputTokens+=a.usage.inputTokens,n.usage.outputTokens+=a.usage.outputTokens,n.usage.cacheReadTokens+=a.usage.cacheReadTokens,n.usage.cacheCreationTokens+=a.usage.cacheWriteTokens),a.model&&(n.usage.models=[...new Set([...n.usage.models??[],`cursor/${a.model.id}`])]),a.result&&n.lastOutput(a.result),i&&!n.finished())throw new Error(`Cursor: ${zn(n.maxTurns)}`);return a}finally{e.close()}}function Ym(e=""){return/api key|unauthenticated|unauthori[sz]ed|logged.?out/i.test(e)?". Sign in once with: npx saffron login cursor":""}async function Wd(){let e=Sr();if(e)throw new Error(e);let{Cursor:t}=await vr(qd,"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 Fi=["claude","codex","antigravity","cursor"];function Kd(e){return Fi.includes(e)}var Li={codex:Md,antigravity:Ud,cursor:Jd};function Yd(e,t){return e==="claude"?new Is(t):new Cs(Li[e],t,()=>{let n=Zd(e);return n.length>0?`${pn[e]} cannot start here: ${n.join("; ")}`:void 0})}var Ls=(...e)=>Gd.join(Zm.homedir(),...e);function Vd(e){try{return import.meta.resolve(e),!0}catch{return!1}}function Zd(e){switch(e){case"claude":return[];case"codex":{let t=Ps();return t?[t]:Vd("@openai/codex-sdk")?[]:["npm install --save-dev @openai/codex-sdk"]}case"antigravity":return Ns()?Ms().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=Sr();return t?[t]:Vd("@cursor/sdk")?[]:["npm install --save-dev @cursor/sdk"]}}}function Bi(e){let t=process.env,n=Zd(e);if(e==="claude")t.ANTHROPIC_API_KEY||t.CLAUDE_CODE_OAUTH_TOKEN||t.CLAUDE_CODE_USE_BEDROCK||t.CLAUDE_CODE_USE_VERTEX||Fs.existsSync(Ls(".claude",".credentials.json"))||Fs.existsSync(Ls(".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"&&!Ps()){let r=Gd.join(t.CODEX_HOME??Ls(".codex"),"auth.json");!["keyring","auto"].includes(Ni().cli_auth_credentials_store??"")&&!Fs.existsSync(r)&&n.push("a Codex login with your ChatGPT plan: npx @openai/codex login")}else e==="cursor"&&!Sr()&&!t.CURSOR_API_KEY&&!Fs.existsSync(Ls(".cursor","sdk","auth.json"))&&n.push("a Cursor sign-in for Saffron (once per machine): npx saffron login cursor");return n}var pn={claude:"Claude",codex:"Codex",antigravity:"Antigravity",cursor:"Cursor"};var Yt=(e,t=0)=>typeof e=="number"?e:t,re=e=>typeof e=="string"?e:void 0;function Qm(e,t){if(!t)return;let n=re(t.selector);if(n)return Xm(n);let r=re(t.url);if(r)return r;if(e==="press"||e==="keyboardPress")return re(t.key)}function Xm(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 ew(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 i=re(o.callId);switch(o.type){case"before":{if(!i)break;let a=re(o.method)??"",c=o.class==="Tracing"&&a==="tracingGroup",l=a==="__waitInfo__";t.set(i,{callId:i,method:l?re(o.title)??"wait":a,subject:Qm(a,o.params),selector:re(o.params?.selector),startTime:Yt(o.startTime),log:[],pageId:re(o.pageId),before:Di(o,"beforeSnapshot"),calls:[],parentId:re(o.parentId),isGroup:c,title:re(o.title),isWait:l}),n.push(i);break}case"input":{let a=i?t.get(i):void 0;if(a){a.input=Di({...o,pageId:a.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);a.input&&d?a.input.point=d:d&&(a.inputPoint=d)}break}case"frame-snapshot":{let a=o.snapshot,c=a?.callId?t.get(a.callId):void 0;if(c&&a?.phase&&a.pageId&&a.isMainFrame!==!1){let l=a.phase,d={pageId:a.pageId,name:`${l}@${a.callId}`,callId:a.callId,phase:l};if(c.pageId||(c.pageId=a.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 a=i?t.get(i):void 0,c=re(o.message);a&&c&&a.log.push(c);break}case"after":{let a=i?t.get(i):void 0;if(!a)break;a.endTime=Yt(o.endTime,a.startTime);let c=o.error;c?.message&&(a.error=c.message);let l=Di({...o,pageId:a.pageId},"afterSnapshot");l&&(a.after=l);break}default:break}}return{calls:t,order:n,events:r}}function Di(e,t){let n=re(e[t]),r=re(e.pageId);return n&&r?{pageId:r,name:n}:void 0}function Qd(e){return Ui(hc(e))}function Ui(e){let n=e.filter(v=>v.name.endsWith(".trace")).flatMap(v=>v.data.toString("utf8").split(`
|
|
371
|
+
`)),{calls:r,order:s,events:o}=ew(n),i=new Map;for(let v of o){let S=v.snapshot;if(v.type!=="frame-snapshot"||!S?.isMainFrame||!S.pageId||!S.frameUrl)continue;let x=S.snapshotName??(S.callId&&S.phase?`${S.phase}@${S.callId}`:void 0);x&&i.set(`${S.pageId}/${x}`,S.frameUrl)}for(let v of r.values())for(let S of[v.before,v.input,v.after])S&&(S.url=i.get(`${S.pageId}/${S.name}`));let a=[];for(let v of s){let S=r.get(v),x=S.parentId?r.get(S.parentId):void 0;x?x.calls.push(S):a.push(v)}let c=v=>{let{parentId:S,isGroup:x,title:b,isWait:k,...h}=v;return{...h,calls:h.calls.map(y=>c(y))}},l=v=>v.find(S=>S.error&&!S.isWait)?.error,d=[],p=[];for(let v of a){let S=r.get(v);if(!S.isGroup){p.push(c(S));continue}let x={title:S.title??"",startTime:S.startTime,endTime:S.endTime,error:S.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 k=[];for(let h of S.calls)if(h.isGroup){let y={title:h.title??"",startTime:h.startTime,endTime:h.endTime,error:h.error??l(h.calls),calls:h.calls.map(E=>c(E))},A=/^\[saffron:action:(\d+)\] ([\s\S]*)$/.exec(y.title);A&&(y.actionIndex=Number(A[1]),y.title=A[2]),x.actions.push(y)}else k.push(h),x.calls.push(c(h));x.error||(x.error=x.actions.find(h=>h.error)?.error??l(k)),d.push(x)}let u=[],f=new Map,g,w,R,$=Number.POSITIVE_INFINITY,m=0;for(let v of o)if(v.type==="context-options")g=re(v.title),w=re(v.browserName),R=Number(v.version)||void 0,$=Math.min($,Yt(v.monotonicTime,$));else if(v.type==="screencast-frame"){let S=re(v.file)??(re(v.sha1)?`resources/${re(v.sha1)}`:""),x={pageId:re(v.pageId)??"",file:S,sha1:S.slice(S.lastIndexOf("/")+1),timestamp:Yt(v.timestamp),width:Yt(v.width),height:Yt(v.height)};u.push(x),f.has(x.pageId)||f.set(x.pageId,{pageId:x.pageId})}else if(v.type==="event"&&v.class==="BrowserContext"&&v.method==="page"){let S=re(v.params?.pageId);S&&f.set(S,{...f.get(S),pageId:S,openedAt:Yt(v.time)})}else if(v.type==="event"&&v.class==="BrowserContext"&&v.method==="pageClosed"){let S=re(v.params?.pageId);S&&f.set(S,{...f.get(S),pageId:S,closedAt:Yt(v.time)})}else if(v.type==="frame-snapshot"){let S=v.snapshot;if(S?.pageId){let x=f.get(S.pageId)??{pageId:S.pageId};S.isMainFrame&&S.frameUrl&&(x.url=S.frameUrl),f.set(S.pageId,x)}}for(let v of r.values())$=Math.min($,v.startTime),m=Math.max(m,v.endTime??v.startTime);for(let v of u)m=Math.max(m,v.timestamp);return Number.isFinite($)||($=0),{format:R,title:g,browserName:w,startTime:$,endTime:m,steps:d,calls:p,frames:u,requests:tw(e),pages:[...f.values()],resources:e.filter(v=>v.name.startsWith("resources/")).map(v=>v.name.slice(10))}}function tw(e){let t=[];for(let n of e.filter(r=>r.name.endsWith(".network")))for(let r of n.data.toString("utf8").split(`
|
|
372
|
+
`)){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,i=o.request,a=o.response,c=o._monotonicTime;t.push({pageId:re(o.pageref),method:re(i?.method)??"GET",url:re(i?.url)??"",status:Number.isFinite(a?.status)?a?.status:void 0,mimeType:re(a?.content?.mimeType),startedAt:Date.parse(re(o.startedDateTime)??"")||0,time:typeof o.time=="number"?o.time:void 0,timestamp:typeof c=="number"?c:void 0})}return t}import Jn from"node:fs";import{createHash as nw,randomBytes as lf}from"node:crypto";import Xd from"node:fs";import Ct from"node:path";var ef="This run has no reviewable proposal identity. Run the scenario again to review from replay.",zi="The proposal does not belong to this replay.",tf=e=>e instanceof Error?e.message:String(e),nf=(e,t)=>`No pending proposal at ${Ct.relative(e,t)}: it has been accepted, rejected or removed since this run.`;function rf(e,t){let n=/ \(example \d+\)$/.exec(t.scenario),r=n?[t.scenario,t.scenario.slice(0,n.index)]:[t.scenario],s=t.proposalFile,o=a=>a.split(/[\\/]/).slice(-2).join("/"),i=Ct.win32.isAbsolute(s)?a=>o(a)===o(s):a=>Ct.resolve(a)===Ct.resolve(e,s);return r.map(a=>qe(e,t.feature,a)).find(i)}function sf(e,t,n){if(Ge(n)!==t.proposalRevision)return{available:!1,reason:"The proposal changed since this run. Open the latest run before reviewing it."};let r=a=>({available:!1,reason:a,rejectable:!0}),s;try{s=Cn(n)}catch(a){if(a instanceof le)return r(`The proposal cannot be read: ${Ct.relative(e,n)}: ${a.problem}`);throw a}let{feature:o,scenario:i}=s.cache;if(Ct.resolve(qe(e,o,i))!==Ct.resolve(n)||o!==t.feature||!(t.scenario===i||t.scenario===s.meta.recordedFor||t.scenario.startsWith(i+" (example ")))return{available:!1,reason:zi};try{if(!Xd.existsSync(Ct.resolve(e,o)))return r(`STALE: ${o} no longer exists`);let a=Le(e,pe(e),[Ct.resolve(e,o)]),c=We(e,s,a);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:Ht(Pe(se(e,o,i)),s.cache),featureEdits:s.meta.featureEdits??[]}}catch(a){return r(tf(a))}}function of(e,t){if(!t.proposalFile||!t.proposalRevision)return{available:!1,reason:ef};try{let n=rf(e,{...t,proposalFile:t.proposalFile});return n?Xd.existsSync(n)?sf(e,t,n):{available:!1,reason:nf(e,n)}:{available:!1,reason:zi}}catch(n){return{available:!1,reason:tf(n)}}}function af(e,t,n,r=!1){if(!t.proposalFile||!t.proposalRevision)throw new Error(ef);let s=rf(e,{...t,proposalFile:t.proposalFile});if(!s)throw new Error(zi);Gr(s,()=>{let o=sf(e,t,s);if(n==="reject"){if(!o.available&&!o.rejectable)throw new Error(o.reason);Zr(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.");Yr(e,s)}},()=>{throw new Error(nf(e,s))})}import rw from"node:http";import gn from"node:path";function cf(){return`<!DOCTYPE html>
|
|
365
373
|
<html lang="en">
|
|
366
374
|
<head>
|
|
367
375
|
<meta charset="utf-8">
|
|
@@ -448,7 +456,7 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
448
456
|
</head>
|
|
449
457
|
<body>
|
|
450
458
|
<header>
|
|
451
|
-
${
|
|
459
|
+
${dr(26)}
|
|
452
460
|
<div><div class="name" id="title">Saffron replay</div><div class="sub" id="subtitle"></div></div>
|
|
453
461
|
<span class="status" id="status"></span>
|
|
454
462
|
<label id="scenarioPicker" hidden>Scenario <select id="scenarioSelect" aria-label="Scenario"></select></label>
|
|
@@ -511,7 +519,7 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
511
519
|
const outcome = { green: "\u2713", yellow: "\u2726", red: "\u2717" };
|
|
512
520
|
const features = [...new Set(data.scenarios.map((s) => s.feature))];
|
|
513
521
|
$("scenarioSelect").innerHTML = features.map((f) => '<optgroup label="' + esc(f) + '">' + data.scenarios.map((s, i) => s.feature !== f ? ""
|
|
514
|
-
: '<option value="' + i + '">' + esc(outcome[s.status] ?? "\xB7") + " " + esc(s.scenario) + '</option>').join("") + '</optgroup>').join("");
|
|
522
|
+
: '<option value="' + i + '">' + esc(outcome[s.status] ?? "\xB7") + " " + esc(s.scenario) + (s.browser ? " \xB7 " + esc(s.browser) : "") + '</option>').join("") + '</optgroup>').join("");
|
|
515
523
|
$("scenarioSelect").value = String(data.scenarioIndex);
|
|
516
524
|
$("scenarioSelect").onchange = () => { location.search = "?scenario=" + $("scenarioSelect").value; };
|
|
517
525
|
$("scenarioPicker").hidden = false;
|
|
@@ -617,6 +625,8 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
617
625
|
recorded,
|
|
618
626
|
error: rs ? rs.error : ts?.error,
|
|
619
627
|
retries: rs?.retries,
|
|
628
|
+
tabs: rs?.tabs,
|
|
629
|
+
responses: rs?.responses,
|
|
620
630
|
// Not found in this trace (an older one, say): the page falls back to the last errored action.
|
|
621
631
|
failedAction: failedAction !== undefined && failedAction >= 0 ? failedAction : undefined,
|
|
622
632
|
actions: ts?.actions ?? [],
|
|
@@ -672,12 +682,14 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
672
682
|
const inOrder = (s) => s.actions.map((action, j) => ({ action, j, t: action.startTime }))
|
|
673
683
|
.concat(s.calls.map((call, j) => ({ call, j, t: call.startTime })))
|
|
674
684
|
.sort((a, b) => (a.t ?? 0) - (b.t ?? 0));
|
|
685
|
+
const tabWords = (t) => (t.how === "opened" ? "opened tab " : t.how === "closed" ? "closed a tab, on tab " : "switched to tab ") + Number(t.tab);
|
|
686
|
+
const urlPath = (url) => String(url ?? "").replace(/^https?:\\/\\/[^/]+/, "");
|
|
675
687
|
const mark = (step) => (step.healed ? "\u2726" : step.status === "passed" ? "\u2713" : step.status === "failed" ? "\u2717" : "\xB7");
|
|
676
688
|
|
|
677
689
|
$("steps").innerHTML = steps.map((s) => \`
|
|
678
690
|
<div class="step \${esc(s.status)}\${s.healed ? " healed" : ""}" data-step="\${s.index}">
|
|
679
691
|
<span class="mark" title="\${s.healed ? "healed by the agent" : ""}">\${mark(s)}</span><span class="kw">\${esc(keywordOf(s.title))}</span><span class="text">\${esc(textOf(s.title))}</span>
|
|
680
|
-
\${s.healed ? \`<div class="meta">healed by the agent</div>\` : s.proposed ? \`<div class="meta">ran the proposed recording</div>\` : s.recorded ? \`<div class="meta">recorded by the agent</div>\` : ""}\${s.retries ? \`<div class="meta">retried \xD7\${Number(s.retries)}</div>\` : ""}\${s.startTime !== undefined ? \`<div class="meta">\${ms(s.startTime)} \u2192 \${ms(s.endTime)}</div>\` : ""}
|
|
692
|
+
\${s.healed ? \`<div class="meta">healed by the agent</div>\` : s.proposed ? \`<div class="meta">ran the proposed recording</div>\` : s.recorded ? \`<div class="meta">recorded by the agent</div>\` : ""}\${s.retries ? \`<div class="meta">retried \xD7\${Number(s.retries)}</div>\` : ""}\${(s.tabs ?? []).map((t) => \`<div class="meta">\${esc(tabWords(t))}</div>\`).join("")}\${s.startTime !== undefined ? \`<div class="meta">\${ms(s.startTime)} \u2192 \${ms(s.endTime)}</div>\` : ""}
|
|
681
693
|
<div class="actions">\${inOrder(s).map((x) => x.action ? \`<div class="action \${x.action.error ? "failed" : ""}" data-step="\${s.index}" data-action="\${x.j}"><span>\${esc(x.action.title)}</span><span class="t">\${ms(x.action.startTime)}</span></div>\`
|
|
682
694
|
: \`<div class="action \${x.call.error ? "failed" : ""}" data-step="\${s.index}" data-call="\${x.j}"><span>\${esc(callLabel(x.call))}\${callSubject(x.call) ? " " + esc(callSubject(x.call)) : ""}</span><span class="t">\${ms(x.call.startTime)}</span></div>\`).join("")}</div>
|
|
683
695
|
</div>\`).join("");
|
|
@@ -859,6 +871,8 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
859
871
|
if (step.retries) parts.push(\`<div class="panel gold"><div class="panel-title gold">retries</div><div>\${step.status === "failed"
|
|
860
872
|
? \`An action in this step was retried \xD7\${Number(step.retries)} and still failed.\`
|
|
861
873
|
: \`An action in this step was retried \xD7\${Number(step.retries)} before it passed. Slow page, or a race the recording did not wait for.\`}</div></div>\`);
|
|
874
|
+
if (step.tabs?.length) parts.push(\`<div class="panel"><div class="panel-title">tabs</div>\${step.tabs.map((t) => \`<div>\${esc(tabWords(t))} \xB7 <span class="mono" title="\${esc(t.url)}">\${esc(urlPath(t.url) || t.url)}</span></div>\`).join("")}</div>\`);
|
|
875
|
+
if (step.responses?.length) parts.push(\`<div class="panel"><div class="panel-title">responses checked</div>\${step.responses.map((r) => \`<div class="mono" title="\${esc(r.url)}">\${r.action === "expectResponse" ? "expected" : "waited for"}: \${esc(r.method)} \${esc(urlPath(r.url))} \u2192 \${Number(r.status)}</div>\`).join("")}</div>\`);
|
|
862
876
|
// A polled assertion is the same call many times over: one panel per
|
|
863
877
|
// run of identical calls, with the count and the span, not a wall. Only
|
|
864
878
|
// within one row: two clicks on the same button are two actions.
|
|
@@ -907,7 +921,7 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
907
921
|
const rows = trace.requests.slice().sort((x, y) => (x.timestamp ?? 0) - (y.timestamp ?? 0)).map((r) => {
|
|
908
922
|
const inside = from !== undefined && r.timestamp !== undefined && windows.some(([f, t]) => r.timestamp >= f - 1 && r.timestamp < (t ?? f + 1));
|
|
909
923
|
const cls = r.status ? "st" + String(r.status)[0] : "";
|
|
910
|
-
return \`<tr class="\${inside ? "in" : ""}"><td class="\${esc(cls)}">\${esc(r.status)}</td><td>\${esc(r.method)}</td><td class="u" title="\${esc(r.url)}">\${esc(r.url
|
|
924
|
+
return \`<tr class="\${inside ? "in" : ""}"><td class="\${esc(cls)}">\${esc(r.status)}</td><td>\${esc(r.method)}</td><td class="u" title="\${esc(r.url)}">\${esc(urlPath(r.url))}</td><td>\${r.timestamp !== undefined ? ms(r.timestamp) : ""}</td></tr>\`;
|
|
911
925
|
});
|
|
912
926
|
$("network").innerHTML = rows.length ? \`<div class="panel"><div class="panel-title">requests \xB7 \${trace.requests.length} \xB7 in white: made during the selection or after it, before the next action</div><table class="net">\${rows.join("")}</table></div>\` : \`<div class="panel"><div class="panel-title">requests</div>No network activity was recorded.</div>\`;
|
|
913
927
|
}
|
|
@@ -954,6 +968,14 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
954
968
|
}
|
|
955
969
|
parts.push(\`<div class="panel gold"><div class="panel-title gold">what the proposal changes</div><div class="diff">\${lines.join("")}</div><div style="margin-top:8px;color:var(--muted)">Review with <span class="mono">saffron diff</span>, then <span class="mono">saffron accept</span> or <span class="mono">saffron reject</span>.</div></div>\`);
|
|
956
970
|
}
|
|
971
|
+
const u = scenario.usage;
|
|
972
|
+
if (u) {
|
|
973
|
+
const tokens = Number(u.inputTokens ?? 0) + Number(u.outputTokens ?? 0);
|
|
974
|
+
const cost = Number(u.costUsd ?? 0);
|
|
975
|
+
parts.push(\`<div class="panel"><div class="panel-title">AI cost of this scenario's run</div><div>\${Number(u.aiCalls) === 0
|
|
976
|
+
? "None: every step replayed from the recording at zero tokens."
|
|
977
|
+
: \`\${Number(u.aiCalls)} AI call\${Number(u.aiCalls) === 1 ? "" : "s"} \xB7 \${tokens.toLocaleString()} tokens in+out\${cost > 0 ? " \xB7 $" + cost.toFixed(4) + " at API rates" : ""}\${u.models?.length ? " \xB7 " + esc(u.models.join(", ")) : ""}\`}</div>\${Number(u.aiCalls) > 0 ? '<div class="panel-hint">The agent\\'s steps are marked healed or recorded; replayed steps cost nothing. Providers report cost per session, not per step.</div>' : ""}</div>\`);
|
|
978
|
+
}
|
|
957
979
|
if (scenario.uniques) parts.push(\`<div class="panel"><div class="panel-title">unique values in this run</div>\${Object.entries(scenario.uniques).map(([k, v]) => \`<div class="mono">\${esc(k)} = \${esc(v)}</div>\`).join("")}</div>\`);
|
|
958
980
|
if (scenario.screenshots?.length) parts.push(\`<div class="panel"><div class="panel-title">screenshots</div>\${scenario.screenshots.map((s) => \`<div><a href="/artifact/screenshot/\${encodeURIComponent(s.file)}\${sessionQuery}" target="_blank" style="color:var(--gold)">\${esc(s.kind)}\${s.step ? " \xB7 " + esc(s.step) : ""}</a></div>\`).join("")}</div>\`);
|
|
959
981
|
parts.push(\`<div class="panel"><div class="panel-title">this replay</div><div>\${steps.filter((s) => s.startTime !== undefined && s.status !== "observed").length} of \${total} steps ran \xB7 \${trace.frames.length} frames \xB7 \${trace.requests.length} requests \xB7 \${ms(trace.endTime)} total</div></div>\`);
|
|
@@ -1061,9 +1083,9 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
1061
1083
|
})();
|
|
1062
1084
|
</script>
|
|
1063
1085
|
</body>
|
|
1064
|
-
</html>`}var Jt=class extends Error{},ld="This trace is gone, or a later run replaced it (one that stopped before writing its report). Run the scenario again to replay it.",Wh=4;function ci(e,t){let n=Vt.join(e,t.file);try{xn.accessSync(n,xn.constants.R_OK)}catch(s){let o=s.code;if(o==="ENOENT"||o==="ENOTDIR")return;throw s}if(!vt(Vt.join(e,".saffron","artifacts"),n))throw new Error(`${t.file} is not a file under .saffron/artifacts`);let r=xn.readFileSync(n);return t.sha256&&Ue(r)!==t.sha256?void 0:r}function Gh(){let e=es.resolve("playwright-core/package.json");return Vt.join(Vt.dirname(e),"lib","vite","traceViewer")}var ut={".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"},Kh=new Set([".png",".jpeg",".jpg"]),ai={"Content-Security-Policy":"sandbox","X-Content-Type-Options":"nosniff"};function Yh(e){let t=/<script>([\s\S]*?)<\/script>/.exec(e)[1];return["default-src 'self'",`script-src 'sha256-${Jh("sha256").update(t).digest("base64")}'`,"style-src 'self' 'unsafe-inline'","worker-src 'self'","connect-src 'self'","frame-src 'self'","object-src 'none'","base-uri 'none'","form-action 'none'","frame-ancestors 'none'"].join("; ")}async function dd(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=_n(v),x=oi(S.map($=>({name:$.name,get data(){return $.read()}})));return{zip:v,model:x,resources:new Map(S.filter($=>$.name.includes("/")).map($=>[$.name,$.read]))}},s=v=>{try{return v.data?v.data:!v.read&&!xn.existsSync(v.file)?new Jt(ld):(v.read?v.read():xn.readFileSync(v.file))??new Jt(ld)}catch(S){return S instanceof Jt?S:new Jt(`This trace cannot be read: ${S instanceof Error?S.message:String(S)}`)}},o=[];{let v=t[n],S=v.traces.map(s),x=v.traces.map(($,h)=>({t:$,data:S[h]})).filter(({data:$},h)=>h===0||Buffer.isBuffer($));for(let[$,h]of v.traces.entries()){let m=S[$];$>0&&!Buffer.isBuffer(m)&&o.push({file:h.file,reason:m.message})}t[n]={...v,traces:x.map(({t:$,data:h})=>({...$,data:Buffer.isBuffer(h)?h:void 0,read:Buffer.isBuffer(h)?void 0:()=>{throw h}}))}}let i=new Map,a=(v,S)=>{let x=`${v}:${S}`,$=i.get(x);if($)i.delete(x);else{let h=s(t[v].traces[S]);if(!Buffer.isBuffer(h))throw h;$=r(h);let m=[...i.keys()].filter(w=>{let[g,R]=w.split(":").map(Number),C=t[g].traces[R];return g!==n&&(C.data!==void 0||C.checked===!0)});v!==n&&m.length>=Wh&&i.delete(m[0])}return i.set(x,$),$},c=new Map,l=(v,S)=>{let x=t[v].scenario.screenshots?.find(m=>m.file===S);if(!x)return;let $=`${v}:${S}`;if(c.has($))return c.get($);let h=an(e.projectRoot,x);return(v===n||!x.sha256)&&c.set($,h),h};try{a(n,0)}catch(v){if(t.length===1)throw v}for(let v of t[n].scenario.screenshots??[])l(n,v.file);let d=Gh(),p=ad(),u={"Content-Security-Policy":Yh(p)},f=cd(32).toString("hex"),y=cd(8).toString("hex"),b=t.map(v=>({feature:v.scenario.feature,scenario:v.scenario.scenario,status:v.scenario.status})),k=Vh.createServer((v,S)=>{let x=($,h,m,w={})=>{S.writeHead($,{"Content-Type":m,"Cache-Control":"no-store",...w}),S.end(h)};try{let $=v.headers.host??"";if(!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/i.test($))return x(403,"Invalid host","text/plain");let h=new URL(v.url??"/",`http://${$}`),m=Number(h.searchParams.get("scenario")??n),w=Number.isInteger(m)?t[m]:void 0,g=Number(h.searchParams.get("session")??"0"),R=w&&Number.isInteger(g)?w.traces[g]:void 0,C=()=>x(404,w?"No such trace session":"No such scenario","text/plain");if(h.pathname.startsWith("/api/review/")&&!e.viewOnly){if(v.method!=="POST")return x(405,"Use POST","text/plain");if(v.headers.origin!==`http://${$}`||v.headers["x-saffron-review"]!==f)return x(403,"Review must come from this replay page","text/plain");if(!w||!R)return C();let _=h.pathname.slice(12);if(_!=="accept"&&_!=="reject")return x(404,"Unknown decision","text/plain");try{return id(e.projectRoot,w.scenario,_,v.headers["x-saffron-unverified"]==="yes"),x(200,JSON.stringify({decision:_}),ut[".json"])}catch(I){return x(409,JSON.stringify({error:I instanceof Error?I.message:String(I)}),ut[".json"])}}if(v.method!=="GET"&&v.method!=="HEAD")return x(405,"Use GET","text/plain");if(h.pathname==="/"||h.pathname==="/index.html")return x(200,p,ut[".html"],u);if(h.pathname==="/api/replay"){let _=w?m:n,I=R?g:0,j=t[_],A=j.traces[I],H={scenarios:b,scenarioIndex:_,sessions:j.traces.map(P=>P.label),session:I,proof:A.proof===!0,instance:y},B;try{B=a(_,I).model}catch(P){let E=P instanceof Jt?P.message:`This trace cannot be read: ${P instanceof Error?P.message:String(P)}`;return x(200,JSON.stringify({...H,scenario:j.scenario,unavailable:E}),ut[".json"])}let N=e.viewOnly?{review:{available:!1,reason:e.viewOnly}}:{review:od(e.projectRoot,j.scenario),reviewToken:f};return x(200,JSON.stringify({...H,scenario:{...j.scenario,steps:A.steps,uniques:A.uniques},trace:B,...N}),ut[".json"])}if(h.pathname.startsWith("/artifact/")){let _=h.searchParams.get("v");if(_!==null&&_!==y)return x(410,"This replay was restarted. Reload the page.","text/plain");if(!w||!R)return C();if(h.pathname==="/artifact/trace.zip")return x(200,a(m,g).zip,ut[".zip"],ai);if(h.pathname.startsWith("/artifact/resource/")){let I=decodeURIComponent(h.pathname.slice(19)),j=a(m,g).resources,A=(j.get(I)??(I.includes("/")?void 0:j.get(`resources/${I}`)))?.();if(!A)return x(404,"no such resource","text/plain");let H=Vt.extname(h.pathname).toLowerCase();return x(200,A,Kh.has(H)?ut[H]:"application/octet-stream",ai)}if(h.pathname.startsWith("/artifact/screenshot/")){let I=l(m,decodeURIComponent(h.pathname.slice(21)));return I?x(200,I,ut[".jpg"],ai):x(404,"no such file","text/plain")}}if(h.pathname==="/trace/ping")return x(200,"ok","text/plain");if(h.pathname.startsWith("/trace/")){let _=h.pathname.slice(7)||"index.html",I=Vt.join(d,_);if(!vt(d,I))return x(404,"not found","text/plain");let j=Vt.extname(I).toLowerCase(),A=xn.readFileSync(I);return S.writeHead(200,{"Content-Type":ut[j]??"application/octet-stream","Service-Worker-Allowed":"/trace/"}),S.end(A)}x(404,"not found","text/plain")}catch{S.headersSent?S.destroy():x(400,"Invalid request","text/plain")}});return await new Promise((v,S)=>{k.once("error",S),k.listen(e.port??0,"127.0.0.1",()=>v())}),{url:`http://127.0.0.1:${k.address().port}/`,skipped:o,close:()=>new Promise(v=>k.close(()=>v()))}}import fd from"node:fs";import fs from"node:path";function ps(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 pd(e,t){let n=new Map,r=new Set,s=new Map,o=Math.min(e.steps.length,t.steps.length);for(let a=0;a<o;a++){if(e.steps[a].kind==="assertion"||t.steps[a].kind==="assertion"||e.steps[a].gherkin!==t.steps[a].gherkin)continue;let c=e.steps[a].actions,l=t.steps[a].actions;if(c.length===l.length)for(let d=0;d<c.length;d++){let p=c[d],u=l[d];if(p.action!==u.action||Ie.has(p.action)||!p.target||!u.target)continue;let f=ps(p.target),y=JSON.stringify(u.target);s.has(f)&&s.get(f)!==y&&r.add(f),s.set(f,y),f!==ps(u.target)&&n.set(f,{from:p.target,to:u.target})}}let i=[...n.entries()];return{changes:i.filter(([a])=>!r.has(a)).map(([,a])=>a),ambiguous:i.filter(([a])=>r.has(a)).map(([,a])=>a.from)}}function Zh(e){let t=fs.join(e,".saffron","cache");if(!fd.existsSync(t))return[];let n=[];for(let r of fd.readdirSync(t,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(fs.join(t,s))}return n}function ud(e,t,n,r){if(t.length===0)return[];let s=new Map,o=new Set;for(let a of t){let c=ps(a.from),l=s.get(c);l&&JSON.stringify(l.to)!==JSON.stringify(a.to)&&o.add(c),s.set(c,a)}for(let a of o)s.delete(a);let i=[];for(let a of Zh(e)){if(fs.resolve(a)===fs.resolve(n))continue;let c=wt(a);if(!c)continue;let l=[],d=!1;for(let p of c.steps){if(p.kind==="assertion")continue;let u=0;for(let f of p.actions){if(!f.target||Ie.has(f.action))continue;let y=s.get(ps(f.target));y&&(u++,r||(f.target=structuredClone(y.to),d=!0))}u>0&&l.push({gherkin:p.gherkin,count:u})}l.length>0&&(d&&sn(a,c),i.push({file:a,scenario:c.scenario,feature:c.feature,steps:l}))}return i}function us(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 gd from"node:fs";import gs from"node:path";function hd(e,t,n,r,s){let o=Te(gs.join(e,t),e,s).find(c=>c.name===n);if(!o)return`scenario "${n}" is no longer in ${t}`;let i=o.steps.map(c=>c.text);if(i.length!==r.length)return`the scenario has ${i.length} steps now and had ${r.length} when this was recorded`;let a=i.findIndex((c,l)=>c!==r[l]);return a<0?void 0:`step ${a+1} now reads "${i[a]}" and was recorded as "${r[a]}"`}function md(e,t,n,r,s,o){let i={applied:[],skipped:[],appliedIndices:[]},a=Te(gs.join(e,t),e,s).find(u=>u.name===n);if(!a){for(let u of r)i.skipped.push({index:u.index,reason:`scenario "${n}" not found in ${t}`});return i}let c=new Map;for(let u of r){let f=a.steps[u.index]?.source;if(!f||f.fromBackground)continue;let y=`${f.file}:${f.line}`;c.set(y,(c.get(y)??new Set).add(u.text))}let l=$e(a),d=new Map,p=new Set;for(let u of r){let f=a.steps[u.index];if(!f?.source){i.skipped.push({index:u.index,reason:`no step at index ${u.index}`});continue}let y=[...$s([u.text,...ks([u.text])])].filter(g=>!l.has(g));if(y.length>0){i.skipped.push({index:u.index,reason:`it would make the scenario name ${y.map(g=>`{env:${g}}`).join(", ")}, which it does not reference now; a recording can only use the environment variables its scenario names, so edit the feature file by hand if you mean it`});continue}if(o&&o[u.index]!==f.text){i.skipped.push({index:u.index,reason:`step ${u.index+1} now reads "${f.text}", not the "${o[u.index]??"(nothing)"}" this edit was written for`});continue}let{file:b,line:k,fromStepSet:T,fromBackground:v}=f.source;if(v){i.skipped.push({index:u.index,reason:`"${f.text}" is a Background step shared across scenarios; edit it manually`});continue}let S=`${b}:${k}`,x=c.get(S);if(x.size>1){i.skipped.push({index:u.index,reason:`${S}${T?` (StepSet "${T}")`:""} is reached by several steps and the edits disagree: ${[...x].map(g=>`"${g}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(p.has(S)){i.appliedIndices.push(u.index);continue}let $=d.get(b);$||($=gd.readFileSync(gs.join(e,b),"utf8").split(`
|
|
1065
|
-
`),d.set(
|
|
1066
|
-
`));return i}
|
|
1086
|
+
</html>`}var un=class extends Error{},df="This trace is gone, or a later run replaced it (one that stopped before writing its report). Run the scenario again to replay it.",sw=4;function qi(e,t){let n=gn.join(e,t.file);try{Jn.accessSync(n,Jn.constants.R_OK)}catch(s){let o=s.code;if(o==="ENOENT"||o==="ENOTDIR")return;throw s}if(!Dt(gn.join(e,".saffron","artifacts"),n))throw new Error(`${t.file} is not a file under .saffron/artifacts`);let r=Jn.readFileSync(n);return t.sha256&&rt(r)!==t.sha256?void 0:r}function ow(){let e=As.resolve("playwright-core/package.json");return gn.join(gn.dirname(e),"lib","vite","traceViewer")}var Pt={".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"},iw=new Set([".png",".jpeg",".jpg"]),Hi={"Content-Security-Policy":"sandbox","X-Content-Type-Options":"nosniff"};function aw(e){let t=/<script>([\s\S]*?)<\/script>/.exec(e)[1];return["default-src 'self'",`script-src 'sha256-${nw("sha256").update(t).digest("base64")}'`,"style-src 'self' 'unsafe-inline'","worker-src 'self'","connect-src 'self'","frame-src 'self'","object-src 'none'","base-uri 'none'","form-action 'none'","frame-ancestors 'none'"].join("; ")}async function ff(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(m=>({...m,proof:m.proof??!0}))]}],n=e.start??0;if(!t[n])throw new Error("No scenario to replay");let r=m=>{let v=Xn(m),S=Ui(v.map(x=>({name:x.name,get data(){return x.read()}})));return{zip:m,model:S,resources:new Map(v.filter(x=>x.name.includes("/")).map(x=>[x.name,x.read]))}},s=m=>{try{return m.data?m.data:!m.read&&!Jn.existsSync(m.file)?new un(df):(m.read?m.read():Jn.readFileSync(m.file))??new un(df)}catch(v){return v instanceof un?v:new un(`This trace cannot be read: ${v instanceof Error?v.message:String(v)}`)}},o=[];{let m=t[n],v=m.traces.map(s),S=m.traces.map((x,b)=>({t:x,data:v[b]})).filter(({data:x},b)=>b===0||Buffer.isBuffer(x));for(let[x,b]of m.traces.entries()){let k=v[x];x>0&&!Buffer.isBuffer(k)&&o.push({file:b.file,reason:k.message})}t[n]={...m,traces:S.map(({t:x,data:b})=>({...x,data:Buffer.isBuffer(b)?b:void 0,read:Buffer.isBuffer(b)?void 0:()=>{throw b}}))}}let i=new Map,a=(m,v)=>{let S=`${m}:${v}`,x=i.get(S);if(x)i.delete(S);else{let b=s(t[m].traces[v]);if(!Buffer.isBuffer(b))throw b;x=r(b);let k=[...i.keys()].filter(h=>{let[y,A]=h.split(":").map(Number),E=t[y].traces[A];return y!==n&&(E.data!==void 0||E.checked===!0)});m!==n&&k.length>=sw&&i.delete(k[0])}return i.set(S,x),x},c=new Map,l=(m,v)=>{let S=t[m].scenario.screenshots?.find(k=>k.file===v);if(!S)return;let x=`${m}:${v}`;if(c.has(x))return c.get(x);let b=An(e.projectRoot,S);return(m===n||!S.sha256)&&c.set(x,b),b};try{a(n,0)}catch(m){if(t.length===1)throw m}for(let m of t[n].scenario.screenshots??[])l(n,m.file);let d=ow(),p=cf(),u={"Content-Security-Policy":aw(p)},f=lf(32).toString("hex"),g=lf(8).toString("hex"),w=t.map(m=>({feature:m.scenario.feature,scenario:m.scenario.scenario,status:m.scenario.status,browser:m.scenario.browser})),R=rw.createServer((m,v)=>{let S=(x,b,k,h={})=>{v.writeHead(x,{"Content-Type":k,"Cache-Control":"no-store",...h}),v.end(b)};try{let x=m.headers.host??"";if(!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/i.test(x))return S(403,"Invalid host","text/plain");let b=new URL(m.url??"/",`http://${x}`),k=Number(b.searchParams.get("scenario")??n),h=Number.isInteger(k)?t[k]:void 0,y=Number(b.searchParams.get("session")??"0"),A=h&&Number.isInteger(y)?h.traces[y]:void 0,E=()=>S(404,h?"No such trace session":"No such scenario","text/plain");if(b.pathname.startsWith("/api/review/")&&!e.viewOnly){if(m.method!=="POST")return S(405,"Use POST","text/plain");if(m.headers.origin!==`http://${x}`||m.headers["x-saffron-review"]!==f)return S(403,"Review must come from this replay page","text/plain");if(!h||!A)return E();let P=b.pathname.slice(12);if(P!=="accept"&&P!=="reject")return S(404,"Unknown decision","text/plain");try{return af(e.projectRoot,h.scenario,P,m.headers["x-saffron-unverified"]==="yes"),S(200,JSON.stringify({decision:P}),Pt[".json"])}catch(O){return S(409,JSON.stringify({error:O instanceof Error?O.message:String(O)}),Pt[".json"])}}if(m.method!=="GET"&&m.method!=="HEAD")return S(405,"Use GET","text/plain");if(b.pathname==="/"||b.pathname==="/index.html")return S(200,p,Pt[".html"],u);if(b.pathname==="/api/replay"){let P=h?k:n,O=A?y:0,M=t[P],T=M.traces[O],C={scenarios:w,scenarioIndex:P,sessions:M.traces.map(_=>_.label),session:O,proof:T.proof===!0,instance:g},j;try{j=a(P,O).model}catch(_){let F=_ instanceof un?_.message:`This trace cannot be read: ${_ instanceof Error?_.message:String(_)}`;return S(200,JSON.stringify({...C,scenario:M.scenario,unavailable:F}),Pt[".json"])}let L=e.viewOnly?{review:{available:!1,reason:e.viewOnly}}:{review:of(e.projectRoot,M.scenario),reviewToken:f};return S(200,JSON.stringify({...C,scenario:{...M.scenario,steps:T.steps,uniques:T.uniques},trace:j,...L}),Pt[".json"])}if(b.pathname.startsWith("/artifact/")){let P=b.searchParams.get("v");if(P!==null&&P!==g)return S(410,"This replay was restarted. Reload the page.","text/plain");if(!h||!A)return E();if(b.pathname==="/artifact/trace.zip")return S(200,a(k,y).zip,Pt[".zip"],Hi);if(b.pathname.startsWith("/artifact/resource/")){let O=decodeURIComponent(b.pathname.slice(19)),M=a(k,y).resources,T=(M.get(O)??(O.includes("/")?void 0:M.get(`resources/${O}`)))?.();if(!T)return S(404,"no such resource","text/plain");let C=gn.extname(b.pathname).toLowerCase();return S(200,T,iw.has(C)?Pt[C]:"application/octet-stream",Hi)}if(b.pathname.startsWith("/artifact/screenshot/")){let O=l(k,decodeURIComponent(b.pathname.slice(21)));return O?S(200,O,Pt[".jpg"],Hi):S(404,"no such file","text/plain")}}if(b.pathname==="/trace/ping")return S(200,"ok","text/plain");if(b.pathname.startsWith("/trace/")){let P=b.pathname.slice(7)||"index.html",O=gn.join(d,P);if(!Dt(d,O))return S(404,"not found","text/plain");let M=gn.extname(O).toLowerCase(),T=Jn.readFileSync(O);return v.writeHead(200,{"Content-Type":Pt[M]??"application/octet-stream","Service-Worker-Allowed":"/trace/"}),v.end(T)}S(404,"not found","text/plain")}catch{v.headersSent?v.destroy():S(400,"Invalid request","text/plain")}});return await new Promise((m,v)=>{R.once("error",v),R.listen(e.port??0,"127.0.0.1",()=>m())}),{url:`http://127.0.0.1:${R.address().port}/`,skipped:o,close:()=>new Promise(m=>R.close(()=>m()))}}import pf from"node:fs";import Bs from"node:path";function Ds(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 uf(e,t){let n=new Map,r=new Set,s=new Map,o=Math.min(e.steps.length,t.steps.length);for(let a=0;a<o;a++){if(e.steps[a].kind==="assertion"||t.steps[a].kind==="assertion"||e.steps[a].gherkin!==t.steps[a].gherkin)continue;let c=e.steps[a].actions,l=t.steps[a].actions;if(c.length===l.length)for(let d=0;d<c.length;d++){let p=c[d],u=l[d];if(p.action!==u.action||He.has(p.action)||!p.target||!u.target)continue;let f=Ds(p.target),g=JSON.stringify(u.target);s.has(f)&&s.get(f)!==g&&r.add(f),s.set(f,g),f!==Ds(u.target)&&n.set(f,{from:p.target,to:u.target})}}let i=[...n.entries()];return{changes:i.filter(([a])=>!r.has(a)).map(([,a])=>a),ambiguous:i.filter(([a])=>r.has(a)).map(([,a])=>a.from)}}function cw(e){let t=Bs.join(e,".saffron","cache");if(!pf.existsSync(t))return[];let n=[];for(let r of pf.readdirSync(t,{recursive:!0})){let s=String(r);s.endsWith(".json")&&n.push(Bs.join(t,s))}return n}function gf(e,t,n,r){if(t.length===0)return[];let s=new Map,o=new Set;for(let a of t){let c=Ds(a.from),l=s.get(c);l&&JSON.stringify(l.to)!==JSON.stringify(a.to)&&o.add(c),s.set(c,a)}for(let a of o)s.delete(a);let i=[];for(let a of cw(e)){if(Bs.resolve(a)===Bs.resolve(n))continue;let c=Lt(a);if(!c)continue;let l=[],d=!1;for(let p of c.steps){if(p.kind==="assertion")continue;let u=0;for(let f of p.actions){if(!f.target||He.has(f.action))continue;let g=s.get(Ds(f.target));g&&(u++,r||(f.target=structuredClone(g.to),d=!0))}u>0&&l.push({gherkin:p.gherkin,count:u})}l.length>0&&(d&&Tn(a,c),i.push({file:a,scenario:c.scenario,feature:c.feature,steps:l}))}return i}function Us(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 hf from"node:fs";import zs from"node:path";function mf(e,t,n,r,s){let o=Be(zs.join(e,t),e,s).find(c=>c.name===n);if(!o)return`scenario "${n}" is no longer in ${t}`;let i=o.steps.map(c=>c.text);if(i.length!==r.length)return`the scenario has ${i.length} steps now and had ${r.length} when this was recorded`;let a=i.findIndex((c,l)=>c!==r[l]);return a<0?void 0:`step ${a+1} now reads "${i[a]}" and was recorded as "${r[a]}"`}function wf(e,t,n,r,s,o){let i={applied:[],skipped:[],appliedIndices:[]},a=Be(zs.join(e,t),e,s).find(u=>u.name===n);if(!a){for(let u of r)i.skipped.push({index:u.index,reason:`scenario "${n}" not found in ${t}`});return i}let c=new Map;for(let u of r){let f=a.steps[u.index]?.source;if(!f||f.fromBackground)continue;let g=`${f.file}:${f.line}`;c.set(g,(c.get(g)??new Set).add(u.text))}let l=Fe(a),d=new Map,p=new Set;for(let u of r){let f=a.steps[u.index];if(!f?.source){i.skipped.push({index:u.index,reason:`no step at index ${u.index}`});continue}let g=[...Xs([u.text,...eo([u.text])])].filter(y=>!l.has(y));if(g.length>0){i.skipped.push({index:u.index,reason:`it would make the scenario name ${g.map(y=>`{env:${y}}`).join(", ")}, which it does not reference now; a recording can only use the environment variables its scenario names, so edit the feature file by hand if you mean it`});continue}if(o&&o[u.index]!==f.text){i.skipped.push({index:u.index,reason:`step ${u.index+1} now reads "${f.text}", not the "${o[u.index]??"(nothing)"}" this edit was written for`});continue}let{file:w,line:R,fromStepSet:$,fromBackground:m}=f.source;if(m){i.skipped.push({index:u.index,reason:`"${f.text}" is a Background step shared across scenarios; edit it manually`});continue}let v=`${w}:${R}`,S=c.get(v);if(S.size>1){i.skipped.push({index:u.index,reason:`${v}${$?` (StepSet "${$}")`:""} is reached by several steps and the edits disagree: ${[...S].map(y=>`"${y}"`).join(" vs ")}. Decide the wording in the definition by hand`});continue}if(p.has(v)){i.appliedIndices.push(u.index);continue}let x=d.get(w);x||(x=hf.readFileSync(zs.join(e,w),"utf8").split(`
|
|
1087
|
+
`),d.set(w,x));let b=x[R-1],k=b?.match(/^(\s*)(\S+)\s+(.*)$/);if(!k||k[3]!==f.text){i.skipped.push({index:u.index,reason:`${w}:${R} no longer matches the recorded step text`});continue}let h=`${k[1]}${k[2]} ${u.text}`;x[R-1]=h,p.add(v),i.appliedIndices.push(u.index),i.applied.push({file:w,line:R,from:b.trim(),to:h.trim(),fromStepSet:$})}for(let[u,f]of d)i.applied.some(g=>g.file===u)&&hf.writeFileSync(zs.join(e,u),f.join(`
|
|
1088
|
+
`));return i}import{spawn as lw}from"node:child_process";function Ji(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 dw(e,t,n){let r=Date.now();return new Promise(s=>{let o=process.platform!=="win32",i=lw(t,{cwd:n.cwd,shell:!0,detached:o,env:{...process.env,...n.env},stdio:["ignore",process.stderr,process.stderr]}),a=!1,c=!1,l=()=>{try{o&&i.pid?process.kill(-i.pid,"SIGKILL"):i.kill("SIGKILL")}catch{}},d=setTimeout(()=>{a=!0,l()},n.timeoutMs),p=()=>{c=!0,l()};n.signal?.addEventListener("abort",p,{once:!0});let u=f=>{clearTimeout(d),n.signal?.removeEventListener("abort",p),s({phase:e,command:t,exitCode:f,durationMs:Date.now()-r,...a?{timedOut:a}:{},...c?{cancelled:c}:{}})};i.on("error",()=>u(null)),i.on("exit",f=>u(a||c?null:f))})}var Hs=e=>e.exitCode!==0;async function Wi(e,t,n){let r=[];for(let s of t){if(n.signal?.aborted)break;console.error(`saffron ${e}: ${s}`);let o=await dw(e,s,n);if(r.push(o),o.cancelled||Hs(o)&&(console.error(`saffron ${e} failed: ${Vi(o,n.timeoutMs)}`),e==="setup"))break}return r}function Vi(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}`}var fw=`
|
|
1067
1089
|
.wrap { max-width: 1180px; }
|
|
1068
1090
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 0 14px; align-items: start; }
|
|
1069
1091
|
.panel { min-width: 0; }
|
|
@@ -1105,42 +1127,42 @@ ${e.prompt}`,e)),i.status==="error"&&!e.finished())throw new Error(`Cursor: ${i.
|
|
|
1105
1127
|
details.more summary { padding: 6px 0 0; font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
|
|
1106
1128
|
details.more summary:hover { background: none; color: var(--text); }
|
|
1107
1129
|
@media (max-width: 560px) { .wrap { padding: 14px 12px 40px; } .grid { grid-template-columns: 1fr; } .kpis { grid-template-columns: repeat(2, 1fr); } }
|
|
1108
|
-
`,
|
|
1109
|
-
${
|
|
1110
|
-
${
|
|
1111
|
-
${
|
|
1112
|
-
${
|
|
1113
|
-
${
|
|
1114
|
-
${
|
|
1115
|
-
${
|
|
1116
|
-
${
|
|
1117
|
-
</section>`,d=`<div class="panel"><div class="panel-title">suite health \xB7 each scenario's latest result</div>${
|
|
1118
|
-
${
|
|
1119
|
-
${
|
|
1120
|
-
</div>`,u
|
|
1130
|
+
`,hn=(e,t)=>`${e} ${t}${e===1?"":"s"}`,Gi=e=>new Date(e).toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}),Ki=e=>new Date(e).toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"});function xr(e,t=""){let n=e.name??e.scenario;return`<div class="row-main"><div class="scn" data-f="${N(e.feature)}" data-s="${N(n)}" title="${N(`${e.feature} \u203A ${e.scenario}`)}">${N(e.scenario)}</div><div class="feat">${N(e.feature)}${e.browser?` \xB7 ${N(e.browser)}`:""}</div>${t}</div>`}function Ot(e,t,n,r,s,o=""){return`<div class="panel${o?` accent-${o}`:""}"><div class="panel-head"><div class="panel-title${n?` ${n}`:""}">${N(e)}</div>${t===void 0?"":`<span class="count${t>0&&n?` ${n}`:""}">${t}</span>`}</div>${r||`<div class="empty">${N(s)}</div>`}</div>`}function yf(e){let t=e.reduce((n,[r])=>n+r,0)||1;return`<div class="stack">${e.filter(([n])=>n>0).map(([n,r,s])=>`<span style="width:${(n/t*100).toFixed(2)}%;background:${s??`var(${r})`}"></span>`).join("")}</div>`}function bf(e,t,n,r){if(e.length===0)return'<div class="empty">No runs yet.</div>';let s=Math.max(1,...e.map(t)),o=10,i=e.map((a,c)=>{let l=Math.max(2,t(a)/s*62);return`<rect x="${c*o+1.5}" y="${(64-l).toFixed(1)}" width="${o-3}" height="${l.toFixed(1)}" rx="1.5" fill="var(${r(a)})"><title>${N(n(a))}</title></rect>`}).join("");return`<svg class="bars" viewBox="0 0 ${e.length*o} 64" preserveAspectRatio="none" aria-hidden="true">${i}</svg><div class="axis"><span>${N(Ki(e[0].startedAt))}</span><span>${N(Ki(e.at(-1).startedAt))}</span></div>`}var $r=e=>e.length?`<ul class="rows">${e.join("")}</ul>`:"";function vf(e,t){let n=e.suite,r=n.green+n.yellow+n.unverified+n.red,s=r===0?void 0:Math.round((n.green+n.yellow)/r*100),o=x=>x>0?` \xB7 ${x} unverified`:"",i=n.red,a=e.lastRun,c=s===void 0?"":s>=95?"green":s>=80?"gold":"red",l=`<section class="kpis">
|
|
1131
|
+
${ye("tests",String(n.tests),`${hn(n.scenarios,"scenario")} \xB7 ${hn(n.features,"feature")}`)}
|
|
1132
|
+
${ye("passing",s===void 0?"\u2013":`${s}<span class="unit">%</span>`,`${n.green+n.yellow} of ${r} \xB7 latest results${o(n.unverified)}`,c,"",Mn(e.trend.map(x=>x.passRate),"var(--green)"))}
|
|
1133
|
+
${ye("failing",String(i),i>0?"\u25BC red the last time they ran":"all clear",i>0?"red":"",i>0?"red":"green")}
|
|
1134
|
+
${ye("flaky",String(e.flaky.length),e.flaky.length>0?"pass and fail by turns on one base URL":"none in 20 runs",e.flaky.length>0?"gold":"",e.flaky.length>0?"gold":"green")}
|
|
1135
|
+
${ye("never run",String(e.neverRun.length),e.neverRun.length>0?"in no recorded run":"every scenario has run",e.neverRun.length>0?"gold":"",e.neverRun.length>0?"gold":"green")}
|
|
1136
|
+
${ye("last run",a?_e(a.durationMs):"\u2013",a?`${hn(a.ran,"test")} \xB7 ${a.passRate}% passed${o(a.unverified)}`:"no run yet","","",Mn(e.trend.map(x=>x.durationMs),"var(--muted)"))}
|
|
1137
|
+
${ye("zero-ai replay",a?`${a.replayRate}<span class="unit">%</span>`:"\u2013","replayed from recordings, last run",a&&a.replayRate>=90?"green":"")}
|
|
1138
|
+
${ye("recorded",n.scenarios?`${Math.round(n.recorded/n.scenarios*100)}<span class="unit">%</span>`:"\u2013",n.scenarios-n.recorded>0?`${n.scenarios-n.recorded} still need the agent`:"every scenario has a current recording",n.recorded===n.scenarios?"green":"")}
|
|
1139
|
+
</section>`,d=`<div class="panel"><div class="panel-title">suite health \xB7 each scenario's latest result</div>${yf([[n.green,"--green"],[n.yellow,"--gold"],[n.unverified,"--gold","repeating-linear-gradient(135deg, var(--gold) 0 4px, var(--gold-soft) 4px 8px)"],[n.red,"--red"],[n.neverRun,"--faint"]])}<div class="legend"><span><i class="dot green"></i>${n.green} passed</span><span><i class="dot gold"></i>${n.yellow} healed</span>${n.unverified>0?`<span><i class="dot unverified"></i>${n.unverified} healed, unverified</span>`:""}<span><i class="dot red"></i>${n.red} failed</span><span><i class="dot faint"></i>${n.neverRun} never run</span></div></div>`,p=`<div class="grid">
|
|
1140
|
+
${Ot(`pass rate \xB7 last ${hn(e.trend.length,"run")}`,void 0,"",bf(e.trend,x=>x.passRate,x=>`${Gi(x.startedAt)}: ${x.passRate}% of ${x.tests} passed, ${x.red} failed`,x=>x.red>0?"--red":"--green"),"")}
|
|
1141
|
+
${Ot(`execution time \xB7 last ${hn(e.trend.length,"run")}`,void 0,"",bf(e.trend,x=>x.durationMs,x=>`${Gi(x.startedAt)}: ${_e(x.durationMs)} for ${hn(x.tests,"test")}`,()=>"--muted"),"")}
|
|
1142
|
+
</div>`,u=$r(e.recentFailures.map(x=>`<li><i class="dot ${x.stillFailing?"red":"green"}"></i>${xr(x,x.error?`<div class="err" title="${N(x.error)}">${N(x.error)}</div>`:"")}<div class="row-side">${x.stillFailing?`<span class="red">${x.streak} run${x.streak===1?"":"s"} in a row</span>`:'<span class="green">passed since</span>'}<br>${N(Ki(x.at))}</div></li>`)),f=$r(e.flaky.map(x=>`<li><i class="dot gold"></i>${xr(x)}<div class="row-side">flipped ${x.flips}\xD7 in ${x.runs} runs<br>${x.reds} red \xB7 last <span class="${x.last==="red"?"red":"green"}">${x.last==="red"?"failed":"passed"}</span></div></li>`)),g=$r(e.healing.map(x=>`<li><i class="dot gold"></i>${xr(x)}<div class="row-side">healed ${x.heals}\xD7${x.unverified>0?` <span class="gold">(${x.unverified} unverified)</span>`:""}<br>in ${x.runs} runs</div></li>`)),w=Math.max(1,...e.slowest.map(x=>x.avgMs)),R=$r(e.slowest.map(x=>`<li>${xr(x,`<div class="meter" style="width:${(x.avgMs/w*100).toFixed(1)}%"></div>`)}<div class="row-side">avg ${_e(x.avgMs)}<br>last ${_e(x.lastMs)}</div></li>`)),$=e.tags.length?`<table class="tags">${e.tags.map(x=>{let b=x.scenarios-x.neverRun,k=x.red>0?`<span style="color:var(--red)">${x.red} red</span>`:b===0?"never run":`${Math.round((x.green+x.yellow)/b*100)}%`;return`<tr><td class="tagname">${N(x.tag)}</td><td class="num">${x.scenarios}</td><td class="stackcell">${yf([[x.green,"--green"],[x.yellow,"--gold"],[x.unverified,"--gold","repeating-linear-gradient(135deg, var(--gold) 0 4px, var(--gold-soft) 4px 8px)"],[x.red,"--red"],[x.neverRun,"--faint"]])}</td><td class="num">${k}</td></tr>`}).join("")}</table>`:"",m=(x,b)=>$r(x.map(k=>`<li><i class="dot ${b}"></i>${xr(k)}</li>`)),v=e.notInLastRun.length?`<details class="more"><summary>left out by tags or paths \xB7 show ${e.notInLastRun.length}</summary>${m(e.notInLastRun,"idle")}</details>`:"",S=e.historyRuns===0?"no runs recorded yet":`${hn(e.historyRuns,"run")} in .saffron/history.jsonl`;return`<!doctype html>
|
|
1121
1143
|
<html lang="en">
|
|
1122
1144
|
<head>
|
|
1123
1145
|
<meta charset="utf-8"/>
|
|
1124
1146
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
1125
1147
|
<title>Saffron dashboard</title>
|
|
1126
|
-
${
|
|
1148
|
+
${ei}
|
|
1127
1149
|
<style>
|
|
1128
|
-
${
|
|
1150
|
+
${ti}${fw}</style>
|
|
1129
1151
|
</head>
|
|
1130
1152
|
<body>
|
|
1131
1153
|
<div class="topline"></div>
|
|
1132
1154
|
<div class="wrap">
|
|
1133
1155
|
<header class="top">
|
|
1134
1156
|
<div class="brand">
|
|
1135
|
-
${
|
|
1157
|
+
${dr(38)}
|
|
1136
1158
|
<div>
|
|
1137
1159
|
<div class="brand-name">saffron</div>
|
|
1138
1160
|
<div class="brand-sub">suite quality</div>
|
|
1139
1161
|
</div>
|
|
1140
1162
|
</div>
|
|
1141
1163
|
<div class="run-meta">
|
|
1142
|
-
<span>${
|
|
1143
|
-
${a?`<span>\xB7</span><span>last run ${
|
|
1164
|
+
<span>${N(S)}</span>
|
|
1165
|
+
${a?`<span>\xB7</span><span>last run ${N(Gi(a.startedAt))}</span>`:""}
|
|
1144
1166
|
</div>
|
|
1145
1167
|
</header>
|
|
1146
1168
|
|
|
@@ -1152,20 +1174,20 @@ ${Ro}${nm}</style>
|
|
|
1152
1174
|
|
|
1153
1175
|
<div class="section-head"><span class="section-title">stability</span></div>
|
|
1154
1176
|
<div class="grid">
|
|
1155
|
-
${
|
|
1156
|
-
${
|
|
1157
|
-
${
|
|
1158
|
-
${
|
|
1177
|
+
${Ot("recent failures \xB7 last 10 runs",e.recentFailures.length,"red",u,"Nothing failed in the last 10 runs.",i>0?"red":"")}
|
|
1178
|
+
${Ot("flaky \xB7 last 20 runs",e.flaky.length,"gold",f,"No scenario passed and failed by turns.",e.flaky.length>0?"gold":"")}
|
|
1179
|
+
${Ot("healed again and again",e.healing.length,"gold",g?`<div class="note">The page keeps changing under these: review the heals, or fix the step.</div>${g}`:"","No scenario needed the agent twice in the last 20 runs.")}
|
|
1180
|
+
${Ot("slowest \xB7 average over 20 runs",void 0,"",R,"No timings yet.")}
|
|
1159
1181
|
</div>
|
|
1160
1182
|
|
|
1161
1183
|
<div class="section-head"><span class="section-title">coverage</span></div>
|
|
1162
1184
|
<div class="grid">
|
|
1163
|
-
${
|
|
1164
|
-
${
|
|
1165
|
-
${
|
|
1185
|
+
${Ot("tags \xB7 latest results",e.tags.length,"",$,"No tags in the feature files.")}
|
|
1186
|
+
${Ot("never run",e.neverRun.length,"gold",m(e.neverRun,"faint"),"Every scenario has run at least once.")}
|
|
1187
|
+
${Ot("not in the last run",e.notInLastRun.length,"",v,"The last run ran every scenario that has run before.")}
|
|
1166
1188
|
</div>
|
|
1167
1189
|
|
|
1168
|
-
<footer>${
|
|
1190
|
+
<footer>${Xo(11)} generated by saffron v${N(t)}</footer>
|
|
1169
1191
|
</div>
|
|
1170
1192
|
<script>
|
|
1171
1193
|
if (typeof window.saffronOpen === "function") document.body.classList.add("linked");
|
|
@@ -1176,60 +1198,62 @@ ${Ro}${nm}</style>
|
|
|
1176
1198
|
</script>
|
|
1177
1199
|
</body>
|
|
1178
1200
|
</html>
|
|
1179
|
-
`}import{spawnSync as
|
|
1180
|
-
${e.stdout}`,
|
|
1181
|
-
`).map(n=>n.trim()).find(n=>n&&!/^WARNING:/.test(n))??t;function kn(e){return e.missing?{kind:"missing-tool",message:"the GitHub CLI (gh) is not installed here: install it from cli.github.com and sign in with gh auth login"}:e.timedOut?{kind:"failed",message:"gh did not finish within 10 minutes"}:/gh auth login|not logged in|authentication required/i.test(tr(e))?{kind:"signed-out",message:"gh is not signed in: run gh auth login"}:/none of the git remotes|no git remotes|not a git repository/i.test(tr(e))?{kind:"not-a-repository",message:hi(e,"not a GitHub repository")}:{kind:"failed",message:hi(e,`gh exited with ${e.status}`)}}function Pt(e){if(e.missing)return{kind:"missing-tool",message:"the Azure CLI (az) is not installed here: install it, add its azure-devops extension (az extension add --name azure-devops) and sign in (az login, or AZURE_DEVOPS_EXT_PAT)"};if(e.timedOut)return{kind:"failed",message:"az did not finish within 10 minutes"};let t=tr(e);return/azure-devops|'pipelines' is (misspelled|not in)|not in the 'az' command group/i.test(t)&&!/login/i.test(t)?{kind:"missing-tool",message:"the Azure CLI has no azure-devops extension: az extension add --name azure-devops"}:/az login|az devops login|TF400813|not authorized|authentication failed|\b401\b/i.test(t)?{kind:"signed-out",message:"az is not signed in to Azure DevOps: run az login, or set AZURE_DEVOPS_EXT_PAT"}:/--organization|--org\b|--project|must be specified|could not detect/i.test(t)?{kind:"not-a-repository",message:"cannot tell which Azure DevOps organization and project: run this in a clone of an Azure Repos repository, or set ci.organization and ci.project in saffron.config.json"}:{kind:"failed",message:hi(e,`az exited with ${e.status}`)}}function yi(e,t,n){let r=n??t.ci?.provider;if(r!==void 0){if(!rm.includes(r))throw new D(`unknown CI provider "${r}": use "github" or "azure"`);return r}return Vr(e).some(s=>s.startsWith("dev.azure.com/"))?"azure":"github"}var rr=e=>[...e.ci?.organization?["--organization",e.ci.organization]:[],...e.ci?.project?["--project",e.ci.project]:[]];function om(e,t){let n=pe("git",["rev-parse","--abbrev-ref","HEAD"],e,t),r=n.status===0?n.stdout.trim():"";return r&&r!=="HEAD"?r:void 0}function im(e){let t=new Map;for(let[n,r]of Object.entries(ft(e))){try{if(Ne(Q.join(e,n))!==r.revision)continue}catch{continue}r.from.run&&t.set(r.from.run,(t.get(r.from.run)??0)+1)}return t}function Rd(e,t=Date.now()){let n=e?t-Date.parse(e):NaN;if(!(n>=0))return;let r=Math.round(n/6e4);if(r<60)return`${r} min ago`;let s=Math.round(r/60);return s<48?`${s} h ago`:`${Math.round(s/24)} days ago`}var G=e=>typeof e=="string"&&e?e:void 0,kd=e=>G(e)?.replace(/^refs\/(heads|tags)\//,""),am="databaseId,number,attempt,displayTitle,workflowName,status,conclusion,event,headBranch,headSha,createdAt,url";function cm(e){let t=e.status==="completed"?"completed":e.status==="in_progress"?"in_progress":"queued";return{id:String(e.databaseId),name:G(e.workflowName)??"workflow",number:String(e.number??""),title:G(e.displayTitle),status:t,conclusion:t==="completed"?G(e.conclusion):void 0,event:G(e.event),branch:G(e.headBranch),commit:G(e.headSha),createdAt:G(e.createdAt),url:G(e.url),attempt:typeof e.attempt=="number"?e.attempt:void 0}}var lm={completed:"completed",inProgress:"in_progress",cancelling:"in_progress"},dm={succeeded:"success",partiallySucceeded:"partial",failed:"failure",canceled:"cancelled"},fm={individualCI:"push",batchedCI:"push",pullRequest:"pull_request",schedule:"schedule",manual:"manual"};function Ed(e){let t=e.triggerInfo??{};return kd(t["pr.sourceBranch"])??kd(e.sourceBranch)}function Ad(e){let t=e.triggerInfo??{},n=lm[String(e.status)]??"queued",r=G(e.result);return{id:String(e.id),name:G(e.definition?.name)??"pipeline",number:String(e.buildNumber??e.id),status:n,conclusion:n==="completed"&&r?dm[r]??r:void 0,event:G(e.reason)?fm[String(e.reason)]??String(e.reason):void 0,branch:Ed(e),commit:G(t["pr.sourceSha"])??G(e.sourceVersion),createdAt:G(e.queueTime),url:G((e._links??{}).web?.href)}}function mi(e){try{let t=JSON.parse(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:void 0}catch{return}}function ys(e){try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(n=>!!n&&typeof n=="object"):void 0}catch{return}}function pm(e){return new Set(e.map(t=>t.startsWith("dev.azure.com/")?t.slice(t.lastIndexOf("/_git/")+6):t.slice(t.indexOf("/")+1)))}function um(e,t,n,r,s,o,i=Math.min(Math.max(o,50),500)){for(let a=i;;a=Math.min(a*2,500)){let c=pe("az",["pipelines","runs","list","--top",String(a),...r,"--output","json",...rr(t)],e,n);if(c.missing||c.status!==0)return Pt(c);let l=ys(c.stdout);if(!l)return{kind:"failed",message:"az answered with something other than a list of runs"};let d=l.filter(s);if(d.length>=o||l.length<a||a>=500)return d}}function _d(e,t,n={}){let r=n.env??process.env,s=yi(e,t,n.provider),o=n.branch??om(e,r),i=o===void 0?void 0:$n(o),a=n.limit??20,c;if(s==="github"){let d=pe("gh",["run","list","--limit",String(a),"--json",am,...i?["--branch",i]:[]],e,r);if(d.missing||d.status!==0)return{provider:s,branch:i,runs:[],problem:kn(d)};let p=ys(d.stdout);if(!p)return{provider:s,branch:i,runs:[],problem:{kind:"failed",message:"gh answered with something other than a list of runs"}};c=p.map(cm)}else{let d=pm(Vr(e)),p=b=>{let k=G(b.repository?.name)?.toLowerCase();return d.size===0||!k||d.has(k)},u=b=>p(b)&&(!i||Ed(b)===i),f=i?[["--branch",`refs/heads/${i}`],["--branch",`refs/tags/${i}`],["--reason","pullRequest"]]:[[]],y=new Map;for(let b of f){let k=um(e,t,r,b,u,a);if(!Array.isArray(k))return{provider:s,branch:i,runs:[],problem:k};for(let T of k)y.set(String(T.id),T)}c=[...y.values()].sort((b,k)=>(Date.parse(G(k.queueTime)??"")||0)-(Date.parse(G(b.queueTime)??"")||0)).slice(0,a).map(Ad)}let l=im(e);return{provider:s,branch:i,runs:c.map(d=>({...d,pending:l.get(`${s}:${d.id}`)??0}))}}var Td=e=>`run ${e} has no Saffron bundle: none was uploaded, or it expired. Its workflow needs saffron export and an upload of saffron-bundle* (see the guide, section 19)`;function Id(e,t,n,r,s,o){let i,a;if(n==="github"){let c=pe("gh",["api",`repos/{owner}/{repo}/actions/runs/${r}`],e,s);if(c.missing||c.status!==0)throw new D(kn(c).message,kn(c).kind);let l=mi(c.stdout),d=G(l?.repository?.full_name);if(!l||String(l.id)!==r||!d)throw new D(`gh answered with something other than run ${r}`);let p=G(l.head_repository?.full_name);p?.toLowerCase()!==d.toLowerCase()&&(i={event:G(l.event),from:p});let u=l.status==="completed"?"completed":l.status==="in_progress"?"in_progress":"queued";a={provider:n,id:r,attempt:typeof l.run_attempt=="number"?l.run_attempt:1,name:G(l.name),branch:G(l.head_branch),commit:G(l.head_sha),url:G(l.html_url),status:u,conclusion:u==="completed"?G(l.conclusion):void 0,createdAt:G(l.created_at)}}else{let c=pe("az",["pipelines","runs","show","--id",r,"--output","json",...rr(t)],e,s);if(c.missing||c.status!==0)throw new D(Pt(c).message,Pt(c).kind);let l=mi(c.stdout);if(!l||String(l.id)!==r)throw new D(`az answered with something other than run ${r}`);let d=Ad(l);/^true$/i.test(String((l.triggerInfo??{})["pr.isFork"]))&&(i={event:d.event}),a={provider:n,id:r,attempt:1,name:d.name,branch:d.branch,commit:d.commit,url:d.url,status:d.status,conclusion:d.conclusion,createdAt:d.createdAt}}if(i&&!o)throw new D(`run ${r}${i.event?` (${i.event})`:""} comes from ${i.from?`the fork ${i.from}`:"a fork"}: code from outside this repository made its bundle, and could have put anything in it. Read that pull request, then import with --allow-fork if you trust it`,"fork");return a}var Cd=/^saffron-bundle[\w.-]*$/;function gm(e,t,n,r,s){let o;if(n==="github"){let i=pe("gh",["api",`repos/{owner}/{repo}/actions/runs/${r}/artifacts?per_page=100`],e,s);if(i.missing||i.status!==0)throw new D(kn(i).message,kn(i).kind);let a=mi(i.stdout)?.artifacts;o=Array.isArray(a)?a.filter(c=>!!c&&typeof c=="object"):void 0}else{let i=pe("az",["pipelines","runs","artifact","list","--run-id",r,"--output","json",...rr(t)],e,s);if(i.missing||i.status!==0)throw new D(Pt(i).message,Pt(i).kind);o=ys(i.stdout)}if(!o)throw new D(`${n==="github"?"gh":"az"} answered with something other than a list of artifacts`);return o.filter(i=>typeof i.name=="string"&&Cd.test(i.name)).map(i=>({name:i.name,sizeBytes:Number(i.size_in_bytes??((i.resource??{}).properties??{}).artifactsize)||0,expired:i.expired===!0}))}function Pd(e,t,n,r,s,o,i){if(n==="github"){let c=pe("gh",["run","download",r,"--pattern","saffron-bundle*","--dir",s],e,o);if(c.missing||c.status!==0)throw new D(/no (valid )?artifact/i.test(tr(c))?Td(r):kn(c).message);return}let a=rr(t);if(!i){let c=pe("az",["pipelines","runs","artifact","list","--run-id",r,"--output","json",...a],e,o);if(c.missing||c.status!==0)throw new D(Pt(c).message);i=(ys(c.stdout)??[]).map(l=>l.name).filter(l=>typeof l=="string"&&Cd.test(l))}if(i.length===0)throw new D(Td(r));for(let c of i){let l=pe("az",["pipelines","runs","artifact","download","--run-id",r,"--artifact-name",c,"--path",Q.join(s,c),...a],e,o);if(l.missing||l.status!==0)throw new D(`cannot download ${c}: ${Pt(l).message}`)}}function Od(e,t,n,r={}){jd(n);let s=r.env??process.env,o=yi(e,t,r.provider),i=Id(e,t,o,n,s,r.allowFork===!0),a=Nd(e,o,n,i.attempt);if(i.status==="completed"&&oe.existsSync(a))return{provider:o,dir:Q.join(a,"bundles"),remove:()=>{}};let{dir:c,remove:l}=Bo(e);try{return Pd(e,t,o,n,c,s),{provider:o,dir:c,remove:l}}catch(d){throw l(),d}}function jd(e){if(!/^\d+$/.test(e))throw new D(`--run takes the run's id, a number as saffron runs lists it, not "${e}"`)}var wi=e=>Q.join(e,".saffron","ci"),Nd=(e,t,n,r)=>Q.join(wi(e),`${t}-${n}`,`attempt-${r}`),hm=10,mm=1024**3;function ym(e){let t=0;for(let n of oe.readdirSync(e,{recursive:!0})){let r=oe.lstatSync(Q.join(e,String(n)),{throwIfNoEntry:!1});r?.isFile()&&(t+=r.size)}return t}function wm(e,t){let n=wi(e),r=oe.readdirSync(n,{withFileTypes:!0}).filter(i=>i.isDirectory()).map(i=>Q.join(n,i.name)).map(i=>({run:i,mtime:oe.statSync(i).mtimeMs,bytes:ym(i)})).sort((i,a)=>+(a.run===t)-+(i.run===t)||a.mtime-i.mtime),s=0,o=0;for(let i of r){if(i.run!==t&&(s+1>hm||o+i.bytes>mm)){oe.rmSync(i.run,{recursive:!0,force:!0});continue}s++,o+=i.bytes}}var nr=class extends D{constructor(n,r){super(`this run's results are ${Math.ceil(n/1024**2)} MB, over --max-download ${Math.round(r/1024**2)} MB. Run again with a higher --max-download to download them`,"too-large");this.sizeBytes=n}sizeBytes};function bi(e,t,n,r={}){jd(n);let s=r.env??process.env,o=yi(e,t,r.provider),i=Id(e,t,o,n,s,r.allowFork===!0);if(i.status!=="completed")return{run:i,artifacts:[],reasonKind:"running",reason:`run ${n} has not finished: its results are uploaded when it does`};let a=gm(e,t,o,n,s),c=a.filter(k=>!k.expired),l=c.map(({name:k,sizeBytes:T})=>({name:k,sizeBytes:T})),d=Nd(e,o,n,i.attempt),p=new Date;if(oe.existsSync(d))return oe.utimesSync(Q.dirname(d),p,p),{run:i,folder:d,cached:!0,artifacts:l};if(c.length===0)return a.length>0?{run:i,artifacts:l,reasonKind:"expired",reason:`run ${n}'s Saffron bundle expired: the service keeps artifacts for a limited time`}:{run:i,artifacts:l,reasonKind:"none",reason:`run ${n} uploaded no Saffron bundle. The guide's workflow exports only when a run fails; set if: always() on the export and upload steps to keep every run's results`};let u=c.reduce((k,T)=>k+T.sizeBytes,0);if(r.maxDownloadBytes!==void 0&&u>r.maxDownloadBytes)throw new nr(u,r.maxDownloadBytes);let f=wi(e);oe.mkdirSync(f,{recursive:!0});let y=Q.join(f,".gitignore");oe.existsSync(y)||oe.writeFileSync(y,`*
|
|
1182
|
-
`)
|
|
1183
|
-
`).map(t=>t.trim()).find(Boolean);async function
|
|
1184
|
-
`),
|
|
1185
|
-
`).slice(-3).join(" ")}`);return e.stdout}function
|
|
1201
|
+
`}import{spawnSync as Nf}from"node:child_process";import Qe from"node:fs";import Ff from"node:os";import fe from"node:path";import{spawnSync as Sf}from"node:child_process";import ae from"node:fs";import oe from"node:path";var pw=["github","azure"],qs={github:"GitHub Actions",azure:"Azure Pipelines"};function Wn(e){if(e.startsWith("@")||/[\x00-\x1f\x7f]/.test(e))throw new z(`${JSON.stringify(e)} is not a branch name Saffron passes on`);return e}function uw(e){let t=e.replace(/(\\*)"/g,'$1$1\\"').replace(/(\\*)$/,"$1$1");return t=`"${t}"`,t=t.replace(/[()%!^"<>&|;, ]/g,"^$&"),t.replace(/[()%!^"<>&|;, ]/g,"^$&")}function Re(e,t,n,r,s){let o=process.platform==="win32"&&e==="az",i=o?Sf("az.cmd",t.map(uw),{cwd:n,env:r,input:s,encoding:"utf8",shell:!0,windowsVerbatimArguments:!0,timeout:6e5,maxBuffer:64*1024*1024}):Sf(e,t,{cwd:n,env:r,input:s,encoding:"utf8",timeout:6e5,maxBuffer:64*1024*1024}),a=i.error?.code,c=a==="ENOENT"||o&&i.status===9009;return{status:i.status,stdout:(i.stdout??"").trim(),stderr:(i.stderr??"").trim(),missing:c,timedOut:a==="ETIMEDOUT"}}var kr=e=>`${e.stderr}
|
|
1202
|
+
${e.stdout}`,Yi=(e,t)=>kr(e).split(`
|
|
1203
|
+
`).map(n=>n.trim()).find(n=>n&&!/^WARNING:/.test(n))??t;function Vn(e){return e.missing?{kind:"missing-tool",message:"the GitHub CLI (gh) is not installed here: install it from cli.github.com and sign in with gh auth login"}:e.timedOut?{kind:"failed",message:"gh did not finish within 10 minutes"}:/gh auth login|not logged in|authentication required/i.test(kr(e))?{kind:"signed-out",message:"gh is not signed in: run gh auth login"}:/none of the git remotes|no git remotes|not a git repository/i.test(kr(e))?{kind:"not-a-repository",message:Yi(e,"not a GitHub repository")}:{kind:"failed",message:Yi(e,`gh exited with ${e.status}`)}}function mn(e){if(e.missing)return{kind:"missing-tool",message:"the Azure CLI (az) is not installed here: install it, add its azure-devops extension (az extension add --name azure-devops) and sign in (az login, or AZURE_DEVOPS_EXT_PAT)"};if(e.timedOut)return{kind:"failed",message:"az did not finish within 10 minutes"};let t=kr(e);return/azure-devops|'pipelines' is (misspelled|not in)|not in the 'az' command group/i.test(t)&&!/login/i.test(t)?{kind:"missing-tool",message:"the Azure CLI has no azure-devops extension: az extension add --name azure-devops"}:/az login|az devops login|TF400813|not authorized|authentication failed|\b401\b/i.test(t)?{kind:"signed-out",message:"az is not signed in to Azure DevOps: run az login, or set AZURE_DEVOPS_EXT_PAT"}:/--organization|--org\b|--project|must be specified|could not detect/i.test(t)?{kind:"not-a-repository",message:"cannot tell which Azure DevOps organization and project: run this in a clone of an Azure Repos repository, or set ci.organization and ci.project in saffron.config.json"}:{kind:"failed",message:Yi(e,`az exited with ${e.status}`)}}function Xi(e,t,n){let r=n??t.ci?.provider;if(r!==void 0){if(!pw.includes(r))throw new z(`unknown CI provider "${r}": use "github" or "azure"`);return r}return vs(e).some(s=>s.startsWith("dev.azure.com/"))?"azure":"github"}var Tr=e=>[...e.ci?.organization?["--organization",e.ci.organization]:[],...e.ci?.project?["--project",e.ci.project]:[]];function gw(e,t){let n=Re("git",["rev-parse","--abbrev-ref","HEAD"],e,t),r=n.status===0?n.stdout.trim():"";return r&&r!=="HEAD"?r:void 0}function hw(e){let t=new Map;for(let[n,r]of Object.entries(_t(e))){try{if(Ge(oe.join(e,n))!==r.revision)continue}catch{continue}r.from.run&&t.set(r.from.run,(t.get(r.from.run)??0)+1)}return t}function $f(e,t=Date.now()){let n=e?t-Date.parse(e):NaN;if(!(n>=0))return;let r=Math.round(n/6e4);if(r<60)return`${r} min ago`;let s=Math.round(r/60);return s<48?`${s} h ago`:`${Math.round(s/24)} days ago`}var X=e=>typeof e=="string"&&e?e:void 0,xf=e=>X(e)?.replace(/^refs\/(heads|tags)\//,""),mw="databaseId,number,attempt,displayTitle,workflowName,status,conclusion,event,headBranch,headSha,createdAt,url";function ww(e){let t=e.status==="completed"?"completed":e.status==="in_progress"?"in_progress":"queued";return{id:String(e.databaseId),name:X(e.workflowName)??"workflow",number:String(e.number??""),title:X(e.displayTitle),status:t,conclusion:t==="completed"?X(e.conclusion):void 0,event:X(e.event),branch:X(e.headBranch),commit:X(e.headSha),createdAt:X(e.createdAt),url:X(e.url),attempt:typeof e.attempt=="number"?e.attempt:void 0}}var yw={completed:"completed",inProgress:"in_progress",cancelling:"in_progress"},bw={succeeded:"success",partiallySucceeded:"partial",failed:"failure",canceled:"cancelled"},vw={individualCI:"push",batchedCI:"push",pullRequest:"pull_request",schedule:"schedule",manual:"manual"};function kf(e){let t=e.triggerInfo??{};return xf(t["pr.sourceBranch"])??xf(e.sourceBranch)}function Rf(e){let t=e.triggerInfo??{},n=yw[String(e.status)]??"queued",r=X(e.result);return{id:String(e.id),name:X(e.definition?.name)??"pipeline",number:String(e.buildNumber??e.id),status:n,conclusion:n==="completed"&&r?bw[r]??r:void 0,event:X(e.reason)?vw[String(e.reason)]??String(e.reason):void 0,branch:kf(e),commit:X(t["pr.sourceSha"])??X(e.sourceVersion),createdAt:X(e.queueTime),url:X((e._links??{}).web?.href)}}function Zi(e){try{let t=JSON.parse(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:void 0}catch{return}}function ea(e){try{let t=JSON.parse(e);return Array.isArray(t)?t.filter(n=>!!n&&typeof n=="object"):void 0}catch{return}}function Sw(e){return new Set(e.map(t=>t.startsWith("dev.azure.com/")?t.slice(t.lastIndexOf("/_git/")+6):t.slice(t.indexOf("/")+1)))}function xw(e,t,n,r,s,o,i=Math.min(Math.max(o,50),500)){for(let a=i;;a=Math.min(a*2,500)){let c=Re("az",["pipelines","runs","list","--top",String(a),...r,"--output","json",...Tr(t)],e,n);if(c.missing||c.status!==0)return mn(c);let l=ea(c.stdout);if(!l)return{kind:"failed",message:"az answered with something other than a list of runs"};let d=l.filter(s);if(d.length>=o||l.length<a||a>=500)return d}}function Tf(e,t,n={}){let r=n.env??process.env,s=Xi(e,t,n.provider),o=n.branch??gw(e,r),i=o===void 0?void 0:Wn(o),a=n.limit??20,c;if(s==="github"){let d=Re("gh",["run","list","--limit",String(a),"--json",mw,...i?["--branch",i]:[]],e,r);if(d.missing||d.status!==0)return{provider:s,branch:i,runs:[],problem:Vn(d)};let p=ea(d.stdout);if(!p)return{provider:s,branch:i,runs:[],problem:{kind:"failed",message:"gh answered with something other than a list of runs"}};c=p.map(ww)}else{let d=Sw(vs(e)),p=w=>{let R=X(w.repository?.name)?.toLowerCase();return d.size===0||!R||d.has(R)},u=w=>p(w)&&(!i||kf(w)===i),f=i?[["--branch",`refs/heads/${i}`],["--branch",`refs/tags/${i}`],["--reason","pullRequest"]]:[[]],g=new Map;for(let w of f){let R=xw(e,t,r,w,u,a);if(!Array.isArray(R))return{provider:s,branch:i,runs:[],problem:R};for(let $ of R)g.set(String($.id),$)}c=[...g.values()].sort((w,R)=>(Date.parse(X(R.queueTime)??"")||0)-(Date.parse(X(w.queueTime)??"")||0)).slice(0,a).map(Rf)}let l=hw(e);return{provider:s,branch:i,runs:c.map(d=>({...d,pending:l.get(`${s}:${d.id}`)??0}))}}var Qi=e=>`run ${e} has no Saffron bundle: none was uploaded, or it expired. Its workflow needs saffron export and an upload of saffron-bundle* (see the guide, section 19)`;function Ef(e,t,n,r,s,o){let i,a;if(n==="github"){let c=Re("gh",["api",`repos/{owner}/{repo}/actions/runs/${r}`],e,s);if(c.missing||c.status!==0)throw new z(Vn(c).message,Vn(c).kind);let l=Zi(c.stdout),d=X(l?.repository?.full_name);if(!l||String(l.id)!==r||!d)throw new z(`gh answered with something other than run ${r}`);let p=X(l.head_repository?.full_name);p?.toLowerCase()!==d.toLowerCase()&&(i={event:X(l.event),from:p});let u=l.status==="completed"?"completed":l.status==="in_progress"?"in_progress":"queued";a={provider:n,id:r,attempt:typeof l.run_attempt=="number"?l.run_attempt:1,name:X(l.name),branch:X(l.head_branch),commit:X(l.head_sha),url:X(l.html_url),status:u,conclusion:u==="completed"?X(l.conclusion):void 0,createdAt:X(l.created_at)}}else{let c=Re("az",["pipelines","runs","show","--id",r,"--output","json",...Tr(t)],e,s);if(c.missing||c.status!==0)throw new z(mn(c).message,mn(c).kind);let l=Zi(c.stdout);if(!l||String(l.id)!==r)throw new z(`az answered with something other than run ${r}`);let d=Rf(l);/^true$/i.test(String((l.triggerInfo??{})["pr.isFork"]))&&(i={event:d.event}),a={provider:n,id:r,attempt:1,name:d.name,branch:d.branch,commit:d.commit,url:d.url,status:d.status,conclusion:d.conclusion,createdAt:d.createdAt}}if(i&&!o)throw new z(`run ${r}${i.event?` (${i.event})`:""} comes from ${i.from?`the fork ${i.from}`:"a fork"}: code from outside this repository made its bundle, and could have put anything in it. Read that pull request, then import with --allow-fork if you trust it`,"fork");return a}var $w=/^saffron-bundle[\w.-]*$/;function Af(e,t,n,r,s){let o;if(n==="github"){o=[];for(let i=1;o;i++){let a=Re("gh",["api",`repos/{owner}/{repo}/actions/runs/${r}/artifacts?per_page=100&page=${i}`],e,s);if(a.missing||a.status!==0)throw new z(Vn(a).message,Vn(a).kind);let c=Zi(a.stdout)?.artifacts;if(!Array.isArray(c))o=void 0;else if(o.push(...c.filter(l=>!!l&&typeof l=="object")),c.length<100)break}}else{let i=Re("az",["pipelines","runs","artifact","list","--run-id",r,"--output","json",...Tr(t)],e,s);if(i.missing||i.status!==0)throw new z(mn(i).message,mn(i).kind);o=ea(i.stdout)}if(!o)throw new z(`${n==="github"?"gh":"az"} answered with something other than a list of artifacts`);return o.filter(i=>typeof i.name=="string"&&$w.test(i.name)).map(i=>({name:i.name,sizeBytes:Number(i.size_in_bytes??((i.resource??{}).properties??{}).artifactsize)||0,expired:i.expired===!0}))}function _f(e,t,n,r,s,o,i){if(i.length===0)throw new z(Qi(r));for(let a of i){let c=n==="github"?Re("gh",["run","download",r,"-n",a,"--dir",oe.join(s,a)],e,o):Re("az",["pipelines","runs","artifact","download","--run-id",r,"--artifact-name",a,"--path",oe.join(s,a),...Tr(t)],e,o);if(c.missing||c.status!==0)throw n==="github"&&/no (valid )?artifact/i.test(kr(c))?new z(Qi(r)):new z(`cannot download ${a}: ${(n==="github"?Vn(c):mn(c)).message}`)}}function If(e,t,n,r={}){Cf(n);let s=r.env??process.env,o=Xi(e,t,r.provider),i=Ef(e,t,o,n,s,r.allowFork===!0),a=Pf(e,o,n,i.attempt);if(i.status==="completed"&&ae.existsSync(a))return{provider:o,dir:oe.join(a,"bundles"),remove:()=>{}};let c=Af(e,t,o,n,s).filter(p=>!p.expired).map(p=>p.name);if(c.length===0)throw new z(Qi(n));let{dir:l,remove:d}=mi(e);try{return _f(e,t,o,n,l,s,c),{provider:o,dir:l,remove:d}}catch(p){throw d(),p}}function Cf(e){if(!/^\d+$/.test(e))throw new z(`--run takes the run's id, a number as saffron runs lists it, not "${e}"`)}var ta=e=>oe.join(e,".saffron","ci"),Pf=(e,t,n,r)=>oe.join(ta(e),`${t}-${n}`,`attempt-${r}`),kw=10,Rw=1024**3;function Tw(e){let t=0;for(let n of ae.readdirSync(e,{recursive:!0})){let r=ae.lstatSync(oe.join(e,String(n)),{throwIfNoEntry:!1});r?.isFile()&&(t+=r.size)}return t}function Ew(e,t){let n=ta(e),r=ae.readdirSync(n,{withFileTypes:!0}).filter(i=>i.isDirectory()).map(i=>oe.join(n,i.name)).map(i=>({run:i,mtime:ae.statSync(i).mtimeMs,bytes:Tw(i)})).sort((i,a)=>+(a.run===t)-+(i.run===t)||a.mtime-i.mtime),s=0,o=0;for(let i of r){if(i.run!==t&&(s+1>kw||o+i.bytes>Rw)){ae.rmSync(i.run,{recursive:!0,force:!0});continue}s++,o+=i.bytes}}var Rr=class extends z{constructor(n,r){super(`this run's results are ${Math.ceil(n/1024**2)} MB, over --max-download ${Math.round(r/1024**2)} MB. Run again with a higher --max-download to download them`,"too-large");this.sizeBytes=n}sizeBytes};function na(e,t,n,r={}){Cf(n);let s=r.env??process.env,o=Xi(e,t,r.provider),i=Ef(e,t,o,n,s,r.allowFork===!0);if(i.status!=="completed")return{run:i,artifacts:[],reasonKind:"running",reason:`run ${n} has not finished: its results are uploaded when it does`};let a=Af(e,t,o,n,s),c=a.filter($=>!$.expired),l=c.map(({name:$,sizeBytes:m})=>({name:$,sizeBytes:m})),d=Pf(e,o,n,i.attempt),p=new Date;if(ae.existsSync(d))return ae.utimesSync(oe.dirname(d),p,p),{run:i,folder:d,cached:!0,artifacts:l};if(c.length===0)return a.length>0?{run:i,artifacts:l,reasonKind:"expired",reason:`run ${n}'s Saffron bundle expired: the service keeps artifacts for a limited time`}:{run:i,artifacts:l,reasonKind:"none",reason:`run ${n} uploaded no Saffron bundle. The guide's workflow exports only when a run fails; set if: always() on the export and upload steps to keep every run's results`};let u=c.reduce(($,m)=>$+m.sizeBytes,0);if(r.maxDownloadBytes!==void 0&&u>r.maxDownloadBytes)throw new Rr(u,r.maxDownloadBytes);let f=ta(e);ae.mkdirSync(f,{recursive:!0});let g=oe.join(f,".gitignore");ae.existsSync(g)||ae.writeFileSync(g,`*
|
|
1204
|
+
`),ae.mkdirSync(oe.dirname(d),{recursive:!0});let w=ae.mkdtempSync(`${d}.partial-`);try{_f(e,t,o,n,oe.join(w,"bundles"),s,c.map($=>$.name));try{ae.renameSync(w,d)}catch($){if(!ae.existsSync(d))throw $;return ae.utimesSync(oe.dirname(d),p,p),{run:i,folder:d,cached:!0,artifacts:l}}}finally{ae.rmSync(w,{recursive:!0,force:!0})}let R=Date.now()-1440*60*1e3;for(let $ of ae.readdirSync(oe.dirname(d))){let m=oe.join(oe.dirname(d),$),v=/^attempt-(\d+)$/.exec($),S=/^attempt-\d+\.partial$/.test($)||/^attempt-\d+\.partial-/.test($)&&(ae.statSync(m,{throwIfNoEntry:!1})?.mtimeMs??1/0)<R;(v&&Number(v[1])<i.attempt||S)&&ae.rmSync(m,{recursive:!0,force:!0})}return ae.utimesSync(oe.dirname(d),p,p),Ew(e,oe.dirname(d)),{run:i,folder:d,cached:!1,artifacts:l}}async function ra(e,t,n){let{run:r,folder:s}=t;return Al(e,s,[oe.join(s,"bundles")],n,{run:`${r.provider}:${r.id}`,describe:o=>`${qs[r.provider]} run ${r.id} (${oe.basename(oe.dirname(o))})`})}var Aw=e=>e?.split(`
|
|
1205
|
+
`).map(t=>t.trim()).find(Boolean);async function Of(e,t,n,r,s={}){let o=na(e,t,r,s);if(!o.folder)return{results:{run:o.run,bundle:null,reasonKind:o.reasonKind,reason:o.reason,warnings:[]}};let i=o.folder,a=await ra(e,{...o,folder:i},n),c=g=>Ae(e,oe.join(i,g)),l=g=>ae.existsSync(oe.join(i,g)),d={run:o.run,bundle:{folder:Ae(e,i),cached:o.cached===!0,artifacts:o.artifacts},warnings:a.warnings},{report:p}=a;if(p){let{green:g,yellow:w,red:R,aiCalls:$,costUsd:m}=p.totals;d.totals={green:g,yellow:w,red:R,aiCalls:$,costUsd:m},p.browsers?.length&&(d.browsers=p.browsers),d.scenarios=p.scenarios.map(v=>{let S=(v.evidence??[]).filter(y=>l(y.file)),x=S.filter(y=>y.kind!=="trace"),b=x.find(y=>y.kind==="failure")??x.find(y=>y.kind==="before-heal")??x[0],k=S.filter(y=>y.kind==="trace"),h=k.find(y=>!y.proof)??k[0];return{feature:v.feature,scenario:an(v),displayName:v.scenario,tags:v.tags,status:v.status,durationMs:v.durationMs,verified:v.verified,failingStep:v.steps.find(y=>y.status==="failed")?.gherkin,error:Aw(v.error),narrative:v.narrative,screenshot:b&&c(b.file),trace:h&&c(h.file),...v.browser?{browser:v.browser}:{}}}),ae.writeFileSync(oe.join(i,"report.json"),`${JSON.stringify(p,null,2)}
|
|
1206
|
+
`),Oe(oe.join(i,"report.html"),qt(p,i)),d.reportHtml=c("report.html")}else d.scenarios=[];d.proposals=a.proposals;let{warnings:u,...f}=d;return{results:{...f,warnings:u},report:p}}function jf(e,t,n,r){let s=Re("az",["repos","pr","create","--draft","true","--source-branch",`refs/heads/${Wn(n.branch)}`,"--target-branch",`refs/heads/${Wn(n.base)}`,"--title",n.title,"--description",`@${n.bodyFile}`,"--output","json",...Tr(t)],e,r);if(s.missing||s.status!==0)return{problem:`az repos pr create failed: ${mn(s).message}`};try{let o=JSON.parse(s.stdout),i=o.repository?.webUrl;return{url:i&&o.pullRequestId!==void 0?`${i}/pullrequest/${o.pullRequestId}`:void 0}}catch{return{}}}function Mf(e,t,n){if(e.missing)throw new z(`${n}: ${t} is not installed here`);if(e.timedOut)throw new z(`${n}: ${t} did not finish within 10 minutes`);if(e.status!==0)throw new z(`${n}: ${(e.stderr||e.stdout||`exit ${e.status}`).split(`
|
|
1207
|
+
`).slice(-3).join(" ")}`);return e.stdout}function _w(e,t){let n=i=>Re("git",["config",i],e,t).stdout,r=t.GIT_AUTHOR_NAME||n("user.name"),s=t.GIT_AUTHOR_EMAIL||n("user.email"),o=t.GITHUB_ACTIONS==="true"?{name:"github-actions[bot]",email:"41898282+github-actions[bot]@users.noreply.github.com"}:{name:"Saffron",email:"saffron@localhost"};return{...t,GIT_AUTHOR_NAME:r||o.name,GIT_AUTHOR_EMAIL:s||o.email,GIT_COMMITTER_NAME:t.GIT_COMMITTER_NAME||r||o.name,GIT_COMMITTER_EMAIL:t.GIT_COMMITTER_EMAIL||s||o.email}}var Js=(e,t)=>`${e} ${t}${e===1?"":"s"}`,Er=e=>`\`${e.replaceAll("`","'")}\``,sa=e=>{let t=e.startsWith("-")?`./${e}`:e;return/^[\w./-]+$/.test(t)?t:`'${t.replaceAll("'","'\\''")}'`};function oa(e,t=""){let n=Math.max(0,...[...e.matchAll(/`+/g)].map(s=>s[0].length)),r="`".repeat(Math.max(3,n+1));return`${r}${t}
|
|
1186
1208
|
${e}
|
|
1187
|
-
${r}`}var
|
|
1209
|
+
${r}`}var Lf={github:6e4,azure:3800};function Iw(e,t,n,r="",s=Lf.github){let{manifest:o,proposals:i}=t,a=o.run?o.url&&/^https?:\/\//i.test(o.url)?`[run ${o.run}](${o.url})`:`run ${Er(o.run)}`:"a run",c=o.headCommit??o.commit,l=[o.branch&&`on ${Er(o.branch)}`,c&&`at ${Er(c.slice(0,7))}`].filter(Boolean).join(" "),d=[`Saffron filed ${Js(i.length,"proposal")} in ${a}${l?` ${l}`:""}. This pull request carries ${i.length===1?"the proposal, not the recording":"the proposals, not the recordings"}: nothing is accepted until you accept it.`,"","**To review and accept** (no agent runs on your machine):","",oa([`git fetch origin ${sa(n)} && git switch ${sa(n)}`,...r?[`cd ${sa(r.replace(/\/$/,""))}`]:[],"npx -y -p saffron-ai saffron diff","npx -y -p saffron-ai saffron accept --all",'git add -A .saffron/cache && git add -u .saffron/proposals && git commit -m "Accept Saffron proposals" && git push'].join(`
|
|
1188
1210
|
`),"bash"),"","Then mark this pull request ready for review. `accept --all` leaves UNVERIFIED proposals out: read one, and accept it by file if it is right. `saffron accept` refuses a proposal whose scenario or recording changed since it was filed."],p=d.join(`
|
|
1189
|
-
`).length;for(let[u,{proposal:f}]of i.entries()){let{feature:
|
|
1190
|
-
`),
|
|
1191
|
-
`);if(p+
|
|
1211
|
+
`).length;for(let[u,{proposal:f}]of i.entries()){let{feature:g,scenario:w}=f.cache,{mode:R,verified:$,proofError:m,narrative:v,adaptations:S}=f.meta,x=$===!0?"**verified \u2713**: a zero-AI proof replay passed":$===!1?"**UNVERIFIED \u2717**: its proof replay failed":"not verified: proof replays were off",b=[v,...S.map(y=>`\u26A0 ${y}`),...$===!1&&m?[`Proof replay: ${m}`]:[]].filter(Boolean).join(`
|
|
1212
|
+
`),k=rs(Ht(Lt(se(e,g,w)),f.cache,f.meta.featureEdits)),h=["",`### ${Er(g)} \u203A ${Er(w)}`,"",`${R==="heal"?"Heal":"First recording"}, ${x}`,"",...b?[oa(b,"text"),""]:[],oa(k,"diff")].join(`
|
|
1213
|
+
`);if(p+h.length>s){d.push("",`\u2026and ${Js(i.length-u,"more proposal")}: run \`saffron diff\` on the branch to see ${i.length-u===1?"it":"them"}.`);break}d.push(h),p+=h.length+1}return`${d.join(`
|
|
1192
1214
|
`)}
|
|
1193
|
-
`}var
|
|
1194
|
-
`)[0]:String(
|
|
1195
|
-
`)}function
|
|
1196
|
-
`).slice(-2).join(" ")}`,failed:!0}:{...
|
|
1197
|
-
`).pop()}}import
|
|
1198
|
-
`)}function
|
|
1215
|
+
`}var ia=e=>e.split(/[\\/]/).join("/");function Cw(e,t,n,r,s,o){let i=Qe.mkdtempSync(fe.join(Ff.tmpdir(),"saffron-head-")),a=g=>{let w=Nf("git",["show",`${n}:${r}${ia(g)}`],{cwd:e,env:s,timeout:6e5,maxBuffer:67108864});return w.status===0?w.stdout:void 0},c=g=>{let w=fe.relative(e,fe.resolve(e,g));return!w.startsWith("..")&&!fe.isAbsolute(w)?ia(w):void 0},l=c(o.features??"features"),d=c(o.dataDir??"data"),p=Nf("git",["ls-tree","-r","-z","--full-tree","--name-only",n,"--",...[l,d].flatMap(g=>g===void 0?[]:[`${r}${g}`||"."])],{cwd:e,env:s,encoding:"utf8",timeout:6e5,maxBuffer:64*1024*1024}),u=Me(),f=[];try{for(let w of(l!==void 0||d!==void 0)&&p.status===0?p.stdout.split("\0").filter(Boolean):[]){let R=w.slice(r.length);if(!(d!==void 0&&(d===""||R.startsWith(`${d}/`)))&&!R.endsWith(".saffron"))continue;let m=a(R);m!==void 0&&(Qe.mkdirSync(fe.dirname(fe.join(i,R)),{recursive:!0}),Qe.writeFileSync(fe.join(i,R),m))}d!==void 0&&at(i,d,u.env);let g;try{g=Le(i,o,[])}catch(w){let R=`cannot be checked: the StepSets at ${n.slice(0,7)} do not parse: ${w instanceof Error?w.message.split(`
|
|
1216
|
+
`)[0]:String(w)}`;return t.proposals.map(({file:$})=>({file:$,reason:R}))}for(let{file:w,proposal:R}of t.proposals){let{feature:$,scenario:m}=R.cache,v=a($);if(!v){f.push({file:w,reason:`is for ${$}, which is not in ${n.slice(0,7)}`});continue}Qe.mkdirSync(fe.dirname(fe.join(i,$)),{recursive:!0}),Qe.writeFileSync(fe.join(i,$),v);let S=ia(fe.relative(e,se(e,$,m))),x=a(S);Qe.rmSync(fe.join(i,S),{force:!0}),x&&(Qe.mkdirSync(fe.dirname(fe.join(i,S)),{recursive:!0}),Qe.writeFileSync(fe.join(i,S),x));let b;try{b=We(i,R,g)}catch(k){b=`cannot be checked: ${k instanceof Error?k.message:String(k)}`}b&&f.push({file:w,reason:`does not fit ${n.slice(0,7)}: ${b}`})}}finally{d!==void 0&&at(e,u.dir,u.env),Qe.rmSync(i,{recursive:!0,force:!0})}return f}function Pw(e){let{manifest:t,proposals:n}=e;return[`Saffron: ${Js(n.length,"proposal")} to review${t.run?` from run ${t.run}`:""}`,"",...n.map(({proposal:r})=>`${r.cache.feature} \u203A ${r.cache.scenario} (${r.meta.mode}${r.meta.verified===!0?", verified":r.meta.verified===!1?", UNVERIFIED":""})`),"","Proposals only: nothing is accepted until someone runs saffron accept."].join(`
|
|
1217
|
+
`)}function Ar(e,t){if(t!==void 0&&/[$`;|&<>()\s'"\\]/.test(t))throw new z(`${e} ${JSON.stringify(t)}: a branch name with a space, a quote or any of $ \` ; | & < > ( ) \\ is not accepted, as the pull request gives it to a shell`)}function Bf(e,t,n,r={}){let s=r.env??process.env,o=(b,k,h={})=>Mf(Re("git",k,e,h.env??s,h.input),"git",b),{manifest:i}=t;if(t.proposals.length===0)throw new z("there are no pending proposals to push");let a=r.base??i.branch;if(!a)throw new z("cannot tell which branch the pull request is for: pass --base <branch>");if(Wn(a),Wn(n),Ar("--branch",n),Ar("--base",r.base),n===a)throw new z(`--branch ${n} is the branch the proposals are for: name a new one, e.g. saffron/heal-<run id>`);if(n.startsWith("-"))throw new z(`${n} is not a branch name git accepts`);o(`${n} is not a branch name git accepts`,["check-ref-format","--branch",n]);let c=i.headCommit??i.commit;if(!c||!/^[0-9a-f]{40}([0-9a-f]{24})?$/i.test(c))throw new z("cannot tell which commit the run tested: run it in a git checkout");let l=o("not a git checkout",["rev-parse","HEAD"]);if(c!==l&&!gi(e,l).includes(c)&&Re("git",["merge-base","--is-ancestor",c,l],e,s).status!==0)throw new z(`${c.slice(0,7)}, the commit the branch would start on, is not this checkout's HEAD, a commit it merged or one it descends from: run export --branch in the checkout the run tested`);Re("git",["cat-file","-e",`${c}^{commit}`],e,s).status!==0&&o(`the commit to build on, ${c.slice(0,7)}, is not in this clone and could not be fetched`,["fetch","--quiet","--no-tags","--depth=1","origin",c]);let d=Re("git",["ls-remote","--exit-code","--heads","origin",`refs/heads/${n}`],e,s);if(d.status===0)throw new z(`branch ${n} already exists on origin: give each run its own, e.g. saffron/heal-<run id>-<attempt>`);d.status!==2&&Mf(d,"git","cannot reach origin");let p=o("not a git checkout",["rev-parse","--show-prefix"]),u=Cw(e,t,c,p,s,r.config??{}),f={...t,proposals:t.proposals.filter(b=>!u.some(k=>k.file===b.file))};if(f.proposals.length===0)throw new z(`no proposal fits ${c.slice(0,7)}, the commit the branch would start on: ${u.map(b=>`${fe.relative(e,b.file)} ${b.reason}`).join("; ")}`);let g=Qe.mkdtempSync(fe.join(Ff.tmpdir(),"saffron-index-")),w={...s,GIT_INDEX_FILE:fe.join(g,"index")},R;try{o("cannot read the tested commit",["read-tree",c],{env:w});for(let{file:k,bytes:h}of f.proposals){let y=o("cannot store a proposal",["hash-object","-w","--stdin"],{input:h}),A=`${p}${fe.relative(e,k).split(fe.sep).join("/")}`;o("cannot stage a proposal",["update-index","--add","--cacheinfo",`100644,${y},${A}`],{env:w})}let b=o("cannot write the tree",["write-tree"],{env:w});R=o("cannot commit the proposals",["commit-tree",b,"-p",c,"-m",Pw(f)],{env:_w(e,s)})}finally{Qe.rmSync(g,{recursive:!0,force:!0})}o(`cannot push ${n} to origin`,["push","--quiet","origin",`${R}:refs/heads/${n}`]);let $=i.repository?.startsWith("github.com/")?"github":i.repository?.startsWith("dev.azure.com/")?"azure":s.GITHUB_ACTIONS==="true"?"github":s.TF_BUILD==="True"?"azure":void 0,m=fe.join(fe.dirname(f.file),"saffron-pull-request.md");Qe.writeFileSync(m,Iw(e,f,n,p,Lf[$??"github"]));let v={branch:n,base:a,commit:R,parent:c,bodyFile:m,leftOut:u};if(!$)return{...v,noPullRequest:`${i.repository??"this repository"} is not on GitHub or Azure DevOps`};let S=`Saffron: ${Js(f.proposals.length,"proposal")} to review for ${a}`;if($==="azure"){let b=jf(e,r.config??{},{branch:n,base:a,title:S,bodyFile:m},s);return b.problem?{...v,noPullRequest:b.problem,failed:!0}:b.url?{...v,pullRequest:b.url}:{...v,noPullRequest:"az created it but did not say where",failed:!0}}let x=Re("gh",["pr","create","--draft","--base",a,"--head",n,"--title",S,"--body-file",m],e,s);return x.missing?{...v,noPullRequest:"the gh CLI is not installed here",failed:!0}:x.status!==0?{...v,noPullRequest:`gh pr create failed: ${(x.stderr||x.stdout).split(`
|
|
1218
|
+
`).slice(-2).join(" ")}`,failed:!0}:{...v,pullRequest:x.stdout.split(`
|
|
1219
|
+
`).pop()}}import G from"node:fs";import ne from"node:path";var _r={claude:".claude/skills",agents:".agents/skills",copilot:".github/skills",cursor:".cursor/skills"},Ow=["claude","agents"],zf="<!-- saffron:start -->",ca="<!-- saffron:end -->";function jw(){return[zf,"## 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`.","- A heal made in CI arrives as a bundle: `npx saffron runs` lists the"," branch's CI runs, `npx saffron import --run <id>` files its proposals"," here; then review and accept as above. `npx saffron trace <scenario>`"," replays a red or healed scenario step by step.","- 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/`.",ca,""].join(`
|
|
1220
|
+
`)}function Df(e,t){if(!G.existsSync(e))return G.writeFileSync(e,t),"created";let n=G.readFileSync(e,"utf8"),r=n.indexOf(zf),s=n.indexOf(ca);if(r!==-1&&s!==-1&&s>r){let i=n.slice(0,r)+t.trimEnd()+n.slice(s+ca.length);return i===n?"unchanged":(G.writeFileSync(e,i),"updated")}let o=n.endsWith(`
|
|
1199
1221
|
`)?`
|
|
1200
1222
|
`:`
|
|
1201
1223
|
|
|
1202
|
-
`;return
|
|
1203
|
-
`),s?"updated":"created")}function
|
|
1204
|
-
`),n.scaffolded.push("saffron.config.json"));let o=
|
|
1224
|
+
`;return G.writeFileSync(e,n+o+t),"updated"}var aa={command:"npx",args:["saffron","mcp"]},Nw={claude:{file:".mcp.json",key:"mcpServers",entry:aa},cursor:{file:".cursor/mcp.json",key:"mcpServers",entry:aa},copilot:{file:".vscode/mcp.json",key:"servers",entry:{type:"stdio",...aa}}},Uf={agents:"codex mcp add saffron -- npx saffron mcp (Codex; other hosts: see their MCP settings)"};function Mw(e,t){let n=ne.join(e,t.file),r={},s=G.existsSync(n);if(s)try{r=JSON.parse(G.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,G.mkdirSync(ne.dirname(n),{recursive:!0}),G.writeFileSync(n,JSON.stringify(r,null,2)+`
|
|
1225
|
+
`),s?"updated":"created")}function Hf(e,t){G.mkdirSync(t,{recursive:!0});for(let n of G.readdirSync(e,{withFileTypes:!0})){let r=ne.join(e,n.name),s=ne.join(t,n.name);n.isDirectory()?Hf(r,s):G.copyFileSync(r,s)}}function qf(e){let t=ne.join(e.packageRoot,"skills","saffron");if(!G.existsSync(ne.join(t,"SKILL.md")))throw new Error(`bundled skill not found at ${t}`);let n={skillDirs:[],instructionFiles:[],scaffolded:[],mcpFiles:[],mcpHints:[]},r=e.targets??Ow;for(let s of r){let o=ne.join(e.projectRoot,_r[s],"saffron");G.existsSync(o)&&G.rmSync(o,{recursive:!0,force:!0}),Hf(t,o),n.skillDirs.push(ne.relative(e.projectRoot,o))}if(e.mcp!==!1)for(let s of r){let o=Nw[s];o?n.mcpFiles.push({file:o.file,outcome:Mw(e.projectRoot,o)}):Uf[s]&&n.mcpHints.push(Uf[s])}if(e.instructions!==!1){let s=jw(),o=ne.join(e.projectRoot,"AGENTS.md");n.instructionFiles.push({file:"AGENTS.md",outcome:Df(o,s)});let i=ne.join(e.projectRoot,"CLAUDE.md");G.existsSync(i)&&n.instructionFiles.push({file:"CLAUDE.md",outcome:Df(i,s)})}if(e.examples){n.examples=Fw(e.projectRoot,e.packageRoot);for(let s of n.examples.files)n.scaffolded.push(s)}if(e.scaffold!==!1){let s=ne.join(e.projectRoot,"saffron.config.json");G.existsSync(s)||(G.writeFileSync(s,JSON.stringify({baseURL:"http://localhost:3000",features:"features"},null,2)+`
|
|
1226
|
+
`),n.scaffolded.push("saffron.config.json"));let o=ne.join(e.projectRoot,"features");G.existsSync(o)||(G.mkdirSync(o,{recursive:!0}),n.scaffolded.push("features/"));let i=ne.join(e.projectRoot,".gitignore"),a=[".saffron/reports/",".saffron/artifacts/",".saffron/ci/",...e.examples?[".env"]:[]],c=G.existsSync(i)?G.readFileSync(i,"utf8"):"",l=[];for(let d of a)c.split(`
|
|
1205
1227
|
`).some(p=>p.trim()===d)||(c+=(c&&!c.endsWith(`
|
|
1206
1228
|
`)?`
|
|
1207
1229
|
`:"")+d+`
|
|
1208
|
-
`,l.push(d));l.length>0&&(
|
|
1209
|
-
`)}import{spawn as
|
|
1210
|
-
`))}function Yd(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 ${qt[n]??n} models; this ${qt[t]} run uses its default models (--model and --heal-model pick others).`),{})}function Ei(e,t){let n=e??t??"claude";return Kl(n)||(console.error(`invalid provider "${n}": use ${ti.map(r=>`"${r}"`).join(", ")}`),process.exit(2)),n}async function Nm(e){let t=Pm.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 bs(e){console.error(`saffron: ${e.message}`),process.exit(2)}function Ri(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var Fm={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Mm="\x1B[0m",J=new Om().name("saffron").description("AI-fallback test runner for end-to-end tests written in plain language: zero-token cached replay, runtime AI healing.").version(Be()).option("-p, --project-root <dir>","project root",process.cwd());J.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]","feature files or directories (default: features/)").option("--base-url <url>","base URL of the app under test").option("--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"',bd).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, over a pending proposal this machine did not file too (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=L.resolve(J.opts().projectRoot),r=se(n);t.env&&Ve(n,r.dataDir,t.env);let s=t.baseUrl??r.baseURL,o=Kd(n,e.length>0?e:r.features?[r.features]:[]);o.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2));let i=o.find(E=>{let O=L.relative(n,E);return O.split(/[\\/]/)[0]===".."||L.isAbsolute(O)});i&&(console.error(`${i} is outside the project (${n}). Run saffron from the project that holds it, or point --project-root at it.`),process.exit(2));let a=Ei(t.provider,r.provider),c=Yd(r,a);t.agent!==!1&&jm(a);let l;try{let E=ke(n,r,o);l=o.flatMap(O=>Te(O,n,E))}catch(E){throw E instanceof ge&&bs(E),E}let d;try{d=yd(t.filter)}catch(E){if(!(E instanceof Qn))throw E;console.error(`${E.message}. Write tags joined with and, or, not and parentheses: "@smoke and not @wip".`),process.exit(2)}d&&(l=wd(l,d),l.length===0&&(console.error(`No scenarios match --filter "${d.text}" in ${o.length} feature file(s).`),process.exit(2)));let p;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 E=gc(String(t.shardBy??r.shardBy??"scenario")),O=hc(l,{...uc(String(t.shard)),by:E});l=O.scenarios,p={...O.info,ciRun:Br(),attempt:Ur()}}catch(E){console.error(E instanceof Error?E.message:String(E)),process.exit(2)}if(console.log(`shard ${p.index}/${p.total}${p.by==="file"?" (by file)":""}: ${l.length} of ${p.suiteSize} scenario(s)`),l.length===0){let E=new Date,O=Io([],E,s),M=wo([],E,Ke(n).divergent.length,s);console.log(`report: ${Po(n,{...O,shard:p,history:M})}`),process.exit(0)}}if(t.rerecord){t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));let E=0;for(let O of l){let M=ee(n,O.featurePath,O.name);ce.existsSync(M)&&(ce.rmSync(M),E++)}console.log(`rerecord: removed ${E} cache file(s) for ${l.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}{let E=l;try{let M=Kd(n,[]),U=ke(n,r,M);E=M.flatMap(X=>{try{return Te(X,n,U)}catch{return[]}})}catch{}let O=Ji(n,E);if(O.length>0){console.error("cache path collision:");for(let M of O)console.error(` ${M}`);process.exit(2)}}{let E=new Map;for(let M of l)for(let U of $e(M))process.env[U]===void 0&&!E.has(U)&&E.set(U,M.displayName);let O=new Map;for(let M of l)for(let U of st(_e(M)))if(!O.has(U))try{fr(U)}catch(X){O.set(U,`${X instanceof Error?X.message:String(X)} (used by "${M.displayName}")`)}if(O.size>0){for(let M of O.values())console.error(M);process.exit(2)}if(E.size>0){for(let[M,U]of E)console.error(`missing environment variable ${M} (referenced as {env:${M}} by "${U}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let u=[],f=[];if(t.hooks!==!1)try{u=di(r.setup,"setup"),f=di(r.teardown,"teardown")}catch(E){console.error(E instanceof Error?E.message:String(E)),process.exit(2)}let y=new Kr({projectRoot:n,baseURL:s,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,screenshot:r.screenshot,trace:(()=>{let E=t.trace??r.trace??"off";return Ys.includes(E)||(console.error(`invalid --trace "${E}": use ${Ys.map(O=>`"${O}"`).join(", ")}`),process.exit(2)),E})(),retries:r.retries,storageState:t.storageState??r.storageState,browser:(()=>{let E=t.browser??r.browser??"chromium";return wl(E)||(console.error(`invalid --browser "${E}": use ${Wo.map(O=>`"${O}"`).join(", ")}`),process.exit(2)),E})(),healModel:t.healModel??c.healModel,rerecord:t.rerecord===!0,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:(()=>{let E=t.assertionPolicy??r.assertionPolicy??"strict";return E!=="strict"&&E!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${E}": use "strict" or "adaptable-mid"`),process.exit(2)),E})(),agent:t.agent?Yl(a,{model:t.model??c.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0}),b=new Date,k=Math.max(1,Number.parseInt(String(t.workers??r.workers??1),10)||1),T=new Array(l.length),v=E=>{let O=E.scenario,M=Fm[E.status],U=E.usage.inputTokens+E.usage.outputTokens,X=E.usage.cacheReadTokens+E.usage.cacheCreationTokens,He=X>0?`, ${Ri(X)} cache traffic`:"";console.log(`${M}${E.status.toUpperCase().padEnd(7)}${Mm}${O.displayName} (${E.source}, ${E.durationMs}ms, ${E.usage.aiCalls} AI calls, ${U} tokens${He})`);for(let qe of E.adaptations)console.log(` \u26A0 ${qe}`);E.status==="yellow"&&E.usage.aiCalls===0&&E.narrative&&console.log(` ${E.narrative}`),E.seededSteps&&console.log(` \u26A1 ${E.seededSteps}/${E.stepResults.length} steps seeded from existing recordings (zero AI)`);let ht=E.stepResults.reduce((qe,lr)=>qe+(lr.retries??0),0);ht&&console.log(` \u21BB ${ht} action ${ht===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),E.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):E.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${E.proofError?.split(`
|
|
1211
|
-
`)[0]}`),
|
|
1212
|
-
interrupted: stopping, then running teardown`),
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
${I
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
`).map(
|
|
1220
|
-
|
|
1221
|
-
`)
|
|
1230
|
+
`,l.push(d));l.length>0&&(G.writeFileSync(i,c),n.scaffolded.push(`.gitignore (+ ${l.join(", ")})`))}return n}function Fw(e,t){let n=ne.join(t,"templates","saucedemo");if(!G.existsSync(ne.join(n,"features")))return{installed:!1,files:[],reason:`bundled examples not found at ${n}`};let r=ne.join(e,"features");if(G.existsSync(r)&&G.readdirSync(r).length>0)return{installed:!1,files:[],reason:"features/ is not empty; examples are only installed into an empty features directory"};let s=[];G.mkdirSync(r,{recursive:!0});for(let c of G.readdirSync(ne.join(n,"features")))G.copyFileSync(ne.join(n,"features",c),ne.join(r,c)),s.push(`features/${c}`);let o=ne.join(n,"data");if(G.existsSync(o)){G.mkdirSync(ne.join(e,"data"),{recursive:!0});for(let c of G.readdirSync(o)){let l=ne.join(e,"data",c);G.existsSync(l)||(G.copyFileSync(ne.join(o,c),l),s.push(`data/${c}`))}}let i=ne.join(e,"saffron.config.json");G.existsSync(i)||(G.copyFileSync(ne.join(n,"saffron.config.json"),i),s.push("saffron.config.json (baseURL https://www.saucedemo.com, retries 1)"));let a=ne.join(e,".env.example");return G.existsSync(a)||(G.copyFileSync(ne.join(n,".env.example"),a),s.push(".env.example")),{installed:!0,files:s}}import Lw from"node:path";function Jf(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?Lw.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(`
|
|
1231
|
+
`)}import{spawn as Bw}from"node:child_process";function Dw(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 Ir(e){let{command:t,args:n,options:r}=Dw(e),s=t.split(" ")[0],o=Bw(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 la(e,t){let n=t.length>0?t:["features"],r=[];for(let s of n){let o=U.isAbsolute(s)?s:U.join(e,s);if(ue.existsSync(o))if(ue.statSync(o).isDirectory())for(let i of ue.readdirSync(o,{recursive:!0})){let a=String(i);Tt(a)&&r.push(U.join(o,a))}else Tt(o)&&r.push(o)}return r.sort()}function Hw(e){let t=Bi(e);t.length!==0&&console.error([`note: the ${pn[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(`
|
|
1232
|
+
`))}function Wf(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 ${pn[n]??n} models; this ${pn[t]} run uses its default models (--model and --heal-model pick others).`),{})}function fa(e,t){let n=e??t??"claude";return Kd(n)||(console.error(`invalid provider "${n}": use ${Fi.map(r=>`"${r}"`).join(", ")}`),process.exit(2)),n}async function qw(e){let t=Uw.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 Ws(e){console.error(`saffron: ${e.message}`),process.exit(2)}function da(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e4?`${(e/1e3).toFixed(1)}k`:e.toLocaleString()}var Jw={green:"\x1B[32m",yellow:"\x1B[33m",red:"\x1B[31m"},Ww="\x1B[0m",K=new zw().name("saffron").description("AI-fallback test runner for end-to-end tests written in plain language: zero-token cached replay, runtime AI healing.").version(nt()).option("-p, --project-root <dir>","project root",process.cwd());K.command("run").description("Run feature files (cached replay; AI records/heals on demand)").argument("[paths...]",'feature files or directories (default: features/); file:line runs the scenario at that line, Cucumber-style (login.saffron:12, or :12:30 for two); file:name the scenario of that name ("login.saffron:Successful login")').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"',Bl).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, over a pending proposal this machine did not file too (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. A list ("chromium,firefox,webkit") runs the suite in each, into one report').option("--device <name>",'run under a Playwright device profile, e.g. "iPhone 15" or "Pixel 7"').option("--viewport <size>","viewport as WIDTHxHEIGHT, e.g. 1280x720 (over the device's)").option("--heal-model <model>","cheaper model for heal sessions").option("--workers <n>","parallel replay workers").option("--agent-workers <n>","agent sessions (recording, healing) that may run at once, each in a browser of its own (default 1)").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=U.resolve(K.opts().projectRoot),r=pe(n);t.env&&at(n,r.dataDir,t.env);let s=t.baseUrl??r.baseURL,o=Dl(e),i=e.length===0?la(n,r.features?[r.features]:[]):o.plain.length>0?la(n,o.plain):[],a=[...o.picks.keys()].map(I=>U.resolve(n,I)),c=[...o.plain,...o.picks.keys()].find(I=>!ue.existsSync(U.resolve(n,I)));c!==void 0&&(console.error(`${c} does not exist (in ${n}).`),process.exit(2));let l=[...new Set([...i,...a])].sort();l.length===0&&(console.error("No .feature/.saffron files found."),process.exit(2));let d=l.find(I=>{let B=U.relative(n,I);return B.split(/[\\/]/)[0]===".."||U.isAbsolute(B)});d&&(console.error(`${d} is outside the project (${n}). Run saffron from the project that holds it, or point --project-root at it.`),process.exit(2));let p=fa(t.provider,r.provider),u=Wf(r,p);t.agent!==!1&&Hw(p);let f;try{let I=Le(n,r,l);f=l.flatMap(B=>Be(B,n,I))}catch(I){throw I instanceof $e&&Ws(I),I}let g=f;if(o.picks.size>0)try{f=Hl(f,n,o.picks,new Set(i))}catch(I){if(!(I instanceof fn))throw I;console.error(I.message),process.exit(2)}let w;try{w=Fl(t.filter)}catch(I){if(!(I instanceof hr))throw I;console.error(`${I.message}. Write tags joined with and, or, not and parentheses: "@smoke and not @wip".`),process.exit(2)}if(w&&(f=Ll(f,w),f.length===0&&(console.error(`No scenarios match --filter "${w.text}" in ${l.length} feature file(s).`),process.exit(2))),t.agent!==!1){let I=Z=>{try{let me=Pe(se(n,Z.featurePath,Z.name));return!!me&&sr(Z,me)}catch{return!1}},B=f.filter(Z=>t.rerecord||!I(Z)),q=Ul(B,g).filter(Z=>!f.includes(Z));q.length>0&&(console.log(`${q.length} more Examples row(s) share a recording this run ${t.rerecord?"replaces":"records"}, and run to replay it`),f=[...f,...q])}let R=(()=>{let I=t.browser??r.browser??"chromium",B=(Array.isArray(I)?I:String(I).split(",")).map(Z=>String(Z).trim()).filter(Boolean),q=B.find(Z=>!wd(Z));return(q!==void 0||B.length===0)&&(console.error(`invalid --browser "${q??""}": use ${Ai.map(Z=>`"${Z}"`).join(", ")}, or a list of them`),process.exit(2)),[...new Set(B)].sort((Z,me)=>+!Bn(Z)-+!Bn(me))})(),$=R.length>1,m;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 I=al(String(t.shardBy??r.shardBy??"scenario")),B=cl(f,{...il(String(t.shard)),by:I});if(f=B.scenarios,$)throw new Error("saffron: a browser matrix and --shard do not mix: give each shard job one --browser (a CI matrix runs them side by side)");m={...B.info,ciRun:gs(),attempt:hs(),browser:R[0]}}catch(I){console.error(I instanceof Error?I.message:String(I)),process.exit(2)}if(console.log(`shard ${m.index}/${m.total}${m.by==="file"?" (by file)":""}: ${f.length} of ${m.suiteSize} scenario(s)`),f.length===0){let I=new Date,B=si([],I,s),q=Yo([],I,lt(n).divergent.length,s);console.log(`report: ${di(n,{...B,shard:m,history:q})}`),process.exit(0)}}t.rerecord&&t.agent===!1&&(console.error("saffron: --rerecord needs the agent; drop --no-agent"),process.exit(2));{let I=f;try{let q=[...new Set([...la(n,r.features?[r.features]:[]),...l])],Z=Le(n,r,q);I=q.flatMap(me=>{try{return Be(me,n,Z)}catch{return[]}})}catch{}let B=Na(n,I);if(B.length>0){console.error("cache path collision:");for(let q of B)console.error(` ${q}`);process.exit(2)}}{let I=new Map;for(let q of f)for(let Z of Fe(q))process.env[Z]===void 0&&!I.has(Z)&&I.set(Z,q.displayName);let B=new Map;for(let q of f)for(let Z of $t(ze(q)))if(!B.has(Z))try{jr(Z)}catch(me){B.set(Z,`${me instanceof Error?me.message:String(me)} (used by "${q.displayName}")`)}if(B.size>0){for(let q of B.values())console.error(q);process.exit(2)}if(I.size>0){for(let[q,Z]of I)console.error(`missing environment variable ${q} (referenced as {env:${q}} by "${Z}")`);console.error("set the variable(s), or add them to a git-ignored .env in the project root"),process.exit(2)}}let v=[],S=[];if(t.hooks!==!1)try{v=Ji(r.setup,"setup"),S=Ji(r.teardown,"teardown")}catch(I){console.error(I instanceof Error?I.message:String(I)),process.exit(2)}let x=t.device??r.device,b=x===void 0?void 0:String(x);if(b!==void 0&&(typeof x!="string"||!_i.includes(b))){let I=_i.filter(B=>B.toLowerCase().includes(b.toLowerCase().split(" ")[0])).slice(0,8);console.error(`unknown --device "${b}". ${I.length>0?`Close: ${I.map(B=>`"${B}"`).join(", ")}`:'Use a Playwright device name, e.g. "iPhone 15", "Pixel 7" or "Desktop Chrome"'}`),process.exit(2)}let k=t.viewport??r.viewport,h=k===void 0?void 0:yd(String(k));k!==void 0&&!h&&(console.error(`invalid --viewport "${k}": use WIDTHxHEIGHT, e.g. 1280x720`),process.exit(2));let y=I=>Math.max(1,Number.parseInt(String(I??1),10)||1),A=y(t.workers??r.workers),E=y(t.agentWorkers??r.agentWorkers),P=t.agent?Yd(p,{model:t.model??u.model,maxTurns:r.maxTurns,snapshotMode:r.snapshotMode}):void 0,O=(()=>{let I=t.trace??r.trace??"off";return Ao.includes(I)||(console.error(`invalid --trace "${I}": use ${Ao.map(B=>`"${B}"`).join(", ")}`),process.exit(2)),I})(),M=(()=>{let I=t.assertionPolicy??r.assertionPolicy??"strict";return I!=="strict"&&I!=="adaptable-mid"&&(console.error(`invalid --assertion-policy "${I}": use "strict" or "adaptable-mid"`),process.exit(2)),I})(),T=I=>new $s({projectRoot:n,baseURL:s,headed:t.headed,actionTimeoutMs:r.actionTimeoutMs,pollIntervalMs:r.pollIntervalMs,screenshot:r.screenshot,trace:O,retries:r.retries,storageState:t.storageState??r.storageState,browser:I,device:b,viewport:h,artifactsPerBrowser:$,healModel:t.healModel??u.healModel,rerecord:t.rerecord===!0,verifyProposals:t.verify===!1?!1:r.verifyProposals,reuseSteps:t.reuse===!1?!1:r.reuseSteps,assertionPolicy:M,agent:P}),C=new Set,j=async I=>{let B=T(I);return C.add(B),await B.start(),B},L=async I=>{for(let B of[...I])C.delete(B),await B.stop()},_=new Date,F=[],D=new Map,H=new Map,V=async(I,B,q,Z,me)=>{let vt=$?`${B} \xB7 ${q}`:q,Xe=Date.now(),ee=await I.runScenario(Z,me),Ne=Date.now(),Ee=D.get(vt)??{worker:vt,runs:0,busyMs:0,spanMs:0,browser:B};D.set(vt,Ee),Ee.runs++,Ee.busyMs+=Ne-Xe;let St=H.get(B)??{first:Xe,last:Ne};return H.set(B,{first:Math.min(St.first,Xe),last:Math.max(St.last,Ne)}),ee.worker=vt,ee.browser=B,ee},W=I=>{let B=I.scenario,q=Jw[I.status],Z=I.usage.inputTokens+I.usage.outputTokens,me=I.usage.cacheReadTokens+I.usage.cacheCreationTokens,vt=me>0?`, ${da(me)} cache traffic`:"";console.log(`${q}${I.status.toUpperCase().padEnd(7)}${Ww}${B.displayName}${$?` [${I.browser}]`:""} (${I.source}, ${I.durationMs}ms, ${I.usage.aiCalls} AI calls, ${Z} tokens${vt})`);for(let ee of I.adaptations)console.log(` \u26A0 ${ee}`);I.status==="yellow"&&I.usage.aiCalls===0&&I.narrative&&console.log(` ${I.narrative}`),I.seededSteps&&console.log(` \u26A1 ${I.seededSteps}/${I.stepResults.length} steps seeded from existing recordings (zero AI)`);let Xe=I.stepResults.reduce((ee,Ne)=>ee+(Ne.retries??0),0);Xe&&console.log(` \u21BB ${Xe} action ${Xe===1?"retry":"retries"} absorbed before settling (retries: ${r.retries??1} per action)`),I.verified===!0?console.log(" \u2713 proposal verified by zero-AI proof replay"):I.verified===!1&&console.log(` \u2717 proposal UNVERIFIED, proof replay failed: ${I.proofError?.split(`
|
|
1233
|
+
`)[0]}`),I.error&&console.log(` ${I.error}`)},ce=new AbortController,st={cwd:n,timeoutMs:r.hookTimeoutMs??go.hookTimeoutMs,env:{SAFFRON_BASE_URL:s,SAFFRON_ENV:Me().env,SAFFRON_SHARD:m?`${m.index}/${m.total}`:void 0}},Te=[],wn,bt=()=>wn??=Wi("teardown",S,st).then(I=>{Te.push(...I)}),ot=Promise.resolve([]);v.length+S.length>0&&process.on("SIGINT",()=>{if(ce.signal.aborted){console.error("still cleaning up: teardown finishes, then Saffron exits");return}console.error(`
|
|
1234
|
+
interrupted: stopping, then running teardown`),ce.abort(),ot.then(()=>L(C)).catch(()=>{}).then(bt).finally(()=>process.exit(130))}),v.length>0&&(ot=Wi("setup",v,{...st,signal:ce.signal}),Te.push(...await ot),ce.signal.aborted&&await new Promise(()=>{}),Te.some(Hs)&&(console.error("No scenario was run: the application is not in the state the suite expects."),await bt(),process.exit(2)));let jt=new Map;try{for(let I of R)jt.set(I,await j(I))}catch(I){throw await L(C).catch(()=>{}),await bt(),I instanceof It&&(console.error(I.message),process.exit(2)),I}let ep=async I=>{let B=jt.get(I),q=new Array(f.length);if(A===1&&E===1||f.length===1){for(let ee=0;ee<f.length;ee++)q[ee]=await V(B,I,"1",f[ee]),W(q[ee]);return q}let Z=P!==void 0&&Bn(I),me=[];await xi(f.map((ee,Ne)=>[{s:ee,i:Ne}]),A,async(ee,Ne)=>{let Ee=await V(B,I,String(Ne+1),ee.s,{agentDisabled:!0});Ee.status==="red"&&Z?me.push(ee):(q[ee.i]=Ee,W(Ee))});let vt=ed(me.sort((ee,Ne)=>ee.i-Ne.i),ee=>se(n,ee.s.featurePath,ee.s.name)),Xe=[];try{for(let Ee=1;Ee<Math.min(E,vt.length);Ee++)try{Xe.push(await j(I))}catch(St){console.log(` \u26A0 agent worker ${Ee+1} could not start a browser (${(St instanceof Error?St.message:String(St)).split(`
|
|
1235
|
+
`)[0]}); going on with ${Ee}`);break}let ee=[B,...Xe],Ne=ee.length;await xi(vt,Ne,async(Ee,St)=>{q[Ee.i]=await V(ee[St],I,Ne>1?`agent ${St+1}`:"agent",Ee.s),W(q[Ee.i])})}finally{await L(Xe)}return q};try{if(t.rerecord){let I=0;for(let B of f){let q=se(n,B.featurePath,B.name);ue.existsSync(q)&&(ue.rmSync(q),I++)}console.log(`rerecord: removed ${I} cache file(s) for ${f.length} scenario(s); the agent records them fresh (seeded from the other recordings). Restore with git if needed.`)}for(let I of R)$&&console.log(`
|
|
1236
|
+
${I}${Bn(I)?"":" (replay only: recording and healing need a Chromium-family browser)"}`),F.push(...await ep(I)),await L([jt.get(I)]),jt.delete(I)}finally{await L(C),await bt()}let ve=si(F,_,s);if(Te.length>0&&(ve.hooks=Te),D.size>0){let I=[...H.values()];ve.timing={wallMs:Math.max(...I.map(B=>B.last))-Math.min(...I.map(B=>B.first)),workers:[...D.values()].sort((B,q)=>R.indexOf(B.browser)-R.indexOf(q.browser)||B.worker.localeCompare(q.worker,void 0,{numeric:!0})).map(({browser:B,...q})=>({...q,spanMs:H.get(B).last-H.get(B).first}))}}$&&(ve.browsers=R);let tp=lt(n).divergent.length,Pr=o.picks.size>0,Vs={...Yo(F,_,tp,s),...Pr?{partial:!0}:{}};m||(Pr||(ve.trends=Qo(cr(n),Vs)),Zo(n,Vs)),ve.totals.aiCalls>0&&(ve.totals.plan=await P?.planUsage?.());let Gs,pa=!1;if(m){let I=di(n,{...ve,shard:m,history:Vs}),B=I.replace(/\.json$/,".html");ue.writeFileSync(B,qt(ve,n)),Gs=`${B}
|
|
1237
|
+
${I}`}else{let I=Pr?ci(ai(n),ve):ve;pa=I!==ve;let{html:B,json:q}=fr(n,I);Gs=`${B}
|
|
1238
|
+
${q}`}let Se=ve.totals,np=Se.cacheReadTokens+Se.cacheCreationTokens;console.log(`
|
|
1239
|
+
${Se.green} passed, ${Se.yellow} pending review, ${Se.red} failed \xB7 ${Se.aiCalls} AI calls \xB7 ${(Se.inputTokens+Se.outputTokens).toLocaleString()} tokens in+out${np>0?` \xB7 ${da(Se.cacheReadTokens)} cache read \xB7 ${da(Se.cacheCreationTokens)} cache written`:""}${qo(Se)}${Se.models?.length?` \xB7 ${Se.models.join(", ")}`:""}`);let Gn=ve.timing;Gn&&Gn.workers.length>1&&Gn.wallMs>0&&console.log(`workers over ${_e(Gn.wallMs)}: ${Gn.workers.map(I=>`${I.worker} ${ri(I)} busy`).join(" \xB7 ")}`),ve.scenarios.length>3&&console.log(`slowest: ${ni(ve,3).map(I=>`${I.scenario}${ve.browsers&&I.browser?` [${I.browser}]`:""} ${_e(I.durationMs)}`).join(" \xB7 ")}`);let Ks=ve.trends;Ks?.prev&&console.log(Jo(Ks.prev));for(let I of Ks?.chronic??[])console.log(`\u26A0 chronic: ${I.feature} \u203A ${I.scenario}: ${I.heals} heal(s), ${I.reds} red(s) in last ${I.runs} run(s); re-record or fix the feature text instead of paying for more heals`);console.log(`report: ${Gs}`),Pr&&!m&&console.log(Kf(pa)),m&&console.log(`shard report, not a whole run: history and trends wait for \`saffron report --merge\` over all ${m.total} shard-*-of-${m.total}.json files`);let ua=ft(U.resolve(K.opts().projectRoot),Cr);ua.length>0&&console.log(`${ua.length} cache proposal(s) pending. Review with: saffron accept | saffron reject`);let ga=t.strict??r.strict??!1;ga&&Se.yellow>0&&console.log(`strict mode: ${Se.yellow} scenario(s) pending review treated as failure. Review proposals to go green.`);let rp=Se.red>0||ga&&Se.yellow>0,Ys=Te.filter(I=>I.phase==="teardown"&&Hs(I));Ys.length>0&&console.log(`teardown failed: ${Ys.map(I=>Vi(I,st.timeoutMs)).join("; ")}. The environment may hold data from this run.`),process.exit(rp?1:Ys.length>0?2:0)});K.command("accept").description("Promote pending cache proposals to committed caches").argument("[files...]","proposal file(s) (default with --all: every pending proposal, less those --all leaves out)").option("--all","accept every pending proposal that is verified and was not imported from a CI run (see --include-unverified, --include-imported)").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 that are unverified or not checked (their proof replay failed, or did not run)").option("--include-imported","with --all: also accept proposals imported from a CI run (saffron import), which --all leaves for review by file").action((e,t)=>{let n=U.resolve(K.opts().projectRoot),r=pe(n),s=t.all&&!t.includeImported?_t(n):{},o=0,i=e.length>0?e.map(wo).map(a=>({...a,file:U.resolve(a.file)})):t.all?ft(n,Cr).map(a=>({file:a.file,revision:void 0})):[];if(i.length===0){Xf(n);return}try{for(let{file:a,revision:c}of i){let l=v=>{console.log(`not accepted: ${U.basename(a)} ${he(v)}`),process.exitCode=1},d=Qf(n,a,c!==void 0,()=>{if(c&&Ge(a)!==c)return l(Zf);let v=s[U.relative(n,a).split(U.sep).join("/")];if(v&&Ge(a)===v.revision){o++;return}let S;try{S=Cn(a)}catch(k){if(!(k instanceof le))throw k;return l(`cannot be read: ${k.problem}`)}if(S.meta.bound){let k=We(n,S,Le(n,r,[]));if(k)return l(`is stale: ${k}. Run the scenario again for a proposal made against what is there now, or reject this one.`)}else console.log(`\u26A0 ${U.basename(a)} is ${is}; accepting as it is.`);if(S.meta.verified!==!0&&t.all&&!t.includeUnverified){let k=S.meta.verified===!1?S.meta.proofError?.split(`
|
|
1240
|
+
`)[0]:"not checked: no proof replay ran";console.log(`skipped UNVERIFIED ${U.basename(a)} (${he(k)}). Re-record it (saffron run --rerecord), or accept it explicitly by file, or pass --include-unverified.`);return}S.meta.verified===!1&&console.log(`\u26A0 accepting UNVERIFIED proposal (proof replay failed: ${he(S.meta.proofError?.split(`
|
|
1241
|
+
`)[0])}): expect a heal or red on the next run.`);let x=S.cache.steps.map(k=>k.gherkin);if(t.withFeatureEdit&&(S.meta.featureEdits??[]).length>0){let k=mf(n,S.cache.feature,S.cache.scenario,x,Le(n,r,[]));if(k)return l(`carries feature edits, and ${k}. Run the scenario again for a fresh proposal, or accept this one without --with-feature-edit.`)}let b=Pe(se(n,S.cache.feature,S.cache.scenario));return{proposal:S,recordedSteps:x,oldCache:b,cacheFile:Yr(n,a)}},l);if(!d)continue;let{proposal:p,recordedSteps:u,oldCache:f,cacheFile:g}=d;console.log(`accepted \u2192 ${g}`);let w=f?uf(f,p.cache):{changes:[],ambiguous:[]},R=w.changes;for(let v of w.ambiguous)console.log(` locator fix for ${he(Us(v))} not propagated: this heal sent it to more than one place, so other caches are left alone.`);if(R.length>0){let v=gf(n,R,g,!t.propagate);for(let S of R)console.log(` locator fix: ${he(Us(S.from))} \u2192 ${he(Us(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(x=>`"${x.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 $=p.meta.featureEdits??[];if(!t.withFeatureEdit||$.length===0)continue;let m=wf(n,p.cache.feature,p.cache.scenario,$,Le(n,r,[]),u);for(let v of m.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(` + ${he(v.to)}`)}for(let v of m.skipped)console.log(` skipped edit [${v.index}]: ${he(v.reason)}`);if(m.appliedIndices.length>0){let v=new Set(m.appliedIndices),S=Pe(g);for(let x of $)v.has(x.index)&&S.steps[x.index]&&(S.steps[x.index].gherkin=x.text);Tn(g,S)}}}catch(a){throw a instanceof $e&&Ws(a),a}o>0&&console.log(`skipped ${o} proposal${o===1?"":"s"} imported from a CI run: review ${o===1?"it":"them"} (saffron diff) and accept by file, or pass --include-imported.`)});K.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=U.resolve(K.opts().projectRoot),r=pe(n),s;try{s=dt(n,r.features??"features")}catch(i){throw i instanceof $e&&Ws(i),i}if(e&&(s=Jr(s,e)),t.json){console.log(JSON.stringify(s,null,2));return}if(t.snippets){let i=U.join(n,".vscode","saffron.code-snippets");ue.mkdirSync(U.dirname(i),{recursive:!0}),ue.writeFileSync(i,za(s)),console.log(`wrote ${s.steps.length} step + ${s.stepSets.length} StepSet snippet(s) \u2192 ${i}`),console.log("re-run after recording sessions to keep completions current");return}let o={recorded:"\x1B[32m\u25CF\x1B[0m",divergent:"\x1B[33m\u25CF\x1B[0m",unrecorded:"\u25CB"};if(s.stepSets.length>0){console.log("step sets:");for(let i of s.stepSets)console.log(` ${i.name} (${i.steps.length} steps, ${i.usage} scenario(s)), ${i.file}:${i.line}`);console.log("")}for(let i of s.steps){let a=i.fromStepSet?` [${i.fromStepSet}]`:"";console.log(`${o[i.status]} ${i.keyword} ${i.text} \xD7${i.usage}${a}`)}console.log(`
|
|
1242
|
+
${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`)});K.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=U.resolve(K.opts().projectRoot),r=pe(n),s=fa(t.provider,r.provider),o=ue.readFileSync(U.resolve(e),"utf8"),i;try{i=dt(n,r.features??"features")}catch(d){throw d instanceof $e&&Ws(d),d}console.log(`authoring with ${i.steps.length} known step(s) and ${i.stepSets.length} step set(s) as vocabulary\u2026`);let a=await Nl(o,i,t.model??Wf(r,s).model,s==="claude"?void 0:Li[s]),c=t.out?U.resolve(t.out):U.join(n,r.features??"features",`${U.basename(e).replace(/\.[^.]+$/,"")}.saffron`);ue.existsSync(c)&&(console.error(`refusing to overwrite ${c}: pass --out for a new path`),process.exit(1)),ue.mkdirSync(U.dirname(c),{recursive:!0}),ue.writeFileSync(c,a.content);let l=a.content.split(`
|
|
1243
|
+
`).map(d=>d.trim().replace(/^(Given|When|Then|And|But|\*)\s+/,"")).filter(d=>i.steps.some(p=>p.text===d)).length;console.log(`wrote ${c}`),console.log(`${l} step line(s) reuse the existing vocabulary (seedable)${a.usage.costUsd?` \xB7 $${a.usage.costUsd.toFixed(4)}`:""}`),console.log("review the draft, then record it: saffron run "+U.relative(process.cwd(),c))});K.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=U.resolve(K.opts().projectRoot),n=fa(e,pe(t).provider),r=n==="cursor"?Wd:n==="antigravity"?Bd:void 0;if(r)try{await r()}catch(o){console.error(o instanceof Error?o.message:String(o)),process.exit(1)}let s=Bi(n);if(s.length===0){console.log(`${pn[n]}: ready to record and heal.`);return}console.log([`${pn[n]} still needs:`,...s.map(o=>` \xB7 ${o}`)].join(`
|
|
1244
|
+
`)),process.exit(1)});K.command("mcp").description("Serve the step vocabulary to AI assistants over stdio MCP (search_steps, list_step_sets)").action(async()=>{let e=U.resolve(K.opts().projectRoot),t=pe(e);await Cl(e,t.features??"features")});K.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(Jf(Vr())),process.exit(2));let e=U.resolve(K.opts().projectRoot),t=pe(e),n=process.argv.includes("-p")||process.argv.some(r=>r.startsWith("--project-root"));Ql(e,t.features??"features",{rootFromClient:!n})});K.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=U.resolve(K.opts().projectRoot),r=e.length>0?e.map(wo).map(s=>({...s,file:U.resolve(s.file)})):t.all?ft(n,Cr).map(s=>({file:s.file,revision:void 0})):[];if(r.length===0){Xf(n);return}for(let{file:s,revision:o}of r){let i=Qf(n,s,o!==void 0,()=>{if(o&&Ge(s)!==o)return Zf;Zr(s)},a=>a);if(i){console.log(`not rejected: ${U.basename(s)} ${i}`),process.exitCode=1;continue}console.log(`rejected ${s}`)}});K.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(_r).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=U.resolve(K.opts().projectRoot),n=Vr();n||(console.error("saffron: cannot locate the installed package root"),process.exit(2));let r=e.agents?String(e.agents).split(",").map(i=>i.trim()).filter(Boolean):void 0;for(let i of r??[])i in _r||(console.error(`saffron: unknown agent target "${i}" (expected one of ${Object.keys(_r).join(", ")})`),process.exit(2));let s=e.examples===!0;e.examples===void 0&&process.stdin.isTTY&&process.stdout.isTTY&&(s=await qw("Install the Saucedemo example tests (login, cart, checkout) into features/ for a guided first run? [y/N] "));let o=qf({projectRoot:t,packageRoot:n,targets:r,instructions:e.instructions,scaffold:e.scaffold,mcp:e.mcp,examples:s});for(let i of o.skillDirs)console.log(`skill \u2192 ${i}/`);for(let i of o.mcpFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (saffron mcp server)`);for(let i of o.mcpHints)console.log(`mcp hint \u2192 ${i}`);for(let i of o.instructionFiles)console.log(`${i.outcome.padEnd(9)} \u2192 ${i.file} (managed Saffron block)`);for(let i of o.scaffolded)console.log(`created \u2192 ${i}`);o.examples&&!o.examples.installed&&console.log(`examples \u2192 not installed: ${o.examples.reason}`),console.log("\nAgents now load the saffron skill when writing tests. Re-run after `npm update saffron-ai` to refresh it."),o.examples?.installed&&console.log(["","Your guided first run (recording needs Claude credentials; replays never do):"," cp .env.example .env # public demo credentials, resolved at replay"," npx playwright install chromium"," npx saffron run --filter @smoke # the agent records two scenarios (about $3 of AI usage), files proposals"," npx saffron accept --all # review, then promote them to caches"," npx saffron run --filter @smoke # replays at zero tokens","Then try the rest: npx saffron run (login errors outline, checkout with a data table)"].join(`
|
|
1245
|
+
`))});K.command("export").description("Pack the pending proposals, the last run's report and its screenshots and traces into one bundle, for review on another checkout (CI uploads it as an artifact)").option("-o, --out <file>","where to write the bundle (default: .saffron/reports/saffron-bundle.zip)").option("--branch <name>","also commit the pending proposals (not the caches) onto the commit the run tested as a new branch, push it to origin, and open a draft pull request (GitHub through gh, Azure DevOps through az)").option("--base <branch>","with --branch: the branch the pull request is for (default: the branch the run tested)").action(e=>{let t=U.resolve(K.opts().projectRoot);e.base!==void 0&&e.branch===void 0&&(console.error("saffron export: --base needs --branch"),process.exit(2));try{Ar("--branch",e.branch),Ar("--base",e.base)}catch(a){console.error(`saffron export: ${a.message}`),process.exit(2)}let n;try{n=yl(t,{out:e.out?U.resolve(e.out):void 0})}catch(a){console.error(`saffron export: ${a instanceof Error?a.message:String(a)}`),process.exit(2)}for(let a of n.unreadable)console.error(`\u26A0 proposal ${a.file} cannot be read: ${he(a.problem)}. Left out of the bundle.`);let r=n.manifest;if(console.log(`bundle: ${n.file}`),console.log(` ${r.proposals.length} proposal(s) \xB7 ${r.evidence.length} screenshot and trace file(s) \xB7 ${r.report?"the run's report":"no run report"}${r.shard?` \xB7 shard ${r.shard.index}/${r.shard.total}`:""}`),console.log(` from ${ur(r)}`),n.missing.length>0&&console.log(` ${n.missing.length} file(s) the report names are gone, or were replaced by a later run: left out`),n.dropped.length>0&&console.log(` ${n.dropped.length} trace(s) of green scenarios left out: with them the bundle would be over the 4 GB one bundle holds`),console.log("Review it in a checkout of this repository with: saffron import <bundle>"),e.branch===void 0)return;if(n.proposals.length===0){console.log("No pending proposals: no branch to push.");return}let s;try{let a=pe(t);s=Bf(t,n,String(e.branch),{base:e.base,config:a})}catch(a){console.error(`saffron export: the bundle is written, but no branch was pushed: ${a instanceof Error?a.message:String(a)}`),process.exit(2)}for(let a of s.leftOut)console.error(`\u26A0 left out of the branch: ${U.relative(t,a.file)} ${a.reason}`);let o=n.proposals.length-s.leftOut.length,i=o===1?"the proposal":`${o} proposals`;console.log(`branch: ${s.branch} pushed to origin (${s.commit.slice(0,7)}: ${i} on top of ${s.parent.slice(0,7)})`),s.pullRequest?console.log(`draft pull request: ${s.pullRequest}`):(console.log(`no pull request: ${s.noPullRequest}. Open one from ${s.branch} to ${s.base}; its description is in ${s.bodyFile}`),s.failed&&(process.exitCode=1))});K.command("runs").description("List the CI runs of the checkout's branch (GitHub Actions through gh, Azure Pipelines through az), with how many of each run's proposals are pending here; import one with saffron import --run <id>").option("--branch <name>","another branch (default: the checkout's; every branch on a detached HEAD)").option("--limit <n>","how many runs (default 20)").option("--provider <name>",`"github" or "azure" (default: ci.provider in the config, else the repository's host)`).option("--json","machine-readable output").action(e=>{let t=U.resolve(K.opts().projectRoot),n=e.limit===void 0?void 0:Number(e.limit);n!==void 0&&!(Number.isInteger(n)&&n>0)&&(console.error(`invalid --limit "${e.limit}": use a whole number above 0`),process.exit(2));let r;try{r=Tf(t,pe(t),{provider:e.provider,branch:e.branch,limit:n})}catch(i){let a=i instanceof Error?i.message:String(i);e.json?console.log(JSON.stringify({error:a},null,2)):console.error(`saffron runs: ${a}`),process.exit(2)}if(r.problem&&(process.exitCode=2),e.json){console.log(JSON.stringify(r.problem?{...r,error:r.problem.message}:r,null,2));return}if(r.problem){console.error(`saffron runs: ${r.problem.message}`);return}let s=`${qs[r.provider]} runs ${r.branch?`on ${r.branch}`:"on every branch"}`;if(r.runs.length===0){console.log(`No ${s}.`);return}let o=i=>i.status!=="completed"?"\u25CF":i.conclusion==="success"?"\u2713":i.conclusion==="failure"?"\u2717":"\u25CB";console.log(`${s}:`);for(let i of r.runs){let a=[`${i.name} #${i.number}`,i.status==="completed"?i.conclusion:i.status.replace("_"," "),$f(i.createdAt),i.commit?.slice(0,7),i.pending?`${i.pending} proposal${i.pending===1?"":"s"} pending here`:void 0].filter(Boolean);console.log(` ${i.id.padStart(12)} ${o(i)} ${a.join(" \xB7 ")}`)}console.log("Import one with: saffron import --run <id>")});K.command("import").description("Bring a bundle from `saffron export` (a CI run) into this checkout: its proposals, each checked against the files here, and the run's report, screenshots and traces. Then review and accept as usual").argument("[bundles...]","bundle files, folders holding saffron-bundle*.zip (a CI artifact download), or http(s) URLs").option("--run <id>","instead: download the bundles of this CI run (an id from saffron runs) through gh or az, and import them").option("--provider <name>",`with --run: "github" or "azure" (default: ci.provider in the config, else the repository's host)`).option("--allow-fork","with --run: import a run a fork's pull request started (code from outside this repository made its bundle); read that pull request first").option("--overwrite","replace a different proposal already pending here for the same scenario").option("--allow-unknown-repository","import a bundle that does not say which repository it comes from (made outside a git checkout); its proposals are still checked by their bindings").option("--json","machine-readable output").action(async(e,t)=>{let n=U.resolve(K.opts().projectRoot);function r(d,p=!1){t.json?console.log(JSON.stringify({error:d},null,2)):console.error(`saffron import: ${d}${p?"":". Nothing was imported."}`),process.exit(2)}t.run===void 0==(e.length===0)&&r("give bundle files, folders or URLs, or --run <id>, and not both"),t.provider!==void 0&&t.run===void 0&&r("--provider goes with --run"),t.allowFork&&t.run===void 0&&r("--allow-fork goes with --run");let s,o,i;try{let d=pe(n),p=Le(n,d,[]);t.run!==void 0&&(i=If(n,d,String(t.run),{provider:t.provider,allowFork:t.allowFork===!0}));let u=i?[i.dir]:e.map(g=>/^https?:\/\//i.test(g)?g:U.resolve(g)),f=i;s=await $l(n,u,p,{overwrite:t.overwrite===!0,allowUnknownRepository:t.allowUnknownRepository===!0,describe:f?g=>`${qs[f.provider]} run ${t.run} (${U.basename(U.dirname(g))})`:void 0,run:f?`${f.provider}:${t.run}`:void 0})}catch(d){o=d}finally{i?.remove()}if((o instanceof z||o instanceof $e)&&r(o.message),(o!==void 0||!s)&&r(o instanceof Error?o.message:String(o),!0),s.proposals.some(d=>d.outcome==="refused")&&(process.exitCode=1),t.json){console.log(JSON.stringify(i?{run:{provider:i.provider,id:String(t.run)},...s}:s,null,2));return}for(let d of s.bundles)console.log(`bundle: ${d.source}`),console.log(` from ${ur(d.origin)}${d.shard?` \xB7 shard ${d.shard.index}/${d.shard.total}`:""} \xB7 saffron-ai ${d.runner}`);for(let d of s.warnings)console.log(`\u26A0 ${d}`);for(let d of s.proposals){let p=d.feature?`${d.feature} \u203A ${d.scenario}`:d.file;d.outcome==="imported"?console.log(`imported \u2192 ${d.file} (${p})`):d.outcome==="unchanged"?console.log(`unchanged: ${p}: ${d.reason??"already pending here"}`):console.log(`not imported: ${p} ${d.reason}`)}let{written:a,unchanged:c}=s.evidence;a>0&&console.log(`evidence: ${a} screenshot and trace file(s) written${c?`, ${c} already here`:""}`),s.report==="written"&&console.log("report: .saffron/reports/latest.html now shows the imported run (saffron report, saffron trace)");let l=s.proposals.filter(d=>d.outcome==="imported").length;l===0&&a===0&&s.report!=="written"&&!s.proposals.some(d=>d.outcome==="refused")?console.log("Nothing changed: this was already imported."):l>0&&console.log("Review with: saffron diff \xB7 accept with: saffron accept <file> | --all --include-imported \xB7 then commit the caches with your git tools")});K.command("diff").description("Show what a pending proposal changes in the committed recording: the action list a reviewer is approving, not just the narrative, and the feature file edits it would apply").argument("[files...]","proposal file(s); default: every pending proposal").option("--json","machine-readable output").action((e,t)=>{let n=U.resolve(K.opts().projectRoot),r=e.length>0?e.map(o=>U.resolve(o)):ft(n,Cr).map(o=>o.file);if(r.length===0){console.log("No pending proposals.");return}let s=r.map(o=>{let i=Cn(o),a=Pe(se(n,i.cache.feature,i.cache.scenario));return{file:U.relative(n,o),verified:i.meta.verified,stale:i.meta.bound?Yf(n,i):is,...Ht(a,i.cache,i.meta.featureEdits)}});if(t.json){console.log(JSON.stringify(s.length===1?s[0]:s,null,2));return}console.log(s.map(o=>`${rs(o)}${o.stale?`
|
|
1222
1246
|
|
|
1223
|
-
STALE: ${
|
|
1247
|
+
STALE: ${he(o.stale)}`:""}`).join(`
|
|
1224
1248
|
|
|
1225
|
-
`))});
|
|
1226
|
-
${
|
|
1227
|
-
Press Ctrl-C to stop.`),t.open!==!1&&
|
|
1228
|
-
`)),process.exit(2));let s=e.yes&&!e.check?
|
|
1229
|
-
`))}e.check&&r.orphans.length>0&&process.exit(1)});
|
|
1230
|
-
${
|
|
1231
|
-
`)[0]:String(n)}`}}var
|
|
1232
|
-
`);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(` ${
|
|
1249
|
+
`))});K.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|id>","only if the last run is this one (its startedAt): an editor's row names the run it lists. Or a CI run's id (from saffron runs): replay that run's traces without importing it, view only").option("--provider <name>",`with a CI run's id: "github" or "azure" (default: ci.provider in the config, else the repository's host)`).option("--allow-fork","with a CI run's id: view a run a fork's pull request started").option("--max-download <MB>","with a CI run's id: refuse to download more than this many megabytes (default: no limit)").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=U.resolve(K.opts().projectRoot),s=typeof t.run=="string"&&/^\d+$/.test(t.run)?t.run:void 0,o=s?`CI run ${s}`:"the last run",i=r,a,c;if(s){let b=Vf("trace",t.json===!0),k=Gf(t.maxDownload,b);try{let h=pe(r),y=na(r,h,s,{provider:t.provider,allowFork:t.allowFork===!0,maxDownloadBytes:k});if(!y.folder)throw new z(y.reason??`run ${s} has no Saffron bundle`);let A=await ra(r,{...y,folder:y.folder},Le(r,h,[]));if(!A.report)throw new z(`run ${s}'s bundle holds no run report`);i=y.folder,a=A.report}catch(h){return b(h)}c=`Viewed from CI run ${s}: nothing is filed here, so there is nothing to accept or reject. To review its proposals, import the run (saffron import --run ${s}).`}else{(t.provider!==void 0||t.allowFork||t.maxDownload!==void 0)&&(console.error("saffron trace: --provider, --allow-fork and --max-download go with --run <CI run id>"),process.exit(2));let b=U.join(r,".saffron","reports","latest.json");ue.existsSync(b)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),a=JSON.parse(ue.readFileSync(b,"utf8")),t.run!==void 0&&a.startedAt!==t.run&&(console.error(`The last run changed since this was listed: it is now the run of ${a.startedAt}. Refresh the panel to replay it.`),process.exit(2))}let l=a.scenarios.filter(b=>b.evidence?.some(k=>k.kind==="trace"));l.length===0&&(console.error(`${s?o:"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 d=e?.trim(),p=l;if(d){let b=l.filter(k=>k.scenario===d||k.feature===d||`${k.feature}:${k.scenario}`===d||`${k.feature}:${k.scenario}@${k.browser}`===d||`${k.scenario}@${k.browser}`===d);p=b.length>0?b:l.filter(k=>k.scenario.toLowerCase().includes(d.toLowerCase()))}if(p.length===0||t.json&&d&&p.length>1){console.error(p.length===0?`No traced scenario matches "${d}". Traced in ${o}:`:`--json prints one trace. Traced in ${o}:`);for(let b of l)console.error(` ${b.status.padEnd(6)} ${b.feature}:${b.scenario}${a.browsers&&b.browser?`@${ie(b.browser)}`:""}`);console.error('Name one: saffron trace "<scenario name>", or "<feature>:<scenario name>" as listed'),process.exit(2)}let u=p.findIndex(b=>b.status==="red"),f=p.findIndex(b=>b.status==="yellow"),g=u>=0?u:f>=0?f:0,w=p[g],R=b=>U.basename(b,".zip").replace("proof-","Proof replay "),$=b=>{let k=b.evidence.filter(y=>y.kind==="trace"),h=k.find(y=>!y.proof)??k[0];return[h,...k.filter(y=>y!==h)]},[m]=$(w),v;if(p.length===1||t.json){let b=U.join(i,m.file);try{v=qi(i,m)}catch(k){console.error(`The trace cannot be read: ${k instanceof Error?k.message:String(k)}`),process.exit(2)}if(v||(console.error(ue.existsSync(b)?"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: ${b}. Run the scenario again.`),process.exit(2)),t.json){console.log(JSON.stringify(Qd(v),null,2));return}}let S=p.map((b,k)=>{let[h,...y]=$(b);return{scenario:{feature:b.feature,scenario:b.scenario,status:b.status,error:b.error,adaptations:b.adaptations,narrative:b.narrative,proposalDiff:b.proposalDiff,proposalFile:b.proposalFile,proposalRevision:b.proposalRevision,screenshots:b.evidence?.filter(A=>A.kind!=="trace"),ranAt:a.startedAt,source:["cache","agent-record","agent-heal"].find(A=>A===b.source),proposalReplay:b.proposalReplay===!0?!0:void 0,usage:b.usage,browser:a.browsers?b.browser:void 0},traces:[h,...y].map((A,E)=>({file:U.join(i,A.file),data:k===g&&E===0?v:void 0,read:()=>qi(i,A),checked:A.sha256!==void 0,label:A.proof?R(A.file):"Execution",proof:A.proof===!0,steps:A.proof?A.traceSteps:A.traceSteps??b.steps,uniques:A.proof?A.traceUniques:b.uniques}))}}),x;try{x=await ff({entries:S,start:g,projectRoot:i,port:n,viewOnly:c})}catch(b){console.error(`Could not start the replay: ${b instanceof Error?b.message:String(b)}`),process.exit(2)}for(let{file:b,reason:k}of x.skipped)console.error(`Skipping ${U.relative(i,b)}. ${k}`);console.log(`Saffron replay: ${p.length===1?`${w.feature} \u203A ${w.scenario}`:`${p.length} scenarios, opening on ${w.scenario}`}
|
|
1250
|
+
${x.url}
|
|
1251
|
+
Press Ctrl-C to stop.`),t.open!==!1&&Ir(x.url),await new Promise(b=>{process.once("SIGINT",()=>{x.close().finally(b)})}),process.exit(0)});K.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=U.resolve(K.opts().projectRoot),n=pe(t),r=Xr(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(`
|
|
1252
|
+
`)),process.exit(2));let s=e.yes&&!e.check?ec(t,r.orphans):[];if(e.json)console.log(JSON.stringify({...r,removed:s},null,2));else if(r.orphans.length===0)console.log(`Nothing to prune. All ${r.kept} recordings belong to a scenario.`);else{let o=r.orphans.filter(c=>c.kind==="cache").length,i=r.orphans.filter(c=>c.kind==="proposal").length,a=r.orphans.length-o-i;console.log([`${r.orphans.length} orphaned ${r.orphans.length===1?"file":"files"} (${o} cache, ${i} proposal, ${a} screenshot ${a===1?"folder":"folders"}), ${r.kept} still owned:`,"",...r.orphans.map(c=>` ${s.length>0?"removed ":""}${tc(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(`
|
|
1253
|
+
`))}e.check&&r.orphans.length>0&&process.exit(1)});K.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=U.resolve(K.opts().projectRoot),n=pe(t),r=gr(t,n);if(e.json){console.log(JSON.stringify(r,null,2));return}Il(r)});K.command("report").description("Open the latest HTML report, or merge shard reports into it, or view a CI run's results without importing them (--run)").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").option("--run <id>","view a CI run's results (an id from saffron runs): downloaded once into .saffron/ci/, checked like an import, nothing filed. Import it with saffron import --run").option("--provider <name>",`with --run: "github" or "azure" (default: ci.provider in the config, else the repository's host)`).option("--allow-fork","with --run: view a run a fork's pull request started").option("--max-download <MB>","with --run: refuse to download more than this many megabytes (default: no limit)").option("--json","with --run: machine-readable output").action(async(e,t)=>{let n=U.resolve(K.opts().projectRoot);if(t.run!==void 0){await Vw(n,String(t.run),t);return}for(let s of["provider","allowFork","maxDownload","json"])t[s]!==void 0&&(console.error(`saffron report: --${s.replace(/[A-Z]/g,o=>`-${o.toLowerCase()}`)} goes with --run`),process.exit(2));if(t.merge){Gw(n,e);return}e.length>0&&(console.error("saffron report: paths are only read with --merge"),process.exit(2));let r=U.join(n,".saffron","reports","latest.html");ue.existsSync(r)||(console.error("No report found. Run `saffron run` first."),process.exit(2)),console.log(r),Ir(r)});function Vf(e,t){return n=>{let r=n instanceof Error?n.message:String(n);if(t){let s=n instanceof z?n.kind:void 0;console.log(JSON.stringify({error:r,...s?{kind:s}:{},...n instanceof Rr?{sizeBytes:n.sizeBytes}:{}},null,2))}else console.error(`saffron ${e}: ${r}`);process.exit(2)}}function Gf(e,t){let n=e===void 0?void 0:Number(e);return n!==void 0&&!(n>0)&&t(`invalid --max-download "${e}": use a number of megabytes above 0`),n===void 0?void 0:n*1024**2}async function Vw(e,t,n){let r=Vf("report",n.json===!0),s=Gf(n.maxDownload,r),o;try{let l=pe(e);o=await Of(e,l,Le(e,l,[]),t,{provider:n.provider,allowFork:n.allowFork===!0,maxDownloadBytes:s})}catch(l){return r(l)}let{results:i}=o;if(n.json){console.log(JSON.stringify(i,null,2));return}if(!i.bundle){console.log(i.reason);return}for(let l of i.warnings)console.log(`\u26A0 ${l}`);let a=i.proposals??[];if(a.length>0&&console.log(`${a.length} proposal${a.length===1?"":"s"}, ${a.filter(l=>l.importable).length} importable: saffron import --run ${t}`),!i.reportHtml){console.log(`run ${t}'s bundle holds no run report`);return}let c=U.join(e,i.reportHtml);console.log(c),Ir(c)}K.command("dashboard").description("Open the suite's quality dashboard: tests, failing, flaky and never-run scenarios, healing, timing and tags, from the run history").option("--stdout","print the page instead of writing and opening it (what the IDE panels read)").option("--no-open","write the page without opening a browser").action(e=>{let t=U.resolve(K.opts().projectRoot),n=gr(t,pe(t)),r=vf(n.quality,n.version);if(e.stdout){process.stdout.write(r);return}let s=U.join(t,".saffron","reports","dashboard.html");ue.mkdirSync(U.dirname(s),{recursive:!0}),ue.writeFileSync(s,r),console.log(s),e.open!==!1&&Ir(s)});function Kf(e){return`named scenarios only: ${e?"the report shows them in place of their last results, beside the rest of the last run":"the report holds just them: the last report was missing, imported from CI, for another base URL or billed another way"}; their history line counts for them, not as a run of the suite`}function Gw(e,t){let n=t.length>0?t.map(w=>U.resolve(w)):[U.join(e,".saffron")],r;try{r=ws(ll(n))}catch(w){console.error(`saffron report --merge: ${w instanceof Error?w.message:String(w)}`),process.exit(2)}let{report:s,history:o,replaced:i}=r;for(let w of i)console.log(`\u26A0 ${w} is a shard a later attempt of the run ran again: left out`);let a=cr(e),c=a.find(w=>w.runId===o.runId),l=c?a.filter(w=>w.runId!==o.runId):a;o.partial||(s.trends=Qo(l,o));let d=c!==void 0&&JSON.stringify(c)===JSON.stringify(o);d||Zc(e,o);let p=o.partial?ci(ai(e),s):s,{html:u,json:f}=fr(e,p),g=s.totals;console.log(`merged ${s.shards} shard(s): ${g.scenarios} scenario(s) \xB7 ${g.green} passed, ${g.yellow} pending review, ${g.red} failed \xB7 ${g.aiCalls} AI calls${qo(g)}`),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"),s.trends?.prev&&console.log(Jo(s.trends.prev));for(let w of s.trends?.chronic??[])console.log(`\u26A0 chronic: ${w.feature} \u203A ${w.scenario}: ${w.heals} heal(s), ${w.reds} red(s) in last ${w.runs} run(s)`);o.partial&&console.log(Kf(p!==s)),console.log(`report: ${u}
|
|
1254
|
+
${f}`)}function Yf(e,t){try{return We(e,t,Le(e,pe(e),[]))}catch(n){return`cannot check: ${n instanceof Error?n.message.split(`
|
|
1255
|
+
`)[0]:String(n)}`}}var he=e=>ie(String(e??""),1/0),Cr=(e,t)=>console.error(`\u26A0 proposal ${e} cannot be read: ${he(t)}`),Kw="is no longer pending: it was accepted, rejected or removed since it was listed.",Yw="is not a pending proposal.",Zw="is not a proposal file under .saffron/proposals.",Zf="changed since it was listed. Refresh and review it again.",Qw="is being decided by another process. Try again.";function Qf(e,t,n,r,s){if(!t.endsWith(".json")||ue.existsSync(t)&&!Dt(U.join(e,".saffron","proposals"),t))return s(Zw);try{return Gr(t,r,()=>s(n?Kw:Yw))}catch(o){if(!(o instanceof rn))throw o;return s(Qw)}}function Xf(e){let t=ft(e,Cr);if(t.length===0){console.log("No pending proposals.");return}console.log(`Pending proposals:
|
|
1256
|
+
`);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(` ${he(r.cache.feature)} \u203A ${he(r.cache.scenario)} (${he(r.meta.mode)}, ${he(r.meta.createdAt)}${s})`);let o=r.meta.bound?Yf(e,r):is;o&&console.log(` \u2717 STALE: ${he(o)}`),console.log(` ${he(r.meta.narrative)}`);for(let i of r.meta.adaptations)console.log(` \u26A0 ${he(i)}`);r.meta.suggestedFeatureEdit&&console.log(` suggested .feature edit:
|
|
1233
1257
|
${r.meta.suggestedFeatureEdit.split(`
|
|
1234
|
-
`).map(i=>` ${
|
|
1235
|
-
`)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}
|
|
1258
|
+
`).map(i=>` ${he(i)}`).join(`
|
|
1259
|
+
`)}`),console.log()}console.log("Accept with: saffron accept <file> | --all")}K.parseAsync().catch(e=>{process.env.SAFFRON_DEBUG?console.error(e):console.error(`saffron: ${e instanceof Error?e.message:String(e)}`),process.exit(2)});
|