diff --git a/.github/actions/setup-ruby-and-dependencies/action.yml b/.github/actions/setup-ruby-and-dependencies/action.yml index ed27ed77..848c5075 100644 --- a/.github/actions/setup-ruby-and-dependencies/action.yml +++ b/.github/actions/setup-ruby-and-dependencies/action.yml @@ -26,13 +26,27 @@ runs: if: ${{ inputs.cache-apt-packages == 'true' }} uses: jetthoughts/cache-apt-pkgs-action@fix/upgrade-actions-cache-v5 with: - packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev + packages: libvips libglib2.0-0 libglib2.0-dev libwebp-dev libvips42 libpng-dev fonts-dejavu fonts-liberation fonts-ubuntu fonts-noto-color-emoji version: tests-v2 - name: Install vips (fallback) if: ${{ inputs.cache-apt-packages != 'true' }} - run: sudo apt-get -qq update && sudo apt-get -qq install -y libvips + run: sudo apt-get -qq update && sudo apt-get -qq install -y libvips fonts-dejavu fonts-liberation fonts-ubuntu fonts-noto-color-emoji shell: bash - - run: sudo sed -i 's/true/false/g' /etc/fonts/conf.d/10-yes-antialias.conf + - name: Configure font rendering + run: | + sudo tee /etc/fonts/local.conf >/dev/null <<'XML' + + + + + false + false + hintnone + none + + + XML + sudo fc-cache -f shell: bash diff --git a/README.md b/README.md index e7e71fa8..faaff0a6 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,13 @@ Enable `Capybara::Screenshot.disable_animations = true` to freeze CSS animations
CI screenshots differ from local -Set `window_size` for consistent dimensions and use `perceptual_threshold: 2.0` to ignore anti-aliasing differences across environments. +Set `window_size` for consistent dimensions and use `perceptual_threshold: 2.0` to ignore anti-aliasing differences across environments. For cross-OS baselines, use the preset: + +```ruby +Capybara::Screenshot.enable_consistent_screenshots! +``` + +For advanced tuning and custom injections, see `docs/configuration.md`.
diff --git a/docs/adr/0001-screenshot-prep-plugins.md b/docs/adr/0001-screenshot-prep-plugins.md new file mode 100644 index 00000000..2a758c27 --- /dev/null +++ b/docs/adr/0001-screenshot-prep-plugins.md @@ -0,0 +1,47 @@ +# ADR 0001: Screenshot Preparation Plugins (Deferred) + +Date: 2026-04-14 +Status: Deferred + +## Context + +We added more pre-capture steps for stable screenshots, including: +- DOM normalization CSS +- Font readiness waits +- Animation disabling +- Caret hiding +- Custom CSS and JS injections + +The number of knobs and prep steps is growing. A plugin pipeline could make +ordering explicit and allow app-level extensions without monkey-patching. + +## Decision + +Defer a plugin system for now. + +We will keep the simple `configure_consistency` API plus existing flags as +aliases. This provides a single entry point and keeps complexity low. + +## Consequences + +Benefits: +- Minimal code and API surface +- Easy onboarding for new users + +Costs: +- Prep order is still encoded in `Screenshoter` +- Extensibility is limited to CSS/JS injections and flags + +## Revisit Criteria + +Re-open this decision when: +- We need more than one custom preparation step per app +- We add two or more new prep steps beyond CSS/JS injection +- Prep ordering or conditional logic becomes hard to reason about + +## Next Refactoring Ideas + +If revisited, implement a lightweight pipeline: +- `plugins` list with callables +- Context object with `inject_css`, `inject_js`, `wait_for_fonts`, and `session` +- Built-in plugins for existing steps, mapped from current flags diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..8096e6af --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,5 @@ +# Architecture Decision Records + +## ADR Index + +- [ADR 0001: Screenshot Preparation Plugins (Deferred)](0001-screenshot-prep-plugins.md) diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 3f8f3ec5..6e870bc1 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -90,7 +90,8 @@ That's it. On failure, this will: ### 3. Ruby + libvips setup action -For consistent CI environments (libvips, font antialiasing disabled), use the setup action: +For consistent CI environments (libvips, standardized fonts, hinting disabled), +use the setup action: ```yaml - uses: snap-diff/snap_diff-capybara/.github/actions/setup-ruby-and-dependencies@master @@ -99,7 +100,10 @@ For consistent CI environments (libvips, font antialiasing disabled), use the se cache-apt-packages: true ``` -This installs Ruby, libvips (with apt caching), and disables font antialiasing for consistent rendering across CI runs. +This installs Ruby, libvips (with apt caching), installs the core font stack, +and disables font hinting for consistent rendering across CI runs. + +For local or Docker setups, see `docs/os-setup.md`. #### Inputs diff --git a/docs/configuration.md b/docs/configuration.md index e540ae08..50855446 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -135,6 +135,83 @@ If you want to skip the assertion for change in the screen shot, set Capybara::Screenshot::Diff.enabled = false ``` +### DOM normalization (recommended for cross-OS baselines) + +To reduce visual noise from OS-level font rendering, scrollbars, and UI chrome, +the default config injects a normalization stylesheet and waits for web fonts +before capture. You can override as needed: + +```ruby +Capybara::Screenshot.normalize_css = false +Capybara::Screenshot.wait_for_fonts = false +``` + +The built-in normalization stylesheet: +- disables animations/transitions +- standardizes font rendering +- hides carets and number spinners +- hides OS-specific scrollbars + +To supply custom CSS instead: + +```ruby +Capybara::Screenshot.normalize_css = true +Capybara::Screenshot.normalize_stylesheet = <<~CSS + /* your custom normalization rules */ +CSS +``` + +### One-line preset + +If you'd rather toggle everything in one call: + +```ruby +Capybara::Screenshot.enable_consistent_screenshots! +``` + +### Unified consistency config (recommended) + +For a single entry point with custom injections: + +```ruby +Capybara::Screenshot.configure_consistency(preset: :default) do |c| + c.blur_active_element = true + c.hide_caret = true + c.disable_animations = true + c.normalize_css = true + c.wait_for_fonts = true + c.css << "/* your custom css */" + c.js << "/* your custom js */" +end +``` + +Available presets: +- `:default` (enable normalization + font wait + disable animations + hide caret + blur) +- `:off` (disable all consistency shims) + +**Compatibility:** existing flags (`normalize_css`, `wait_for_fonts`, `disable_animations`, etc.) +remain supported as aliases. + +For OS-level setup (fonts + hinting), see `docs/os-setup.md`. + +### Cross-OS baseline preset (Ubuntu ↔ Alpine) + +If you compare baselines across `glibc` and `musl`, combine perceptual diffing +with a tighter tolerated diff area: + +```ruby +Capybara::Screenshot::Diff.configure do |screenshot, diff| + screenshot.window_size = [1280, 1024] + screenshot.disable_animations = true + screenshot.normalize_css = true + screenshot.wait_for_fonts = true + + diff.driver = :vips + diff.perceptual_threshold = 2.0 + diff.tolerance = 0.00005 # 0.005% of pixels +end +``` + Using an environment variable ```ruby diff --git a/docs/os-setup.md b/docs/os-setup.md new file mode 100644 index 00000000..0d790d87 --- /dev/null +++ b/docs/os-setup.md @@ -0,0 +1,72 @@ +# OS Setup for Consistent Screenshots + +Screenshot rendering varies across OS and C libraries. For reliable baselines +across Ubuntu (glibc) and Alpine (musl), standardize fonts and disable hinting. + +## Ubuntu (glibc) + +Install fonts: + +```bash +sudo apt-get update +sudo apt-get install -y \ + fonts-dejavu \ + fonts-liberation \ + fonts-ubuntu \ + fonts-noto-color-emoji +``` + +Disable font hinting and subpixel tweaks: + +```bash +sudo tee /etc/fonts/local.conf >/dev/null <<'XML' + + + + + false + false + hintnone + none + + +XML + +sudo fc-cache -f +``` + +## Alpine (musl) + +Install fonts: + +```bash +apk add --no-cache \ + ttf-dejavu \ + ttf-liberation \ + ttf-ubuntu-font-family \ + font-noto-emoji +``` + +Disable font hinting and subpixel tweaks: + +```bash +cat > /etc/fonts/local.conf <<'XML' + + + + + false + false + hintnone + none + + +XML + +fc-cache -f +``` + +## GitHub Actions (Ubuntu) + +If you use the provided setup action, the OS preparation is handled for you. +See `docs/ci-integration.md` for the GitHub Actions snippets. diff --git a/lib/capybara/screenshot/diff/browser_helpers.rb b/lib/capybara/screenshot/diff/browser_helpers.rb index 80ac7289..bd257603 100644 --- a/lib/capybara/screenshot/diff/browser_helpers.rb +++ b/lib/capybara/screenshot/diff/browser_helpers.rb @@ -73,6 +73,76 @@ def self.disable_animations session.execute_script(DISABLE_ANIMATIONS_SCRIPT) end + DEFAULT_NORMALIZE_CSS = <<~CSS + /* Kill animations and transitions */ + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + scroll-behavior: auto !important; + } + + /* Standardize font rendering */ + * { + -webkit-font-smoothing: antialiased !important; + -moz-osx-font-smoothing: grayscale !important; + text-rendering: geometricPrecision !important; + } + + /* Neutralize inputs and dynamic artifacts */ + * { caret-color: transparent !important; } + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none !important; + } + + /* Hide OS-specific scrollbars */ + ::-webkit-scrollbar { display: none !important; } + * { scrollbar-width: none !important; -ms-overflow-style: none !important; } + CSS + + def self.normalize_css(css = nil) + css ||= DEFAULT_NORMALIZE_CSS + + session.execute_script(<<~JS, css) + (function(cssText) { + if (!document.getElementById('csdNormalizeStyle')) { + let style = document.createElement('style'); + style.setAttribute('id', 'csdNormalizeStyle'); + style.textContent = cssText; + document.head.appendChild(style); + } + })(arguments[0]); + JS + end + + def self.inject_custom_stylesheets(stylesheets) + Array(stylesheets).each_with_index do |css, index| + inject_stylesheet(css, "csdCustomStyle#{index}") + end + end + + def self.inject_custom_scripts(scripts) + Array(scripts).each do |script| + session.execute_script(script) + end + end + + def self.inject_stylesheet(css, element_id) + session.execute_script(<<~JS, css, element_id) + (function(cssText, styleId) { + if (!document.getElementById(styleId)) { + let style = document.createElement('style'); + style.setAttribute('id', styleId); + style.textContent = cssText; + document.head.appendChild(style); + } + })(arguments[0], arguments[1]); + JS + end + FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS function activeElement(){ const ae = document.activeElement; @@ -113,6 +183,17 @@ def self.pending_image_to_load BrowserHelpers.session.evaluate_script(IMAGE_WAIT_SCRIPT) end + FONTS_READY_SCRIPT = <<~JS + (function() { + if (!document.fonts) return true; + return document.fonts.status === "loaded"; + })(); + JS + + def self.fonts_ready? + BrowserHelpers.session.evaluate_script(FONTS_READY_SCRIPT) + end + def self.current_capybara_driver_class session.driver.class end diff --git a/lib/capybara/screenshot/diff/screenshot_matcher.rb b/lib/capybara/screenshot/diff/screenshot_matcher.rb index 2b5309e2..97713ab4 100644 --- a/lib/capybara/screenshot/diff/screenshot_matcher.rb +++ b/lib/capybara/screenshot/diff/screenshot_matcher.rb @@ -102,6 +102,9 @@ def extract_capture_and_comparison_options!(driver_options = {}) # screenshot options capybara_screenshot_options: driver_options[:capybara_screenshot_options], crop: driver_options.delete(:crop), + normalize_css: driver_options.delete(:normalize_css), + normalize_stylesheet: driver_options.delete(:normalize_stylesheet), + wait_for_fonts: driver_options.delete(:wait_for_fonts), # delivery options screenshot_format: driver_options[:screenshot_format], # stability options diff --git a/lib/capybara/screenshot/diff/screenshoter.rb b/lib/capybara/screenshot/diff/screenshoter.rb index bfd48860..35dfc6ca 100644 --- a/lib/capybara/screenshot/diff/screenshoter.rb +++ b/lib/capybara/screenshot/diff/screenshoter.rb @@ -69,6 +69,11 @@ def notice_how_to_avoid_this def prepare_page_for_screenshot(timeout:) wait_images_loaded(timeout: timeout) if timeout + wait_for_fonts(timeout: timeout) if wait_for_fonts? + + BrowserHelpers.normalize_css(normalize_stylesheet) if normalize_css? + BrowserHelpers.inject_custom_stylesheets(Screenshot.custom_stylesheets) + BrowserHelpers.inject_custom_scripts(Screenshot.custom_scripts) blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element @@ -94,8 +99,39 @@ def wait_images_loaded(timeout:) end end + def wait_for_fonts(timeout:) + return unless timeout + + deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + loop do + break if BrowserHelpers.fonts_ready? + + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at + raise CapybaraScreenshotDiff::ExpectationNotMet.new("Fonts have not been loaded after #{timeout}s", caller) + end + + sleep 0.025 + end + end + private + def normalize_css? + return @capture_options[:normalize_css] unless @capture_options[:normalize_css].nil? + + Screenshot.normalize_css + end + + def normalize_stylesheet + @capture_options.key?(:normalize_stylesheet) ? @capture_options[:normalize_stylesheet] : Screenshot.normalize_stylesheet + end + + def wait_for_fonts? + return @capture_options[:wait_for_fonts] unless @capture_options[:wait_for_fonts].nil? + + Screenshot.wait_for_fonts + end + def save_and_process_screenshot(screenshot_path) tmpfile = Tempfile.new([screenshot_path.basename.to_s, PNG_EXTENSION]) BrowserHelpers.session.save_screenshot(tmpfile.path, **capybara_screenshot_options) diff --git a/lib/capybara/screenshot/diff/vcs.rb b/lib/capybara/screenshot/diff/vcs.rb index 13dd7d8f..00a13fb8 100644 --- a/lib/capybara/screenshot/diff/vcs.rb +++ b/lib/capybara/screenshot/diff/vcs.rb @@ -9,7 +9,8 @@ module Diff module Vcs def self.checkout_vcs(root, screenshot_path, checkout_path) root_path = root.to_s - git_root, _, status = Open3.capture3("git", "-C", root_path, "rev-parse", "--show-toplevel") + git_env = {"GIT_DIR" => nil, "GIT_WORK_TREE" => nil, "GIT_INDEX_FILE" => nil} + git_root, _, status = Open3.capture3(git_env, "git", "-C", root_path, "rev-parse", "--show-toplevel") return false unless status.success? git_root = git_root.chomp @@ -17,13 +18,13 @@ def self.checkout_vcs(root, screenshot_path, checkout_path) if Screenshot.use_lfs tmp_path = "#{checkout_path}.tmp" - success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL) + success = system(git_env, "git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL) if success - system("git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL) + system(git_env, "git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL) end File.delete(tmp_path) if File.exist?(tmp_path) else - success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL) + success = system(git_env, "git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL) end unless success diff --git a/lib/capybara_screenshot_diff.rb b/lib/capybara_screenshot_diff.rb index f275e32b..e08ddb69 100644 --- a/lib/capybara_screenshot_diff.rb +++ b/lib/capybara_screenshot_diff.rb @@ -31,6 +31,11 @@ module Screenshot mattr_accessor :enabled mattr_accessor(:hide_caret) { true } mattr_accessor :disable_animations + mattr_accessor(:normalize_css) { true } + mattr_accessor :normalize_stylesheet + mattr_accessor(:wait_for_fonts) { true } + mattr_accessor(:custom_stylesheets) { [] } + mattr_accessor(:custom_scripts) { [] } mattr_reader(:root) { (defined?(Rails) && defined?(Rails.root) && Rails.root) || Pathname(".").expand_path } mattr_accessor :stability_time_limit mattr_accessor :window_size @@ -40,6 +45,81 @@ module Screenshot mattr_accessor(:capybara_screenshot_options) { {} } class << self + class ConsistencyConfig + attr_accessor :css, :js, + :normalize_css, :wait_for_fonts, :disable_animations, + :hide_caret, :blur_active_element + + def initialize( + css:, + js:, + normalize_css:, + wait_for_fonts:, + disable_animations:, + hide_caret:, + blur_active_element: + ) + @css = css + @js = js + @normalize_css = normalize_css + @wait_for_fonts = wait_for_fonts + @disable_animations = disable_animations + @hide_caret = hide_caret + @blur_active_element = blur_active_element + end + + def apply_to_screenshot!(screenshot) + screenshot.normalize_css = normalize_css + screenshot.wait_for_fonts = wait_for_fonts + screenshot.disable_animations = disable_animations + screenshot.hide_caret = hide_caret + screenshot.blur_active_element = blur_active_element + end + end + + def enable_consistent_screenshots!( + normalize_css: true, + wait_for_fonts: true, + disable_animations: true, + hide_caret: true, + blur_active_element: true + ) + self.normalize_css = normalize_css + self.wait_for_fonts = wait_for_fonts + self.disable_animations = disable_animations + self.hide_caret = hide_caret + self.blur_active_element = blur_active_element + end + + def configure_consistency(preset: :default) + case preset + when :default + enable_consistent_screenshots! + when :off + enable_consistent_screenshots!( + normalize_css: false, + wait_for_fonts: false, + disable_animations: false, + hide_caret: false, + blur_active_element: false + ) + else + raise ArgumentError, "Unknown consistency preset: #{preset.inspect}" + end + + config = ConsistencyConfig.new( + css: custom_stylesheets, + js: custom_scripts, + normalize_css: normalize_css, + wait_for_fonts: wait_for_fonts, + disable_animations: disable_animations, + hide_caret: hide_caret, + blur_active_element: blur_active_element + ) + yield config if block_given? + config.apply_to_screenshot!(self) + end + def root=(path) @@root = Pathname(path).expand_path end diff --git a/lib/capybara_screenshot_diff/dsl.rb b/lib/capybara_screenshot_diff/dsl.rb index 96db07fe..59113b87 100644 --- a/lib/capybara_screenshot_diff/dsl.rb +++ b/lib/capybara_screenshot_diff/dsl.rb @@ -49,6 +49,9 @@ def screenshot_group(name) # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels. # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels. # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips). + # @option options [Boolean] :normalize_css Inject a CSS normalization layer before capturing. + # @option options [String] :normalize_stylesheet Custom CSS to inject when :normalize_css is enabled. + # @option options [Boolean] :wait_for_fonts Wait for document fonts to load before capturing. # @return [Boolean] True if the screenshot was successfully captured and processed. # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled. # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable. diff --git a/test/test_helper.rb b/test/test_helper.rb index 0d9abbc0..826b89c0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -58,6 +58,10 @@ class ActiveSupport::TestCase Capybara::Screenshot.blur_active_element = false Capybara::Screenshot.hide_caret = false Capybara::Screenshot.disable_animations = false + Capybara::Screenshot.normalize_css = false + Capybara::Screenshot.wait_for_fonts = false + Capybara::Screenshot.custom_stylesheets = [] + Capybara::Screenshot.custom_scripts = [] end teardown do diff --git a/test/unit/screenshot_test.rb b/test/unit/screenshot_test.rb index 718a76a0..06213045 100644 --- a/test/unit/screenshot_test.rb +++ b/test/unit/screenshot_test.rb @@ -24,5 +24,24 @@ class ScreenshotTest < ActiveSupport::TestCase ensure Capybara::Screenshot.root = @orig_root if @orig_root end + + test "configure_consistency presets defaults and allows custom injections" do + Capybara::Screenshot.custom_stylesheets = [] + Capybara::Screenshot.custom_scripts = [] + + Capybara::Screenshot.configure_consistency(preset: :off) do |c| + c.css << ".csd-test { color: red; }" + c.js << "window.__csd_test = true;" + c.hide_caret = true + end + + assert_equal false, Capybara::Screenshot.normalize_css + assert_equal false, Capybara::Screenshot.wait_for_fonts + assert_equal false, Capybara::Screenshot.disable_animations + assert_equal true, Capybara::Screenshot.hide_caret + assert_equal false, Capybara::Screenshot.blur_active_element + assert_includes Capybara::Screenshot.custom_stylesheets, ".csd-test { color: red; }" + assert_includes Capybara::Screenshot.custom_scripts, "window.__csd_test = true;" + end end end diff --git a/test/unit/screenshoter_test.rb b/test/unit/screenshoter_test.rb index 4fee149c..a8d45052 100644 --- a/test/unit/screenshoter_test.rb +++ b/test/unit/screenshoter_test.rb @@ -47,6 +47,32 @@ class ScreenshoterTest < ActiveSupport::TestCase assert_nil screenshoter.prepare_page_for_screenshot(timeout: nil) # does not raise an error end + + test "#prepare_page_for_screenshot injects normalization and custom hooks in order" do + Capybara::Screenshot.normalize_css = true + Capybara::Screenshot.custom_stylesheets = ["body { color: red; }"] + Capybara::Screenshot.custom_scripts = ["window.__csd = true;"] + Capybara::Screenshot.blur_active_element = false + Capybara::Screenshot.hide_caret = false + Capybara::Screenshot.disable_animations = false + + screenshoter = Screenshoter.new({wait: nil}, {driver: :chunky_png}) + calls = [] + + BrowserHelpers.stub(:normalize_css, ->(css = nil) { calls << [:normalize_css, css] }) do + BrowserHelpers.stub(:inject_custom_stylesheets, ->(styles) { calls << [:inject_custom_stylesheets, styles] }) do + BrowserHelpers.stub(:inject_custom_scripts, ->(scripts) { calls << [:inject_custom_scripts, scripts] }) do + screenshoter.prepare_page_for_screenshot(timeout: nil) + end + end + end + + assert_equal [ + [:normalize_css, nil], + [:inject_custom_stylesheets, ["body { color: red; }"]], + [:inject_custom_scripts, ["window.__csd = true;"]] + ], calls + end end end end diff --git a/test/unit/vcs_test.rb b/test/unit/vcs_test.rb index f85b5159..20765370 100644 --- a/test/unit/vcs_test.rb +++ b/test/unit/vcs_test.rb @@ -32,6 +32,30 @@ class VcsTest < ActiveSupport::TestCase assert base_screenshot_path.exist? assert_equal screenshot_path.size, base_screenshot_path.size end + + test "#checkout_vcs ignores external git env overrides" do + screenshot_path = file_fixture("images/a.png") + base_screenshot_path = Pathname.new(@base_screenshot.path) + + with_env("GIT_DIR" => "/tmp/does-not-exist", "GIT_WORK_TREE" => "/tmp/does-not-exist") do + assert Vcs.checkout_vcs(@tmp_dir, screenshot_path, base_screenshot_path), + "checkout_vcs failed with overridden git env: root=#{@tmp_dir}" + end + + assert base_screenshot_path.exist? + assert_equal screenshot_path.size, base_screenshot_path.size + end + + private + + def with_env(vars) + previous = vars.transform_values { |_, _| nil } + vars.each_key { |key| previous[key] = ENV[key] } + vars.each { |key, value| ENV[key] = value } + yield + ensure + previous.each { |key, value| ENV[key] = value } + end end end end