capybara-screenshot-diff 1.10.3.1 → 1.13.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/Rakefile +29 -1
  5. data/capybara-screenshot-diff.gemspec +4 -3
  6. data/docs/RELEASE_PREP.md +42 -0
  7. data/docs/UPGRADING.md +390 -0
  8. data/docs/architecture.md +270 -0
  9. data/docs/ci-integration.md +238 -0
  10. data/docs/configuration.md +395 -0
  11. data/docs/docker-testing.md +24 -0
  12. data/docs/drivers.md +102 -0
  13. data/docs/framework-setup.md +87 -0
  14. data/docs/images/snap_diff_annotated.png +0 -0
  15. data/docs/images/snap_diff_web_ui.png +0 -0
  16. data/docs/migration-guide.md +286 -0
  17. data/docs/organization.md +204 -0
  18. data/docs/reporters.md +46 -0
  19. data/docs/thread_safety.md +97 -0
  20. data/gems.rb +2 -1
  21. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  22. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  23. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  24. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  25. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  26. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  27. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  28. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  29. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  30. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  31. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  32. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
  33. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  34. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  35. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  36. data/lib/capybara/screenshot/diff/version.rb +1 -1
  37. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  38. data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
  39. data/lib/capybara_screenshot_diff/dsl.rb +123 -7
  40. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  41. data/lib/capybara_screenshot_diff/minitest.rb +10 -5
  42. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  43. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  44. data/lib/capybara_screenshot_diff/rspec.rb +20 -4
  45. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
  46. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  47. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  48. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  49. data/lib/capybara_screenshot_diff/static.rb +11 -0
  50. data/lib/capybara_screenshot_diff.rb +31 -5
  51. metadata +51 -8
  52. data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
@@ -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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Screenshot
5
+ module Diff
6
+ # Handles image preprocessing operations (skip_area and median filtering)
7
+ #
8
+ # This class applies preprocessing filters to images before comparison,
9
+ # such as masking specific regions (skip_area) or applying noise reduction.
10
+ # It's designed to work with either direct image objects or with options.
11
+ class ImagePreprocessor
12
+ attr_reader :driver, :options
13
+
14
+ def initialize(driver, options = {})
15
+ @driver = driver
16
+ @options = options
17
+ end
18
+
19
+ # Process a comparison object directly
20
+ # This allows reusing the comparison's existing options
21
+ # @param [Comparison] comparison the comparison object
22
+ # @return [Comparison] the comparison object
23
+ def process_comparison(comparison)
24
+ # Process both images
25
+ comparison.base_image = process_image(comparison.base_image, comparison.base_image_path)
26
+ comparison.new_image = process_image(comparison.new_image, comparison.new_image_path)
27
+
28
+ comparison
29
+ end
30
+
31
+ private
32
+
33
+ def process_image(image, path)
34
+ result = image
35
+ result = apply_skip_area(result) if skip_area
36
+ result = apply_median_filter(result, path) if median_filter_window_size
37
+ result
38
+ end
39
+
40
+ def apply_skip_area(image)
41
+ skip_area.reduce(image) do |result, region|
42
+ driver.add_black_box(result, region)
43
+ end
44
+ end
45
+
46
+ def apply_median_filter(image, path)
47
+ if driver.supports?(:filter_image_with_median)
48
+ driver.filter_image_with_median(image, median_filter_window_size)
49
+ else
50
+ warn_about_skipped_median_filter(path)
51
+ image
52
+ end
53
+ end
54
+
55
+ def warn_about_skipped_median_filter(path)
56
+ warn(
57
+ "[capybara-screenshot-diff] Median filter has been skipped for #{path} " \
58
+ "because it is not supported by #{driver.class}"
59
+ )
60
+ end
61
+
62
+ def skip_area
63
+ options[:skip_area]
64
+ end
65
+
66
+ def median_filter_window_size
67
+ options[:median_filter_window_size]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -8,7 +8,8 @@ module Capybara::Screenshot::Diff
8
8
  def initialize(difference)
9
9
  @difference = difference
10
10
 
11
- screenshot_format = difference.comparison.options[:screenshot_format] || comparison.new_image_path.extname.slice(1..-1)
11
+ ext = comparison.new_image_path.extname.delete_prefix(".")
12
+ screenshot_format = difference.comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
12
13
  @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
13
14
  @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
14
15
  @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
@@ -32,6 +33,7 @@ module Capybara::Screenshot::Diff
32
33
  def clean_tmp_files
33
34
  annotated_base_image_path.unlink if annotated_base_image_path.exist?
34
35
  annotated_image_path.unlink if annotated_image_path.exist?
36
+ heatmap_diff_path.unlink if heatmap_diff_path.exist?
35
37
  end
36
38
 
37
39
  def build_error_for_different_dimensions
