ripplo 0.0.12 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -31
- package/dist/index.js +190 -251
- package/package.json +7 -7
- package/dist/mcp/index.js +0 -844
package/dist/mcp/index.js
DELETED
|
@@ -1,844 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
var Oo=Object.create;var xt=Object.defineProperty;var Vo=Object.getOwnPropertyDescriptor;var Uo=Object.getOwnPropertyNames;var Fo=Object.getPrototypeOf,Wo=Object.prototype.hasOwnProperty;var Te=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Lo=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of Uo(t))!Wo.call(e,n)&&n!==r&&xt(e,n,{get:()=>t[n],enumerable:!(o=Vo(t,n))||o.enumerable});return e};var _o=(e,t,r)=>(r=e!=null?Oo(Fo(e)):{},Lo(t||!e||!e.__esModule?xt(r,"default",{value:e,enumerable:!0}):r,e));var qr=Te(Oe=>{"use strict";Object.defineProperty(Oe,"__esModule",{value:!0});Oe.timingSafeEqual=void 0;function Dr(e,t=""){if(!e)throw new Error(t)}function Es(e,t){if(e.byteLength!==t.byteLength)return!1;e instanceof DataView||(e=new DataView(ArrayBuffer.isView(e)?e.buffer:e)),t instanceof DataView||(t=new DataView(ArrayBuffer.isView(t)?t.buffer:t)),Dr(e instanceof DataView),Dr(t instanceof DataView);let r=e.byteLength,o=0,n=-1;for(;++n<r;)o|=e.getUint8(n)^t.getUint8(n);return o===0}Oe.timingSafeEqual=Es});var Gr=Te(D=>{"use strict";var Is=D&&D.__extends||(function(){var e=function(t,r){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(o,n){o.__proto__=n}||function(o,n){for(var a in n)n.hasOwnProperty(a)&&(o[a]=n[a])},e(t,r)};return function(t,r){e(t,r);function o(){this.constructor=t}t.prototype=r===null?Object.create(r):(o.prototype=r.prototype,new o)}})();Object.defineProperty(D,"__esModule",{value:!0});var N=256,mt=(function(){function e(t){t===void 0&&(t="="),this._paddingCharacter=t}return e.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(t*8+5)/6|0},e.prototype.encode=function(t){for(var r="",o=0;o<t.length-2;o+=3){var n=t[o]<<16|t[o+1]<<8|t[o+2];r+=this._encodeByte(n>>>18&63),r+=this._encodeByte(n>>>12&63),r+=this._encodeByte(n>>>6&63),r+=this._encodeByte(n>>>0&63)}var a=t.length-o;if(a>0){var n=t[o]<<16|(a===2?t[o+1]<<8:0);r+=this._encodeByte(n>>>18&63),r+=this._encodeByte(n>>>12&63),a===2?r+=this._encodeByte(n>>>6&63):r+=this._paddingCharacter||"",r+=this._paddingCharacter||""}return r},e.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(t*6+7)/8|0},e.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},e.prototype.decode=function(t){if(t.length===0)return new Uint8Array(0);for(var r=this._getPaddingLength(t),o=t.length-r,n=new Uint8Array(this.maxDecodedLength(o)),a=0,i=0,c=0,l=0,p=0,g=0,f=0;i<o-4;i+=4)l=this._decodeChar(t.charCodeAt(i+0)),p=this._decodeChar(t.charCodeAt(i+1)),g=this._decodeChar(t.charCodeAt(i+2)),f=this._decodeChar(t.charCodeAt(i+3)),n[a++]=l<<2|p>>>4,n[a++]=p<<4|g>>>2,n[a++]=g<<6|f,c|=l&N,c|=p&N,c|=g&N,c|=f&N;if(i<o-1&&(l=this._decodeChar(t.charCodeAt(i)),p=this._decodeChar(t.charCodeAt(i+1)),n[a++]=l<<2|p>>>4,c|=l&N,c|=p&N),i<o-2&&(g=this._decodeChar(t.charCodeAt(i+2)),n[a++]=p<<4|g>>>2,c|=g&N),i<o-3&&(f=this._decodeChar(t.charCodeAt(i+3)),n[a++]=g<<6|f,c|=f&N),c!==0)throw new Error("Base64Coder: incorrect characters for decoding");return n},e.prototype._encodeByte=function(t){var r=t;return r+=65,r+=25-t>>>8&6,r+=51-t>>>8&-75,r+=61-t>>>8&-15,r+=62-t>>>8&3,String.fromCharCode(r)},e.prototype._decodeChar=function(t){var r=N;return r+=(42-t&t-44)>>>8&-N+t-43+62,r+=(46-t&t-48)>>>8&-N+t-47+63,r+=(47-t&t-58)>>>8&-N+t-48+52,r+=(64-t&t-91)>>>8&-N+t-65+0,r+=(96-t&t-123)>>>8&-N+t-97+26,r},e.prototype._getPaddingLength=function(t){var r=0;if(this._paddingCharacter){for(var o=t.length-1;o>=0&&t[o]===this._paddingCharacter;o--)r++;if(t.length<4||r>2)throw new Error("Base64Coder: incorrect padding")}return r},e})();D.Coder=mt;var ve=new mt;function $s(e){return ve.encode(e)}D.encode=$s;function js(e){return ve.decode(e)}D.decode=js;var Br=(function(e){Is(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype._encodeByte=function(r){var o=r;return o+=65,o+=25-r>>>8&6,o+=51-r>>>8&-75,o+=61-r>>>8&-13,o+=62-r>>>8&49,String.fromCharCode(o)},t.prototype._decodeChar=function(r){var o=N;return o+=(44-r&r-46)>>>8&-N+r-45+62,o+=(94-r&r-96)>>>8&-N+r-95+63,o+=(47-r&r-58)>>>8&-N+r-48+52,o+=(64-r&r-91)>>>8&-N+r-65+0,o+=(96-r&r-123)>>>8&-N+r-97+26,o},t})(mt);D.URLSafeCoder=Br;var Mr=new Br;function Os(e){return Mr.encode(e)}D.encodeURLSafe=Os;function Vs(e){return Mr.decode(e)}D.decodeURLSafe=Vs;D.encodedLength=function(e){return ve.encodedLength(e)};D.maxDecodedLength=function(e){return ve.maxDecodedLength(e)};D.decodedLength=function(e){return ve.decodedLength(e)}});var Hr=Te((zr,Ve)=>{"use strict";(function(e,t){var r={};t(r);var o=r.default;for(var n in r)o[n]=r[n];typeof Ve=="object"&&typeof Ve.exports=="object"?Ve.exports=o:typeof define=="function"&&define.amd?define(function(){return o}):e.sha256=o})(zr,function(e){"use strict";e.__esModule=!0,e.digestLength=32,e.blockSize=64;var t=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]);function r(f,d,m,y,A){for(var k,v,S,W,E,T,G,I,O,$,ge,ye,Pe;A>=64;){for(k=d[0],v=d[1],S=d[2],W=d[3],E=d[4],T=d[5],G=d[6],I=d[7],$=0;$<16;$++)ge=y+$*4,f[$]=(m[ge]&255)<<24|(m[ge+1]&255)<<16|(m[ge+2]&255)<<8|m[ge+3]&255;for($=16;$<64;$++)O=f[$-2],ye=(O>>>17|O<<15)^(O>>>19|O<<13)^O>>>10,O=f[$-15],Pe=(O>>>7|O<<25)^(O>>>18|O<<14)^O>>>3,f[$]=(ye+f[$-7]|0)+(Pe+f[$-16]|0);for($=0;$<64;$++)ye=(((E>>>6|E<<26)^(E>>>11|E<<21)^(E>>>25|E<<7))+(E&T^~E&G)|0)+(I+(t[$]+f[$]|0)|0)|0,Pe=((k>>>2|k<<30)^(k>>>13|k<<19)^(k>>>22|k<<10))+(k&v^k&S^v&S)|0,I=G,G=T,T=E,E=W+ye|0,W=S,S=v,v=k,k=ye+Pe|0;d[0]+=k,d[1]+=v,d[2]+=S,d[3]+=W,d[4]+=E,d[5]+=T,d[6]+=G,d[7]+=I,y+=64,A-=64}return y}var o=(function(){function f(){this.digestLength=e.digestLength,this.blockSize=e.blockSize,this.state=new Int32Array(8),this.temp=new Int32Array(64),this.buffer=new Uint8Array(128),this.bufferLength=0,this.bytesHashed=0,this.finished=!1,this.reset()}return f.prototype.reset=function(){return this.state[0]=1779033703,this.state[1]=3144134277,this.state[2]=1013904242,this.state[3]=2773480762,this.state[4]=1359893119,this.state[5]=2600822924,this.state[6]=528734635,this.state[7]=1541459225,this.bufferLength=0,this.bytesHashed=0,this.finished=!1,this},f.prototype.clean=function(){for(var d=0;d<this.buffer.length;d++)this.buffer[d]=0;for(var d=0;d<this.temp.length;d++)this.temp[d]=0;this.reset()},f.prototype.update=function(d,m){if(m===void 0&&(m=d.length),this.finished)throw new Error("SHA256: can't update because hash was finished.");var y=0;if(this.bytesHashed+=m,this.bufferLength>0){for(;this.bufferLength<64&&m>0;)this.buffer[this.bufferLength++]=d[y++],m--;this.bufferLength===64&&(r(this.temp,this.state,this.buffer,0,64),this.bufferLength=0)}for(m>=64&&(y=r(this.temp,this.state,d,y,m),m%=64);m>0;)this.buffer[this.bufferLength++]=d[y++],m--;return this},f.prototype.finish=function(d){if(!this.finished){var m=this.bytesHashed,y=this.bufferLength,A=m/536870912|0,k=m<<3,v=m%64<56?64:128;this.buffer[y]=128;for(var S=y+1;S<v-8;S++)this.buffer[S]=0;this.buffer[v-8]=A>>>24&255,this.buffer[v-7]=A>>>16&255,this.buffer[v-6]=A>>>8&255,this.buffer[v-5]=A>>>0&255,this.buffer[v-4]=k>>>24&255,this.buffer[v-3]=k>>>16&255,this.buffer[v-2]=k>>>8&255,this.buffer[v-1]=k>>>0&255,r(this.temp,this.state,this.buffer,0,v),this.finished=!0}for(var S=0;S<8;S++)d[S*4+0]=this.state[S]>>>24&255,d[S*4+1]=this.state[S]>>>16&255,d[S*4+2]=this.state[S]>>>8&255,d[S*4+3]=this.state[S]>>>0&255;return this},f.prototype.digest=function(){var d=new Uint8Array(this.digestLength);return this.finish(d),d},f.prototype._saveState=function(d){for(var m=0;m<this.state.length;m++)d[m]=this.state[m]},f.prototype._restoreState=function(d,m){for(var y=0;y<this.state.length;y++)this.state[y]=d[y];this.bytesHashed=m,this.finished=!1,this.bufferLength=0},f})();e.Hash=o;var n=(function(){function f(d){this.inner=new o,this.outer=new o,this.blockSize=this.inner.blockSize,this.digestLength=this.inner.digestLength;var m=new Uint8Array(this.blockSize);if(d.length>this.blockSize)new o().update(d).finish(m).clean();else for(var y=0;y<d.length;y++)m[y]=d[y];for(var y=0;y<m.length;y++)m[y]^=54;this.inner.update(m);for(var y=0;y<m.length;y++)m[y]^=106;this.outer.update(m),this.istate=new Uint32Array(8),this.ostate=new Uint32Array(8),this.inner._saveState(this.istate),this.outer._saveState(this.ostate);for(var y=0;y<m.length;y++)m[y]=0}return f.prototype.reset=function(){return this.inner._restoreState(this.istate,this.inner.blockSize),this.outer._restoreState(this.ostate,this.outer.blockSize),this},f.prototype.clean=function(){for(var d=0;d<this.istate.length;d++)this.ostate[d]=this.istate[d]=0;this.inner.clean(),this.outer.clean()},f.prototype.update=function(d){return this.inner.update(d),this},f.prototype.finish=function(d){return this.outer.finished?this.outer.finish(d):(this.inner.finish(d),this.outer.update(d,this.digestLength).finish(d)),this},f.prototype.digest=function(){var d=new Uint8Array(this.digestLength);return this.finish(d),d},f})();e.HMAC=n;function a(f){var d=new o().update(f),m=d.digest();return d.clean(),m}e.hash=a,e.default=a;function i(f,d){var m=new n(f).update(d),y=m.digest();return m.clean(),y}e.hmac=i;function c(f,d,m,y){var A=y[0];if(A===0)throw new Error("hkdf: cannot expand more");d.reset(),A>1&&d.update(f),m&&d.update(m),d.update(y),d.finish(f),y[0]++}var l=new Uint8Array(e.digestLength);function p(f,d,m,y){d===void 0&&(d=l),y===void 0&&(y=32);for(var A=new Uint8Array([1]),k=i(d,f),v=new n(k),S=new Uint8Array(v.digestLength),W=S.length,E=new Uint8Array(y),T=0;T<y;T++)W===S.length&&(c(S,v,m,A),W=0),E[T]=S[W++];return v.clean(),S.fill(0),A.fill(0),E}e.hkdf=p;function g(f,d,m,y){for(var A=new n(f),k=A.digestLength,v=new Uint8Array(4),S=new Uint8Array(k),W=new Uint8Array(k),E=new Uint8Array(y),T=0;T*k<y;T++){var G=T+1;v[0]=G>>>24&255,v[1]=G>>>16&255,v[2]=G>>>8&255,v[3]=G>>>0&255,A.reset(),A.update(d),A.update(v),A.finish(W);for(var I=0;I<k;I++)S[I]=W[I];for(var I=2;I<=m;I++){A.reset(),A.update(W).finish(W);for(var O=0;O<k;O++)S[O]^=W[O]}for(var I=0;I<k&&T*k+I<y;I++)E[T*k+I]=S[I]}for(var T=0;T<k;T++)S[T]=W[T]=0;for(var T=0;T<4;T++)v[T]=0;return A.clean(),E}e.pbkdf2=g})});var Yr=Te(pe=>{"use strict";Object.defineProperty(pe,"__esModule",{value:!0});pe.Webhook=pe.WebhookVerificationError=void 0;var Us=qr(),Jr=Gr(),Fs=Hr(),Kr=300,ht=class e extends Error{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype),this.name="ExtendableError",this.stack=new Error(t).stack}},te=class e extends ht{constructor(t){super(t),Object.setPrototypeOf(this,e.prototype),this.name="WebhookVerificationError"}};pe.WebhookVerificationError=te;var Ue=class e{constructor(t,r){if(!t)throw new Error("Secret can't be empty.");if(r?.format==="raw")t instanceof Uint8Array?this.key=t:this.key=Uint8Array.from(t,o=>o.charCodeAt(0));else{if(typeof t!="string")throw new Error("Expected secret to be of type string");t.startsWith(e.prefix)&&(t=t.substring(e.prefix.length)),this.key=Jr.decode(t)}}verify(t,r){let o={};for(let d of Object.keys(r))o[d.toLowerCase()]=r[d];let n=o["webhook-id"],a=o["webhook-signature"],i=o["webhook-timestamp"];if(!a||!n||!i)throw new te("Missing required headers");let c=this.verifyTimestamp(i),p=this.sign(n,c,t).split(",")[1],g=a.split(" "),f=new globalThis.TextEncoder;for(let d of g){let[m,y]=d.split(",");if(m==="v1"&&(0,Us.timingSafeEqual)(f.encode(y),f.encode(p)))return JSON.parse(t.toString())}throw new te("No matching signature found")}sign(t,r,o){if(typeof o!="string")if(o.constructor.name==="Buffer")o=o.toString();else throw new Error("Expected payload to be of type string or Buffer.");let n=new TextEncoder,a=Math.floor(r.getTime()/1e3),i=n.encode(`${t}.${a}.${o}`);return`v1,${Jr.encode(Fs.hmac(this.key,i))}`}verifyTimestamp(t){let r=Math.floor(Date.now()/1e3),o=parseInt(t,10);if(isNaN(o))throw new te("Invalid Signature Headers");if(r-o>Kr)throw new te("Message timestamp too old");if(o>r+Kr)throw new te("Message timestamp too new");return new Date(o*1e3)}};pe.Webhook=Ue;Ue.prefix="whsec_"});import{StdioServerTransport as Ul}from"@modelcontextprotocol/sdk/server/stdio.js";import{McpServer as El}from"@modelcontextprotocol/sdk/server/mcp.js";import{z as H}from"zod";function z({locator:e,page:t}){switch(e.by){case"css":return t.locator(e.value);case"testId":return t.getByTestId(e.value);case"role":return t.getByRole(e.role,{exact:e.exact,name:e.name});case"text":return t.getByText(e.value,{exact:e.exact});case"label":return t.getByLabel(e.value,{exact:e.exact});case"placeholder":return t.getByPlaceholder(e.value);case"altText":return t.getByAltText(e.value)}}import rp from"@anthropic-ai/sdk";import{z as Pt}from"zod";import{z as Go}from"zod";async function be({page:e,runStartTime:t,targets:r}){let o=e.viewportSize();if(o==null)throw new Error("Page has no viewport set");let[n,a]=await Promise.all([e.screenshot({type:"png"}),qo(r)]),i=n.toString("base64");return{annotations:a,screenshotBase64:i,snapshotTimestamp:Math.round(performance.now()-t),url:e.url(),viewportHeight:o.height,viewportWidth:o.width}}function Do(e){return"locator"in e}async function qo(e){return(await Promise.all(e.map(r=>Bo(r)))).filter(r=>r!=null)}async function Bo(e){if(!Do(e))return{height:0,label:e.label,type:e.type,width:0,x:0,y:0};let t=await e.locator.boundingBox({timeout:2e3}).catch(()=>null);if(t!=null)return{height:Math.round(t.height),label:e.label,type:e.type,width:Math.round(t.width),x:Math.round(t.x),y:Math.round(t.y)}}function Me({node:e,page:t}){if(e.type==="assertUrl"||e.type==="waitForUrl")return[{label:e.type,type:"urlBar"}];if(e.type==="drag"){let n=z({locator:e.source,page:t}),a=z({locator:e.target,page:t});return[{label:"drag-source",locator:n,type:"action"},{label:"drag-target",locator:a,type:"action"}]}if(!Mo(e))return[];let r=e.type.startsWith("assert")?"assertion":"action",o=z({locator:e.locator,page:t});return[{label:e.type,locator:o,type:r}]}function Mo(e){return"locator"in e&&e.locator!=null}function u({description:e,execute:t,name:r,schema:o}){let n=Go.toJSONSchema(o,{target:"draft-2020-12"});return{anthropicTool:{description:e,input_schema:{...n,type:"object"},name:r},name:r,async execute(a,i){let c=o.parse(i);return{...await Promise.resolve(t(a,c)),kind:"action"}}}}async function h(e){let t=e.specNode==null?[]:Me({node:e.specNode,page:e.page}),r=await be({page:e.page,runStartTime:e.runStartTime,targets:t}),o=[...e.assertions],n={annotations:r.annotations,assertions:o,detail:e.detail,duration:Math.round(e.duration),nodeId:`agent-step-${String(e.stepIndex)}`,nodeType:e.nodeType,screenshotBase64:r.screenshotBase64,snapshotTimestamp:r.snapshotTimestamp,status:e.status,stepIndex:e.stepIndex,title:e.title,url:r.url,viewportHeight:r.viewportHeight,viewportWidth:r.viewportWidth},a=o.length>0?o.map(i=>`${i.status}: ${i.description} \u2014 ${i.detail??""}`).join(`
|
|
3
|
-
`):`${e.status}: ${e.title}`;return{specNode:e.specNode,stepResult:n,toolOutput:a}}var zo=Pt.object({selector:Pt.string().describe("CSS selector for the checkbox or radio button")}),Tt=u({description:"Assert that a checkbox or radio button is checked",name:"assert_checked",schema:zo,execute:async(e,t)=>{let r=performance.now(),o=await e.page.locator(t.selector).isChecked().catch(()=>!1),n=o?"passed":"failed",a=[{description:`Element ${t.selector} is checked`,detail:o?void 0:"Element is not checked",status:n}],c={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"assertChecked"};return h({assertions:a,detail:o?"Element is checked":"Element is not checked",duration:performance.now()-r,nodeType:"assertChecked",page:e.page,runStartTime:e.runStartTime,specNode:c,status:n,stepIndex:e.stepIndex,title:`Assert checked: ${t.selector}`})}});import{z as ce}from"zod";var Ho=ce.object({httpOnly:ce.boolean().optional().describe("Assert httpOnly flag value"),name:ce.string().describe("Cookie name to check"),sameSite:ce.enum(["Strict","Lax","None"]).optional().describe("Assert sameSite value"),secure:ce.boolean().optional().describe("Assert secure flag value"),value:ce.string().optional().describe("Expected cookie value (substring match)")}),At=u({description:"Assert properties of a browser cookie. Check existence, value, and security flags (httpOnly, secure, sameSite).",name:"assert_cookie",schema:Ho,execute:async(e,t)=>{let r=performance.now(),n=(await e.page.context().cookies()).find(g=>g.name===t.name),a=n==null?[{description:`Cookie "${t.name}" exists`,detail:"Cookie not found",status:"failed"}]:[{description:`Cookie "${t.name}" exists`,detail:void 0,status:"passed"},...Jo({cookie:n,input:t})],i=a.some(g=>g.status==="failed"),c=i?"failed":"passed",p={id:`agent-step-${String(e.stepIndex)}`,name:{type:"static",value:t.name},type:"assertCookie"};return h({assertions:a,detail:i?a.filter(g=>g.status==="failed").map(g=>g.detail).join("; "):"Cookie OK",duration:performance.now()-r,nodeType:"assertCookie",page:e.page,runStartTime:e.runStartTime,specNode:p,status:c,stepIndex:e.stepIndex,title:`Assert cookie: ${t.name}`})}});function Jo({cookie:e,input:t}){return[Ko(t.value,e.value),Ct("httpOnly",t.httpOnly,e.httpOnly),Ct("secure",t.secure,e.secure),Yo("sameSite",t.sameSite,e.sameSite)].filter(r=>r!=null)}function Ko(e,t){if(e==null)return;let r=t.includes(e);return{description:`Cookie value contains "${e}"`,detail:r?void 0:`Got: "${t}"`,status:r?"passed":"failed"}}function Ct(e,t,r){if(t!=null)return{description:`Cookie ${e} is ${String(t)}`,detail:r===t?void 0:`Got: ${String(r)}`,status:r===t?"passed":"failed"}}function Yo(e,t,r){if(t!=null)return{description:`Cookie ${e} is ${t}`,detail:r===t?void 0:`Got: ${r}`,status:r===t?"passed":"failed"}}import{z as Nt}from"zod";var Qo=Nt.object({selector:Nt.string().describe("CSS selector for the element to check focus on")}),Et=u({description:"Assert that an element has keyboard focus. Use for accessibility tab-order testing.",name:"assert_focused",schema:Qo,execute:async(e,t)=>{let r=performance.now(),n=!!await e.page.locator(t.selector).evaluate("(el) => document.activeElement === el").catch(()=>!1),a=n?"passed":"failed",i=[{description:`Element ${t.selector} is focused`,detail:n?void 0:"Element does not have focus",status:a}],l={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"assertFocused"};return h({assertions:i,detail:n?"Element is focused":"Element does not have focus",duration:performance.now()-r,nodeType:"assertFocused",page:e.page,runStartTime:e.runStartTime,specNode:l,status:a,stepIndex:e.stepIndex,title:`Assert focused: ${t.selector}`})}});import{z as de}from"zod";var Xo=de.object({bodyContains:de.string().optional().describe("Assert response body contains this string"),headerName:de.string().optional().describe("Response header name to check"),headerValue:de.string().optional().describe("Expected value the header should contain"),status:de.number().int().optional().describe("Expected HTTP status code"),urlPattern:de.string().describe("URL substring to match the response against")}),It=u({description:"Wait for a network response matching the URL pattern and assert on its status, body, or headers. Useful for verifying API calls and security headers (CSP, CORS).",name:"assert_response",schema:Xo,execute:async(e,t)=>{let r=performance.now(),o=await e.page.waitForResponse(p=>p.url().includes(t.urlPattern),{timeout:1e4}),n=[Zo(t.status,o.status()),await en(t.bodyContains,o),tn(t.headerName,t.headerValue,o.headers())].filter(p=>p!=null),a=n.length===0?[{description:`Response matching "${t.urlPattern}" received`,detail:void 0,status:"passed"}]:n,i=a.some(p=>p.status==="failed"),l={id:`agent-step-${String(e.stepIndex)}`,type:"assertResponse",urlPattern:{type:"static",value:t.urlPattern}};return h({assertions:a,detail:i?a.filter(p=>p.status==="failed").map(p=>p.detail).join("; "):"Response OK",duration:performance.now()-r,nodeType:"assertResponse",page:e.page,runStartTime:e.runStartTime,specNode:l,status:i?"failed":"passed",stepIndex:e.stepIndex,title:`Assert response: ${t.urlPattern}`})}});function Zo(e,t){if(e!=null)return{description:`Response status equals ${String(e)}`,detail:t===e?void 0:`Got: ${String(t)}`,status:t===e?"passed":"failed"}}async function en(e,t){if(e==null)return;let o=(await t.text()).includes(e);return{description:`Response body contains "${e}"`,detail:o?void 0:"Not found in response body",status:o?"passed":"failed"}}function tn(e,t,r){if(e==null||t==null)return;let o=r[e.toLowerCase()],n=o!=null&&o.includes(t);return{description:`Response header "${e}" contains "${t}"`,detail:n?void 0:`Got: "${o??"(missing)"}"`,status:n?"passed":"failed"}}import{z as Ge}from"zod";var rn=Ge.object({expected:Ge.string().describe("The expected text content"),selector:Ge.string().describe("CSS selector for the element to check")}),$t=u({description:"Assert that an element's text content matches the expected value",name:"assert_text",schema:rn,execute:async(e,t)=>{let r=performance.now(),o=await e.page.locator(t.selector).textContent({timeout:5e3}).catch(()=>null),n=o!=null&&o.includes(t.expected),a=n?"passed":"failed",i=[{description:`Text contains "${t.expected}"`,detail:`Actual: "${o??"(not found)"}"`,status:a}],c=`agent-step-${String(e.stepIndex)}`,l={expected:{type:"static",value:t.expected},id:c,locator:{by:"css",value:t.selector},operator:"contains",type:"assertText"};return h({assertions:i,detail:n?`Text matches: "${t.expected}"`:`Expected "${t.expected}", got "${o??"(not found)"}"`,duration:performance.now()-r,nodeType:"assertText",page:e.page,runStartTime:e.runStartTime,specNode:l,status:a,stepIndex:e.stepIndex,title:`Assert text: ${t.selector}`})}});import{z as ze}from"zod";var on=ze.object({expected:ze.string().describe("Expected page title or substring"),operator:ze.enum(["equals","contains","startsWith","endsWith"]).describe("Comparison operator").default("contains")}),jt=u({description:"Assert that the page title matches an expected value",name:"assert_title",schema:on,execute:async(e,t)=>{let r=performance.now(),o=await e.page.title(),n=nn({actual:o,expected:t.expected,operator:t.operator}),a=n?"passed":"failed",i=[{description:`Title ${t.operator} "${t.expected}"`,detail:n?void 0:`Got: "${o}"`,status:a}],c=`agent-step-${String(e.stepIndex)}`,l={expected:{type:"static",value:t.expected},id:c,operator:t.operator,type:"assertTitle"};return h({assertions:i,detail:n?"Title matches":`Expected title ${t.operator} "${t.expected}", got "${o}"`,duration:performance.now()-r,nodeType:"assertTitle",page:e.page,runStartTime:e.runStartTime,specNode:l,status:a,stepIndex:e.stepIndex,title:`Assert title ${t.operator} "${t.expected}"`})}});function nn({actual:e,expected:t,operator:r}){return r==="equals"?e===t:r==="contains"?e.includes(t):r==="startsWith"?e.startsWith(t):r==="endsWith"?e.endsWith(t):!1}import{z as Ot}from"zod";var an=Ot.object({selector:Ot.string().describe("CSS selector for the element to check")}),Vt=u({description:"Assert that an element matching the selector is visible on the page",name:"assert_visible",schema:an,execute:async(e,t)=>{let r=performance.now(),o=await e.page.locator(t.selector).isVisible().catch(()=>!1),n=o?"passed":"failed",a=[{description:`Element ${t.selector} is visible`,detail:o?"Element is visible":"Element is not visible",status:n}],c={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"assertVisible"};return h({assertions:a,detail:o?"Element is visible":"Element is not visible",duration:performance.now()-r,nodeType:"assertVisible",page:e.page,runStartTime:e.runStartTime,specNode:c,status:n,stepIndex:e.stepIndex,title:`Assert visible: ${t.selector}`})}});import{z as Ut}from"zod";var sn=Ut.object({selector:Ut.string().describe("CSS selector for the checkbox to check")}),Ft=u({description:"Check a checkbox matching the CSS selector",name:"check",schema:sn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).check({timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"check"};return h({assertions:[],detail:`Checked ${t.selector}`,duration:performance.now()-r,nodeType:"check",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Check ${t.selector}`})}});import{z as Wt}from"zod";var ln=Wt.object({selector:Wt.string().describe("CSS selector for the input to clear")}),Lt=u({description:"Clear the contents of an input field",name:"clear",schema:ln,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).clear({timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"clear"};return h({assertions:[],detail:`Cleared ${t.selector}`,duration:performance.now()-r,nodeType:"clear",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Clear ${t.selector}`})}});import{z as _t}from"zod";var cn=_t.object({selector:_t.string().describe("CSS selector for the element to click")}),Dt=u({description:"Click an element matching the CSS selector",name:"click",schema:cn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).click({timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"click"};return h({assertions:[],detail:`Clicked ${t.selector}`,duration:performance.now()-r,nodeType:"click",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Click ${t.selector}`})}});import{z as He}from"zod";var dn=He.object({action:He.enum(["read","write"]).describe("Whether to read from or write to the clipboard"),value:He.string().optional().describe("Text to write (required for write action)")}),qt=u({description:"Read from or write to the browser clipboard. For write, provide the text value. For read, the clipboard contents are returned.",name:"clipboard",schema:dn,execute:async(e,t)=>{let r=performance.now(),o=`agent-step-${String(e.stepIndex)}`;if(t.action==="write"){if(t.value==null)throw new Error("clipboard write requires a value");let i=JSON.stringify(t.value);await e.page.evaluate(`navigator.clipboard.writeText(${i})`);let c={action:"write",id:o,type:"clipboard",value:{type:"static",value:t.value}};return h({assertions:[],detail:`Wrote to clipboard: "${t.value}"`,duration:performance.now()-r,nodeType:"clipboard",page:e.page,runStartTime:e.runStartTime,specNode:c,status:"passed",stepIndex:e.stepIndex,title:"Clipboard write"})}let n=String(await e.page.evaluate("navigator.clipboard.readText()")),a={action:"read",id:o,type:"clipboard"};return h({assertions:[],detail:`Clipboard contents: "${n}"`,duration:performance.now()-r,nodeType:"clipboard",page:e.page,runStartTime:e.runStartTime,specNode:a,status:"passed",stepIndex:e.stepIndex,title:"Clipboard read"})}});import{z as Ce}from"zod";var Bt=Ce.object({summary:Ce.string().describe("This is the ONLY output the user sees. If the agent profile specifies an Output format, follow those instructions exactly. Otherwise, provide a summary of your findings."),verdict:Ce.enum(["pass","fail"]).describe("Whether the test passed or failed. If the agent profile defines Success Criteria, base this verdict on whether those criteria were met.")}),Mt={anthropicTool:{description:"Call this tool when you have finished your evaluation. If the agent profile defines an Output section, the summary MUST follow those output instructions exactly. Otherwise, summarize your findings.",input_schema:{...Ce.toJSONSchema(Bt,{target:"draft-2020-12"}),type:"object"},name:"complete_test"},name:"complete_test",execute(e,t){let r=Bt.parse(t),o={kind:"verdict",summary:r.summary,toolOutput:`Verdict: ${r.verdict}
|
|
4
|
-
${r.summary}`,verdict:r.verdict};return Promise.resolve(o)}};import{z as Gt}from"zod";var un=Gt.object({selector:Gt.string().describe("CSS selector for the element to extract text from")}),zt=u({description:"Extract the text content of an element matching the CSS selector. Returns the text so you can use it in assertions or decisions.",name:"extract_text",schema:un,execute:async(e,t)=>{let r=performance.now(),n=await e.page.locator(t.selector).textContent({timeout:5e3})??"",i={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"extractText",variable:"extractedText"};return h({assertions:[],detail:`Extracted text: "${n}"`,duration:performance.now()-r,nodeType:"extractText",page:e.page,runStartTime:e.runStartTime,specNode:i,status:"passed",stepIndex:e.stepIndex,title:`Extract text from ${t.selector}`})}});import{z as Je}from"zod";var pn=Je.object({selector:Je.string().describe("CSS selector for the input element"),value:Je.string().describe("The text to type into the element")}),Ht=u({description:"Clear and type text into an input element matching the CSS selector",name:"fill",schema:pn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).fill(t.value,{timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"fill",value:{type:"static",value:t.value}};return h({assertions:[],detail:`Filled ${t.selector} with "${t.value}"`,duration:performance.now()-r,nodeType:"fill",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Fill ${t.selector}`})}});import{z as Jt}from"zod";var fn=Jt.object({level:Jt.string().describe("Filter by log level: 'error', 'warning', 'log', 'info', 'debug', or 'all'").default("all")}),Kt=u({description:"Get console log messages from the browser. Optionally filter by level (error, warning, log, info, debug, all).",name:"get_console_logs",schema:fn,execute:(e,t)=>{let r=t.level==="all"?e.monitor.consoleEntries:e.monitor.consoleEntries.filter(n=>n.level===t.level);if(r.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:`No console ${t.level==="all"?"messages":t.level+" messages"} recorded.`};let o=r.map(n=>mn(n)).join(`
|
|
5
|
-
`);return{specNode:void 0,stepResult:void 0,toolOutput:`${String(r.length)} console messages:
|
|
6
|
-
${o}`}}});function mn(e){let t=e.url.length>0?` (${e.url})`:"";return`[${e.level.toUpperCase()}] ${e.text}${t}`}import{z as Yt}from"zod";var Ke=50,hn=Yt.object({urlFilter:Yt.string().describe("Optional regex to filter network entries by URL. Omit to see all.").default("")}),Qt=u({description:"Get a summary of network requests/responses. Shows method, URL, status code, and content type. Optionally filter by URL regex.",name:"get_network_log",schema:hn,execute:(e,t)=>{let r=t.urlFilter.length>0?new RegExp(t.urlFilter,"i"):void 0,o=r==null?e.monitor.networkEntries:e.monitor.networkEntries.filter(c=>r.test(c.url));if(o.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:"No network requests recorded."};let n=o.slice(-Ke),a=n.map(c=>{let l=c.statusCode==null?"pending":String(c.statusCode),p=c.responseHeaders["content-type"]??"";return`${c.method} ${l} ${c.url} [${p}]`}).join(`
|
|
7
|
-
`),i=o.length>Ke?`
|
|
8
|
-
(showing last ${String(Ke)} of ${String(o.length)})`:"";return{specNode:void 0,stepResult:void 0,toolOutput:`${String(n.length)} network requests:${i}
|
|
9
|
-
${a}`}}});import{z as Xt}from"zod";var gn=Xt.object({urlPattern:Xt.string().describe("Regex pattern to match the request URL")}),Zt=u({description:"Get the full response body for a specific network request matching the URL pattern. Returns the most recent match.",name:"get_network_response",schema:gn,execute:(e,t)=>{let r=new RegExp(t.urlPattern,"i"),o=e.monitor.networkEntries.filter(l=>r.test(l.url));if(o.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:`No network requests matching: ${t.urlPattern}`};let n=o.at(-1);if(n==null)return{specNode:void 0,stepResult:void 0,toolOutput:`No network requests matching: ${t.urlPattern}`};let a=n.statusCode==null?"pending":String(n.statusCode),i=Object.entries(n.responseHeaders).map(([l,p])=>` ${l}: ${p}`).join(`
|
|
10
|
-
`),c=n.responseBody??"(no body captured)";return{specNode:void 0,stepResult:void 0,toolOutput:`${n.method} ${a} ${n.url}
|
|
11
|
-
|
|
12
|
-
Response Headers:
|
|
13
|
-
${i}
|
|
14
|
-
|
|
15
|
-
Body:
|
|
16
|
-
${c}`}}});import{z as er}from"zod";var yn=er.object({selector:er.string().optional().describe("CSS selector to scope the tree to a subtree (defaults to full page)")}),tr=u({description:"Get the accessibility tree (ARIA snapshot) of the current page or a specific element. Use this to verify ARIA roles, labels, states, and the semantic structure of the page.",name:"get_accessibility_tree",schema:yn,execute:async(e,t)=>{let r=t.selector==null?e.page.locator(":root"):e.page.locator(t.selector);try{return{specNode:void 0,stepResult:void 0,toolOutput:await r.ariaSnapshot()}}catch{return{specNode:void 0,stepResult:void 0,toolOutput:"(unable to capture accessibility tree)"}}}});import{z as wn}from"zod";var bn=wn.object({}),rr=u({description:"Get a text representation of the current page content for inspection",name:"get_page_content",schema:bn,execute:async(e,t)=>{let r=await e.page.locator("body").textContent({timeout:5e3}),o=e.page.url(),n=await e.page.title();return{specNode:void 0,stepResult:void 0,toolOutput:`URL: ${o}
|
|
17
|
-
Title: ${n}
|
|
18
|
-
|
|
19
|
-
${r??""}`}}});import{z as Sn}from"zod";var kn=Sn.object({}),or=u({description:"Get uncaught JavaScript exceptions (page errors) that have occurred. These are runtime errors thrown in the browser that were not caught by error handlers.",name:"get_page_errors",schema:kn,execute:(e,t)=>{if(e.monitor.pageErrors.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:"No page errors recorded."};let r=e.monitor.pageErrors.map((o,n)=>`--- Error ${String(n+1)} ---
|
|
20
|
-
${o}`).join(`
|
|
21
|
-
|
|
22
|
-
`);return{specNode:void 0,stepResult:void 0,toolOutput:`${String(e.monitor.pageErrors.length)} page errors:
|
|
23
|
-
|
|
24
|
-
${r}`}}});import{z as nr}from"zod";var vn=nr.object({type:nr.enum(["all","cookies","localStorage","sessionStorage"]).describe("Which storage to retrieve").default("all")}),sr=u({description:"Get browser storage state: cookies (with httpOnly, secure, sameSite flags), localStorage, and/or sessionStorage. Useful for verifying authentication state, security cookie flags, and stored data.",name:"get_storage",schema:vn,execute:async(e,t)=>({specNode:void 0,stepResult:void 0,toolOutput:[...t.type==="all"||t.type==="cookies"?[await xn(e.page)]:[],...t.type==="all"||t.type==="localStorage"?[await ar(e.page,"localStorage")]:[],...t.type==="all"||t.type==="sessionStorage"?[await ar(e.page,"sessionStorage")]:[]].join(`
|
|
25
|
-
|
|
26
|
-
`)})});async function xn(e){let t=await e.context().cookies();if(t.length===0)return`## Cookies
|
|
27
|
-
(none)`;let r=t.map(o=>` ${o.name}=${o.value} (domain=${o.domain}, httpOnly=${String(o.httpOnly)}, secure=${String(o.secure)}, sameSite=${o.sameSite})`);return`## Cookies (${String(t.length)})
|
|
28
|
-
${r.join(`
|
|
29
|
-
`)}`}async function ar(e,t){let r=await e.evaluate(`JSON.stringify(Object.fromEntries(Object.entries(${t})))`);return`## ${t}
|
|
30
|
-
${String(r)}`}import{z as Ye}from"zod";var Rn=Ye.object({action:Ye.enum(["accept","dismiss"]).describe("Whether to accept or dismiss the dialog"),promptText:Ye.string().optional().describe("Text to enter for prompt dialogs")}),ir=u({description:"Set up a handler for the next browser dialog (alert, confirm, prompt). Call this BEFORE the action that triggers the dialog.",name:"handle_dialog",schema:Rn,execute:(e,t)=>{let r=performance.now();e.page.once("dialog",async a=>{t.action==="accept"?await a.accept(t.promptText??void 0):await a.dismiss()});let o=`agent-step-${String(e.stepIndex)}`,n={action:t.action,id:o,type:"handleDialog"};return h({assertions:[],detail:`Dialog handler set: ${t.action}`,duration:performance.now()-r,nodeType:"handleDialog",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Handle dialog: ${t.action}`})}});import{z as lr}from"zod";var Pn=lr.object({selector:lr.string().describe("CSS selector for the element to hover over")}),cr=u({description:"Hover over an element matching the CSS selector",name:"hover",schema:Pn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).hover({timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"hover"};return h({assertions:[],detail:`Hovered over ${t.selector}`,duration:performance.now()-r,nodeType:"hover",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Hover ${t.selector}`})}});import{z as Qe}from"zod";var Tn=Qe.object({permission:Qe.string().describe("Permission name (e.g. geolocation, notifications, camera, clipboard-read)"),state:Qe.enum(["granted","prompt"]).describe("Permission state to set")}),dr=u({description:"Set a browser permission state (geolocation, notifications, camera, etc.)",name:"set_permission",schema:Tn,execute:async(e,t)=>{let r=performance.now(),o=e.page.context();t.state==="granted"?await o.grantPermissions([t.permission]):await o.clearPermissions();let a={id:`agent-step-${String(e.stepIndex)}`,permission:t.permission,state:t.state,type:"setPermission"};return h({assertions:[],detail:`Set ${t.permission} to ${t.state}`,duration:performance.now()-r,nodeType:"setPermission",page:e.page,runStartTime:e.runStartTime,specNode:a,status:"passed",stepIndex:e.stepIndex,title:`Set permission: ${t.permission} \u2192 ${t.state}`})}});import{z as ur}from"zod";var Cn=ur.object({url:ur.string().describe("The URL to navigate to")}),pr=u({description:"Navigate the browser to a URL",name:"navigate",schema:Cn,execute:async(e,t)=>{let r=performance.now();await e.page.goto(t.url,{waitUntil:"domcontentloaded"});let n={id:`agent-step-${String(e.stepIndex)}`,type:"goto",url:{type:"static",value:t.url}};return h({assertions:[],detail:`Navigated to ${t.url}`,duration:performance.now()-r,nodeType:"goto",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Navigate to ${t.url}`})}});import{z as fr}from"zod";var An=fr.object({key:fr.string().describe("Key to press, e.g. 'Enter', 'Tab', 'Escape'")}),mr=u({description:"Press a keyboard key",name:"press",schema:An,execute:async(e,t)=>{let r=performance.now();await e.page.keyboard.press(t.key);let n={id:`agent-step-${String(e.stepIndex)}`,key:t.key,type:"press"};return h({assertions:[],detail:`Pressed ${t.key}`,duration:performance.now()-r,nodeType:"press",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Press ${t.key}`})}});import{z as Xe}from"zod";var Nn=Xe.object({height:Xe.number().int().positive().describe("Viewport height in pixels"),width:Xe.number().int().positive().describe("Viewport width in pixels")}),hr=u({description:"Resize the browser viewport to the specified dimensions. Use this to test responsive layouts at different screen sizes (e.g. mobile: 375x812, tablet: 768x1024, desktop: 1440x900).",name:"resize_viewport",schema:Nn,execute:async(e,t)=>{let r=performance.now();await e.page.setViewportSize({height:t.height,width:t.width});let o=`agent-step-${String(e.stepIndex)}`,n={height:t.height,id:o,type:"setViewport",width:t.width};return h({assertions:[],detail:`Resized viewport to ${String(t.width)}x${String(t.height)}`,duration:performance.now()-r,nodeType:"setViewport",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Resize viewport to ${String(t.width)}x${String(t.height)}`})}});import{z as gr}from"zod";var En=gr.object({selector:gr.string().describe("CSS selector for the element to right-click")}),yr=u({description:"Right-click an element to open the context menu",name:"right_click",schema:En,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).click({button:"right",timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"rightClick"};return h({assertions:[],detail:`Right-clicked ${t.selector}`,duration:performance.now()-r,nodeType:"rightClick",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Right-click ${t.selector}`})}});import{z as Ae}from"zod";var In=Ae.object({selector:Ae.string().optional().describe("CSS selector to scroll within (omit to scroll the page)"),x:Ae.number().int().optional().describe("Horizontal scroll offset in pixels").default(0),y:Ae.number().int().optional().describe("Vertical scroll offset in pixels").default(0)}),wr=u({description:"Scroll the page or a specific element by pixel offset. Positive y scrolls down, negative scrolls up.",name:"scroll",schema:In,execute:async(e,t)=>{let r=performance.now();if(t.selector==null)await e.page.mouse.wheel(t.x,t.y);else{let i=await e.page.locator(t.selector).elementHandle();if(i==null)throw new Error("Scroll target element not found");await i.evaluate(`(el) => el.scrollBy(${String(t.x)}, ${String(t.y)})`)}let n={id:`agent-step-${String(e.stepIndex)}`,type:"scroll",x:t.x,y:t.y},a=t.selector??"page";return h({assertions:[],detail:`Scrolled ${a} by (${String(t.x)}, ${String(t.y)})`,duration:performance.now()-r,nodeType:"scroll",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Scroll ${a}`})}});import{z as $n}from"zod";var jn=$n.object({}),br=u({description:"Take a screenshot of the current page",name:"screenshot",schema:jn,execute:async(e,t)=>{let r=performance.now(),n={id:`agent-step-${String(e.stepIndex)}`,type:"screenshot"};return h({assertions:[],detail:"Screenshot captured",duration:performance.now()-r,nodeType:"screenshot",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:"Screenshot"})}});import{z as Sr}from"zod";var On=Sr.object({pattern:Sr.string().describe("Regex pattern to search console log messages")}),kr=u({description:"Search console log messages for a regex pattern. Returns matching entries.",name:"search_console",schema:On,execute:(e,t)=>{let r=new RegExp(t.pattern,"i"),o=e.monitor.consoleEntries.filter(a=>r.test(a.text));if(o.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:`No console messages matching: ${t.pattern}`};let n=o.map(a=>Vn(a)).join(`
|
|
31
|
-
`);return{specNode:void 0,stepResult:void 0,toolOutput:`${String(o.length)} matching console messages:
|
|
32
|
-
${n}`}}});function Vn(e){let t=e.url.length>0?` (${e.url})`:"";return`[${e.level.toUpperCase()}] ${e.text}${t}`}import{z as vr}from"zod";var Un=vr.object({pattern:vr.string().describe("Regex pattern to search across network request URLs and response bodies")}),xr=u({description:"Search network requests and response bodies for a regex pattern. Useful for finding API calls, tokens, or specific data in responses.",name:"search_network",schema:Un,execute:(e,t)=>{let r=new RegExp(t.pattern,"i"),o=e.monitor.networkEntries.filter(a=>r.test(a.url)||a.responseBody!=null&&r.test(a.responseBody));if(o.length===0)return{specNode:void 0,stepResult:void 0,toolOutput:`No network entries matching: ${t.pattern}`};let n=o.map(a=>{let i=a.statusCode==null?"pending":String(a.statusCode),c=r.test(a.url)?" [URL match]":"",l=a.responseBody!=null&&r.test(a.responseBody)?" [body match]":"";return`${a.method} ${i} ${a.url}${c}${l}`}).join(`
|
|
33
|
-
`);return{specNode:void 0,stepResult:void 0,toolOutput:`${String(o.length)} matching network entries:
|
|
34
|
-
${n}`}}});import{z as Rr}from"zod";var Fn=20,Wn=2,Ln=Rr.object({pattern:Rr.string().describe("Regex pattern to search for in the page HTML")}),Pr=u({description:"Search the current page's full HTML/DOM for a regex pattern. Returns matching lines with context. Useful for finding elements, attributes, text content, or hidden data without reading the entire page.",name:"search_page",schema:Ln,execute:async(e,t)=>{let o=(await e.page.content()).split(`
|
|
35
|
-
`),n=_n({contextLines:Wn,lines:o,maxResults:Fn,pattern:t.pattern});return n.length===0?{specNode:void 0,stepResult:void 0,toolOutput:`No matches found for pattern: ${t.pattern}`}:{specNode:void 0,stepResult:void 0,toolOutput:n.join(`
|
|
36
|
-
---
|
|
37
|
-
`)}}});function _n({contextLines:e,lines:t,maxResults:r,pattern:o}){let n=new RegExp(o,"i"),a=[];return t.forEach((i,c)=>{if(a.length>=r||!n.test(i))return;let l=Math.max(0,c-e),p=Math.min(t.length-1,c+e),g=t.slice(l,p+1).map((f,d)=>{let m=l+d+1;return`${l+d===c?">":" "} ${String(m)}: ${f}`}).join(`
|
|
38
|
-
`);a.push(g)}),a}import{z as Ze}from"zod";var Dn=Ze.object({selector:Ze.string().describe("CSS selector for the select element"),value:Ze.string().describe("The option value to select")}),Tr=u({description:"Select an option from a dropdown/select element",name:"select",schema:Dn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).selectOption(t.value,{timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"select",value:{type:"static",value:t.value}};return h({assertions:[],detail:`Selected "${t.value}" in ${t.selector}`,duration:performance.now()-r,nodeType:"select",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Select ${t.value}`})}});import{z as Cr}from"zod";var qn=Cr.object({selector:Cr.string().describe("CSS selector for the checkbox to uncheck")}),Ar=u({description:"Uncheck a checkbox matching the CSS selector",name:"uncheck",schema:qn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).uncheck({timeout:5e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},type:"uncheck"};return h({assertions:[],detail:`Unchecked ${t.selector}`,duration:performance.now()-r,nodeType:"uncheck",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Uncheck ${t.selector}`})}});import{z as Nr}from"zod";var Bn=Nr.object({ms:Nr.number().describe("Milliseconds to wait")}),Er=u({description:"Wait for a specified duration in milliseconds",name:"wait",schema:Bn,execute:async(e,t)=>{let r=performance.now();await e.page.waitForTimeout(t.ms);let o=`agent-step-${String(e.stepIndex)}`,n={duration:t.ms,id:o,type:"wait"};return h({assertions:[],detail:`Waited ${String(t.ms)}ms`,duration:performance.now()-r,nodeType:"wait",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Wait ${String(t.ms)}ms`})}});import{z as et}from"zod";var Mn=et.object({selector:et.string().describe("CSS selector for the element to wait for"),state:et.enum(["visible","hidden","attached","detached"]).default("visible").describe("The state to wait for (default: visible)")}),Ir=u({description:"Wait for an element matching the CSS selector to reach the specified state. More reliable than a blind wait \u2014 use this to wait for elements to appear, disappear, or attach/detach from the DOM.",name:"wait_for",schema:Mn,execute:async(e,t)=>{let r=performance.now();await e.page.locator(t.selector).waitFor({state:t.state,timeout:15e3});let n={id:`agent-step-${String(e.stepIndex)}`,locator:{by:"css",value:t.selector},state:t.state,type:"waitFor"};return h({assertions:[],detail:`Waited for ${t.selector} to be ${t.state}`,duration:performance.now()-r,nodeType:"waitFor",page:e.page,runStartTime:e.runStartTime,specNode:n,status:"passed",stepIndex:e.stepIndex,title:`Wait for ${t.selector} to be ${t.state}`})}});var tt=[pr,Dt,Ht,mr,Ft,Ar,Tr,cr,Er,Ir,br,rr,Pr,Lt,yr,wr,ir,qt,dr,Vt,$t,jt,Tt,Et,At,It,Kt,Qt,Zt,or,tr,sr,kr,xr,hr,zt,Mt],Gu=new Map(tt.map(e=>[e.name,e])),Gn=tt.map(e=>e.anthropicTool),zn=tt.map(e=>`- **${e.name}**: ${e.anthropicTool.description??""}`).join(`
|
|
39
|
-
`);import Ku from"fs";import Qu from"path";import Hn from"pino";var L=Hn({transport:{options:{ignore:"pid,hostname"},target:"pino-pretty"}});import{execFile as dp}from"child_process";import{createRequire as pp}from"module";import mp from"fs";import gp from"path";import{chromium as wp}from"playwright";import{mkdir as vp,writeFile as xp}from"fs/promises";import Pp from"path";import{readdir as Bp,rm as Mp,stat as Gp}from"fs/promises";import Hp from"os";import ea from"path";import{graphql as ta}from"gql.tada";import{print as Qn}from"graphql";async function X(e){let t=Qn(e.document),r=JSON.stringify({query:t,variables:e.variables}),o=`${e.config.ripploServerUrl}/graphql`,n;try{n=await fetch(o,{body:r,headers:{Authorization:`Bearer ${e.config.token}`,"Content-Type":"application/json"},method:"POST"})}catch(i){let c=i instanceof Error?i.message:String(i);throw new Error(`Failed to connect to Ripplo server at ${e.config.ripploServerUrl}. Make sure \`npx ripplo\` is running and your internet connection is active. Details: ${c}`)}let a=await n.json();if(!Xn(a))throw new Error("Invalid GraphQL response");if(Zn(a))throw new Error(a.errors.map(i=>i.message).join(", "));if(a.data==null)throw new Error("No data returned from server");return a.data}function Xn(e){return typeof e=="object"&&e!=null&&("data"in e||"errors"in e)}function Zn(e){return Array.isArray(e.errors)&&e.errors.length>0}var Xp=ea.join(process.cwd(),".ripplo","debug"),Zp=360*60*1e3,ef=ta(`
|
|
40
|
-
mutation RevokeCurrentCliToken {
|
|
41
|
-
revokeCurrentCliToken
|
|
42
|
-
}
|
|
43
|
-
`);import Ur from"fs";import nt from"path";import{z as J}from"zod";var na=J.object({baseUrl:J.string(),cloudBaseUrl:J.string(),preconditionApiPath:J.string(),projectId:J.string()}),aa=J.object({ripploServerUrl:J.string(),token:J.string(),webhookSecret:J.string()}),sa=na.extend(aa.shape),Wr=nt.join(process.cwd(),".ripplo"),ia=nt.join(Wr,"settings.json"),la=nt.join(Wr,"settings.local.json");function Fr(e){if(!Ur.existsSync(e))return null;let t=Ur.readFileSync(e,"utf8");return JSON.parse(t)}function at(){let e=Fr(ia),t=Fr(la);if(e==null||t==null)return null;let r=sa.safeParse({...e,...t});return r.success?r.data:null}import{z as s}from"zod";import{z as q}from"zod";import{z as R}from"zod";var da=R.object({by:R.literal("css"),value:R.string().min(1)}),ua=R.object({by:R.literal("testId"),value:R.string().min(1)}),pa=R.object({by:R.literal("role"),exact:R.boolean().optional(),name:R.string().optional(),role:R.string().min(1)}),fa=R.object({by:R.literal("text"),exact:R.boolean().optional(),value:R.string().min(1)}),ma=R.object({by:R.literal("label"),exact:R.boolean().optional(),value:R.string().min(1)}),ha=R.object({by:R.literal("placeholder"),value:R.string().min(1)}),ga=R.object({by:R.literal("altText"),value:R.string().min(1)}),b=R.discriminatedUnion("by",[da,ua,pa,fa,ma,ha,ga]);import{z as Lr}from"zod";var U=Lr.enum(["equals","notEquals","contains","startsWith","endsWith","matches"]),K=Lr.enum(["equals","notEquals","greaterThan","greaterThanOrEqual","lessThan","lessThanOrEqual"]);import{z as j}from"zod";var ya=j.object({type:j.literal("static"),value:j.union([j.string(),j.number(),j.boolean()])}),st=j.object({name:j.string().min(1),type:j.literal("variable")}),Z=j.discriminatedUnion("type",[ya,st]),x=j.discriminatedUnion("type",[j.object({type:j.literal("static"),value:j.string()}),st]),oe=j.discriminatedUnion("type",[j.object({type:j.literal("static"),value:j.number().int().nonnegative()}),st]);var ne=q.discriminatedUnion("type",[q.object({locator:b,type:q.literal("elementVisible")}),q.object({locator:b,type:q.literal("elementNotVisible")}),q.object({expected:x,operator:U,type:q.literal("urlMatch")}),q.object({expected:x,locator:b,operator:U,type:q.literal("textMatch")}),q.object({expected:Z,operator:K,type:q.literal("variableCompare"),variable:q.string().min(1)})]);import{z as _}from"zod";var ue=_.discriminatedUnion("type",[_.object({default:_.string().optional(),type:_.literal("string")}),_.object({default:_.number().optional(),type:_.literal("number")}),_.object({default:_.boolean().optional(),type:_.literal("boolean")}),_.object({key:_.string().min(1),type:_.literal("env")})]);var wa=s.string().min(1),Y=s.array(wa).min(1),w={comment:s.string().optional(),id:s.string().min(1),label:s.string().optional(),next:s.string().optional(),timeout:s.number().int().positive().optional()},ba=s.object({...w,type:s.literal("goto"),url:x}),Sa=s.object({...w,locator:b,type:s.literal("click")}),ka=s.object({...w,locator:b,type:s.literal("fill"),value:x}),va=s.object({...w,locator:b,type:s.literal("select"),value:x}),xa=s.object({...w,locator:b,type:s.literal("hover")}),Ra=s.object({...w,key:s.string().min(1),locator:b.optional(),type:s.literal("press")}),Pa=s.object({...w,locator:b,type:s.literal("check")}),Ta=s.object({...w,locator:b,type:s.literal("uncheck")}),Ca=s.object({...w,type:s.literal("screenshot")}),Aa=s.object({...w,height:s.number().int().positive(),type:s.literal("setViewport"),width:s.number().int().positive()}),Na=s.object({...w,duration:s.number().int().positive(),type:s.literal("wait")}),Ea=s.object({...w,message:s.string().min(1),type:s.literal("fail")}),Ia=s.object({...w,type:s.literal("setVariable"),value:Z,variable:s.string().min(1)}),$a=s.object({...w,locator:b,type:s.literal("extractText"),variable:s.string().min(1)}),ja=s.object({...w,files:s.array(s.string()).min(1),locator:b,type:s.literal("upload")}),Oa=s.object({...w,locator:b,type:s.literal("dblclick")}),Va=s.object({...w,source:b,target:b,type:s.literal("drag")}),Ua=s.object({...w,locator:b,type:s.literal("scrollIntoView")}),Fa=s.object({...w,locator:b,type:s.literal("type"),value:x}),Wa=s.object({...w,locator:b,type:s.literal("focus")}),La=s.object({...w,locator:b,type:s.literal("clear")}),_a=s.object({...w,locator:b,type:s.literal("rightClick")}),Da=s.object({...w,action:s.enum(["accept","dismiss"]),promptText:s.string().optional(),type:s.literal("handleDialog")}),qa=s.object({...w,locator:b.optional(),type:s.literal("scroll"),x:s.number().int().optional(),y:s.number().int().optional()}),Ba=s.object({...w,action:s.enum(["read","write"]),type:s.literal("clipboard"),value:x.optional(),variable:s.string().min(1).optional()}),Ma=s.object({...w,permission:s.string().min(1),state:s.enum(["granted","prompt"]),type:s.literal("setPermission")}),Ga=s.object({...w,locator:b,state:s.enum(["visible","hidden","attached","detached"]).optional(),type:s.literal("waitFor")}),za=s.object({...w,expected:x,operator:U,type:s.literal("waitForUrl")}),Ha=s.object({...w,type:s.literal("waitForResponse"),urlPattern:x}),Ja=s.object({...w,type:s.literal("waitForRequest"),urlPattern:x}),Ka=s.object({...w,locator:b,type:s.literal("assertVisible")}),Ya=s.object({...w,locator:b,type:s.literal("assertNotVisible")}),Qa=s.object({...w,expected:x,locator:b,operator:U,type:s.literal("assertText")}),Xa=s.object({...w,expected:x,operator:U,type:s.literal("assertUrl")}),Za=s.object({...w,expected:oe,locator:b,operator:K,type:s.literal("assertCount")}),es=s.object({...w,expected:x,locator:b,operator:U,type:s.literal("assertValue")}),ts=s.object({...w,attribute:s.string().min(1),expected:x,locator:b,operator:U,type:s.literal("assertAttribute")}),rs=s.object({...w,locator:b,type:s.literal("assertEnabled")}),os=s.object({...w,locator:b,type:s.literal("assertDisabled")}),ns=s.object({...w,expected:x,operator:U,type:s.literal("assertTitle")}),as=s.object({...w,locator:b,type:s.literal("assertChecked")}),ss=s.object({...w,locator:b,type:s.literal("assertNotChecked")}),is=s.object({...w,locator:b,type:s.literal("assertFocused")}),ls=s.object({...w,httpOnly:s.boolean().optional(),name:x,operator:U.optional(),sameSite:s.enum(["Strict","Lax","None"]).optional(),secure:s.boolean().optional(),type:s.literal("assertCookie"),value:x.optional()}),cs=s.object({...w,bodyContains:x.optional(),headerContains:s.object({name:s.string().min(1),value:x}).optional(),status:s.number().int().positive().optional(),type:s.literal("assertResponse"),urlPattern:x}),ds=s.object({...w,alternate:Y.optional(),condition:ne,consequent:Y,type:s.literal("if")}),us=s.object({...w,body:Y,iteratorVar:s.string().min(1).optional(),times:s.number().int().positive(),type:s.literal("loop")}),ps=s.object({...w,body:Y,collection:s.union([s.array(s.record(s.string(),s.unknown())).min(1),s.object({name:s.string().min(1),type:s.literal("variable")})]),iteratorVar:s.string().min(1),type:s.literal("forEach")}),fs=s.object({...w,branches:s.array(Y).min(2),type:s.literal("parallel")}),ms=s.object({...w,body:Y,catch:Y.optional(),finally:Y.optional(),type:s.literal("try")}).refine(e=>e.catch!=null||e.finally!=null,{message:"try node must have at least one of 'catch' or 'finally'"}),hs=s.object({...w,nodes:Y,type:s.literal("group")}),Se=s.discriminatedUnion("type",[ba,Sa,ka,va,xa,Ra,Pa,Ta,Ga,za,Ha,Ja,Ka,Ya,Qa,Xa,Za,es,ts,rs,os,Ca,Aa,Na,Ea,Ia,$a,ja,Oa,Va,Ua,Fa,Wa,La,_a,Da,qa,Ba,Ma,ns,as,ss,is,ls,cs,ds,us,ps,fs,hs]),_r=s.union([Se,ms]),ae=s.object({entryNode:s.string().min(1),nodes:s.record(s.string(),_r),variables:s.record(s.string(),ue).optional(),version:s.literal(2)});function Ne(e){let t=ae.safeParse(e);return t.success?{data:t.data,success:!0}:{errors:t.error.issues.map(r=>({message:r.message,path:r.path.join(".")})),success:!1}}import{graphql as Fe,readFragment as ph}from"gql.tada";import{z as Ee}from"zod";var it=Ee.object({from:Ee.string().min(1).describe("Key of the source state in the states record"),to:Ee.string().min(1).describe("Key of the target state in the states record"),workflow:Ee.string().min(1).describe("Filename (without .json) of the workflow in .ripplo/workflows/ that executes this edge")}).describe("A directed edge between two states, executed by a workflow");import{z as ee}from"zod";import{z as C}from"zod";var lt=C.object({precondition:C.string().min(1).describe("Name of the precondition to check or execute, e.g. 'auth:admin'")}).describe("Request payload sent by Ripplo to both the check and execute endpoints"),Ie=C.object({satisfied:C.boolean().describe("Whether the precondition is already satisfied. If true, execution is skipped.")}).describe("Response from POST {preconditionApiUrl}/check"),$e=C.object({data:C.record(C.string(),C.string()).optional().describe("Key-value data returned by the precondition. Values are injected as variables that can be interpolated into route patterns ({{key}}) and workflow spec variable references. For example, a data:project precondition should return { projectId: 'clx...' } so the route /projects/{{projectId}}/settings resolves correctly."),error:C.string().optional().describe("Human-readable error message if success is false"),navigateTo:C.string().optional().describe("URL to navigate the browser to after this precondition executes. Use this to set the starting page for the test \u2014 e.g. an auth token URL or the page the workflow begins on. If multiple preconditions return navigateTo, only the last one is used."),success:C.boolean().describe("Whether the precondition was successfully satisfied")}).describe("Response from PUT {preconditionApiUrl}/execute. Set-Cookie headers are automatically captured and injected into the browser context. The data field provides values for route param interpolation and workflow variables."),ct=C.object({preconditions:C.array(C.string().min(1)).describe("Names of all preconditions that were executed during this run")}).describe("Request payload sent after a run completes. Fire-and-forget \u2014 errors are logged but don't fail the run."),dt=C.object({error:C.string().optional().describe("Human-readable error message if success is false"),success:C.boolean().describe("Whether teardown completed successfully")}).describe("Response from PUT {preconditionApiUrl}/teardown"),se=C.object({depends:C.array(C.string().min(1)).optional().describe("Names of other preconditions that must be satisfied first. Resolved via topological sort; cycles are rejected at validation time."),description:C.string().min(1).describe("Human-readable description of what this precondition ensures"),returns:C.array(C.string().min(1)).optional().describe("Keys that the execute response's data field will contain. e.g. ['projectId', 'workflowId']. These are used for route param interpolation ({{projectId}}) and workflow variables. Declared here so generated types are strongly typed per precondition.")}).describe("A named precondition declared at the graph level. States reference these by name.");import{z as je}from"zod";var ut=je.object({preconditions:je.array(je.string().min(1)).describe("Ordered list of precondition names to satisfy before entering this state"),route:je.string().min(1).describe("URL pattern with {{placeholders}} for dynamic segments, e.g. '/projects/{{projectId}}/settings'")}).describe("A distinct application state \u2014 a unique combination of location, auth context, and data conditions");var ke=ee.object({edges:ee.array(it).describe("Directed edges between states, each executed by a workflow"),preconditions:ee.record(ee.string(),se).describe("Named preconditions keyed by name (e.g. 'auth:admin', 'data:three-projects'). States reference these by name."),resetPrecondition:ee.string().optional().describe("Name of a precondition to run before every state setup as a global clean slate"),states:ee.record(ee.string(),ut).describe("States keyed by stable ID (kebab-case)"),version:ee.literal(3).describe("Schema version, always 3")}).describe("Ripplo State Graph v3 \u2014 models application states, edges, and executable preconditions");function pt(e){let t=ke.safeParse(e);return t.success?{data:t.data,success:!0}:{errors:t.error.issues.map(r=>({message:r.message,path:r.path.join(".")})),success:!1}}function Ns(e){let t=e.preconditions,r=new Set,o=new Set,n=[];function a(i){if(o.has(i)){n.push(`Precondition "${i}" has a circular dependency`);return}if(r.has(i))return;r.add(i),o.add(i);let c=t[i];c?.depends!=null&&c.depends.forEach(l=>{a(l)}),o.delete(i)}return Object.keys(t).forEach(i=>{a(i)}),n}function ft(e){let t=[],r=new Set(Object.keys(e.preconditions)),o=new Set(Object.keys(e.states));e.edges.forEach((l,p)=>{o.has(l.from)||t.push({message:`References non-existent source state "${l.from}"`,path:`edges[${String(p)}].from`}),o.has(l.to)||t.push({message:`References non-existent target state "${l.to}"`,path:`edges[${String(p)}].to`})}),Object.entries(e.states).forEach(([l,p])=>{p.preconditions.forEach(g=>{r.has(g)||t.push({message:`References non-existent precondition "${g}"`,path:`states.${l}.preconditions`})})}),Object.entries(e.preconditions).forEach(([l,p])=>{p.depends!=null&&p.depends.forEach(g=>{r.has(g)||t.push({message:`Depends on non-existent precondition "${g}"`,path:`preconditions.${l}.depends`})})}),e.resetPrecondition!=null&&!r.has(e.resetPrecondition)&&t.push({message:`References non-existent precondition "${e.resetPrecondition}"`,path:"resetPrecondition"});let n=new Set(e.edges.flatMap(l=>[l.from,l.to]));Object.keys(e.states).forEach(l=>{n.has(l)||t.push({message:"State is not referenced by any edge (orphan)",path:`states.${l}`})});let a=new Set;Object.values(e.states).forEach(l=>{l.preconditions.forEach(p=>a.add(p))}),Object.values(e.preconditions).forEach(l=>{l.depends!=null&&l.depends.forEach(p=>a.add(p))}),e.resetPrecondition!=null&&a.add(e.resetPrecondition),Object.keys(e.preconditions).forEach(l=>{a.has(l)||t.push({message:"Defined but never used by any state or dependency",path:`preconditions.${l}`})});let i=new Set;return e.edges.forEach((l,p)=>{let g=`${l.from}|${l.to}|${l.workflow}`;i.has(g)&&t.push({message:`Duplicate edge from "${l.from}" to "${l.to}" with workflow "${l.workflow}"`,path:`edges[${String(p)}]`}),i.add(g)}),Ns(e).forEach(l=>t.push({message:l,path:"preconditions"})),t}import{parseSetCookie as Qm}from"set-cookie-parser";import{z as Qr}from"zod";var Ws=_o(Yr(),1);import Gm from"crypto";var th=Qr.record(Qr.string(),se);import{graphql as gt}from"gql.tada";var ah=gt(`
|
|
44
|
-
mutation StartRunCLI($runId: String!, $platform: String!, $agentProfileId: String) {
|
|
45
|
-
startRun(runId: $runId, platform: $platform, agentProfileId: $agentProfileId) {
|
|
46
|
-
id
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
`),sh=gt(`
|
|
50
|
-
mutation SubmitRunStepsCLI($runResultId: String!, $steps: [StepInput!]!) {
|
|
51
|
-
submitRunSteps(runResultId: $runResultId, steps: $steps)
|
|
52
|
-
}
|
|
53
|
-
`),ih=gt(`
|
|
54
|
-
mutation CompleteRunCLI(
|
|
55
|
-
$runResultId: String!
|
|
56
|
-
$status: String!
|
|
57
|
-
$duration: Int!
|
|
58
|
-
$passCount: Int!
|
|
59
|
-
$failCount: Int!
|
|
60
|
-
$warnCount: Int!
|
|
61
|
-
$summary: String
|
|
62
|
-
) {
|
|
63
|
-
completeRun(
|
|
64
|
-
runResultId: $runResultId
|
|
65
|
-
status: $status
|
|
66
|
-
duration: $duration
|
|
67
|
-
passCount: $passCount
|
|
68
|
-
failCount: $failCount
|
|
69
|
-
warnCount: $warnCount
|
|
70
|
-
summary: $summary
|
|
71
|
-
) {
|
|
72
|
-
id
|
|
73
|
-
status
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
`);var zs=Fe(`
|
|
77
|
-
fragment WorkflowRun on Workflow {
|
|
78
|
-
id
|
|
79
|
-
slug
|
|
80
|
-
name
|
|
81
|
-
spec
|
|
82
|
-
}
|
|
83
|
-
`),Hs=Fe(`
|
|
84
|
-
fragment ProjectRun on Project {
|
|
85
|
-
id
|
|
86
|
-
baseUrl
|
|
87
|
-
preconditionApiPath
|
|
88
|
-
stateGraph {
|
|
89
|
-
preconditions
|
|
90
|
-
edges {
|
|
91
|
-
sourceState {
|
|
92
|
-
stableId
|
|
93
|
-
preconditions
|
|
94
|
-
}
|
|
95
|
-
workflowSlug
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
workflows {
|
|
99
|
-
...WorkflowRun
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
`,[zs]),Rh=Fe(`
|
|
103
|
-
query ProjectRun($projectId: String!) {
|
|
104
|
-
project(id: $projectId) {
|
|
105
|
-
...ProjectRun
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
`,[Hs]),Ph=Fe(`
|
|
109
|
-
query AgentProfileRun($id: String!) {
|
|
110
|
-
agentProfile(id: $id) {
|
|
111
|
-
id
|
|
112
|
-
name
|
|
113
|
-
description
|
|
114
|
-
output
|
|
115
|
-
successCriteria
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
`);function B(e){return{content:[{text:e,type:"text"}]}}async function Xr(e){let t=at();return t==null?B("Not logged in. Run: ripplo login"):e(t)}import{z as F}from"zod";import Js from"fs";import pg from"path";import{z as fe}from"zod";var xe=fe.object({additionalChecks:fe.array(fe.string()).default([]),description:fe.string(),expectedOutcome:fe.string(),name:fe.string(),spec:ae});function We(e){let t=Js.readFileSync(e,"utf8"),r=JSON.parse(t);return xe.parse(r)}var Ks=F.object({additionalProperties:F.unknown().optional(),properties:F.record(F.string(),F.record(F.string(),F.unknown())).optional(),required:F.array(F.string()).optional(),type:F.string().optional()}),Zr=F.object({oneOf:F.array(Ks).optional()});function eo(){let e=ri(),t={category:"Control Flow",fields:"body, catch?, finally?",typeName:"try"},r=[...e.map(a=>oi(a)),t],o=ni(r);return`# Ripplo Workflow Spec v2
|
|
119
|
-
|
|
120
|
-
## Node Types
|
|
121
|
-
|
|
122
|
-
${Ys.filter(a=>o.has(a)).map(a=>{let c=(o.get(a)??[]).map(l=>`- ${l.typeName} { ${l.fields} }`);return`### ${a}
|
|
123
|
-
${c.join(`
|
|
124
|
-
`)}`}).join(`
|
|
125
|
-
|
|
126
|
-
`)}
|
|
127
|
-
|
|
128
|
-
All nodes also have: id, type, next?, timeout?, label?, comment?
|
|
129
|
-
|
|
130
|
-
## Quick Reference
|
|
131
|
-
- Locator types: css, testId, role, text, label, placeholder, altText
|
|
132
|
-
- Value refs: { type: "static", value: "..." } or { type: "variable", name: "..." }
|
|
133
|
-
- Operators (string): equals, notEquals, contains, startsWith, endsWith, matches
|
|
134
|
-
- Operators (numeric): equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual
|
|
135
|
-
|
|
136
|
-
## Variable Interpolation
|
|
137
|
-
|
|
138
|
-
Template variables use **double curly braces**: \`{{variableName}}\`. This is the ONLY valid syntax.
|
|
139
|
-
|
|
140
|
-
- **CORRECT**: \`"/projects/{{projectId}}"\`
|
|
141
|
-
- **WRONG**: \`"/projects/$projectId"\`, \`"/projects/\${projectId}"\`, \`"/projects/{projectId}"\`
|
|
142
|
-
|
|
143
|
-
## Workflow File Structure
|
|
144
|
-
|
|
145
|
-
Each workflow file in \`.ripplo/workflows/\` is a JSON file with: name, description, expectedOutcome, additionalChecks, spec.
|
|
146
|
-
The spec contains: version (2), entryNode, nodes, variables?.
|
|
147
|
-
|
|
148
|
-
Use \`get_node_schema\` for full JSON Schema of specific node types.
|
|
149
|
-
Use \`get_spec_patterns\` for locator, condition, and variable schemas + best practices.`}function to({nodeTypes:e}){let r=Zr.parse(structuredClone(F.toJSONSchema(Se))).oneOf??[],o=new Map;r.forEach(a=>{let i=oo(a);i.length>0&&o.set(i,a)});let n=[];return e.forEach(a=>{if(a==="try"){n.push(ii);return}let i=o.get(a);if(i==null){n.push(`## ${a}
|
|
150
|
-
|
|
151
|
-
Unknown node type "${a}".`);return}n.push(`## ${a}
|
|
152
|
-
|
|
153
|
-
\`\`\`json
|
|
154
|
-
${JSON.stringify(i,null,2)}
|
|
155
|
-
\`\`\``)}),n.push(si),n.join(`
|
|
156
|
-
|
|
157
|
-
`)}function ro(){return`# Spec Patterns & Building Blocks
|
|
158
|
-
|
|
159
|
-
${[{label:"Locator",schema:b},{label:"Condition",schema:ne},{label:"StringValueRef",schema:x},{label:"NumericValueRef",schema:oe},{label:"ValueRef",schema:Z},{label:"ComparisonOperator (string)",schema:U},{label:"NumericOperator",schema:K},{label:"VariableDef",schema:ue},{label:"WorkflowFile (top-level)",schema:xe},{label:"WorkflowSpec (the spec field)",schema:ae}].map(({label:r,schema:o})=>`### ${r}
|
|
160
|
-
|
|
161
|
-
\`\`\`json
|
|
162
|
-
${JSON.stringify(F.toJSONSchema(o),null,2)}
|
|
163
|
-
\`\`\``).join(`
|
|
164
|
-
|
|
165
|
-
`)}
|
|
166
|
-
|
|
167
|
-
## Best Practices
|
|
168
|
-
|
|
169
|
-
1. **Locator preference**: role > text > label > placeholder > testId > css
|
|
170
|
-
2. **Use variables** for dynamic data (credentials, URLs, test data)
|
|
171
|
-
3. **Use env variables** for secrets: \`{ "type": "env", "key": "PASSWORD" }\`
|
|
172
|
-
4. **Add assertions** after important actions to verify state
|
|
173
|
-
5. **Use \`waitFor\`** before interacting with elements that may not be immediately available
|
|
174
|
-
6. **Keep node IDs descriptive**: \`fill-email\`, \`click-submit\`, \`assert-dashboard-visible\`
|
|
175
|
-
7. **Use \`group\`** to organize related steps
|
|
176
|
-
8. **Use \`try/catch\`** for steps that might fail (e.g., dismissing optional modals)
|
|
177
|
-
9. **Always call \`validate\`** after editing a workflow file
|
|
178
|
-
10. **URLs in goto nodes must be relative paths** (\`/login\`), never absolute URLs \u2014 the baseUrl handles the origin
|
|
179
|
-
11. **Value references use \`"type": "static"\`**, never \`"type": "literal"\`
|
|
180
|
-
12. **Node IDs must match their key** in the \`nodes\` object
|
|
181
|
-
13. **Template variables use \`{{name}}\`**, never \`$name\` or \`\${name}\` \u2014 only double curly braces work`}var Ys=["Actions","Waits","Assertions","Variables","Control Flow","Other"],Qs=new Set(["if","loop","forEach","parallel","try","group"]),Xs=new Set(["setVariable","extractText"]),Zs=new Set(["screenshot","setViewport","fail"]),ei=new Set(["comment","id","label","next","timeout","type"]);function ti(e){return e.startsWith("assert")?"Assertions":e.startsWith("waitFor")||e==="wait"?"Waits":Qs.has(e)?"Control Flow":Xs.has(e)?"Variables":Zs.has(e)?"Other":"Actions"}function ri(){return Zr.parse(structuredClone(F.toJSONSchema(Se))).oneOf??[]}function oo(e){let t=e.properties?.type;if(t==null)return"";let r=t.const;return typeof r=="string"?r:""}function oi(e){let t=oo(e),r=new Set(e.required??[]),o=Object.keys(e.properties??{}).filter(n=>!ei.has(n)).map(n=>r.has(n)?n:`${n}?`).join(", ");return{category:ti(t),fields:o,typeName:t}}function ni(e){let t=new Map;return e.forEach(r=>{let o=t.get(r.category)??[];o.push(r),t.set(r.category,o)}),t}function ai(){return`---
|
|
182
|
-
|
|
183
|
-
## Referenced Schemas
|
|
184
|
-
|
|
185
|
-
${[me("Locator",b),me("StringValueRef",x),me("NumericValueRef",oe),me("ComparisonOperator",U),me("NumericOperator",K),me("Condition",ne)].join(`
|
|
186
|
-
|
|
187
|
-
`)}`}var si=ai();function me(e,t){return`### ${e}
|
|
188
|
-
|
|
189
|
-
\`\`\`json
|
|
190
|
-
${JSON.stringify(F.toJSONSchema(t),null,2)}
|
|
191
|
-
\`\`\``}var ii=["## try","","The `try` node uses a refinement: at least one of `catch` or `finally` is required.","","Fields: body (required), catch? (optional), finally? (optional)","Plus base fields: id, type, next?, timeout?, label?, comment?"].join(`
|
|
192
|
-
`);import{graphql as Le,readFragment as li}from"gql.tada";import no from"figures";var Re={cross:no.cross,tick:no.tick};var so=Le(`
|
|
193
|
-
fragment WorkflowIdMCP on Workflow {
|
|
194
|
-
id
|
|
195
|
-
slug
|
|
196
|
-
}
|
|
197
|
-
`),ci=Le(`
|
|
198
|
-
query ProjectWorkflowIdsMCP($projectId: String!) {
|
|
199
|
-
project(id: $projectId) {
|
|
200
|
-
id
|
|
201
|
-
devSession {
|
|
202
|
-
id
|
|
203
|
-
}
|
|
204
|
-
workflows {
|
|
205
|
-
...WorkflowIdMCP
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
`,[so]),di=Le(`
|
|
210
|
-
mutation CreateRunMCP($workflowId: String!, $platforms: [String!]!, $concurrency: Int!) {
|
|
211
|
-
createRun(workflowId: $workflowId, platforms: $platforms, concurrency: $concurrency) {
|
|
212
|
-
id
|
|
213
|
-
status
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
`),ui=Le(`
|
|
217
|
-
query RunStatusMCP($id: String!) {
|
|
218
|
-
run(id: $id) {
|
|
219
|
-
id
|
|
220
|
-
status
|
|
221
|
-
results {
|
|
222
|
-
id
|
|
223
|
-
status
|
|
224
|
-
duration
|
|
225
|
-
passCount
|
|
226
|
-
failCount
|
|
227
|
-
warnCount
|
|
228
|
-
summary
|
|
229
|
-
traceEntries {
|
|
230
|
-
title
|
|
231
|
-
status
|
|
232
|
-
detail
|
|
233
|
-
duration
|
|
234
|
-
assertions {
|
|
235
|
-
description
|
|
236
|
-
status
|
|
237
|
-
detail
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
`);async function io({config:e,slugs:t}){let r=await pi({config:e,slugs:t}),o=await Promise.allSettled(r.map(async({slug:l,workflowId:p})=>({runId:await mi({config:e,workflowId:p}),slug:l}))),n=[],a=[];o.forEach((l,p)=>{let g=r[p];if(g==null)return;if(l.status==="fulfilled"){n.push(l.value);return}let f=l.reason instanceof Error?l.reason.message:String(l.reason);a.push({runId:"",slug:g.slug,summary:`ERROR creating run: ${f}`})}),(await Promise.allSettled(n.map(async({runId:l,slug:p})=>{let g=await gi({config:e,runId:l}),f=fi({config:e,runId:l,slug:p}),d=`.ripplo/debug/${l}/`;return{runId:l,slug:p,summary:`View run: ${f}
|
|
244
|
-
Debug artifacts: ${d}
|
|
245
|
-
|
|
246
|
-
${g}`}}))).forEach((l,p)=>{let g=n[p];if(g==null)return;if(l.status==="fulfilled"){a.push(l.value);return}let f=l.reason instanceof Error?l.reason.message:String(l.reason);a.push({runId:g.runId,slug:g.slug,summary:`ERROR polling run ${g.runId}: ${f}`})});let c=a.map(l=>`**${l.slug}:**
|
|
247
|
-
${l.summary}`).join(`
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
`);return{results:a,summary:c}}async function pi({config:e,slugs:t}){let r=await X({config:e,document:ci,variables:{projectId:e.projectId}});if(r.project==null)throw new Error("Project not found");if(r.project.devSession==null)throw new Error("No active dev session. Start one by running `ripplo` (or `npx ripplo`) in your terminal, then try again.");let o=(r.project.workflows??[]).map(n=>li(so,n));return t.length===0?o.map(n=>({slug:n.slug,workflowId:n.id})):t.map(n=>{let a=o.find(i=>i.slug===n);if(a==null)throw new Error(`Workflow "${n}" not found on server. Is the dev session running? (It watches .ripplo/ files and syncs them automatically.)`);return{slug:n,workflowId:a.id}})}function fi({config:e,runId:t,slug:r}){return`${e.ripploServerUrl.replace(/:3000\b/,":3001")}/projects/${e.projectId}/workflows/${r}/runs/${t}`}async function mi({config:e,workflowId:t}){let r=await X({config:e,document:di,variables:{concurrency:1,platforms:["chromium"],workflowId:t}});if(r.createRun==null)throw new Error("Failed to create run");return r.createRun.id}var hi=2e3,ao=150;async function gi({config:e,runId:t}){let r=0;for(;r<ao;){await wi(hi),r+=1;let n=(await X({config:e,document:ui,variables:{id:t}})).run;if(n==null)throw new Error(`Run ${t} not found`);if(!(n.status==="pending"||n.status==="running"))return yi({results:n.results??[],runId:t,status:n.status})}return`Run ${t} timed out after polling ${String(ao)} times.`}function yi({results:e,runId:t,status:r}){let o=[`Run ${t}: ${r.toUpperCase()}`];return e.forEach(n=>{o.push(` ${n.status}: ${String(n.passCount)} passed, ${String(n.failCount)} failed (${String(n.duration??0)}ms)`),n.summary!=null&&o.push(` Summary: ${n.summary}`),n.traceEntries.forEach(a=>{let i=a.status==="passed"?Re.tick:Re.cross,c=a.detail==null?"":` \u2014 ${a.detail}`;o.push(` ${i} ${a.title} (${String(a.duration)}ms)${c}`),a.assertions.forEach(l=>{let p=l.status==="passed"?Re.tick:Re.cross,g=l.detail==null?"":` \u2014 ${l.detail}`;o.push(` ${p} ${l.description}${g}`)})})}),o.join(`
|
|
252
|
-
`)}function wi(e){return new Promise(t=>{setTimeout(t,e)})}import{z as bi}from"zod";function lo(){return Si()}function co(){return ki()}function ie(e,t){let r=bi.toJSONSchema(t);return`### ${e}
|
|
253
|
-
|
|
254
|
-
\`\`\`json
|
|
255
|
-
${JSON.stringify(r,null,2)}
|
|
256
|
-
\`\`\``}function Si(){return[`# Ripplo State Graph Spec
|
|
257
|
-
|
|
258
|
-
The graph schema is documented below as JSON Schema (generated from Zod at runtime \u2014 always in sync with the code).
|
|
259
|
-
|
|
260
|
-
## File Structure
|
|
261
|
-
|
|
262
|
-
The graph lives at \`.ripplo/graph.json\`. The top-level shape is the StateGraph schema below. One graph per project.`,`## Graph Schema
|
|
263
|
-
|
|
264
|
-
`+ie("StateGraph",ke),`## Preconditions (summary)
|
|
265
|
-
|
|
266
|
-
Preconditions are named capabilities declared at the graph level and referenced by name from states. They represent what must be true before entering a state (auth, data, feature flags, etc.).
|
|
267
|
-
|
|
268
|
-
### Execution flow:
|
|
269
|
-
1. **Check**: \`POST {preconditionApiPath}/check\` \u2014 is it already satisfied?
|
|
270
|
-
2. **Execute** (if not satisfied): \`PUT {preconditionApiPath}/execute\` \u2014 satisfy it (Set-Cookie headers captured automatically)
|
|
271
|
-
3. **Teardown** (after run): \`PUT {preconditionApiPath}/teardown\` \u2014 clean up (fire-and-forget)
|
|
272
|
-
|
|
273
|
-
Dependencies (\`depends\`) are resolved via topological sort. Cycles are rejected at validation time.
|
|
274
|
-
|
|
275
|
-
Use \`get_precondition_schema\` for full precondition API schemas, setup guide, and framework-specific auth examples.`].join(`
|
|
276
|
-
|
|
277
|
-
`)}function ki(){return["# Precondition API \u2014 Full Reference\n\nPreconditions are named capabilities declared at the graph level and referenced by name from states.\n\nRipplo does NOT run any business logic. The user's app exposes a Precondition API with three endpoints, and Ripplo sends the precondition name in the request body.\n\n### Execution flow for each precondition:\n1. **Check**: `POST {preconditionApiPath}/check` \u2014 is it already satisfied?\n2. **Execute** (if not satisfied): `PUT {preconditionApiPath}/execute` \u2014 satisfy it\n3. Set-Cookie headers from execute responses are automatically captured and injected into the browser. All cookie attributes (Domain, Path, SameSite, HttpOnly, Secure, Expires, Max-Age) are preserved. Always include `Path=/` and an appropriate `SameSite` value on auth cookies.\n4. If the execute response includes `navigateTo`, the browser navigates to that URL after all preconditions complete (sets the starting page for the test)\n5. **Teardown** (after run): `PUT {preconditionApiPath}/teardown` \u2014 clean up (fire-and-forget)\n\n### Request signing (Standard Webhooks)\n\nAll precondition requests are signed using the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) specification (HMAC-SHA256). Every request includes three headers:\n- `webhook-id` \u2014 unique message ID\n- `webhook-timestamp` \u2014 Unix timestamp (seconds)\n- `webhook-signature` \u2014 `v1,<base64_hmac>`\n\nThe precondition API **must verify these signatures** to ensure requests come from Ripplo. See the setup guide below for verification instructions.\n\nDependencies (`depends`) are resolved via topological sort with deduplication. Cycles are rejected at validation time.",ie("Precondition (graph-level declaration)",se),ie("Precondition Check/Execute Request",lt),ie("Check Response",Ie),ie("Execute Response",$e),ie("Teardown Request",ct),ie("Teardown Response",dt),xi()].join(`
|
|
278
|
-
|
|
279
|
-
`)}function vi(){return`### Framework-specific auth examples
|
|
280
|
-
|
|
281
|
-
#### Supabase (with @supabase/ssr)
|
|
282
|
-
|
|
283
|
-
\`\`\`typescript
|
|
284
|
-
// PUT /api/test/preconditions/execute
|
|
285
|
-
case "auth:user": {
|
|
286
|
-
const email = \\\`ripplo-test-\\\${Date.now()}@example.com\\\`;
|
|
287
|
-
const password = "test-password-123";
|
|
288
|
-
// Create user with auto-confirmed email
|
|
289
|
-
await supabaseAdmin.auth.admin.createUser({
|
|
290
|
-
email,
|
|
291
|
-
password,
|
|
292
|
-
email_confirm: true,
|
|
293
|
-
});
|
|
294
|
-
// Sign in to get session tokens
|
|
295
|
-
const { data } = await supabaseAdmin.auth.signInWithPassword({ email, password });
|
|
296
|
-
if (!data.session) {
|
|
297
|
-
return res.json({ success: false, error: "Failed to create session" });
|
|
298
|
-
}
|
|
299
|
-
// Set Supabase auth cookies \u2014 captured by Ripplo automatically
|
|
300
|
-
const cookieName = \\\`sb-\\\${SUPABASE_PROJECT_REF}-auth-token\\\`;
|
|
301
|
-
const cookieValue = Buffer.from(JSON.stringify({
|
|
302
|
-
access_token: data.session.access_token,
|
|
303
|
-
refresh_token: data.session.refresh_token,
|
|
304
|
-
})).toString("base64url");
|
|
305
|
-
res.setHeader("Set-Cookie", \\\`\\\${cookieName}=\\\${cookieValue}; Path=/; HttpOnly; SameSite=Lax\\\`);
|
|
306
|
-
res.json({ success: true, navigateTo: "/dashboard" });
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
\`\`\`
|
|
310
|
-
|
|
311
|
-
**Key points for Supabase:**
|
|
312
|
-
- Use \`admin.createUser()\` with \`email_confirm: true\`
|
|
313
|
-
- Use \`signInWithPassword()\` to get session tokens after creating the user
|
|
314
|
-
- Set the cookie in Supabase's expected format: \`sb-<project-ref>-auth-token\`
|
|
315
|
-
- Always include \`Path=/; SameSite=Lax\` on the Set-Cookie header
|
|
316
|
-
- The \`navigateTo\` URL sets the browser's starting page after cookies are injected`}function xi(){return`## Precondition API Setup
|
|
317
|
-
|
|
318
|
-
To enable precondition execution, the user's app must expose three REST endpoints.
|
|
319
|
-
|
|
320
|
-
### Step 1: Set \`preconditionApiPath\` in \`.ripplo/settings.json\`
|
|
321
|
-
|
|
322
|
-
\`\`\`json
|
|
323
|
-
{
|
|
324
|
-
"preconditionApiPath": "http://localhost:3000/api/test/preconditions"
|
|
325
|
-
}
|
|
326
|
-
\`\`\`
|
|
327
|
-
|
|
328
|
-
This can be a relative path (appended to \`baseUrl\`) or an absolute URL. Absolute URLs are useful when the precondition API runs on a different server than the app.
|
|
329
|
-
|
|
330
|
-
### Step 2: Generate typed schemas
|
|
331
|
-
|
|
332
|
-
Use the \`--output\` flag to place the file where it naturally fits in the project:
|
|
333
|
-
|
|
334
|
-
\`\`\`bash
|
|
335
|
-
ripplo generate-types --output src/generated/precondition-api.ts # simple project
|
|
336
|
-
ripplo generate-types --output apps/server/src/generated/precondition-api.ts # monorepo
|
|
337
|
-
\`\`\`
|
|
338
|
-
|
|
339
|
-
**Important: Wire this into the project's existing dev workflow.** Look for how the project already runs codegen (Prisma generate, GraphQL codegen, gql.tada, etc.) and add \`ripplo generate-types\` alongside it \u2014 in a package.json script, turbo pipeline, or CI step.
|
|
340
|
-
|
|
341
|
-
The generated file should be committed to the repo and the \`--output\` path should be importable from the server code without path gymnastics.
|
|
342
|
-
|
|
343
|
-
${Ri()}
|
|
344
|
-
|
|
345
|
-
### Key principles:
|
|
346
|
-
|
|
347
|
-
1. **Verify webhook signatures**: Every precondition request is signed using the Standard Webhooks spec. Install the \`standardwebhooks\` package (available for JS, Python, Go, Ruby, and more) and use its \`verify()\` method \u2014 do NOT hand-roll HMAC checks, as the library enforces timestamp tolerance for replay protection. The signing secret is the project's webhook secret \u2014 for local development, read it from \`.ripplo/settings.local.json\` (\`webhookSecret\` field) and set it as the \`RIPPLO_WEBHOOK_SECRET\` environment variable in your \`.env\` or \`.env.local\` file. In CI/staging, use the value from Ripplo Settings > Security in the web dashboard. Example:
|
|
348
|
-
\`\`\`typescript
|
|
349
|
-
import { Webhook } from "standardwebhooks";
|
|
350
|
-
const wh = new Webhook(process.env.RIPPLO_WEBHOOK_SECRET);
|
|
351
|
-
// In your middleware:
|
|
352
|
-
wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"], "webhook-timestamp": req.headers["webhook-timestamp"], "webhook-signature": req.headers["webhook-signature"] });
|
|
353
|
-
\`\`\`
|
|
354
|
-
2. **Guard with environment variable**: Wrap all precondition route registration behind an environment variable check (e.g., \`process.env.ENABLE_RIPPLO_TESTING === "true"\`). These endpoints create users, seed data, and delete records \u2014 they must never be accessible in production. Set \`ENABLE_RIPPLO_TESTING=true\` in local development, CI, and dedicated testing/staging environments \u2014 anywhere you run Ripplo tests.
|
|
355
|
-
3. **Fresh, isolated context per run**: Auth preconditions must create a new ephemeral test user/session with unique identifiers (e.g., \`ripplo-test-\${crypto.randomUUID().slice(0,8)}@example.com\`). Data preconditions must create uniquely-named entities. Every run gets a fresh browser context with no shared state.
|
|
356
|
-
4. **Idempotency via check**: The check endpoint prevents double-execution.
|
|
357
|
-
5. **Teardown for cleanup**: Called after each run with all executed precondition names.
|
|
358
|
-
6. **All logic in your app**: Ripplo just calls your endpoints.
|
|
359
|
-
7. **Parallel-safe teardown**: Multiple workflows run simultaneously. Teardown must only delete data belonging to that specific run's user \u2014 use the session cookies forwarded in the teardown request to identify the user. Never bulk-delete by prefix (e.g., \`WHERE email LIKE 'ripplo-test-%'\`) \u2014 this destroys other parallel runs' active data.
|
|
360
|
-
|
|
361
|
-
${vi()}
|
|
362
|
-
|
|
363
|
-
## Best Practices
|
|
364
|
-
|
|
365
|
-
1. **Use kebab-case for state and edge IDs** \u2014 e.g. \`admin-dashboard\`, \`login-to-dashboard\`
|
|
366
|
-
2. **Keep \`id\` matching the key** \u2014 the state's \`id\` field must match its key in the \`states\` object
|
|
367
|
-
3. **Name preconditions with category prefixes** \u2014 e.g. \`auth:admin\`, \`data:projects\`, \`feature:dark-mode\`
|
|
368
|
-
4. **Use \`depends\` to avoid duplication** \u2014 if \`data:projects\` needs auth, declare \`depends: ["auth:admin"]\`
|
|
369
|
-
5. **Keep precondition handlers fast** \u2014 check should be a lightweight query, execute should create minimal test data
|
|
370
|
-
6. **Create fresh test users** \u2014 auth preconditions should create ephemeral users, not log in as shared accounts
|
|
371
|
-
7. **Validate after every edit** \u2014 call \`validate\` to catch missing refs and cycles
|
|
372
|
-
8. **Regenerate types after editing preconditions** \u2014 run \`ripplo generate-types\` when precondition names change
|
|
373
|
-
9. **Set \`ENABLE_RIPPLO_TESTING=true\` in dev, CI, and staging** \u2014 enable it anywhere you run Ripplo tests, but never in production deployments. Add it to \`.env.local\`, CI config, or your staging environment \u2014 not \`.env.production\``}function Ri(){return`### Step 3: Implement the three endpoints
|
|
374
|
-
|
|
375
|
-
Using the generated types (adjust import path to match your \`--output\`).
|
|
376
|
-
|
|
377
|
-
**Important: Guard these routes behind an environment variable.** These endpoints create users, seed data, and delete records \u2014 they should be enabled in development and dedicated testing/staging environments, but must never be accessible in production. Only register them when \`ENABLE_RIPPLO_TESTING\` is explicitly set.
|
|
378
|
-
|
|
379
|
-
\`\`\`typescript
|
|
380
|
-
import { Webhook } from "standardwebhooks";
|
|
381
|
-
import {
|
|
382
|
-
PreconditionCheckRequestSchema,
|
|
383
|
-
PreconditionExecuteRequestSchema,
|
|
384
|
-
PreconditionTeardownRequestSchema,
|
|
385
|
-
} from "@/generated/precondition-api";
|
|
386
|
-
|
|
387
|
-
// Only register precondition routes when explicitly enabled
|
|
388
|
-
if (process.env.ENABLE_RIPPLO_TESTING !== "true") {
|
|
389
|
-
throw new Error("Precondition routes require ENABLE_RIPPLO_TESTING=true");
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// Verify Standard Webhooks signature on all precondition requests
|
|
393
|
-
const wh = new Webhook(process.env.RIPPLO_WEBHOOK_SECRET);
|
|
394
|
-
function verifySignature(req, res, next) {
|
|
395
|
-
try {
|
|
396
|
-
wh.verify(JSON.stringify(req.body), {
|
|
397
|
-
"webhook-id": req.headers["webhook-id"],
|
|
398
|
-
"webhook-timestamp": req.headers["webhook-timestamp"],
|
|
399
|
-
"webhook-signature": req.headers["webhook-signature"],
|
|
400
|
-
});
|
|
401
|
-
next();
|
|
402
|
-
} catch {
|
|
403
|
-
res.status(401).json({ error: "Invalid webhook signature" });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Apply to all precondition routes
|
|
408
|
-
app.use("/api/test/preconditions", verifySignature);
|
|
409
|
-
|
|
410
|
-
// POST /api/test/preconditions/check
|
|
411
|
-
app.post("/api/test/preconditions/check", async (req, res) => {
|
|
412
|
-
const { precondition } = PreconditionCheckRequestSchema.parse(req.body);
|
|
413
|
-
switch (precondition) {
|
|
414
|
-
case "auth:admin": {
|
|
415
|
-
const session = getSessionFromCookies(req);
|
|
416
|
-
res.json({ satisfied: session?.role === "admin" });
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
case "data:three-projects": {
|
|
420
|
-
// Check if this user already has 3+ projects
|
|
421
|
-
const session = getSessionFromCookies(req);
|
|
422
|
-
const count = session ? await db.project.count({ where: { ownerId: session.userId } }) : 0;
|
|
423
|
-
res.json({ satisfied: count >= 3 });
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
default:
|
|
427
|
-
res.json({ satisfied: false });
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
// PUT /api/test/preconditions/execute
|
|
432
|
-
// Set-Cookie headers are captured automatically by Ripplo
|
|
433
|
-
app.put("/api/test/preconditions/execute", async (req, res) => {
|
|
434
|
-
const { precondition } = PreconditionExecuteRequestSchema.parse(req.body);
|
|
435
|
-
switch (precondition) {
|
|
436
|
-
case "auth:admin": {
|
|
437
|
-
const user = await createTestUser({ role: "admin" });
|
|
438
|
-
const session = await createSession(user.id);
|
|
439
|
-
res.cookie("session", session.token, { httpOnly: true });
|
|
440
|
-
// navigateTo sets the browser's starting page after this precondition runs.
|
|
441
|
-
// Use it to land on the right page (e.g. after setting auth via a URL).
|
|
442
|
-
res.json({ success: true, navigateTo: \\\`\\\${baseUrl}/dashboard\\\` });
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
case "data:three-projects": {
|
|
446
|
-
// Create uniquely-named projects scoped to the authenticated user
|
|
447
|
-
const suffix = crypto.randomUUID().slice(0, 8);
|
|
448
|
-
const session = getSessionFromCookies(req);
|
|
449
|
-
await Promise.all(
|
|
450
|
-
Array.from({ length: 3 }, (_, i) =>
|
|
451
|
-
db.project.create({ data: { name: \\\`ripplo-test-\\\${suffix}-\\\${i}\\\`, ownerId: session.userId } })
|
|
452
|
-
)
|
|
453
|
-
);
|
|
454
|
-
res.json({ success: true, data: { projectPrefix: \\\`ripplo-test-\\\${suffix}\\\` } });
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
default:
|
|
458
|
-
res.json({ success: false, error: "Unknown precondition" });
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
// PUT /api/test/preconditions/teardown
|
|
463
|
-
app.put("/api/test/preconditions/teardown", async (req, res) => {
|
|
464
|
-
const { preconditions } = PreconditionTeardownRequestSchema.parse(req.body);
|
|
465
|
-
// Scope cleanup to this run's user \u2014 never bulk-delete all test data
|
|
466
|
-
const session = getSessionFromCookies(req);
|
|
467
|
-
if (session) {
|
|
468
|
-
await cleanupTestDataForUser(session.userId);
|
|
469
|
-
}
|
|
470
|
-
res.json({ success: true });
|
|
471
|
-
});
|
|
472
|
-
\`\`\``}var uo={assertAttribute:"assertion",assertChecked:"assertion",assertCookie:"assertion",assertCount:"assertion",assertDisabled:"assertion",assertEnabled:"assertion",assertFocused:"assertion",assertNotChecked:"assertion",assertNotVisible:"assertion",assertResponse:"assertion",assertText:"assertion",assertTitle:"assertion",assertUrl:"assertion",assertValue:"assertion",assertVisible:"assertion",check:"action",clear:"action",click:"action",clipboard:"other",dblclick:"action",drag:"action",extractText:"other",fail:"other",fill:"action",focus:"action",forEach:"controlFlow",goto:"other",group:"controlFlow",handleDialog:"action",hover:"action",if:"controlFlow",loop:"controlFlow",parallel:"controlFlow",press:"action",rightClick:"action",screenshot:"other",scroll:"action",scrollIntoView:"action",select:"action",setPermission:"other",setVariable:"other",setViewport:"other",try:"controlFlow",type:"action",uncheck:"action",upload:"action",wait:"other",waitFor:"other",waitForRequest:"other",waitForResponse:"other",waitForUrl:"other"},_e=new Set,yt=new Set;Object.entries(uo).forEach(([e,t])=>{t==="assertion"&&_e.add(e),t==="action"&&yt.add(e)});function po(e){let t=We(e),{spec:r}=t,o=Ei(r),n=Ii(r),a=Pi({orderedNodes:o,spec:r,terminalNodes:n}),i=Ui({flags:a,orderedNodes:o,terminalNodes:n,wf:t});return{flags:a,report:i}}function Pi({orderedNodes:e,spec:t,terminalNodes:r}){let o=e.filter(c=>_e.has(c.type)).length,n=e.filter(c=>yt.has(c.type)).length,a=Object.keys(t.nodes).length,i=r.some(c=>_e.has(c.type));return[...Ti(o),...Ci(i,o),...Ai(n),...Ni(o,a)]}function Ti(e){return e===0?["NO_ASSERTIONS: The workflow has zero assertion nodes. It cannot verify its expectedOutcome."]:[]}function Ci(e,t){return!e&&t>0?["NO_TERMINAL_ASSERTION: No terminal node (end of flow) is an assertion. The workflow may not verify the final state."]:[]}function Ai(e){return e===0?["NO_INTERACTIONS: The workflow has zero interaction nodes (click, fill, select, etc.). It does not exercise any user action."]:[]}function Ni(e,t){if(e>0&&t>3){let r=e/t;if(r<.15){let o=String(Math.round(r*100));return[`LOW_ASSERTION_RATIO: Only ${String(e)}/${String(t)} nodes are assertions (${o}%). Consider adding more verification.`]}}return[]}function Ei(e){let t=new Set,r=[];function o(n){if(t.has(n))return;t.add(n);let a=e.nodes[n];a!=null&&(r.push(a),a.next!=null&&o(a.next),fo(a).forEach(i=>{o(i)}))}return o(e.entryNode),r}function fo(e){switch(e.type){case"if":return[...e.consequent,...e.alternate??[]];case"loop":case"forEach":return[...e.body];case"try":return[...e.body,...e.catch??[],...e.finally??[]];case"group":return[...e.nodes];case"parallel":return e.branches.flat();case"assertVisible":case"assertNotVisible":case"assertText":case"assertUrl":case"assertCount":case"assertValue":case"click":case"fill":case"select":case"check":case"uncheck":case"press":case"hover":case"goto":case"waitFor":case"screenshot":case"setViewport":case"wait":case"fail":case"setVariable":case"extractText":case"waitForUrl":case"waitForResponse":case"waitForRequest":case"upload":case"dblclick":case"drag":case"scrollIntoView":case"type":case"focus":case"assertAttribute":case"assertEnabled":case"assertDisabled":case"assertTitle":case"assertChecked":case"assertNotChecked":case"assertFocused":case"assertCookie":case"assertResponse":case"clear":case"rightClick":case"handleDialog":case"scroll":case"clipboard":case"setPermission":return[]}}function Ii(e){return Object.values(e.nodes).filter(t=>t.next!=null?!1:fo(t).length===0)}function P(e){return e.by==="role"?e.name==null?`role=${e.role}`:`role=${e.role}[${e.name}]`:`${e.by}=${e.value}`}function M(e){return e.type==="static"?`"${e.value}"`:`{{${e.name}}}`}function $i(e){return e.type==="static"?String(e.value):`{{${e.name}}}`}var mo=new Set,ho=new Set;Object.entries(uo).forEach(([e,t])=>{t==="controlFlow"&&mo.add(e),t==="assertion"&&ho.add(e)});function go(e){let t=`[${e.type}]`;return mo.has(e.type)?Vi(e,t):ho.has(e.type)?Oi(e,t):ji(e,t)}function ji(e,t){let r=e;switch(r.type){case"goto":return`${t} Navigate to ${M(r.url)}`;case"click":return`${t} click ${P(r.locator)}`;case"hover":return`${t} hover ${P(r.locator)}`;case"check":return`${t} check ${P(r.locator)}`;case"uncheck":return`${t} uncheck ${P(r.locator)}`;case"fill":return`${t} Fill ${P(r.locator)} with ${M(r.value)}`;case"select":return`${t} Select ${M(r.value)} in ${P(r.locator)}`;case"press":return`${t} Press "${r.key}"`;case"waitFor":{let o=r.state==null?"":` to be ${r.state}`;return`${t} Wait for ${P(r.locator)}${o}`}case"waitForUrl":return`${t} Wait for URL ${r.operator} ${M(r.expected)}`;case"waitForResponse":return`${t} Wait for response matching ${M(r.urlPattern)}`;case"waitForRequest":return`${t} Wait for request matching ${M(r.urlPattern)}`;case"extractText":return`${t} Extract text from ${P(r.locator)} into $${r.variable}`;case"screenshot":case"clipboard":return`${t} ${r.type}`;case"setViewport":return`${t} setViewport ${String(r.width)}x${String(r.height)}`;case"wait":return`${t} wait ${String(r.duration)}ms`;case"fail":return`${t} fail: ${r.message}`;case"setVariable":return`${t} setVariable ${r.variable}`;case"upload":return`${t} Upload files to ${P(r.locator)}`;case"dblclick":return`${t} Double-click ${P(r.locator)}`;case"drag":return`${t} Drag ${P(r.source)} to ${P(r.target)}`;case"scrollIntoView":return`${t} Scroll into view ${P(r.locator)}`;case"type":return`${t} Type ${M(r.value)} into ${P(r.locator)}`;case"focus":return`${t} Focus ${P(r.locator)}`;case"clear":return`${t} Clear ${P(r.locator)}`;case"rightClick":return`${t} Right-click ${P(r.locator)}`;case"handleDialog":return`${t} Handle dialog: ${r.action}`;case"scroll":return r.locator==null?`${t} Scroll page`:`${t} Scroll ${P(r.locator)}`;case"setPermission":return`${t} Set permission ${r.permission} to ${r.state}`}}function Oi(e,t){let r=e;switch(r.type){case"assertVisible":return`${t} Assert visible: ${P(r.locator)}`;case"assertNotVisible":return`${t} Assert not visible: ${P(r.locator)}`;case"assertText":return`${t} Assert text of ${P(r.locator)} ${r.operator} ${M(r.expected)}`;case"assertUrl":return`${t} Assert URL ${r.operator} ${M(r.expected)}`;case"assertCount":return`${t} Assert count of ${P(r.locator)} ${r.operator} ${$i(r.expected)}`;case"assertValue":return`${t} Assert value of ${P(r.locator)} ${r.operator} ${M(r.expected)}`;case"assertAttribute":return`${t} Assert attribute "${r.attribute}" of ${P(r.locator)} ${r.operator} ${M(r.expected)}`;case"assertEnabled":return`${t} Assert enabled: ${P(r.locator)}`;case"assertDisabled":return`${t} Assert disabled: ${P(r.locator)}`}}function Vi(e,t){let r=e;switch(r.type){case"if":return`${t} if (condition)`;case"loop":return`${t} loop ${String(r.times)} times`;case"forEach":return`${t} forEach ${r.iteratorVar}`;case"parallel":return`${t} parallel (${String(r.branches.length)} branches)`;case"group":return`${t} group (${String(r.nodes.length)} nodes)`;case"try":return`${t} try/catch`}}function Ui({flags:e,orderedNodes:t,terminalNodes:r,wf:o}){let n=t.filter(c=>_e.has(c.type)).length,a=t.filter(c=>yt.has(c.type)).length;return[Fi(o),Wi(t),Li(r),_i({actionCount:a,assertionCount:n,orderedNodes:t,terminalNodes:r}),...Di(e),qi].join(`
|
|
473
|
-
`)}function Fi(e){let t=["## Declared Intent",`**Name:** ${e.name}`,`**Description:** ${e.description}`,`**Expected Outcome:** ${e.expectedOutcome}`];if(e.additionalChecks.length>0){let r=e.additionalChecks.map(o=>`- ${o}`).join(`
|
|
474
|
-
`);t.push(`**Additional Checks:**
|
|
475
|
-
${r}`)}return t.join(`
|
|
476
|
-
`)}function Wi(e){return["","## Workflow Steps (execution order)",...e.map((r,o)=>`${String(o+1)}. ${go(r)}`)].join(`
|
|
477
|
-
`)}function Li(e){return["","## Terminal Nodes (where the workflow ends)",...e.map(r=>`- ${r.id}: ${go(r)}`)].join(`
|
|
478
|
-
`)}function _i({actionCount:e,assertionCount:t,orderedNodes:r,terminalNodes:o}){return["","## Statistics",`- Total nodes: ${String(r.length)}`,`- Action nodes: ${String(e)}`,`- Assertion nodes: ${String(t)}`,`- Terminal nodes: ${String(o.length)}`].join(`
|
|
479
|
-
`)}function Di(e){return e.length===0?[]:[["","## Flags",...e.map(r=>`- ${r}`)].join(`
|
|
480
|
-
`)]}var qi=["","## Review Instructions","Compare the **Declared Intent** against the **Workflow Steps** and answer these questions:","","1. Does the workflow actually test the **Expected Outcome**? Look at the assertions \u2014 do they verify that the expected outcome was achieved, or do they only check intermediate states?","2. Does the workflow exercise the flow described in **Description**? Are the right forms filled, buttons clicked, and pages navigated?","3. Are there assertions after the key state-changing action (e.g., after form submission, after deletion, after creation)? The final result should be verified, not just that a button was clicked.","4. If there are **Additional Checks**, does the workflow have assertions that cover them?","5. Could this workflow pass even if the feature is broken? For example, if it only asserts a page loaded but never checks the actual outcome.","","If any answer is NO, revise the workflow to address the gap. Then call `validate` and `review_workflow` again."].join(`
|
|
481
|
-
`);import re from"fs";import le from"path";import{chromium as Yi}from"playwright";import De from"fs";import yo from"path";import{z as Q}from"zod";var Bi=Q.object({baseUrl:Q.string().optional(),cloudBaseUrl:Q.string(),preconditionApiPath:Q.string().optional(),projectId:Q.string()});function wt(e){let t=yo.join(e,".ripplo","settings.json");if(!De.existsSync(t))return{baseUrl:void 0,cloudBaseUrl:void 0,errors:[{message:".ripplo/settings.json not found",path:""}],preconditionApiPath:void 0,valid:!1,warnings:[]};let r=Gi(t);if(!r.success)return{baseUrl:void 0,cloudBaseUrl:void 0,errors:[{message:r.error,path:"settings.json"}],preconditionApiPath:void 0,valid:!1,warnings:[]};let o=Bi.safeParse(r.data);if(!o.success)return{baseUrl:void 0,cloudBaseUrl:void 0,errors:o.error.issues.map(c=>({message:c.message,path:c.path.join(".")})),preconditionApiPath:void 0,valid:!1,warnings:[]};let n=[],a=yo.join(e,".ripplo","graph.json");return De.existsSync(a)&&Mi(a)&&(o.data.preconditionApiPath==null||o.data.preconditionApiPath.length===0)&&n.push("Graph has preconditions but preconditionApiPath is not set in settings.json. Preconditions will not be executed. Set preconditionApiPath to the URL where your app serves precondition endpoints (can be a relative path appended to baseUrl, or an absolute URL)."),{baseUrl:o.data.baseUrl,cloudBaseUrl:o.data.cloudBaseUrl,errors:[],preconditionApiPath:o.data.preconditionApiPath,valid:!0,warnings:n}}async function qe(e){try{await fetch(e,{method:"HEAD",signal:AbortSignal.timeout(5e3)});return}catch{return`baseUrl (${e}) is not responding. Make sure your dev server is running on that port.`}}function Mi(e){return bt(e)>0}function bt(e){try{let t=De.readFileSync(e,"utf8"),r=JSON.parse(t),n=Q.object({preconditions:Q.record(Q.string(),Q.unknown()).optional()}).safeParse(r);return n.success?Object.keys(n.data.preconditions??{}).length:0}catch{return 0}}async function wo({baseUrl:e,preconditionApiPath:t}){let r=t.startsWith("http://")||t.startsWith("https://")?t:`${e}${t}`;try{return(await fetch(`${r}/check`,{body:JSON.stringify({precondition:"__ripplo_health_check"}),headers:{"Content-Type":"application/json"},method:"POST",signal:AbortSignal.timeout(5e3)})).ok?"Precondition endpoint accepted an unsigned request \u2014 webhook signature verification may be missing or misconfigured. All precondition endpoints must verify the webhook-id, webhook-signature, and webhook-timestamp headers using the Standard Webhooks spec. See get_precondition_schema for setup instructions.":void 0}catch{return}}function Gi(e){try{let t=De.readFileSync(e,"utf8");return{data:JSON.parse(t),success:!0}}catch(t){return{error:t instanceof Error?t.message:"Failed to read file",success:!1}}}import kt from"fs";import vt from"path";import zi from"fs";import Fg from"path";import{z as V}from"zod";var St=V.object({edges:V.array(V.object({from:V.string(),to:V.string(),workflow:V.string()})),preconditions:V.record(V.string(),V.unknown()).optional(),resetPrecondition:V.string().optional(),states:V.record(V.string(),V.object({preconditions:V.array(V.string()),route:V.string()})),version:V.literal(3)});function bo(e){let t=zi.readFileSync(e,"utf8"),r=JSON.parse(t);return St.parse(r)}function So(e){return{edges:e.edges,preconditions:e.preconditions??{},resetPrecondition:e.resetPrecondition,states:e.states,version:e.version}}function ko(e){let t=Hi(e);if(!t.success)return{errors:[{message:t.error,path:""}],valid:!1};let r=St.safeParse(t.data);if(!r.success)return{errors:r.error.issues.map(g=>({message:g.message,path:g.path.join(".")})),valid:!1};let o=So(r.data),n=pt(o);if(!n.success)return{errors:n.errors,valid:!1};let a=[...ft(n.data)],i=vt.join(vt.dirname(e),"workflows"),c=kt.existsSync(i),l=new Set(n.data.edges.map(p=>p.workflow));return n.data.edges.forEach((p,g)=>{if(!c){a.push({message:`References workflow "${p.workflow}" but workflows directory does not exist`,path:`edges[${String(g)}].workflow`});return}let f=vt.join(i,`${p.workflow}.json`);kt.existsSync(f)||a.push({message:`References workflow "${p.workflow}" but no file exists at workflows/${p.workflow}.json`,path:`edges[${String(g)}].workflow`})}),c&&kt.readdirSync(i).filter(p=>p.endsWith(".json")).forEach(p=>{let g=p.replace(/\.json$/,"");l.has(g)||a.push({message:`Workflow file "workflows/${p}" is not referenced by any edge`,path:`workflows/${p}`})}),{errors:a,valid:a.length===0}}function Hi(e){try{return{data:bo(e),success:!0}}catch(t){return{error:t instanceof Error?t.message:"Failed to read file",success:!1}}}function xo(e){let t=Ji(e);if(!t.success)return{errors:[{message:t.error,path:""}],valid:!1};let r=xe.safeParse(t.data);if(!r.success)return{errors:r.error.issues.map(i=>({message:i.message,path:i.path.join(".")})),valid:!1};let o=Ne(r.data.spec);if(!o.success)return{errors:o.errors,valid:!1};let n=Ki(o.data);return n.length>0?{errors:n,valid:!1}:{errors:[],valid:!0}}function Ji(e){try{return{data:We(e),success:!0}}catch(t){return{error:t instanceof Error?t.message:"Failed to read file",success:!1}}}function Ki(e){let t=new Set(Object.keys(e.nodes)),r=[];t.has(e.entryNode)||r.push({message:`entryNode "${e.entryNode}" does not exist in nodes`,path:"entryNode"}),Object.entries(e.nodes).forEach(([i,c])=>{vo(c).forEach(l=>{t.has(l.id)||r.push({message:`references non-existent node "${l.id}"`,path:`nodes.${i}.${l.field}`})})});let o=new Set,n=t.has(e.entryNode)?[e.entryNode]:[],a=0;for(;a<n.length;){let i=n[a];if(a+=1,i==null||o.has(i))continue;o.add(i);let c=e.nodes[i];c!=null&&vo(c).forEach(l=>{o.has(l.id)||n.push(l.id)})}return t.forEach(i=>{o.has(i)||r.push({message:"unreachable from entryNode",path:`nodes.${i}`})}),r}function vo(e){let t=[];typeof e.next=="string"&&t.push({field:"next",id:e.next}),["consequent","alternate","body","catch","finally","nodes"].forEach(n=>{let a=e[n];Array.isArray(a)&&a.forEach((i,c)=>{typeof i=="string"&&t.push({field:`${n}[${String(c)}]`,id:i})})});let o=e.branches;return Array.isArray(o)&&o.forEach((n,a)=>{Array.isArray(n)&&n.forEach((i,c)=>{typeof i=="string"&&t.push({field:`branches[${String(a)}][${String(c)}]`,id:i})})}),t}async function Ro(e){let t=Qi(e),r=Xi(e),o=Zi(e),n=el(),a=tl(e),i=rl(e),c=await ol(e,t);return[t,r,o,...c,a,i,n]}function Qi(e){let t=wt(e);return t.valid?{missingFields:[],type:"settings",valid:!0}:{missingFields:t.errors.map(o=>o.path).filter(o=>o.length>0),type:"settings",valid:!1}}function Xi(e){let t=le.join(e,".ripplo","settings.local.json");if(!re.existsSync(t))return{exists:!1,hasToken:!1,hasWebhookSecret:!1,type:"local-settings"};try{let r=re.readFileSync(t,"utf8"),o=JSON.parse(r);if(o==null||typeof o!="object")return{exists:!0,hasToken:!1,hasWebhookSecret:!1,type:"local-settings"};let n=Object.fromEntries(Object.entries(o)),a=typeof n.token=="string"&&n.token.length>0,i=typeof n.webhookSecret=="string"&&n.webhookSecret.length>0;return{exists:!0,hasToken:a,hasWebhookSecret:i,type:"local-settings"}}catch{return{exists:!0,hasToken:!1,hasWebhookSecret:!1,type:"local-settings"}}}function Zi(e){let t=le.join(e,".mcp.json");if(!re.existsSync(t))return{installed:!1,type:"mcp-installed"};try{let r=re.readFileSync(t,"utf8"),o=JSON.parse(r);if(o==null||typeof o!="object")return{installed:!1,type:"mcp-installed"};let a=Object.fromEntries(Object.entries(o)).mcpServers;return a==null||typeof a!="object"?{installed:!1,type:"mcp-installed"}:{installed:"ripplo"in a,type:"mcp-installed"}}catch{return{installed:!1,type:"mcp-installed"}}}function el(){let e=Yi.executablePath();return{installed:re.existsSync(e),type:"browser"}}function tl(e){let t=le.join(e,".ripplo","graph.json");if(!re.existsSync(t))return{errorCount:0,errors:[],found:!1,type:"state-graph",valid:!1};let r=ko(t);return{errorCount:r.errors.length,errors:r.errors,found:!0,type:"state-graph",valid:r.valid}}function rl(e){let t=le.join(e,".ripplo","workflows");if(!re.existsSync(t))return{invalidNames:[],invalidWorkflows:[],total:0,type:"workflows",valid:0};let r=re.readdirSync(t).filter(a=>a.endsWith(".json")).map(a=>le.join(t,a)),o=[],n=[];return r.forEach(a=>{let i=xo(a);if(!i.valid){let c=le.basename(a,".json");o.push(c),n.push({errors:i.errors,name:c})}}),{invalidNames:o,invalidWorkflows:n,total:r.length,type:"workflows",valid:r.length-o.length}}async function ol(e,t){if(!t.valid)return[];let r=wt(e),o=[];if(r.baseUrl!=null){let a=await qe(r.baseUrl)==null;o.push({baseUrl:r.baseUrl,reachable:a,type:"dev-server"})}if(r.cloudBaseUrl!=null){let a=await qe(r.cloudBaseUrl)==null;o.push({reachable:a,type:"cloud-base-url",url:r.cloudBaseUrl})}let n=await nl(r);return o.push(...n),o}async function nl(e){let t=le.join(process.cwd(),".ripplo","graph.json"),r=bt(t),o=e.preconditionApiPath!=null&&e.preconditionApiPath.length>0,n={apiPathConfigured:o,count:r,endpointReachable:void 0,type:"preconditions"};if(r===0||!o)return[n];let a=al(e.baseUrl,e.preconditionApiPath);if(a==null)return[n];let i=await qe(a)==null,c=[];if(c.push({apiPathConfigured:!0,count:r,endpointReachable:i,type:"preconditions"}),Po(e.preconditionApiPath)&&c.push({reachable:i,type:"precondition-endpoint",url:a}),!i||e.baseUrl==null)return c;let l=await wo({baseUrl:e.baseUrl,preconditionApiPath:e.preconditionApiPath});return c.push({rejectsUnsigned:l==null,type:"webhook-verification"}),c}function Po(e){return e.startsWith("http://")||e.startsWith("https://")}function al(e,t){if(Po(t))return t;if(e!=null)return`${e}${t}`}function To(e){switch(e.type){case"settings":return sl(e);case"local-settings":return dl(e);case"dev-server":return il(e);case"preconditions":return ul(e);case"webhook-verification":return ll(e);case"state-graph":return pl(e);case"workflows":return fl(e);case"browser":return cl(e);case"cloud-base-url":return e.reachable?`\u2713 Cloud: ${e.url} is reachable`:`! Cloud: ${e.url} is not reachable (ok if not deployed yet)`;case"precondition-endpoint":return e.reachable?`\u2713 Precondition endpoint: ${e.url} is reachable`:`\u2717 Precondition endpoint: ${e.url} is not reachable`;case"mcp-installed":return e.installed?"\u2713 MCP: Ripplo MCP server configured in .mcp.json":"\u2717 MCP: Ripplo MCP server not found in .mcp.json. Run: ripplo setup-mcp"}}function sl(e){return e.valid?"\u2713 Settings: Project configured":`\u2717 Settings: Missing fields: ${e.missingFields.join(", ")}`}function il(e){return e.reachable?`\u2713 Dev server: ${e.baseUrl} is reachable`:`\u2717 Dev server: ${e.baseUrl} is not responding. Make sure your dev server is running.`}function ll(e){return e.rejectsUnsigned?"\u2713 Webhook verification: Unsigned requests are correctly rejected":"\u2717 Webhook verification: Endpoint accepted an unsigned request \u2014 webhook signature verification may be missing or misconfigured. See get_precondition_schema for setup instructions."}function cl(e){return e.installed?"\u2713 Browser: Chromium installed":"\u2717 Browser: Chromium not installed. Run: npx playwright install chromium"}function dl(e){if(!e.exists)return"\u2717 Auth: settings.local.json not found. Run `ripplo login` to authenticate.";let t=[];return e.hasToken||t.push("token"),e.hasWebhookSecret||t.push("webhookSecret"),t.length===0?"\u2713 Auth: Token and webhook secret configured":`\u2717 Auth: Missing ${t.join(" and ")} in settings.local.json`}function ul(e){return e.count===0?"! Preconditions: None defined in graph":e.apiPathConfigured?e.endpointReachable===void 0?`! Preconditions: ${String(e.count)} defined (could not verify endpoint)`:`\u2713 Preconditions: ${String(e.count)} defined, endpoint configured`:`\u2717 Preconditions: ${String(e.count)} defined but preconditionApiPath is not set in settings.json`}function pl(e){if(!e.found)return"\u2717 State graph: graph.json not found";if(e.valid)return"\u2713 State graph: Valid";let t=e.errors.map(r=>` - ${r.path===""?"":r.path+": "}${r.message}`);return`\u2717 State graph: ${String(e.errorCount)} validation error${e.errorCount===1?"":"s"}
|
|
482
|
-
${t.join(`
|
|
483
|
-
`)}`}function fl(e){if(e.total===0)return"! Workflows: No workflow files found in .ripplo/workflows/";if(e.invalidNames.length===0)return`\u2713 Workflows: ${String(e.total)} valid`;let t=e.invalidWorkflows.map(r=>ml(r));return`\u2717 Workflows: ${String(e.invalidNames.length)} invalid
|
|
484
|
-
${t.join(`
|
|
485
|
-
`)}`}function ml(e){let t=e.errors.map(r=>" - "+(r.path===""?"":r.path+": ")+r.message);return" "+e.name+`:
|
|
486
|
-
`+t.join(`
|
|
487
|
-
`)}import Co from"fs";import hl from"path";function Ao(e){let t=gl(),r=[`Create a Ripplo workflow spec for: ${e}`,"","IMPORTANT: The `ripplo` dev session watches `.ripplo/` for file changes and automatically syncs them to the server. There is no manual sync command \u2014 never run one.","","1. Call get_spec_documentation to learn the workflow spec format.","2. Call get_graph_documentation to learn the state graph and precondition system."];return t?r.push("3. This is the first workflow \u2014 build a minimal graph:"," a. Create a graph with exactly 2 states (start state and end state) and 1 edge."," b. Define preconditions for whatever the source state needs (e.g. `auth:user` for logged-in pages, `data:project` for existing entities)."," c. Set `preconditionApiPath` in `.ripplo/settings.json`."," d. Write to `.ripplo/graph.json`."," e. Call validate. Fix any errors.","4. Read the relevant source files (routes, components, forms) to find real URLs, form fields, and button labels.","5. Set up precondition endpoints (if the source state has preconditions):"," a. Check if precondition endpoints already exist at the preconditionApiPath. If so, skip."," b. Implement the three endpoints (check, execute, teardown) \u2014 see get_precondition_schema for the API contract. IMPORTANT: Wrap all precondition routes behind `ENABLE_RIPPLO_TESTING=true` env var \u2014 enable in dev/staging/CI but never in production. All requests are signed using Standard Webhooks (HMAC-SHA256) \u2014 add signature verification middleware using the `standardwebhooks` package. See get_precondition_schema for details."," c. Read the app's auth system to understand how to create sessions programmatically (direct DB/ORM calls, not UI login)."," d. Read the data model to understand how to seed required entities."," e. Each executor should create ephemeral test data with a `ripplo-test-` prefix for easy cleanup."," f. Delegate this to a sub-agent \u2014 give it the precondition names, auth architecture, and data model summary."," g. PARALLEL SAFETY: Every execute handler must generate unique names/emails using random suffixes (e.g., `ripplo-test-${crypto.randomUUID().slice(0,8)}`). Never hardcode entity names \u2014 parallel runs will hit unique constraint violations. Return created entity IDs in the `data` response. Teardown must only delete that run's data (use session cookies to identify the user, not prefix-based bulk deletion).","6. Create .ripplo/workflows/<slug>.json with a complete spec using real locators from the code.","7. Call validate on the file. Fix any errors.","8. Call review_workflow on the file. Read the report and fix any gaps \u2014 ensure assertions verify the expectedOutcome, not just intermediate states. Re-validate after changes.","9. Call `run` with the workflow slug to run it. Report the result and include the run URL."):r.push("3. Read .ripplo/graph.json to understand existing states, edges, and preconditions.","4. Read the relevant source files (routes, components, forms) to find real URLs, form fields, and button labels.","5. Determine what preconditions this flow needs (e.g. auth, test data). If the required preconditions don't exist in graph.json:"," a. Add them to the graph's preconditions object with appropriate depends chains."," b. Add or update the state that this workflow transitions from, ensuring it lists the required preconditions."," c. Add the edge connecting the source state to the target state with the workflow slug."," d. Call validate. Fix any errors."," f. Check if precondition API endpoints exist at the preconditionApiPath. If not, create them following the pattern in get_precondition_schema. Verify they are guarded behind `ENABLE_RIPPLO_TESTING=true` env var \u2014 if the guard is missing, add it."," g. Run ripplo generate-types to regenerate the typed schemas after adding preconditions."," h. Verify precondition executors are parallel-safe: unique data per run (random suffixes), IDs returned via `data`, teardown scoped to that run's user (not global prefix deletion).","6. Create .ripplo/workflows/<slug>.json with a complete spec using real locators from the code.","7. Call validate on the file. Fix any errors.","8. Call review_workflow on the file. Read the report and fix any gaps \u2014 ensure assertions verify the expectedOutcome, not just intermediate states. Re-validate after changes.","9. Call `run` with the workflow slug to run it. Report the result and include the run URL."),r.join(`
|
|
488
|
-
`)}function gl(){let e=hl.join(process.cwd(),".ripplo","workflows");return Co.existsSync(e)?!Co.readdirSync(e).some(r=>r.endsWith(".json")):!0}import Eo from"fs";import yl from"path";var he="## Iteration \u2014 run until it works\n\nYou MUST keep iterating until the workflow passes AND accomplishes the expected outcome:\n\n1. Call `run` with the workflow slug(s). Pass multiple slugs to run workflows in parallel \u2014 always preferred over calling `run` multiple times. If any fail, read the debug artifacts from `.ripplo/debug/<runId>/`.\n2. Read debug artifacts from `.ripplo/debug/<runId>/` for deeper investigation:\n - Grep `console.log` for errors/warnings around the failure timestamp.\n - Check `network.jsonl` for failed requests, unexpected status codes, or missing API responses.\n - Read `steps/<failedIndex>/dom.html` to inspect the actual DOM at the failing step.\n - Read `steps/<failedIndex>/accessibility-tree.txt` to understand page structure and find correct locators.\n - Read `steps/<failedIndex>/storage.json` to check auth state, session data, or app state.\n - Compare with `steps/<failedIndex - 1>/` to see what changed between the previous step and the failure.\n - Check `page-errors.log` for uncaught JavaScript exceptions.\n3. Identify the root cause \u2014 don't just retry. Common root causes:\n - Wrong locator (check the DOM and accessibility tree from the debug artifacts)\n - Missing waitFor before an element appears\n - Wrong URL path or missing query params\n - Missing or misconfigured precondition\n - Race condition (action fires before page transition completes)\n - Parallel-safety violation (unique constraint error, \"not authorized\" from session conflict) \u2014 precondition creates hardcoded/shared data instead of unique-per-run data, or teardown globally deletes all test data instead of only that run's data. Fix the precondition executor.\n - A bug in the user's application code (not the workflow)\n4. If the root cause is a bug in the application (not the workflow spec), report it to the user with the evidence (failed step, expected vs actual, relevant source code, debug artifact excerpts) and ask them to fix it. Do NOT work around app bugs by weakening assertions.\n5. Otherwise, fix the root cause in the workflow spec.\n6. Call `validate`, then `review_workflow`, then `run` again.\n7. When the run passes, call `review_workflow` one final time. Verify the passing run actually tests the expectedOutcome \u2014 a workflow that passes but doesn't assert the outcome is not done.\n8. If the same failure repeats after 3 targeted fixes, report the root cause to the user and ask for help. Do NOT silently give up.",No="If the run fails, iterate:\n- Read debug artifacts from `.ripplo/debug/<runId>/` \u2014 check `console.log`, `network.jsonl`, and `steps/<failedIndex>/dom.html` for root cause clues.\n- **Bad locator**: element not found or wrong element \u2014 check `dom.html` and `accessibility-tree.txt`, re-read the component source, fix the locator in the spec, re-run.\n- **Bad assertion**: text or visibility mismatch \u2014 fix the expected value, re-run.\n- **Precondition issue**: state wasn't set up correctly \u2014 check `storage.json` for auth/session state, fix the precondition API, re-run.\n- **Parallel collision**: unique constraint error or auth failure when multiple runs execute simultaneously \u2014 precondition creates shared data or teardown nukes all test data globally. Fix executor to use unique names per run and scope teardown to that run's user/session.\n- **App-level bug**: the application itself is broken \u2014 note the failure as an app issue. Do not keep iterating on test-side fixes.\n- A workflow is done when its run **passes** or when the failure is confirmed as **app-level**.";function Io(e){return`# Debug a Failing Workflow
|
|
489
|
-
|
|
490
|
-
${wl(e)}
|
|
491
|
-
|
|
492
|
-
## Step 1: Trigger a fresh run
|
|
493
|
-
|
|
494
|
-
Call \`run\` with the workflow slug to get a fresh failure with up-to-date debug artifacts. If debugging multiple workflows, pass all slugs to \`run\` to execute them in parallel and compare results.
|
|
495
|
-
|
|
496
|
-
## Step 2: Inspect debug artifacts
|
|
497
|
-
|
|
498
|
-
Read files from \`.ripplo/debug/<runId>/\` for the full browser state at every step:
|
|
499
|
-
|
|
500
|
-
### Run-level files (start here)
|
|
501
|
-
- **\`summary.txt\`** \u2014 run overview, pass/fail counts, step listing
|
|
502
|
-
- **\`console.log\`** \u2014 grep for \`ERROR\`, \`WARN\`, or keywords related to the failure
|
|
503
|
-
- **\`network.jsonl\`** \u2014 grep for failed status codes (4xx, 5xx), missing responses, or relevant API endpoints
|
|
504
|
-
- **\`page-errors.log\`** \u2014 uncaught JavaScript exceptions (often the real root cause)
|
|
505
|
-
|
|
506
|
-
### Per-step files (drill into the failure)
|
|
507
|
-
- **\`steps/<failedIndex>/dom.html\`** \u2014 the actual DOM at the failing step. Search for the element the locator targets.
|
|
508
|
-
- **\`steps/<failedIndex>/accessibility-tree.txt\`** \u2014 structured page layout. Use this to find correct locators (roles, labels, text).
|
|
509
|
-
- **\`steps/<failedIndex>/storage.json\`** \u2014 localStorage, sessionStorage, and cookies. Check auth tokens, session state, app flags.
|
|
510
|
-
- **\`steps/<failedIndex>/screenshot.png\`** \u2014 visual state of the page at this step.
|
|
511
|
-
- **\`steps/<failedIndex>/info.json\`** \u2014 step metadata (title, status, duration, assertions, error detail).
|
|
512
|
-
|
|
513
|
-
### Comparative analysis
|
|
514
|
-
- **Compare \`steps/<failedIndex>/\` with \`steps/<failedIndex - 1>/\`** to see what changed:
|
|
515
|
-
- Did the DOM change unexpectedly between steps?
|
|
516
|
-
- Did storage/cookies get cleared or modified?
|
|
517
|
-
- Did a network request fail between the two steps?
|
|
518
|
-
|
|
519
|
-
## Step 3: Diagnose the root cause
|
|
520
|
-
|
|
521
|
-
Always check console.log and network.jsonl BEFORE looking at screenshots \u2014 text is faster to search and more informative.
|
|
522
|
-
|
|
523
|
-
Never guess at fixes \u2014 always have evidence from the debug artifacts before changing anything.
|
|
524
|
-
|
|
525
|
-
Categorize the root cause:
|
|
526
|
-
|
|
527
|
-
- **App bug** \u2014 the application itself has a defect (unexpected API response, JS exception, broken UI). Report to the user with evidence: the specific error from console.log or network.jsonl, the DOM state, and what should have happened instead. Do NOT work around app bugs.
|
|
528
|
-
- **Wrong locator** \u2014 the element exists in the DOM but the selector doesn't match. Use \`dom.html\` and \`accessibility-tree.txt\` to find the correct selector.
|
|
529
|
-
- **Timing issue** \u2014 the element isn't ready when the action fires. Check if there's a page transition or async load between steps. Add or adjust a \`waitFor\` node.
|
|
530
|
-
- **Precondition issue** \u2014 the app isn't in the expected state at the start of the workflow. Check \`storage.json\` at step 0 for missing auth tokens or session data. If precondition requests return 401, the most common cause is a missing or incorrect \`RIPPLO_WEBHOOK_SECRET\` environment variable on the app's server \u2014 the value must match the \`webhookSecret\` field in \`.ripplo/settings.local.json\`. Call \`get_precondition_schema\` to review the full setup guide before reading server internals. Fix the precondition setup.
|
|
531
|
-
- **Spec logic error** \u2014 wrong flow order, missing steps, incorrect assertion values. Fix the spec based on the actual app behavior observed in the debug artifacts.
|
|
532
|
-
- **Data dependency** \u2014 test data is missing or stale. Check network.jsonl for 404s on data fetches. Fix precondition data seeding.
|
|
533
|
-
- **Parallel-safety violation** \u2014 unique constraint errors, "not authorized", or data unexpectedly missing mid-run. Happens when preconditions create shared resources (hardcoded names, shared sessions) or teardown globally deletes all test-prefixed data. Evidence: \`network.jsonl\` shows 500 with "unique constraint" or 401/403 mid-workflow; \`storage.json\` shows session cookies disappearing between steps. Fix: precondition executors must generate unique data per run and teardown must scope deletion to that run's user only.
|
|
534
|
-
|
|
535
|
-
## Step 4: Fix and re-run
|
|
536
|
-
|
|
537
|
-
${he}
|
|
538
|
-
|
|
539
|
-
## Important rules
|
|
540
|
-
|
|
541
|
-
- **Text first, screenshots second.** Always grep console.log and network.jsonl before looking at screenshots.
|
|
542
|
-
- **Evidence before changes.** Never modify the spec without citing specific evidence from the debug artifacts.
|
|
543
|
-
- **Compare steps.** When a failure is unclear, diff the DOM/storage between the step before and the failing step.
|
|
544
|
-
- **Check the network.** If the app returns unexpected API responses (check network.jsonl), this is likely an app bug, not a spec issue.
|
|
545
|
-
- **Report app bugs clearly.** Include: the failing step, what the spec expected, what actually happened, the relevant console/network evidence, and the source code if applicable.`}function wl(e){if(e!=null)return`Debug the workflow: **${e}**
|
|
546
|
-
|
|
547
|
-
Find it in \`.ripplo/workflows/\`. If the name doesn't match exactly, look for a slug or partial match.`;let t=bl();return t.length===0?"No workflows found in `.ripplo/workflows/`. Create one first with `/create`.":`No specific workflow was provided. Available workflows:
|
|
548
|
-
${t.map(r=>`- ${r}`).join(`
|
|
549
|
-
`)}
|
|
550
|
-
|
|
551
|
-
Ask the user which workflow to debug, or pick the one most likely to be failing.`}function bl(){let e=yl.join(process.cwd(),".ripplo","workflows");return Eo.existsSync(e)?Eo.readdirSync(e).filter(t=>t.endsWith(".json")).map(t=>t.replace(/\.json$/,"")):[]}function $o(e){let t="Read `baseUrl` from `.ripplo/settings.json`. If it's not set, find the dev server URL from the project's config (package.json scripts, vite.config.ts, next.config.js, etc.) and set it in settings.json.";return e.scope!=null&&e.scope!==""?Sl({baseUrlInstruction:t,scope:e.scope}):[vl(t),xl(),Rl(),Pl(),Al(),Tl(),Cl(),Nl()].join(`
|
|
552
|
-
|
|
553
|
-
`)}function Sl(e){let t=kl(e.scope);return`# Ripplo Explore (Scoped to Changes)
|
|
554
|
-
|
|
555
|
-
Analyze code changes and create or update workflow specs to test the affected user flows.
|
|
556
|
-
|
|
557
|
-
${e.baseUrlInstruction}
|
|
558
|
-
|
|
559
|
-
## Step 1: Determine what changed
|
|
560
|
-
|
|
561
|
-
${t}
|
|
562
|
-
|
|
563
|
-
After getting the diff, summarize:
|
|
564
|
-
- Which files changed (group by area: routes, components, API, data model, etc.)
|
|
565
|
-
- What user-facing behavior is affected
|
|
566
|
-
|
|
567
|
-
## Step 2: Identify affected user flows
|
|
568
|
-
|
|
569
|
-
From the changed files, trace which user-facing flows are affected:
|
|
570
|
-
- Route changes \u2192 which pages/views are affected?
|
|
571
|
-
- Component changes \u2192 which interactions changed (forms, buttons, navigation)?
|
|
572
|
-
- API/data model changes \u2192 which flows depend on the changed endpoints or entities?
|
|
573
|
-
- Read the changed files to understand what specifically changed in each flow.
|
|
574
|
-
|
|
575
|
-
Cross-reference with existing workflows:
|
|
576
|
-
- Read \`.ripplo/workflows/\` to see what workflows already exist.
|
|
577
|
-
- For each affected flow, determine if an existing workflow needs updating or if a new one is needed.
|
|
578
|
-
- Skip flows that are unaffected by the changes.
|
|
579
|
-
|
|
580
|
-
**Present your findings to the user before proceeding:**
|
|
581
|
-
- List each affected flow with a one-line description
|
|
582
|
-
- Note whether each needs a new workflow or an update to an existing one
|
|
583
|
-
- Ask the user to confirm, adjust, or skip any flows
|
|
584
|
-
|
|
585
|
-
## Step 3: Set up (once)
|
|
586
|
-
|
|
587
|
-
1. Call \`get_spec_documentation\` to learn the workflow spec format.
|
|
588
|
-
2. Call \`get_graph_documentation\` to learn the state graph and precondition system.
|
|
589
|
-
3. Read \`.ripplo/graph.json\` to understand existing states, edges, and preconditions.
|
|
590
|
-
|
|
591
|
-
## Step 4: Create/update workflows
|
|
592
|
-
|
|
593
|
-
For each confirmed flow, delegate to a sub-agent:
|
|
594
|
-
|
|
595
|
-
1. Read the relevant source files (routes, components, forms) to find real URLs, form fields, and button labels.
|
|
596
|
-
2. If updating an existing workflow, read the current spec and modify only what changed. If creating new, start fresh.
|
|
597
|
-
3. Determine what preconditions this flow needs. If the required preconditions don't exist in graph.json:
|
|
598
|
-
a. Add them to the graph's preconditions object with appropriate depends chains.
|
|
599
|
-
b. Add or update the state that this workflow transitions from, ensuring it lists the required preconditions.
|
|
600
|
-
c. Add the edge connecting the source state to the target state with the workflow slug.
|
|
601
|
-
d. Call \`validate\`. Fix any errors.
|
|
602
|
-
f. Check if precondition API endpoints exist at the preconditionApiPath. If not, create them following the pattern in \`get_precondition_schema\`.
|
|
603
|
-
g. Run \`ripplo generate-types\` to regenerate the typed schemas after adding preconditions.
|
|
604
|
-
4. Write the workflow to \`.ripplo/workflows/<slug>.json\`.
|
|
605
|
-
5. Call \`validate\`. Fix any errors.
|
|
606
|
-
6. Call \`review_workflow\` on the file. Read the report and fix any gaps \u2014 ensure assertions verify the expectedOutcome, not just intermediate states. Re-validate after changes.
|
|
607
|
-
7. Call \`run\` with the workflow slug. Report the result and include the run URL.
|
|
608
|
-
|
|
609
|
-
${he}
|
|
610
|
-
|
|
611
|
-
## Step 5: Report summary
|
|
612
|
-
|
|
613
|
-
After all workflows are processed, report:
|
|
614
|
-
- Total workflows created/updated
|
|
615
|
-
- Pass/fail status for each
|
|
616
|
-
- Any flows that need user attention (app bugs, unresolvable failures)
|
|
617
|
-
- Any flows that were skipped and why`}function kl(e){return`The user provided this context: "${e}"
|
|
618
|
-
- If it looks like a PR number or URL, run \`gh pr diff <number>\` and \`gh pr view <number>\` to get the changes and description.
|
|
619
|
-
- If it looks like a branch name, run \`git diff main...<branch> --stat\` to see what changed.
|
|
620
|
-
- If it equals "changes" or is empty, auto-detect:
|
|
621
|
-
- Run \`git status\` and \`git diff --stat\` to check for uncommitted changes.
|
|
622
|
-
- If there are uncommitted changes, use those as the diff.
|
|
623
|
-
- Otherwise, run \`git diff main...HEAD --stat\` to see what changed on this branch vs main.
|
|
624
|
-
- If on main with no changes, ask the user what they'd like to create workflows for.
|
|
625
|
-
- Otherwise, treat it as a description and run \`git diff main...HEAD --stat\` to find relevant changes.`}function vl(e){return`# Ripplo Explore
|
|
626
|
-
|
|
627
|
-
Build a comprehensive state graph and workflow specs for this application.
|
|
628
|
-
|
|
629
|
-
${e}
|
|
630
|
-
|
|
631
|
-
## What you're building
|
|
632
|
-
|
|
633
|
-
A **state graph** that models:
|
|
634
|
-
- **States**: distinct application contexts (unique combinations of route + auth + data conditions)
|
|
635
|
-
- **Edges**: user actions that move between states (form submissions, dialog flows, CRUD operations)
|
|
636
|
-
- **Preconditions**: named requirements for reaching each state (auth roles, data that must exist)
|
|
637
|
-
|
|
638
|
-
Then **workflow specs** for each edge \u2014 the actual Playwright test steps.
|
|
639
|
-
|
|
640
|
-
**Goal: Complete coverage of every meaningful user journey.** Do not artificially cap the number of states or edges. If the app has 30 testable flows, produce 30 edges.
|
|
641
|
-
|
|
642
|
-
## Phased approach (follow this order strictly)
|
|
643
|
-
|
|
644
|
-
1. **Explore** \u2014 discover routes, auth, data model, interactive components (sub-agents)
|
|
645
|
-
2. **Graph** \u2014 build state graph with preconditions and edges
|
|
646
|
-
3. **Precondition API** \u2014 wire up check/execute/teardown endpoints
|
|
647
|
-
4. **Bootstrap** \u2014 pick ONE simple workflow, generate it, trigger a run, iterate until it passes
|
|
648
|
-
5. **Scale out** \u2014 generate remaining workflows in batches of 3-5, triggering a run for each
|
|
649
|
-
6. **Report** \u2014 final summary with pass/fail per workflow
|
|
650
|
-
|
|
651
|
-
**A workflow is not done until its run passes** (or the failure is confirmed as an app-level bug, not a test issue).
|
|
652
|
-
|
|
653
|
-
## Context management
|
|
654
|
-
|
|
655
|
-
This is a large task. **Aggressively delegate to sub-agents** to keep the main context window lean. The main context should orchestrate and synthesize \u2014 not hold raw exploration results or write implementation code directly.
|
|
656
|
-
|
|
657
|
-
- **Exploration**: Delegate codebase exploration to sub-agents. Each sub-agent returns a structured summary, not raw file contents. The main context works from summaries.
|
|
658
|
-
- **Workflow generation**: Always use sub-agents for writing workflow specs. Launch them in parallel batches.
|
|
659
|
-
- **Precondition API implementation**: Delegate endpoint implementation entirely to a sub-agent. Give it the graph's precondition names, the auth/data architecture summary from exploration, and let it handle reading source code, writing handlers, and wiring up the codegen. Do NOT read app source code for endpoint implementation in the main context.
|
|
660
|
-
- **General rule**: If a task involves reading more than 2-3 files or writing more than one file, it should be a sub-agent.
|
|
661
|
-
- **Recursive sub-agents**: Sub-agents should spawn their own sub-agents when they hit something that needs deeper exploration. An exploration agent that discovers a complex auth middleware layer should spawn a deeper agent to trace it fully. A workflow generation agent that needs to understand a component tree should spawn an agent to map it. There is no depth limit \u2014 go as deep as needed to get complete, accurate results. Each level returns a structured summary to its parent.`}function xl(){return`## Understanding the application
|
|
662
|
-
|
|
663
|
-
Explore the codebase deeply enough to discover every testable surface. **Use sub-agents for all exploration** \u2014 the main context should not read source files directly during discovery. Launch exploration agents in parallel where possible, and have each return a structured summary (routes found, components found, entities found, etc.) rather than raw file contents.
|
|
664
|
-
|
|
665
|
-
### Routes & navigation
|
|
666
|
-
|
|
667
|
-
Find every route/page in the application:
|
|
668
|
-
- Route definitions (file-based routing, router config, dynamic segments, catch-all routes)
|
|
669
|
-
- Route guards, auth middleware, role checks \u2014 which routes require which roles
|
|
670
|
-
- Layout components and what routes they wrap
|
|
671
|
-
- Redirect rules, fallback routes, error boundaries
|
|
672
|
-
- Nested route hierarchies (e.g., /projects/:id/settings has sub-tabs)
|
|
673
|
-
- Dynamic segments and what entities they reference
|
|
674
|
-
|
|
675
|
-
### Auth & sessions
|
|
676
|
-
|
|
677
|
-
Understand the auth system completely:
|
|
678
|
-
- Auth provider type (OAuth, password, magic link, API keys, session tokens)
|
|
679
|
-
- Session storage mechanism (cookies, localStorage, JWT, httpOnly cookies)
|
|
680
|
-
- Role/permission model (admin, member, viewer, org-level, project-level, etc.)
|
|
681
|
-
- How roles gate UI \u2014 what each role can and cannot see or do
|
|
682
|
-
- Test login endpoints, seed scripts, dev-only auth bypass, or factory functions
|
|
683
|
-
- Signup/registration flows and how they differ from login
|
|
684
|
-
- Session expiry, refresh token behavior
|
|
685
|
-
|
|
686
|
-
### Data model & entities
|
|
687
|
-
|
|
688
|
-
Map every core entity and how they relate:
|
|
689
|
-
- All entities (database schema, ORM models, GraphQL types, API resources)
|
|
690
|
-
- Entity relationships (belongs-to, has-many, many-to-many, polymorphic)
|
|
691
|
-
- Which entities are required to reach which pages (e.g., "workflow detail requires a project and a workflow")
|
|
692
|
-
- Seed data scripts, factory functions, test fixtures
|
|
693
|
-
- API endpoints or mutations that can create/delete test data programmatically
|
|
694
|
-
- Required vs optional fields on each entity
|
|
695
|
-
- Cascade behavior (deleting a project deletes its workflows?)
|
|
696
|
-
|
|
697
|
-
### Interactive components \u2014 exhaustive inventory
|
|
698
|
-
|
|
699
|
-
Find ALL interactive UI that triggers state changes. Miss nothing:
|
|
700
|
-
- Dialogs, modals, drawers, sheets, popovers with forms or actions
|
|
701
|
-
- Forms (create, edit, filter, search) \u2014 note every field, validation, and submit mechanism
|
|
702
|
-
- Inline editing (click-to-edit, editable cells, contentEditable)
|
|
703
|
-
- Action menus, context menus, dropdown actions (delete, duplicate, archive, rename, etc.)
|
|
704
|
-
- Toggle switches, checkboxes that trigger mutations (not just local state)
|
|
705
|
-
- Drag-and-drop interactions that reorder or move items
|
|
706
|
-
- Bulk selection + bulk action patterns
|
|
707
|
-
- Confirmation dialogs (delete confirmations, unsaved changes warnings)
|
|
708
|
-
- Wizards, multi-step flows, onboarding sequences
|
|
709
|
-
- Tab panels where switching tabs loads different data or shows different actions
|
|
710
|
-
- File upload/import/export flows
|
|
711
|
-
- Settings pages with save/update actions
|
|
712
|
-
- Notification/toast actions (undo, dismiss, retry)
|
|
713
|
-
- Keyboard shortcuts that trigger actions
|
|
714
|
-
- Real-time/WebSocket-driven UI updates that change available actions
|
|
715
|
-
|
|
716
|
-
### Conditional UI & distinct states
|
|
717
|
-
|
|
718
|
-
Identify every place where the same route renders fundamentally different UI:
|
|
719
|
-
- Empty state components (zero items, first-time user, no search results)
|
|
720
|
-
- Conditional renders based on data (list vs empty, enabled vs disabled features)
|
|
721
|
-
- Error states (failed loads, permission denied, not found, rate limited)
|
|
722
|
-
- Loading states that gate interactions
|
|
723
|
-
- Feature flags or plan-based feature gating
|
|
724
|
-
- Pagination boundaries (first page vs last page behavior)
|
|
725
|
-
- Before/after states (pre-submission vs post-submission, draft vs published)
|
|
726
|
-
- Dialog-open vs dialog-closed at the same route
|
|
727
|
-
|
|
728
|
-
### API & mutation layer
|
|
729
|
-
|
|
730
|
-
Map every write operation to its UI trigger:
|
|
731
|
-
- Every mutation/endpoint that handles writes/updates/deletes
|
|
732
|
-
- Which UI component triggers each mutation
|
|
733
|
-
- Form actions (if using framework form actions)
|
|
734
|
-
- Optimistic updates and their rollback behavior
|
|
735
|
-
- Which mutations are restricted to which roles`}function Rl(){return`## Extracting edges
|
|
736
|
-
|
|
737
|
-
From everything discovered, extract EVERY meaningful user journey as an edge.
|
|
738
|
-
|
|
739
|
-
### What counts as an edge
|
|
740
|
-
|
|
741
|
-
An edge is a user action that **mutates application state** or **navigates through a multi-step flow**:
|
|
742
|
-
- CRUD operations: create, read (detail view), update/edit, delete for every entity
|
|
743
|
-
- Form submissions: login, signup, settings update, profile edit, any form with a submit button
|
|
744
|
-
- Dialog flows: open dialog \u2192 fill form \u2192 submit \u2192 assert result
|
|
745
|
-
- Multi-step flows: wizards, onboarding, checkout \u2014 each full completion is one journey
|
|
746
|
-
- State changes: draft \u2192 published, pending \u2192 approved, enable \u2192 disable
|
|
747
|
-
- Inline actions: role changes via dropdown, toggle switches, inline edits
|
|
748
|
-
- Bulk operations: select multiple \u2192 perform action
|
|
749
|
-
- Import/export: file upload, data import, CSV export
|
|
750
|
-
|
|
751
|
-
### What is NOT an edge
|
|
752
|
-
|
|
753
|
-
- Navigation-only clicks: clicking a sidebar link and asserting a URL changed. This tests the router, not the app.
|
|
754
|
-
- Read-only page views: landing on a page and asserting content is visible with no interaction.
|
|
755
|
-
- Third-party OAuth redirects: cannot be automated without the provider.
|
|
756
|
-
|
|
757
|
-
### Coverage verification
|
|
758
|
-
|
|
759
|
-
Before building the graph, verify completeness:
|
|
760
|
-
- **Component coverage**: Is every interactive component from the inventory covered by at least one edge? If not, add an edge or note why it's excluded.
|
|
761
|
-
- **Entity coverage**: For every core entity, is there a create, update, and delete edge? Missing CRUD operations are coverage gaps.
|
|
762
|
-
- **Role coverage**: If the app has multiple roles, are role-specific actions covered? (e.g., "admin can invite members" vs "member cannot")
|
|
763
|
-
- **Empty/conditional state coverage**: Are empty states and conditional branches covered? (e.g., "first project creation from empty state" vs "additional project from populated list")`}function Pl(){return"## Building the graph\n\n1. Call `get_graph_documentation` to learn the schema (includes JSON Schema generated from the Zod types).\n2. Define **preconditions** at the graph level for every auth role and data requirement discovered. Name them with category prefixes: `auth:admin`, `auth:viewer`, `data:projects`, `data:empty-state`. Use `depends` when one precondition requires another (e.g., `data:three-projects` depends on `auth:admin`).\n3. Define **states** for every unique application context. Two states at the same route are distinct if they have different available edges or different UI (e.g., empty list vs populated list, dialog open vs closed). Each state references its preconditions by name.\n4. Define **edges** connecting states \u2014 one per user journey.\n5. Set `preconditionApiPath` in `.ripplo/settings.json` to where the app's precondition endpoints will live. Can be a relative path (appended to `baseUrl`) or an absolute URL.\n6. Write to `.ripplo/graph.json`, call `validate`, fix errors."}function Tl(){return`## Bootstrap: verify preconditions with one workflow
|
|
764
|
-
|
|
765
|
-
Before generating all workflows, validate that the precondition API works end-to-end.
|
|
766
|
-
|
|
767
|
-
1. **Pick the simplest edge** in the graph \u2014 one with minimal preconditions (e.g., basic auth + a simple CRUD action).
|
|
768
|
-
2. **Generate a workflow spec** for that edge only, following the workflow generation process below.
|
|
769
|
-
3. **Call \`run\`** with the workflow slug.
|
|
770
|
-
4. **Inspect the result.** If the run failed, read debug artifacts from \`.ripplo/debug/<runId>/\` \u2014 check \`summary.txt\`, \`console.log\`, \`network.jsonl\`, and \`steps/<failedIndex>/\` for DOM snapshots, storage state, and screenshots.
|
|
771
|
-
5. **If the run fails, diagnose and fix:**
|
|
772
|
-
- **Precondition failure** (auth didn't set up, data didn't seed): fix the precondition API endpoints, re-run.
|
|
773
|
-
- **Bad locator or assertion** in the workflow spec: fix the spec, re-run.
|
|
774
|
-
- **App-level bug** (the app itself is broken): note it and move on \u2014 this is not fixable from the test side.
|
|
775
|
-
6. **Iterate until this first workflow passes.** This proves auth, data seeding, and the run infrastructure all work.
|
|
776
|
-
|
|
777
|
-
**Do NOT proceed to batch workflow generation until the bootstrap workflow passes.** Every issue you fix here prevents the same failure from hitting all subsequent workflows.`}function Cl(){return`## Generating workflow specs
|
|
778
|
-
|
|
779
|
-
Each workflow spec should be a **QA-quality test plan**, not just a happy-path click sequence. Think of it like a detailed bug report: describe exactly what happens at each step, what should be visible, what state the UI should be in. A QA engineer should be able to read the spec and know exactly what to verify.
|
|
780
|
-
|
|
781
|
-
### Depth of exploration per workflow
|
|
782
|
-
|
|
783
|
-
Before writing a workflow spec, the sub-agent must deeply understand the component it's testing:
|
|
784
|
-
- Read the actual component source code \u2014 find every form field, validation rule, error message, success state, loading state
|
|
785
|
-
- Trace through the component tree \u2014 if a dialog renders a form component which renders field components, read all of them
|
|
786
|
-
- Find the mutation/API call \u2014 what does submit actually do? What are the success/error responses?
|
|
787
|
-
- Find conditional renders \u2014 what happens on validation failure? What does the success state look like? Are there toast notifications, redirects, inline messages?
|
|
788
|
-
- Find edge cases in the code \u2014 disabled states, empty field validation, character limits, duplicate detection
|
|
789
|
-
|
|
790
|
-
### What makes a good workflow spec
|
|
791
|
-
|
|
792
|
-
**Thorough assertions** \u2014 don't just assert the URL changed. Assert:
|
|
793
|
-
- The correct elements are visible after each action (dialog opened, form rendered, success message shown)
|
|
794
|
-
- Text content matches expectations (headings, labels, feedback messages, error messages)
|
|
795
|
-
- The UI reflects the mutation result (new item appears in list, counter updates, status changes)
|
|
796
|
-
- Elements that should NOT be visible are gone (dialog closed after submit, loading spinner gone, error cleared)
|
|
797
|
-
|
|
798
|
-
**Realistic interactions** \u2014 match how a real user would interact:
|
|
799
|
-
- Fill all required form fields with realistic test data
|
|
800
|
-
- Use the actual submit mechanism (button click, not form submit)
|
|
801
|
-
- Wait for async operations (loading states, API responses) before asserting
|
|
802
|
-
- Handle any intermediate UI states (confirmation dialogs, loading spinners)
|
|
803
|
-
|
|
804
|
-
**Edge-case awareness** \u2014 where the code reveals edge cases, cover them:
|
|
805
|
-
- What happens with empty required fields? (if there's validation, test it)
|
|
806
|
-
- What does the error state look like? (if there's error handling UI, assert it)
|
|
807
|
-
- Are there character limits, format requirements, uniqueness constraints?
|
|
808
|
-
|
|
809
|
-
The goal is that even if the spec is 90% correct, it's detailed enough that a human can quickly spot and fix the remaining 10%. A vague spec is worse than a detailed spec with minor locator issues.
|
|
810
|
-
|
|
811
|
-
### File syncing
|
|
812
|
-
|
|
813
|
-
The \`ripplo\` dev session watches \`.ripplo/\` for file changes and automatically syncs them to the server. There is no manual sync command \u2014 never run one.
|
|
814
|
-
|
|
815
|
-
### Workflow generation process
|
|
816
|
-
|
|
817
|
-
For each edge, create and verify a workflow spec:
|
|
818
|
-
1. Call \`get_spec_documentation\` for the format.
|
|
819
|
-
2. Read the relevant component files to find **real locators** (test IDs, button text, form fields, aria labels). **Never fabricate locators** \u2014 always trace them to source code. Read deeply \u2014 follow imports, read sub-components, find the actual rendered elements.
|
|
820
|
-
3. Add thorough assertions after every meaningful action \u2014 not just URL checks, but visibility, text content, and state verification.
|
|
821
|
-
4. Write the spec to \`.ripplo/workflows/<edge-id>.json\`.
|
|
822
|
-
5. Call \`validate\`, fix errors.
|
|
823
|
-
6. **Call \`run\` with the workflow slug.** This is mandatory \u2014 never skip it. After generating a batch, use \`run\` with all slugs from the batch to run them simultaneously.
|
|
824
|
-
7. **Inspect the result.** Read debug artifacts from \`.ripplo/debug/<runId>/\` \u2014 check \`summary.txt\`, \`console.log\`, and \`steps/<failedIndex>/\` for DOM, storage, and screenshots.
|
|
825
|
-
8. ${No}
|
|
826
|
-
|
|
827
|
-
### Parallelism
|
|
828
|
-
|
|
829
|
-
Because workflows run in parallel, each workflow's preconditions must create isolated test data \u2014 unique names, fresh sessions, scoped teardown. See the **Parallel safety** section in Precondition API guidance.
|
|
830
|
-
|
|
831
|
-
Generate workflows in **parallel batches of 3-5** using sub-agents. Each sub-agent handles the full cycle for its assigned edge: generate spec \u2192 validate \u2192 trigger run \u2192 iterate on failures. Sub-agents should spawn their own sub-agents for deep component exploration.
|
|
832
|
-
|
|
833
|
-
**Do not launch all workflows at once.** Use batches so systemic issues are caught early \u2014 if batch 1 reveals a broken precondition pattern, fix it before batch 2.
|
|
834
|
-
|
|
835
|
-
Each sub-agent should receive: edge details, component file paths, action sequence, and instructions to call \`get_spec_documentation\`, read the component files, write the spec, validate, trigger a run, and iterate until it passes. **Workflow sub-agents must go deep** \u2014 they should spawn their own sub-agents to trace through complex component trees, understand form validation logic, find error states, and map out multi-step flows. A shallow read of the top-level component is never enough.
|
|
836
|
-
|
|
837
|
-
### Final summary
|
|
838
|
-
|
|
839
|
-
Report: total states, total edges, workflows generated, **pass/fail per workflow**, and any app-level bugs discovered. Group failures by cause (locator issues, precondition issues, app bugs).`}function Al(){return'## Wiring up the Precondition API\n\nOnce the graph is complete, wire up precondition endpoints **before** generating any workflow specs. The app needs these so Ripplo can check and execute preconditions at runtime.\n\n**Delegate this entire section to a sub-agent.** Pass it: the list of precondition names from the graph, the auth architecture summary (how sessions work, how users are created), the data model summary (how to seed entities), and the `preconditionApiPath`. The sub-agent handles everything below \u2014 reading source code, generating types, writing endpoint handlers, and wiring into the dev workflow. It should spawn its own sub-agents if it needs to dig deeper into the auth system, trace session creation logic, or understand how to seed specific entity types.\n\n### Step 1: Generate types\n\nRun `ripplo generate-types --output <path>` to generate Zod schemas and TypeScript types from the graph\'s precondition names. Pick an output path that\'s natural for the project\'s server code (e.g., `src/generated/precondition-api.ts` or `apps/server/src/generated/precondition-api.ts`).\n\nWire this command into the project\'s existing codegen workflow \u2014 look for how the project already runs generators (Prisma, GraphQL codegen, etc.) and add `ripplo generate-types` alongside it in a package.json script, turbo pipeline, or similar.\n\n### Step 2: Implement the endpoints\n\nCreate three endpoints in the app\'s server. Use the generated types for request validation. Refer to `get_precondition_schema` for the full API contract and example code.\n\nFor each precondition in the graph, implement the business logic:\n\n**Check endpoint** (`POST {preconditionApiPath}/check`):\n- Receives `{ "precondition": "auth:admin" }`\n- Returns `{ "satisfied": true/false }`\n- Should be a lightweight query \u2014 check session cookies, count entities, etc.\n\n**Execute endpoint** (`PUT {preconditionApiPath}/execute`):\n- Receives `{ "precondition": "auth:admin" }`\n- Returns `{ "success": true/false, "error"?: "..." }`\n- Creates test users, seeds data, etc. Set-Cookie headers are captured automatically by Ripplo.\n- **Create fresh ephemeral test users** for auth preconditions \u2014 don\'t reuse shared accounts.\n\n**Teardown endpoint** (`PUT {preconditionApiPath}/teardown`):\n- Receives `{ "preconditions": ["auth:admin", "data:projects"] }`\n- Returns `{ "success": true/false }`\n- Cleans up test data created during the run. Fire-and-forget \u2014 errors don\'t fail the run.\n\n### Implementation approach\n\n- Read the app\'s existing auth system to understand how sessions are created programmatically (not via UI login)\n- Read the data model to understand how to seed entities via direct DB access or internal API calls\n- Use the app\'s existing test utilities, factories, or seed scripts if they exist\n- Create a new router/controller for the precondition endpoints \u2014 keep test infrastructure separate from production routes\n- **Wrap all precondition routes behind an environment variable guard** (e.g., `ENABLE_RIPPLO_TESTING`). Only register the routes when this variable is explicitly set to `"true"`. These endpoints create users, seed data, and delete records \u2014 they should be enabled in development and dedicated testing/staging environments, but must never be accessible in production. Add the env var to `.env.local`, CI config, or staging environment config \u2014 not `.env.production`\n- Import the generated schemas from the output path for type-safe request parsing\n- **Common auth pitfalls to avoid:**\n - Do NOT guess auth API methods \u2014 read the actual SDK types/docs (e.g., Supabase has no `admin.createSession()`)\n - Always include `Path=/` and `SameSite=Lax` on auth cookies in Set-Cookie headers\n - For Supabase: use `admin.createUser({ email_confirm: true })` + `signInWithPassword()` to get session tokens, then set the `sb-<ref>-auth-token` cookie\n - For NextAuth: use the adapter\'s `createSession()` method and set the `next-auth.session-token` cookie\n - Call `get_precondition_schema` for framework-specific examples\n - Test the precondition API manually (curl) before triggering a full workflow run\n\n### Parallel safety\n\nMultiple workflows run simultaneously. Every precondition executor must create **isolated, non-conflicting data per run**:\n\n- **Unique identifiers**: Generate unique names/emails/slugs per run using `crypto.randomUUID()` or `Date.now()` suffixes. Never hardcode entity names like `"Test Project"` \u2014 two parallel runs will collide on unique constraints.\n- **Return dynamic IDs via `data`**: The execute response\'s `data` field passes values to the workflow. Return created entity IDs so workflows reference them dynamically (e.g., `{ "projectId": "cuid-abc123" }`).\n- **Scoped teardown**: Teardown must only delete entities created by that specific run. The teardown request includes cookies from the run\'s session \u2014 use them to identify which user/data to clean up. Never use `deleteMany` with a prefix match or `TRUNCATE` \u2014 it destroys other parallel runs\' data.\n- **Independent sessions**: Each run creates its own auth session. Never assume a singleton test user or shared session exists.\n\n### Step 3: Verify\n\nEnsure `preconditionApiPath` is set in `.ripplo/settings.json` to point to the endpoints (e.g., `"preconditionApiPath": "http://localhost:3000/api/test/preconditions"`). Can be a relative path (appended to `baseUrl`) or an absolute URL. The precondition runner will call these endpoints during test execution.'}function Nl(){return'## Rules\n\n- Value references use `"type": "static"`, never `"type": "literal"`\n- Goto node URLs must be relative paths (`/projects`), never absolute URLs\n- Graph files use `version: 3`, workflow spec files use `version: 2`\n- IDs must match their object keys (state ID = its key in the states object)\n- Call `validate` after every edit \u2014 don\'t batch without validation\n- Workflow filenames should match their edge IDs\n- Prefer locator stability: testId > role > label > text > css\n- State names should be descriptive and encode what\'s unique: `projects-list-empty`, `create-workflow-dialog-open`, `member-role-updated` \u2014 never `state-1` or `page-loaded`'}function jo(){let e=new El({name:"ripplo",version:"0.1.0"});return Il(e),$l(e),jl(e),Ol(e),Vl(e),e}function Il(e){e.registerTool("get_spec_documentation",{description:"Returns a compact summary of all node types with their fields, locator types, operators, and value ref formats. Start here before creating or editing workflow specs. Use get_node_schema for full JSON Schema of specific types."},()=>B(eo())),e.registerTool("get_node_schema",{description:"Returns full JSON Schemas for one or more node types. Pass an array of type names (e.g. ['click', 'fill', 'assertText']). Includes referenced sub-schemas (locators, operators, value refs) automatically. Call after get_spec_documentation when you need exact node shapes.",inputSchema:{nodeTypes:H.array(H.string()).min(1).describe("Node type names to get schemas for (e.g. ['click', 'fill'])")}},t=>B(to({nodeTypes:t.nodeTypes}))),e.registerTool("get_spec_patterns",{description:"Returns full JSON Schemas for shared building blocks: locators, conditions, value references, operators, and variables. Plus best practices for writing specs."},()=>B(ro())),e.registerTool("review_workflow",{description:"Review the semantic quality of a workflow spec. Call AFTER validate passes. Analyzes whether the workflow actually tests what it claims \u2014 checks assertions against expectedOutcome, verifies the flow exercises the described behavior, and flags gaps. Returns a structured report for you to review and act on.",inputSchema:{file:H.string().describe("Absolute path to the workflow JSON file")}},t=>{let r=po(t.file),o=r.flags.length===0?"PASS":"NEEDS REVIEW";return B(`${o}
|
|
840
|
-
|
|
841
|
-
${r.report}`)})}function $l(e){e.registerTool("get_graph_documentation",{description:"Returns the state graph schema and a summary of the precondition system. Start here before creating or editing graph files. Use get_precondition_schema for full precondition API details."},()=>B(lo())),e.registerTool("get_precondition_schema",{description:"Returns full precondition API schemas, setup guide, framework-specific auth examples, and best practices. Call when implementing precondition endpoints."},()=>B(co())),e.registerTool("validate",{description:"Run all health checks on the Ripplo project. Validates settings, auth, dev server connectivity, preconditions, webhook verification, state graph, workflows, and browser. MUST be called after every edit to .ripplo/ files. Fix all errors before proceeding.",inputSchema:{dir:H.string().optional().describe("Working directory (defaults to cwd)")}},async t=>{let o=(await Ro(t.dir??process.cwd())).map(n=>To(n));return B(o.join(`
|
|
842
|
-
`))})}function jl(e){e.registerPrompt("explore",{argsSchema:{scope:H.string().default("").describe("Narrow exploration to code changes. Pass a PR number, branch name, 'changes' for local diff, or a description. Leave empty for full exhaustive exploration.")},description:"Guided flow to crawl a codebase, build a state graph, and generate workflow specs. Optionally scoped to recent code changes."},t=>({messages:[{content:{text:$o({scope:t.scope}),type:"text"},role:"user"}]}))}function Ol(e){e.registerTool("run",{description:"Run one or more workflows in parallel. Creates runs on the server and waits for the local dev mode agent to execute them. Multiple workflows execute concurrently. Requires `ripplo` (or `npx ripplo`) to be running. The dev session automatically watches `.ripplo/` files and syncs them to the server \u2014 no manual sync step is needed. Workflows are defined as JSON files in `.ripplo/workflows/` \u2014 the slug is the filename without `.json`. Results include run URLs and debug artifact paths.",inputSchema:{slugs:H.array(H.string()).describe("Workflow slugs to run (supports 1 or many, all run in parallel)")}},t=>Xr(async r=>{try{let o=await io({config:r,slugs:t.slugs});return B(o.summary)}catch(o){let n=o instanceof Error?o.message:String(o),i=n.includes("fetch failed")||n.includes("Failed to connect")||n.includes("Could not reach")||n.includes("ECONNREFUSED")?"\n\nSetup checklist:\n1. Is `ripplo` (or `npx ripplo`) running in a terminal?\n2. Is the dev server running? Check `baseUrl` in `.ripplo/settings.json` \u2014 is the port correct?\n3. Run the `validate` tool to check for configuration issues.":"";return B(`ERROR: ${n}${i}`)}}))}function Vl(e){e.registerPrompt("run",{argsSchema:{workflow:H.string().optional().describe("Workflow name to run (optional)")},description:"Run ripplo user flows locally using Playwright"},t=>({messages:[{content:{text:t.workflow==null?["Run all workflows in parallel:","","1. Read the `.ripplo/workflows/` directory to find all workflow JSON files. The slug is the filename without `.json`.","2. Call `run` with all slugs at once to execute them in parallel.","3. Report an aggregate summary: total run, passed, failed, and failure details.","","Do not ask any questions. Do not read workflow file contents."].join(`
|
|
843
|
-
`):`Read \`.ripplo/workflows/\` to find the workflow named "${t.workflow}" (check the "name" field in each JSON file), then call the \`run\` tool with its slug (filename without .json). Do not ask any questions or read any files.`,type:"text"},role:"user"}]})),e.registerPrompt("create",{argsSchema:{flow:H.string().optional().describe("User flow to test (e.g. login, checkout)")},description:"Create a new Ripplo workflow spec by analyzing the codebase"},t=>({messages:[{content:{text:[Ao(t.flow??"the most important user flow"),"",he].join(`
|
|
844
|
-
`),type:"text"},role:"user"}]})),e.registerPrompt("debug",{argsSchema:{workflow:H.string().optional().describe("Workflow name or slug to debug (lists available if omitted)")},description:"Debug a failing workflow \u2014 identifies root cause using browser logs, DOM, network, and storage snapshots from .ripplo/debug/"},t=>({messages:[{content:{text:Io(t.workflow),type:"text"},role:"user"}]}))}async function Fl(){let e=jo(),t=new Ul;await e.connect(t)}Fl().catch(e=>{process.exit(1)});
|