Skip to content

Commit d11e0bc

Browse files
FRSgitclaude
andcommitted
feat: add deferred failures and batch image review UI
All cy.matchImage() calls no longer fail immediately when the diff threshold is exceeded. Instead, failures are accumulated and reported as a single error at the end of the spec (opt-in via pluginVisualRegressionDeferFailures env var). A persistent floating action button (plugin logo) is injected into the Cypress runner and shows a badge with the count of pending diffs. Clicking it opens: - an informational modal when there are no diffs - a fullscreen carousel for reviewing all changed images when diffs exist The carousel supports Replace image / Skip this change / keyboard navigation and shows a success animation on the FAB after approvals. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 579a89c commit d11e0bc

5 files changed

Lines changed: 312 additions & 13 deletions

File tree

packages/cypress-plugin-visual-regression-diff/src/commands.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { FILE_SUFFIX, LINK_PREFIX, TASK } from './constants';
1+
import { FAB_BADGE_CLASS, FILE_SUFFIX, LINK_PREFIX, TASK } from './constants';
22
import type pixelmatch from 'pixelmatch';
33
import * as Base64 from '@frsource/base64';
4-
import type { CompareImagesTaskReturn } from './types';
4+
import type { CompareImagesTaskReturn, PendingDiffRecord } from './types';
55

66
declare global {
77
// eslint-disable-next-line @typescript-eslint/no-namespace
@@ -195,6 +195,9 @@ Cypress.Commands.add(
195195
throw constructCypressError(log, new Error('Unexpected error!'));
196196
}
197197

198+
const imgOldPath =
199+
matchAgainstPath || imgPath.replace(FILE_SUFFIX.actual, '');
200+
198201
log.set(
199202
'message',
200203
`${res.message}${
@@ -204,9 +207,7 @@ Cypress.Commands.add(
204207
JSON.stringify({
205208
title,
206209
imgPath,
207-
imgOldPath:
208-
matchAgainstPath ||
209-
imgPath.replace(FILE_SUFFIX.actual, ''),
210+
imgOldPath,
210211
imgDiffBase64: res.imgDiffBase64,
211212
imgNewBase64: res.imgNewBase64,
212213
imgOldBase64: res.imgOldBase64,
@@ -218,12 +219,7 @@ Cypress.Commands.add(
218219
}`,
219220
);
220221

221-
if (res.error) {
222-
log.set('consoleProps', () => res);
223-
throw constructCypressError(log, new Error(res.message));
224-
}
225-
226-
return {
222+
const matchImageReturn = {
227223
diffValue: res.imgDiff,
228224
imgNewPath: imgPath,
229225
imgPath: imgPath.replace(FILE_SUFFIX.actual, ''),
@@ -244,6 +240,50 @@ Cypress.Commands.add(
244240
? Cypress.Buffer.from(res.imgDiffBase64, 'base64')
245241
: undefined,
246242
};
243+
244+
if (res.error) {
245+
log.set('consoleProps', () => res);
246+
247+
if (Cypress.env('pluginVisualRegressionDeferFailures')) {
248+
const record: PendingDiffRecord = {
249+
title,
250+
imgPath,
251+
imgOldPath,
252+
imgNewBase64: res.imgNewBase64 ?? '',
253+
imgOldBase64: res.imgOldBase64 ?? '',
254+
imgDiffBase64: res.imgDiffBase64 ?? '',
255+
message: res.message ?? '',
256+
};
257+
return (
258+
cy
259+
.task<number>(TASK.recordPendingDiff, record, { log: false })
260+
.then((count) => {
261+
/* c8 ignore start */
262+
if (top) {
263+
const badge = top.document.querySelector(
264+
`.${FAB_BADGE_CLASS}`,
265+
);
266+
if (badge) {
267+
badge.textContent = String(count);
268+
badge.removeAttribute('hidden');
269+
badge.classList.add(`${FAB_BADGE_CLASS}--pulse`);
270+
setTimeout(
271+
() =>
272+
badge.classList.remove(`${FAB_BADGE_CLASS}--pulse`),
273+
700,
274+
);
275+
}
276+
}
277+
/* c8 ignore stop */
278+
return matchImageReturn;
279+
}) as unknown as Cypress.MatchImageReturn
280+
);
281+
}
282+
283+
throw constructCypressError(log, new Error(res.message));
284+
}
285+
286+
return matchImageReturn;
247287
});
248288
},
249289
);

packages/cypress-plugin-visual-regression-diff/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const PLUGIN_NAME = 'cp-visual-regression-diff';
22
export const LINK_PREFIX = `#${PLUGIN_NAME}-`;
33
export const OVERLAY_CLASS = `${PLUGIN_NAME}-overlay`;
4+
export const FAB_CLASS = `${PLUGIN_NAME}-fab`;
5+
export const FAB_BADGE_CLASS = `${PLUGIN_NAME}-fab-badge`;
46
export const IMAGE_SNAPSHOT_PREFIX = `__${PLUGIN_NAME}_snapshots__`;
57

68
export enum FILE_SUFFIX {
@@ -15,6 +17,9 @@ export const TASK = {
1517
cleanupImages: `${PLUGIN_NAME}-cleanupImages`,
1618
doesFileExist: `${PLUGIN_NAME}-doesFileExist`,
1719
processImgPath: `${PLUGIN_NAME}-processImgPath`,
20+
recordPendingDiff: `${PLUGIN_NAME}-recordPendingDiff`,
21+
getPendingDiffs: `${PLUGIN_NAME}-getPendingDiffs`,
22+
clearPendingDiffs: `${PLUGIN_NAME}-clearPendingDiffs`,
1823
/* c8 ignore next */
1924
};
2025

packages/cypress-plugin-visual-regression-diff/src/support.ts

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import * as Base64 from '@frsource/base64';
22
import './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 */
614
function queueClear() {
@@ -17,6 +25,7 @@ function queueRun() {
1725
(cy as unknown as { queue: { run: () => void } }).queue.run();
1826
}
1927
/* c8 ignore stop */
28+
2029
export 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 */
73269
before(() => {
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

78295
after(() => {
@@ -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

Comments
 (0)