capybara-screenshot-diff 0.5.3 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4918e7dba3e123055b2c5aae345fce7dd8a1219d
4
- data.tar.gz: 662f6f68099b888e068a62ec18454ddbcdf2adf6
3
+ metadata.gz: 3417ac0f41265b198a6807b0ae2ea3fe24d4fcae
4
+ data.tar.gz: efcc110c396d89cf8ed51e982c57cc1a6c3e9b4b
5
5
  SHA512:
6
- metadata.gz: 6929b38a90048524ec74c867f93a2b14160c34cae83d325963bfefd0bbd28a134e9f3471360b3649226b33e43414d60da35ccab499d4101fddac08688eed91ab
7
- data.tar.gz: 3638b34d359a68c75eaaa49593396efc227383126259b95ad5af12b6e75103164bd9b7f22485cf97831f83415defcb8f5072923de6cdbfedb8b450836ff692e9
6
+ metadata.gz: 0d1aecff95b1e1eb031397488bec689f4f1410de71a093914fc2601932237abfb8de30dce236ececb641c0b85dae350d012b6881a5e69c01b1f0eafea300fae9
7
+ data.tar.gz: 19bb7deb896fe5de11c94ca2d1c4309dba6a774603b859050ff1a6c31a3cc07c5b5e1cdac4355b35b154cea3db5bbf108cd3572e6b32a75845104b4935922d89
data/.rubocop.yml CHANGED
@@ -18,9 +18,6 @@ Layout/MultilineOperationIndentation:
18
18
  Lint/Debugger:
19
19
  Enabled: false
20
20
 
21
- Metrics/ClassLength:
22
- Max: 101
23
-
24
21
  Metrics/LineLength:
25
22
  Max: 110
26
23
 
data/.rubocop_todo.yml CHANGED
@@ -1,28 +1,36 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2017-06-08 11:42:01 +0200 using RuboCop version 0.49.1.
3
+ # on 2017-07-06 16:03:55 +0200 using RuboCop version 0.49.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
7
7
  # versions of RuboCop, may require this file to be generated again.
8
8
 
9
- # Offense count: 4
9
+ # Offense count: 1
10
+ # Cop supports --auto-correct.
11
+ # Configuration parameters: EnforcedStyle, SupportedStyles.
12
+ # SupportedStyles: auto_detection, squiggly, active_support, powerpack, unindent
13
+ Layout/IndentHeredoc:
14
+ Exclude:
15
+ - 'lib/capybara/screenshot/diff/capybara_setup.rb'
16
+
17
+ # Offense count: 7
10
18
  Metrics/AbcSize:
11
- Max: 31
19
+ Max: 26
20
+
21
+ # Offense count: 1
22
+ # Configuration parameters: CountComments.
23
+ Metrics/ClassLength:
24
+ Max: 186
12
25
 
13
26
  # Offense count: 2
14
27
  Metrics/CyclomaticComplexity:
15
28
  Max: 8
16
29
 
17
- # Offense count: 7
30
+ # Offense count: 8
18
31
  # Configuration parameters: CountComments.
19
32
  Metrics/MethodLength:
20
- Max: 25
21
-
22
- # Offense count: 1
23
- # Configuration parameters: CountKeywordArgs.
24
- Metrics/ParameterLists:
25
- Max: 6
33
+ Max: 24
26
34
 
27
35
  # Offense count: 1
28
36
  Metrics/PerceivedComplexity:
data/README.md CHANGED
@@ -101,6 +101,7 @@ that test will get the same prefix:
101
101
  setup do
102
102
  screenshot_section 'my_feature'
103
103
  end
104
+
104
105
  test 'my subfeature' do
105
106
  screenshot_group 'subfeature'
106
107
  visit '/feature'
