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.
- checksums.yaml +4 -4
- data/.gitattributes +4 -0
- data/.github/workflows/test.yml +129 -0
- data/.gitignore +1 -1
- data/.standard.yml +11 -0
- data/Dockerfile +60 -0
- data/README.md +86 -2
- data/Rakefile +10 -8
- data/bin/console +3 -3
- data/bin/install-vips +11 -0
- data/bin/standardrb +29 -0
- data/capybara-screenshot-diff.gemspec +18 -26
- data/gemfiles/rails42.gemfile +4 -2
- data/gemfiles/rails50.gemfile +3 -2
- data/gemfiles/rails51.gemfile +3 -2
- data/gemfiles/rails52.gemfile +3 -2
- data/gemfiles/rails60_gems.rb +8 -0
- data/gemfiles/rails61_gems.rb +7 -0
- data/gems.rb +29 -0
- data/lib/capybara/screenshot/diff.rb +24 -14
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +355 -0
- data/lib/capybara/screenshot/diff/drivers/utils.rb +24 -0
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +180 -0
- data/lib/capybara/screenshot/diff/image_compare.rb +142 -293
- data/lib/capybara/screenshot/diff/os.rb +7 -7
- data/lib/capybara/screenshot/diff/stabilization.rb +78 -46
- data/lib/capybara/screenshot/diff/test_methods.rb +46 -55
- data/lib/capybara/screenshot/diff/vcs.rb +8 -3
- data/lib/capybara/screenshot/diff/version.rb +1 -1
- data/matrix_test.rb +20 -22
- metadata +20 -111
- data/.rubocop.yml +0 -65
- data/.rubocop_todo.yml +0 -52
- data/.travis.yml +0 -30
- data/Gemfile +0 -6
- data/gemfiles/common.gemfile +0 -12
- data/gemfiles/rails60.gemfile +0 -5
data/gemfiles/rails42.gemfile
CHANGED
data/gemfiles/rails50.gemfile
CHANGED
data/gemfiles/rails51.gemfile
CHANGED
data/gemfiles/rails52.gemfile
CHANGED
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
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
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
|
-
|
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) {
|
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
|
-
|
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(
|
50
|
-
|
51
|
-
|
59
|
+
def self.included(klass)
|
60
|
+
klass.include TestMethods
|
61
|
+
klass.setup do
|
52
62
|
if Capybara::Screenshot.window_size
|
53
|
-
if
|
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
|
-
|
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
|