├── .dockerignore ├── .gitattributes ├── .github ├── actions │ ├── setup-ruby-and-dependencies │ │ └── action.yml │ └── upload-screenshots │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .standard.yml ├── .yamllint.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── bundle ├── console ├── dtest ├── install-vips ├── rake ├── setup └── standardrb ├── capybara-screenshot-diff.gemspec ├── gemfiles ├── edge_gems.rb ├── rails70_gems.rb ├── rails71_gems.rb ├── rails72_gems.rb └── rails80_gems.rb ├── gems.rb ├── lib ├── capybara-screenshot-diff.rb ├── capybara │ └── screenshot │ │ ├── diff.rb │ │ └── diff │ │ ├── area_calculator.rb │ │ ├── browser_helpers.rb │ │ ├── capture_strategy.rb │ │ ├── comparison.rb │ │ ├── comparison_loader.rb │ │ ├── cucumber.rb │ │ ├── difference.rb │ │ ├── difference_finder.rb │ │ ├── drivers.rb │ │ ├── drivers │ │ ├── base_driver.rb │ │ ├── chunky_png_driver.rb │ │ └── vips_driver.rb │ │ ├── image_compare.rb │ │ ├── image_preprocessor.rb │ │ ├── os.rb │ │ ├── region.rb │ │ ├── reporters │ │ └── default.rb │ │ ├── screenshot_coordinator.rb │ │ ├── screenshot_matcher.rb │ │ ├── screenshot_namer_dsl.rb │ │ ├── screenshoter.rb │ │ ├── stable_capture_strategy.rb │ │ ├── stable_screenshoter.rb │ │ ├── standard_capture_strategy.rb │ │ ├── utils.rb │ │ ├── vcs.rb │ │ └── version.rb ├── capybara_screenshot_diff.rb └── capybara_screenshot_diff │ ├── attempts_reporter.rb │ ├── backtrace_filter.rb │ ├── cucumber.rb │ ├── dsl.rb │ ├── error_with_filtered_backtrace.rb │ ├── minitest.rb │ ├── rspec.rb │ ├── screenshot_assertion.rb │ ├── screenshot_namer.rb │ ├── snap.rb │ └── snap_manager.rb ├── scripts └── benchmark │ └── find_region_benchmark.rb ├── test ├── fixtures │ ├── app │ │ ├── doc │ │ │ └── screenshots │ │ │ │ ├── .keep │ │ │ │ ├── linux │ │ │ │ ├── cuprite │ │ │ │ │ ├── cropped_screenshot.png │ │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ │ ├── index-cropped.png │ │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ │ ├── index-vips.webp │ │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ │ ├── index.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ │ └── record_screenshot │ │ │ │ │ │ ├── record_index │ │ │ │ │ │ └── 00_index.png │ │ │ │ │ │ ├── record_index_as_webp │ │ │ │ │ │ └── 00_index-vips.webp │ │ │ │ │ │ ├── record_index_cropped │ │ │ │ │ │ └── 00_index-cropped.png │ │ │ │ │ │ └── record_index_with_stability │ │ │ │ │ │ └── 00_index.png │ │ │ │ ├── selenium_chrome_headless │ │ │ │ │ ├── cropped_screenshot.png │ │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ │ ├── index-cropped.png │ │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ │ ├── index-vips.webp │ │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ │ ├── index.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ │ └── record_screenshot │ │ │ │ │ │ ├── record_index │ │ │ │ │ │ └── 00_index.png │ │ │ │ │ │ ├── record_index_as_webp │ │ │ │ │ │ └── 00_index-vips.webp │ │ │ │ │ │ ├── record_index_cropped │ │ │ │ │ │ └── 00_index-cropped.png │ │ │ │ │ │ └── record_index_with_stability │ │ │ │ │ │ └── 00_index.png │ │ │ │ └── selenium_headless │ │ │ │ │ ├── cropped_screenshot.png │ │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ │ ├── index-cropped.png │ │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ │ ├── index-vips.webp │ │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ │ ├── index.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ │ └── record_screenshot │ │ │ │ │ ├── record_index │ │ │ │ │ └── 00_index.png │ │ │ │ │ ├── record_index_as_webp │ │ │ │ │ └── 00_index-vips.webp │ │ │ │ │ ├── record_index_cropped │ │ │ │ │ └── 00_index-cropped.png │ │ │ │ │ └── record_index_with_stability │ │ │ │ │ └── 00_index.png │ │ │ │ └── macos │ │ │ │ ├── cuprite │ │ │ │ ├── cropped_screenshot.png │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ ├── index-cropped.png │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ ├── index-vips.webp │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ ├── index.png │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ └── record_screenshot │ │ │ │ │ ├── record_index │ │ │ │ │ └── 00_index.png │ │ │ │ │ ├── record_index_as_webp │ │ │ │ │ └── 00_index-vips.webp │ │ │ │ │ ├── record_index_cropped │ │ │ │ │ └── 00_index-cropped.png │ │ │ │ │ └── record_index_with_stability │ │ │ │ │ └── 00_index.png │ │ │ │ ├── selenium_chrome_headless │ │ │ │ ├── cropped_screenshot.png │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ ├── index-cropped.png │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ ├── index-vips.webp │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ ├── index.png │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ └── record_screenshot │ │ │ │ │ ├── record_index │ │ │ │ │ └── 00_index.png │ │ │ │ │ ├── record_index_as_webp │ │ │ │ │ └── 00_index-vips.webp │ │ │ │ │ ├── record_index_cropped │ │ │ │ │ └── 00_index-cropped.png │ │ │ │ │ └── record_index_with_stability │ │ │ │ │ └── 00_index.png │ │ │ │ └── selenium_headless │ │ │ │ ├── cropped_screenshot.png │ │ │ │ ├── index-blur_active_element-disabled.png │ │ │ │ ├── index-blur_active_element-enabled.png │ │ │ │ ├── index-cropped.png │ │ │ │ ├── index-hide_caret-disabled.png │ │ │ │ ├── index-hide_caret-enabled.png │ │ │ │ ├── index-vips.webp │ │ │ │ ├── index-without-img-cropped.png │ │ │ │ ├── index.png │ │ │ │ ├── index_with_skip_area_as_array_of_css.png │ │ │ │ ├── index_with_skip_area_as_array_of_css_and_p.png │ │ │ │ └── record_screenshot │ │ │ │ ├── record_index │ │ │ │ └── 00_index.png │ │ │ │ ├── record_index_as_webp │ │ │ │ └── 00_index-vips.webp │ │ │ │ ├── record_index_cropped │ │ │ │ └── 00_index-cropped.png │ │ │ │ └── record_index_with_stability │ │ │ │ └── 00_index.png │ │ ├── image.png │ │ ├── index-with-anim.html │ │ ├── index-without-img.html │ │ └── index.html │ ├── comparisons │ │ ├── a-and-b.diff.png │ │ ├── a-and-b.heatmap.diff.png │ │ ├── a-and-c.diff.png │ │ ├── b-and-a.diff.png │ │ └── c-and-a.diff.png │ ├── images │ │ ├── a.png │ │ ├── a.webp │ │ ├── a_cropped.png │ │ ├── b.png │ │ ├── c.png │ │ ├── d.png │ │ ├── portrait.png │ │ └── portrait_b.png │ └── rspec_spec.rb ├── integration │ ├── browser_screenshot_test.rb │ ├── record_screenshot_test.rb │ ├── rspec_test.rb │ └── test_methods_system_test.rb ├── support │ ├── capybara_screenshot_diff │ │ └── dsl_stub.rb │ ├── non_minitest_assertions.rb │ ├── screenshoter_stub.rb │ ├── setup_capybara.rb │ ├── setup_capybara_drivers.rb │ ├── setup_rails_app.rb │ ├── stub_test_methods.rb │ ├── test_doubles.rb │ └── test_helpers.rb ├── system_test_case.rb ├── test_helper.rb └── unit │ ├── area_calculator_test.rb │ ├── comparison_loader_test.rb │ ├── diff_test.rb │ ├── difference_finder_test.rb │ ├── difference_test.rb │ ├── drivers │ ├── chunky_png_driver_test.rb │ ├── utils_test.rb │ └── vips_driver_test.rb │ ├── dsl_test.rb │ ├── image_compare_test.rb │ ├── image_preprocessor_test.rb │ ├── region_test.rb │ ├── reporters │ └── default_test.rb │ ├── screenshot_namer_test.rb │ ├── screenshot_test.rb │ ├── screenshoter_test.rb │ ├── snap_manager_test.rb │ ├── stable_screenshoter_test.rb │ └── vcs_test.rb └── tmp └── .keep /.dockerignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .ruby-lsp/ 3 | coverage/ 4 | tmp/ 5 | 6 | # Ignore report files 7 | *.attempt_*.png 8 | *.diff.png 9 | *.base.png 10 | *.attempt_*.webp 11 | *.diff.webp 12 | *.base.webp 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.gemspec diff=ruby 2 | *.rake diff=ruby 3 | *.rb diff=ruby 4 | *.md diff=md 5 | -------------------------------------------------------------------------------- /.github/actions/setup-ruby-and-dependencies/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Setup Ruby and Cache' 3 | description: 'Set up Ruby environment and cache apt packages' 4 | inputs: 5 | ruby-version: 6 | description: 'Ruby version to set up' 7 | required: true 8 | ruby-cache-version: 9 | description: 'Bundler cache version' 10 | required: false 11 | cache-apt-packages: 12 | description: 'Whether to cache apt packages' 13 | required: false 14 | default: 'false' 15 | runs: 16 | using: 'composite' 17 | steps: 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ inputs.ruby-version }} 22 | bundler-cache: true 23 | cache-version: ${{ inputs.ruby-cache-version }}-v1 24 | 25 | - run: sudo apt-get -qq update 26 | shell: bash 27 | 28 | - name: Install and cache vips 29 | if: ${{ inputs.cache-apt-packages == 'true' }} 30 | uses: awalsh128/cache-apt-pkgs-action@latest 31 | with: 32 | packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev 33 | version: tests-v2 34 | 35 | # fallback if cache version is outdated 36 | - run: sudo apt-get -qq install libvips 37 | shell: bash 38 | 39 | - run: sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-yes-antialias.conf 40 | shell: bash 41 | -------------------------------------------------------------------------------- /.github/actions/upload-screenshots/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Upload screenshots for debug' 3 | description: 'To reproduce the issue locally, download the screenshots from the failed test' 4 | inputs: 5 | name: 6 | description: 'Customize the name of the artifact' 7 | required: true 8 | runs: 9 | using: 'composite' 10 | steps: 11 | - uses: actions/upload-artifact@v4 12 | with: 13 | name: ${{ inputs.name }}-diffs 14 | retention-days: 1 15 | path: | 16 | test/fixtures/app/doc/screenshots/ 17 | 18 | - uses: actions/upload-artifact@v4 19 | with: 20 | name: ${{ inputs.name }}-capybara-fails 21 | retention-days: 1 22 | path: | 23 | tmp/capybara/screenshots-diffs/ 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: 5 | pull_request: 6 | branches: [ master ] 7 | paths: 8 | - '**.rb' 9 | - '**.yml' 10 | - '.github/workflows/lint.yml' 11 | - '!bin/**' 12 | 13 | env: 14 | RUBY_YJIT_ENABLE: 1 15 | 16 | jobs: 17 | lint: 18 | name: Ruby & YAML 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Ruby 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: '3.3' 29 | bundler-cache: true 30 | 31 | - name: Run Standard Ruby linter 32 | run: bin/standardrb --no-fix --fail-fast 33 | 34 | - name: Run Yaml linter 35 | run: | 36 | sudo apt-get install --fix-missing -qq --no-install-recommends yamllint 37 | yamllint ./ 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | push: 7 | name: Push gem to RubyGems.org 8 | runs-on: ubuntu-latest 9 | 10 | permissions: 11 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 12 | contents: write # IMPORTANT: this permission is required for `rake release` to push the release tag 13 | 14 | steps: 15 | # Set up 16 | - uses: actions/checkout@v4 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true 21 | ruby-version: ruby 22 | 23 | # Release 24 | - uses: rubygems/release-gem@v1 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Test 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | type: [ opened, synchronize, reopened, review_requested ] 10 | paths: 11 | - '**.gemfile' 12 | - '**.rb' 13 | - '.github/workflows/**' 14 | - '!bin/**' 15 | workflow_dispatch: 16 | 17 | env: 18 | BUNDLE_GEMFILE: gemfiles/rails80_gems.rb 19 | DEBIAN_FRONTEND: noninteractive 20 | FERRUM_PROCESS_TIMEOUT: 40 21 | JAVA_OPTS: -Xmn2g -Xms6g -Xmx6g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss1m 22 | -XX:+UseG1GC -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -XX:ReservedCodeCacheSize=256m 23 | -XX:+UseCodeCacheFlushing 24 | JRUBY_OPTS: --dev -J-Djruby.thread.pool.enabled=true 25 | MALLOC_ARENA_MAX: 2 26 | RUBY_GC_HEAP_FREE_SLOTS: 600000 27 | RUBY_GC_HEAP_GROWTH_FACTOR: 1.1 28 | RUBY_YJIT_ENABLE: 1 29 | 30 | concurrency: 31 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 32 | cancel-in-progress: true 33 | 34 | jobs: 35 | # Test that new contributors can run the tests directly after checkout. 36 | test-minimal-setup: 37 | name: Test with minimal setup 38 | runs-on: ubuntu-latest 39 | timeout-minutes: 5 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - uses: ./.github/actions/setup-ruby-and-dependencies 44 | with: 45 | ruby-version: 3.4 46 | 47 | - run: bin/rake test 48 | env: 49 | SCREENSHOT_DRIVER: vips 50 | 51 | functional-test: 52 | name: Functional Test 53 | runs-on: ubuntu-latest 54 | timeout-minutes: 5 55 | 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v4 59 | 60 | - uses: ./.github/actions/setup-ruby-and-dependencies 61 | with: 62 | ruby-version: 3.4 63 | cache-apt-packages: true 64 | 65 | - run: bin/rake test 66 | env: 67 | COVERAGE: enabled 68 | DISABLE_SKIP_TESTS: 1 69 | SCREENSHOT_DRIVER: vips 70 | 71 | - uses: ./.github/actions/upload-screenshots 72 | if: failure() 73 | with: 74 | name: base-screenshots 75 | 76 | - name: Uploading Coverage Report 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: coverage 80 | retention-days: 1 81 | path: coverage 82 | 83 | matrix: 84 | name: Test Ruby & Rails 85 | # Test on master, when a review is requested or manually invoked. 86 | if: > 87 | github.ref == 'refs/heads/master' || 88 | github.event_name == 'workflow_dispatch' || 89 | github.event.pull_request.requested_reviewers.length > 0 90 | needs: [ functional-test ] 91 | runs-on: ubuntu-latest 92 | timeout-minutes: ${{ contains(matrix.ruby-version, 'jruby') && 20 || 8 }} 93 | continue-on-error: ${{ matrix.experimental }} 94 | strategy: 95 | matrix: 96 | ruby-version: [ 3.4, 3.3, 3.2, jruby-9.4, jruby-10.0 ] 97 | gemfile: 98 | - rails70_gems.rb 99 | - rails71_gems.rb 100 | - rails72_gems.rb 101 | - rails80_gems.rb 102 | experimental: [ false ] 103 | exclude: 104 | # We already tested last version 105 | - ruby-version: 3.4 106 | gemfile: rails80_gems.rb 107 | experimental: false 108 | # JRuby 9.x is Ruby 3.1 compatible, and Rails 8 requires Ruby 3.2. 109 | - ruby-version: jruby-9.4 110 | gemfile: rails80_gems.rb 111 | experimental: false 112 | include: 113 | - ruby-version: 3.4 114 | gemfile: edge_gems.rb 115 | experimental: true 116 | - ruby-version: jruby-head 117 | gemfile: rails80_gems.rb 118 | experimental: true 119 | 120 | env: 121 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }} 122 | 123 | steps: 124 | - uses: actions/checkout@v4 125 | 126 | - uses: ./.github/actions/setup-ruby-and-dependencies 127 | with: 128 | ruby-version: ${{ matrix.ruby-version }} 129 | ruby-cache-version: ${{ matrix.ruby-version }}-${{ matrix.gemfile }}-1 130 | cache-apt-packages: true 131 | 132 | - name: Run tests (with 2 retries) 133 | uses: nick-fields/retry@v3 134 | with: 135 | timeout_minutes: ${{ contains(matrix.ruby-version, 'jruby') && 7 || 3 }} 136 | max_attempts: 3 137 | command: bin/rake test 138 | 139 | matrix-screenshot-driver: 140 | name: Test Drivers 141 | if: > 142 | github.ref == 'refs/heads/master' || 143 | github.event.inputs || 144 | github.event_name == 'workflow_dispatch' || 145 | github.event.pull_request.requested_reviewers.length > 0 146 | needs: [ 'functional-test' ] 147 | 148 | strategy: 149 | matrix: 150 | capybara-driver: [ selenium_headless, selenium_chrome_headless, cuprite ] 151 | screenshot-driver: [ vips, chunky_png ] 152 | 153 | runs-on: ubuntu-latest 154 | 155 | timeout-minutes: 5 156 | 157 | steps: 158 | - uses: actions/checkout@v4 159 | 160 | - uses: ./.github/actions/setup-ruby-and-dependencies 161 | with: 162 | ruby-version: 3.4 163 | cache-apt-packages: ${{ matrix.screenshot-driver == 'vips' }} 164 | 165 | - name: Cache Selenium 166 | uses: actions/cache@v4 167 | with: 168 | path: ~/.cache/selenium 169 | key: ${{ runner.os }}-selenium-${{ matrix.capybara-driver }} 170 | 171 | - run: bin/rake test:integration 172 | env: 173 | CAPYBARA_DRIVER: ${{ matrix.capybara-driver }} 174 | SCREENSHOT_DRIVER: ${{ matrix.screenshot-driver }} 175 | 176 | - uses: ./.github/actions/upload-screenshots 177 | if: always() 178 | with: 179 | name: screenshots-${{ matrix.capybara-driver }}-${{ matrix.screenshot-driver }} 180 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /.bundle/ 3 | /.idea 4 | /.windsurf 5 | /.yardoc 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /gemfiles/*.lock 10 | /gems.locked 11 | /pkg/ 12 | /spec/reports/ 13 | /tmp/ 14 | /vendor/sigs/ 15 | 16 | # Ignore report files 17 | *.attempt_*.png 18 | *.diff.png 19 | *.base.png 20 | 21 | *.attempt_*.webp 22 | *.diff.webp 23 | *.base.webp 24 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | fix: true # default: false 3 | parallel: true # default: false 4 | format: progress # default: Standard::Formatter 5 | ruby_version: 3.1 # to support JRuby 9.4 6 | default_ignores: false # default: true 7 | 8 | ignore: # default: [] 9 | - '.*' 10 | - 'bin/**/*' 11 | - 'coverage/**/*' 12 | - 'gemfiles/**/*': 13 | - 'Security/Eval' 14 | - 'gemfiles/vendor/**/*' 15 | - 'sig/**/*' 16 | - 'tmp/**/*' 17 | - 'vendor/**/*' 18 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: relaxed 4 | 5 | rules: 6 | line-length: 7 | max: 120 8 | indentation: 9 | indent-sequences: whatever 10 | 11 | ignore: | 12 | bin 13 | coverage 14 | gemfiles 15 | scripts 16 | sig 17 | test 18 | tmp 19 | vendor 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Bug reports and pull requests are welcome on GitHub at https://github.com/snap-diff/snap_diff-capybara. 5 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected 6 | to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 7 | 8 | ## Testing 9 | 10 | Run the tests before committing using Rake 11 | 12 | rake 13 | 14 | ## Merging to master 15 | 16 | Before merging to `master`, 17 | please have a member of the project review your changes, 18 | and make sure the tests are green. 19 | 20 | ## Releasing 21 | 22 | To release a new version, update the version number in 23 | [lib/capybara/screenshot/diff/version.rb](lib/capybara/screenshot/diff/version.rb), 24 | and then run 25 | 26 | bundle exec rake release 27 | 28 | which will create a git tag for the version, push git commits and tags, and 29 | push the `.gem` file to [rubygems.org](https://rubygems.org). 30 | 31 | Then go to https://github.com/snap-diff/snap_diff-capybara/releases and create 32 | a new release for the new tag. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 3 | # $ docker build . -t csd 4 | # $ docker run -v $(pwd):/app -ti csd rake test 5 | 6 | FROM jetthoughts/cimg-ruby:3.4-chrome 7 | 8 | ENV DEBIAN_FRONTEND=noninteractive \ 9 | BUNDLE_PATH=/bundle 10 | 11 | RUN --mount=type=cache,target=/var/cache/apt \ 12 | sudo sed -i 's|http://security.ubuntu.com/ubuntu|http://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list && \ 13 | sudo apt-get update -qq && \ 14 | sudo apt-get install -qq --fix-missing \ 15 | automake \ 16 | build-essential \ 17 | curl \ 18 | fftw3-dev \ 19 | gettext \ 20 | gobject-introspection \ 21 | gtk-doc-tools \ 22 | libexif-dev \ 23 | libfftw3-dev \ 24 | libgif-dev \ 25 | libglib2.0-dev \ 26 | libgsf-1-dev \ 27 | libgtk2.0-dev \ 28 | libmagickwand-dev \ 29 | libmatio-dev \ 30 | libopenexr-dev \ 31 | libopenslide-dev \ 32 | liborc-0.4-dev \ 33 | libpango1.0-dev \ 34 | libpoppler-glib-dev \ 35 | librsvg2-dev \ 36 | libtiff5-dev \ 37 | libvips-dev \ 38 | libwebp-dev \ 39 | libxml2-dev \ 40 | swig && \ 41 | sudo apt-get autoclean 42 | 43 | RUN sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-antialias.conf 44 | 45 | 46 | RUN sudo mkdir -p /bundle /tmp/.X11-unix && \ 47 | sudo chmod 1777 /bundle /tmp/.X11-unix 48 | 49 | WORKDIR /app 50 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Uwe Kubosch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | task default: :test 7 | 8 | Rake::TestTask.new(:test) do |t| 9 | t.libs << "test" 10 | t.libs << "lib" 11 | t.test_files = FileList["test/**/*_test.rb"] 12 | end 13 | 14 | Rake::TestTask.new("test:unit") do |t| 15 | t.libs << "test" 16 | t.libs << "lib" 17 | t.test_files = FileList["test/unit/**/*_test.rb"] 18 | end 19 | 20 | Rake::TestTask.new("test:integration") do |t| 21 | t.libs << "test" 22 | t.libs << "lib" 23 | t.test_files = FileList["test/integration/**/*_test.rb"] 24 | end 25 | 26 | desc "Run all tests with coverage" 27 | task :coverage do 28 | ENV["COVERAGE"] = "true" 29 | Rake::Task["test"].invoke 30 | end 31 | 32 | task "clobber" do 33 | puts "Cleanup tmp/*.png" 34 | FileUtils.rm_rf(Dir["./tmp/*"]) 35 | end 36 | 37 | task "test:benchmark" do 38 | require_relative "scripts/benchmark/find_region_benchmark" 39 | benchmark = Capybara::Screenshot::Diff::Drivers::FindRegionBenchmark.new 40 | 41 | puts "For Medium Screen Size: 800x600" 42 | benchmark.for_medium_size_screens 43 | 44 | puts "" 45 | puts "*" * 100 46 | 47 | puts "For Small Screen Size: 80x60" 48 | benchmark.for_small_images 49 | end 50 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../gems.rb", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "capybara/screenshot/diff" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start 16 | -------------------------------------------------------------------------------- /bin/dtest: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o pipefail 4 | 5 | export DOCKER_DEFAULT_PLATFORM=linux/amd64 6 | 7 | # Define allowed environment variables to pass to Docker 8 | ALLOWED_ENV_VARS=( 9 | "CI" "DEBUG" "TEST_ENV" "RAILS_ENV" "RACK_ENV" "COVERAGE" "DISABLE_ROLLBACK_COMPARISON_RUNTIME_FILES" 10 | "RECORD_SCREENSHOTS" "TEST" "TESTOPTS" "SCREENSHOT_DRIVER" 11 | ) 12 | 13 | # Build the Docker env args string 14 | DOCKER_ENV_ARGS="" 15 | for var in "${ALLOWED_ENV_VARS[@]}"; do 16 | if [[ -n "${!var}" ]]; then 17 | DOCKER_ENV_ARGS="$DOCKER_ENV_ARGS -e $var=${!var}" 18 | fi 19 | done 20 | 21 | # Build the Docker image 22 | docker build . -t csd:test 23 | 24 | # Run setup 25 | (docker run $DOCKER_ENV_ARGS -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test bin/setup) || exit 1 26 | 27 | # Run tests with different drivers 28 | echo "Running tests..." 29 | DRIVERS=("cuprite" "selenium_chrome_headless" "selenium_headless") 30 | for driver in "${DRIVERS[@]}"; do 31 | echo "Running tests with $driver driver..." 32 | docker run $DOCKER_ENV_ARGS -e CAPYBARA_DRIVER="$driver" \ 33 | -v ${PWD}:/app -v csd-bundle-cache:/bundle --rm -it csd:test \ 34 | bin/rake test "$@" 35 | 36 | CAPYBARA_DRIVER="$driver" bin/rake test "$@" 37 | done 38 | -------------------------------------------------------------------------------- /bin/install-vips: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | version=${VIPS_VERSION:-8.15.2} 6 | 7 | wget "https://github.com/libvips/libvips/releases/download/v$version/vips-$version.tar.gz" 8 | tar xf "vips-$version.tar.gz" 9 | cd "vips-$version" 10 | CXXFLAGS=-D_GLIBCXX_USE_CXX11_ABI=0 ./configure --enable-debug=no --without-python "$*" 11 | make && make install && ldconfig 12 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../gems.rb", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle update 7 | bundle update --bundler --ruby 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /bin/standardrb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'standardrb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../gems.rb", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if /This file was generated by Bundler/.match?(File.read(bundle_binstub, 300)) 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("standard", "standardrb") 30 | -------------------------------------------------------------------------------- /capybara-screenshot-diff.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "capybara/screenshot/diff/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "capybara-screenshot-diff" 9 | spec.version = Capybara::Screenshot::Diff::VERSION 10 | spec.authors = ["Uwe Kubosch"] 11 | spec.email = ["uwe@kubosch.no"] 12 | spec.summary = "Track your GUI changes with diff assertions" 13 | spec.description = "Save screen shots and track changes with graphical diff" 14 | spec.homepage = "https://github.com/donv/capybara-screenshot-diff" 15 | spec.required_ruby_version = ">= 3.1" 16 | spec.license = "MIT" 17 | spec.metadata["allowed_push_host"] = "https://rubygems.org/" 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{(^(\.|tmp|bin|test|spec|features|gemfiles|scripts|foo)/)|(^(\.|Dockerfile|CONTRIBUTING|README))}) 20 | end 21 | 22 | spec.bindir = "exe" 23 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 24 | spec.require_paths = ["lib"] 25 | 26 | spec.add_development_dependency "actionpack", ">= 7.0", "< 9" 27 | spec.add_development_dependency "activesupport", ">= 7.0", "< 9" 28 | spec.add_runtime_dependency "capybara", ">= 2", "< 4" 29 | end 30 | -------------------------------------------------------------------------------- /gemfiles/edge_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gems = "#{File.dirname __dir__}/gems.rb" 4 | eval File.read(gems), binding, gems 5 | 6 | git "https://github.com/rails/rails.git" do 7 | gem "activesupport" 8 | gem "actionpack" 9 | end 10 | -------------------------------------------------------------------------------- /gemfiles/rails70_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gems = "#{File.dirname __dir__}/gems.rb" 4 | eval File.read(gems), binding, gems 5 | 6 | gem "actionpack", "~> 7.0.0" 7 | gem "activesupport", "~> 7.0.0", require: %w[active_support/deprecator active_support/test_case] 8 | gem "mutex_m" 9 | gem "drb" 10 | gem "bigdecimal" 11 | -------------------------------------------------------------------------------- /gemfiles/rails71_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gems = "#{File.dirname __dir__}/gems.rb" 4 | eval File.read(gems), binding, gems 5 | 6 | gem "activesupport", "~> 7.1.0", require: %w[logger active_support/deprecator active_support] 7 | gem "actionpack", "~> 7.1.0", require: %w[action_controller action_dispatch] 8 | -------------------------------------------------------------------------------- /gemfiles/rails72_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gems = "#{File.dirname __dir__}/gems.rb" 4 | eval File.read(gems), binding, gems 5 | 6 | gem "actionpack", "~> 7.2.0" 7 | -------------------------------------------------------------------------------- /gemfiles/rails80_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gems = "#{File.dirname __dir__}/gems.rb" 4 | eval File.read(gems), binding, gems 5 | 6 | gem "activesupport", "~> 8.0.0" 7 | gem "actionpack", "~> 8.0.0" 8 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in capybara-screenshot-diff.gemspec 6 | gemspec path: __dir__ 7 | 8 | gem "rake" 9 | 10 | # Image processing libraries 11 | gem "chunky_png", ">= 1.3", require: false 12 | gem "oily_png", platform: :ruby, git: "https://github.com/wvanbergen/oily_png", ref: "44042006e79efd42ce4b52c1d78a4c70f0b4b1b2" 13 | gem "ruby-vips", require: false 14 | 15 | group :test do 16 | gem "capybara", ">= 3.26" 17 | gem "mutex_m" # Needed for RubyMine debugging. Try removing it. 18 | gem "minitest", require: false 19 | gem "minitest-stub-const", require: false 20 | gem "simplecov", require: false 21 | gem "rspec", require: false 22 | end 23 | 24 | # Capybara Server 25 | gem "puma", require: false 26 | gem "rackup", require: false 27 | 28 | # Capybara Drivers 29 | gem "cuprite", require: false 30 | gem "selenium-webdriver", ">= 4.11", require: false 31 | 32 | # Test Frameworks 33 | # gem "cucumber", require: false 34 | # gem "cucumber-rails", require: false 35 | 36 | group :tools do 37 | gem "standard", require: false 38 | end 39 | -------------------------------------------------------------------------------- /lib/capybara-screenshot-diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/minitest" 4 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/minitest" 4 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/area_calculator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | class AreaCalculator 7 | def initialize(crop_coordinates, skip_area) 8 | @crop_coordinates = crop_coordinates 9 | @skip_area = skip_area 10 | end 11 | 12 | def calculate_crop 13 | return @_calculated_crop if defined?(@_calculated_crop) 14 | return @_calculated_crop = nil unless @crop_coordinates 15 | 16 | # TODO: Move out from this class, this should be done on before screenshot and should not depend on Browser 17 | @crop_coordinates = BrowserHelpers.bounds_for_css(@crop_coordinates).first if @crop_coordinates.is_a?(String) 18 | @_calculated_crop = Region.from_edge_coordinates(*@crop_coordinates) 19 | end 20 | 21 | # Cast skip areas params into Region 22 | # and if there is crop then makes absolute coordinates to eb relative to crop top left corner 23 | def calculate_skip_area 24 | return nil unless @skip_area 25 | 26 | crop_region = calculate_crop 27 | skip_area = Array(@skip_area) 28 | 29 | css_selectors, coords_list = skip_area.compact.partition { |region| region.is_a? String } 30 | regions, coords_list = coords_list.partition { |region| region.is_a? Region } 31 | 32 | regions.concat(build_regions_for(BrowserHelpers.bounds_for_css(*css_selectors))) unless css_selectors.empty? 33 | regions.concat(build_regions_for(coords_list.flatten.each_slice(4))) unless coords_list.empty? 34 | 35 | regions.compact! 36 | 37 | if crop_region 38 | regions 39 | .map! { |region| crop_region.find_relative_intersect(region) } 40 | .filter! { |region| region&.present? } 41 | end 42 | 43 | regions 44 | end 45 | 46 | private 47 | 48 | def build_regions_for(coordinates) 49 | coordinates 50 | .map { |entry| Region.from_edge_coordinates(*entry) } 51 | .tap { |it| it.compact! } 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/browser_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "region" 4 | 5 | module Capybara 6 | module Screenshot 7 | module BrowserHelpers 8 | def self.resize_window_if_needed 9 | if ::Capybara::Screenshot.respond_to?(:window_size) && ::Capybara::Screenshot.window_size 10 | resize_to(::Capybara::Screenshot.window_size) 11 | end 12 | end 13 | 14 | def self.resize_to(window_size) 15 | if session.driver.respond_to?(:resize) 16 | session.driver.resize(*window_size) 17 | elsif BrowserHelpers.selenium? 18 | session.driver.browser.manage.window.resize_to(*window_size) 19 | end 20 | end 21 | 22 | def self.selenium? 23 | current_capybara_driver_class <= Capybara::Selenium::Driver 24 | end 25 | 26 | def self.window_size_is_wrong?(expected_window_size = nil) 27 | selenium? && expected_window_size && 28 | session.driver.browser.manage.window.size != ::Selenium::WebDriver::Dimension.new(*expected_window_size) 29 | end 30 | 31 | def self.bounds_for_css(*css_selectors) 32 | css_selectors.reduce([]) do |regions, selector| 33 | regions.concat(all_visible_regions_for(selector)) 34 | end 35 | end 36 | 37 | IMAGE_WAIT_SCRIPT = <<~JS 38 | function pending_image() { 39 | const images = document.images 40 | for (var i = 0; i < images.length; i++) { 41 | if (!images[i].complete && images[i].loading !== "lazy") { 42 | return images[i].src 43 | } 44 | } 45 | return false 46 | }(window) 47 | JS 48 | 49 | HIDE_CARET_SCRIPT = <<~JS 50 | if (!document.getElementById('csdHideCaretStyle')) { 51 | let style = document.createElement('style'); 52 | style.setAttribute('id', 'csdHideCaretStyle'); 53 | document.head.appendChild(style); 54 | let styleSheet = style.sheet; 55 | styleSheet.insertRule("* { caret-color: transparent !important; }", 0); 56 | } 57 | JS 58 | 59 | def self.hide_caret 60 | session.execute_script(HIDE_CARET_SCRIPT) 61 | end 62 | 63 | FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS 64 | function activeElement(){ 65 | const ae = document.activeElement; 66 | if (ae.nodeName === "INPUT" || ae.nodeName === "TEXTAREA") { 67 | ae.blur(); 68 | return ae; 69 | } 70 | return null; 71 | }(window); 72 | JS 73 | 74 | def self.blur_from_focused_element 75 | session.evaluate_script(FIND_ACTIVE_ELEMENT_SCRIPT) 76 | end 77 | 78 | GET_BOUNDING_CLIENT_RECT_SCRIPT = <<~JS 79 | [ 80 | this.getBoundingClientRect().left, 81 | this.getBoundingClientRect().top, 82 | this.getBoundingClientRect().right, 83 | this.getBoundingClientRect().bottom 84 | ] 85 | JS 86 | 87 | def self.all_visible_regions_for(selector) 88 | BrowserHelpers.session.all(selector, visible: true).map(&method(:region_for)) 89 | end 90 | 91 | def self.region_for(element) 92 | element.evaluate_script(GET_BOUNDING_CLIENT_RECT_SCRIPT).map { |point| point.negative? ? 0 : point.ceil.to_i } 93 | end 94 | 95 | def self.session 96 | Capybara.current_session 97 | end 98 | 99 | def self.pending_image_to_load 100 | BrowserHelpers.session.evaluate_script(IMAGE_WAIT_SCRIPT) 101 | end 102 | 103 | def self.current_capybara_driver_class 104 | session.driver.class 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/capture_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | # Abstract base class for all screenshot‐capture strategies. 7 | # A concrete strategy receives the raw capture/comparison option hashes, 8 | # leaving them intact for now (we will introduce typed option objects in a 9 | # later phase). It must implement `#take_comparison_screenshot` accepting 10 | # a Snap. 11 | class CaptureStrategy 12 | def initialize(capture_options, comparison_options) 13 | @capture_options = capture_options 14 | @comparison_options = comparison_options 15 | end 16 | 17 | # @param snapshot [CapybaraScreenshotDiff::Snap] 18 | # @return [void] 19 | def take_comparison_screenshot(_snapshot) 20 | raise NotImplementedError, "subclass responsibility" 21 | end 22 | 23 | private 24 | 25 | attr_reader :capture_options, :comparison_options 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/comparison.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara::Screenshot::Diff 4 | class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path) 5 | def skip_area 6 | options[:skip_area] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/comparison_loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | # Loads and preprocesses images for comparison 7 | # 8 | # This class is responsible for loading images and creating a Comparison object. 9 | # It coordinates with the ImagePreprocessor to apply any necessary filters 10 | # before creating the comparison. This follows the Single Responsibility Principle 11 | # by focusing solely on loading and assembling the comparison. 12 | class ComparisonLoader 13 | def initialize(driver) 14 | @driver = driver 15 | end 16 | 17 | # Load images and create a comparison object 18 | # @param [String] base_path the path to the base image 19 | # @param [String] new_path the path to the new image 20 | # @param [Hash] options options for the comparison 21 | # @return [Comparison] the comparison object 22 | def call(base_path, new_path, options = {}) 23 | # Load the raw images 24 | base_img, new_img = @driver.load_images(base_path, new_path) 25 | 26 | # Create a preliminary comparison with raw images 27 | # This is used for enhanced preprocessing that needs context 28 | Comparison.new(new_img, base_img, options, @driver, new_path, base_path) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/cucumber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/cucumber" 4 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/difference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | # Represents a difference between two images 9 | # 10 | # This value object encapsulates the result of an image comparison operation. 11 | # It follows the Single Responsibility Principle by focusing solely on representing 12 | # the difference state, including: 13 | # - Whether images are different or equal 14 | # - Why they differ (dimensions, pixels, etc.) 15 | # - The specific region of difference 16 | # - Whether differences are tolerable based on configured thresholds 17 | # 18 | # As part of the layered comparison architecture, this class represents the final 19 | # output of the comparison process, containing all data needed for reporting. 20 | # Represents a difference between two images 21 | class Difference < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil) 22 | def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil) 23 | Difference.new( 24 | nil, 25 | {difference_level: nil, max_color_distance: 0}, 26 | comparison, 27 | failed_by, 28 | base_image_path, 29 | new_image_path 30 | ).freeze 31 | end 32 | 33 | def different? 34 | failed? || !(blank? || tolerable?) 35 | end 36 | 37 | def equal? 38 | !different? 39 | end 40 | 41 | def failed? 42 | !!failed_by 43 | end 44 | 45 | def options 46 | comparison.options 47 | end 48 | 49 | def tolerance 50 | options[:tolerance] 51 | end 52 | 53 | def skip_area 54 | options[:skip_area] 55 | end 56 | 57 | def area_size_limit 58 | options[:area_size_limit] 59 | end 60 | 61 | def blank? 62 | region.nil? || region_area_size.zero? 63 | end 64 | 65 | def region_area_size 66 | region&.size || 0 67 | end 68 | 69 | def ratio 70 | meta[:difference_level] 71 | end 72 | 73 | def to_h 74 | {area_size: region_area_size, region: coordinates}.merge!(meta) 75 | end 76 | 77 | def coordinates 78 | region&.to_edge_coordinates 79 | end 80 | 81 | def inspect 82 | to_h.to_json 83 | end 84 | 85 | def tolerable? 86 | !!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio)) 87 | end 88 | 89 | # Path accessors for backward compatibility 90 | def new_image_path 91 | image_path || comparison&.new_image_path 92 | end 93 | 94 | def original_image_path 95 | base_image_path || comparison&.base_image_path 96 | end 97 | 98 | def diff_mask 99 | meta[:diff_mask] 100 | end 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/difference_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/screenshot/diff/comparison" 4 | require "capybara/screenshot/diff/difference" 5 | 6 | module Capybara 7 | module Screenshot 8 | module Diff 9 | # Analyzes image differences with configurable tolerance levels. 10 | # 11 | # This class implements the core comparison logic for detecting visual differences 12 | # between images while accounting for various tolerances and optimizations. 13 | # 14 | # The comparison process follows these steps: 15 | # 1. Dimension Check (Fastest) 16 | # - Compares image dimensions first for quick rejection 17 | # - Different dimensions always indicate a difference 18 | # 19 | # 2. Pixel Equality Check (Fast) 20 | # - Performs bitwise comparison if dimensions match 21 | # - Returns immediately if images are exactly identical 22 | # 23 | # 3. Tolerant Comparison (Slower) 24 | # - Only runs if quick checks don't determine equality 25 | # - Respects configured tolerances for color and shift differences 26 | # - Can ignore specific regions (skip_area) 27 | # - Considers anti-aliasing and sub-pixel rendering differences 28 | # 29 | # The class is designed to be stateless and thread-safe, with all configuration 30 | # passed in through the constructor. 31 | class DifferenceFinder 32 | TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze 33 | 34 | attr_reader :driver, :options 35 | 36 | # Creates a new DifferenceFinder instance. 37 | # 38 | # @param driver [Drivers::Base] The image processing driver to use. 39 | # Must implement the driver interface expected by DifferenceFinder. 40 | # @param options [Hash] Configuration options for the comparison: 41 | # @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0). 42 | # @option options [Numeric] :color_distance_limit Maximum allowed color distance. 43 | # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance. 44 | # @option options [Numeric] :area_size_limit Maximum allowed difference area size. 45 | # @option options [Array] :skip_area Regions to exclude from comparison. 46 | def initialize(driver, options) 47 | @driver = driver 48 | @options = options 49 | end 50 | 51 | # Analyzes the comparison and determines if images are different. 52 | # 53 | # @param comparison [Comparison] The comparison object containing images to analyze. 54 | # @param quick_mode [Boolean] When true, performs minimal checks and returns early. 55 | # In quick mode, returns [is_equal, difference] where: 56 | # - is_equal is true if images are considered equal 57 | # - difference is a Difference object or nil 58 | # When false, returns a Difference object directly. 59 | # @return [Array, Difference] Result format depends on quick_mode parameter. 60 | # @raise [ArgumentError] If the comparison object is invalid. 61 | def call(comparison, quick_mode: true) 62 | # Process the comparison and return result 63 | 64 | # Handle dimension differences 65 | unless driver.same_dimension?(comparison) 66 | result = build_null_difference(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true}) 67 | return quick_mode ? [false, result] : result 68 | end 69 | 70 | # Handle identical pixels 71 | if driver.same_pixels?(comparison) 72 | result = build_null_difference(comparison, comparison.base_image_path, comparison.new_image_path) 73 | return quick_mode ? [true, result] : result 74 | end 75 | 76 | # Handle early return for non-tolerable options 77 | if quick_mode && without_tolerable_options? 78 | return [false, nil] 79 | end 80 | 81 | # Process difference region 82 | region = driver.find_difference_region(comparison) 83 | 84 | # Only create a proper difference object if we've completed the comparison 85 | quick_mode ? [!region.different?, region] : region 86 | end 87 | 88 | private 89 | 90 | def without_tolerable_options? 91 | (options.keys & TOLERABLE_OPTIONS).empty? 92 | end 93 | 94 | # Build a no-difference result that represents identical images 95 | def build_null_difference(comparison, base_path, new_path, failed_by = nil) 96 | Difference.build_null(comparison, base_path, new_path, failed_by) 97 | end 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/drivers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | module Drivers 7 | def self.for(driver_options = {}) 8 | driver_option = driver_options.is_a?(Hash) ? driver_options.fetch(:driver, :chunky_png) : driver_options 9 | return driver_option unless driver_option.is_a?(Symbol) 10 | 11 | Utils.find_driver_class_for(driver_option).new 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/drivers/base_driver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/screenshot/diff/difference" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | # Compare two images and determine if they are equal, different, or within some comparison 9 | # range considering color values and difference area size. 10 | module Drivers 11 | class BaseDriver 12 | PNG_EXTENSION = ".png" 13 | 14 | def same_dimension?(comparison) 15 | dimension(comparison.base_image) == dimension(comparison.new_image) 16 | end 17 | 18 | def height_for(image) 19 | image.height 20 | end 21 | 22 | def width_for(image) 23 | image.width 24 | end 25 | 26 | def image_area_size(image) 27 | width_for(image) * height_for(image) 28 | end 29 | 30 | def dimension(image) 31 | [width_for(image), height_for(image)] 32 | end 33 | 34 | def supports?(feature) 35 | respond_to?(feature) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/image_preprocessor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | # Handles image preprocessing operations (skip_area and median filtering) 7 | # 8 | # This class applies preprocessing filters to images before comparison, 9 | # such as masking specific regions (skip_area) or applying noise reduction. 10 | # It's designed to work with either direct image objects or with options. 11 | class ImagePreprocessor 12 | attr_reader :driver, :options 13 | 14 | def initialize(driver, options = {}) 15 | @driver = driver 16 | @options = options 17 | end 18 | 19 | # Process a comparison object directly 20 | # This allows reusing the comparison's existing options 21 | # @param [Comparison] comparison the comparison object 22 | # @return [Comparison] the comparison object 23 | def process_comparison(comparison) 24 | # Process both images 25 | comparison.base_image = process_image(comparison.base_image, comparison.base_image_path) 26 | comparison.new_image = process_image(comparison.new_image, comparison.new_image_path) 27 | 28 | comparison 29 | end 30 | 31 | def call(images) 32 | images.map { |image| process_image(image, nil) } 33 | end 34 | 35 | private 36 | 37 | def process_image(image, path) 38 | result = image 39 | result = apply_skip_area(result) if skip_area 40 | result = apply_median_filter(result, path) if median_filter_window_size 41 | result 42 | end 43 | 44 | def apply_skip_area(image) 45 | skip_area.reduce(image) do |result, region| 46 | driver.add_black_box(result, region) 47 | end 48 | end 49 | 50 | def apply_median_filter(image, path) 51 | if driver.supports?(:filter_image_with_median) 52 | driver.filter_image_with_median(image, median_filter_window_size) 53 | else 54 | warn_about_skipped_median_filter(path) 55 | image 56 | end 57 | end 58 | 59 | def warn_about_skipped_median_filter(path) 60 | warn( 61 | "[capybara-screenshot-diff] Median filter has been skipped for #{path} " \ 62 | "because it is not supported by #{driver.class}" 63 | ) 64 | end 65 | 66 | def skip_area 67 | options[:skip_area] 68 | end 69 | 70 | def median_filter_window_size 71 | options[:median_filter_window_size] 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/os.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Os 6 | ON_WINDOWS = !!(RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/) 7 | ON_MAC = !!(RbConfig::CONFIG["host_os"] =~ /darwin/) 8 | ON_LINUX = !!(RbConfig::CONFIG["host_os"] =~ /linux/) 9 | 10 | def self.name 11 | return "windows" if ON_WINDOWS 12 | return "macos" if ON_MAC 13 | return "linux" if ON_LINUX 14 | 15 | "unknown" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/region.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Region 4 | attr_accessor :x, :y, :width, :height 5 | 6 | def initialize(x, y, width, height) 7 | @x, @y, @width, @height = x, y, width, height 8 | end 9 | 10 | def self.from_edge_coordinates(left, top, right, bottom) 11 | return nil unless left && top && right && bottom 12 | return nil if right < left || bottom < top 13 | 14 | Region.new(left, top, right - left, bottom - top) 15 | end 16 | 17 | def to_edge_coordinates 18 | [left, top, right, bottom] 19 | end 20 | 21 | def to_top_left_corner_coordinates 22 | [x, y, width, height] 23 | end 24 | 25 | def top 26 | y 27 | end 28 | 29 | def bottom 30 | y + height 31 | end 32 | 33 | def left 34 | x 35 | end 36 | 37 | def right 38 | x + width 39 | end 40 | 41 | def size 42 | return 0 if width < 0 || height < 0 43 | 44 | result = width * height 45 | result.zero? ? 1 : result 46 | end 47 | 48 | def to_a 49 | [@x, @y, @width, @height] 50 | end 51 | 52 | def find_intersect_with(region) 53 | return nil unless intersect?(region) 54 | 55 | new_left = [x, region.x].max 56 | new_top = [y, region.y].max 57 | 58 | Region.new(new_left, new_top, [right, region.right].min - new_left, [bottom, region.bottom].min - new_top) 59 | end 60 | 61 | def intersect?(region) 62 | left <= region.right && right >= region.left && top <= region.bottom && bottom >= region.top 63 | end 64 | 65 | def move_by(right_by, down_by) 66 | Region.new(x + right_by, y + down_by, width, height) 67 | end 68 | 69 | def find_relative_intersect(region) 70 | intersect = find_intersect_with(region) 71 | return nil unless intersect 72 | 73 | intersect.move_by(-x, -y) 74 | end 75 | 76 | def cover?(x, y) 77 | x.between?(left, right) && y.between?(top, bottom) 78 | end 79 | 80 | def empty? 81 | width.zero? || height.zero? 82 | end 83 | 84 | def blank? 85 | empty? 86 | end 87 | 88 | def present? 89 | !empty? 90 | end 91 | 92 | def inspect 93 | "Region(x: #{x}, y: #{y}, width: #{width}, height: #{height})" 94 | end 95 | 96 | # need to add this method to make it work with assert_equal 97 | def ==(other) 98 | case other 99 | when Region 100 | x == other.x && y == other.y && width == other.width && height == other.height 101 | when Array 102 | to_a == other 103 | else 104 | false 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/reporters/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara::Screenshot::Diff 4 | module Reporters 5 | class Default 6 | attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path, :difference 7 | 8 | def initialize(difference) 9 | @difference = difference 10 | 11 | screenshot_format = difference.comparison.options[:screenshot_format] || comparison.new_image_path.extname.slice(1..-1) 12 | @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}") 13 | @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}") 14 | @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}") 15 | end 16 | 17 | def generate 18 | if difference.equal? 19 | # NOTE: Delete previous run runtime files 20 | clean_tmp_files 21 | return nil 22 | end 23 | 24 | if difference.failed? && difference.failed_by[:different_dimensions] 25 | return build_error_for_different_dimensions 26 | end 27 | 28 | annotate_and_save_images 29 | build_error_message 30 | end 31 | 32 | def clean_tmp_files 33 | annotated_base_image_path.unlink if annotated_base_image_path.exist? 34 | annotated_image_path.unlink if annotated_image_path.exist? 35 | end 36 | 37 | def build_error_for_different_dimensions 38 | change_msg = [comparison.base_image, comparison.new_image] 39 | .map { |image| driver.dimension(image).join("x") } 40 | .join(" => ") 41 | 42 | "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}" 43 | end 44 | 45 | def annotate_and_save_images 46 | save_annotation_for(new_image, annotated_image_path) 47 | save_annotation_for(base_image, annotated_base_image_path) 48 | save_heatmap_diff if difference.diff_mask 49 | end 50 | 51 | def save_annotation_for(image, image_path) 52 | image = annotate_difference(image, difference.region) 53 | image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area 54 | 55 | save(image, image_path.to_path) 56 | end 57 | 58 | DIFF_COLOR = [255, 0, 0, 255].freeze 59 | 60 | def annotate_difference(image, region) 61 | driver.draw_rectangles([image], region, DIFF_COLOR, offset: 1).first 62 | end 63 | 64 | SKIP_COLOR = [255, 192, 0, 255].freeze 65 | 66 | def annotate_skip_areas(image, skip_areas) 67 | skip_areas.reduce(image) do |memo, region| 68 | driver.draw_rectangles([memo], region, SKIP_COLOR).first 69 | end 70 | end 71 | 72 | def save(image, image_path) 73 | driver.save_image_to(image, image_path.to_s) 74 | end 75 | 76 | NEW_LINE = "\n" 77 | 78 | def build_error_message 79 | [ 80 | "(#{difference.inspect})", 81 | image_path.to_path, 82 | annotated_base_image_path.to_path, 83 | annotated_image_path.to_path, 84 | heatmap_diff_path.to_path 85 | ].join(NEW_LINE) 86 | end 87 | 88 | private 89 | 90 | def save_heatmap_diff 91 | merged_image = driver.merge(new_image, base_image) 92 | highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: DIFF_COLOR) 93 | 94 | save(highlighted_mask, heatmap_diff_path.to_path) 95 | end 96 | 97 | def base_image 98 | difference.comparison.base_image 99 | end 100 | 101 | def new_image 102 | difference.comparison.new_image 103 | end 104 | 105 | def base_image_path 106 | comparison.base_image_path 107 | end 108 | 109 | def image_path 110 | comparison.new_image_path 111 | end 112 | 113 | def driver 114 | @_driver ||= comparison.driver 115 | end 116 | 117 | def comparison 118 | @_comparison ||= difference.comparison 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/screenshot_coordinator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "standard_capture_strategy" 4 | require_relative "stable_capture_strategy" 5 | 6 | module Capybara 7 | module Screenshot 8 | module Diff 9 | # Orchestrates the selection of a capture strategy based on capture and 10 | # comparison options. This replaces the previous ScreenshotTaker factory. 11 | module ScreenshotCoordinator 12 | module_function 13 | 14 | # Unified public API to obtain a comparison screenshot. 15 | # 16 | # Usage (internal): 17 | # ScreenshotCoordinator.capture(full_name, capture_options, comparison_options) 18 | # 19 | # @param snap_or_name [CapybaraScreenshotDiff::Snap, String] 20 | # @param capture_options [Hash] 21 | # @param comparison_options [Hash] 22 | # @return [CapybaraScreenshotDiff::Snap] 23 | def capture(snap_or_name, capture_options, comparison_options) 24 | snap = ensure_snap(snap_or_name, capture_options) 25 | strategy(capture_options, comparison_options).take_comparison_screenshot(snap) 26 | snap 27 | end 28 | 29 | # ------------------------------------------------------------------ 30 | def strategy(capture_options, comparison_options) 31 | strategy_klass = capture_options[:stability_time_limit] ? StableCaptureStrategy : StandardCaptureStrategy 32 | strategy_klass.new(capture_options, comparison_options) 33 | end 34 | 35 | private_class_method :strategy 36 | 37 | def ensure_snap(snap_or_name, capture_options) 38 | return snap_or_name if snap_or_name.is_a?(CapybaraScreenshotDiff::Snap) 39 | 40 | CapybaraScreenshotDiff::SnapManager.snapshot( 41 | snap_or_name, 42 | capture_options[:screenshot_format] || "png" 43 | ) 44 | end 45 | 46 | private_class_method :ensure_snap 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/screenshot_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/snap_manager" 4 | require_relative "screenshoter" 5 | require_relative "stable_screenshoter" 6 | require_relative "browser_helpers" 7 | require_relative "vcs" 8 | require_relative "area_calculator" 9 | require_relative "screenshot_coordinator" 10 | 11 | module Capybara 12 | module Screenshot 13 | module Diff 14 | class ScreenshotMatcher 15 | attr_reader :screenshot_full_name, :driver_options, :screenshot_format 16 | 17 | def initialize(screenshot_full_name, options = {}) 18 | @screenshot_full_name = screenshot_full_name 19 | @driver_options = Diff.default_options.merge(options) 20 | 21 | @screenshot_format = @driver_options[:screenshot_format] 22 | @snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format) 23 | end 24 | 25 | def build_screenshot_assertion(skip_stack_frames: 0) 26 | check_window_size! 27 | prepare_screenshot_options 28 | check_base_screenshot 29 | 30 | capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options) 31 | 32 | capture_screenshot(capture_options, comparison_options) 33 | 34 | # Pre-computation: No need to compare without base screenshot 35 | # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result 36 | return unless need_to_compare? 37 | 38 | create_screenshot_assertion(skip_stack_frames + 1, comparison_options) 39 | end 40 | 41 | private 42 | 43 | def need_to_compare? 44 | @snapshot.base_path.exist? 45 | end 46 | 47 | def check_window_size! 48 | if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size) 49 | current_size = BrowserHelpers.selenium? ? 50 | BrowserHelpers.session.driver.browser.manage.window.size.to_s : 51 | "unknown" 52 | 53 | raise CapybaraScreenshotDiff::WindowSizeMismatchError.new(<<~ERROR.chomp, caller) 54 | Window size mismatch detected! 55 | Expected: #{Screenshot.window_size.inspect} 56 | Actual: #{current_size} 57 | 58 | Screenshots cannot be compared when window sizes don't match. 59 | Please ensure the browser window is properly sized before taking screenshots. 60 | ERROR 61 | end 62 | end 63 | 64 | def prepare_screenshot_options 65 | area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area]) 66 | 67 | driver_options[:crop] = area_calculator.calculate_crop 68 | driver_options[:skip_area] = area_calculator.calculate_skip_area 69 | driver_options[:driver] = Drivers.for(driver_options[:driver]) 70 | end 71 | 72 | def check_base_screenshot 73 | @snapshot.checkout_base_screenshot 74 | 75 | if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist? 76 | raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller) 77 | No existing screenshot found for #{@snapshot.base_path}! 78 | To stop seeing this error disable by `Capybara::Screenshot::Diff.fail_if_new=false` 79 | ERROR 80 | end 81 | end 82 | 83 | def capture_screenshot(capture_options, comparison_options) 84 | Capybara::Screenshot::Diff::ScreenshotCoordinator.capture(@snapshot, capture_options, comparison_options) 85 | end 86 | 87 | def create_screenshot_assertion(skip_stack_frames, comparison_options) 88 | CapybaraScreenshotDiff::ScreenshotAssertion.from([ 89 | caller(skip_stack_frames + 1), 90 | screenshot_full_name, 91 | ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options) 92 | ]) 93 | end 94 | 95 | def extract_capture_and_comparison_options!(driver_options = {}) 96 | [ 97 | { 98 | # screenshot options 99 | capybara_screenshot_options: driver_options[:capybara_screenshot_options], 100 | crop: driver_options.delete(:crop), 101 | # delivery options 102 | screenshot_format: driver_options[:screenshot_format], 103 | # stability options 104 | stability_time_limit: driver_options.delete(:stability_time_limit), 105 | wait: driver_options.delete(:wait) 106 | }, 107 | driver_options 108 | ] 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/screenshot_namer_dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/screenshot_namer" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | # Provides methods for managing screenshot naming conventions 9 | # with support for grouping and sectioning for better organization. 10 | module ScreenshotNamerDSL 11 | # Sets the current section name for screenshots 12 | # @param name [String] Section name 13 | # @return [void] 14 | def screenshot_section(name) 15 | screenshot_namer.section = name 16 | end 17 | 18 | # Sets the current group name for screenshots 19 | # @param name [String] Group name 20 | # @return [void] 21 | def screenshot_group(name) 22 | screenshot_namer.group = name 23 | end 24 | 25 | private 26 | 27 | # Access the current screenshot namer instance 28 | # @return [CapybaraScreenshotDiff::ScreenshotNamer] 29 | def screenshot_namer 30 | CapybaraScreenshotDiff.screenshot_namer 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/screenshoter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "os" 4 | require_relative "browser_helpers" 5 | 6 | module Capybara 7 | module Screenshot 8 | class Screenshoter 9 | attr_reader :capture_options, :driver 10 | 11 | def initialize(capture_options, driver) 12 | @capture_options = capture_options 13 | @driver = driver 14 | end 15 | 16 | def crop 17 | @capture_options[:crop] 18 | end 19 | 20 | def wait 21 | @capture_options[:wait] 22 | end 23 | 24 | def capybara_screenshot_options 25 | @capture_options[:capybara_screenshot_options] || {} 26 | end 27 | 28 | # Try to get screenshot from browser. 29 | # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts 30 | # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug 31 | def take_comparison_screenshot(snapshot) 32 | capture_screenshot_at(snapshot) 33 | snapshot.cleanup_attempts 34 | end 35 | 36 | PNG_EXTENSION = ".png" 37 | 38 | def take_screenshot(screenshot_path) 39 | blurred_input = prepare_page_for_screenshot(timeout: wait) 40 | 41 | # Take browser screenshot and save 42 | save_and_process_screenshot(screenshot_path) 43 | 44 | blurred_input&.click 45 | end 46 | 47 | def process_screenshot(stored_path, screenshot_path) 48 | screenshot_image = driver.from_file(stored_path) 49 | 50 | # TODO(uwe): Remove when chromedriver takes right size screenshots 51 | # TODO: Adds tests when this case is true 52 | screenshot_image = resize_if_needed(screenshot_image) if selenium_with_retina_screen? 53 | # ODOT 54 | 55 | screenshot_image = driver.crop(crop, screenshot_image) if crop 56 | 57 | driver.save_image_to(screenshot_image, screenshot_path) 58 | end 59 | 60 | def notice_how_to_avoid_this 61 | unless defined?(@_csd_retina_warned) 62 | warn "Halving retina screenshot. " \ 63 | 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.' 64 | @_csd_retina_warned = true 65 | end 66 | end 67 | 68 | def prepare_page_for_screenshot(timeout:) 69 | wait_images_loaded(timeout: timeout) if timeout 70 | 71 | blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element 72 | 73 | if Screenshot.hide_caret 74 | BrowserHelpers.hide_caret 75 | end 76 | 77 | blurred_input 78 | end 79 | 80 | def wait_images_loaded(timeout:) 81 | return unless timeout 82 | 83 | deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout 84 | loop do 85 | pending_image = BrowserHelpers.pending_image_to_load 86 | break unless pending_image 87 | 88 | if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at 89 | raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller) 90 | end 91 | 92 | sleep 0.025 93 | end 94 | end 95 | 96 | private 97 | 98 | def save_and_process_screenshot(screenshot_path) 99 | tmpfile = Tempfile.new([screenshot_path.basename.to_s, PNG_EXTENSION]) 100 | BrowserHelpers.session.save_screenshot(tmpfile.path, **capybara_screenshot_options) 101 | # Load saved screenshot and pre-process it 102 | process_screenshot(tmpfile.path, screenshot_path) 103 | ensure 104 | File.unlink(tmpfile) if tmpfile 105 | end 106 | 107 | def capture_screenshot_at(snapshot) 108 | take_screenshot(snapshot.next_attempt_path!) 109 | 110 | snapshot.commit_last_attempt 111 | end 112 | 113 | def resize_if_needed(saved_image) 114 | expected_image_width = Screenshot.window_size[0] 115 | return saved_image if driver.width_for(saved_image) < expected_image_width * 2 116 | 117 | notice_how_to_avoid_this 118 | 119 | new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image) 120 | driver.resize_image_to(saved_image, expected_image_width, new_height) 121 | end 122 | 123 | def selenium_with_retina_screen? 124 | Os::ON_MAC && BrowserHelpers.selenium? && Screenshot.window_size 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/stable_capture_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "capture_strategy" 4 | require_relative "stable_screenshoter" 5 | 6 | module Capybara 7 | module Screenshot 8 | module Diff 9 | # Capture strategy that waits until the page content stabilises by taking 10 | # several attempts and comparing them. 11 | class StableCaptureStrategy < CaptureStrategy 12 | def initialize(capture_options, comparison_options) 13 | super 14 | @screenshoter = StableScreenshoter.new(capture_options, comparison_options) 15 | end 16 | 17 | def take_comparison_screenshot(snapshot) 18 | @screenshoter.take_comparison_screenshot(snapshot) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/stable_screenshoter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | class StableScreenshoter 7 | STABILITY_OPTIONS = [:stability_time_limit, :wait] 8 | 9 | attr_reader :stability_time_limit, :wait 10 | 11 | # Initializes a new instance of StableScreenshoter 12 | # 13 | # This method sets up a new screenshoter with specific capture and comparison options. It validates the presence of 14 | # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`. 15 | # 16 | # @param capture_options [Hash] The options for capturing screenshots, must include `:stability_time_limit` and `:wait`. 17 | # @param comparison_options [Hash, nil] The options for comparing screenshots, defaults to `nil` which uses `Diff.default_options`. 18 | # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`. 19 | def initialize(capture_options, comparison_options = {}) 20 | @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS) 21 | 22 | raise ArgumentError, "wait should be provided for stable screenshots" unless wait 23 | raise ArgumentError, "stability_time_limit should be provided for stable screenshots" unless stability_time_limit 24 | raise ArgumentError, "stability_time_limit (#{stability_time_limit}) should be less or equal than wait (#{wait}) for stable screenshots" unless stability_time_limit <= wait 25 | 26 | @comparison_options = comparison_options 27 | 28 | driver = Diff::Drivers.for(@comparison_options) 29 | @screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), driver) 30 | end 31 | 32 | # Takes a comparison screenshot ensuring page stability 33 | # 34 | # Attempts to take a stable screenshot of the page by comparing several screenshot attempts until the page stops updating 35 | # or the `:wait` limit is reached. If unable to achieve a stable state within the time limit, it annotates the attempts 36 | # to aid debugging. 37 | # 38 | # @param snapshot Snap The snapshot details to take a stable screenshot of. 39 | # @return [void] 40 | # @raise [RuntimeError] If a stable screenshot cannot be obtained within the specified `:wait` time. 41 | def take_comparison_screenshot(snapshot) 42 | result = take_stable_screenshot(snapshot) 43 | 44 | # We failed to get stable browser state! Generate difference between attempts to overview moving parts! 45 | unless result 46 | # FIXME(uwe): Change to store the failure and only report if the test succeeds functionally. 47 | annotate_attempts_and_fail!(snapshot) 48 | end 49 | 50 | # store success attempt as actual screenshot 51 | snapshot.commit_last_attempt 52 | 53 | # cleanup all previous attempts 54 | snapshot.cleanup_attempts 55 | end 56 | 57 | def take_stable_screenshot(snapshot) 58 | # We try to compare first attempt with checkout version, in order to not run next screenshots 59 | deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait 60 | 61 | # Cleanup all previous attempts for sure 62 | snapshot.cleanup_attempts 63 | 64 | 0.step do |i| 65 | # FIXME: it should be wait, and wait should be replaced with stability_time_limit 66 | sleep(stability_time_limit) unless i == 0 # test prev_attempt_path is nil 67 | 68 | attempt_next_screenshot(snapshot) 69 | 70 | return true if attempt_successful?(snapshot) 71 | return false if timeout?(deadline_at) 72 | end 73 | end 74 | 75 | private 76 | 77 | def attempt_successful?(snapshot) 78 | return false unless snapshot.prev_attempt_path 79 | 80 | build_last_attempts_comparison_for(snapshot).quick_equal? 81 | rescue ArgumentError 82 | false 83 | end 84 | 85 | def attempt_next_screenshot(snapshot) 86 | @screenshoter.take_screenshot(snapshot.next_attempt_path!) 87 | end 88 | 89 | def timeout?(deadline_at) 90 | Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at 91 | end 92 | 93 | def build_last_attempts_comparison_for(snapshot) 94 | ImageCompare.new(snapshot.attempt_path, snapshot.prev_attempt_path, @comparison_options) 95 | end 96 | 97 | # TODO: Move to the HistoricalReporter 98 | def annotate_attempts_and_fail!(snapshot) 99 | require "capybara_screenshot_diff/attempts_reporter" 100 | attempts_reporter = CapybaraScreenshotDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit}) 101 | 102 | # TODO: Move fail to the queue after tests passed 103 | raise CapybaraScreenshotDiff::UnstableImage.new(attempts_reporter.generate, caller) 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/standard_capture_strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "capture_strategy" 4 | require_relative "screenshoter" 5 | 6 | module Capybara 7 | module Screenshot 8 | module Diff 9 | # Default capture strategy – grabs a single screenshot via the generic 10 | # `Screenshoter` and returns immediately. 11 | class StandardCaptureStrategy < CaptureStrategy 12 | def initialize(capture_options, comparison_options) 13 | super 14 | driver = comparison_options[:driver] 15 | @screenshoter = Diff.screenshoter.new(capture_options, driver) 16 | end 17 | 18 | def take_comparison_screenshot(snapshot) 19 | @screenshoter.take_comparison_screenshot(snapshot) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | module Utils 7 | def self.detect_available_drivers 8 | result = [] 9 | begin 10 | result << :vips if defined?(Vips) || require("vips") 11 | rescue LoadError 12 | # vips not present 13 | Object.send(:remove_const, :Vips) if defined?(Vips) 14 | end 15 | begin 16 | result << :chunky_png if defined?(ChunkyPNG) || require("chunky_png") 17 | rescue LoadError 18 | # chunky_png not present 19 | Object.send(:remove_const, :ChunkyPNG) if defined?(ChunkyPNG) 20 | end 21 | result 22 | end 23 | 24 | def self.find_driver_class_for(driver) 25 | driver = AVAILABLE_DRIVERS.first if driver == :auto 26 | 27 | LOADED_DRIVERS[driver] ||= 28 | case driver 29 | when :chunky_png 30 | require "capybara/screenshot/diff/drivers/chunky_png_driver" 31 | Drivers::ChunkyPNGDriver 32 | when :vips 33 | require "capybara/screenshot/diff/drivers/vips_driver" 34 | Drivers::VipsDriver 35 | else 36 | fail "Wrong adapter #{driver.inspect}. Available adapters: #{AVAILABLE_DRIVERS.inspect}" 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/vcs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "os" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | module Vcs 9 | def self.checkout_vcs(root, screenshot_path, checkout_path) 10 | if svn?(root) 11 | restore_svn_revision(screenshot_path, checkout_path) 12 | else 13 | restore_git_revision(screenshot_path, checkout_path, root: root) 14 | end 15 | end 16 | 17 | def self.svn?(root) 18 | (root / ".svn").exist? 19 | end 20 | 21 | SILENCE_ERRORS = Os::ON_WINDOWS ? "2>nul" : "2>/dev/null" 22 | 23 | def self.restore_git_revision(screenshot_path, checkout_path = screenshot_path, root:) 24 | vcs_file_path = screenshot_path.relative_path_from(root) 25 | redirect_target = "#{checkout_path} #{SILENCE_ERRORS}" 26 | show_command = "git show HEAD~0:./#{vcs_file_path}" 27 | 28 | Dir.chdir(root) do 29 | if Screenshot.use_lfs 30 | system("#{show_command} > #{checkout_path}.tmp #{SILENCE_ERRORS}", exception: !!ENV["DEBUG"]) 31 | 32 | `git lfs smudge < #{checkout_path}.tmp > #{redirect_target}` if $CHILD_STATUS == 0 33 | 34 | File.delete "#{checkout_path}.tmp" 35 | else 36 | system("#{show_command} > #{redirect_target}", exception: !!ENV["DEBUG"]) 37 | end 38 | end 39 | 40 | if $CHILD_STATUS != 0 41 | checkout_path.delete if checkout_path.exist? 42 | false 43 | else 44 | true 45 | end 46 | end 47 | 48 | def self.restore_svn_revision(screenshot_path, checkout_path) 49 | committed_file_name = screenshot_path + "../.svn/text-base/" + "#{screenshot_path.basename}.svn-base" 50 | if committed_file_name.exist? 51 | FileUtils.cp(committed_file_name, checkout_path) 52 | return true 53 | end 54 | 55 | svn_info = `svn info #{screenshot_path} #{SILENCE_ERRORS}` 56 | unless svn_info.empty? 57 | wc_root = svn_info.slice(/(?<=Working Copy Root Path: ).*$/) 58 | checksum = svn_info.slice(/(?<=Checksum: ).*$/) 59 | 60 | if checksum 61 | committed_file_name = "#{wc_root}/.svn/pristine/#{checksum[0..1]}/#{checksum}.svn-base" 62 | FileUtils.cp(committed_file_name, checkout_path) 63 | return true 64 | end 65 | end 66 | 67 | false 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/capybara/screenshot/diff/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | VERSION = "1.11.0" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/dsl" 4 | require "capybara/screenshot/diff/version" 5 | require "capybara/screenshot/diff/utils" 6 | require "capybara/screenshot/diff/image_compare" 7 | require "capybara_screenshot_diff/snap_manager" 8 | require "capybara/screenshot/diff/screenshot_namer_dsl" 9 | require "capybara/screenshot/diff/screenshoter" 10 | require "capybara/screenshot/diff/reporters/default" 11 | 12 | require "capybara_screenshot_diff/error_with_filtered_backtrace" 13 | 14 | module CapybaraScreenshotDiff 15 | class CapybaraScreenshotDiffError < ErrorWithFilteredBacktrace; end 16 | 17 | class ExpectationNotMet < CapybaraScreenshotDiffError; end 18 | 19 | class UnstableImage < CapybaraScreenshotDiffError; end 20 | 21 | class WindowSizeMismatchError < ErrorWithFilteredBacktrace; end 22 | end 23 | 24 | module Capybara 25 | module Screenshot 26 | mattr_accessor :add_driver_path 27 | mattr_accessor :add_os_path 28 | mattr_accessor :blur_active_element 29 | mattr_accessor :enabled 30 | mattr_accessor :hide_caret 31 | mattr_reader(:root) { (defined?(Rails) && defined?(Rails.root) && Rails.root) || Pathname(".").expand_path } 32 | mattr_accessor :stability_time_limit 33 | mattr_accessor :window_size 34 | mattr_accessor(:save_path) { "doc/screenshots" } 35 | mattr_accessor(:use_lfs) 36 | mattr_accessor(:screenshot_format) { "png" } 37 | mattr_accessor(:capybara_screenshot_options) { {} } 38 | 39 | class << self 40 | def root=(path) 41 | @@root = Pathname(path).expand_path 42 | end 43 | 44 | def active? 45 | enabled || (enabled.nil? && Diff.enabled) 46 | end 47 | 48 | def screenshot_area 49 | parts = [Screenshot.save_path] 50 | parts << Os.name if Screenshot.add_os_path 51 | parts << Capybara.current_driver.to_s if Screenshot.add_driver_path 52 | File.join(*parts) 53 | end 54 | 55 | def screenshot_area_abs 56 | root / screenshot_area 57 | end 58 | end 59 | 60 | # Module to track screenshot changes 61 | module Diff 62 | mattr_accessor(:delayed) { true } 63 | mattr_accessor :area_size_limit 64 | mattr_accessor(:fail_if_new) { false } 65 | mattr_accessor(:fail_on_difference) { true } 66 | mattr_accessor :color_distance_limit 67 | mattr_accessor(:enabled) { true } 68 | mattr_accessor :shift_distance_limit 69 | mattr_accessor :skip_area 70 | mattr_accessor(:driver) { :auto } 71 | mattr_accessor :tolerance 72 | 73 | mattr_accessor(:screenshoter) { Screenshoter } 74 | mattr_accessor(:manager) { CapybaraScreenshotDiff::SnapManager } 75 | 76 | AVAILABLE_DRIVERS = Utils.detect_available_drivers.freeze 77 | 78 | def self.default_options 79 | { 80 | area_size_limit: area_size_limit, 81 | color_distance_limit: color_distance_limit, 82 | driver: driver, 83 | screenshot_format: Screenshot.screenshot_format, 84 | capybara_screenshot_options: Screenshot.capybara_screenshot_options, 85 | shift_distance_limit: shift_distance_limit, 86 | skip_area: skip_area, 87 | stability_time_limit: Screenshot.stability_time_limit, 88 | tolerance: tolerance || ((driver == :vips) ? 0.001 : nil), 89 | wait: Capybara.default_max_wait_time 90 | } 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/attempts_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/screenshot/diff/image_compare" 4 | 5 | module CapybaraScreenshotDiff 6 | class AttemptsReporter 7 | def initialize(snapshot, comparison_options, stability_options = {}) 8 | @snapshot = snapshot 9 | @comparison_options = comparison_options 10 | @wait = stability_options[:wait] 11 | end 12 | 13 | def generate 14 | attempts_screenshot_paths = @snapshot.find_attempts_paths 15 | 16 | annotate_attempts(attempts_screenshot_paths) 17 | 18 | "Could not get stable screenshot within #{@wait}s:\n#{attempts_screenshot_paths.join("\n")}" 19 | end 20 | 21 | def build_comparison_for(attempt_path, previous_attempt_path) 22 | Capybara::Screenshot::Diff::ImageCompare.new(attempt_path, previous_attempt_path, @comparison_options) 23 | end 24 | 25 | private 26 | 27 | def annotate_attempts(attempts_screenshot_paths) 28 | previous_file = nil 29 | attempts_screenshot_paths.reverse_each do |file_name| 30 | if previous_file && File.exist?(previous_file) 31 | attempts_comparison = build_comparison_for(file_name, previous_file) 32 | 33 | if attempts_comparison.different? 34 | FileUtils.mv(attempts_comparison.reporter.annotated_base_image_path, previous_file, force: true) 35 | else 36 | warn "[capybara-screenshot-diff] Some attempts was stable, but mistakenly marked as not: " \ 37 | "#{previous_file} and #{file_name} are equal" 38 | end 39 | 40 | FileUtils.rm(attempts_comparison.reporter.annotated_image_path, force: true) 41 | end 42 | 43 | previous_file = file_name 44 | end 45 | 46 | previous_file 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/backtrace_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CapybaraScreenshotDiff 4 | class BacktraceFilter 5 | LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR 6 | 7 | def initialize(lib_directory = LIB_DIRECTORY) 8 | @lib_directory = lib_directory 9 | end 10 | 11 | # Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties 12 | # @param backtrace [Array] 13 | # @return [Array] 14 | def filtered(backtrace) 15 | backtrace 16 | .reject { |location| File.expand_path(location).start_with?(@lib_directory) } 17 | .reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/cucumber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/dsl" 4 | 5 | World(::CapybaraScreenshotDiff::DSL) 6 | 7 | Before do 8 | Capybara::Screenshot::Diff.delayed = false 9 | Capybara::Screenshot::BrowserHelpers.resize_window_if_needed 10 | end 11 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff" 4 | require "capybara/screenshot/diff/drivers" 5 | require "capybara/screenshot/diff/image_compare" 6 | require "capybara/screenshot/diff/screenshot_matcher" 7 | require "capybara/screenshot/diff/screenshot_namer_dsl" 8 | require "capybara_screenshot_diff/screenshot_assertion" 9 | 10 | module CapybaraScreenshotDiff 11 | # DSL for taking screenshots and making assertions in Capybara tests. 12 | # This module provides methods for taking screenshots, comparing them against baselines, 13 | # and managing the comparison process with various configuration options. 14 | # 15 | # The DSL is designed to be included in your test context (e.g., RSpec, Minitest) 16 | # to provide screenshot comparison capabilities. 17 | module DSL 18 | include Capybara::DSL 19 | include Capybara::Screenshot::Diff::ScreenshotNamerDSL 20 | 21 | # Takes a screenshot and optionally compares it against a baseline image. 22 | # 23 | # The method follows a layered optimization strategy for comparison: 24 | # 1. First checks if screenshot functionality is active 25 | # 2. Builds a full screenshot name using the current context 26 | # 3. Creates a screenshot assertion object 27 | # 4. Either validates immediately or defers validation based on options 28 | # 29 | # @param name [String] The base name of the screenshot, used to generate the filename. 30 | # @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors. 31 | # @param options [Hash] Additional options for taking the screenshot and comparison. 32 | # @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed) 33 | # Whether to validate the screenshot immediately or delay validation. 34 | # @option options [Array] :crop [x, y, width, height] Area to crop the screenshot to. 35 | # @option options [Array>] :skip_area Array of [x, y, width, height] areas to ignore. 36 | # @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison. 37 | # @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels. 38 | # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels. 39 | # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels. 40 | # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips). 41 | # @return [Boolean] True if the screenshot was successfully captured and processed. 42 | # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled. 43 | # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable. 44 | # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations. 45 | def screenshot(name, skip_stack_frames: 0, **options) 46 | return false unless Capybara::Screenshot.active? 47 | 48 | # Get the full name with section and group information 49 | full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name) 50 | 51 | # Build the screenshot assertion 52 | assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1) 53 | 54 | return false unless assertion 55 | 56 | # Determine if validation should be delayed or immediate 57 | delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed) 58 | 59 | if delayed 60 | CapybaraScreenshotDiff.add_assertion(assertion) 61 | else 62 | assertion.validate! 63 | end 64 | 65 | true 66 | end 67 | 68 | # Alias for backward compatibility with older test suites. 69 | # @see #screenshot 70 | alias_method :assert_matches_screenshot, :screenshot 71 | 72 | private 73 | 74 | # Builds a screenshot assertion object that can be validated immediately or later. 75 | # 76 | # This method constructs a screenshot assertion that encapsulates the comparison logic. 77 | # The actual comparison is deferred until {ScreenshotAssertion#validate!} is called. 78 | # 79 | # @param name [String] The full name of the screenshot, including any section/group context. 80 | # @param options [Hash] Options for screenshot taking and comparison. 81 | # See {#screenshot} for available options. 82 | # @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting. 83 | # @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed. 84 | # @see ScreenshotAssertion 85 | def build_screenshot_assertion(name, options, skip_stack_frames: 0) 86 | Capybara::Screenshot::Diff::ScreenshotMatcher 87 | .new(name, options) 88 | .build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff/backtrace_filter" 4 | 5 | module CapybaraScreenshotDiff 6 | # @private 7 | class ErrorWithFilteredBacktrace < StandardError 8 | # @private 9 | def initialize(message = nil, backtrace = []) 10 | super(message) 11 | filter = BacktraceFilter.new 12 | set_backtrace(filter.filtered(backtrace)) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/minitest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest" 4 | require "capybara_screenshot_diff/dsl" 5 | 6 | used_deprecated_entrypoint = caller.any? do |path| 7 | path.include?("capybara-screenshot-diff.rb") || path.include?("capybara/screenshot/diff.rb") 8 | end 9 | 10 | if used_deprecated_entrypoint 11 | warn <<~MSG 12 | [DEPRECATION] The default activation of `capybara_screenshot_diff/minitest` will be removed. 13 | Please `require "capybara_screenshot_diff/minitest"` explicitly. 14 | MSG 15 | end 16 | 17 | module CapybaraScreenshotDiff 18 | module Minitest 19 | module Assertions 20 | include ::CapybaraScreenshotDiff::DSL 21 | 22 | def screenshot(*args, skip_stack_frames: 0, **opts) 23 | self.assertions += 1 24 | 25 | super(*args, skip_stack_frames: skip_stack_frames + 1, **opts) 26 | rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e 27 | raise ::Minitest::Assertion, e.message 28 | end 29 | 30 | alias_method :assert_matches_screenshot, :screenshot 31 | 32 | def setup 33 | super 34 | ::Capybara::Screenshot::BrowserHelpers.resize_window_if_needed 35 | end 36 | 37 | def before_teardown 38 | super 39 | CapybaraScreenshotDiff.verify 40 | rescue CapybaraScreenshotDiff::ExpectationNotMet => e 41 | assertion = ::Minitest::Assertion.new(e) 42 | assertion.set_backtrace(e.backtrace) 43 | failures << assertion 44 | ensure 45 | CapybaraScreenshotDiff.reset 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec/core" 4 | require "capybara_screenshot_diff/dsl" 5 | 6 | RSpec::Matchers.define :match_screenshot do |name, **options| 7 | description { "match a screenshot" } 8 | 9 | match do |_page| 10 | screenshot(name, **options) 11 | true 12 | end 13 | end 14 | 15 | RSpec.configure do |config| 16 | config.include CapybaraScreenshotDiff::DSL, type: :feature 17 | config.include CapybaraScreenshotDiff::DSL, type: :system 18 | 19 | config.before do 20 | if self.class.include?(CapybaraScreenshotDiff::DSL) 21 | Capybara::Screenshot::BrowserHelpers.resize_window_if_needed 22 | end 23 | end 24 | 25 | config.after do 26 | if self.class.include?(CapybaraScreenshotDiff::DSL) 27 | begin 28 | CapybaraScreenshotDiff.verify 29 | rescue CapybaraScreenshotDiff::ExpectationNotMet => e 30 | raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) } 31 | ensure 32 | CapybaraScreenshotDiff.reset 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/screenshot_assertion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | 5 | module CapybaraScreenshotDiff 6 | class ScreenshotAssertion 7 | attr_reader :name, :args 8 | attr_accessor :compare, :caller 9 | 10 | def initialize(name, **args, &block) 11 | @name = name 12 | @args = args 13 | 14 | yield(self) if block_given? 15 | end 16 | 17 | def self.from(screenshot_job) 18 | return screenshot_job if screenshot_job.is_a?(ScreenshotAssertion) 19 | 20 | caller, name, compare = screenshot_job 21 | ScreenshotAssertion.new(name).tap do |it| 22 | it.caller = caller 23 | it.compare = compare 24 | end 25 | end 26 | 27 | def validate 28 | return unless compare 29 | 30 | self.class.assert_image_not_changed(caller, name, compare) 31 | end 32 | 33 | def validate! 34 | error_msg = validate 35 | 36 | if error_msg 37 | raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller) 38 | end 39 | end 40 | 41 | # Verifies that all scheduled screenshots do not show any unintended differences. 42 | # 43 | # @param screenshots [Array(Array(Array(String), String, ImageCompare))] The list of match screenshots jobs. Defaults to all screenshots taken during the test. 44 | # @return [Array, nil] Returns an array of error messages if there are screenshot differences, otherwise nil. 45 | # @note This method is typically called at the end of a test to assert all screenshots are as expected. 46 | def self.verify_screenshots!(screenshots) 47 | return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference 48 | 49 | test_screenshot_errors = screenshots.map do |assertion| 50 | assertion.validate 51 | end 52 | 53 | test_screenshot_errors.compact! 54 | 55 | test_screenshot_errors.empty? ? nil : test_screenshot_errors 56 | ensure 57 | screenshots&.clear 58 | end 59 | 60 | # Asserts that an image has not changed compared to its baseline. 61 | # 62 | # @param backtrace [Array(String)] The caller context, used for error reporting. 63 | # @param name [String] The name of the screenshot being verified. 64 | # @param comparison [Object] The comparison object containing the result and details of the comparison. 65 | # @return [String, nil] Returns an error message if the screenshot differs from the baseline, otherwise nil. 66 | # @note This method is used internally to verify individual screenshots. 67 | def self.assert_image_not_changed(backtrace, name, comparison) 68 | result = comparison.different? 69 | 70 | # Cleanup after comparisons 71 | if !result && comparison.base_image_path.exist? 72 | FileUtils.mv(comparison.base_image_path, comparison.image_path, force: true) 73 | elsif !comparison.dimensions_changed? 74 | FileUtils.rm_rf(comparison.base_image_path) 75 | end 76 | 77 | return unless result 78 | 79 | "Screenshot does not match for '#{name}': #{comparison.error_message}\n#{backtrace.join("\n")}" 80 | end 81 | end 82 | 83 | class AssertionRegistry 84 | attr_reader :assertions, :screenshot_namer 85 | 86 | def initialize 87 | @assertions = [] 88 | @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new 89 | end 90 | 91 | def add_assertion(assertion) 92 | assertion = ScreenshotAssertion.from(assertion) 93 | return unless assertion.compare 94 | 95 | @assertions.push(assertion) 96 | 97 | assertion 98 | end 99 | 100 | def assertions_present? 101 | !@assertions.empty? 102 | end 103 | 104 | def verify(screenshots = CapybaraScreenshotDiff.assertions) 105 | return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference 106 | 107 | failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions 108 | failed_screenshot = failed_assertions.first 109 | result = ScreenshotAssertion.verify_screenshots!(screenshots) 110 | 111 | if result 112 | raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller) 113 | end 114 | end 115 | 116 | def failed_assertions 117 | assertions.filter { |screenshot_assert| screenshot_assert.compare&.different? } 118 | end 119 | 120 | def reset 121 | @assertions.clear 122 | @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new 123 | end 124 | end 125 | end 126 | 127 | module CapybaraScreenshotDiff 128 | class << self 129 | require "forwardable" 130 | extend Forwardable 131 | 132 | def registry 133 | Thread.current[:capybara_screenshot_diff_registry] ||= AssertionRegistry.new 134 | end 135 | 136 | def_delegator :registry, :add_assertion 137 | def_delegator :registry, :assertions 138 | def_delegator :registry, :assertions_present? 139 | def_delegator :registry, :failed_assertions 140 | def_delegator :registry, :reset 141 | def_delegator :registry, :screenshot_namer 142 | def_delegator :registry, :verify 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/screenshot_namer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "pathname" 5 | 6 | module CapybaraScreenshotDiff 7 | # Handles the naming, path generation, and organization of screenshots. 8 | # This class encapsulates logic related to screenshot sections, groups, 9 | # and counters, providing a centralized way to determine screenshot filenames 10 | # and directories. 11 | class ScreenshotNamer 12 | attr_reader :section, :group 13 | 14 | def initialize(screenshot_area = nil) 15 | @section = nil 16 | @group = nil 17 | @counter = nil 18 | @screenshot_area = screenshot_area 19 | end 20 | 21 | def screenshot_area 22 | @screenshot_area ||= Capybara::Screenshot.screenshot_area 23 | end 24 | 25 | # Sets the current section for screenshots. 26 | # @param name [String, nil] The name of the section. 27 | def section=(name) 28 | @section = name&.to_s 29 | reset_group_counter 30 | end 31 | 32 | # Sets the current group for screenshots and resets the counter. 33 | # @param name [String, nil] The name of the group. 34 | def group=(name) 35 | @group = name&.to_s 36 | reset_group_counter 37 | end 38 | 39 | # Builds the full, unique name for a screenshot, including any counter. 40 | # @param base_name [String] The base name for the screenshot. 41 | # @return [String] The full screenshot name. 42 | def full_name(base_name) 43 | name = base_name.to_s 44 | 45 | if @counter 46 | name = format("%02i_%s", @counter, name) 47 | @counter += 1 48 | end 49 | 50 | File.join(*directory_parts.push(name.to_s)) 51 | end 52 | 53 | # Builds the full path for a screenshot file, including section and group directories. 54 | # @param base_name [String] The base name for the screenshot. 55 | # @return [String] The absolute path for the screenshot file. 56 | def full_name_with_path(base_name) 57 | File.join(screenshot_area, full_name(base_name)) 58 | end 59 | 60 | # Returns the directory parts (section and group) for constructing paths. 61 | # @return [Array] An array of directory names. 62 | def directory_parts 63 | parts = [] 64 | parts << @section unless @section.nil? || @section.empty? 65 | parts << @group unless @group.nil? || @group.empty? 66 | parts 67 | end 68 | 69 | # Calculates the directory path for the current section and group. 70 | # @return [String] The full path to the directory. 71 | def current_group_directory 72 | File.join(*([screenshot_area] + directory_parts)) 73 | end 74 | 75 | # Clears the directory for the current screenshot group. 76 | # This is typically used when starting a new group to remove old screenshots. 77 | def clear_current_group_directory 78 | dir_to_clear = current_group_directory 79 | FileUtils.rm_rf(dir_to_clear) if Dir.exist?(dir_to_clear) 80 | end 81 | 82 | private 83 | 84 | def reset_group_counter 85 | @counter = (@group.nil? || @group.empty?) ? nil : 0 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/snap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CapybaraScreenshotDiff 4 | class Snap 5 | attr_reader :full_name, :format, :path, :base_path, :manager, :attempt_path, :prev_attempt_path, :attempts_count 6 | 7 | def initialize(full_name, format, manager: SnapManager.instance) 8 | @full_name = full_name 9 | @format = format 10 | @path = manager.abs_path_for(Pathname.new(@full_name).sub_ext(".#{@format}")) 11 | @base_path = @path.sub_ext(".base.#{@format}") 12 | @manager = manager 13 | @attempts_count = 0 14 | end 15 | 16 | def delete! 17 | path.delete if path.exist? 18 | base_path.delete if base_path.exist? 19 | cleanup_attempts 20 | end 21 | 22 | def checkout_base_screenshot 23 | @manager.checkout_file(path, base_path) 24 | end 25 | 26 | def path_for(version = :actual) 27 | case version 28 | when :base 29 | base_path 30 | else 31 | path 32 | end 33 | end 34 | 35 | def next_attempt_path! 36 | @prev_attempt_path = @attempt_path 37 | @attempt_path = path.sub_ext(sprintf(".attempt_%02i.#{format}", @attempts_count)) 38 | ensure 39 | @attempts_count += 1 40 | end 41 | 42 | def commit_last_attempt 43 | @manager.move(attempt_path, path) 44 | end 45 | 46 | def cleanup_attempts 47 | @manager.cleanup_attempts!(self) 48 | @attempts_count = 0 49 | end 50 | 51 | def find_attempts_paths 52 | Dir[@manager.abs_path_for("**/#{full_name}.attempt_[0-9][0-9].#{format}")] 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/capybara_screenshot_diff/snap_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/screenshot/diff/vcs" 4 | require "active_support/core_ext/module/attribute_accessors" 5 | 6 | require "capybara_screenshot_diff/snap" 7 | 8 | module CapybaraScreenshotDiff 9 | class SnapManager 10 | attr_reader :root 11 | 12 | def initialize(root) 13 | @root = Pathname.new(root) 14 | @snapshots = Set.new 15 | end 16 | 17 | def snapshot(screenshot_full_name, screenshot_format = "png") 18 | Snap.new(screenshot_full_name, screenshot_format, manager: self).tap do |snapshot| 19 | @snapshots << snapshot 20 | end 21 | end 22 | 23 | def self.snapshot(screenshot_full_name, screenshot_format = "png") 24 | instance.snapshot(screenshot_full_name, screenshot_format) 25 | end 26 | 27 | def abs_path_for(relative_path) 28 | @root / relative_path 29 | end 30 | 31 | def checkout_file(path, as_path) 32 | create_output_directory_for(as_path) unless as_path.exist? 33 | Capybara::Screenshot::Diff::Vcs.checkout_vcs(root, path, as_path) 34 | end 35 | 36 | def provision_snap_with(snap, path, version: :actual) 37 | managed_path = snap.path_for(version) 38 | create_output_directory_for(managed_path) unless managed_path.exist? 39 | FileUtils.cp(path, managed_path) 40 | end 41 | 42 | def create_output_directory_for(path = nil) 43 | path ? path.dirname.mkpath : root.mkpath 44 | end 45 | 46 | # TODO: rename to delete! 47 | def cleanup! 48 | snapshots.each do |snapshot| 49 | cleanup_attempts!(snapshot) 50 | snapshot.delete! 51 | end 52 | end 53 | 54 | def self.cleanup! 55 | instance.cleanup! 56 | end 57 | 58 | def cleanup_attempts!(snapshot) 59 | FileUtils.rm_rf snapshot.find_attempts_paths, secure: true 60 | end 61 | 62 | def move(new_screenshot_path, screenshot_path) 63 | FileUtils.mv(new_screenshot_path, screenshot_path, force: true) 64 | end 65 | 66 | def screenshots 67 | root.children.map { |f| f.basename.to_s } 68 | end 69 | 70 | attr_reader :snapshots 71 | 72 | def self.screenshots 73 | instance.screenshots 74 | end 75 | 76 | def self.root 77 | instance.root 78 | end 79 | 80 | def self.instance 81 | Capybara::Screenshot::Diff.manager.new(Capybara::Screenshot.screenshot_area_abs) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /scripts/benchmark/find_region_benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "benchmark" 4 | 5 | require "capybara/screenshot/diff" 6 | require "capybara/screenshot/diff/drivers/vips_driver" 7 | require "capybara/screenshot/diff/drivers/chunky_png_driver" 8 | 9 | module Capybara::Screenshot::Diff 10 | class Drivers::FindRegionBenchmark 11 | TEST_IMAGES_DIR = Pathname.new(File.expand_path("../../test/fixtures/images", __dir__)) 12 | APP_SCREENSHOTS_DIR = Pathname.new( 13 | File.expand_path("../../test/fixtures/app/doc/screenshots/chrome/macos/", __dir__) 14 | ) 15 | 16 | def for_medium_size_screens 17 | image_path = (APP_SCREENSHOTS_DIR / "index.png").to_path 18 | base_image_path = (APP_SCREENSHOTS_DIR / "index-blur_active_element-enabled.png").to_path 19 | 20 | Benchmark.bm(50) do |x| 21 | experiment_for(x, :chunky_png, :different?, "same images", image_path, image_path) 22 | experiment_for(x, :vips, :different?, "same images", image_path, image_path) 23 | experiment_for(x, :chunky_png, :quick_equal?, "different images", image_path, base_image_path) 24 | experiment_for(x, :vips, :quick_equal?, "different images", image_path, base_image_path) 25 | end 26 | end 27 | 28 | def for_small_images 29 | image_path = (TEST_IMAGES_DIR / "a.png").to_path 30 | base_image_path = (TEST_IMAGES_DIR / "b.png").to_path 31 | 32 | Benchmark.bm(50) do |x| 33 | experiment_for(x, :chunky_png, :different?, "same images", image_path, image_path) 34 | experiment_for(x, :vips, :different?, "same images", image_path, image_path) 35 | experiment_for(x, :chunky_png, :quick_equal?, "different images", image_path, base_image_path) 36 | experiment_for(x, :vips, :quick_equal?, "different images", image_path, base_image_path) 37 | end 38 | end 39 | 40 | private 41 | 42 | def experiment_for(x, driver, method, suffix, new_path, base_path) 43 | x.report("[#{suffix}] #{driver}##{method}") do 44 | 50.times do 45 | ImageCompare.new(new_path, base_path, driver: driver).public_send(method) 46 | 47 | Vips.cache_set_max(0) 48 | Vips.cache_set_max(1000) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/.keep -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/cuprite/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/linux/selenium_headless/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/cuprite/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_chrome_headless/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/cropped_screenshot.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-blur_active_element-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-disabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-hide_caret-enabled.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index-without-img-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index-without-img-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/index_with_skip_area_as_array_of_css_and_p.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_as_webp/00_index-vips.webp -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_cropped/00_index-cropped.png -------------------------------------------------------------------------------- /test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_with_stability/00_index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/doc/screenshots/macos/selenium_headless/record_screenshot/record_index_with_stability/00_index.png -------------------------------------------------------------------------------- /test/fixtures/app/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/app/image.png -------------------------------------------------------------------------------- /test/fixtures/app/index-with-anim.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 20 |

