Skip to content

Instantly share code, notes, and snippets.

@garriguv
Last active May 8, 2026 14:16
Show Gist options
  • Select an option

  • Save garriguv/42c9654817a76ee633795fb10acbfffb to your computer and use it in GitHub Desktop.

Select an option

Save garriguv/42c9654817a76ee633795fb10acbfffb to your computer and use it in GitHub Desktop.
bin/visual-check: a small Rails CLI that drives a real browser so a coding agent can verify its own UI changes. Companion to https://engineering.getdexter.co/
name visual-check
description How to visually verify UI changes using bin/visual-check. Use when making any UI/frontend change, or when asked to check how the app looks or behaves in the browser.
user-invocable false

Visual Check

bin/visual-check is a CLI that drives a real browser (Capybara + Selenium headless Chrome) against the running bin/dev server. Use it to verify that UI changes look and behave correctly before declaring a frontend task complete.

When to use

  • You edited a ViewComponent, ERB template, or Tailwind class.
  • You changed a flow (form, redirect path, multi-step interaction).
  • You are reproducing or fixing a visual bug.
  • Someone asked "does this look right?"

Prerequisite

bin/dev must be running in another terminal. bin/visual-check does not start the server for you. It assumes the dev server is on port 3000 (override with --port).

Current dev server state:

!curl -sfI --max-time 2 http://localhost:3000/up >/dev/null 2>&1 && echo "dev server reachable on :3000" || echo "dev server NOT reachable on :3000. Start bin/dev yourself in the background, then poll /up before invoking bin/visual-check."

If the server is not reachable, start it yourself. Run bin/dev in the background, then poll http://localhost:3000/up until it answers before invoking bin/visual-check. Don't ask the user to start it.

Basic invocation

Pipe the script into bin/visual-check via a heredoc. This avoids bash escaping issues with quotes, #{}, backticks, and $.

bin/visual-check --as vincent@example.com <<'RUBY'
visit posts_path
screenshot "posts-index"
click_on "New post"
screenshot "new-post-form"
RUBY

Always use a single-quoted heredoc delimiter (<<'RUBY') so the shell does not interpolate your script.

Flags:

  • --as EMAIL: sign in as this dev-stub user (e.g. vincent@example.com). The CLI navigates to /dev/sign_in_as?user=…, the dev-only route the app uses to set the current user for subsequent requests.
  • --account NAME: scope the dev sign-in to this account, for multi-tenant apps. Passed through to /dev/sign_in_as as the account query param.
  • --viewport WxH: default 1440x900.
  • --headed: show the browser window (default is headless).
  • --port N: override port resolution.
  • --run-id ID: override the generated run id.

Script API

Inside the script you have:

  • Capybara DSL: visit, click_on, fill_in, find, within, have_content, page, etc. Same vocabulary as test/system/.
  • Rails models: User.find_by(...), Post.last, etc. The CLI shares the dev-server's database.
  • URL helpers: posts_path, post_path(post), etc.
  • Run-level verbs:
    • screenshot(name) writes a PNG to tmp/visual-check/<run-id>/<name>.png.
    • snapshot(name) writes HTML to tmp/visual-check/<run-id>/<name>.html.
  • Locals (when --as is set):
    • current_user: the User record (matched by email).

Names for screenshot/snapshot must match [a-z0-9][a-z0-9-_]*. This prevents ../etc/passwd-style escapes.

Common patterns

Before/after a click:

bin/visual-check --as vincent@example.com <<'RUBY'
visit post_path(Post.last)
screenshot "post-before"
click_on "Publish"
screenshot "post-after"
RUBY

Walk a flow with one login:

bin/visual-check --as vincent@example.com <<'RUBY'
visit new_post_path
fill_in "Title", with: "Hello world"
fill_in "Body", with: "First post"
click_on "Create Post"
screenshot "post-created"
RUBY

Reproduce a bug:

bin/visual-check --as vincent@example.com <<'RUBY'
visit dashboard_path
screenshot "before-bug"
# steps that trigger the bug
screenshot "after-bug"
RUBY

Reading failures

On any script exception, the CLI writes:

  • failure.png: screenshot of the dying state.
  • failure.html: the current DOM.
  • meta.json: includes exit_code: 1 and an error object (class, message, backtrace).

All under tmp/visual-check/<run-id>/. Read the failure PNG before assuming the script is wrong. Often the page loaded something unexpected (a flash error, an unauthenticated redirect).

Reporting back to the user

After running, always:

  1. Include the run directory path in your response so the user can look at the artifacts.
  2. Summarise what you observed, not just "ran successfully." Example: "The new published badge renders top-right on the post detail page; both default and headed runs look correct. Run: tmp/visual-check/20260508-141522-a3f/."

