capybara-screenshot-diff 1.15.1 → 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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/docs/thread_safety.md +15 -0
  4. data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
  5. data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
  6. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
  7. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  8. data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
  9. data/lib/capybara/screenshot/diff/difference.rb +6 -102
  10. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
  11. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
  12. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
  13. data/lib/capybara/screenshot/diff/drivers.rb +7 -14
  14. data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
  15. data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
  16. data/lib/capybara/screenshot/diff/os.rb +8 -13
  17. data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
  18. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
  19. data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
  20. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
  21. data/lib/capybara/screenshot/diff/utils.rb +5 -40
  22. data/lib/capybara/screenshot/diff/vcs.rb +5 -37
  23. data/lib/capybara/screenshot/diff/version.rb +7 -1
  24. data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
  25. data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
  26. data/lib/capybara_screenshot_diff/dsl.rb +7 -129
  27. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
  28. data/lib/capybara_screenshot_diff/minitest.rb +6 -56
  29. data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
  30. data/lib/capybara_screenshot_diff/rspec.rb +5 -63
  31. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
  32. data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
  33. data/lib/capybara_screenshot_diff/snap.rb +5 -64
  34. data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
  35. data/lib/capybara_screenshot_diff/static.rb +3 -5
  36. data/lib/capybara_screenshot_diff.rb +29 -100
  37. data/lib/snap_diff/annotation_service.rb +84 -0
  38. data/lib/snap_diff/area_calculator.rb +56 -0
  39. data/lib/snap_diff/attempts_reporter.rb +51 -0
  40. data/lib/snap_diff/browser_helpers.rb +121 -0
  41. data/lib/snap_diff/capture/viewport.rb +40 -0
  42. data/lib/snap_diff/comparison.rb +238 -0
  43. data/lib/snap_diff/comparison_result.rb +104 -0
  44. data/lib/snap_diff/config.rb +6 -2
  45. data/lib/snap_diff/deprecation.rb +84 -0
  46. data/lib/snap_diff/driver.rb +37 -0
  47. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  48. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  49. data/lib/snap_diff/drivers.rb +14 -0
  50. data/lib/snap_diff/dsl.rb +143 -0
  51. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  52. data/lib/snap_diff/image_preprocessor.rb +68 -0
  53. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  54. data/lib/snap_diff/integrations/minitest.rb +70 -0
  55. data/lib/snap_diff/integrations/rspec.rb +68 -0
  56. data/lib/snap_diff/legacy_shims.rb +99 -0
  57. data/lib/snap_diff/os.rb +17 -0
  58. data/lib/snap_diff/reporters/html.rb +143 -0
  59. data/lib/snap_diff/reporting.rb +53 -0
  60. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  61. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  62. data/lib/snap_diff/screenshot_namer.rb +81 -0
  63. data/lib/snap_diff/screenshoter.rb +129 -0
  64. data/lib/snap_diff/snap.rb +66 -0
  65. data/lib/snap_diff/snap_manager.rb +125 -0
  66. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  67. data/lib/snap_diff/static.rb +11 -0
  68. data/lib/snap_diff/utils.rb +38 -0
  69. data/lib/snap_diff/vcs.rb +35 -0
  70. data/lib/snap_diff/version.rb +5 -0
  71. data/lib/snap_diff.rb +22 -6
  72. metadata +36 -2
  73. /data/lib/{capybara_screenshot_diff → snap_diff}/reporters/templates/report.html.erb +0 -0
@@ -1,301 +1,7 @@
1
1
  # frozen_string_literal: true
