capybara-screenshot-diff 1.8.0 → 1.10.2

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -11
  3. data/capybara-screenshot-diff.gemspec +3 -4
  4. data/gems.rb +11 -8
  5. data/lib/capybara/screenshot/diff/area_calculator.rb +56 -0
  6. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -5
  7. data/lib/capybara/screenshot/diff/comparison.rb +6 -0
  8. data/lib/capybara/screenshot/diff/cucumber.rb +1 -9
  9. data/lib/capybara/screenshot/diff/difference.rb +8 -4
  10. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +0 -5
  11. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +10 -5
  12. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +15 -4
  13. data/lib/capybara/screenshot/diff/drivers.rb +1 -1
  14. data/lib/capybara/screenshot/diff/image_compare.rb +84 -114
  15. data/lib/capybara/screenshot/diff/region.rb +28 -7
  16. data/lib/capybara/screenshot/diff/reporters/default.rb +121 -0
  17. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +36 -74
  18. data/lib/capybara/screenshot/diff/screenshoter.rb +47 -56
  19. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +65 -63
  20. data/lib/capybara/screenshot/diff/test_methods.rb +81 -13
  21. data/lib/capybara/screenshot/diff/{drivers/utils.rb → utils.rb} +2 -7
  22. data/lib/capybara/screenshot/diff/vcs.rb +26 -20
  23. data/lib/capybara/screenshot/diff/version.rb +1 -1
  24. data/lib/capybara/screenshot/diff.rb +1 -111
  25. data/lib/capybara-screenshot-diff.rb +1 -1
  26. data/lib/capybara_screenshot_diff/attempts_reporter.rb +49 -0
  27. data/lib/capybara_screenshot_diff/cucumber.rb +12 -0
  28. data/lib/capybara_screenshot_diff/dsl.rb +11 -0
  29. data/lib/capybara_screenshot_diff/minitest.rb +49 -0
  30. data/lib/capybara_screenshot_diff/rspec.rb +32 -0
  31. data/lib/capybara_screenshot_diff/snap.rb +55 -0
  32. data/lib/capybara_screenshot_diff/snap_manager.rb +76 -0
  33. data/lib/capybara_screenshot_diff.rb +86 -0
  34. metadata +20 -48
  35. data/lib/capybara/screenshot/diff/stabilization.rb +0 -0
  36. data/sig/capybara/screenshot/diff/diff.rbs +0 -28
  37. data/sig/capybara/screenshot/diff/difference.rbs +0 -33
  38. data/sig/capybara/screenshot/diff/drivers/base_driver.rbs +0 -63
  39. data/sig/capybara/screenshot/diff/drivers/browser_helpers.rbs +0 -36
  40. data/sig/capybara/screenshot/diff/drivers/chunky_png_driver.rbs +0 -89
  41. data/sig/capybara/screenshot/diff/drivers/utils.rbs +0 -13
  42. data/sig/capybara/screenshot/diff/drivers/vips_driver.rbs +0 -25
  43. data/sig/capybara/screenshot/diff/image_compare.rbs +0 -93
  44. data/sig/capybara/screenshot/diff/os.rbs +0 -11
  45. data/sig/capybara/screenshot/diff/region.rbs +0 -43
  46. data/sig/capybara/screenshot/diff/screenshot_matcher.rbs +0 -60
  47. data/sig/capybara/screenshot/diff/screenshoter.rbs +0 -48
  48. data/sig/capybara/screenshot/diff/stable_screenshoter.rbs +0 -29
  49. data/sig/capybara/screenshot/diff/test_methods.rbs +0 -39
  50. data/sig/capybara/screenshot/diff/vcs.rbs +0 -17
@@ -7,13 +7,6 @@ class Region
7
7
  @x, @y, @width, @height = x, y, width, height
8
8
  end
9
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
10
  def self.from_edge_coordinates(left, top, right, bottom)
18
11
  return nil unless left && top && right && bottom
19
12
  return nil if right < left || bottom < top
