capybara-screenshot-diff 1.15.0 → 2.0.0.alpha1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/docs/RELEASE_PREP.md +6 -6
  4. data/docs/thread_safety.md +15 -0
  5. data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
  6. data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
  7. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
  8. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  9. data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
  10. data/lib/capybara/screenshot/diff/difference.rb +6 -102
  11. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
  12. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
  13. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
  14. data/lib/capybara/screenshot/diff/drivers.rb +7 -14
  15. data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
  16. data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
  17. data/lib/capybara/screenshot/diff/os.rb +8 -13
  18. data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
  19. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
  20. data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
  21. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
  22. data/lib/capybara/screenshot/diff/utils.rb +5 -40
  23. data/lib/capybara/screenshot/diff/vcs.rb +5 -37
  24. data/lib/capybara/screenshot/diff/version.rb +7 -1
  25. data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
  26. data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
  27. data/lib/capybara_screenshot_diff/dsl.rb +7 -129
  28. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
  29. data/lib/capybara_screenshot_diff/minitest.rb +6 -56
  30. data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
  31. data/lib/capybara_screenshot_diff/rspec.rb +5 -63
  32. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
  33. data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
  34. data/lib/capybara_screenshot_diff/snap.rb +5 -64
  35. data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
  36. data/lib/capybara_screenshot_diff/static.rb +3 -5
  37. data/lib/capybara_screenshot_diff.rb +29 -100
  38. data/lib/snap_diff/annotation_service.rb +84 -0
  39. data/lib/snap_diff/area_calculator.rb +56 -0
  40. data/lib/snap_diff/attempts_reporter.rb +51 -0
  41. data/lib/snap_diff/browser_helpers.rb +121 -0
  42. data/lib/snap_diff/capture/viewport.rb +40 -0
  43. data/lib/snap_diff/comparison.rb +238 -0
  44. data/lib/snap_diff/comparison_result.rb +104 -0
  45. data/lib/snap_diff/config.rb +6 -2
  46. data/lib/snap_diff/deprecation.rb +84 -0
  47. data/lib/snap_diff/driver.rb +37 -0
  48. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  49. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  50. data/lib/snap_diff/drivers.rb +14 -0
  51. data/lib/snap_diff/dsl.rb +143 -0
  52. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  53. data/lib/snap_diff/image_preprocessor.rb +68 -0
  54. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  55. data/lib/snap_diff/integrations/minitest.rb +70 -0
  56. data/lib/snap_diff/integrations/rspec.rb +68 -0
  57. data/lib/snap_diff/legacy_shims.rb +99 -0
  58. data/lib/snap_diff/os.rb +17 -0
  59. data/lib/snap_diff/reporters/html.rb +143 -0
  60. data/lib/snap_diff/reporting.rb +53 -0
  61. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  62. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  63. data/lib/snap_diff/screenshot_namer.rb +81 -0
  64. data/lib/snap_diff/screenshoter.rb +129 -0
  65. data/lib/snap_diff/snap.rb +66 -0
  66. data/lib/snap_diff/snap_manager.rb +125 -0
  67. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  68. data/lib/snap_diff/static.rb +11 -0
  69. data/lib/snap_diff/utils.rb +38 -0
  70. data/lib/snap_diff/vcs.rb +35 -0
  71. data/lib/snap_diff/version.rb +5 -0
  72. data/lib/snap_diff.rb +22 -6
  73. metadata +36 -2
  74. /data/lib/{capybara_screenshot_diff → snap_diff}/reporters/templates/report.html.erb +0 -0
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rack/files"
4
- require "capybara_screenshot_diff/minitest"
3
+ require "snap_diff/static"
5
4
 
6
5
  module CapybaraScreenshotDiff
7
- def self.serve(directory, root: Dir.pwd)
8
- Capybara.app = Rack::Files.new(directory)
9
- Capybara::Screenshot.root = root
6
+ def self.serve(...)
7
+ SnapDiff.serve(...)
10
8
  end
11
9
  end