2
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 "capybara/screenshot/diff/drivers/base_driver"
11
-
12
- module Capybara
13
- module Screenshot
14
- module Diff
15
- # Compare two images and determine if they are equal, different, or within some comparison
16
- # range considering color values and difference area size.
17
- module Drivers
18
- class ChunkyPNGDriver < BaseDriver
19
- include ChunkyPNG::Color
20
-
21
- def load_images(old_file_name, new_file_name)
22
- old_bytes, new_bytes = load_image_files(old_file_name, new_file_name)
23
-
24
- _load_images(old_bytes, new_bytes)
25
- end
26
-
27
- def add_black_box(image, _region)
28
- image
29
- end
30
-
31
- def find_difference_region(comparison)
32
- DifferenceRegionFinder.new(comparison, self).perform
33
- end
34
-
35
- def crop(region, i)
36
- i.crop(*region.to_top_left_corner_coordinates)
37
- end
38
-
39
- def from_file(filename_or_path)
40
- ChunkyPNG::Image.from_file(filename_or_path.to_s)
41
- end
42
-
43
- def save_image_to(image, filename)
44
- image.save(filename, :fast_rgba)
45
- end
46
-
47
- def resize_image_to(image, new_width, new_height)
48
- image.resample_bilinear(new_width, new_height)
49
- end
50
-
51
- def load_image_files(old_file_name, file_name)
52
- [old_file_name.binread, file_name.binread]
53
- end
54
-
55
- def draw_rectangles(images, region, (r, g, b), offset: 0)
56
- border_color = ChunkyPNG::Color.rgb(r, g, b)
57
- border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100)
58
-
59
- images.map do |image|
60
- new_img = image.dup
61
- new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color)
62
- new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow)
63
- new_img
64
- end
65
- end
66
-
67
- def same_pixels?(comparison)
68
- comparison.new_image == comparison.base_image
69
- end
70
-
71
- private
72
-
73
- def _load_images(old_file, new_file)
74
- [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
75
- end
76
-
77
- class DifferenceRegionFinder
78
- attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit
79
-
80
- def initialize(comparison, driver = nil)
81
- @comparison = comparison
82
- @driver = driver
83
-
84
- @color_distance_limit = comparison.options[:color_distance_limit]
85
- @shift_distance_limit = comparison.options[:shift_distance_limit]
86
- @skip_area = comparison.options[:skip_area]
87
- end
88
-
89
- def perform
90
- find_difference_region(@comparison)
91
- end
92
-
93
- def find_difference_region(comparison)
94
- new_image, base_image, = comparison.new_image, comparison.base_image
95
-
96
- meta = {}
97
- meta[:max_color_distance] = 0
98
- meta[:max_shift_distance] = 0 if shift_distance_limit
99
-
100
- region = find_top(base_image, new_image, cache: meta)
101
- region = if region.nil? || region[1].nil?
102
- nil
103
- else
104
- find_diff_rectangle(base_image, new_image, region, cache: meta)
105
- end
106
-
107
- result = Difference.new(region, meta, comparison)
108
-
109
- unless result.blank?
110
- meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance]
111
-
112
- if comparison.options[:tolerance]
113
- meta[:difference_level] = difference_level(nil, base_image, region)
114
- end
115
- end
116
-
117
- result
118
- end
119
-
120
- def difference_level(_diff_mask, base_image, region)
121
- image_area_size = @driver.image_area_size(base_image)
122
- return nil if image_area_size.zero?
123
-
124
- region.size.to_f / image_area_size
125
- end
126
-
127
- def find_diff_rectangle(org_img, new_img, area_coordinates, cache:)
128
- left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache)
129
- bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache)
130
-
131
- Region.from_edge_coordinates(left, top, right, bottom)
132
- end
133
-
134
- def find_top(old_img, new_img, cache:)
135
- old_img.height.times do |y|
136
- old_img.width.times do |x|
137
- return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache)
138
- end
139
- end
140
- nil
141
- end
142
-
143
- def find_left_right_and_top(old_img, new_img, region, cache:)
144
- region = region.is_a?(Region) ? region.to_edge_coordinates : region
145
-
146
- left = region[0] || old_img.width - 1
147
- top = region[1]
148
- right = region[2] || 0
149
- bottom = region[3]
150
-
151
- old_img.height.times do |y|
152
- (0...left).find do |x|
153
- next if same_color?(old_img, new_img, x, y, cache: cache)
154
-
155
- top ||= y
156
- bottom = y
157
- left = x
158
- right = x if x > right
159
- x
160
- end
161
- (old_img.width - 1).step(right + 1, -1).find do |x|
162
- unless same_color?(old_img, new_img, x, y, cache: cache)
163
- bottom = y
164
- right = x
165
- end
166
- end
167
- end
168
-
169
- [left, top, right, bottom]
170
- end
171
-
172
- def find_bottom(old_img, new_img, left, right, bottom, cache:)
173
- if bottom
174
- (old_img.height - 1).step(bottom + 1, -1).find do |y|
175
- (left..right).find do |x|
176
- bottom = y unless same_color?(old_img, new_img, x, y, cache: cache)
177
- end
178
- end
179
- end
180
-
181
- bottom
182
- end
183
-
184
- def same_color?(old_img, new_img, x, y, cache:)
185
- return true if skipped_region?(x, y)
186
-
187
- color_distance =
188
- color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
189
-
190
- if color_distance > cache[:max_color_distance]
191
- cache[:max_color_distance] = color_distance
192
- end
193
-
194
- color_matches = color_distance == 0 ||
195
- (!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit)
196
-
197
- return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY
198
-
199
- shift_distance = (color_matches && 0) ||
200
- shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
201
- if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance])
202
- cache[:max_shift_distance] = shift_distance
203
- end
204
-
205
- color_matches
206
- end
207
-
208
- def skipped_region?(x, y)
209
- return false unless @skip_area
210
-
211
- @skip_area.any? { |region| region.cover?(x, y) }
212
- end
213
-
214
- def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
215
- org_color = old_img[x, y]
216
- unless shift_distance_limit
217
- return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
218
- end
219
-
220
- start_x = [0, x - shift_distance_limit].max
221
- end_x = [x + shift_distance_limit, new_img.width - 1].min
222
- start_y = [0, y - shift_distance_limit].max
223
- end_y = [y + shift_distance_limit, new_img.height - 1].min
224
-
225
- min_distance = Float::INFINITY
226
- (start_y..end_y).each do |dy|
227
- (start_x..end_x).each do |dx|
228
- distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
229
- return 0 if distance == 0
230
- min_distance = distance if distance < min_distance
231
- end
232
- end
233
- min_distance
234
- end
235
-
236
- def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
237
- org_color = old_img[x, y]
238
- shift_distance = 0
239
- loop do
240
- bounds_breached = 0
241
- top_row = y - shift_distance
242
- if top_row >= 0 # top
243
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
244
- if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
245
- return shift_distance
246
- end
247
- end
248
- else
249
- bounds_breached += 1
250
- end
251
- if shift_distance > 0
252
- if (x - shift_distance) >= 0 # left
253
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
254
- .each do |dy|
255
- if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
256
- return shift_distance
257
- end
258
- end
259
- else
260
- bounds_breached += 1
261
- end
262
- if (y + shift_distance) < new_img.height # bottom
263
- ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
264
- if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
265
- return shift_distance
266
- end
267
- end
268
- else
269
- bounds_breached += 1
270
- end
271
- if (x + shift_distance) < new_img.width # right
272
- ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
273
- .each do |dy|
274
- if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
275
- return shift_distance
276
- end
277
- end
278
- else
279
- bounds_breached += 1
280
- end
281
- end
282
- break if bounds_breached == 4
283
-
284
- shift_distance += 1
285
- end
286
- Float::INFINITY
287
- end
288
-
289
- def color_matches(new_img, org_color, x, y, color_distance_limit)
290
- new_color = new_img[x, y]
291
- return new_color == org_color unless color_distance_limit
292
-
293
- color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
294
- color_distance <= color_distance_limit
295
- end
296
- end
297
- end
298
- end
299
- end
300
- end
301
- end
3
+ # Forwarder (ADR-004 v2 step 4): ChunkyPNGDriver lives in SnapDiff::Drivers
4
+ # now; the module alias in capybara/screenshot/diff/drivers.rb makes it
5
+ # reachable as Capybara::Screenshot::Diff::Drivers::ChunkyPNGDriver.
6
+ require "capybara/screenshot/diff/drivers"
7
+ require "snap_diff/drivers/chunky_png_driver"
@@ -1,171 +1,7 @@
1
1
  # frozen_string_literal: true