@@ -83,4 +76,32 @@ class Region
83
76
  def cover?(x, y)
84
77
  left <= x && x <= right && top <= y && y <= bottom
85
78
  end
79
+
80
+ def empty?
81
+ width.zero? || height.zero?
82
+ end
83
+
84
+ def blank?
85
+ empty?
86
+ end
87
+
88
+ def present?
89
+ !empty?
90
+ end
91
+
92
+ def inspect
93
+ "Region(x: #{x}, y: #{y}, width: #{width}, height: #{height})"
94
+ end
95
+
96
+ # need to add this method to make it work with assert_equal
97
+ def ==(other)
98
+ case other
99
+ when Region
100
+ x == other.x && y == other.y && width == other.width && height == other.height
101
+ when Array
102
+ to_a == other
103
+ else
104
+ false
105
+ end
106
+ end
86
107
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Screenshot::Diff
4
+ module Reporters
5
+ class Default
6
+ attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path, :difference
7
+
8
+ def initialize(difference)
9
+ @difference = difference
10
+
11
+ screenshot_format = difference.comparison.options[:screenshot_format] || comparison.new_image_path.extname.slice(1..-1)
12
+ @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
13
+ @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
14
+ @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
15
+ end
16
+
17
+ def generate
18
+ if difference.equal?
19
+ # NOTE: Delete previous run runtime files
20
+ clean_tmp_files
21
+ return nil
22
+ end
23
+
24
+ if difference.failed? && difference.failed_by[:different_dimensions]
25
+ return build_error_for_different_dimensions
26
+ end
27
+
28
+ annotate_and_save_images
29
+ build_error_message
30
+ end
31
+
32
+ def clean_tmp_files
33
+ annotated_base_image_path.unlink if annotated_base_image_path.exist?
34
+ annotated_image_path.unlink if annotated_image_path.exist?
35
+ end
36
+
37
+ def build_error_for_different_dimensions
38
+ change_msg = [comparison.base_image, comparison.new_image]
39
+ .map { |image| driver.dimension(image).join("x") }
40
+ .join(" => ")
41
+
42
+ "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
43
+ end
44
+
45
+ def annotate_and_save_images
46
+ save_annotation_for(new_image, annotated_image_path)
47
+ save_annotation_for(base_image, annotated_base_image_path)
48
+ save_heatmap_diff if difference.meta[:diff_mask]
49
+ end
50
+
51
+ def save_annotation_for(image, image_path)
52
+ image = annotate_difference(image, difference.region)
53
+ image = annotate_skip_areas(image, difference.skip_area) if difference.skip_area
54
+
55
+ save(image, image_path.to_path)
56
+ end
57
+
58
+ DIFF_COLOR = [255, 0, 0, 255].freeze
59
+
60
+ def annotate_difference(image, region)
61
+ driver.draw_rectangles([image], region, DIFF_COLOR, offset: 1).first
62
+ end
63
+
64
+ SKIP_COLOR = [255, 192, 0, 255].freeze
65
+
66
+ def annotate_skip_areas(image, skip_areas)
67
+ skip_areas.reduce(image) do |memo, region|
68
+ driver.draw_rectangles([memo], region, SKIP_COLOR).first
69
+ end
70
+ end
71
+
72
+ def save(image, image_path)
73
+ driver.save_image_to(image, image_path.to_s)
74
+ end
75
+
76
+ NEW_LINE = "\n"
77
+
78
+ def build_error_message
79
+ [
80
+ "(#{difference.inspect})",
81
+ image_path.to_path,
82
+ annotated_base_image_path.to_path,
83
+ annotated_image_path.to_path
84
+ ].join(NEW_LINE)
85
+ end
86
+
87
+ private
88
+
89
+ def save_heatmap_diff
90
+ merged_image = driver.merge(new_image, base_image)
91
+ highlighted_mask = driver.highlight_mask(difference.meta[:diff_mask], merged_image, color: DIFF_COLOR)
92
+
93
+ save(highlighted_mask, heatmap_diff_path.to_path)
94
+ end
95
+
96
+ def base_image
97
+ difference.comparison.base_image
98
+ end
99
+
100
+ def new_image
101
+ difference.comparison.new_image
102
+ end
103
+
104
+ def base_image_path
105
+ comparison.base_image_path
106
+ end
107
+
108
+ def image_path
109
+ comparison.new_image_path
110
+ end
111
+
112
+ def driver
113
+ @_driver ||= comparison.driver
114
+ end
115
+
116
+ def comparison
117
+ @_comparison ||= difference.comparison
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,94 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "capybara_screenshot_diff/snap_manager"
3
4
  require_relative "screenshoter"
