snap_diff-capybara 0.0.1 → 2.0.0.alpha1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/LICENSE.txt +21 -0
  5. data/Rakefile +65 -0
  6. data/capybara-screenshot-diff.gemspec +29 -0
  7. data/docs/RELEASE_PREP.md +44 -0
  8. data/docs/UPGRADING.md +475 -0
  9. data/docs/architecture.md +267 -0
  10. data/docs/ci-integration.md +238 -0
  11. data/docs/configuration.md +395 -0
  12. data/docs/docker-testing.md +24 -0
  13. data/docs/drivers.md +102 -0
  14. data/docs/framework-setup.md +87 -0
  15. data/docs/images/snap_diff_annotated.png +0 -0
  16. data/docs/images/snap_diff_web_ui.png +0 -0
  17. data/docs/migration-guide.md +286 -0
  18. data/docs/organization.md +204 -0
  19. data/docs/reporters.md +46 -0
  20. data/docs/thread_safety.md +112 -0
  21. data/gems.rb +39 -0
  22. data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
  23. data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
  24. data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
  25. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  26. data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
  27. data/lib/capybara/screenshot/diff/difference.rb +8 -0
  28. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
  29. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
  30. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
  31. data/lib/capybara/screenshot/diff/drivers.rb +9 -0
  32. data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
  33. data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
  34. data/lib/capybara/screenshot/diff/os.rb +14 -0
  35. data/lib/capybara/screenshot/diff/region.rb +107 -0
  36. data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
  37. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
  38. data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
  39. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
  40. data/lib/capybara/screenshot/diff/utils.rb +7 -0
  41. data/lib/capybara/screenshot/diff/vcs.rb +7 -0
  42. data/lib/capybara/screenshot/diff/version.rb +15 -0
  43. data/lib/capybara/screenshot/diff.rb +3 -0
  44. data/lib/capybara-screenshot-diff.rb +3 -0
  45. data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
  46. data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
  47. data/lib/capybara_screenshot_diff/dsl.rb +12 -0
  48. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
  49. data/lib/capybara_screenshot_diff/minitest.rb +14 -0
  50. data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
  51. data/lib/capybara_screenshot_diff/rspec.rb +8 -0
  52. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
  53. data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
  54. data/lib/capybara_screenshot_diff/snap.rb +7 -0
  55. data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
  56. data/lib/capybara_screenshot_diff/static.rb +9 -0
  57. data/lib/capybara_screenshot_diff.rb +50 -0
  58. data/lib/snap_diff/annotation_service.rb +84 -0
  59. data/lib/snap_diff/area_calculator.rb +56 -0
  60. data/lib/snap_diff/attempts_reporter.rb +51 -0
  61. data/lib/snap_diff/browser_helpers.rb +121 -0
  62. data/lib/snap_diff/capture/viewport.rb +40 -0
  63. data/lib/snap_diff/comparison.rb +238 -0
  64. data/lib/snap_diff/comparison_result.rb +104 -0
  65. data/lib/snap_diff/config.rb +78 -0
  66. data/lib/snap_diff/deprecation.rb +84 -0
  67. data/lib/snap_diff/driver.rb +37 -0
  68. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  69. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  70. data/lib/snap_diff/drivers.rb +14 -0
  71. data/lib/snap_diff/dsl.rb +143 -0
  72. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  73. data/lib/snap_diff/image_preprocessor.rb +68 -0
  74. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  75. data/lib/snap_diff/integrations/minitest.rb +70 -0
  76. data/lib/snap_diff/integrations/rspec.rb +68 -0
  77. data/lib/snap_diff/legacy_shims.rb +99 -0
  78. data/lib/snap_diff/os.rb +17 -0
  79. data/lib/snap_diff/reporters/html.rb +143 -0
  80. data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
  81. data/lib/snap_diff/reporting.rb +53 -0
  82. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  83. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  84. data/lib/snap_diff/screenshot_namer.rb +81 -0
  85. data/lib/snap_diff/screenshoter.rb +129 -0
  86. data/lib/snap_diff/snap.rb +66 -0
  87. data/lib/snap_diff/snap_manager.rb +125 -0
  88. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  89. data/lib/snap_diff/static.rb +11 -0
  90. data/lib/snap_diff/utils.rb +38 -0
  91. data/lib/snap_diff/vcs.rb +35 -0
  92. data/lib/snap_diff/version.rb +5 -0
  93. data/lib/snap_diff.rb +65 -0
  94. metadata +160 -10
  95. data/README.md +0 -3
