capybara-screenshot-diff 1.6.2 → 1.8.3

Sign up to get free protection for your applications and to get access to all the features.
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 +25 -0
  11. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +65 -100
  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 -210
  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 +38 -35
  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,210 +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
- Dir.mktmpdir do |dir|
114
- resized_image_file = "#{dir}/resized.png"
115
- driver.save_image_to(resized_image, resized_image_file)
116
- FileUtils.mv(resized_image_file, file_name)
117
- end
118
- end
119
-
120
- def stabilization_images(base_file)
121
- Dir["#{base_file.chomp(".png")}_x*.png#{ImageCompare::TMP_FILE_SUFFIX}"].sort
122
- end
123
-
124
- def clean_stabilization_images(base_file)
125
- FileUtils.rm stabilization_images(base_file)
126
- end
127
-
128
- def prepare_page_for_screenshot(timeout:)
129
- assert_images_loaded(timeout: timeout)
130
- if Capybara::Screenshot.blur_active_element
131
- active_element = execute_script(<<-JS)
132
- ae = document.activeElement;
133
- if (ae.nodeName === "INPUT" || ae.nodeName === "TEXTAREA") {
134
- ae.blur();
135
- return ae;
136
- }
137
- return null;
138
- JS
139
- blurred_input = page.driver.send :unwrap_script_result, active_element
140
- end
141
- execute_script(HIDE_CARET_SCRIPT) if Capybara::Screenshot.hide_caret
142
- blurred_input
143
- end
144
-
145
- def take_right_size_screenshot(comparison, crop:)
146
- save_screenshot(comparison.new_file_name)
147
-
148
- # TODO(uwe): Remove when chromedriver takes right size screenshots
149
- reduce_retina_image_size(comparison.new_file_name, comparison.driver)
150
- # ODOT
151
-
152
- if crop
153
- full_img = comparison.driver.from_file(comparison.new_file_name)
154
- area_img = full_img.crop(crop[0], crop[1], crop[2] - crop[0], crop[3] - crop[1])
155
- comparison.driver.save_image_to(area_img, comparison.new_file_name)
156
- end
157
- end
158
-
159
- def check_max_wait_time(comparison, screenshot_started_at, max_wait_time:)
160
- return if (Time.now - screenshot_started_at) < max_wait_time
161
-
162
- annotate_stabilization_images(comparison)
163
- # FIXME(uwe): Change to store the failure and only report if the test succeeds functionally.
164
- fail("Could not get stable screenshot within #{max_wait_time}s\n" \
165
- "#{stabilization_images(comparison.new_file_name).join("\n")}")
166
- end
167
-
168
- def annotate_stabilization_images(comparison)
169
- previous_file = comparison.old_file_name
170
- stabilization_images(comparison.new_file_name).each do |file_name|
171
- if File.exist? previous_file
172
- stabilization_comparison = make_stabilization_comparison_from(
173
- comparison,
174
- file_name,
175
- previous_file
176
- )
177
- if stabilization_comparison.different?
178
- FileUtils.mv stabilization_comparison.annotated_new_file_name, file_name
179
- end
180
- FileUtils.rm stabilization_comparison.annotated_old_file_name
181
- end
182
- previous_file = file_name
183
- end
184
- end
185
-
186
- def max_wait_time(shift_distance_limit, wait)
187
- shift_factor = shift_distance_limit ? (shift_distance_limit * 2 + 1) ^ 2 : 1
188
- wait * shift_factor
189
- end
190
-
191
- def assert_images_loaded(timeout:)
192
- return unless respond_to? :evaluate_script
193
-
194
- start = Time.now
195
- loop do
196
- pending_image = evaluate_script IMAGE_WAIT_SCRIPT
197
- break unless pending_image
198
-
199
- assert(
200
- (Time.now - start) < timeout,
201
- "Images not loaded after #{timeout}s: #{pending_image.inspect}"
202
- )
203
-
204
- sleep 0.1
205
- end
206
- end
207
- end
208
- end
209
- end
210
- 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