capybara-screenshot-diff 1.10.3 → 1.12.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/Rakefile +29 -1
  4. data/capybara-screenshot-diff.gemspec +4 -3
  5. data/docs/RELEASE_PREP.md +58 -0
  6. data/docs/UPGRADING.md +390 -0
  7. data/docs/ci-integration.md +208 -0
  8. data/docs/configuration.md +379 -0
  9. data/docs/docker-testing.md +24 -0
  10. data/docs/drivers.md +102 -0
  11. data/docs/framework-setup.md +87 -0
  12. data/docs/images/snap_diff_web_ui.png +0 -0
  13. data/docs/organization.md +226 -0
  14. data/docs/reporters.md +46 -0
  15. data/docs/thread_safety.md +97 -0
  16. data/gems.rb +2 -1
  17. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  18. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  19. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  20. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  21. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  22. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  23. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  24. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  25. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  26. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  27. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  28. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +63 -36
  29. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  30. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  31. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  32. data/lib/capybara/screenshot/diff/version.rb +1 -1
  33. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  34. data/lib/capybara_screenshot_diff/cucumber.rb +2 -0
  35. data/lib/capybara_screenshot_diff/dsl.rb +102 -7
  36. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  37. data/lib/capybara_screenshot_diff/minitest.rb +4 -2
  38. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  39. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  40. data/lib/capybara_screenshot_diff/rspec.rb +12 -2
  41. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +61 -23
  42. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  43. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  44. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  45. data/lib/capybara_screenshot_diff/static.rb +11 -0
  46. data/lib/capybara_screenshot_diff.rb +30 -5
  47. metadata +47 -8
  48. data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
@@ -0,0 +1,97 @@
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
@@ -30,6 +30,10 @@ module Capybara
30
30
  def dimension(image)
31
31
  [width_for(image), height_for(image)]
32
32
  end
33
+
34
+ def supports?(feature)
35
+ respond_to?(feature)
36
+ end
33
37
  end
34
38
  end
35
39
  end
@@ -24,10 +24,6 @@ module Capybara
24
24
  _load_images(old_bytes, new_bytes)
25
25
  end
26
26
 
27
- def filter_image_with_median(_image)
28
- raise NotImplementedError
29
- end
30
-
31
27
  def add_black_box(image, _region)
32
28
  image
33
29
  end
@@ -217,22 +213,24 @@ module Capybara
217
213
 
218
214
  def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
219
215
  org_color = old_img[x, y]
220
- if shift_distance_limit
221
- start_x = [0, x - shift_distance_limit].max
222
- end_x = [x + shift_distance_limit, new_img.width - 1].min
223
- xs = (start_x..end_x).to_a
224
- start_y = [0, y - shift_distance_limit].max
225
- end_y = [y + shift_distance_limit, new_img.height - 1].min
226
- ys = (start_y..end_y).to_a
227
- new_pixels = xs.product(ys)
228
-
229
- distances = new_pixels.map do |dx, dy|
230
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
216
+ unless shift_distance_limit
217
+ return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
218
+ end
219
+
220
+ start_x = [0, x - shift_distance_limit].max
221
+ end_x = [x + shift_distance_limit, new_img.width - 1].min
222
+ start_y = [0, y - shift_distance_limit].max
223
+ end_y = [y + shift_distance_limit, new_img.height - 1].min
224
+
225
+ min_distance = Float::INFINITY
226
+ (start_y..end_y).each do |dy|
227
+ (start_x..end_x).each do |dx|
228
+ distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
229
+ return 0 if distance == 0
230
+ min_distance = distance if distance < min_distance
231
231
  end
232
- distances.min
233
- else
234
- ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
235
232
  end
233
+ min_distance
236
234
  end
237
235
 
238
236
  def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
@@ -254,10 +252,10 @@ module Capybara
254
252
  if (x - shift_distance) >= 0 # left
255
253
  ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
256
254
  .each do |dy|
257
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
258
- return shift_distance
255
+ if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
256
+ return shift_distance
257
+ end
259
258
  end
260
- end
261
259
  else
262
260
  bounds_breached += 1
263
261
  end