@@ -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>
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Process-global reporter lifecycle: registration, per-test notification,
5
+ # end-of-suite finalization. One list of reporters for the whole process,
6
+ # guarded by one mutex.
7
+ #
8
+ # Deliberately separate from the per-test session lifecycle (the
9
+ # thread-local AssertionRegistry reached through
10
+ # CapybaraScreenshotDiff.registry): reporters outlive any single test,
11
+ # the registry does not. CapybaraScreenshotDiff keeps its public
12
+ # reporters/reporters_mutex/finalize_reporters! methods as thin shims
13
+ # over this module.
14
+ module Reporting
15
+ @reporters = []
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ attr_reader :reporters, :mutex
20
+
21
+ # Delivers a finished test's assertions to every registered reporter.
22
+ # Iterates over a snapshot so a reporter mutating the list mid-notify
23
+ # cannot affect the current round. A raising reporter is warned about
24
+ # and skipped; the rest are still notified.
25
+ def notify(assertions)
26
+ return if assertions.nil? || assertions.empty?
27
+
28
+ reporters_snapshot = @mutex.synchronize { @reporters.dup }
29
+ return if reporters_snapshot.empty?
30
+
31
+ reporters_snapshot.each do |reporter|
32
+ reporter.record(assertions)
33
+ rescue => e
34
+ warn "[capybara-screenshot-diff] Reporter failed: #{e.message}"
35
+ end
36
+ end
37
+
38
+ # End-of-suite hook: finalizes each reporter and prints its summary.
39
+ # A raising reporter is warned about and skipped; the rest are still
40
+ # finalized.
41
+ def finalize!
42
+ @mutex.synchronize { @reporters.dup }.each do |reporter|
43
+ reporter.finalize
44
+ if (msg = reporter.summary)
45
+ $stdout.puts msg
46
+ end
47
+ rescue => e
48
+ warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "screenshot_namer"
5
+
6
+ module SnapDiff
7
+ class ScreenshotAssertion
8
+ attr_reader :name, :args
9
+ attr_accessor :compare, :caller
10
+
11
+ def initialize(name, **args)
12
+ @name = name
13
+ @args = args
14
+ end
15
+
16
+ # One-line debugging summary. Never triggers the (expensive, file-touching)
17
+ # comparison itself: an unprocessed comparison shows as "pending".
18
+ def inspect
19
+ return "#<#{self.class.name} #{name.inspect} (no comparison)>" unless compare
20
+
21
+ state = if compare.processed?
22
+ compare.difference.different? ? "different" : "matches"
23
+ else
24
+ "pending"
25
+ end
26
+ "#<#{self.class.name} #{name.inspect} #{state} new=#{compare.image_path} base=#{compare.base_image_path}>"
27
+ end
28
+
29
+ def validate
30
+ return unless compare
31
+
32
+ if compare.different?
33
+ "Screenshot does not match for '#{name}': #{compare.error_message}\n#{caller.join("\n")}"
34
+ else
35
+ archive_baseline!
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Commits the baseline after a passing comparison: the base image is
41
+ # moved over the actual image, so the captured screenshot becomes the
42
+ # recorded baseline again. This is the file-mutating half of the verify
43
+ # flow, kept explicit and separate from the pure "are they different?"
44
+ # question. Idempotent: a second call is a no-op.
45
+ def archive_baseline!
46
+ return unless compare && !compare.different? && compare.base_image_path.exist?
47
+
48
+ FileUtils.mv(compare.base_image_path, compare.image_path, force: true)
49
+ end
50
+
51
+ def validate!
52
+ error_msg = validate
53
+
54
+ if error_msg
55
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller)
56
+ end
57
+ end
58
+
59
+ # Verifies that all scheduled screenshots do not show any unintended differences.
60
+ #
61
+ # @param screenshots [Array(Array(Array(String), String, Comparison))] The list of match screenshots jobs. Defaults to all screenshots taken during the test.
62
+ # @return [Array, nil] Returns an array of error messages if there are screenshot differences, otherwise nil.
63
+ # @note This method is typically called at the end of a test to assert all screenshots are as expected.
64
+ def self.verify_screenshots!(screenshots)
65
+ return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
66
+
67
+ test_screenshot_errors = screenshots.map do |assertion|
68
+ assertion.validate
69
+ end
70
+
71
+ test_screenshot_errors.compact!
72
+
73
+ test_screenshot_errors.empty? ? nil : test_screenshot_errors
74
+ end
75
+
76
+ # Asserts that an image has not changed compared to its baseline.
77
+ #
78
+ # @param backtrace [Array(String)] The caller context, used for error reporting.
79
+ # @param name [String] The name of the screenshot being verified.
80
+ # @param comparison [Object] The comparison object containing the result and details of the comparison.
81
+ # @return [String, nil] Returns an error message if the screenshot differs from the baseline, otherwise nil.
82
+ # @note Legacy entry point; delegates to the instance verify flow
83
+ # (pure question + explicit #archive_baseline! on pass).
84
+ def self.assert_image_not_changed(backtrace, name, comparison)
85
+ assertion = new(name)
86
+ assertion.caller = backtrace
87
+ assertion.compare = comparison
88
+ assertion.validate
89
+ end
90
+ end
91
+
92
+ class AssertionRegistry
93
+ attr_reader :assertions, :screenshot_namer, :new_screenshots
94
+
95
+ def initialize
96
+ @assertions = []
97
+ @new_screenshots = []
98
+ @screenshot_namer = SnapDiff::ScreenshotNamer.new
99
+ end
100
+
101
+ def add_assertion(assertion)
102
+ return unless assertion&.compare
103
+
104
+ @assertions.push(assertion)
105
+
106
+ assertion
107
+ end
108
+
109
+ def assertions_present?
110
+ !@assertions.empty?
111
+ end
112
+
113
+ def record_new_screenshot(name)
114
+ @new_screenshots.push(name)
115
+ end
116
+
117
+ def new_screenshots_present?
118
+ !@new_screenshots.empty?
119
+ end
120
+
121
+ def verify(screenshots = CapybaraScreenshotDiff.assertions)
122
+ return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
123
+
124
+ failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions
125
+ failed_screenshot = failed_assertions.first
126
+ result = ScreenshotAssertion.verify_screenshots!(screenshots)
127
+
128
+ if result
129
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
130
+ end
131
+ end
132
+
133
+ def failed_assertions
134
+ assertions.filter { |screenshot_assert| screenshot_assert.compare&.different? }
135
+ end
136
+
137
+ def reset
138
+ @assertions.clear
139
+ @new_screenshots.clear
140
+ @screenshot_namer = SnapDiff::ScreenshotNamer.new
141
+ end
142
+ end
143
+ end