Animation

21 | 22 |
23 | 24 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /test/fixtures/app/index-without-img.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /test/fixtures/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /test/fixtures/comparisons/a-and-b.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/comparisons/a-and-b.diff.png -------------------------------------------------------------------------------- /test/fixtures/comparisons/a-and-b.heatmap.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/comparisons/a-and-b.heatmap.diff.png -------------------------------------------------------------------------------- /test/fixtures/comparisons/a-and-c.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/comparisons/a-and-c.diff.png -------------------------------------------------------------------------------- /test/fixtures/comparisons/b-and-a.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/comparisons/b-and-a.diff.png -------------------------------------------------------------------------------- /test/fixtures/comparisons/c-and-a.diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/comparisons/c-and-a.diff.png -------------------------------------------------------------------------------- /test/fixtures/images/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/a.png -------------------------------------------------------------------------------- /test/fixtures/images/a.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/a.webp -------------------------------------------------------------------------------- /test/fixtures/images/a_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/a_cropped.png -------------------------------------------------------------------------------- /test/fixtures/images/b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/b.png -------------------------------------------------------------------------------- /test/fixtures/images/c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/c.png -------------------------------------------------------------------------------- /test/fixtures/images/d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/d.png -------------------------------------------------------------------------------- /test/fixtures/images/portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/portrait.png -------------------------------------------------------------------------------- /test/fixtures/images/portrait_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/test/fixtures/images/portrait_b.png -------------------------------------------------------------------------------- /test/fixtures/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/rspec" 4 | 5 | require "capybara_screenshot_diff/rspec" 6 | require "support/stub_test_methods" 7 | 8 | unless defined?(SCREEN_SIZE) 9 | require "test_helper" 10 | require "support/setup_capybara_drivers" 11 | end 12 | 13 | RSpec.describe "capybara_screenshot_diff/rspec", type: :feature do 14 | before do 15 | Capybara.current_driver = Capybara.javascript_driver 16 | Capybara.page.current_window.resize_to(*SCREEN_SIZE) 17 | Capybara::Screenshot.window_size = SCREEN_SIZE 18 | 19 | Capybara::Screenshot.save_path = "doc/screenshots" 20 | Capybara::Screenshot.root = Rails.root / "../test/fixtures/app" 21 | Capybara::Screenshot.add_os_path = true 22 | Capybara::Screenshot.add_driver_path = true 23 | Capybara::Screenshot::Diff.driver = ENV.fetch("SCREENSHOT_DRIVER", "chunky_png").to_sym 24 | Capybara::Screenshot::Diff.tolerance = 0.5 25 | end 26 | 27 | it "should include CapybaraScreenshotDiff in rspec" do 28 | expect(self.class.ancestors).to include CapybaraScreenshotDiff::DSL 29 | end 30 | 31 | it "visits and compare screenshot on teardown" do 32 | visit "/" 33 | screenshot "index" 34 | end 35 | 36 | it "use custom matcher" do 37 | visit "/" 38 | 39 | expect(page).to match_screenshot("index", skip_stack_frames: 1, driver: :chunky_png) 40 | end 41 | 42 | it "does not conflicts with rspec methods" do 43 | expect { raise StandardError }.to raise_error(StandardError) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/integration/record_screenshot_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "system_test_case" 4 | 5 | class RecordScreenshotTest < SystemTestCase 6 | setup do 7 | screenshot_section class_name.underscore.sub(/(_feature|_system)?_test$/, "") unless CapybaraScreenshotDiff.screenshot_namer.section 8 | screenshot_group name[5..] unless CapybaraScreenshotDiff.screenshot_namer.group 9 | 10 | @original_tolerance = Capybara::Screenshot::Diff.tolerance 11 | Capybara::Screenshot::Diff.tolerance = (Capybara::Screenshot::Diff.driver == :vips) ? 0.035 : 0.7 12 | end 13 | 14 | teardown do 15 | Capybara::Screenshot.blur_active_element = nil 16 | Capybara::Screenshot::Diff.tolerance = @original_tolerance 17 | end 18 | 19 | def test_record_index 20 | visit "/" 21 | 22 | screenshot "index" 23 | end 24 | 25 | def test_record_index_cropped 26 | visit "/" 27 | 28 | screenshot "index-cropped", crop: "form" 29 | end 30 | 31 | def test_record_index_as_webp 32 | skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) 33 | 34 | visit "/" 35 | 36 | screenshot "index-vips", screenshot_format: "webp", driver: :vips 37 | end 38 | 39 | def test_record_index_with_stability 40 | visit "/" 41 | 42 | screenshot "index", stability_time_limit: 0.1, wait: (RUBY_ENGINE == "jruby") ? 10 : 1 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/integration/rspec_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "system_test_case" 4 | 5 | module CapybaraScreenshotDiff 6 | class RspecTest < SystemTestCase 7 | test "RSpec integration runs successfully with capybara-screenshot-diff" do 8 | # Ensure that the RSpec module is loaded 9 | require "rspec/core" 10 | 11 | # Run the RSpec spec file 12 | capture_output = StringIO.new 13 | spec_file = file_fixture("rspec_spec.rb").to_s 14 | rspec_status = RSpec::Core::Runner.run([spec_file], capture_output, capture_output) 15 | 16 | assert_equal 0, rspec_status, "RSpec tests failed:\n#{capture_output.string}" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/integration/test_methods_system_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NOTE: For this test we need only chrome browser, 4 | # because we can spot problem by counting running chrome driver processes 5 | unless ENV["CAPYBARA_DRIVER"].include?("selenium_chrome") 6 | return 7 | end 8 | warn "Regression test for `driven_by :selenium_chrome` construction." 9 | 10 | require "system_test_case" 11 | require "action_pack/version" 12 | require "objspace" 13 | 14 | module Capybara 15 | module Screenshot 16 | module Diff 17 | class TestMethodsSystemTest < ActionDispatch::SystemTestCase 18 | include CapybaraScreenshotDiff::DSL 19 | include CapybaraScreenshotDiff::DSLStub 20 | 21 | driven_by :selenium, using: :headless_chrome 22 | 23 | def test_current_capybara_driver_class_do_not_spawn_new_process_when_we_use_system_test_cases 24 | # NOTE: There is possible that we have several drivers usage in the one suite, 25 | # so each of them will have separate instance 26 | other_activated_drivers = ObjectSpace.each_object(Capybara::Selenium::Driver).count 27 | 28 | 3.times { BrowserHelpers.current_capybara_driver_class } 29 | 30 | run_chrome_drivers = ObjectSpace.each_object(Capybara::Selenium::Driver).count 31 | assert run_chrome_drivers.positive? 32 | assert run_chrome_drivers - other_activated_drivers <= 1 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/capybara_screenshot_diff/dsl_stub.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module CapybaraScreenshotDiff 4 | module DSLStub 5 | extend ActiveSupport::Concern 6 | 7 | def setup 8 | super 9 | @manager = CapybaraScreenshotDiff::SnapManager.new(Capybara::Screenshot.root / "doc/screenshots") 10 | Capybara::Screenshot::Diff.screenshoter = Capybara::Screenshot::ScreenshoterStub 11 | end 12 | 13 | def teardown 14 | @manager.cleanup! 15 | Capybara::Screenshot::Diff.screenshoter = Capybara::Screenshot::Screenshoter 16 | CapybaraScreenshotDiff.reset 17 | super 18 | end 19 | 20 | # Prepare comparison images and build ImageCompare for them 21 | def make_comparison(fixture_base_image, fixture_new_image = nil, destination: "screenshot", **options) 22 | fixture_new_image ||= fixture_base_image 23 | snap = create_snapshot_for(fixture_base_image, fixture_new_image, name: destination) 24 | Capybara::Screenshot::Diff::ImageCompare.new(snap.path, snap.base_path, **options) 25 | end 26 | 27 | # Prepare images for comparison in a test 28 | # 29 | # @param snap [CapybaraScreenshotDiff::Snap] the snapshot to prepare 30 | # @param expected [String] the base name of the original base image 31 | # @param actual [String] the base name of the original new image 32 | def set_test_images(snap, expected, actual) 33 | @manager.provision_snap_with(snap, fixture_image_path_from(actual, snap.format), version: :actual) 34 | @manager.provision_snap_with(snap, fixture_image_path_from(expected, snap.format), version: :base) 35 | end 36 | 37 | ImageCompareStub = Struct.new( 38 | :driver, :driver_options, :shift_distance_limit, :quick_equal?, :different?, :reporter, keyword_init: true 39 | ) 40 | 41 | def build_image_compare_stub(equal: true) 42 | ImageCompareStub.new( 43 | driver: ::Minitest::Mock.new, 44 | reporter: ::Minitest::Mock.new, 45 | driver_options: Capybara::Screenshot::Diff.default_options, 46 | shift_distance_limit: nil, 47 | quick_equal?: equal, 48 | different?: !equal 49 | ) 50 | end 51 | 52 | def take_stable_screenshot_with(snap, stability_time_limit: 0.01, wait: 10) 53 | screenshoter = Capybara::Screenshot::Diff::StableScreenshoter.new({stability_time_limit: stability_time_limit, wait: wait}) 54 | screenshoter.take_stable_screenshot(snap) 55 | end 56 | 57 | def create_snapshot_for(expected, actual = nil, name: nil) 58 | actual ||= expected 59 | name ||= "#{actual}_#{Time.now.nsec}" 60 | @manager.snapshot(name).tap do |snap| 61 | set_test_images(snap, expected, actual) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/non_minitest_assertions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara_screenshot_diff" 4 | 5 | module CapybaraScreenshotDiff 6 | module NonMinitest 7 | module Assertions 8 | def self.included(klass) 9 | klass.include CapybaraScreenshotDiff::DSL 10 | 11 | klass.setup do 12 | Capybara::Screenshot::BrowserHelpers.resize_window_if_needed 13 | end 14 | 15 | klass.teardown do 16 | CapybaraScreenshotDiff.verify 17 | ensure 18 | CapybaraScreenshotDiff.reset 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/screenshoter_stub.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/screenshot/diff/screenshoter" 4 | 5 | class Capybara::Screenshot::ScreenshoterStub < Capybara::Screenshot::Screenshoter 6 | def pending_image_to_load 7 | end 8 | 9 | # Stub of the Capybara's save_screenshot 10 | def save_screenshot(path) 11 | source_image = path.basename.to_path 12 | source_image.slice!(/\.attempt_\d+/) 13 | source_image.slice!(/^\d\d_/) 14 | source_image.slice!(/_\d+(?=\.)/) 15 | 16 | FileUtils.mkdir_p(path.dirname) 17 | FileUtils.cp(File.expand_path(source_image, TEST_IMAGES_DIR), path) 18 | 19 | path 20 | end 21 | 22 | def evaluate_script(*) 23 | # Do nothing 24 | end 25 | 26 | def prepare_page_for_screenshot(**) 27 | nil 28 | end 29 | 30 | def take_screenshot(screenshot_path) 31 | stored_path = save_screenshot(screenshot_path) # rubocop:disable Lint/Debugger 32 | 33 | process_screenshot(stored_path, screenshot_path) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/setup_capybara.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "support/setup_rails_app" 4 | 5 | Capybara.app = Rails.application 6 | Capybara.default_max_wait_time = 1 7 | Capybara.disable_animation = true 8 | Capybara.server = :puma, {Silent: true} 9 | Capybara.threadsafe = true 10 | -------------------------------------------------------------------------------- /test/support/setup_capybara_drivers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["CAPYBARA_DRIVER"] ||= "cuprite" 4 | 5 | SCREEN_SIZE = [800, 600] 6 | if ENV["CAPYBARA_DRIVER"] == "selenium_chrome_headless" && Capybara::Screenshot::Os.name == "linux" 7 | SCREEN_SIZE[1] += 87 # Add extra space for address field etc. 8 | end 9 | BROWSERS = {cuprite: "chrome", selenium_headless: "firefox", selenium_chrome_headless: "chrome"} 10 | 11 | CHROME_ARGS = { 12 | "allow-running-insecure-content" => nil, 13 | "autoplay-policy" => "user-gesture-required", 14 | "disable-add-to-shelf" => nil, 15 | "disable-background-networking" => nil, 16 | "disable-background-timer-throttling" => nil, 17 | "disable-backgrounding-occluded-windows" => nil, 18 | "disable-breakpad" => nil, 19 | "disable-checker-imaging" => nil, 20 | "disable-client-side-phishing-detection" => nil, 21 | "disable-component-extensions-with-background-pages" => nil, 22 | "disable-datasaver-prompt" => nil, 23 | "disable-default-apps" => nil, 24 | "disable-desktop-notifications" => nil, 25 | "disable-dev-shm-usage" => nil, 26 | "disable-domain-reliability" => nil, 27 | "disable-extensions" => nil, 28 | "disable-features" => "TranslateUI,BlinkGenPropertyTrees", 29 | "disable-gpu" => nil, 30 | "disable-hang-monitor" => nil, 31 | "disable-infobars" => nil, 32 | "disable-ipc-flooding-protection" => nil, 33 | "disable-notifications" => nil, 34 | "disable-popup-blocking" => nil, 35 | "disable-prompt-on-repost" => nil, 36 | "disable-renderer-backgrounding" => nil, 37 | "disable-setuid-sandbox" => nil, 38 | "disable-site-isolation-trials" => nil, 39 | "disable-sync" => nil, 40 | "disable-web-security" => nil, 41 | "enable-automation" => nil, 42 | "enable-features" => "NetworkService,NetworkServiceInProcess", 43 | "enable-logging" => "stderr", 44 | "force-color-profile" => "srgb", 45 | "force-device-scale-factor" => "1", 46 | "hide-scrollbars" => nil, 47 | "headless" => nil, 48 | # "headless" => "new", 49 | "ignore-certificate-errors" => nil, 50 | "js-flags" => "--random-seed=1157259157", 51 | "log-level" => "0", 52 | "metrics-recording-only" => nil, 53 | "mute-audio" => nil, 54 | "no-default-browser-check" => nil, 55 | "no-first-run" => nil, 56 | "no-sandbox" => nil, 57 | "password-store=basic" => nil, 58 | "test-type" => nil, 59 | "use-mock-keychain" => nil, 60 | "window-size" => SCREEN_SIZE.join(",") 61 | } 62 | 63 | if ENV["CAPYBARA_DRIVER"] == "cuprite" 64 | # NOTE: do not require cuprite by default 65 | require "capybara/cuprite" 66 | 67 | Capybara.register_driver(:cuprite) do |app| 68 | Capybara::Cuprite::Driver.new( 69 | app, 70 | browser_options: CHROME_ARGS, 71 | js_errors: true, 72 | process_timeout: ENV["CI"] ? 40 : 5, 73 | screen_size: SCREEN_SIZE, 74 | timeout: ENV["CI"] ? 40 : 5, 75 | window_size: SCREEN_SIZE 76 | ) 77 | end 78 | end 79 | 80 | Capybara.register_driver :selenium_chrome_headless do |app| 81 | version = Capybara::Selenium::Driver.load_selenium 82 | options_key = Capybara::Selenium::Driver::CAPS_VERSION.satisfied_by?(version) ? :capabilities : :options 83 | browser_options = Selenium::WebDriver::Chrome::Options.new 84 | CHROME_ARGS.each { browser_options.add_argument("--#{_1}=#{_2}") } 85 | Capybara::Selenium::Driver.new(app, :browser => :chrome, options_key => browser_options).tap do |driver| 86 | driver.browser.manage.window.size = Selenium::WebDriver::Dimension.new(*SCREEN_SIZE) 87 | end 88 | end 89 | 90 | Capybara.save_path = Pathname.new("tmp/capybara").expand_path 91 | Capybara.javascript_driver = ENV.fetch("CAPYBARA_DRIVER", :cuprite).to_sym 92 | Capybara.disable_animation = true 93 | -------------------------------------------------------------------------------- /test/support/setup_rails_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack" 4 | require "rackup" if Rack::RELEASE >= "3" 5 | 6 | require "logger" # for Rails 7.0 7 | require "action_controller" 8 | 9 | # NOTE: Simulate Rails Environment 10 | module Rails 11 | def self.root 12 | Pathname("../../tmp").expand_path(__dir__) 13 | end 14 | 15 | def self.application 16 | Rack::Builder.new { 17 | use(Rack::Static, urls: [""], root: "test/fixtures/app", index: "index.html") 18 | run ->(_env) { [200, {}, []] } 19 | }.to_app 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/stub_test_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "screenshoter_stub" 4 | require_relative "capybara_screenshot_diff/dsl_stub" 5 | -------------------------------------------------------------------------------- /test/support/test_doubles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Capybara 4 | module Screenshot 5 | module Diff 6 | module TestDoubles 7 | # Test double for file paths with configurable size and existence 8 | class TestPath 9 | attr_reader :size_value 10 | 11 | # Initialize a path with a size value and existence flag 12 | # @param size_value [Integer] The size of the file 13 | # @param exists [Boolean] Whether the file exists, defaults to true 14 | def initialize(size_value, exists = true) 15 | @size_value = size_value 16 | @exists = exists 17 | end 18 | 19 | def size 20 | @size_value 21 | end 22 | 23 | def exist? 24 | @exists 25 | end 26 | end 27 | 28 | # Test double for image drivers with configurable behavior 29 | class TestDriver 30 | attr_reader :add_black_box_calls, :filter_calls, :dimension_check_calls, :pixel_check_calls, :difference_region_calls, :load_images_called, :load_images_args 31 | attr_accessor :same_dimension_result, :same_pixels_result, :difference_region_result, :images_to_return 32 | 33 | # Initializes a new TestDriver 34 | # @param is_vips_driver [Boolean] whether this driver should behave like a VipsDriver 35 | # @param images_to_return [Array] images to return from load_images method 36 | def initialize(is_vips_driver = false, images_to_return = nil) 37 | @is_vips_driver = is_vips_driver 38 | @images_to_return = images_to_return || [:base_image, :new_image] 39 | @add_black_box_calls = [] 40 | @filter_calls = [] 41 | @dimension_check_calls = [] 42 | @pixel_check_calls = [] 43 | @difference_region_calls = [] 44 | @load_images_called = false 45 | @load_images_args = nil 46 | @same_dimension_result = true 47 | @same_pixels_result = true 48 | @difference_region_result = nil 49 | end 50 | 51 | def is_a?(klass) 52 | return @is_vips_driver if klass == Drivers::VipsDriver 53 | super 54 | end 55 | 56 | def add_black_box(image, region) 57 | @add_black_box_calls << {image: image, region: region} 58 | "processed_#{image}" 59 | end 60 | 61 | def filter_image_with_median(image, size) 62 | @filter_calls << {image: image, size: size} 63 | # Return the filtered image, converting to the expected format 64 | "filtered_#{image}" 65 | end 66 | 67 | def same_dimension?(comparison) 68 | @dimension_check_calls << comparison 69 | @same_dimension_result 70 | end 71 | 72 | def same_pixels?(comparison) 73 | @pixel_check_calls << comparison 74 | @same_pixels_result 75 | end 76 | 77 | def find_difference_region(comparison) 78 | @difference_region_calls << comparison 79 | @difference_region_result 80 | end 81 | 82 | def load_images(base_path, new_path) 83 | @load_images_called = true 84 | @load_images_args = [base_path, new_path] 85 | @images_to_return 86 | end 87 | 88 | def supports?(...) 89 | @is_vips_driver 90 | end 91 | 92 | # Return Object to avoid infinite recursion 93 | def class 94 | Object 95 | end 96 | end 97 | 98 | # Test double for image preprocessors 99 | class TestPreprocessor 100 | attr_reader :call_called, :call_args, :process_comparison_called, :process_comparison_args, :processed_images 101 | 102 | def initialize(processed_images) 103 | @processed_images = processed_images 104 | @call_called = false 105 | @call_args = nil 106 | @process_comparison_called = false 107 | @process_comparison_args = nil 108 | end 109 | 110 | def call(images) 111 | @call_called = true 112 | @call_args = images 113 | processed_images 114 | end 115 | 116 | # Process a comparison object directly 117 | # Mirrors the implementation in ImagePreprocessor 118 | def process_comparison(comparison) 119 | @process_comparison_called = true 120 | @process_comparison_args = comparison 121 | comparison 122 | end 123 | end 124 | 125 | # Test double for difference results 126 | class TestDifference 127 | attr_reader :different_value 128 | 129 | def initialize(different_value) 130 | @different_value = different_value 131 | end 132 | 133 | def different? 134 | @different_value 135 | end 136 | end 137 | 138 | # Simple test double for comparison objects 139 | class TestComparison 140 | attr_reader :new_image, :base_image, :options, :driver 141 | attr_accessor :new_image_path, :base_image_path 142 | 143 | def initialize(options = {}) 144 | @new_image = options[:new_image] 145 | @base_image = options[:base_image] 146 | @options = options[:options] || {} 147 | @driver = options[:driver] 148 | @new_image_path = options[:new_image_path] || options[:image_path] 149 | @base_image_path = options[:base_image_path] 150 | end 151 | end 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/support/test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "support/test_doubles" 4 | 5 | module TestHelpers 6 | include Capybara::Screenshot::Diff::TestDoubles 7 | # Common assertions for image comparison tests 8 | module Assertions 9 | # Asserts that a dimension check was called a specific number of times 10 | # @param driver [Object] The test driver object 11 | # @param times [Integer] The expected number of calls (default: 1) 12 | def assert_dimension_check_called(driver, times = 1) 13 | assert_equal times, driver.dimension_check_calls.size, 14 | "Expected dimension check to be called #{times} time(s)" 15 | end 16 | 17 | # Asserts that a pixel check was called a specific number of times 18 | # @param driver [Object] The test driver object 19 | # @param times [Integer] The expected number of calls (default: 1) 20 | def assert_pixel_check_called(driver, times = 1) 21 | assert_equal times, driver.pixel_check_calls.size, 22 | "Expected pixel check to be called #{times} time(s)" 23 | end 24 | 25 | # Asserts that a difference region check was called a specific number of times 26 | # @param driver [Object] The test driver object 27 | # @param times [Integer] The expected number of calls (default: 1) 28 | def assert_difference_region_called(driver, times = 1) 29 | assert_equal times, driver.difference_region_calls.size, 30 | "Expected difference region check to be called #{times} time(s)" 31 | end 32 | end 33 | 34 | # Common setup methods for test drivers 35 | module DriverSetup 36 | # Sets up driver results for testing 37 | # @param driver [Object] The test driver object 38 | # @param same_dimension [Boolean] Whether dimensions match (default: true) 39 | # @param same_pixels [Boolean, nil] Whether pixels match (default: nil for no change) 40 | # @param difference_region [Object, nil] The difference region result (default: nil) 41 | def setup_driver_results(driver, same_dimension: true, same_pixels: nil, difference_region: nil) 42 | driver.same_dimension_result = same_dimension 43 | driver.same_pixels_result = same_pixels unless same_pixels.nil? 44 | driver.difference_region_result = difference_region if difference_region 45 | end 46 | end 47 | 48 | # Common test data generators 49 | module TestData 50 | # Creates a test driver with the given options 51 | # @param is_vips [Boolean] Whether to create a VIPS driver (default: false) 52 | # @param images [Array, nil] Images to return from load_images (default: nil) 53 | # @return [TestDoubles::TestDriver] A test driver object 54 | def create_test_driver(is_vips: false, images: nil) 55 | Capybara::Screenshot::Diff::TestDoubles::TestDriver.new(is_vips, images) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/system_test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "capybara_screenshot_diff/minitest" 5 | 6 | require "support/setup_capybara_drivers" 7 | 8 | class SystemTestCase < ActiveSupport::TestCase 9 | setup do 10 | Capybara.current_driver = Capybara.javascript_driver 11 | Capybara.page.current_window.resize_to(*SCREEN_SIZE) 12 | 13 | Capybara::Screenshot.enabled = true 14 | Capybara::Screenshot::Diff.enabled = true 15 | 16 | # TODO: Reset original settings to previous values 17 | @orig_root = Capybara::Screenshot.root 18 | Capybara::Screenshot.root = Rails.root / "../test/fixtures/app" 19 | 20 | @orig_save_path = Capybara::Screenshot.save_path 21 | Capybara::Screenshot.save_path = "./doc/screenshots" 22 | 23 | Capybara::Screenshot::Diff.driver = ENV.fetch("SCREENSHOT_DRIVER", "chunky_png").to_sym 24 | 25 | # TODO: Makes configurations copying and restoring much easier 26 | 27 | @orig_add_os_path = Capybara::Screenshot.add_os_path 28 | Capybara::Screenshot.add_os_path = true 29 | @orig_add_driver_path = Capybara::Screenshot.add_driver_path 30 | Capybara::Screenshot.add_driver_path = true 31 | # NOTE: Only works before `include Capybara::Screenshot::Diff` line 32 | @orig_window_size = Capybara::Screenshot.window_size 33 | Capybara::Screenshot.window_size = SCREEN_SIZE 34 | 35 | # NOTE: For small screenshots we should have pixel perfect comparisons 36 | @orig_tolerance = Capybara::Screenshot::Diff.tolerance 37 | Capybara::Screenshot::Diff.tolerance = nil 38 | end 39 | 40 | include Capybara::Screenshot::Diff 41 | include CapybaraScreenshotDiff::Minitest::Assertions 42 | 43 | teardown do 44 | # Restore to previous values 45 | Capybara::Screenshot.root = @orig_root 46 | Capybara::Screenshot.save_path = @orig_save_path 47 | Capybara::Screenshot.add_os_path = @orig_add_os_path 48 | Capybara::Screenshot.add_driver_path = @orig_add_driver_path 49 | Capybara::Screenshot.window_size = @orig_window_size 50 | Capybara::Screenshot::Diff.tolerance = @orig_tolerance 51 | Capybara.current_driver = Capybara.default_driver 52 | 53 | if Capybara::Screenshot::Diff.driver == :vips 54 | Vips.cache_set_max(0) 55 | Vips.cache_set_max(1000) 56 | end 57 | end 58 | 59 | private 60 | 61 | def rollback_comparison_runtime_files(screenshot_assert) 62 | comparison = screenshot_assert.is_a?(CapybaraScreenshotDiff::ScreenshotAssertion) ? screenshot_assert.compare : screenshot_assert[2] 63 | return unless comparison 64 | 65 | save_annotations_for_debug(comparison) 66 | 67 | screenshot_path = comparison.image_path 68 | Vcs.restore_git_revision(screenshot_path, root: Capybara::Screenshot.root) 69 | 70 | if comparison.difference 71 | comparison.reporter.clean_tmp_files 72 | end 73 | end 74 | 75 | def save_annotations_for_debug(comparison) 76 | debug_diffs_save_path = Pathname.new(Capybara.save_path) / "screenshots-diffs" / name 77 | debug_diffs_save_path.mkpath unless debug_diffs_save_path.exist? 78 | 79 | if File.exist?(comparison.image_path) 80 | FileUtils.cp(comparison.image_path, debug_diffs_save_path) 81 | end 82 | 83 | if comparison.reporter.annotated_base_image_path.exist? 84 | FileUtils.mv(comparison.reporter.annotated_base_image_path, debug_diffs_save_path, force: true) 85 | end 86 | 87 | if comparison.reporter.annotated_image_path.exist? 88 | FileUtils.mv(comparison.reporter.annotated_image_path, debug_diffs_save_path, force: true) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV["COVERAGE"] 4 | require "simplecov" 5 | SimpleCov.start "test_frameworks" do 6 | enable_coverage :branch 7 | minimum_coverage line: 90, branch: 68 8 | 9 | add_filter("gemfiles") 10 | add_filter("test") 11 | end 12 | end 13 | 14 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 15 | 16 | require "pathname" 17 | TEST_IMAGES_DIR = Pathname.new(File.expand_path("fixtures/images", __dir__)) 18 | 19 | require "support/setup_rails_app" 20 | require "minitest/autorun" 21 | 22 | require "capybara/minitest" 23 | require "support/setup_capybara" 24 | 25 | require "capybara_screenshot_diff/minitest" 26 | 27 | require "support/stub_test_methods" 28 | require "support/setup_capybara_drivers" 29 | require "support/test_helpers" 30 | 31 | Capybara::Screenshot.root = Rails.root 32 | Capybara::Screenshot.save_path = "./doc/screenshots" 33 | 34 | class ActiveSupport::TestCase 35 | include TestHelpers::Assertions 36 | include TestHelpers::DriverSetup 37 | include TestHelpers::TestData 38 | 39 | # Set up fixtures and test helpers 40 | self.file_fixture_path = Pathname.new(File.expand_path("fixtures", __dir__)) 41 | 42 | teardown do 43 | CapybaraScreenshotDiff::SnapManager.cleanup! unless persist_comparisons? 44 | end 45 | 46 | def persist_comparisons? 47 | ENV["DEBUG"] || ENV["DISABLE_ROLLBACK_COMPARISON_RUNTIME_FILES"] || ENV["RECORD_SCREENSHOTS"] 48 | end 49 | 50 | def optional_test 51 | unless ENV["DISABLE_SKIP_TESTS"] 52 | skip "This is optional test! To enable provide DISABLE_SKIP_TESTS=1" 53 | end 54 | end 55 | 56 | private 57 | 58 | def fixture_image_path_from(original_new_image, ext = "png") 59 | file_fixture("images/#{original_new_image}.#{ext}") 60 | end 61 | 62 | def assert_same_images(expected_image_name, image_path) 63 | expected_image_path = file_fixture("comparisons/#{expected_image_name}") 64 | assert_predicate(Capybara::Screenshot::Diff::ImageCompare.new(image_path, expected_image_path), :quick_equal?) 65 | end 66 | 67 | def assert_stored_screenshot(filename) 68 | assert_includes( 69 | CapybaraScreenshotDiff::SnapManager.screenshots, 70 | filename, 71 | "Screenshot #{filename} not found in #{CapybaraScreenshotDiff::SnapManager.instance.root}" 72 | ) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/unit/area_calculator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "capybara/screenshot/diff/area_calculator" 5 | 6 | module Capybara 7 | module Screenshot 8 | module Diff 9 | class AreaCalculatorTest < ActiveSupport::TestCase 10 | class CalculateSkipAreaTest < self 11 | test "#calculate_skip_area returns empty array when no skip areas overlap with crop area" do 12 | skip_area = [[0, 0, 100, 100], [200, 200, 100, 100]] 13 | crop_area = [100, 100, 100, 100] 14 | calculator = AreaCalculator.new(crop_area, skip_area) 15 | 16 | result = calculator.calculate_skip_area 17 | 18 | assert_empty result 19 | end 20 | 21 | test "#calculate_skip_area returns intersecting regions when skip areas overlap with crop area" do 22 | skip_area = [Region.new(50, 50, 150, 150)] 23 | crop_area = Region.new(0, 0, 200, 200) 24 | calculator = AreaCalculator.new(crop_area, skip_area) 25 | 26 | result = calculator.calculate_skip_area 27 | 28 | assert_equal [Region.new(50, 50, 150, 150)], result 29 | end 30 | end 31 | 32 | class InitializationTest < self 33 | test "#initialize handles Region objects for skip areas correctly" do 34 | skip_area = [Region.new(0, 0, 100, 100)] 35 | crop_area = Region.new(0, 0, 200, 200) 36 | 37 | calculator = AreaCalculator.new(crop_area, skip_area) 38 | 39 | assert_equal [Region.new(0, 0, 100, 100)], calculator.calculate_skip_area 40 | end 41 | 42 | test "#initialize converts array coordinates to Region objects" do 43 | skip_area = [[0, 0, 100, 100]] 44 | crop_area = [0, 0, 200, 200] 45 | 46 | calculator = AreaCalculator.new(crop_area, skip_area) 47 | result = calculator.calculate_skip_area 48 | 49 | assert_equal 1, result.size 50 | assert_kind_of Region, result.first 51 | assert_equal [0, 0, 100, 100], 52 | [result.first.left, result.first.top, result.first.right, result.first.bottom] 53 | end 54 | end 55 | 56 | class EdgeCaseTest < self 57 | test "#calculate_skip_area returns empty array when skip_areas is empty" do 58 | calculator = AreaCalculator.new([0, 0, 100, 100], []) 59 | 60 | result = calculator.calculate_skip_area 61 | 62 | assert_empty result 63 | end 64 | 65 | test "#calculate_skip_area returns nil when skip_areas is not provided (nil)" do 66 | calculator = AreaCalculator.new([0, 0, 100, 100], nil) 67 | 68 | result = calculator.calculate_skip_area 69 | 70 | assert_nil result 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/unit/comparison_loader_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "support/test_doubles" 5 | require "capybara/screenshot/diff/comparison_loader" 6 | 7 | module Capybara 8 | module Screenshot 9 | module Diff 10 | class ComparisonLoaderTest < ActiveSupport::TestCase 11 | include TestDoubles 12 | 13 | def setup 14 | @base_path = Pathname.new("base/path.png") 15 | @new_path = Pathname.new("new/path.png") 16 | @options = {tolerance: 0.01} 17 | @driver = TestDriver.new 18 | @loader = ComparisonLoader.new(@driver) 19 | end 20 | 21 | test "#call returns Comparison instance with correct attributes" do 22 | comparison = @loader.call(@base_path, @new_path, @options) 23 | 24 | assert_kind_of Comparison, comparison 25 | assert_equal :base_image, comparison.base_image 26 | assert_equal :new_image, comparison.new_image 27 | assert_equal @options, comparison.options 28 | assert_equal @driver, comparison.driver 29 | end 30 | 31 | test "#call loads base and new images in correct order" do 32 | # Configure the driver to return specific images 33 | images = [:first_image, :second_image] 34 | driver = TestDriver.new(false, images) 35 | loader = ComparisonLoader.new(driver) 36 | 37 | comparison = loader.call(@base_path, @new_path, {}) 38 | 39 | assert_equal :first_image, comparison.base_image 40 | assert_equal :second_image, comparison.new_image 41 | end 42 | 43 | test "#call passes options to the comparison" do 44 | custom_options = {tolerance: 0.05, median_filter_window_size: 3} 45 | comparison = @loader.call(@base_path, @new_path, custom_options) 46 | 47 | assert_equal custom_options, comparison.options 48 | end 49 | 50 | test "#call uses driver to load images with correct paths" do 51 | loader = ComparisonLoader.new(@driver) 52 | loader.call(@base_path, @new_path, {}) 53 | 54 | assert @driver.load_images_called 55 | assert_equal [@base_path, @new_path], @driver.load_images_args 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/unit/difference_finder_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "support/test_doubles" 5 | require "capybara/screenshot/diff/difference_finder" 6 | 7 | module Capybara 8 | module Screenshot 9 | module Diff 10 | class DifferenceFinderTest < ActiveSupport::TestCase 11 | include CapybaraScreenshotDiff::DSLStub 12 | include TestDoubles 13 | 14 | class InitializationTest < self 15 | setup do 16 | @base_path = TestDoubles::TestPath.new(12345) 17 | @new_path = TestDoubles::TestPath.new(54321) 18 | @driver = TestDoubles::TestDriver.new(false) 19 | setup_test_comparison 20 | end 21 | 22 | test "#initialize sets driver and options correctly" do 23 | driver = TestDoubles::TestDriver.new 24 | options = {tolerance: 0.05} 25 | 26 | finder = DifferenceFinder.new(driver, options) 27 | 28 | assert_equal driver, finder.driver 29 | assert_equal options, finder.options 30 | end 31 | end 32 | 33 | class QuickModeTest < self 34 | setup do 35 | @base_path = TestDoubles::TestPath.new(12345) 36 | @new_path = TestDoubles::TestPath.new(54321) 37 | @driver = TestDoubles::TestDriver.new(false) 38 | setup_test_comparison 39 | @finder = create_finder 40 | end 41 | 42 | test "#call in quick_mode returns true with difference when images match exactly" do 43 | setup_driver_results(@driver, same_dimension: true, same_pixels: true) 44 | 45 | result, difference = @finder.call(@comparison, quick_mode: true) 46 | 47 | assert result, "Expected call to return true" 48 | refute_nil difference, "Expected a difference object" 49 | assert_dimension_check_called(@driver) 50 | assert_pixel_check_called(@driver) 51 | end 52 | 53 | test "#call in quick_mode with tolerance returns true when difference is within tolerance" do 54 | test_difference = TestDifference.new(false) # Not different (within tolerance) 55 | setup_driver_results(@driver, same_dimension: true, same_pixels: false, difference_region: test_difference) 56 | 57 | finder = create_finder(tolerance: 0.01) 58 | result, difference = finder.call(@comparison, quick_mode: true) 59 | 60 | assert result, "Expected call to return true when within tolerance" 61 | assert_equal test_difference, difference 62 | end 63 | 64 | test "#call in quick_mode returns false without difference when pixels differ beyond tolerance" do 65 | setup_driver_results(@driver, same_dimension: true, same_pixels: false) 66 | 67 | result, difference = @finder.call(@comparison, quick_mode: true) 68 | 69 | refute result, "Expected call to return false when pixels differ" 70 | assert_nil difference, "Expected no difference object in quick mode" 71 | assert_dimension_check_called(@driver) 72 | assert_pixel_check_called(@driver) 73 | assert_difference_region_called(@driver, 0) 74 | end 75 | end 76 | 77 | class FullModeTest < self 78 | setup do 79 | @base_path = TestDoubles::TestPath.new(12345) 80 | @new_path = TestDoubles::TestPath.new(54321) 81 | @driver = TestDoubles::TestDriver.new(false) 82 | setup_test_comparison 83 | @finder = create_finder 84 | end 85 | 86 | test "#call in full_mode returns failed difference when image dimensions differ" do 87 | setup_driver_results(@driver, same_dimension: false) 88 | 89 | result = @finder.call(@comparison, quick_mode: false) 90 | 91 | assert_instance_of Difference, result 92 | assert result.failed?, "Expected failed result when dimensions differ" 93 | assert_dimension_check_called(@driver) 94 | assert_pixel_check_called(@driver, 0) 95 | end 96 | 97 | test "#call in full_mode returns equal result when images match exactly" do 98 | setup_driver_results(@driver, same_dimension: true, same_pixels: true) 99 | 100 | result = @finder.call(@comparison, quick_mode: false) 101 | 102 | assert_instance_of Difference, result 103 | assert result.equal?, "Expected equal result when pixels match" 104 | assert_dimension_check_called(@driver) 105 | assert_pixel_check_called(@driver) 106 | end 107 | 108 | test "#call in full_mode returns difference when pixels differ beyond tolerance" do 109 | test_difference = TestDifference.new(true) 110 | setup_driver_results(@driver, same_dimension: true, same_pixels: false, difference_region: test_difference) 111 | 112 | result = @finder.call(@comparison, quick_mode: false) 113 | 114 | assert_equal test_difference, result 115 | assert_difference_region_called(@driver) 116 | end 117 | end 118 | 119 | private 120 | 121 | def setup_test_comparison 122 | @comparison = TestDoubles::TestComparison.new 123 | @comparison.base_image_path = @base_path 124 | @comparison.new_image_path = @new_path 125 | end 126 | 127 | def create_finder(options = {}) 128 | DifferenceFinder.new(@driver, options) 129 | end 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/unit/difference_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "capybara/screenshot/diff/difference" 5 | 6 | module Capybara::Screenshot::Diff 7 | class DifferenceTest < ActiveSupport::TestCase 8 | setup do 9 | @difference = Difference.new(nil, {}, nil, {different_dimensions: []}) 10 | end 11 | 12 | test "#different? returns true when images have different dimensions" do 13 | assert_predicate @difference, :different? 14 | end 15 | 16 | test "#failed? returns true when images have different dimensions" do 17 | assert_predicate @difference, :failed? 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/unit/drivers/utils_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "capybara/screenshot/diff/utils" 5 | require "minitest/stub_const" 6 | 7 | module Capybara 8 | module Screenshot 9 | module Diff 10 | class UtilsTest < ActiveSupport::TestCase 11 | test "#detect_available_drivers includes :vips when ruby-vips gem is available" do 12 | skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) 13 | Object.stub :require, ->(gem) { gem == "vips" } do 14 | assert_includes Utils.detect_available_drivers, :vips 15 | end 16 | end 17 | 18 | test "#detect_available_drivers excludes :vips when ruby-vips gem is not available" do 19 | Object.stub_remove_const(:Vips) do 20 | Object.stub :require, ->(gem) { gem != "vips" } do 21 | assert_not_includes Utils.detect_available_drivers, :vips 22 | end 23 | end 24 | end 25 | 26 | test "#detect_available_drivers excludes :vips when system libvips is not installed" do 27 | Object.stub_remove_const(:Vips) do 28 | Object.stub :require, ->(gem) { gem == "vips" && raise(LoadError.new("Could not ... vips")) } do 29 | assert_not_includes Utils.detect_available_drivers, :vips 30 | end 31 | end 32 | end 33 | 34 | test "#detect_available_drivers returns drivers in order of preference when multiple are available" do 35 | Object.stub_consts(Vips: Class.new, ChunkyPNG: Class.new) do 36 | Object.stub :require, true do 37 | assert_equal %i[vips chunky_png], Utils.detect_available_drivers 38 | end 39 | end 40 | end 41 | 42 | test "#detect_available_drivers includes :chunky_png when the gem is available" do 43 | Object.stub :require, ->(gem) { gem == "chunky_png" } do 44 | assert_includes Utils.detect_available_drivers, :chunky_png 45 | end 46 | end 47 | 48 | test "#detect_available_drivers excludes :chunky_png when the gem is not available" do 49 | Object.stub_remove_const(:ChunkyPNG) do 50 | Object.stub :require, ->(gem) { gem != "chunky_png" } do 51 | assert_not_includes Utils.detect_available_drivers, :chunky_png 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/unit/image_preprocessor_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "support/test_doubles" 5 | require "support/test_helpers" 6 | 7 | module Capybara 8 | module Screenshot 9 | module Diff 10 | class ImagePreprocessorTest < ActiveSupport::TestCase 11 | include CapybaraScreenshotDiff::DSLStub 12 | include TestHelpers 13 | 14 | def setup 15 | super 16 | @test_images = [:base_image, :new_image] 17 | @driver = create_test_driver 18 | end 19 | 20 | test "#call returns original images when no preprocessing options are provided" do 21 | preprocessor = ImagePreprocessor.new(@driver, {}) 22 | 23 | result = preprocessor.call(@test_images) 24 | 25 | assert_equal @test_images, result 26 | assert_empty @driver.add_black_box_calls 27 | assert_empty @driver.filter_calls 28 | end 29 | 30 | test "#call applies black box to skip areas when skip_area option is provided" do 31 | skip_area = [{x: 10, y: 20, width: 30, height: 40}] 32 | preprocessor = ImagePreprocessor.new(@driver, skip_area: skip_area) 33 | 34 | result = preprocessor.call(@test_images) 35 | 36 | assert_equal %w[processed_base_image processed_new_image], result 37 | assert_equal 2, @driver.add_black_box_calls.size 38 | 39 | first_call = @driver.add_black_box_calls[0] 40 | second_call = @driver.add_black_box_calls[1] 41 | 42 | assert_equal skip_area.first, first_call[:region] 43 | assert_equal skip_area.first, second_call[:region] 44 | assert_equal :base_image, first_call[:image] 45 | assert_equal :new_image, second_call[:image] 46 | end 47 | 48 | test "#call applies median filter when VipsDriver is available and median_filter_window_size is specified" do 49 | skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) 50 | 51 | @driver = create_test_driver(is_vips: true) 52 | window_size = 3 53 | options = {median_filter_window_size: window_size} 54 | preprocessor = ImagePreprocessor.new(@driver, options) 55 | 56 | result = preprocessor.call(@test_images) 57 | 58 | assert_equal ["filtered_base_image", "filtered_new_image"], result 59 | assert_equal 2, @driver.filter_calls.size 60 | 61 | first_call = @driver.filter_calls[0] 62 | second_call = @driver.filter_calls[1] 63 | 64 | assert_equal window_size, first_call[:size] 65 | assert_equal window_size, second_call[:size] 66 | assert_equal :base_image, first_call[:image] 67 | assert_equal :new_image, second_call[:image] 68 | end 69 | 70 | test "call warns and skips median filter when VipsDriver is not available" do 71 | window_size = 3 72 | options = { 73 | median_filter_window_size: window_size, 74 | image_path: "some/path.png" 75 | } 76 | 77 | expected_warning = /Median filter has been skipped for.*because it is not supported/ 78 | 79 | warning_output = capture_io do 80 | preprocessor = ImagePreprocessor.new(@driver, options) 81 | result = preprocessor.call(@test_images) 82 | 83 | assert_equal @test_images, result 84 | assert_empty @driver.filter_calls 85 | end 86 | 87 | assert_match expected_warning, warning_output.join 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/unit/region_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Capybara::Screenshot::Diff 6 | class RegionTest < ActiveSupport::TestCase 7 | test "#move_by updates region coordinates by specified deltas" do 8 | region = Region.new(10, 10, 10, 10).move_by(-5, -5) 9 | 10 | assert_equal 5, region.x 11 | assert_equal 5, region.y 12 | assert_equal 10, region.width 13 | assert_equal 10, region.height 14 | end 15 | 16 | test "#find_intersect_with returns intersection with another region" do 17 | crop = Region.new(5, 5, 10, 10) 18 | region = Region.new(10, 10, 20, 20).find_intersect_with(crop) 19 | 20 | assert_equal 10, region.x 21 | assert_equal 10, region.y 22 | assert_equal 5, region.width 23 | assert_equal 5, region.height 24 | end 25 | 26 | test "#find_relative_intersect returns intersection with relative coordinates" do 27 | crop = Region.new(5, 5, 10, 10) 28 | 29 | region = crop.find_relative_intersect(Region.new(0, 0, 20, 20)) 30 | 31 | assert_equal 0, region.x 32 | assert_equal 0, region.y 33 | assert_equal 10, region.width 34 | assert_equal 10, region.height 35 | 36 | region = crop.find_relative_intersect(Region.new(10, 10, 20, 20)) 37 | 38 | assert_equal 5, region.x 39 | assert_equal 5, region.y 40 | assert_equal 5, region.width 41 | assert_equal 5, region.height 42 | end 43 | 44 | test ".from_edge_coordinates returns nil when right or bottom is nil" do 45 | assert_nil Region.from_edge_coordinates(0, 0, nil, nil) 46 | end 47 | 48 | test ".from_edge_coordinates returns nil when region has zero or negative dimensions" do 49 | assert_nil Region.from_edge_coordinates(10, 10, 9, 11) 50 | assert_nil Region.from_edge_coordinates(10, 10, 11, 9) 51 | end 52 | 53 | test "#== returns true when comparing with an identical Region" do 54 | assert_equal Region.new(10, 10, 10, 10), Region.new(10, 10, 10, 10) 55 | assert_not_equal Region.new(10, 10, 10, 10), Region.new(10, 10, 10, 11) 56 | end 57 | 58 | test "#== returns true when comparing with equivalent Array of coordinates" do 59 | assert_equal Region.new(10, 10, 10, 10), [10, 10, 10, 10] 60 | assert_not_equal Region.new(10, 10, 10, 10), [10, 10, 10, 11] 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/unit/reporters/default_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "capybara/screenshot/diff/reporters/default" 5 | 6 | unless defined?(Vips) 7 | warn "VIPS not present. Skipping VIPS driver tests." 8 | return 9 | end 10 | require "capybara/screenshot/diff/drivers/vips_driver" 11 | 12 | module Capybara::Screenshot::Diff 13 | class Reporters::DefaultTest < ActiveSupport::TestCase 14 | setup do 15 | @_tmpdir = Pathname.new(Dir.mktmpdir) 16 | end 17 | 18 | teardown do 19 | FileUtils.remove_entry @_tmpdir if @_tmpdir 20 | end 21 | 22 | test "for vips driver generates heatmap diff file" do 23 | skip "VIPS not present. Skipping VIPS driver tests." unless defined?(Vips) 24 | driver = Drivers::VipsDriver.new 25 | comparison = build_comparison_for(driver, "a.png", "b.png") 26 | reporter = Reporters::Default.new(driver.find_difference_region(comparison)) 27 | 28 | reporter.generate 29 | 30 | assert_same_images "a-and-b.heatmap.diff.png", reporter.heatmap_diff_path 31 | end 32 | 33 | private 34 | 35 | def build_comparison_for(driver, *images) 36 | new_image = driver.from_file(TEST_IMAGES_DIR.join(images.first)) 37 | base_image = driver.from_file(TEST_IMAGES_DIR.join(images.last)) 38 | 39 | Comparison.new(new_image, base_image, {}, driver, @_tmpdir / images.first, @_tmpdir / images.last) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/unit/screenshot_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "minitest/mock" 5 | 6 | module Capybara 7 | class ScreenshotTest < ActiveSupport::TestCase 8 | test "SnapManager.root returns an absolute path" do 9 | assert CapybaraScreenshotDiff::SnapManager.root.absolute? 10 | end 11 | 12 | test "Screenshot.root returns a Pathname when Rails.root is a Pathname" do 13 | # NOTE: We test that Rails.root is Pathname, which is true. 14 | assert_kind_of Pathname, Capybara::Screenshot.root 15 | assert Capybara::Screenshot.root.absolute? 16 | end 17 | 18 | test "Screenshot.root can be set to a relative path and is converted to absolute" do 19 | @orig_root = Capybara::Screenshot.root 20 | 21 | Capybara::Screenshot.root = "./tmp" 22 | assert_kind_of Pathname, Capybara::Screenshot.root 23 | assert Capybara::Screenshot.root.absolute? 24 | ensure 25 | Capybara::Screenshot.root = @orig_root if @orig_root 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/screenshoter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "minitest/mock" 5 | 6 | module Capybara 7 | module Screenshot 8 | class ScreenshoterTest < ActiveSupport::TestCase 9 | include CapybaraScreenshotDiff::DSL 10 | include CapybaraScreenshotDiff::DSLStub 11 | 12 | test "#take_screenshot without wait skips image loading" do 13 | screenshoter = Screenshoter.new({wait: nil}, ::Minitest::Mock.new) 14 | 15 | mock = ::Minitest::Mock.new 16 | mock.expect(:save_screenshot, true) { |path| path.include?("01_a.png") } 17 | 18 | BrowserHelpers.stub(:session, mock) do 19 | screenshoter.stub(:process_screenshot, true) do 20 | screenshoter.take_screenshot(Pathname.new("tmp/01_a.png")) 21 | end 22 | end 23 | 24 | assert mock.verify 25 | end 26 | 27 | test "#take_screenshot with custom screenshot options" do 28 | screenshoter = Screenshoter.new( 29 | {wait: nil, capybara_screenshot_options: {full: true}}, 30 | ::Minitest::Mock.new 31 | ) 32 | 33 | mock = ::Minitest::Mock.new 34 | mock.expect(:save_screenshot, true) { |path, options| path.include?("01_a.png") && options[:full] } 35 | 36 | BrowserHelpers.stub(:session, mock) do 37 | screenshoter.stub(:process_screenshot, true) do 38 | screenshoter.take_screenshot(Pathname.new("tmp/01_a.png")) 39 | end 40 | end 41 | 42 | assert mock.verify 43 | end 44 | 45 | test "#prepare_page_for_screenshot without wait does not raise any error" do 46 | screenshoter = Screenshoter.new({wait: nil}, ::Minitest::Mock.new) 47 | 48 | assert_nil screenshoter.prepare_page_for_screenshot(timeout: nil) # does not raise an error 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/unit/snap_manager_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module CapybaraScreenshotDiff 6 | class SnapManagerTest < ActiveSupport::TestCase 7 | setup do 8 | @manager = SnapManager.new(Dir.mktmpdir("snap_diff-storage")) 9 | end 10 | 11 | teardown do 12 | @manager.cleanup! 13 | end 14 | 15 | test "#provision_snap_with copies the file to the snap path" do 16 | snap = @manager.snapshot("test_image") 17 | path = fixture_image_path_from("a") 18 | 19 | @manager.provision_snap_with(snap, path) 20 | 21 | assert_predicate snap.path, :exist? 22 | assert_not_predicate snap.base_path, :exist? 23 | end 24 | 25 | test "#provision_snap_with populate the base version of the snapshot" do 26 | snap = @manager.snapshot("test_image") 27 | path = fixture_image_path_from("a") 28 | 29 | @manager.provision_snap_with(snap, path, version: :base) 30 | 31 | assert_not_predicate snap.path, :exist? 32 | assert_predicate snap.base_path, :exist? 33 | end 34 | 35 | test "#screenshots_dir returns all created snapshots" do 36 | assert_equal [], @manager.snapshots.to_a 37 | 38 | snap = @manager.snapshot("test_image") 39 | path = fixture_image_path_from("a") 40 | 41 | @manager.provision_snap_with(snap, path) 42 | assert_equal [snap], @manager.snapshots.to_a 43 | end 44 | 45 | test "#screenshots_dir ignores attempts" do 46 | assert_equal [], @manager.snapshots.to_a 47 | 48 | snap = @manager.snapshot("test_image") 49 | path = fixture_image_path_from("a") 50 | 51 | @manager.provision_snap_with(snap, path, version: :attempt) 52 | 53 | assert_equal [snap], @manager.snapshots.to_a 54 | end 55 | 56 | test "#snapshot overrides the file extension" do 57 | snap = @manager.snapshot("test_image") 58 | assert_equal "test_image", snap.full_name 59 | assert_includes snap.path.to_s, "test_image.png" 60 | assert_includes snap.base_path.to_s, "test_image.base.png" 61 | assert_includes snap.next_attempt_path!.to_s, "test_image.attempt_00.png" 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/unit/stable_screenshoter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | class StableScreenshoterTest < ActiveSupport::TestCase 9 | include CapybaraScreenshotDiff::DSLStub 10 | 11 | setup do 12 | @manager = CapybaraScreenshotDiff::SnapManager.new(Capybara::Screenshot.root / "stable_screenshoter_test") 13 | @manager.create_output_directory_for 14 | end 15 | 16 | teardown do 17 | @manager.cleanup! 18 | end 19 | 20 | test "#take_stable_screenshot retries until images are stable across iterations" do 21 | image_compare_stub = build_image_compare_stub 22 | 23 | mock = ::Minitest::Mock.new(image_compare_stub) 24 | 25 | mock.expect(:quick_equal?, false) 26 | mock.expect(:quick_equal?, false) 27 | mock.expect(:quick_equal?, true) 28 | 29 | ImageCompare.stub :new, mock do 30 | snap = @manager.snapshot("02_a") 31 | take_stable_screenshot_with(snap) 32 | end 33 | 34 | assert mock.verify 35 | end 36 | 37 | test "#take_stable_screenshot raises ArgumentError when wait parameter is nil" do 38 | assert_raises ArgumentError, "wait should be provided" do 39 | take_stable_screenshot_with(@manager.snapshot("02_a"), wait: nil) 40 | end 41 | end 42 | 43 | test "#take_stable_screenshot raises ArgumentError when stability_time_limit is nil" do 44 | assert_raises ArgumentError, "stability_time_limit should be provided" do 45 | take_stable_screenshot_with(@manager.snapshot("02_a"), stability_time_limit: nil) 46 | end 47 | end 48 | 49 | test "#take_comparison_screenshot cleans up temporary files after successful comparison" do 50 | image_compare_stub = build_image_compare_stub 51 | 52 | mock = ::Minitest::Mock.new(image_compare_stub) 53 | mock.expect(:quick_equal?, false) 54 | mock.expect(:quick_equal?, true) 55 | 56 | snap = @manager.snapshot("02_a") 57 | assert_not_predicate snap.path, :exist? 58 | 59 | ImageCompare.stub :new, mock do 60 | Capybara::Screenshot::Diff::StableScreenshoter 61 | .new({stability_time_limit: 0.5, wait: 1}, image_compare_stub.driver_options) 62 | .take_comparison_screenshot(snap) 63 | end 64 | 65 | mock.verify 66 | assert_empty snap.find_attempts_paths 67 | assert_predicate snap.path, :exist? 68 | assert_not_predicate snap.path.size, :zero? 69 | end 70 | 71 | test "#take_comparison_screenshot raises UnstableImage when stability timeout is reached" do 72 | snap = @manager.snapshot("01_a") 73 | 74 | screenshot_path = snap.path 75 | 76 | # Stub annotated files for generated comparison annotations 77 | # We need to have different from screenshot_path name because of other stubs 78 | pseudo_snap_for_annotations = @manager.snapshot("02_a") 79 | annotated_screenshot_path = pseudo_snap_for_annotations.path 80 | annotated_attempts_paths = [ 81 | [annotated_screenshot_path.sub_ext(".attempt_01.latest.png"), annotated_screenshot_path.sub_ext(".attempt_01.committed.png")], 82 | [annotated_screenshot_path.sub_ext(".attempt_02.latest.png"), annotated_screenshot_path.sub_ext(".attempt_02.committed.png")] 83 | ] 84 | 85 | FileUtils.touch(annotated_attempts_paths) 86 | 87 | mock = ::Minitest::Mock.new(build_image_compare_stub(equal: false)) 88 | annotated_attempts_paths.reverse_each do |(actual_path, base_path)| 89 | mock.reporter.expect(:annotated_image_path, actual_path.to_s) 90 | mock.reporter.expect(:annotated_base_image_path, base_path.to_s) 91 | end 92 | 93 | assert_raises CapybaraScreenshotDiff::UnstableImage, "Could not get stable screenshot within 1s" do 94 | ImageCompare.stub :new, mock do 95 | # Wait time is less then stability time, which will generate problem 96 | Capybara::Screenshot::Diff::StableScreenshoter 97 | .new({stability_time_limit: 0.5, wait: 1}, build_image_compare_stub(equal: false).driver_options) 98 | .take_comparison_screenshot(snap) 99 | end 100 | end 101 | 102 | mock.verify 103 | mock.reporter.verify 104 | 105 | # There are no runtime files to find difference on stabilization 106 | assert_empty Dir["tmp/*_a*.latest.png"] 107 | assert_empty Dir["tmp/*_a*.committed.png"] 108 | 109 | # All stabilization files should be annotated 110 | last_annotation = screenshot_path.sub_ext(".attempt_02.png") 111 | assert_equal 0, last_annotation.size, "#{last_annotation.to_path} should be override with annotated version" 112 | last_annotation = screenshot_path.sub_ext(".attempt_01.png") 113 | assert_equal 0, last_annotation.size, "#{last_annotation.to_path} should be override with annotated version" 114 | ensure 115 | snap&.delete! 116 | pseudo_snap_for_annotations&.delete! 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/unit/vcs_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module Capybara 6 | module Screenshot 7 | module Diff 8 | class VcsTest < ActiveSupport::TestCase 9 | include Vcs 10 | 11 | setup do 12 | @base_screenshot = Tempfile.new(%w[vcs_base_screenshot. .attempt.0.png], Screenshot.root) 13 | end 14 | 15 | teardown do 16 | if @base_screenshot.is_a?(Tempfile) 17 | @base_screenshot.close 18 | @base_screenshot.unlink 19 | end 20 | end 21 | 22 | test "#restore_git_revision checks out and verifies the original screenshot" do 23 | screenshot_path = file_fixture("images/a.png") 24 | 25 | base_screenshot_path = Pathname.new(@base_screenshot.path) 26 | assert Vcs.restore_git_revision(screenshot_path, base_screenshot_path, root: Screenshot.root) 27 | 28 | assert base_screenshot_path.exist? 29 | assert_equal screenshot_path.size, base_screenshot_path.size 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-diff/snap_diff-capybara/74fc271455e2fc2a6a3aeb23a3dbca3e2243e969/tmp/.keep --------------------------------------------------------------------------------