From a3565f7f53302b387c019baf66e05da8d0169c4a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:00:01 +0200 Subject: [PATCH 1/8] Post a before/after screenshot gallery on every stylesheet pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing a change to this theme means opening an admin and looking at it, which is slow enough that it does not happen. This shoots the looking. `rake screens` generates a throwaway Rails app with ActiveAdmin installed, compiles the theme twice — once from the pull request's base, once from its head — and takes the same set of screenshots against each. A pull request then gets one comment with a before/after row per scenario, so the whole visual effect of a change is on the page without checking anything out. Three decisions worth stating: * The dummy app supplies markup only. The theme is compiled with sassc and swapped into the page at screenshot time rather than wired through the app's asset pipeline, so "before" and "after" differ by exactly one stylesheet and switching revisions costs a recompile instead of a rebuild. It also keeps sassc-rails and any opinion about host asset pipelines out of this. ActiveAdmin is taken as `~> 3.0`, so the gallery tracks the latest 3.x without anyone remembering to bump it. * Scenarios are data (test/screens/scenarios.rb): a name, the variable overrides to compile with, a path, what to hover or click, and what to crop. Adding a case is adding a hash. The fifteen here cover the menu and its dropdowns, the title bar, the index table and its tool row, pagination, the filter sidebar, forms and show pages — under light, dark-panel, light-panel and enlarged configurations. * No pixel comparison against golden files. Font rendering differs between machines, so a byte-exact baseline would mostly produce false alarms and teach everyone to ignore it. Identical digests only decide whether a scenario is listed or folded away as untouched; a human looks at the rest. Two workflows rather than one because both open theme pull requests come from forks: Screens runs in the pull request's context, where the token is read-only, and leaves an artifact; Screens comment picks it up afterwards with the repository's own permissions. Images are published as assets on a prerelease, so they stay out of the git history — nothing is committed to a branch and a clone does not carry them. Ferrum drives Chrome over CDP from Ruby, so CI needs no Node toolchain, only the browser the runner already has. --- .github/workflows/screens-comment.yml | 78 ++++++++++ .github/workflows/screens.yml | 43 ++++++ .gitignore | 4 + Gemfile | 4 + Rakefile | 19 +++ test/screens/comment.rb | 73 +++++++++ test/screens/dummy.rb | 205 ++++++++++++++++++++++++++ test/screens/scenarios.rb | 142 ++++++++++++++++++ test/screens/shoot.rb | 185 +++++++++++++++++++++++ 9 files changed, 753 insertions(+) create mode 100644 .github/workflows/screens-comment.yml create mode 100644 .github/workflows/screens.yml create mode 100644 .gitignore create mode 100644 test/screens/comment.rb create mode 100644 test/screens/dummy.rb create mode 100644 test/screens/scenarios.rb create mode 100644 test/screens/shoot.rb diff --git a/.github/workflows/screens-comment.yml b/.github/workflows/screens-comment.yml new file mode 100644 index 0000000..439793f --- /dev/null +++ b/.github/workflows/screens-comment.yml @@ -0,0 +1,78 @@ +name: Screens comment + +# Second half of the screenshot workflow. Screens runs in the pull request's +# context (read-only, fork-safe) and leaves an artifact; this one runs in the +# repository's context, so it can publish the images and write the comment. +# +# The images go to a prerelease tagged `screens` — release assets are not part +# of the git history, so nothing lands in the repository or in a branch, and a +# clone does not carry them. +on: + workflow_run: + workflows: ["Screens"] + types: [completed] + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + publish: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: screens + path: screens + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read the PR number + id: pr + run: echo "number=$(cat screens/pr-number)" >> "$GITHUB_OUTPUT" + + - name: Publish the images as release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.pr.outputs.number }} + run: | + set -euo pipefail + gh release view screens >/dev/null 2>&1 || \ + gh release create screens --prerelease --title "Screenshots" \ + --notes "Images referenced from pull request comments. Not part of the repository." + + mkdir -p upload + for side in before after; do + for file in screens/$side/*.png; do + cp "$file" "upload/pr-$PR-$side-$(basename "$file")" + done + done + gh release upload screens upload/*.png --clobber + + - name: Build and post the comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.pr.outputs.number }} + BASE: ${{ github.event.workflow_run.pull_requests[0].base.ref || 'master' }} + run: | + set -euo pipefail + ruby test/screens/comment.rb \ + --before screens/before --after screens/after --base "$BASE" \ + --url-prefix "https://github.com/${{ github.repository }}/releases/download/screens/pr-$PR-" \ + > comment.md + + # Replace this workflow's previous comment instead of stacking a new + # one on every push. + existing=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" \ + --jq 'map(select(.body | startswith(""))) | .[0].id // empty') + if [ -n "$existing" ]; then + gh api --method PATCH "repos/${{ github.repository }}/issues/comments/$existing" \ + -F body=@comment.md >/dev/null + else + gh api --method POST "repos/${{ github.repository }}/issues/$PR/comments" \ + -F body=@comment.md >/dev/null + fi diff --git a/.github/workflows/screens.yml b/.github/workflows/screens.yml new file mode 100644 index 0000000..d51980a --- /dev/null +++ b/.github/workflows/screens.yml @@ -0,0 +1,43 @@ +name: Screens + +# Runs in the PR's own context, so it also works for pull requests from forks — +# which means it gets a read-only token and no secrets. It only produces an +# artifact; publishing the images and posting the comment is screens-comment.yml, +# which runs afterwards with the repository's own permissions. +on: + pull_request: + paths: + - 'app/assets/stylesheets/**' + - 'test/screens/**' + - '.github/workflows/screens.yml' + +permissions: + contents: read + +jobs: + shoot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Shoot both revisions + run: bundle exec rake "screens[origin/${{ github.base_ref }}]" + + - name: Record which PR this was + run: echo "${{ github.event.pull_request.number }}" > tmp/screens/pr-number + + - uses: actions/upload-artifact@v4 + with: + name: screens + path: | + tmp/screens/before + tmp/screens/after + tmp/screens/pr-number + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbf309a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/Gemfile.lock +/pkg/ +/tmp/ +*.gem diff --git a/Gemfile b/Gemfile index b02e4a8..0f6af00 100644 --- a/Gemfile +++ b/Gemfile @@ -6,4 +6,8 @@ gemspec group :development, :test do gem 'rake' gem 'sassc' + # Screenshots. Ferrum is a pure-Ruby CDP client, so CI needs no Node + # toolchain — only the Chrome that the runner already has. Rails itself is + # not here: test/screens/dummy.rb generates an app that bundles its own. + gem 'ferrum' end diff --git a/Rakefile b/Rakefile index 6ae6529..92f1d9c 100644 --- a/Rakefile +++ b/Rakefile @@ -6,3 +6,22 @@ task :css do end task default: :css + +desc "Shoot the screen gallery for this branch and for its base" +task :screens, [:base] do |_task, args| + base = args[:base] || ENV.fetch("BASE_REF", "origin/master") + app = ENV.fetch("DUMMY_APP", File.expand_path("tmp/screens-dummy")) + out = ENV.fetch("SCREENS_OUT", File.expand_path("tmp/screens")) + theme = "app/assets/stylesheets/wigu/active_admin_theme.scss" + + ruby "test/screens/dummy.rb #{app}" + + # The base revision of the theme, compiled from git rather than from a + # checkout, so the dummy app is built once and reused for both runs. + before_root = File.join(out, "before-theme", "wigu") + mkdir_p before_root + File.write(File.join(before_root, "active_admin_theme.scss"), `git show #{base}:#{theme}`) + + ruby "test/screens/shoot.rb --app #{app} --out #{out}/after --theme-root app/assets/stylesheets" + ruby "test/screens/shoot.rb --app #{app} --out #{out}/before --theme-root #{File.join(out, "before-theme")}" +end diff --git a/test/screens/comment.rb b/test/screens/comment.rb new file mode 100644 index 0000000..2fa57ee --- /dev/null +++ b/test/screens/comment.rb @@ -0,0 +1,73 @@ +# Renders the PR comment: one row per scenario, before on the left, after on +# the right. Images are referenced by URL — nothing is committed to the repo. +# +# ruby test/screens/comment.rb --before tmp/screens/before --after tmp/screens/after \ +# --url-prefix https://github.com/owner/repo/releases/download/screens/pr-51- +require "json" +require "optparse" + +options = { before: "tmp/screens/before", after: "tmp/screens/after", url_prefix: "" } +OptionParser.new do |opts| + opts.on("--before DIR") { |v| options[:before] = v } + opts.on("--after DIR") { |v| options[:after] = v } + opts.on("--url-prefix URL") { |v| options[:url_prefix] = v } + opts.on("--base REF") { |v| options[:base] = v } +end.parse! + +def index(dir) + path = File.join(dir, "index.json") + File.exist?(path) ? JSON.parse(File.read(path), symbolize_names: true) : [] +end + +before = index(options[:before]).to_h { |s| [s[:name], s] } +after = index(options[:after]) + +puts "" +puts "## Screens" +puts +puts "Every scenario shot twice against the same ActiveAdmin and the same dummy " \ + "admin: once with the theme as it is on `#{options[:base] || "the base branch"}`, " \ + "once with the theme from this PR. Nothing is committed — the images are " \ + "release assets." +puts + +changed = [] +unchanged = [] + +after.each do |shot| + next unless shot[:file] + pair = before[shot[:name]] + (pair && pair[:digest] == shot[:digest] ? unchanged : changed) << [shot, pair] +end + +if changed.empty? + puts "This PR does not change any of the #{after.size} scenarios." +else + changed.each do |shot, pair| + puts "### #{shot[:name]}" + puts + puts shot[:why] if shot[:why] + puts + puts "| before | after |" + puts "|---|---|" + b = pair&.dig(:file) ? "![before](#{options[:url_prefix]}before-#{pair[:file]})" : "_not shot_" + puts "| #{b} | ![after](#{options[:url_prefix]}after-#{shot[:file]}) |" + puts + end +end + +unless unchanged.empty? + puts "
Unchanged by this PR (#{unchanged.size})" + puts + unchanged.each { |shot, _| puts "- #{shot[:name]}" } + puts + puts "
" + puts +end + +failures = after.select { |s| s[:error] } +unless failures.empty? + puts "> [!WARNING]" + puts "> #{failures.size} scenario(s) could not be shot, so they are not compared here:" + failures.each { |s| puts "> - `#{s[:name]}` — #{s[:error]}" } +end diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb new file mode 100644 index 0000000..f86db19 --- /dev/null +++ b/test/screens/dummy.rb @@ -0,0 +1,205 @@ +# Generates a throwaway Rails app with ActiveAdmin installed, so the +# screenshots are taken against ActiveAdmin's real markup instead of a +# hand-written approximation. +# +# The app only produces markup. The theme is not wired through its asset +# pipeline: test/screens/shoot.rb compiles the theme with sassc and swaps it in +# at screenshot time, which keeps this app free of sassc-rails and of any +# opinion about how a host project builds its assets. +# +# ruby test/screens/dummy.rb [path] # default: tmp/screens-dummy +require "fileutils" +require "shellwords" + +APP = File.expand_path(ARGV[0] || "tmp/screens-dummy", Dir.pwd) +# Rails 8 defaults to propshaft + importmap; ActiveAdmin 3 still expects +# sprockets and jquery-rails, so pin the generator to the last 7.x. +RAILS = ENV.fetch("RAILS_VERSION", "7.2.3.1") + +# Every command runs with the parent's bundler environment cleared, so the +# generated app resolves against its own Gemfile and not this gem's. +def sh(command, chdir: Dir.pwd, env: {}) + puts " $ #{command}" + cleared = %w[BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH RUBYOPT RUBYLIB] + .to_h { |key| [key, nil] } + system(cleared.merge(env), command, chdir: chdir, exception: true) +end + +def write(relative, contents) + path = File.join(APP, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, contents) +end + +if File.directory?(APP) + puts "dummy: reusing #{APP}" +else + puts "dummy: generating #{APP} (rails #{RAILS})" + FileUtils.mkdir_p(File.dirname(APP)) + + # Pin the generator itself through its own Gemfile rather than trusting + # whatever `rails` resolves to on PATH — on a machine with more than one Ruby + # the shim and the installed railties can disagree. + boot = File.join(File.dirname(APP), "screens-boot") + FileUtils.mkdir_p(boot) + File.write(File.join(boot, "Gemfile"), + %(source "https://rubygems.org"\ngem "rails", "#{RAILS}"\n)) + sh "bundle install --quiet", chdir: boot, env: { "BUNDLE_GEMFILE" => File.join(boot, "Gemfile") } + sh "bundle exec rails new #{Shellwords.escape(APP)} " \ + "--asset-pipeline=sprockets --skip-git --skip-bootsnap --skip-jbuilder " \ + "--skip-action-mailbox --skip-action-text --skip-action-cable --skip-active-storage " \ + "--skip-hotwire --skip-test --skip-system-test --skip-kamal --skip-solid --skip-ci " \ + "--skip-rubocop --skip-brakeman --skip-dev-gems --skip-docker --quiet", + chdir: boot, env: { "BUNDLE_GEMFILE" => File.join(boot, "Gemfile") } + + File.open(File.join(APP, "Gemfile"), "a") do |gemfile| + gemfile.puts + gemfile.puts %(gem "activeadmin", "~> 3.0") + gemfile.puts %(gem "sassc-rails") + end + + sh "bundle install --quiet", chdir: APP + sh "bin/rails generate active_admin:install --skip-users --quiet", chdir: APP +end + +# --- Content ----------------------------------------------------------------- +# Enough of an admin to exercise everything the theme touches: an index table +# with pagination, filters, batch actions and scopes; a form; a show page; and a +# menu deep enough to have a second-level flyout with a long label. + +write "db/migrate/20260101000000_create_screens_schema.rb", <<~RUBY + class CreateScreensSchema < ActiveRecord::Migration[7.2] + def change + create_table :authors do |t| + t.string :name + t.timestamps + end + create_table :posts do |t| + t.string :title + t.text :body + t.boolean :published, default: false + t.date :published_on + t.references :author + t.timestamps + end + create_table :settings do |t| + t.string :key + t.string :value + t.timestamps + end + end + end +RUBY + +# Ransack 4 requires an explicit allowlist per model; without it the filter +# sidebar raises and ActiveAdmin quietly redirects to the dashboard. +RANSACK = <<~RUBY + def self.ransackable_attributes(_auth = nil) = column_names + def self.ransackable_associations(_auth = nil) = reflect_on_all_associations.map { |a| a.name.to_s } +RUBY + +write "app/models/author.rb", <<~RUBY + class Author < ApplicationRecord + has_many :posts + #{RANSACK}end +RUBY + +write "app/models/post.rb", <<~RUBY + class Post < ApplicationRecord + belongs_to :author, optional: true + scope :published, -> { where(published: true) } + scope :drafts, -> { where(published: false) } + #{RANSACK}end +RUBY + +write "app/models/setting.rb", <<~RUBY + class Setting < ApplicationRecord + #{RANSACK}end +RUBY + +write "app/admin/authors.rb", <<~RUBY + ActiveAdmin.register Author do + permit_params :name + end +RUBY + +write "app/admin/posts.rb", <<~RUBY + ActiveAdmin.register Post do + permit_params :title, :body, :published, :published_on, :author_id + + scope :all, default: true + scope :published + scope :drafts + + filter :title + filter :author + filter :published_on + + index do + selectable_column + id_column + column :title + column :author + column :published + column :published_on + actions + end + end +RUBY + +# The menu is what most of the header findings are about: a top-level item with +# a dropdown, a second-level item with its own flyout, and a label long enough +# to show what an unbounded panel does. +write "app/admin/settings.rb", <<~RUBY + ActiveAdmin.register Setting do + permit_params :key, :value + menu parent: "System", label: "Configuration Settings" + end +RUBY + +write "app/admin/components.rb", <<~RUBY + ActiveAdmin.register_page "Background Job Queue Monitor" do + menu parent: ["System", "Components"] + content { para "placeholder" } + end +RUBY + +write "app/admin/delivery.rb", <<~RUBY + ActiveAdmin.register_page "Outbound SMS Delivery Receipt Reconciliation Settings" do + menu parent: ["System", "Components"] + content { para "placeholder" } + end +RUBY + +write "app/admin/audit.rb", <<~RUBY + ActiveAdmin.register_page "Audit Log" do + menu parent: "System" + content { para "placeholder" } + end +RUBY + +write "db/seeds.rb", <<~RUBY + Author.destroy_all + Post.destroy_all + Setting.destroy_all + + authors = 4.times.map { |i| Author.create!(name: "Author \#{i + 1}") } + 60.times do |i| + Post.create!(title: "Post number \#{i + 1}", body: "Body \#{i + 1}", + published: i.even?, published_on: Date.new(2026, 1, 1) + i, + author: authors[i % authors.size]) + end + 3.times { |i| Setting.create!(key: "setting_\#{i}", value: "value_\#{i}") } +RUBY + +# ActiveAdmin's install generator leaves an authentication hook pointing at a +# method this app does not have; the screenshots are of a logged-in admin. +initializer = File.join(APP, "config/initializers/active_admin.rb") +contents = File.read(initializer) +contents = contents.sub(/^\s*config\.authentication_method.*$/, " config.authentication_method = false") +contents = contents.sub(/^\s*config\.current_user_method.*$/, " config.current_user_method = false") +File.write(initializer, contents) + +sh "bin/rails db:drop db:create db:migrate db:seed", chdir: APP + +puts "dummy: ready at #{APP}" diff --git a/test/screens/scenarios.rb b/test/screens/scenarios.rb new file mode 100644 index 0000000..6d7ffd2 --- /dev/null +++ b/test/screens/scenarios.rb @@ -0,0 +1,142 @@ +# One entry per thing worth looking at. Adding a case to the gallery is adding +# a hash here — there is no code to touch. +# +# name folder/file name and the heading in the PR comment +# why one line explaining what to look at, printed under the heading +# overrides SCSS variable overrides, exactly as a host project would write them +# path path in the dummy admin +# steps [[:hover, selector], [:click, selector], [:focus, selector], +# [:eval, "js"], [:wait, seconds]] +# crop selector whose box is captured; omit for the full viewport +# pad pixels added around the crop box (default 12) +# theme "light" (default) or "dark" — sets data-theme on +# +# Selectors are ActiveAdmin's own: menu items get an id from the menu label, so +# "System" is #system and its children are #system > ul > li. + +SCENARIOS = [ + { + name: "menu-dropdown", + why: "Dropdown panel: row height, text colour, the current-item marker, " \ + "and the seam where the pill meets the panel.", + path: "/admin/posts", + steps: [[:hover, "#system > a"]], + crop: ["#header", "#system > ul"], + }, + { + name: "menu-light-theme", + why: "The whole point of the menu variables: a light panel must stay " \ + "readable, including the hovered item and the current one.", + overrides: '$skinMenuPanelColor: #ffffff; $skinMenuTextColor: #333333; + $skinMenuPillColor: #f0f0f0;', + path: "/admin/settings", + steps: [[:hover, "#system > a"], [:hover, "#system > ul > li:first-child > a"]], + crop: ["#header", "#system > ul"], + }, + { + name: "menu-dark-panel", + why: "A dark panel must keep its hover feedback and its submenu marker.", + overrides: '$skinMenuPanelColor: #222222;', + path: "/admin/posts", + steps: [[:hover, "#system > a"]], + crop: ["#header", "#system > ul"], + }, + { + name: "menu-long-label", + why: "A long submenu label must stay inside the window — scrolling to " \ + "reach it breaks the :hover chain and closes the menu.", + path: "/admin/posts", + steps: [[:hover, "#system > a"], [:hover, "#components > a"]], + viewport: [1280, 420], + }, + { + name: "menu-roomy", + why: "With the size knobs turned up, the marker must stay centred and the " \ + "dropdown must not inherit the top-level font size.", + overrides: '$skinMenuFontSize: 1.6em; $skinMenuItemPaddingY: 12px;', + path: "/admin/posts", + steps: [[:hover, "#system > a"]], + crop: ["#header", "#system > ul"], + }, + { + name: "menu-split-colours", + why: "Pill and panel coloured independently — the junction between them " \ + "must not show the header through.", + overrides: '$skinMenuPillColor: #e63946;', + path: "/admin/posts", + steps: [[:hover, "#system > a"]], + crop: ["#header", "#system > ul"], + pad: 4, + }, + { + name: "menu-keyboard-focus", + why: "A dropdown item reached by keyboard needs the theme's own indicator, " \ + "not just the browser outline.", + overrides: '$skinMenuItemHoverColor: #3a7fb5;', + path: "/admin/posts", + steps: [[:hover, "#system > a"], [:focus, "#system > ul > li:nth-child(2) > a"]], + crop: ["#header", "#system > ul"], + }, + { + name: "header-and-title-bar", + why: "Header padding and the title bar: logo baseline, breadcrumb, and the " \ + "two action buttons, which must agree with each other.", + overrides: '$skinTitleBarButtonPaddingY: 6px; $skinTitleBarButtonPaddingX: 14px;', + path: "/admin/posts", + crop: "#title_bar", + pad: 60, + }, + { + name: "index-table", + why: "Index table: header cells, zebra striping, row hover, and the " \ + "selected state after ticking checkboxes.", + path: "/admin/posts", + steps: [[:click, "#collection_selection_toggle_all"], + [:hover, "table.index_table tbody tr:nth-child(3)"]], + crop: "#active_admin_content", + }, + { + name: "index-table-tools", + why: "Scopes, batch actions and the rest of the tool row — one height, one " \ + "fill, and the active scope has to be identifiable.", + path: "/admin/posts", + steps: [[:click, "#collection_selection_toggle_all"], + [:click, "div.batch_actions_selector a.dropdown_menu_button"]], + crop: "div.table_tools", + pad: 24, + }, + { + name: "pagination", + why: "Page numbers, including the hovered one, and the record count line.", + path: "/admin/posts", + steps: [[:hover, ".pagination span.page a"]], + crop: "#index_footer", + pad: 24, + }, + { + name: "filters-sidebar", + why: "Filter sidebar: labels, inputs, focus ring and the buttons.", + path: "/admin/posts", + steps: [[:focus, "#q_title"]], + crop: "#filters_sidebar_section", + }, + { + name: "form", + why: "Form: fieldset header, labels, inputs, and the submit button.", + path: "/admin/posts/new", + crop: "form.formtastic", + }, + { + name: "show-page", + why: "Show page: panel header, attribute table and the action buttons.", + path: "/admin/posts/1", + crop: "#active_admin_content", + }, + { + name: "status-tags-and-links", + why: "Content link colour against the page background, and status tags.", + path: "/admin/posts", + crop: "table.index_table", + pad: 0, + }, +].freeze diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb new file mode 100644 index 0000000..b6ba578 --- /dev/null +++ b/test/screens/shoot.rb @@ -0,0 +1,185 @@ +# Takes one screenshot per scenario against a given revision of the theme. +# +# ruby test/screens/shoot.rb --theme-root app/assets/stylesheets --out tmp/after +# +# The dummy app (test/screens/dummy.rb) supplies ActiveAdmin's real markup. The +# theme is compiled here with sassc and swapped into the page, rather than wired +# through the app's asset pipeline — that way "before" and "after" differ only +# by the stylesheet, and switching revisions costs a recompile instead of a +# rebuild. +require "json" +require "digest" +require "fileutils" +require "optparse" +require "net/http" +require "sassc" +require "ferrum" + +require_relative "scenarios" + +options = { + theme_root: "app/assets/stylesheets", + out: "tmp/screens", + app: File.expand_path("tmp/screens-dummy", Dir.pwd), + port: 3777, + viewport: [1440, 900], +} +OptionParser.new do |opts| + opts.on("--theme-root DIR") { |v| options[:theme_root] = v } + opts.on("--out DIR") { |v| options[:out] = v } + opts.on("--app DIR") { |v| options[:app] = v } + opts.on("--port N", Integer) { |v| options[:port] = v } +end.parse! + +BASE = "http://127.0.0.1:#{options[:port]}" +FileUtils.mkdir_p(options[:out]) + +# --- CSS --------------------------------------------------------------------- + +ACTIVE_ADMIN = File.join(Gem::Specification.find_by_name("activeadmin").gem_dir, + "app/assets/stylesheets") + +# Exactly what a host project's active_admin.scss does: overrides first, then +# ActiveAdmin, then the theme on top. +def compile(theme_root, overrides) + source = <<~SCSS + @import "active_admin/mixins"; + #{overrides} + @import "active_admin/base"; + @import "wigu/active_admin_theme"; + SCSS + SassC::Engine.new(source, load_paths: [ACTIVE_ADMIN, theme_root], style: :compressed).render +end + +css_cache = Hash.new do |cache, overrides| + cache[overrides] = compile(options[:theme_root], overrides) +end + +# --- App --------------------------------------------------------------------- + +def wait_for(url, seconds: 90) + deadline = Time.now + seconds + loop do + begin + return true if Net::HTTP.get_response(URI(url)).code + rescue StandardError + raise "app did not come up at #{url}" if Time.now > deadline + sleep 1 + end + end +end + +env = %w[BUNDLE_GEMFILE BUNDLE_PATH RUBYOPT RUBYLIB].to_h { |k| [k, nil] } +server = spawn(env.merge("RAILS_ENV" => "development"), + "bin/rails", "server", "-p", options[:port].to_s, "-b", "127.0.0.1", + chdir: options[:app], out: File::NULL, err: File::NULL) +at_exit { Process.kill("TERM", server) rescue nil } +wait_for("#{BASE}/admin") + +# --- Browser ----------------------------------------------------------------- + +browser = Ferrum::Browser.new(headless: true, window_size: options[:viewport], + browser_options: { "force-color-profile" => "srgb", + "hide-scrollbars" => nil }) +at_exit { browser.quit rescue nil } + +# Animations and transitions would make the same scenario render differently +# depending on how fast the machine is. +STEADY = <<~CSS + *, *::before, *::after { transition: none !important; animation: none !important; } +CSS + +def box(page, selector) + page.evaluate(<<~JS) + (() => { + const el = document.querySelector(#{selector.to_json}); + if (!el) return null; + const b = el.getBoundingClientRect(); + return { x: b.left, y: b.top, width: b.width, height: b.height }; + })() + JS +end + +def run_step(page, (action, argument)) + case action + when :hover + b = box(page, argument) or raise "hover: #{argument} not found" + page.mouse.move(x: b["x"] + b["width"] / 2, y: b["y"] + b["height"] / 2) + sleep 0.15 + when :click + b = box(page, argument) or raise "click: #{argument} not found" + page.mouse.move(x: b["x"] + b["width"] / 2, y: b["y"] + b["height"] / 2).down.up + sleep 0.25 + when :focus + page.execute("document.querySelector(#{argument.to_json}).focus()") + sleep 0.15 + when :eval then page.execute(argument) + when :wait then sleep(argument) + else raise "unknown step #{action.inspect}" + end +end + +taken = [] + +SCENARIOS.each do |scenario| + name = scenario[:name] + width, height = scenario[:viewport] || options[:viewport] + page = browser.create_page + page.resize(width: width, height: height) + + page.go_to("#{BASE}#{scenario[:path]}") + + # Replace ActiveAdmin's own stylesheet with the compiled theme, so the page + # shows this revision of the theme over this version of ActiveAdmin. + # `execute`, not `evaluate`: the latter takes a single expression and would + # drop this silently, leaving an unstyled page. + page.execute(<<~JS) + document.querySelectorAll('link[rel="stylesheet"], style[data-theme-css]').forEach(n => n.remove()); + const s = document.createElement('style'); + s.setAttribute('data-theme-css', '1'); + s.textContent = #{(css_cache[scenario[:overrides].to_s] + STEADY).to_json}; + document.head.appendChild(s); + document.documentElement.setAttribute('data-theme', #{(scenario[:theme] || "light").to_json}); + JS + sleep 0.2 + + Array(scenario[:steps]).each { |step| run_step(page, step) } + + area = nil + if scenario[:crop] + # A list unions the boxes: an open dropdown is positioned absolutely, so + # #header alone would crop it off even though it is the thing to look at. + boxes = Array(scenario[:crop]).map do |selector| + box(page, selector) or raise "#{name}: crop #{selector} not found" + end + pad = scenario.fetch(:pad, 12) + left = boxes.map { |b| b["x"] }.min - pad + top = boxes.map { |b| b["y"] }.min - pad + right = boxes.map { |b| b["x"] + b["width"] }.max + pad + bottom = boxes.map { |b| b["y"] + b["height"] }.max + pad + x = [left, 0].max + y = [top, 0].max + area = { x: x, y: y, + width: [right - x, width - x].min, + height: [bottom - y, height - y].min } + end + + path = File.join(options[:out], "#{name}.png") + page.screenshot(path: path, **(area ? { area: area } : {})) + page.close + + # Both revisions are shot on the same machine in the same run, so an identical + # digest means the PR genuinely does not touch this scenario and it can be + # folded away in the comment instead of adding noise. + taken << { name: name, why: scenario[:why], file: File.basename(path), + digest: Digest::SHA256.file(path).hexdigest } + puts "shot #{name}" +rescue StandardError => e + warn "shot #{name}: FAILED — #{e.message}" + taken << { name: name, why: scenario[:why], error: e.message } +end + +File.write(File.join(options[:out], "index.json"), JSON.pretty_generate(taken)) +failed = taken.count { |t| t[:error] } +puts "shoot: #{taken.size - failed}/#{taken.size} scenarios" +exit(1) if failed.positive? From 81d686d80cca2d28460490b318848560f0611f2d Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:02:54 +0200 Subject: [PATCH 2/8] Isolate the dummy app's bundler config from the repository's ruby/setup-ruby with bundler-cache writes .bundle/config into the repository with frozen and a vendored path. The generated app and the bootstrap Gemfile live under the repository, so bundler walked up and applied a lockfile that does not describe them: CI could not resolve rails for the dummy app at all. --- test/screens/dummy.rb | 10 +++++++++- test/screens/shoot.rb | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index f86db19..0f1ce34 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -18,11 +18,19 @@ # Every command runs with the parent's bundler environment cleared, so the # generated app resolves against its own Gemfile and not this gem's. +# +# BUNDLE_APP_CONFIG matters on CI: ruby/setup-ruby with bundler-cache writes +# .bundle/config into the repository with `frozen` and a vendored path, and +# bundler walks up to find it — these directories live under the repository, so +# without this they would inherit a lockfile they are not described by. def sh(command, chdir: Dir.pwd, env: {}) puts " $ #{command}" cleared = %w[BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH RUBYOPT RUBYLIB] .to_h { |key| [key, nil] } - system(cleared.merge(env), command, chdir: chdir, exception: true) + isolated = { "BUNDLE_APP_CONFIG" => File.join(chdir, ".bundle"), + "BUNDLE_FROZEN" => "false", + "BUNDLE_DEPLOYMENT" => "false" } + system(cleared.merge(isolated).merge(env), command, chdir: chdir, exception: true) end def write(relative, contents) diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb index b6ba578..21a120d 100644 --- a/test/screens/shoot.rb +++ b/test/screens/shoot.rb @@ -69,7 +69,8 @@ def wait_for(url, seconds: 90) end end -env = %w[BUNDLE_GEMFILE BUNDLE_PATH RUBYOPT RUBYLIB].to_h { |k| [k, nil] } +env = %w[BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH RUBYOPT RUBYLIB].to_h { |k| [k, nil] } +env["BUNDLE_APP_CONFIG"] = File.join(options[:app], ".bundle") server = spawn(env.merge("RAILS_ENV" => "development"), "bin/rails", "server", "-p", options[:port].to_s, "-b", "127.0.0.1", chdir: options[:app], out: File::NULL, err: File::NULL) From 476bde49e63f8ad17a9cd1005491e194f1dd7013 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:04:03 +0200 Subject: [PATCH 3/8] Clear every BUNDLE_* for the dummy app, not a list of them ruby/setup-ruby exports several; missing one left the bootstrap Gemfile resolving in local mode against the repository's lockfile and failing to find rails at all. --- test/screens/dummy.rb | 6 ++++-- test/screens/shoot.rb | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index 0f1ce34..72451b8 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -25,8 +25,10 @@ # without this they would inherit a lockfile they are not described by. def sh(command, chdir: Dir.pwd, env: {}) puts " $ #{command}" - cleared = %w[BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH RUBYOPT RUBYLIB] - .to_h { |key| [key, nil] } + # Clear every BUNDLE_* rather than a list of them: ruby/setup-ruby exports + # several, and missing one leaves this resolving against the wrong lockfile. + cleared = ENV.keys.grep(/\ABUNDLE_/).to_h { |key| [key, nil] } + cleared.merge!("RUBYOPT" => nil, "RUBYLIB" => nil) isolated = { "BUNDLE_APP_CONFIG" => File.join(chdir, ".bundle"), "BUNDLE_FROZEN" => "false", "BUNDLE_DEPLOYMENT" => "false" } diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb index 21a120d..8aea808 100644 --- a/test/screens/shoot.rb +++ b/test/screens/shoot.rb @@ -69,8 +69,9 @@ def wait_for(url, seconds: 90) end end -env = %w[BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH RUBYOPT RUBYLIB].to_h { |k| [k, nil] } -env["BUNDLE_APP_CONFIG"] = File.join(options[:app], ".bundle") +env = ENV.keys.grep(/\ABUNDLE_/).to_h { |k| [k, nil] } +env.merge!("RUBYOPT" => nil, "RUBYLIB" => nil, + "BUNDLE_APP_CONFIG" => File.join(options[:app], ".bundle")) server = spawn(env.merge("RAILS_ENV" => "development"), "bin/rails", "server", "-p", options[:port].to_s, "-b", "127.0.0.1", chdir: options[:app], out: File::NULL, err: File::NULL) From 9a505686b1ff6057bbf417cb3129bd07256ba6f2 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:08:40 +0200 Subject: [PATCH 4/8] Generate the dummy app with the ambient Rails Pinning Rails 7.2 meant installing a second Rails and then fighting over which one the shim resolved to. Rails 8 runs ActiveAdmin 3.5.2 fine; the one thing it stopped doing is writing app/assets/config/manifest.js for --asset-pipeline=sprockets, which sprockets-rails refuses to boot without, so the generator writes it. No version pin left to drift. --- test/screens/dummy.rb | 46 +++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index 72451b8..77bc849 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -9,12 +9,8 @@ # # ruby test/screens/dummy.rb [path] # default: tmp/screens-dummy require "fileutils" -require "shellwords" APP = File.expand_path(ARGV[0] || "tmp/screens-dummy", Dir.pwd) -# Rails 8 defaults to propshaft + importmap; ActiveAdmin 3 still expects -# sprockets and jquery-rails, so pin the generator to the last 7.x. -RAILS = ENV.fetch("RAILS_VERSION", "7.2.3.1") # Every command runs with the parent's bundler environment cleared, so the # generated app resolves against its own Gemfile and not this gem's. @@ -35,6 +31,17 @@ def sh(command, chdir: Dir.pwd, env: {}) system(cleared.merge(isolated).merge(env), command, chdir: chdir, exception: true) end +# Run a command with the current interpreter and its own RubyGems, never via +# PATH: on a machine with more than one Ruby the `gem` and `rails` on PATH can +# belong to a different installation than the one running this script, and then +# `gem install rails` and `rails new` disagree about what is installed. +def ruby_sh(*arguments, chdir: Dir.pwd) + puts " $ ruby #{arguments.join(" ")}" + cleared = ENV.keys.grep(/\ABUNDLE_/).to_h { |key| [key, nil] } + cleared.merge!("RUBYOPT" => nil, "RUBYLIB" => nil) + system(cleared, Gem.ruby, *arguments, chdir: chdir, exception: true) +end + def write(relative, contents) path = File.join(APP, relative) FileUtils.mkdir_p(File.dirname(path)) @@ -44,23 +51,24 @@ def write(relative, contents) if File.directory?(APP) puts "dummy: reusing #{APP}" else - puts "dummy: generating #{APP} (rails #{RAILS})" + puts "dummy: generating #{APP}" FileUtils.mkdir_p(File.dirname(APP)) - # Pin the generator itself through its own Gemfile rather than trusting - # whatever `rails` resolves to on PATH — on a machine with more than one Ruby - # the shim and the installed railties can disagree. - boot = File.join(File.dirname(APP), "screens-boot") - FileUtils.mkdir_p(boot) - File.write(File.join(boot, "Gemfile"), - %(source "https://rubygems.org"\ngem "rails", "#{RAILS}"\n)) - sh "bundle install --quiet", chdir: boot, env: { "BUNDLE_GEMFILE" => File.join(boot, "Gemfile") } - sh "bundle exec rails new #{Shellwords.escape(APP)} " \ - "--asset-pipeline=sprockets --skip-git --skip-bootsnap --skip-jbuilder " \ - "--skip-action-mailbox --skip-action-text --skip-action-cable --skip-active-storage " \ - "--skip-hotwire --skip-test --skip-system-test --skip-kamal --skip-solid --skip-ci " \ - "--skip-rubocop --skip-brakeman --skip-dev-gems --skip-docker --quiet", - chdir: boot, env: { "BUNDLE_GEMFILE" => File.join(boot, "Gemfile") } + ruby_sh Gem.bin_path("railties", "rails"), "new", APP, + "--asset-pipeline=sprockets", "--skip-git", "--skip-bootsnap", "--skip-jbuilder", + "--skip-action-mailbox", "--skip-action-text", "--skip-action-cable", + "--skip-active-storage", "--skip-hotwire", "--skip-test", "--skip-system-test", + "--skip-kamal", "--skip-solid", "--skip-ci", "--skip-rubocop", "--skip-brakeman", + "--skip-dev-gems", "--skip-docker", "--quiet" + + # `--asset-pipeline=sprockets` installs the gem but, since Rails 7, no longer + # writes the manifest sprockets-rails refuses to boot without. + FileUtils.mkdir_p(File.join(APP, "app/assets/stylesheets")) + FileUtils.mkdir_p(File.join(APP, "app/assets/javascripts")) + write "app/assets/config/manifest.js", <<~JS + //= link_directory ../stylesheets .css + //= link_directory ../javascripts .js + JS File.open(File.join(APP, "Gemfile"), "a") do |gemfile| gemfile.puts From 0c0ae2b2a1f9c03f6803a920ebccca667503844d Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:10:51 +0200 Subject: [PATCH 5/8] Generate the dummy app outside the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruby/setup-ruby writes .bundle/config into the working directory and bundler walks up from wherever it runs, so an app generated under tmp/ inherited a lockfile that does not describe it and resolved in local mode — CI could not install the app's own gems at all. --- Rakefile | 3 ++- test/screens/dummy.rb | 7 ++++++- test/screens/shoot.rb | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Rakefile b/Rakefile index 92f1d9c..8e76b61 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,5 @@ require "bundler/gem_tasks" +require "tmpdir" desc "Compile the theme against ActiveAdmin and check the result" task :css do @@ -10,7 +11,7 @@ task default: :css desc "Shoot the screen gallery for this branch and for its base" task :screens, [:base] do |_task, args| base = args[:base] || ENV.fetch("BASE_REF", "origin/master") - app = ENV.fetch("DUMMY_APP", File.expand_path("tmp/screens-dummy")) + app = ENV.fetch("DUMMY_APP", File.join(Dir.tmpdir, "active-admin-theme-screens")) out = ENV.fetch("SCREENS_OUT", File.expand_path("tmp/screens")) theme = "app/assets/stylesheets/wigu/active_admin_theme.scss" diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index 77bc849..6260b78 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -9,8 +9,13 @@ # # ruby test/screens/dummy.rb [path] # default: tmp/screens-dummy require "fileutils" +require "tmpdir" -APP = File.expand_path(ARGV[0] || "tmp/screens-dummy", Dir.pwd) +# Outside the repository on purpose: ruby/setup-ruby writes a .bundle/config +# into the working directory, and bundler walks up from wherever it is run, so +# an app generated under tmp/ inherits a lockfile that does not describe it and +# resolves in local mode. +APP = File.expand_path(ARGV[0] || File.join(Dir.tmpdir, "active-admin-theme-screens"), Dir.pwd) # Every command runs with the parent's bundler environment cleared, so the # generated app resolves against its own Gemfile and not this gem's. diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb index 8aea808..624d367 100644 --- a/test/screens/shoot.rb +++ b/test/screens/shoot.rb @@ -12,6 +12,7 @@ require "fileutils" require "optparse" require "net/http" +require "tmpdir" require "sassc" require "ferrum" @@ -20,7 +21,7 @@ options = { theme_root: "app/assets/stylesheets", out: "tmp/screens", - app: File.expand_path("tmp/screens-dummy", Dir.pwd), + app: File.join(Dir.tmpdir, "active-admin-theme-screens"), port: 3777, viewport: [1440, 900], } From c1011c02164d3729cd11079f611e0583ea6a1c47 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:13:44 +0200 Subject: [PATCH 6/8] Escape the gem's own bundle with with_unbundled_env Clearing BUNDLE_* by hand missed whatever ruby/setup-ruby adds, and the symptom was bundler resolving the dummy app in local mode: it refused to fetch the app's gems and reported them as not installed. with_unbundled_env restores the environment as it was before bundler touched it, which is what this needs and does not have to be kept in step with the CI action. --- test/screens/dummy.rb | 40 +++++++++++++++++----------------------- test/screens/shoot.rb | 14 ++++++++------ 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index 6260b78..8cc1281 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -8,6 +8,7 @@ # opinion about how a host project builds its assets. # # ruby test/screens/dummy.rb [path] # default: tmp/screens-dummy +require "bundler" require "fileutils" require "tmpdir" @@ -17,34 +18,27 @@ # resolves in local mode. APP = File.expand_path(ARGV[0] || File.join(Dir.tmpdir, "active-admin-theme-screens"), Dir.pwd) -# Every command runs with the parent's bundler environment cleared, so the -# generated app resolves against its own Gemfile and not this gem's. -# -# BUNDLE_APP_CONFIG matters on CI: ruby/setup-ruby with bundler-cache writes -# .bundle/config into the repository with `frozen` and a vendored path, and -# bundler walks up to find it — these directories live under the repository, so -# without this they would inherit a lockfile they are not described by. -def sh(command, chdir: Dir.pwd, env: {}) +# This script is run from `rake screens`, i.e. from inside this gem's bundle. +# Everything below must escape it: the generated app has its own Gemfile and +# must resolve against that. with_unbundled_env restores the environment as it +# was before bundler touched it — clearing a list of BUNDLE_* variables by hand +# misses whatever the CI Ruby action added, and the symptom is bundler quietly +# resolving in local mode and failing to find gems that are simply not fetched. +def unbundled + defined?(Bundler) ? Bundler.with_unbundled_env { yield } : yield +end + +def sh(command, chdir: Dir.pwd) puts " $ #{command}" - # Clear every BUNDLE_* rather than a list of them: ruby/setup-ruby exports - # several, and missing one leaves this resolving against the wrong lockfile. - cleared = ENV.keys.grep(/\ABUNDLE_/).to_h { |key| [key, nil] } - cleared.merge!("RUBYOPT" => nil, "RUBYLIB" => nil) - isolated = { "BUNDLE_APP_CONFIG" => File.join(chdir, ".bundle"), - "BUNDLE_FROZEN" => "false", - "BUNDLE_DEPLOYMENT" => "false" } - system(cleared.merge(isolated).merge(env), command, chdir: chdir, exception: true) + unbundled { system(command, chdir: chdir, exception: true) } end -# Run a command with the current interpreter and its own RubyGems, never via -# PATH: on a machine with more than one Ruby the `gem` and `rails` on PATH can -# belong to a different installation than the one running this script, and then -# `gem install rails` and `rails new` disagree about what is installed. +# Run with the current interpreter rather than via PATH: on a machine with more +# than one Ruby, the `rails` on PATH can belong to a different installation than +# the one running this script. def ruby_sh(*arguments, chdir: Dir.pwd) puts " $ ruby #{arguments.join(" ")}" - cleared = ENV.keys.grep(/\ABUNDLE_/).to_h { |key| [key, nil] } - cleared.merge!("RUBYOPT" => nil, "RUBYLIB" => nil) - system(cleared, Gem.ruby, *arguments, chdir: chdir, exception: true) + unbundled { system(Gem.ruby, *arguments, chdir: chdir, exception: true) } end def write(relative, contents) diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb index 624d367..eef7476 100644 --- a/test/screens/shoot.rb +++ b/test/screens/shoot.rb @@ -13,6 +13,7 @@ require "optparse" require "net/http" require "tmpdir" +require "bundler" require "sassc" require "ferrum" @@ -70,12 +71,13 @@ def wait_for(url, seconds: 90) end end -env = ENV.keys.grep(/\ABUNDLE_/).to_h { |k| [k, nil] } -env.merge!("RUBYOPT" => nil, "RUBYLIB" => nil, - "BUNDLE_APP_CONFIG" => File.join(options[:app], ".bundle")) -server = spawn(env.merge("RAILS_ENV" => "development"), - "bin/rails", "server", "-p", options[:port].to_s, "-b", "127.0.0.1", - chdir: options[:app], out: File::NULL, err: File::NULL) +# Escape this gem's bundle so the dummy app boots on its own Gemfile; see the +# same note in dummy.rb. +server = Bundler.with_unbundled_env do + spawn({ "RAILS_ENV" => "development" }, + "bin/rails", "server", "-p", options[:port].to_s, "-b", "127.0.0.1", + chdir: options[:app], out: File::NULL, err: File::NULL) +end at_exit { Process.kill("TERM", server) rescue nil } wait_for("#{BASE}/admin") From 9c7c80b2ede19a0374a45f27c8199f05e2178586 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:15:09 +0200 Subject: [PATCH 7/8] Resolve the Rails generator outside this gem's bundle Gem.bin_path in the parent found the railties vendored for this gem, which is not on the load path once with_unbundled_env drops the bundle, so exe/rails could not require rails/cli. Install Rails as an ordinary gem and let the unbundled child resolve it. --- test/screens/dummy.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/screens/dummy.rb b/test/screens/dummy.rb index 8cc1281..7913870 100644 --- a/test/screens/dummy.rb +++ b/test/screens/dummy.rb @@ -53,7 +53,13 @@ def write(relative, contents) puts "dummy: generating #{APP}" FileUtils.mkdir_p(File.dirname(APP)) - ruby_sh Gem.bin_path("railties", "rails"), "new", APP, + # Install the generator outside this gem's bundle and resolve its path in the + # child, which is also unbundled — resolving it here would find the railties + # vendored for this gem, which is not on the load path once the bundle is + # gone. --conservative is a no-op when a Rails is already installed. + ruby_sh File.join(RbConfig::CONFIG["bindir"], "gem"), + "install", "rails", "--conservative", "--no-document" + ruby_sh "-e", 'load Gem.bin_path("railties", "rails")', "--", "new", APP, "--asset-pipeline=sprockets", "--skip-git", "--skip-bootsnap", "--skip-jbuilder", "--skip-action-mailbox", "--skip-action-text", "--skip-action-cable", "--skip-active-storage", "--skip-hotwire", "--skip-test", "--skip-system-test", From 8af39673f8886b8d78e462e78700747fea1a7db1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Fri, 2 Oct 2026 11:18:58 +0200 Subject: [PATCH 8/8] Survive a slow scenario on CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner is slower than a developer machine and timed out on one page, which failed the job and cost the whole gallery. Raise Ferrum's timeouts, and report a scenario that could not be shot in the comment instead of aborting — only an entirely empty run is a failure now. --- test/screens/shoot.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/screens/shoot.rb b/test/screens/shoot.rb index eef7476..35a997a 100644 --- a/test/screens/shoot.rb +++ b/test/screens/shoot.rb @@ -83,7 +83,10 @@ def wait_for(url, seconds: 90) # --- Browser ----------------------------------------------------------------- +# The defaults are tuned for a developer machine; a CI runner is slower and +# times out mid-scenario, which costs the whole gallery for one slow page. browser = Ferrum::Browser.new(headless: true, window_size: options[:viewport], + timeout: 30, process_timeout: 60, browser_options: { "force-color-profile" => "srgb", "hide-scrollbars" => nil }) at_exit { browser.quit rescue nil } @@ -187,4 +190,7 @@ def run_step(page, (action, argument)) File.write(File.join(options[:out], "index.json"), JSON.pretty_generate(taken)) failed = taken.count { |t| t[:error] } puts "shoot: #{taken.size - failed}/#{taken.size} scenarios" -exit(1) if failed.positive? +# A scenario that could not be shot is reported in the comment rather than +# failing the job: losing the whole gallery because one page was slow is worse +# than a gallery with a gap in it. Nothing at all is still a failure. +exit(1) if failed == taken.size