UNPKG

@stacksjs/stx

Version:

A performant UI Framework. Powered by Bun.

127 lines (117 loc) 12.3 kB
// @bun import B from"fs";import $ from"path";import k from"crypto";var V=[{name:"mobile",width:375,height:667,isMobile:!0,hasTouch:!0},{name:"tablet",width:768,height:1024,isMobile:!0,hasTouch:!0},{name:"desktop",width:1280,height:800,isMobile:!1,hasTouch:!1},{name:"wide",width:1920,height:1080,isMobile:!1,hasTouch:!1}];function J(N={}){let Y={snapshotDir:N.snapshotDir||"__snapshots__",screenshotDir:N.screenshotDir||"__screenshots__",updateSnapshots:N.updateSnapshots||process.env.UPDATE_SNAPSHOTS==="true",threshold:N.threshold??0.01,viewports:N.viewports||V,generateDiff:N.generateDiff??!0,diffDir:N.diffDir||"__diffs__",compare:N.compare||b,ci:N.ci??process.env.CI==="true",retries:N.retries??(N.ci?3:1)};return{async snapshot(M,X){let q=$.join(Y.snapshotDir,`${X}.html`),G=C(M),Q=K(G);if(B.existsSync(q)){let W=await Bun.file(q).text(),Z=K(C(W));if(Q===Z)return{passed:!0,snapshotPath:q,message:"Snapshot matches"};if(Y.updateSnapshots)return await B.promises.mkdir($.dirname(q),{recursive:!0}),await Bun.write(q,G),{passed:!0,snapshotPath:q,message:"Snapshot updated"};let F=$.join(Y.diffDir,`${X}.actual.html`),U=$.join(Y.diffDir,`${X}.diff.html`);return await B.promises.mkdir(Y.diffDir,{recursive:!0}),await Bun.write(F,G),await Bun.write(U,I(W,G)),{passed:!1,snapshotPath:q,actualPath:F,diffPath:U,message:"Snapshot does not match"}}return await B.promises.mkdir($.dirname(q),{recursive:!0}),await Bun.write(q,G),{passed:!0,snapshotPath:q,message:"New snapshot created"}},async snapshotJson(M,X){let q=$.join(Y.snapshotDir,`${X}.json`),G=JSON.stringify(M,null,2);if(B.existsSync(q)){let Q=await Bun.file(q).text();if(G===Q)return{passed:!0,snapshotPath:q};if(Y.updateSnapshots)return await Bun.write(q,G),{passed:!0,snapshotPath:q,message:"Snapshot updated"};return{passed:!1,snapshotPath:q,message:`JSON snapshot mismatch: ${z(Q,G)}`}}return await B.promises.mkdir($.dirname(q),{recursive:!0}),await Bun.write(q,G),{passed:!0,snapshotPath:q,message:"New snapshot created"}},matchInlineSnapshot(M,X){let q=C(M);if(X===void 0)return{passed:!0,expected:q};let G=C(X);return{passed:q===G,expected:q}},getConfig(){return Y}}}function D(N={}){let Y=J(N),M=Y.getConfig();return{async compareScreenshots(X,q,G="desktop"){let Q=$.join(M.screenshotDir,G,`${X}.png`),W=$.join(M.diffDir,G,`${X}.actual.png`),Z=$.join(M.diffDir,G,`${X}.diff.png`);if(B.existsSync(Q)){let F=await Bun.file(Q).arrayBuffer(),U=Buffer.from(F),_=await M.compare(q,U);if(_.match||_.diffPercentage<=M.threshold)return{viewport:G,passed:!0,baselinePath:Q};if(M.updateSnapshots)return await B.promises.mkdir($.dirname(Q),{recursive:!0}),await Bun.write(Q,q),{viewport:G,passed:!0,baselinePath:Q};if(await B.promises.mkdir($.dirname(W),{recursive:!0}),await Bun.write(W,q),_.diffImage&&M.generateDiff)await Bun.write(Z,_.diffImage);return{viewport:G,passed:!1,baselinePath:Q,actualPath:W,diffPath:M.generateDiff?Z:void 0,diffPercentage:_.diffPercentage}}return await B.promises.mkdir($.dirname(Q),{recursive:!0}),await Bun.write(Q,q),{viewport:G,passed:!0,baselinePath:Q}},async testViewports(X,q,G=M.viewports){let Q=[];for(let W of G){let Z=null;for(let F=0;F<M.retries;F++)try{let U=await q(W),_=await this.compareScreenshots(X,U,W.name);Q.push(_),Z=null;break}catch(U){if(Z=U,F<M.retries-1)await new Promise((_)=>setTimeout(_,100*(F+1)))}if(Z)Q.push({viewport:W.name,passed:!1,baselinePath:"",diffPercentage:100})}return Q},getSnapshotTester(){return Y}}}function L(N={}){let M=D(N).getSnapshotTester(),X=new Map;return{addStory(q){X.set(`${q.component}/${q.name}`,q)},addStories(q){for(let G of q)this.addStory(G)},getStories(){return Array.from(X.values())},getStoriesForComponent(q){return Array.from(X.values()).filter((G)=>G.component===q)},async testStory(q){let G=X.get(q);if(!G)return{name:q,passed:!1,duration:0,snapshots:[],screenshots:[],errors:[Error(`Story not found: ${q}`)]};let Q=Date.now(),W={name:q,passed:!0,duration:0,snapshots:[],screenshots:[],errors:[]};try{let Z=await G.render(),F=await M.snapshot(Z,q.replace(/\//g,"-"));if(W.snapshots.push(F),!F.passed)W.passed=!1}catch(Z){W.passed=!1,W.errors.push(Z)}return W.duration=Date.now()-Q,W},async testAllStories(){let q=[];for(let G of X.keys()){let Q=await this.testStory(G);q.push(Q)}return q},async generateStoryIndex(){let q={};for(let G of X.values()){if(!q[G.component])q[G.component]=[];q[G.component].push({name:G.name,args:G.args})}return JSON.stringify(q,null,2)}}}async function S(N,Y={}){let M={outputDir:Y.outputDir||"visual-test-report",format:Y.format||"html",title:Y.title||"Visual Test Report",includeScreenshots:Y.includeScreenshots??!0};await B.promises.mkdir(M.outputDir,{recursive:!0});let X=N.length,q=N.filter((F)=>F.passed).length,G=X-q,Q=N.reduce((F,U)=>F+U.duration,0);if(M.format==="json"){let F={title:M.title,summary:{total:X,passed:q,failed:G,duration:Q},results:N,generatedAt:new Date().toISOString()},U=$.join(M.outputDir,"report.json");return await Bun.write(U,JSON.stringify(F,null,2)),U}if(M.format==="markdown"){let F=`# ${M.title} `;F+=`## Summary `,F+=`- Total: ${X} `,F+=`- Passed: ${q} `,F+=`- Failed: ${G} `,F+=`- Duration: ${Q}ms `,F+=`## Results `;for(let _ of N){let R=_.passed?"\u2705":"\u274C";if(F+=`### ${R} ${_.name} `,F+=`Duration: ${_.duration}ms `,_.errors.length>0){F+=`**Errors:** `;for(let A of _.errors)F+=`- ${A.message} `;F+=` `}if(_.snapshots.length>0){F+=`**Snapshots:** `;for(let A of _.snapshots){let j=A.passed?"\u2705":"\u274C";F+=`- ${j} ${A.snapshotPath} `}F+=` `}}let U=$.join(M.outputDir,"report.md");return await Bun.write(U,F),U}let W=`<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${M.title}</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 2rem; } h1 { margin-bottom: 1rem; } .summary { display: flex; gap: 2rem; margin-bottom: 2rem; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .stat { text-align: center; } .stat-value { font-size: 2rem; font-weight: bold; } .stat-label { font-size: 0.875rem; color: #666; } .passed { color: #22c55e; } .failed { color: #ef4444; } .results { display: flex; flex-direction: column; gap: 1rem; } .result { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; } .result-header { padding: 1rem; background: #f9f9f9; display: flex; justify-content: space-between; align-items: center; cursor: pointer; } .result-header:hover { background: #f0f0f0; } .result-body { padding: 1rem; display: none; } .result.expanded .result-body { display: block; } .status { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.875rem; } .status.pass { background: #dcfce7; color: #166534; } .status.fail { background: #fee2e2; color: #991b1b; } .error { background: #fef2f2; padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-family: monospace; font-size: 0.875rem; } .diff-view { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-top: 1rem; } .diff-view img { max-width: 100%; border: 1px solid #ddd; border-radius: 4px; } .diff-label { font-size: 0.875rem; color: #666; margin-bottom: 0.5rem; } </style> </head> <body> <h1>${M.title}</h1> <div class="summary"> <div class="stat"> <div class="stat-value">${X}</div> <div class="stat-label">Total Tests</div> </div> <div class="stat"> <div class="stat-value passed">${q}</div> <div class="stat-label">Passed</div> </div> <div class="stat"> <div class="stat-value failed">${G}</div> <div class="stat-label">Failed</div> </div> <div class="stat"> <div class="stat-value">${Q}ms</div> <div class="stat-label">Duration</div> </div> </div> <div class="results"> ${N.map((F)=>` <div class="result ${F.passed?"":"expanded"}"> <div class="result-header" onclick="this.parentElement.classList.toggle('expanded')"> <span>${F.name}</span> <span class="status ${F.passed?"pass":"fail"}">${F.passed?"PASS":"FAIL"}</span> </div> <div class="result-body"> <p>Duration: ${F.duration}ms</p> ${F.errors.length>0?` <div class="error"> ${F.errors.map((U)=>`<div>${E(U.message)}</div>`).join("")} </div> `:""} ${F.snapshots.filter((U)=>!U.passed).map((U)=>` <div class="snapshot-diff"> <p>Snapshot: ${U.snapshotPath}</p> ${U.diffPath?`<p><a href="file://${U.diffPath}">View diff</a></p>`:""} </div> `).join("")} ${F.screenshots.filter((U)=>!U.passed).map((U)=>` <div class="diff-view"> <div> <div class="diff-label">Baseline</div> ${M.includeScreenshots?`<img src="file://${U.baselinePath}" alt="baseline">`:`<p>${U.baselinePath}</p>`} </div> <div> <div class="diff-label">Actual</div> ${M.includeScreenshots&&U.actualPath?`<img src="file://${U.actualPath}" alt="actual">`:`<p>${U.actualPath||"N/A"}</p>`} </div> <div> <div class="diff-label">Diff (${(U.diffPercentage||0).toFixed(2)}%)</div> ${M.includeScreenshots&&U.diffPath?`<img src="file://${U.diffPath}" alt="diff">`:`<p>${U.diffPath||"N/A"}</p>`} </div> </div> `).join("")} </div> </div> `).join("")} </div> <script> // Auto-expand failed tests document.querySelectorAll('.result.fail').forEach(el => el.classList.add('expanded')); </script> </body> </html>`,Z=$.join(M.outputDir,"report.html");return await Bun.write(Z,W),Z}function C(N){return N.replace(/<!--[\s\S]*?-->/g,"").replace(/\s+/g," ").replace(/>\s+</g,"><").replace(/>\s+/g,">").replace(/\s+</g,"<").replace(/"/g,'"').replace(/"/g,'"').trim()}function K(N){return k.createHash("md5").update(N).digest("hex")}function I(N,Y){let M=N.split(` `),X=Y.split(` `),q="<html><head><style>";q+=".diff { font-family: monospace; white-space: pre; }",q+=".add { background: #dcfce7; }",q+=".remove { background: #fee2e2; }",q+=".line-num { color: #888; width: 3em; display: inline-block; }",q+='</style></head><body><div class="diff">';let G=Math.max(M.length,X.length);for(let Q=0;Q<G;Q++){let W=M[Q]||"",Z=X[Q]||"";if(W===Z)q+=`<div><span class="line-num">${Q+1}</span> ${E(W)}</div>`;else{if(W)q+=`<div class="remove"><span class="line-num">${Q+1}</span>-${E(W)}</div>`;if(Z)q+=`<div class="add"><span class="line-num">${Q+1}</span>+${E(Z)}</div>`}}return q+="</div></body></html>",q}function z(N,Y){let M=JSON.parse(N),X=JSON.parse(Y),q=[];function G(Q,W,Z){if(typeof W!==typeof Z){q.push(`${Q}: type changed from ${typeof W} to ${typeof Z}`);return}if(W===null||Z===null){if(W!==Z)q.push(`${Q}: ${JSON.stringify(W)} \u2192 ${JSON.stringify(Z)}`);return}if(typeof W==="object"){let F=Object.keys(W),U=Object.keys(Z);for(let _ of F)if(!U.includes(_))q.push(`${Q}.${_}: removed`);else G(`${Q}.${_}`,W[_],Z[_]);for(let _ of U)if(!F.includes(_))q.push(`${Q}.${_}: added`)}else if(W!==Z)q.push(`${Q}: ${JSON.stringify(W)} \u2192 ${JSON.stringify(Z)}`)}return G("root",M,X),q.join(` `)}function E(N){return N.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}async function b(N,Y){if(N.length!==Y.length)return{match:!1,diffPercentage:100,diffPixels:Math.max(N.length,Y.length)};let M=0;for(let q=0;q<N.length;q++)if(N[q]!==Y[q])M++;let X=M/N.length*100;return{match:M===0,diffPercentage:X,diffPixels:M}}var O=null;async function H(N,Y){if(!O)O=J();return O.snapshot(N,Y)}async function v(N,Y){if(!O)O=J();return O.snapshotJson(N,Y)}function P(){O=null}var x={createSnapshotTester:J,createVisualRegressionTester:D,createStoryTester:L,generateReport:S,snapshot:H,snapshotJson:v,defaultViewports:V};export{v as snapshotJson,H as snapshot,P as resetDefaultTester,S as generateReport,V as defaultViewports,x as default,D as createVisualRegressionTester,L as createStoryTester,J as createSnapshotTester};