@@ -241,6 +242,26 @@ Capybara::Screenshot.stability_time_limit = 0.5
241
242
  ```
242
243
 
243
244
 
245
+
246
+ ### Allowed color distance
247
+
248
+ Sometimes you want to allow small differences in the images. You can set set the difference
249
+ threshold for the comparison using the `color_distance_limit` option to the `screenshot` method:
250
+
251
+ ```ruby
252
+ test 'color threshold' do
253
+ visit '/'
254
+ screenshot 'index', color_distance_limit: 30
255
+ end
256
+ ```
257
+
258
+ The difference is calculated as the eucledian distance. You can also set this globally:
259
+
260
+ ```ruby
261
+ Capybara::Screenshot::Diff.color_distance_limit = 42
262
+ ```
263
+
264
+
244
265
  ## Development
245
266
 
246
267
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -17,8 +17,8 @@ module Capybara
17
17
 
18
18
  # Module to track screen shot changes
19
19
  module Diff
20
- mattr_accessor :enabled
21
- self.enabled = true
20
+ mattr_accessor :color_distance_limit
21
+ mattr_accessor(:enabled) { true }
22
22
  end
23
23
  end
24
24
  end
@@ -97,8 +97,8 @@ module ActionDispatch
97
97
 
98
98
  teardown do
99
99
  if Capybara::Screenshot::Diff.enabled && @test_screenshots
100
- test_screenshot_errors =
101
- @test_screenshots.map { |args| assert_image_not_changed(*args) }.compact
100
+ test_screenshot_errors = @test_screenshots
101
+ .map { |caller, name, compare| assert_image_not_changed(caller, name, compare) }.compact
102
102
  fail(test_screenshot_errors.join("\n\n")) if test_screenshot_errors.any?
103
103
  end
104
104
  end
@@ -114,7 +114,8 @@ module ActionDispatch
114
114
  FileUtils.rm_rf screenshot_dir
115
115
  end
116
116
 
117
- def screenshot(name)
117
+ def screenshot(name, color_distance_limit: Capybara::Screenshot::Diff.color_distance_limit,
118
+ area_size_limit: nil)
118
119
  return unless Capybara::Screenshot.active?
119
120
  return if window_size_is_wrong?
120
121
  if @screenshot_counter
@@ -123,16 +124,15 @@ module ActionDispatch
123
124
  end
124
125
  name = full_name(name)
125
126
  file_name = "#{self.class.screenshot_area_abs}/#{name}.png"
126
- org_name = "#{self.class.screenshot_area_abs}/#{name}_0.png~"
127
- new_name = "#{self.class.screenshot_area_abs}/#{name}_1.png~"
128
127
 
129
128
  FileUtils.mkdir_p File.dirname(file_name)
130
- committed_file_name = check_vcs(name, file_name, org_name)
131
- previous_file_exists = committed_file_name && File.exist?(committed_file_name)
132
- previous_size = File.size(committed_file_name) if previous_file_exists
133
- take_stable_screenshot(file_name, previous_size)
134
- return unless previous_file_exists
135
- (@test_screenshots ||= []) << [caller[0], name, file_name, committed_file_name, new_name, org_name]
129
+ committed_file_name = check_vcs(name, file_name)
130
+ comparison = Capybara::Screenshot::Diff::ImageCompare.new(committed_file_name, file_name,
131
+ dimensions: Capybara::Screenshot.window_size, color_distance_limit: color_distance_limit,
132
+ area_size_limit: area_size_limit)
133
+ take_stable_screenshot(comparison)
134
+ return unless comparison.old_file_exists?
135
+ (@test_screenshots ||= []) << [caller[0], name, comparison]
136
136
  end
137
137
 
138
138
  private def window_size_is_wrong?
@@ -142,7 +142,7 @@ module ActionDispatch
142
142
  Selenium::WebDriver::Dimension.new(*Capybara::Screenshot.window_size)
143
143
  end
144
144
 
145
- def check_vcs(name, file_name, org_name)
145
+ private def check_vcs(name, file_name)
146
146
  svn_file_name = "#{self.class.screenshot_area_abs}/.svn/text-base/#{name}.png.svn-base"
147
147
  if File.exist?(svn_file_name)
148
148
  committed_file_name = svn_file_name
@@ -155,17 +155,21 @@ module ActionDispatch
155
155
  committed_file_name = "#{wc_root}/.svn/pristine/#{checksum[0..1]}/#{checksum}.svn-base"
156
156
  end
157
157
  else
158
- committed_file_name = org_name
159
- redirect_target = "#{committed_file_name} #{SILENCE_ERRORS}"
160
- `git show HEAD~0:./#{self.class.screenshot_area}/#{name}.png > #{redirect_target}`
161
- if File.size(committed_file_name) == 0
162
- FileUtils.rm_f committed_file_name
163
- end
158
+ committed_file_name = restore_git_revision(name,
159
+ Capybara::Screenshot::Diff::ImageCompare.annotated_old_file_name(file_name))
164
160
  end
