Skip to content

Commit fbabb1c

Browse files
committed
feat: standardize screenshot consistency
1 parent 64c5aef commit fbabb1c

15 files changed

Lines changed: 483 additions & 6 deletions

File tree

.github/actions/setup-ruby-and-dependencies/action.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,27 @@ runs:
2626
if: ${{ inputs.cache-apt-packages == 'true' }}
2727
uses: jetthoughts/cache-apt-pkgs-action@fix/upgrade-actions-cache-v5
2828
with:
29-
packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev
29+
packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev fonts-dejavu fonts-liberation fonts-ubuntu fonts-noto-color-emoji
3030
version: tests-v2
3131

3232
- name: Install vips (fallback)
3333
if: ${{ inputs.cache-apt-packages != 'true' }}
34-
run: sudo apt-get -qq update && sudo apt-get -qq install -y libvips
34+
run: sudo apt-get -qq update && sudo apt-get -qq install -y libvips fonts-dejavu fonts-liberation fonts-ubuntu fonts-noto-color-emoji
3535
shell: bash
3636

37-
- run: sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-yes-antialias.conf
37+
- name: Configure font rendering
38+
run: |
39+
sudo tee /etc/fonts/local.conf >/dev/null <<'XML'
40+
<?xml version="1.0"?>
41+
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
42+
<fontconfig>
43+
<match target="font">
44+
<edit name="hinting" mode="assign"><bool>false</bool></edit>
45+
<edit name="autohint" mode="assign"><bool>false</bool></edit>
46+
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
47+
<edit name="rgba" mode="assign"><const>none</const></edit>
48+
</match>
49+
</fontconfig>
50+
XML
51+
sudo fc-cache -f
3852
shell: bash

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,13 @@ Enable `Capybara::Screenshot.disable_animations = true` to freeze CSS animations
187187
<details>
188188
<summary><strong>CI screenshots differ from local</strong></summary>
189189

190-
Set `window_size` for consistent dimensions and use `perceptual_threshold: 2.0` to ignore anti-aliasing differences across environments.
190+
Set `window_size` for consistent dimensions and use `perceptual_threshold: 2.0` to ignore anti-aliasing differences across environments. For cross-OS baselines, use the preset:
191+
192+
```ruby
193+
Capybara::Screenshot.enable_consistent_screenshots!
194+
```
195+
196+
For advanced tuning and custom injections, see `docs/configuration.md`.
191197
</details>
192198

193199
<details>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ADR 0001: Screenshot Preparation Plugins (Deferred)
2+
3+
Date: 2026-04-14
4+
Status: Deferred
5+
6+
## Context
7+
8+
We added more pre-capture steps for stable screenshots, including:
9+
- DOM normalization CSS
10+
- Font readiness waits
11+
- Animation disabling
12+
- Caret hiding
13+
- Custom CSS and JS injections
14+
15+
The number of knobs and prep steps is growing. A plugin pipeline could make
16+
ordering explicit and allow app-level extensions without monkey-patching.
17+
18+
## Decision
19+
20+
Defer a plugin system for now.
21+
22+
We will keep the simple `configure_consistency` API plus existing flags as
23+
aliases. This provides a single entry point and keeps complexity low.
24+
25+
## Consequences
26+
27+
Benefits:
28+
- Minimal code and API surface
29+
- Easy onboarding for new users
30+
31+
Costs:
32+
- Prep order is still encoded in `Screenshoter`
33+
- Extensibility is limited to CSS/JS injections and flags
34+
35+
## Revisit Criteria
36+
37+
Re-open this decision when:
38+
- We need more than one custom preparation step per app
39+
- We add two or more new prep steps beyond CSS/JS injection
40+
- Prep ordering or conditional logic becomes hard to reason about
41+
42+
## Next Refactoring Ideas
43+
44+
If revisited, implement a lightweight pipeline:
45+
- `plugins` list with callables
46+
- Context object with `inject_css`, `inject_js`, `wait_for_fonts`, and `session`
47+
- Built-in plugins for existing steps, mapped from current flags

docs/adr/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Architecture Decision Records
2+
3+
## ADR Index
4+
5+
- [ADR 0001: Screenshot Preparation Plugins (Deferred)](0001-screenshot-prep-plugins.md)

docs/ci-integration.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ That's it. On failure, this will:
9090

9191
### 3. Ruby + libvips setup action
9292

93-
For consistent CI environments (libvips, font antialiasing disabled), use the setup action:
93+
For consistent CI environments (libvips, standardized fonts, hinting disabled),
94+
use the setup action:
9495

9596
```yaml
9697
- uses: snap-diff/snap_diff-capybara/.github/actions/setup-ruby-and-dependencies@master
@@ -99,7 +100,10 @@ For consistent CI environments (libvips, font antialiasing disabled), use the se
99100
cache-apt-packages: true
100101
```
101102