2
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 "capybara/screenshot/diff/drivers/base_driver"
11
-
12
- module Capybara
13
- module Screenshot
14
- module Diff
15
- # Compare two images and determine if they are equal, different, or within some comparison
16
- # range considering color values and difference area size.
17
- module Drivers
18
- class VipsDriver < BaseDriver
19
- def find_difference_region(comparison)
20
- new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
21
-
22
- diff_mask = if options[:perceptual_threshold]
23
- self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
24
- else
25
- self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
26
- end
27
- region = self.class.difference_region_by(diff_mask)
28
- # TODO: schedule research when we got this case for VIPs
29
- # region = nil if region && region_covers_entire_image?(region, base_image)
30
-
31
- result = Difference.new(region, {}, comparison)
32
-
33
- unless result.blank?
34
- result.meta[:difference_level] = difference_level(diff_mask, base_image) if comparison.options[:tolerance]
35
- result.meta[:diff_mask] = diff_mask
36
- end
37
-
38
- result
39
- end
40
-
41
- def crop(region, i)
42
- i.crop(*region.to_top_left_corner_coordinates)
43
- rescue Vips::Error => e
44
- warn(
45
- "[capybara-screenshot-diff] Crop has been failed for " \
46
- "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
47
- )
48
- raise e
49
- end
50
-
51
- def filter_image_with_median(image, median_filter_window_size)
52
- image.median(median_filter_window_size)
53
- end
54
-
55
- def add_black_box(memo, region)
56
- return memo unless region
57
-
58
- memo.draw_rect([0, 0, 0, 0], *region.to_top_left_corner_coordinates, fill: true)
59
- end
60
-
61
- def difference_level(diff_mask, old_img, _region = nil)
62
- self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
63
- end
64
-
65
- MAX_FILENAME_LENGTH = 200
66
-
67
- # Vips could not work with the same file. Per each process we require to create new file
68
- def save_image_to(image, filename)
69
- # Dir::Tmpname will happily produce tempfile names that are too long for most unix filesystems,
70
- # which leads to "unix error: File name too long". Apply a limit to avoid this.
71
- limited_filename = filename.to_s[-MAX_FILENAME_LENGTH..] || filename.to_s
72
- ::Dir::Tmpname.create([limited_filename, PNG_EXTENSION]) do |tmp_image_filename|
73
- image.write_to_file(tmp_image_filename)
74
- FileUtils.mv(tmp_image_filename, filename)
75
- end
76
- end
77
-
78
- def resize_image_to(image, new_width, new_height)
79
- image.resize(new_width.to_f / image.width, vscale: new_height.to_f / image.height)
80
- end
81
-
82
- def load_images(old_file_name, new_file_name)
83
- [from_file(old_file_name), from_file(new_file_name)]
84
- end
85
-
86
- def from_file(filename)
87
- result = ::Vips::Image.new_from_file(filename.to_s)
88
-
89
- result = result.colourspace(:srgb) if result.bands < 3
90
- result = result.bandjoin(255) if result.bands == 3
91
-
92
- result
93
- end
94
-
95
- def draw_rectangles(images, region, rgba, offset: 0)
96
- images.map do |image|
97
- image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
98
- end
99
- end
100
-
101
- def same_pixels?(comparison)
102
- (comparison.new_image == comparison.base_image).min == 255
103
- end
104
-
105
- def merge(new_image, base_image)
106
- base_image.composite2(new_image, :over)
107
- end
108
-
109
- def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
110
- diff_mask.ifthenelse(color, merged_image * 0.75)
111
- end
112
-
113
- private
114
-
115
- class << self
116
- def difference_area(old_image, new_image, color_distance: 0)
117
- mask = difference_mask(new_image, old_image, color_distance)
118
- difference_area_size_by(mask)
119
- end
120
-
121
- def difference_area_size_by(difference_mask)
122
- diff_mask = difference_mask == 0
123
- diff_mask.hist_find.to_a[0][0].max
124
- end
125
-
126
- def difference_mask(base_image, new_image, color_distance = nil)
127
- result = (new_image - base_image).abs
128
- color_distance ? result > color_distance : result
129
- end
130
-
131
- def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
132
- color_diff = perceptual_color_diff(base_image, new_image) > threshold
133
- alpha_diff = alpha_channel_diff(base_image, new_image)
134
- alpha_diff ? (color_diff | alpha_diff) : color_diff
135
- end
136
-
137
- def perceptual_color_diff(base_image, new_image)
138
- base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
139
- new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
140
- base_lab = base_rgb.colourspace(:lab)
141
- new_lab = new_rgb.colourspace(:lab)
142
- base_lab.dE00(new_lab)
143
- rescue Vips::Error
144
- base_lab.dE76(new_lab)
145
- end
146
-
147
- def alpha_channel_diff(base_image, new_image)
148
- return unless base_image.bands > 3 && new_image.bands > 3
149
-
150
- (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
151
- end
152
-
153
- def difference_region_by(diff_mask)
154
- columns, rows = diff_mask.bandor.project
155
-
156
- left = columns.profile[1].min
157
- right = columns.width - columns.flip(:horizontal).profile[1].min
158
-
159
- top = rows.profile[0].min
160
- bottom = rows.height - rows.flip(:vertical).profile[0].min
161
-
162
- return nil if right < left || bottom < top
163
-
164
- Region.from_edge_coordinates(left, top, right, bottom)
165
- end
166
- end
167
- end
168
- end
169
- end
170
- end
171
- end
3
+ # Forwarder (ADR-004 v2 step 4): VipsDriver lives in SnapDiff::Drivers now;
4
+ # the module alias in capybara/screenshot/diff/drivers.rb makes it reachable
5
+ # as Capybara::Screenshot::Diff::Drivers::VipsDriver.
6
+ require "capybara/screenshot/diff/drivers"
7
+ require "snap_diff/drivers/vips_driver"
@@ -1,16 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Capybara
4
- module Screenshot
5
- module Diff
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
15
- end
16
- end
3
+ # Forwarder (ADR-004 v2 step 6): Capybara::Screenshot::Diff::Drivers now
4
+ # resolves lazily via snap_diff/legacy_shims' const_missing, with a
5
+ # deprecation warning pointing at SnapDiff::Drivers. The forwarded module is
6
+ # the same object, so Drivers.for and the Drivers::VipsDriver /
7
+ # Drivers::ChunkyPNGDriver constants keep resolving through the old name.
8
+ require "snap_diff/drivers"
9
+ require "snap_diff/legacy_shims"