165
161
  end
166
162
  committed_file_name
167
163
  end
168
164
 
165
+ private def restore_git_revision(name, org_name)
166
+ committed_file_name = org_name
167
+ redirect_target = "#{committed_file_name} #{SILENCE_ERRORS}"
168
+ `git show HEAD~0:./#{self.class.screenshot_area}/#{name}.png > #{redirect_target}`
169
+ FileUtils.rm_f(committed_file_name) if File.size(committed_file_name) == 0
170
+ committed_file_name
171
+ end
172
+
169
173
  IMAGE_WAIT_SCRIPT = <<EOF.freeze
170
174
  function pending_image() {
171
175
  var images = document.images;
@@ -190,28 +194,33 @@ EOF
190
194
  end
191
195
  end
192
196
 
193
- def take_stable_screenshot(file_name, original_file_size = nil)
197
+ private def take_stable_screenshot(comparison)
194
198
  assert_images_loaded
195
- old_file_size = original_file_size
199
+ previous_file_size = comparison.old_file_size
196
200
  screeenshot_started_at = last_image_change_at = Time.now
197
201
  loop do
198
- save_screenshot(file_name)
202
+ save_screenshot(comparison.new_file_name)
199
203
 
200
204
  # TODO(uwe): Remove when chromedriver take right size screenshots
201
- reduce_retina_image_size(file_name)
205
+ reduce_retina_image_size(comparison.new_file_name)
202
206
  # EMXIF
203
207
 
204
208
  break unless Capybara::Screenshot.stability_time_limit
205
- new_file_size = File.size(file_name)
206
- break if new_file_size == original_file_size
207
- break if new_file_size == old_file_size &&
208
- (Time.now - last_image_change_at) > Capybara::Screenshot.stability_time_limit
209
- last_image_change_at = Time.now if new_file_size != old_file_size
210
- old_file_size = new_file_size
211
- sleep 0.1
209
+ break if comparison.quick_equal?
210
+
211
+ if comparison.new_file_size == previous_file_size
212
+ if (Time.now - last_image_change_at) > Capybara::Screenshot.stability_time_limit
213
+ break
214
+ end
215
+ else
216
+ last_image_change_at = Time.now
217
+ end
212
218
 
213
219
  assert (Time.now - screeenshot_started_at) < Capybara.default_max_wait_time,
214
220
  "Could not get stable screenshot within #{Capybara.default_max_wait_time}s"
221
+
222
+ previous_file_size = comparison.new_file_size
223
+ comparison.reset
215
224
  end
216
225
  end
217
226
 
@@ -225,11 +234,13 @@ EOF
225
234
  resized_image.save(file_name)
226
235
  end
227
236
 
228
- def assert_image_not_changed(caller, name, file_name, committed_file_name, new_name, org_name)
229
- if Capybara::Screenshot::Diff::ImageCompare.compare(committed_file_name, file_name,
230
- Capybara::Screenshot.window_size)
231
- "Screenshot does not match for '#{name}'\n#{file_name}\n#{org_name}\n#{new_name}\nat #{caller}"
232
- end
237
+ def assert_image_not_changed(caller, name, comparison)
238
+ return unless comparison.different?
239
+ "Screenshot does not match for '#{name}' (area: #{comparison.size} #{comparison.dimensions}" \
240
+ ", max_color_distance: #{comparison.max_color_distance.round(1)})\n" \
241
+ "#{comparison.new_file_name}\n#{comparison.annotated_old_file_name}\n" \
242
+ "#{comparison.annotated_new_file_name}\n" \
243
+ "at #{caller}"
233
244
  end
234
245
  end
235
246
  end
@@ -6,53 +6,130 @@ module Capybara
6
6
  class ImageCompare
7
7
  include ChunkyPNG::Color
8
8
 
9
+ attr_reader :annotated_new_file_name, :annotated_old_file_name, :new_file_name
10
+
9
11
  def self.compare(*args)
10
- new(*args).compare
12
+ new(*args).different?
13
+ end
14
+
15
+ def self.annotated_old_file_name(new_file_name)
16
+ "#{new_file_name.chomp('.png')}_0.png~"
11
17
  end
12
18
 
13
- def initialize(old_file_name, new_file_name, dimensions = nil)
19
+ def initialize(old_file_name, new_file_name, dimensions: nil, color_distance_limit: nil,
20
+ area_size_limit: nil)
14
21
  @old_file_name = old_file_name
15
- @file_name = new_file_name
22
+ @new_file_name = new_file_name
23
+ @color_distance_limit = color_distance_limit
24
+ @area_size_limit = area_size_limit
16
25
  @dimensions = dimensions
26
+ @annotated_old_file_name = self.class.annotated_old_file_name(new_file_name)
27
+ @annotated_new_file_name = "#{new_file_name.chomp('.png')}_1.png~"
28
+ reset
17
29
  end
18
30
 
19
- def compare
20
- name = @file_name.chomp('.png')
21
- org_file_name = "#{name}_0.png~"
22
- new_file_name = "#{name}_1.png~"
31
+ def reset
32
+ @max_color_distance = 0 if @color_distance_limit
33
+ @left = @top = @right = @bottom = nil
34
+ end
35
+
36
+ # Compare the two image files and return `true` or `false` as quickly as possible.
37
+ # Return falsish if the old file does not exist or the image dimensions do not match.
38
+ def quick_equal?
39
+ return nil unless old_file_exists?
40
+ return true if new_file_size == old_file_size
41
+ old_file, new_file = load_image_files(@old_file_name, @new_file_name)
42
+ return true if old_file == new_file
43
+ images = load_images(old_file, new_file)
44
+ crop_images(images, @dimensions) if @dimensions
45
+
46
+ old_img = images.first
47
+ new_img = images.last
48
+
49
+ return false if sizes_changed?(old_img, new_img)
50
+
51
+ return true if old_img.pixels == new_img.pixels
52
+
53
+ @left, @top, @right, @bottom = find_top(old_img, new_img)
54
+
55
+ return true if @top.nil?
56
+
57
+ false
58
+ end
23
59
 
24
- return nil unless File.exist? @old_file_name
60
+ # Compare the two images referenced by this object, and return `true` if they are different,
61
+ # and `false` if they are the same.
62
+ # Return `nil` if the old file does not exist or if the image dimensions do not match.
63
+ def different?
64
+ return nil unless old_file_exists?
25
65
 
26
- images = load_images(@old_file_name, @file_name)
66
+ old_file, new_file = load_image_files(@old_file_name, @new_file_name)
27
67
 
28
- unless images
29
- clean_tmp_files(new_file_name, org_file_name)
68
+ if old_file == new_file
69
+ clean_tmp_files(@annotated_old_file_name, @annotated_new_file_name)
30
70
  return false
31
71
  end
32
72
 
73
+ images = load_images(old_file, new_file)
74
+
33
75
  crop_images(images, @dimensions) if @dimensions
34
- org_img = images.first
76
+
77
+ old_img = images.first
35
78
  new_img = images.last
36
- if sizes_changed?(org_img, new_img, name)
37
- save_images(new_file_name, new_img, org_file_name, org_img)
79
+
80
+ if sizes_changed?(old_img, new_img)
81
+ save_images(@annotated_new_file_name, new_img, @annotated_old_file_name, old_img)
38
82
  return true
39
83
  end
40
84
 
41
- if org_img.pixels == new_img.pixels
42
- clean_tmp_files(new_file_name, org_file_name)
85
+ if old_img.pixels == new_img.pixels
86
+ clean_tmp_files(@annotated_new_file_name, @annotated_old_file_name)
43
87
  return false
44
88
  end
45
89
 
46
- @left, @top, @right, @bottom = find_diff_rectangle(org_img, new_img)
47
- draw_rectangles(images, @bottom, @left, @right, @top)
48
- save_images(new_file_name, new_img, org_file_name, org_img)
90
+ @left, @top, @right, @bottom = find_diff_rectangle(old_img, new_img)
91
+
92
+ return false if @top.nil?
93
+ annotated_old_img, annotated_new_img = draw_rectangles(images, @bottom, @left, @right, @top)
94
+ save_images(@annotated_new_file_name, annotated_new_img,
95
+ @annotated_old_file_name, annotated_old_img)
49
96
  true
50
97
  end
51
98
 
99
+ def old_file_exists?
100
+ @old_file_name && File.exist?(@old_file_name)
101
+ end
102
+
103
+ def old_file_size
104
+ @_old_filesize ||= old_file_exists? && File.size(@old_file_name)
105
+ end
106
+
107
+ def new_file_size
108
+ File.size(@new_file_name)
109
+ end
110
+
52
111
  def dimensions
53
112
  [@left, @top, @right, @bottom]
54
113
  end
55
114
 
115
+ def size
116
+ (@right - @left + 1) * (@bottom - @top + 1)
117
+ end
118
+
119
+ def max_color_distance
120
+ return @max_color_distance if @max_color_distance
121
+ old_file, new_file = load_image_files(@old_file_name, @new_file_name)
122
+ return @max_color_distance = 0 if old_file == new_file
123
+
124
+ old_image, new_image = load_images(old_file, new_file)
125
+
126
+ pixel_pairs = old_image.pixels.zip(new_image.pixels)
127
+ @max_color_distance = pixel_pairs.inject(0) do |max, (p1, p2)|
128
+ d = ChunkyPNG::Color.euclidean_distance_rgba(p1, p2)
129
+ [max, d].max
130
+ end
131
+ end
132
+
56
133
  private
57
134
 
58
135
  def save_images(new_file_name, new_img, org_file_name, org_img)
@@ -60,28 +137,29 @@ module Capybara
60
137
  new_img.save(new_file_name)
61
138
  end
62
139
 
63
- def clean_tmp_files(new_file_name, org_file_name)
64
- File.delete(org_file_name) if File.exist?(org_file_name)
140
+ def clean_tmp_files(old_file_name, new_file_name)
141
+ File.delete(old_file_name) if File.exist?(old_file_name)
65
142
  File.delete(new_file_name) if File.exist?(new_file_name)
66
143
  end
67
144
 
68
- def load_images(old_file_name, file_name)
145
+ def load_images(old_file, new_file)
146
+ [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
147
+ end
148
+
149
+ def load_image_files(old_file_name, file_name)
69
150
  old_file = File.binread(old_file_name)
70
151
  new_file = File.binread(file_name)
71
-
72
- return false if old_file == new_file
73
-
74
- [ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
152
+ [old_file, new_file]
75
153
  end
76
154
 
77
- def sizes_changed?(org_image, new_image, name)
155
+ def sizes_changed?(org_image, new_image)
78
156
  return unless org_image.dimension != new_image.dimension
79
157
  change_msg = [org_image, new_image].map { |i| "#{i.width}x#{i.height}" }.join(' => ')
80
- puts "Image size has changed for #{name}: #{change_msg}"
158
+ puts "Image size has changed for #{@new_file_name}: #{change_msg}"
81
159
  true
82
160
  end
83
161
 
84
- def crop_images(images, dimensions)
162
+ private def crop_images(images, dimensions)
85
163
  images.map! do |i|
86
164
  if i.dimension.to_a == dimensions || i.width < dimensions[0] || i.height < dimensions[1]
87
165
  i
@@ -91,38 +169,73 @@ module Capybara
91
169
  end
92
170
  end
93
171
 
94
- def draw_rectangles(images, bottom, left, right, top)
95
- images.each do |image|
96
- image.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(255, 0, 0))
172
+ private def draw_rectangles(images, bottom, left, right, top)
173
+ images.map do |image|
174
+ new_img = image.dup
175
+ new_img.rect(left - 1, top - 1, right + 1, bottom + 1, ChunkyPNG::Color.rgb(255, 0, 0))
176
+ new_img
177
+ end
178
+ end
179
+
180
+ private def find_diff_rectangle(org_img, new_img)
181
+ left, top, right, bottom = find_left_right_and_top(org_img, new_img)
182
+ bottom = find_bottom(org_img, new_img, left, right, bottom)
183
+ [left, top, right, bottom]
184
+ end
185
+
186
+ private def find_top(old_img, new_img)
187
+ old_img.height.times do |y|
188
+ old_img.width.times do |x|
189
+ return [x, y, x, y] unless same_color?(old_img, new_img, x, y)
190
+ end
97
191
  end
98
192
  end
99
193
 
100
- def find_diff_rectangle(org_img, new_img)
101
- top = bottom = nil
102
- left = org_img.width - 1
103
- right = 0
104
- org_img.height.times do |y|
194
+ private def find_left_right_and_top(old_img, new_img)
195
+ top = @top
196
+ bottom = @bottom
197
+ left = @left || old_img.width - 1
198
+ right = @right || 0
199
+ old_img.height.times do |y|
105
200
  (0...left).find do |x|
106
- next if org_img[x, y] == new_img[x, y]
201
+ next if same_color?(old_img, new_img, x, y)
107
202
  top ||= y
108
203
  bottom = y
109
204
  left = x
110
205
  right = x if x > right
111
206
  x
112
207
  end
113
- (org_img.width - 1).step(right + 1, -1).find do |x|
114
- if org_img[x, y] != new_img[x, y]
208
+ (old_img.width - 1).step(right + 1, -1).find do |x|
209
+ unless same_color?(old_img, new_img, x, y)
115
210
  bottom = y
116
211
  right = x
117
212
  end
118
213
  end
119
214
  end
120
- (org_img.height - 1).step(bottom + 1, -1).find do |y|
121
- (left..right).find do |x|
122
- bottom = y if org_img[x, y] != new_img[x, y]
215
+ [left, top, right, bottom]
216
+ end
217
+
218
+ private def find_bottom(old_img, new_img, left, right, bottom)
219
+ if bottom
220
+ (old_img.height - 1).step(bottom + 1, -1).find do |y|
221
+ (left..right).find do |x|
222
+ bottom = y unless same_color?(old_img, new_img, x, y)
223
+ end
123
224
  end
124
225
  end
125
- [left, top, right, bottom]
226
+ bottom
227
+ end
228
+
229
+ private def same_color?(old_img, new_img, x, y)
230
+ org_color = old_img[x, y]
231
+ new_color = new_img[x, y]
232
+ if @color_distance_limit && @color_distance_limit > 0
233
+ distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
234
+ @max_color_distance = distance if distance > @max_color_distance
235
+ distance <= @color_distance_limit
236
+ else
237
+ org_color == new_color
238
+ end
126
239
  end
127
240
  end
128
241
  end
@@ -3,7 +3,7 @@
3
3
  module Capybara
4
4
  module Screenshot
5
5
  module Diff
6
- VERSION = '0.5.3'.freeze
6
+ VERSION = '0.6.0'.freeze
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-screenshot-diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Uwe Kubosch
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-06-27 00:00:00.000000000 Z
11
+ date: 2017-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack