capybara-screenshot-diff 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- eval File.read("#{__dir__}/common.gemfile")
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
4
5
 
5
- gem 'actionpack', '~>4.2.7'
6
+ gem "actionpack", "~>4.2.7"
7
+ gem "bigdecimal", "<2", platform: :mri
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- eval File.read("#{__dir__}/common.gemfile")
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
4
5
 
5
- gem 'actionpack', '~>5.0.1'
6
+ gem "actionpack", "~>5.0.1"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- eval File.read("#{__dir__}/common.gemfile")
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
4
5
 
5
- gem 'actionpack', '~>5.1.2'
6
+ gem "actionpack", "~>5.1.2"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- eval File.read("#{__dir__}/common.gemfile")
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
4
5
 
5
- gem 'actionpack', '~>5.2.1'
6
+ gem "actionpack", "~>5.2.1"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
5
+
6
+ gem "actionpack", "~> 6.0.1", "< 6.1"
7
+ gem "capybara", ">= 2.15"
8
+ gem "selenium-webdriver"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ gems = "#{File.dirname __dir__}/gems.rb"
4
+ eval File.read(gems), binding, gems
5
+
6
+ gem "actionpack", "~> 6.1.0", "< 6.2"
7
+ gem "capybara", ">= 3.26"
data/gems.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in capybara-screenshot-diff.gemspec
6
+ gemspec path: __dir__
7
+
8
+ gem "rake"
9
+
10
+ # Image processing libraries
11
+ gem "oily_png", platform: :ruby
12
+ gem "ruby-vips", require: false
13
+
14
+ # Test
15
+ gem "minitest", require: false
16
+ gem "minitest-stub-const", require: false
17
+ gem "simplecov", require: false
18
+
19
+ # Capybara Server
20
+ gem "puma", require: false
21
+
22
+ # Capybara Drivers
23
+ gem "cuprite", require: false
24
+ gem "selenium-webdriver", require: false
25
+ gem "webdrivers", require: false
26
+
27
+ group :tools do
28
+ gem "standard", require: false
29
+ end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'capybara/dsl'
4
- require 'capybara/screenshot/diff/version'
5
- require 'capybara/screenshot/diff/image_compare'
6
- require 'capybara/screenshot/diff/test_methods'
3
+ require "capybara/dsl"
4
+ require "capybara/screenshot/diff/version"
5
+ require "capybara/screenshot/diff/drivers/utils"
6
+ require "capybara/screenshot/diff/image_compare"
7
+ require "capybara/screenshot/diff/test_methods"
7
8
 
8
9
  module Capybara
9
10
  module Screenshot
@@ -13,12 +14,17 @@ module Screenshot
13
14
  mattr_accessor :blur_active_element
14
15
  mattr_accessor :enabled
15
16
  mattr_accessor :hide_caret
16
- mattr_accessor(:root) { (defined?(Rails.root) && Rails.root) || File.expand_path('.') }
17
+ mattr_reader(:root) { (defined?(Rails.root) && Rails.root) || Pathname(".").expand_path }
17
18
  mattr_accessor :stability_time_limit
18
19
  mattr_accessor :window_size
19
- mattr_accessor(:save_path) { 'doc/screenshots' }
20
+ mattr_accessor(:save_path) { "doc/screenshots" }
21
+ mattr_accessor(:use_lfs)
20
22
 
21
23
  class << self
24
+ def root=(path)
25
+ @@root = Pathname(path).expand_path
26
+ end
27
+
22
28
  def active?
23
29
  enabled || (enabled.nil? && Diff.enabled)
24
30
  end
@@ -31,7 +37,7 @@ def screenshot_area
31
37
  end
32
38
 
33
39
  def screenshot_area_abs
34
- "#{root}/#{screenshot_area}"
40
+ root / screenshot_area
35
41
  end
36
42
  end
37
43
 
@@ -45,20 +51,24 @@ module Diff
45
51
  mattr_accessor(:enabled) { true }
46
52
  mattr_accessor :shift_distance_limit
47
53
  mattr_accessor :skip_area
54
+ mattr_accessor(:driver) { :auto }
55
+ mattr_accessor(:tolerance) { 0.001 }
56
+
57
+ AVAILABLE_DRIVERS = Utils.detect_available_drivers.freeze
48
58
 
