capybara-screenshot-diff 1.15.0 → 2.0.0.alpha1

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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/docs/RELEASE_PREP.md +6 -6
  4. data/docs/thread_safety.md +15 -0
  5. data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
  6. data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
  7. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
  8. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  9. data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
  10. data/lib/capybara/screenshot/diff/difference.rb +6 -102
  11. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
  12. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
  13. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
  14. data/lib/capybara/screenshot/diff/drivers.rb +7 -14
  15. data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
  16. data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
  17. data/lib/capybara/screenshot/diff/os.rb +8 -13
  18. data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
  19. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
  20. data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
  21. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
  22. data/lib/capybara/screenshot/diff/utils.rb +5 -40
  23. data/lib/capybara/screenshot/diff/vcs.rb +5 -37
  24. data/lib/capybara/screenshot/diff/version.rb +7 -1
  25. data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
  26. data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
  27. data/lib/capybara_screenshot_diff/dsl.rb +7 -129
  28. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
  29. data/lib/capybara_screenshot_diff/minitest.rb +6 -56
  30. data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
  31. data/lib/capybara_screenshot_diff/rspec.rb +5 -63
  32. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
  33. data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
  34. data/lib/capybara_screenshot_diff/snap.rb +5 -64
  35. data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
  36. data/lib/capybara_screenshot_diff/static.rb +3 -5
  37. data/lib/capybara_screenshot_diff.rb +29 -100
  38. data/lib/snap_diff/annotation_service.rb +84 -0
  39. data/lib/snap_diff/area_calculator.rb +56 -0
  40. data/lib/snap_diff/attempts_reporter.rb +51 -0
  41. data/lib/snap_diff/browser_helpers.rb +121 -0
  42. data/lib/snap_diff/capture/viewport.rb +40 -0
  43. data/lib/snap_diff/comparison.rb +238 -0
  44. data/lib/snap_diff/comparison_result.rb +104 -0
  45. data/lib/snap_diff/config.rb +6 -2
  46. data/lib/snap_diff/deprecation.rb +84 -0
  47. data/lib/snap_diff/driver.rb +37 -0
  48. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  49. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  50. data/lib/snap_diff/drivers.rb +14 -0
  51. data/lib/snap_diff/dsl.rb +143 -0
  52. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  53. data/lib/snap_diff/image_preprocessor.rb +68 -0
  54. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  55. data/lib/snap_diff/integrations/minitest.rb +70 -0
  56. data/lib/snap_diff/integrations/rspec.rb +68 -0
  57. data/lib/snap_diff/legacy_shims.rb +99 -0
  58. data/lib/snap_diff/os.rb +17 -0
  59. data/lib/snap_diff/reporters/html.rb +143 -0
  60. data/lib/snap_diff/reporting.rb +53 -0
  61. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  62. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  63. data/lib/snap_diff/screenshot_namer.rb +81 -0
  64. data/lib/snap_diff/screenshoter.rb +129 -0
  65. data/lib/snap_diff/snap.rb +66 -0
  66. data/lib/snap_diff/snap_manager.rb +125 -0
  67. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  68. data/lib/snap_diff/static.rb +11 -0
  69. data/lib/snap_diff/utils.rb +38 -0
  70. data/lib/snap_diff/vcs.rb +35 -0
  71. data/lib/snap_diff/version.rb +5 -0
  72. data/lib/snap_diff.rb +22 -6
  73. metadata +36 -2
  74. /data/lib/{capybara_screenshot_diff → snap_diff}/reporters/templates/report.html.erb +0 -0
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ require "snap_diff/comparison_result"
7
+ require "snap_diff/drivers"
8
+ require "snap_diff/image_preprocessor"
9
+ require "capybara/screenshot/diff/reporters/default"
10
+
11
+ # The internal images-holder struct and the driver cache keep their legacy
12
+ # Capybara::Screenshot::Diff homes for now: SnapDiff::Comparison is the
13
+ # comparison class below (ex-ImageCompare), so the struct cannot take the
14
+ # same name. Its SnapDiff home arrives only when it is folded into
15
+ # Comparison as a nested value (v2 design section 2) -- renaming it here
16
+ # would exceed step 5's two approved renames.
17
+ module Capybara
18
+ module Screenshot
19
+ module Diff
20
+ LOADED_DRIVERS = {}
21
+
22
+ # Holds the two images (and their paths/options/driver) being compared.
23
+ class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
24
+ def skip_area
25
+ options[:skip_area]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ module SnapDiff
33
+ # Handles comparison of two images with a focus on performance and accuracy.
34
+ #
35
+ # This class implements a multi-layered optimization strategy for image comparison:
36
+ #
37
+ # 1. Early File-based Checks (Fastest):
38
+ # - Verifies both images exist (raises ArgumentError if not)
39
+ # - Compares file sizes (different sizes → different images)
40
+ # - Performs byte-by-byte comparison for identical files (exact match)
41
+ #
42
+ # 2. Quick Comparison (Fast):
43
+ # - Compares image dimensions (different dimensions → different images)
44
+ # - Performs pixel-by-pixel comparison if dimensions match
45
+ #
46
+ # 3. Detailed Analysis (Slower):
47
+ # - Only performed if quick comparison finds differences
48
+ # - Handles anti-aliasing, color tolerance, and shift detection
49
+ # - Respects skip_area and other comparison parameters
50
+ #
51
+ # This layered approach ensures optimal performance by:
52
+ # - Using the fastest possible method for early rejection
53
+ # - Only performing expensive operations when absolutely necessary
54
+ # - Maintaining high accuracy for complex comparisons
55
+ class Comparison
56
+ TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
57
+
58
+ attr_reader :driver, :driver_options
59
+ attr_reader :image_path, :base_image_path
60
+ attr_reader :difference, :error_message
61
+
62
+ def initialize(image_path, base_image_path, options = {})
63
+ @image_path = Pathname.new(image_path)
64
+ @base_image_path = Pathname.new(base_image_path)
65
+
66
+ ensure_files_exist!
67
+
68
+ @driver_options = options.freeze
69
+ @driver = Drivers.for(@driver_options)
70
+ @without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
71
+ end
72
+
73
+ # Performs a quick comparison of two image files.
74
+ #
75
+ # This method is optimized for speed and will return as soon as a difference is found.
76
+ # It's used for fast rejection before performing more expensive comparisons.
77
+ #
78
+ # @return [Boolean]
79
+ # - `true` if images are exactly identical (byte-for-byte match)
80
+ # - `false` if images are different or if a quick difference is detected
81
+ #
82
+ # @note This method will raise ArgumentError if either image file is missing.
83
+ def quick_equal?
84
+ if base_image_path.size == image_path.size
85
+ return true if files_identical?(base_image_path, image_path)
86
+ end
87
+
88
+ result, difference = find_difference(quick_mode: true)
89
+ self.difference = difference
90
+ result
91
+ end
92
+
93
+ def ensure_files_exist!
94
+ raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist?
95
+ raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist?
96
+ end
97
+
98
+ # Determines if the images are different according to the comparison rules.
99
+ #
100
+ # This method performs a full comparison if not already done, including any
101
+ # configured tolerances for color differences and shift distances.
102
+ #
103
+ # @return [Boolean]
104
+ # - `true` if the images are different beyond configured tolerances
105
+ # - `false` if the images are considered identical
106
+ #
107
+ # @see #processed
108
+ # @see #analyze_difference
109
+ def different?
110
+ processed.difference.different?
111
+ end
112
+
113
+ def dimensions_changed?
114
+ difference.failed_by&.[](:different_dimensions)
115
+ end
116
+
117
+ def reporter
118
+ @reporter ||= build_reporter
119
+ end
120
+
121
+ def processed?
122
+ !!difference
123
+ end
124
+
125
+ def processed
126
+ self.difference = find_difference(quick_mode: false) unless processed?
127
+ @error_message ||= reporter.generate
128
+ self
129
+ end
130
+
131
+ private
132
+
133
+ def without_tolerable_options?
134
+ @without_tolerable_options
135
+ end
136
+
137
+ def load_images_and_build_comparison(base_path, new_path, options)
138
+ base_img, new_img = driver.load_images(base_path, new_path)
139
+ Capybara::Screenshot::Diff::Comparison.new(new_img, base_img, options, driver, new_path, base_path)
140
+ end
141
+
142
+ def image_preprocessor
143
+ @image_preprocessor ||= ImagePreprocessor.new(driver, driver_options)
144
+ end
145
+
146
+ def find_difference(quick_mode: false)
147
+ # Validate images exist
148
+ return build_null_difference("missing_image") unless images_exist?
149
+
150
+ # Create comparison with preprocessed images
151
+ comparison = load_comparison(base_image_path, image_path, driver_options)
152
+
153
+ analyze_difference(comparison, quick_mode: quick_mode)
154
+ end
155
+
156
+ # Analyzes the comparison and determines if images are different.
157
+ #
158
+ # @param comparison [Capybara::Screenshot::Diff::Comparison] The comparison object containing images to analyze.
159
+ # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
160
+ # In quick mode, returns [is_equal, difference] where:
161
+ # - is_equal is true if images are considered equal
162
+ # - difference is a ComparisonResult object or nil
163
+ # When false, returns a ComparisonResult object directly.
164
+ # @return [Array, ComparisonResult] Result format depends on quick_mode parameter.
165
+ def analyze_difference(comparison, quick_mode: true)
166
+ # Handle dimension differences
167
+ unless driver.same_dimension?(comparison)
168
+ result = ComparisonResult.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
169
+ return quick_mode ? [false, result] : result
170
+ end
171
+
172
+ # Handle identical pixels
173
+ if driver.same_pixels?(comparison)
174
+ result = ComparisonResult.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
175
+ return quick_mode ? [true, result] : result
176
+ end
177
+
178
+ # Handle early return for non-tolerable options
179
+ if quick_mode && without_tolerable_options?
180
+ return [false, nil]
181
+ end
182
+
183
+ # Process difference region
184
+ region = driver.find_difference_region(comparison)
185
+
186
+ # Only create a proper difference object if we've completed the comparison
187
+ quick_mode ? [!region.different?, region] : region
188
+ end
189
+
190
+ def difference=(new_difference)
191
+ @error_message = nil
192
+ @reporter = nil
193
+ @difference = new_difference
194
+ end
195
+
196
+ def build_reporter
197
+ current_difference = difference || build_null_difference
198
+ Capybara::Screenshot::Diff::Reporters::Default.new(current_difference)
199
+ end
200
+
201
+ # Loads and preprocesses images for detailed comparison.
202
+ #
203
+ # This method is responsible for:
204
+ # 1. Loading both images using the configured driver
205
+ # 2. Applying any necessary preprocessing (cropping, normalization)
206
+ # 3. Creating a Capybara::Screenshot::Diff::Comparison object that holds the image data
207
+ #
208
+ # @param base_path [String,Pathname] Path to the baseline/reference image
209
+ # @param new_path [String,Pathname] Path to the new/candidate image
210
+ # @param options [Hash] Comparison options including:
211
+ # - :crop [Array<Integer>] Optional crop area [x, y, width, height]
212
+ # - :skip_area [Array<Array>] Areas to exclude from comparison
213
+ # - :tolerance [Numeric] Color tolerance threshold
214
+ # @return [Capybara::Screenshot::Diff::Comparison] Prepared comparison object ready for analysis
215
+ # @raise [ArgumentError] If image files are invalid or unreadable
216
+ def load_comparison(base_path, new_path, options)
217
+ comparison = load_images_and_build_comparison(base_path, new_path, options)
218
+ image_preprocessor.process_comparison(comparison)
219
+ end
220
+
221
+ def build_null_difference(failed_by = nil)
222
+ comparison = Capybara::Screenshot::Diff::Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
223
+ ComparisonResult.build_null(comparison, base_image_path, image_path, failed_by)
224
+ end
225
+
226
+ # Check if both images exist
227
+ def images_exist?
228
+ base_image_path.exist? && image_path.exist?
229
+ end
230
+
231
+ # Check if files are identical by content
232
+ def files_identical?(file1, file2)
233
+ FileUtils.compare_file(file1, file2)
234
+ rescue SystemCallError, IOError
235
+ false
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SnapDiff
6
+ # Represents the result of comparing two images
7
+ #
8
+ # This value object encapsulates the result of an image comparison operation.
9
+ # It follows the Single Responsibility Principle by focusing solely on representing
10
+ # the difference state, including:
11
+ # - Whether images are different or equal
12
+ # - Why they differ (dimensions, pixels, etc.)
13
+ # - The specific region of difference
14
+ # - Whether differences are tolerable based on configured thresholds
15
+ #
16
+ # As part of the layered comparison architecture, this class represents the final
17
+ # output of the comparison process, containing all data needed for reporting.
18
+ class ComparisonResult < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil)
19
+ def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil)
20
+ ComparisonResult.new(
21
+ nil,
22
+ {difference_level: nil, max_color_distance: 0},
23
+ comparison,
24
+ failed_by,
25
+ base_image_path,
26
+ new_image_path
27
+ ).freeze
28
+ end
29
+
30
+ def different?
31
+ failed? || !(blank? || tolerable?)
32
+ end
33
+
34
+ def equal?
35
+ !different?
36
+ end
37
+
38
+ def failed?
39
+ !!failed_by
40
+ end
41
+
42
+ def options
43
+ comparison.options
44
+ end
45
+
46
+ def tolerance
47
+ options[:tolerance]
48
+ end
49
+
50
+ def skip_area
51
+ comparison.skip_area
52
+ end
53
+
54
+ def area_size_limit
55
+ options[:area_size_limit]
56
+ end
57
+
58
+ def blank?
59
+ region.nil? || region_area_size.zero?
60
+ end
61
+
62
+ def region_area_size
63
+ @region_area_size ||= region&.size || 0
64
+ end
65
+
66
+ def ratio
67
+ meta[:difference_level]
68
+ end
69
+
70
+ def to_h
71
+ {area_size: region_area_size, region: coordinates}.merge!(meta)
72
+ end
73
+
74
+ def coordinates
75
+ region&.to_edge_coordinates
76
+ end
77
+
78
+ # One-line debugging summary with the difference metrics.
79
+ # (Error messages use #to_h — see Reporters::Default#build_error_message.)
80
+ def inspect
81
+ "#<#{self.class.name} different=#{different?} failed_by=#{failed_by.inspect} " \
82
+ "area_size=#{region_area_size} region=#{coordinates.inspect} " \
83
+ "difference_level=#{ratio.inspect} " \
84
+ "base=#{original_image_path} new=#{new_image_path}>"
85
+ end
86
+
87
+ def tolerable?
88
+ !!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio))
89
+ end
90
+
91
+ # Path accessors for backward compatibility
92
+ def new_image_path
93
+ image_path || comparison&.new_image_path
94
+ end
95
+
96
+ def original_image_path
97
+ base_image_path || comparison&.base_image_path
98
+ end
99
+
100
+ def diff_mask
101
+ meta[:diff_mask]
102
+ end
103
+ end
104
+ end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The MAPPING below references the legacy modules, so they must be loaded first.
4
- require "capybara_screenshot_diff"
3
+ # The MAPPING below references the legacy modules, so they must be loaded
4
+ # first. Requires config_legacy directly (a leaf, see its own header
5
+ # comment), not the capybara_screenshot_diff umbrella -- config.rb is
6
+ # required by both snap_diff.rb and (indirectly) capybara_screenshot_diff.rb,
7
+ # and neither of those may lead back here.
8
+ require "capybara/screenshot/diff/config_legacy"
5
9
 
