discourse/spec/support/theme_screenshot_marker.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

78 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
# Included in all system specs (via rails_helper) to provide screenshot_marker.
#
# Usage:
# it "opens the composer" do
# # ... set up state ...
# screenshot_marker(label: "composer-open")
# # ... assertions ...
# end
#
# screenshot_marker is a no-op unless TAKE_SCREENSHOTS=1 is set.
# Output lands in tmp/theme-screenshots/raw/ (or SCREENSHOTS_DIR/raw/).
#
# When run via the theme_screenshots_spec matrix runner, SCREENSHOTS_THEME_ID,
# SCREENSHOTS_THEME_NAME, SCREENSHOTS_MODE, and SCREENSHOTS_DEVICE are injected
# internally as env vars. The before hook sets SiteSetting.default_theme_id so
# every subsequent page visit in the example renders with the correct theme.
#
module ThemeScreenshotMarker
def self.included(base)
base.before do
next unless ENV["TAKE_SCREENSHOTS"] == "1"
SiteSetting.global_notice = ""
if (theme_id = ENV["SCREENSHOTS_THEME_ID"].presence&.to_i) && theme_id != 0
SiteSetting.default_theme_id = theme_id
end
if (mode = ENV["SCREENSHOTS_MODE"].presence)
page.driver.with_playwright_page { |pw_page| pw_page.emulate_media(colorScheme: mode) }
end
page.driver.with_playwright_page do |pw_page|
pw_page.add_style_tag(content: "#global-notice-theme-preview { display: none !important; }")
end
end
end
# `only:` constrains which device leg of the matrix the screenshot belongs
# to. Use it for features that don't exist on the other device (e.g. the
# full search menu is desktop-only). The orchestrator parses this kwarg
# from the source to also skip the surrounding example on the wrong leg —
# this in-method check is a belt-and-braces fallback.
def screenshot_marker(label:, only: nil)
return unless ENV["TAKE_SCREENSHOTS"] == "1"
device = ENV["SCREENSHOTS_DEVICE"] || "desktop"
return if only && only.to_s != device
output_dir = ENV["SCREENSHOTS_DIR"] || Rails.root.join("tmp/theme-screenshots").to_s
raw_dir = File.join(output_dir, "raw")
FileUtils.mkdir_p(raw_dir)
theme_name = ENV["SCREENSHOTS_THEME_NAME"] || "default"
mode = ENV["SCREENSHOTS_MODE"] || "light"
page.driver.with_playwright_page do |pw_page|
unless @message_bus_blocked
pw_page.route(%r{/message-bus/}, ->(route, _request) { route.abort })
@message_bus_blocked = true
end
pw_page.wait_for_load_state(state: "networkidle", timeout: 10_000)
rescue Playwright::TimeoutError
# page is visually complete; a stray background request is still open
end
filename = File.join(raw_dir, "#{device}-#{theme_name}-#{mode}-#{label}.png")
page.driver.with_playwright_page do |pw_page|
pw_page.set_viewport_size(width: pw_page.viewport_size[:width], height: 1200)
pw_page.screenshot(path: filename)
end
puts "📸 #{filename}"
end
end