Skip to content

Commit 993bcb9

Browse files
pftgclaude
andcommitted
feat: redesign HTML report with premium UI and visual testing
Self-contained single HTML file, zero external dependencies, works offline. Views: Both (side-by-side) | Base only | New only | Heatmap - Annotation toggle (key A) switches between original and annotated images - Per-image zoom with synchronized drag-to-pan across panels - Ctrl/Cmd/Alt + scroll wheel zoom, click image to toggle annotations - Keyboard shortcuts: 1-4 views, arrows navigate, / search, +/- zoom Reporter: - Public render/template_path API, diff stats (diff_level, area_size) - Percentage badge in topbar and sidebar (e.g. "2.34% diff") - Path existence check, idempotent finalize, DEBUG-only diagnostics Tests: - Unit tests use real ImageCompare objects (no Struct stubs) - 5 screenshot integration tests with perceptual_threshold - HTML reporter enabled for all integration tests via system_test_case - Rake report:sample task for manual testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9a4309c commit 993bcb9

12 files changed

Lines changed: 635 additions & 145 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: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ module Reporters
1010
class HTML
1111
attr_reader :output_path, :failures, :total
1212

13-
def initialize(output_path: "tmp/snap_diff-report/index.html")
13+
def initialize(output_path: "tmp/snap_diff/index.html")
1414
@output_path = Pathname.new(output_path)
1515
@report_dir = @output_path.dirname
1616
@failures = []
1717
@total = 0
18+
@finalized = false
1819
end
1920

2021
def record(assertions)
@@ -27,15 +28,22 @@ def record(assertions)
2728

2829
@failures << failure_entry_for(assertion.name, compare)
2930
rescue => e
30-
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}"
31+
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}" if ENV["DEBUG"]
3132
end
3233
end
3334

3435
def finalize
35-
return if failures.empty?
36+
return if @finalized
37+
38+
@finalized = true
39+
40+
if failures.empty?
41+
warn "[snap_diff] No failures found, HTML report not generated" if ENV["DEBUG"]
42+
return
43+
end
3644

3745
write_report
38-
$stdout.puts "[snap_diff] HTML report: #{output_path}"
46+
true
3947
end
4048

4149
def passed = total - failures.size
@@ -46,33 +54,44 @@ def success_rate
4654
(passed.to_f / total * 100).round(1)
4755
end
4856

57+
def render
58+
ERB.new(File.read(self.class.template_path)).result(binding)
59+
end
60+
61+
def self.template_path
62+
File.expand_path("templates/report.html.erb", __dir__)
63+
end
64+
4965
private
5066

5167
def failure_entry_for(name, compare)
68+
difference = compare.difference
5269
{
5370
name: name,
5471
original: relative_path(compare.base_image_path),
5572
new: relative_path(compare.image_path),
73+
base_diff: relative_path(compare.reporter.annotated_base_image_path),
5674
diff: relative_path(compare.reporter.annotated_image_path),
57-
heatmap: relative_path(compare.reporter.heatmap_diff_path)
75+
heatmap: relative_path(compare.reporter.heatmap_diff_path),
76+
diff_level: difference.ratio && (difference.ratio * 100).round(2),
77+
area_size: difference.region_area_size,
78+
max_color_distance: difference.meta[:max_color_distance]&.round(1)
5879
}
5980
end
6081

6182
def relative_path(path)
62-
return "" unless path
83+
return unless path
6384

64-
Pathname.new(path).relative_path_from(@report_dir).to_s
65-
rescue ArgumentError
66-
path.to_s
85+
pathname = Pathname.new(path)
86+
return unless pathname.exist?
87+
88+
pathname.relative_path_from(@report_dir).to_s
6789
end
6890

6991
def write_report
7092
FileUtils.mkdir_p(@report_dir)
71-
File.write(output_path, ERB.new(File.read(template_path)).result(binding))
93+
File.write(output_path, render)
7294
end
73-
74-
def template_path = File.expand_path("templates/report.html.erb", __dir__)
75-
def failed_screenshots = failures
7695
end
7796
end
7897
end
@@ -83,9 +102,9 @@ def failed_screenshots = failures
83102

84103
at_exit do
85104
CapybaraScreenshotDiff.reporters.each do |reporter|
86-
reporter.finalize
105+
wrote = reporter.finalize
106+
$stdout.puts "[snap_diff] HTML report: #{reporter.output_path}" if wrote
87107
rescue => e
88-
warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
89-
warn e.full_message(highlight: false) if e.respond_to?(:full_message)
108+
warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})" if ENV["DEBUG"]
90109
end
91110
end

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

Lines changed: 450 additions & 112 deletions
Large diffs are not rendered by default.

