discourse/spec/system/theme_screenshots_spec.rb
Penar Musaraj ef406fc8c0
DEV: Add theme screenshots system spec and skill (#39426)
Previously, there was no systematic way to visually review changes
across multiple screens in a theme. Or to compare how Foundation and
Horizon render key UI screens

This adds a `screenshot_marker(label:)` helper in system specs (globally
included via ThemeScreenshotMarker) that captures a PNG at that point in
a test. A matrix runner spec at `theme_screenshots_spec.rb`
auto-discovers any system spec containing screenshot markers and runs
those blocks against each theme × color mode × device combination. At
the end it generates a `compare.html` viewer for side-by-side
comparison.

<img width="3399" height="1400" alt="image"
src="https://github.com/user-attachments/assets/4ddd650a-6133-4da5-901e-5ec2f2d0906a"
/>


This can be run via CLI or via the attached skill. Directly:

### All themes × light/dark × desktop/mobile (default)
TAKE_SCREENSHOTS=1 bin/rspec spec/system/theme_screenshots_spec.rb


#### Foundation only, dark mode, desktop only
TAKE_SCREENSHOTS=1 SCREENSHOTS_THEMES=foundation SCREENSHOTS_MODES=dark
SCREENSHOTS_DEVICES=desktop bin/rspec
spec/system/theme_screenshots_spec.rb

#### Third-party theme in addition to core themes
TAKE_SCREENSHOTS=1
SCREENSHOTS_THEME_URL=https://github.com/discourse/discourse-air
bin/rspec spec/system/theme_screenshots_spec.rb



You can also run this via the agent skill, which builds the command from
natural language: `/discourse-screenshots` for the full default matrix,
or `/discourse-screenshots foundation only, dark only, desktop` and
similar limited runs.

### Use cases

- review broad changes in core against key UI screens across the app
- compare layouts/screens between core themes and/or a custom theme
- preview a new theme's look and feel across different areas of the app

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:36:29 -04:00

517 lines
20 KiB
Ruby
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
# System spec for capturing screenshots at marked points in other system specs.
# Run via:
#
# TAKE_SCREENSHOTS=1 bin/rspec spec/system/theme_screenshots_spec.rb
#
# Discovers all system specs that call `screenshot_marker` and runs them under
# each combination of theme × device × color mode, then generates a single
# HTML comparison page with tabs for device and color mode.
#
# Optional env vars:
# SCREENSHOTS_DIR — output directory (default: tmp/theme-screenshots)
# SCREENSHOTS_MODES — comma-separated modes (default: light,dark)
# SCREENSHOTS_DEVICES — comma-separated devices (default: desktop,mobile)
# SCREENSHOTS_THEMES — comma-separated theme names (default: foundation,horizon)
# SCREENSHOTS_THEME_URL — git URL of a remote theme to install into the test DB
# (theme name is deduced from the repo name in the URL)
# SCREENSHOTS_SUBSET — substring filter on marker labels; only examples whose
# label contains this string are captured (e.g. "topic")
DEVICES = (ENV["SCREENSHOTS_DEVICES"] || "desktop,mobile").split(",").map(&:strip).freeze
describe "Theme screenshots" do
before { skip "Set TAKE_SCREENSHOTS=1 to run this spec" if ENV["TAKE_SCREENSHOTS"] != "1" }
let(:output_dir) do
dir = ENV["SCREENSHOTS_DIR"] || Rails.root.join("tmp/theme-screenshots").to_s
FileUtils.mkdir_p(dir)
dir
end
let(:raw_dir) do
dir = File.join(output_dir, "raw")
FileUtils.mkdir_p(dir)
dir
end
let(:modes) { (ENV["SCREENSHOTS_MODES"] || "light,dark").split(",").map(&:strip) }
let(:themes) do
core = Theme::CORE_THEMES.map { |name, id| { name: name, id: id } }
base =
if ENV["SCREENSHOTS_THEMES"].present?
requested = ENV["SCREENSHOTS_THEMES"].split(",").map { |n| n.downcase.strip }
core.select { |t| requested.include?(t[:name]) }
else
core
end
if (url = ENV["SCREENSHOTS_THEME_URL"].presence)
name = File.basename(url.chomp("/"), ".git")
tmpdir = Dir.mktmpdir("discourse_theme_screenshot_")
system("git", "clone", "--depth", "1", url, tmpdir, exception: true)
theme = RemoteTheme.import_theme_from_directory(tmpdir)
theme.update!(user_selectable: true)
Stylesheet::Manager.clear_theme_cache!
base = base + [{ name: name, id: theme.id }]
end
base
end
before do
Theme.where(id: themes.map { |t| t[:id] }).update_all(user_selectable: true)
themes.each do |t|
SystemThemesManager.sync_theme!(t[:name]) if Theme::CORE_THEMES.key?(t[:name])
end
SiteIconManager.clear_cache!
allow(TopicUser).to receive(:track_visit!)
end
it "captures screenshots" do
FileUtils.rm_f(Dir.glob(File.join(raw_dir, "*.png")))
run_marker_matrix(device: "desktop") if DEVICES.include?("desktop")
run_marker_matrix(device: "mobile") if DEVICES.include?("mobile")
generate_comparison_html
end
private
def discover_marker_specs
patterns = [
Rails.root.join("spec/system/**/*.rb").to_s,
Rails.root.join("plugins/*/spec/system/**/*.rb").to_s,
]
Dir
.glob(patterns)
.reject { |f| File.realpath(f) == File.realpath(__FILE__) }
.select { |f| File.read(f).include?("screenshot_marker") }
end
def run_marker_matrix(device:)
specs = discover_marker_specs
if specs.empty?
puts "No specs with screenshot_marker markers found."
return
end
puts "Found #{specs.size} spec(s) with markers: #{specs.map { |s| File.basename(s) }.join(", ")}"
marker_groups = load_marker_groups(specs)
filter_marker_examples(marker_groups, specs, device)
filter_device_split(marker_groups, device)
apply_device_metadata(marker_groups, device)
# `group.run` sets `RSpec.current_example` per inner example and leaves it
# `nil` when the group finishes. The outer example's after-hooks (e.g.
# rails_helper's `extra_failure_lines`) read it, so restore it after each
# inner run.
outer_example = RSpec.current_example
begin
themes.each do |theme|
modes.each do |mode|
with_screenshot_env(theme: theme, mode: mode, device: device) do
marker_groups.each do |group|
group.run(RSpec.configuration.reporter)
RSpec.current_example = outer_example
end
end
end
end
ensure
RSpec.current_example = outer_example
cleanup_marker_groups(marker_groups)
end
end
# Loads each marker spec file via `load` so its `describe` blocks register new
# example groups in `RSpec.world`.
def load_marker_groups(spec_files)
groups = []
spec_files.each do |spec_file|
before = RSpec.world.example_groups.dup
load spec_file
groups.concat(RSpec.world.example_groups - before)
end
groups
end
# Filter specs that don't include a `screenshot_marker`
# Examples whose `screenshot_marker` was called with `only: :desktop` (or
# `:mobile`) are kept only on the matching leg.
def filter_marker_examples(groups, spec_files, device)
constraints = spec_files.to_h { |f| [File.expand_path(f), screenshot_example_constraints(f)] }
groups.each { |g| keep_screenshot_examples(g, constraints, device) }
end
# Returns a hash mapping the line number of each `it` / `scenario` / `example`
# / `specify` block that contains `screenshot_marker` to a constraint hash:
# { only: :desktop, labels: ["my-label"] } — call site has `only: :desktop` or `only: "desktop"`
# { only: nil, labels: ["a", "b"] } — call site has no constraint
#
# If a single example contains multiple `screenshot_marker` calls with
# conflicting constraints, the most permissive (no `only`) wins.
# All labels across all markers in the example are collected.
def screenshot_example_constraints(spec_file)
lines = File.readlines(spec_file)
result = {}
lines.each_with_index do |line, idx|
next if line.exclude?("screenshot_marker")
only_match = line.match(/only:\s*(?::(\w+)|["'](\w+)["'])/)
only = only_match ? (only_match[1] || only_match[2]).to_sym : nil
label_match = line.match(/label:\s*["']([^"']+)["']/)
label = label_match ? label_match[1] : nil
(idx - 1).downto(0) do |i|
if lines[i] =~ /^\s*(it|scenario|example|specify)\b/
existing = result[i + 1]
if existing
existing[:only] = only if existing[:only] # most permissive (nil) wins
existing[:labels] << label if label
else
result[i + 1] = { only: only, labels: label ? [label] : [] }
end
break
end
end
end
result
end
def keep_screenshot_examples(group, constraints_per_file, device)
subset = ENV["SCREENSHOTS_SUBSET"].presence
group.examples.select! do |ex|
file = File.expand_path(ex.metadata[:file_path])
constraint = (constraints_per_file[file] || {})[ex.metadata[:line_number]]
next false unless constraint
only = constraint[:only]
next false unless only.nil? || only.to_s == device
next false if subset && constraint[:labels].none? { |l| l.include?(subset) }
true
end
group.children.each { |child| keep_screenshot_examples(child, constraints_per_file, device) }
group.children.reject! { |child| empty_group?(child) }
end
def empty_group?(group)
group.examples.empty? && group.children.all? { |c| empty_group?(c) }
end
# Some specs split their suite by device with sibling contexts — one tagged
# `mobile: true`, the other left as desktop (e.g. signup_spec's
# "when desktop" / "when mobile" using shared_examples). Detect that pattern
# by spotting a group whose children have a mix of mobile and non-mobile
# metadata, and keep only the side that matches the current leg. Specs
# without this pattern are unaffected.
def filter_device_split(groups, device)
groups.each { |g| filter_device_split_recursively(g, device) }
groups.reject! { |g| empty_group?(g) }
end
def filter_device_split_recursively(group, device)
group.children.each { |child| filter_device_split_recursively(child, device) }
has_mobile = group.children.any? { |c| c.metadata[:mobile] }
has_non_mobile = group.children.any? { |c| !c.metadata[:mobile] }
return unless has_mobile && has_non_mobile
if device == "mobile"
group.children.select! { |c| c.metadata[:mobile] }
else
group.children.select! { |c| !c.metadata[:mobile] }
end
end
# The `:mobile` metadata is what triggers the global `before(:each)` hook in
# rails_helper to switch the Capybara driver to the mobile WebKit driver.
# Marker specs don't tag themselves `:mobile`, so we stamp it on at runtime
# when we're in the mobile leg of the matrix.
def apply_device_metadata(groups, device)
return unless device == "mobile"
groups.each { |g| set_mobile_recursively(g) }
end
def set_mobile_recursively(group)
group.metadata[:mobile] = true
group.examples.each { |ex| ex.metadata[:mobile] = true }
group.children.each { |child| set_mobile_recursively(child) }
end
def cleanup_marker_groups(groups)
groups.each { |g| RSpec.world.example_groups.delete(g) }
end
def with_screenshot_env(theme:, mode:, device:)
saved = {}
overrides = {
"TAKE_SCREENSHOTS" => "1",
"SCREENSHOTS_DIR" => output_dir,
"SCREENSHOTS_THEME_NAME" => theme[:name],
"SCREENSHOTS_THEME_ID" => theme[:id].to_s,
"SCREENSHOTS_MODE" => mode.to_s,
"SCREENSHOTS_DEVICE" => device,
}
overrides.each do |k, v|
saved[k] = ENV[k]
ENV[k] = v
end
yield
ensure
saved.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
# Scans raw_dir for all captured PNGs and builds an HTML comparison page.
def generate_comparison_html
theme_names = themes.map { |t| t[:name] }
# panels[device][mode][label] = [{label: theme_name, file: abs_path}, ...]
panels =
Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = Hash.new { |h3, k3| h3[k3] = [] } } }
Dir
.glob(File.join(raw_dir, "*.png"))
.sort
.each do |file|
basename = File.basename(file, ".png")
DEVICES.each do |device|
next unless basename.start_with?("#{device}-")
rest = basename.delete_prefix("#{device}-")
theme_names.each do |theme_name|
next unless rest.start_with?("#{theme_name}-")
rest2 = rest.delete_prefix("#{theme_name}-")
modes.each do |mode|
next unless rest2.start_with?("#{mode}-")
label = rest2.delete_prefix("#{mode}-")
panels[device][mode][label] << { label: theme_name, file: file }
break
end
break
end
break
end
end
return if panels.empty?
html_path = File.join(output_dir, "compare.html")
write_comparison_html(html_path, panels)
puts "🌐 Comparison: file://#{html_path}"
end
def write_comparison_html(html_path, panels)
html_dir = File.dirname(html_path)
available_devices = DEVICES.select { |d| panels[d].any? }
available_modes = modes.select { |m| panels.values.any? { |dm| dm[m]&.any? } }
all_labels = panels.values.flat_map { |modes_h| modes_h.values.flat_map(&:keys) }.uniq
panels_html =
available_devices
.flat_map do |device|
available_modes.flat_map do |mode|
all_labels.map do |label|
entries = panels[device][mode][label]
next "" if entries.empty?
cols =
entries.map do |entry|
rel = Pathname.new(entry[:file]).relative_path_from(Pathname.new(html_dir)).to_s
<<~COL
<div class="col">
<div class="col-label">#{entry[:label]}</div>
<img src="#{rel}" loading="lazy" onclick="openLightbox(this)">
</div>
COL
end
panel_id = "panel-#{device}-#{mode}-#{label}"
<<~PANEL
<div class="panel" id="#{panel_id}" data-device="#{device}" data-mode="#{mode}" data-label="#{label}">
#{cols.join}
</div>
PANEL
end
end
end
.join("\n")
device_tabs_html =
available_devices
.map do |d|
active = d == available_devices.first ? " active" : ""
%(<button class="tab#{active}" data-device="#{d}" onclick="selectDevice('#{d}')">#{d.capitalize}</button>)
end
.join("\n ")
mode_tabs_html =
available_modes
.map do |m|
active = m == available_modes.first ? " active" : ""
%(<button class="tab#{active}" data-mode="#{m}" onclick="selectMode('#{m}')">#{m.capitalize}</button>)
end
.join("\n ")
panels_json =
available_devices.flat_map do |device|
available_modes.flat_map do |mode|
all_labels.filter_map do |label|
next if panels[device][mode][label].empty?
%({ "device": "#{device}", "mode": "#{mode}", "label": "#{label}" })
end
end
end
File.write(html_path, <<~HTML)
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Theme Screenshots</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #111; color: #eee; font-family: system-ui, sans-serif; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; padding: 7px 12px; background: #1a1a1a; border-bottom: 1px solid #333; flex-shrink: 0; }
.header-left { display: flex; align-items: center; gap: 4px; }
.header-center { display: flex; justify-content: center; }
.header-right { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.tab-group { display: flex; gap: 4px; }
.tab { padding: 3px 12px; background: #2a2a2a; color: #888; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 13px; }
.tab.active { background: #3a3a3a; color: #fff; border-color: #666; }
.sep { width: 1px; background: #333; height: 22px; margin: 0 4px; }
.btn { padding: 3px 10px; background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 14px; line-height: 1.4; }
.btn:disabled { opacity: 0.3; cursor: default; }
#counter { font-size: 12px; color: #666; min-width: 44px; text-align: center; }
#label-display { font-size: 14px; font-weight: 600; color: #fff; text-align: center; letter-spacing: 0.01em; }
.content { flex: 1; overflow: hidden; }
.panel { display: none; flex-direction: row; gap: 2px; height: 100%; }
.panel.active { display: flex; }
.col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.col-label { flex-shrink: 0; background: #1a1a1a; padding: 5px 10px; font-size: 12px; font-weight: 600; text-align: center; border-bottom: 1px solid #222; }
.col img { flex: 1; min-height: 0; width: 100%; object-fit: contain; object-position: top center; display: block; cursor: zoom-in; }
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 100; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: zoom-out; }
.lightbox.active { display: flex; }
.lightbox-label { color: #bbb; font-size: 13px; font-weight: 600; flex-shrink: 0; }
.lightbox img { max-width: 95vw; max-height: calc(95vh - 32px); object-fit: contain; cursor: zoom-out; }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="tab-group" id="device-tabs">
#{device_tabs_html}
</div>
<div class="sep"></div>
<div class="tab-group" id="mode-tabs">
#{mode_tabs_html}
</div>
</div>
<div class="header-center">
<span id="label-display"></span>
</div>
<div class="header-right">
<button class="btn" id="btn-prev" onclick="navigate(-1)">&#8592;</button>
<span id="counter"></span>
<button class="btn" id="btn-next" onclick="navigate(1)">&#8594;</button>
</div>
</header>
<div class="content">
#{panels_html}
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<div class="lightbox-label" id="lightbox-label"></div>
<img id="lightbox-img" src="">
</div>
<script>
var allPanels = [#{panels_json.join(",\n")}];
var currentDevice = #{available_devices.first.to_json};
var currentMode = #{available_modes.first.to_json};
var labelIndex = 0;
function filtered() {
return allPanels.filter(function(p) {
return p.device === currentDevice && p.mode === currentMode;
});
}
function show() {
var set = filtered();
labelIndex = Math.max(0, Math.min(set.length - 1, labelIndex));
document.querySelectorAll('.panel').forEach(function(el) {
el.classList.remove('active');
});
if (!set.length) return;
var p = set[labelIndex];
var el = document.getElementById('panel-' + p.device + '-' + p.mode + '-' + p.label);
if (el) el.classList.add('active');
document.getElementById('counter').textContent = (labelIndex + 1) + ' / ' + set.length;
document.getElementById('label-display').textContent = p.label;
document.getElementById('btn-prev').disabled = labelIndex === 0;
document.getElementById('btn-next').disabled = labelIndex === set.length - 1;
}
function navigate(dir) {
labelIndex += dir;
show();
}
function selectDevice(device) {
currentDevice = device;
document.querySelectorAll('#device-tabs .tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.device === device);
});
show();
}
function selectMode(mode) {
currentMode = mode;
document.querySelectorAll('#mode-tabs .tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.mode === mode);
});
show();
}
var lightboxImages = [];
var lightboxIndex = 0;
function showLightboxImage() {
var img = lightboxImages[lightboxIndex];
document.getElementById('lightbox-label').textContent = img.closest('.col').querySelector('.col-label').textContent;
document.getElementById('lightbox-img').src = img.src;
}
function openLightbox(img) {
var panel = img.closest('.panel');
lightboxImages = Array.from(panel.querySelectorAll('.col img'));
lightboxIndex = lightboxImages.indexOf(img);
showLightboxImage();
document.getElementById('lightbox').classList.add('active');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { closeLightbox(); return; }
if (document.getElementById('lightbox').classList.contains('active')) {
if (e.key === 'ArrowLeft') { lightboxIndex = (lightboxIndex - 1 + lightboxImages.length) % lightboxImages.length; showLightboxImage(); }
if (e.key === 'ArrowRight') { lightboxIndex = (lightboxIndex + 1) % lightboxImages.length; showLightboxImage(); }
return;
}
if (e.key === 'ArrowLeft') navigate(-1);
if (e.key === 'ArrowRight') navigate(1);
});
show();
</script>
</body>
</html>
HTML
end
end