Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ end

**Screenshots differ between CI and local** — Use `tolerance: 0.001` or `perceptual_threshold: 2.0`. Set `window_size` for consistent dimensions.

**Animations cause flaky diffs** — `Capybara.disable_animation = true`, or `stability_time_limit: 1`.
**Animations cause flaky diffs** — `Capybara::Screenshot.disable_animations = true` disables CSS animations/transitions before each screenshot. Or use `stability_time_limit: 1` to wait for animations to finish.

**Dynamic content always differs** — `screenshot "page", skip_area: [".timestamp", "#ad-banner"]`

Expand Down
13 changes: 13 additions & 0 deletions bin/ci
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash
set -e

echo "Running linter..."
if ! bin/standardrb; then
echo "Lint errors found. Auto-fixing..."
bin/standardrb -a
echo "Lint errors were auto-fixed. Please review and re-commit."
exit 1
fi

echo "Running unit tests..."
bin/rake test:unit
13 changes: 13 additions & 0 deletions lib/capybara/screenshot/diff/browser_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ def self.hide_caret
session.execute_script(HIDE_CARET_SCRIPT)
end

DISABLE_ANIMATIONS_SCRIPT = <<~JS
if (!document.getElementById('csdDisableAnimationsStyle')) {
let style = document.createElement('style');
style.setAttribute('id', 'csdDisableAnimationsStyle');
style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
document.head.appendChild(style);
}
JS

def self.disable_animations
session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
end

FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
function activeElement(){
const ae = document.activeElement;
Expand Down
7 changes: 0 additions & 7 deletions lib/capybara/screenshot/diff/drivers/vips_driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,6 @@ def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_R

private

def region_covers_entire_image?(region, base_image)
region.x.zero? &&
region.y.zero? &&
region.height == height_for(base_image) &&
region.width == width_for(base_image)
end

class << self
def difference_area(old_image, new_image, color_distance: 0)
mask = difference_mask(new_image, old_image, color_distance)
Expand Down
7 changes: 4 additions & 3 deletions lib/capybara/screenshot/diff/screenshoter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ module Screenshot
class Screenshoter
attr_reader :capture_options, :driver

# @param capture_options [Hash] Options for capturing (window_size, wait, etc.)
# @param comparison_options [Hash] Options for image comparison (driver, tolerance, etc.)
def initialize(capture_options, comparison_options = {})
@capture_options = capture_options
@driver = Diff::Drivers.for(comparison_options)
Expand Down Expand Up @@ -70,9 +72,8 @@ def prepare_page_for_screenshot(timeout:)

blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element

if Screenshot.hide_caret
BrowserHelpers.hide_caret
end
BrowserHelpers.hide_caret if Screenshot.hide_caret
BrowserHelpers.disable_animations if Screenshot.disable_animations

blurred_input
end
Expand Down
43 changes: 20 additions & 23 deletions lib/capybara/screenshot/diff/vcs.rb
Original file line number Diff line number Diff line change
@@ -1,40 +1,37 @@
# frozen_string_literal: true

require "open3"
require_relative "os"

module Capybara
module Screenshot
module Diff
module Vcs
SILENCE_ERRORS = Os::ON_WINDOWS ? "2>nul" : "2>/dev/null"

def self.checkout_vcs(root, screenshot_path, checkout_path)
abs_screenshot_path = Pathname.new(screenshot_path).expand_path
redirect_target = "#{checkout_path} #{SILENCE_ERRORS}"

Dir.chdir(root) do
git_toplevel = `git rev-parse --show-toplevel 2>/dev/null`.chomp
return false if git_toplevel.empty?

vcs_file_path = abs_screenshot_path.relative_path_from(Pathname.new(git_toplevel))
show_command = "git show HEAD:#{vcs_file_path}"
if Screenshot.use_lfs
system("#{show_command} > #{checkout_path}.tmp #{SILENCE_ERRORS}", exception: !!ENV["DEBUG"])

`git lfs smudge < #{checkout_path}.tmp > #{redirect_target}` if $CHILD_STATUS == 0

File.delete "#{checkout_path}.tmp"
else
system("#{show_command} > #{redirect_target}", exception: !!ENV["DEBUG"])
root_path = root.to_s
git_root, status = Open3.capture2("git", "-C", root_path, "rev-parse", "--show-toplevel")
return false unless status.success?

git_root = git_root.chomp
vcs_file_path = Pathname.new(screenshot_path).expand_path.relative_path_from(Pathname.new(git_root)).to_s

if Screenshot.use_lfs
tmp_path = "#{checkout_path}.tmp"
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL)
if success
system("git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL)
end
File.delete(tmp_path) if File.exist?(tmp_path)
else
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL)
end

