capybara-screenshot-diff 1.10.3 → 1.12.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/Rakefile +29 -1
  4. data/capybara-screenshot-diff.gemspec +4 -3
  5. data/docs/RELEASE_PREP.md +58 -0
  6. data/docs/UPGRADING.md +390 -0
  7. data/docs/ci-integration.md +208 -0
  8. data/docs/configuration.md +379 -0
  9. data/docs/docker-testing.md +24 -0
  10. data/docs/drivers.md +102 -0
  11. data/docs/framework-setup.md +87 -0
  12. data/docs/images/snap_diff_web_ui.png +0 -0
  13. data/docs/organization.md +226 -0
  14. data/docs/reporters.md +46 -0
  15. data/docs/thread_safety.md +97 -0
  16. data/gems.rb +2 -1
  17. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  18. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  19. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  20. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  21. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  22. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  23. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  24. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  25. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  26. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  27. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  28. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +63 -36
  29. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  30. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  31. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  32. data/lib/capybara/screenshot/diff/version.rb +1 -1
  33. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  34. data/lib/capybara_screenshot_diff/cucumber.rb +2 -0
  35. data/lib/capybara_screenshot_diff/dsl.rb +102 -7
  36. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  37. data/lib/capybara_screenshot_diff/minitest.rb +4 -2
  38. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  39. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  40. data/lib/capybara_screenshot_diff/rspec.rb +12 -2
  41. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +61 -23
  42. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  43. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  44. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  45. data/lib/capybara_screenshot_diff/static.rb +11 -0
  46. data/lib/capybara_screenshot_diff.rb +30 -5
  47. metadata +47 -8
  48. data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "erb"
5
+ require "fileutils"
6
+ require "pathname"
7
+ require "json"
8
+
9
+ module CapybaraScreenshotDiff
10
+ module Reporters
11
+ class HTML
12
+ attr_reader :failures, :total
13
+
14
+ def initialize(output_path: nil, embed_images: false)
15
+ @explicit_output_path = output_path
16
+ @embed_images = embed_images
17
+ @failures = []
18
+ @total = 0
19
+ @finalized = false
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def record(assertions)
24
+ return if @finalized
25
+
26
+ failures = []
27
+ total = 0
28
+
29
+ assertions.each do |assertion|
30
+ compare = assertion.compare
31
+ next unless compare
32
+
33
+ total += 1
34
+ next unless compare.difference&.different?
35
+
36
+ failures << failure_entry_for(assertion.name, compare)
37
+ rescue => e
38
+ warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}" if ENV["DEBUG"]
39
+ end
40
+
41
+ @mutex.synchronize do
42
+ return if @finalized
43
+ @total += total
44
+ @failures.concat(failures)
45
+ end
46
+ end
47
+
48
+ def finalize
49
+ @mutex.synchronize do
50
+ return if @finalized
51
+ return if failures.empty?
52
+
53
+ write_report
54
+ @finalized = true
55
+ output_path
56
+ end
57
+ end
58
+
59
+ def output_path
60
+ @output_path ||= Pathname.new(@explicit_output_path || self.class.default_output_path)
61
+ end
62
+
63
+ def passed = total - failures.size
64
+ def failed = failures.size
65
+
66
+ def summary
67
+ return if total.zero?
68
+
69
+ screenshots_label = (total == 1) ? "1 screenshot" : "#{total} screenshots"
70
+
71
+ if failures.empty?
72
+ "[snap_diff] #{screenshots_label} compared, no failures."
73
+ else
74
+ failures_label = (failures.size == 1) ? "1 failure" : "#{failures.size} failures"
75
+ "[snap_diff] #{screenshots_label} compared, #{failures_label}. Report: #{output_path}"
76
+ end
77
+ end
78
+
79
+ def render
80
+ ERB.new(File.read(self.class.template_path)).result(binding)
81
+ end
82
+
83
+ def self.template_path
84
+ File.expand_path("templates/report.html.erb", __dir__)
85
+ end
86
+
87
+ def self.default_output_path
88
+ root = Capybara::Screenshot.root || Pathname.pwd
89
+ root / Capybara::Screenshot.save_path / "snap_diff_report.html"
90
+ end
91
+
92
+ private
93
+
94
+ def failure_entry_for(name, compare)
95
+ difference = compare.difference
96
+ {
97
+ name: name,
98
+ original: resolve_image(compare.base_image_path),
99
+ new: resolve_image(compare.image_path),
100
+ base_diff: resolve_image(compare.reporter.annotated_base_image_path),
101
+ diff: resolve_image(compare.reporter.annotated_image_path),
102
+ heatmap: resolve_image(compare.reporter.heatmap_diff_path),
103
+ diff_level: difference.ratio && (difference.ratio * 100).round(2),
104
+ area_size: difference.region_area_size,
105
+ max_color_distance: difference.meta[:max_color_distance]&.round(1)
106
+ }
107
+ end
108
+
109
+ def resolve_image(path)
110
+ return unless path
111
+
112
+ pathname = Pathname.new(path).expand_path
113
+ return unless pathname.exist?
114
+
115
+ @embed_images ? data_uri(pathname) : pathname.relative_path_from(output_path.dirname.expand_path).to_s
116
+ end
117
+
118
+ def data_uri(pathname)
119
+ ext = pathname.extname.delete_prefix(".")
120
+ mime = (ext == "webp") ? "image/webp" : "image/png"
121
+ "data:#{mime};base64,#{Base64.strict_encode64(pathname.binread)}"
122
+ end
123
+
124
+ def write_report
125
+ FileUtils.mkdir_p(output_path.dirname)
126
+ File.write(output_path, render)
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ # Auto-register reporter.
133
+ # Framework adapters (Minitest, RSpec, Cucumber) call finalize_reporters! via native hooks.
134
+ # For custom frameworks, call CapybaraScreenshotDiff.finalize_reporters! manually.
135
+ unless CapybaraScreenshotDiff.reporters.any?(CapybaraScreenshotDiff::Reporters::HTML)
136
+ CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new(embed_images: !!ENV["CI"])
137
+ end
@@ -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
10
  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|
@@ -27,10 +35,12 @@ RSpec.configure do |config|
27
35
  begin
28
36
  CapybaraScreenshotDiff.verify
29
37
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
30
- raise RSpec::Expectations::ExpectationNotMetError, e.message
38
+ raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
31
39
  ensure
32
40
  CapybaraScreenshotDiff.reset
33
41
  end
34
42
  end
35
43
  end
44
+
45
+ config.after(:suite) { CapybaraScreenshotDiff.finalize_reporters! }
36
46
  end