capybara-screenshot-diff 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d2139e98f2c24b265a13882b8faf30282f5ef519dcbb77907f5d69d8f565606
4
- data.tar.gz: 9302c796f95734c4ec4383f926c0aad03630caed8505f1d8fc815fe11bdb3c61
3
+ metadata.gz: e22999718673e3e7cec926c524deedb3e3d0ead26e7c8350b777f5a453f02ad6
4
+ data.tar.gz: 1c10e980fb7787a493c763ddc3ee8f1a7ada5180af7277e389b24761a359f68d
5
5
  SHA512:
6
- metadata.gz: b072316d36fe69af44d3f318ba2d24968e9e0b377f85842c9a21d29aeb4ae7375a6b5b02c4842c9f3d9eb3115ed4fbcc33edff6c3b74b8ce788069d2301da95a
7
- data.tar.gz: 30389f03f593e74c888a2d9de77717f1c839ad8ce41539ef0e6633b5dcff609ed546d7ed5279b3ab7ac9c86c53ed6608872e5b28a70663c7c2aef7c0974fcdf5
6
+ metadata.gz: 6a3328839042af4d915150aba0c135d52f99074b9c31a69bdf9f6ff7e2b5a4c2c352b94f767ed9ec24f517c232257d1111bce1db3f019a668b8b4ba5314b09b1
7
+ data.tar.gz: 30c9cabdbabf2823c7fe77b4b825f7d59d0e3a8b49d373544eabb3ad171bee58d9ca8e57c6555b93254e7f04d0aa04075dd84839f4623151ae818efdd020845d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v1.15.0] - 2026-08-22
9
+
10
+ ### Added
11
+ - **`SnapDiff::Config`** — one flat object consolidating all 27 settings behind `SnapDiff.config` / `SnapDiff.configure { |config| ... }`; purely additive, the existing accessors stay canonical and both views share one source of truth ([#200](https://github.com/snap-diff/snap_diff-capybara/pull/200))
12
+
13
+ ### Fixed
14
+ - **`pending_if_new` no longer masks real failures** — a raising Minitest `teardown` or a raising RSpec `after` hook registered before the gem could previously be reported as a skipped/pending test with exit 0; the pending marker now defers until the framework's teardown chain has run and yields to any failure. Known residual: an RSpec `config.append_after` registered after this gem still runs later than the gem's hook — require the gem last if you use appended after-hooks ([#203](https://github.com/snap-diff/snap_diff-capybara/pull/203))
15
+ - **`BacktraceFilter` path-boundary matching** — a custom `lib_directory` of `/app/lib` no longer also filters locations under `/app/library` ([#202](https://github.com/snap-diff/snap_diff-capybara/pull/202))
16
+
17
+ ### Internal
18
+ - Failure-masking guard tests for the `pending_if_new` teardown paths via subprocess RSpec runs ([#199](https://github.com/snap-diff/snap_diff-capybara/pull/199))
19
+ - Hygiene pass — skip-area/VCS-baseline and `BacktraceFilter` guard tests, entry-point forwarder dedupe, `Comparison` and `BacktraceFilter` merged into their sole consumers (−2 files, constant paths preserved) ([#201](https://github.com/snap-diff/snap_diff-capybara/pull/201))
20
+
21
+ ---
22
+
23
+ ## [v1.14.0] - 2026-08-22
24
+
25
+ ### Added
26
+ - **`SnapDiff` namespace aliases** — `SnapDiff::Comparison`, `SnapDiff.compare`, and `SnapDiff.start` provide a forward-looking entry point onto the existing `Capybara::Screenshot::Diff` API, no behavior changes ([#166](https://github.com/snap-diff/snap_diff-capybara/issues/166))
27
+
28
+ ### Fixed
29
+ - **Standalone require path** — `require "capybara_screenshot_diff"` (or `require "snap_diff"`) followed by `Diff.compare` no longer raises `NameError` for the missing drivers require ([#194](https://github.com/snap-diff/snap_diff-capybara/pull/194))
30
+
31
+ ### Internal
32
+ - Merged `DifferenceFinder` into `ImageCompare` (ADR-004 step) ([#192](https://github.com/snap-diff/snap_diff-capybara/pull/192))
33
+ - Extracted `AnnotationService` from `Reporters::Default`; full public surface preserved via delegation ([#193](https://github.com/snap-diff/snap_diff-capybara/pull/193))
34
+ - Consolidated `pending_if_new` teardown logic into one shared helper across Minitest/RSpec/Cucumber adapters ([#197](https://github.com/snap-diff/snap_diff-capybara/pull/197))
35
+ - Test-suite driver-coverage banner and CI guard against silently missing drivers ([#198](https://github.com/snap-diff/snap_diff-capybara/pull/198))
36
+
37
+ ---
38
+
8
39
  ## [v1.13.0] - 2026-08-22
9
40
 
10
41
  ### Added
data/docs/RELEASE_PREP.md CHANGED
@@ -1,24 +1,23 @@
1
- # Release Preparation — v1.13.0
1
+ # Release Preparation — v1.15.0
2
2
 
3
3
  ## Summary
4
4
 
5
- Issue #191 API decoupling: `assert_matches_screenshot` becomes the primary
6
- assertion method, plus a capture-only API and opt-in pending-instead-of-pass
7
- for missing baselines.
5
+ Additive `SnapDiff::Config`, two failure-masking fixes in the `pending_if_new`
6
+ teardown paths, a `BacktraceFilter` boundary fix, and an internal hygiene pass.
8
7
 
9
8
  ## Release Checklist
10
9
 
11
10
  ### Pre-Release
12
11
 
13
- - [x] Update version to `1.13.0`
14
- - [x] Run tests: `bundle exec rake test:unit` (235 runs, 0 failures)
12
+ - [x] Update version to `1.15.0`
13
+ - [x] Run tests: `bundle exec rake test:unit` (267 runs, 0 failures)
15
14
  - [x] Update CHANGELOG.md
16
15
 
17
16
  ### Release (One Click)
18
17
 
19
18
  1. Push to GitHub
20
19
  2. Go to [Actions → Release](https://github.com/snap-diff/snap_diff-capybara/actions/workflows/release.yml)
21
- 3. Click **Run workflow**, enter `1.13.0`
20
+ 3. Click **Run workflow**, enter `1.15.0`
22
21
  4. Workflow will: test → tag → publish to RubyGems → create GitHub Release
23
22
 
24
23
  ### Post-Release
@@ -28,15 +27,18 @@ for missing baselines.
28
27
 
29
28
  ## What Changed
30
29
 
31
- ### New Features
32
- - `capture_screenshot` DSL method capture without comparing or asserting
33
- - `compare:` option on `screenshot` `compare: false` captures only
34
- - `Capybara::Screenshot::Diff.pending_if_new` — mark tests skipped in teardown
35
- when a screenshot has no committed baseline (Minitest, RSpec, and Cucumber)
30
+ ### Added
31
+ - `SnapDiff::Config` flat, additive consolidation of all 27 settings
32
+ (`SnapDiff.config` / `SnapDiff.configure`); old accessors stay canonical
36
33
 
37
- ### Behavior Changes
38
- - `assert_matches_screenshot` is now the primary assertion method; `screenshot`
39
- remains as a convenience wrapper and is safe to override in user test classes —
40
- the gem no longer calls it internally ([#191](https://github.com/snap-diff/snap_diff-capybara/issues/191))
34
+ ### Fixed
35
+ - `pending_if_new` no longer converts real teardown/after-hook failures into
36
+ pending tests (Minitest defers to `after_teardown`; RSpec uses `append_after`;
37
+ known residual for consumer `append_after` hooks documented)
38
+ - `BacktraceFilter` custom `lib_directory` matches on a path boundary
39
+
40
+ ### Internal
41
+ - Guard tests for failure masking and skip-area/VCS-baseline regressions;
42
+ two files merged into sole consumers (constant paths preserved)
41
43
 
42
44
  See [CHANGELOG.md](../CHANGELOG.md) for full details.
data/docs/UPGRADING.md CHANGED
@@ -1,6 +1,91 @@
1
- # Upgrading to v1.12.0
1
+ # Upgrading
2
2
 
3
- ## Overview
3
+ ## Upgrading to v1.13.0
4
+
5
+ ### Overview
6
+
7
+ Version 1.13.0 is a **minor release** clarifying API terminology and adding new capture methods. No breaking changes — your existing code continues to work.
8
+
9
+ **Estimated upgrade time:** 0 minutes (no action required for most users)
10
+
11
+ ---
12
+
13
+ ### Quick Upgrade Path (Most Users)
14
+
15
+ ```ruby
16
+ # In your Gemfile
17
+ gem 'capybara-screenshot-diff', '~> 1.13.0'
18
+ ```
19
+
20
+ ```bash
21
+ bundle update capybara-screenshot-diff
22
+ bundle exec rake test
23
+ ```
24
+
25
+ **That's it!** Existing `screenshot` calls work unchanged. New methods available if needed.
26
+
27
+ ---
28
+
29
+ ### What Changed
30
+
31
+ #### API Clarification: Primary Method is `assert_matches_screenshot`
32
+
33
+ **v1.12.0 and earlier:** `screenshot` was the primary method
34
+ **v1.13.0+:** `assert_matches_screenshot` is the primary method
35
+
36
+ **Action required:** None. `screenshot` continues to work as-is.
37
+
38
+ The method names now better reflect their behavior:
39
+ - `assert_matches_screenshot(name)` — takes screenshot and asserts it matches baseline
40
+ - `screenshot(name, compare: true)` — convenience wrapper (same behavior as above when `compare: true`)
41
+ - `capture_screenshot(name)` — new: captures without asserting
42
+
43
+ ```ruby
44
+ # All three work and are safe to use:
45
+ assert_matches_screenshot "homepage" # Primary: explicit intent
46
+ screenshot "homepage" # Shorthand (familiar)
47
+ screenshot "homepage", compare: false # Capture only
48
+ capture_screenshot "homepage" # Also capture only
49
+ ```
50
+
51
+ **Safe to override:** You can safely define your own `screenshot` method in your test base class — the gem's implementation won't interfere.
52
+
53
+ ---
54
+
55
+ ### New: `capture_screenshot` Method
56
+
57
+ Capture without comparing to baseline:
58
+
59
+ ```ruby
60
+ capture_screenshot "dynamic_page" # No assertion
61
+ ```
62
+
63
+ Equivalent to: `screenshot "dynamic_page", compare: false`
64
+
65
+ ---
66
+
67
+ ### New: `Diff.pending_if_new` Helper
68
+
69
+ Mark baseline-less tests pending instead of failing during initial CI runs:
70
+
71
+ ```ruby
72
+ # In test_helper.rb — before running tests
73
+ Capybara::Screenshot::Diff.pending_if_new = true
74
+ ```
75
+
76
+ **CI requirement:** When using `pending_if_new`, ensure CI is configured with `fail_if_new: false` (see [Configuration Reference](configuration.md#quick-setup)):
77
+
78
+ ```ruby
79
+ Capybara::Screenshot::Diff.configure do |screenshot, diff|
80
+ diff.fail_if_new = false # Allow baselines to be added
81
+ end
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Upgrading to v1.12.0
87
+
88
+ ### Overview
4
89
 
5
90
  Version 1.12.0 is a **minor release** with new features, performance improvements, and default behavior changes. This guide will help you upgrade smoothly.
6
91
 
data/docs/architecture.md CHANGED
@@ -100,7 +100,7 @@ The comparison engine uses a **layered optimization strategy** to balance speed
100
100
  - `quick_equal?` is designed for fast rejection — it early-returns as soon as a difference is found
101
101
  - `different?` triggers the full comparison if not already processed
102
102
  - `processed` guarantees the comparison is complete and returns the result with all metadata
103
- - The `DifferenceFinder` handles the actual pixel analysis, delegating to the driver
103
+ - `ImageCompare#analyze_difference` handles the actual pixel analysis, delegating to the driver
104
104
 
105
105
  ### 5. Drivers (`lib/capybara/screenshot/diff/drivers/`)
106
106
 
@@ -239,7 +239,6 @@ lib/
239
239
  screenshot_assertion.rb # Assertion + registry objects
240
240
  attempts_reporter.rb # Debug reporting for unstable captures
241
241
  error_with_filtered_backtrace.rb # Error with filtered stack
242
- backtrace_filter.rb # Library/gem frame filtering
243
242
  reporters/
244
243
  html.rb # Interactive HTML report reporter
245
244
  templates/report.html.erb # HTML report template
@@ -255,9 +254,7 @@ lib/
255
254
  stable_screenshoter.rb # Stability detection wrapper
256
255
  screenshot_matcher.rb # Orchestrator for capture + compare
257
256
  image_compare.rb # Layered comparison engine
258
- comparison.rb # Comparison value object
259
257
  difference.rb # Difference result value object
260
- difference_finder.rb # Core diff analysis logic
261
258
  image_preprocessor.rb # Pre-processing (skip areas, median filter)
262
259
  area_calculator.rb # Crop/skip area coordinate resolution
263
260
  region.rb # Bounding box region value object
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Screenshot::Diff
4
+ # Draws diff/skip-area rectangles and the heatmap overlay for a Difference,
5
+ # and saves the resulting images to their `.diff.*` / `.heatmap.diff.*` paths.
6
+ #
7
+ # Extracted from Reporters::Default (ADR-004 PR 7) so the reporter only
8
+ # builds error messages; this class owns all image annotation work.
9
+ class AnnotationService
10
+ attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path
11
+
12
+ def initialize(difference)
13
+ @difference = difference
14
+
15
+ comparison = difference.comparison
16
+ ext = comparison.new_image_path.extname.delete_prefix(".")
17
+ screenshot_format = comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
18
+ @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
19
+ @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
20
+ @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
21
+ end
22
+
23
+ def clean_tmp_files
24
+ annotated_base_image_path.unlink if annotated_base_image_path.exist?
25
+ annotated_image_path.unlink if annotated_image_path.exist?
26
+ heatmap_diff_path.unlink if heatmap_diff_path.exist?
27
+ end
28
+
29
+ def annotate_and_save_images
30
+ save_annotation_for(new_image, annotated_image_path)
31
+ save_annotation_for(base_image, annotated_base_image_path)
32
+ save_heatmap_diff if difference.diff_mask
33
+ end
34
+
35
+ def save_annotation_for(image, image_path)
36
+ image = annotate_difference(image, difference.region)
37
+ image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
38
+
39
+ save(image, image_path.to_path)
40
+ end
41
+
42
+ def annotate_difference(image, region)
43
+ driver.draw_rectangles([image], region, CapybaraScreenshotDiff::RED_RGBA, offset: 1).first
44
+ end
45
+
46
+ def annotate_skip_areas(image, skip_areas)
47
+ skip_areas.reduce(image) do |memo, region|
48
+ driver.draw_rectangles([memo], region, CapybaraScreenshotDiff::ORANGE_RGBA).first
49
+ end
50
+ end
51
+
52
+ def save(image, image_path)
53
+ driver.save_image_to(image, image_path.to_s)
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :difference
59
+
60
+ def save_heatmap_diff
61
+ merged_image = driver.merge(new_image, base_image)
62
+ highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
63
+
64
+ save(highlighted_mask, heatmap_diff_path.to_path)
65
+ end
66
+
67
+ def base_image
68
+ difference.comparison.base_image
69
+ end
70
+
71
+ def new_image
72
+ difference.comparison.new_image
73
+ end
74
+
75
+ def driver
76
+ @_driver ||= difference.comparison.driver
77
+ end
78
+ end
79
+ end
@@ -3,9 +3,9 @@
3
3
  require "pathname"
4
4
  require "fileutils"
5
5
 
6
- require "capybara/screenshot/diff/comparison"
6
+ require "capybara/screenshot/diff/difference"
7
+ require "capybara/screenshot/diff/drivers"
7
8
  require "capybara/screenshot/diff/image_preprocessor"
8
- require "capybara/screenshot/diff/difference_finder"
9
9
  require "capybara/screenshot/diff/reporters/default"
10
10
 
11
11
  module Capybara
@@ -13,6 +13,13 @@ module Capybara
13
13
  module Diff
14
14
  LOADED_DRIVERS = {}
15
15
 
16
+ # Holds the two images (and their paths/options/driver) being compared.
17
+ class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
18
+ def skip_area
19
+ options[:skip_area]
20
+ end
21
+ end
22
+
16
23
  # Handles comparison of two images with a focus on performance and accuracy.
17
24
  #
18
25
  # This class implements a multi-layered optimization strategy for image comparison:
@@ -36,6 +43,8 @@ module Capybara
36
43
  # - Only performing expensive operations when absolutely necessary
37
44
  # - Maintaining high accuracy for complex comparisons
38
45
  class ImageCompare
46
+ TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
47
+
39
48
  attr_reader :driver, :driver_options
40
49
  attr_reader :image_path, :base_image_path
41
50
  attr_reader :difference, :error_message
@@ -48,6 +57,7 @@ module Capybara
48
57
 
49
58
  @driver_options = options.freeze
50
59
  @driver = Drivers.for(@driver_options)
60
+ @without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
51
61
  end
52
62
 
53
63
  # Performs a quick comparison of two image files.
@@ -85,7 +95,7 @@ module Capybara
85
95
  # - `false` if the images are considered identical
86
96
  #
87
97
  # @see #processed
88
- # @see DifferenceFinder
98
+ # @see #analyze_difference
89
99
  def different?
90
100
  processed.difference.different?
91
101
  end
@@ -110,8 +120,8 @@ module Capybara
110
120
 
111
121
  private
112
122
 
113
- def difference_finder
114
- @difference_finder ||= DifferenceFinder.new(driver, driver_options)
123
+ def without_tolerable_options?
124
+ @without_tolerable_options
115
125
  end
116
126
 
117
127
  def load_images_and_build_comparison(base_path, new_path, options)
@@ -130,8 +140,41 @@ module Capybara
130
140
  # Create comparison with preprocessed images
131
141
  comparison = load_comparison(base_image_path, image_path, driver_options)
132
142
 
133
- # Use difference finder to analyze the comparison
134
- difference_finder.call(comparison, quick_mode: quick_mode)
143
+ analyze_difference(comparison, quick_mode: quick_mode)
144
+ end
145
+
146
+ # Analyzes the comparison and determines if images are different.
147
+ #
148
+ # @param comparison [Comparison] The comparison object containing images to analyze.
149
+ # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
150
+ # In quick mode, returns [is_equal, difference] where:
151
+ # - is_equal is true if images are considered equal
152
+ # - difference is a Difference object or nil
153
+ # When false, returns a Difference object directly.
154
+ # @return [Array, Difference] Result format depends on quick_mode parameter.
155
+ def analyze_difference(comparison, quick_mode: true)
156
+ # Handle dimension differences
157
+ unless driver.same_dimension?(comparison)
158
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
159
+ return quick_mode ? [false, result] : result
160
+ end
161
+
162
+ # Handle identical pixels
163
+ if driver.same_pixels?(comparison)
164
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
165
+ return quick_mode ? [true, result] : result
166
+ end
167
+
168
+ # Handle early return for non-tolerable options
169
+ if quick_mode && without_tolerable_options?
170
+ return [false, nil]
171
+ end
172
+
173
+ # Process difference region
174
+ region = driver.find_difference_region(comparison)
175
+
176
+ # Only create a proper difference object if we've completed the comparison
177
+ quick_mode ? [!region.different?, region] : region
135
178
  end
136
179
 
137
180
  def difference=(new_difference)
@@ -1,18 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "capybara/screenshot/diff/annotation_service"
4
+
3
5
  module Capybara::Screenshot::Diff
4
6
  module Reporters
5
7
  class Default
6
- attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path, :difference
8
+ attr_reader :difference
7
9
 
8
10
  def initialize(difference)
9
11
  @difference = difference
12
+ @annotation_service = AnnotationService.new(difference)
13
+ end
14
+
15
+ def annotated_image_path
16
+ annotation_service.annotated_image_path
17
+ end
10
18
 
11
- ext = comparison.new_image_path.extname.delete_prefix(".")
12
- screenshot_format = difference.comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
13
- @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
14
- @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
15
- @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
19
+ def annotated_base_image_path
20
+ annotation_service.annotated_base_image_path
21
+ end
22
+
23
+ def heatmap_diff_path
24
+ annotation_service.heatmap_diff_path
16
25
  end
17
26
 
18
27
  def generate
@@ -31,44 +40,35 @@ module Capybara::Screenshot::Diff
31
40
  end
32
41
 
33
42
  def clean_tmp_files
34
- annotated_base_image_path.unlink if annotated_base_image_path.exist?
35
- annotated_image_path.unlink if annotated_image_path.exist?
36
- heatmap_diff_path.unlink if heatmap_diff_path.exist?
37
- end
38
-
39
- def build_error_for_different_dimensions
40
- change_msg = [comparison.base_image, comparison.new_image]
41
- .map { |image| driver.dimension(image).join("x") }
42
- .join(" => ")
43
-
44
- "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
43
+ annotation_service.clean_tmp_files
45
44
  end
46
45
 
47
46
  def annotate_and_save_images
48
- save_annotation_for(new_image, annotated_image_path)
49
- save_annotation_for(base_image, annotated_base_image_path)
50
- save_heatmap_diff if difference.diff_mask
47
+ annotation_service.annotate_and_save_images
51
48
  end
52
49
 
53
50
  def save_annotation_for(image, image_path)
54
- image = annotate_difference(image, difference.region)
55
- image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
56
-
57
- save(image, image_path.to_path)
51
+ annotation_service.save_annotation_for(image, image_path)
58
52
  end
59
53
 
60
54
  def annotate_difference(image, region)
61
- driver.draw_rectangles([image], region, CapybaraScreenshotDiff::RED_RGBA, offset: 1).first
55
+ annotation_service.annotate_difference(image, region)
62
56
  end
63
57
 
64
58
  def annotate_skip_areas(image, skip_areas)
65
- skip_areas.reduce(image) do |memo, region|
66
- driver.draw_rectangles([memo], region, CapybaraScreenshotDiff::ORANGE_RGBA).first
67
- end
59
+ annotation_service.annotate_skip_areas(image, skip_areas)
68
60
  end
69
61
 
70
62
  def save(image, image_path)
71
- driver.save_image_to(image, image_path.to_s)
63
+ annotation_service.save(image, image_path)
64
+ end
65
+
66
+ def build_error_for_different_dimensions
67
+ change_msg = [comparison.base_image, comparison.new_image]
68
+ .map { |image| driver.dimension(image).join("x") }
69
+ .join(" => ")
70
+
71
+ "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
72
72
  end
73
73
 
74
74
  NEW_LINE = "\n"
@@ -85,20 +85,7 @@ module Capybara::Screenshot::Diff
85
85
 
86
86
  private
87
87
 
88
- def save_heatmap_diff
89
- merged_image = driver.merge(new_image, base_image)
90
- highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
91
-
92
- save(highlighted_mask, heatmap_diff_path.to_path)
93
- end
94
-
95
- def base_image
96
- difference.comparison.base_image
97
- end
98
-
99
- def new_image
100
- difference.comparison.new_image
101
- end
88
+ attr_reader :annotation_service
102
89
 
103
90
  def base_image_path
104
91
  comparison.base_image_path
@@ -14,7 +14,7 @@ module Capybara
14
14
  # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`.
15
15
  #
16
16
  # @param capture_options [Hash] The options for capturing screenshots, must include `:stability_time_limit` and `:wait`.
17
- # @param comparison_options [Hash, nil] The options for comparing screenshots, defaults to `nil` which uses `Diff.default_options`.
17
+ # @param comparison_options [Hash] The options for comparing screenshots, defaults to `{}`. Same signature as {Capybara::Screenshot::Screenshoter#initialize}.
18
18
  # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`.
19
19
  def initialize(capture_options, comparison_options = {})
20
20
  @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS)
@@ -3,7 +3,7 @@
3
3
  module Capybara
4
4
  module Screenshot
5
5
  module Diff
6
- VERSION = "1.13.0"
6
+ VERSION = "1.15.0"
7
7
  end
8
8
  end
9
9
  end
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "capybara_screenshot_diff/minitest"
3
+ require "capybara-screenshot-diff"
@@ -10,9 +10,8 @@ Before do
10
10
  end
11
11
 
12
12
  After do |scenario|
13
- if !scenario.failed? && Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
14
- names = CapybaraScreenshotDiff.new_screenshots
15
- skip_this_scenario("No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them.")
13
+ if !scenario.failed? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
14
+ skip_this_scenario(msg)
16
15
  end
17
16
  ensure
18
17
  CapybaraScreenshotDiff.reset
@@ -1,8 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "capybara_screenshot_diff/backtrace_filter"
4
-
5
3
  module CapybaraScreenshotDiff
4
+ class BacktraceFilter
5
+ LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR
6
+
7
+ def initialize(lib_directory = LIB_DIRECTORY)
8
+ # Trailing separator keeps the prefix match on a directory boundary,
9
+ # so "/app/lib" does not also reject "/app/library".
10
+ @lib_directory = lib_directory.end_with?(File::SEPARATOR) ? lib_directory : lib_directory + File::SEPARATOR
11
+ end
12
+
13
+ # Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
14
+ # @param backtrace [Array<String>]
15
+ # @return [Array<String>]
16
+ def filtered(backtrace)
17
+ backtrace
18
+ .reject { |location| File.expand_path(location).start_with?(@lib_directory) }
19
+ .reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
20
+ end
21
+ end
22
+
6
23
  # @private
7
24
  class ErrorWithFilteredBacktrace < StandardError
8
25
  # @private
@@ -36,10 +36,10 @@ module CapybaraScreenshotDiff
36
36
  super
37
37
  CapybaraScreenshotDiff.verify
38
38
 
39
- if ::Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
40
- names = CapybaraScreenshotDiff.new_screenshots
41
- skip "No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them."
42
- end
39
+ # Computed here (before teardown/reset), but the actual `skip` is
40
+ # deferred to `after_teardown` so a real error raised by the user's
41
+ # `teardown` isn't masked by a pending skip recorded before it ran.
42
+ @capybara_screenshot_diff_pending_message = CapybaraScreenshotDiff.pending_screenshots_message
43
43
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
44
44
  assertion = ::Minitest::Assertion.new(e)
45
45
  assertion.set_backtrace(e.backtrace)
@@ -47,6 +47,16 @@ module CapybaraScreenshotDiff
47
47
  ensure
48
48
  CapybaraScreenshotDiff.reset
49
49
  end
50
+
51
+ def after_teardown
52
+ super
53
+
54
+ # Never mask a real failure (from `verify` above or from the user's
55
+ # own `teardown`) with a pending marker.
56
+ if failures.empty? && (msg = @capybara_screenshot_diff_pending_message)
57
+ skip(msg)
58
+ end
59
+ end
50
60
  end
51
61
  end
52
62
  end
@@ -30,15 +30,29 @@ RSpec.configure do |config|
30
30
  end
31
31
  end
32
32
 
33
- config.after do |example|
33
+ # `append_after` (as opposed to the default `after`, which prepends) adds
34
+ # this hook to the *end* of the after-hook chain regardless of when it's
35
+ # registered relative to the user's own `after`/`config.after` hooks. RSpec
36
+ # runs `after(:each)` hooks in reverse registration order, so a plain
37
+ # `config.after` here would run BEFORE a user hook registered earlier in
38
+ # their own spec_helper (before this file was required) -- and if that
39
+ # later-running user hook raises, its exception gets folded into
40
+ # `pending_exception` instead of `example.exception`, silently masking the
41
+ # failure behind our pending skip. `append_after` runs after the full user
42
+ # after-chain no matter the registration order, closing that gap.
43
+ config.append_after do |example|
34
44
  if self.class.include?(CapybaraScreenshotDiff::DSL)
35
45
  begin
36
46
  CapybaraScreenshotDiff.verify
37
47
 
38
- # Never mask a real failure with a pending marker.
39
- if example.exception.nil? && Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
40
- names = CapybaraScreenshotDiff.new_screenshots
41
- skip "No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them."
48
+ # Never mask a real failure with a pending marker. Kept as
49
+ # defense-in-depth: `append_after` observes failures from plain
50
+ # `after`/`prepend_after` user hooks, but appended hooks run FIFO,
51
+ # so a user `append_after` registered after this gem still runs
52
+ # later than us — RSpec has no "run absolutely last" construct.
53
+ # Mitigation for such consumers: require this gem last.
54
+ if example.exception.nil? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
55
+ skip(msg)
42
56
  end
43
57
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
44
58
  raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
@@ -158,6 +158,17 @@ module CapybaraScreenshotDiff
158
158
  def_delegator :registry, :screenshot_namer
159
159
  def_delegator :registry, :verify
160
160
 
161
+ # Message to skip the test with when a new screenshot has no baseline yet
162
+ # and `pending_if_new` is enabled. Adapters call this after verifying
163
+ # screenshots, and skip the test with the returned message when present.
164
+ #
165
+ # @return [String, nil] the pending message, or nil when there is nothing to report
166
+ def pending_screenshots_message
167
+ return unless ::Capybara::Screenshot::Diff.pending_if_new && new_screenshots_present?
168
+
169
+ "No baseline for: #{new_screenshots.join(", ")}. Commit the captured screenshots to record them."
170
+ end
171
+
161
172
  private
162
173
 
163
174
  def notify_reporters(assertions)
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Lazy load: snap_diff.rb requires this file, so eagerly `require`-ing it
4
+ # here would be circular. `autoload` defers the load until SnapDiff is
5
+ # first referenced, by which point this file has already finished loading.
6
+ autoload :SnapDiff, "snap_diff"
7
+
3
8
  require "capybara/dsl"
4
9
  require "capybara/screenshot/diff/version"
5
10
  require "capybara/screenshot/diff/utils"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The MAPPING below references the legacy modules, so they must be loaded first.
4
+ require "capybara_screenshot_diff"
5
+
6
+ module SnapDiff
7
+ # Flat, additive consolidation of every existing
8
+ # +Capybara::Screenshot+ / +Capybara::Screenshot::Diff+ +mattr_accessor+
9
+ # setting behind one object: <tt>SnapDiff.config.<em>attr</em></tt>.
10
+ #
11
+ # Storage ownership: the OLD mattr_accessors remain the single source of
12
+ # truth. Config holds no state of its own -- every reader/writer defined
13
+ # from {MAPPING} simply forwards to the existing accessor. This is
14
+ # deliberate, not just simplest: several of those accessors carry default
15
+ # logic with observable timing (+fail_if_new+ derives its default from
16
+ # +ENV["CI"]+, +root+ falls back to +Rails.root+) that Rails' +mattr_accessor+
17
+ # evaluates once, at class-body-eval time, when +capybara_screenshot_diff.rb+
18
+ # first loads. Re-implementing that logic here -- even faithfully -- would
19
+ # mean evaluating it again, at a different moment (Config#new time), which
20
+ # is exactly the kind of "when defaults are evaluated" divergence the v2
21
+ # consolidation must not introduce. Delegating sidesteps the question
22
+ # entirely: Config never evaluates a default, it only ever forwards to
23
+ # whichever accessor already owns one. Bidirectional consistency (a write
24
+ # through either the old accessor or Config is visible through the other)
25
+ # falls out for free, because both paths read and write the exact same
26
+ # class-variable-backed storage.
27
+ class Config
28
+ # config attr name => [owning module, mattr_accessor name].
29
+ #
30
+ # The two names differ only for +screenshot_enabled+:
31
+ # +Capybara::Screenshot.enabled+ and +Capybara::Screenshot::Diff.enabled+
32
+ # are independent settings (see +Capybara::Screenshot.active?+, which
33
+ # reads both) that happen to share a bare name in their own modules. A
34
+ # flat Config can't expose two attributes both called +enabled+, so the
35
+ # Screenshot-side one is renamed here; Diff's keeps the bare +enabled+
36
+ # name since it's the one most existing configuration touches directly.
37
+ MAPPING = {
38
+ # Capybara::Screenshot
39
+ add_driver_path: [Capybara::Screenshot, :add_driver_path],
40
+ add_os_path: [Capybara::Screenshot, :add_os_path],
41
+ blur_active_element: [Capybara::Screenshot, :blur_active_element],
42
+ screenshot_enabled: [Capybara::Screenshot, :enabled],
43
+ hide_caret: [Capybara::Screenshot, :hide_caret],
44
+ disable_animations: [Capybara::Screenshot, :disable_animations],
45
+ root: [Capybara::Screenshot, :root],
46
+ stability_time_limit: [Capybara::Screenshot, :stability_time_limit],
47
+ window_size: [Capybara::Screenshot, :window_size],
48
+ save_path: [Capybara::Screenshot, :save_path],
49
+ use_lfs: [Capybara::Screenshot, :use_lfs],
50
+ screenshot_format: [Capybara::Screenshot, :screenshot_format],
51
+ capybara_screenshot_options: [Capybara::Screenshot, :capybara_screenshot_options],
52
+ # Capybara::Screenshot::Diff
53
+ delayed: [Capybara::Screenshot::Diff, :delayed],
54
+ area_size_limit: [Capybara::Screenshot::Diff, :area_size_limit],
55
+ fail_if_new: [Capybara::Screenshot::Diff, :fail_if_new],
56
+ pending_if_new: [Capybara::Screenshot::Diff, :pending_if_new],
57
+ fail_on_difference: [Capybara::Screenshot::Diff, :fail_on_difference],
58
+ color_distance_limit: [Capybara::Screenshot::Diff, :color_distance_limit],
59
+ enabled: [Capybara::Screenshot::Diff, :enabled],
60
+ shift_distance_limit: [Capybara::Screenshot::Diff, :shift_distance_limit],
61
+ skip_area: [Capybara::Screenshot::Diff, :skip_area],
62
+ driver: [Capybara::Screenshot::Diff, :driver],
63
+ tolerance: [Capybara::Screenshot::Diff, :tolerance],
64
+ perceptual_threshold: [Capybara::Screenshot::Diff, :perceptual_threshold],
65
+ screenshoter: [Capybara::Screenshot::Diff, :screenshoter],
66
+ manager: [Capybara::Screenshot::Diff, :manager]
67
+ }.freeze
68
+
69
+ MAPPING.each do |name, (mod, mattr)|
70
+ define_method(name) { mod.public_send(mattr) }
71
+ define_method(:"#{name}=") { |value| mod.public_send(:"#{mattr}=", value) }
72
+ end
73
+ end
74
+ end
data/lib/snap_diff.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara_screenshot_diff"
4
+ require "snap_diff/config"
5
+
6
+ # Forward-looking namespace for the gem, per ADR-004.
7
+ #
8
+ # These are pure additive aliases onto the existing
9
+ # +Capybara::Screenshot::Diff+ API — no behavior changes, no deprecation
10
+ # warnings. See ADR-004 for the full migration plan.
11
+ module SnapDiff
12
+ Comparison = Capybara::Screenshot::Diff::ImageCompare
13
+
14
+ def self.compare(...)
15
+ Capybara::Screenshot::Diff.compare(...)
16
+ end
17
+
18
+ # v1-style configuration: yields the two existing mattr_accessor holders
19
+ # (+Capybara::Screenshot+, +Capybara::Screenshot::Diff+) exactly as
20
+ # +Capybara::Screenshot::Diff.configure+ always has. Kept byte-for-byte
21
+ # identical to +Diff.configure+ for existing callers migrating namespaces
22
+ # without changing call shape.
23
+ #
24
+ # SnapDiff.start do |screenshot, diff|
25
+ # screenshot.window_size = [1280, 1024]
26
+ # diff.tolerance = 0.0005
27
+ # end
28
+ def self.start(&block)
29
+ Capybara::Screenshot::Diff.configure(&block)
30
+ end
31
+
32
+ # Forward-looking configuration: yields the single consolidated
33
+ # {SnapDiff::Config} object instead of the two old holders. Same
34
+ # underlying storage as +start+ / the old mattr_accessors -- this is a
35
+ # different *shape* of the same settings, not a second source of truth.
36
+ #
37
+ # SnapDiff.configure do |config|
38
+ # config.window_size = [1280, 1024]
39
+ # config.tolerance = 0.0005
40
+ # end
41
+ def self.configure
42
+ yield config
43
+ end
44
+
45
+ # The single consolidated settings object. See {SnapDiff::Config}.
46
+ def self.config
47
+ @config ||= Config.new
48
+ end
49
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-screenshot-diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.13.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Uwe Kubosch
@@ -98,12 +98,11 @@ files:
98
98
  - gems.rb
99
99
  - lib/capybara-screenshot-diff.rb
100
100
  - lib/capybara/screenshot/diff.rb
101
+ - lib/capybara/screenshot/diff/annotation_service.rb
101
102
  - lib/capybara/screenshot/diff/area_calculator.rb
102
103
  - lib/capybara/screenshot/diff/browser_helpers.rb
103
- - lib/capybara/screenshot/diff/comparison.rb
104
104
  - lib/capybara/screenshot/diff/cucumber.rb
105
105
  - lib/capybara/screenshot/diff/difference.rb
106
- - lib/capybara/screenshot/diff/difference_finder.rb
107
106
  - lib/capybara/screenshot/diff/drivers.rb
108
107
  - lib/capybara/screenshot/diff/drivers/base_driver.rb
109
108
  - lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb
@@ -121,7 +120,6 @@ files:
121
120
  - lib/capybara/screenshot/diff/version.rb
122
121
  - lib/capybara_screenshot_diff.rb
123
122
  - lib/capybara_screenshot_diff/attempts_reporter.rb
124
- - lib/capybara_screenshot_diff/backtrace_filter.rb
125
123
  - lib/capybara_screenshot_diff/cucumber.rb
126
124
  - lib/capybara_screenshot_diff/dsl.rb
127
125
  - lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb
@@ -134,6 +132,8 @@ files:
134
132
  - lib/capybara_screenshot_diff/snap.rb
135
133
  - lib/capybara_screenshot_diff/snap_manager.rb
136
134
  - lib/capybara_screenshot_diff/static.rb
135
+ - lib/snap_diff.rb
136
+ - lib/snap_diff/config.rb
137
137
  homepage: https://github.com/snap-diff/snap_diff-capybara
138
138
  licenses:
139
139
  - MIT
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Capybara::Screenshot::Diff
4
- class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
5
- def skip_area
6
- options[:skip_area]
7
- end
8
- end
9
- end
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "capybara/screenshot/diff/comparison"
4
- require "capybara/screenshot/diff/difference"
5
-
6
- module Capybara
7
- module Screenshot
8
- module Diff
9
- # Analyzes image differences with configurable tolerance levels.
10
- #
11
- # This class implements the core comparison logic for detecting visual differences
12
- # between images while accounting for various tolerances and optimizations.
13
- #
14
- # The comparison process follows these steps:
15
- # 1. Dimension Check (Fastest)
16
- # - Compares image dimensions first for quick rejection
17
- # - Different dimensions always indicate a difference
18
- #
19
- # 2. Pixel Equality Check (Fast)
20
- # - Performs bitwise comparison if dimensions match
21
- # - Returns immediately if images are exactly identical
22
- #
23
- # 3. Tolerant Comparison (Slower)
24
- # - Only runs if quick checks don't determine equality
25
- # - Respects configured tolerances for color and shift differences
26
- # - Can ignore specific regions (skip_area)
27
- # - Considers anti-aliasing and sub-pixel rendering differences
28
- #
29
- # The class is designed to be stateless and thread-safe, with all configuration
30
- # passed in through the constructor.
31
- class DifferenceFinder
32
- TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
33
-
34
- attr_reader :driver, :options
35
-
36
- # Creates a new DifferenceFinder instance.
37
- #
38
- # @param driver [Drivers::Base] The image processing driver to use.
39
- # Must implement the driver interface expected by DifferenceFinder.
40
- # @param options [Hash] Configuration options for the comparison:
41
- # @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0).
42
- # @option options [Numeric] :color_distance_limit Maximum allowed color distance.
43
- # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance.
44
- # @option options [Numeric] :area_size_limit Maximum allowed difference area size.
45
- # @option options [Array<Array>] :skip_area Regions to exclude from comparison.
46
- def initialize(driver, options)
47
- @driver = driver
48
- @options = options
49
- @without_tolerable_options = (options.keys & TOLERABLE_OPTIONS).empty?
50
- end
51
-
52
- # Analyzes the comparison and determines if images are different.
53
- #
54
- # @param comparison [Comparison] The comparison object containing images to analyze.
55
- # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
56
- # In quick mode, returns [is_equal, difference] where:
57
- # - is_equal is true if images are considered equal
58
- # - difference is a Difference object or nil
59
- # When false, returns a Difference object directly.
60
- # @return [Array, Difference] Result format depends on quick_mode parameter.
61
- # @raise [ArgumentError] If the comparison object is invalid.
62
- def call(comparison, quick_mode: true)
63
- # Process the comparison and return result
64
-
65
- # Handle dimension differences
66
- unless driver.same_dimension?(comparison)
67
- result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
68
- return quick_mode ? [false, result] : result
69
- end
70
-
71
- # Handle identical pixels
72
- if driver.same_pixels?(comparison)
73
- result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
74
- return quick_mode ? [true, result] : result
75
- end
76
-
77
- # Handle early return for non-tolerable options
78
- if quick_mode && without_tolerable_options?
79
- return [false, nil]
80
- end
81
-
82
- # Process difference region
83
- region = driver.find_difference_region(comparison)
84
-
85
- # Only create a proper difference object if we've completed the comparison
86
- quick_mode ? [!region.different?, region] : region
87
- end
88
-
89
- private
90
-
91
- def without_tolerable_options?
92
- @without_tolerable_options
93
- end
94
- end
95
- end
96
- end
97
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CapybaraScreenshotDiff
4
- class BacktraceFilter
5
- LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR
6
-
7
- def initialize(lib_directory = LIB_DIRECTORY)
8
- @lib_directory = lib_directory
9
- end
10
-
11
- # Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
12
- # @param backtrace [Array<String>]
13
- # @return [Array<String>]
14
- def filtered(backtrace)
15
- backtrace
16
- .reject { |location| File.expand_path(location).start_with?(@lib_directory) }
17
- .reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
18
- end
19
- end
20
- end