@@ -273,10 +271,10 @@ module Capybara
273
271
  if (x + shift_distance) < new_img.width # right
274
272
  ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
275
273
  .each do |dy|
276
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
277
- return shift_distance
274
+ if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
275
+ return shift_distance
276
+ end
278
277
  end
279
- end
280
278
  else
281
279
  bounds_breached += 1
282
280
  end
@@ -15,15 +15,18 @@ module Capybara
15
15
  # Compare two images and determine if they are equal, different, or within some comparison
16
16
  # range considering color values and difference area size.
17
17
  module Drivers
18
- DEFAULT_HIGHLIGHT_COLOR = [255, 0, 0, 255].freeze
19
-
20
18
  class VipsDriver < BaseDriver
21
19
  def find_difference_region(comparison)
22
20
  new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
23
21
 
24
- diff_mask = VipsUtil.difference_mask(base_image, new_image, options[:color_distance_limit])
25
- region = VipsUtil.difference_region_by(diff_mask)
26
- region = nil if region && same_as?(region, base_image)
22
+ diff_mask = if options[:perceptual_threshold]
23
+ self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
24
+ else
25
+ self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
26
+ end
27
+ region = self.class.difference_region_by(diff_mask)
28
+ # TODO: schedule research when we got this case for VIPs
29
+ # region = nil if region && region_covers_entire_image?(region, base_image)
27
30
 
28
31
  result = Difference.new(region, {}, comparison)
29
32
 
@@ -40,7 +43,7 @@ module Capybara
40
43
  rescue Vips::Error => e
41
44
  warn(
42
45
  "[capybara-screenshot-diff] Crop has been failed for " \
43
- "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
46
+ "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
44
47
  )
45
48
  raise e
46
49
  end
@@ -56,7 +59,7 @@ module Capybara
56
59
  end
57
60
 
58
61
  def difference_level(diff_mask, old_img, _region = nil)
59
- VipsUtil.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
62
+ self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
60
63
  end
61
64
 
62
65
  MAX_FILENAME_LENGTH = 200
@@ -89,10 +92,6 @@ module Capybara
89
92
  result
90
93
  end
91
94
 
92
- def dimension(image)
93
- [width_for(image), height_for(image)]
94
- end
95
-
96
95
  def draw_rectangles(images, region, rgba, offset: 0)
97
96
  images.map do |image|
98
97
  image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
@@ -107,37 +106,51 @@ module Capybara
107
106
  base_image.composite2(new_image, :over)
108
107
  end
109
108
 
110
- def highlight_mask(diff_mask, merged_image, color: DEFAULT_HIGHLIGHT_COLOR)
109
+ def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
111
110
  diff_mask.ifthenelse(color, merged_image * 0.75)
112
111
  end
113
112
 
114
113
  private
115
114
 
116
- def same_as?(region, base_image)
117
- region.x.zero? &&
118
- region.y.zero? &&
119
- region.height == height_for(base_image) &&
120
- region.width == width_for(base_image)
121
- end
122
-
123
- class VipsUtil
124
- def self.difference_area(old_image, new_image, color_distance: 0)
125
- difference_mask = difference_mask(new_image, old_image, color_distance)
126
- difference_area_size_by(difference_mask)
115
+ class << self
116
+ def difference_area(old_image, new_image, color_distance: 0)
117
+ mask = difference_mask(new_image, old_image, color_distance)
118
+ difference_area_size_by(mask)
127
119
  end
128
120
 
129
- def self.difference_area_size_by(difference_mask)
121
+ def difference_area_size_by(difference_mask)
130
122
  diff_mask = difference_mask == 0
131
123
  diff_mask.hist_find.to_a[0][0].max
132
124
  end
133
125
 
134
- def self.difference_mask(base_image, new_image, color_distance = nil)
126
+ def difference_mask(base_image, new_image, color_distance = nil)
135
127
  result = (new_image - base_image).abs
136
-
137
128
  color_distance ? result > color_distance : result
138
129
  end
139
130
 
