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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "vips"
5
+ rescue LoadError => e
6
+ raise 'Required ruby-vips gem is missing. Add `gem "ruby-vips"` to Gemfile' if e.message.match?(/vips/i)
7
+ raise
8
+ end
9
+
10
+ require "snap_diff/driver"
11
+ require "snap_diff/drivers"
12
+ require "snap_diff/comparison_result"
13
+ # Defines SnapDiff::RED_RGBA, the highlight_mask default color.
14
+ require "snap_diff/annotation_service"
15
+
16
+ module SnapDiff
17
+ module Drivers
18
+ class VipsDriver
19
+ include SnapDiff::Driver
20
+
21
+ def find_difference_region(comparison)
22
+ new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
23
+
24
+ diff_mask = if options[:perceptual_threshold]
25
+ self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
26
+ else
27
+ self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
28
+ end
29
+ region = self.class.difference_region_by(diff_mask)
30
+ # TODO: schedule research when we got this case for VIPs
31
+ # region = nil if region && region_covers_entire_image?(region, base_image)
32
+
33
+ result = ComparisonResult.new(region, {}, comparison)
34
+
35
+ unless result.blank?
36
+ result.meta[:difference_level] = difference_level(diff_mask, base_image) if comparison.options[:tolerance]
37
+ result.meta[:diff_mask] = diff_mask
38
+ end
39
+
40
+ result
41
+ end
42
+
43
+ def crop(region, i)
44
+ i.crop(*region.to_top_left_corner_coordinates)
45
+ rescue Vips::Error => e
46
+ warn(
47
+ "[capybara-screenshot-diff] Crop has been failed for " \
48
+ "{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
49
+ )
50
+ raise e
51
+ end
52
+
53
+ def filter_image_with_median(image, median_filter_window_size)
54
+ image.median(median_filter_window_size)
55
+ end
56
+
57
+ def add_black_box(memo, region)
58
+ return memo unless region
59
+
60
+ memo.draw_rect([0, 0, 0, 0], *region.to_top_left_corner_coordinates, fill: true)
61
+ end
62
+
63
+ def difference_level(diff_mask, old_img, _region = nil)
64
+ self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
65
+ end
66
+
67
+ MAX_FILENAME_LENGTH = 200
68
+
69
+ # Vips could not work with the same file. Per each process we require to create new file
70
+ def save_image_to(image, filename)
71
+ # Dir::Tmpname will happily produce tempfile names that are too long for most unix filesystems,
72
+ # which leads to "unix error: File name too long". Apply a limit to avoid this.
73
+ limited_filename = filename.to_s[-MAX_FILENAME_LENGTH..] || filename.to_s
74
+ ::Dir::Tmpname.create([limited_filename, PNG_EXTENSION]) do |tmp_image_filename|
75
+ image.write_to_file(tmp_image_filename)
76
+ FileUtils.mv(tmp_image_filename, filename)
77
+ end
78
+ end
79
+
80
+ def resize_image_to(image, new_width, new_height)
81
+ image.resize(new_width.to_f / image.width, vscale: new_height.to_f / image.height)
82
+ end
83
+
84
+ def load_images(old_file_name, new_file_name)
85
+ [from_file(old_file_name), from_file(new_file_name)]
86
+ end
87
+
88
+ def from_file(filename)
89
+ result = ::Vips::Image.new_from_file(filename.to_s)
90
+
91
+ result = result.colourspace(:srgb) if result.bands < 3
92
+ result = result.bandjoin(255) if result.bands == 3
93
+
94
+ result
95
+ end
96
+
97
+ def draw_rectangles(images, region, rgba, offset: 0)
98
+ images.map do |image|
99
+ image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
100
+ end
101
+ end
102
+
103
+ def same_pixels?(comparison)
104
+ (comparison.new_image == comparison.base_image).min == 255
105
+ end
106
+
107
+ def merge(new_image, base_image)
108
+ base_image.composite2(new_image, :over)
109
+ end
110
+
111
+ def highlight_mask(diff_mask, merged_image, color: SnapDiff::RED_RGBA)
112
+ diff_mask.ifthenelse(color, merged_image * 0.75)
113
+ end
114
+
115
+ private
116
+
117
+ class << self
118
+ def difference_area(old_image, new_image, color_distance: 0)
119
+ mask = difference_mask(new_image, old_image, color_distance)
120
+ difference_area_size_by(mask)
121
+ end
122
+
123
+ def difference_area_size_by(difference_mask)
124
+ diff_mask = difference_mask == 0
125
+ diff_mask.hist_find.to_a[0][0].max
126
+ end
127
+
128
+ def difference_mask(base_image, new_image, color_distance = nil)
129
+ result = (new_image - base_image).abs
130
+ color_distance ? result > color_distance : result
131
+ end
132
+
133
+ def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
134
+ color_diff = perceptual_color_diff(base_image, new_image) > threshold
135
+ alpha_diff = alpha_channel_diff(base_image, new_image)
136
+ alpha_diff ? (color_diff | alpha_diff) : color_diff
137
+ end
138
+
139
+ def perceptual_color_diff(base_image, new_image)
140
+ base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
141
+ new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
142
+ base_lab = base_rgb.colourspace(:lab)
143
+ new_lab = new_rgb.colourspace(:lab)
144
+ base_lab.dE00(new_lab)
145
+ rescue Vips::Error
146
+ base_lab.dE76(new_lab)
147
+ end
148
+
149
+ def alpha_channel_diff(base_image, new_image)
150
+ return unless base_image.bands > 3 && new_image.bands > 3
151
+
152
+ (base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
153
+ end
154
+
155
+ def difference_region_by(diff_mask)
156
+ columns, rows = diff_mask.bandor.project
157
+
158
+ left = columns.profile[1].min
159
+ right = columns.width - columns.flip(:horizontal).profile[1].min
160
+
161
+ top = rows.profile[0].min
162
+ bottom = rows.height - rows.flip(:vertical).profile[0].min
163
+
164
+ return nil if right < left || bottom < top
165
+
166
+ Region.from_edge_coordinates(left, top, right, bottom)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Compare two images and determine if they are equal, different, or within some comparison
5
+ # range considering color values and difference area size.
6
+ module Drivers
7
+ def self.for(driver_options = {})
8
+ driver_option = driver_options.is_a?(Hash) ? driver_options.fetch(:driver, :chunky_png) : driver_options
9
+ return driver_option unless driver_option.is_a?(Symbol)
10
+
11
+ Utils.find_driver_class_for(driver_option).new
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # No require of "capybara_screenshot_diff" here: every Capybara::Screenshot
4
+ # / CapybaraScreenshotDiff reference below is inside a method body, resolved
5
+ # lazily at call time, not at this file's own load time -- so this unit has
6
+ # no eager dependency on the umbrella entry point, and requiring it here
7
+ # would create a require cycle back to capybara_screenshot_diff.rb (which
8
+ # requires this file's old-path forwarder).
9
+ # DSL includes Capybara::DSL directly below, so it needs the base gem
10
+ # loaded regardless of what pulled this file in.
11
+ require "capybara/dsl"
12
+ require "capybara/screenshot/diff/config_legacy"
13
+ require "capybara/screenshot/diff/drivers"
14
+ require "snap_diff/comparison"
15
+ require "capybara/screenshot/diff/screenshot_matcher"
16
+ require_relative "screenshot_namer"
17
+ require_relative "screenshot_assertion"
18
+
19
+ module SnapDiff
20
+ # DSL for taking screenshots and making assertions in Capybara tests.
21
+ # This module provides methods for taking screenshots, comparing them against baselines,
22
+ # and managing the comparison process with various configuration options.
23
+ #
24
+ # The DSL is designed to be included in your test context (e.g., RSpec, Minitest)
25
+ # to provide screenshot comparison capabilities.
26
+ module DSL
27
+ include Capybara::DSL
28
+
29
+ def screenshot_section(name)
30
+ screenshot_namer.section = name
31
+ end
32
+
33
+ def screenshot_group(name)
34
+ screenshot_namer.group = name
35
+ end
36
+
37
+ # Takes a screenshot and compares it against a baseline image.
38
+ #
39
+ # The method follows a layered optimization strategy for comparison:
40
+ # 1. First checks if screenshot functionality is active
41
+ # 2. Builds a full screenshot name using the current context
42
+ # 3. Creates a screenshot assertion object
43
+ # 4. Either validates immediately or defers validation based on options
44
+ #
45
+ # @param name [String] The base name of the screenshot, used to generate the filename.
46
+ # @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors.
47
+ # @param options [Hash] Additional options for taking the screenshot and comparison.
48
+ # @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed)
49
+ # Whether to validate the screenshot immediately or delay validation.
50
+ # @option options [Array<Integer>] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to.
51
+ # @option options [Array<Array<Integer>>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore.
52
+ # @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison.
53
+ # Represents the maximum allowed ratio of different pixels (0.0-1.0 scale).
54
+ # @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels.
55
+ # Uses Euclidean RGBA distance (0-510 scale). Mutually exclusive with :perceptual_threshold.
56
+ # @option options [Numeric] :perceptual_threshold Maximum perceptual color difference (CIE dE00).
57
+ # Uses human perception-based scale (0-100+). VIPS only. Takes priority over :color_distance_limit if both set.
58
+ # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels.
59
+ # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
60
+ # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
61
+ # @return [Boolean] True if the screenshot was successfully captured and processed.
62
+ # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
63
+ # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
64
+ # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
65
+ def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
66
+ return false unless Capybara::Screenshot.active?
67
+
68
+ # Get the full name with section and group information
69
+ full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
70
+
71
+ # Build the screenshot assertion
72
+ assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
73
+
74
+ return false unless assertion
75
+
76
+ # Determine if validation should be delayed or immediate
77
+ delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
78
+
79
+ if delayed
80
+ CapybaraScreenshotDiff.add_assertion(assertion)
81
+ else
82
+ assertion.validate!
83
+ end
84
+
85
+ true
86
+ end
87
+
88
+ # Convenience wrapper around {#assert_matches_screenshot} and {#capture_screenshot}.
89
+ # @param compare [Boolean] When false, only captures the screenshot without comparing it to a baseline.
90
+ # @see #assert_matches_screenshot
91
+ # @see #capture_screenshot
92
+ def screenshot(name, skip_stack_frames: 0, compare: true, **options)
93
+ if compare
94
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options)
95
+ else
96
+ capture_screenshot(name, **options)
97
+ end
98
+ end
99
+
100
+ # Captures a screenshot without comparing it to a baseline.
101
+ # @param name [String] The base name of the screenshot, used to generate the filename.
102
+ # @param options [Hash] Additional options for taking the screenshot. See {#assert_matches_screenshot}.
103
+ # @return [Boolean] True if the screenshot was successfully captured.
104
+ def capture_screenshot(name, **options)
105
+ return false unless Capybara::Screenshot.active?
106
+
107
+ full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
108
+ SnapDiff::ScreenshotMatcher.new(full_name, options).capture
109
+
110
+ true
111
+ end
112
+
113
+ # Asserts the current page has no visual changes from the baseline.
114
+ # Override in your base test class to add project-specific behavior
115
+ # (e.g., waiting for Turbo, default skip areas).
116
+ def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
117
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
118
+ end
119
+
120
+ private
121
+
122
+ # Builds a screenshot assertion object that can be validated immediately or later.
123
+ #
124
+ # This method constructs a screenshot assertion that encapsulates the comparison logic.
125
+ # The actual comparison is deferred until {ScreenshotAssertion#validate!} is called.
126
+ #
127
+ # @param name [String] The full name of the screenshot, including any section/group context.
128
+ # @param options [Hash] Options for screenshot taking and comparison.
129
+ # See {#assert_matches_screenshot} for available options.
130
+ # @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
131
+ # @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
132
+ # @see ScreenshotAssertion
133
+ def build_screenshot_assertion(name, options, skip_stack_frames: 0)
134
+ SnapDiff::ScreenshotMatcher
135
+ .new(name, options)
136
+ .build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
137
+ end
138
+
139
+ def screenshot_namer
140
+ CapybaraScreenshotDiff.screenshot_namer
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ class BacktraceFilter
5
+ LIB_DIRECTORY = File.expand_path(File.join(File.dirname(__FILE__), "..")) + File::SEPARATOR
6
+
7
+ def initialize(lib_directory = LIB_DIRECTORY)
8
+ # Trailing separator keeps the prefix match on a directory boundary,
9
+ # so "/app/lib" does not also reject "/app/library".
10
+ @lib_directory = lib_directory.end_with?(File::SEPARATOR) ? lib_directory : lib_directory + File::SEPARATOR
11
+ end
12
+
13
+ # Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
14
+ # @param backtrace [Array<String>]
15
+ # @return [Array<String>]
16
+ def filtered(backtrace)
17
+ backtrace
18
+ .reject { |location| File.expand_path(location).start_with?(@lib_directory) }
19
+ .reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
20
+ end
21
+ end
22
+
23
+ # @private
24
+ class ErrorWithFilteredBacktrace < StandardError
25
+ # @private
26
+ def initialize(message = nil, backtrace = [])
27
+ super(message)
28
+ filter = BacktraceFilter.new
29
+ set_backtrace(filter.filtered(backtrace))
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # Handles image preprocessing operations (skip_area and median filtering)
5
+ #
6
+ # This class applies preprocessing filters to images before comparison,
7
+ # such as masking specific regions (skip_area) or applying noise reduction.
8
+ # It's designed to work with either direct image objects or with options.
9
+ class ImagePreprocessor
10
+ attr_reader :driver, :options
11
+
12
+ def initialize(driver, options = {})
13
+ @driver = driver
14
+ @options = options
15
+ end
16
+
17
+ # Process a comparison object directly
18
+ # This allows reusing the comparison's existing options
19
+ # @param [Comparison] comparison the comparison object
20
+ # @return [Comparison] the comparison object
21
+ def process_comparison(comparison)
22
+ # Process both images
23
+ comparison.base_image = process_image(comparison.base_image, comparison.base_image_path)
24
+ comparison.new_image = process_image(comparison.new_image, comparison.new_image_path)
25
+
26
+ comparison
27
+ end
28
+
29
+ private
30
+
31
+ def process_image(image, path)
32
+ result = image
33
+ result = apply_skip_area(result) if skip_area
34
+ result = apply_median_filter(result, path) if median_filter_window_size
35
+ result
36
+ end
37
+
38
+ def apply_skip_area(image)
39
+ skip_area.reduce(image) do |result, region|
40
+ driver.add_black_box(result, region)
41
+ end
42
+ end
43
+
44
+ def apply_median_filter(image, path)
45
+ if driver.supports?(:filter_image_with_median)
46
+ driver.filter_image_with_median(image, median_filter_window_size)
47
+ else
48
+ warn_about_skipped_median_filter(path)
49
+ image
50
+ end
51
+ end
52
+
53
+ def warn_about_skipped_median_filter(path)
54
+ warn(
55
+ "[capybara-screenshot-diff] Median filter has been skipped for #{path} " \
56
+ "because it is not supported by #{driver.class}"
57
+ )
58
+ end
59
+
60
+ def skip_area
61
+ options[:skip_area]
62
+ end
63
+
64
+ def median_filter_window_size
65
+ options[:median_filter_window_size]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # See the matching comment in integrations/minitest.rb.
4
+ require_relative "../dsl"
5
+ require "capybara_screenshot_diff/screenshot_assertion"
6
+
7
+ World(::SnapDiff::DSL)
8
+
9
+ Before do
10
+ Capybara::Screenshot::Diff.delayed = false
11
+ SnapDiff::BrowserHelpers.resize_window_if_needed
12
+ end
13
+
14
+ After do |scenario|
15
+ if !scenario.failed? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
16
+ skip_this_scenario(msg)
17
+ end
18
+ ensure
19
+ CapybaraScreenshotDiff.reset
20
+ end
21
+
22
+ AfterAll { CapybaraScreenshotDiff.finalize_reporters! }
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "../dsl"
5
+ # The CapybaraScreenshotDiff.verify/.reset/.pending_screenshots_message/
6
+ # .finalize_reporters! calls below need the registry singleton machinery,
7
+ # which lives in the *old* capybara_screenshot_diff/screenshot_assertion.rb
8
+ # file (deliberately not moved -- see that file's own comment) and is no
9
+ # longer pulled in transitively by requiring dsl.rb alone.
10
+ require "capybara_screenshot_diff/screenshot_assertion"
11
+
12
+ used_deprecated_entrypoint = caller.any? do |path|
13
+ path.include?("capybara-screenshot-diff.rb") || path.include?("capybara/screenshot/diff.rb")
14
+ end
15
+
16
+ if used_deprecated_entrypoint
17
+ warn <<~MSG
18
+ [DEPRECATION] The default activation of `capybara_screenshot_diff/minitest` will be removed.
19
+ Please `require "capybara_screenshot_diff/minitest"` explicitly.
20
+ MSG
21
+ end
22
+
23
+ module SnapDiff
24
+ module Minitest
25
+ module Assertions
26
+ include ::SnapDiff::DSL
27
+
28
+ def assert_matches_screenshot(*args, skip_stack_frames: 0, **opts)
29
+ self.assertions += 1
30
+
31
+ super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
32
+ rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e
33
+ raise ::Minitest::Assertion, e.message
34
+ end
35
+
36
+ def setup
37
+ super
38
+ ::SnapDiff::BrowserHelpers.resize_window_if_needed
39
+ end
40
+
41
+ def before_teardown
42
+ super
43
+ CapybaraScreenshotDiff.verify
44
+
45
+ # Computed here (before teardown/reset), but the actual `skip` is
46
+ # deferred to `after_teardown` so a real error raised by the user's
47
+ # `teardown` isn't masked by a pending skip recorded before it ran.
48
+ @capybara_screenshot_diff_pending_message = CapybaraScreenshotDiff.pending_screenshots_message
49
+ rescue CapybaraScreenshotDiff::ExpectationNotMet => e
50
+ assertion = ::Minitest::Assertion.new(e)
51
+ assertion.set_backtrace(e.backtrace)
52
+ failures << assertion
53
+ ensure
54
+ CapybaraScreenshotDiff.reset
55
+ end
56
+
57
+ def after_teardown
58
+ super
59
+
60
+ # Never mask a real failure (from `verify` above or from the user's
61
+ # own `teardown`) with a pending marker.
62
+ if failures.empty? && (msg = @capybara_screenshot_diff_pending_message)
63
+ skip(msg)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ ::Minitest.after_run { CapybaraScreenshotDiff.finalize_reporters! } if ::Minitest.respond_to?(:after_run)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "../dsl"
5
+ # See the matching comment in integrations/minitest.rb.
6
+ require "capybara_screenshot_diff/screenshot_assertion"
7
+
8
+ RSpec::Matchers.define :match_screenshot do |name, **options|
9
+ description { "match screenshot '#{name}'" }
10
+
11
+ match do |_page|
12
+ assert_matches_screenshot(name, **options)
13
+ true
14
+ end
15
+
16
+ failure_message do
17
+ "Expected page to match screenshot '#{name}'"
18
+ end
19
+
20
+ failure_message_when_negated do
21
+ "Expected page not to match screenshot '#{name}'"
22
+ end
23
+ end
24
+
25
+ RSpec.configure do |config|
26
+ config.include SnapDiff::DSL, type: :feature
27
+ config.include SnapDiff::DSL, type: :system
28
+
29
+ config.before do
30
+ if self.class.include?(SnapDiff::DSL)
31
+ SnapDiff::BrowserHelpers.resize_window_if_needed
32
+ end
33
+ end
34
+
35
+ # `append_after` (as opposed to the default `after`, which prepends) adds
36
+ # this hook to the *end* of the after-hook chain regardless of when it's
37
+ # registered relative to the user's own `after`/`config.after` hooks. RSpec
38
+ # runs `after(:each)` hooks in reverse registration order, so a plain
39
+ # `config.after` here would run BEFORE a user hook registered earlier in
40
+ # their own spec_helper (before this file was required) -- and if that
41
+ # later-running user hook raises, its exception gets folded into
42
+ # `pending_exception` instead of `example.exception`, silently masking the
43
+ # failure behind our pending skip. `append_after` runs after the full user
44
+ # after-chain no matter the registration order, closing that gap.
45
+ config.append_after do |example|
46
+ if self.class.include?(SnapDiff::DSL)
47
+ begin
48
+ CapybaraScreenshotDiff.verify
49
+
50
+ # Never mask a real failure with a pending marker. Kept as
51
+ # defense-in-depth: `append_after` observes failures from plain
52
+ # `after`/`prepend_after` user hooks, but appended hooks run FIFO,
53
+ # so a user `append_after` registered after this gem still runs
54
+ # later than us — RSpec has no "run absolutely last" construct.
55
+ # Mitigation for such consumers: require this gem last.
56
+ if example.exception.nil? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
57
+ skip(msg)
58
+ end
59
+ rescue CapybaraScreenshotDiff::ExpectationNotMet => e
60
+ raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
61
+ ensure
62
+ CapybaraScreenshotDiff.reset
63
+ end
64
+ end
65
+ end
66
+
67
+ config.after(:suite) { CapybaraScreenshotDiff.finalize_reporters! }
68
+ end