diff --git a/.github/actions/upload-screenshots/action.yml b/.github/actions/upload-screenshots/action.yml index 49930b0b..142b559b 100644 --- a/.github/actions/upload-screenshots/action.yml +++ b/.github/actions/upload-screenshots/action.yml @@ -1,23 +1,72 @@ --- -name: 'Upload screenshots for debug' -description: 'To reproduce the issue locally, download the screenshots from the failed test' +name: 'Upload SnapDiff screenshots' +description: 'Upload screenshot diffs and HTML report as CI artifacts' inputs: name: - description: 'Customize the name of the artifact' + description: 'Artifact name prefix' required: true + report-path: + description: 'Path to the HTML report directory' + default: 'tmp/snap_diff' + retention-days: + description: 'Number of days to retain artifacts' + default: '2' +outputs: + report-url: + description: 'Direct URL to the inline HTML report artifact' + value: ${{ steps.upload-report.outputs.artifact-url }} + report-full-url: + description: 'Direct URL to the full report artifact (with images)' + value: ${{ steps.upload-report-full.outputs.artifact-url }} runs: using: 'composite' steps: - - uses: actions/upload-artifact@v6 + - name: Upload screenshot diffs + uses: actions/upload-artifact@v7 with: name: ${{ inputs.name }}-diffs - retention-days: 1 - path: | - test/fixtures/app/doc/screenshots/ + retention-days: ${{ inputs.retention-days }} + path: test/fixtures/app/doc/screenshots/ + if-no-files-found: ignore - - uses: actions/upload-artifact@v6 + - name: Upload Capybara failure screenshots + uses: actions/upload-artifact@v7 with: name: ${{ inputs.name }}-capybara-fails - retention-days: 1 - path: | - tmp/capybara/screenshots-diffs/ + retention-days: ${{ inputs.retention-days }} + path: tmp/capybara/screenshots-diffs/ + if-no-files-found: ignore + + - name: Check for HTML report + id: check-report + shell: bash + run: | + if [ -f "${{ inputs.report-path }}/index.html" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare HTML report for inline preview + if: steps.check-report.outputs.exists == 'true' + shell: bash + run: cp "${{ inputs.report-path }}/index.html" "${{ inputs.report-path }}/${{ inputs.name }}-snap_diff-report.html" + + - name: Upload HTML report (inline preview) + id: upload-report + if: steps.check-report.outputs.exists == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }}-snap_diff-report + retention-days: ${{ inputs.retention-days }} + path: ${{ inputs.report-path }}/${{ inputs.name }}-snap_diff-report.html + archive: false + + - name: Upload HTML report with images (full download) + id: upload-report-full + if: steps.check-report.outputs.exists == 'true' + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }}-report-full + retention-days: ${{ inputs.retention-days }} + path: ${{ inputs.report-path }}/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c02c18a..bb87aed7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,12 +5,21 @@ name: Test on: push: branches: [ master ] + paths: + - '**.gemfile' + - '**.rb' + - '.github/workflows/**' + - '.github/actions/**' + - 'Gemfile*' + - '!bin/**' pull_request: - type: [ opened, synchronize, reopened, review_requested ] + types: [ opened, synchronize, reopened, review_requested ] paths: - '**.gemfile' - '**.rb' - '.github/workflows/**' + - '.github/actions/**' + - 'Gemfile*' - '!bin/**' workflow_dispatch: @@ -52,6 +61,9 @@ jobs: name: Functional Test runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + pull-requests: write steps: - name: Checkout code @@ -70,9 +82,46 @@ jobs: - uses: ./.github/actions/upload-screenshots if: failure() + id: upload-screenshots with: name: base-screenshots + - name: Find existing report comment + if: always() && github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'Screenshot diffs' + + - name: Comment PR with report link + if: failure() && github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + ### Screenshot diffs detected + + | Artifact | Link | + |----------|------| + | HTML report (inline preview) | ${{ steps.upload-screenshots.outputs.report-url || 'N/A' }} | + | Full report with images | ${{ steps.upload-screenshots.outputs.report-full-url || 'N/A' }} | + | [All artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts) | Browse all | + + - name: Update comment on success + if: success() && github.event_name == 'pull_request' && steps.find-comment.outputs.comment-id != '' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + ### Screenshot diffs resolved + + All screenshots match their baselines. Previous diffs have been fixed. + - name: Uploading Coverage Report uses: actions/upload-artifact@v7 with: @@ -170,6 +219,26 @@ jobs: SCREENSHOT_DRIVER: ${{ matrix.screenshot-driver }} - uses: ./.github/actions/upload-screenshots - if: always() + if: failure() with: name: screenshots-${{ matrix.capybara-driver }}-${{ matrix.screenshot-driver }} + + test-report-upload: + name: Test Report Upload + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-ruby-and-dependencies + with: + ruby-version: "4.0" + cache-apt-packages: true + + - name: Generate sample report + run: bin/rake 'report:sample[embed]' + + - uses: ./.github/actions/upload-screenshots + with: + name: test-report diff --git a/.gitignore b/.gitignore index 46e68f0c..c4a033f2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ *.base.webp .ruby-version .ai/ +.qwen/ +.claude/ .specs/ .emdash.json CLAUDE.md diff --git a/README.md b/README.md index e41653e5..0d21bcc0 100644 --- a/README.md +++ b/README.md @@ -1,927 +1,144 @@ +[![Gem Version](https://badge.fury.io/rb/capybara-screenshot-diff.svg)](https://rubygems.org/gems/capybara-screenshot-diff) [![Test](https://github.com/snap-diff/snap_diff-capybara/actions/workflows/test.yml/badge.svg)](https://github.com/snap-diff/snap_diff-capybara/actions/workflows/test.yml) [![DeepWiki](https://img.shields.io/badge/DeepWiki-snap--diff%2Fsnap__diff--capybara-blue.svg?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTEyIDJhMTAgMTAgMCAxIDAgMCAyMCAxMCAxMCAwIDAgMCAwLTIweiIvPjxwYXRoIGQ9Ik0xMiA2djEyIi8+PHBhdGggZD0iTTYgMTJoMTIiLz48L3N2Zz4=)](https://deepwiki.com/snap-diff/snap_diff-capybara) # Capybara::Screenshot::Diff -Ever wondered what your project looked like two years ago? To answer that, you -start taking screen shots during your tests. Capybara provides the -`save_screenshot` method for this. Very good. +Catch visual regressions before they ship. Screenshots taken during tests are automatically compared against committed baselines — if the UI changed, the test fails. -Ever introduced a graphical change unintended? Never want it to happen again? -Then this gem is for you! Use this gem to detect changes in your pages by -taking screen shots and comparing them to the previous revision. - -## Quick Start (60 seconds) - -```ruby -# 1. Add to Gemfile -gem 'capybara-screenshot-diff' - -# 2. Add to test/test_helper.rb -require 'capybara_screenshot_diff/minitest' - -# 3. In any system test -class MyTest < ActionDispatch::SystemTestCase - include CapybaraScreenshotDiff::DSL - include CapybaraScreenshotDiff::Minitest::Assertions - - test "homepage" do - visit "/" - screenshot "homepage" # First run: saves baseline. Next runs: compares. - end -end -``` - -That's it. Screenshots are saved to `doc/screenshots/` and compared against the committed version on each test run. - -## Features - -- **Screenshot Capturing**: Easily capture screenshots at any point in your tests to track the visual state of your application. -- **Visual Change Detection**: Automatically detect changes in screenshots taken across test runs to identify unintended visual modifications. -- **RSpec & Minitest Integration**: Utilize additional assertions and allow to run comparison after test body completed. - - -## Installation - -Add these lines to your application's Gemfile: +## Quick Start ```ruby +# Gemfile gem 'capybara-screenshot-diff' -gem 'oily_png', platform: :ruby ``` -Minitest users will want to do the following to avoid a deprecation warning: - ```ruby -gem 'capybara-screenshot-diff', require: false -gem 'oily_png', platform: :ruby +# test/test_helper.rb +require 'capybara_screenshot_diff/minitest' ``` -And then execute: - - $ bundle - -Or install it yourself as: - - $ gem install capybara-screenshot-diff - -### Requirements - -`capybara-screenshot-diff` supports the currently supported versions of Ruby and Rails: - -* Ruby 3.2+ (https://www.ruby-lang.org/en/downloads/branches/) -* Rails 7.1+ (https://guides.rubyonrails.org/maintenance_policy.html) - -* [for :vips driver] libvips 8.9 or later, see the [libvips install instructions](https://libvips.github.io/libvips/install.html) - -## Usage - -### Including DSL - -To use the screenshot capturing and change detection features in your tests, include the `CapybaraScreenshotDiff::DSL` in your test classes. It provides the `screenshot` method to capture and compare screenshots. - -There are different modules for different testing frameworks integrations. - -### Minitest - -For Minitest, need to require `capybara_screenshot_diff/minitest`. -In your test class, include the `CapybaraScreenshotDiff::Minitest::Assertions` module: - ```ruby -require 'capybara_screenshot_diff/minitest' - +# test/application_system_test_case.rb class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - # Make the Capybara & Capybara Screenshot Diff DSLs available in tests - include CapybaraScreenshotDiff::DSL - # Make `assert_*` methods behave like Minitest assertions include CapybaraScreenshotDiff::Minitest::Assertions - - def test_my_feature - visit '/' - assert_matches_screenshot 'index' - end -end -``` - -### RSpec - -To use the screenshot capturing and change detection features in your tests, -include the `CapybaraScreenshotDiff::DSL` in your test classes. -It adds `match_screenshot` matcher to RSpec. - -> **Important**: -> The `CapybaraScreenshotDiff::DSL` is automatically included in all feature and system tests by default. - - -```ruby -require 'capybara_screenshot_diff/rspec' - -describe 'Permissions admin', type: :feature do - it 'works with permissions' do - visit('/') - expect(page).to match_screenshot('home_page') - end -end - - -describe 'Permissions admin', type: :non_feature do - include CapybaraScreenshotDiff::DSL - - it 'works with permissions' do - visit('/') - expect(page).to match_screenshot('home_page') - end -end -``` -### Cucumber - -Load Cucumber support by adding the following line (typically to your `features/support/env.rb` file): - -```ruby -require 'capybara_screenshot_diff/cucumber' -``` - -And in the steps you can use: - -```ruby -Then('I should not see any visual difference') do - screenshot 'homepage' -end -``` - -### Quick Setup - -Configure all settings in one place using the `configure` helper: - -```ruby -# In test_helper.rb or rails_helper.rb -Capybara::Screenshot::Diff.configure do |screenshot, diff| - screenshot.window_size = [1280, 1024] - screenshot.stability_time_limit = 1 - screenshot.blur_active_element = true - screenshot.hide_caret = true - diff.driver = :vips - diff.tolerance = 0.0005 - diff.color_distance_limit = 15 -end -``` - -**Note:** `fail_if_new` defaults to `true` in CI environments (when `ENV['CI']` is set). New screenshots are allowed locally but rejected in CI — no configuration needed. - -**Note:** Setting `Capybara::Screenshot.enabled = false` is sufficient to disable all screenshots. There is no need to define no-op modules or monkey-patch the gem. - -### Recommended tolerance values - -| Use Case | VIPS `tolerance` | ChunkyPNG `color_distance_limit` | `stability_time_limit` | -|----------|-----------------|--------------------------------|----------------------| -| Animated/complex pages | 0.01 | 30 | 2s | -| Standard Rails apps | 0.0005 | 15 | 1s | -| Pixel-perfect design tests | 0.0001 | 5 | 1s | - -### Configuration Tiers - -**Tier 1 — Zero config (works immediately):** -`blur_active_element`, `hide_caret`, and `fail_if_new` (in CI) are enabled by default. -Just `require 'capybara_screenshot_diff/minitest'` and call `screenshot`. - -**Tier 2 — Set when tests are flaky:** - -| Setting | When to use | -|---------|-------------| -| `window_size` | Screenshots differ between machines due to different browser sizes | -| `tolerance` | Sub-pixel rendering differences cause false positives | -| `skip_area` | Dynamic content (timestamps, ads) changes between runs | -| `stability_time_limit` | Animations or loading states cause inconsistent captures | - -**Tier 3 — Advanced tuning:** - -| Setting | When to use | -|---------|-------------| -| `perceptual_threshold` | Anti-aliasing false positives across OS/browser versions | -| `shift_distance_limit` | Content shifts by a few pixels (ChunkyPNG only) | -| `area_size_limit` | Allow small diff regions below a pixel count | -| `color_distance_limit` | Fine-tune raw RGB channel tolerance | -| `median_filter_window_size` | Smooth noise before comparison (VIPS only) | - -### Standalone image comparison - -Compare any two images without Capybara or a browser — useful for PDF regression, generated images, or CI artifact validation: - -```ruby -result = Capybara::Screenshot::Diff.compare("baseline.png", "current.png") -result.quick_equal? # => true if byte-identical -result.different? # => true if visually different (respects tolerance) - -# With options -result = Capybara::Screenshot::Diff.compare("baseline.pdf.png", "current.pdf.png", - driver: :vips, - tolerance: 0.001, - perceptual_threshold: 2.0 -) -``` - -### Perceptual color comparison (VIPS only) - -By default, color differences are measured using raw RGB channel distance. This can produce -false positives from anti-aliasing and sub-pixel font rendering — the same page rendered on -different OS versions or browsers will have slightly different pixel values at text edges. - -The `perceptual_threshold` option uses the CIE dE00 formula instead, which measures color -difference the way human eyes perceive it. Anti-aliasing artifacts typically score below 2.0 -on the dE00 scale and are automatically ignored. - -```ruby -# Per-screenshot: ignore anti-aliasing, catch real visual changes -screenshot 'dashboard', perceptual_threshold: 2.0 - -# Global: apply to all screenshots -Capybara::Screenshot::Diff.perceptual_threshold = 2.0 - -# dE00 scale reference: -# < 1.0 — not perceptible by human eyes -# 1-2 — perceptible through close observation (anti-aliasing, font hinting) -# 2-10 — perceptible at a glance (color shifts, layout changes) -# > 10 — clearly different colors -``` - -Use `perceptual_threshold` when you see false positives from font rendering differences across -CI environments, or when `color_distance_limit` with raw RGB requires frequent tuning. - -**Note:** `perceptual_threshold` and `color_distance_limit` are independent options on different -scales. `perceptual_threshold` uses dE00 (0-100+), `color_distance_limit` uses Euclidean RGB -distance (0-441). Set one or the other, not both. - -### Taking screenshots - -Add `screenshot ''` to your tests. The screenshot will be saved in -the `doc/screenshots` directory. - -Change your existing `save_screenshot` calls to `screenshot` - -```ruby -test 'my useful feature' do - visit '/' - screenshot 'welcome_index' - click_button 'Useful feature' - screenshot 'feature_index' - click_button 'Perform action' - screenshot 'action_performed' -end -``` - -This will produce a sequence of images like this - -``` -doc - screenshots - action_performed - feature_index - welcome_index -``` - -To store the screen shot history, add the `doc/screenshots` directory to your -version control system (git, svn, etc). - - Screen shots are compared to the previously COMMITTED version of the same screen shot. - -### Screenshot groups - -Commonly it is useful to group screenshots around a feature, and record them as -a sequence. To do this, add a `screenshot_group` call to the start of your -test. - -```ruby -test 'my useful feature' do - screenshot_group 'useful_feature' - visit '/' - screenshot 'welcome_index' - click_button 'Useful feature' - screenshot 'feature_index' - click_button 'Perform action' - screenshot 'action_performed' end ``` -This will produce a sequence of images like this - -``` -doc - screenshots - useful_feature - 00-welcome_index - 01-feature_index - 02-action_performed -``` - -**All files in the screenshot group directory will be deleted when -`screenshot_group` is called.** - - -#### Screenshot sections - -You can introduce another level above the screenshot group called a -`screenshot_section`. The section name is inserted just before the group name -in the save path. If called in the setup of the test, all screenshots in -that test will get the same prefix: - -```ruby -setup do - screenshot_section 'my_feature' -end - -test 'my subfeature' do - screenshot_group 'subfeature' - visit '/feature' - click_button 'Interesting button' - screenshot 'subfeature_index' - click_button 'Perform action' - screenshot 'action_performed' -end -``` - -This will produce a sequence of images like this - -``` -doc - screenshots - my_feature - subfeature - 00-subfeature_index - 01-action_performed -``` - - -#### Setting `screenshot_section` and/or `screenshot_group` for all tests - -Setting the `screenshot_section` and/or `screenshot_group` for all tests can be -done in the super class setup: - ```ruby -class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - setup do - screenshot_section class_name.underscore.sub(/(_feature|_system)?_test$/, '') - screenshot_group name[5..-1] +# test/system/homepage_test.rb +class HomepageTest < ApplicationSystemTestCase + test "homepage" do + visit "/" + screenshot "homepage" end end ``` -`screenshot_section` and/or `screenshot_group` can still be overridden in each -test. - - -### Capturing one area instead of the whole page - -You can crop images before comparison to be run, by providing region to crop as `[left, top, right, bottom]` or by css selector like `body .tag` - -```ruby -test 'the cool' do - visit '/feature' - screenshot 'cool_element', crop: '#my_element' -end -``` - -**Note:** When using a retina device screenshots dimensions might be off. If -you are using (headless) chrome you can prevent this by setting the -`force-device-scale-factor` argument to `1`. - -For Rails system specs using selenium you can do so for example by using the -following snippet: - -```ruby -driven_by :selenium, using: :chrome_headless do |options| - options.args << '--force-device-scale-factor=1' -end -``` - -### Multiple Capybara drivers - -Often it is useful to test your app using different browsers. To avoid the -screenshots for different Capybara drivers to overwrite each other, set - -```ruby -Capybara::Screenshot.add_driver_path = true -``` - -The example above will then save your screenshots like this -(for poltergeist and selenium): - -``` -doc - screenshots - poltergeist - useful_feature - 00-welcome_index - 01-feature_index - 02-action_performed - selenium - useful_feature - 00-welcome_index - 01-feature_index - 02-action_performed -``` - -### Multiple OSs - -If you run your tests on multiple operating systems, you will most likely find -the screen shots differ. To avoid the screenshots for different OSs to -overwrite each other, set - -```ruby -Capybara::Screenshot.add_os_path = true -``` - -The example above will then save your screenshots like this -(for Linux and Windows): - -``` -doc - screenshots - linux - useful_feature - 00-welcome_index - 01-feature_index - 02-action_performed - windows - useful_feature - 00-welcome_index - 01-feature_index - 02-action_performed -``` - -If you combine this config with the `add_driver_path` config, the driver will be -put in front of the OS name. - -### Screen size - -You can specify the desired screen size using - -```ruby -Capybara::Screenshot.window_size = [1024, 768] -``` - -This will force the screen shots to the given size, and skip taking screen shots -unless the desired window size can be achieved. - -### Disabling screen shots - -If you want to skip taking screen shots, set - -```ruby -Capybara::Screenshot.enabled = false -``` - -You can of course set this by an environment variable - -```ruby -Capybara::Screenshot.enabled = ENV['TAKE_SCREENSHOTS'] -``` - -### Disabling diff - -If you want to skip the assertion for change in the screen shot, set - -```ruby -Capybara::Screenshot::Diff.enabled = false -``` - -Using an environment variable - -```ruby -Capybara::Screenshot::Diff.enabled = ENV['COMPARE_SCREENSHOTS'] -``` - -### Tolerate screenshot differences - -To allow screenshot differences, but still fail on functional errors, you can set the following option: - -```ruby -Capybara::Screenshot::Diff.fail_on_difference = false -``` - -It defaults to `true`. This can be useful in continuous integration to a generate a screenshot difference -report while still reporting functional errors. - -### Does not tolerate new screenshots - -To fail the test if a new screenshot is taken, set the following option: - -```ruby -Capybara::Screenshot::Diff.fail_if_new = true -``` - -If `fail_if_new` is set to `true`, the test will fail if a new screenshot is taken -that does not have a corresponding previous image to compare against. -This can be useful in situations where you want to ensure -that every screenshot taken by your tests corresponds to an expected state of your application. - -### Screen shot save path - -By default, `Capybara::Screenshot::Diff` saves screenshots to a -`doc/screenshots` folder, relative to either `Rails.root` (if you're in Rails), -or your current directory otherwise. - -If you want to change where screenshots are saved to, then there are two -configuration options that that are relevant. - -The most likely one you'll want to modify is ... - -```ruby -Capybara::Screenshot.save_path = "other/path" -``` - -The `save_path` option is relative to `Capybara::Screenshot.root`. - -`Capybara::Screenshot.root` defaults to either `Rails.root` (if you're in -Rails) or your current directory. You can change it to something entirely -different if necessary, such as when using an alternative web framework. - -```ruby -Capybara::Screenshot.root = Hanami.root -``` - -### Screen shot stability - -To ensure that animations are finished before saving a screen shot, you can add -a stability time limit. If the stability time limit is set, a second screen -shot will be taken and compared to the first. This is repeated until two -subsequent screen shots are identical. - -```ruby -Capybara::Screenshot.stability_time_limit = 0.1 -``` - -This can be overridden on a single screenshot: - -```ruby -test 'stability_time_limit' do - visit '/' - screenshot 'index', stability_time_limit: 0.5 -end -``` - -### Maximum wait limit - -When the `stability_time_limit` is set, but no stable screenshot can be taken, a timeout occurs. -The timeout occurs after `Capybara.default_max_wait_time`, but can be overridden by an option. - -```ruby -test 'max wait time' do - visit '/' - screenshot 'index', wait: 20.seconds -end -``` - -### Hiding the caret for active input elements - -In Chrome the screenshot includes the blinking input cursor. This can make it impossible to get a -stable screenshot. To get around this you can set the `hide caret` option: - -```ruby -Capybara::Screenshot.hide_caret = true -``` - -This will make the cursor (caret) transparent (invisible), so the blinking does not delay the screen shot. - - -### Removing focus from the active element - -Another way to avoid the cursor blinking is to set the `blur_active_element` option: - -```ruby -Capybara::Screenshot.blur_active_element = true -``` - -This will remove the focus from the active element, removing the blinking cursor. - - - -### Allowed color distance - -Sometimes you want to allow small differences in the images. For example, Chrome renders the same -page slightly differently sometimes. You can set set the color difference threshold for the -comparison using the `color_distance_limit` option to the `screenshot` method: - -```ruby -test 'color threshold' do - visit '/' - screenshot 'index', color_distance_limit: 30 -end -``` - -The difference is calculated as the eucledian distance. You can also set this globally: - -```ruby -Capybara::Screenshot::Diff.color_distance_limit = 42 -``` - - -### Allowed shift distance - -Sometimes you want to allow small movements in the images. For example, jquer-tablesorter -renders the same table slightly differently sometimes. You can set set the shift distance -threshold for the comparison using the `shift_distance_limit` option to the `screenshot` -method: - -```ruby -test 'color threshold' do - visit '/' - screenshot 'index', shift_distance_limit: 2 -end -``` - -The difference is calculated as maximum distance in either the X or the Y axis. -You can also set this globally: - -```ruby -Capybara::Screenshot::Diff.shift_distance_limit = 1 -``` - -**Note:** For each increase in `shift_distance_limit` more pixels are searched for a matching color value, and -this will impact performance **severely** if a match cannot be found. - -If `shift_distance_limit` is `nil` shift distance is not measured. If `shift_distance_limit` is set, -even to `0`, shift distance is measured and reported on image differences. - -### Allowed difference size - -You can set set a threshold for the differing area size for the comparison -using the `area_size_limit` option to the `screenshot` method: - -```ruby -test 'area threshold' do - visit '/' - screenshot 'index', area_size_limit: 17 -end -``` - -The difference is calculated as `width * height`. You can also set this globally: - -```ruby -Capybara::Screenshot::Diff.area_size_limit = 42 -``` - - -### Skipping an area - -Sometimes you have expected change that you want to ignore. -You can use the `skip_area` option with `[left, top, right, bottom]` -or css selector like `'#footer'` or `'.container .skipped_element'` to the `screenshot` method to ignore an area. -Be aware that if the selector is not in the page then the library will wait the default wait time for it to appear. -Therefore, it is best to only use css selectors for skip_areas you know will be in the page: - -```ruby -test 'unstable area' do - visit '/' - screenshot 'index', skip_area: [[17, 6, 27, 16], '.container .skipped_element', '#footer'] -end -``` - -The arguments are `[left, top, right, bottom]` for the area you want to ignore. You can also set this globally: - -```ruby -Capybara::Screenshot::Diff.skip_area = [0, 0, 64, 48] -``` - -If you need to ignore multiple areas: - -```ruby -screenshot 'index', skip_area: [[0, 0, 64, 48], [17, 6, 27, 16], 'css_selector .element'] +```bash +bundle exec rake test # First run always passes — saves baselines +bundle exec rake test # Second run compares against baselines +git add doc/screenshots/ +git commit -m "chore: add screenshot baselines" ``` -### Available Image Processing Drivers - -There are several image processing supported by this gem. -There are several options to setup active driver: `:auto`, `:chunky_png` and `:vips`. - -* `:auto` - will try to load `:vips` if there is gem `ruby-vips`, in other cases will load `:chunky_png` -* `:chunky_png` and `:vips` will load correspondent driver - -### Enable VIPS image processing - -[Vips](https://www.rubydoc.info/gems/ruby-vips/Vips/Image) driver provides a faster comparison, -and could be enabled by adding `ruby-vips` to `Gemfile`. - -If need to setup explicitly Vips driver, there are several ways to do this: - -* Globally: `Capybara::Screenshot::Diff.driver = :vips` -* Per screenshot option: `screenshot 'index', driver: :vips` - -With enabled VIPS there are new alternatives to process differences, which are easier to find and support. -For example, `shift_distance_limit` is a very heavy operation. Instead, use `median_filter_window_size`. +That's it. The first run saves baseline screenshots (always passes). Subsequent runs compare against them — if the UI changed, the test fails. Commit baselines to git so CI catches regressions. -#### Tolerance level (vips only) +> **CI note:** In CI, `fail_if_new` is `true` by default — new screenshots without a committed baseline will fail. Always commit your baselines before pushing. -You can set a “tolerance” anywhere from 0% to 100%. This is the amount of change that's allowable. -If the screenshot has changed by more than that amount, it'll flag it as a failure. +For RSpec, Cucumber, or non-Rails setup, see [Framework Setup](docs/framework-setup.md). -This is alternative to "Allowed difference size", only the difference that area calculates including valid pixels. -But "tolerance" compares only different pixels. +## What You Get -You can use the `tolerance` option to the `screenshot` method to set level: +When a screenshot differs, the test fails with a clear message: -```ruby -test 'unstable area' do - visit '/' - screenshot 'index', tolerance: 0.3 -end -``` - -You can also set this globally: - -```ruby -Capybara::Screenshot::Diff.tolerance = 0.3 +```text +Screenshot does not match for 'homepage': +({"area_size":1250,"region":[0,19,199,83],"max_color_distance":42.5}) ``` -#### Median filter size (vips only) +And generates diff files for inspection: -This is an alternative to "Allowed shift distance", but much faster. -You can find more about this strategy on [Median Filter](https://en.wikipedia.org/wiki/Median_filter). -Think about this like smoothing of the image, before comparison. +| File | Description | +|------|-------------| +| `homepage.png` | Committed baseline | +| `homepage.diff.png` | Visual diff with changes highlighted in red | +| `homepage.heatmap.diff.png` | Heatmap of pixel differences | -You can use the `median_filter_window_size` option to the `screenshot` method to set level: +Enable the [HTML report](docs/reporters.md) for an interactive dashboard with side-by-side comparison, zoom, and annotation toggle. +**Compare any two images** without a browser — PDFs, generated images, CI artifacts: ```ruby -test 'unstable area' do - visit '/' - screenshot 'index', median_filter_window_size: 2 -end +Capybara::Screenshot::Diff.compare("baseline.png", "current.png") ``` -### Skipping stack frames in the error output - -If you would like to override the `screenshot` method or for some other reason would like to skip stack -frames when reporting image differences, you can use the `skip_stack_frames` option: +## Installation ```ruby -test 'test visiting the index' do - visit root_path - screenshot :index -end - -private +# Gemfile +gem 'capybara-screenshot-diff' -def screenshot(name, **options) - super(name, skip_stack_frames: 1, **options) -end +# Optional: faster image processing (recommended) +gem 'ruby-vips' ``` -### Screenshot Format - -You can specify the format of the screenshots taken by setting the `screenshot_format` option. By default, the format is set to `"png"`. However, you can change this to any format supported by your image processing driver. For example, to set the format to `"webp"`, you can do the following: - -```ruby -Capybara::Screenshot.screenshot_format = "webp" -``` +Then run `bundle install`. -### Customize Capybara#screenshot options +**Requirements:** Ruby 3.2+, Rails 7.1+. For the `:vips` driver: [libvips 8.9+](https://libvips.github.io/libvips/install.html). -Allow to bypass screenshot options to Capybara driver. +## Next Steps -```ruby -# To create full page screenshots for Selenium -Capybara::Screenshot.capybara_screenshot_options[:full_page] = true +- **Crop to element:** `screenshot "form", crop: "#main-form"` +- **Ignore regions:** `screenshot "dashboard", skip_area: [".timestamp"]` +- **Run in CI:** See [GitHub Actions setup](docs/ci-integration.md) +- **HTML report:** `require 'capybara_screenshot_diff/reporters/html'` — [details](docs/reporters.md) -screenshot('index', median_filter_window_size: 2, capybara_screenshot_options: {full_page: false}) -``` +## Tuning Flaky Tests -### HTML Report +**Defaults work for most Rails apps.** `blur_active_element`, `hide_caret`, and `fail_if_new` (in CI) are enabled automatically. -Generate an interactive HTML report of screenshot differences: +If you see inconsistent results, add tolerance: ```ruby -# Add to test_helper.rb — one line, that's it -require 'capybara_screenshot_diff/reporters/html' -``` - -After running tests, open the report (generated only when there are failures): - -```bash -open tmp/snap_diff-report/index.html -``` - -The report includes a sidebar with thumbnails, side-by-side comparison with diff toggle, search, and summary stats. No configuration needed — just require it. - -**Note:** The report is not generated when all screenshots match. In parallel test environments, each worker writes to the same file — the last worker's results will be in the report. - -### Custom Reporters - -Build your own reporter by implementing `record` and `finalize`: - -```ruby -class MyReporter - def record(assertions) - assertions.each do |assertion| - next unless assertion.compare&.difference&.different? - # process the failure — send to Slack, write JSON, etc. - end - end - - def finalize - # called once at process exit — write summary, upload report, etc. - end +Capybara::Screenshot::Diff.configure do |screenshot, diff| + screenshot.window_size = [1280, 1024] + diff.tolerance = 0.0005 end - -# Register in test_helper.rb -CapybaraScreenshotDiff.reporters << MyReporter.new -``` - -Reporters are notified before assertions are cleared on each test teardown. `finalize` is called via `at_exit`. - -### Non-Rails Projects (Hugo, Jekyll, Static Sites) - -```ruby -# test/test_helper.rb -require 'capybara_screenshot_diff/static' - -CapybaraScreenshotDiff.serve("_site") # or "public", "build", "dist" ``` -This sets up Capybara to serve static files and configures screenshot paths automatically. - -### GitHub Actions Integration +| Use Case | VIPS `tolerance` | ChunkyPNG `color_distance_limit` | +|----------|-----------------|--------------------------------| +| Standard Rails apps | 0.0005 | 15 | +| Animated/complex pages | 0.01 | 30 | +| Pixel-perfect design | 0.0001 | 5 | -Upload the HTML report as a CI artifact so reviewers can browse screenshot diffs directly: - -```yaml -# .github/workflows/test.yml -- name: Run tests - run: bundle exec rake test - -- name: Upload screenshot report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: screenshot-diffs - path: tmp/snap_diff-report/ - retention-days: 7 -``` - -To enable the HTML report, add to your test helper: - -```ruby -require 'capybara_screenshot_diff/reporters/html' -``` +See [Configuration Reference](docs/configuration.md) for all options. ## Troubleshooting -**"No existing screenshot found"** -First run creates baselines. Record them: `RECORD_SCREENSHOTS=1 bundle exec rake test`. -In CI, `fail_if_new` is `true` by default — commit your baselines first. - -**Screenshots differ between CI and local** -Font rendering varies across OS versions. Use `tolerance: 0.001` or `perceptual_threshold: 2.0` -to ignore sub-pixel differences. Set `window_size` to ensure consistent browser dimensions. - -**Animations cause flaky diffs** -Set `Capybara.disable_animation = true` in your test helper to disable CSS animations globally. -For JS animations, add `stability_time_limit: 1` to wait for the page to stabilize. -The gem takes multiple screenshots and compares them until two consecutive ones match. +**"No existing screenshot found"** — First run saves baselines. Run `bundle exec rake test` twice, then commit `doc/screenshots/`. -**Dynamic content (timestamps, ads) always differs** -Use `skip_area` to ignore specific regions: -```ruby -screenshot "dashboard", skip_area: [".timestamp", "#ad-banner"] -``` - -**Screenshot assertion didn't seem to run** -Check that `Capybara::Screenshot.enabled` is not `false`. With `delayed: true` (default), -comparisons run in `before_teardown`, not inline — errors appear after the test body. +**Screenshots differ between CI and local** — Use `tolerance: 0.001` or `perceptual_threshold: 2.0`. Set `window_size` for consistent dimensions. -**Debug mode** -Set `DEBUG=1` to keep comparison runtime files (`.diff.png`, `.heatmap.diff.png`) and -enable verbose diagnostic messages from the reporter: -```bash -DEBUG=1 bundle exec rake test -``` +**Animations cause flaky diffs** — `Capybara.disable_animation = true`, or `stability_time_limit: 1`. -## Development +**Dynamic content always differs** — `screenshot "page", skip_area: [".timestamp", "#ad-banner"]` -After checking out the repo, run `bin/setup` to install dependencies. -Then, run `rake test` to run the tests. -You can also run `bin/console` for an interactive prompt that will allow you to experiment. +**Debug mode** — `DEBUG=1 bundle exec rake test` keeps `.diff.png` files for inspection. -To install this gem onto your local machine, run `bundle exec rake install`. +## Advanced Topics -### Running tests in Docker +- [Framework Setup](docs/framework-setup.md) — Minitest, RSpec, Cucumber +- [Image Processing Drivers](docs/drivers.md) — VIPS, ChunkyPNG, perceptual threshold +- [Screenshot Organization](docs/organization.md) — groups, sections, cropping, multi-browser +- [Configuration Reference](docs/configuration.md) — all options explained +- [Reporters](docs/reporters.md) — HTML report, custom reporters +- [CI & Non-Rails Integration](docs/ci-integration.md) — GitHub Actions, reusable action, baseline updates +- [Docker Testing](docs/docker-testing.md) — bin/dtest, recording baselines -Screenshot tests depend on exact browser rendering, which varies across OS and browser versions. Use `bin/dtest` to run tests inside Docker for consistent, reproducible results matching CI: - -```bash -bin/dtest # Run all tests with all drivers -bin/dtest test/integration/ # Run specific test directory -``` - -This builds a Docker image with Chrome and runs the test suite against three Capybara drivers: `cuprite`, `selenium_chrome_headless`, and `selenium_headless`. - -#### Recording baseline screenshots - -Screenshot baselines are committed to the repo and compared against during tests. When you set up the project for the first time, or after upgrading the browser/driver, you need to re-record them: - -```bash -RECORD_SCREENSHOTS=1 bin/dtest -``` +## Development -This skips screenshot comparisons and saves new baselines instead. Without this step, tests will fail because your local browser renders pixels differently from the previously committed baselines. +After checking out the repo, run `bin/setup` then `rake test`. See [Docker Testing](docs/docker-testing.md) for reproducible CI-matching test runs. ## Contributing @@ -930,4 +147,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) ## License The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). - diff --git a/Rakefile b/Rakefile index bc3cd013..0e83a620 100644 --- a/Rakefile +++ b/Rakefile @@ -29,9 +29,10 @@ task :coverage do Rake::Task["test"].invoke end -desc "Generate sample HTML report for manual testing" -task "report:sample" do - ruby "scripts/generate_sample_report.rb" +desc "Generate sample HTML report. Use bin/rake 'report:sample[embed]' for base64 images" +task "report:sample", [:embed] do |_t, args| + embed_arg = args[:embed] ? "--embed" : "" + ruby "scripts/generate_sample_report.rb #{embed_arg}" end task "clobber" do diff --git a/docs/ci-integration.md b/docs/ci-integration.md new file mode 100644 index 00000000..d5f17b85 --- /dev/null +++ b/docs/ci-integration.md @@ -0,0 +1,193 @@ +# CI & Non-Rails Integration + +## Non-Rails Projects (Hugo, Jekyll, Static Sites) + +```ruby +# test/test_helper.rb +require 'capybara_screenshot_diff/static' + +CapybaraScreenshotDiff.serve("_site") # or "public", "build", "dist" +``` + +This sets up Capybara to serve static files and configures screenshot paths automatically. + +## GitHub Actions Integration + +### 1. Enable the HTML report + +Add to your test helper: + +```ruby +require 'capybara_screenshot_diff/reporters/html' +``` + +### 2. Upload artifacts (manual setup) + +This is the full YAML so you understand what each step does: + +```yaml +# .github/workflows/test.yml +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + # Install libvips for the :vips driver (optional — skip if using :chunky_png) + - name: Install libvips + run: sudo apt-get install -y libvips-dev + + - name: Run tests + run: bundle exec rake test + + # Upload HTML report — renders inline in Actions UI (no download needed) + - name: Upload screenshot report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: screenshot-report + path: tmp/snap_diff/index.html + archive: false + retention-days: 2 + + # Upload full report with images (for offline review) + - name: Upload full screenshot report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: screenshot-report-full + path: tmp/snap_diff/ + retention-days: 2 +``` + +### 3. Or use the reusable action (one line) + +Instead of the manual upload steps above, reference our composite action directly: + +```yaml + - name: Run tests + run: bundle exec rake test + + - name: Upload screenshot reports + if: failure() + uses: snap-diff/snap_diff-capybara/.github/actions/upload-screenshots@master + with: + name: screenshots +``` + +This uploads diffs, Capybara failure screenshots, and the HTML report (inline + full) in one step. + +**Inputs:** + +| Input | Default | Description | +|-------|---------|-------------| +| `name` | (required) | Artifact name prefix | +| `report-path` | `tmp/snap_diff` | Path to HTML report directory | +| `retention-days` | `2` | Days to retain artifacts | + +### 4. PR comment with link to report (optional) + +Automatically comment on the PR pointing to the artifact. Uses `find-comment` to update existing comments instead of creating duplicates: + +```yaml + - name: Find existing comment + if: failure() && github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'Screenshot diffs detected' + + - name: Comment PR with report link + if: failure() && github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body: | + ### Screenshot diffs detected + [View report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts) +``` + +**Required:** Add permissions to the job for PR commenting to work: + +```yaml +jobs: + test: + permissions: + contents: read + pull-requests: write +``` + +## Update Baselines in CI + +When intentional UI changes are made, baselines need to be re-recorded. You can do this locally: + +```bash +RECORD_SCREENSHOTS=1 bundle exec rake test +git add test/fixtures/screenshots/ +git commit -m "chore: update screenshot baselines" +``` + +Or add a workflow that maintainers can trigger manually: + +```yaml +# .github/workflows/update-baselines.yml +name: Update Screenshot Baselines + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to update baselines on' + required: true + default: 'main' + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Install libvips + run: sudo apt-get install -y libvips-dev + + - name: Record new baselines + run: RECORD_SCREENSHOTS=1 bundle exec rake test + continue-on-error: true + + - name: Commit updated baselines + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add test/fixtures/ doc/screenshots/ + git diff --staged --quiet || git commit -m "chore: update screenshot baselines" + git push +``` + +**How it works:** +1. Go to Actions → "Update Screenshot Baselines" → "Run workflow" +2. Enter the branch name (e.g. your PR branch) +3. The workflow records new baselines, commits, and pushes + +**Safety:** +- Only maintainers with write access can trigger `workflow_dispatch` +- The commit uses `git diff --staged --quiet ||` to skip empty commits +- `GITHUB_TOKEN` pushes don't trigger subsequent CI runs ([by design](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows)) + +[← Back to README](../README.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..e94e4501 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,335 @@ +# Configuration Reference + +## Quick Setup + +Configure all settings in one place using the `configure` helper: + +```ruby +# In test_helper.rb or rails_helper.rb +Capybara::Screenshot::Diff.configure do |screenshot, diff| + screenshot.window_size = [1280, 1024] + screenshot.stability_time_limit = 1 + screenshot.blur_active_element = true + screenshot.hide_caret = true + diff.driver = :vips + diff.tolerance = 0.0005 + diff.color_distance_limit = 15 +end +``` + +**Note:** `fail_if_new` defaults to `true` in CI environments (when `ENV['CI']` is set). New screenshots are allowed locally but rejected in CI — no configuration needed. + +**Note:** Setting `Capybara::Screenshot.enabled = false` is sufficient to disable all screenshots. There is no need to define no-op modules or monkey-patch the gem. + +## Recommended tolerance values + +| Use Case | VIPS `tolerance` | ChunkyPNG `color_distance_limit` | `stability_time_limit` | +|----------|-----------------|--------------------------------|----------------------| +| Animated/complex pages | 0.01 | 30 | 2s | +| Standard Rails apps | 0.0005 | 15 | 1s | +| Pixel-perfect design tests | 0.0001 | 5 | 1s | + +## Configuration Tiers + +**Tier 1 — Zero config (works immediately):** +`blur_active_element`, `hide_caret`, and `fail_if_new` (in CI) are enabled by default. +Just `require 'capybara_screenshot_diff/minitest'` and call `screenshot`. + +**Tier 2 — Set when tests are flaky:** + +| Setting | When to use | +|---------|-------------| +| `window_size` | Screenshots differ between machines due to different browser sizes | +| `tolerance` | Sub-pixel rendering differences cause false positives | +| `skip_area` | Dynamic content (timestamps, ads) changes between runs | +| `stability_time_limit` | Animations or loading states cause inconsistent captures | + +**Tier 3 — Advanced tuning:** + +| Setting | When to use | +|---------|-------------| +| `perceptual_threshold` | Anti-aliasing false positives across OS/browser versions | +| `shift_distance_limit` | Content shifts by a few pixels (ChunkyPNG only) | +| `area_size_limit` | Allow small diff regions below a pixel count | +| `color_distance_limit` | Fine-tune raw RGB channel tolerance | +| `median_filter_window_size` | Smooth noise before comparison (VIPS only) | + +--- + +## Common Options + +### Screen size + +You can specify the desired screen size using + +```ruby +Capybara::Screenshot.window_size = [1024, 768] +``` + +This will force the screen shots to the given size, and skip taking screen shots +unless the desired window size can be achieved. + +### Disabling screen shots + +If you want to skip taking screen shots, set + +```ruby +Capybara::Screenshot.enabled = false +``` + +You can of course set this by an environment variable + +```ruby +Capybara::Screenshot.enabled = ENV['TAKE_SCREENSHOTS'] +``` + +### Disabling diff + +If you want to skip the assertion for change in the screen shot, set + +```ruby +Capybara::Screenshot::Diff.enabled = false +``` + +Using an environment variable + +```ruby +Capybara::Screenshot::Diff.enabled = ENV['COMPARE_SCREENSHOTS'] +``` + +### Tolerate screenshot differences + +To allow screenshot differences, but still fail on functional errors, you can set the following option: + +```ruby +Capybara::Screenshot::Diff.fail_on_difference = false +``` + +It defaults to `true`. This can be useful in continuous integration to a generate a screenshot difference +report while still reporting functional errors. + +### Does not tolerate new screenshots + +To fail the test if a new screenshot is taken, set the following option: + +```ruby +Capybara::Screenshot::Diff.fail_if_new = true +``` + +If `fail_if_new` is set to `true`, the test will fail if a new screenshot is taken +that does not have a corresponding previous image to compare against. +This can be useful in situations where you want to ensure +that every screenshot taken by your tests corresponds to an expected state of your application. + +### Screen shot save path + +By default, `Capybara::Screenshot::Diff` saves screenshots to a +`doc/screenshots` folder, relative to either `Rails.root` (if you're in Rails), +or your current directory otherwise. + +If you want to change where screenshots are saved to, then there are two +configuration options that that are relevant. + +The most likely one you'll want to modify is ... + +```ruby +Capybara::Screenshot.save_path = "other/path" +``` + +The `save_path` option is relative to `Capybara::Screenshot.root`. + +`Capybara::Screenshot.root` defaults to either `Rails.root` (if you're in +Rails) or your current directory. You can change it to something entirely +different if necessary, such as when using an alternative web framework. + +```ruby +Capybara::Screenshot.root = Hanami.root +``` + +### Screen shot stability + +To ensure that animations are finished before saving a screen shot, you can add +a stability time limit. If the stability time limit is set, a second screen +shot will be taken and compared to the first. This is repeated until two +subsequent screen shots are identical. + +```ruby +Capybara::Screenshot.stability_time_limit = 0.1 +``` + +This can be overridden on a single screenshot: + +```ruby +test 'stability_time_limit' do + visit '/' + screenshot 'index', stability_time_limit: 0.5 +end +``` + +### Maximum wait limit + +When the `stability_time_limit` is set, but no stable screenshot can be taken, a timeout occurs. +The timeout occurs after `Capybara.default_max_wait_time`, but can be overridden by an option. + +```ruby +test 'max wait time' do + visit '/' + screenshot 'index', wait: 20.seconds +end +``` + +### Hiding the caret for active input elements + +In Chrome the screenshot includes the blinking input cursor. This can make it impossible to get a +stable screenshot. To get around this you can set the `hide caret` option: + +```ruby +Capybara::Screenshot.hide_caret = true +``` + +This will make the cursor (caret) transparent (invisible), so the blinking does not delay the screen shot. + + +### Removing focus from the active element + +Another way to avoid the cursor blinking is to set the `blur_active_element` option: + +```ruby +Capybara::Screenshot.blur_active_element = true +``` + +This will remove the focus from the active element, removing the blinking cursor. + + + +### Allowed color distance + +Sometimes you want to allow small differences in the images. For example, Chrome renders the same +page slightly differently sometimes. You can set set the color difference threshold for the +comparison using the `color_distance_limit` option to the `screenshot` method: + +```ruby +test 'color threshold' do + visit '/' + screenshot 'index', color_distance_limit: 30 +end +``` + +The difference is calculated as the euclidean distance. You can also set this globally: + +```ruby +Capybara::Screenshot::Diff.color_distance_limit = 42 +``` + + +### Allowed shift distance + +Sometimes you want to allow small movements in the images. For example, jquery-tablesorter +renders the same table slightly differently sometimes. You can set set the shift distance +threshold for the comparison using the `shift_distance_limit` option to the `screenshot` +method: + +```ruby +test 'color threshold' do + visit '/' + screenshot 'index', shift_distance_limit: 2 +end +``` + +The difference is calculated as maximum distance in either the X or the Y axis. +You can also set this globally: + +```ruby +Capybara::Screenshot::Diff.shift_distance_limit = 1 +``` + +**Note:** For each increase in `shift_distance_limit` more pixels are searched for a matching color value, and +this will impact performance **severely** if a match cannot be found. + +If `shift_distance_limit` is `nil` shift distance is not measured. If `shift_distance_limit` is set, +even to `0`, shift distance is measured and reported on image differences. + +### Allowed difference size + +You can set set a threshold for the differing area size for the comparison +using the `area_size_limit` option to the `screenshot` method: + +```ruby +test 'area threshold' do + visit '/' + screenshot 'index', area_size_limit: 17 +end +``` + +The difference is calculated as `width * height`. You can also set this globally: + +```ruby +Capybara::Screenshot::Diff.area_size_limit = 42 +``` + + +### Skipping an area + +Sometimes you have expected change that you want to ignore. +You can use the `skip_area` option with `[left, top, right, bottom]` +or css selector like `'#footer'` or `'.container .skipped_element'` to the `screenshot` method to ignore an area. +Be aware that if the selector is not in the page then the library will wait the default wait time for it to appear. +Therefore, it is best to only use css selectors for skip_areas you know will be in the page: + +```ruby +test 'unstable area' do + visit '/' + screenshot 'index', skip_area: [[17, 6, 27, 16], '.container .skipped_element', '#footer'] +end +``` + +The arguments are `[left, top, right, bottom]` for the area you want to ignore. You can also set this globally: + +```ruby +Capybara::Screenshot::Diff.skip_area = [0, 0, 64, 48] +``` + +If you need to ignore multiple areas: + +```ruby +screenshot 'index', skip_area: [[0, 0, 64, 48], [17, 6, 27, 16], 'css_selector .element'] +``` + +### Skipping stack frames in the error output + +If you would like to override the `screenshot` method or for some other reason would like to skip stack +frames when reporting image differences, you can use the `skip_stack_frames` option: + +```ruby +test 'test visiting the index' do + visit root_path + screenshot :index +end + +private + +def screenshot(name, **options) + super(name, skip_stack_frames: 1, **options) +end +``` + +### Screenshot Format + +You can specify the format of the screenshots taken by setting the `screenshot_format` option. By default, the format is set to `"png"`. However, you can change this to any format supported by your image processing driver. For example, to set the format to `"webp"`, you can do the following: + +```ruby +Capybara::Screenshot.screenshot_format = "webp" +``` + +### Customize Capybara#screenshot options + +Allow to bypass screenshot options to Capybara driver. + +```ruby +# To create full page screenshots for Selenium +Capybara::Screenshot.capybara_screenshot_options[:full_page] = true + +screenshot('index', median_filter_window_size: 2, capybara_screenshot_options: {full_page: false}) +``` + +[← Back to README](../README.md) diff --git a/docs/docker-testing.md b/docs/docker-testing.md new file mode 100644 index 00000000..5dd861fd --- /dev/null +++ b/docs/docker-testing.md @@ -0,0 +1,24 @@ +# Docker Testing + +## Running tests in Docker + +Screenshot tests depend on exact browser rendering, which varies across OS and browser versions. Use `bin/dtest` to run tests inside Docker for consistent, reproducible results matching CI: + +```bash +bin/dtest # Run all tests with all drivers +bin/dtest test/integration/ # Run specific test directory +``` + +This builds a Docker image with Chrome and runs the test suite against three Capybara drivers: `cuprite`, `selenium_chrome_headless`, and `selenium_headless`. + +## Recording baseline screenshots + +Screenshot baselines are committed to the repo and compared against during tests. When you set up the project for the first time, or after upgrading the browser/driver, you need to re-record them: + +```bash +RECORD_SCREENSHOTS=1 bin/dtest +``` + +This skips screenshot comparisons and saves new baselines instead. Without this step, tests will fail because your local browser renders pixels differently from the previously committed baselines. + +[← Back to README](../README.md) diff --git a/docs/drivers.md b/docs/drivers.md new file mode 100644 index 00000000..3c840332 --- /dev/null +++ b/docs/drivers.md @@ -0,0 +1,93 @@ +# Image Processing Drivers + +## Perceptual color comparison (VIPS only) + +By default, color differences are measured using raw RGB channel distance. This can produce +false positives from anti-aliasing and sub-pixel font rendering — the same page rendered on +different OS versions or browsers will have slightly different pixel values at text edges. + +The `perceptual_threshold` option uses the CIE dE00 formula instead, which measures color +difference the way human eyes perceive it. Anti-aliasing artifacts typically score below 2.0 +on the dE00 scale and are automatically ignored. + +```ruby +# Per-screenshot: ignore anti-aliasing, catch real visual changes +screenshot 'dashboard', perceptual_threshold: 2.0 + +# Global: apply to all screenshots +Capybara::Screenshot::Diff.perceptual_threshold = 2.0 + +# dE00 scale reference: +# < 1.0 — not perceptible by human eyes +# 1-2 — perceptible through close observation (anti-aliasing, font hinting) +# 2-10 — perceptible at a glance (color shifts, layout changes) +# > 10 — clearly different colors +``` + +Use `perceptual_threshold` when you see false positives from font rendering differences across +CI environments, or when `color_distance_limit` with raw RGB requires frequent tuning. + +**Note:** `perceptual_threshold` and `color_distance_limit` are independent options on different +scales. `perceptual_threshold` uses dE00 (0-100+), `color_distance_limit` uses Euclidean RGB +distance (0-441). Set one or the other, not both. + +## Available Image Processing Drivers + +There are several image processing supported by this gem. +There are several options to setup active driver: `:auto`, `:chunky_png` and `:vips`. + +* `:auto` - will try to load `:vips` if there is gem `ruby-vips`, in other cases will load `:chunky_png` +* `:chunky_png` and `:vips` will load correspondent driver + +## Enable VIPS image processing + +[Vips](https://www.rubydoc.info/gems/ruby-vips/Vips/Image) driver provides a faster comparison, +and could be enabled by adding `ruby-vips` to `Gemfile`. + +If need to setup explicitly Vips driver, there are several ways to do this: + +* Globally: `Capybara::Screenshot::Diff.driver = :vips` +* Per screenshot option: `screenshot 'index', driver: :vips` + +With enabled VIPS there are new alternatives to process differences, which are easier to find and support. +For example, `shift_distance_limit` is a very heavy operation. Instead, use `median_filter_window_size`. + +## Tolerance level (vips only) + +You can set a "tolerance" anywhere from 0% to 100%. This is the amount of change that's allowable. +If the screenshot has changed by more than that amount, it'll flag it as a failure. + +This is alternative to "Allowed difference size", only the difference that area calculates including valid pixels. +But "tolerance" compares only different pixels. + +You can use the `tolerance` option to the `screenshot` method to set level: + +```ruby +test 'unstable area' do + visit '/' + screenshot 'index', tolerance: 0.3 +end +``` + +You can also set this globally: + +```ruby +Capybara::Screenshot::Diff.tolerance = 0.3 +``` + +## Median filter size (vips only) + +This is an alternative to "Allowed shift distance", but much faster. +You can find more about this strategy on [Median Filter](https://en.wikipedia.org/wiki/Median_filter). +Think about this like smoothing of the image, before comparison. + +You can use the `median_filter_window_size` option to the `screenshot` method to set level: + +```ruby +test 'unstable area' do + visit '/' + screenshot 'index', median_filter_window_size: 2 +end +``` + +[← Back to README](../README.md) diff --git a/docs/framework-setup.md b/docs/framework-setup.md new file mode 100644 index 00000000..72160eb6 --- /dev/null +++ b/docs/framework-setup.md @@ -0,0 +1,77 @@ +# Framework Setup + +## Including DSL + +To use the screenshot capturing and change detection features in your tests, include the `CapybaraScreenshotDiff::DSL` in your test classes. It provides the `screenshot` method to capture and compare screenshots. + +There are different modules for different testing frameworks integrations. + +## Minitest + +For Minitest, need to require `capybara_screenshot_diff/minitest`. +In your test class, include the `CapybaraScreenshotDiff::Minitest::Assertions` module: + +```ruby +require 'capybara_screenshot_diff/minitest' + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # Make the Capybara & Capybara Screenshot Diff DSLs available in tests + include CapybaraScreenshotDiff::DSL + # Make `assert_*` methods behave like Minitest assertions + include CapybaraScreenshotDiff::Minitest::Assertions + + def test_my_feature + visit '/' + assert_matches_screenshot 'index' + end +end +``` + +## RSpec + +To use the screenshot capturing and change detection features in your tests, +include the `CapybaraScreenshotDiff::DSL` in your test classes. +It adds `match_screenshot` matcher to RSpec. + +> **Important**: +> The `CapybaraScreenshotDiff::DSL` is automatically included in all feature and system tests by default. + + +```ruby +require 'capybara_screenshot_diff/rspec' + +describe 'Permissions admin', type: :feature do + it 'works with permissions' do + visit('/') + expect(page).to match_screenshot('home_page') + end +end + + +describe 'Permissions admin', type: :non_feature do + include CapybaraScreenshotDiff::DSL + + it 'works with permissions' do + visit('/') + expect(page).to match_screenshot('home_page') + end +end +``` + +## Cucumber + +Load Cucumber support by adding the following line (typically to your `features/support/env.rb` file): + +```ruby +require 'capybara_screenshot_diff/cucumber' +``` + +And in the steps you can use: + +```ruby +Then('I should not see any visual difference') do + screenshot 'homepage' +end +``` + +[← Back to README](../README.md) diff --git a/docs/organization.md b/docs/organization.md new file mode 100644 index 00000000..a1646cb8 --- /dev/null +++ b/docs/organization.md @@ -0,0 +1,202 @@ +# Screenshot Organization + +## Taking screenshots + +Add `screenshot ''` to your tests. The screenshot will be saved in +the `doc/screenshots` directory. + +Change your existing `save_screenshot` calls to `screenshot` + +```ruby +test 'my useful feature' do + visit '/' + screenshot 'welcome_index' + click_button 'Useful feature' + screenshot 'feature_index' + click_button 'Perform action' + screenshot 'action_performed' +end +``` + +This will produce a sequence of images like this + +``` +doc + screenshots + action_performed + feature_index + welcome_index +``` + +To store the screen shot history, add the `doc/screenshots` directory to your +version control system (git, svn, etc). + + Screen shots are compared to the previously COMMITTED version of the same screen shot. + +## Screenshot groups + +Commonly it is useful to group screenshots around a feature, and record them as +a sequence. To do this, add a `screenshot_group` call to the start of your +test. + +```ruby +test 'my useful feature' do + screenshot_group 'useful_feature' + visit '/' + screenshot 'welcome_index' + click_button 'Useful feature' + screenshot 'feature_index' + click_button 'Perform action' + screenshot 'action_performed' +end +``` + +This will produce a sequence of images like this + +``` +doc + screenshots + useful_feature + 00_welcome_index + 01_feature_index + 02_action_performed +``` + +**Note:** `screenshot_group` sets the group name for organizing screenshots. It does not delete existing files. + + +## Screenshot sections + +You can introduce another level above the screenshot group called a +`screenshot_section`. The section name is inserted just before the group name +in the save path. If called in the setup of the test, all screenshots in +that test will get the same prefix: + +```ruby +setup do + screenshot_section 'my_feature' +end + +test 'my subfeature' do + screenshot_group 'subfeature' + visit '/feature' + click_button 'Interesting button' + screenshot 'subfeature_index' + click_button 'Perform action' + screenshot 'action_performed' +end +``` + +This will produce a sequence of images like this + +``` +doc + screenshots + my_feature + subfeature + 00_subfeature_index + 01_action_performed +``` + + +## Setting `screenshot_section` and/or `screenshot_group` for all tests + +Setting the `screenshot_section` and/or `screenshot_group` for all tests can be +done in the super class setup: + +```ruby +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + setup do + screenshot_section class_name.underscore.sub(/(_feature|_system)?_test$/, '') + screenshot_group name[5..-1] + end +end +``` + +`screenshot_section` and/or `screenshot_group` can still be overridden in each +test. + + +## Capturing one area instead of the whole page + +You can crop images before comparison to be run, by providing region to crop as `[left, top, right, bottom]` or by css selector like `body .tag` + +```ruby +test 'the cool' do + visit '/feature' + screenshot 'cool_element', crop: '#my_element' +end +``` + +**Note:** When using a retina device screenshots dimensions might be off. If +you are using (headless) chrome you can prevent this by setting the +`force-device-scale-factor` argument to `1`. + +For Rails system specs using selenium you can do so for example by using the +following snippet: + +```ruby +driven_by :selenium, using: :chrome_headless do |options| + options.args << '--force-device-scale-factor=1' +end +``` + +## Multiple Capybara drivers + +Often it is useful to test your app using different browsers. To avoid the +screenshots for different Capybara drivers to overwrite each other, set + +```ruby +Capybara::Screenshot.add_driver_path = true +``` + +The example above will then save your screenshots like this +(for poltergeist and selenium): + +``` +doc + screenshots + poltergeist + useful_feature + 00_welcome_index + 01_feature_index + 02_action_performed + selenium + useful_feature + 00_welcome_index + 01_feature_index + 02_action_performed +``` + +## Multiple OSs + +If you run your tests on multiple operating systems, you will most likely find +the screen shots differ. To avoid the screenshots for different OSs to +overwrite each other, set + +```ruby +Capybara::Screenshot.add_os_path = true +``` + +The example above will then save your screenshots like this +(for Linux and Windows): + +``` +doc + screenshots + linux + useful_feature + 00_welcome_index + 01_feature_index + 02_action_performed + windows + useful_feature + 00_welcome_index + 01_feature_index + 02_action_performed +``` + +If you combine this config with the `add_driver_path` config, the driver will be +put in front of the OS name. + +[← Back to README](../README.md) diff --git a/docs/reporters.md b/docs/reporters.md new file mode 100644 index 00000000..2a139b6e --- /dev/null +++ b/docs/reporters.md @@ -0,0 +1,46 @@ +# Reporters + +## HTML Report + +Generate an interactive HTML report of screenshot differences: + +```ruby +# Add to test_helper.rb — one line, that's it +require 'capybara_screenshot_diff/reporters/html' +``` + +After running tests, open the report (generated only when there are failures): + +```bash +open tmp/snap_diff/index.html +``` + +The report includes a sidebar with thumbnails, side-by-side comparison with diff toggle, search, and summary stats. No configuration needed — just require it. + +**Note:** The report is not generated when all screenshots match. In parallel test environments, each worker writes to the same file — the last worker's results will be in the report. + +## Custom Reporters + +Build your own reporter by implementing `record` and `finalize`: + +```ruby +class MyReporter + def record(assertions) + assertions.each do |assertion| + next unless assertion.compare&.difference&.different? + # process the failure — send to Slack, write JSON, etc. + end + end + + def finalize + # called once at process exit — write summary, upload report, etc. + end +end + +# Register in test_helper.rb +CapybaraScreenshotDiff.reporters << MyReporter.new +``` + +Reporters are notified before assertions are cleared on each test teardown. `finalize` is called via `at_exit`. + +[← Back to README](../README.md) diff --git a/lib/capybara/screenshot/diff/vcs.rb b/lib/capybara/screenshot/diff/vcs.rb index 843552f0..1c1aeea4 100644 --- a/lib/capybara/screenshot/diff/vcs.rb +++ b/lib/capybara/screenshot/diff/vcs.rb @@ -9,11 +9,15 @@ module Vcs SILENCE_ERRORS = Os::ON_WINDOWS ? "2>nul" : "2>/dev/null" def self.checkout_vcs(root, screenshot_path, checkout_path) - vcs_file_path = screenshot_path.relative_path_from(root) + abs_screenshot_path = Pathname.new(screenshot_path).expand_path redirect_target = "#{checkout_path} #{SILENCE_ERRORS}" - show_command = "git show HEAD~0:./#{vcs_file_path}" Dir.chdir(root) do + git_toplevel = `git rev-parse --show-toplevel 2>/dev/null`.chomp + return false if git_toplevel.empty? + + vcs_file_path = abs_screenshot_path.relative_path_from(Pathname.new(git_toplevel)) + show_command = "git show HEAD:#{vcs_file_path}" if Screenshot.use_lfs system("#{show_command} > #{checkout_path}.tmp #{SILENCE_ERRORS}", exception: !!ENV["DEBUG"]) diff --git a/lib/capybara_screenshot_diff/dsl.rb b/lib/capybara_screenshot_diff/dsl.rb index 5643e880..a6552dc5 100644 --- a/lib/capybara_screenshot_diff/dsl.rb +++ b/lib/capybara_screenshot_diff/dsl.rb @@ -38,8 +38,8 @@ def screenshot_group(name) # @param options [Hash] Additional options for taking the screenshot and comparison. # @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed) # Whether to validate the screenshot immediately or delay validation. - # @option options [Array] :crop [x, y, width, height] Area to crop the screenshot to. - # @option options [Array>] :skip_area Array of [x, y, width, height] areas to ignore. + # @option options [Array] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to. + # @option options [Array>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore. # @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison. # @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels. # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels. diff --git a/lib/capybara_screenshot_diff/reporters/html.rb b/lib/capybara_screenshot_diff/reporters/html.rb index 68c87e20..57f910bf 100644 --- a/lib/capybara_screenshot_diff/reporters/html.rb +++ b/lib/capybara_screenshot_diff/reporters/html.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "base64" require "erb" require "fileutils" require "pathname" @@ -10,9 +11,10 @@ module Reporters class HTML attr_reader :output_path, :failures, :total - def initialize(output_path: "tmp/snap_diff/index.html") + def initialize(output_path: "tmp/snap_diff/index.html", embed_images: false) @output_path = Pathname.new(output_path) @report_dir = @output_path.dirname + @embed_images = embed_images @failures = [] @total = 0 @finalized = false @@ -36,14 +38,10 @@ def finalize return if @finalized @finalized = true - - if failures.empty? - warn "[snap_diff] No failures found, HTML report not generated" if ENV["DEBUG"] - return - end + return if failures.empty? write_report - true + output_path end def passed = total - failures.size @@ -68,24 +66,30 @@ def failure_entry_for(name, compare) difference = compare.difference { name: name, - original: relative_path(compare.base_image_path), - new: relative_path(compare.image_path), - base_diff: relative_path(compare.reporter.annotated_base_image_path), - diff: relative_path(compare.reporter.annotated_image_path), - heatmap: relative_path(compare.reporter.heatmap_diff_path), + original: resolve_image(compare.base_image_path), + new: resolve_image(compare.image_path), + base_diff: resolve_image(compare.reporter.annotated_base_image_path), + diff: resolve_image(compare.reporter.annotated_image_path), + heatmap: resolve_image(compare.reporter.heatmap_diff_path), diff_level: difference.ratio && (difference.ratio * 100).round(2), area_size: difference.region_area_size, max_color_distance: difference.meta[:max_color_distance]&.round(1) } end - def relative_path(path) + def resolve_image(path) return unless path - pathname = Pathname.new(path) + pathname = Pathname.new(path).expand_path return unless pathname.exist? - pathname.relative_path_from(@report_dir).to_s + @embed_images ? data_uri(pathname) : pathname.relative_path_from(@report_dir.expand_path).to_s + end + + def data_uri(pathname) + ext = pathname.extname.delete_prefix(".") + mime = (ext == "webp") ? "image/webp" : "image/png" + "data:#{mime};base64,#{Base64.strict_encode64(pathname.binread)}" end def write_report @@ -96,14 +100,17 @@ def write_report end end +# Auto-register reporter and at_exit hook. +# The reporter only writes when there are failures (finalize checks failures.empty?). +# Scripts that create their own reporter instance call record/finalize directly. unless CapybaraScreenshotDiff.reporters.any?(CapybaraScreenshotDiff::Reporters::HTML) - CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new + CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new(embed_images: !!ENV["CI"]) end at_exit do CapybaraScreenshotDiff.reporters.each do |reporter| - wrote = reporter.finalize - $stdout.puts "[snap_diff] HTML report: #{reporter.output_path}" if wrote + result = reporter.finalize + $stdout.puts "[snap_diff] HTML report: #{result}" if result.is_a?(Pathname) rescue => e warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})" if ENV["DEBUG"] end diff --git a/scripts/generate_sample_report.rb b/scripts/generate_sample_report.rb index a912ba3f..d46d10ec 100644 --- a/scripts/generate_sample_report.rb +++ b/scripts/generate_sample_report.rb @@ -9,7 +9,7 @@ require "capybara/screenshot/diff" require "capybara_screenshot_diff/reporters/html" -output_path = File.expand_path("../tmp/sample_report.html", __dir__) +output_path = File.expand_path("../tmp/snap_diff/index.html", __dir__) # Build real comparisons using the gem's own ImageCompare. # Each pair gets a unique copy of the base image to avoid annotation file conflicts. @@ -19,7 +19,8 @@ {name: "portrait-layout", base: "portrait", new: "portrait_b"} ] -reporter = CapybaraScreenshotDiff::Reporters::HTML.new(output_path: output_path) +embed = ARGV.include?("--embed") || !!ENV["CI"] +reporter = CapybaraScreenshotDiff::Reporters::HTML.new(output_path: output_path, embed_images: embed) fixtures = File.expand_path("../test/fixtures/images", __dir__) tmp_dir = File.expand_path("../tmp/sample_images", __dir__) FileUtils.mkdir_p(tmp_dir) diff --git a/test/unit/reporters/html_reporter_test.rb b/test/unit/reporters/html_reporter_test.rb index ee309e69..c3611342 100644 --- a/test/unit/reporters/html_reporter_test.rb +++ b/test/unit/reporters/html_reporter_test.rb @@ -78,6 +78,26 @@ class HTMLReporterTest < ActiveSupport::TestCase assert_includes @output_path.read, "valid" end + test "#record uses relative paths by default" do + reporter = HTML.new(output_path: @output_path) + + reporter.record([build_failing_assertion("rel")]) + reporter.finalize + + html = @output_path.read + assert_not_includes html, "data:image" + end + + test "#record embeds base64 images when embed_images: true" do + reporter = HTML.new(output_path: @output_path, embed_images: true) + + reporter.record([build_failing_assertion("embed")]) + reporter.finalize + + html = @output_path.read + assert_includes html, "data:image/png;base64," + end + private def build_passing_assertion(name) diff --git a/test/unit/vcs_test.rb b/test/unit/vcs_test.rb index 76c288c9..e1cf0a11 100644 --- a/test/unit/vcs_test.rb +++ b/test/unit/vcs_test.rb @@ -9,6 +9,7 @@ class VcsTest < ActiveSupport::TestCase include Vcs setup do + FileUtils.mkdir_p(Screenshot.root) @base_screenshot = Tempfile.new(%w[vcs_base_screenshot. .attempt.0.png], Screenshot.root) end