Skip to content

Commit 861183f

Browse files
pftgclaude
andcommitted
feat: add HTML reporter for visual diff dashboard
Interactive HTML report generated automatically when required: require 'capybara_screenshot_diff/reporters/html' Features: - Side-by-side baseline vs current with diff toggle - Searchable sidebar with thumbnails - Summary stats (total/failed/passed/success rate) - Zero config — at_exit hook auto-generates report - Based on prototype from showcase project (Tailwind + Alpine.js) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1364048 commit 861183f

5 files changed

Lines changed: 379 additions & 1 deletion

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,49 @@ Capybara::Screenshot.capybara_screenshot_options[:full_page] = true
736736
screenshot('index', median_filter_window_size: 2, capybara_screenshot_options: {full_page: false})
737737
```
738738

739+
### HTML Report
740+
741+
Generate an interactive HTML report of screenshot differences:
742+
743+
```ruby
744+
# Add to test_helper.rb — one line, that's it
745+
require 'capybara_screenshot_diff/reporters/html'
746+
```
747+
748+
After running tests, open the report (generated only when there are failures):
749+
750+
```bash
751+
open tmp/snap_diff-report/index.html
752+
```
753+
754+
The report includes a sidebar with thumbnails, side-by-side comparison with diff toggle, search, and summary stats. No configuration needed — just require it.
755+
756+
**Note:** The report is not generated when all screenshots match. In parallel test environments, each worker writes to the same file — the last worker's results will be in the report.
757+
758+
### Custom Reporters
759+
760+
Build your own reporter by implementing `record` and `finalize`:
761+
762+
```ruby
763+
class MyReporter
764+
def record(assertions)
765+
assertions.each do |assertion|
766+
next unless assertion.compare&.difference&.different?
767+
# process the failure — send to Slack, write JSON, etc.
768+
end
769+
end
770+
771+
def finalize
772+
# called once at process exit — write summary, upload report, etc.
773+
end
774+
end
775+
776+
# Register in test_helper.rb
777+
CapybaraScreenshotDiff.reporters << MyReporter.new
778+
```
779+
780+
Reporters are notified before assertions are cleared on each test teardown. `finalize` is called via `at_exit`.
781+
739782
## Development
740783

741784
After checking out the repo, run `bin/setup` to install dependencies.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
require "erb"
4+
require "fileutils"
5+
require "pathname"
6+
require "json"
7+
8+
module CapybaraScreenshotDiff
9+
module Reporters
10+
class HTML
11+
attr_reader :output_path, :failures, :total
12+
13+
def initialize(output_path: "tmp/snap_diff-report/index.html")
14+
@output_path = Pathname.new(output_path)
15+
@report_dir = @output_path.dirname
16+
@failures = []
17+
@total = 0
18+
end
19+
20+
def record(assertions)
21+
assertions.each do |assertion|
22+
compare = assertion.compare
23+
next unless compare
24+
25+
@total += 1
26+
next unless compare.difference&.different?
27+
28+
@failures << failure_entry_for(assertion.name, compare)
29+
rescue => e
30+
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}"
31+
end
32+
end
33+
34+
def finalize
35+
return if failures.empty?
36+
37+
write_report
38+
$stdout.puts "[snap_diff] HTML report: #{output_path}"
39+
end
40+
41+
def passed = total - failures.size
42+
def failed = failures.size
43+
44+
def success_rate
45+
return 0 if total.zero?
46+
(passed.to_f / total * 100).round(1)
47+
end
48+
49+
private
50+
51+
def failure_entry_for(name, compare)
52+
{
53+
name: name,
54+
original: relative_path(compare.base_image_path),
55+
new: relative_path(compare.image_path),
56+
diff: relative_path(compare.reporter.annotated_image_path),
57+
heatmap: relative_path(compare.reporter.heatmap_diff_path)
58+
}
59+
end
60+
61+
def relative_path(path)
62+
return "" unless path
63+
64+
Pathname.new(path).relative_path_from(@report_dir).to_s
65+
rescue ArgumentError
66+
path.to_s
67+
end
68+
69+
def write_report
70+
FileUtils.mkdir_p(@report_dir)
71+
File.write(output_path, ERB.new(File.read(template_path)).result(binding))
72+
end
73+
74+
def template_path = File.expand_path("templates/report.html.erb", __dir__)
75+
def failed_screenshots = failures
76+
end
77+
end
78+
end
79+
80+
unless CapybaraScreenshotDiff.reporters.any?(CapybaraScreenshotDiff::Reporters::HTML)
81+
CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new
82+
end
83+
84+
at_exit do
85+
CapybaraScreenshotDiff.reporters.each do |reporter|
86+
reporter.finalize
87+
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)
90+
end
91+
end
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<!DOCTYPE html>
2+
<html class="h-full bg-white">
3+
<head>
4+
<meta charset="UTF-8"/>
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6+
<title>Screenshot Diff Report — <%= failed %> failures</title>
7+
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
8+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
9+
</head>
10+
11+
<body class="h-full overflow-hidden">
12+
<div x-data>
13+
<div class="flex h-screen">
14+
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
15+
16+
<!-- Summary Bar -->
17+
<div class="bg-gray-50 border-b px-6 py-3 flex items-center justify-between">
18+
<h1 class="text-lg font-semibold text-gray-900">Screenshot Diff Report</h1>
19+
<div class="flex gap-4 text-sm">
20+
<span class="text-gray-600">Total: <strong><%= total %></strong></span>
21+
<span class="text-red-600">Failed: <strong><%= failed %></strong></span>
22+
<span class="text-green-600">Passed: <strong><%= passed %></strong></span>
23+
<span class="text-gray-500">(<%= success_rate %>%)</span>
24+
</div>
25+
</div>
26+
27+
<div class="relative z-0 flex flex-1 overflow-hidden">
28+
29+
<!-- Main Content -->
30+
<main class="relative z-0 flex-1 overflow-y-auto focus:outline-none sm:order-last p-6">
31+
<div x-data="{items: $store.screenshots.items}">
32+
<template x-for="(item, id) in items">
33+
<article x-show="item.selected">
34+
<h2 class="text-lg font-medium text-gray-900 mb-4" x-text="item.name"></h2>
35+
36+
<div class="mb-4">
37+
<button @click="$store.diff.toggle()"
38+
:class="$store.diff.on ? 'bg-red-100 text-red-700' : 'bg-white text-gray-700'"
39+
type="button"
40+
class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
41+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
42+
<path d="M16.5 6a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v7.5a3 3 0 0 0 3 3v-6A4.5 4.5 0 0 1 10.5 6h6Z"/>
43+
<path d="M18 7.5a3 3 0 0 1 3 3V18a3 3 0 0 1-3 3h-7.5a3 3 0 0 1-3-3v-7.5a3 3 0 0 1 3-3H18Z"/>
44+
</svg>
45+
<span x-text="$store.diff.on ? 'Showing Diff' : 'Show Diff'"></span>
46+
</button>
47+
</div>
48+
49+
<div class="grid grid-cols-2 gap-4">
50+
<div>
51+
<p class="text-sm font-medium text-gray-500 mb-2">Baseline</p>
52+
<div class="bg-black rounded-lg border overflow-hidden">
53+
<img class="w-full" :alt="'Baseline: ' + item.name" :src="item.original">
54+
</div>
55+
</div>
56+
<div>
57+
<p class="text-sm font-medium text-gray-500 mb-2" x-text="$store.diff.on ? 'Diff' : 'Current'"></p>
58+
<div class="bg-black rounded-lg border overflow-hidden">
59+
<img class="w-full" :alt="'Current: ' + item.name" :src="$store.screenshots.comparison(id)">
60+
</div>
61+
</div>
62+
</div>
63+
</article>
64+
</template>
65+
</div>
66+
</main>
67+
68+
<!-- Sidebar -->
69+
<aside class="hidden w-72 border-r border-gray-200 sm:order-first sm:flex sm:flex-col">
70+
<div class="p-3">
71+
<input type="text"
72+
x-model="$store.screenshots.search"
73+
placeholder="Search screenshots..."
74+
class="w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
75+
</div>
76+
<nav class="flex-1 overflow-y-auto p-3" x-data="{items: $store.screenshots.items}">
77+
<template x-for="(item, id) in items">
78+
<div class="mb-2" x-show="item.name.toLowerCase().includes(($store.screenshots.search || '').toLowerCase())">
79+
<button @click="$store.screenshots.select(id)"
80+
class="w-full text-left rounded-lg border-2 p-1 transition-colors"
81+
:class="item.selected ? 'border-indigo-500 shadow-md' : 'border-transparent hover:border-gray-300'">
82+
<div class="aspect-w-5 aspect-h-3 overflow-hidden rounded bg-black">
83+
<img class="object-contain" :alt="item.name" :src="item.diff || item.new">
84+
</div>
85+
<p class="mt-1 truncate text-xs text-gray-600" x-text="item.name"></p>
86+
</button>
87+
</div>
88+
</template>
89+
</nav>
90+
</aside>
91+
</div>
92+
</div>
93+
</div>
94+
</div>
95+
96+
<script>
97+
document.addEventListener('alpine:init', () => {
98+
Alpine.store('diff', {
99+
on: false,
100+
toggle() { this.on = !this.on }
101+
})
102+
103+
Alpine.store('screenshots', {
104+
current: 0,
105+
search: '',
106+
items: <%= failed_screenshots.to_json.gsub("</", "<\\/") %>,
107+
108+
select(id) {
109+
if (this.items.length === 0) return
110+
if (this.items[this.current]) this.items[this.current].selected = false
111+
this.current = id
112+
this.items[id].selected = true
113+
},
114+
115+
comparison(id) {
116+
return Alpine.store('diff').on ? this.items[id].diff : this.items[id].new
117+
}
118+
})
119+
120+
const store = Alpine.store('screenshots')
121+
if (store.items.length > 0) store.select(0)
122+
})
123+
</script>
124+
</body>
125+
</html>

lib/capybara_screenshot_diff/screenshot_assertion.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,28 @@ def registry
124124
def_delegator :registry, :assertions
125125
def_delegator :registry, :assertions_present?
126126
def_delegator :registry, :failed_assertions
127-
def_delegator :registry, :reset
127+
def reset
128+
notify_reporters(registry.assertions)
129+
registry.reset
130+
end
131+
132+
def reporters
133+
@reporters ||= []
134+
end
135+
128136
def_delegator :registry, :screenshot_namer
129137
def_delegator :registry, :verify
138+
139+
private
140+
141+
def notify_reporters(assertions)
142+
return if reporters.empty? || assertions.nil? || assertions.empty?
143+
144+
reporters.each do |reporter|
145+
reporter.record(assertions)
146+
rescue => e
147+
warn "[capybara-screenshot-diff] Reporter failed: #{e.message}"
148+
end
149+
end
130150
end
131151
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "capybara_screenshot_diff/reporters/html"
5+
6+
module CapybaraScreenshotDiff
7+
module Reporters
8+
class HTMLReporterTest < ActiveSupport::TestCase
9+
setup do
10+
@output_dir = Pathname.new(Dir.mktmpdir)
11+
@output_path = @output_dir / "report.html"
12+
end
13+
14+
teardown do
15+
FileUtils.remove_entry(@output_dir)
16+
end
17+
18+
test "#format with no assertions writes nothing" do
19+
reporter = HTML.new(output_path: @output_path)
20+
reporter.record([])
21+
22+
assert_not @output_path.exist?
23+
end
24+
25+
test "#format with passing assertions writes nothing" do
26+
reporter = HTML.new(output_path: @output_path)
27+
28+
assertion = build_passing_assertion("index")
29+
reporter.record([assertion])
30+
31+
assert_not @output_path.exist?
32+
end
33+
34+
test "#record and #finalize with failing assertion generates HTML file" do
35+
reporter = HTML.new(output_path: @output_path)
36+
37+
reporter.record([build_failing_assertion("index")])
38+
reporter.finalize
39+
40+
assert @output_path.exist?
41+
html = @output_path.read
42+
assert_includes html, "<!DOCTYPE html>"
43+
assert_includes html, "index"
44+
end
45+
46+
test "#record and #finalize includes summary stats" do
47+
reporter = HTML.new(output_path: @output_path)
48+
49+
reporter.record([
50+
build_failing_assertion("page_a"),
51+
build_passing_assertion("page_b"),
52+
build_failing_assertion("page_c")
53+
])
54+
reporter.finalize
55+
56+
html = @output_path.read
57+
assert_match(/Total.*<strong>3<\/strong>/, html)
58+
assert_match(/Failed.*<strong>2<\/strong>/, html)
59+
assert_match(/Passed.*<strong>1<\/strong>/, html)
60+
end
61+
62+
test "#record tolerates broken assertions without crashing" do
63+
reporter = HTML.new(output_path: @output_path)
64+
65+
broken = ScreenshotAssertion.new("broken")
66+
broken.compare = Object.new # will raise on .difference
67+
68+
valid = build_failing_assertion("valid")
69+
70+
assert_nothing_raised do
71+
reporter.record([broken, valid])
72+
end
73+
74+
reporter.finalize
75+
assert @output_path.exist?
76+
assert_includes @output_path.read, "valid"
77+
end
78+
79+
private
80+
81+
def build_passing_assertion(name)
82+
difference_stub = Struct.new(:different?).new(false)
83+
compare_stub = Struct.new(:difference, :different?).new(difference_stub, false)
84+
ScreenshotAssertion.new(name).tap { |a| a.compare = compare_stub }
85+
end
86+
87+
def build_failing_assertion(name)
88+
reporter_stub = Struct.new(:annotated_image_path, :annotated_base_image_path, :heatmap_diff_path)
89+
.new(@output_dir / "#{name}.diff.png", @output_dir / "#{name}.base.diff.png", @output_dir / "#{name}.heatmap.diff.png")
90+
91+
difference_stub = Struct.new(:different?).new(true)
92+
compare_stub = Struct.new(:difference, :different?, :base_image_path, :image_path, :reporter)
93+
.new(difference_stub, true, @output_dir / "#{name}.base.png", @output_dir / "#{name}.png", reporter_stub)
94+
95+
ScreenshotAssertion.new(name).tap { |a| a.compare = compare_stub }
96+
end
97+
end
98+
end
99+
end

0 commit comments

Comments
 (0)