49
- def self.included(clas)
50
- clas.include TestMethods
51
- clas.setup do
59
+ def self.included(klass)
60
+ klass.include TestMethods
61
+ klass.setup do
52
62
  if Capybara::Screenshot.window_size
53
- if selenium?
54
- page.driver.browser.manage.window.resize_to(*Capybara::Screenshot.window_size)
55
- elsif poltergeist?
63
+ if page.driver.respond_to?(:resize)
56
64
  page.driver.resize(*Capybara::Screenshot.window_size)
65
+ elsif selenium?
66
+ page.driver.browser.manage.window.resize_to(*Capybara::Screenshot.window_size)
57
67
  end
58
68
  end
59
69
  end
60
70
 
61
- clas.teardown do
71
+ klass.teardown do
62
72
  if Capybara::Screenshot::Diff.enabled && @test_screenshots
63
73
  test_screenshot_errors = @test_screenshots
64
74
  .map { |caller, name, compare| assert_image_not_changed(caller, name, compare) }
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chunky_png"
4
+
5
+ module Capybara
6
+ module Screenshot
7
+ module Diff
8
+ # Compare two images and determine if they are equal, different, or within some comparison
9
+ # range considering color values and difference area size.
10
+ module Drivers
11
+ class ChunkyPNGDriver
12
+ include ChunkyPNG::Color
13
+
14
+ attr_reader :new_file_name, :old_file_name
15
+
16
+ def initialize(new_file_name, old_file_name = nil, **options)
17
+ @new_file_name = new_file_name
18
+ @old_file_name = old_file_name || "#{new_file_name}~"
19
+
20
+ @color_distance_limit = options[:color_distance_limit]
21
+ @shift_distance_limit = options[:shift_distance_limit]
22
+ @skip_area = options[:skip_area]
23
+
24
+ reset
25
+ end
26
+
27
+ # Resets the calculated data about the comparison with regard to the "new_image".
28
+ # Data about the original image is kept.
29
+ def reset
30
+ @max_color_distance = @color_distance_limit ? 0 : nil
31
+ @max_shift_distance = @shift_distance_limit ? 0 : nil
32
+ end
33
+
34
+ def load_images(old_file_name, new_file_name)
35
+ old_bytes, new_bytes = load_image_files(old_file_name, new_file_name)
36
+
37
+ _load_images(old_bytes, new_bytes)
38
+ end
39
+
40
+ def filter_image_with_median(_image)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def add_black_box(image, _region)
45
+ image
46
+ end
47
+
48
+ def difference_level(_diff_mask, old_img, region)
49
+ size(region).to_f / image_area_size(old_img)
50
+ end
51
+
52
+ def image_area_size(old_img)
53
+ width_for(old_img) * height_for(old_img)
54
+ end
55
+
56
+ def shift_distance_equal?
57
+ # Stub
58
+ false
59
+ end
60
+
61
+ def shift_distance_different?
62
+ # Stub
63
+ true
64
+ end
65
+
66
+ def find_difference_region(new_image, old_image, color_distance_limit, shift_distance_limit, area_size_limit, fast_fail: false)
67
+ return nil, nil if new_image.pixels == old_image.pixels
68
+
69
+ if fast_fail && !(color_distance_limit || shift_distance_limit || area_size_limit)
70
+ return [0, 0, width_for(new_image), height_for(new_image)], nil
71
+ end
72
+
73
+ region = find_top(old_image, new_image)
74
+ region = if region.nil? || region[1].nil?
75
+ nil
76
+ else
77
+ find_diff_rectangle(old_image, new_image, region)
78
+ end
79
+
80
+ [region, nil]
81
+ end
82
+
83
+ def height_for(image)
84
+ image.height
85
+ end
86
+
87
+ def width_for(image)
88
+ image.width
89
+ end
90
+
91
+ def size(region)
92
+ return 0 unless region
93
+
94
+ (region[2] - region[0] + 1) * (region[3] - region[1] + 1)
95
+ end
96
+
97
+ def max_color_distance
98
+ calculate_metrics unless @max_color_distance
99
+ @max_color_distance
100
+ end
101
+
102
+ def max_shift_distance
103
+ calculate_metrics unless @max_shift_distance || !@shift_distance_limit
104
+ @max_shift_distance
105
+ end
106
+
107
+ def adds_error_details_to(log)
108
+ max_color_distance = self.max_color_distance.ceil(1)
109
+ max_shift_distance = self.max_shift_distance
110
+
111
+ log[:max_color_distance] = max_color_distance
112
+ log.merge!(max_shift_distance: max_shift_distance) if max_shift_distance
113
+ end
114
+
115
+ def crop(dimensions, i)
116
+ i.crop(0, 0, *dimensions)
117
+ end
118
+
119
+ def from_file(filename)
120
+ ChunkyPNG::Image.from_file(filename)
121
+ end
122
+
123
+ # private
124
+
125
+ def calculate_metrics
126
+ old_file, new_file = load_image_files(@old_file_name, @new_file_name)
127
+
128
+ if old_file == new_file
129
+ @max_color_distance = 0
130
+ @max_shift_distance = 0
131
+ return
132
+ end
133
+
134
+ old_image, new_image = _load_images(old_file, new_file)
135
+ calculate_max_color_distance(new_image, old_image)
136
+ calculate_max_shift_limit(new_image, old_image)
137
+ end
138
+
139
+ def calculate_max_color_distance(new_image, old_image)
140
+ pixel_pairs = old_image.pixels.zip(new_image.pixels)
141
+ @max_color_distance = pixel_pairs.inject(0) { |max, (p1, p2)|
142
+ next max unless p1 && p2
143
+
144
+ d = ChunkyPNG::Color.euclidean_distance_rgba(p1, p2)
145
+ [max, d].max
146
+ }
147
+ end
148
+
149
+ def calculate_max_shift_limit(new_img, old_img)
150
+ (0...new_img.width).each do |x|
151
+ (0...new_img.height).each do |y|
152
+ shift_distance =
153
+ shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
154
+ if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
155
+ @max_shift_distance = shift_distance
156
+ return if @max_shift_distance == Float::INFINITY # rubocop: disable Lint/NonLocalExitFromIterator
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ def save_image_to(image, filename)
163
+ image.save(filename)
164
+ end
165
+
166
+ def resize_image_to(image, new_width, new_height)
167
+ image.resample_bilinear(new_width, new_height)
168
+ end
169
+
170
+ def load_image_files(old_file_name, file_name)
171
+ old_file = File.binread(old_file_name)
172
+ new_file = File.binread(file_name)
173
+ [old_file, new_file]
174
+ end
175
+
176
+ def dimension_changed?(old_image, new_image)
177
+ return unless old_image.dimension != new_image.dimension
178
+
179
+ change_msg = [old_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(" => ")
180
+ warn "Image size has changed for #{@new_file_name}: #{change_msg}"
181
+ true
182
+ end
183
+
184
+ def draw_rectangles(images, (left, top, right, bottom), (r, g, b))
185
+ images.map do |image|
186
+ new_img = image.dup
187
+ new_img.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(r, g, b))
188
+ new_img
189
+ end
190
+ end
191
+
192
+ private
193
+
194
+ def find_diff_rectangle(org_img, new_img, region)
195
+ left, top, right, bottom = find_left_right_and_top(org_img, new_img, region)
196
+ bottom = find_bottom(org_img, new_img, left, right, bottom)
197
+ [left, top, right, bottom]
198
+ end
199
+
200
+ def find_top(old_img, new_img)
201
+ old_img.height.times do |y|
202
+ old_img.width.times do |x|
203
+ return [x, y, x, y] unless same_color?(old_img, new_img, x, y)
204
+ end
205
+ end
206
+ nil
207
+ end
208
+
209
+ def find_left_right_and_top(old_img, new_img, region)
210
+ left = region[0] || old_img.width - 1
211
+ top = region[1]
212
+ bottom = region[2]
213
+ right = region[3] || 0
214
+ old_img.height.times do |y|
215
+ (0...left).find do |x|
216
+ next if same_color?(old_img, new_img, x, y)
217
+
218
+ top ||= y
219
+ bottom = y
220
+ left = x
221
+ right = x if x > right
222
+ x
223
+ end
224
+ (old_img.width - 1).step(right + 1, -1).find do |x|
225
+ unless same_color?(old_img, new_img, x, y)
226
+ bottom = y
227
+ right = x
228
+ end
229
+ end
230
+ end
231
+ [left, top, right, bottom]
232
+ end
233
+
234
+ def find_bottom(old_img, new_img, left, right, bottom)
235
+ if bottom
236
+ (old_img.height - 1).step(bottom + 1, -1).find do |y|
237
+ (left..right).find do |x|
238
+ bottom = y unless same_color?(old_img, new_img, x, y)
239
+ end
240
+ end
241
+ end
242
+ bottom
243
+ end
244
+
245
+ def same_color?(old_img, new_img, x, y)
246
+ @skip_area&.each do |skip_start_x, skip_start_y, skip_end_x, skip_end_y|
247
+ return true if skip_start_x <= x && x <= skip_end_x && skip_start_y <= y && y <= skip_end_y
248
+ end
249
+
250
+ color_distance =
251
+ color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
252
+ if !@max_color_distance || color_distance > @max_color_distance
253
+ @max_color_distance = color_distance
254
+ end
255
+ color_matches = color_distance == 0 || (@color_distance_limit && @color_distance_limit > 0 &&
256
+ color_distance <= @color_distance_limit)
257
+ return color_matches if !@shift_distance_limit || @max_shift_distance == Float::INFINITY
258
+
259
+ shift_distance = (color_matches && 0) ||
260
+ shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
261
+ if shift_distance && (@max_shift_distance.nil? || shift_distance > @max_shift_distance)
262
+ @max_shift_distance = shift_distance
263
+ end
264
+ color_matches
265
+ end
266
+
267
+ def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
268
+ org_color = old_img[x, y]
269
+ if shift_distance_limit
270
+ start_x = [0, x - shift_distance_limit].max
271
+ end_x = [x + shift_distance_limit, new_img.width - 1].min
272
+ xs = (start_x..end_x).to_a
273
+ start_y = [0, y - shift_distance_limit].max
274
+ end_y = [y + shift_distance_limit, new_img.height - 1].min
275
+ ys = (start_y..end_y).to_a
276
+ new_pixels = xs.product(ys)
277
+ distances = new_pixels.map { |dx, dy|
278
+ new_color = new_img[dx, dy]
279
+ ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
280
+ }
281
+ distances.min
282
+ else
283
+ ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
284
+ end
285
+ end
286
+
287
+ def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
288
+ org_color = old_img[x, y]
289
+ shift_distance = 0
290
+ loop do
291
+ bounds_breached = 0
292
+ top_row = y - shift_distance
293
+ if top_row >= 0 # top
294
+ ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
295
+ if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
296
+ return shift_distance
297
+ end
298
+ end
299
+ else
300
+ bounds_breached += 1
301
+ end
302
+ if shift_distance > 0
303
+ if (x - shift_distance) >= 0 # left
304
+ ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
305
+ .each do |dy|
306
+ if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
307
+ return shift_distance
308
+ end
309
+ end
310
+ else
311
+ bounds_breached += 1
312
+ end
313
+ if (y + shift_distance) < new_img.height # bottom
314
+ ([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
315
+ if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
316
+ return shift_distance
317
+ end
318
+ end
319
+ else
320
+ bounds_breached += 1
321
+ end
322
+ if (x + shift_distance) < new_img.width # right
323
+ ([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
324
+ .each do |dy|
325
+ if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
326
+ return shift_distance
327
+ end
328
+ end
329
+ else
330
+ bounds_breached += 1
331
+ end
332
+ end
333
+ break if bounds_breached == 4
334
+
335
+ shift_distance += 1
336
+ end
337
+ Float::INFINITY
338
+ end
339
+
340
+ def color_matches(new_img, org_color, x, y, color_distance_limit)
341
+ new_color = new_img[x, y]
342
+ return new_color == org_color unless color_distance_limit
343
+
344
+ color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
345
+ color_distance <= color_distance_limit
346
+ end
347
+
348
+ def _load_images(old_file, new_file)
349
+ [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end