if $CHILD_STATUS != 0
unless success
checkout_path.delete if checkout_path.exist?
false
else
true
return false
end

true
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/capybara_screenshot_diff.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module Screenshot
mattr_accessor(:blur_active_element) { true }
mattr_accessor :enabled
mattr_accessor(:hide_caret) { true }
mattr_accessor :disable_animations
mattr_reader(:root) { (defined?(Rails) && defined?(Rails.root) && Rails.root) || Pathname(".").expand_path }
mattr_accessor :stability_time_limit
mattr_accessor :window_size
Expand Down
20 changes: 14 additions & 6 deletions lib/capybara_screenshot_diff/reporters/html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
module CapybaraScreenshotDiff
module Reporters
class HTML
attr_reader :output_path, :failures, :total
attr_reader :failures, :total

def initialize(output_path: "tmp/snap_diff/index.html", embed_images: false)
@output_path = Pathname.new(output_path)
@report_dir = @output_path.dirname
def initialize(output_path: nil, embed_images: false)
@explicit_output_path = output_path
@embed_images = embed_images
@failures = []
@total = 0
Expand Down Expand Up @@ -57,6 +56,10 @@ def finalize
end
end

def output_path
@output_path ||= Pathname.new(@explicit_output_path || self.class.default_output_path)
end

def passed = total - failures.size
def failed = failures.size

Expand All @@ -73,6 +76,11 @@ def self.template_path
File.expand_path("templates/report.html.erb", __dir__)
end

def self.default_output_path
root = Capybara::Screenshot.root || Pathname.pwd
root / Capybara::Screenshot.save_path / "snap_diff_report.html"
end

private

def failure_entry_for(name, compare)
Expand All @@ -96,7 +104,7 @@ def resolve_image(path)
pathname = Pathname.new(path).expand_path
return unless pathname.exist?

@embed_images ? data_uri(pathname) : pathname.relative_path_from(@report_dir.expand_path).to_s
@embed_images ? data_uri(pathname) : pathname.relative_path_from(output_path.dirname.expand_path).to_s
end

def data_uri(pathname)
Expand All @@ -106,7 +114,7 @@ def data_uri(pathname)
end

def write_report
FileUtils.mkdir_p(@report_dir)
FileUtils.mkdir_p(output_path.dirname)
File.write(output_path, render)
end
end
Expand Down
7 changes: 0 additions & 7 deletions lib/capybara_screenshot_diff/screenshot_namer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,6 @@ def current_group_directory
File.join(*([screenshot_area] + directory_parts))
end

# Clears the directory for the current screenshot group.
# This is typically used when starting a new group to remove old screenshots.
def clear_current_group_directory
dir_to_clear = current_group_directory
FileUtils.rm_rf(dir_to_clear) if Dir.exist?(dir_to_clear)
end

private

def reset_group_counter
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate_sample_report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
require "capybara/screenshot/diff"
require "capybara_screenshot_diff/reporters/html"

output_path = File.expand_path("../tmp/snap_diff/index.html", __dir__)
output_path = CapybaraScreenshotDiff::Reporters::HTML.default_output_path

# Build real comparisons using the gem's own ImageCompare.
# Each pair gets a unique copy of the base image to avoid annotation file conflicts.
Expand Down
40 changes: 40 additions & 0 deletions test/support/driver_contract_tests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

# Shared contract tests for all image processing drivers.
# Include in any driver test class that uses DSLStub (provides make_comparison).
module DriverContractTests
extend ActiveSupport::Concern

included do
test "[contract] quick_equal? returns true for identical images" do
comp = make_comparison(:a, :a)
assert comp.quick_equal?
end

test "[contract] different? returns false for identical images" do
comp = make_comparison(:a, :a)
assert_not comp.different?
end

test "[contract] different? returns true for different images" do
comp = make_comparison(:a, :c)
assert comp.different?
end

test "[contract] different? generates annotated images for different images" do
comp = make_comparison(:a, :c)
assert comp.different?

assert File.exist?(comp.reporter.annotated_base_image_path)
assert File.exist?(comp.reporter.annotated_image_path)
end

test "[contract] different? does not create annotated images for identical images" do
comp = make_comparison(:c, :c)
assert_not comp.different?

