Drive snapshots through browser actions instead of DB seeding
Snapshots now exercise real UI flows — creating series, completing
tasks — doubling as integration tests. Replaces the static
SEED_DATA/build_snapshot_list data structure with a Capture class
that drives Ferrum directly.

Assisted-by: Claude Opus 4.6 via Claude Code
change tkxqxltzsxoslptlnyrqlvtqymqvlxnu
commit 2b729dc96b395de54bfcf783efdaa8b700c665e8
author Alpha Chen <alpha@kejadlen.dev>
date
parent vuvwqzqq
diff --git a/Rakefile b/Rakefile
index 6fd85f2..fcf09ce 100644
--- a/Rakefile
+++ b/Rakefile
@@ -101,12 +101,11 @@ namespace :snapshots do
   task :capture do
     ENV["DATABASE_URL"] = ":memory:"
     require "ketchup/web"
-    require "ketchup/seed"
     require "ketchup/snapshots"
     require "puma"
     require "puma/configuration"
 
-    output_dir = File.join(Snapshots.cache_dir, "current")
+    output_dir = File.join(Ketchup::Snapshots.cache_dir, "current")
 
     config = Puma::Configuration.new do |c|
       c.app Web.freeze.app
@@ -120,7 +119,7 @@ namespace :snapshots do
     sleep 1 until launcher.connected_ports.any?
 
     port = launcher.connected_ports.first
-    Snapshots.capture(output_dir: output_dir, port: port)
+    Ketchup::Snapshots::Capture.new(output_dir: output_dir, port: port).call
     launcher.stop
     thread.join
 
@@ -136,7 +135,7 @@ namespace :snapshots do
   task :diff do
     require "ketchup/snapshots"
 
-    base_dir = Snapshots.cache_dir
+    base_dir = Ketchup::Snapshots.cache_dir
     baseline_dir = File.join(base_dir, "baseline")
     current_dir = File.join(base_dir, "current")
 
@@ -157,7 +156,7 @@ namespace :snapshots do
     Rake::Task["snapshots:capture"].invoke
 
     output_path = File.join(base_dir, "diff.html")
