diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..57985403 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.github/ +.ruby-lsp/ +coverage/ +tmp/ + +# Ignore report files +*.attempt_*.png +*.diff.png +*.base.png +*.attempt_*.webp +*.diff.webp +*.base.webp diff --git a/.github/actions/setup-ruby-and-dependencies/action.yml b/.github/actions/setup-ruby-and-dependencies/action.yml index 27441777..be672e9b 100644 --- a/.github/actions/setup-ruby-and-dependencies/action.yml +++ b/.github/actions/setup-ruby-and-dependencies/action.yml @@ -35,3 +35,6 @@ runs: # fallback if cache version is outdated - run: sudo apt-get -qq install libvips shell: bash + + - run: sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-yes-antialias.conf + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c82daec..24355993 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ env: JAVA_OPTS: -Xmn2g -Xms6g -Xmx6g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss1m -XX:+UseG1GC -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -XX:ReservedCodeCacheSize=256m -XX:+UseCodeCacheFlushing - JRUBY_OPTS: --dev + JRUBY_OPTS: --dev -J-Djruby.thread.pool.enabled=true MALLOC_ARENA_MAX: 2 RUBY_GC_HEAP_FREE_SLOTS: 600000 RUBY_GC_HEAP_GROWTH_FACTOR: 1.1 @@ -45,6 +45,8 @@ jobs: ruby-version: 3.4 - run: bin/rake test + env: + SCREENSHOT_DRIVER: vips functional-test: name: Functional Test @@ -87,11 +89,11 @@ jobs: github.event.pull_request.requested_reviewers.length > 0 needs: [ functional-test ] runs-on: ubuntu-latest - timeout-minutes: ${{ contains(matrix.ruby-version, 'jruby') && 12 || 8 }} + timeout-minutes: ${{ contains(matrix.ruby-version, 'jruby') && 20 || 8 }} continue-on-error: ${{ matrix.experimental }} strategy: matrix: - ruby-version: [ 3.4, 3.3, 3.2, jruby ] + ruby-version: [ 3.4, 3.3, 3.2, jruby-9.4, jruby-10.0 ] gemfile: - rails70_gems.rb - rails71_gems.rb @@ -104,7 +106,7 @@ jobs: gemfile: rails80_gems.rb experimental: false # JRuby 9.x is Ruby 3.1 compatible, and Rails 8 requires Ruby 3.2. - - ruby-version: jruby + - ruby-version: jruby-9.4 gemfile: rails80_gems.rb experimental: false include: @@ -130,7 +132,7 @@ jobs: - name: Run tests (with 2 retries) uses: nick-fields/retry@v3 with: - timeout_minutes: ${{ contains(matrix.ruby-version, 'jruby') && 3 || 3 }} + timeout_minutes: ${{ contains(matrix.ruby-version, 'jruby') && 7 || 3 }} max_attempts: 3 command: bin/rake test diff --git a/.gitignore b/.gitignore index 84aee455..cb06f4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *~ /.bundle/ /.idea +/.windsurf /.yardoc /_yardoc/ /coverage/ diff --git a/.standard.yml b/.standard.yml index 007f357a..a2dae368 100644 --- a/.standard.yml +++ b/.standard.yml @@ -2,7 +2,7 @@ fix: true # default: false parallel: true # default: false format: progress # default: Standard::Formatter -ruby_version: 3.2 # default: RUBY_VERSION +ruby_version: 3.1 # to support JRuby 9.4 default_ignores: false # default: true ignore: # default: [] diff --git a/Dockerfile b/Dockerfile index 3192cf67..afc7174d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,15 @@ # $ docker build . -t csd # $ docker run -v $(pwd):/app -ti csd rake test -FROM --platform=linux/amd64 jetthoughts/cimg-ruby:3.4-chrome +FROM jetthoughts/cimg-ruby:3.4-chrome -# Install dependencies and clean up in one layer to reduce image size -RUN sudo apt-get update -qq && \ - DEBIAN_FRONTEND=noninteractive sudo apt-get install -qq \ +ENV DEBIAN_FRONTEND=noninteractive \ + BUNDLE_PATH=/bundle + +RUN --mount=type=cache,target=/var/cache/apt \ + sudo sed -i 's|http://security.ubuntu.com/ubuntu|http://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list && \ + sudo apt-get update -qq && \ + sudo apt-get install -qq --fix-missing \ automake \ build-essential \ curl \ @@ -34,20 +38,12 @@ RUN sudo apt-get update -qq && \ libwebp-dev \ libxml2-dev \ swig && \ - sudo apt-get autoremove -y && \ - sudo apt-get autoclean && \ - sudo apt-get clean && \ - sudo rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + sudo apt-get autoclean -WORKDIR /app -COPY gems.rb gemfiles capybara-screenshot-diff.gemspec /app/ -COPY lib/capybara/screenshot/diff/version.rb /app/lib/capybara/screenshot/diff/ +RUN sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-antialias.conf -# Set the location for Bundler to store gems -ENV BUNDLE_PATH=/bundle -RUN sudo mkdir /bundle && \ - sudo chmod a+w+r /bundle \ - sudo mkdir -p /tmp/.X11-unix && \ - sudo chmod 1777 /tmp/.X11-unix +RUN sudo mkdir -p /bundle /tmp/.X11-unix && \ + sudo chmod 1777 /bundle /tmp/.X11-unix +WORKDIR /app diff --git a/bin/dtest b/bin/dtest index 8c219d24..1e4db122 100755 --- a/bin/dtest +++ b/bin/dtest @@ -1,14 +1,37 @@ #!/bin/bash -set -eo pipefail +set -o pipefail export DOCKER_DEFAULT_PLATFORM=linux/amd64 +# Define allowed environment variables to pass to Docker +ALLOWED_ENV_VARS=( + "CI" "DEBUG" "TEST_ENV" "RAILS_ENV" "RACK_ENV" "COVERAGE" "DISABLE_ROLLBACK_COMPARISON_RUNTIME_FILES" + "RECORD_SCREENSHOTS" "TEST" "TESTOPTS" "SCREENSHOT_DRIVER" +) + +# Build the Docker env args string +DOCKER_ENV_ARGS="" +for var in "${ALLOWED_ENV_VARS[@]}"; do + if [[ -n "${!var}" ]]; then + DOCKER_ENV_ARGS="$DOCKER_ENV_ARGS -e $var=${!var}" + fi +done + +# Build the Docker image docker build . -t csd:test -docker run -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/setup +# Run setup +(docker run $DOCKER_ENV_ARGS -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/setup) || exit 1 +# Run tests with different drivers echo "Running tests..." -docker run -e CAPYBARA_DRIVER=cuprite -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/rake test -docker run -e CAPYBARA_DRIVER=selenium_chrome_headless -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/rake test -docker run -e CAPYBARA_DRIVER=selenium_headless -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/rake test +DRIVERS=("cuprite" "selenium_chrome_headless" "selenium_headless") +for driver in "${DRIVERS[@]}"; do + echo "Running tests with $driver driver..." + docker run $DOCKER_ENV_ARGS -e CAPYBARA_DRIVER="$driver" \ + -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test \ + bin/rake test "$@" + + CAPYBARA_DRIVER="$driver" bin/rake test "$@" +done diff --git a/capybara-screenshot-diff.gemspec b/capybara-screenshot-diff.gemspec index cfe4c3c4..663e9687 100644 --- a/capybara-screenshot-diff.gemspec +++ b/capybara-screenshot-diff.gemspec @@ -23,6 +23,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_runtime_dependency "actionpack", ">= 7.0", "< 9" + spec.add_development_dependency "actionpack", ">= 7.0", "< 9" + spec.add_development_dependency "activesupport", ">= 7.0", "< 9" spec.add_runtime_dependency "capybara", ">= 2", "< 4" end diff --git a/gemfiles/edge_gems.rb b/gemfiles/edge_gems.rb index acd22987..c4745b3d 100644 --- a/gemfiles/edge_gems.rb +++ b/gemfiles/edge_gems.rb @@ -4,5 +4,6 @@ eval File.read(gems), binding, gems git "https://github.com/rails/rails.git" do + gem "activesupport" gem "actionpack" end diff --git a/gemfiles/rails70_gems.rb b/gemfiles/rails70_gems.rb index 2edc1b74..8358a52e 100644 --- a/gemfiles/rails70_gems.rb +++ b/gemfiles/rails70_gems.rb @@ -4,6 +4,7 @@ eval File.read(gems), binding, gems gem "actionpack", "~> 7.0.0" +gem "activesupport", "~> 7.0.0", require: %w[active_support/deprecator active_support/test_case] gem "mutex_m" gem "drb" gem "bigdecimal" diff --git a/gemfiles/rails71_gems.rb b/gemfiles/rails71_gems.rb index 02634398..db46028c 100644 --- a/gemfiles/rails71_gems.rb +++ b/gemfiles/rails71_gems.rb @@ -3,4 +3,5 @@ gems = "#{File.dirname __dir__}/gems.rb" eval File.read(gems), binding, gems -gem "actionpack", "~> 7.1.0" +gem "activesupport", "~> 7.1.0", require: %w[logger active_support/deprecator active_support] +gem "actionpack", "~> 7.1.0", require: %w[action_controller action_dispatch] diff --git a/gemfiles/rails80_gems.rb b/gemfiles/rails80_gems.rb index 3f86dbce..548e3714 100644 --- a/gemfiles/rails80_gems.rb +++ b/gemfiles/rails80_gems.rb @@ -3,4 +3,5 @@ gems = "#{File.dirname __dir__}/gems.rb" eval File.read(gems), binding, gems +gem "activesupport", "~> 8.0.0" gem "actionpack", "~> 8.0.0" diff --git a/lib/capybara/screenshot/diff/capture_strategy.rb b/lib/capybara/screenshot/diff/capture_strategy.rb new file mode 100644 index 00000000..62a4010f --- /dev/null +++ b/lib/capybara/screenshot/diff/capture_strategy.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Capybara + module Screenshot + module Diff + # Abstract base class for all screenshot‐capture strategies. + # A concrete strategy receives the raw capture/comparison option hashes, + # leaving them intact for now (we will introduce typed option objects in a + # later phase). It must implement `#take_comparison_screenshot` accepting + # a Snap. + class CaptureStrategy + def initialize(capture_options, comparison_options) + @capture_options = capture_options + @comparison_options = comparison_options + end + + # @param snapshot [CapybaraScreenshotDiff::Snap] + # @return [void] + def take_comparison_screenshot(_snapshot) + raise NotImplementedError, "subclass responsibility" + end + + private + + attr_reader :capture_options, :comparison_options + end + end + end +end diff --git a/lib/capybara/screenshot/diff/comparison.rb b/lib/capybara/screenshot/diff/comparison.rb index 9e1f8258..a60f591c 100644 --- a/lib/capybara/screenshot/diff/comparison.rb +++ b/lib/capybara/screenshot/diff/comparison.rb @@ -2,5 +2,8 @@ module Capybara::Screenshot::Diff class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path) + def skip_area + options[:skip_area] + end end end diff --git a/lib/capybara/screenshot/diff/comparison_loader.rb b/lib/capybara/screenshot/diff/comparison_loader.rb new file mode 100644 index 00000000..7979c2c1 --- /dev/null +++ b/lib/capybara/screenshot/diff/comparison_loader.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Capybara + module Screenshot + module Diff + # Loads and preprocesses images for comparison + # + # This class is responsible for loading images and creating a Comparison object. + # It coordinates with the ImagePreprocessor to apply any necessary filters + # before creating the comparison. This follows the Single Responsibility Principle + # by focusing solely on loading and assembling the comparison. + class ComparisonLoader + def initialize(driver) + @driver = driver + end + + # Load images and create a comparison object + # @param [String] base_path the path to the base image + # @param [String] new_path the path to the new image + # @param [Hash] options options for the comparison + # @return [Comparison] the comparison object + def call(base_path, new_path, options = {}) + # Load the raw images + base_img, new_img = @driver.load_images(base_path, new_path) + + # Create a preliminary comparison with raw images + # This is used for enhanced preprocessing that needs context + Comparison.new(new_img, base_img, options, @driver, new_path, base_path) + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/difference.rb b/lib/capybara/screenshot/diff/difference.rb index cec6003d..6d029194 100644 --- a/lib/capybara/screenshot/diff/difference.rb +++ b/lib/capybara/screenshot/diff/difference.rb @@ -5,7 +5,31 @@ module Capybara module Screenshot module Diff - class Difference < Struct.new(:region, :meta, :comparison, :failed_by) + # Represents a difference between two images + # + # This value object encapsulates the result of an image comparison operation. + # It follows the Single Responsibility Principle by focusing solely on representing + # the difference state, including: + # - Whether images are different or equal + # - Why they differ (dimensions, pixels, etc.) + # - The specific region of difference + # - Whether differences are tolerable based on configured thresholds + # + # As part of the layered comparison architecture, this class represents the final + # output of the comparison process, containing all data needed for reporting. + # Represents a difference between two images + class Difference < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil) + def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil) + Difference.new( + nil, + {difference_level: nil, max_color_distance: 0}, + comparison, + failed_by, + base_image_path, + new_image_path + ).freeze + end + def different? failed? || !(blank? || tolerable?) end @@ -61,6 +85,19 @@ def inspect def tolerable? !!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio)) end + + # Path accessors for backward compatibility + def new_image_path + image_path || comparison&.new_image_path + end + + def original_image_path + base_image_path || comparison&.base_image_path + end + + def diff_mask + meta[:diff_mask] + end end end end diff --git a/lib/capybara/screenshot/diff/difference_finder.rb b/lib/capybara/screenshot/diff/difference_finder.rb new file mode 100644 index 00000000..abd74070 --- /dev/null +++ b/lib/capybara/screenshot/diff/difference_finder.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "capybara/screenshot/diff/comparison" +require "capybara/screenshot/diff/difference" + +module Capybara + module Screenshot + module Diff + # Analyzes image differences with configurable tolerance levels. + # + # This class implements the core comparison logic for detecting visual differences + # between images while accounting for various tolerances and optimizations. + # + # The comparison process follows these steps: + # 1. Dimension Check (Fastest) + # - Compares image dimensions first for quick rejection + # - Different dimensions always indicate a difference + # + # 2. Pixel Equality Check (Fast) + # - Performs bitwise comparison if dimensions match + # - Returns immediately if images are exactly identical + # + # 3. Tolerant Comparison (Slower) + # - Only runs if quick checks don't determine equality + # - Respects configured tolerances for color and shift differences + # - Can ignore specific regions (skip_area) + # - Considers anti-aliasing and sub-pixel rendering differences + # + # The class is designed to be stateless and thread-safe, with all configuration + # passed in through the constructor. + class DifferenceFinder + TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze + + attr_reader :driver, :options + + # Creates a new DifferenceFinder instance. + # + # @param driver [Drivers::Base] The image processing driver to use. + # Must implement the driver interface expected by DifferenceFinder. + # @param options [Hash] Configuration options for the comparison: + # @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0). + # @option options [Numeric] :color_distance_limit Maximum allowed color distance. + # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance. + # @option options [Numeric] :area_size_limit Maximum allowed difference area size. + # @option options [Array] :skip_area Regions to exclude from comparison. + def initialize(driver, options) + @driver = driver + @options = options + end + + # Analyzes the comparison and determines if images are different. + # + # @param comparison [Comparison] The comparison object containing images to analyze. + # @param quick_mode [Boolean] When true, performs minimal checks and returns early. + # In quick mode, returns [is_equal, difference] where: + # - is_equal is true if images are considered equal + # - difference is a Difference object or nil + # When false, returns a Difference object directly. + # @return [Array, Difference] Result format depends on quick_mode parameter. + # @raise [ArgumentError] If the comparison object is invalid. + def call(comparison, quick_mode: true) + # Process the comparison and return result + + # Handle dimension differences + unless driver.same_dimension?(comparison) + result = build_null_difference(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true}) + return quick_mode ? [false, result] : result + end + + # Handle identical pixels + if driver.same_pixels?(comparison) + result = build_null_difference(comparison, comparison.base_image_path, comparison.new_image_path) + return quick_mode ? [true, result] : result + end + + # Handle early return for non-tolerable options + if quick_mode && without_tolerable_options? + return [false, nil] + end + + # Process difference region + region = driver.find_difference_region(comparison) + + # Only create a proper difference object if we've completed the comparison + quick_mode ? [!region.different?, region] : region + end + + private + + def without_tolerable_options? + (options.keys & TOLERABLE_OPTIONS).empty? + end + + # Build a no-difference result that represents identical images + def build_null_difference(comparison, base_path, new_path, failed_by = nil) + Difference.build_null(comparison, base_path, new_path, failed_by) + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/drivers/base_driver.rb b/lib/capybara/screenshot/diff/drivers/base_driver.rb index 8082966b..0ae22958 100644 --- a/lib/capybara/screenshot/diff/drivers/base_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/base_driver.rb @@ -30,6 +30,10 @@ def image_area_size(image) def dimension(image) [width_for(image), height_for(image)] end + + def supports?(feature) + respond_to?(feature) + end end end end diff --git a/lib/capybara/screenshot/diff/drivers/vips_driver.rb b/lib/capybara/screenshot/diff/drivers/vips_driver.rb index b166b611..63e0777a 100644 --- a/lib/capybara/screenshot/diff/drivers/vips_driver.rb +++ b/lib/capybara/screenshot/diff/drivers/vips_driver.rb @@ -40,7 +40,7 @@ def crop(region, i) rescue Vips::Error => e warn( "[capybara-screenshot-diff] Crop has been failed for " \ - "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }" + "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }" ) raise e end diff --git a/lib/capybara/screenshot/diff/image_compare.rb b/lib/capybara/screenshot/diff/image_compare.rb index 2ccfc461..2ce87757 100644 --- a/lib/capybara/screenshot/diff/image_compare.rb +++ b/lib/capybara/screenshot/diff/image_compare.rb @@ -1,17 +1,42 @@ # frozen_string_literal: true +require "pathname" +require "fileutils" + require "capybara/screenshot/diff/comparison" +require "capybara/screenshot/diff/comparison_loader" +require "capybara/screenshot/diff/image_preprocessor" +require "capybara/screenshot/diff/difference_finder" +require "capybara/screenshot/diff/reporters/default" module Capybara module Screenshot module Diff LOADED_DRIVERS = {} - # Compare two image and determine if they are equal, different, or within some comparison - # range considering color values and difference area size. + # Handles comparison of two images with a focus on performance and accuracy. + # + # This class implements a multi-layered optimization strategy for image comparison: + # + # 1. Early File-based Checks (Fastest): + # - Verifies both images exist (raises ArgumentError if not) + # - Compares file sizes (different sizes → different images) + # - Performs byte-by-byte comparison for identical files (exact match) + # + # 2. Quick Comparison (Fast): + # - Compares image dimensions (different dimensions → different images) + # - Performs pixel-by-pixel comparison if dimensions match + # + # 3. Detailed Analysis (Slower): + # - Only performed if quick comparison finds differences + # - Handles anti-aliasing, color tolerance, and shift detection + # - Respects skip_area and other comparison parameters + # + # This layered approach ensures optimal performance by: + # - Using the fastest possible method for early rejection + # - Only performing expensive operations when absolutely necessary + # - Maintaining high accuracy for complex comparisons class ImageCompare - TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze - attr_reader :driver, :driver_options attr_reader :image_path, :base_image_path attr_reader :difference, :error_message @@ -20,41 +45,53 @@ def initialize(image_path, base_image_path, options = {}) @image_path = Pathname.new(image_path) @base_image_path = Pathname.new(base_image_path) - @driver_options = options.dup + ensure_files_exist! + @driver_options = options.dup @driver = Drivers.for(@driver_options) end - # Compare the two image files and return `true` or `false` as quickly as possible. - # Return falsely if the old file does not exist or the image dimensions do not match. + # Performs a quick comparison of two image files. + # + # This method is optimized for speed and will return as soon as a difference is found. + # It's used for fast rejection before performing more expensive comparisons. + # + # @return [Boolean] + # - `true` if images are exactly identical (byte-for-byte match) + # - `false` if images are different or if a quick difference is detected + # + # @note This method will raise ArgumentError if either image file is missing. def quick_equal? - require_images_exists! - - # NOTE: This is very fuzzy logic, but so far it's helps to support current performance. - return true if new_file_size == old_file_size - - comparison = load_and_process_images + ensure_files_exist! - unless driver.same_dimension?(comparison) - self.difference = build_failed_difference(comparison, {different_dimensions: true}) - return false + # Quick file size check - if sizes are equal, perform a simple file comparison + if base_image_path.size == image_path.size + # If we have identical files (same size and content), we can return true immediately + # without more expensive comparison + return true if files_identical?(base_image_path, image_path) end - if driver.same_pixels?(comparison) - self.difference = build_no_difference(comparison) - return true - end - - # NOTE: Could not make any difference to be tolerable, so skip and return as not equal. - return false if without_tolerable_options? - - self.difference = driver.find_difference_region(comparison) + result, difference = find_difference(quick_mode: true) + self.difference = difference + result + end - !difference.different? + def ensure_files_exist! + raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist? + raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist? end - # Compare the two image referenced by this object, and return `true` if they are different, - # and `false` if they are the same. + # Determines if the images are different according to the comparison rules. + # + # This method performs a full comparison if not already done, including any + # configured tolerances for color differences and shift distances. + # + # @return [Boolean] + # - `true` if the images are different beyond configured tolerances + # - `false` if the images are considered identical + # + # @see #processed + # @see DifferenceFinder def different? processed.difference.different? end @@ -64,10 +101,7 @@ def dimensions_changed? end def reporter - @reporter ||= begin - current_difference = difference || build_no_difference(nil) - Capybara::Screenshot::Diff::Reporters::Default.new(current_difference) - end + @reporter ||= build_reporter end def processed? @@ -75,122 +109,87 @@ def processed? end def processed - self.difference = find_difference unless processed? + self.difference = find_difference(quick_mode: false) unless processed? @error_message ||= reporter.generate self end private - def find_difference - require_images_exists! - - comparison = load_and_process_images - - unless driver.same_dimension?(comparison) - return build_failed_difference(comparison, {different_dimensions: true}) - end - - if driver.same_pixels?(comparison) - build_no_difference(comparison) - else - driver.find_difference_region(comparison) - end + def difference_finder + @difference_finder ||= DifferenceFinder.new(driver, driver_options) end - def require_images_exists! - raise ArgumentError, "There is no original (base) screenshot version to compare, located: #{base_image_path}" unless base_image_path.exist? - raise ArgumentError, "There is no new screenshot version to compare, located: #{image_path}" unless image_path.exist? + def comparison_loader + @comparison_loader ||= ComparisonLoader.new(driver) end - def difference=(new_difference) - @error_message = nil - @reporter = nil - @difference = new_difference + def image_preprocessor + @image_preprocessor ||= ImagePreprocessor.new(driver, driver_options) end - def image_files_exist? - @base_image_path.exist? && @image_path.exist? - end - - def without_tolerable_options? - (@driver_options.keys & TOLERABLE_OPTIONS).empty? - end - - def build_failed_difference(comparison, failed_by) - Difference.new( - nil, - {difference_level: nil, max_color_distance: 0}, - comparison, - failed_by - ) - end + def find_difference(quick_mode: false) + # Validate images exist + return build_null_difference("missing_image") unless images_exist? - def load_and_process_images - images = driver.load_images(base_image_path, image_path) - base_image, new_image = preprocess_images(images) - Comparison.new(new_image, base_image, @driver_options, driver, image_path, base_image_path) - end - - def skip_area - @driver_options[:skip_area] - end - - def median_filter_window_size - @driver_options[:median_filter_window_size] - end + # Create comparison with preprocessed images + comparison = load_comparison(base_image_path, image_path, driver_options) - def preprocess_images(images) - images.map { |image| preprocess_image(image) } + # Use difference finder to analyze the comparison + difference_finder.call(comparison, quick_mode: quick_mode) end - def preprocess_image(image) - result = image - - if skip_area - result = ignore_skipped_area(result) - end - - if median_filter_window_size - if driver.is_a?(Drivers::VipsDriver) - result = blur_image_by(image, median_filter_window_size) - else - warn( - "[capybara-screenshot-diff] Median filter has been skipped for #{image_path} " \ - "because it is not supported by #{driver.class.name}" - ) - end - end - - result + def difference=(new_difference) + @error_message = nil + @reporter = nil + @difference = new_difference end - def blur_image_by(image, size) - driver.filter_image_with_median(image, size) + def build_reporter + current_difference = difference || build_null_difference + Reporters::Default.new(current_difference) end - def ignore_skipped_area(image) - skip_area&.reduce(image) { |memo, region| driver.add_black_box(memo, region) } + # Loads and preprocesses images for detailed comparison. + # + # This method is responsible for: + # 1. Loading both images using the configured driver + # 2. Applying any necessary preprocessing (cropping, normalization) + # 3. Creating a Comparison object that holds the image data + # + # @param base_path [String,Pathname] Path to the baseline/reference image + # @param new_path [String,Pathname] Path to the new/candidate image + # @param options [Hash] Comparison options including: + # - :crop [Array] Optional crop area [x, y, width, height] + # - :skip_area [Array] Areas to exclude from comparison + # - :tolerance [Numeric] Color tolerance threshold + # @return [Comparison] Prepared comparison object ready for analysis + # @raise [ArgumentError] If image files are invalid or unreadable + def load_comparison(base_path, new_path, options) + comparison = comparison_loader.call(base_path, new_path, options) + image_preprocessor.process_comparison(comparison) end - def old_file_size - base_image_path.size + def build_null_difference(failed_by = nil, comparison = nil) + Difference.build_null(comparison || build_null_comparison, base_image_path, image_path, failed_by) end - def new_file_size - image_path.size + def build_null_comparison + Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze end - def build_no_difference(comparison = nil) - Difference.new( - nil, - {difference_level: nil, max_color_distance: 0}, - comparison || build_comparison - ).freeze + # Check if both images exist + def images_exist? + base_image_path.exist? && image_path.exist? end - def build_comparison - Capybara::Screenshot::Diff::Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze + # Check if files are identical by content + def files_identical?(file1, file2) + # Compare file contents + FileUtils.identical?(file1, file2) + rescue + # If there's any error reading the files, they're not identical + false end end end diff --git a/lib/capybara/screenshot/diff/image_preprocessor.rb b/lib/capybara/screenshot/diff/image_preprocessor.rb new file mode 100644 index 00000000..135a1f42 --- /dev/null +++ b/lib/capybara/screenshot/diff/image_preprocessor.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Capybara + module Screenshot + module Diff + # Handles image preprocessing operations (skip_area and median filtering) + # + # This class applies preprocessing filters to images before comparison, + # such as masking specific regions (skip_area) or applying noise reduction. + # It's designed to work with either direct image objects or with options. + class ImagePreprocessor + attr_reader :driver, :options + + def initialize(driver, options = {}) + @driver = driver + @options = options + end + + # Process a comparison object directly + # This allows reusing the comparison's existing options + # @param [Comparison] comparison the comparison object + # @return [Comparison] the comparison object + def process_comparison(comparison) + # Process both images + comparison.base_image = process_image(comparison.base_image, comparison.base_image_path) + comparison.new_image = process_image(comparison.new_image, comparison.new_image_path) + + comparison + end + + def call(images) + images.map { |image| process_image(image, nil) } + end + + private + + def process_image(image, path) + result = image + result = apply_skip_area(result) if skip_area + result = apply_median_filter(result, path) if median_filter_window_size + result + end + + def apply_skip_area(image) + skip_area.reduce(image) do |result, region| + driver.add_black_box(result, region) + end + end + + def apply_median_filter(image, path) + if driver.supports?(:filter_image_with_median) + driver.filter_image_with_median(image, median_filter_window_size) + else + warn_about_skipped_median_filter(path) + image + end + end + + def warn_about_skipped_median_filter(path) + warn( + "[capybara-screenshot-diff] Median filter has been skipped for #{path} " \ + "because it is not supported by #{driver.class}" + ) + end + + def skip_area + options[:skip_area] + end + + def median_filter_window_size + options[:median_filter_window_size] + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/reporters/default.rb b/lib/capybara/screenshot/diff/reporters/default.rb index e1e19a5d..96400cff 100644 --- a/lib/capybara/screenshot/diff/reporters/default.rb +++ b/lib/capybara/screenshot/diff/reporters/default.rb @@ -45,12 +45,12 @@ def build_error_for_different_dimensions def annotate_and_save_images save_annotation_for(new_image, annotated_image_path) save_annotation_for(base_image, annotated_base_image_path) - save_heatmap_diff if difference.meta[:diff_mask] + save_heatmap_diff if difference.diff_mask end def save_annotation_for(image, image_path) image = annotate_difference(image, difference.region) - image = annotate_skip_areas(image, difference.skip_area) if difference.skip_area + image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area save(image, image_path.to_path) end @@ -80,7 +80,8 @@ def build_error_message "(#{difference.inspect})", image_path.to_path, annotated_base_image_path.to_path, - annotated_image_path.to_path + annotated_image_path.to_path, + heatmap_diff_path.to_path ].join(NEW_LINE) end @@ -88,7 +89,7 @@ def build_error_message def save_heatmap_diff merged_image = driver.merge(new_image, base_image) - highlighted_mask = driver.highlight_mask(difference.meta[:diff_mask], merged_image, color: DIFF_COLOR) + highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: DIFF_COLOR) save(highlighted_mask, heatmap_diff_path.to_path) end diff --git a/lib/capybara/screenshot/diff/screenshot_coordinator.rb b/lib/capybara/screenshot/diff/screenshot_coordinator.rb new file mode 100644 index 00000000..83c4f0ab --- /dev/null +++ b/lib/capybara/screenshot/diff/screenshot_coordinator.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "standard_capture_strategy" +require_relative "stable_capture_strategy" + +module Capybara + module Screenshot + module Diff + # Orchestrates the selection of a capture strategy based on capture and + # comparison options. This replaces the previous ScreenshotTaker factory. + module ScreenshotCoordinator + module_function + + # Unified public API to obtain a comparison screenshot. + # + # Usage (internal): + # ScreenshotCoordinator.capture(full_name, capture_options, comparison_options) + # + # @param snap_or_name [CapybaraScreenshotDiff::Snap, String] + # @param capture_options [Hash] + # @param comparison_options [Hash] + # @return [CapybaraScreenshotDiff::Snap] + def capture(snap_or_name, capture_options, comparison_options) + snap = ensure_snap(snap_or_name, capture_options) + strategy(capture_options, comparison_options).take_comparison_screenshot(snap) + snap + end + + # ------------------------------------------------------------------ + def strategy(capture_options, comparison_options) + strategy_klass = capture_options[:stability_time_limit] ? StableCaptureStrategy : StandardCaptureStrategy + strategy_klass.new(capture_options, comparison_options) + end + + private_class_method :strategy + + def ensure_snap(snap_or_name, capture_options) + return snap_or_name if snap_or_name.is_a?(CapybaraScreenshotDiff::Snap) + + CapybaraScreenshotDiff::SnapManager.snapshot( + snap_or_name, + capture_options[:screenshot_format] || "png" + ) + end + + private_class_method :ensure_snap + end + end + end +end diff --git a/lib/capybara/screenshot/diff/screenshot_matcher.rb b/lib/capybara/screenshot/diff/screenshot_matcher.rb index 0e92fe21..2470fd3d 100644 --- a/lib/capybara/screenshot/diff/screenshot_matcher.rb +++ b/lib/capybara/screenshot/diff/screenshot_matcher.rb @@ -6,6 +6,7 @@ require_relative "browser_helpers" require_relative "vcs" require_relative "area_calculator" +require_relative "screenshot_coordinator" module Capybara module Screenshot @@ -21,37 +22,75 @@ def initialize(screenshot_full_name, options = {}) @snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format) end - def build_screenshot_matches_job - # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates - return if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size) + def build_screenshot_assertion(skip_stack_frames: 0) + check_window_size! + prepare_screenshot_options + check_base_screenshot - # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates + capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options) + + capture_screenshot(capture_options, comparison_options) + + # Pre-computation: No need to compare without base screenshot + # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result + return unless need_to_compare? + + create_screenshot_assertion(skip_stack_frames + 1, comparison_options) + end + + private + + def need_to_compare? + @snapshot.base_path.exist? + end + + def check_window_size! + if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size) + current_size = BrowserHelpers.selenium? ? + BrowserHelpers.session.driver.browser.manage.window.size.to_s : + "unknown" + + raise CapybaraScreenshotDiff::WindowSizeMismatchError.new(<<~ERROR.chomp, caller) + Window size mismatch detected! + Expected: #{Screenshot.window_size.inspect} + Actual: #{current_size} + + Screenshots cannot be compared when window sizes don't match. + Please ensure the browser window is properly sized before taking screenshots. + ERROR + end + end + + def prepare_screenshot_options area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area]) - driver_options[:crop] = area_calculator.calculate_crop - # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates - # Allow nil or single or multiple areas + driver_options[:crop] = area_calculator.calculate_crop driver_options[:skip_area] = area_calculator.calculate_skip_area driver_options[:driver] = Drivers.for(driver_options[:driver]) + end + def check_base_screenshot @snapshot.checkout_base_screenshot - # When fail_if_new is true no need to create screenshot if base screenshot is missing - return if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist? - - capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options) - - # Load new screenshot from Browser - take_comparison_screenshot(capture_options, comparison_options, @snapshot) - - # Pre-computation: No need to compare without base screenshot - return unless @snapshot.base_path.exist? + if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist? + raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller) + No existing screenshot found for #{@snapshot.base_path}! + To stop seeing this error disable by `Capybara::Screenshot::Diff.fail_if_new=false` + ERROR + end + end - # Add comparison job in the queue - [screenshot_full_name, ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)] + def capture_screenshot(capture_options, comparison_options) + Capybara::Screenshot::Diff::ScreenshotCoordinator.capture(@snapshot, capture_options, comparison_options) end - private + def create_screenshot_assertion(skip_stack_frames, comparison_options) + CapybaraScreenshotDiff::ScreenshotAssertion.from([ + caller(skip_stack_frames + 1), + screenshot_full_name, + ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options) + ]) + end def extract_capture_and_comparison_options!(driver_options = {}) [ @@ -68,22 +107,6 @@ def extract_capture_and_comparison_options!(driver_options = {}) driver_options ] end - - # Try to get screenshot from browser. - # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts - # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug - def take_comparison_screenshot(capture_options, comparison_options, snapshot = nil) - screenshoter = build_screenshoter_for(capture_options, comparison_options) - screenshoter.take_comparison_screenshot(snapshot) - end - - def build_screenshoter_for(capture_options, comparison_options = {}) - if capture_options[:stability_time_limit] - StableScreenshoter.new(capture_options, comparison_options) - else - Diff.screenshoter.new(capture_options, comparison_options[:driver]) - end - end end end end diff --git a/lib/capybara/screenshot/diff/screenshot_namer_dsl.rb b/lib/capybara/screenshot/diff/screenshot_namer_dsl.rb new file mode 100644 index 00000000..4498020b --- /dev/null +++ b/lib/capybara/screenshot/diff/screenshot_namer_dsl.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "capybara_screenshot_diff/screenshot_namer" + +module Capybara + module Screenshot + module Diff + # Provides methods for managing screenshot naming conventions + # with support for grouping and sectioning for better organization. + module ScreenshotNamerDSL + # Sets the current section name for screenshots + # @param name [String] Section name + # @return [void] + def screenshot_section(name) + screenshot_namer.section = name + end + + # Sets the current group name for screenshots + # @param name [String] Group name + # @return [void] + def screenshot_group(name) + screenshot_namer.group = name + end + + private + + # Access the current screenshot namer instance + # @return [CapybaraScreenshotDiff::ScreenshotNamer] + def screenshot_namer + CapybaraScreenshotDiff.screenshot_namer + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/screenshoter.rb b/lib/capybara/screenshot/diff/screenshoter.rb index a05e31c7..a68b2ddf 100644 --- a/lib/capybara/screenshot/diff/screenshoter.rb +++ b/lib/capybara/screenshot/diff/screenshoter.rb @@ -86,7 +86,7 @@ def wait_images_loaded(timeout:) break unless pending_image if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at - raise CapybaraScreenshotDiff::ExpectationNotMet, "Images have not been loaded after #{timeout}s: #{pending_image.inspect}" + raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller) end sleep 0.025 diff --git a/lib/capybara/screenshot/diff/stable_capture_strategy.rb b/lib/capybara/screenshot/diff/stable_capture_strategy.rb new file mode 100644 index 00000000..20745e57 --- /dev/null +++ b/lib/capybara/screenshot/diff/stable_capture_strategy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "capture_strategy" +require_relative "stable_screenshoter" + +module Capybara + module Screenshot + module Diff + # Capture strategy that waits until the page content stabilises by taking + # several attempts and comparing them. + class StableCaptureStrategy < CaptureStrategy + def initialize(capture_options, comparison_options) + super + @screenshoter = StableScreenshoter.new(capture_options, comparison_options) + end + + def take_comparison_screenshot(snapshot) + @screenshoter.take_comparison_screenshot(snapshot) + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/stable_screenshoter.rb b/lib/capybara/screenshot/diff/stable_screenshoter.rb index 79419358..5e27e8c4 100644 --- a/lib/capybara/screenshot/diff/stable_screenshoter.rb +++ b/lib/capybara/screenshot/diff/stable_screenshoter.rb @@ -100,7 +100,7 @@ def annotate_attempts_and_fail!(snapshot) attempts_reporter = CapybaraScreenshotDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit}) # TODO: Move fail to the queue after tests passed - raise CapybaraScreenshotDiff::UnstableImage, attempts_reporter.generate + raise CapybaraScreenshotDiff::UnstableImage.new(attempts_reporter.generate, caller) end end end diff --git a/lib/capybara/screenshot/diff/standard_capture_strategy.rb b/lib/capybara/screenshot/diff/standard_capture_strategy.rb new file mode 100644 index 00000000..bba67c5c --- /dev/null +++ b/lib/capybara/screenshot/diff/standard_capture_strategy.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "capture_strategy" +require_relative "screenshoter" + +module Capybara + module Screenshot + module Diff + # Default capture strategy – grabs a single screenshot via the generic + # `Screenshoter` and returns immediately. + class StandardCaptureStrategy < CaptureStrategy + def initialize(capture_options, comparison_options) + super + driver = comparison_options[:driver] + @screenshoter = Diff.screenshoter.new(capture_options, driver) + end + + def take_comparison_screenshot(snapshot) + @screenshoter.take_comparison_screenshot(snapshot) + end + end + end + end +end diff --git a/lib/capybara/screenshot/diff/test_methods.rb b/lib/capybara/screenshot/diff/test_methods.rb deleted file mode 100644 index c384a112..00000000 --- a/lib/capybara/screenshot/diff/test_methods.rb +++ /dev/null @@ -1,157 +0,0 @@ -# frozen_string_literal: true - -require "English" -require "capybara" -require "action_controller" -require "action_dispatch" -require "active_support/core_ext/string/strip" -require "pathname" - -require_relative "drivers" -require_relative "image_compare" -require_relative "vcs" -require_relative "browser_helpers" -require_relative "region" - -require_relative "screenshot_matcher" - -# == Capybara::Screenshot::Diff::TestMethods -# -# This module provides methods for capturing screenshots and verifying them against -# baseline images to detect visual changes. It's designed to be included in test -# classes to add visual regression testing capabilities. - -module Capybara - module Screenshot - module Diff - module TestMethods - # @!attribute [rw] test_screenshots - # @return [Array(Array(Array(String), String, ImageCompare | Minitest::Mock))] An array where each element is an array containing the caller context, - # the name of the screenshot, and the comparison object. This attribute stores information about each screenshot - # scheduled for comparison to ensure they do not show any unintended differences. - def initialize(*) - super - @screenshot_counter = nil - @screenshot_group = nil - @screenshot_section = nil - @test_screenshot_errors = nil - end - - # Builds the full name for a screenshot, incorporating counters and group names for uniqueness. - # - # @param name [String] The base name for the screenshot. - # @return [String] The full, unique name for the screenshot. - def build_full_name(name) - if @screenshot_counter - name = format("%02i_#{name}", @screenshot_counter) - @screenshot_counter += 1 - end - - File.join(*group_parts.push(name.to_s)) - end - - # Determines the directory path for saving screenshots. - # - # @return [String] The full path to the directory where screenshots are saved. - def screenshot_dir - File.join(*([Screenshot.screenshot_area] + group_parts)) - end - - def screenshot_section(name) - @screenshot_section = name.to_s - end - - def screenshot_group(name) - @screenshot_group = name.to_s - @screenshot_counter = (@screenshot_group.nil? || @screenshot_group.empty?) ? nil : 0 - name_present = !(name.nil? || name.empty?) - return unless Screenshot.active? && name_present - - FileUtils.rm_rf screenshot_dir - end - - # Schedules a screenshot comparison job for later execution. - # - # This method adds a job to the queue of screenshots to be matched. It's used when `Capybara::Screenshot::Diff.delayed` - # is set to true, allowing for batch processing of screenshot comparisons at a later point, typically at the end of a test. - # - # @param job [Array(Array(String), String, ImageCompare)] The job to be scheduled, consisting of the caller context, screenshot name, and comparison object. - # @return [Boolean] Always returns true, indicating the job was successfully scheduled. - def schedule_match_job(job) - CapybaraScreenshotDiff.add_assertion(job) - true - end - - def group_parts - parts = [] - parts << @screenshot_section unless @screenshot_section.nil? || @screenshot_section.empty? - parts << @screenshot_group unless @screenshot_group.nil? || @screenshot_group.empty? - parts - end - - # Takes a screenshot and optionally compares it against a baseline image. - # - # @param name [String] The name of the screenshot, used to generate the filename. - # @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors, for cleaner error messages. - # @param options [Hash] Additional options for taking the screenshot, such as custom dimensions or selectors. - # @return [Boolean] Returns true if the screenshot was successfully captured and matches the baseline, false otherwise. - # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If the screenshot does not match the baseline image and fail_if_new is set to true. - # @example Capture a full-page screenshot named 'login_page' - # screenshot('login_page', skip_stack_frames: 1, full: true) - def screenshot(name, skip_stack_frames: 0, **options) - return false unless Screenshot.active? - - # setup - screenshot_full_name = build_full_name(name) - - # exercise - match_changes_job = build_screenshot_matches_job(screenshot_full_name, options) - - # verify - backtrace = caller(skip_stack_frames + 1).reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ } - - unless match_changes_job - if Screenshot::Diff.fail_if_new - _raise_error(<<-ERROR.strip_heredoc, backtrace) - No existing screenshot found for #{screenshot_full_name}! - To stop seeing this error disable by `Capybara::Screenshot::Diff.fail_if_new=false` - ERROR - end - - return false - end - - match_changes_job.prepend(backtrace) - - if Screenshot::Diff.delayed - schedule_match_job(match_changes_job) - else - invoke_match_job(match_changes_job) - end - end - - private - - def invoke_match_job(job) - error_msg = CapybaraScreenshotDiff::ScreenshotAssertion.from(job).validate - - if error_msg - _raise_error(error_msg, job[0]) - end - - true - end - - def _raise_error(error_msg, backtrace) - raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg).tap { _1.set_backtrace(backtrace) } - end - - def build_screenshot_matches_job(screenshot_full_name, options) - ScreenshotMatcher - .new(screenshot_full_name, options) - .build_screenshot_matches_job - end - end - end - end -end diff --git a/lib/capybara_screenshot_diff.rb b/lib/capybara_screenshot_diff.rb index d90f51dc..eb176cc8 100644 --- a/lib/capybara_screenshot_diff.rb +++ b/lib/capybara_screenshot_diff.rb @@ -5,16 +5,20 @@ require "capybara/screenshot/diff/utils" require "capybara/screenshot/diff/image_compare" require "capybara_screenshot_diff/snap_manager" -require "capybara/screenshot/diff/test_methods" +require "capybara/screenshot/diff/screenshot_namer_dsl" require "capybara/screenshot/diff/screenshoter" require "capybara/screenshot/diff/reporters/default" +require "capybara_screenshot_diff/error_with_filtered_backtrace" + module CapybaraScreenshotDiff - class CapybaraScreenshotDiffError < StandardError; end + class CapybaraScreenshotDiffError < ErrorWithFilteredBacktrace; end class ExpectationNotMet < CapybaraScreenshotDiffError; end class UnstableImage < CapybaraScreenshotDiffError; end + + class WindowSizeMismatchError < ErrorWithFilteredBacktrace; end end module Capybara diff --git a/lib/capybara_screenshot_diff/backtrace_filter.rb b/lib/capybara_screenshot_diff/backtrace_filter.rb new file mode 100644 index 00000000..fc24df97 --- /dev/null +++ b/lib/capybara_screenshot_diff/backtrace_filter.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module CapybaraScreenshotDiff + class BacktraceFilter + LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR + + def initialize(lib_directory = LIB_DIRECTORY) + @lib_directory = lib_directory + end + + # Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties + # @param backtrace [Array] + # @return [Array] + def filtered(backtrace) + backtrace + .reject { |location| File.expand_path(location).start_with?(@lib_directory) } + .reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ } + end + end +end diff --git a/lib/capybara_screenshot_diff/dsl.rb b/lib/capybara_screenshot_diff/dsl.rb index a8c7fb48..4fa74afa 100644 --- a/lib/capybara_screenshot_diff/dsl.rb +++ b/lib/capybara_screenshot_diff/dsl.rb @@ -1,18 +1,91 @@ # frozen_string_literal: true require "capybara_screenshot_diff" -require "capybara/screenshot/diff/test_methods" -require_relative "screenshot_assertion" +require "capybara/screenshot/diff/drivers" +require "capybara/screenshot/diff/image_compare" +require "capybara/screenshot/diff/screenshot_matcher" +require "capybara/screenshot/diff/screenshot_namer_dsl" +require "capybara_screenshot_diff/screenshot_assertion" module CapybaraScreenshotDiff + # DSL for taking screenshots and making assertions in Capybara tests. + # This module provides methods for taking screenshots, comparing them against baselines, + # and managing the comparison process with various configuration options. + # + # The DSL is designed to be included in your test context (e.g., RSpec, Minitest) + # to provide screenshot comparison capabilities. module DSL include Capybara::DSL - include Capybara::Screenshot::Diff::TestMethods + include Capybara::Screenshot::Diff::ScreenshotNamerDSL - alias_method :_screenshot, :screenshot - def screenshot(name, **args) - assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new(name, **args) { _screenshot(name, **args) } - CapybaraScreenshotDiff.add_assertion(assertion) + # Takes a screenshot and optionally compares it against a baseline image. + # + # The method follows a layered optimization strategy for comparison: + # 1. First checks if screenshot functionality is active + # 2. Builds a full screenshot name using the current context + # 3. Creates a screenshot assertion object + # 4. Either validates immediately or defers validation based on options + # + # @param name [String] The base name of the screenshot, used to generate the filename. + # @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors. + # @param options [Hash] Additional options for taking the screenshot and comparison. + # @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed) + # Whether to validate the screenshot immediately or delay validation. + # @option options [Array] :crop [x, y, width, height] Area to crop the screenshot to. + # @option options [Array>] :skip_area Array of [x, y, width, height] areas to ignore. + # @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison. + # @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels. + # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels. + # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels. + # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips). + # @return [Boolean] True if the screenshot was successfully captured and processed. + # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled. + # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable. + # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations. + def screenshot(name, skip_stack_frames: 0, **options) + return false unless Capybara::Screenshot.active? + + # Get the full name with section and group information + full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name) + + # Build the screenshot assertion + assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1) + + return false unless assertion + + # Determine if validation should be delayed or immediate + delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed) + + if delayed + CapybaraScreenshotDiff.add_assertion(assertion) + else + assertion.validate! + end + + true + end + + # Alias for backward compatibility with older test suites. + # @see #screenshot + alias_method :assert_matches_screenshot, :screenshot + + private + + # Builds a screenshot assertion object that can be validated immediately or later. + # + # This method constructs a screenshot assertion that encapsulates the comparison logic. + # The actual comparison is deferred until {ScreenshotAssertion#validate!} is called. + # + # @param name [String] The full name of the screenshot, including any section/group context. + # @param options [Hash] Options for screenshot taking and comparison. + # See {#screenshot} for available options. + # @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting. + # @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed. + # @see ScreenshotAssertion + def build_screenshot_assertion(name, options, skip_stack_frames: 0) + Capybara::Screenshot::Diff::ScreenshotMatcher + .new(name, options) + .build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1) end end end diff --git a/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb b/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb new file mode 100644 index 00000000..092c8e05 --- /dev/null +++ b/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "capybara_screenshot_diff/backtrace_filter" + +module CapybaraScreenshotDiff + # @private + class ErrorWithFilteredBacktrace < StandardError + # @private + def initialize(message = nil, backtrace = []) + super(message) + filter = BacktraceFilter.new + set_backtrace(filter.filtered(backtrace)) + end + end +end diff --git a/lib/capybara_screenshot_diff/minitest.rb b/lib/capybara_screenshot_diff/minitest.rb index 88e748b8..fc35b5f1 100644 --- a/lib/capybara_screenshot_diff/minitest.rb +++ b/lib/capybara_screenshot_diff/minitest.rb @@ -22,7 +22,7 @@ module Assertions def screenshot(*args, skip_stack_frames: 0, **opts) self.assertions += 1 - super(*args, skip_stack_frames: skip_stack_frames + 3, **opts) + super(*args, skip_stack_frames: skip_stack_frames + 1, **opts) rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e raise ::Minitest::Assertion, e.message end @@ -39,7 +39,7 @@ def before_teardown CapybaraScreenshotDiff.verify rescue CapybaraScreenshotDiff::ExpectationNotMet => e assertion = ::Minitest::Assertion.new(e) - assertion.set_backtrace [] + assertion.set_backtrace(e.backtrace) failures << assertion ensure CapybaraScreenshotDiff.reset diff --git a/lib/capybara_screenshot_diff/rspec.rb b/lib/capybara_screenshot_diff/rspec.rb index b3f086bc..65a605c7 100644 --- a/lib/capybara_screenshot_diff/rspec.rb +++ b/lib/capybara_screenshot_diff/rspec.rb @@ -27,7 +27,7 @@ begin CapybaraScreenshotDiff.verify rescue CapybaraScreenshotDiff::ExpectationNotMet => e - raise RSpec::Expectations::ExpectationNotMetError, e.message + raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) } ensure CapybaraScreenshotDiff.reset end diff --git a/lib/capybara_screenshot_diff/screenshot_assertion.rb b/lib/capybara_screenshot_diff/screenshot_assertion.rb index 6b33505b..ea3380cf 100644 --- a/lib/capybara_screenshot_diff/screenshot_assertion.rb +++ b/lib/capybara_screenshot_diff/screenshot_assertion.rb @@ -30,6 +30,14 @@ def validate self.class.assert_image_not_changed(caller, name, compare) end + def validate! + error_msg = validate + + if error_msg + raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller) + end + end + # Verifies that all scheduled screenshots do not show any unintended differences. # # @param screenshots [Array(Array(Array(String), String, ImageCompare))] The list of match screenshots jobs. Defaults to all screenshots taken during the test. @@ -73,10 +81,11 @@ def self.assert_image_not_changed(backtrace, name, comparison) end class AssertionRegistry - attr_reader :assertions + attr_reader :assertions, :screenshot_namer def initialize @assertions = [] + @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new end def add_assertion(assertion) @@ -93,9 +102,15 @@ def assertions_present? end def verify(screenshots = CapybaraScreenshotDiff.assertions) + return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference + + failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions + failed_screenshot = failed_assertions.first result = ScreenshotAssertion.verify_screenshots!(screenshots) - raise CapybaraScreenshotDiff::ExpectationNotMet, result.join("\n\n") if result + if result + raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller) + end end def failed_assertions @@ -104,6 +119,7 @@ def failed_assertions def reset @assertions.clear + @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new end end end @@ -122,6 +138,7 @@ def registry def_delegator :registry, :assertions_present? def_delegator :registry, :failed_assertions def_delegator :registry, :reset + def_delegator :registry, :screenshot_namer def_delegator :registry, :verify end end diff --git a/lib/capybara_screenshot_diff/screenshot_namer.rb b/lib/capybara_screenshot_diff/screenshot_namer.rb new file mode 100644 index 00000000..aaa70c92 --- /dev/null +++ b/lib/capybara_screenshot_diff/screenshot_namer.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module CapybaraScreenshotDiff + # Handles the naming, path generation, and organization of screenshots. + # This class encapsulates logic related to screenshot sections, groups, + # and counters, providing a centralized way to determine screenshot filenames + # and directories. + class ScreenshotNamer + attr_reader :section, :group + + def initialize(screenshot_area = nil) + @section = nil + @group = nil + @counter = nil + @screenshot_area = screenshot_area + end + + def screenshot_area + @screenshot_area ||= Capybara::Screenshot.screenshot_area + end + + # Sets the current section for screenshots. + # @param name [String, nil] The name of the section. + def section=(name) + @section = name&.to_s + reset_group_counter + end + + # Sets the current group for screenshots and resets the counter. + # @param name [String, nil] The name of the group. + def group=(name) + @group = name&.to_s + reset_group_counter + end + + # Builds the full, unique name for a screenshot, including any counter. + # @param base_name [String] The base name for the screenshot. + # @return [String] The full screenshot name. + def full_name(base_name) + name = base_name.to_s + + if @counter + name = format("%02i_%s", @counter, name) + @counter += 1 + end + + File.join(*directory_parts.push(name.to_s)) + end + + # Builds the full path for a screenshot file, including section and group directories. + # @param base_name [String] The base name for the screenshot. + # @return [String] The absolute path for the screenshot file. + def full_name_with_path(base_name) + File.join(screenshot_area, full_name(base_name)) + end + + # Returns the directory parts (section and group) for constructing paths. + # @return [Array] An array of directory names. + def directory_parts + parts = [] + parts << @section unless @section.nil? || @section.empty? + parts << @group unless @group.nil? || @group.empty? + parts + end + + # Calculates the directory path for the current section and group. + # @return [String] The full path to the directory. + 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 + @counter = (@group.nil? || @group.empty?) ? nil : 0 + end + end +end diff --git a/lib/capybara_screenshot_diff/snap.rb b/lib/capybara_screenshot_diff/snap.rb index cbc95696..1c3196bc 100644 --- a/lib/capybara_screenshot_diff/snap.rb +++ b/lib/capybara_screenshot_diff/snap.rb @@ -49,7 +49,7 @@ def cleanup_attempts end def find_attempts_paths - Dir[@manager.abs_path_for "**/#{full_name}.attempt_*.#{format}"] + Dir[@manager.abs_path_for("**/#{full_name}.attempt_[0-9][0-9].#{format}")] end end end diff --git a/lib/capybara_screenshot_diff/snap_manager.rb b/lib/capybara_screenshot_diff/snap_manager.rb index 0a75c98d..6e14bff1 100644 --- a/lib/capybara_screenshot_diff/snap_manager.rb +++ b/lib/capybara_screenshot_diff/snap_manager.rb @@ -11,10 +11,13 @@ class SnapManager def initialize(root) @root = Pathname.new(root) + @snapshots = Set.new end def snapshot(screenshot_full_name, screenshot_format = "png") - Snap.new(screenshot_full_name, screenshot_format, manager: self) + Snap.new(screenshot_full_name, screenshot_format, manager: self).tap do |snapshot| + @snapshots << snapshot + end end def self.snapshot(screenshot_full_name, screenshot_format = "png") @@ -42,7 +45,10 @@ def create_output_directory_for(path = nil) # TODO: rename to delete! def cleanup! - FileUtils.rm_rf root, secure: true + snapshots.each do |snapshot| + cleanup_attempts!(snapshot) + snapshot.delete! + end end def self.cleanup! @@ -61,6 +67,8 @@ def screenshots root.children.map { |f| f.basename.to_s } end + attr_reader :snapshots + def self.screenshots instance.screenshots end diff --git a/test/capybara/screenshot/diff/comparison_loader_test.rb b/test/capybara/screenshot/diff/comparison_loader_test.rb new file mode 100644 index 00000000..b6a872c1 --- /dev/null +++ b/test/capybara/screenshot/diff/comparison_loader_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "test_doubles" + +module Capybara + module Screenshot + module Diff + class ComparisonLoaderTest < ActionDispatch::IntegrationTest + include CapybaraScreenshotDiff::DSLStub + include TestDoubles + + test "loads images and applies preprocessing" do + # Setup + base_path = Pathname.new("base/path.png") + new_path = Pathname.new("new/path.png") + options = {tolerance: 0.01} + + raw_images = [:raw_base_image, :raw_new_image] + + driver = TestDriver.new(false, raw_images) + + # Action + loader = ComparisonLoader.new(driver) + comparison = loader.call(base_path, new_path, options) + + # Verify the comparison object + assert_kind_of Comparison, comparison + assert_equal raw_images[1], comparison.new_image + assert_equal raw_images[0], comparison.base_image + assert_equal options, comparison.options + assert_equal driver, comparison.driver + end + end + end + end +end diff --git a/test/capybara/screenshot/diff/difference_finder_test.rb b/test/capybara/screenshot/diff/difference_finder_test.rb new file mode 100644 index 00000000..204c8ed5 --- /dev/null +++ b/test/capybara/screenshot/diff/difference_finder_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "test_doubles" + +module Capybara + module Screenshot + module Diff + class DifferenceFinderTest < ActionDispatch::IntegrationTest + include CapybaraScreenshotDiff::DSLStub + include TestDoubles + + def setup + @base_path = TestPath.new(12345) + @new_path = TestPath.new(54321) # Different size + @driver = TestDriver.new(false) + + # Create a test comparison with paths directly + @comparison = TestComparison.new + @comparison.base_image_path = @base_path + @comparison.new_image_path = @new_path + end + + def create_difference_factory + lambda do |comparison, failed_by = nil| + @factory_calls ||= [] + @factory_calls << {comparison: comparison, failed_by: failed_by} + + if failed_by + :dimension_difference_result + else + :no_difference_result + end + end + end + + test "when dimensions are the same and pixels are the same then returns true in quick mode" do + # Setup + @driver.same_dimension_result = true + @driver.same_pixels_result = true + + # Action + finder = DifferenceFinder.new(@driver, {}) + result, difference = finder.call(@comparison, quick_mode: true) + + # Verify + assert result + refute_nil difference + assert_equal 1, @driver.dimension_check_calls.size + assert_equal 1, @driver.pixel_check_calls.size + end + + test "when dimensions differ then returns a difference with failed dimensions" do + # Setup + @driver.same_dimension_result = false + + # Action + finder = DifferenceFinder.new(@driver, {}) + result = finder.call(@comparison, quick_mode: false) + + # Verify + assert_instance_of Difference, result + assert result.failed? + assert_equal 1, @driver.dimension_check_calls.size + assert_equal 0, @driver.pixel_check_calls.size + end + + test "when pixels are the same then returns no difference" do + # Setup + @driver.same_dimension_result = true + @driver.same_pixels_result = true + + # Action + finder = DifferenceFinder.new(@driver, {}) + result = finder.call(@comparison, quick_mode: false) + + # Verify + assert_instance_of Difference, result + assert result.equal? + assert_equal 1, @driver.dimension_check_calls.size + assert_equal 1, @driver.pixel_check_calls.size + end + + test "when pixels differ then checks difference region" do + # Setup + @driver.same_dimension_result = true + @driver.same_pixels_result = false + test_difference = TestDifference.new(true) # It is different + @driver.difference_region_result = test_difference + + # Action + finder = DifferenceFinder.new(@driver, {}) + result = finder.call(@comparison, quick_mode: false) + + # Verify + assert_equal test_difference, result + assert_equal 1, @driver.difference_region_calls.size + end + + test "when in quick mode returns array with comparison result and difference" do + # Setup + @driver.same_dimension_result = true + @driver.same_pixels_result = false + test_difference = TestDifference.new(false) # Not different (within tolerance) + @driver.difference_region_result = test_difference + + # Action + finder = DifferenceFinder.new(@driver, {tolerance: 0.01}) + result, difference = finder.call(@comparison, quick_mode: true) + + # Verify + assert result # Not different == true equality + assert_equal test_difference, difference + end + + test "when comparison has no tolerable options in quick mode, returns early" do + # Setup + @driver.same_dimension_result = true + @driver.same_pixels_result = false + + # Action + finder = DifferenceFinder.new(@driver, {}) + result, difference = finder.call(@comparison, quick_mode: true) + + # Verify + refute result # Different == false equality + assert_nil difference # Quick mode with no tolerance returns nil difference + assert_equal 1, @driver.dimension_check_calls.size + assert_equal 1, @driver.pixel_check_calls.size + assert_equal 0, @driver.difference_region_calls.size # Should not process difference region + end + end + end + end +end diff --git a/test/capybara/screenshot/diff/drivers/chunky_png_driver_test.rb b/test/capybara/screenshot/diff/drivers/chunky_png_driver_test.rb index 7d114639..e2f65136 100644 --- a/test/capybara/screenshot/diff/drivers/chunky_png_driver_test.rb +++ b/test/capybara/screenshot/diff/drivers/chunky_png_driver_test.rb @@ -9,7 +9,7 @@ module Screenshot module Diff module Drivers class ChunkyPNGDriverTest < ActionDispatch::IntegrationTest - include TestMethodsStub + include CapybaraScreenshotDiff::DSLStub teardown do FileUtils.rm Dir["#{Rails.root}/screenshot*.png"] @@ -146,7 +146,8 @@ class ChunkyPNGDriverTest < ActionDispatch::IntegrationTest private def make_comparison(old_img, new_img, options = {}) - super(old_img, new_img, **options.merge(driver: :chunky_png)) + snap = create_snapshot_for(old_img, new_img) + ImageCompare.new(snap.path, snap.base_path, **options) end def sample_region diff --git a/test/capybara/screenshot/diff/drivers/vips_driver_test.rb b/test/capybara/screenshot/diff/drivers/vips_driver_test.rb index ea9636fa..7a23c927 100644 --- a/test/capybara/screenshot/diff/drivers/vips_driver_test.rb +++ b/test/capybara/screenshot/diff/drivers/vips_driver_test.rb @@ -14,7 +14,7 @@ module Screenshot module Diff module Drivers class VipsDriverTest < ActionDispatch::IntegrationTest - include TestMethodsStub + include CapybaraScreenshotDiff::DSLStub setup do @new_screenshot_result = Tempfile.new(%w[screenshot .png], Rails.root) diff --git a/test/capybara/screenshot/diff/image_compare_refactor_test.rb b/test/capybara/screenshot/diff/image_compare_refactor_test.rb new file mode 100644 index 00000000..8f2cc1e0 --- /dev/null +++ b/test/capybara/screenshot/diff/image_compare_refactor_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" +require "minitest/stub_const" +require_relative "test_doubles" + +module Capybara + module Screenshot + module Diff + class ImageCompareRefactorTest < ActionDispatch::IntegrationTest + include CapybaraScreenshotDiff::DSLStub + include TestDoubles + + test "when comparing identical images then quick_equal? returns true and different? returns false" do + # Setup + comparison = make_comparison(:a, :a) + + # Action & Verify + assert_predicate comparison, :quick_equal? + assert_not_predicate comparison, :different? + end + + test "when comparing different images then quick_equal? returns false and different? returns true" do + # Setup + comparison = make_comparison(:a, :b) + + # Action & Verify + assert_not_predicate comparison, :quick_equal? + assert_predicate comparison, :different? + end + + test "when images have different dimensions then dimensions_changed? returns true" do + # Setup + comparison = make_comparison(:portrait, :a) + + # Action + comparison.processed + + # Verify + assert_predicate comparison, :dimensions_changed? + assert_kind_of Reporters::Default, comparison.reporter + end + end + end + end +end diff --git a/test/capybara/screenshot/diff/image_compare_test.rb b/test/capybara/screenshot/diff/image_compare_test.rb index 88df28fa..95a2b71b 100644 --- a/test/capybara/screenshot/diff/image_compare_test.rb +++ b/test/capybara/screenshot/diff/image_compare_test.rb @@ -13,21 +13,21 @@ module Capybara module Screenshot module Diff class ImageCompareTest < ActionDispatch::IntegrationTest - include TestMethodsStub + include CapybaraScreenshotDiff::DSLStub test "it can be instantiated with chunky_png driver" do - comparison = ImageCompare.new("images/b.png", "images/b.base.png") + comparison = make_comparison(:b) assert_kind_of Drivers::ChunkyPNGDriver, comparison.driver end test "it can be instantiated with explicit chunky_png adapter" do - comparison = ImageCompare.new("images/b.png", "images/b.base.png", driver: :chunky_png) + comparison = make_comparison(:b, driver: :chunky_png) assert_kind_of Drivers::ChunkyPNGDriver, comparison.driver end test "it can be instantiated with vips adapter" do skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) - comparison = ImageCompare.new("images/b.png", "images/b.base.png", driver: :vips) + comparison = make_comparison(:b, driver: :vips) assert_kind_of Drivers::VipsDriver, comparison.driver end @@ -60,30 +60,35 @@ class ImageCompareTest < ActionDispatch::IntegrationTest end test "could pass use tolerance for chunky_png driver" do - assert ImageCompare.new("images/b.png", "images/b.base.png", driver: :chunky_png, tolerance: 0.02) + comp = make_comparison(:a, :b, driver: :chunky_png, tolerance: 0.02) + assert comp.quick_equal? + assert_not comp.different? end test "it can be instantiated with dimensions" do - assert ImageCompare.new("images/b.png", "images/b.base.png", dimensions: [80, 80]) + comp = make_comparison(:b, dimensions: [80, 80]) + assert comp.quick_equal? + assert_not comp.different? end test "for driver: :auto returns first from available drivers" do skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) - comparison = ImageCompare.new("images/b.png", "images/b.base.png", driver: :auto) + comparison = make_comparison(:b, driver: :auto) assert_kind_of Drivers::VipsDriver, comparison.driver end test "for driver: :auto raise error if no drivers are available" do Capybara::Screenshot::Diff.stub_const(:AVAILABLE_DRIVERS, []) do assert_raise(RuntimeError) do - ImageCompare.new("images/b.png", "images/b.base.png", driver: :auto) + comparison = make_comparison(:b, driver: :auto) + assert comparison.quick_equal? end end end end class IntegrationRegressionTest < ActionDispatch::IntegrationTest - include TestMethodsStub + include CapybaraScreenshotDiff::DSLStub AVAILABLE_DRIVERS = [{}, {driver: :chunky_png}] diff --git a/test/capybara/screenshot/diff/image_preprocessor_test.rb b/test/capybara/screenshot/diff/image_preprocessor_test.rb new file mode 100644 index 00000000..4907c935 --- /dev/null +++ b/test/capybara/screenshot/diff/image_preprocessor_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "test_doubles" + +module Capybara + module Screenshot + module Diff + class ImagePreprocessorTest < ActionDispatch::IntegrationTest + include CapybaraScreenshotDiff::DSLStub + include TestDoubles + + def setup + @test_images = [:base_image, :new_image] + end + + test "when no preprocessing options are provided then returns original images unchanged" do + # Setup + driver = TestDriver.new(false) + options = {} + + # Action + preprocessor = ImagePreprocessor.new(driver, options) + result = preprocessor.call(@test_images) + + # Verify + assert_equal @test_images, result + assert_empty driver.add_black_box_calls + assert_empty driver.filter_calls + end + + test "when skip_area is specified then applies black box to that region" do + # Setup + driver = TestDriver.new(false) + skip_area = [{x: 10, y: 20, width: 30, height: 40}] + options = {skip_area: skip_area} + + # Action + preprocessor = ImagePreprocessor.new(driver, options) + result = preprocessor.call(@test_images) + + # Verify + assert_equal %w[processed_base_image processed_new_image], result + + assert_equal 2, driver.add_black_box_calls.size + + first_call = driver.add_black_box_calls[0] + second_call = driver.add_black_box_calls[1] + + assert_equal skip_area.first, first_call[:region] + assert_equal skip_area.first, second_call[:region] + assert_equal :base_image, first_call[:image] + assert_equal :new_image, second_call[:image] + end + + test "when median filter is specified with VipsDriver then applies filter to images" do + skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) + + # Setup + driver = TestDriver.new(true) # true = is a VipsDriver + window_size = 3 + options = {median_filter_window_size: window_size} + + # Action + preprocessor = ImagePreprocessor.new(driver, options) + result = preprocessor.call(@test_images) + + # Verify + assert_equal ["filtered_base_image", "filtered_new_image"], result + + assert_equal 2, driver.filter_calls.size + + first_call = driver.filter_calls[0] + second_call = driver.filter_calls[1] + + assert_equal window_size, first_call[:size] + assert_equal window_size, second_call[:size] + assert_equal :base_image, first_call[:image] + assert_equal :new_image, second_call[:image] + end + + test "when median filter is specified with non-VipsDriver then issues warning and returns original images" do + # Setup + driver = TestDriver.new(false) # false = is not a VipsDriver + window_size = 3 + options = { + median_filter_window_size: window_size, + image_path: "some/path.png" + } + + # Set up a warning expectation + expected_warning = /Median filter has been skipped for.*because it is not supported/ + + # Action with warning capture + preprocessor = ImagePreprocessor.new(driver, options) + + warning_output = capture_io do + result = preprocessor.call(@test_images) + + # Verify images unchanged + assert_equal @test_images, result + assert_empty driver.filter_calls + end + + # Verify warning + assert_match expected_warning, warning_output.join + end + end + end + end +end diff --git a/test/capybara/screenshot/diff/screenshoter_test.rb b/test/capybara/screenshot/diff/screenshoter_test.rb index ad534ac3..0c76ae2c 100644 --- a/test/capybara/screenshot/diff/screenshoter_test.rb +++ b/test/capybara/screenshot/diff/screenshoter_test.rb @@ -6,8 +6,8 @@ module Capybara module Screenshot class ScreenshoterTest < ActionDispatch::IntegrationTest - include Diff::TestMethods - include Diff::TestMethodsStub + include CapybaraScreenshotDiff::DSL + include CapybaraScreenshotDiff::DSLStub test "#take_screenshot without wait skips image loading" do screenshoter = Screenshoter.new({wait: nil}, ::Minitest::Mock.new) diff --git a/test/capybara/screenshot/diff/stable_screenshoter_test.rb b/test/capybara/screenshot/diff/stable_screenshoter_test.rb index 0cbc6399..26f4001d 100644 --- a/test/capybara/screenshot/diff/stable_screenshoter_test.rb +++ b/test/capybara/screenshot/diff/stable_screenshoter_test.rb @@ -6,7 +6,7 @@ module Capybara module Screenshot module Diff class StableScreenshoterTest < ActionDispatch::IntegrationTest - include TestMethodsStub + include CapybaraScreenshotDiff::DSLStub setup do @manager = CapybaraScreenshotDiff::SnapManager.new(Capybara::Screenshot.root / "stable_screenshoter_test") @@ -57,7 +57,7 @@ class StableScreenshoterTest < ActionDispatch::IntegrationTest assert_not_predicate snap.path, :exist? ImageCompare.stub :new, mock do - StableScreenshoter + Capybara::Screenshot::Diff::StableScreenshoter .new({stability_time_limit: 0.5, wait: 1}, image_compare_stub.driver_options) .take_comparison_screenshot(snap) end @@ -93,7 +93,7 @@ class StableScreenshoterTest < ActionDispatch::IntegrationTest assert_raises CapybaraScreenshotDiff::UnstableImage, "Could not get stable screenshot within 1s" do ImageCompare.stub :new, mock do # Wait time is less then stability time, which will generate problem - StableScreenshoter + Capybara::Screenshot::Diff::StableScreenshoter .new({stability_time_limit: 0.5, wait: 1}, build_image_compare_stub(equal: false).driver_options) .take_comparison_screenshot(snap) end diff --git a/test/capybara/screenshot/diff/test_doubles.rb b/test/capybara/screenshot/diff/test_doubles.rb new file mode 100644 index 00000000..aa6cc1a1 --- /dev/null +++ b/test/capybara/screenshot/diff/test_doubles.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Capybara + module Screenshot + module Diff + module TestDoubles + # Test double for file paths with configurable size and existence + class TestPath + attr_reader :size_value + + # Initialize a path with a size value and existence flag + # @param size_value [Integer] The size of the file + # @param exists [Boolean] Whether the file exists, defaults to true + def initialize(size_value, exists = true) + @size_value = size_value + @exists = exists + end + + def size + @size_value + end + + def exist? + @exists + end + end + + # Test double for image drivers with configurable behavior + class TestDriver + attr_reader :add_black_box_calls, :filter_calls, :dimension_check_calls, :pixel_check_calls, :difference_region_calls, :load_images_called, :load_images_args + attr_accessor :same_dimension_result, :same_pixels_result, :difference_region_result, :images_to_return + + # Initializes a new TestDriver + # @param is_vips_driver [Boolean] whether this driver should behave like a VipsDriver + # @param images_to_return [Array] images to return from load_images method + def initialize(is_vips_driver = false, images_to_return = nil) + @is_vips_driver = is_vips_driver + @images_to_return = images_to_return || [:base_image, :new_image] + @add_black_box_calls = [] + @filter_calls = [] + @dimension_check_calls = [] + @pixel_check_calls = [] + @difference_region_calls = [] + @load_images_called = false + @load_images_args = nil + @same_dimension_result = true + @same_pixels_result = true + @difference_region_result = nil + end + + def is_a?(klass) + return @is_vips_driver if klass == Drivers::VipsDriver + super + end + + def add_black_box(image, region) + @add_black_box_calls << {image: image, region: region} + "processed_#{image}" + end + + def filter_image_with_median(image, size) + @filter_calls << {image: image, size: size} + # Return the filtered image, converting to the expected format + "filtered_#{image}" + end + + def same_dimension?(comparison) + @dimension_check_calls << comparison + @same_dimension_result + end + + def same_pixels?(comparison) + @pixel_check_calls << comparison + @same_pixels_result + end + + def find_difference_region(comparison) + @difference_region_calls << comparison + @difference_region_result + end + + def load_images(base_path, new_path) + @load_images_called = true + @load_images_args = [base_path, new_path] + @images_to_return + end + + def supports?(...) + @is_vips_driver + end + + # Return Object to avoid infinite recursion + 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 + + def initialize(different_value) + @different_value = different_value + end + + def different? + @different_value + end + end + + # Simple test double for comparison objects + class TestComparison + attr_reader :new_image, :base_image, :options, :driver + attr_accessor :new_image_path, :base_image_path + + def initialize(options = {}) + @new_image = options[:new_image] + @base_image = options[:base_image] + @options = options[:options] || {} + @driver = options[:driver] + @new_image_path = options[:new_image_path] || options[:image_path] + @base_image_path = options[:base_image_path] + end + end + end + end + end +end diff --git a/test/capybara/screenshot/diff/test_methods_test.rb b/test/capybara/screenshot/diff/test_methods_test.rb deleted file mode 100644 index b8a2d474..00000000 --- a/test/capybara/screenshot/diff/test_methods_test.rb +++ /dev/null @@ -1,124 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "capybara_screenshot_diff/screenshot_assertion" - -module Capybara - module Screenshot - module Diff - class TestMethodsTest < ActionDispatch::IntegrationTest - include TestMethods - include TestMethodsStub - - test "raise error on missing screenshot when fail_if_new is true" do - Vcs.stub(:checkout_vcs, false) do - Capybara::Screenshot::Diff.stub(:fail_if_new, true) do - assert_raises CapybaraScreenshotDiff::ExpectationNotMet, match: /No existing screenshot found for/ do - screenshot "not_existing_screenshot-name" - end - end - end - end - - def test_assert_image_not_changed - message = assert_image_not_changed(["my_test.rb:42"], "name", make_comparison(:a, :c)) - value = (RUBY_VERSION >= "2.4") ? 187.4 : 188 - assert_equal <<-MSG.strip_heredoc.chomp, message - Screenshot does not match for 'name': ({"area_size":629,"region":[11,3,48,20],"max_color_distance":#{value}}) - #{Rails.root}/doc/screenshots/screenshot.png - #{Rails.root}/doc/screenshots/screenshot.base.diff.png - #{Rails.root}/doc/screenshots/screenshot.diff.png - my_test.rb:42 - MSG - end - - def test_assert_image_not_changed_with_shift_distance_limit - message = assert_image_not_changed( - ["my_test.rb:42"], - "name", - make_comparison(:a, :c, shift_distance_limit: 1, driver: :chunky_png) - ) - value = (RUBY_VERSION >= "2.4") ? 5.0 : 5 - assert_equal <<-MSG.strip_heredoc.chomp, message - Screenshot does not match for 'name': ({"area_size":629,"region":[11,3,48,20],"max_color_distance":#{value},"max_shift_distance":15}) - #{Rails.root}/doc/screenshots/screenshot.png - #{Rails.root}/doc/screenshots/screenshot.base.diff.png - #{Rails.root}/doc/screenshots/screenshot.diff.png - my_test.rb:42 - MSG - end - - def test_screenshot_support_drivers_options - skip "vips is disabled" unless defined?(Capybara::Screenshot::Diff::Drivers::VipsDriverTest) - assert_not screenshot("a", driver: :vips) - end - - def assert_no_screenshot_jobs_scheduled - assert_not_predicate CapybaraScreenshotDiff.registry, :assertions_present? - end - - def test_skip_stack_frames - Vcs.stub(:checkout_vcs, true) do - assert_no_screenshot_jobs_scheduled - make_comparison(:a, :c, destination: "a.png") - - our_screenshot("a", 0) - assert_equal 1, CapybaraScreenshotDiff.assertions.size - assert_match(/our_screenshot'/, CapybaraScreenshotDiff.assertions[0].caller.first) - assert_equal "a", CapybaraScreenshotDiff.assertions[0].name - - our_screenshot("a", 1) - assert_equal 2, CapybaraScreenshotDiff.assertions.size - assert_match( - %r{/test_methods_test.rb:.*?test_skip_stack_frames}, - CapybaraScreenshotDiff.assertions[1].caller.first - ) - assert_equal "a", CapybaraScreenshotDiff.assertions[1].name - end - end - - def test_inline_screenshot_assertion_validation - Vcs.stub(:checkout_vcs, true) do - Capybara::Screenshot::Diff.stub(:delayed, false) do - make_comparison(:a, :b, destination: "a.png") - screenshot("a") - end - end - end - - def test_skip_area_and_stability_time_limit - assert_not screenshot(:a, skip_area: [0, 0, 1, 1], stability_time_limit: 0.01) - end - - def test_creates_new_screenshot - screenshot(:c) - - snap = CapybaraScreenshotDiff::SnapManager.snapshot("c") - assert_predicate snap.path, :exist? - end - - def test_cleanup_base_image_for_no_change - comparison = make_comparison(:a, :a) - assert_image_not_changed(["my_test.rb:42"], "name", comparison) - assert_not comparison.base_image_path.exist? - end - - def test_cleanup_base_image_for_changes - comparison = make_comparison(:a, :b) - assert_image_not_changed(["my_test.rb:42"], "name", comparison) - assert_not comparison.base_image_path.exist? - end - - private - - def our_screenshot(name, skip_stack_frames) - screenshot(name, skip_stack_frames: skip_stack_frames) - end - - def assert_image_not_changed(*) - CapybaraScreenshotDiff::ScreenshotAssertion.assert_image_not_changed(*) - end - end - end - end -end diff --git a/test/capybara/screenshot/diff_test.rb b/test/capybara/screenshot/diff_test.rb index a9d0fc6e..0f156d13 100644 --- a/test/capybara/screenshot/diff_test.rb +++ b/test/capybara/screenshot/diff_test.rb @@ -24,10 +24,10 @@ class DiffTest < ActionDispatch::IntegrationTest include Capybara::Screenshot::Diff include CapybaraScreenshotDiff::Minitest::Assertions - include Diff::TestMethodsStub + include CapybaraScreenshotDiff::DSLStub teardown do - CapybaraScreenshotDiff::SnapManager.cleanup! + CapybaraScreenshotDiff::SnapManager.cleanup! unless persist_comparisons? CapybaraScreenshotDiff.reset Capybara::Screenshot.add_driver_path = @orig_add_driver_path @@ -41,24 +41,27 @@ def test_that_it_has_a_version_number end def test_screenshot_groups_are_replaced - assert_nil @screenshot_group + assert_nil screenshot_namer.group screenshot_group "a" - assert_equal "a", @screenshot_group + assert_equal "a", screenshot_namer.group screenshot_group "b" - assert_equal "b", @screenshot_group + assert_equal "b", screenshot_namer.group end def test_screenshot_section_is_prepended - assert_nil @screenshot_section - assert_nil @screenshot_group + assert_nil screenshot_namer.section + assert_nil screenshot_namer.group + screenshot_section "a" - assert_equal "a", @screenshot_section + assert_equal "a", screenshot_namer.section assert_match %r{doc/screenshots/(macos|linux)/rack_test/a}, screenshot_dir + screenshot_group "b" - assert_equal "b", @screenshot_group + assert_equal "b", screenshot_namer.group assert_match %r{doc/screenshots/(macos|linux)/rack_test/a/b}, screenshot_dir + screenshot_group "c" - assert_equal "c", @screenshot_group + assert_equal "c", screenshot_namer.group assert_match %r{doc/screenshots/(macos|linux)/rack_test/a/c}, screenshot_dir end @@ -78,9 +81,11 @@ def test_screenshot_section_is_prepended def test_screenshot_with_alternate_save_path default_path = Capybara::Screenshot.save_path Capybara::Screenshot.save_path = "foo/bar" + screenshot_section "a" screenshot_group "b" screenshot "a" + assert_match %r{foo/bar/(macos|linux)/rack_test/a/b}, screenshot_dir ensure Capybara::Screenshot.save_path = default_path @@ -99,7 +104,7 @@ def test_screenshot_with_stability_time_limit screenshot_group "b" assert_equal "b/00_a", build_full_name("a") screenshot_section "c" - assert_equal "c/b/01_a", build_full_name("a") + assert_equal "c/b/00_a", build_full_name("a") screenshot_group nil assert_equal "c/a", build_full_name("a") end @@ -143,12 +148,13 @@ class SampleMiniTestCase < ActionDispatch::IntegrationTest def _test_sample_screenshot_error mock = ::Minitest::Mock.new mock.expect(:different?, true) + mock.expect(:different?, true) mock.expect(:dimensions_changed?, false) mock.expect(:base_image_path, Pathname.new("screenshot.base.png")) mock.expect(:error_message, "expected error message") assertion = CapybaraScreenshotDiff::ScreenshotAssertion.from([["my_test.rb:42"], "sample_screenshot", mock]) - schedule_match_job(assertion) + CapybaraScreenshotDiff.add_assertion(assertion) assert true end @@ -175,13 +181,14 @@ def teardown def _test_sample_screenshot_error comparison = ::Minitest::Mock.new - comparison.expect(:different?, true) + comparison.expect(:different?, true) # to find backtrace + comparison.expect(:different?, true) # to find messages comparison.expect(:dimensions_changed?, false) comparison.expect(:base_image_path, Pathname.new("screenshot.base.png")) comparison.expect(:error_message, "expected error message for non minitest") assertion = CapybaraScreenshotDiff::ScreenshotAssertion.from([["my_test.rb:42"], "sample_screenshot", comparison]) - schedule_match_job(assertion) + CapybaraScreenshotDiff.add_assertion(assertion) end end @@ -191,7 +198,7 @@ class ScreenshotFormatTest < ActionDispatch::IntegrationTest end include Capybara::Screenshot::Diff - include Diff::TestMethodsStub + include CapybaraScreenshotDiff::DSLStub include CapybaraScreenshotDiff::Minitest::Assertions teardown do @@ -222,6 +229,18 @@ class ScreenshotFormatTest < ActionDispatch::IntegrationTest end end end + + def screenshot_dir + screenshot_namer.current_group_directory + end + + def screenshot_namer + CapybaraScreenshotDiff.screenshot_namer + end + + def build_full_name(name) + CapybaraScreenshotDiff.screenshot_namer.full_name(name) + end end end end diff --git a/test/capybara_screenshot_diff/dsl_test.rb b/test/capybara_screenshot_diff/dsl_test.rb new file mode 100644 index 00000000..ee843dd2 --- /dev/null +++ b/test/capybara_screenshot_diff/dsl_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "test_helper" +require "capybara_screenshot_diff" +require "capybara_screenshot_diff/screenshot_assertion" + +module CapybaraScreenshotDiff + class DSLTest < ActionDispatch::IntegrationTest + include CapybaraScreenshotDiff::DSL + include CapybaraScreenshotDiff::DSLStub + + test "raise error on missing screenshot when fail_if_new is true" do + Capybara::Screenshot::Diff::Vcs.stub(:checkout_vcs, false) do + Capybara::Screenshot::Diff.stub(:fail_if_new, true) do + assert_raises CapybaraScreenshotDiff::ExpectationNotMet, match: /No existing screenshot found for/ do + screenshot "not_existing_screenshot-name" + end + end + end + end + + def test_assert_image_not_changed + message = assert_image_not_changed(["my_test.rb:42"], "name", make_comparison(:a, :c, destination: "screenshot.png")) + value = (RUBY_VERSION >= "2.4") ? 187.4 : 188 + assert_equal <<~MSG.chomp, message + Screenshot does not match for 'name': ({"area_size":629,"region":[11,3,48,20],"max_color_distance":#{value}}) + #{Rails.root}/doc/screenshots/screenshot.png + #{Rails.root}/doc/screenshots/screenshot.base.diff.png + #{Rails.root}/doc/screenshots/screenshot.diff.png + #{Rails.root}/doc/screenshots/screenshot.heatmap.diff.png + my_test.rb:42 + MSG + end + + def test_assert_image_not_changed_with_shift_distance_limit + message = assert_image_not_changed( + ["my_test.rb:42"], + "name", + make_comparison(:a, :c, destination: "screenshot.png", shift_distance_limit: 1, driver: :chunky_png) + ) + value = (RUBY_VERSION >= "2.4") ? 5.0 : 5 + assert_equal <<~MSG.chomp, message + Screenshot does not match for 'name': ({"area_size":629,"region":[11,3,48,20],"max_color_distance":#{value},"max_shift_distance":15}) + #{Rails.root}/doc/screenshots/screenshot.png + #{Rails.root}/doc/screenshots/screenshot.base.diff.png + #{Rails.root}/doc/screenshots/screenshot.diff.png + #{Rails.root}/doc/screenshots/screenshot.heatmap.diff.png + my_test.rb:42 + MSG + end + + def test_screenshot_support_drivers_options + skip "vips is disabled" unless defined?(Capybara::Screenshot::Diff::Drivers::VipsDriverTest) + assert_not screenshot("a", driver: :vips) + end + + def assert_no_screenshot_jobs_scheduled + assert_not_predicate CapybaraScreenshotDiff.registry, :assertions_present? + end + + def test_skip_stack_frames_with_zero_skip + Capybara::Screenshot::Diff::Vcs.stub(:checkout_vcs, true) do + assert_no_screenshot_jobs_scheduled + + snap = create_snapshot_for(:a, :c) + + our_screenshot(snap.full_name, 0) + assert_equal 1, CapybaraScreenshotDiff.assertions.size + assert_match(/our_screenshot'/, CapybaraScreenshotDiff.assertions[0].caller.first) + assert_equal snap.full_name, CapybaraScreenshotDiff.assertions[0].name + end + end + + def test_skip_stack_frames_with_one_skip + Capybara::Screenshot::Diff::Vcs.stub(:checkout_vcs, true) do + assert_no_screenshot_jobs_scheduled + + snap = create_snapshot_for(:a, :c) + + our_screenshot(snap.full_name, 1) + assert_equal 1, CapybaraScreenshotDiff.assertions.size + assert_match( + %r{/dsl_test.rb:.*?test_skip_stack_frames_with_one_skip}, + CapybaraScreenshotDiff.assertions[0].caller.first + ) + assert_equal snap.full_name, CapybaraScreenshotDiff.assertions[0].name + end + end + + def test_inline_screenshot_assertion_validation_with_difference + Capybara::Screenshot::Diff::Vcs.stub(:checkout_vcs, true) do + Capybara::Screenshot::Diff.stub(:delayed, false) do + assert_raises(CapybaraScreenshotDiff::ExpectationNotMet) do + snap = create_snapshot_for(:c, :a) + screenshot(snap.full_name, delayed: false) + end + end + end + end + + def test_inline_screenshot_assertion_validation_without_difference + Capybara::Screenshot::Diff::Vcs.stub(:checkout_vcs, true) do + Capybara::Screenshot::Diff.stub(:delayed, false) do + snap = create_snapshot_for(:a) + assert_nothing_raised { screenshot(snap.full_name, delayed: false) } + end + end + end + + def test_skip_area_and_stability_time_limit + assert_not screenshot(:a, skip_area: [0, 0, 1, 1], stability_time_limit: 0.01) + end + + def test_creates_new_screenshot + screenshot(:c) + + snap = CapybaraScreenshotDiff::SnapManager.snapshot("c") + assert_predicate snap.path, :exist? + end + + def test_cleanup_base_image_for_no_change + comparison = make_comparison(:a, :a) + assert_image_not_changed(["my_test.rb:42"], "name", comparison) + assert_not comparison.base_image_path.exist? + end + + def test_cleanup_base_image_for_changes + comparison = make_comparison(:a, :b) + assert_image_not_changed(["my_test.rb:42"], "name", comparison) + assert_not comparison.base_image_path.exist? + end + + private + + def our_screenshot(name, skip_stack_frames) + screenshot(name, skip_stack_frames: skip_stack_frames) + end + + def assert_image_not_changed(*args) + CapybaraScreenshotDiff::ScreenshotAssertion.assert_image_not_changed(*args) + end + end +end diff --git a/test/capybara_screenshot_diff/screenshot_namer_test.rb b/test/capybara_screenshot_diff/screenshot_namer_test.rb new file mode 100644 index 00000000..6ff22e3c --- /dev/null +++ b/test/capybara_screenshot_diff/screenshot_namer_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "test_helper" + +module CapybaraScreenshotDiff + class ScreenshotNamerTest < ActiveSupport::TestCase + setup do + @screenshot_area_root = Dir.mktmpdir("screenshots_area") + @screenshot_namer = ScreenshotNamer.new(@screenshot_area_root) + end + + teardown do + FileUtils.remove_entry(@screenshot_area_root) if Dir.exist?(@screenshot_area_root) + end + + test "#group= adds group to screenshot directory path" do + @screenshot_namer.group = "group_name" + assert_includes @screenshot_namer.current_group_directory.to_s, "group_name" + end + + test "#group= resets counter when group changes" do + @screenshot_namer.group = "group1" + assert_equal "group1/00_image", @screenshot_namer.full_name("image") + assert_equal "group1/01_image", @screenshot_namer.full_name("image") + + @screenshot_namer.group = "group2" + assert_equal "group2/00_image", @screenshot_namer.full_name("image") + end + + test "#group= handles nil group" do + @screenshot_namer.group = nil + assert_equal "image", @screenshot_namer.full_name("image") + assert_equal [], @screenshot_namer.directory_parts + end + + test "#group= handles empty string group" do + @screenshot_namer.group = "" + assert_equal "image", @screenshot_namer.full_name("image") + assert_equal [], @screenshot_namer.directory_parts + end + + test "#section= sets section for directory path" do + @screenshot_namer.section = "section_name" + assert_includes @screenshot_namer.current_group_directory.to_s, "section_name" + end + + test "#section= handles nil section" do + @screenshot_namer.section = nil + assert_equal [], @screenshot_namer.directory_parts + end + + test "#section= handles empty string section" do + @screenshot_namer.section = "" + assert_equal [], @screenshot_namer.directory_parts + end + + test "#full_name generates basic name when no group is set" do + assert_equal "image_a", @screenshot_namer.full_name("image_a") + assert_equal "image_b", @screenshot_namer.full_name("image_b") + end + + test "#full_name generates prefixed and incremented names when group is set" do + @screenshot_namer.group = "user_flow" + assert_equal "user_flow/00_step1", @screenshot_namer.full_name("step1") + assert_equal "user_flow/01_step2", @screenshot_namer.full_name("step2") + end + + test "#full_name handles symbol base_name and group" do + @screenshot_namer.group = "symbols" + assert_equal "symbols/00_my_symbol", @screenshot_namer.full_name(:my_symbol) + @screenshot_namer.group = nil + assert_equal "plain_symbol", @screenshot_namer.full_name(:plain_symbol) + end + + test "#full_name_with_path includes screenshot_area" do + expected_path = File.join(@screenshot_area_root, "image_a") + assert_equal expected_path, @screenshot_namer.full_name_with_path("image_a") + end + + test "#full_name_with_path with nil screenshot_area" do + namer_no_area = @screenshot_namer + namer_no_area.section = "s" + namer_no_area.group = "g" + assert_includes namer_no_area.full_name_with_path("image"), File.join("s", "g", "00_image") + end + + test "#full_name_with_path includes section when set" do + @screenshot_namer.section = "checkout" + expected_path = File.join(@screenshot_area_root, "checkout", "details") + assert_equal expected_path, @screenshot_namer.full_name_with_path("details") + end + + test "#full_name_with_path includes group and counter when set" do + @screenshot_namer.group = "payment" + expected_path = File.join(@screenshot_area_root, "payment", "00_credit_card") + assert_equal expected_path, @screenshot_namer.full_name_with_path("credit_card") + expected_path_next = File.join(@screenshot_area_root, "payment", "01_confirmation") + assert_equal expected_path_next, @screenshot_namer.full_name_with_path("confirmation") + end + + test "#full_name_with_path includes section and group" do + @screenshot_namer.section = "user_profile" + @screenshot_namer.group = "avatar_upload" + expected_path = File.join(@screenshot_area_root, "user_profile", "avatar_upload", "00_new_image") + assert_equal expected_path, @screenshot_namer.full_name_with_path("new_image") + end + + test "#full_name_with_path with active group for duplicated name adds counter" do + @screenshot_namer.group = "user_flow" + assert_equal "user_flow/00_step1", @screenshot_namer.full_name("step1") + assert_equal "user_flow/01_step1", @screenshot_namer.full_name("step1") + assert_equal "user_flow/02_step1", @screenshot_namer.full_name("step1") + end + + test "#full_name_with_path without active group for duplicated name ignores" do + @screenshot_namer.group = nil + assert_equal "step1", @screenshot_namer.full_name("step1") + assert_equal "step1", @screenshot_namer.full_name("step1") + end + + test "#directory_parts is empty initially" do + assert_equal [], @screenshot_namer.directory_parts + end + + test "#directory_parts contains section when set" do + @screenshot_namer.section = "s1" + assert_equal ["s1"], @screenshot_namer.directory_parts + end + + test "#directory_parts contains group when set" do + @screenshot_namer.group = "g1" + assert_equal ["g1"], @screenshot_namer.directory_parts + end + + test "#directory_parts contains section and group when both set" do + @screenshot_namer.section = "s1" + @screenshot_namer.group = "g1" + assert_equal ["s1", "g1"], @screenshot_namer.directory_parts + end + + test "#clear_current_group_directory removes group directory" do + @screenshot_namer.group = "to_clear" + dir_path = @screenshot_namer.current_group_directory + FileUtils.mkdir_p(dir_path) + assert Dir.exist?(dir_path) + + @screenshot_namer.clear_current_group_directory + assert_not Dir.exist?(dir_path) + end + + test "#clear_current_group_directory is safe when directory doesn't exist" do + @screenshot_namer.group = "nonexistent" + assert_nothing_raised { @screenshot_namer.clear_current_group_directory } + end + + test "#current_group_directory constructs full directory path" do + @screenshot_namer.section = "section1" + @screenshot_namer.group = "group1" + expected_path = File.join(@screenshot_area_root, "section1", "group1") + assert_equal expected_path, @screenshot_namer.current_group_directory + end + end +end diff --git a/test/capybara_screenshot_diff/snap_manager_test.rb b/test/capybara_screenshot_diff/snap_manager_test.rb index c5315828..e4b163be 100644 --- a/test/capybara_screenshot_diff/snap_manager_test.rb +++ b/test/capybara_screenshot_diff/snap_manager_test.rb @@ -31,5 +31,34 @@ class SnapManagerTest < ActiveSupport::TestCase assert_not_predicate snap.path, :exist? assert_predicate snap.base_path, :exist? end + + test "#screenshots_dir returns all created snapshots" do + assert_equal [], @manager.snapshots.to_a + + snap = @manager.snapshot("test_image") + path = fixture_image_path_from("a") + + @manager.provision_snap_with(snap, path) + assert_equal [snap], @manager.snapshots.to_a + end + + test "#screenshots_dir ignores attempts" do + assert_equal [], @manager.snapshots.to_a + + snap = @manager.snapshot("test_image") + path = fixture_image_path_from("a") + + @manager.provision_snap_with(snap, path, version: :attempt) + + assert_equal [snap], @manager.snapshots.to_a + end + + test "#snapshot overrides the file extension" do + snap = @manager.snapshot("test_image") + assert_equal "test_image", snap.full_name + assert_includes snap.path.to_s, "test_image.png" + assert_includes snap.base_path.to_s, "test_image.base.png" + assert_includes snap.next_attempt_path!.to_s, "test_image.attempt_00.png" + end end end diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png index 198a7e80..dee2803e 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png index 4b4dab9e..825526c3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png index 7109f53c..a799e1fc 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png index 826d2605..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png index 4b4dab9e..825526c3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png index 7109f53c..a799e1fc 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png index b47eff3a..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index.png index 14f833ec..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png index 64344e75..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png index 64344e75..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png index 14f833ec..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp index 613f10d4..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp and b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png index b47eff3a..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png index 14f833ec..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png and b/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png index e8644606..dee2803e 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png index a5b651a8..19ed37a1 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png index ad9cf0db..a799e1fc 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png index 9d2386e6..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png index 510da9e1..825526c3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png index ad9cf0db..7738b691 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png index 9c46ede0..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png index 14f833ec..7243bef8 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png index 66e82905..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png index 66e82905..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png index 14f833ec..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp index 613f10d4..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png index b47eff3a..fe9296b3 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png index 14f833ec..d2afd411 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png index 6c923165..5f44230c 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png index 5a9234f2..88bfc801 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png index 09d88558..4ddad7ff 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png index 9ff279ec..7c7573fe 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png index f5b93070..4699b162 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png index 09d88558..4ddad7ff 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png index 9ff279ec..7c7573fe 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png index 0b28ff60..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png index 2e1d0b66..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png index 2e1d0b66..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png index 0b28ff60..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp index 2a969aac..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png index 9ff279ec..7c7573fe 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png index 0b28ff60..36e78545 100644 Binary files a/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png and b/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png index fc2318c4..97299a30 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png index 61f65962..f229897b 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png index bac7c5d5..90a1b15c 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png index 86445f66..d1fa4f38 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png index 61f65962..f229897b 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png index bac7c5d5..90a1b15c 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png index ada74ae9..d1fa4f38 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index.png index d5da2f3f..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png index 06d00ab8..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png index 06d00ab8..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png index d5da2f3f..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp index d5da2f3f..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp and b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png index ada74ae9..d1fa4f38 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png index d5da2f3f..d5d38baf 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png and b/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png index 8adc8f06..97299a30 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png index d35d89ad..936dcc55 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png index a300a553..e1fa7be1 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png index 21090013..d1fa4f38 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png index 954fbce7..c6d04302 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png index cfe71495..e1fa7be1 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-without-img-cropped.png new file mode 100644 index 00000000..d1fa4f38 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png index ffe8042a..ab6a97cd 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png index 805cf6ca..53d0fa90 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png index 805cf6ca..53d0fa90 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index/00_index.png new file mode 100644 index 00000000..ab6a97cd Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp new file mode 100644 index 00000000..ab6a97cd Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png new file mode 100644 index 00000000..d1fa4f38 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png new file mode 100644 index 00000000..ab6a97cd Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png index 50e5ee84..e2ad0111 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png index ca0d4843..a3607dca 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png index ed2f48f4..0775227a 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png index 65026f01..556025a7 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png index 4a490931..200f0f65 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png index ed2f48f4..0775227a 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-without-img-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-without-img-cropped.png new file mode 100644 index 00000000..556025a7 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-without-img-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png index 8d3f7e23..cf6c0f64 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png index c82ddf11..cf6c0f64 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png index c82ddf11..cf6c0f64 100644 Binary files a/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index/00_index.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index/00_index.png new file mode 100644 index 00000000..cf6c0f64 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index/00_index.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp new file mode 100644 index 00000000..cf6c0f64 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png new file mode 100644 index 00000000..556025a7 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png differ diff --git a/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_with_stability/00_index.png b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_with_stability/00_index.png new file mode 100644 index 00000000..cf6c0f64 Binary files /dev/null and b/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_with_stability/00_index.png differ diff --git a/test/fixtures/app/index-with-anim.html b/test/fixtures/app/index-with-anim.html index 2056539d..a9af96d4 100644 --- a/test/fixtures/app/index-with-anim.html +++ b/test/fixtures/app/index-with-anim.html @@ -4,6 +4,7 @@
- +
- +
diff --git a/test/fixtures/app/index.html b/test/fixtures/app/index.html index d695e1a5..98f41784 100644 --- a/test/fixtures/app/index.html +++ b/test/fixtures/app/index.html @@ -1,18 +1,23 @@ - + +
- +
- +
diff --git a/test/fixtures/files/rspec_spec.rb b/test/fixtures/files/rspec_spec.rb index 8b885f44..5315f812 100644 --- a/test/fixtures/files/rspec_spec.rb +++ b/test/fixtures/files/rspec_spec.rb @@ -21,10 +21,11 @@ Capybara::Screenshot.add_os_path = true Capybara::Screenshot.add_driver_path = true Capybara::Screenshot::Diff.driver = ENV.fetch("SCREENSHOT_DRIVER", "chunky_png").to_sym + Capybara::Screenshot::Diff.tolerance = 0.5 end it "should include CapybaraScreenshotDiff in rspec" do - expect(self.class.ancestors).to include Capybara::Screenshot::Diff::TestMethods + expect(self.class.ancestors).to include CapybaraScreenshotDiff::DSL end it "visits and compare screenshot on teardown" do diff --git a/test/integration/browser_screenshot_test.rb b/test/integration/browser_screenshot_test.rb index 29b2887c..9da4b4d1 100644 --- a/test/integration/browser_screenshot_test.rb +++ b/test/integration/browser_screenshot_test.rb @@ -6,17 +6,20 @@ module Capybara::Screenshot class BrowserScreenshotTest < SystemTestCase setup do Capybara::Screenshot.blur_active_element = true + @original_tolerance = Capybara::Screenshot::Diff.tolerance + Capybara::Screenshot::Diff.tolerance = (Capybara::Screenshot::Diff.driver == :vips) ? 0.035 : 0.13 end teardown do Capybara::Screenshot.blur_active_element = nil + Capybara::Screenshot::Diff.tolerance = @original_tolerance end def before_teardown if CapybaraScreenshotDiff.assertions_present? # NOTE: We rollback new screenshots in order to remain their original state # and only for debug mode we keep them - unless ENV["DEBUG"] && !ENV["DISABLE_ROLLBACK_COMPARISON_RUNTIME_FILES"] + unless persist_comparisons? CapybaraScreenshotDiff.assertions.each(&method(:rollback_comparison_runtime_files)) end # NOTE: We clear tracked different errors in order to not raise error @@ -31,12 +34,14 @@ def test_screenshot_without_changes end def test_screenshot_with_changes + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end visit "/" fill_in "First Field:", with: "Some changes in the field" - assert_matches_screenshot("index") - + assert_matches_screenshot("index", tolerance: nil) assert_screenshot_error_for("index") end @@ -46,10 +51,11 @@ def test_window_size_should_resize_browser_window_in_setup def test_screenshot_with_hide_caret_enabled Capybara::Screenshot.hide_caret = true - visit "/" + fill_in "First Field:", with: "Test Input With Hide Caret" - assert_matches_screenshot "index-hide_caret-enabled" + + assert_matches_screenshot("index-hide_caret-enabled") ensure Capybara::Screenshot.hide_caret = nil end @@ -68,9 +74,9 @@ def test_screenshot_with_hide_caret_disabled def test_screenshot_with_blur_active_element_enabled Capybara::Screenshot.blur_active_element = true - visit "/" fill_in "First Field:", with: "Test Input With Hide Caret" + assert_matches_screenshot "index-blur_active_element-enabled" ensure Capybara::Screenshot.blur_active_element = nil @@ -78,9 +84,9 @@ def test_screenshot_with_blur_active_element_enabled def test_screenshot_with_blur_active_element_disabled Capybara::Screenshot.blur_active_element = false - visit "/" fill_in "First Field:", with: "Test Input Without Hide Caret" + assert_matches_screenshot "index-blur_active_element-disabled" ensure Capybara::Screenshot.blur_active_element = nil @@ -93,8 +99,11 @@ def test_screenshot_selected_element end test "skip_area accepts passing multiple coordinates as one array" do - visit "/" + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + visit "/" fill_in "First Field:", with: "Changed" fill_in "Second Field:", with: "Changed" @@ -114,6 +123,10 @@ def test_screenshot_selected_element test "crop accepts css selector" do visit "/index-without-img.html" + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + assert_matches_screenshot("index-without-img-cropped", crop: "form") assert_no_screenshot_errors @@ -122,23 +135,41 @@ def test_screenshot_selected_element test "skip_area accepts css selector" do visit "/" + assert_matches_screenshot("index_with_skip_area_as_array_of_css", skip_area: ["form"]) + assert_matches_screenshot("index_with_skip_area_as_array_of_css_and_p", skip_area: [[90, 950, 180, 1000], "form"]) + end + + test "skip_area accepts css selector and ignores changes" do + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + + visit "/" + fill_in "First Field:", with: "Changed" fill_in "Second Field:", with: "Changed" assert_matches_screenshot("index", skip_area: "form") - assert_matches_screenshot("index_with_skip_area_as_array_of_css", skip_area: ["form"]) - assert_matches_screenshot("index_with_skip_area_as_array_of_css_and_p", skip_area: [[90, 950, 180, 1000], "form"]) assert_no_screenshot_errors end - test "skip_area converts coordinates to be relative to cropped region" do + test "cropped screenshot" do visit "/index.html" + assert_matches_screenshot("index-cropped", skip_area: "#first-field", crop: "form") + end + + test "skip_area converts coordinates to be relative to cropped region" do + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + + visit "/index.html" fill_in "First Field:", with: "New Change" fill_in "Second Field:", with: "New Change" - assert_matches_screenshot("index-cropped", skip_area: "#first-field", crop: "form") + assert_matches_screenshot("index-cropped", skip_area: "#first-field", crop: "form", tolerance: 0.001) assert_not_predicate( CapybaraScreenshotDiff.failed_assertions, @@ -148,18 +179,23 @@ def test_screenshot_selected_element end test "skip_area by css selectors" do - visit "/" + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + visit "/" fill_in "First Field:", with: "Test Input With Hide Caret" assert_matches_screenshot("index", skip_area: "form") - assert_no_screenshot_errors end test "crop and skip_area by css selectors" do - visit "/index-without-img.html" + if ENV["RECORD_SCREENSHOTS"] + skip "we record screenshots only in" + end + visit "/index-without-img.html" fill_in "First Field:", with: "Test Input With Hide Caret" assert_matches_screenshot("index-without-img-cropped", skip_area: "input", crop: "form") @@ -193,7 +229,7 @@ def test_screenshot_selected_element # because quick_equal could produce incorrect result, # because of the same size screenshots 10.times do - assert_matches_screenshot "index-with-anim", stability_time_limit: 0.33, wait: 0.5 + assert_matches_screenshot "index-with-anim", stability_time_limit: 0.33, wait: 0.5, tolerance: nil end end ensure @@ -238,7 +274,7 @@ def assert_no_screenshot_errors assert( screenshots.empty?, - "expecting not to have any difference. But got next:\n#{error_messages.join(";\n")}" + "expecting not to have any difference. But got next:\n\n#{error_messages.join(";\n")}" ) end end diff --git a/test/integration/record_screenshot_test.rb b/test/integration/record_screenshot_test.rb index 651d1b5e..793ff51d 100644 --- a/test/integration/record_screenshot_test.rb +++ b/test/integration/record_screenshot_test.rb @@ -4,8 +4,16 @@ class RecordScreenshotTest < SystemTestCase setup do - screenshot_section class_name.underscore.sub(/(_feature|_system)?_test$/, "") unless @screenshot_section - screenshot_group name[5..] unless @screenshot_group + screenshot_section class_name.underscore.sub(/(_feature|_system)?_test$/, "") unless CapybaraScreenshotDiff.screenshot_namer.section + screenshot_group name[5..] unless CapybaraScreenshotDiff.screenshot_namer.group + + @original_tolerance = Capybara::Screenshot::Diff.tolerance + Capybara::Screenshot::Diff.tolerance = (Capybara::Screenshot::Diff.driver == :vips) ? 0.035 : 0.7 + end + + teardown do + Capybara::Screenshot.blur_active_element = nil + Capybara::Screenshot::Diff.tolerance = @original_tolerance end def test_record_index diff --git a/test/integration/test_methods_system_test.rb b/test/integration/test_methods_system_test.rb index 5aef8602..15b2b646 100644 --- a/test/integration/test_methods_system_test.rb +++ b/test/integration/test_methods_system_test.rb @@ -15,8 +15,8 @@ module Capybara module Screenshot module Diff class TestMethodsSystemTest < ActionDispatch::SystemTestCase - include TestMethods - include TestMethodsStub + include CapybaraScreenshotDiff::DSL + include CapybaraScreenshotDiff::DSLStub driven_by :selenium, using: :headless_chrome diff --git a/test/support/capybara_screenshot_diff/dsl_stub.rb b/test/support/capybara_screenshot_diff/dsl_stub.rb new file mode 100644 index 00000000..b8502d20 --- /dev/null +++ b/test/support/capybara_screenshot_diff/dsl_stub.rb @@ -0,0 +1,63 @@ +module CapybaraScreenshotDiff + module DSLStub + extend ActiveSupport::Concern + + included do + setup do + @manager = CapybaraScreenshotDiff::SnapManager.new(Capybara::Screenshot.root / "doc/screenshots") + Capybara::Screenshot::Diff.screenshoter = Capybara::Screenshot::ScreenshoterStub + end + + teardown do + @manager.cleanup! + Capybara::Screenshot::Diff.screenshoter = Capybara::Screenshot::Screenshoter + CapybaraScreenshotDiff.reset + end + end + + # Prepare comparison images and build ImageCompare for them + def make_comparison(fixture_base_image, fixture_new_image = nil, destination: "screenshot", **options) + fixture_new_image ||= fixture_base_image + snap = create_snapshot_for(fixture_base_image, fixture_new_image, name: destination) + Capybara::Screenshot::Diff::ImageCompare.new(snap.path, snap.base_path, **options) + end + + # Prepare images for comparison in a test + # + # @param snap [CapybaraScreenshotDiff::Snap] the snapshot to prepare + # @param expected [String] the base name of the original base image + # @param actual [String] the base name of the original new image + def set_test_images(snap, expected, actual) + @manager.provision_snap_with(snap, fixture_image_path_from(actual, snap.format), version: :actual) + @manager.provision_snap_with(snap, fixture_image_path_from(expected, snap.format), version: :base) + end + + ImageCompareStub = Struct.new( + :driver, :driver_options, :shift_distance_limit, :quick_equal?, :different?, :reporter, keyword_init: true + ) + + def build_image_compare_stub(equal: true) + ImageCompareStub.new( + driver: ::Minitest::Mock.new, + reporter: ::Minitest::Mock.new, + driver_options: Capybara::Screenshot::Diff.default_options, + shift_distance_limit: nil, + quick_equal?: equal, + different?: !equal + ) + end + + def take_stable_screenshot_with(snap, stability_time_limit: 0.01, wait: 10) + screenshoter = Capybara::Screenshot::Diff::StableScreenshoter.new({stability_time_limit: stability_time_limit, wait: wait}) + screenshoter.take_stable_screenshot(snap) + end + + def create_snapshot_for(expected, actual = nil, name: nil) + actual ||= expected + name ||= "#{actual}_#{Time.now.nsec}" + @manager.snapshot(name).tap do |snap| + set_test_images(snap, expected, actual) + end + end + end +end diff --git a/test/support/non_minitest_assertions.rb b/test/support/non_minitest_assertions.rb index 209a31fa..ca0fbe84 100644 --- a/test/support/non_minitest_assertions.rb +++ b/test/support/non_minitest_assertions.rb @@ -6,7 +6,7 @@ module CapybaraScreenshotDiff module NonMinitest module Assertions def self.included(klass) - klass.include Capybara::Screenshot::Diff::TestMethods + klass.include CapybaraScreenshotDiff::DSL klass.setup do Capybara::Screenshot::BrowserHelpers.resize_window_if_needed diff --git a/test/support/screenshoter_stub.rb b/test/support/screenshoter_stub.rb index a1dc1b36..179ba00e 100644 --- a/test/support/screenshoter_stub.rb +++ b/test/support/screenshoter_stub.rb @@ -11,6 +11,7 @@ def save_screenshot(path) source_image = path.basename.to_path source_image.slice!(/\.attempt_\d+/) source_image.slice!(/^\d\d_/) + source_image.slice!(/_\d+(?=\.)/) FileUtils.cp(File.expand_path(source_image, TEST_IMAGES_DIR), path) diff --git a/test/support/setup_rails_app.rb b/test/support/setup_rails_app.rb index 21ae0829..bfee43da 100644 --- a/test/support/setup_rails_app.rb +++ b/test/support/setup_rails_app.rb @@ -3,6 +3,9 @@ require "rack" require "rackup" if Rack::RELEASE >= "3" +require "logger" # for Rails 7.0 +require "action_controller" + # NOTE: Simulate Rails Environment module Rails def self.root diff --git a/test/support/stub_test_methods.rb b/test/support/stub_test_methods.rb index 98a3479b..ad1575bf 100644 --- a/test/support/stub_test_methods.rb +++ b/test/support/stub_test_methods.rb @@ -1,60 +1,4 @@ # frozen_string_literal: true require_relative "screenshoter_stub" - -module Capybara - module Screenshot - module Diff - module TestMethodsStub - extend ActiveSupport::Concern - - included do - setup do - @manager = CapybaraScreenshotDiff::SnapManager.new(Capybara::Screenshot.root / "doc/screenshots") - Diff.screenshoter = ScreenshoterStub - end - - teardown do - @manager.cleanup! - Diff.screenshoter = Screenshoter - CapybaraScreenshotDiff.reset - end - end - - # Prepare comparison images and build ImageCompare for them - def make_comparison(fixture_base_image, fixture_new_image, destination: "screenshot", **options) - snap = @manager.snapshot(destination) - - set_test_images(snap, fixture_base_image, fixture_new_image) - - ImageCompare.new(snap.path, snap.base_path, **options) - end - - def set_test_images(snap, original_base_image, original_new_image, ext: "png") - @manager.provision_snap_with(snap, fixture_image_path_from(original_new_image, snap.format), version: :actual) - @manager.provision_snap_with(snap, fixture_image_path_from(original_base_image, snap.format), version: :base) - end - - ImageCompareStub = Struct.new( - :driver, :driver_options, :shift_distance_limit, :quick_equal?, :different?, :reporter, keyword_init: true - ) - - def build_image_compare_stub(equal: true) - ImageCompareStub.new( - driver: ::Minitest::Mock.new, - reporter: ::Minitest::Mock.new, - driver_options: Capybara::Screenshot::Diff.default_options, - shift_distance_limit: nil, - quick_equal?: equal, - different?: !equal - ) - end - - def take_stable_screenshot_with(snap, stability_time_limit: 0.01, wait: 10) - screenshoter = StableScreenshoter.new({stability_time_limit: stability_time_limit, wait: wait}) - screenshoter.take_stable_screenshot(snap) - end - end - end - end -end +require_relative "capybara_screenshot_diff/dsl_stub" diff --git a/test/test_helper.rb b/test/test_helper.rb index f463fd1b..1b893d0a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -31,7 +31,11 @@ class ActiveSupport::TestCase self.file_fixture_path = Pathname.new(File.expand_path("fixtures", __dir__)) teardown do - FileUtils.rm_rf Dir[Capybara::Screenshot.root / "*"] + CapybaraScreenshotDiff::SnapManager.cleanup! unless persist_comparisons? + end + + def persist_comparisons? + ENV["DEBUG"] || ENV["DISABLE_ROLLBACK_COMPARISON_RUNTIME_FILES"] || ENV["RECORD_SCREENSHOTS"] end def optional_test