assert_not File.exist?(comp.reporter.annotated_base_image_path)
assert_not File.exist?(comp.reporter.annotated_image_path)
end
end
end
30 changes: 2 additions & 28 deletions test/support/test_doubles.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,39 +89,13 @@ def supports?(...)
@is_vips_driver
end

# Return Object to avoid infinite recursion
# Returns Object so warning messages in ImagePreprocessor don't
# couple tests to the TestDriver class name.
def class
Object
end
end

# Test double for image preprocessors
class TestPreprocessor
attr_reader :call_called, :call_args, :process_comparison_called, :process_comparison_args, :processed_images

def initialize(processed_images)
@processed_images = processed_images
@call_called = false
@call_args = nil
@process_comparison_called = false
@process_comparison_args = nil
end

def call(images)
@call_called = true
@call_args = images
processed_images
end

# Process a comparison object directly
# Mirrors the implementation in ImagePreprocessor
def process_comparison(comparison)
@process_comparison_called = true
@process_comparison_args = comparison
comparison
end
end

# Test double for difference results
class TestDifference
attr_reader :different_value
Expand Down
26 changes: 20 additions & 6 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,34 @@ class ActiveSupport::TestCase
# Set up fixtures and test helpers
self.file_fixture_path = Pathname.new(File.expand_path("fixtures", __dir__))

# Snapshot ALL global state before each test, restore after.
# Prevents one test from poisoning another via leaked mattr_accessor changes.
GLOBAL_STATE_MODULES = [
Capybara::Screenshot,
Capybara::Screenshot::Diff
].freeze

setup do
@_orig_fail_if_new = Capybara::Screenshot::Diff.fail_if_new
@_orig_blur = Capybara::Screenshot.blur_active_element
@_orig_hide_caret = Capybara::Screenshot.hide_caret
@_global_snapshots = GLOBAL_STATE_MODULES.map { |mod|
attrs = mod.class_variables.map { |cv| [cv, mod.class_variable_get(cv)] }
[mod, attrs]
}.to_h
@_orig_cwd = Dir.pwd
@_orig_capybara_app = Capybara.app

Capybara::Screenshot::Diff.fail_if_new = false
Capybara::Screenshot.blur_active_element = false
Capybara::Screenshot.hide_caret = false
Capybara::Screenshot.disable_animations = false
end

teardown do
Capybara::Screenshot::Diff.fail_if_new = @_orig_fail_if_new
Capybara::Screenshot.blur_active_element = @_orig_blur
Capybara::Screenshot.hide_caret = @_orig_hide_caret
# Restore all global state
@_global_snapshots&.each do |mod, attrs|
attrs.each { |cv, val| mod.class_variable_set(cv, val) }
end
Dir.chdir(@_orig_cwd) if @_orig_cwd && Dir.pwd != @_orig_cwd
Capybara.app = @_orig_capybara_app if @_orig_capybara_app
CapybaraScreenshotDiff::SnapManager.cleanup! unless persist_comparisons?
end

Expand Down
2 changes: 2 additions & 0 deletions test/unit/drivers/chunky_png_driver_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
require "test_helper"
require "capybara/screenshot/diff/image_compare"
require "capybara/screenshot/diff/drivers/chunky_png_driver"
require "support/driver_contract_tests"

module Capybara
module Screenshot
module Diff
module Drivers
class ChunkyPNGDriverTest < ActiveSupport::TestCase
include CapybaraScreenshotDiff::DSLStub
include DriverContractTests

class QuickEqualTest < self
test "#quick_equal? returns true when comparing identical images" do
Expand Down
2 changes: 2 additions & 0 deletions test/unit/drivers/vips_driver_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "test_helper"
require "support/driver_contract_tests"

unless defined?(Vips)
warn "VIPS not present. Skipping VIPS driver tests."
Expand All @@ -15,6 +16,7 @@ module Diff
module Drivers
class VipsDriverTest < ActiveSupport::TestCase
include CapybaraScreenshotDiff::DSLStub
include DriverContractTests

setup do
@new_screenshot_result = Tempfile.new(%w[screenshot .png], Rails.root)
Expand Down
6 changes: 6 additions & 0 deletions test/unit/reporters/html_reporter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ class HTMLReporterTest < ActiveSupport::TestCase
assert_includes @output_path.read, "valid"
end

test "HTML reporter defaults to screenshot root path" do
reporter = HTML.new
expected = Capybara::Screenshot.root / Capybara::Screenshot.save_path / "snap_diff_report.html"
assert_equal expected, reporter.output_path
end

test "#record uses relative paths by default" do
reporter = HTML.new(output_path: @output_path)

Expand Down
Loading
Loading