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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +190 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +65 -0
- data/capybara-screenshot-diff.gemspec +29 -0
- data/docs/RELEASE_PREP.md +44 -0
- data/docs/UPGRADING.md +702 -0
- data/docs/architecture.md +289 -0
- data/docs/ci-integration.md +238 -0
- data/docs/configuration.md +395 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_annotated.png +0 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/migration-guide.md +286 -0
- data/docs/organization.md +204 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +112 -0
- data/gems.rb +39 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
- data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
- data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +8 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers.rb +9 -0
- data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
- data/lib/capybara/screenshot/diff/os.rb +14 -0
- data/lib/capybara/screenshot/diff/region.rb +107 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
- data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/utils.rb +7 -0
- data/lib/capybara/screenshot/diff/vcs.rb +7 -0
- data/lib/capybara/screenshot/diff/version.rb +15 -0
- data/lib/capybara/screenshot/diff.rb +3 -0
- data/lib/capybara-screenshot-diff.rb +3 -0
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
- data/lib/capybara_screenshot_diff/dsl.rb +12 -0
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
- data/lib/capybara_screenshot_diff/minitest.rb +14 -0
- data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
- data/lib/capybara_screenshot_diff/rspec.rb +8 -0
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
- data/lib/capybara_screenshot_diff/snap.rb +7 -0
- data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
- data/lib/capybara_screenshot_diff/static.rb +9 -0
- data/lib/capybara_screenshot_diff.rb +50 -0
- data/lib/snap_diff/annotation_service.rb +84 -0
- data/lib/snap_diff/area_calculator.rb +56 -0
- data/lib/snap_diff/attempts_reporter.rb +51 -0
- data/lib/snap_diff/browser_helpers.rb +121 -0
- data/lib/snap_diff/capture/viewport.rb +40 -0
- data/lib/snap_diff/comparison.rb +238 -0
- data/lib/snap_diff/comparison_result.rb +104 -0
- data/lib/snap_diff/config.rb +78 -0
- data/lib/snap_diff/deprecation.rb +84 -0
- data/lib/snap_diff/driver.rb +37 -0
- data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
- data/lib/snap_diff/drivers/vips_driver.rb +171 -0
- data/lib/snap_diff/drivers.rb +14 -0
- data/lib/snap_diff/dsl.rb +143 -0
- data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
- data/lib/snap_diff/image_preprocessor.rb +68 -0
- data/lib/snap_diff/integrations/cucumber.rb +22 -0
- data/lib/snap_diff/integrations/minitest.rb +70 -0
- data/lib/snap_diff/integrations/rspec.rb +68 -0
- data/lib/snap_diff/legacy_shims.rb +99 -0
- data/lib/snap_diff/os.rb +17 -0
- data/lib/snap_diff/reporters/html.rb +143 -0
- data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
- data/lib/snap_diff/reporting.rb +53 -0
- data/lib/snap_diff/screenshot_assertion.rb +143 -0
- data/lib/snap_diff/screenshot_matcher.rb +113 -0
- data/lib/snap_diff/screenshot_namer.rb +81 -0
- data/lib/snap_diff/screenshoter.rb +129 -0
- data/lib/snap_diff/snap.rb +66 -0
- data/lib/snap_diff/snap_manager.rb +125 -0
- data/lib/snap_diff/stable_screenshoter.rb +103 -0
- data/lib/snap_diff/static.rb +11 -0
- data/lib/snap_diff/utils.rb +38 -0
- data/lib/snap_diff/vcs.rb +35 -0
- data/lib/snap_diff/version.rb +5 -0
- data/lib/snap_diff.rb +65 -0
- metadata +160 -10
- 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
|