Created
November 4, 2025 03:11
-
-
Save johnsyweb/fad45365661de4523d6e7f8f5ba85703 to your computer and use it in GitHub Desktop.
Script to capture before/after screenshots of website changes using Jekyll server and Chrome headless
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Script to capture before/after screenshots of the about page | |
| # and create a composite image showing the font change | |
| # | |
| # This script should be run in an environment where Ruby is managed by mise | |
| # (or similar tool) to ensure the correct Ruby version is available. | |
| require 'fileutils' | |
| require 'open3' | |
| require 'timeout' | |
| require 'net/http' | |
| require 'uri' | |
| BEFORE_COMMIT = '0a6a959' # Commit before all font/filename changes | |
| AFTER_COMMIT = '0b33c79' # Commit with font changes (Atkinson Hyperlegible) | |
| OUTPUT_DIR = File.join(__dir__, '..', 'tmp', 'font_comparison') | |
| BEFORE_SCREENSHOT = File.join(OUTPUT_DIR, 'before.png') | |
| AFTER_SCREENSHOT = File.join(OUTPUT_DIR, 'after.png') | |
| COMPOSITE_IMAGE = File.join(__dir__, '..', 'images', '2025-11-04-about-page-font-comparison.jpg') | |
| JEKYLL_PORT = 4000 | |
| ABOUT_URL = "http://localhost:#{JEKYLL_PORT}/about/" | |
| def run_command(cmd, description) | |
| puts "→ #{description}..." | |
| start_time = Time.now | |
| stdout, stderr, status = Open3.capture3(cmd) | |
| elapsed = Time.now - start_time | |
| unless status.success? | |
| puts "Error: #{description}" | |
| puts stderr unless stderr.empty? | |
| exit 1 | |
| end | |
| puts " ✓ Completed in #{elapsed.round(2)}s" | |
| stdout | |
| end | |
| def check_dependencies | |
| puts "Checking dependencies..." | |
| # Check for Chrome/Chromium | |
| chrome_paths = [ | |
| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', | |
| '/Applications/Chromium.app/Contents/MacOS/Chromium', | |
| `which google-chrome 2>/dev/null`.strip, | |
| `which chromium 2>/dev/null`.strip | |
| ].compact.reject(&:empty?) | |
| chrome = chrome_paths.find { |path| File.exist?(path) } | |
| unless chrome | |
| puts "Error: Chrome or Chromium not found. Please install Chrome." | |
| puts " brew install --cask google-chrome" | |
| exit 1 | |
| end | |
| # Check for ImageMagick | |
| unless system('which convert > /dev/null 2>&1') | |
| puts "Error: ImageMagick not found. Install with:" | |
| puts " brew install imagemagick" | |
| exit 1 | |
| end | |
| puts "✓ Dependencies OK" | |
| chrome | |
| end | |
| def start_jekyll_server | |
| puts "→ Starting Jekyll server on port #{JEKYLL_PORT}..." | |
| # Start Jekyll server in background | |
| pid = spawn('mise', 'exec', '--', 'bundle', 'exec', 'jekyll', 'serve', '--port', JEKYLL_PORT.to_s, '--host', 'localhost', out: '/dev/null', err: '/dev/null') | |
| # Wait for server to be ready | |
| max_attempts = 30 | |
| attempt = 0 | |
| while attempt < max_attempts | |
| begin | |
| uri = URI(ABOUT_URL) | |
| http = Net::HTTP.new(uri.host, uri.port) | |
| http.open_timeout = 1 | |
| http.read_timeout = 1 | |
| response = http.get(uri.path) | |
| if response.code == '200' | |
| puts " ✓ Jekyll server ready" | |
| return pid | |
| end | |
| rescue => e | |
| # Server not ready yet, wait a bit | |
| sleep 0.5 | |
| end | |
| attempt += 1 | |
| end | |
| # If we get here, server didn't start | |
| Process.kill('TERM', pid) if pid | |
| puts "Error: Jekyll server did not start within 15 seconds" | |
| exit 1 | |
| end | |
| def stop_jekyll_server(pid) | |
| if pid | |
| puts "→ Stopping Jekyll server..." | |
| Process.kill('TERM', pid) | |
| Process.wait(pid) rescue nil | |
| puts " ✓ Jekyll server stopped" | |
| end | |
| end | |
| def capture_screenshot(url, output_path, chrome_path) | |
| puts "→ Capturing screenshot of #{url}..." | |
| start_time = Time.now | |
| # Check if we should run in headless mode (default: true, set HEADLESS=false to disable) | |
| headless = ENV['HEADLESS'] != 'false' | |
| # Use Chrome without user data dir, just disable caching | |
| # iPhone-sized viewport (390x844 is iPhone 12/13 size) | |
| cmd = [ | |
| chrome_path | |
| ] | |
| if headless | |
| cmd << '--headless=new' | |
| cmd << '--window-size=390,844' # iPhone-sized viewport | |
| cmd << "--screenshot=#{output_path}" | |
| else | |
| cmd << '--window-size=390,844' | |
| cmd << '--window-position=0,0' | |
| puts " → Running Chrome in non-headless mode (window will appear briefly)..." | |
| puts " → Press Ctrl+C to cancel, or wait for screenshot..." | |
| end | |
| cmd.concat([ | |
| '--hide-scrollbars', | |
| '--no-first-run', | |
| '--disable-logging', | |
| '--disable-extensions', | |
| '--disable-background-networking', | |
| '--disable-dev-shm-usage', | |
| '--disable-sync', | |
| '--disable-translate', | |
| '--disk-cache-size=1', | |
| '--v8-cache-options=none', | |
| url | |
| ]) | |
| # Always log the Chrome command line | |
| puts " → Running Chrome command: #{cmd.join(' ')}" | |
| # Show Chrome output if not in headless mode or if DEBUG is set | |
| show_output = !headless || ENV['DEBUG'] == 'true' | |
| if headless | |
| # Run Chrome with timeout in headless mode | |
| # Use system instead of Open3.capture3 to avoid threading issues with Chrome | |
| begin | |
| Timeout.timeout(30) do | |
| if show_output | |
| success = system(*cmd) | |
| unless success | |
| puts " Error: Chrome exited with non-zero status" | |
| exit 1 | |
| end | |
| else | |
| # Redirect output to /dev/null for quiet operation | |
| success = system(*cmd, out: '/dev/null', err: '/dev/null') | |
| unless success | |
| # If it failed, try again with stderr visible to see what went wrong | |
| puts " Error: Chrome screenshot failed, trying again with output visible..." | |
| system(*cmd, out: '/dev/null') | |
| exit 1 | |
| end | |
| end | |
| end | |
| rescue Timeout::Error | |
| puts "Error: Chrome screenshot timed out after 30 seconds" | |
| exit 1 | |
| end | |
| else | |
| # In non-headless mode, just run Chrome and let user see what happens | |
| puts " → Starting Chrome (non-headless) - window will appear..." | |
| puts " → Chrome will stay open so you can see what's happening" | |
| puts " → Press Ctrl+C in this terminal to continue after inspecting..." | |
| system(*cmd) | |
| puts " → Note: Screenshot was not captured in non-headless mode" | |
| puts " → Set HEADLESS=true or remove HEADLESS env var to use headless mode" | |
| return | |
| end | |
| # Wait a moment for file to be written | |
| sleep 1 | |
| unless File.exist?(output_path) | |
| puts "Error: Screenshot file was not created" | |
| exit 1 | |
| end | |
| elapsed = Time.now - start_time | |
| file_size = File.size(output_path) | |
| puts " ✓ Screenshot saved to #{output_path} (#{file_size} bytes) in #{elapsed.round(2)}s" | |
| end | |
| def create_composite | |
| puts "→ Creating composite image..." | |
| start_time = Time.now | |
| # Get image dimensions | |
| before_info = `identify #{BEFORE_SCREENSHOT}`.split | |
| after_info = `identify #{AFTER_SCREENSHOT}`.split | |
| before_size = before_info[2] # e.g., "390x844" | |
| after_size = after_info[2] | |
| before_width, before_height = before_size.split('x').map(&:to_i) | |
| after_width, after_height = after_size.split('x').map(&:to_i) | |
| # Use same height for both, resize proportionally | |
| target_height = [before_height, after_height].min | |
| before_target_width = (before_width * target_height / before_height.to_f).to_i | |
| after_target_width = (after_width * target_height / after_height.to_f).to_i | |
| total_width = before_target_width + after_target_width | |
| label_height = 50 | |
| padding = 10 | |
| # Solarized-dark colors | |
| bg_color = '#002a35' # solarized-dark background | |
| text_color = '#839496' # solarized-dark text | |
| accent_color = '#2aa198' # solarized-dark accent | |
| # Create composite with labels | |
| # Use system with array to avoid shell escaping issues | |
| # Calculate center positions for each label area | |
| before_center_x = padding + before_target_width / 2 | |
| after_center_x = padding + before_target_width + after_target_width / 2 | |
| label_y = label_height / 2 | |
| # Create label images separately for proper centering | |
| before_label_file = File.join(OUTPUT_DIR, 'before_label.png') | |
| after_label_file = File.join(OUTPUT_DIR, 'after_label.png') | |
| # Create label images | |
| label_cmd1 = [ | |
| 'convert', | |
| '-size', "#{before_target_width}x#{label_height}", | |
| "xc:#{bg_color}", | |
| '-font', 'AtkinsonHyperlegible-Bold', | |
| '-pointsize', '22', | |
| '-fill', text_color, | |
| '-gravity', 'Center', | |
| '-annotate', '+0+0', 'Before (Verdana)', | |
| before_label_file | |
| ] | |
| label_cmd2 = [ | |
| 'convert', | |
| '-size', "#{after_target_width}x#{label_height}", | |
| "xc:#{bg_color}", | |
| '-font', 'AtkinsonHyperlegible-Bold', | |
| '-pointsize', '22', | |
| '-fill', text_color, | |
| '-gravity', 'Center', | |
| '-annotate', '+0+0', 'After (Atkinson Hyperlegible)', | |
| after_label_file | |
| ] | |
| Open3.capture3(*label_cmd1) | |
| Open3.capture3(*label_cmd2) | |
| # Composite everything together | |
| cmd = [ | |
| 'convert', | |
| '-size', "#{total_width + padding * 2}x#{target_height + label_height + padding * 2}", | |
| "xc:#{bg_color}", | |
| '(', before_label_file, ')', | |
| '-geometry', "+#{padding}+#{padding}", '-composite', | |
| '(', after_label_file, ')', | |
| '-geometry', "+#{padding + before_target_width}+#{padding}", '-composite', | |
| '(', BEFORE_SCREENSHOT, '-resize', "#{before_target_width}x#{target_height}", ')', | |
| '-geometry', "+#{padding}+#{label_height + padding}", '-composite', | |
| '(', AFTER_SCREENSHOT, '-resize', "#{after_target_width}x#{target_height}", ')', | |
| '-geometry', "+#{before_target_width + padding}+#{label_height + padding}", '-composite', | |
| '-quality', '90', | |
| COMPOSITE_IMAGE | |
| ] | |
| stdout, stderr, status = Open3.capture3(*cmd) | |
| unless status.success? | |
| puts "Error: Creating composite image" | |
| puts stderr unless stderr.empty? | |
| exit 1 | |
| end | |
| elapsed = Time.now - start_time | |
| puts " ✓ Composite image saved to #{COMPOSITE_IMAGE} in #{elapsed.round(2)}s" | |
| end | |
| def verify_font_usage(should_have_atkinson) | |
| # Check the CSS file for Atkinson Hyperlegible | |
| # Try both possible CSS filenames (style.css or style-20150308.css) | |
| css_dir = File.join(__dir__, '..', 'assets', 'css') | |
| css_file = File.join(css_dir, 'style.css') | |
| old_css_file = File.join(css_dir, 'style-20150308.css') | |
| # Find which CSS file exists | |
| actual_css_file = if File.exist?(css_file) | |
| css_file | |
| elsif File.exist?(old_css_file) | |
| old_css_file | |
| else | |
| nil | |
| end | |
| unless actual_css_file | |
| puts " Warning: CSS file not found (checked style.css and style-20150308.css), skipping verification" | |
| return | |
| end | |
| css_content = File.read(actual_css_file) | |
| has_atkinson = css_content.include?('Atkinson Hyperlegible') | |
| if should_have_atkinson && !has_atkinson | |
| puts " Error: Expected to find 'Atkinson Hyperlegible' in CSS (#{File.basename(actual_css_file)}), but it's not present" | |
| exit 1 | |
| elsif !should_have_atkinson && has_atkinson | |
| puts " Error: Found 'Atkinson Hyperlegible' in CSS (#{File.basename(actual_css_file)}), but it should not be present" | |
| exit 1 | |
| end | |
| puts " ✓ Font verification passed (#{should_have_atkinson ? 'has' : 'does not have'} Atkinson Hyperlegible in #{File.basename(actual_css_file)})" | |
| end | |
| def checkout_commit(commit) | |
| current_branch = `git rev-parse --abbrev-ref HEAD`.strip | |
| puts "→ Checking out commit #{commit}..." | |
| # Stash any uncommitted changes to avoid them affecting the build | |
| has_stash = false | |
| unless system('git diff --quiet && git diff --cached --quiet') | |
| puts " → Stashing uncommitted changes..." | |
| system('git stash push -m "Auto-stash for font comparison script"') | |
| has_stash = true | |
| end | |
| run_command("git checkout #{commit}", "Checking out commit") | |
| # Ensure bundle dependencies are up to date | |
| # Use mise exec to ensure correct Ruby version for this commit | |
| puts "→ Checking bundle dependencies..." | |
| unless system('mise exec -- bundle check > /dev/null 2>&1') | |
| puts " → Running bundle install..." | |
| # Use mise exec to ensure correct Ruby version | |
| stdout, stderr, status = Open3.capture3('mise exec -- bundle install') | |
| unless status.success? | |
| if stderr.include?('requires ruby version') || stderr.include?('incompatible with the current version') | |
| puts " Warning: bundle install failed due to Ruby version mismatch" | |
| puts " → Continuing anyway (gems may already be installed)..." | |
| else | |
| puts " Error: bundle install failed" | |
| puts stderr unless stderr.empty? | |
| exit 1 | |
| end | |
| else | |
| puts " ✓ Bundle dependencies installed" | |
| end | |
| else | |
| puts " ✓ Bundle dependencies OK" | |
| end | |
| [current_branch, has_stash] | |
| end | |
| # Main execution | |
| puts "Font Comparison Screenshot Script" | |
| puts "=" * 50 | |
| # Setup | |
| FileUtils.mkdir_p(OUTPUT_DIR) | |
| FileUtils.mkdir_p(File.dirname(COMPOSITE_IMAGE)) | |
| # Remove old screenshots before starting | |
| puts "→ Cleaning up old screenshots..." | |
| FileUtils.rm_f(BEFORE_SCREENSHOT) if File.exist?(BEFORE_SCREENSHOT) | |
| FileUtils.rm_f(AFTER_SCREENSHOT) if File.exist?(AFTER_SCREENSHOT) | |
| FileUtils.rm_f(COMPOSITE_IMAGE) if File.exist?(COMPOSITE_IMAGE) | |
| puts " ✓ Cleanup complete" | |
| chrome_path = check_dependencies | |
| # Save current branch and stash state | |
| original_branch = `git rev-parse --abbrev-ref HEAD`.strip | |
| stash_was_created = false | |
| begin | |
| # Capture BEFORE screenshot | |
| puts "\n--- Capturing BEFORE screenshot ---" | |
| original_branch, stash_was_created = checkout_commit(BEFORE_COMMIT) | |
| unless system('which mise > /dev/null 2>&1') | |
| puts "Error: mise not found. Please install mise:" | |
| puts " curl https://mise.run | sh" | |
| exit 1 | |
| end | |
| verify_font_usage(false) # Before should NOT have Atkinson Hyperlegible | |
| jekyll_pid = start_jekyll_server | |
| begin | |
| capture_screenshot(ABOUT_URL, BEFORE_SCREENSHOT, chrome_path) | |
| ensure | |
| stop_jekyll_server(jekyll_pid) | |
| end | |
| # Capture AFTER screenshot | |
| puts "\n--- Capturing AFTER screenshot ---" | |
| checkout_commit(AFTER_COMMIT) | |
| verify_font_usage(true) # After SHOULD have Atkinson Hyperlegible | |
| jekyll_pid = start_jekyll_server | |
| begin | |
| capture_screenshot(ABOUT_URL, AFTER_SCREENSHOT, chrome_path) | |
| ensure | |
| stop_jekyll_server(jekyll_pid) | |
| end | |
| # Create composite | |
| puts "\n--- Creating composite image ---" | |
| create_composite | |
| puts "\n✓ Done! Composite image created at: #{COMPOSITE_IMAGE}" | |
| ensure | |
| # Cleanup | |
| if original_branch | |
| run_command("git checkout #{original_branch}", "Restoring original branch") | |
| if stash_was_created | |
| puts "→ Restoring stashed changes..." | |
| system('git stash pop') | |
| end | |
| end | |
| end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment