capybara-screenshot-diff 1.6.3 → 1.8.3

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +29 -0
  3. data/capybara-screenshot-diff.gemspec +6 -3
  4. data/gems.rb +8 -2
  5. data/lib/capybara/screenshot/diff/browser_helpers.rb +102 -0
  6. data/lib/capybara/screenshot/diff/cucumber.rb +11 -0
  7. data/lib/capybara/screenshot/diff/difference.rb +63 -0
  8. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +42 -0
  9. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +193 -252
  10. data/lib/capybara/screenshot/diff/drivers/utils.rb +18 -0
  11. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +61 -102
  12. data/lib/capybara/screenshot/diff/drivers.rb +16 -0
  13. data/lib/capybara/screenshot/diff/image_compare.rb +138 -154
  14. data/lib/capybara/screenshot/diff/os.rb +1 -1
  15. data/lib/capybara/screenshot/diff/region.rb +86 -0
  16. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +128 -0
  17. data/lib/capybara/screenshot/diff/screenshoter.rb +136 -0
  18. data/lib/capybara/screenshot/diff/stabilization.rb +0 -208
  19. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +106 -0
  20. data/lib/capybara/screenshot/diff/test_methods.rb +57 -63
  21. data/lib/capybara/screenshot/diff/vcs.rb +48 -21
  22. data/lib/capybara/screenshot/diff/version.rb +1 -1
  23. data/lib/capybara/screenshot/diff.rb +15 -19
  24. data/sig/capybara/screenshot/diff/diff.rbs +28 -0
  25. data/sig/capybara/screenshot/diff/difference.rbs +33 -0
  26. data/sig/capybara/screenshot/diff/drivers/base_driver.rbs +63 -0
  27. data/sig/capybara/screenshot/diff/drivers/browser_helpers.rbs +36 -0
  28. data/sig/capybara/screenshot/diff/drivers/chunky_png_driver.rbs +89 -0
  29. data/sig/capybara/screenshot/diff/drivers/utils.rbs +13 -0
  30. data/sig/capybara/screenshot/diff/drivers/vips_driver.rbs +25 -0
  31. data/sig/capybara/screenshot/diff/image_compare.rbs +93 -0
  32. data/sig/capybara/screenshot/diff/os.rbs +11 -0
  33. data/sig/capybara/screenshot/diff/region.rbs +43 -0
  34. data/sig/capybara/screenshot/diff/screenshot_matcher.rbs +60 -0
  35. data/sig/capybara/screenshot/diff/screenshoter.rbs +48 -0
  36. data/sig/capybara/screenshot/diff/stable_screenshoter.rbs +29 -0
  37. data/sig/capybara/screenshot/diff/test_methods.rbs +39 -0
  38. data/sig/capybara/screenshot/diff/vcs.rbs +17 -0
  39. metadata +30 -25
  40. data/.gitattributes +0 -4
  41. data/.github/workflows/lint.yml +0 -25
  42. data/.github/workflows/test.yml +0 -120
  43. data/.gitignore +0 -12
  44. data/.standard.yml +0 -12
  45. data/CONTRIBUTING.md +0 -22
  46. data/Dockerfile +0 -60
  47. data/README.md +0 -555
  48. data/bin/bundle +0 -114
  49. data/bin/console +0 -15
  50. data/bin/install-vips +0 -11
  51. data/bin/rake +0 -27
  52. data/bin/setup +0 -8
  53. data/bin/standardrb +0 -29
  54. data/gemfiles/rails52.gemfile +0 -6
  55. data/gemfiles/rails60_gems.rb +0 -8
  56. data/gemfiles/rails61_gems.rb +0 -7
  57. data/gemfiles/rails70_gems.rb +0 -7
  58. data/tmp/.keep +0 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Region
4
+ attr_accessor :x, :y, :width, :height
5
+
6
+ def initialize(x, y, width, height)
7
+ @x, @y, @width, @height = x, y, width, height
8
+ end
9
+
10
+ def self.from_top_left_corner_coordinates(x, y, width, height)
11
+ return nil unless x && y && width && height
12
+ return nil if width < 0 || height < 0
13
+
14
+ Region.new(x, y, width, height)
15
+ end
16
+
17
+ def self.from_edge_coordinates(left, top, right, bottom)
18
+ return nil unless left && top && right && bottom
19
+ return nil if right < left || bottom < top
20
+
21
+ Region.new(left, top, right - left, bottom - top)
22
+ end
23
+
24
+ def to_edge_coordinates
25
+ [left, top, right, bottom]
26
+ end
27
+
28
+ def to_top_left_corner_coordinates
29
+ [x, y, width, height]
30
+ end
31
+
32
+ def top
33
+ y
34
+ end
35
+
36
+ def bottom
37
+ y + height
38
+ end
39
+
40
+ def left
41
+ x
42
+ end
43
+
44
+ def right
45
+ x + width
46
+ end
47
+
48
+ def size
49
+ return 0 if width < 0 || height < 0
50
+
51
+ result = width * height
52
+ result.zero? ? 1 : result
53
+ end
54
+
55
+ def to_a
56
+ [@x, @y, @width, @height]
57
+ end
58
+
59
+ def find_intersect_with(region)
60
+ return nil unless intersect?(region)
61
+
62
+ new_left = [x, region.x].max
63
+ new_top = [y, region.y].max
64
+
65
+ Region.new(new_left, new_top, [right, region.right].min - new_left, [bottom, region.bottom].min - new_top)
66
+ end
67
+
68
+ def intersect?(region)
69
+ left <= region.right && right >= region.left && top <= region.bottom && bottom >= region.top
70
+ end
71
+
72
+ def move_by(right_by, down_by)
73
+ Region.new(x + right_by, y + down_by, width, height)
74
+ end
75
+
76
+ def find_relative_intersect(region)
77
+ intersect = find_intersect_with(region)
78
+ return nil unless intersect
79
+
80
+ intersect.move_by(-x, -y)
81
+ end
82
+
83
+ def cover?(x, y)
84
+ left <= x && x <= right && top <= y && y <= bottom
85
+ end
86
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screenshoter"
4
+ require_relative "stable_screenshoter"
5
+ require_relative "browser_helpers"
6
+ require_relative "vcs"
7
+
8
+ module Capybara
9
+ module Screenshot
10
+ module Diff
11
+ class ScreenshotMatcher
12
+ attr_reader :screenshot_full_name, :driver_options, :screenshot_path, :base_screenshot_path
13
+
14
+ def initialize(screenshot_full_name, options = {})
15
+ @screenshot_full_name = screenshot_full_name
16
+ @driver_options = Diff.default_options.merge(options)
17
+
18
+ @screenshot_path = Screenshot.screenshot_area_abs / Pathname.new(screenshot_full_name).sub_ext(".png")
19
+ @base_screenshot_path = ScreenshotMatcher.base_image_path_from(@screenshot_path)
20
+ end
21
+
22
+ def build_screenshot_matches_job
23
+ # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
24
+ return if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
25
+
26
+ # Stability Screenshoter Options
27
+
28
+ # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
29
+ crop = calculate_crop_region(driver_options)
30
+
31
+ # Allow nil or single or multiple areas
32
+ # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
33
+ if driver_options[:skip_area]
34
+ # Cast skip area args to Region and makes relative to crop
35
+ driver_options[:skip_area] = calculate_skip_area(driver_options[:skip_area], crop)
36
+ end
37
+ driver_options[:driver] = Drivers.for(driver_options)
38
+
39
+ create_output_directory_for(screenshot_path) unless screenshot_path.exist?
40
+
41
+ checkout_base_screenshot
42
+
43
+ capture_options = {
44
+ crop: crop,
45
+ stability_time_limit: driver_options.delete(:stability_time_limit),
46
+ wait: driver_options.delete(:wait)
47
+ }
48
+
49
+ take_comparison_screenshot(capture_options, driver_options, screenshot_path)
50
+
51
+ return unless base_screenshot_path.exist?
52
+
53
+ # Add comparison job in the queue
54
+ [
55
+ screenshot_full_name,
56
+ ImageCompare.new(screenshot_path.to_s, base_screenshot_path.to_s, driver_options)
57
+ ]
58
+ end
59
+
60
+ def cleanup
61
+ FileUtils.rm_f(base_screenshot_path)
62
+ end
63
+
64
+ def self.base_image_path_from(screenshot_path)
65
+ screenshot_path.sub_ext(".base.png")
66
+ end
67
+
68
+ private
69
+
70
+ def checkout_base_screenshot
71
+ Vcs.checkout_vcs(screenshot_path, base_screenshot_path)
72
+ end
73
+
74
+ def calculate_crop_region(driver_options)
75
+ crop_coordinates = driver_options.delete(:crop)
76
+ return nil unless crop_coordinates
77
+
78
+ crop_coordinates = BrowserHelpers.bounds_for_css(crop_coordinates).first if crop_coordinates.is_a?(String)
79
+ Region.from_edge_coordinates(*crop_coordinates)
80
+ end
81
+
82
+ def create_output_directory_for(screenshot_path)
83
+ screenshot_path.dirname.mkpath
84
+ end
85
+
86
+ # Try to get screenshot from browser.
87
+ # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
88
+ # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
89
+ def take_comparison_screenshot(capture_options, driver_options, screenshot_path)
90
+ screenshoter = build_screenshoter_for(capture_options, driver_options)
91
+ screenshoter.take_comparison_screenshot(screenshot_path)
92
+ end
93
+
94
+ def build_screenshoter_for(capture_options, comparison_options = {})
95
+ if capture_options[:stability_time_limit]
96
+ StableScreenshoter.new(capture_options, comparison_options)
97
+ else
98
+ Diff.screenshoter.new(capture_options, comparison_options[:driver])
99
+ end
100
+ end
101
+
102
+ # Cast skip areas params into Region
103
+ # and if there is crop then makes absolute coordinates to eb relative to crop top left corner
104
+ def calculate_skip_area(skip_area, crop)
105
+ crop_region = crop && Region.new(*crop)
106
+ skip_area = Array(skip_area)
107
+
108
+ css_selectors, regions = skip_area.compact.partition { |region| region.is_a? String }
109
+
110
+ result = []
111
+ unless css_selectors.empty?
112
+ result.concat(build_regions_for(BrowserHelpers.bounds_for_css(*css_selectors)))
113
+ end
114
+ result.concat(build_regions_for(regions.flatten.each_slice(4))) unless regions.empty?
115
+ result.compact!
116
+
117
+ result.map! { |region| crop_region.find_relative_intersect(region) } if crop_region
118
+
119
+ result
120
+ end
121
+
122
+ def build_regions_for(coordinates)
123
+ coordinates.map { |coordinates_entity| Region.from_edge_coordinates(*coordinates_entity) }
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "os"
4
+ require_relative "browser_helpers"
5
+
6
+ module Capybara
7
+ module Screenshot
8
+ class Screenshoter
9
+ attr_reader :capture_options, :comparison_options, :driver
10
+
11
+ def initialize(capture_options, driver)
12
+ @capture_options = capture_options
13
+ @comparison_options = comparison_options
14
+ @driver = driver
15
+ end
16
+
17
+ def crop
18
+ @capture_options[:crop]
19
+ end
20
+
21
+ def wait
22
+ @capture_options[:wait]
23
+ end
24
+
25
+ def self.attempts_screenshot_paths(base_file)
26
+ Dir["#{base_file.to_s.chomp(".png")}.attempt_*.png"].sort
27
+ end
28
+
29
+ def self.cleanup_attempts_screenshots(base_file)
30
+ FileUtils.rm_rf attempts_screenshot_paths(base_file)
31
+ end
32
+
33
+ # Try to get screenshot from browser.
34
+ # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
35
+ # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
36
+ def take_comparison_screenshot(screenshot_path)
37
+ new_screenshot_path = Screenshoter.gen_next_attempt_path(screenshot_path, 0)
38
+
39
+ take_screenshot(new_screenshot_path)
40
+
41
+ FileUtils.mv(new_screenshot_path, screenshot_path, force: true)
42
+ Screenshoter.cleanup_attempts_screenshots(screenshot_path)
43
+ end
44
+
45
+ def self.gen_next_attempt_path(screenshot_path, iteration)
46
+ Pathname.new(screenshot_path).sub_ext(format(".attempt_%02i.png", iteration))
47
+ end
48
+
49
+ def take_screenshot(screenshot_path)
50
+ blurred_input = prepare_page_for_screenshot(timeout: wait)
51
+
52
+ # Take browser screenshot and save
53
+ browser_save_screenshot(screenshot_path)
54
+
55
+ # Load saved screenshot and pre-process it
56
+ process_screenshot(screenshot_path)
57
+ ensure
58
+ blurred_input&.click
59
+ end
60
+
61
+ def browser_save_screenshot(screenshot_path)
62
+ BrowserHelpers.session.save_screenshot(screenshot_path)
63
+ end
64
+
65
+ def process_screenshot(screenshot_path)
66
+ # TODO(uwe): Remove when chromedriver takes right size screenshots
67
+ # TODO: Adds tests when this case is true
68
+ if selenium_with_retina_screen?
69
+ reduce_retina_image_size(screenshot_path)
70
+ end
71
+ # ODOT
72
+
73
+ if crop
74
+ image = driver.from_file(screenshot_path)
75
+ cropped_image = driver.crop(crop, image)
76
+ driver.save_image_to(cropped_image, screenshot_path)
77
+ end
78
+ end
79
+
80
+ def reduce_retina_image_size(file_name)
81
+ expected_image_width = Screenshot.window_size[0]
82
+ saved_image = driver.from_file(file_name.to_s)
83
+ return if driver.width_for(saved_image) < expected_image_width * 2
84
+
85
+ notice_how_to_avoid_this
86
+
87
+ new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image)
88
+ resized_image = driver.resize_image_to(saved_image, expected_image_width, new_height)
89
+
90
+ driver.save_image_to(resized_image, file_name)
91
+ end
92
+
93
+ def notice_how_to_avoid_this
94
+ unless defined?(@_csd_retina_warned)
95
+ warn "Halving retina screenshot. " \
96
+ 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
97
+ @_csd_retina_warned = true
98
+ end
99
+ end
100
+
101
+ def prepare_page_for_screenshot(timeout:)
102
+ wait_images_loaded(timeout: timeout)
103
+
104
+ blurred_input = if Screenshot.blur_active_element
105
+ BrowserHelpers.blur_from_focused_element
106
+ end
107
+
108
+ if Screenshot.hide_caret
109
+ BrowserHelpers.hide_caret
110
+ end
111
+
112
+ blurred_input
113
+ end
114
+
115
+ def wait_images_loaded(timeout:)
116
+ start = Time.now
117
+ loop do
118
+ pending_image = BrowserHelpers.pending_image_to_load
119
+ break unless pending_image
120
+
121
+ if (Time.now - start) >= timeout
122
+ raise Capybara::Screenshot::Diff::ASSERTION, "Images not loaded after #{timeout}s: #{pending_image.inspect}"
123
+ end
124
+
125
+ sleep 0.025
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def selenium_with_retina_screen?
132
+ Os::ON_MAC && BrowserHelpers.selenium? && Screenshot.window_size
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1,208 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "os"
4
-
5
- module Capybara
6
- module Screenshot
7
- module Diff
8
- module Stabilization
9
- include Os
10
-
11
- IMAGE_WAIT_SCRIPT = <<-JS.strip_heredoc.freeze
12
- function pending_image() {
13
- var images = document.images;
14
- for (var i = 0; i < images.length; i++) {
15
- if (!images[i].complete) {
16
- return images[i].src;
17
- }
18
- }
19
- return false;
20
- }()
21
- JS
22
-
23
- HIDE_CARET_SCRIPT = <<~JS
24
- if (!document.getElementById('csdHideCaretStyle')) {
25
- let style = document.createElement('style');
26
- style.setAttribute('id', 'csdHideCaretStyle');
27
- document.head.appendChild(style);
28
- let styleSheet = style.sheet;
29
- styleSheet.insertRule("* { caret-color: transparent !important; }", 0);
30
- }
31
- JS
32
-
33
- def take_stable_screenshot(comparison, stability_time_limit:, wait:, crop:)
34
- previous_file_name = comparison.old_file_name
35
- screenshot_started_at = last_image_change_at = Time.now
36
- clean_stabilization_images(comparison.new_file_name)
37
-
38
- 1.step do |i|
39
- take_right_size_screenshot(comparison, crop: crop)
40
- if comparison.quick_equal?
41
- clean_stabilization_images(comparison.new_file_name)
42
- break
43
- end
44
- comparison.reset
45
-
46
- if previous_file_name
47
- stabilization_comparison = make_stabilization_comparison_from(
48
- comparison,
49
- comparison.new_file_name,
50
- previous_file_name
51
- )
52
- if stabilization_comparison.quick_equal?
53
- if (Time.now - last_image_change_at) > stability_time_limit
54
- clean_stabilization_images(comparison.new_file_name)
55
- break
56
- end
57
- next
58
- else
59
- last_image_change_at = Time.now
60
- end
61
- end
62
-
63
- previous_file_name = build_snapshot_version_file_name(
64
- comparison,
65
- i,
66
- screenshot_started_at,
67
- stabilization_comparison
68
- )
69
-
70
- FileUtils.mv(comparison.new_file_name, previous_file_name)
71
-
72
- check_max_wait_time(
73
- comparison,
74
- screenshot_started_at,
75
- max_wait_time: max_wait_time(comparison.shift_distance_limit, wait)
76
- )
77
- end
78
- end
79
-
80
- def notice_how_to_avoid_this
81
- unless @_csd_retina_warned
82
- warn "Halving retina screenshot. " \
83
- 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
84
- @_csd_retina_warned = true
85
- end
86
- end
87
-
88
- private
89
-
90
- def build_snapshot_version_file_name(comparison, iteration, screenshot_started_at, stabilization_comparison)
91
- "#{comparison.new_file_name.chomp(".png")}" \
92
- "_x#{format("%02i", iteration)}_#{(Time.now - screenshot_started_at).round(1)}s" \
93
- "_#{stabilization_comparison.difference_region&.to_s&.gsub(", ", "_") || :initial}.png" \
94
- "#{ImageCompare::TMP_FILE_SUFFIX}"
95
- end
96
-
97
- def make_stabilization_comparison_from(comparison, new_file_name, previous_file_name)
98
- ImageCompare.new(new_file_name, previous_file_name, comparison.driver_options)
99
- end
100
-
101
- def reduce_retina_image_size(file_name, driver)
102
- return if !ON_MAC || !selenium? || !Capybara::Screenshot.window_size
103
-
104
- expected_image_width = Capybara::Screenshot.window_size[0]
105
- saved_image = driver.from_file(file_name)
106
- return if driver.width_for(saved_image) < expected_image_width * 2
107
-
108
- notice_how_to_avoid_this
109
-
110
- new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image)
111
- resized_image = driver.resize_image_to(saved_image, expected_image_width, new_height)
112
-
113
- driver.save_image_to(resized_image, file_name)
114
- end
115
-
116
- def stabilization_images(base_file)
117
- Dir["#{base_file.chomp(".png")}_x*.png#{ImageCompare::TMP_FILE_SUFFIX}"].sort
118
- end
119
-
120
- def clean_stabilization_images(base_file)
121
- FileUtils.rm stabilization_images(base_file)
122
- end
123
-
124
- def prepare_page_for_screenshot(timeout:)
125
- assert_images_loaded(timeout: timeout)
126
- if Capybara::Screenshot.blur_active_element
127
- active_element = execute_script(<<-JS)
128
- ae = document.activeElement;
129
- if (ae.nodeName === "INPUT" || ae.nodeName === "TEXTAREA") {
130
- ae.blur();
131
- return ae;
132
- }
133
- return null;
134
- JS
135
- blurred_input = page.driver.send :unwrap_script_result, active_element
136
- end
137
- execute_script(HIDE_CARET_SCRIPT) if Capybara::Screenshot.hide_caret
138
- blurred_input
139
- end
140
-
141
- def take_right_size_screenshot(comparison, crop:)
142
- driver = comparison.driver
143
-
144
- save_screenshot(comparison.new_file_name)
145
-
146
- # TODO(uwe): Remove when chromedriver takes right size screenshots
147
- reduce_retina_image_size(comparison.new_file_name, driver)
148
- # ODOT
149
-
150
- if crop
151
- full_img = driver.from_file(comparison.new_file_name)
152
- area_img = driver.crop([crop[0], crop[1], crop[2] - crop[0], crop[3] - crop[1]], full_img)
153
- driver.save_image_to(area_img, comparison.new_file_name)
154
- end
155
- end
156
-
157
- def check_max_wait_time(comparison, screenshot_started_at, max_wait_time:)
158
- return if (Time.now - screenshot_started_at) < max_wait_time
159
-
160
- annotate_stabilization_images(comparison)
161
- # FIXME(uwe): Change to store the failure and only report if the test succeeds functionally.
162
- fail("Could not get stable screenshot within #{max_wait_time}s\n" \
163
- "#{stabilization_images(comparison.new_file_name).join("\n")}")
164
- end
165
-
166
- def annotate_stabilization_images(comparison)
167
- previous_file = comparison.old_file_name
168
- stabilization_images(comparison.new_file_name).each do |file_name|
169
- if File.exist? previous_file
170
- stabilization_comparison = make_stabilization_comparison_from(
171
- comparison,
172
- file_name,
173
- previous_file
174
- )
175
- if stabilization_comparison.different?
176
- FileUtils.mv stabilization_comparison.annotated_new_file_name, file_name
177
- end
178
- FileUtils.rm stabilization_comparison.annotated_old_file_name
179
- end
180
- previous_file = file_name
181
- end
182
- end
183
-
184
- def max_wait_time(shift_distance_limit, wait)
185
- shift_factor = shift_distance_limit ? (shift_distance_limit * 2 + 1) ^ 2 : 1
186
- wait * shift_factor
187
- end
188
-
189
- def assert_images_loaded(timeout:)
190
- return unless respond_to? :evaluate_script
191
-
192
- start = Time.now
193
- loop do
194
- pending_image = evaluate_script IMAGE_WAIT_SCRIPT
195
- break unless pending_image
196
-
197
- assert(
198
- (Time.now - start) < timeout,
199
- "Images not loaded after #{timeout}s: #{pending_image.inspect}"
200
- )
201
-
202
- sleep 0.1
203
- end
204
- end
205
- end
206
- end
207
- end
208
- end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Screenshot
5
+ module Diff
6
+ class StableScreenshoter
7
+ STABILITY_OPTIONS = [:stability_time_limit, :wait]
8
+
9
+ attr_reader :stability_time_limit, :wait
10
+
11
+ def initialize(capture_options, comparison_options = nil)
12
+ @stability_time_limit, @wait = capture_options.fetch_values(:stability_time_limit, :wait)
13
+ @comparison_options = comparison_options || Diff.default_options
14
+ @screenshoter = Diff.screenshoter.new(capture_options.except(*STABILITY_OPTIONS), @comparison_options[:driver])
15
+ end
16
+
17
+ # Try to get screenshot from browser.
18
+ # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
19
+ # On reaching `wait` limit then it has been failed. On failing we annotate screenshot attempts to help to debug
20
+ def take_comparison_screenshot(screenshot_path)
21
+ new_screenshot_path = take_stable_screenshot(screenshot_path)
22
+
23
+ # We failed to get stable browser state! Generate difference between attempts to overview moving parts!
24
+ unless new_screenshot_path
25
+ # FIXME(uwe): Change to store the failure and only report if the test succeeds functionally.
26
+ annotate_attempts_and_fail!(screenshot_path)
27
+ end
28
+
29
+ FileUtils.mv(new_screenshot_path, screenshot_path, force: true)
30
+ Screenshoter.cleanup_attempts_screenshots(screenshot_path)
31
+ end
32
+
33
+ def take_stable_screenshot(screenshot_path)
34
+ # We try to compare first attempt with checkout version, in order to not run next screenshots
35
+ attempt_path = nil
36
+ screenshot_started_at = last_attempt_at = Time.now
37
+
38
+ # Cleanup all previous attempts for sure
39
+ Screenshoter.cleanup_attempts_screenshots(screenshot_path)
40
+
41
+ 0.step do |i|
42
+ # Prevents redundant screenshots generations
43
+ sleep(stability_time_limit) unless i == 0
44
+
45
+ elapsed_time = last_attempt_at - screenshot_started_at
46
+
47
+ prev_attempt_path = attempt_path
48
+ attempt_path = Screenshoter.gen_next_attempt_path(screenshot_path, i)
49
+
50
+ @screenshoter.take_screenshot(attempt_path)
51
+ last_attempt_at = Time.now
52
+
53
+ next unless prev_attempt_path
54
+ stabilization_comparator = build_comparison_for(attempt_path, prev_attempt_path)
55
+
56
+ # If previous screenshot is equal to the current, then we are good
57
+ return attempt_path if prev_attempt_path && stabilization_comparator.quick_equal?
58
+
59
+ # If timeout then we failed to generate valid screenshot
60
+ return nil if timeout?(elapsed_time)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def build_comparison_for(attempt_path, previous_attempt_path)
67
+ ImageCompare.new(attempt_path, previous_attempt_path, @comparison_options)
68
+ end
69
+
70
+ def annotate_attempts_and_fail!(screenshot_path)
71
+ screenshot_attempts = Screenshoter.attempts_screenshot_paths(screenshot_path)
72
+
73
+ annotate_stabilization_images(screenshot_attempts)
74
+
75
+ # TODO: Move fail to the queue after tests passed
76
+ fail("Could not get stable screenshot within #{wait}s:\n#{screenshot_attempts.join("\n")}")
77
+ end
78
+
79
+ # TODO: Add tests that we annotate all files except first one
80
+ def annotate_stabilization_images(attempts_screenshot_paths)
81
+ previous_file = nil
82
+ attempts_screenshot_paths.reverse_each do |file_name|
83
+ if previous_file && File.exist?(previous_file)
84
+ attempts_comparison = build_comparison_for(file_name, previous_file)
85
+
86
+ if attempts_comparison.different?
87
+ FileUtils.mv(attempts_comparison.annotated_base_image_path, previous_file, force: true)
88
+ else
89
+ warn "[capybara-screenshot-diff] Some attempts was stable, but mistakenly marked as not: " \
90
+ "#{previous_file} and #{file_name} are equal"
91
+ end
92
+
93
+ FileUtils.rm(attempts_comparison.annotated_image_path, force: true)
94
+ end
95
+
96
+ previous_file = file_name
97
+ end
98
+ end
99
+
100
+ def timeout?(elapsed_time)
101
+ elapsed_time > wait
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end