Skip to content

Commit 40e8a35

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 40e8a35

5 files changed

Lines changed: 352 additions & 1 deletion

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,23 @@ 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:
749+
750+
```bash
751+
open tmp/csd-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+
739756
## Development
740757

741758
After checking out the repo, run `bin/setup` to install dependencies.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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
12+
13+
def initialize(output_path: "tmp/csd-report/index.html")
14+
@output_path = Pathname.new(output_path)
15+
@all_failures = []
16+
@total = 0
17+
end
18+
19+
def record(assertions)
20+
assertions.each do |assertion|
21+
compare = assertion.compare
22+
next unless compare
23+
24+
@total += 1
25+
next unless compare.difference&.different?
26+
27+
@all_failures << build_failure_entry(assertion)
28+
rescue => e
29+
warn "[capybara-screenshot-diff] Reporter skipped '#{assertion.name}': #{e.message}"
30+
end
31+
end
32+
33+
def format(assertions = nil)
34+
record(assertions) if assertions&.any?
35+
finalize
36+
end
37+
38+
def finalize
39+
return if @all_failures.empty?
40+
41+
@failures = @all_failures
42+
@passed = @total - @failures.size
43+
44+
write_report
45+
$stdout.puts "[capybara-screenshot-diff] HTML report: #{@output_path}"
46+
end
47+
48+
private
49+
50+
def build_failure_entry(assertion)
51+
compare = assertion.compare
52+
{
53+
name: assertion.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+
Pathname.new(path).relative_path_from(@output_path.dirname).to_s
63+
rescue ArgumentError
64+
path.to_s
65+
end
66+
67+
def write_report
68+
FileUtils.mkdir_p(@output_path.dirname)
69+
template = ERB.new(File.read(template_path))
70+
File.write(@output_path, template.result(binding))
71+
end
72+
73+
def template_path
74+
File.expand_path("templates/report.html.erb", __dir__)
75+
end
76+
77+
def failed_screenshots
78+
@failures
79+
end
80+
81+
def total
82+
@total
83+
end
84+
85+
def passed
86+
@passed
87+
end
88+
89+
def failed
90+
@failures.size
91+
end
92+
93+
def success_rate
94+
return 0 if @total.zero?
95+
(@passed.to_f / @total * 100).round(1)
96+
end
97+
end
98+
end
99+
end
100+
101+
CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new
102+
103+
at_exit do
104+
CapybaraScreenshotDiff.reporters.each(&:finalize)
105+
rescue => e
106+
warn "[capybara-screenshot-diff] HTML report generation failed: #{e.message}"
107+
end
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 %>,
107+
108+
init() {
109+
if (this.items.length > 0) this.select(0)
110+
},
111+
112+
select(id) {
113+
if (this.items.length === 0) return
114+
if (this.items[this.current]) this.items[this.current].selected = false
115+
this.current = id
116+
this.items[id].selected = true
117+
},
118+
119+
comparison(id) {
120+
return Alpine.store('diff').on ? this.items[id].diff : this.items[id].new
121+
}
122+
})
123+
})
124+
</script>
125+
</body>
126+
</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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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.format([])
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.format([assertion])
30+
31+
assert_not @output_path.exist?
32+
end
33+
34+
test "#format with failing assertion generates HTML file" do
35+
reporter = HTML.new(output_path: @output_path)
36+
37+
assertion = build_failing_assertion("index")
38+
reporter.format([assertion])
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 "#format includes summary stats" do
47+
reporter = HTML.new(output_path: @output_path)
48+
49+
assertions = [
50+
build_failing_assertion("page_a"),
51+
build_passing_assertion("page_b"),
52+
build_failing_assertion("page_c")
53+
]
54+
reporter.format(assertions)
55+
56+
html = @output_path.read
57+
assert_includes html, "3" # total
58+
assert_includes html, "2" # failed
59+
end
60+
61+
private
62+
63+
def build_passing_assertion(name)
64+
difference_stub = Struct.new(:different?).new(false)
65+
compare_stub = Struct.new(:difference, :different?).new(difference_stub, false)
66+
ScreenshotAssertion.new(name).tap { |a| a.compare = compare_stub }
67+
end
68+
69+
def build_failing_assertion(name)
70+
reporter_stub = Struct.new(:annotated_image_path, :annotated_base_image_path, :heatmap_diff_path)
71+
.new(@output_dir / "#{name}.diff.png", @output_dir / "#{name}.base.diff.png", @output_dir / "#{name}.heatmap.diff.png")
72+
73+
difference_stub = Struct.new(:different?).new(true)
74+
compare_stub = Struct.new(:difference, :different?, :base_image_path, :image_path, :reporter)
75+
.new(difference_stub, true, @output_dir / "#{name}.base.png", @output_dir / "#{name}.png", reporter_stub)
76+
77+
ScreenshotAssertion.new(name).tap { |a| a.compare = compare_stub }
78+
end
79+
end
80+
end
81+
end

0 commit comments

Comments
 (0)