snap_diff-capybara 0.0.1 → 2.0.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -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 +475 -0
- data/docs/architecture.md +267 -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,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,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
|