102-
This installs Ruby, libvips (with apt caching), and disables font antialiasing for consistent rendering across CI runs.
103+
This installs Ruby, libvips (with apt caching), installs the core font stack,
104+
and disables font hinting for consistent rendering across CI runs.
105+
106+
For local or Docker setups, see `docs/os-setup.md`.
103107

104108
#### Inputs
105109

docs/configuration.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,83 @@ If you want to skip the assertion for change in the screen shot, set
135135
Capybara::Screenshot::Diff.enabled = false
136136
```
137137

138+
### DOM normalization (recommended for cross-OS baselines)
139+
140+
To reduce visual noise from OS-level font rendering, scrollbars, and UI chrome,
141+
the default config injects a normalization stylesheet and waits for web fonts
142+
before capture. You can override as needed:
143+
144+
```ruby
145+
Capybara::Screenshot.normalize_css = false
146+
Capybara::Screenshot.wait_for_fonts = false
147+
```
148+
149+
The built-in normalization stylesheet:
150+
- disables animations/transitions
151+
- standardizes font rendering
152+
- hides carets and number spinners
153+
- hides OS-specific scrollbars
154+
155+
To supply custom CSS instead:
156+
157+
```ruby
158+
Capybara::Screenshot.normalize_css = true
159+
Capybara::Screenshot.normalize_stylesheet = <<~CSS
160+
/* your custom normalization rules */
161+
CSS
162+
```
163+
164+
### One-line preset
165+
166+
If you'd rather toggle everything in one call:
167+
168+
```ruby
169+
Capybara::Screenshot.enable_consistent_screenshots!
170+
```
171+
172+
### Unified consistency config (recommended)
173+
174+
For a single entry point with custom injections:
175+
176+
```ruby
177+
Capybara::Screenshot.configure_consistency(preset: :default) do |c|
178+
c.blur_active_element = true
179+
c.hide_caret = true
180+
c.disable_animations = true
181+
c.normalize_css = true
182+
c.wait_for_fonts = true
183+
c.css << "/* your custom css */"
184+
c.js << "/* your custom js */"
185+
end
186+
```
187+
188+
Available presets:
189+
- `:default` (enable normalization + font wait + disable animations + hide caret + blur)
190+
- `:off` (disable all consistency shims)
191+
192+
**Compatibility:** existing flags (`normalize_css`, `wait_for_fonts`, `disable_animations`, etc.)
193+
remain supported as aliases.
194+
195+
For OS-level setup (fonts + hinting), see `docs/os-setup.md`.
196+
197+
### Cross-OS baseline preset (Ubuntu ↔ Alpine)
198+
199+
If you compare baselines across `glibc` and `musl`, combine perceptual diffing
200+
with a tighter tolerated diff area:
201+
202+
```ruby
203+
Capybara::Screenshot::Diff.configure do |screenshot, diff|
204+
screenshot.window_size = [1280, 1024]
205+
screenshot.disable_animations = true
206+
screenshot.normalize_css = true
207+
screenshot.wait_for_fonts = true
208+
209+
diff.driver = :vips
210+
diff.perceptual_threshold = 2.0
211+
diff.tolerance = 0.00005 # 0.005% of pixels
212+
end
213+
```
214+
138215
Using an environment variable
139216

140217
```ruby

docs/os-setup.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# OS Setup for Consistent Screenshots
2+
3+
Screenshot rendering varies across OS and C libraries. For reliable baselines
4+
across Ubuntu (glibc) and Alpine (musl), standardize fonts and disable hinting.
5+
6+
## Ubuntu (glibc)
7+
8+
Install fonts:
9+
10+
```bash
11+
sudo apt-get update
12+
sudo apt-get install -y \
13+
fonts-dejavu \
14+
fonts-liberation \
15+
fonts-ubuntu \
16+
fonts-noto-color-emoji
17+
```
18+
19+
Disable font hinting and subpixel tweaks:
20+
21+
```bash
22+
sudo tee /etc/fonts/local.conf >/dev/null <<'XML'
23+
<?xml version="1.0"?>
24+
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
25+
<fontconfig>
26+
<match target="font">
27+
<edit name="hinting" mode="assign"><bool>false</bool></edit>
28+
<edit name="autohint" mode="assign"><bool>false</bool></edit>
29+
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
30+
<edit name="rgba" mode="assign"><const>none</const></edit>
31+
</match>
32+
</fontconfig>
33+
XML
34+
35+
sudo fc-cache -f
36+
```
37+
38+
## Alpine (musl)
39+
40+
Install fonts:
41+
42+
```bash
43+
apk add --no-cache \
44+
ttf-dejavu \
45+
ttf-liberation \
46+
ttf-ubuntu-font-family \
47+
font-noto-emoji
48+
```
49+
50+
Disable font hinting and subpixel tweaks:
51+
52+
```bash
53+
cat > /etc/fonts/local.conf <<'XML'
54+
<?xml version="1.0"?>
55+
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
56+
<fontconfig>
57+
<match target="font">
58+
<edit name="hinting" mode="assign"><bool>false</bool></edit>
59+
<edit name="autohint" mode="assign"><bool>false</bool></edit>
60+
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
61+
<edit name="rgba" mode="assign"><const>none</const></edit>
62+
</match>
63+
</fontconfig>
64+
XML
65+
66+
fc-cache -f
67+
```
68+
69+
## GitHub Actions (Ubuntu)
70+
71+
If you use the provided setup action, the OS preparation is handled for you.
72+
See `docs/ci-integration.md` for the GitHub Actions snippets.