4
5
  require_relative "stable_screenshoter"
5
6
  require_relative "browser_helpers"
6
7
  require_relative "vcs"
8
+ require_relative "area_calculator"
7
9
 
8
10
  module Capybara
9
11
  module Screenshot
10
12
  module Diff
11
13
  class ScreenshotMatcher
12
- attr_reader :screenshot_full_name, :driver_options, :screenshot_path, :base_screenshot_path
14
+ attr_reader :screenshot_full_name, :driver_options, :screenshot_format
13
15
 
14
16
  def initialize(screenshot_full_name, options = {})
15
17
  @screenshot_full_name = screenshot_full_name
16
18
  @driver_options = Diff.default_options.merge(options)
17
19
 
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
+ @screenshot_format = @driver_options[:screenshot_format]
21
+ @snapshot = CapybaraScreenshotDiff::SnapManager.snapshot(screenshot_full_name, @screenshot_format)
20
22
  end
21
23
 
22
24
  def build_screenshot_matches_job
23
25
  # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
24
26
  return if BrowserHelpers.window_size_is_wrong?(Screenshot.window_size)
25
27
 
26
- # Stability Screenshoter Options
27
-
28
28
  # TODO: Move this into screenshot stage, in order to re-evaluate coordinates after page updates
29
- crop = calculate_crop_region(driver_options)
29
+ area_calculator = AreaCalculator.new(driver_options.delete(:crop), driver_options[:skip_area])
30
+ driver_options[:crop] = area_calculator.calculate_crop
30
31
 
31
- # Allow nil or single or multiple areas
32
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)
33
+ # Allow nil or single or multiple areas
34
+ driver_options[:skip_area] = area_calculator.calculate_skip_area
35
+ driver_options[:driver] = Drivers.for(driver_options[:driver])
38
36
 
39
- create_output_directory_for(screenshot_path) unless screenshot_path.exist?
37
+ @snapshot.checkout_base_screenshot
40
38
 
41
- checkout_base_screenshot
39
+ # When fail_if_new is true no need to create screenshot if base screenshot is missing
40
+ return if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
42
41
 
43
- capture_options = {
44
- crop: crop,
45
- stability_time_limit: driver_options.delete(:stability_time_limit),
46
- wait: driver_options.delete(:wait)
47
- }
42
+ capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
48
43
 
49
- take_comparison_screenshot(capture_options, driver_options, screenshot_path)
44
+ # Load new screenshot from Browser
45
+ take_comparison_screenshot(capture_options, comparison_options, @snapshot)
50
46
 
51
- return unless base_screenshot_path.exist?
47
+ # Pre-computation: No need to compare without base screenshot
48
+ return unless @snapshot.base_path.exist?
52
49
 
53
50
  # 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")
51
+ [screenshot_full_name, ImageCompare.new(@snapshot.path, @snapshot.base_path, comparison_options)]
66
52
  end
67
53
 
68
54
  private
69
55
 
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
56
+ def extract_capture_and_comparison_options!(driver_options = {})
57
+ [
58
+ {
59
+ # screenshot options
60
+ capybara_screenshot_options: driver_options[:capybara_screenshot_options],
61
+ crop: driver_options.delete(:crop),
62
+ # delivery options
63
+ screenshot_format: driver_options[:screenshot_format],
64
+ # stability options
65
+ stability_time_limit: driver_options.delete(:stability_time_limit),
66
+ wait: driver_options.delete(:wait)
67
+ },
68
+ driver_options
69
+ ]
84
70
  end
