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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/docs/RELEASE_PREP.md +6 -6
  4. data/docs/thread_safety.md +15 -0
  5. data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
  6. data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
  7. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
  8. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  9. data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
  10. data/lib/capybara/screenshot/diff/difference.rb +6 -102
  11. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
  12. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
  13. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
  14. data/lib/capybara/screenshot/diff/drivers.rb +7 -14
  15. data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
  16. data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
  17. data/lib/capybara/screenshot/diff/os.rb +8 -13
  18. data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
  19. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
  20. data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
  21. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
  22. data/lib/capybara/screenshot/diff/utils.rb +5 -40
  23. data/lib/capybara/screenshot/diff/vcs.rb +5 -37
  24. data/lib/capybara/screenshot/diff/version.rb +7 -1
  25. data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
  26. data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
  27. data/lib/capybara_screenshot_diff/dsl.rb +7 -129
  28. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
  29. data/lib/capybara_screenshot_diff/minitest.rb +6 -56
  30. data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
  31. data/lib/capybara_screenshot_diff/rspec.rb +5 -63
  32. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
  33. data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
  34. data/lib/capybara_screenshot_diff/snap.rb +5 -64
  35. data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
  36. data/lib/capybara_screenshot_diff/static.rb +3 -5
  37. data/lib/capybara_screenshot_diff.rb +29 -100
  38. data/lib/snap_diff/annotation_service.rb +84 -0
  39. data/lib/snap_diff/area_calculator.rb +56 -0
  40. data/lib/snap_diff/attempts_reporter.rb +51 -0
  41. data/lib/snap_diff/browser_helpers.rb +121 -0
  42. data/lib/snap_diff/capture/viewport.rb +40 -0
  43. data/lib/snap_diff/comparison.rb +238 -0
  44. data/lib/snap_diff/comparison_result.rb +104 -0
  45. data/lib/snap_diff/config.rb +6 -2
  46. data/lib/snap_diff/deprecation.rb +84 -0
  47. data/lib/snap_diff/driver.rb +37 -0
  48. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  49. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  50. data/lib/snap_diff/drivers.rb +14 -0
  51. data/lib/snap_diff/dsl.rb +143 -0
  52. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  53. data/lib/snap_diff/image_preprocessor.rb +68 -0
  54. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  55. data/lib/snap_diff/integrations/minitest.rb +70 -0
  56. data/lib/snap_diff/integrations/rspec.rb +68 -0
  57. data/lib/snap_diff/legacy_shims.rb +99 -0
  58. data/lib/snap_diff/os.rb +17 -0
  59. data/lib/snap_diff/reporters/html.rb +143 -0
  60. data/lib/snap_diff/reporting.rb +53 -0
  61. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  62. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  63. data/lib/snap_diff/screenshot_namer.rb +81 -0
  64. data/lib/snap_diff/screenshoter.rb +129 -0
  65. data/lib/snap_diff/snap.rb +66 -0
  66. data/lib/snap_diff/snap_manager.rb +125 -0
  67. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  68. data/lib/snap_diff/static.rb +11 -0
  69. data/lib/snap_diff/utils.rb +38 -0
  70. data/lib/snap_diff/vcs.rb +35 -0
  71. data/lib/snap_diff/version.rb +5 -0
  72. data/lib/snap_diff.rb +22 -6
  73. metadata +36 -2
  74. /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