scripts/generate_sample_report.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
# Generate a sample HTML report for manual testing.
4+
# Uses absolute file:// paths so images load when opened directly in a browser.
5+
6+
require "bundler/setup"
7+
require "capybara_screenshot_diff"
8+
require "capybara_screenshot_diff/minitest"
9+
require "capybara/screenshot/diff"
10+
require "capybara_screenshot_diff/reporters/html"
11+
12+
output_path = File.expand_path("../tmp/sample_report.html", __dir__)
13+
14+
# Build real comparisons using the gem's own ImageCompare.
15+
# Each pair gets a unique copy of the base image to avoid annotation file conflicts.
16+
pairs = [
17+
{name: "islands-map", base: "a", new: "b"},
18+
{name: "islands-variant", base: "a", new: "c"},
19+
{name: "portrait-layout", base: "portrait", new: "portrait_b"}
20+
]
21+
22+
reporter = CapybaraScreenshotDiff::Reporters::HTML.new(output_path: output_path)
23+
fixtures = File.expand_path("../test/fixtures/images", __dir__)
24+
tmp_dir = File.expand_path("../tmp/sample_images", __dir__)
25+
FileUtils.mkdir_p(tmp_dir)
26+
27+
assertions = pairs.map do |pair|
28+
# Copy to tmp so each comparison has its own base/new files for annotations
29+
base_copy = "#{tmp_dir}/#{pair[:name]}_base.png"
30+
new_copy = "#{tmp_dir}/#{pair[:name]}_new.png"
31+
FileUtils.cp("#{fixtures}/#{pair[:base]}.png", base_copy)
32+
FileUtils.cp("#{fixtures}/#{pair[:new]}.png", new_copy)
33+
34+
compare = Capybara::Screenshot::Diff::ImageCompare.new(new_copy, base_copy, driver: :vips)
35+
compare.processed
36+
37+
CapybaraScreenshotDiff::ScreenshotAssertion.new(pair[:name]).tap { |a| a.compare = compare }
38+
end
39+
40+
# Add passing assertions (identical images = no difference)
41+
passing = %w[dashboard settings profile users].map do |name|
42+
compare = Capybara::Screenshot::Diff::ImageCompare.new("#{fixtures}/a.png", "#{fixtures}/a.png")
43+
compare.processed
44+
CapybaraScreenshotDiff::ScreenshotAssertion.new(name).tap { |a| a.compare = compare }
45+
end
46+
47+
reporter.record(assertions + passing)
48+
reporter.finalize
49+
50+
puts "Screenshots: #{reporter.failed} failed, #{reporter.passed} passed, #{reporter.total} total"
283 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+
5+
class ReportScreenshotTest < SystemTestCase
6+
PERCEPTUAL_THRESHOLD = 2.0
7+
8+
setup do
9+
screenshot_section "html_report"
10+
11+
@report_dir = Pathname.new("test/fixtures/app/report")
12+
@report_path = @report_dir / "index.html"
13+
skip "Run with RECORD_SCREENSHOTS=1 to update report fixtures" unless ENV["RECORD_SCREENSHOTS"]
14+
generate_sample_report
15+
visit "/report/index.html"
16+
end
17+
18+
teardown do
19+
FileUtils.rm_rf(@report_dir)
20+
end
21+
22+
def test_report_both_view
23+
screenshot "report_both", perceptual_threshold: PERCEPTUAL_THRESHOLD
24+
end
25+
26+
def test_report_base_view
27+
find("[data-view='base']").click
28+
screenshot "report_base", perceptual_threshold: PERCEPTUAL_THRESHOLD
29+
end
30+
31+
def test_report_new_view
32+
find("[data-view='new']").click
33+
screenshot "report_new", perceptual_threshold: PERCEPTUAL_THRESHOLD
34+
end
35+
36+
def test_report_heatmap_view
37+
find("[data-view='heatmap']").click
38+
screenshot "report_heatmap", perceptual_threshold: PERCEPTUAL_THRESHOLD
39+
end
40+
41+
def test_report_annotated_both
42+
find("#annotate-toggle").click
43+
screenshot "report_annotated_both", perceptual_threshold: PERCEPTUAL_THRESHOLD
44+
end
45+
46+
private
47+
48+
def generate_sample_report
49+
img_dir = @report_dir / "images"
50+
img_dir.mkpath
51+
52+
# Copy fixtures into served directory so browser can load them
53+
fixtures = Pathname.new("test/fixtures/images")
54+
%w[a.png b.png c.png].each { |f| FileUtils.cp(fixtures / f, img_dir / f) }
55+
56+
# Run real comparisons inside the served directory
57+
assertions = [
58+
build_assertion("islands-map", img_dir / "a.png", img_dir / "b.png"),
59+
build_assertion("islands-variant", img_dir / "a.png", img_dir / "c.png")
60+
]
61+
62+
reporter = CapybaraScreenshotDiff::Reporters::HTML.new(output_path: @report_path)
63+
reporter.record(assertions)
64+
reporter.finalize
65+
end
66+
67+
def build_assertion(name, base_path, new_path)
68+
# Copy to unique files so shared base images don't overwrite each other's annotations
69+
unique_base = @report_dir / "images" / "#{name}_base.png"
70+
unique_new = @report_dir / "images" / "#{name}_new.png"
71+
FileUtils.cp(base_path, unique_base)
72+
FileUtils.cp(new_path, unique_new)
73+
74+
compare = Capybara::Screenshot::Diff::ImageCompare.new(unique_new, unique_base, driver: :vips)
75+
compare.processed
76+
CapybaraScreenshotDiff::ScreenshotAssertion.new(name).tap { |a| a.compare = compare }
77+
end
78+
end

0 commit comments

Comments
 (0)