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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "chunky_png"
|
|
5
|
+
rescue LoadError => e
|
|
6
|
+
raise 'Required chunky_png gem is missing. Add `gem "chunky_png"` to Gemfile' if e.message.match?(/chunky_png/i)
|
|
7
|
+
raise
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require "snap_diff/driver"
|
|
11
|
+
require "snap_diff/drivers"
|
|
12
|
+
require "snap_diff/comparison_result"
|
|
13
|
+
|
|
14
|
+
module SnapDiff
|
|
15
|
+
module Drivers
|
|
16
|
+
class ChunkyPNGDriver
|
|
17
|
+
include SnapDiff::Driver
|
|
18
|
+
include ChunkyPNG::Color
|
|
19
|
+
|
|
20
|
+
def load_images(old_file_name, new_file_name)
|
|
21
|
+
old_bytes, new_bytes = load_image_files(old_file_name, new_file_name)
|
|
22
|
+
|
|
23
|
+
_load_images(old_bytes, new_bytes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_black_box(image, _region)
|
|
27
|
+
image
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_difference_region(comparison)
|
|
31
|
+
DifferenceRegionFinder.new(comparison, self).perform
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def crop(region, i)
|
|
35
|
+
i.crop(*region.to_top_left_corner_coordinates)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def from_file(filename_or_path)
|
|
39
|
+
ChunkyPNG::Image.from_file(filename_or_path.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def save_image_to(image, filename)
|
|
43
|
+
image.save(filename, :fast_rgba)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resize_image_to(image, new_width, new_height)
|
|
47
|
+
image.resample_bilinear(new_width, new_height)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def load_image_files(old_file_name, file_name)
|
|
51
|
+
[old_file_name.binread, file_name.binread]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def draw_rectangles(images, region, (r, g, b), offset: 0)
|
|
55
|
+
border_color = ChunkyPNG::Color.rgb(r, g, b)
|
|
56
|
+
border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100)
|
|
57
|
+
|
|
58
|
+
images.map do |image|
|
|
59
|
+
new_img = image.dup
|
|
60
|
+
new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color)
|
|
61
|
+
new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow)
|
|
62
|
+
new_img
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def same_pixels?(comparison)
|
|
67
|
+
comparison.new_image == comparison.base_image
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def _load_images(old_file, new_file)
|
|
73
|
+
[ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class DifferenceRegionFinder
|
|
77
|
+
attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit
|
|
78
|
+
|
|
79
|
+
def initialize(comparison, driver = nil)
|
|
80
|
+
@comparison = comparison
|
|
81
|
+
@driver = driver
|
|
82
|
+
|
|
83
|
+
@color_distance_limit = comparison.options[:color_distance_limit]
|
|
84
|
+
@shift_distance_limit = comparison.options[:shift_distance_limit]
|
|
85
|
+
@skip_area = comparison.options[:skip_area]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def perform
|
|
89
|
+
find_difference_region(@comparison)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def find_difference_region(comparison)
|
|
93
|
+
new_image, base_image, = comparison.new_image, comparison.base_image
|
|
94
|
+
|
|
95
|
+
meta = {}
|
|
96
|
+
meta[:max_color_distance] = 0
|
|
97
|
+
meta[:max_shift_distance] = 0 if shift_distance_limit
|
|
98
|
+
|
|
99
|
+
region = find_top(base_image, new_image, cache: meta)
|
|
100
|
+
region = if region.nil? || region[1].nil?
|
|
101
|
+
nil
|
|
102
|
+
else
|
|
103
|
+
find_diff_rectangle(base_image, new_image, region, cache: meta)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = ComparisonResult.new(region, meta, comparison)
|
|
107
|
+
|
|
108
|
+
unless result.blank?
|
|
109
|
+
meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance]
|
|
110
|
+
|
|
111
|
+
if comparison.options[:tolerance]
|
|
112
|
+
meta[:difference_level] = difference_level(nil, base_image, region)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def difference_level(_diff_mask, base_image, region)
|
|
120
|
+
image_area_size = @driver.image_area_size(base_image)
|
|
121
|
+
return nil if image_area_size.zero?
|
|
122
|
+
|
|
123
|
+
region.size.to_f / image_area_size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def find_diff_rectangle(org_img, new_img, area_coordinates, cache:)
|
|
127
|
+
left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache)
|
|
128
|
+
bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache)
|
|
129
|
+
|
|
130
|
+
Region.from_edge_coordinates(left, top, right, bottom)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_top(old_img, new_img, cache:)
|
|
134
|
+
old_img.height.times do |y|
|
|
135
|
+
old_img.width.times do |x|
|
|
136
|
+
return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def find_left_right_and_top(old_img, new_img, region, cache:)
|
|
143
|
+
region = region.is_a?(Region) ? region.to_edge_coordinates : region
|
|
144
|
+
|
|
145
|
+
left = region[0] || old_img.width - 1
|
|
146
|
+
top = region[1]
|
|
147
|
+
right = region[2] || 0
|
|
148
|
+
bottom = region[3]
|
|
149
|
+
|
|
150
|
+
old_img.height.times do |y|
|
|
151
|
+
(0...left).find do |x|
|
|
152
|
+
next if same_color?(old_img, new_img, x, y, cache: cache)
|
|
153
|
+
|
|
154
|
+
top ||= y
|
|
155
|
+
bottom = y
|
|
156
|
+
left = x
|
|
157
|
+
right = x if x > right
|
|
158
|
+
x
|
|
159
|
+
end
|
|
160
|
+
(old_img.width - 1).step(right + 1, -1).find do |x|
|
|
161
|
+
unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
162
|
+
bottom = y
|
|
163
|
+
right = x
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[left, top, right, bottom]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def find_bottom(old_img, new_img, left, right, bottom, cache:)
|
|
172
|
+
if bottom
|
|
173
|
+
(old_img.height - 1).step(bottom + 1, -1).find do |y|
|
|
174
|
+
(left..right).find do |x|
|
|
175
|
+
bottom = y unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
bottom
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def same_color?(old_img, new_img, x, y, cache:)
|
|
184
|
+
return true if skipped_region?(x, y)
|
|
185
|
+
|
|
186
|
+
color_distance =
|
|
187
|
+
color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
|
|
188
|
+
|
|
189
|
+
if color_distance > cache[:max_color_distance]
|
|
190
|
+
cache[:max_color_distance] = color_distance
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
color_matches = color_distance == 0 ||
|
|
194
|
+
(!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit)
|
|
195
|
+
|
|
196
|
+
return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY
|
|
197
|
+
|
|
198
|
+
shift_distance = (color_matches && 0) ||
|
|
199
|
+
shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
|
|
200
|
+
if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance])
|
|
201
|
+
cache[:max_shift_distance] = shift_distance
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
color_matches
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def skipped_region?(x, y)
|
|
208
|
+
return false unless @skip_area
|
|
209
|
+
|
|
210
|
+
@skip_area.any? { |region| region.cover?(x, y) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
|
|
214
|
+
org_color = old_img[x, y]
|
|
215
|
+
unless shift_distance_limit
|
|
216
|
+
return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
start_x = [0, x - shift_distance_limit].max
|
|
220
|
+
end_x = [x + shift_distance_limit, new_img.width - 1].min
|
|
221
|
+
start_y = [0, y - shift_distance_limit].max
|
|
222
|
+
end_y = [y + shift_distance_limit, new_img.height - 1].min
|
|
223
|
+
|
|
224
|
+
min_distance = Float::INFINITY
|
|
225
|
+
(start_y..end_y).each do |dy|
|
|
226
|
+
(start_x..end_x).each do |dx|
|
|
227
|
+
distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
|
|
228
|
+
return 0 if distance == 0
|
|
229
|
+
min_distance = distance if distance < min_distance
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
min_distance
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
|
|
236
|
+
org_color = old_img[x, y]
|
|
237
|
+
shift_distance = 0
|
|
238
|
+
loop do
|
|
239
|
+
bounds_breached = 0
|
|
240
|
+
top_row = y - shift_distance
|
|
241
|
+
if top_row >= 0 # top
|
|
242
|
+
([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
|
|
243
|
+
if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
|
|
244
|
+
return shift_distance
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
else
|
|
248
|
+
bounds_breached += 1
|
|
249
|
+
end
|
|
250
|
+
if shift_distance > 0
|
|
251
|
+
if (x - shift_distance) >= 0 # left
|
|
252
|
+
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
253
|
+
.each do |dy|
|
|
254
|
+
if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
|
|
255
|
+
return shift_distance
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
else
|
|
259
|
+
bounds_breached += 1
|
|
260
|
+
end
|
|
261
|
+
if (y + shift_distance) < new_img.height # bottom
|
|
262
|
+
([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
|
|
263
|
+
if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
|
|
264
|
+
return shift_distance
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
bounds_breached += 1
|
|
269
|
+
end
|
|
270
|
+
if (x + shift_distance) < new_img.width # right
|
|
271
|
+
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
272
|
+
.each do |dy|
|
|
273
|
+
if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
|
|
274
|
+
return shift_distance
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
else
|
|
278
|
+
bounds_breached += 1
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
break if bounds_breached == 4
|
|
282
|
+
|
|
283
|
+
shift_distance += 1
|
|
284
|
+
end
|
|
285
|
+
Float::INFINITY
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def color_matches(new_img, org_color, x, y, color_distance_limit)
|
|
289
|
+
new_color = new_img[x, y]
|
|
290
|
+
return new_color == org_color unless color_distance_limit
|
|
291
|
+
|
|
292
|
+
color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
|
|
293
|
+
color_distance <= color_distance_limit
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "vips"
|
|
5
|
+
rescue LoadError => e
|
|
6
|
+
raise 'Required ruby-vips gem is missing. Add `gem "ruby-vips"` to Gemfile' if e.message.match?(/vips/i)
|
|
7
|
+
raise
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require "snap_diff/driver"
|
|
11
|
+
require "snap_diff/drivers"
|
|
12
|
+
require "snap_diff/comparison_result"
|
|
13
|
+
# Defines SnapDiff::RED_RGBA, the highlight_mask default color.
|
|
14
|
+
require "snap_diff/annotation_service"
|
|
15
|
+
|
|
16
|
+
module SnapDiff
|
|
17
|
+
module Drivers
|
|
18
|
+
class VipsDriver
|
|
19
|
+
include SnapDiff::Driver
|
|
20
|
+
|
|
21
|
+
def find_difference_region(comparison)
|
|
22
|
+
new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
|
|
23
|
+
|
|
24
|
+
diff_mask = if options[:perceptual_threshold]
|
|
25
|
+
self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
|
|
26
|
+
else
|
|
27
|
+
self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
|
|
28
|
+
end
|
|
29
|
+
region = self.class.difference_region_by(diff_mask)
|
|
30
|
+
# TODO: schedule research when we got this case for VIPs
|
|
31
|
+
# region = nil if region && region_covers_entire_image?(region, base_image)
|
|
32
|
+
|
|
33
|
+
result = ComparisonResult.new(region, {}, comparison)
|
|
34
|
+
|
|
35
|
+
unless result.blank?
|
|
36
|
+
result.meta[:difference_level] = difference_level(diff_mask, base_image) if comparison.options[:tolerance]
|
|
37
|
+
result.meta[:diff_mask] = diff_mask
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def crop(region, i)
|
|
44
|
+
i.crop(*region.to_top_left_corner_coordinates)
|
|
45
|
+
rescue Vips::Error => e
|
|
46
|
+
warn(
|
|
47
|
+
"[capybara-screenshot-diff] Crop has been failed for " \
|
|
48
|
+
"{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
|
|
49
|
+
)
|
|
50
|
+
raise e
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def filter_image_with_median(image, median_filter_window_size)
|
|
54
|
+
image.median(median_filter_window_size)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_black_box(memo, region)
|
|
58
|
+
return memo unless region
|
|
59
|
+
|
|
60
|
+
memo.draw_rect([0, 0, 0, 0], *region.to_top_left_corner_coordinates, fill: true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def difference_level(diff_mask, old_img, _region = nil)
|
|
64
|
+
self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
MAX_FILENAME_LENGTH = 200
|
|
68
|
+
|
|
69
|
+
# Vips could not work with the same file. Per each process we require to create new file
|
|
70
|
+
def save_image_to(image, filename)
|
|
71
|
+
# Dir::Tmpname will happily produce tempfile names that are too long for most unix filesystems,
|
|
72
|
+
# which leads to "unix error: File name too long". Apply a limit to avoid this.
|
|
73
|
+
limited_filename = filename.to_s[-MAX_FILENAME_LENGTH..] || filename.to_s
|
|
74
|
+
::Dir::Tmpname.create([limited_filename, PNG_EXTENSION]) do |tmp_image_filename|
|
|
75
|
+
image.write_to_file(tmp_image_filename)
|
|
76
|
+
FileUtils.mv(tmp_image_filename, filename)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resize_image_to(image, new_width, new_height)
|
|
81
|
+
image.resize(new_width.to_f / image.width, vscale: new_height.to_f / image.height)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_images(old_file_name, new_file_name)
|
|
85
|
+
[from_file(old_file_name), from_file(new_file_name)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def from_file(filename)
|
|
89
|
+
result = ::Vips::Image.new_from_file(filename.to_s)
|
|
90
|
+
|
|
91
|
+
result = result.colourspace(:srgb) if result.bands < 3
|
|
92
|
+
result = result.bandjoin(255) if result.bands == 3
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def draw_rectangles(images, region, rgba, offset: 0)
|
|
98
|
+
images.map do |image|
|
|
99
|
+
image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def same_pixels?(comparison)
|
|
104
|
+
(comparison.new_image == comparison.base_image).min == 255
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def merge(new_image, base_image)
|
|
108
|
+
base_image.composite2(new_image, :over)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def highlight_mask(diff_mask, merged_image, color: SnapDiff::RED_RGBA)
|
|
112
|
+
diff_mask.ifthenelse(color, merged_image * 0.75)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
class << self
|
|
118
|
+
def difference_area(old_image, new_image, color_distance: 0)
|
|
119
|
+
mask = difference_mask(new_image, old_image, color_distance)
|
|
120
|
+
difference_area_size_by(mask)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def difference_area_size_by(difference_mask)
|
|
124
|
+
diff_mask = difference_mask == 0
|
|
125
|
+
diff_mask.hist_find.to_a[0][0].max
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def difference_mask(base_image, new_image, color_distance = nil)
|
|
129
|
+
result = (new_image - base_image).abs
|
|
130
|
+
color_distance ? result > color_distance : result
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
|
|
134
|
+
color_diff = perceptual_color_diff(base_image, new_image) > threshold
|
|
135
|
+
alpha_diff = alpha_channel_diff(base_image, new_image)
|
|
136
|
+
alpha_diff ? (color_diff | alpha_diff) : color_diff
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def perceptual_color_diff(base_image, new_image)
|
|
140
|
+
base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
|
|
141
|
+
new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
|
|
142
|
+
base_lab = base_rgb.colourspace(:lab)
|
|
143
|
+
new_lab = new_rgb.colourspace(:lab)
|
|
144
|
+
base_lab.dE00(new_lab)
|
|
145
|
+
rescue Vips::Error
|
|
146
|
+
base_lab.dE76(new_lab)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def alpha_channel_diff(base_image, new_image)
|
|
150
|
+
return unless base_image.bands > 3 && new_image.bands > 3
|
|
151
|
+
|
|
152
|
+
(base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def difference_region_by(diff_mask)
|
|
156
|
+
columns, rows = diff_mask.bandor.project
|
|
157
|
+
|
|
158
|
+
left = columns.profile[1].min
|
|
159
|
+
right = columns.width - columns.flip(:horizontal).profile[1].min
|
|
160
|
+
|
|
161
|
+
top = rows.profile[0].min
|
|
162
|
+
bottom = rows.height - rows.flip(:vertical).profile[0].min
|
|
163
|
+
|
|
164
|
+
return nil if right < left || bottom < top
|
|
165
|
+
|
|
166
|
+
Region.from_edge_coordinates(left, top, right, bottom)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
# Compare two images and determine if they are equal, different, or within some comparison
|
|
5
|
+
# range considering color values and difference area size.
|
|
6
|
+
module Drivers
|
|
7
|
+
def self.for(driver_options = {})
|
|
8
|
+
driver_option = driver_options.is_a?(Hash) ? driver_options.fetch(:driver, :chunky_png) : driver_options
|
|
9
|
+
return driver_option unless driver_option.is_a?(Symbol)
|
|
10
|
+
|
|
11
|
+
Utils.find_driver_class_for(driver_option).new
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# No require of "capybara_screenshot_diff" here: every Capybara::Screenshot
|
|
4
|
+
# / CapybaraScreenshotDiff reference below is inside a method body, resolved
|
|
5
|
+
# lazily at call time, not at this file's own load time -- so this unit has
|
|
6
|
+
# no eager dependency on the umbrella entry point, and requiring it here
|
|
7
|
+
# would create a require cycle back to capybara_screenshot_diff.rb (which
|
|
8
|
+
# requires this file's old-path forwarder).
|
|
9
|
+
# DSL includes Capybara::DSL directly below, so it needs the base gem
|
|
10
|
+
# loaded regardless of what pulled this file in.
|
|
11
|
+
require "capybara/dsl"
|
|
12
|
+
require "capybara/screenshot/diff/config_legacy"
|
|
13
|
+
require "capybara/screenshot/diff/drivers"
|
|
14
|
+
require "snap_diff/comparison"
|
|
15
|
+
require "capybara/screenshot/diff/screenshot_matcher"
|
|
16
|
+
require_relative "screenshot_namer"
|
|
17
|
+
require_relative "screenshot_assertion"
|
|
18
|
+
|
|
19
|
+
module SnapDiff
|
|
20
|
+
# DSL for taking screenshots and making assertions in Capybara tests.
|
|
21
|
+
# This module provides methods for taking screenshots, comparing them against baselines,
|
|
22
|
+
# and managing the comparison process with various configuration options.
|
|
23
|
+
#
|
|
24
|
+
# The DSL is designed to be included in your test context (e.g., RSpec, Minitest)
|
|
25
|
+
# to provide screenshot comparison capabilities.
|
|
26
|
+
module DSL
|
|
27
|
+
include Capybara::DSL
|
|
28
|
+
|
|
29
|
+
def screenshot_section(name)
|
|
30
|
+
screenshot_namer.section = name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def screenshot_group(name)
|
|
34
|
+
screenshot_namer.group = name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Takes a screenshot and compares it against a baseline image.
|
|
38
|
+
#
|
|
39
|
+
# The method follows a layered optimization strategy for comparison:
|
|
40
|
+
# 1. First checks if screenshot functionality is active
|
|
41
|
+
# 2. Builds a full screenshot name using the current context
|
|
42
|
+
# 3. Creates a screenshot assertion object
|
|
43
|
+
# 4. Either validates immediately or defers validation based on options
|
|
44
|
+
#
|
|
45
|
+
# @param name [String] The base name of the screenshot, used to generate the filename.
|
|
46
|
+
# @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors.
|
|
47
|
+
# @param options [Hash] Additional options for taking the screenshot and comparison.
|
|
48
|
+
# @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed)
|
|
49
|
+
# Whether to validate the screenshot immediately or delay validation.
|
|
50
|
+
# @option options [Array<Integer>] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to.
|
|
51
|
+
# @option options [Array<Array<Integer>>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore.
|
|
52
|
+
# @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison.
|
|
53
|
+
# Represents the maximum allowed ratio of different pixels (0.0-1.0 scale).
|
|
54
|
+
# @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels.
|
|
55
|
+
# Uses Euclidean RGBA distance (0-510 scale). Mutually exclusive with :perceptual_threshold.
|
|
56
|
+
# @option options [Numeric] :perceptual_threshold Maximum perceptual color difference (CIE dE00).
|
|
57
|
+
# Uses human perception-based scale (0-100+). VIPS only. Takes priority over :color_distance_limit if both set.
|
|
58
|
+
# @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels.
|
|
59
|
+
# @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
|
|
60
|
+
# @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
|
|
61
|
+
# @return [Boolean] True if the screenshot was successfully captured and processed.
|
|
62
|
+
# @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
|
|
63
|
+
# @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
|
|
64
|
+
# @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
|
|
65
|
+
def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
|
|
66
|
+
return false unless Capybara::Screenshot.active?
|
|
67
|
+
|
|
68
|
+
# Get the full name with section and group information
|
|
69
|
+
full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
|
|
70
|
+
|
|
71
|
+
# Build the screenshot assertion
|
|
72
|
+
assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
|
|
73
|
+
|
|
74
|
+
return false unless assertion
|
|
75
|
+
|
|
76
|
+
# Determine if validation should be delayed or immediate
|
|
77
|
+
delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
|
|
78
|
+
|
|
79
|
+
if delayed
|
|
80
|
+
CapybaraScreenshotDiff.add_assertion(assertion)
|
|
81
|
+
else
|
|
82
|
+
assertion.validate!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Convenience wrapper around {#assert_matches_screenshot} and {#capture_screenshot}.
|
|
89
|
+
# @param compare [Boolean] When false, only captures the screenshot without comparing it to a baseline.
|
|
90
|
+
# @see #assert_matches_screenshot
|
|
91
|
+
# @see #capture_screenshot
|
|
92
|
+
def screenshot(name, skip_stack_frames: 0, compare: true, **options)
|
|
93
|
+
if compare
|
|
94
|
+
assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options)
|
|
95
|
+
else
|
|
96
|
+
capture_screenshot(name, **options)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Captures a screenshot without comparing it to a baseline.
|
|
101
|
+
# @param name [String] The base name of the screenshot, used to generate the filename.
|
|
102
|
+
# @param options [Hash] Additional options for taking the screenshot. See {#assert_matches_screenshot}.
|
|
103
|
+
# @return [Boolean] True if the screenshot was successfully captured.
|
|
104
|
+
def capture_screenshot(name, **options)
|
|
105
|
+
return false unless Capybara::Screenshot.active?
|
|
106
|
+
|
|
107
|
+
full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
|
|
108
|
+
SnapDiff::ScreenshotMatcher.new(full_name, options).capture
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Asserts the current page has no visual changes from the baseline.
|
|
114
|
+
# Override in your base test class to add project-specific behavior
|
|
115
|
+
# (e.g., waiting for Turbo, default skip areas).
|
|
116
|
+
def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
|
|
117
|
+
assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Builds a screenshot assertion object that can be validated immediately or later.
|
|
123
|
+
#
|
|
124
|
+
# This method constructs a screenshot assertion that encapsulates the comparison logic.
|
|
125
|
+
# The actual comparison is deferred until {ScreenshotAssertion#validate!} is called.
|
|
126
|
+
#
|
|
127
|
+
# @param name [String] The full name of the screenshot, including any section/group context.
|
|
128
|
+
# @param options [Hash] Options for screenshot taking and comparison.
|
|
129
|
+
# See {#assert_matches_screenshot} for available options.
|
|
130
|
+
# @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
|
|
131
|
+
# @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
|
|
132
|
+
# @see ScreenshotAssertion
|
|
133
|
+
def build_screenshot_assertion(name, options, skip_stack_frames: 0)
|
|
134
|
+
SnapDiff::ScreenshotMatcher
|
|
135
|
+
.new(name, options)
|
|
136
|
+
.build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def screenshot_namer
|
|
140
|
+
CapybaraScreenshotDiff.screenshot_namer
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
class BacktraceFilter
|
|
5
|
+
LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR
|
|
6
|
+
|
|
7
|
+
def initialize(lib_directory = LIB_DIRECTORY)
|
|
8
|
+
# Trailing separator keeps the prefix match on a directory boundary,
|
|
9
|
+
# so "/app/lib" does not also reject "/app/library".
|
|
10
|
+
@lib_directory = lib_directory.end_with?(File::SEPARATOR) ? lib_directory : lib_directory + File::SEPARATOR
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
|
|
14
|
+
# @param backtrace [Array<String>]
|
|
15
|
+
# @return [Array<String>]
|
|
16
|
+
def filtered(backtrace)
|
|
17
|
+
backtrace
|
|
18
|
+
.reject { |location| File.expand_path(location).start_with?(@lib_directory) }
|
|
19
|
+
.reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @private
|
|
24
|
+
class ErrorWithFilteredBacktrace < StandardError
|
|
25
|
+
# @private
|
|
26
|
+
def initialize(message = nil, backtrace = [])
|
|
27
|
+
super(message)
|
|
28
|
+
filter = BacktraceFilter.new
|
|
29
|
+
set_backtrace(filter.filtered(backtrace))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|