Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .github/actions/setup-ruby-and-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,27 @@ runs:
if: ${{ inputs.cache-apt-packages == 'true' }}
uses: jetthoughts/cache-apt-pkgs-action@fix/upgrade-actions-cache-v5
with:
packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev
packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev fonts-dejavu fonts-liberation fonts-ubuntu fonts-noto-color-emoji
version: tests-v2

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

- run: sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-yes-antialias.conf
- name: Configure font rendering
run: |
sudo tee /etc/fonts/local.conf >/dev/null <<'XML'
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="font">
<edit name="hinting" mode="assign"><bool>false</bool></edit>
<edit name="autohint" mode="assign"><bool>false</bool></edit>
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
<edit name="rgba" mode="assign"><const>none</const></edit>
</match>
</fontconfig>
XML
sudo fc-cache -f
shell: bash
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,13 @@ Enable `Capybara::Screenshot.disable_animations = true` to freeze CSS animations
<details>
<summary><strong>CI screenshots differ from local</strong></summary>

Set `window_size` for consistent dimensions and use `perceptual_threshold: 2.0` to ignore anti-aliasing differences across environments.
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:

```ruby
Capybara::Screenshot.enable_consistent_screenshots!
```

For advanced tuning and custom injections, see `docs/configuration.md`.
</details>

<details>
Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0001-screenshot-prep-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR 0001: Screenshot Preparation Plugins (Deferred)

Date: 2026-04-14
Status: Deferred

## Context

We added more pre-capture steps for stable screenshots, including:
- DOM normalization CSS
- Font readiness waits
- Animation disabling
- Caret hiding
- Custom CSS and JS injections

The number of knobs and prep steps is growing. A plugin pipeline could make
ordering explicit and allow app-level extensions without monkey-patching.

## Decision

Defer a plugin system for now.

We will keep the simple `configure_consistency` API plus existing flags as
aliases. This provides a single entry point and keeps complexity low.

## Consequences

Benefits:
- Minimal code and API surface
- Easy onboarding for new users

Costs:
- Prep order is still encoded in `Screenshoter`
- Extensibility is limited to CSS/JS injections and flags

## Revisit Criteria

Re-open this decision when:
- We need more than one custom preparation step per app
- We add two or more new prep steps beyond CSS/JS injection
- Prep ordering or conditional logic becomes hard to reason about

## Next Refactoring Ideas

If revisited, implement a lightweight pipeline:
- `plugins` list with callables
- Context object with `inject_css`, `inject_js`, `wait_for_fonts`, and `session`
- Built-in plugins for existing steps, mapped from current flags
5 changes: 5 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Architecture Decision Records

## ADR Index

- [ADR 0001: Screenshot Preparation Plugins (Deferred)](0001-screenshot-prep-plugins.md)
8 changes: 6 additions & 2 deletions docs/ci-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ That's it. On failure, this will:

### 3. Ruby + libvips setup action

For consistent CI environments (libvips, font antialiasing disabled), use the setup action:
For consistent CI environments (libvips, standardized fonts, hinting disabled),
use the setup action:

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

This installs Ruby, libvips (with apt caching), and disables font antialiasing for consistent rendering across CI runs.
This installs Ruby, libvips (with apt caching), installs the core font stack,
and disables font hinting for consistent rendering across CI runs.

For local or Docker setups, see `docs/os-setup.md`.

#### Inputs

Expand Down
77 changes: 77 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,83 @@ If you want to skip the assertion for change in the screen shot, set
Capybara::Screenshot::Diff.enabled = false
```

### DOM normalization (recommended for cross-OS baselines)

To reduce visual noise from OS-level font rendering, scrollbars, and UI chrome,
the default config injects a normalization stylesheet and waits for web fonts
before capture. You can override as needed:

```ruby
Capybara::Screenshot.normalize_css = false
Capybara::Screenshot.wait_for_fonts = false
```

The built-in normalization stylesheet:
- disables animations/transitions
- standardizes font rendering
- hides carets and number spinners
- hides OS-specific scrollbars

To supply custom CSS instead:

```ruby
Capybara::Screenshot.normalize_css = true
Capybara::Screenshot.normalize_stylesheet = <<~CSS
/* your custom normalization rules */
CSS
```

### One-line preset

If you'd rather toggle everything in one call:

```ruby
Capybara::Screenshot.enable_consistent_screenshots!
```

### Unified consistency config (recommended)

For a single entry point with custom injections:

```ruby
Capybara::Screenshot.configure_consistency(preset: :default) do |c|
c.blur_active_element = true
c.hide_caret = true
c.disable_animations = true
c.normalize_css = true
c.wait_for_fonts = true
c.css << "/* your custom css */"
c.js << "/* your custom js */"
end
```

Available presets:
- `:default` (enable normalization + font wait + disable animations + hide caret + blur)
- `:off` (disable all consistency shims)

**Compatibility:** existing flags (`normalize_css`, `wait_for_fonts`, `disable_animations`, etc.)
remain supported as aliases.

For OS-level setup (fonts + hinting), see `docs/os-setup.md`.

### Cross-OS baseline preset (Ubuntu ↔ Alpine)

If you compare baselines across `glibc` and `musl`, combine perceptual diffing
with a tighter tolerated diff area:

```ruby
Capybara::Screenshot::Diff.configure do |screenshot, diff|
screenshot.window_size = [1280, 1024]
screenshot.disable_animations = true
screenshot.normalize_css = true
screenshot.wait_for_fonts = true