What this is not

  • Not a replacement for system tests. Tests in test/system/ still own assertion-level regression coverage.
  • Not safe against staging or production. The CLI talks to http://localhost:<port> only.
  • Not a pixel-diff tool. Artifacts are for human/agent interpretation.
#!/usr/bin/env ruby
# frozen_string_literal: true
ENV["RAILS_ENV"] ||= "development"
require_relative "../config/environment"
exit VisualCheck::CLI.new(argv: ARGV, stdin: $stdin, stdout: $stdout, stderr: $stderr).run
# frozen_string_literal: true
require "capybara/dsl"
require "capybara/selenium/driver"
require "digest"
require "fileutils"
require "json"
require "optparse"
require "securerandom"
require "selenium/webdriver"
require "socket"
require "uri"
module VisualCheck
VIEWPORT_PATTERN = /\A(\d+)x(\d+)\z/
def self.parse_viewport(str)
match = VIEWPORT_PATTERN.match(str.to_s)
raise ArgumentError, "viewport must be WxH (got #{str.inspect})" unless match
[match[1].to_i, match[2].to_i]
end
def self.register_driver!(viewport:, headed:, base_url:)
width, height = parse_viewport(viewport)
Capybara.register_driver(:visual_check) do |app|
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new") unless headed
options.add_argument("--window-size=#{width},#{height}")
options.add_argument("--no-sandbox")
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end
Capybara.app_host = base_url
Capybara.run_server = false
Capybara.default_max_wait_time = 5
Capybara.current_driver = :visual_check
end
class DevServer
DEFAULT_PORT = 3000
class Unavailable < StandardError
end
def initialize(explicit_port:)
@explicit_port = explicit_port
end
def port
@port ||= @explicit_port&.to_i || DEFAULT_PORT
end
def base_url
"http://localhost:#{port}"
end
def reachable!(timeout: 2)
Socket.tcp("127.0.0.1", port, connect_timeout: timeout).close
true
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
raise Unavailable, "dev server not reachable at :#{port}. Start bin/dev in another terminal (#{e.class})"
end
end
class RunDirectory
attr_reader :run_id, :screenshots, :snapshots
def initialize(root: default_root, run_id: nil)
@root = Pathname(root)
@run_id = run_id || generate_run_id
@screenshots = []
@snapshots = []
end
def path
@root.join(@run_id)
end
def screenshot_path(name)
ensure_path
path.join("#{name}.png")
end
def snapshot_path(name)
ensure_path
path.join("#{name}.html")
end
def record_screenshot(name)
@screenshots << "#{name}.png"
end
def record_snapshot(name)
@snapshots << "#{name}.html"
end
def write_meta!(started_at:, ended_at:, exit_code:, user:, account:, viewport:, headed:, port:, script_sha256:, error:)
meta = {
run_id: @run_id,
started_at: format_time(started_at),
ended_at: format_time(ended_at),
exit_code: exit_code,
user: user,
account: account,
viewport: viewport,
headed: headed,
port: port,
script_sha256: script_sha256,
screenshots: @screenshots,
snapshots: @snapshots,
error: error
}
ensure_path
path.join("meta.json").write(JSON.pretty_generate(meta))
end
private
def default_root
Rails.root.join("tmp/visual-check")
end
def ensure_path
FileUtils.mkdir_p(path)
end
def generate_run_id
now = Time.now.utc
"#{now.strftime("%Y%m%d-%H%M%S")}-#{SecureRandom.alphanumeric(3).downcase}"
end
def format_time(time)
time.strftime("%FT%T%:z")
end
end
class ScriptContext
include Capybara::DSL
include Rails.application.routes.url_helpers
NAME_PATTERN = /\A[a-z0-9][a-z0-9\-_]*\z/i
attr_reader :current_user
def initialize(run_directory:, capybara_session:, current_user:)
@run_directory = run_directory
@capybara_session = capybara_session
@current_user = current_user
end
# Capybara::DSL delegates to Capybara.current_session by default;
# override so the script always drives the session we were handed.
def page
@capybara_session
end
def current_session
@capybara_session
end
def screenshot(name)
validate_name!(name)
@capybara_session.save_screenshot(@run_directory.screenshot_path(name).to_s)
@run_directory.record_screenshot(name)
name
end
def snapshot(name)
validate_name!(name)
@run_directory.snapshot_path(name).write(@capybara_session.html)
@run_directory.record_snapshot(name)
name
end
def evaluate(source)
instance_eval(source, "(visual-check-script)", 1)
end
private
def validate_name!(name)
raise ArgumentError, "name must match #{NAME_PATTERN.source}" unless NAME_PATTERN.match?(name.to_s)
end
end
class CLI
Options = Struct.new(
:email, :account, :viewport, :headed, :explicit_port, :run_id, :help, :help_text,
keyword_init: true
)
def self.parse_argv(argv)
options = Options.new(viewport: "1440x900", headed: false, help: false)
parser = OptionParser.new do |o|
o.banner = "Usage: bin/visual-check [options] < script.rb"
o.on("--as EMAIL", "Sign in as this dev-stub user") { |v| options.email = v }
o.on("--account NAME", "Scope the dev sign-in to this account (multi-tenant apps)") { |v| options.account = v }
o.on("--viewport WxH", "Browser viewport (default: 1440x900)") { |v| options.viewport = v }
o.on("--headed", "Show the browser window (default: headless)") { options.headed = true }
o.on("--port N", Integer, "Override port resolution") { |v| options.explicit_port = v }
o.on("--run-id ID", "Override generated run id") { |v| options.run_id = v }
o.on("-h", "--help") { options.help = true }
end
parser.parse!(argv.dup)
options.help_text = parser.to_s
options
end
def initialize(argv:, stdin:, stdout:, stderr:)
@argv = argv
@stdin = stdin
@stdout = stdout
@stderr = stderr
end
def run
options = self.class.parse_argv(@argv)
if options.help
@stdout.puts options.help_text
return 0
end
script_source = @stdin.read
dev_server = resolve_dev_server(options)
user = resolve_user(options.email)
run_dir = announce_run(dev_server, user, options.account, options.run_id)
session = build_session(dev_server, options)
drive_session(session, run_dir, dev_server, user, options, script_source)
rescue DevServer::Unavailable, ActiveRecord::RecordNotFound, ArgumentError => e
@stderr.puts "error: #{e.message}"
2
end
private
def resolve_dev_server(options)
DevServer.new(explicit_port: options.explicit_port).tap do |server|
server.port # raises Unavailable if unresolvable
server.reachable!
end
end
def resolve_user(email)
email ? User.find_by!(email: email) : nil
end
def announce_run(dev_server, user, account, run_id)
@stdout.puts "▸ dev server: #{dev_server.base_url}"
if user
label = account ? "#{user.email} (#{account})" : user.email
@stdout.puts "▸ logged in as #{label}"
end
RunDirectory.new(run_id: run_id).tap do |run_dir|
@stdout.puts "▸ run id: #{run_dir.run_id}"
end
end
def build_session(dev_server, options)
VisualCheck.register_driver!(
viewport: options.viewport,
headed: options.headed,
base_url: dev_server.base_url
)
Capybara::Session.new(:visual_check)
end
def drive_session(session, run_dir, dev_server, user, options, script_source)
started_at = Time.now.utc
session.visit(sign_in_path(user.email, options.account)) if user
context = ScriptContext.new(
run_directory: run_dir,
capybara_session: session,
current_user: user
)
exit_code, error_meta = run_script(context, script_source, run_dir, session)
run_dir.write_meta!(
started_at: started_at,
ended_at: Time.now.utc,
exit_code: exit_code,
user: user&.email,
account: options.account,
viewport: options.viewport,
headed: options.headed,
port: dev_server.port,
script_sha256: Digest::SHA256.hexdigest(script_source),
error: error_meta
)
print_summary(exit_code, run_dir)
exit_code
ensure
session.quit
end
def sign_in_path(email, account)
params = {user: email}
params[:account] = account if account
"/dev/sign_in_as?#{URI.encode_www_form(params)}"
end
def run_script(context, source, run_dir, session)
context.evaluate(source)
[0, nil]
rescue StandardError => e
@stderr.puts "✗ error: #{e.class}: #{e.message}"
e.backtrace.first(20).each { |line| @stderr.puts " #{line}" }
capture_failure(session, run_dir)
[1, {class: e.class.name, message: e.message, backtrace: e.backtrace}]
end
def capture_failure(session, run_dir)
session.save_screenshot(run_dir.screenshot_path("failure").to_s)
run_dir.record_screenshot("failure")
run_dir.snapshot_path("failure").write(session.html)
run_dir.record_snapshot("failure")
rescue StandardError => e
@stderr.puts "(failed to capture failure artifacts: #{e.class})"
end
def print_summary(exit_code, run_dir)
run_dir.screenshots.each do |name|
label = name.start_with?("failure.") ? "failure screenshot" : "screenshot"
@stdout.puts "▸ #{label}: #{run_dir.path.join(name)}"
end
run_dir.snapshots.each do |name|
label = name.start_with?("failure.") ? "failure snapshot" : "snapshot"
@stdout.puts "▸ #{label}: #{run_dir.path.join(name)}"
end
if exit_code.zero?
@stdout.puts "✓ done. #{run_dir.screenshots.size} screenshots, #{run_dir.snapshots.size} snapshots in #{run_dir.path}"
else
@stdout.puts "exit #{exit_code}"
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment