capybara-screenshot-diff 1.2.0 → 1.4.1

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.
@@ -1,34 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- lib = File.expand_path('lib', __dir__)
3
+ lib = File.expand_path("lib", __dir__)
4
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
- require 'capybara/screenshot/diff/version'
5
+ require "capybara/screenshot/diff/version"
6
6
 
7
7
  Gem::Specification.new do |spec|
8
- spec.name = 'capybara-screenshot-diff'
9
- spec.version = Capybara::Screenshot::Diff::VERSION
10
- spec.authors = ['Uwe Kubosch']
11
- spec.email = ['uwe@kubosch.no']
12
- spec.summary = 'Track your GUI changes with diff assertions'
13
- spec.description = 'Save screen shots and track changes with graphical diff'
14
- spec.homepage = 'https://github.com/donv/capybara-screenshot-diff'
15
- spec.required_ruby_version = '~> 2.3'
16
- spec.license = 'MIT'
17
- spec.metadata['allowed_push_host'] = 'https://rubygems.org/'
8
+ spec.name = "capybara-screenshot-diff"
9
+ spec.version = Capybara::Screenshot::Diff::VERSION
10
+ spec.authors = ["Uwe Kubosch"]
11
+ spec.email = ["uwe@kubosch.no"]
12
+ spec.summary = "Track your GUI changes with diff assertions"
13
+ spec.description = "Save screen shots and track changes with graphical diff"
14
+ spec.homepage = "https://github.com/donv/capybara-screenshot-diff"
15
+ spec.required_ruby_version = "~> 2.5"
16
+ spec.license = "MIT"
17
+ spec.metadata["allowed_push_host"] = "https://rubygems.org/"
18
18
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
- spec.bindir = 'exe'
20
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
- spec.require_paths = ['lib']
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
22
 
23
- spec.add_runtime_dependency 'actionpack', '>= 4.2', '< 7'
24
- spec.add_runtime_dependency 'capybara', '>= 2', '< 4'
25
- spec.add_runtime_dependency 'chunky_png', '~> 1.3'
26
-
27
- spec.add_development_dependency 'bundler', '~> 1.11'
28
- spec.add_development_dependency 'minitest', '~> 5.0'
29
- spec.add_development_dependency 'minitest-reporters'
30
- spec.add_development_dependency 'rake'
31
- spec.add_development_dependency 'rubocop', '~> 0.54'
32
- spec.add_development_dependency 'rubocop-performance', '~> 0.0'
33
- spec.add_development_dependency 'simplecov', '~> 0.11'
23
+ spec.add_runtime_dependency "actionpack", ">= 4.2", "< 7"
24
+ spec.add_runtime_dependency "capybara", ">= 2", "< 4"
25
+ spec.add_runtime_dependency "chunky_png", "~> 1.3"
34
26
  end
@@ -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