capybara-screenshot-diff 1.10.3 → 1.12.0
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 +64 -0
- data/Rakefile +29 -1
- data/capybara-screenshot-diff.gemspec +4 -3
- data/docs/RELEASE_PREP.md +58 -0
- data/docs/UPGRADING.md +390 -0
- data/docs/ci-integration.md +208 -0
- data/docs/configuration.md +379 -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_web_ui.png +0 -0
- data/docs/organization.md +226 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +97 -0
- data/gems.rb +2 -1
- data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
- data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
- data/lib/capybara/screenshot/diff/comparison.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +40 -3
- data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
- data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +63 -36
- data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
- data/lib/capybara/screenshot/diff/vcs.rb +19 -52
- data/lib/capybara/screenshot/diff/version.rb +1 -1
- data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +2 -0
- data/lib/capybara_screenshot_diff/dsl.rb +102 -7
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
- data/lib/capybara_screenshot_diff/minitest.rb +4 -2
- data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
- data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
- data/lib/capybara_screenshot_diff/rspec.rb +12 -2
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +61 -23
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
- data/lib/capybara_screenshot_diff/snap.rb +14 -3
- data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
- data/lib/capybara_screenshot_diff/static.rb +11 -0
- data/lib/capybara_screenshot_diff.rb +30 -5
- metadata +47 -8
- data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara/screenshot/diff/comparison"
|
|
4
|
+
require "capybara/screenshot/diff/difference"
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Screenshot
|
|
8
|
+
module Diff
|
|
9
|
+
# Analyzes image differences with configurable tolerance levels.
|
|
10
|
+
#
|
|
11
|
+
# This class implements the core comparison logic for detecting visual differences
|
|
12
|
+
# between images while accounting for various tolerances and optimizations.
|
|
13
|
+
#
|
|
14
|
+
# The comparison process follows these steps:
|
|
15
|
+
# 1. Dimension Check (Fastest)
|
|
16
|
+
# - Compares image dimensions first for quick rejection
|
|
17
|
+
# - Different dimensions always indicate a difference
|
|
18
|
+
#
|
|
19
|
+
# 2. Pixel Equality Check (Fast)
|
|
20
|
+
# - Performs bitwise comparison if dimensions match
|
|
21
|
+
# - Returns immediately if images are exactly identical
|
|
22
|
+
#
|
|
23
|
+
# 3. Tolerant Comparison (Slower)
|
|
24
|
+
# - Only runs if quick checks don't determine equality
|
|
25
|
+
# - Respects configured tolerances for color and shift differences
|
|
26
|
+
# - Can ignore specific regions (skip_area)
|
|
27
|
+
# - Considers anti-aliasing and sub-pixel rendering differences
|
|
28
|
+
#
|
|
29
|
+
# The class is designed to be stateless and thread-safe, with all configuration
|
|
30
|
+
# passed in through the constructor.
|
|
31
|
+
class DifferenceFinder
|
|
32
|
+
TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :driver, :options
|
|
35
|
+
|
|
36
|
+
# Creates a new DifferenceFinder instance.
|
|
37
|
+
#
|
|
38
|
+
# @param driver [Drivers::Base] The image processing driver to use.
|
|
39
|
+
# Must implement the driver interface expected by DifferenceFinder.
|
|
40
|
+
# @param options [Hash] Configuration options for the comparison:
|
|
41
|
+
# @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0).
|
|
42
|
+
# @option options [Numeric] :color_distance_limit Maximum allowed color distance.
|
|
43
|
+
# @option options [Numeric] :shift_distance_limit Maximum allowed shift distance.
|
|
44
|
+
# @option options [Numeric] :area_size_limit Maximum allowed difference area size.
|
|
45
|
+
# @option options [Array<Array>] :skip_area Regions to exclude from comparison.
|
|
46
|
+
def initialize(driver, options)
|
|
47
|
+
@driver = driver
|
|
48
|
+
@options = options
|
|
49
|
+
@without_tolerable_options = (options.keys & TOLERABLE_OPTIONS).empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Analyzes the comparison and determines if images are different.
|
|
53
|
+
#
|
|
54
|
+
# @param comparison [Comparison] The comparison object containing images to analyze.
|
|
55
|
+
# @param quick_mode [Boolean] When true, performs minimal checks and returns early.
|
|
56
|
+
# In quick mode, returns [is_equal, difference] where:
|
|
57
|
+
# - is_equal is true if images are considered equal
|
|
58
|
+
# - difference is a Difference object or nil
|
|
59
|
+
# When false, returns a Difference object directly.
|
|
60
|
+
# @return [Array, Difference] Result format depends on quick_mode parameter.
|
|
61
|
+
# @raise [ArgumentError] If the comparison object is invalid.
|
|
62
|
+
def call(comparison, quick_mode: true)
|
|
63
|
+
# Process the comparison and return result
|
|
64
|
+
|
|
65
|
+
# Handle dimension differences
|
|
66
|
+
unless driver.same_dimension?(comparison)
|
|
67
|
+
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
|
|
68
|
+
return quick_mode ? [false, result] : result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle identical pixels
|
|
72
|
+
if driver.same_pixels?(comparison)
|
|
73
|
+
result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
|
|
74
|
+
return quick_mode ? [true, result] : result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Handle early return for non-tolerable options
|
|
78
|
+
if quick_mode && without_tolerable_options?
|
|
79
|
+
return [false, nil]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Process difference region
|
|
83
|
+
region = driver.find_difference_region(comparison)
|
|
84
|
+
|
|
85
|
+
# Only create a proper difference object if we've completed the comparison
|
|
86
|
+
quick_mode ? [!region.different?, region] : region
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def without_tolerable_options?
|
|
92
|
+
@without_tolerable_options
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -24,10 +24,6 @@ module Capybara
|
|
|
24
24
|
_load_images(old_bytes, new_bytes)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def filter_image_with_median(_image)
|
|
28
|
-
raise NotImplementedError
|
|
29
|
-
end
|
|
30
|
-
|
|
31
27
|
def add_black_box(image, _region)
|
|
32
28
|
image
|
|
33
29
|
end
|
|
@@ -217,22 +213,24 @@ module Capybara
|
|
|
217
213
|
|
|
218
214
|
def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
|
|
219
215
|
org_color = old_img[x, y]
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
216
|
+
unless shift_distance_limit
|
|
217
|
+
return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
start_x = [0, x - shift_distance_limit].max
|
|
221
|
+
end_x = [x + shift_distance_limit, new_img.width - 1].min
|
|
222
|
+
start_y = [0, y - shift_distance_limit].max
|
|
223
|
+
end_y = [y + shift_distance_limit, new_img.height - 1].min
|
|
224
|
+
|
|
225
|
+
min_distance = Float::INFINITY
|
|
226
|
+
(start_y..end_y).each do |dy|
|
|
227
|
+
(start_x..end_x).each do |dx|
|
|
228
|
+
distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
|
|
229
|
+
return 0 if distance == 0
|
|
230
|
+
min_distance = distance if distance < min_distance
|
|
231
231
|
end
|
|
232
|
-
distances.min
|
|
233
|
-
else
|
|
234
|
-
ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
235
232
|
end
|
|
233
|
+
min_distance
|
|
236
234
|
end
|
|
237
235
|
|
|
238
236
|
def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
|
|
@@ -254,10 +252,10 @@ module Capybara
|
|
|
254
252
|
if (x - shift_distance) >= 0 # left
|
|
255
253
|
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
256
254
|
.each do |dy|
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
|
|
256
|
+
return shift_distance
|
|
257
|
+
end
|
|
259
258
|
end
|
|
260
|
-
end
|
|
261
259
|
else
|
|
262
260
|
bounds_breached += 1
|
|
263
261
|
end
|
|
@@ -273,10 +271,10 @@ module Capybara
|
|
|
273
271
|
if (x + shift_distance) < new_img.width # right
|
|
274
272
|
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
275
273
|
.each do |dy|
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
|
|
275
|
+
return shift_distance
|
|
276
|
+
end
|
|
278
277
|
end
|
|
279
|
-
end
|
|
280
278
|
else
|
|
281
279
|
bounds_breached += 1
|
|
282
280
|
end
|
|
@@ -15,15 +15,18 @@ module Capybara
|
|
|
15
15
|
# Compare two images and determine if they are equal, different, or within some comparison
|
|
16
16
|
# range considering color values and difference area size.
|
|
17
17
|
module Drivers
|
|
18
|
-
DEFAULT_HIGHLIGHT_COLOR = [255, 0, 0, 255].freeze
|
|
19
|
-
|
|
20
18
|
class VipsDriver < BaseDriver
|
|
21
19
|
def find_difference_region(comparison)
|
|
22
20
|
new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
|
|
23
21
|
|
|
24
|
-
diff_mask =
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
diff_mask = if options[:perceptual_threshold]
|
|
23
|
+
self.class.perceptual_difference_mask(base_image, new_image, options[:perceptual_threshold])
|
|
24
|
+
else
|
|
25
|
+
self.class.difference_mask(base_image, new_image, options[:color_distance_limit])
|
|
26
|
+
end
|
|
27
|
+
region = self.class.difference_region_by(diff_mask)
|
|
28
|
+
# TODO: schedule research when we got this case for VIPs
|
|
29
|
+
# region = nil if region && region_covers_entire_image?(region, base_image)
|
|
27
30
|
|
|
28
31
|
result = Difference.new(region, {}, comparison)
|
|
29
32
|
|
|
@@ -40,7 +43,7 @@ module Capybara
|
|
|
40
43
|
rescue Vips::Error => e
|
|
41
44
|
warn(
|
|
42
45
|
"[capybara-screenshot-diff] Crop has been failed for " \
|
|
43
|
-
|
|
46
|
+
"{ region: #{region.to_top_left_corner_coordinates.inspect}, image: #{dimension(i).join("x")} }"
|
|
44
47
|
)
|
|
45
48
|
raise e
|
|
46
49
|
end
|
|
@@ -56,7 +59,7 @@ module Capybara
|
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
def difference_level(diff_mask, old_img, _region = nil)
|
|
59
|
-
|
|
62
|
+
self.class.difference_area_size_by(diff_mask).to_f / image_area_size(old_img)
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
MAX_FILENAME_LENGTH = 200
|
|
@@ -89,10 +92,6 @@ module Capybara
|
|
|
89
92
|
result
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
def dimension(image)
|
|
93
|
-
[width_for(image), height_for(image)]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
95
|
def draw_rectangles(images, region, rgba, offset: 0)
|
|
97
96
|
images.map do |image|
|
|
98
97
|
image.draw_rect(rgba, region.left - offset, region.top - offset, region.width + (offset * 2), region.height + (offset * 2))
|
|
@@ -107,37 +106,51 @@ module Capybara
|
|
|
107
106
|
base_image.composite2(new_image, :over)
|
|
108
107
|
end
|
|
109
108
|
|
|
110
|
-
def highlight_mask(diff_mask, merged_image, color:
|
|
109
|
+
def highlight_mask(diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
|
|
111
110
|
diff_mask.ifthenelse(color, merged_image * 0.75)
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
private
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
region.width == width_for(base_image)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
class VipsUtil
|
|
124
|
-
def self.difference_area(old_image, new_image, color_distance: 0)
|
|
125
|
-
difference_mask = difference_mask(new_image, old_image, color_distance)
|
|
126
|
-
difference_area_size_by(difference_mask)
|
|
115
|
+
class << self
|
|
116
|
+
def difference_area(old_image, new_image, color_distance: 0)
|
|
117
|
+
mask = difference_mask(new_image, old_image, color_distance)
|
|
118
|
+
difference_area_size_by(mask)
|
|
127
119
|
end
|
|
128
120
|
|
|
129
|
-
def
|
|
121
|
+
def difference_area_size_by(difference_mask)
|
|
130
122
|
diff_mask = difference_mask == 0
|
|
131
123
|
diff_mask.hist_find.to_a[0][0].max
|
|
132
124
|
end
|
|
133
125
|
|
|
134
|
-
def
|
|
126
|
+
def difference_mask(base_image, new_image, color_distance = nil)
|
|
135
127
|
result = (new_image - base_image).abs
|
|
136
|
-
|
|
137
128
|
color_distance ? result > color_distance : result
|
|
138
129
|
end
|
|
139
130
|
|
|
140
|
-
def
|
|
131
|
+
def perceptual_difference_mask(base_image, new_image, threshold = 2.0)
|
|
132
|
+
color_diff = perceptual_color_diff(base_image, new_image) > threshold
|
|
133
|
+
alpha_diff = alpha_channel_diff(base_image, new_image)
|
|
134
|
+
alpha_diff ? (color_diff | alpha_diff) : color_diff
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def perceptual_color_diff(base_image, new_image)
|
|
138
|
+
base_rgb = (base_image.bands > 3) ? base_image.extract_band(0, n: 3) : base_image
|
|
139
|
+
new_rgb = (new_image.bands > 3) ? new_image.extract_band(0, n: 3) : new_image
|
|
140
|
+
base_lab = base_rgb.colourspace(:lab)
|
|
141
|
+
new_lab = new_rgb.colourspace(:lab)
|
|
142
|
+
base_lab.dE00(new_lab)
|
|
143
|
+
rescue Vips::Error
|
|
144
|
+
base_lab.dE76(new_lab)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def alpha_channel_diff(base_image, new_image)
|
|
148
|
+
return unless base_image.bands > 3 && new_image.bands > 3
|
|
149
|
+
|
|
150
|
+
(base_image.extract_band(3) - new_image.extract_band(3)).abs > 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def difference_region_by(diff_mask)
|
|
141
154
|
columns, rows = diff_mask.bandor.project
|
|
142
155
|
|
|
143
156
|
left = columns.profile[1].min
|
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
3
6
|
require "capybara/screenshot/diff/comparison"
|
|
7
|
+
require "capybara/screenshot/diff/image_preprocessor"
|
|
8
|
+
require "capybara/screenshot/diff/difference_finder"
|
|
9
|
+
require "capybara/screenshot/diff/reporters/default"
|
|
4
10
|
|
|
5
11
|
module Capybara
|
|
6
12
|
module Screenshot
|
|
7
13
|
module Diff
|
|
8
14
|
LOADED_DRIVERS = {}
|
|
9
15
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
16
|
+
# Handles comparison of two images with a focus on performance and accuracy.
|
|
17
|
+
#
|
|
18
|
+
# This class implements a multi-layered optimization strategy for image comparison:
|
|
19
|
+
#
|
|
20
|
+
# 1. Early File-based Checks (Fastest):
|
|
21
|
+
# - Verifies both images exist (raises ArgumentError if not)
|
|
22
|
+
# - Compares file sizes (different sizes → different images)
|
|
23
|
+
# - Performs byte-by-byte comparison for identical files (exact match)
|
|
24
|
+
#
|
|
25
|
+
# 2. Quick Comparison (Fast):
|
|
26
|
+
# - Compares image dimensions (different dimensions → different images)
|
|
27
|
+
# - Performs pixel-by-pixel comparison if dimensions match
|
|
28
|
+
#
|
|
29
|
+
# 3. Detailed Analysis (Slower):
|
|
30
|
+
# - Only performed if quick comparison finds differences
|
|
31
|
+
# - Handles anti-aliasing, color tolerance, and shift detection
|
|
32
|
+
# - Respects skip_area and other comparison parameters
|
|
33
|
+
#
|
|
34
|
+
# This layered approach ensures optimal performance by:
|
|
35
|
+
# - Using the fastest possible method for early rejection
|
|
36
|
+
# - Only performing expensive operations when absolutely necessary
|
|
37
|
+
# - Maintaining high accuracy for complex comparisons
|
|
12
38
|
class ImageCompare
|
|
13
|
-
TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
|
|
14
|
-
|
|
15
39
|
attr_reader :driver, :driver_options
|
|
16
40
|
attr_reader :image_path, :base_image_path
|
|
17
41
|
attr_reader :difference, :error_message
|
|
@@ -20,41 +44,48 @@ module Capybara
|
|
|
20
44
|
@image_path = Pathname.new(image_path)
|
|
21
45
|
@base_image_path = Pathname.new(base_image_path)
|
|
22
46
|
|
|
23
|
-
|
|
47
|
+
ensure_files_exist!
|
|
24
48
|
|
|
49
|
+
@driver_options = options.freeze
|
|
25
50
|
@driver = Drivers.for(@driver_options)
|
|
26
51
|
end
|
|
27
52
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
53
|
+
# Performs a quick comparison of two image files.
|
|
54
|
+
#
|
|
55
|
+
# This method is optimized for speed and will return as soon as a difference is found.
|
|
56
|
+
# It's used for fast rejection before performing more expensive comparisons.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
# - `true` if images are exactly identical (byte-for-byte match)
|
|
60
|
+
# - `false` if images are different or if a quick difference is detected
|
|
61
|
+
#
|
|
62
|
+
# @note This method will raise ArgumentError if either image file is missing.
|
|
30
63
|
def quick_equal?
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# NOTE: This is very fuzzy logic, but so far it's helps to support current performance.
|
|
34
|
-
return true if new_file_size == old_file_size
|
|
35
|
-
|
|
36
|
-
comparison = load_and_process_images
|
|
37
|
-
|
|
38
|
-
unless driver.same_dimension?(comparison)
|
|
39
|
-
self.difference = build_failed_difference(comparison, {different_dimensions: true})
|
|
40
|
-
return false
|
|
64
|
+
if base_image_path.size == image_path.size
|
|
65
|
+
return true if files_identical?(base_image_path, image_path)
|
|
41
66
|
end
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# NOTE: Could not make any difference to be tolerable, so skip and return as not equal.
|
|
49
|
-
return false if without_tolerable_options?
|
|
50
|
-
|
|
51
|
-
self.difference = driver.find_difference_region(comparison)
|
|
68
|
+
result, difference = find_difference(quick_mode: true)
|
|
69
|
+
self.difference = difference
|
|
70
|
+
result
|
|
71
|
+
end
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
def ensure_files_exist!
|
|
74
|
+
raise ArgumentError, "There is no original (base) screenshot located at #{@base_image_path}" unless @base_image_path.exist?
|
|
75
|
+
raise ArgumentError, "There is no new screenshot located at #{@image_path}" unless @image_path.exist?
|
|
54
76
|
end
|
|
55
77
|
|
|
56
|
-
#
|
|
57
|
-
#
|
|
78
|
+
# Determines if the images are different according to the comparison rules.
|
|
79
|
+
#
|
|
80
|
+
# This method performs a full comparison if not already done, including any
|
|
81
|
+
# configured tolerances for color differences and shift distances.
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
# - `true` if the images are different beyond configured tolerances
|
|
85
|
+
# - `false` if the images are considered identical
|
|
86
|
+
#
|
|
87
|
+
# @see #processed
|
|
88
|
+
# @see DifferenceFinder
|
|
58
89
|
def different?
|
|
59
90
|
processed.difference.different?
|
|
60
91
|
end
|
|
@@ -64,10 +95,7 @@ module Capybara
|
|
|
64
95
|
end
|
|
65
96
|
|
|
66
97
|
def reporter
|
|
67
|
-
@reporter ||=
|
|
68
|
-
current_difference = difference || build_no_difference(nil)
|
|
69
|
-
Capybara::Screenshot::Diff::Reporters::Default.new(current_difference)
|
|
70
|
-
end
|
|
98
|
+
@reporter ||= build_reporter
|
|
71
99
|
end
|
|
72
100
|
|
|
73
101
|
def processed?
|
|
@@ -75,122 +103,83 @@ module Capybara
|
|
|
75
103
|
end
|
|
76
104
|
|
|
77
105
|
def processed
|
|
78
|
-
self.difference = find_difference unless processed?
|
|
106
|
+
self.difference = find_difference(quick_mode: false) unless processed?
|
|
79
107
|
@error_message ||= reporter.generate
|
|
80
108
|
self
|
|
81
109
|
end
|
|
82
110
|
|
|
83
111
|
private
|
|
84
112
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
comparison = load_and_process_images
|
|
89
|
-
|
|
90
|
-
unless driver.same_dimension?(comparison)
|
|
91
|
-
return build_failed_difference(comparison, {different_dimensions: true})
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
if driver.same_pixels?(comparison)
|
|
95
|
-
build_no_difference(comparison)
|
|
96
|
-
else
|
|
97
|
-
driver.find_difference_region(comparison)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def require_images_exists!
|
|
102
|
-
raise ArgumentError, "There is no original (base) screenshot version to compare, located: #{base_image_path}" unless base_image_path.exist?
|
|
103
|
-
raise ArgumentError, "There is no new screenshot version to compare, located: #{image_path}" unless image_path.exist?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def difference=(new_difference)
|
|
107
|
-
@error_message = nil
|
|
108
|
-
@reporter = nil
|
|
109
|
-
@difference = new_difference
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def image_files_exist?
|
|
113
|
-
@base_image_path.exist? && @image_path.exist?
|
|
113
|
+
def difference_finder
|
|
114
|
+
@difference_finder ||= DifferenceFinder.new(driver, driver_options)
|
|
114
115
|
end
|
|
115
116
|
|
|
116
|
-
def
|
|
117
|
-
(
|
|
117
|
+
def load_images_and_build_comparison(base_path, new_path, options)
|
|
118
|
+
base_img, new_img = driver.load_images(base_path, new_path)
|
|
119
|
+
Comparison.new(new_img, base_img, options, driver, new_path, base_path)
|
|
118
120
|
end
|
|
119
121
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
nil,
|
|
123
|
-
{difference_level: nil, max_color_distance: 0},
|
|
124
|
-
comparison,
|
|
125
|
-
failed_by
|
|
126
|
-
)
|
|
122
|
+
def image_preprocessor
|
|
123
|
+
@image_preprocessor ||= ImagePreprocessor.new(driver, driver_options)
|
|
127
124
|
end
|
|
128
125
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
Comparison.new(new_image, base_image, @driver_options, driver, image_path, base_image_path)
|
|
133
|
-
end
|
|
126
|
+
def find_difference(quick_mode: false)
|
|
127
|
+
# Validate images exist
|
|
128
|
+
return build_null_difference("missing_image") unless images_exist?
|
|
134
129
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def median_filter_window_size
|
|
140
|
-
@driver_options[:median_filter_window_size]
|
|
141
|
-
end
|
|
130
|
+
# Create comparison with preprocessed images
|
|
131
|
+
comparison = load_comparison(base_image_path, image_path, driver_options)
|
|
142
132
|
|
|
143
|
-
|
|
144
|
-
|
|
133
|
+
# Use difference finder to analyze the comparison
|
|
134
|
+
difference_finder.call(comparison, quick_mode: quick_mode)
|
|
145
135
|
end
|
|
146
136
|
|
|
147
|
-
def
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
result = ignore_skipped_area(result)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
if median_filter_window_size
|
|
155
|
-
if driver.is_a?(Drivers::VipsDriver)
|
|
156
|
-
result = blur_image_by(image, median_filter_window_size)
|
|
157
|
-
else
|
|
158
|
-
warn(
|
|
159
|
-
"[capybara-screenshot-diff] Median filter has been skipped for #{image_path} " \
|
|
160
|
-
"because it is not supported by #{driver.class.name}"
|
|
161
|
-
)
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
result
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def blur_image_by(image, size)
|
|
169
|
-
driver.filter_image_with_median(image, size)
|
|
137
|
+
def difference=(new_difference)
|
|
138
|
+
@error_message = nil
|
|
139
|
+
@reporter = nil
|
|
140
|
+
@difference = new_difference
|
|
170
141
|
end
|
|
171
142
|
|
|
172
|
-
def
|
|
173
|
-
|
|
143
|
+
def build_reporter
|
|
144
|
+
current_difference = difference || build_null_difference
|
|
145
|
+
Reporters::Default.new(current_difference)
|
|
174
146
|
end
|
|
175
147
|
|
|
176
|
-
|
|
177
|
-
|
|
148
|
+
# Loads and preprocesses images for detailed comparison.
|
|
149
|
+
#
|
|
150
|
+
# This method is responsible for:
|
|
151
|
+
# 1. Loading both images using the configured driver
|
|
152
|
+
# 2. Applying any necessary preprocessing (cropping, normalization)
|
|
153
|
+
# 3. Creating a Comparison object that holds the image data
|
|
154
|
+
#
|
|
155
|
+
# @param base_path [String,Pathname] Path to the baseline/reference image
|
|
156
|
+
# @param new_path [String,Pathname] Path to the new/candidate image
|
|
157
|
+
# @param options [Hash] Comparison options including:
|
|
158
|
+
# - :crop [Array<Integer>] Optional crop area [x, y, width, height]
|
|
159
|
+
# - :skip_area [Array<Array>] Areas to exclude from comparison
|
|
160
|
+
# - :tolerance [Numeric] Color tolerance threshold
|
|
161
|
+
# @return [Comparison] Prepared comparison object ready for analysis
|
|
162
|
+
# @raise [ArgumentError] If image files are invalid or unreadable
|
|
163
|
+
def load_comparison(base_path, new_path, options)
|
|
164
|
+
comparison = load_images_and_build_comparison(base_path, new_path, options)
|
|
165
|
+
image_preprocessor.process_comparison(comparison)
|
|
178
166
|
end
|
|
179
167
|
|
|
180
|
-
def
|
|
181
|
-
image_path.
|
|
168
|
+
def build_null_difference(failed_by = nil)
|
|
169
|
+
comparison = Comparison.new(nil, nil, driver_options, driver, image_path, base_image_path).freeze
|
|
170
|
+
Difference.build_null(comparison, base_image_path, image_path, failed_by)
|
|
182
171
|
end
|
|
183
172
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
{difference_level: nil, max_color_distance: 0},
|
|
188
|
-
comparison || build_comparison
|
|
189
|
-
).freeze
|
|
173
|
+
# Check if both images exist
|
|
174
|
+
def images_exist?
|
|
175
|
+
base_image_path.exist? && image_path.exist?
|
|
190
176
|
end
|
|
191
177
|
|
|
192
|
-
|
|
193
|
-
|
|
178
|
+
# Check if files are identical by content
|
|
179
|
+
def files_identical?(file1, file2)
|
|
180
|
+
FileUtils.compare_file(file1, file2)
|
|
181
|
+
rescue SystemCallError, IOError
|
|
182
|
+
false
|
|
194
183
|
end
|
|
195
184
|
end
|
|
196
185
|
end
|