140
- def self.difference_region_by(diff_mask)
131
+ def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
132
+ color_diff = perceptual_color_diff(base_image, new_image) > threshold
133
+ alpha_diff = alpha_channel_diff(base_image, new_image)
134
+ alpha_diff ? (color_diff | alpha_diff) : color_diff
135
+ end
136
+
137
+ def perceptual_color_diff(base_image, new_image)
138
+ base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
139
+ new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
140
+ base_lab = base_rgb.colourspace(:lab)
141
+ new_lab = new_rgb.colourspace(:lab)
142
+ base_lab.dE00(new_lab)
143
+ rescue Vips::Error
144
+ base_lab.dE76(new_lab)
145
+ end
146
+
147
+ def alpha_channel_diff(base_image, new_image)
148
+ return unless base_image.bands > 3 && new_image.bands > 3
149
+
150
+ (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
151
+ end
152
+
153
+ def difference_region_by(diff_mask)
141
154
  columns, rows = diff_mask.bandor.project
142
155
 
143
156
  left = columns.profile[1].min
@@ -1,17 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+ require "fileutils"
5
+
3
6
  require "capybara/screenshot/diff/comparison"
7
+ require "capybara/screenshot/diff/image_preprocessor"
8
+ require "capybara/screenshot/diff/difference_finder"
9
+ require "capybara/screenshot/diff/reporters/default"
4
10
 
5
11
  module Capybara
6
12
  module Screenshot
7
13
  module Diff
8
14
  LOADED_DRIVERS = {}
9
15
 
10
- # Compare two image and determine if they are equal, different, or within some comparison
11
- # range considering color values and difference area size.
16
+ # Handles comparison of two images with a focus on performance and accuracy.
17
+ #
18
+ # This class implements a multi-layered optimization strategy for image comparison:
19
+ #
20
+ # 1. Early File-based Checks (Fastest):
21
+ # - Verifies both images exist (raises ArgumentError if not)
22
+ # - Compares file sizes (different sizes → different images)
23
+ # - Performs byte-by-byte comparison for identical files (exact match)
24
+ #
25
+ # 2. Quick Comparison (Fast):
26
+ # - Compares image dimensions (different dimensions → different images)
27
+ # - Performs pixel-by-pixel comparison if dimensions match
28
+ #
29
+ # 3. Detailed Analysis (Slower):
30
+ # - Only performed if quick comparison finds differences
31
+ # - Handles anti-aliasing, color tolerance, and shift detection
32
+ # - Respects skip_area and other comparison parameters
33
+ #
34
+ # This layered approach ensures optimal performance by:
35
+ # - Using the fastest possible method for early rejection
36
+ # - Only performing expensive operations when absolutely necessary
37
+ # - Maintaining high accuracy for complex comparisons
12
38
  class ImageCompare
13
- TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
14
-
15
39
  attr_reader :driver, :driver_options
16
40
  attr_reader :image_path, :base_image_path
17
41
  attr_reader :difference, :error_message
@@ -20,41 +44,48 @@ module Capybara
20
44
  @image_path = Pathname.new(image_path)
21
45
  @base_image_path = Pathname.new(base_image_path)
22
46
 
23
- @driver_options = options.dup
47
+ ensure_files_exist!
24
48
 
49
+ @driver_options = options.freeze
25
50
  @driver = Drivers.for(@driver_options)
26
51
  end
27
52
 
28
- # Compare the two image files and return `true` or `false` as quickly as possible.
29
- # Return falsely if the old file does not exist or the image dimensions do not match.
53
+ # Performs a quick comparison of two image files.
54
+ #
55
+ # This method is optimized for speed and will return as soon as a difference is found.
56
+ # It's used for fast rejection before performing more expensive comparisons.
57
+ #
58
+ # @return [Boolean]
59
+ # - `true` if images are exactly identical (byte-for-byte match)
60
+ # - `false` if images are different or if a quick difference is detected
61
+ #
62
+ # @note This method will raise ArgumentError if either image file is missing.
30
63
  def quick_equal?
31
- require_images_exists!
32
-
33
- # NOTE: This is very fuzzy logic, but so far it's helps to support current performance.
34
- return true if new_file_size == old_file_size
35
-
36
- comparison = load_and_process_images
37
-
38
- unless driver.same_dimension?(comparison)
39
- self.difference = build_failed_difference(comparison, {different_dimensions: true})
40
- return false
64
+ if base_image_path.size == image_path.size
65
+ return true if files_identical?(base_image_path, image_path)
41
66
  end
42
67
 
43
- if driver.same_pixels?(comparison)
44
- self.difference = build_no_difference(comparison)
45
- return true
46
- end
47
-
48
- # NOTE: Could not make any difference to be tolerable, so skip and return as not equal.
49
- return false if without_tolerable_options?
50
-
51
- self.difference = driver.find_difference_region(comparison)
68
+ result, difference = find_difference(quick_mode: true)
69
+ self.difference = difference
70
+ result
71
+ end
52
72
 
53
- !difference.different?
73
+ def ensure_files_exist!
74
+ raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist?
75
+ raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist?
54
76
  end
55
77
 
56
- # Compare the two image referenced by this object, and return `true` if they are different,
57
- # and `false` if they are the same.
78
+ # Determines if the images are different according to the comparison rules.
79
+ #
80
+ # This method performs a full comparison if not already done, including any
81
+ # configured tolerances for color differences and shift distances.
82
+ #
83
+ # @return [Boolean]
84
+ # - `true` if the images are different beyond configured tolerances
85
+ # - `false` if the images are considered identical
86
+ #
87
+ # @see #processed
88
+ # @see DifferenceFinder
58
89
  def different?
59
90
  processed.difference.different?
60
91
  end
@@ -64,10 +95,7 @@ module Capybara
64
95
  end
65
96
 
66
97
  def reporter
67
- @reporter ||= begin
68
- current_difference = difference || build_no_difference(nil)
69
- Capybara::Screenshot::Diff::Reporters::Default.new(current_difference)
70
- end
98
+ @reporter ||= build_reporter
71
99
  end
72
100
 
73
101
  def processed?
@@ -75,122 +103,83 @@ module Capybara
75
103
  end
76
104
 
77
105
  def processed
78
- self.difference = find_difference unless processed?
106
+ self.difference = find_difference(quick_mode: false) unless processed?
79
107
  @error_message ||= reporter.generate
80
108
  self
81
109
  end
82
110
 
83
111
  private
84
112
 
85
- def find_difference
86
- require_images_exists!
87
-
88
- comparison = load_and_process_images
89
-
90
- unless driver.same_dimension?(comparison)
91
- return build_failed_difference(comparison, {different_dimensions: true})
92
- end
93
-
94
- if driver.same_pixels?(comparison)
95
- build_no_difference(comparison)
96
- else
97
- driver.find_difference_region(comparison)
98
- end
99
- end
100
-
101
- def require_images_exists!
102
- raise ArgumentError, "There is no original (base) screenshot version to compare, located: #{base_image_path}" unless base_image_path.exist?
103
- raise ArgumentError, "There is no new screenshot version to compare, located: #{image_path}" unless image_path.exist?
104
- end
105
-
106
- def difference=(new_difference)
107
- @error_message = nil
108
- @reporter = nil
109
- @difference = new_difference
110
- end
111
-
112
- def image_files_exist?
113
- @base_image_path.exist? && @image_path.exist?
113
+ def difference_finder
114
+ @difference_finder ||= DifferenceFinder.new(driver, driver_options)
114
115
  end
115
116
 
116
- def without_tolerable_options?
117
- (@driver_options.keys & TOLERABLE_OPTIONS).empty?
117
+ def load_images_and_build_comparison(base_path, new_path, options)
118
+ base_img, new_img = driver.load_images(base_path, new_path)
119
+ Comparison.new(new_img, base_img, options, driver, new_path, base_path)
118
120
  end
119
121
 
120
- def build_failed_difference(comparison, failed_by)
121
- Difference.new(
122
- nil,
123
- {difference_level: nil, max_color_distance: 0},
124
- comparison,
125
- failed_by
126
- )
122
+ def image_preprocessor
123
+ @image_preprocessor ||= ImagePreprocessor.new(driver, driver_options)
127
124
  end
128
125
 
129
- def load_and_process_images
130
- images = driver.load_images(base_image_path, image_path)
131
- base_image, new_image = preprocess_images(images)
132
- Comparison.new(new_image, base_image, @driver_options, driver, image_path, base_image_path)
133
- end
126
+ def find_difference(quick_mode: false)
127
+ # Validate images exist
128
+ return build_null_difference("missing_image") unless images_exist?
134
129
 
135
- def skip_area
136
- @driver_options[:skip_area]
137
- end
138
-
139
- def median_filter_window_size
140
- @driver_options[:median_filter_window_size]
141
- end
130
+ # Create comparison with preprocessed images
131
+ comparison = load_comparison(base_image_path, image_path, driver_options)
142
132
 
143
- def preprocess_images(images)
144
- images.map { |image| preprocess_image(image) }
133
+ # Use difference finder to analyze the comparison
134
+ difference_finder.call(comparison, quick_mode: quick_mode)
145
135
  end
146
136
 
147
- def preprocess_image(image)
148
- result = image
149
-
150
- if skip_area
151
- result = ignore_skipped_area(result)
152
- end
153
-
154
- if median_filter_window_size
155
- if driver.is_a?(Drivers::VipsDriver)
156
- result = blur_image_by(image, median_filter_window_size)
157
- else
158
- warn(
159
- "[capybara-screenshot-diff] Median filter has been skipped for #{image_path} " \
160
- "because it is not supported by #{driver.class.name}"
161
- )
162
- end
163
- end
164
-
165
- result
166
- end
167
-
168
- def blur_image_by(image, size)
169
- driver.filter_image_with_median(image, size)
137
+ def difference=(new_difference)
138
+ @error_message = nil
139
+ @reporter = nil
140
+ @difference = new_difference
170
141
  end
171
142
 
172
- def ignore_skipped_area(image)
173
- skip_area&.reduce(image) { |memo, region| driver.add_black_box(memo, region) }
143
+ def build_reporter
144
+ current_difference = difference || build_null_difference
145
+ Reporters::Default.new(current_difference)
174
146
  end
175
147
 
176
- def old_file_size
177
- base_image_path.size
148
+ # Loads and preprocesses images for detailed comparison.
149
+ #
150
+ # This method is responsible for:
151
+ # 1. Loading both images using the configured driver
152
+ # 2. Applying any necessary preprocessing (cropping, normalization)
153
+ # 3. Creating a Comparison object that holds the image data
154
+ #
155
+ # @param base_path [String,Pathname] Path to the baseline/reference image
156
+ # @param new_path [String,Pathname] Path to the new/candidate image
157
+ # @param options [Hash] Comparison options including:
158
+ # - :crop [Array<Integer>] Optional crop area [x, y, width, height]
159
+ # - :skip_area [Array<Array>] Areas to exclude from comparison
160
+ # - :tolerance [Numeric] Color tolerance threshold
161
+ # @return [Comparison] Prepared comparison object ready for analysis
162
+ # @raise [ArgumentError] If image files are invalid or unreadable
163
+ def load_comparison(base_path, new_path, options)
164
+ comparison = load_images_and_build_comparison(base_path, new_path, options)
165
+ image_preprocessor.process_comparison(comparison)
178
166
  end
179
167
 
180
- def new_file_size
181
- image_path.size
168
+ def build_null_difference(failed_by = nil)
169
+ comparison = Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
170
+ Difference.build_null(comparison, base_image_path, image_path, failed_by)
182
171
  end
183
172
 
184
- def build_no_difference(comparison = nil)
185
- Difference.new(
186
- nil,
187
- {difference_level: nil, max_color_distance: 0},
188
- comparison || build_comparison
189
- ).freeze
173
+ # Check if both images exist
174
+ def images_exist?
175
+ base_image_path.exist? && image_path.exist?
190
176
  end
191
177
 
192
- def build_comparison
193
- Capybara::Screenshot::Diff::Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
178
+ # Check if files are identical by content
179
+ def files_identical?(file1, file2)
180
+ FileUtils.compare_file(file1, file2)
181
+ rescue SystemCallError, IOError
182
+ false
194
183
  end
195
184
  end
196
185
  end