@@ -45,27 +47,23 @@ module Capybara::Screenshot::Diff
45
47
  def annotate_and_save_images
46
48
  save_annotation_for(new_image, annotated_image_path)
47
49
  save_annotation_for(base_image, annotated_base_image_path)
48
- save_heatmap_diff if difference.meta[:diff_mask]
50
+ save_heatmap_diff if difference.diff_mask
49
51
  end
50
52
 
51
53
  def save_annotation_for(image, image_path)
52
54
  image = annotate_difference(image, difference.region)
53
- image = annotate_skip_areas(image, difference.skip_area) if difference.skip_area
55
+ image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
54
56
 
55
57
  save(image, image_path.to_path)
56
58
  end
57
59
 
58
- DIFF_COLOR = [255, 0, 0, 255].freeze
59
-
60
60
  def annotate_difference(image, region)
61
- driver.draw_rectangles([image], region, DIFF_COLOR, offset: 1).first
61
+ driver.draw_rectangles([image], region, CapybaraScreenshotDiff::RED_RGBA, offset: 1).first
62
62
  end
63
63
 
64
- SKIP_COLOR = [255, 192, 0, 255].freeze
65
-
66
64
  def annotate_skip_areas(image, skip_areas)
67
65
  skip_areas.reduce(image) do |memo, region|
68
- driver.draw_rectangles([memo], region, SKIP_COLOR).first
66
+ driver.draw_rectangles([memo], region, CapybaraScreenshotDiff::ORANGE_RGBA).first
69
67
  end
70
68
  end
71
69
 
@@ -80,7 +78,8 @@ module Capybara::Screenshot::Diff
80
78
  "(#{difference.inspect})",
81
79
  image_path.to_path,
82
80
  annotated_base_image_path.to_path,
83
- annotated_image_path.to_path
81
+ annotated_image_path.to_path,
82
+ heatmap_diff_path.to_path
84
83
  ].join(NEW_LINE)
85
84
  end
86
85
 
@@ -88,7 +87,7 @@ module Capybara::Screenshot::Diff
88
87
 
89
88
  def save_heatmap_diff
90
89
  merged_image = driver.merge(new_image, base_image)
91
- highlighted_mask = driver.highlight_mask(difference.meta[:diff_mask], merged_image, color: DIFF_COLOR)
90
+ highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
92
91
 
93
92
  save(highlighted_mask, heatmap_diff_path.to_path)
94
93
  end
@@ -21,37 +21,94 @@ module Capybara
21
21
  @snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
22
22
  end
23
23
 
24
- def build_screenshot_matches_job
25
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
26
- return if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
24
+ def build_screenshot_assertion(skip_stack_frames: 0)
25
+ check_window_size!
26
+ prepare_screenshot_options
27
+ check_base_screenshot
27
28
 
28
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
29
- area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
30
- driver_options[:crop] = area_calculator.calculate_crop
29
+ capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
31
30
 
32
- # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
33
- # Allow nil or single or multiple areas
34
- driver_options[:skip_area] = area_calculator.calculate_skip_area
35
- driver_options[:driver] = Drivers.for(driver_options[:driver])
31
+ capture_screenshot(capture_options, comparison_options)
36
32
 
37
- @snapshot.checkout_base_screenshot
33
+ # Pre-computation: No need to compare without base screenshot
34
+ # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
35
+ unless need_to_compare?
36
+ CapybaraScreenshotDiff.record_new_screenshot(screenshot_full_name)
37
+ return
38
+ end
38
39
 
39
- # When fail_if_new is true no need to create screenshot if base screenshot is missing
40
- return if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
40
+ create_screenshot_assertion(skip_stack_frames + 1, comparison_options)
41
+ end
42
+
43
+ # Captures a screenshot without comparing it to a baseline.
44
+ def capture
45
+ check_window_size!
46
+ prepare_screenshot_options
41
47
 
42
48
  capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
43
49
 
44
- # Load new screenshot from Browser
45
- take_comparison_screenshot(capture_options, comparison_options, @snapshot)
50
+ @snapshot.manager.create_output_directory_for(@snapshot.path)
51
+ capture_screenshot(capture_options, comparison_options)
52
+ end
46
53
 
47
- # Pre-computation: No need to compare without base screenshot
48
- return unless @snapshot.base_path.exist?
54
+ private
49
55
 
50
- # Add comparison job in the queue
51
- [screenshot_full_name, ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)]
56
+ def need_to_compare?
57
+ @snapshot.base_path.exist?
52
58
  end
53
59
 