-    Snapshots.generate_diff_html(
+    Ketchup::Snapshots.generate_diff_html(
       baseline_dir: baseline_dir,
       current_dir: current_dir,
       output_path: output_path
@@ -168,7 +167,7 @@ namespace :snapshots do
   desc "Capture, diff, and open the viewer"
   task :review do
     Rake::Task["snapshots:diff"].invoke
-    system("open", File.join(Snapshots.cache_dir, "diff.html"))
+    system("open", File.join(Ketchup::Snapshots.cache_dir, "diff.html"))
   end
 
   desc "Generate gallery HTML from images in a directory"
@@ -177,7 +176,7 @@ namespace :snapshots do
 
     images_dir = args.fetch(:images_dir)
     output_path = args.fetch(:output_path)
-    Snapshots.generate_gallery_html(images_dir: images_dir, output_path: output_path)
+    Ketchup::Snapshots.generate_gallery_html(images_dir: images_dir, output_path: output_path)
     puts output_path
   end
 end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 1d85f41..ed84089 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -2,223 +2,228 @@
 
 require "fileutils"
 
-module Snapshots
-  SEED_DATA = [
-    {
-      note: "Call Mom",
-      interval_unit: "week",
-      interval_count: 2,
-      due_date: Date.new(2026, 1, 15),
-      history: [
-        { due_date: Date.new(2026, 1, 1), completed_at: Time.new(2026, 1, 2, 10, 0, 0), note: "Left a message" },
-        { due_date: Date.new(2025, 12, 18), completed_at: Time.new(2025, 12, 18, 14, 0, 0), note: nil }
-      ]
-    },
-    {
-      note: "Water the plants\n\nCheck soil moisture *before* watering",
-      interval_unit: "day",
-      interval_count: 3,
-      due_date: Date.new(2026, 2, 10),
-      history: []
-    },
-    {
-      note: "Clean the kitchen",
-      interval_unit: "week",
-      interval_count: 1,
-      due_date: Date.new(2026, 2, 20),
-      history: [
-        { due_date: Date.new(2026, 2, 13), completed_at: Time.new(2026, 2, 13, 9, 0, 0), note: "Deep cleaned the oven" }
-      ]
-    },
-    {
-      note: "Review finances",
-      interval_unit: "month",
-      interval_count: 1,
-      due_date: Date.new(2026, 3, 1),
-      history: []
-    },
-    {
-      note: "Dentist appointment\n\n**Dr. Chen**, 10am\n555-0142 to reschedule",
-      interval_unit: "quarter",
-      interval_count: 1,
-      due_date: Date.new(2026, 4, 15),
-      history: []
-    }
-  ].freeze
-
-  def self.cache_dir
-    base = ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache"))
-    File.join(base, "ketchup", "snapshots")
-  end
+module Ketchup
+  module Snapshots
+    def self.cache_dir
+      base = ENV.fetch("XDG_CACHE_HOME", File.expand_path("~/.cache"))
+      File.join(base, "ketchup", "snapshots")
+    end
 
-  def self.capture(output_dir:, port:)
-    require "ferrum"
+    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
 
-    FileUtils.rm_rf(output_dir)
-    FileUtils.mkdir_p(output_dir)
+      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
 
-    user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
-    Seed.call(user: user, series: SEED_DATA)
+    def self.generate_gallery_html(images_dir:, output_path:)
+      images = Dir.glob(File.join(images_dir, "*.png")).sort.map { |f| File.basename(f) }
 
-    snapshots = build_snapshot_list(user, port)
+      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
 
-    browser = Ferrum::Browser.new(
-      headless: true,
-      window_size: [1280, 900]
-    )
-    browser.headers.set(
-      "Tailscale-User-Login" => user.login,
-      "Tailscale-User-Name" => user.name
-    )
+      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
 
-    snapshots.each do |snap|
-      browser.goto(snap[:url])
-      # Allow JS/Alpine to initialize
-      sleep 0.3
+    class Capture
+      def initialize(output_dir:, port:)
+        require "ferrum"
 
-      path = File.join(output_dir, "#{snap[:name]}.png")
+        @output_dir = output_dir
+        @port = port
+        @base = "http://127.0.0.1:#{port}"
+      end
 
-      if snap[:selector]
-        browser.screenshot(path: path, selector: snap[:selector])
-      else
-        browser.screenshot(path: path)
+      def call
+        FileUtils.rm_rf(@output_dir)
+        FileUtils.mkdir_p(@output_dir)
+
+        user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
+
+        @browser = Ferrum::Browser.new(
+          headless: true,
+          window_size: [1280, 900]
+        )
+        @browser.headers.set(
+          "Tailscale-User-Login" => user.login,
+          "Tailscale-User-Name" => user.name
+        )
+
+        # Empty dashboard
+        snap("empty-dashboard") do
+          goto @base
+        end
+
+        # Create a series through the UI
+        goto @base
+        fill_new_series(note: "Call Mom", interval_count: 2, interval_unit: "week")
+        @browser.at_css("#create-series-btn").click
+        sleep 0.3
+
+        # Series detail after creation (redirects to detail page)
+        snap("series-detail")
+
+        # Create more series for a populated dashboard
+        [
+          { note: "Water the plants", interval_count: 3, interval_unit: "day" },
+          { note: "Clean the kitchen", interval_count: 1, interval_unit: "week" },
+          { note: "Review finances", interval_count: 1, interval_unit: "month" },
+          { note: "Dentist appointment", interval_count: 1, interval_unit: "quarter" },
+        ].each do |series|
+          goto @base
+          fill_new_series(**series)
+          @browser.at_css("#create-series-btn").click
+          sleep 0.3
+        end
+
+        snap("dashboard") do
+          goto @base
+        end
+
+        # Complete a task
+        goto @base
+        @browser.at_css(".complete-btn").click
+        sleep 0.3
+
+        snap("after-complete")
+      ensure
+        @browser&.quit
       end
-    end
-  ensure
-    browser&.quit
-  end
 
-  def self.build_snapshot_list(user, port)
-    base = "http://127.0.0.1:#{port}"
+      private
 
-    # Find the first series with history (for detail view)
-    detail_series = user.series.find { |s| !s.completed_tasks.empty? }
-    detail_series ||= user.series.first
+      def goto(url)
+        @browser.goto(url)
+        sleep 0.3
+      end
 
-    [
-      { name: "dashboard", url: base, selector: nil },
-      { name: "overdue-column", url: base, selector: ".column:first-child" },
-      { name: "upcoming-column", url: base, selector: ".column:nth-child(2)" },
-      { name: "series-detail", url: "#{base}/series/#{detail_series.id}", selector: nil },
-      { name: "series-sidebar", url: "#{base}/series/#{detail_series.id}", selector: ".column-aside" }
-    ]
-  end
+      def snap(name, selector: nil)
+        yield if block_given?
+        sleep 0.3
 
-  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
+        path = File.join(@output_dir, "#{name}.png")
+        if selector
+          @browser.screenshot(path: path, selector: selector)
+        else
+          @browser.screenshot(path: path)
+        end
+      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 fill_new_series(note:, interval_count: 1, interval_unit: "day")
+        textarea = @browser.at_css("#series-note-editor textarea")
+        textarea.focus
+        textarea.type(note)
 
-  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
+        count_input = @browser.at_css("input[name='interval_count']")
+        count_input.focus
+        count_input.evaluate("this.value = ''")
+        count_input.type(interval_count.to_s)
 
-    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)
+        unit_select = @browser.at_css("select[name='interval_unit']")
+        unit_select.select(interval_unit)
+      end
+    end
   end
 end