@@ -1,121 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Lazy load: snap_diff.rb requires this file, so eagerly `require`-ing it
4
- # here would be circular. `autoload` defers the load until SnapDiff is
5
- # first referenced, by which point this file has already finished loading.
6
- autoload :SnapDiff, "snap_diff"
7
-
8
3
  require "capybara/dsl"
4
+ require "capybara/screenshot/diff/config_legacy"
9
5
  require "capybara/screenshot/diff/version"
6
+ require "capybara/screenshot/diff/os"
7
+ require "capybara/screenshot/diff/browser_helpers"
10
8
  require "capybara/screenshot/diff/utils"
11
9
  require "capybara/screenshot/diff/image_compare"
12
10
  require "capybara_screenshot_diff/snap_manager"
11
+ require "capybara_screenshot_diff/snap"
13
12
  require "capybara/screenshot/diff/screenshoter"
13
+ require "capybara/screenshot/diff/stable_screenshoter"
14
+ require "capybara/screenshot/diff/vcs"
15
+ require "capybara/screenshot/diff/area_calculator"
16
+ require "capybara/screenshot/diff/image_preprocessor"
17
+ require "capybara/screenshot/diff/annotation_service"
18
+ require "capybara_screenshot_diff/screenshot_namer"
19
+ require "capybara_screenshot_diff/screenshot_assertion"
20
+ require "capybara_screenshot_diff/attempts_reporter"
21
+ require "capybara/screenshot/diff/screenshot_matcher"
14
22
  require "capybara/screenshot/diff/reporters/default"
15
23
 
16
24
  require "capybara_screenshot_diff/error_with_filtered_backtrace"
17
25
 
18
26
  module CapybaraScreenshotDiff
19
- RED_RGBA = [255, 0, 0, 255].freeze
20
- ORANGE_RGBA = [255, 192, 0, 255].freeze
27
+ # RED_RGBA / ORANGE_RGBA moved to SnapDiff (snap_diff/annotation_service)
28
+ # so the bare "snap_diff" entry gets them too; the old names resolve via
29
+ # snap_diff/legacy_shims with a deprecation warning.
21
30
 
22
- class CapybaraScreenshotDiffError < ErrorWithFilteredBacktrace; end
31
+ class CapybaraScreenshotDiffError < SnapDiff::ErrorWithFilteredBacktrace; end
23
32
 
24
33
  class ExpectationNotMet < CapybaraScreenshotDiffError; end
25
34
 
26
35
  class UnstableImage < CapybaraScreenshotDiffError; end
27
36
 
28
- class WindowSizeMismatchError < ErrorWithFilteredBacktrace; end
37
+ class WindowSizeMismatchError < SnapDiff::ErrorWithFilteredBacktrace; end
29
38
  end
30
39
 