6
10
  module SnapDiff
7
11
  # Flat, additive consolidation of every existing
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # @api private
5
+ #
6
+ # Internal until the v2 namespace transition; not a public contract.
7
+ #
8
+ # Warn-once-per-subject deprecation helper. Dormant: nothing in the
9
+ # current codebase calls this yet -- it exists so the gated v2 shim layer
10
+ # (ADR-004's +const_missing+-based legacy constant shim and the
11
+ # mattr_accessor-to-Config method shims) has pre-tested warning
12
+ # machinery to call into once it lands.
13
+ module Deprecation
14
+ # Emission channel: Kernel#warn, not a direct +$stderr.puts+.
15
+ #
16
+ # Kernel#warn delegates to +Warning.warn+ (Ruby >= 2.4), so anything
17
+ # that hooks +Warning.warn+ -- a test suite that raises on warnings, a
18
+ # custom log formatter, Ruby's own -W flag -- sees these messages the
19
+ # same way it sees every other Ruby warning. Writing straight to
20
+ # +$stderr+ would bypass that hook entirely and be invisible to any
21
+ # caller who has customized +Warning+ behavior.
22
+ MUTEX = Mutex.new
23
+ @seen = {}
24
+
25
+ class << self
26
+ # Emit a deprecation warning for +subject+, exactly once per unique
27
+ # +subject+ per process.
28
+ #
29
+ # @param subject [String] the deprecated old-namespace name being
30
+ # referenced, e.g. "Capybara::Screenshot::Diff::ImageCompare"
31
+ # @param replacement [String] the new-namespace name to use instead
32
+ # @param category [Symbol] what kind of thing moved, for the human
33
+ # reading the message (e.g. :constant, :config)
34
+ # @return [void]
35
+ def warn(subject, replacement, category:)
36
+ return if SnapDiff.silence_deprecations?
37
+
38
+ first_time = MUTEX.synchronize do
39
+ @seen.key?(subject) ? false : (@seen[subject] = true)
40
+ end
41
+ return unless first_time
42
+
43
+ Kernel.warn(message_for(subject, replacement, category))
44
+ end
45
+
46
+ # @api private
47
+ #
48
+ # Clears the seen-set. For tests only -- lets each example assert
49
+ # "warns once" from a clean slate instead of leaking state across
50
+ # the suite.
51
+ # @return [void]
52
+ def reset!
53
+ MUTEX.synchronize { @seen.clear }
54
+ end
55
+
56
+ private
57
+
58
+ def message_for(subject, replacement, category)
59
+ "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
60
+ "use `#{replacement}` instead."
61
+ end
62
+ end
63
+ end
64
+
65
+ class << self
66
+ # @api private
67
+ attr_accessor :silence_deprecations
68
+
69
+ # @api private
70
+ #
71
+ # @return [Boolean] true if deprecation warnings should be suppressed,
72
+ # either via the {silence_deprecations} accessor or the
73
+ # SNAP_DIFF_SILENCE_DEPRECATIONS env var (truthy = "1"/"true").
74
+ def silence_deprecations?
75
+ !!silence_deprecations || truthy_env?(ENV["SNAP_DIFF_SILENCE_DEPRECATIONS"])
76
+ end
77
+
78
+ private
79
+
80
+ def truthy_env?(value)
81
+ %w[1 true].include?(value.to_s.downcase)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Shared default behavior for image-processing drivers.
5
+ #
6
+ # Replaces the old +Capybara::Screenshot::Diff::Drivers::BaseDriver+
7
+ # superclass (ADR-004 v2 step 4): concrete drivers +include Driver+
8
+ # instead of inheriting. Method names are intentionally unchanged from
9
+ # v1 — see dissent #4 in the v2 architecture design.
10
+ module Driver
11
+ PNG_EXTENSION = ".png"
12
+
13
+ def same_dimension?(comparison)
14
+ dimension(comparison.base_image) == dimension(comparison.new_image)
15
+ end
16
+
17
+ def height_for(image)
18
+ image.height
19
+ end
20
+
21
+ def width_for(image)
22
+ image.width
23
+ end
24
+
25
+ def image_area_size(image)
26
+ width_for(image) * height_for(image)
27
+ end
28
+
29
+ def dimension(image)
30
+ [width_for(image), height_for(image)]
31
+ end
32
+
33
+ def supports?(feature)
34
+ respond_to?(feature)
35
+ end
36
+ end
37
+ end