Add typed manifest entries for snapshot metadata
Snapshot names alone lost context about which route and CSS selector
produced each screenshot. A typed Entry carries path and selector
through capture, manifest, and into the diff/gallery views. Reading
both baseline and current manifests lets removed snapshots retain
their metadata in diffs.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/Gemfile.lock b/Gemfile.lock
index e661379..38cf10f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -29,7 +29,6 @@ GEM
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
- ffi (1.17.3)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
@@ -180,7 +179,6 @@ CHECKSUMS
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
ferrum (0.17.1) sha256=51d591120fc593e5a13b5d9d6474389f5145bb92a91e36eab147b5d096c8cbe7
- ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
ffi (1.17.3-aarch64-linux-gnu) sha256=28ad573df26560f0aedd8a90c3371279a0b2bd0b4e834b16a2baa10bd7a97068
ffi (1.17.3-aarch64-linux-musl) sha256=020b33b76775b1abacc3b7d86b287cef3251f66d747092deec592c7f5df764b2
ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
diff --git a/Rakefile b/Rakefile
index 286d899..5ec030b 100644
--- a/Rakefile
+++ b/Rakefile
@@ -69,7 +69,7 @@ namespace :snapshots do
desc "Compare current screenshots against baseline from latest release"
task diff: [:capture, *css_targets] do
require "erb"
- require "json"
+ require "ketchup/snapshots"
base_dir = Pathname(cache_dir)
baseline_dir = base_dir / "baseline"
@@ -89,15 +89,16 @@ namespace :snapshots do
puts "No baseline found — showing current screenshots only"
end
- manifest = current_dir / "manifest.json"
- manifest_entries = manifest.exist? ? JSON.parse(manifest.read) : []
- order = manifest_entries.map { |e| e["name"] }
- paths = manifest_entries.each_with_object({}) { |e, h| h[e["name"]] = e["path"] }
+ 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)
@@ -107,7 +108,8 @@ namespace :snapshots do
{
name: name,
- path: paths[name],
+ path: entry.path,
+ selector: entry.selector,
status: status,
baseline: has_baseline ? "baseline/#{name}.png" : nil,
current: has_current ? "current/#{name}.png" : nil,
@@ -128,24 +130,24 @@ namespace :snapshots do
desc "Generate gallery HTML from images in a directory"
task :gallery, [:images_dir, :output_path] do |_t, args|
require "erb"
- require "json"
+ require "ketchup/snapshots"
images_dir = Pathname(args.fetch(:images_dir) { File.join(cache_dir, "current") })
output_path = Pathname(args.fetch(:output_path) { File.join(cache_dir, "gallery.html") })
css_sources.each_key { |src| cp src, output_path.dirname.to_s }
- manifest = images_dir / "manifest.json"
- manifest_entries = manifest.exist? ? JSON.parse(manifest.read) : []
- order = manifest_entries.map { |e| e["name"] }
- paths = manifest_entries.each_with_object({}) { |e, h| h[e["name"]] = e["path"] }
+ 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|
- { name: name, path: paths[name], filename: (images_rel / "#{name}.png").to_s }
+ entry = by_name.fetch(name)
+ { name: name, path: entry.path, selector: entry.selector, filename: (images_rel / "#{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 40f3aa3..b337214 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -15,6 +15,17 @@ require_relative "web"
module Ketchup
module Snapshots
+ Entry = Data.define(:name, :path, :selector) do
+ def initialize(name:, path:, selector: nil) = super
+
+ def self.read_manifest(dir)
+ manifest = dir / "manifest.json"
+ return [] unless manifest.exist?
+
+ JSON.parse(manifest.read).map { |e| new(name: e.fetch("name"), path: e.fetch("path"), selector: e["selector"]) }
+ end
+ end
+
class Capture
def initialize(output_dir:, logger: Logger.new($stderr), &server)
@output_dir = Pathname(output_dir)
@@ -77,7 +88,7 @@ module Ketchup
wait_for(".task-history")
entries << snap("after-complete")
- (@output_dir / "manifest.json").write(JSON.pretty_generate(entries))
+ (@output_dir / "manifest.json").write(JSON.pretty_generate(entries.map(&:to_h)))
end
def default_server(browser)
@@ -144,7 +155,7 @@ module Ketchup
end
url_path = URI.parse(@browser.current_url).path
@logger.info(name)
- { name: name, path: url_path }
+ Entry.new(name: name, path: url_path, selector: selector)
end
def fill_new_series(note:, interval_count: 1, interval_unit: "day")
diff --git a/templates/snapshot_diff.erb b/templates/snapshot_diff.erb
index ebcb15a..496d478 100644
--- a/templates/snapshot_diff.erb
+++ b/templates/snapshot_diff.erb
@@ -13,15 +13,15 @@
<div class="snapshot">
<h2>
<%= snap[:name] %>
- <% if snap[:path] -%>
- <code><%= snap[:path] %></code>
- <% end -%>
<% if snap[:status] == :new %>
<span class="label new">new</span>
<% elsif snap[:status] == :removed %>
<span class="label removed">removed</span>
<% end %>
</h2>
+ <% if snap[:path] -%>
+ <code class="path"><%= snap[:path] %><%= " #{snap[:selector]}" if snap[:selector] %></code>
+ <% end -%>
<div class="pair">
<div class="side">
<h3>Baseline</h3>
diff --git a/templates/snapshot_gallery.erb b/templates/snapshot_gallery.erb
index 8956852..06acfd0 100644
--- a/templates/snapshot_gallery.erb
+++ b/templates/snapshot_gallery.erb
@@ -11,7 +11,8 @@
<h1><%= title %></h1>
<% images.each do |image| %>
<div class="snapshot">
- <h2><%= image[:name] %><% if image[:path] %> <code><%= image[:path] %></code><% end %></h2>
+ <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] %>">
</div>
<% end %>
diff --git a/templates/snapshots.css b/templates/snapshots.css
index a8b2911..290fa5e 100644
--- a/templates/snapshots.css
+++ b/templates/snapshots.css
@@ -9,7 +9,8 @@ body {
h1 { margin-bottom: var(--space-l); color: #fff; }
.snapshot { margin-bottom: var(--space-xl); }
-.snapshot h2 { margin-bottom: var(--space-s); color: #ccc; }
+.snapshot h2 { margin-bottom: 0; color: #ccc; }
+.snapshot .path { display: block; margin-bottom: var(--space-s); font-size: var(--step--1); color: #888; }
.snapshot img { max-width: 100%; border: 1px solid #333; border-radius: 4px; display: block; margin-inline: auto; }
/* Diff-specific */