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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/docs/RELEASE_PREP.md +6 -6
- data/docs/thread_safety.md +15 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
- data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
- data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
- data/lib/capybara/screenshot/diff/difference.rb +6 -102
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
- data/lib/capybara/screenshot/diff/drivers.rb +7 -14
- data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
- data/lib/capybara/screenshot/diff/os.rb +8 -13
- data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
- data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
- data/lib/capybara/screenshot/diff/utils.rb +5 -40
- data/lib/capybara/screenshot/diff/vcs.rb +5 -37
- data/lib/capybara/screenshot/diff/version.rb +7 -1
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
- data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
- data/lib/capybara_screenshot_diff/dsl.rb +7 -129
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
- data/lib/capybara_screenshot_diff/minitest.rb +6 -56
- data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
- data/lib/capybara_screenshot_diff/rspec.rb +5 -63
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
- data/lib/capybara_screenshot_diff/snap.rb +5 -64
- data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
- data/lib/capybara_screenshot_diff/static.rb +3 -5
- data/lib/capybara_screenshot_diff.rb +29 -100
- data/lib/snap_diff/annotation_service.rb +84 -0
- data/lib/snap_diff/area_calculator.rb +56 -0
- data/lib/snap_diff/attempts_reporter.rb +51 -0
- data/lib/snap_diff/browser_helpers.rb +121 -0
- data/lib/snap_diff/capture/viewport.rb +40 -0
- data/lib/snap_diff/comparison.rb +238 -0
- data/lib/snap_diff/comparison_result.rb +104 -0
- data/lib/snap_diff/config.rb +6 -2
- data/lib/snap_diff/deprecation.rb +84 -0
- data/lib/snap_diff/driver.rb +37 -0
- data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
- data/lib/snap_diff/drivers/vips_driver.rb +171 -0
- data/lib/snap_diff/drivers.rb +14 -0
- data/lib/snap_diff/dsl.rb +143 -0
- data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
- data/lib/snap_diff/image_preprocessor.rb +68 -0
- data/lib/snap_diff/integrations/cucumber.rb +22 -0
- data/lib/snap_diff/integrations/minitest.rb +70 -0
- data/lib/snap_diff/integrations/rspec.rb +68 -0
- data/lib/snap_diff/legacy_shims.rb +99 -0
- data/lib/snap_diff/os.rb +17 -0
- data/lib/snap_diff/reporters/html.rb +143 -0
- data/lib/snap_diff/reporting.rb +53 -0
- data/lib/snap_diff/screenshot_assertion.rb +143 -0
- data/lib/snap_diff/screenshot_matcher.rb +113 -0
- data/lib/snap_diff/screenshot_namer.rb +81 -0
- data/lib/snap_diff/screenshoter.rb +129 -0
- data/lib/snap_diff/snap.rb +66 -0
- data/lib/snap_diff/snap_manager.rb +125 -0
- data/lib/snap_diff/stable_screenshoter.rb +103 -0
- data/lib/snap_diff/static.rb +11 -0
- data/lib/snap_diff/utils.rb +38 -0
- data/lib/snap_diff/vcs.rb +35 -0
- data/lib/snap_diff/version.rb +5 -0
- data/lib/snap_diff.rb +22 -6
- metadata +36 -2
- /data/lib/{capybara_screenshot_diff → snap_diff}/reporters/templates/report.html.erb +0 -0
|
@@ -1,230 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
module Diff
|
|
14
|
-
LOADED_DRIVERS = {}
|
|
15
|
-
|
|
16
|
-
# Holds the two images (and their paths/options/driver) being compared.
|
|
17
|
-
class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
|
|
18
|
-
def skip_area
|
|
19
|
-
options[:skip_area]
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Handles comparison of two images with a focus on performance and accuracy.
|
|
24
|
-
#
|
|
25
|
-
# This class implements a multi-layered optimization strategy for image comparison:
|
|
26
|
-
#
|
|
27
|
-
# 1. Early File-based Checks (Fastest):
|
|
28
|
-
# - Verifies both images exist (raises ArgumentError if not)
|
|
29
|
-
# - Compares file sizes (different sizes → different images)
|
|
30
|
-
# - Performs byte-by-byte comparison for identical files (exact match)
|
|
31
|
-
#
|
|
32
|
-
# 2. Quick Comparison (Fast):
|
|
33
|
-
# - Compares image dimensions (different dimensions → different images)
|
|
34
|
-
# - Performs pixel-by-pixel comparison if dimensions match
|
|
35
|
-
#
|
|
36
|
-
# 3. Detailed Analysis (Slower):
|
|
37
|
-
# - Only performed if quick comparison finds differences
|
|
38
|
-
# - Handles anti-aliasing, color tolerance, and shift detection
|
|
39
|
-
# - Respects skip_area and other comparison parameters
|
|
40
|
-
#
|
|
41
|
-
# This layered approach ensures optimal performance by:
|
|
42
|
-
# - Using the fastest possible method for early rejection
|
|
43
|
-
# - Only performing expensive operations when absolutely necessary
|
|
44
|
-
# - Maintaining high accuracy for complex comparisons
|
|
45
|
-
class ImageCompare
|
|
46
|
-
TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
|
|
47
|
-
|
|
48
|
-
attr_reader :driver, :driver_options
|
|
49
|
-
attr_reader :image_path, :base_image_path
|
|
50
|
-
attr_reader :difference, :error_message
|
|
51
|
-
|
|
52
|
-
def initialize(image_path, base_image_path, options = {})
|
|
53
|
-
@image_path = Pathname.new(image_path)
|
|
54
|
-
@base_image_path = Pathname.new(base_image_path)
|
|
55
|
-
|
|
56
|
-
ensure_files_exist!
|
|
57
|
-
|
|
58
|
-
@driver_options = options.freeze
|
|
59
|
-
@driver = Drivers.for(@driver_options)
|
|
60
|
-
@without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Performs a quick comparison of two image files.
|
|
64
|
-
#
|
|
65
|
-
# This method is optimized for speed and will return as soon as a difference is found.
|
|
66
|
-
# It's used for fast rejection before performing more expensive comparisons.
|
|
67
|
-
#
|
|
68
|
-
# @return [Boolean]
|
|
69
|
-
# - `true` if images are exactly identical (byte-for-byte match)
|
|
70
|
-
# - `false` if images are different or if a quick difference is detected
|
|
71
|
-
#
|
|
72
|
-
# @note This method will raise ArgumentError if either image file is missing.
|
|
73
|
-
def quick_equal?
|
|
74
|
-
if base_image_path.size == image_path.size
|
|
75
|
-
return true if files_identical?(base_image_path, image_path)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
result, difference = find_difference(quick_mode: true)
|
|
79
|
-
self.difference = difference
|
|
80
|
-
result
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def ensure_files_exist!
|
|
84
|
-
raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist?
|
|
85
|
-
raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist?
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Determines if the images are different according to the comparison rules.
|
|
89
|
-
#
|
|
90
|
-
# This method performs a full comparison if not already done, including any
|
|
91
|
-
# configured tolerances for color differences and shift distances.
|
|
92
|
-
#
|
|
93
|
-
# @return [Boolean]
|
|
94
|
-
# - `true` if the images are different beyond configured tolerances
|
|
95
|
-
# - `false` if the images are considered identical
|
|
96
|
-
#
|
|
97
|
-
# @see #processed
|
|
98
|
-
# @see #analyze_difference
|
|
99
|
-
def different?
|
|
100
|
-
processed.difference.different?
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def dimensions_changed?
|
|
104
|
-
difference.failed_by&.[](:different_dimensions)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def reporter
|
|
108
|
-
@reporter ||= build_reporter
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def processed?
|
|
112
|
-
!!difference
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def processed
|
|
116
|
-
self.difference = find_difference(quick_mode: false) unless processed?
|
|
117
|
-
@error_message ||= reporter.generate
|
|
118
|
-
self
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
private
|
|
122
|
-
|
|
123
|
-
def without_tolerable_options?
|
|
124
|
-
@without_tolerable_options
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def load_images_and_build_comparison(base_path, new_path, options)
|
|
128
|
-
base_img, new_img = driver.load_images(base_path, new_path)
|
|
129
|
-
Comparison.new(new_img, base_img, options, driver, new_path, base_path)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def image_preprocessor
|
|
133
|
-
@image_preprocessor ||= ImagePreprocessor.new(driver, driver_options)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def find_difference(quick_mode: false)
|
|
137
|
-
# Validate images exist
|
|
138
|
-
return build_null_difference("missing_image") unless images_exist?
|
|
139
|
-
|
|
140
|
-
# Create comparison with preprocessed images
|
|
141
|
-
comparison = load_comparison(base_image_path, image_path, driver_options)
|
|
142
|
-
|
|
143
|
-
analyze_difference(comparison, quick_mode: quick_mode)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# Analyzes the comparison and determines if images are different.
|
|
147
|
-
#
|
|
148
|
-
# @param comparison [Comparison] The comparison object containing images to analyze.
|
|
149
|
-
# @param quick_mode [Boolean] When true, performs minimal checks and returns early.
|
|
150
|
-
# In quick mode, returns [is_equal, difference] where:
|
|
151
|
-
# - is_equal is true if images are considered equal
|
|
152
|
-
# - difference is a Difference object or nil
|
|
153
|
-
# When false, returns a Difference object directly.
|
|
154
|
-
# @return [Array, Difference] Result format depends on quick_mode parameter.
|
|
155
|
-
def analyze_difference(comparison, quick_mode: true)
|
|
156
|
-
# Handle dimension differences
|
|
157
|
-
unless driver.same_dimension?(comparison)
|
|
158
|
-
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
|
|
159
|
-
return quick_mode ? [false, result] : result
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Handle identical pixels
|
|
163
|
-
if driver.same_pixels?(comparison)
|
|
164
|
-
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
|
|
165
|
-
return quick_mode ? [true, result] : result
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Handle early return for non-tolerable options
|
|
169
|
-
if quick_mode && without_tolerable_options?
|
|
170
|
-
return [false, nil]
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Process difference region
|
|
174
|
-
region = driver.find_difference_region(comparison)
|
|
175
|
-
|
|
176
|
-
# Only create a proper difference object if we've completed the comparison
|
|
177
|
-
quick_mode ? [!region.different?, region] : region
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def difference=(new_difference)
|
|
181
|
-
@error_message = nil
|
|
182
|
-
@reporter = nil
|
|
183
|
-
@difference = new_difference
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def build_reporter
|
|
187
|
-
current_difference = difference || build_null_difference
|
|
188
|
-
Reporters::Default.new(current_difference)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Loads and preprocesses images for detailed comparison.
|
|
192
|
-
#
|
|
193
|
-
# This method is responsible for:
|
|
194
|
-
# 1. Loading both images using the configured driver
|
|
195
|
-
# 2. Applying any necessary preprocessing (cropping, normalization)
|
|
196
|
-
# 3. Creating a Comparison object that holds the image data
|
|
197
|
-
#
|
|
198
|
-
# @param base_path [String,Pathname] Path to the baseline/reference image
|
|
199
|
-
# @param new_path [String,Pathname] Path to the new/candidate image
|
|
200
|
-
# @param options [Hash] Comparison options including:
|
|
201
|
-
# - :crop [Array<Integer>] Optional crop area [x, y, width, height]
|
|
202
|
-
# - :skip_area [Array<Array>] Areas to exclude from comparison
|
|
203
|
-
# - :tolerance [Numeric] Color tolerance threshold
|
|
204
|
-
# @return [Comparison] Prepared comparison object ready for analysis
|
|
205
|
-
# @raise [ArgumentError] If image files are invalid or unreadable
|
|
206
|
-
def load_comparison(base_path, new_path, options)
|
|
207
|
-
comparison = load_images_and_build_comparison(base_path, new_path, options)
|
|
208
|
-
image_preprocessor.process_comparison(comparison)
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def build_null_difference(failed_by = nil)
|
|
212
|
-
comparison = Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
|
|
213
|
-
Difference.build_null(comparison, base_image_path, image_path, failed_by)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# Check if both images exist
|
|
217
|
-
def images_exist?
|
|
218
|
-
base_image_path.exist? && image_path.exist?
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Check if files are identical by content
|
|
222
|
-
def files_identical?(file1, file2)
|
|
223
|
-
FileUtils.compare_file(file1, file2)
|
|
224
|
-
rescue SystemCallError, IOError
|
|
225
|
-
false
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
3
|
+
# Forwarder (ADR-004 v2 step 6): the comparison class lives at
|
|
4
|
+
# SnapDiff::Comparison (ex-ImageCompare); the old name now resolves lazily
|
|
5
|
+
# via snap_diff/legacy_shims' const_missing, with a deprecation warning.
|
|
6
|
+
# snap_diff/comparison itself pulls in the ComparisonResult and Drivers
|
|
7
|
+
# units, and the shims keep the old ::Difference / ::Drivers names
|
|
8
|
+
# resolvable, so this path still provides everything the pre-move
|
|
9
|
+
# image_compare.rb did. The internal Comparison struct and LOADED_DRIVERS
|
|
10
|
+
# keep their legacy names and are defined by snap_diff/comparison.rb itself.
|
|
11
|
+
require "snap_diff/comparison"
|
|
12
|
+
require "snap_diff/legacy_shims"
|
|
@@ -1,72 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
3
|
+
# Forwarder (ADR-004 v2 step 6): Capybara::Screenshot::Diff::ImagePreprocessor
|
|
4
|
+
# now resolves lazily via snap_diff/legacy_shims' const_missing, with a
|
|
5
|
+
# deprecation warning pointing at SnapDiff::ImagePreprocessor.
|
|
6
|
+
require "snap_diff/image_preprocessor"
|
|
7
|
+
require "snap_diff/legacy_shims"
|
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "snap_diff/os"
|
|
4
|
+
|
|
5
|
+
# Deliberately EAGER and silent (v2 step 6 exception): Os is an advertised
|
|
6
|
+
# entry-point constant probed with Object.const_defined? by
|
|
7
|
+
# support_load_probe_test.rb, and const_defined? never triggers
|
|
8
|
+
# const_missing -- a lazy shim would break that contract. See
|
|
9
|
+
# snap_diff/legacy_shims.rb for the full exception list.
|
|
3
10
|
module Capybara
|
|
4
11
|
module Screenshot
|
|
5
|
-
|
|
6
|
-
ON_WINDOWS = !!(RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/)
|
|
7
|
-
ON_MAC = !!(RbConfig::CONFIG["host_os"] =~ /darwin/)
|
|
8
|
-
ON_LINUX = !!(RbConfig::CONFIG["host_os"] =~ /linux/)
|
|
9
|
-
|
|
10
|
-
def self.name
|
|
11
|
-
return "windows" if ON_WINDOWS
|
|
12
|
-
return "macos" if ON_MAC
|
|
13
|
-
return "linux" if ON_LINUX
|
|
14
|
-
|
|
15
|
-
"unknown"
|
|
16
|
-
end
|
|
17
|
-
end
|
|
12
|
+
Os = SnapDiff::Os
|
|
18
13
|
end
|
|
19
14
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "snap_diff/annotation_service"
|
|
4
|
+
# Defines the Capybara::Screenshot::Diff namespace this file reopens.
|
|
5
|
+
require "snap_diff/legacy_shims"
|
|
4
6
|
|
|
5
7
|
module Capybara::Screenshot::Diff
|
|
6
8
|
module Reporters
|
|
@@ -9,7 +11,7 @@ module Capybara::Screenshot::Diff
|
|
|
9
11
|
|
|
10
12
|
def initialize(difference)
|
|
11
13
|
@difference = difference
|
|
12
|
-
@annotation_service = AnnotationService.new(difference)
|
|
14
|
+
@annotation_service = SnapDiff::AnnotationService.new(difference)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def annotated_image_path
|
|
@@ -75,7 +77,7 @@ module Capybara::Screenshot::Diff
|
|
|
75
77
|
|
|
76
78
|
def build_error_message
|
|
77
79
|
[
|
|
78
|
-
"(#{difference.
|
|
80
|
+
"(#{difference.to_h.to_json})",
|
|
79
81
|
image_path.to_path,
|
|
80
82
|
annotated_base_image_path.to_path,
|
|
81
83
|
annotated_image_path.to_path,
|
|
@@ -1,131 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
require_relative "area_calculator"
|
|
9
|
-
|
|
10
|
-
module Capybara
|
|
11
|
-
module Screenshot
|
|
12
|
-
module Diff
|
|
13
|
-
class ScreenshotMatcher
|
|
14
|
-
attr_reader :screenshot_full_name, :driver_options, :screenshot_format
|
|
15
|
-
|
|
16
|
-
def initialize(screenshot_full_name, options = {})
|
|
17
|
-
@screenshot_full_name = screenshot_full_name
|
|
18
|
-
@driver_options = Diff.default_options.merge(options)
|
|
19
|
-
|
|
20
|
-
@screenshot_format = @driver_options[:screenshot_format]
|
|
21
|
-
@snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def build_screenshot_assertion(skip_stack_frames: 0)
|
|
25
|
-
check_window_size!
|
|
26
|
-
prepare_screenshot_options
|
|
27
|
-
check_base_screenshot
|
|
28
|
-
|
|
29
|
-
capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
|
|
30
|
-
|
|
31
|
-
capture_screenshot(capture_options, comparison_options)
|
|
32
|
-
|
|
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
|
|
39
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
|
|
49
|
-
|
|
50
|
-
@snapshot.manager.create_output_directory_for(@snapshot.path)
|
|
51
|
-
capture_screenshot(capture_options, comparison_options)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def need_to_compare?
|
|
57
|
-
@snapshot.base_path.exist?
|
|
58
|
-
end
|
|
59
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
def extract_capture_and_comparison_options!(driver_options = {})
|
|
114
|
-
[
|
|
115
|
-
{
|
|
116
|
-
# screenshot options
|
|
117
|
-
capybara_screenshot_options: driver_options[:capybara_screenshot_options],
|
|
118
|
-
crop: driver_options.delete(:crop),
|
|
119
|
-
# delivery options
|
|
120
|
-
screenshot_format: driver_options[:screenshot_format],
|
|
121
|
-
# stability options
|
|
122
|
-
stability_time_limit: driver_options.delete(:stability_time_limit),
|
|
123
|
-
wait: driver_options.delete(:wait)
|
|
124
|
-
},
|
|
125
|
-
driver_options
|
|
126
|
-
]
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
3
|
+
# Forwarder (ADR-004 v2 step 6): Capybara::Screenshot::Diff::ScreenshotMatcher
|
|
4
|
+
# now resolves lazily via snap_diff/legacy_shims' const_missing, with a
|
|
5
|
+
# deprecation warning pointing at SnapDiff::ScreenshotMatcher.
|
|
6
|
+
require "snap_diff/screenshot_matcher"
|
|
7
|
+
require "snap_diff/legacy_shims"
|
|
@@ -1,129 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Screenshoter
|
|
9
|
-
attr_reader :capture_options, :driver
|
|
10
|
-
|
|
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 = {})
|
|
14
|
-
@capture_options = capture_options
|
|
15
|
-
@driver = Diff::Drivers.for(comparison_options)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def crop
|
|
19
|
-
@capture_options[:crop]
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def wait
|
|
23
|
-
@capture_options[:wait]
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def capybara_screenshot_options
|
|
27
|
-
@capture_options[:capybara_screenshot_options] || {}
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Try to get screenshot from browser.
|
|
31
|
-
# On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
|
|
32
|
-
# On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
|
|
33
|
-
def take_comparison_screenshot(snapshot)
|
|
34
|
-
capture_screenshot_at(snapshot)
|
|
35
|
-
snapshot.cleanup_attempts!
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
PNG_EXTENSION = ".png"
|
|
39
|
-
|
|
40
|
-
def take_screenshot(screenshot_path)
|
|
41
|
-
blurred_input = prepare_page_for_screenshot(timeout: wait)
|
|
42
|
-
|
|
43
|
-
# Take browser screenshot and save
|
|
44
|
-
save_and_process_screenshot(screenshot_path)
|
|
45
|
-
|
|
46
|
-
blurred_input&.click
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def process_screenshot(stored_path, screenshot_path)
|
|
50
|
-
screenshot_image = driver.from_file(stored_path)
|
|
51
|
-
|
|
52
|
-
# TODO(uwe): Remove when chromedriver takes right size screenshots
|
|
53
|
-
# TODO: Adds tests when this case is true
|
|
54
|
-
screenshot_image = resize_if_needed(screenshot_image) if selenium_with_retina_screen?
|
|
55
|
-
# ODOT
|
|
56
|
-
|
|
57
|
-
screenshot_image = driver.crop(crop, screenshot_image) if crop
|
|
58
|
-
|
|
59
|
-
driver.save_image_to(screenshot_image, screenshot_path)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def notice_how_to_avoid_this
|
|
63
|
-
unless defined?(@_csd_retina_warned)
|
|
64
|
-
warn "Halving retina screenshot. " \
|
|
65
|
-
'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
|
|
66
|
-
@_csd_retina_warned = true
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def prepare_page_for_screenshot(timeout:)
|
|
71
|
-
wait_images_loaded(timeout: timeout) if timeout
|
|
72
|
-
|
|
73
|
-
blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element
|
|
74
|
-
|
|
75
|
-
BrowserHelpers.hide_caret if Screenshot.hide_caret
|
|
76
|
-
BrowserHelpers.disable_animations if Screenshot.disable_animations
|
|
77
|
-
|
|
78
|
-
blurred_input
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def wait_images_loaded(timeout:)
|
|
82
|
-
return unless timeout
|
|
83
|
-
|
|
84
|
-
deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
85
|
-
loop do
|
|
86
|
-
pending_image = BrowserHelpers.pending_image_to_load
|
|
87
|
-
break unless pending_image
|
|
88
|
-
|
|
89
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
|
|
90
|
-
raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
sleep 0.025
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
|
|
99
|
-
def save_and_process_screenshot(screenshot_path)
|
|
100
|
-
tmpfile = Tempfile.new([screenshot_path.basename.to_s, PNG_EXTENSION])
|
|
101
|
-
BrowserHelpers.session.save_screenshot(tmpfile.path, **capybara_screenshot_options)
|
|
102
|
-
# Load saved screenshot and pre-process it
|
|
103
|
-
process_screenshot(tmpfile.path, screenshot_path)
|
|
104
|
-
ensure
|
|
105
|
-
tmpfile&.close!
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def capture_screenshot_at(snapshot)
|
|
109
|
-
take_screenshot(snapshot.next_attempt_path!)
|
|
110
|
-
|
|
111
|
-
snapshot.commit_last_attempt
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def resize_if_needed(saved_image)
|
|
115
|
-
expected_image_width = Screenshot.window_size[0]
|
|
116
|
-
return saved_image if driver.width_for(saved_image) < expected_image_width * 2
|
|
117
|
-
|
|
118
|
-
notice_how_to_avoid_this
|
|
119
|
-
|
|
120
|
-
new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image)
|
|
121
|
-
driver.resize_image_to(saved_image, expected_image_width, new_height)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def selenium_with_retina_screen?
|
|
125
|
-
Os::ON_MAC && BrowserHelpers.selenium? && Screenshot.window_size
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
3
|
+
# Forwarder (ADR-004 v2 step 6): Capybara::Screenshot::Screenshoter now
|
|
4
|
+
# resolves lazily via snap_diff/legacy_shims' const_missing, with a
|
|
5
|
+
# deprecation warning pointing at SnapDiff::Screenshoter.
|
|
6
|
+
require "snap_diff/screenshoter"
|
|
7
|
+
require "snap_diff/legacy_shims"
|