diff.driver = :vips
diff.perceptual_threshold = 2.0
diff.tolerance = 0.00005 # 0.005% of pixels
end
```

Using an environment variable

```ruby
Expand Down
72 changes: 72 additions & 0 deletions docs/os-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# OS Setup for Consistent Screenshots

Screenshot rendering varies across OS and C libraries. For reliable baselines
across Ubuntu (glibc) and Alpine (musl), standardize fonts and disable hinting.

## Ubuntu (glibc)

Install fonts:

```bash
sudo apt-get update
sudo apt-get install -y \
fonts-dejavu \
fonts-liberation \
fonts-ubuntu \
fonts-noto-color-emoji
```

Disable font hinting and subpixel tweaks:

```bash
sudo tee /etc/fonts/local.conf >/dev/null <<'XML'
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="font">
<edit name="hinting" mode="assign"><bool>false</bool></edit>
<edit name="autohint" mode="assign"><bool>false</bool></edit>
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
<edit name="rgba" mode="assign"><const>none</const></edit>
</match>
</fontconfig>
XML

sudo fc-cache -f
```

## Alpine (musl)

Install fonts:

```bash
apk add --no-cache \
ttf-dejavu \
ttf-liberation \
ttf-ubuntu-font-family \
font-noto-emoji
```

Disable font hinting and subpixel tweaks:

```bash
cat > /etc/fonts/local.conf <<'XML'
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="font">
<edit name="hinting" mode="assign"><bool>false</bool></edit>
<edit name="autohint" mode="assign"><bool>false</bool></edit>
<edit name="hintstyle" mode="assign"><const>hintnone</const></edit>
<edit name="rgba" mode="assign"><const>none</const></edit>
</match>
</fontconfig>
XML

fc-cache -f
```

## GitHub Actions (Ubuntu)

If you use the provided setup action, the OS preparation is handled for you.
See `docs/ci-integration.md` for the GitHub Actions snippets.
81 changes: 81 additions & 0 deletions lib/capybara/screenshot/diff/browser_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,76 @@ def self.disable_animations
session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
end

DEFAULT_NORMALIZE_CSS = <<~CSS
/* Kill animations and transitions */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}

/* Standardize font rendering */
* {
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
text-rendering: geometricPrecision !important;
}

/* Neutralize inputs and dynamic artifacts */
* { caret-color: transparent !important; }
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important;
}

/* Hide OS-specific scrollbars */
::-webkit-scrollbar { display: none !important; }
* { scrollbar-width: none !important; -ms-overflow-style: none !important; }
CSS

def self.normalize_css(css = nil)
css ||= DEFAULT_NORMALIZE_CSS

session.execute_script(<<~JS, css)
(function(cssText) {
if (!document.getElementById('csdNormalizeStyle')) {
let style = document.createElement('style');
style.setAttribute('id', 'csdNormalizeStyle');
style.textContent = cssText;
document.head.appendChild(style);
}
})(arguments[0]);
JS
end

def self.inject_custom_stylesheets(stylesheets)
Array(stylesheets).each_with_index do |css, index|
inject_stylesheet(css, "csdCustomStyle#{index}")
end
end

def self.inject_custom_scripts(scripts)
Array(scripts).each do |script|
session.execute_script(script)
end
end

def self.inject_stylesheet(css, element_id)
session.execute_script(<<~JS, css, element_id)
(function(cssText, styleId) {
if (!document.getElementById(styleId)) {
let style = document.createElement('style');
style.setAttribute('id', styleId);
style.textContent = cssText;
document.head.appendChild(style);
}
})(arguments[0], arguments[1]);
JS
end

FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
function activeElement(){
const ae = document.activeElement;
Expand Down Expand Up @@ -113,6 +183,17 @@ def self.pending_image_to_load
BrowserHelpers.session.evaluate_script(IMAGE_WAIT_SCRIPT)
end

FONTS_READY_SCRIPT = <<~JS
(function() {
if (!document.fonts) return true;
return document.fonts.status === "loaded";
})();
JS

def self.fonts_ready?
BrowserHelpers.session.evaluate_script(FONTS_READY_SCRIPT)
end

def self.current_capybara_driver_class
session.driver.class
end
Expand Down
3 changes: 3 additions & 0 deletions lib/capybara/screenshot/diff/screenshot_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ def extract_capture_and_comparison_options!(driver_options = {})
# screenshot options
capybara_screenshot_options: driver_options[:capybara_screenshot_options],
crop: driver_options.delete(:crop),
normalize_css: driver_options.delete(:normalize_css),
normalize_stylesheet: driver_options.delete(:normalize_stylesheet),
wait_for_fonts: driver_options.delete(:wait_for_fonts),
# delivery options
screenshot_format: driver_options[:screenshot_format],
# stability options
Expand Down
Loading
Loading