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..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 @@ -6,3 +7,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.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" + + 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..7913870 --- /dev/null +++ b/test/screens/dummy.rb @@ -0,0 +1,228 @@ +# 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 "bundler" +require "fileutils" +require "tmpdir" + +# 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) + +# 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}" + unbundled { system(command, chdir: chdir, exception: true) } +end + +# 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(" ")}" + unbundled { system(Gem.ruby, *arguments, 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}" + FileUtils.mkdir_p(File.dirname(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", + "--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 + 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..35a997a --- /dev/null +++ b/test/screens/shoot.rb @@ -0,0 +1,196 @@ +# 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 "tmpdir" +require "bundler" +require "sassc" +require "ferrum" + +require_relative "scenarios" + +options = { + theme_root: "app/assets/stylesheets", + out: "tmp/screens", + app: File.join(Dir.tmpdir, "active-admin-theme-screens"), + 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 + +# 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") + +# --- 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 } + +# 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" +# 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