capybara-screenshot-diff 0.5.3 → 0.6.0

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.
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