Replace inline HTML generation with ERB templates
Moves HTML out of Ruby string interpolation into proper templates.
Rake task prereqs replace manual `invoke` calls. Capture now owns
the Puma lifecycle.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/Rakefile b/Rakefile
index fcf09ce..6b474d1 100644
--- a/Rakefile
+++ b/Rakefile
@@ -102,26 +102,9 @@ namespace :snapshots do
ENV["DATABASE_URL"] = ":memory:"
require "ketchup/web"
require "ketchup/snapshots"
- require "puma"
- require "puma/configuration"
output_dir = File.join(Ketchup::Snapshots.cache_dir, "current")
-
- config = Puma::Configuration.new do |c|
- c.app Web.freeze.app
- c.bind "tcp://127.0.0.1:0"
- c.log_requests false
- c.quiet
- end
-
- launcher = Puma::Launcher.new(config)
- thread = Thread.new { launcher.run }
- sleep 1 until launcher.connected_ports.any?
-
- port = launcher.connected_ports.first
- Ketchup::Snapshots::Capture.new(output_dir: output_dir, port: port).call
- launcher.stop
- thread.join
+ Ketchup::Snapshots::Capture.new(output_dir: output_dir).call
if ENV["CI"]
require "json"
@@ -132,8 +115,8 @@ namespace :snapshots do
end
desc "Compare current screenshots against baseline from latest release"
- task :diff do
- require "ketchup/snapshots"
+ task diff: :capture do
+ require "erb"
base_dir = Ketchup::Snapshots.cache_dir
baseline_dir = File.join(base_dir, "baseline")
@@ -153,30 +136,50 @@ namespace :snapshots do
puts "No baseline found — showing current screenshots only"
end
- Rake::Task["snapshots:capture"].invoke
+ baseline_images = Dir.glob(File.join(baseline_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
+ current_images = Dir.glob(File.join(current_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
+
+ snapshots = (baseline_images | current_images).sort.map do |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,
+ status: status,
+ baseline: has_baseline ? File.join("baseline", "#{name}.png") : nil,
+ current: has_current ? File.join("current", "#{name}.png") : nil,
+ }
+ end
+
+ template = File.read(File.expand_path("snapshot_diff.erb", __dir__))
output_path = File.join(base_dir, "diff.html")
- Ketchup::Snapshots.generate_diff_html(
- baseline_dir: baseline_dir,
- current_dir: current_dir,
- output_path: output_path
- )
+ File.write(output_path, ERB.new(template, trim_mode: "-").result(binding))
puts "Diff viewer: #{output_path}"
end
desc "Capture, diff, and open the viewer"
- task :review do
- Rake::Task["snapshots:diff"].invoke
+ task review: :diff do
system("open", File.join(Ketchup::Snapshots.cache_dir, "diff.html"))
end
desc "Generate gallery HTML from images in a directory"
task :gallery, [:images_dir, :output_path] do |_t, args|
- require "ketchup/snapshots"
+ require "erb"
images_dir = args.fetch(:images_dir)
output_path = args.fetch(:output_path)
- Ketchup::Snapshots.generate_gallery_html(images_dir: images_dir, output_path: output_path)
+
+ title = "Ketchup Snapshots"
+ images = Dir.glob(File.join(images_dir, "*.png")).sort.map do |f|
+ { name: File.basename(f, ".png"), filename: File.basename(f) }
+ end
+
+ template = File.read(File.expand_path("snapshot_gallery.erb", __dir__))
+ File.write(output_path, ERB.new(template, trim_mode: "-").result(binding))
puts output_path
end
end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index ed84089..06a0657 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -9,137 +9,31 @@ module Ketchup
File.join(base, "ketchup", "snapshots")
end
- def self.generate_diff_html(baseline_dir:, current_dir:, output_path:)
- baseline_images = Dir.glob(File.join(baseline_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
- current_images = Dir.glob(File.join(current_dir, "*.png")).map { |f| File.basename(f, ".png") }.sort
- all_names = (baseline_images + current_images).uniq.sort
-
- rows = all_names.map do |name|
- has_baseline = baseline_images.include?(name)
- has_current = current_images.include?(name)
-
- label = if !has_baseline
- " <span class=\"label new\">new</span>"
- elsif !has_current
- " <span class=\"label removed\">removed</span>"
- else
- ""
- end
-
- baseline_cell = if has_baseline
- baseline_rel = File.join("baseline", "#{name}.png")
- "<img src=\"#{baseline_rel}\" alt=\"baseline #{name}\">"
- else
- "<div class=\"placeholder\">No baseline</div>"
- end
-
- current_cell = if has_current
- current_rel = File.join("current", "#{name}.png")
- "<img src=\"#{current_rel}\" alt=\"current #{name}\">"
- else
- "<div class=\"placeholder\">Removed</div>"
- end
-
- <<~ROW
- <div class="snapshot">
- <h2>#{name}#{label}</h2>
- <div class="pair">
- <div class="side">
- <h3>Baseline</h3>
- #{baseline_cell}
- </div>
- <div class="side">
- <h3>Current</h3>
- #{current_cell}
- </div>
- </div>
- </div>
- ROW
- end
-
- html = <<~HTML
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <title>Ketchup Snapshot Diff</title>
- <style>
- * { box-sizing: border-box; margin: 0; padding: 0; }
- body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
- h1 { margin-bottom: 2rem; color: #fff; }
- .snapshot { margin-bottom: 3rem; }
- .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
- .label { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 4px; vertical-align: middle; }
- .label.new { background: #2d6a4f; color: #b7e4c7; }
- .label.removed { background: #6a2d2d; color: #e4b7b7; }
- .pair { display: flex; gap: 1rem; }
- .side { flex: 1; min-width: 0; }
- .side h3 { margin-bottom: 0.5rem; color: #999; font-size: 0.875rem; text-transform: uppercase; }
- .side img { width: 100%; border: 1px solid #333; border-radius: 4px; }
- .placeholder { padding: 3rem; text-align: center; color: #666; border: 1px dashed #333; border-radius: 4px; }
- </style>
- </head>
- <body>
- <h1>Ketchup Snapshot Diff</h1>
- #{rows.join("\n")}
- </body>
- </html>
- HTML
-
- File.write(output_path, html)
- end
-
- def self.generate_gallery_html(images_dir:, output_path:)
- images = Dir.glob(File.join(images_dir, "*.png")).sort.map { |f| File.basename(f) }
-
- items = images.map do |filename|
- name = File.basename(filename, ".png")
- <<~ITEM
- <div class="snapshot">
- <h2>#{name}</h2>
- <img src="#{filename}" alt="#{name}">
- </div>
- ITEM
- end
-
- html = <<~HTML
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <title>Ketchup Snapshots</title>
- <style>
- * { box-sizing: border-box; margin: 0; padding: 0; }
- body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
- h1 { margin-bottom: 2rem; color: #fff; }
- .snapshot { margin-bottom: 3rem; }
- .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
- .snapshot img { max-width: 100%; border: 1px solid #333; border-radius: 4px; }
- </style>
- </head>
- <body>
- <h1>Ketchup Snapshots</h1>
- #{items.join("\n")}
- </body>
- </html>
- HTML
-
- File.write(output_path, html)
- end
-
class Capture
- def initialize(output_dir:, port:)
+ def initialize(output_dir:)
require "ferrum"
+ require "puma"
+ require "puma/configuration"
@output_dir = output_dir
- @port = port
- @base = "http://127.0.0.1:#{port}"
end
def call
FileUtils.rm_rf(@output_dir)
FileUtils.mkdir_p(@output_dir)
+ config = Puma::Configuration.new do |c|
+ c.app Web.freeze.app
+ c.bind "tcp://127.0.0.1:0"
+ c.log_requests false
+ c.quiet
+ end
+
+ launcher = Puma::Launcher.new(config)
+ thread = Thread.new { launcher.run }
+ sleep 1 until launcher.connected_ports.any?
+
+ @base = "http://127.0.0.1:#{launcher.connected_ports.first}"
user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
@browser = Ferrum::Browser.new(
@@ -190,6 +84,8 @@ module Ketchup
snap("after-complete")
ensure
@browser&.quit
+ launcher&.stop
+ thread&.join
end
private
diff --git a/snapshot_diff.erb b/snapshot_diff.erb
new file mode 100644
index 0000000..2114ce4
--- /dev/null
+++ b/snapshot_diff.erb
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>Ketchup Snapshot Diff</title>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
+ h1 { margin-bottom: 2rem; color: #fff; }
+ .snapshot { margin-bottom: 3rem; }
+ .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
+ .label { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 4px; vertical-align: middle; }
+ .label.new { background: #2d6a4f; color: #b7e4c7; }
+ .label.removed { background: #6a2d2d; color: #e4b7b7; }
+ .pair { display: flex; gap: 1rem; }
+ .side { flex: 1; min-width: 0; }
+ .side h3 { margin-bottom: 0.5rem; color: #999; font-size: 0.875rem; text-transform: uppercase; }
+ .side img { width: 100%; border: 1px solid #333; border-radius: 4px; }
+ .placeholder { padding: 3rem; text-align: center; color: #666; border: 1px dashed #333; border-radius: 4px; }
+ </style>
+</head>
+<body>
+ <h1>Ketchup Snapshot Diff</h1>
+ <% snapshots.each do |snap| %>
+ <div class="snapshot">
+ <h2>
+ <%= snap[:name] %>
+ <% if snap[:status] == :new %>
+ <span class="label new">new</span>
+ <% elsif snap[:status] == :removed %>
+ <span class="label removed">removed</span>
+ <% end %>
+ </h2>
+ <div class="pair">
+ <div class="side">
+ <h3>Baseline</h3>
+ <% if snap[:baseline] %>
+ <img src="<%= snap[:baseline] %>" 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] %>">
+ <% else %>
+ <div class="placeholder">Removed</div>
+ <% end %>
+ </div>
+ </div>
+ </div>
+ <% end %>
+</body>
+</html>
diff --git a/snapshot_gallery.erb b/snapshot_gallery.erb
new file mode 100644
index 0000000..9ddaf8c
--- /dev/null
+++ b/snapshot_gallery.erb
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title><%= title %></title>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+ body { background: #1a1a2e; color: #e0e0e0; font-family: system-ui, sans-serif; padding: 2rem; }
+ h1 { margin-bottom: 2rem; color: #fff; }
+ .snapshot { margin-bottom: 3rem; }
+ .snapshot h2 { margin-bottom: 1rem; color: #ccc; }
+ .snapshot img { max-width: 100%; border: 1px solid #333; border-radius: 4px; }
+ </style>
+</head>
+<body>
+ <h1><%= title %></h1>
+ <% images.each do |image| %>
+ <div class="snapshot">
+ <h2><%= image[:name] %></h2>
+ <img src="<%= image[:filename] %>" alt="<%= image[:name] %>">
+ </div>
+ <% end %>
+</body>
+</html>