snap_diff-capybara 0.0.1 → 2.0.0.beta1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +190 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/LICENSE.txt +21 -0
  5. data/Rakefile +65 -0
  6. data/capybara-screenshot-diff.gemspec +29 -0
  7. data/docs/RELEASE_PREP.md +44 -0
  8. data/docs/UPGRADING.md +702 -0
  9. data/docs/architecture.md +289 -0
  10. data/docs/ci-integration.md +238 -0
  11. data/docs/configuration.md +395 -0
  12. data/docs/docker-testing.md +24 -0
  13. data/docs/drivers.md +102 -0
  14. data/docs/framework-setup.md +87 -0
  15. data/docs/images/snap_diff_annotated.png +0 -0
  16. data/docs/images/snap_diff_web_ui.png +0 -0
  17. data/docs/migration-guide.md +286 -0
  18. data/docs/organization.md +204 -0
  19. data/docs/reporters.md +46 -0
  20. data/docs/thread_safety.md +112 -0
  21. data/gems.rb +39 -0
  22. data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
  23. data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
  24. data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
  25. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  26. data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
  27. data/lib/capybara/screenshot/diff/difference.rb +8 -0
  28. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
  29. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
  30. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
  31. data/lib/capybara/screenshot/diff/drivers.rb +9 -0
  32. data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
  33. data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
  34. data/lib/capybara/screenshot/diff/os.rb +14 -0
  35. data/lib/capybara/screenshot/diff/region.rb +107 -0
  36. data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
  37. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
  38. data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
  39. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
  40. data/lib/capybara/screenshot/diff/utils.rb +7 -0
  41. data/lib/capybara/screenshot/diff/vcs.rb +7 -0
  42. data/lib/capybara/screenshot/diff/version.rb +15 -0
  43. data/lib/capybara/screenshot/diff.rb +3 -0
  44. data/lib/capybara-screenshot-diff.rb +3 -0
  45. data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
  46. data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
  47. data/lib/capybara_screenshot_diff/dsl.rb +12 -0
  48. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
  49. data/lib/capybara_screenshot_diff/minitest.rb +14 -0
  50. data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
  51. data/lib/capybara_screenshot_diff/rspec.rb +8 -0
  52. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
  53. data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
  54. data/lib/capybara_screenshot_diff/snap.rb +7 -0
  55. data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
  56. data/lib/capybara_screenshot_diff/static.rb +9 -0
  57. data/lib/capybara_screenshot_diff.rb +50 -0
  58. data/lib/snap_diff/annotation_service.rb +84 -0
  59. data/lib/snap_diff/area_calculator.rb +56 -0
  60. data/lib/snap_diff/attempts_reporter.rb +51 -0
  61. data/lib/snap_diff/browser_helpers.rb +121 -0
  62. data/lib/snap_diff/capture/viewport.rb +40 -0
  63. data/lib/snap_diff/comparison.rb +238 -0
  64. data/lib/snap_diff/comparison_result.rb +104 -0
  65. data/lib/snap_diff/config.rb +78 -0
  66. data/lib/snap_diff/deprecation.rb +84 -0
  67. data/lib/snap_diff/driver.rb +37 -0
  68. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  69. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  70. data/lib/snap_diff/drivers.rb +14 -0
  71. data/lib/snap_diff/dsl.rb +143 -0
  72. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  73. data/lib/snap_diff/image_preprocessor.rb +68 -0
  74. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  75. data/lib/snap_diff/integrations/minitest.rb +70 -0
  76. data/lib/snap_diff/integrations/rspec.rb +68 -0
  77. data/lib/snap_diff/legacy_shims.rb +99 -0
  78. data/lib/snap_diff/os.rb +17 -0
  79. data/lib/snap_diff/reporters/html.rb +143 -0
  80. data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
  81. data/lib/snap_diff/reporting.rb +53 -0
  82. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  83. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  84. data/lib/snap_diff/screenshot_namer.rb +81 -0
  85. data/lib/snap_diff/screenshoter.rb +129 -0
  86. data/lib/snap_diff/snap.rb +66 -0
  87. data/lib/snap_diff/snap_manager.rb +125 -0
  88. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  89. data/lib/snap_diff/static.rb +11 -0
  90. data/lib/snap_diff/utils.rb +38 -0
  91. data/lib/snap_diff/vcs.rb +35 -0
  92. data/lib/snap_diff/version.rb +5 -0
  93. data/lib/snap_diff.rb +65 -0
  94. metadata +160 -10
  95. data/README.md +0 -3
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Forwarder (ADR-004 v2 step 6): CapybaraScreenshotDiff::SnapManager now
4
+ # resolves lazily via snap_diff/legacy_shims' const_missing, with a
5
+ # deprecation warning pointing at SnapDiff::SnapManager.
6
+ require "snap_diff/snap_manager"
7
+ require "snap_diff/legacy_shims"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snap_diff/static"
4
+
5
+ module CapybaraScreenshotDiff
6
+ def self.serve(...)
7
+ SnapDiff.serve(...)
8
+ end
9
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara/dsl"
4
+ require "capybara/screenshot/diff/config_legacy"
5
+ require "capybara/screenshot/diff/version"
6
+ require "capybara/screenshot/diff/os"
7
+ require "capybara/screenshot/diff/browser_helpers"
8
+ require "capybara/screenshot/diff/utils"
9
+ require "capybara/screenshot/diff/image_compare"
10
+ require "capybara_screenshot_diff/snap_manager"
11
+ require "capybara_screenshot_diff/snap"
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"
22
+ require "capybara/screenshot/diff/reporters/default"
23
+
24
+ require "capybara_screenshot_diff/error_with_filtered_backtrace"
25
+
26
+ module CapybaraScreenshotDiff
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.
30
+
31
+ class CapybaraScreenshotDiffError < SnapDiff::ErrorWithFilteredBacktrace; end
32
+
33
+ class ExpectationNotMet < CapybaraScreenshotDiffError; end
34
+
35
+ class UnstableImage < CapybaraScreenshotDiffError; end
36
+
37
+ class WindowSizeMismatchError < SnapDiff::ErrorWithFilteredBacktrace; end
38
+ end
39
+
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
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ require "snap_diff/comparison_result"
7
+ require "snap_diff/drivers"
8
+ require "snap_diff/image_preprocessor"
9
+ require "capybara/screenshot/diff/reporters/default"
10
+
11
+ # The internal images-holder struct and the driver cache keep their legacy
12
+ # Capybara::Screenshot::Diff homes for now: SnapDiff::Comparison is the
13
+ # comparison class below (ex-ImageCompare), so the struct cannot take the
14
+ # same name. Its SnapDiff home arrives only when it is folded into
15
+ # Comparison as a nested value (v2 design section 2) -- renaming it here
16
+ # would exceed step 5's two approved renames.
17
+ module Capybara
18
+ module Screenshot
19
+ module Diff
20
+ LOADED_DRIVERS = {}
21
+
22
+ # Holds the two images (and their paths/options/driver) being compared.
23
+ class Comparison < Struct.new(:new_image, :base_image, :options, :driver, :new_image_path, :base_image_path)
24
+ def skip_area
25
+ options[:skip_area]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ module SnapDiff
33
+ # Handles comparison of two images with a focus on performance and accuracy.
34
+ #
35
+ # This class implements a multi-layered optimization strategy for image comparison:
36
+ #
37
+ # 1. Early File-based Checks (Fastest):
38
+ # - Verifies both images exist (raises ArgumentError if not)
39
+ # - Compares file sizes (different sizes → different images)
40
+ # - Performs byte-by-byte comparison for identical files (exact match)
41
+ #
42
+ # 2. Quick Comparison (Fast):
43
+ # - Compares image dimensions (different dimensions → different images)
44
+ # - Performs pixel-by-pixel comparison if dimensions match
45
+ #
46
+ # 3. Detailed Analysis (Slower):
47
+ # - Only performed if quick comparison finds differences
48
+ # - Handles anti-aliasing, color tolerance, and shift detection
49
+ # - Respects skip_area and other comparison parameters
50
+ #
51
+ # This layered approach ensures optimal performance by:
52
+ # - Using the fastest possible method for early rejection
53
+ # - Only performing expensive operations when absolutely necessary
54
+ # - Maintaining high accuracy for complex comparisons
55
+ class Comparison
56
+ TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
57
+
58
+ attr_reader :driver, :driver_options
59
+ attr_reader :image_path, :base_image_path
60
+ attr_reader :difference, :error_message
61
+
62
+ def initialize(image_path, base_image_path, options = {})
63
+ @image_path = Pathname.new(image_path)
64
+ @base_image_path = Pathname.new(base_image_path)
65
+
66
+ ensure_files_exist!
67
+
68
+ @driver_options = options.freeze
69
+ @driver = Drivers.for(@driver_options)
70
+ @without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
71
+ end
72
+
73
+ # Performs a quick comparison of two image files.
74
+ #
75
+ # This method is optimized for speed and will return as soon as a difference is found.
76
+ # It's used for fast rejection before performing more expensive comparisons.
77
+ #
78
+ # @return [Boolean]
79
+ # - `true` if images are exactly identical (byte-for-byte match)
80
+ # - `false` if images are different or if a quick difference is detected
81
+ #
82
+ # @note This method will raise ArgumentError if either image file is missing.
83
+ def quick_equal?
84
+ if base_image_path.size == image_path.size
85
+ return true if files_identical?(base_image_path, image_path)
86
+ end
87
+
88
+ result, difference = find_difference(quick_mode: true)
89
+ self.difference = difference
90
+ result
91
+ end
92
+
93
+ def ensure_files_exist!
94
+ raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist?
95
+ raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist?
96
+ end
97
+
98
+ # Determines if the images are different according to the comparison rules.
99
+ #
100
+ # This method performs a full comparison if not already done, including any
101
+ # configured tolerances for color differences and shift distances.
102
+ #
103
+ # @return [Boolean]
104
+ # - `true` if the images are different beyond configured tolerances
105
+ # - `false` if the images are considered identical
106
+ #
107
+ # @see #processed
108
+ # @see #analyze_difference
109
+ def different?
110
+ processed.difference.different?
111
+ end
112
+
113
+ def dimensions_changed?
114
+ difference.failed_by&.[](:different_dimensions)
115
+ end
116
+
117
+ def reporter
118
+ @reporter ||= build_reporter
119
+ end
120
+
121
+ def processed?
122
+ !!difference
123
+ end
124
+
125
+ def processed
126
+ self.difference = find_difference(quick_mode: false) unless processed?
127
+ @error_message ||= reporter.generate
128
+ self
129
+ end
130
+
131
+ private
132
+
133
+ def without_tolerable_options?
134
+ @without_tolerable_options
135
+ end
136
+
137
+ def load_images_and_build_comparison(base_path, new_path, options)
138
+ base_img, new_img = driver.load_images(base_path, new_path)
139
+ Capybara::Screenshot::Diff::Comparison.new(new_img, base_img, options, driver, new_path, base_path)
140
+ end
141
+
142
+ def image_preprocessor
143
+ @image_preprocessor ||= ImagePreprocessor.new(driver, driver_options)
144
+ end
145
+
146
+ def find_difference(quick_mode: false)
147
+ # Validate images exist
148
+ return build_null_difference("missing_image") unless images_exist?
149
+
150
+ # Create comparison with preprocessed images
151
+ comparison = load_comparison(base_image_path, image_path, driver_options)
152
+
153
+ analyze_difference(comparison, quick_mode: quick_mode)
154
+ end
155
+
156
+ # Analyzes the comparison and determines if images are different.
157
+ #
158
+ # @param comparison [Capybara::Screenshot::Diff::Comparison] The comparison object containing images to analyze.
159
+ # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
160
+ # In quick mode, returns [is_equal, difference] where:
161
+ # - is_equal is true if images are considered equal
162
+ # - difference is a ComparisonResult object or nil
163
+ # When false, returns a ComparisonResult object directly.
164
+ # @return [Array, ComparisonResult] Result format depends on quick_mode parameter.
165
+ def analyze_difference(comparison, quick_mode: true)
166
+ # Handle dimension differences
167
+ unless driver.same_dimension?(comparison)
168
+ result = ComparisonResult.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
169
+ return quick_mode ? [false, result] : result
170
+ end
171
+
172
+ # Handle identical pixels
173
+ if driver.same_pixels?(comparison)
174
+ result = ComparisonResult.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
175
+ return quick_mode ? [true, result] : result
176
+ end
177
+
178
+ # Handle early return for non-tolerable options
179
+ if quick_mode && without_tolerable_options?
180
+ return [false, nil]
181
+ end
182
+
183
+ # Process difference region
184
+ region = driver.find_difference_region(comparison)
185
+
186
+ # Only create a proper difference object if we've completed the comparison
187
+ quick_mode ? [!region.different?, region] : region
188
+ end
189
+
190
+ def difference=(new_difference)
191
+ @error_message = nil
192
+ @reporter = nil
193
+ @difference = new_difference
194
+ end
195
+
196
+ def build_reporter
197
+ current_difference = difference || build_null_difference
198
+ Capybara::Screenshot::Diff::Reporters::Default.new(current_difference)
199
+ end
200
+
201
+ # Loads and preprocesses images for detailed comparison.
202
+ #
203
+ # This method is responsible for:
204
+ # 1. Loading both images using the configured driver
205
+ # 2. Applying any necessary preprocessing (cropping, normalization)
206
+ # 3. Creating a Capybara::Screenshot::Diff::Comparison object that holds the image data
207
+ #
208
+ # @param base_path [String,Pathname] Path to the baseline/reference image
209
+ # @param new_path [String,Pathname] Path to the new/candidate image
210
+ # @param options [Hash] Comparison options including:
211
+ # - :crop [Array<Integer>] Optional crop area [x, y, width, height]
212
+ # - :skip_area [Array<Array>] Areas to exclude from comparison
213
+ # - :tolerance [Numeric] Color tolerance threshold
214
+ # @return [Capybara::Screenshot::Diff::Comparison] Prepared comparison object ready for analysis
215
+ # @raise [ArgumentError] If image files are invalid or unreadable
216
+ def load_comparison(base_path, new_path, options)
217
+ comparison = load_images_and_build_comparison(base_path, new_path, options)
218
+ image_preprocessor.process_comparison(comparison)
219
+ end
220
+
221
+ def build_null_difference(failed_by = nil)
222
+ comparison = Capybara::Screenshot::Diff::Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
223
+ ComparisonResult.build_null(comparison, base_image_path, image_path, failed_by)
224
+ end
225
+
226
+ # Check if both images exist
227
+ def images_exist?
228
+ base_image_path.exist? && image_path.exist?
229
+ end
230
+
231
+ # Check if files are identical by content
232
+ def files_identical?(file1, file2)
233
+ FileUtils.compare_file(file1, file2)
234
+ rescue SystemCallError, IOError
235
+ false
236
+ end
237
+ end
238
+ end