31
- module Capybara
32
- module Screenshot
33
- mattr_accessor :add_driver_path
34
- mattr_accessor :add_os_path
35
- mattr_accessor(:blur_active_element) { true }
36
- mattr_accessor :enabled
37
- mattr_accessor(:hide_caret) { true }
38
- mattr_accessor :disable_animations
39
- mattr_reader(:root) { (defined?(Rails) && defined?(Rails.root) && Rails.root) || Pathname(".").expand_path }
40
- mattr_accessor :stability_time_limit
41
- mattr_accessor :window_size
42
- mattr_accessor(:save_path) { "doc/screenshots" }
43
- mattr_accessor(:use_lfs)
44
- mattr_accessor(:screenshot_format) { "png" }
45
- mattr_accessor(:capybara_screenshot_options) { {} }
46
-
47
- class << self
48
- def root=(path)
49
- @@root = Pathname(path).expand_path
50
- end
51
-
52
- def active?
53
- enabled || (enabled.nil? && Diff.enabled)
54
- end
55
-
56
- def screenshot_area
57
- parts = [Screenshot.save_path]
58
- parts << Os.name if Screenshot.add_os_path
59
- parts << Capybara.current_driver.to_s if Screenshot.add_driver_path
60
- File.join(*parts)
61
- end
62
-
63
- def screenshot_area_abs
64
- root / screenshot_area
65
- end
66
- end
67
-
68
- # Module to track screenshot changes
69
- module Diff
70
- mattr_accessor(:delayed) { true }
71
- mattr_accessor :area_size_limit
72
- mattr_accessor(:fail_if_new) { !ENV["CI"].nil? && !ENV["CI"].empty? }
73
- mattr_accessor(:pending_if_new) { false }
74
- mattr_accessor(:fail_on_difference) { true }
75
- mattr_accessor :color_distance_limit
76
- mattr_accessor(:enabled) { true }
77
- mattr_accessor :shift_distance_limit
78
- mattr_accessor :skip_area
79
- mattr_accessor(:driver) { :auto }
80
- mattr_accessor :tolerance
81
- mattr_accessor :perceptual_threshold
82
-
83
- mattr_accessor(:screenshoter) { Screenshoter }
84
- mattr_accessor(:manager) { CapybaraScreenshotDiff::SnapManager }
85
-
86
- AVAILABLE_DRIVERS = Utils.detect_available_drivers.freeze
87
-
88
- # Configure screenshot and diff settings in one block.
89
- #
90
- # Capybara::Screenshot::Diff.configure do |screenshot, diff|
91
- # screenshot.window_size = [1280, 1024]
92
- # screenshot.stability_time_limit = 1
93
- # diff.driver = :vips
94
- # diff.tolerance = 0.0005
95
- # end
96
- def self.configure
97
- yield Screenshot, self
98
- end
99
-
100
- def self.compare(baseline_path, current_path, **options)
101
- ImageCompare.new(current_path, baseline_path, default_options.merge(options))
102
- end
103
-
104
- def self.default_options
105
- {
106
- area_size_limit: area_size_limit,
107
- color_distance_limit: color_distance_limit,
108
- driver: driver,
109
- screenshot_format: Screenshot.screenshot_format,
110
- capybara_screenshot_options: Screenshot.capybara_screenshot_options,
111
- perceptual_threshold: perceptual_threshold,
112
- shift_distance_limit: shift_distance_limit,
113
- skip_area: skip_area,
114
- stability_time_limit: Screenshot.stability_time_limit,
115
- tolerance: tolerance || ((driver == :vips) ? 0.001 : nil),
116
- wait: Capybara.default_max_wait_time
117
- }
118
- end
119
- end
120
- end
121
- end
40
+ require "capybara_screenshot_diff/dsl"
41
+
42
+ # Eager, not autoload: several lib/snap_diff/* units above (Os,
43
+ # Screenshoter, ...) reopen `module SnapDiff` while loading, which cancels
44
+ # any registered `autoload :SnapDiff` before it ever fires (Ruby resolves
45
+ # the constant the first time anything reopens it, autoload or not) --
46
+ # so SnapDiff.start/.compare/.config would silently never be defined
47
+ # without this. Safe to do eagerly, unlike before this file's units
48
+ # moved: snap_diff.rb no longer requires this file back (it only needs
49
+ # the leaf config_legacy.rb + image_compare.rb), so there is no cycle.
50
+ require "snap_diff"
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Annotation colors, defined here (not in the capybara_screenshot_diff
5
+ # umbrella) so they resolve in processes that only `require "snap_diff"`.
6
+ RED_RGBA = [255, 0, 0, 255].freeze
7
+ ORANGE_RGBA = [255, 192, 0, 255].freeze
8
+
9
+ # Draws diff/skip-area rectangles and the heatmap overlay for a Difference,
10
+ # and saves the resulting images to their `.diff.*` / `.heatmap.diff.*` paths.
11
+ #
12
+ # Extracted from Reporters::Default (ADR-004 PR 7) so the reporter only
13
+ # builds error messages; this class owns all image annotation work.
14
+ class AnnotationService
15
+ attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path
16
+
17
+ def initialize(difference)
18
+ @difference = difference
19
+
20
+ comparison = difference.comparison
21
+ ext = comparison.new_image_path.extname.delete_prefix(".")
22
+ screenshot_format = comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
23
+ @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
24
+ @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
25
+ @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
26
+ end
27
+
28
+ def clean_tmp_files
29
+ annotated_base_image_path.unlink if annotated_base_image_path.exist?
30
+ annotated_image_path.unlink if annotated_image_path.exist?
31
+ heatmap_diff_path.unlink if heatmap_diff_path.exist?
32
+ end
33
+
34
+ def annotate_and_save_images
35
+ save_annotation_for(new_image, annotated_image_path)
36
+ save_annotation_for(base_image, annotated_base_image_path)
37
+ save_heatmap_diff if difference.diff_mask
38
+ end
39
+
40
+ def save_annotation_for(image, image_path)
41
+ image = annotate_difference(image, difference.region)
42
+ image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
43
+
44
+ save(image, image_path.to_path)
45
+ end
46
+
47
+ def annotate_difference(image, region)
48
+ driver.draw_rectangles([image], region, RED_RGBA, offset: 1).first
49
+ end
50
+
51
+ def annotate_skip_areas(image, skip_areas)
52
+ skip_areas.reduce(image) do |memo, region|
53
+ driver.draw_rectangles([memo], region, ORANGE_RGBA).first
54
+ end
55
+ end
56
+
57
+ def save(image, image_path)
58
+ driver.save_image_to(image, image_path.to_s)
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :difference
64
+
65
+ def save_heatmap_diff
66
+ merged_image = driver.merge(new_image, base_image)
67
+ highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: RED_RGBA)
68
+
69
+ save(highlighted_mask, heatmap_diff_path.to_path)
70
+ end
71
+
72
+ def base_image
73
+ difference.comparison.base_image
74
+ end
75
+
76
+ def new_image
77
+ difference.comparison.new_image
78
+ end
79
+
80
+ def driver
81
+ @_driver ||= difference.comparison.driver
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Region has not moved yet (ADR-004 v2 scoping: it's already unnamespaced,
4
+ # no v2.0 move needed).
5
+ require "capybara/screenshot/diff/region"
6
+
7
+ module SnapDiff
8
+ class AreaCalculator
9
+ def initialize(crop_coordinates, skip_area)
10
+ @crop_coordinates = crop_coordinates
11
+ @skip_area = skip_area
12
+ end
13
+
14
+ def calculate_crop
15
+ return @_calculated_crop if defined?(@_calculated_crop)
16
+ return @_calculated_crop = nil unless @crop_coordinates
17
+
18
+ # TODO: Move out from this class, this should be done on before screenshot and should not depend on Browser
19
+ @crop_coordinates = BrowserHelpers.bounds_for_css(@crop_coordinates).first if @crop_coordinates.is_a?(String)
20
+ @_calculated_crop = Region.from_edge_coordinates(*@crop_coordinates)
21
+ end
22
+
23
+ # Cast skip areas params into Region
24
+ # and if there is crop then makes absolute coordinates to eb relative to crop top left corner
25
+ def calculate_skip_area
26
+ return nil unless @skip_area
27
+
28
+ crop_region = calculate_crop
29
+ skip_area = Array(@skip_area)
30
+
31
+ css_selectors, coords_list = skip_area.compact.partition { |region| region.is_a? String }
32
+ regions, coords_list = coords_list.partition { |region| region.is_a? Region }
33
+
34
+ regions.concat(build_regions_for(BrowserHelpers.bounds_for_css(*css_selectors))) unless css_selectors.empty?
35
+ regions.concat(build_regions_for(coords_list.flatten.each_slice(4))) unless coords_list.empty?
36
+
37
+ regions.compact!
38
+
39
+ if crop_region
40
+ regions
41
+ .map! { |region| crop_region.find_relative_intersect(region) }
42
+ .filter! { |region| region&.present? }
43
+ end
44
+
45
+ regions
46
+ end
47
+
48
+ private
49
+
50
+ def build_regions_for(coordinates)
51
+ coordinates
52
+ .map { |entry| Region.from_edge_coordinates(*entry) }
53
+ .tap { |region| region.compact! }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require "snap_diff/comparison"
6
+
7
+ module SnapDiff
8
+ class AttemptsReporter
9
+ def initialize(snapshot, comparison_options, stability_options = {})
10
+ @snapshot = snapshot
11
+ @comparison_options = comparison_options
12
+ @wait = stability_options[:wait]
13
+ end
14
+
15
+ def generate
16
+ attempts_screenshot_paths = @snapshot.find_attempts_paths
17
+
18
+ annotate_attempts(attempts_screenshot_paths)
19
+
20
+ "Could not get stable screenshot within #{@wait}s:\n#{attempts_screenshot_paths.join("\n")}"
21
+ end
22
+
23
+ def build_comparison_for(attempt_path, previous_attempt_path)
24
+ Comparison.new(attempt_path, previous_attempt_path, @comparison_options)
25
+ end
26
+
27
+ private
28
+
29
+ def annotate_attempts(attempts_screenshot_paths)
30
+ previous_file = nil
31
+ attempts_screenshot_paths.reverse_each do |file_name|
32
+ if previous_file && File.exist?(previous_file)
33
+ attempts_comparison = build_comparison_for(file_name, previous_file)
34
+
35
+ if attempts_comparison.different?
36
+ FileUtils.mv(attempts_comparison.reporter.annotated_base_image_path, previous_file, force: true)
37
+ else
38
+ warn "[capybara-screenshot-diff] Some attempts was stable, but mistakenly marked as not: " \
39
+ "#{previous_file} and #{file_name} are equal"
40
+ end
41
+
42
+ FileUtils.rm(attempts_comparison.reporter.annotated_image_path, force: true)
43
+ end
44
+
45
+ previous_file = file_name
46
+ end
47
+
48
+ previous_file
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Region has not moved yet (ADR-004 v2 scoping: it's already unnamespaced,
4
+ # no v2.0 move needed) so this is an absolute require, not require_relative.
5
+ require "capybara/screenshot/diff/region"
6
+
7
+ module SnapDiff
8
+ module BrowserHelpers
9
+ def self.resize_window_if_needed
10
+ if ::Capybara::Screenshot.respond_to?(:window_size) && ::Capybara::Screenshot.window_size
11
+ resize_to(::Capybara::Screenshot.window_size)
12
+ end
13
+ end
14
+
15
+ def self.resize_to(window_size)
16
+ if session.driver.respond_to?(:resize)
17
+ session.driver.resize(*window_size)
18
+ elsif BrowserHelpers.selenium?
19
+ session.driver.browser.manage.window.resize_to(*window_size)
20
+ end
21
+ end
22
+
23
+ def self.selenium?
24
+ current_capybara_driver_class <= Capybara::Selenium::Driver
25
+ end
26
+
27
+ def self.window_size_is_wrong?(expected_window_size = nil)
28
+ selenium? && expected_window_size &&
29
+ session.driver.browser.manage.window.size != ::Selenium::WebDriver::Dimension.new(*expected_window_size)
30
+ end
31
+
32
+ def self.bounds_for_css(*css_selectors)
33
+ css_selectors.reduce([]) do |regions, selector|
34
+ regions.concat(all_visible_regions_for(selector))
35
+ end
36
+ end
37
+
38
+ IMAGE_WAIT_SCRIPT = <<~JS
39
+ function pending_image() {
40
+ const images = document.images
41
+ for (var i = 0; i < images.length; i++) {
42
+ if (!images[i].complete && images[i].loading !== "lazy") {
43
+ return images[i].src
44
+ }
45
+ }
46
+ return false
47
+ }(window)
48
+ JS
49
+
50
+ HIDE_CARET_SCRIPT = <<~JS
51
+ if (!document.getElementById('csdHideCaretStyle')) {
52
+ let style = document.createElement('style');
53
+ style.setAttribute('id', 'csdHideCaretStyle');
54
+ document.head.appendChild(style);
55
+ let styleSheet = style.sheet;
56
+ styleSheet.insertRule("* { caret-color: transparent !important; }", 0);
57
+ }
58
+ JS
59
+
60
+ def self.hide_caret
61
+ session.execute_script(HIDE_CARET_SCRIPT)
62
+ end
63
+
64
+ DISABLE_ANIMATIONS_SCRIPT = <<~JS
65
+ if (!document.getElementById('csdDisableAnimationsStyle')) {
66
+ let style = document.createElement('style');
67
+ style.setAttribute('id', 'csdDisableAnimationsStyle');
68
+ style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
69
+ document.head.appendChild(style);
70
+ }
71
+ JS
72
+
73
+ def self.disable_animations
74
+ session.execute_script(DISABLE_ANIMATIONS_SCRIPT)
75
+ end
76
+
77
+ FIND_ACTIVE_ELEMENT_SCRIPT = <<~JS
78
+ function activeElement(){
79
+ const ae = document.activeElement;
80
+ if (ae.nodeName === "INPUT" || ae.nodeName === "TEXTAREA") {
81
+ ae.blur();
82
+ return ae;
83
+ }
84
+ return null;
85
+ }(window);
86
+ JS
87
+
88
+ def self.blur_from_focused_element
89
+ session.evaluate_script(FIND_ACTIVE_ELEMENT_SCRIPT)
90
+ end
91
+
92
+ GET_BOUNDING_CLIENT_RECT_SCRIPT = <<~JS
93
+ [
94
+ this.getBoundingClientRect().left,
95
+ this.getBoundingClientRect().top,
96
+ this.getBoundingClientRect().right,
97
+ this.getBoundingClientRect().bottom
98
+ ]
99
+ JS
100
+
101
+ def self.all_visible_regions_for(selector)
102
+ BrowserHelpers.session.all(selector, visible: true).map { |el| region_for(el) }
103
+ end
104
+
105
+ def self.region_for(element)
106
+ element.evaluate_script(GET_BOUNDING_CLIENT_RECT_SCRIPT).map { |point| point.negative? ? 0 : point.ceil.to_i }
107
+ end
108
+
109
+ def self.session
110
+ Capybara.current_session
111
+ end
112
+
113
+ def self.pending_image_to_load
114
+ BrowserHelpers.session.evaluate_script(IMAGE_WAIT_SCRIPT)
115
+ end
116
+
117
+ def self.current_capybara_driver_class
118
+ session.driver.class
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../browser_helpers"
4
+
5
+ module SnapDiff
6
+ module Capture
7
+ # Per-capture viewport preparation seam.
8
+ #
9
+ # Called exactly once per capture, before the screenshoter runs and outside
10
+ # any stability retry loop. Today it only validates the window size
11
+ # (raise-only, never resizes) — the same guard ScreenshotMatcher carried
12
+ # inline before. v3 hangs scroll-position preservation and element-anchored
13
+ # capture off this seam via the +anchor+ parameter without touching callers.
14
+ module Viewport
15
+ module_function
16
+
17
+ # @param expected_window_size [Array(Integer, Integer), nil] the configured window size
18
+ # @param anchor [Object, nil] reserved for v3 (scroll preservation /
19
+ # element-anchored capture); accepted but unused today.
20
+ # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] if the browser
21
+ # window does not match the expected size.
22
+ def prepare!(expected_window_size, anchor: nil)
23
+ return unless BrowserHelpers.window_size_is_wrong?(expected_window_size)
24
+
25
+ current_size = BrowserHelpers.selenium? ?
26
+ BrowserHelpers.session.driver.browser.manage.window.size.to_s :
27
+ "unknown"
28
+
29
+ raise CapybaraScreenshotDiff::WindowSizeMismatchError.new(<<~ERROR.chomp, caller)
30
+ Window size mismatch detected!
31
+ Expected: #{expected_window_size.inspect}
32
+ Actual: #{current_size}
33
+
34
+ Screenshots cannot be compared when window sizes don't match.
35
+ Please ensure the browser window is properly sized before taking screenshots.
36
+ ERROR
37
+ end
38
+ end
39
+ end
40
+ end