11import * as Base64 from '@frsource/base64' ;
22import './commands' ;
3- import { LINK_PREFIX , OVERLAY_CLASS , TASK } from './constants' ;
3+ import { FAB_CLASS , FAB_BADGE_CLASS , LINK_PREFIX , OVERLAY_CLASS , TASK } from './constants' ;
4+ import type { PendingDiffRecord } from './types' ;
5+
6+ const CAROUSEL_CLASS = 'cp-visual-regression-diff-carousel' ;
7+ const INFO_MODAL_CLASS = 'cp-visual-regression-diff-info-modal' ;
8+
9+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 72 72"><circle cx="35.5" cy="36.5" r="27.5" fill="#4A4A4D" stroke="#FFF"/><g fill="#FFF" transform="matrix(.075 0 0 .075 16 17)"><circle cx="259.8" cy="259.9" r="80"/><path d="M511.7 237.7C450.2 163.4 357 92 259.8 92 162.5 91.9 69.4 163.4 8 237.7A34.8 34.8 0 0 0 8 282a520.8 520.8 0 0 0 91 86.2c109 79.3 212.4 79.5 321.6 0a520.8 520.8 0 0 0 91-86.2 34.8 34.8 0 0 0 0-44.3zM259.8 148a112.1 112.1 0 0 1 0 224 112.1 112.1 0 0 1 0-224z"/></g></svg>` ;
10+
11+ const CHECK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>` ;
412
513/* c8 ignore start */
614function queueClear ( ) {
@@ -17,6 +25,7 @@ function queueRun() {
1725 ( cy as unknown as { queue : { run : ( ) => void } } ) . queue . run ( ) ;
1826}
1927/* c8 ignore stop */
28+
2029export const generateOverlayTemplate = ( {
2130 title,
2231 imgNewBase64,
@@ -69,10 +78,218 @@ export const generateOverlayTemplate = ({
6978 </div>
7079</div>` ;
7180
81+ const generateDiffPanelHTML = ( {
82+ imgNewBase64,
83+ imgOldBase64,
84+ imgDiffBase64,
85+ } : Pick < PendingDiffRecord , 'imgNewBase64' | 'imgOldBase64' | 'imgDiffBase64' > ) =>
86+ `<div style="display:flex;justify-content:space-evenly;align-items:flex-start;gap:15px;flex-wrap:wrap">
87+ <div
88+ style="position:relative;background:#fff;border:solid 15px #fff"
89+ onmouseover="this.querySelector('div').style.opacity=0,this.querySelector('img').style.opacity=1"
90+ onmouseleave="this.querySelector('div').style.opacity=1,this.querySelector('img').style.opacity=0"
91+ >
92+ <h3 style="margin:0 0 8px">New screenshot (hover to compare):</h3>
93+ <img style="min-width:200px;max-width:100%;opacity:0" src="data:image/png;base64,${ imgNewBase64 } " />
94+ <div style="position:absolute;top:0;left:0;background:#fff">
95+ <h3 style="margin:0 0 8px">Old screenshot:</h3>
96+ <img style="min-width:200px;max-width:100%" src="data:image/png;base64,${ imgOldBase64 } " />
97+ </div>
98+ </div>
99+ <div style="background:#fff;border:solid 15px #fff">
100+ <h3 style="margin:0 0 8px">Diff:</h3>
101+ <img style="min-width:200px;max-width:100%" src="data:image/png;base64,${ imgDiffBase64 } " />
102+ </div>
103+ </div>` ;
104+
105+ const generateFABTemplate = ( ) =>
106+ `<button class="${ FAB_CLASS } " title="Cypress Plugin Visual Regression Diff" style="position:fixed;bottom:20px;right:20px;z-index:99999;width:52px;height:52px;border-radius:50%;border:2px solid rgba(255,255,255,.2);background:#4A4A4D;cursor:pointer;padding:0;box-shadow:0 2px 10px rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;overflow:visible;transition:transform .15s">
107+ <span class="${ FAB_BADGE_CLASS } " hidden style="position:absolute;top:-6px;right:-6px;background:#e53e3e;color:#fff;font-size:11px;font-weight:700;min-width:18px;height:18px;border-radius:9px;display:flex;align-items:center;justify-content:center;padding:0 4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none"></span>
108+ <span class="${ FAB_CLASS } -logo" style="display:flex;align-items:center;justify-content:center;width:36px;height:36px">${ LOGO_SVG } </span>
109+ <span class="${ FAB_CLASS } -check" style="display:none;align-items:center;justify-content:center;width:36px;height:36px">${ CHECK_SVG } </span>
110+ </button>
111+ <style>
112+ @keyframes ${ FAB_BADGE_CLASS } -pulse {
113+ 0%,100%{transform:scale(1)}
114+ 50%{transform:scale(1.35)}
115+ }
116+ .${ FAB_BADGE_CLASS } --pulse { animation: ${ FAB_BADGE_CLASS } -pulse .5s ease; }
117+ @keyframes ${ FAB_CLASS } -success {
118+ 0%{opacity:0;transform:scale(.5)}
119+ 40%{opacity:1;transform:scale(1.1)}
120+ 100%{opacity:1;transform:scale(1)}
121+ }
122+ .${ FAB_CLASS } [data-success] .${ FAB_CLASS } -logo { display:none!important; }
123+ .${ FAB_CLASS } [data-success] .${ FAB_CLASS } -check { display:flex!important; animation:${ FAB_CLASS } -success .3s ease forwards; }
124+ .${ FAB_CLASS } :hover { transform:scale(1.08); }
125+ </style>` ;
126+
127+ const generateInfoModalTemplate = ( ) =>
128+ `<div class="${ INFO_MODAL_CLASS } " style="position:fixed;z-index:100000;top:0;bottom:0;left:0;right:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center">
129+ <div style="background:#fff;border-radius:8px;padding:32px;max-width:480px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.3);font-family:sans-serif">
130+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
131+ <div style="width:40px;height:40px;flex-shrink:0">${ LOGO_SVG } </div>
132+ <h2 style="margin:0;font-size:18px;color:#1a202c">Cypress Plugin Visual Regression Diff</h2>
133+ </div>
134+ <p style="color:#4a5568;line-height:1.6;margin:0 0 12px">
135+ This button gives you a quick way to review visual regression changes directly in the Cypress runner.
136+ </p>
137+ <p style="color:#4a5568;line-height:1.6;margin:0 0 20px">
138+ When any <code style="background:#edf2f7;padding:2px 5px;border-radius:3px;font-size:.9em">cy.matchImage()</code> call detects a screenshot that exceeds the diff threshold, a badge will appear here with the count of changed images. Click to open a review carousel where you can <strong>replace</strong> baselines or <strong>skip</strong> changes.
139+ </p>
140+ <p style="color:#718096;font-size:.875em;margin:0 0 20px">
141+ Enable deferred failures via <code style="background:#edf2f7;padding:2px 5px;border-radius:3px">pluginVisualRegressionDeferFailures: true</code> in your Cypress env config to let all tests run before failing.
142+ </p>
143+ <div style="display:flex;justify-content:space-between;align-items:center">
144+ <a href="https://github.com/FRSOURCE/cypress-plugin-visual-regression-diff" target="_blank" style="color:#4299e1;font-size:.875em">View on GitHub ↗</a>
145+ <button data-type="close" style="background:#4A4A4D;color:#fff;border:none;padding:8px 18px;border-radius:5px;cursor:pointer;font-size:.9em">Close</button>
146+ </div>
147+ </div>
148+ </div>` ;
149+
150+ const generateCarouselTemplate = ( ) =>
151+ `<div class="${ CAROUSEL_CLASS } " style="position:fixed;z-index:100000;top:0;bottom:0;left:0;right:0;display:flex;flex-flow:column;background:#1a202c;font-family:sans-serif">
152+ <header style="background:#2d3748;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0">
153+ <div style="display:flex;align-items:center;gap:12px">
154+ <div style="width:28px;height:28px;flex-shrink:0">${ LOGO_SVG } </div>
155+ <div>
156+ <span data-carousel-counter style="font-size:.8em;color:#a0aec0;display:block"></span>
157+ <span data-carousel-title style="font-weight:600;color:#fff;font-size:.95em"></span>
158+ </div>
159+ </div>
160+ <button data-type="close" style="background:transparent;border:1px solid #4a5568;color:#a0aec0;padding:6px 12px;border-radius:4px;cursor:pointer;font-size:.85em">✕ Close</button>
161+ </header>
162+ <div data-carousel-content style="flex:1;overflow:auto;padding:20px;color:#fff"></div>
163+ <footer style="background:#2d3748;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0;flex-wrap:wrap">
164+ <button data-type="prev" style="background:transparent;border:1px solid #4a5568;color:#a0aec0;padding:8px 14px;border-radius:4px;cursor:pointer">← Prev</button>
165+ <div style="display:flex;gap:8px">
166+ <button data-type="skip" style="background:#4a5568;border:none;color:#fff;padding:8px 16px;border-radius:4px;cursor:pointer">Skip this change →</button>
167+ <button data-type="replace" style="background:#38a169;border:none;color:#fff;padding:8px 16px;border-radius:4px;cursor:pointer;font-weight:600">✓ Replace image</button>
168+ </div>
169+ <button data-type="next" style="background:transparent;border:1px solid #4a5568;color:#a0aec0;padding:8px 14px;border-radius:4px;cursor:pointer">Next →</button>
170+ </footer>
171+ </div>` ;
172+
173+ /* c8 ignore start */
174+ function showFABSuccess ( ) {
175+ if ( ! top ) return ;
176+ const fab = top . document . querySelector ( `.${ FAB_CLASS } ` ) ;
177+ if ( ! fab ) return ;
178+ const badge = fab . querySelector ( `.${ FAB_BADGE_CLASS } ` ) ;
179+ if ( badge ) badge . setAttribute ( 'hidden' , '' ) ;
180+ fab . setAttribute ( 'data-success' , 'true' ) ;
181+ setTimeout ( ( ) => fab . removeAttribute ( 'data-success' ) , 1800 ) ;
182+ }
183+
184+ function openCarousel ( diffs : PendingDiffRecord [ ] ) {
185+ if ( ! top || ! diffs . length ) return ;
186+
187+ let currentIndex = 0 ;
188+ let replacedCount = 0 ;
189+
190+ const carouselEl = Cypress . $ ( generateCarouselTemplate ( ) ) . appendTo (
191+ top . document . body ,
192+ ) ;
193+
194+ function renderDiff ( index : number ) {
195+ currentIndex = index ;
196+ const diff = diffs [ index ] ;
197+ carouselEl
198+ . find ( '[data-carousel-content]' )
199+ . html ( generateDiffPanelHTML ( diff ) ) ;
200+ carouselEl
201+ . find ( '[data-carousel-counter]' )
202+ . text ( `Image ${ index + 1 } of ${ diffs . length } ` ) ;
203+ carouselEl . find ( '[data-carousel-title]' ) . text ( `${ diff . title } – screenshot diff` ) ;
204+ ( carouselEl . find ( '[data-type="prev"]' ) [ 0 ] as HTMLButtonElement ) . disabled =
205+ index === 0 ;
206+ ( carouselEl . find ( '[data-type="next"]' ) [ 0 ] as HTMLButtonElement ) . disabled =
207+ index === diffs . length - 1 ;
208+ }
209+
210+ function closeCarousel ( ) {
211+ if ( ! top ) return ;
212+ top . document . removeEventListener ( 'keydown' , handleKey ) ;
213+ carouselEl . remove ( ) ;
214+ if ( replacedCount > 0 ) showFABSuccess ( ) ;
215+ }
216+
217+ function advance ( ) {
218+ if ( currentIndex < diffs . length - 1 ) {
219+ renderDiff ( currentIndex + 1 ) ;
220+ } else {
221+ closeCarousel ( ) ;
222+ }
223+ }
224+
225+ function handleKey ( e : KeyboardEvent ) {
226+ if ( e . key === 'ArrowLeft' && currentIndex > 0 ) renderDiff ( currentIndex - 1 ) ;
227+ else if ( e . key === 'ArrowRight' && currentIndex < diffs . length - 1 )
228+ renderDiff ( currentIndex + 1 ) ;
229+ else if ( e . key === 'Escape' ) closeCarousel ( ) ;
230+ }
231+
232+ top . document . addEventListener ( 'keydown' , handleKey ) ;
233+
234+ renderDiff ( 0 ) ;
235+
236+ carouselEl . on ( 'click' , '[data-type="close"]' , closeCarousel ) ;
237+ carouselEl . on ( 'click' , '[data-type="prev"]' , ( ) =>
238+ renderDiff ( currentIndex - 1 ) ,
239+ ) ;
240+ carouselEl . on ( 'click' , '[data-type="next"]' , ( ) =>
241+ renderDiff ( currentIndex + 1 ) ,
242+ ) ;
243+ carouselEl . on ( 'click' , '[data-type="skip"]' , advance ) ;
244+
245+ carouselEl . on ( 'click' , '[data-type="replace"]' , ( ) => {
246+ queueClear ( ) ;
247+ const diff = diffs [ currentIndex ] ;
248+ cy . task ( TASK . approveImage , {
249+ img : diff . imgPath ,
250+ imgOld : diff . imgOldPath ,
251+ } ) . then ( ( ) => {
252+ replacedCount ++ ;
253+ advance ( ) ;
254+ } ) ;
255+ queueRun ( ) ;
256+ } ) ;
257+ }
258+
259+ function openInfoModal ( ) {
260+ if ( ! top ) return ;
261+ const modal = Cypress . $ ( generateInfoModalTemplate ( ) ) . appendTo (
262+ top . document . body ,
263+ ) ;
264+ modal . on ( 'click' , '[data-type="close"]' , ( ) => modal . remove ( ) ) ;
265+ }
266+ /* c8 ignore stop */
267+
72268/* c8 ignore start */
73269before ( ( ) => {
74270 if ( ! top ) return null ;
75271 Cypress . $ ( `.${ OVERLAY_CLASS } ` , top . document . body ) . remove ( ) ;
272+
273+ // Inject the persistent FAB if not already present
274+ if ( ! top . document . querySelector ( `.${ FAB_CLASS } ` ) ) {
275+ Cypress . $ ( generateFABTemplate ( ) ) . appendTo ( top . document . body ) ;
276+ }
277+
278+ Cypress . $ ( top . document . body ) . on ( 'click' , `.${ FAB_CLASS } ` , ( ) => {
279+ queueClear ( ) ;
280+ cy . task < PendingDiffRecord [ ] > ( TASK . getPendingDiffs , null , {
281+ log : false ,
282+ } ) . then ( ( diffs ) => {
283+ if ( diffs . length > 0 ) {
284+ openCarousel ( diffs ) ;
285+ } else {
286+ openInfoModal ( ) ;
287+ }
288+ } ) ;
289+ queueRun ( ) ;
290+ } ) ;
291+
292+ cy . task ( TASK . clearPendingDiffs , null , { log : false } ) ;
76293} ) ;
77294
78295after ( ( ) => {
@@ -145,5 +362,15 @@ after(() => {
145362 return false ;
146363 } ,
147364 ) ;
365+
366+ cy . task < PendingDiffRecord [ ] > ( TASK . getPendingDiffs , null , {
367+ log : false ,
368+ } ) . then ( ( diffs ) => {
369+ if ( ! top || diffs . length === 0 ) return ;
370+ openCarousel ( diffs ) ;
371+ throw new Error (
372+ `[cypress-plugin-visual-regression-diff] ${ diffs . length } image snapshot(s) exceeded the diff threshold. Review the changes using the plugin UI (bottom-right button).` ,
373+ ) ;
374+ } ) ;
148375} ) ;
149376/* c8 ignore stop */
0 commit comments