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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/Rakefile +29 -1
- data/capybara-screenshot-diff.gemspec +4 -3
- data/docs/RELEASE_PREP.md +42 -0
- data/docs/UPGRADING.md +390 -0
- data/docs/architecture.md +270 -0
- data/docs/ci-integration.md +238 -0
- data/docs/configuration.md +395 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_annotated.png +0 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/migration-guide.md +286 -0
- data/docs/organization.md +204 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +97 -0
- data/gems.rb +2 -1
- data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
- data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
- data/lib/capybara/screenshot/diff/comparison.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +40 -3
- data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
- data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
- data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
- data/lib/capybara/screenshot/diff/vcs.rb +19 -52
- data/lib/capybara/screenshot/diff/version.rb +1 -1
- data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
- data/lib/capybara_screenshot_diff/dsl.rb +123 -7
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
- data/lib/capybara_screenshot_diff/minitest.rb +10 -5
- data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
- data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
- data/lib/capybara_screenshot_diff/rspec.rb +20 -4
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
- data/lib/capybara_screenshot_diff/snap.rb +14 -3
- data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
- data/lib/capybara_screenshot_diff/static.rb +11 -0
- data/lib/capybara_screenshot_diff.rb +31 -5
- metadata +51 -8
- 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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
47
|
+
ensure_files_exist!
|
|
24
48
|
|
|
49
|
+
@driver_options = options.freeze
|
|
25
50
|
@driver = Drivers.for(@driver_options)
|
|
26
51
|
end
|
|
27
52
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
57
|
-
#
|
|
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 ||=
|
|
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
|
|
86
|
-
|
|
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
|
|
117
|
-
(
|
|
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
|
|
121
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
144
|
-
|
|
133
|
+
# Use difference finder to analyze the comparison
|
|
134
|
+
difference_finder.call(comparison, quick_mode: quick_mode)
|
|
145
135
|
end
|
|
146
136
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
173
|
-
|
|
143
|
+
def build_reporter
|
|
144
|
+
current_difference = difference || build_null_difference
|
|
145
|
+
Reporters::Default.new(current_difference)
|
|
174
146
|
end
|
|
175
147
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
image_path.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def build_screenshot_assertion(skip_stack_frames: 0)
|
|
25
|
+
check_window_size!
|
|
26
|
+
prepare_screenshot_options
|
|
27
|
+
check_base_screenshot
|
|
27
28
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
50
|
+
@snapshot.manager.create_output_directory_for(@snapshot.path)
|
|
51
|
+
capture_screenshot(capture_options, comparison_options)
|
|
52
|
+
end
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
return unless @snapshot.base_path.exist?
|
|
54
|
+
private
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
def need_to_compare?
|
|
57
|
+
@snapshot.base_path.exist?
|
|
52
58
|
end
|
|
53
59
|
|
|
54
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
+
tmpfile&.close!
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
def capture_screenshot_at(snapshot)
|