54
- private
60
+ def check_window_size!
61
+ if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
62
+ current_size = BrowserHelpers.selenium? ?
63
+ BrowserHelpers.session.driver.browser.manage.window.size.to_s :
64
+ "unknown"
65
+
66
+ raise CapybaraScreenshotDiff::WindowSizeMismatchError.new(<<~ERROR.chomp, caller)
67
+ Window size mismatch detected!
68
+ Expected: #{Screenshot.window_size.inspect}
69
+ Actual: #{current_size}
70
+
71
+ Screenshots cannot be compared when window sizes don't match.
72
+ Please ensure the browser window is properly sized before taking screenshots.
73
+ ERROR
74
+ end
75
+ end
76
+
77
+ def prepare_screenshot_options
78
+ area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
79
+
80
+ driver_options[:crop] = area_calculator.calculate_crop
81
+ driver_options[:skip_area] = area_calculator.calculate_skip_area
82
+ driver_options[:driver] = Drivers.for(driver_options[:driver])
83
+ end
84
+
85
+ def check_base_screenshot
86
+ @snapshot.checkout_base_screenshot
87
+
88
+ if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
89
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
90
+ No existing screenshot found for #{@snapshot.base_path}!
91
+ To record baselines: RECORD_SCREENSHOTS=1 bundle exec rake test
92
+ To allow new screenshots: Capybara::Screenshot::Diff.fail_if_new = false
93
+ ERROR
94
+ end
95
+ end
96
+
97
+ def capture_screenshot(capture_options, comparison_options)
98
+ screenshoter = if capture_options[:stability_time_limit]
99
+ StableScreenshoter.new(capture_options, comparison_options)
100
+ else
101
+ Diff.screenshoter.new(capture_options, comparison_options)
102
+ end
103
+ screenshoter.take_comparison_screenshot(@snapshot)
104
+ end
105
+
106
+ def create_screenshot_assertion(skip_stack_frames, comparison_options)
107
+ assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new(screenshot_full_name)
108
+ assertion.caller = caller(skip_stack_frames + 1)
109
+ assertion.compare = ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)
110
+ assertion
111
+ end
55
112
 
56
113
  def extract_capture_and_comparison_options!(driver_options = {})
57
114
  [
@@ -68,22 +125,6 @@ module Capybara
68
125
  driver_options
69
126
  ]
70
127
  end
71
-
72
- # Try to get screenshot from browser.
73
- # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
74
- # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
75
- def take_comparison_screenshot(capture_options, comparison_options, snapshot = nil)
76
- screenshoter = build_screenshoter_for(capture_options, comparison_options)
77
- screenshoter.take_comparison_screenshot(snapshot)
78
- end
79
-
80
- def build_screenshoter_for(capture_options, comparison_options = {})
81
- if capture_options[:stability_time_limit]
82
- StableScreenshoter.new(capture_options, comparison_options)
83
- else
84
- Diff.screenshoter.new(capture_options, comparison_options[:driver])
85
- end
86
- end
87
128
  end
88
129
  end
89
130
  end
@@ -8,9 +8,11 @@ module Capybara
8
8
  class Screenshoter
9
9
  attr_reader :capture_options, :driver
10
10
 
11
- def initialize(capture_options, driver)
11
+ # @param capture_options [Hash] Options for capturing (window_size, wait, etc.)
12
+ # @param comparison_options [Hash] Options for image comparison (driver, tolerance, etc.)
13
+ def initialize(capture_options, comparison_options = {})
12
14
  @capture_options = capture_options
13
- @driver = driver
15
+ @driver = Diff::Drivers.for(comparison_options)
14
16
  end
15
17
 
16
18
  def crop
@@ -30,7 +32,7 @@ module Capybara
30
32
  # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
31
33
  def take_comparison_screenshot(snapshot)
32
34
  capture_screenshot_at(snapshot)
33
- snapshot.cleanup_attempts
35
+ snapshot.cleanup_attempts!
34
36
  end
35
37
 
36
38
  PNG_EXTENSION = ".png"
@@ -70,9 +72,8 @@ module Capybara
70
72
 
71
73
  blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element
72
74
 
73
- if Screenshot.hide_caret
74
- BrowserHelpers.hide_caret
75
- end
75
+ BrowserHelpers.hide_caret if Screenshot.hide_caret
76
+ BrowserHelpers.disable_animations if Screenshot.disable_animations
76
77
 
77
78
  blurred_input
78
79
  end
@@ -86,7 +87,7 @@ module Capybara
86
87
  break unless pending_image
87
88
 
88
89
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
89
- raise CapybaraScreenshotDiff::ExpectationNotMet, "Images have not been loaded after #{timeout}s: #{pending_image.inspect}"
90
+ raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
90
91
  end
91
92
 
92
93
  sleep 0.025
@@ -101,7 +102,7 @@ module Capybara
101
102
  # Load saved screenshot and pre-process it
102
103
  process_screenshot(tmpfile.path, screenshot_path)
103
104
  ensure
104
- File.unlink(tmpfile) if tmpfile
105
+ tmpfile&.close!
105
106
  end
106
107
 
107
108
  def capture_screenshot_at(snapshot)