Skip to content

Commit 2624199

Browse files
pftgclaude
andcommitted
feat: diff stats, screenshot tests, zoom & annotation improvements
Reporter: - Add diff_level, area_size, max_color_distance to failure data - Show percentage badge in topbar and sidebar (e.g. "2.34% diff") Tests: - Replace Struct stubs with real ImageCompare objects - Add 5 screenshot integration tests for report views - Add rake report:sample task (scripts/generate_sample_report.rb) UI: - Ctrl/Cmd/Alt + scroll wheel to zoom (3% smooth steps) - Click image to toggle annotated mode - Move cursor on zoomed images - Fix search icon spacing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2467375 commit 2624199

11 files changed

Lines changed: 262 additions & 24 deletions

File tree

Rakefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ task :coverage do
2929
Rake::Task["test"].invoke
3030
end
3131

32+
desc "Generate sample HTML report for manual testing"
33+
task "report:sample" do
34+
ruby "scripts/generate_sample_report.rb"
35+
end
36+
3237
task "clobber" do
3338
puts "Cleanup tmp/*.png"
3439
FileUtils.rm_rf(Dir["./tmp/*"])

lib/capybara_screenshot_diff/reporters/html.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,23 @@ def success_rate
4949
private
5050

5151
def failure_entry_for(name, compare)
52-
{
52+
entry = {
5353
name: name,
5454
original: relative_path(compare.base_image_path),
5555
new: relative_path(compare.image_path),
5656
base_diff: relative_path(compare.reporter.annotated_base_image_path),
5757
diff: relative_path(compare.reporter.annotated_image_path),
5858
heatmap: relative_path(compare.reporter.heatmap_diff_path)
5959
}
60+
61+
if compare.difference
62+
d = compare.difference
63+
entry[:diff_level] = d.ratio ? (d.ratio * 100).round(2) : nil
64+
entry[:area_size] = d.region_area_size
65+
entry[:max_color_distance] = d.meta[:max_color_distance]&.round(1)
66+
end
67+
68+
entry
6069
end
6170

6271
def relative_path(path)

lib/capybara_screenshot_diff/reporters/templates/report.html.erb

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@
6464
/* ── Sidebar ─────────────────────────────────────────────────────── */
6565
#sidebar { width: var(--sidebar-w); flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
6666
#search-wrap { padding: .4rem .5rem; position: relative; }
67-
.search-icon { position: absolute; left: .5rem; top: 50%; transform: translateY(-50%); color: var(--text3); pointer-events: none; }
68-
#search { width: 100%; padding: .375rem .5rem .375rem 1.75rem; 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; }
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; }
6969
#search:focus { border-color: var(--accent); background: var(--bg); }
7070
#search::placeholder { color: var(--text3); }
7171
#sidebar-list { flex: 1; overflow-y: auto; padding: .375rem; display: flex; flex-direction: column; gap: .375rem; }
@@ -113,8 +113,9 @@
113113
.dot-new { width: 5px; height: 5px; border-radius: 50%; background: var(--orange); }
114114
.dot-heat { width: 5px; height: 5px; border-radius: 50%; background: #f59e0b; }
115115
/* img-box: scrollable container for per-image zoom+pan */
116-
.img-box { background: var(--surface); border-radius: var(--radius); overflow: auto; flex: 1; position: relative; cursor: grab; }
117-
.img-box.dragging { cursor: grabbing; }
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; }
118119
.img-box img { display: block; object-fit: contain; transform-origin: 0 0; }
119120
/* At 100% zoom, image fills the box */
120121
.img-box[data-zoom="100"] img { width: 100%; height: 100%; }
@@ -250,11 +251,14 @@
250251
/* Thumbnail: annotated mode → heatmap; normal → new image (no annotation) */
251252
var thumb = state.annotated ? (item.heatmap || item.new || item.original || '') : (item.new || item.original || '');
252253
var hasDiff = item.diff || item.heatmap;
254+
var badgeText = hasDiff
255+
? (item.diff_level != null ? item.diff_level + '%' : 'changed')
256+
: 'pass';
253257
btn.innerHTML =
254258
'<div class="thumb-img-wrap"><img src="' + esc(thumb) + '" alt="" loading="lazy"/></div>' +
255259
'<div class="thumb-footer">' +
256260
'<div class="thumb-name">' + esc(item.name) + '</div>' +
257-
'<span class="thumb-badge ' + (hasDiff ? 'thumb-badge-fail' : 'thumb-badge-pass') + '">' + (hasDiff ? 'changed' : 'pass') + '</span>' +
261+
'<span class="thumb-badge ' + (hasDiff ? 'thumb-badge-fail' : 'thumb-badge-pass') + '">' + badgeText + '</span>' +
258262
'</div>';
259263
btn.addEventListener('click', function() { selectItem(+this.dataset.idx); });
260264
sidebarList.appendChild(btn);
@@ -283,7 +287,11 @@
283287
var pos = filtered.indexOf(state.current);
284288
navCounter.textContent = (pos + 1) + '/' + filtered.length;
285289
var hasDiff = item.diff || item.heatmap;
286-
topBadge.textContent = hasDiff ? 'changed' : 'pass';
290+
if (hasDiff && item.diff_level != null) {
291+
topBadge.textContent = item.diff_level + '% diff';
292+
} else {
293+
topBadge.textContent = hasDiff ? 'changed' : 'pass';
294+
}
287295
topBadge.className = 'diff-badge ' + (hasDiff ? 'diff-badge-fail' : 'diff-badge-pass');
288296

