Poll for expected DOM state instead of sleeping
Arbitrary sleeps masked timing bugs and slowed captures. wait_for
polls for a CSS selector, failing fast once the element appears.
Also: log to stderr, inline output_dir, move templates into
templates/, and pass ERB variables explicitly via result_with_hash.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/BACKLOG.md b/BACKLOG.md
index ffd2578..934ddbd 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -16,3 +16,4 @@
- Fixed-schedule recurrence (e.g., "every Monday" vs "7 days after completion")
- Shared tasks — visible to all users, anyone can mark done
- CLI interface
+- Don't re-download baseline for snapshots unnecessarily
diff --git a/Rakefile b/Rakefile
index 6b474d1..fe857cb 100644
--- a/Rakefile
+++ b/Rakefile
@@ -100,17 +100,14 @@ namespace :snapshots do
desc "Capture screenshots of the app in key states"
task :capture do
ENV["DATABASE_URL"] = ":memory:"
- require "ketchup/web"
require "ketchup/snapshots"
- output_dir = File.join(Ketchup::Snapshots.cache_dir, "current")
- Ketchup::Snapshots::Capture.new(output_dir: output_dir).call
+ capture = Ketchup::Snapshots::Capture.new
+ capture.call
if ENV["CI"]
require "json"
- puts({ output_dir: output_dir }.to_json)
- else
- puts "Screenshots saved to #{output_dir}"
+ puts({ output_dir: capture.output_dir }.to_json)
end
end
@@ -155,9 +152,9 @@ namespace :snapshots do
}
end
- template = File.read(File.expand_path("snapshot_diff.erb", __dir__))
+ template = File.read(File.expand_path("templates/snapshot_diff.erb", __dir__))
output_path = File.join(base_dir, "diff.html")
- File.write(output_path, ERB.new(template, trim_mode: "-").result(binding))
+ File.write(output_path, ERB.new(template, trim_mode: "-").result_with_hash(snapshots: snapshots))
puts "Diff viewer: #{output_path}"
end
@@ -178,8 +175,8 @@ namespace :snapshots do
{ 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))
+ template = File.read(File.expand_path("templates/snapshot_gallery.erb", __dir__))
+ File.write(output_path, ERB.new(template, trim_mode: "-").result_with_hash(title: title, images: images))
puts output_path
end
end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 06a0657..b54600a 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -1,6 +1,13 @@
# frozen_string_literal: true
require "fileutils"
+require "logger"
+
+require "ferrum"
+require "puma"
+require "puma/configuration"
+
+require_relative "web"
module Ketchup
module Snapshots
@@ -10,12 +17,11 @@ module Ketchup
end
class Capture
- def initialize(output_dir:)
- require "ferrum"
- require "puma"
- require "puma/configuration"
+ attr_reader :output_dir
- @output_dir = output_dir
+ def initialize(logger: Logger.new($stderr))
+ @output_dir = File.join(Snapshots.cache_dir, "current")
+ @logger = logger
end
def call
@@ -30,10 +36,17 @@ module Ketchup
end
launcher = Puma::Launcher.new(config)
+ saved_out, saved_err = $stdout.dup, $stderr.dup
+ $stdout.reopen(File::NULL)
+ $stderr.reopen(File::NULL)
thread = Thread.new { launcher.run }
- sleep 1 until launcher.connected_ports.any?
+ sleep 0.1 until launcher.connected_ports.any?
+ $stdout.reopen(saved_out)
+ $stderr.reopen(saved_err)
- @base = "http://127.0.0.1:#{launcher.connected_ports.first}"
+ port = launcher.connected_ports.first
+ @base = "http://127.0.0.1:#{port}"
+ @logger.info("Server listening on port #{port}")
user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
@browser = Ferrum::Browser.new(
@@ -48,13 +61,15 @@ module Ketchup
# Empty dashboard
snap("empty-dashboard") do
goto @base
+ wait_for(".home")
end
# Create a series through the UI
goto @base
+ wait_for("#new-series-form")
fill_new_series(note: "Call Mom", interval_count: 2, interval_unit: "week")
@browser.at_css("#create-series-btn").click
- sleep 0.3
+ wait_for("#series-note-detail")
# Series detail after creation (redirects to detail page)
snap("series-detail")
@@ -67,37 +82,54 @@ module Ketchup
{ note: "Dentist appointment", interval_count: 1, interval_unit: "quarter" },
].each do |series|
goto @base
+ wait_for("#new-series-form")
fill_new_series(**series)
@browser.at_css("#create-series-btn").click
- sleep 0.3
+ wait_for("#series-note-detail")
end
snap("dashboard") do
goto @base
+ wait_for(".home")
end
# Complete a task
goto @base
- @browser.at_css(".complete-btn").click
- sleep 0.3
+ wait_for(".complete-btn").click
+ wait_for(".task-history")
snap("after-complete")
ensure
@browser&.quit
- launcher&.stop
- thread&.join
+ if launcher
+ $stdout.reopen(File::NULL)
+ $stderr.reopen(File::NULL)
+ launcher.stop
+ thread&.join
+ $stdout.reopen(saved_out)
+ $stderr.reopen(saved_err)
+ end
end
private
def goto(url)
@browser.goto(url)
- sleep 0.3
+ end
+
+ def wait_for(selector, timeout: 5)
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ loop do
+ node = @browser.at_css(selector)
+ return node if node
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
+ raise Ferrum::TimeoutError, "waiting for #{selector}" if elapsed > timeout
+ sleep 0.05
+ end
end
def snap(name, selector: nil)
yield if block_given?
- sleep 0.3
path = File.join(@output_dir, "#{name}.png")
if selector
@@ -105,6 +137,7 @@ module Ketchup
else
@browser.screenshot(path: path)
end
+ @logger.info(name)
end
def fill_new_series(note:, interval_count: 1, interval_unit: "day")
diff --git a/snapshot_diff.erb b/templates/snapshot_diff.erb
similarity index 100%
rename from snapshot_diff.erb
rename to templates/snapshot_diff.erb
diff --git a/snapshot_gallery.erb b/templates/snapshot_gallery.erb
similarity index 100%
rename from snapshot_gallery.erb
rename to templates/snapshot_gallery.erb