capybara-screenshot-diff 1.10.3.1 → 1.13.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/Rakefile +29 -1
  5. data/capybara-screenshot-diff.gemspec +4 -3
  6. data/docs/RELEASE_PREP.md +42 -0
  7. data/docs/UPGRADING.md +390 -0
  8. data/docs/architecture.md +270 -0
  9. data/docs/ci-integration.md +238 -0
  10. data/docs/configuration.md +395 -0
  11. data/docs/docker-testing.md +24 -0
  12. data/docs/drivers.md +102 -0
  13. data/docs/framework-setup.md +87 -0
  14. data/docs/images/snap_diff_annotated.png +0 -0
  15. data/docs/images/snap_diff_web_ui.png +0 -0
  16. data/docs/migration-guide.md +286 -0
  17. data/docs/organization.md +204 -0
  18. data/docs/reporters.md +46 -0
  19. data/docs/thread_safety.md +97 -0
  20. data/gems.rb +2 -1
  21. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  22. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  23. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  24. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  25. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  26. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  27. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  28. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  29. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  30. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  31. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  32. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
  33. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  34. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  35. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  36. data/lib/capybara/screenshot/diff/version.rb +1 -1
  37. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  38. data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
  39. data/lib/capybara_screenshot_diff/dsl.rb +123 -7
  40. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  41. data/lib/capybara_screenshot_diff/minitest.rb +10 -5
  42. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  43. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  44. data/lib/capybara_screenshot_diff/rspec.rb +20 -4
  45. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
  46. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  47. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  48. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  49. data/lib/capybara_screenshot_diff/static.rb +11 -0
  50. data/lib/capybara_screenshot_diff.rb +31 -5
  51. metadata +51 -8
  52. data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