85
71
 
86
72
  # Try to get screenshot from browser.
87
73
  # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
88
74
  # 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)
75
+ def take_comparison_screenshot(capture_options, comparison_options, snapshot = nil)
76
+ screenshoter = build_screenshoter_for(capture_options, comparison_options)
77
+ screenshoter.take_comparison_screenshot(snapshot)
92
78
  end
93
79
 
94
80
  def build_screenshoter_for(capture_options, comparison_options = {})
@@ -98,30 +84,6 @@ module Capybara
98
84
  Diff.screenshoter.new(capture_options, comparison_options[:driver])
99
85
  end
100
86
  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
87
  end
126
88
  end
127
89
  end
@@ -6,11 +6,10 @@ require_relative "browser_helpers"
6
6
  module Capybara
7
7
  module Screenshot
8
8
  class Screenshoter
9
- attr_reader :capture_options, :comparison_options, :driver
9
+ attr_reader :capture_options, :driver
10
10
 
11
11
  def initialize(capture_options, driver)
12
12
  @capture_options = capture_options
13
- @comparison_options = comparison_options
14
13
  @driver = driver
15
14
  end
16
15
 
@@ -22,88 +21,54 @@ module Capybara
22
21
  @capture_options[:wait]
23
22
  end
24
23
 
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)
24
+ def capybara_screenshot_options
25
+ @capture_options[:capybara_screenshot_options] || {}
31
26
  end
32
27
 
33
28
  # Try to get screenshot from browser.
34
29
  # On `stability_time_limit` it checks that page stop updating by comparison several screenshot attempts
35
30
  # 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)
31
+ def take_comparison_screenshot(snapshot)
32
+ capture_screenshot_at(snapshot)
33
+ snapshot.cleanup_attempts
43
34
  end
44
35
 
45
- def self.gen_next_attempt_path(screenshot_path, iteration)
46
- Pathname.new(screenshot_path).sub_ext(format(".attempt_%02i.png", iteration))
47
- end
36
+ PNG_EXTENSION = ".png"
48
37
 
49
38
  def take_screenshot(screenshot_path)
50
39
  blurred_input = prepare_page_for_screenshot(timeout: wait)
51
40
 
52
41
  # Take browser screenshot and save
53
- browser_save_screenshot(screenshot_path)
42
+ save_and_process_screenshot(screenshot_path)
54
43
 
55
- # Load saved screenshot and pre-process it
56
- process_screenshot(screenshot_path)
57
- ensure
58
44
  blurred_input&.click
59
45
  end
60
46
 
61
- def browser_save_screenshot(screenshot_path)
62
- BrowserHelpers.session.save_screenshot(screenshot_path)
63
- end
47
+ def process_screenshot(stored_path, screenshot_path)
48
+ screenshot_image = driver.from_file(stored_path)
64
49
 
65
- def process_screenshot(screenshot_path)
66
50
  # TODO(uwe): Remove when chromedriver takes right size screenshots
67
51
  # TODO: Adds tests when this case is true
68
- if selenium_with_retina_screen?
69
- reduce_retina_image_size(screenshot_path)
70
- end
52
+ screenshot_image = resize_if_needed(screenshot_image) if selenium_with_retina_screen?
71
53
  # ODOT
72
54
 
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)
55
+ screenshot_image = driver.crop(crop, screenshot_image) if crop
89
56
 
90
- driver.save_image_to(resized_image, file_name)
57
+ driver.save_image_to(screenshot_image, screenshot_path)
91
58
  end
92
59
 
93
60
  def notice_how_to_avoid_this
94
61
  unless defined?(@_csd_retina_warned)
95
62
  warn "Halving retina screenshot. " \
