Extract Diff class to own snapshot comparison logic
The Rakefile had 30 lines of manifest merging, PNG globbing, and
name-diffing inline. Snapshot ordering and add/remove detection
belong with the snapshot types, not in a Rake task.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/BACKLOG.md b/BACKLOG.md
index ca960df..46e88e4 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -19,8 +19,8 @@
- Don't re-download baseline for snapshots unnecessarily
- Dark mode
- Add snapshots of more states to check (adding notes, etc.)
-- Scope some of the snapshots down to more specific selectors
- Cuprite (Capybara) instead of Ferrum?
- Make snapshots embiggen images on click
- Steep
- Fix pages triggering from release
+- Improve the diff page to highlight differences
diff --git a/Rakefile b/Rakefile
index 5ec030b..0c227ff 100644
--- a/Rakefile
+++ b/Rakefile
@@ -89,32 +89,7 @@ namespace :snapshots do
puts "No baseline found — showing current screenshots only"
end
- current_entries = Ketchup::Snapshots::Entry.read_manifest(current_dir)
- baseline_entries = Ketchup::Snapshots::Entry.read_manifest(baseline_dir)
- order = current_entries.map(&:name)
- by_name = (baseline_entries + current_entries).each_with_object({}) { |e, h| h[e.name] = e }
- baseline_images = baseline_dir.glob("*.png").map { |f| f.basename(".png").to_s }
- current_images = current_dir.glob("*.png").map { |f| f.basename(".png").to_s }
- all_names = order | current_images | baseline_images
-
- snapshots = all_names.map do |name|
- entry = by_name.fetch(name)
- has_baseline = baseline_images.include?(name)
- has_current = current_images.include?(name)
-
- status = if !has_baseline then :new
- elsif !has_current then :removed
- end
-
- {
- name: name,
- path: entry.path,
- selector: entry.selector,
- status: status,
- baseline: has_baseline ? "baseline/#{name}.png" : nil,
- current: has_current ? "current/#{name}.png" : nil,
- }
- end
+ snapshots = Ketchup::Snapshots::Diff.new(baseline_dir: baseline_dir, current_dir: current_dir).comparisons
template = (Pathname(__dir__) / "templates/snapshot_diff.erb").read
output_path = base_dir / "diff.html"
@@ -138,16 +113,11 @@ namespace :snapshots do
css_sources.each_key { |src| cp src, output_path.dirname.to_s }
entries = Ketchup::Snapshots::Entry.read_manifest(images_dir)
- order = entries.map(&:name)
- by_name = entries.each_with_object({}) { |e, h| h[e.name] = e }
- all_pngs = images_dir.glob("*.png").map { |f| f.basename(".png").to_s }
- names = order | all_pngs
title = "Ketchup Snapshots"
images_rel = images_dir.relative_path_from(output_path.dirname)
- images = names.map do |name|
- entry = by_name.fetch(name)
- { name: name, path: entry.path, selector: entry.selector, filename: (images_rel / "#{name}.png").to_s }
+ images = entries.map do |entry|
+ { entry: entry, filename: (images_rel / "#{entry.name}.png").to_s }
end
template = (Pathname(__dir__) / "templates/snapshot_gallery.erb").read
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index b337214..ced23ed 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -26,6 +26,42 @@ module Ketchup
end
end
+ Comparison = Data.define(:name, :baseline, :current)
+
+ class Diff
+ def initialize(baseline_dir:, current_dir:)
+ @baseline = Entry.read_manifest(baseline_dir).each_with_object({}) { |e, h| h[e.name] = e }
+ @current = Entry.read_manifest(current_dir).each_with_object({}) { |e, h| h[e.name] = e }
+ end
+
+ def comparisons
+ return unchanged if @baseline.keys == @current.keys
+
+ require "tempfile"
+ baseline_file = Tempfile.new("baseline")
+ current_file = Tempfile.new("current")
+ baseline_file.write(@baseline.keys.join("\n") + "\n")
+ current_file.write(@current.keys.join("\n") + "\n")
+ baseline_file.close
+ current_file.close
+
+ `diff -u #{baseline_file.path} #{current_file.path}`.lines.drop(2).filter_map do |line|
+ name = line[1..].chomp
+ case line[0]
+ when " " then Comparison.new(name: name, baseline: @baseline.fetch(name), current: @current.fetch(name))
+ when "-" then Comparison.new(name: name, baseline: @baseline.fetch(name), current: nil)
+ when "+" then Comparison.new(name: name, baseline: nil, current: @current.fetch(name))
+ end
+ end
+ end
+
+ private
+
+ def unchanged
+ @current.map { |name, entry| Comparison.new(name: name, baseline: @baseline[name], current: entry) }
+ end
+ end
+
class Capture
def initialize(output_dir:, logger: Logger.new($stderr), &server)
@output_dir = Pathname(output_dir)
diff --git a/templates/snapshot_diff.erb b/templates/snapshot_diff.erb
index 496d478..de85f96 100644
--- a/templates/snapshot_diff.erb
+++ b/templates/snapshot_diff.erb
@@ -10,31 +10,31 @@
<body>
<h1>Ketchup Snapshot Diff</h1>
<% snapshots.each do |snap| %>
+ <% entry = snap.current || snap.baseline -%>
<div class="snapshot">
<h2>
- <%= snap[:name] %>
- <% if snap[:status] == :new %>
+ <%= snap.name %>
+ <% unless snap.baseline %>
<span class="label new">new</span>
- <% elsif snap[:status] == :removed %>
+ <% end -%>
+ <% unless snap.current %>
<span class="label removed">removed</span>
- <% end %>
+ <% end -%>
</h2>
- <% if snap[:path] -%>
- <code class="path"><%= snap[:path] %><%= " #{snap[:selector]}" if snap[:selector] %></code>
- <% end -%>
+ <code class="path"><%= entry.path %><%= " #{entry.selector}" if entry.selector %></code>
<div class="pair">
<div class="side">
<h3>Baseline</h3>
- <% if snap[:baseline] %>
- <img src="<%= snap[:baseline] %>" alt="baseline <%= snap[:name] %>">
+ <% if snap.baseline %>
+ <img src="baseline/<%= snap.name %>.png" alt="baseline <%= snap.name %>">
<% else %>
<div class="placeholder">No baseline</div>
<% end %>
</div>
<div class="side">
<h3>Current</h3>
- <% if snap[:current] %>
- <img src="<%= snap[:current] %>" alt="current <%= snap[:name] %>">
+ <% if snap.current %>
+ <img src="current/<%= snap.name %>.png" alt="current <%= snap.name %>">
<% else %>
<div class="placeholder">Removed</div>
<% end %>
diff --git a/templates/snapshot_gallery.erb b/templates/snapshot_gallery.erb
index 06acfd0..85ad833 100644
--- a/templates/snapshot_gallery.erb
+++ b/templates/snapshot_gallery.erb
@@ -11,9 +11,9 @@
<h1><%= title %></h1>
<% images.each do |image| %>
<div class="snapshot">
- <h2><%= image[:name] %></h2>
- <% if image[:path] %><code class="path"><%= image[:path] %><%= " #{image[:selector]}" if image[:selector] %></code><% end %>
- <img src="<%= image[:filename] %>" alt="<%= image[:name] %>">
+ <h2><%= image[:entry].name %></h2>
+ <code class="path"><%= image[:entry].path %><%= " #{image[:entry].selector}" if image[:entry].selector %></code>
+ <img src="<%= image[:filename] %>" alt="<%= image[:entry].name %>">
</div>
<% end %>
</body>