289297
/* Resolve image sources based on view + annotated toggle */
@@ -326,7 +334,19 @@
326334
viewBtns.forEach(function(btn) {
327335
btn.addEventListener('click', function() { state.view = this.dataset.view; render(); buildSidebar(); });
328336
});
329-
annotateBtn.addEventListener('click', function() { state.annotated = !state.annotated; render(); buildSidebar(); });
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+
});
330350

331351
/* ── Per-image zoom ────────────────────────────────────────────────── */
332352
function applyZoom() {
@@ -339,22 +359,42 @@
339359
img.style.width = '100%';
340360
img.style.height = '100%';
341361
img.style.objectFit = 'contain';
342-
box.style.overflow = 'hidden';
362+
box.classList.remove('pannable');
343363
} else {
344-
/* Scale image beyond container — enable scrolling */
345364
img.style.width = (s * 100) + '%';
346365
img.style.height = 'auto';
347366
img.style.objectFit = '';
348-
box.style.overflow = 'auto';
367+
box.classList.add('pannable');
349368
}
350369
});
351370
$('zoom-label').textContent = state.zoom + '%';
352371
}
353372

354-
/* ── Per-image drag-to-pan ─────────────────────────────────────────── */
373+
/* ── Synchronized drag-to-pan across all img-boxes ───────────────── */
374+
var syncing = false;
375+
376+
function syncScroll(source) {
377+
if (syncing) return;
378+
syncing = true;
379+
var boxes = viewInner.querySelectorAll('.img-box');
380+
boxes.forEach(function(box) {
381+
if (box !== source) {
382+
box.scrollLeft = source.scrollLeft;
383+
box.scrollTop = source.scrollTop;
384+
}
385+
});
386+
syncing = false;
387+
}
388+
355389
function initPan() {
356-
viewInner.querySelectorAll('.img-box').forEach(function(box) {
390+
var boxes = viewInner.querySelectorAll('.img-box');
391+
boxes.forEach(function(box) {
357392
var drag = false, startX, startY, scrollL, scrollT;
393+
394+
/* Sync on any scroll (drag, wheel, trackpad) */
395+
box.addEventListener('scroll', function() { syncScroll(box); });
396+
397+
/* Drag to pan */
358398
box.addEventListener('mousedown', function(e) {
359399
if (state.zoom <= 100) return;
360400
drag = true; box.classList.add('dragging');
@@ -371,12 +411,22 @@
371411
});
372412
}
373413

374-
function zoomTo(v) { state.zoom = Math.max(50, Math.min(400, v)); applyZoom(); }
414+
function zoomTo(v) { state.zoom = Math.max(25, Math.min(500, v)); applyZoom(); }
375415

376416
$('zoom-in').addEventListener('click', function() { zoomTo(state.zoom + 50); });
377417
$('zoom-out').addEventListener('click', function() { zoomTo(state.zoom - 50); });
378418
$('zoom-fit').addEventListener('click', function() { zoomTo(100); });
379419

420+
/* Ctrl/Cmd/Alt + scroll wheel to zoom (smooth, small steps) */
421+
viewArea.addEventListener('wheel', function(e) {
422+
if (!(e.ctrlKey || e.metaKey || e.altKey)) return;
423+
e.preventDefault();
424+
/* Use 5% steps for smooth zoom; clamp deltaY to avoid trackpad acceleration */
425+
var step = 3;
426+
var direction = e.deltaY < 0 ? 1 : -1;
427+
zoomTo(state.zoom + direction * step);
428+
}, { passive: false });
429+
380430
/* ── Nav ───────────────────────────────────────────────────────────── */
381431
$('nav-prev').addEventListener('click', selectPrev);
382432
$('nav-next').addEventListener('click', selectNext);

scripts/generate_sample_report.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# frozen_string_literal: true
2+
3+
# Generate a sample HTML report for manual testing
4+
require "erb"
5+
require "json"
6+
require "pathname"
7+
8+
template_path = File.expand_path("../lib/capybara_screenshot_diff/reporters/templates/report.html.erb", __dir__)
9+
output_path = File.expand_path("../tmp/sample_report.html", __dir__)
10+
fixtures = Pathname.new(File.expand_path("../test/fixtures", __dir__))
11+
12+
img = fixtures / "images"
13+
screenshots = fixtures / "app/doc/screenshots"
14+
mac_sel = screenshots / "macos/selenium_headless"
15+
mac_cup = screenshots / "macos/cuprite"
16+
17+
# Use visually distinct base/new pairs so the difference is obvious
18+
failed_screenshots = [
19+
{
20+
name: "islands-map",
21+
original: (img / "a.png").to_s,
22+
new: (img / "b.png").to_s,
23+
base_diff: (img / "b.diff.png").to_s,
24+
diff: (img / "b.diff.png").to_s,
25+
heatmap: (img / "b.heatmap.diff.png").to_s,
26+
diff_level: 2.34,
27+
area_size: 1250,
28+
max_color_distance: 42.5
29+
},
30+
{
31+
name: "islands-variant",
32+
original: (img / "a.png").to_s,
33+
new: (img / "c.png").to_s,
34+
base_diff: nil,
35+
diff: nil,
36+
heatmap: (img / "a.heatmap.diff.png").to_s,
37+
diff_level: 8.71,
38+
area_size: 4820,
39+
max_color_distance: 118.3
40+
},
41+
{
42+
name: "index-page",
43+
original: (mac_sel / "index.base.png").to_s,
44+
new: (mac_sel / "index.png").to_s,
45+
base_diff: (mac_sel / "record_screenshot/record_index_cropped/00_index-cropped.base.diff.png").to_s,
46+
diff: (mac_sel / "record_screenshot/record_index_cropped/00_index-cropped.diff.png").to_s,
47+
heatmap: (mac_cup / "index.heatmap.diff.png").to_s,
48+
diff_level: 0.12,
49+
area_size: 340,
50+
max_color_distance: 15.2
51+
},
52+
{
53+
name: "cropped-form",
54+
original: (mac_sel / "index-cropped.base.png").to_s,
55+
new: (mac_sel / "index-cropped.png").to_s,
56+
base_diff: nil,
57+
diff: nil,
58+
heatmap: (mac_cup / "index-cropped.heatmap.diff.png").to_s,
59+
diff_level: 0.05,
60+
area_size: 42,
61+
max_color_distance: 3.0
62+
},
63+
{
64+
name: "blur-active-element",
65+
original: (mac_sel / "index-blur_active_element-enabled.base.png").to_s,
66+
new: (mac_sel / "index-blur_active_element-enabled.png").to_s,
67+
base_diff: nil,
68+
diff: nil,
69+
heatmap: nil,
70+
diff_level: nil,
71+
area_size: nil,
72+
max_color_distance: nil
73+
},
74+
{
75+
name: "portrait-layout",
76+
original: (img / "portrait.png").to_s,
77+
new: (img / "portrait_b.png").to_s,
78+
base_diff: nil,
79+
diff: nil,
80+
heatmap: nil,
81+
diff_level: nil,
82+
area_size: nil,
83+
max_color_distance: nil
84+
}
85+
]
86+
87+
total = 10
88+
failed = failed_screenshots.size
89+
passed = total - failed
90+
success_rate = ((passed.to_f / total) * 100).round(1)
91+
92+
template = ERB.new(File.read(template_path))
93+
html = template.result(binding)
94+
File.write(output_path, html)
95+
96+
puts "Generated: #{output_path}"
97+
puts "Screenshots: #{failed} failed, #{passed} passed, #{total} total"
275 KB
Loading
505 KB
Loading
275 KB
Loading
461 KB
Loading
502 KB
Loading
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
require "system_test_case"
4+
require "capybara_screenshot_diff/reporters/html"
5+
6+
class ReportScreenshotTest < SystemTestCase
7+
setup do
8+
@report_path = Pathname.new("test/fixtures/app/sample_report.html")
9+
generate_sample_report
10+
end
11+
12+
teardown do
13+
@report_path.delete if @report_path.exist?
14+
FileUtils.rm_rf(@img_dest) if @img_dest&.exist?
15+
end
16+
17+
def test_report_both_view
18+
visit "/sample_report.html"
19+
screenshot "report_both"
20+
end
21+
22+
def test_report_base_view
23+
visit "/sample_report.html"
24+
find("[data-view='base']").click
25+
screenshot "report_base"
26+
end
27+
28+
def test_report_new_view
29+
visit "/sample_report.html"
30+
find("[data-view='new']").click
31+
screenshot "report_new"
32+
end
33+
34+
def test_report_heatmap_view
35+
visit "/sample_report.html"
36+
find("[data-view='heatmap']").click
37+
screenshot "report_heatmap"
38+
end
39+
40+
def test_report_annotated_both
41+
visit "/sample_report.html"
42+
find("#annotate-toggle").click
43+
screenshot "report_annotated_both"
44+
end
45+
46+
private
47+
48+
def generate_sample_report
49+
# Copy fixture images into the served app directory so Capybara can load them
50+
img_dest = Pathname.new("test/fixtures/app/report_images")
51+
img_dest.mkpath
52+
img_src = Pathname.new("test/fixtures/images")
53+
54+
%w[a.png b.png c.png b.diff.png b.heatmap.diff.png a.heatmap.diff.png].each do |f|
55+
FileUtils.cp(img_src / f, img_dest / f)
56+
end
57+
58+
# Use relative paths from the report HTML location
59+
img = Pathname.new("report_images")
60+
61+
failed_screenshots = [
62+
{name: "islands-map", original: (img / "a.png").to_s, new: (img / "b.png").to_s,
63+
base_diff: (img / "b.diff.png").to_s, diff: (img / "b.diff.png").to_s, heatmap: (img / "b.heatmap.diff.png").to_s},
64+
{name: "islands-variant", original: (img / "a.png").to_s, new: (img / "c.png").to_s,
65+
base_diff: nil, diff: nil, heatmap: (img / "a.heatmap.diff.png").to_s}
66+
]
67+
68+
total = 3
69+
failed = failed_screenshots.size
70+
passed = total - failed
71+
success_rate = ((passed.to_f / total) * 100).round(1)
72+
73+
template = ERB.new(File.read(CapybaraScreenshotDiff::Reporters::HTML.new.send(:template_path)))
74+
File.write(@report_path, template.result(binding))
75+
76+
@img_dest = img_dest
77+
end
78+
end

0 commit comments

Comments
 (0)