96
- 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
63
+ 'You should add "force-device-scale-factor=1" to your Chrome chromeOptions args.'
97
64
  @_csd_retina_warned = true
98
65
  end
99
66
  end
100
67
 
101
68
  def prepare_page_for_screenshot(timeout:)
102
- wait_images_loaded(timeout: timeout)
69
+ wait_images_loaded(timeout: timeout) if timeout
103
70
 
104
- blurred_input = if Screenshot.blur_active_element
105
- BrowserHelpers.blur_from_focused_element
106
- end
71
+ blurred_input = BrowserHelpers.blur_from_focused_element if Screenshot.blur_active_element
107
72
 
108
73
  if Screenshot.hide_caret
109
74
  BrowserHelpers.hide_caret
@@ -113,15 +78,16 @@ module Capybara
113
78
  end
114
79
 
115
80
  def wait_images_loaded(timeout:)
116
- start = Time.now
81
+ return unless timeout
82
+
83
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
117
84
  loop do
118
85
  pending_image = BrowserHelpers.pending_image_to_load
119
86
  break unless pending_image
120
87
 
121
- assert(
122
- (Time.now - start) < timeout,
123
- "Images not loaded after #{timeout}s: #{pending_image.inspect}"
124
- )
88
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
89
+ raise CapybaraScreenshotDiff::ExpectationNotMet, "Images have not been loaded after #{timeout}s: #{pending_image.inspect}"
90
+ end
125
91
 
126
92
  sleep 0.025
127
93
  end
@@ -129,6 +95,31 @@ module Capybara
129
95
 
130
96
  private
131
97
 
98
+ def save_and_process_screenshot(screenshot_path)
99
+ tmpfile = Tempfile.new([screenshot_path.basename.to_s, PNG_EXTENSION])
100
+ BrowserHelpers.session.save_screenshot(tmpfile.path, **capybara_screenshot_options)
101
+ # Load saved screenshot and pre-process it
102
+ process_screenshot(tmpfile.path, screenshot_path)
103
+ ensure
104
+ File.unlink(tmpfile) if tmpfile
105
+ end
106
+
107
+ def capture_screenshot_at(snapshot)
108
+ take_screenshot(snapshot.next_attempt_path!)
109
+
110
+ snapshot.commit_last_attempt
111
+ end
112
+
113
+ def resize_if_needed(saved_image)
114
+ expected_image_width = Screenshot.window_size[0]
115
+ return saved_image if driver.width_for(saved_image) < expected_image_width * 2
116
+
117
+ notice_how_to_avoid_this
118
+
119
+ new_height = expected_image_width * driver.height_for(saved_image) / driver.width_for(saved_image)
120
+ driver.resize_image_to(saved_image, expected_image_width, new_height)
121
+ end
122
+
132
123
  def selenium_with_retina_screen?
133
124
  Os::ON_MAC && BrowserHelpers.selenium? && Screenshot.window_size
134
125
  end
@@ -8,97 +8,99 @@ module Capybara
8
8
 
9
9
  attr_reader :stability_time_limit, :wait
10
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])
11
+ # Initializes a new instance of StableScreenshoter
12
+ #
13
+ # This method sets up a new screenshoter with specific capture and comparison options. It validates the presence of
14
+ # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`.
15
+ #
16
+ # @param capture_options [Hash] The options for capturing screenshots, must include `:stability_time_limit` and `:wait`.
17
+ # @param comparison_options [Hash, nil] The options for comparing screenshots, defaults to `nil` which uses `Diff.default_options`.
18
+ # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`.
19
+ def initialize(capture_options, comparison_options = {})
20
+ @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS)
21
+
22
+ raise ArgumentError, "wait should be provided for stable screenshots" unless wait
23
+ raise ArgumentError, "stability_time_limit should be provided for stable screenshots" unless stability_time_limit
24
+ raise ArgumentError, "stability_time_limit (#{stability_time_limit}) should be less or equal than wait (#{wait}) for stable screenshots" unless stability_time_limit <= wait
25
+
26
+ @comparison_options = comparison_options
27
+
28
+ driver = Diff::Drivers.for(@comparison_options)
29
+ @screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), driver)
15
30
  end