@@ -0,0 +1,463 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>SnapDiff Report — <%= failed %> failures</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ :root {
10
+ --bg: #0f111a;
11
+ --bg2: #181a27;
12
+ --bg3: #1e2035;
13
+ --bg4: #282a40;
14
+ --border: #2d2f45;
15
+ --text: #e8eaed;
16
+ --text2: #9aa0b4;
17
+ --text3: #8b90a8;
18
+ --accent: #7c6cf0;
19
+ --accent-dim: #5b4dc7;
20
+ --accent-bg: rgba(124,108,240,.12);
21
+ --red: #f87171;
22
+ --red-dim: rgba(248,113,113,.15);
23
+ --green: #4ade80;
24
+ --green-dim: rgba(74,222,128,.15);
25
+ --orange: #fb923c;
26
+ --surface: #13151f;
27
+ --radius: 8px;
28
+ --radius-sm: 5px;
29
+ --sidebar-w: 260px;
30
+ --bar-h: 44px;
31
+ --zoom-h: 36px;
32
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
33
+ --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
34
+ }
35
+ body { font-family: var(--font); background: var(--bg); color: var(--text); height: 100dvh; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: antialiased; }
36
+
37
+ /* ── Top bar ─────────────────────────────────────────────────────── */
38
+ #topbar { height: var(--bar-h); background: var(--bg2); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 .75rem; flex-shrink: 0; gap: .5rem; z-index: 20; }
39
+ .logo { display: flex; align-items: center; gap: .4rem; font-size: .75rem; font-weight: 700; color: var(--text); white-space: nowrap; }
40
+ .logo svg { flex-shrink: 0; }
41
+ .sep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
42
+ .nav-group { display: flex; align-items: center; gap: .2rem; }
43
+ .nav-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; transition: all .12s; }
44
+ .nav-btn:hover { background: var(--bg4); color: var(--text); }
45
+ .nav-counter { font-size: .6875rem; font-family: var(--font-mono); color: var(--text2); padding: 0 .25rem; }
46
+ #topbar-title { font-size: .75rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
47
+ .diff-badge { font-size: .625rem; font-family: var(--font-mono); font-weight: 600; padding: .1rem .375rem; border-radius: 10px; white-space: nowrap; flex-shrink: 0; }
48
+ .diff-badge-fail { background: var(--red-dim); color: var(--red); }
49
+ .diff-badge-pass { background: var(--green-dim); color: var(--green); }
50
+ .spacer { flex: 1; }
51
+ .stats { display: flex; gap: .3rem; align-items: center; flex-shrink: 0; }
52
+ .stat-pill { font-size: .625rem; font-family: var(--font-mono); font-weight: 500; padding: .125rem .4rem; border-radius: 10px; display: flex; align-items: center; gap: .2rem; }
53
+ .stat-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
54
+ .stat-fail { background: var(--red-dim); color: var(--red); }
55
+ .stat-fail .dot { background: var(--red); }
56
+ .stat-pass { background: var(--green-dim); color: var(--green); }
57
+ .stat-pass .dot { background: var(--green); }
58
+ .stat-total { background: var(--bg4); color: var(--text2); }
59
+ .stat-total .dot { background: var(--text3); }
60
+
61
+ /* ── Layout ──────────────────────────────────────────────────────── */
62
+ #layout { display: flex; flex: 1; overflow: hidden; }
63
+
64
+ /* ── Sidebar ─────────────────────────────────────────────────────── */
65
+ #sidebar { width: var(--sidebar-w); flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
66
+ #search-wrap { padding: .4rem .5rem; position: relative; }
67
+ .search-icon { position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text3); pointer-events: none; }
68
+ #search { width: 100%; padding: .375rem .5rem .375rem 2.125rem; font-size: .6875rem; font-family: var(--font); background: var(--bg3); color: var(--text); border: 1px solid transparent; border-radius: var(--radius); outline: none; transition: border-color .15s; }
69
+ #search:focus { border-color: var(--accent); background: var(--bg); }
70
+ #search::placeholder { color: var(--text3); }
71
+ #sidebar-list { flex: 1; overflow-y: auto; padding: .375rem; display: flex; flex-direction: column; gap: .375rem; }
72
+ .thumb-btn { width: 100%; text-align: left; background: transparent; border: 2px solid transparent; border-radius: var(--radius); padding: .3rem; cursor: pointer; transition: border-color .1s, background .1s; }
73
+ .thumb-btn:hover { background: var(--bg3); border-color: var(--border); }
74
+ .thumb-btn.active { border-color: var(--accent); background: var(--accent-bg); }
75
+ .thumb-btn.active .thumb-name { color: var(--text); }
76
+ .thumb-img-wrap { aspect-ratio: 16/10; background: var(--surface); border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
77
+ .thumb-img-wrap img { width: 100%; height: 100%; object-fit: contain; display: block; }
78
+ .thumb-footer { display: flex; align-items: center; justify-content: space-between; margin-top: .25rem; gap: .2rem; }
79
+ .thumb-name { font-size: .625rem; font-weight: 500; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
80
+ .thumb-badge { font-size: .5rem; font-family: var(--font-mono); font-weight: 600; padding: .0625rem .25rem; border-radius: 8px; flex-shrink: 0; }
81
+ .thumb-badge-fail { background: var(--red-dim); color: var(--red); }
82
+ .thumb-badge-pass { background: var(--green-dim); color: var(--green); }
83
+
84
+ /* ── Main ────────────────────────────────────────────────────────── */
85
+ #main { flex: 1; overflow: hidden; display: flex; flex-direction: column; background: var(--bg); }
86
+
87
+ /* ── Toolbar ─────────────────────────────────────────────────────── */
88
+ #toolbar { display: flex; align-items: center; padding: .375rem .75rem; gap: .5rem; background: var(--bg2); flex-shrink: 0; }
89
+ .toolbar-label { font-size: .625rem; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: var(--text3); white-space: nowrap; }
90
+ .btn-group { display: flex; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
91
+ .tbtn { padding: .3rem .5rem; font-size: .625rem; font-family: var(--font); font-weight: 500; background: transparent; color: var(--text3); border: none; border-right: 1px solid var(--border); cursor: pointer; transition: all .1s; white-space: nowrap; display: flex; align-items: center; gap: .25rem; }
92
+ .tbtn:last-child { border-right: none; }
93
+ .tbtn:hover { background: var(--bg3); color: var(--text2); }
94
+ .tbtn.active { background: var(--accent); color: #fff; }
95
+ .tbtn svg { width: 13px; height: 13px; opacity: .8; }
96
+ .tbtn.active svg { opacity: 1; }
97
+ /* Annotation toggle */
98
+ .toggle-btn { padding: .3rem .625rem; font-size: .625rem; font-family: var(--font); font-weight: 500; background: var(--bg); color: var(--text3); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: all .1s; display: flex; align-items: center; gap: .3rem; white-space: nowrap; }
99
+ .toggle-btn:hover { color: var(--text2); border-color: var(--text3); }
100
+ .toggle-btn.active { background: var(--red-dim); color: var(--red); border-color: var(--red); }
101
+ .toggle-pip { width: 7px; height: 7px; border-radius: 50%; background: var(--text3); transition: background .1s; }
102
+ .toggle-btn.active .toggle-pip { background: var(--red); }
103
+
104
+ /* ── View area ───────────────────────────────────────────────────── */
105
+ #view-area { flex: 1; overflow: hidden; padding: .5rem; }
106
+ #view-inner { width: 100%; height: 100%; }
107
+
108
+ /* Both (side-by-side) */
109
+ .view-both { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; width: 100%; height: 100%; }
110
+ .img-panel { display: flex; flex-direction: column; min-height: 0; }
111
+ .img-label { font-size: .5625rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text3); margin-bottom: .25rem; display: flex; align-items: center; gap: .25rem; }
112
+ .dot-base { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
113
+ .dot-new { width: 5px; height: 5px; border-radius: 50%; background: var(--orange); }
114
+ .dot-heat { width: 5px; height: 5px; border-radius: 50%; background: #f59e0b; }
115
+ /* img-box: scrollable container for per-image zoom+pan */
116
+ .img-box { background: var(--surface); border-radius: var(--radius); overflow: hidden; flex: 1; position: relative; }
117
+ .img-box.pannable { overflow: auto; cursor: move; }
118
+ .img-box.dragging { cursor: move; }
119
+ .img-box img { display: block; object-fit: contain; transform-origin: 0 0; }
120
+ /* At 100% zoom, image fills the box */
121
+ .img-box[data-zoom="100"] img { width: 100%; height: 100%; }
122
+
123
+ /* Single image */
124
+ .view-single { width: 100%; height: 100%; display: flex; flex-direction: column; }
125
+ .view-single .img-box { flex: 1; }
126
+
127
+ /* ── Zoom bar ────────────────────────────────────────────────────── */
128
+ #zoom-bar { height: var(--zoom-h); background: var(--bg2); border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: center; gap: .5rem; flex-shrink: 0; padding: 0 .75rem; }
129
+ .zoom-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); cursor: pointer; transition: all .1s; }
130
+ .zoom-btn:hover { background: var(--bg4); color: var(--text); }
131
+ .zoom-label { font-size: .625rem; font-family: var(--font-mono); color: var(--text2); min-width: 3rem; text-align: center; }
132
+ .zoom-fit { font-size: .625rem; font-family: var(--font); font-weight: 500; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); padding: .2rem .5rem; cursor: pointer; transition: all .1s; }
133
+ .zoom-fit:hover { background: var(--bg4); color: var(--text); }
134
+
135
+ /* Scrollbar */
136
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
137
+ ::-webkit-scrollbar-track { background: transparent; }
138
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
139
+
140
+ @media (max-width: 768px) {
141
+ #layout { flex-direction: column; }
142
+ #sidebar { width: 100%; max-height: 110px; flex-direction: row; }
143
+ #search-wrap { display: none; }
144
+ #sidebar-list { flex-direction: row; overflow-x: auto; overflow-y: hidden; }
145
+ .thumb-btn { width: auto; min-width: 120px; flex-shrink: 0; }
146
+ .view-both { grid-template-columns: 1fr; }
147
+ }
148
+ </style>
149
+ </head>
150
+ <body>
151
+
152
+ <header id="topbar">
153
+ <div class="logo">
154
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M3 12h18"/></svg>
155
+ SnapDiff
156
+ </div>
157
+ <div class="sep"></div>
158
+ <div class="nav-group">
159
+ <button class="nav-btn" id="nav-prev" aria-label="Previous"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg></button>
160
+ <span class="nav-counter" id="nav-counter">1/<%= failed %></span>
161
+ <button class="nav-btn" id="nav-next" aria-label="Next"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg></button>
162
+ </div>
163
+ <span id="topbar-title"></span>
164
+ <span id="topbar-badge" class="diff-badge diff-badge-fail"></span>
165
+ <div class="spacer"></div>
166
+ <div class="stats">
167
+ <span class="stat-pill stat-fail"><span class="dot"></span><%= failed %> failed</span>
168
+ <span class="stat-pill stat-pass"><span class="dot"></span><%= passed %> passed</span>
169
+ <span class="stat-pill stat-total"><span class="dot"></span><%= total %> total</span>
170
+ </div>
171
+ </header>
172
+
173
+ <div id="layout">
174
+ <aside id="sidebar">
175
+ <div id="search-wrap">
176
+ <svg class="search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
177
+ <input id="search" type="search" placeholder="Filter…" autocomplete="off"/>
178
+ </div>
179
+ <nav id="sidebar-list"></nav>
180
+ </aside>
181
+
182
+ <main id="main">
183
+ <div id="toolbar">
184
+ <span class="toolbar-label">View</span>
185
+ <div class="btn-group" id="view-btns">
186
+ <button class="tbtn active" data-view="both" title="Both (1)">
187
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1" y="1" width="5" height="12" rx="1"/><rect x="8" y="1" width="5" height="12" rx="1"/></svg>
188
+ </button>
189
+ <button class="tbtn" data-view="base" title="Base only (2)">
190
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2" width="10" height="10" rx="1"/><path d="M5 7h4" stroke-width="1.5"/></svg>
191
+ </button>
192
+ <button class="tbtn" data-view="new" title="New only (3)">
193
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="2" y="2" width="10" height="10" rx="1"/><path d="M5 7h4M7 5v4" stroke-width="1.5"/></svg>
194
+ </button>
195
+ <button class="tbtn" data-view="heatmap" title="Heatmap (4)">
196
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1" y="1" width="12" height="12" rx="1"/><circle cx="7" cy="7" r="3" fill="currentColor" opacity=".3"/><circle cx="7" cy="7" r="1.5" fill="currentColor" opacity=".6"/></svg>
197
+ </button>
198
+ </div>
199
+ <div class="sep"></div>
200
+ <button class="toggle-btn" id="annotate-toggle" title="Show annotated versions (A)">
201
+ <span class="toggle-pip"></span> Annotated
202
+ </button>
203
+ </div>
204
+
205
+ <div id="view-area">
206
+ <div id="view-inner"></div>
207
+ </div>
208
+
209
+ <div id="zoom-bar">
210
+ <button class="zoom-btn" id="zoom-out" title="Zoom out (-)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M8 11h6"/></svg></button>
211
+ <span class="zoom-label" id="zoom-label">100%</span>
212
+ <button class="zoom-btn" id="zoom-in" title="Zoom in (+)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M11 8v6M8 11h6"/></svg></button>
213
+ <button class="zoom-fit" id="zoom-fit">Fit to width</button>
214
+ </div>
215
+ </main>
216
+ </div>
217
+
218
+ <script>
219
+ (function() {
220
+ 'use strict';
221
+ var DATA = <%= failures.to_json.gsub("</", "<\\/") %>;
222
+ var state = { current: 0, view: 'both', annotated: false, zoom: 100, search: '' };
223
+ var $ = function(id) { return document.getElementById(id); };
224
+
225
+ var searchEl = $('search'), sidebarList = $('sidebar-list');
226
+ var topTitle = $('topbar-title'), topBadge = $('topbar-badge'), navCounter = $('nav-counter');
227
+ var viewInner = $('view-inner'), viewArea = $('view-area');
228
+ var viewBtns = document.querySelectorAll('#view-btns .tbtn');
229
+ var annotateBtn = $('annotate-toggle');
230
+
231
+ /* ── Filtered list ────────────────────────────────────────────────── */
232
+ var filtered = [];
233
+ function rebuildFiltered() {
234
+ var q = state.search.toLowerCase();
235
+ filtered = [];
236
+ DATA.forEach(function(item, i) { if (!q || item.name.toLowerCase().indexOf(q) !== -1) filtered.push(i); });
237
+ if (filtered.length && filtered.indexOf(state.current) === -1) state.current = filtered[0];
238
+ }
239
+
240
+ /* ── Sidebar ──────────────────────────────────────────────────────── */
241
+ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
242
+
243
+ function buildSidebar() {
244
+ sidebarList.innerHTML = '';
245
+ filtered.forEach(function(idx) {
246
+ var item = DATA[idx];
247
+ var btn = document.createElement('button');
248
+ btn.className = 'thumb-btn' + (idx === state.current ? ' active' : '');
249
+ btn.title = item.name;
250
+ btn.dataset.idx = idx;
251
+ /* Thumbnail: annotated mode → heatmap; normal → new image (no annotation) */
252
+ var thumb = state.annotated ? (item.heatmap || item.new || item.original || '') : (item.new || item.original || '');
253
+ var hasDiff = item.diff || item.heatmap;
254
+ var badgeText = hasDiff
255
+ ? (item.diff_level != null ? item.diff_level + '%' : 'changed')
256
+ : 'pass';
257
+ btn.innerHTML =
258
+ '<div class="thumb-img-wrap"><img src="' + esc(thumb) + '" alt="" loading="lazy"/></div>' +
259
+ '<div class="thumb-footer">' +
260
+ '<div class="thumb-name">' + esc(item.name) + '</div>' +
261
+ '<span class="thumb-badge ' + (hasDiff ? 'thumb-badge-fail' : 'thumb-badge-pass') + '">' + badgeText + '</span>' +
262
+ '</div>';
263
+ btn.addEventListener('click', function() { selectItem(+this.dataset.idx); });
264
+ sidebarList.appendChild(btn);
265
+ });
266
+ }
267
+
268
+ /* ── Selection ────────────────────────────────────────────────────── */
269
+ function selectItem(idx) {
270
+ if (!filtered.length) return;
271
+ if (filtered.indexOf(idx) === -1) idx = filtered[Math.max(0, Math.min(idx, filtered.length - 1))];
272
+ state.current = idx;
273
+ render();
274
+ buildSidebar();
275
+ var a = sidebarList.querySelector('.thumb-btn.active');
276
+ if (a) a.scrollIntoView({ block: 'nearest' });
277
+ }
278
+ function selectNext() { var p = filtered.indexOf(state.current); if (p < filtered.length - 1) selectItem(filtered[p + 1]); }
279
+ function selectPrev() { var p = filtered.indexOf(state.current); if (p > 0) selectItem(filtered[p - 1]); }
280
+
281
+ /* ── Render ───────────────────────────────────────────────────────── */
282
+ function render() {
283
+ var item = DATA[state.current];
284
+ if (!item) return;
285
+
286
+ topTitle.textContent = item.name;
287
+ var pos = filtered.indexOf(state.current);
288
+ navCounter.textContent = (pos + 1) + '/' + filtered.length;
289
+ var hasDiff = item.diff || item.heatmap;
290
+ if (hasDiff && item.diff_level != null) {
291
+ topBadge.textContent = item.diff_level + '% diff';
292
+ } else {
293
+ topBadge.textContent = hasDiff ? 'changed' : 'pass';
294
+ }
295
+ topBadge.className = 'diff-badge ' + (hasDiff ? 'diff-badge-fail' : 'diff-badge-pass');
296
+
297
+ /* Resolve image sources based on view + annotated toggle */
298
+ var ann = state.annotated;
299
+ var baseImg = ann ? (item.base_diff || item.original || '') : (item.original || '');
300
+ var newImg = ann ? (item.diff || item.new || '') : (item.new || '');
301
+ var heatImg = item.heatmap || '';
302
+
303
+ var baseLbl = ann ? 'Annotated Base' : 'Baseline';
304
+ var newLbl = ann ? 'Annotated New' : 'Current';
305
+
306
+ var html = '';
307
+ switch (state.view) {
308
+ case 'both':
309
+ html = '<div class="view-both">' +
310
+ '<div class="img-panel"><div class="img-label"><span class="dot-base"></span> ' + baseLbl + '</div><div class="img-box"><img src="' + esc(baseImg) + '" alt="Base"/></div></div>' +
311
+ '<div class="img-panel"><div class="img-label"><span class="dot-new"></span> ' + newLbl + '</div><div class="img-box"><img src="' + esc(newImg) + '" alt="New"/></div></div>' +
312
+ '</div>';
313
+ break;
314
+ case 'base':
315
+ html = '<div class="view-single"><div class="img-label"><span class="dot-base"></span> ' + baseLbl + '</div><div class="img-box"><img src="' + esc(baseImg) + '" alt="Base"/></div></div>';
316
+ break;
317
+ case 'new':
318
+ html = '<div class="view-single"><div class="img-label"><span class="dot-new"></span> ' + newLbl + '</div><div class="img-box"><img src="' + esc(newImg) + '" alt="New"/></div></div>';
319
+ break;
320
+ case 'heatmap':
321
+ html = '<div class="view-single"><div class="img-label"><span class="dot-heat"></span> Heatmap</div><div class="img-box"><img src="' + esc(heatImg) + '" alt="Heatmap"/></div></div>';
322
+ break;
323
+ }
324
+ viewInner.innerHTML = html;
325
+ applyZoom();
326
+ initPan();
327
+
328
+ /* Update button states */
329
+ viewBtns.forEach(function(b) { b.classList.toggle('active', b.dataset.view === state.view); });
330
+ annotateBtn.classList.toggle('active', state.annotated);
331
+ }
332
+
333
+ /* ── View switching ───────────────────────────────────────────────── */
334
+ viewBtns.forEach(function(btn) {
335
+ btn.addEventListener('click', function() { state.view = this.dataset.view; render(); buildSidebar(); });
336
+ });
337
+ function toggleAnnotated() { state.annotated = !state.annotated; render(); buildSidebar(); }
338
+ annotateBtn.addEventListener('click', toggleAnnotated);
339
+
340
+ /* Click on image toggles annotated mode (ignore drags) */
341
+ var clickStart = null;
342
+ viewArea.addEventListener('mousedown', function(e) { clickStart = { x: e.clientX, y: e.clientY }; });
343
+ viewArea.addEventListener('click', function(e) {
344
+ if (!clickStart) return;
345
+ var dx = Math.abs(e.clientX - clickStart.x), dy = Math.abs(e.clientY - clickStart.y);
346
+ clickStart = null;
347
+ if (dx > 3 || dy > 3) return; /* was a drag, not a click */
348
+ if (e.target.closest('.img-box')) toggleAnnotated();
349
+ });
350
+
351
+ /* ── Per-image zoom ────────────────────────────────────────────────── */
352
+ function applyZoom() {
353
+ var boxes = viewInner.querySelectorAll('.img-box');
354
+ var s = state.zoom / 100;
355
+ boxes.forEach(function(box) {
356
+ var img = box.querySelector('img');
357
+ if (!img) return;
358
+ if (state.zoom === 100) {
359
+ img.style.width = '100%';
360
+ img.style.height = '100%';
361
+ img.style.objectFit = 'contain';
362
+ box.classList.remove('pannable');
363
+ } else {
364
+ img.style.width = (s * 100) + '%';
365
+ img.style.height = 'auto';
366
+ img.style.objectFit = '';
367
+ box.classList.add('pannable');
368
+ }
369
+ });
370
+ $('zoom-label').textContent = state.zoom + '%';
371
+ }
372
+
373
+ /* ── Synchronized drag-to-pan across all img-boxes ───────────────── */
374
+ var syncing = false;
375
+ var dragBox = null, dragStartX, dragStartY, dragScrollL, dragScrollT;
376
+
377
+ function syncScroll(source) {
378
+ if (syncing) return;
379
+ syncing = true;
380
+ viewInner.querySelectorAll('.img-box').forEach(function(box) {
381
+ if (box !== source) { box.scrollLeft = source.scrollLeft; box.scrollTop = source.scrollTop; }
382
+ });
383
+ syncing = false;
384
+ }
385
+
386
+ /* Document-level listeners registered once (avoid accumulation on re-render) */
387
+ document.addEventListener('mousemove', function(e) {
388
+ if (!dragBox) return;
389
+ dragBox.scrollLeft = dragScrollL - (e.clientX - dragStartX);
390
+ dragBox.scrollTop = dragScrollT - (e.clientY - dragStartY);
391
+ });
392
+ document.addEventListener('mouseup', function() {
393
+ if (dragBox) { dragBox.classList.remove('dragging'); dragBox = null; }
394
+ });
395
+
396
+ function initPan() {
397
+ viewInner.querySelectorAll('.img-box').forEach(function(box) {
398
+ box.addEventListener('scroll', function() { syncScroll(box); });
399
+ box.addEventListener('mousedown', function(e) {
400
+ if (state.zoom <= 100) return;
401
+ dragBox = box; box.classList.add('dragging');
402
+ dragStartX = e.clientX; dragStartY = e.clientY;
403
+ dragScrollL = box.scrollLeft; dragScrollT = box.scrollTop;
404
+ e.preventDefault();
405
+ });
406
+ });
407
+ }
408
+
409
+ function zoomTo(v) { state.zoom = Math.max(25, Math.min(500, v)); applyZoom(); }
410
+
411
+ $('zoom-in').addEventListener('click', function() { zoomTo(state.zoom + 50); });
412
+ $('zoom-out').addEventListener('click', function() { zoomTo(state.zoom - 50); });
413
+ $('zoom-fit').addEventListener('click', function() { zoomTo(100); });
414
+
415
+ /* Ctrl/Cmd/Alt + scroll wheel to zoom (smooth, small steps) */
416
+ viewArea.addEventListener('wheel', function(e) {
417
+ if (!(e.ctrlKey || e.metaKey || e.altKey)) return;
418
+ e.preventDefault();
419
+ /* Use 5% steps for smooth zoom; clamp deltaY to avoid trackpad acceleration */
420
+ var step = 3;
421
+ var direction = e.deltaY < 0 ? 1 : -1;
422
+ zoomTo(state.zoom + direction * step);
423
+ }, { passive: false });
424
+
425
+ /* ── Nav ───────────────────────────────────────────────────────────── */
426
+ $('nav-prev').addEventListener('click', selectPrev);
427
+ $('nav-next').addEventListener('click', selectNext);
428
+
429
+ /* ── Search ────────────────────────────────────────────────────────── */
430
+ searchEl.addEventListener('input', function() { state.search = this.value; rebuildFiltered(); buildSidebar(); if (filtered.length) render(); });
431
+
432
+ /* ── Keyboard ──────────────────────────────────────────────────────── */
433
+ document.addEventListener('keydown', function(e) {
434
+ if (e.target.tagName === 'INPUT') { if (e.key === 'Escape') searchEl.blur(); return; }
435
+ switch (e.key) {
436
+ case 'ArrowRight': case 'ArrowDown': e.preventDefault(); selectNext(); break;
437
+ case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); selectPrev(); break;
438
+ case '1': state.view = 'both'; render(); buildSidebar(); break;
439
+ case '2': state.view = 'base'; render(); buildSidebar(); break;
440
+ case '3': state.view = 'new'; render(); buildSidebar(); break;
441
+ case '4': state.view = 'heatmap'; render(); buildSidebar(); break;
442
+ case 'a': case 'A': state.annotated = !state.annotated; render(); buildSidebar(); break;
443
+ case '/': e.preventDefault(); searchEl.focus(); break;
444
+ case '+': case '=': zoomTo(state.zoom + 25); break;
445
+ case '-': zoomTo(state.zoom - 25); break;
446
+ case '0': zoomTo(100); break;
447
+ }
448
+ });
449
+
450
+ /* ── Init ──────────────────────────────────────────────────────────── */
451
+ if (!DATA.length) {
452
+ sidebarList.innerHTML = '<div class="empty-state"><div class="empty-text">No failures</div></div>';
453
+ topTitle.textContent = 'All clear';
454
+ topBadge.style.display = 'none';
455
+ } else {
456
+ rebuildFiltered();
457
+ buildSidebar();
458
+ render();
459
+ }
460
+ }());
461
+ </script>
462
+ </body>
463
+ </html>
@@ -4,12 +4,20 @@ require "rspec/core"
4
4
  require "capybara_screenshot_diff/dsl"
5
5
 
6
6
  RSpec::Matchers.define :match_screenshot do |name, **options|
7
- description { "match a screenshot" }
7
+ description { "match screenshot '#{name}'" }
8
8
 
9
9
  match do |_page|
10
- screenshot(name, **options)
10
+ assert_matches_screenshot(name, **options)
11
11
  true
12
12
  end
13
+
14
+ failure_message do
15
+ "Expected page to match screenshot '#{name}'"
16
+ end
17
+
18
+ failure_message_when_negated do
19
+ "Expected page not to match screenshot '#{name}'"
20
+ end
13
21
  end
14
22
 
15
23
  RSpec.configure do |config|
@@ -22,15 +30,23 @@ RSpec.configure do |config|
22
30
  end
23
31
  end
24
32
 
25
- config.after do
33
+ config.after do |example|
26
34
  if self.class.include?(CapybaraScreenshotDiff::DSL)
27
35
  begin
28
36
  CapybaraScreenshotDiff.verify
37
+
38
+ # Never mask a real failure with a pending marker.
39
+ if example.exception.nil? && Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
40
+ names = CapybaraScreenshotDiff.new_screenshots
41
+ skip "No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them."
42
+ end
29
43
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
30
- raise RSpec::Expectations::ExpectationNotMetError, e.message
44
+ raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
31
45
  ensure
32
46
  CapybaraScreenshotDiff.reset
33
47
  end
34
48
  end
35
49
  end
50
+
51
+ config.after(:suite) { CapybaraScreenshotDiff.finalize_reporters! }
36
52
  end