lib/capybara/screenshot/diff/browser_helpers.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,76 @@ def self.disable_animations
7373
session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
7474
end
7575

76+
DEFAULT_NORMALIZE_CSS = <<~CSS
77+
/* Kill animations and transitions */
78+
*, *::before, *::after {
79+
animation-duration: 0s !important;
80+
animation-delay: 0s !important;
81+
animation-iteration-count: 1 !important;
82+
transition-duration: 0s !important;
83+
transition-delay: 0s !important;
84+
scroll-behavior: auto !important;
85+
}
86+
87+
/* Standardize font rendering */
88+
* {
89+
-webkit-font-smoothing: antialiased !important;
90+
-moz-osx-font-smoothing: grayscale !important;
91+
text-rendering: geometricPrecision !important;
92+
}
93+
94+
/* Neutralize inputs and dynamic artifacts */
95+
* { caret-color: transparent !important; }
96+
input[type="number"]::-webkit-inner-spin-button,
97+
input[type="number"]::-webkit-outer-spin-button {
98+
-webkit-appearance: none !important;
99+
}
100+
101+
/* Hide OS-specific scrollbars */
102+
::-webkit-scrollbar { display: none !important; }
103+
* { scrollbar-width: none !important; -ms-overflow-style: none !important; }
104+
CSS
105+
106+
def self.normalize_css(css = nil)
107+
css ||= DEFAULT_NORMALIZE_CSS
108+
109+
session.execute_script(<<~JS, css)
110+
(function(cssText) {
111+
if (!document.getElementById('csdNormalizeStyle')) {
112+
let style = document.createElement('style');
113+
style.setAttribute('id', 'csdNormalizeStyle');
114+
style.textContent = cssText;
115+
document.head.appendChild(style);
116+
}
117+
})(arguments[0]);
118+
JS
119+
end
120+
121+
def self.inject_custom_stylesheets(stylesheets)
122+
Array(stylesheets).each_with_index do |css, index|
123+
inject_stylesheet(css, "csdCustomStyle#{index}")
124+
end
125+
end
126+
127+
def self.inject_custom_scripts(scripts)
128+
Array(scripts).each do |script|
129+
session.execute_script(script)
130+
end
131+
end
132+
133+
def self.inject_stylesheet(css, element_id)
134+
session.execute_script(<<~JS, css, element_id)
135+
(function(cssText, styleId) {
136+
if (!document.getElementById(styleId)) {
137+
let style = document.createElement('style');
138+
style.setAttribute('id', styleId);
139+
style.textContent = cssText;
140+
document.head.appendChild(style);
141+
}
142+
})(arguments[0], arguments[1]);
143+
JS
144+
end
145+
76146
FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
77147
function activeElement(){
78148
const ae = document.activeElement;
@@ -113,6 +183,17 @@ def self.pending_image_to_load
113183
BrowserHelpers.session.evaluate_script(IMAGE_WAIT_SCRIPT)
114184
end
115185

186+
FONTS_READY_SCRIPT = <<~JS
187+
(function() {
188+
if (!document.fonts) return true;
189+
return document.fonts.status === "loaded";
190+
})();
191+
JS
192+
193+
def self.fonts_ready?
194+
BrowserHelpers.session.evaluate_script(FONTS_READY_SCRIPT)
195+
end
196+
116197
def self.current_capybara_driver_class
117198
session.driver.class
118199
end

lib/capybara/screenshot/diff/screenshot_matcher.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ def extract_capture_and_comparison_options!(driver_options = {})
102102
# screenshot options
103103
capybara_screenshot_options: driver_options[:capybara_screenshot_options],
104104
crop: driver_options.delete(:crop),
105+
normalize_css: driver_options.delete(:normalize_css),
106+
normalize_stylesheet: driver_options.delete(:normalize_stylesheet),
107+
wait_for_fonts: driver_options.delete(:wait_for_fonts),
105108
# delivery options
106109
screenshot_format: driver_options[:screenshot_format],
107110
# stability options

0 commit comments

Comments
 (0)