16
31
 
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)
32
+ # Takes a comparison screenshot ensuring page stability
33
+ #
34
+ # Attempts to take a stable screenshot of the page by comparing several screenshot attempts until the page stops updating
35
+ # or the `:wait` limit is reached. If unable to achieve a stable state within the time limit, it annotates the attempts
36
+ # to aid debugging.
37
+ #
38
+ # @param snapshot Snap The snapshot details to take a stable screenshot of.
39
+ # @return [void]
40
+ # @raise [RuntimeError] If a stable screenshot cannot be obtained within the specified `:wait` time.
41
+ def take_comparison_screenshot(snapshot)
42
+ result = take_stable_screenshot(snapshot)
22
43
 
23
44
  # We failed to get stable browser state! Generate difference between attempts to overview moving parts!
24
- unless new_screenshot_path
45
+ unless result
25
46
  # FIXME(uwe): Change to store the failure and only report if the test succeeds functionally.
26
- annotate_attempts_and_fail!(screenshot_path)
47
+ annotate_attempts_and_fail!(snapshot)
27
48
  end
28
49
 
29
- FileUtils.mv(new_screenshot_path, screenshot_path, force: true)
30
- Screenshoter.cleanup_attempts_screenshots(screenshot_path)
50
+ # store success attempt as actual screenshot
51
+ snapshot.commit_last_attempt
52
+
53
+ # cleanup all previous attempts
54
+ snapshot.cleanup_attempts
31
55
  end
32
56
 
33
- def take_stable_screenshot(screenshot_path)
57
+ def take_stable_screenshot(snapshot)
34
58
  # 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
59
+ deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
37
60
 
38
61
  # Cleanup all previous attempts for sure
39
- Screenshoter.cleanup_attempts_screenshots(screenshot_path)
62
+ snapshot.cleanup_attempts
40
63
 
41
64
  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
65
+ # FIXME: it should be wait, and wait should be replaced with stability_time_limit
66
+ sleep(stability_time_limit) unless i == 0 # test prev_attempt_path is nil
52
67
 
53
- next unless prev_attempt_path
54
- stabilization_comparator = build_comparison_for(attempt_path, prev_attempt_path)
68
+ attempt_next_screenshot(snapshot)
55
69
 
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)
70
+ return true if attempt_successful?(snapshot)
71
+ return false if timeout?(deadline_at)
61
72
  end
62
73
  end
63
74
 
64
75
  private
65
76
 
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)
77
+ def attempt_successful?(snapshot)
78
+ return false unless snapshot.prev_attempt_path
74
79
 
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")}")
80
+ build_last_attempts_comparison_for(snapshot).quick_equal?
81
+ rescue ArgumentError
82
+ false
77
83
  end
78
84
 
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
85
+ def attempt_next_screenshot(snapshot)
86
+ @screenshoter.take_screenshot(snapshot.next_attempt_path!)
87
+ end
92
88
 
93
- FileUtils.rm(attempts_comparison.annotated_image_path, force: true)
94
- end
89
+ def timeout?(deadline_at)
90
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
91
+ end
95
92
 
96
- previous_file = file_name
97
- end
93
+ def build_last_attempts_comparison_for(snapshot)
94
+ ImageCompare.new(snapshot.attempt_path, snapshot.prev_attempt_path, @comparison_options)
98
95
  end
99
96
 
100
- def timeout?(elapsed_time)
101
- elapsed_time > wait
97
+ # TODO: Move to the HistoricalReporter
98
+ def annotate_attempts_and_fail!(snapshot)
99
+ require "capybara_screenshot_diff/attempts_reporter"
100
+ attempts_reporter = CapybaraScreenshotDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit})
101
+
102
+ # TODO: Move fail to the queue after tests passed
103
+ fail(attempts_reporter.generate)
102
104
  end
103
105
  end
104
106
  end