capybara-screenshot-diff 1.15.0 → 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 +59 -0
- data/docs/RELEASE_PREP.md +6 -6
- data/docs/thread_safety.md +15 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
- data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
- data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
- data/lib/capybara/screenshot/diff/difference.rb +6 -102
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
- data/lib/capybara/screenshot/diff/drivers.rb +7 -14
- data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
- data/lib/capybara/screenshot/diff/os.rb +8 -13
- data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
- data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
- data/lib/capybara/screenshot/diff/utils.rb +5 -40
- data/lib/capybara/screenshot/diff/vcs.rb +5 -37
- data/lib/capybara/screenshot/diff/version.rb +7 -1
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
- data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
- data/lib/capybara_screenshot_diff/dsl.rb +7 -129
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
- data/lib/capybara_screenshot_diff/minitest.rb +6 -56
- data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
- data/lib/capybara_screenshot_diff/rspec.rb +5 -63
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
- data/lib/capybara_screenshot_diff/snap.rb +5 -64
- data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
- data/lib/capybara_screenshot_diff/static.rb +3 -5
- data/lib/capybara_screenshot_diff.rb +29 -100
- 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 +6 -2
- 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/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 +22 -6
- metadata +36 -2
- /data/lib/{capybara_screenshot_diff → snap_diff}/reporters/templates/report.html.erb +0 -0
|
@@ -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
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "snap_diff/deprecation"
|
|
4
|
+
require "snap_diff/drivers"
|
|
5
|
+
|
|
6
|
+
# ADR-004 v2 step 6: const_missing-based forwarders for the pre-v2
|
|
7
|
+
# namespaces. Every old-name lookup below resolves -- lazily -- to the exact
|
|
8
|
+
# same object as its SnapDiff:: replacement (identity pinned by
|
|
9
|
+
# test/unit/namespace_forwarding_test.rb) and emits a deprecation warning,
|
|
10
|
+
# once per constant per process, silenceable via
|
|
11
|
+
# SnapDiff.silence_deprecations or SNAP_DIFF_SILENCE_DEPRECATIONS=1.
|
|
12
|
+
#
|
|
13
|
+
# Deliberately eager-and-silent exceptions (plain constants, defined by
|
|
14
|
+
# their own forwarder files, never warn):
|
|
15
|
+
#
|
|
16
|
+
# - Capybara::Screenshot::Os and CapybaraScreenshotDiff::DSL (and the
|
|
17
|
+
# unmapped CapybaraScreenshotDiff::Minitest::Assertions): advertised
|
|
18
|
+
# entry-point constants probed with Object.const_defined? by
|
|
19
|
+
# support_load_probe_test.rb -- const_defined? never triggers
|
|
20
|
+
# const_missing, so a lazy shim would break that contract.
|
|
21
|
+
# - Capybara::Screenshot::Diff::VERSION: the gemspec resolves it at build
|
|
22
|
+
# time; a lazy shim would make every `gem build` warn.
|
|
23
|
+
# - Drivers::ChunkyPNGDriver / Drivers::VipsDriver: real constants on the
|
|
24
|
+
# shared SnapDiff::Drivers module (the Drivers alias is same-object by
|
|
25
|
+
# contract), so const_missing can never fire for the leaf names;
|
|
26
|
+
# resolving them through the old path still warns for ...::Drivers.
|
|
27
|
+
module SnapDiff
|
|
28
|
+
# @api private
|
|
29
|
+
module LegacyShims
|
|
30
|
+
# Installs a warn-then-forward const_missing on +namespace+.
|
|
31
|
+
#
|
|
32
|
+
# @param namespace [Module] the old namespace to hook
|
|
33
|
+
# @param old_prefix [String] how the old constant path reads to a human
|
|
34
|
+
# @param mapping [Hash{Symbol => String}] old leaf name => new full name
|
|
35
|
+
def self.install(namespace, old_prefix, mapping)
|
|
36
|
+
namespace.define_singleton_method(:const_missing) do |name|
|
|
37
|
+
target = mapping[name]
|
|
38
|
+
return super(name) unless target
|
|
39
|
+
|
|
40
|
+
Deprecation.warn("#{old_prefix}::#{name}", target, category: :constant)
|
|
41
|
+
Object.const_get(target)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module Capybara
|
|
48
|
+
module Screenshot
|
|
49
|
+
module Diff
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module CapybaraScreenshotDiff
|
|
55
|
+
module Reporters
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
SnapDiff::LegacyShims.install(Capybara::Screenshot, "Capybara::Screenshot", {
|
|
60
|
+
BrowserHelpers: "SnapDiff::BrowserHelpers",
|
|
61
|
+
Screenshoter: "SnapDiff::Screenshoter"
|
|
62
|
+
}.freeze)
|
|
63
|
+
|
|
64
|
+
SnapDiff::LegacyShims.install(Capybara::Screenshot::Diff, "Capybara::Screenshot::Diff", {
|
|
65
|
+
Vcs: "SnapDiff::Vcs",
|
|
66
|
+
StableScreenshoter: "SnapDiff::StableScreenshoter",
|
|
67
|
+
ImagePreprocessor: "SnapDiff::ImagePreprocessor",
|
|
68
|
+
AreaCalculator: "SnapDiff::AreaCalculator",
|
|
69
|
+
AnnotationService: "SnapDiff::AnnotationService",
|
|
70
|
+
Utils: "SnapDiff::Utils",
|
|
71
|
+
ScreenshotMatcher: "SnapDiff::ScreenshotMatcher",
|
|
72
|
+
Drivers: "SnapDiff::Drivers",
|
|
73
|
+
ImageCompare: "SnapDiff::Comparison",
|
|
74
|
+
Difference: "SnapDiff::ComparisonResult"
|
|
75
|
+
}.freeze)
|
|
76
|
+
|
|
77
|
+
SnapDiff::LegacyShims.install(CapybaraScreenshotDiff, "CapybaraScreenshotDiff", {
|
|
78
|
+
RED_RGBA: "SnapDiff::RED_RGBA",
|
|
79
|
+
ORANGE_RGBA: "SnapDiff::ORANGE_RGBA",
|
|
80
|
+
SnapManager: "SnapDiff::SnapManager",
|
|
81
|
+
Snap: "SnapDiff::Snap",
|
|
82
|
+
ScreenshotNamer: "SnapDiff::ScreenshotNamer",
|
|
83
|
+
AttemptsReporter: "SnapDiff::AttemptsReporter",
|
|
84
|
+
BacktraceFilter: "SnapDiff::BacktraceFilter",
|
|
85
|
+
ErrorWithFilteredBacktrace: "SnapDiff::ErrorWithFilteredBacktrace",
|
|
86
|
+
ScreenshotAssertion: "SnapDiff::ScreenshotAssertion",
|
|
87
|
+
AssertionRegistry: "SnapDiff::AssertionRegistry"
|
|
88
|
+
}.freeze)
|
|
89
|
+
|
|
90
|
+
SnapDiff::LegacyShims.install(CapybaraScreenshotDiff::Reporters, "CapybaraScreenshotDiff::Reporters", {
|
|
91
|
+
HTML: "SnapDiff::Reporters::HTML"
|
|
92
|
+
}.freeze)
|
|
93
|
+
|
|
94
|
+
# BaseDriver dissolved into the SnapDiff::Driver mixin (v2 step 4); the
|
|
95
|
+
# Drivers alias is same-object, so the hook has to live on SnapDiff::Drivers
|
|
96
|
+
# itself. `class MyDriver < BaseDriver` becomes `include SnapDiff::Driver`.
|
|
97
|
+
SnapDiff::LegacyShims.install(SnapDiff::Drivers, "Capybara::Screenshot::Diff::Drivers", {
|
|
98
|
+
BaseDriver: "SnapDiff::Driver"
|
|
99
|
+
}.freeze)
|
data/lib/snap_diff/os.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
module Os
|
|
5
|
+
ON_WINDOWS = !!(RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/)
|
|
6
|
+
ON_MAC = !!(RbConfig::CONFIG["host_os"] =~ /darwin/)
|
|
7
|
+
ON_LINUX = !!(RbConfig::CONFIG["host_os"] =~ /linux/)
|
|
8
|
+
|
|
9
|
+
def self.name
|
|
10
|
+
return "windows" if ON_WINDOWS
|
|
11
|
+
return "macos" if ON_MAC
|
|
12
|
+
return "linux" if ON_LINUX
|
|
13
|
+
|
|
14
|
+
"unknown"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "json"
|
|
8
|
+
# The auto-registration block at the bottom of this file calls
|
|
9
|
+
# CapybaraScreenshotDiff.reporters, which needs the registry singleton
|
|
10
|
+
# machinery in the *old* capybara_screenshot_diff/screenshot_assertion.rb
|
|
11
|
+
# file (deliberately not moved -- see that file's own comment).
|
|
12
|
+
require "capybara_screenshot_diff/screenshot_assertion"
|
|
13
|
+
require "capybara/screenshot/diff/config_legacy"
|
|
14
|
+
|
|
15
|
+
module SnapDiff
|
|
16
|
+
module Reporters
|
|
17
|
+
class HTML
|
|
18
|
+
attr_reader :failures, :total
|
|
19
|
+
|
|
20
|
+
def initialize(output_path: nil, embed_images: false)
|
|
21
|
+
@explicit_output_path = output_path
|
|
22
|
+
@embed_images = embed_images
|
|
23
|
+
@failures = []
|
|
24
|
+
@total = 0
|
|
25
|
+
@finalized = false
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record(assertions)
|
|
30
|
+
return if @finalized
|
|
31
|
+
|
|
32
|
+
failures = []
|
|
33
|
+
total = 0
|
|
34
|
+
|
|
35
|
+
assertions.each do |assertion|
|
|
36
|
+
compare = assertion.compare
|
|
37
|
+
next unless compare
|
|
38
|
+
|
|
39
|
+
total += 1
|
|
40
|
+
next unless compare.difference&.different?
|
|
41
|
+
|
|
42
|
+
failures << failure_entry_for(assertion.name, compare)
|
|
43
|
+
rescue => e
|
|
44
|
+
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}" if ENV["DEBUG"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
return if @finalized
|
|
49
|
+
@total += total
|
|
50
|
+
@failures.concat(failures)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def finalize
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return if @finalized
|
|
57
|
+
return if failures.empty?
|
|
58
|
+
|
|
59
|
+
write_report
|
|
60
|
+
@finalized = true
|
|
61
|
+
output_path
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def output_path
|
|
66
|
+
@output_path ||= Pathname.new(@explicit_output_path || self.class.default_output_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def passed = total - failures.size
|
|
70
|
+
def failed = failures.size
|
|
71
|
+
|
|
72
|
+
def summary
|
|
73
|
+
return if total.zero?
|
|
74
|
+
|
|
75
|
+
screenshots_label = (total == 1) ? "1 screenshot" : "#{total} screenshots"
|
|
76
|
+
|
|
77
|
+
if failures.empty?
|
|
78
|
+
"[snap_diff] #{screenshots_label} compared, no failures."
|
|
79
|
+
else
|
|
80
|
+
failures_label = (failures.size == 1) ? "1 failure" : "#{failures.size} failures"
|
|
81
|
+
"[snap_diff] #{screenshots_label} compared, #{failures_label}. Report: #{output_path}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render
|
|
86
|
+
ERB.new(File.read(self.class.template_path)).result(binding)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.template_path
|
|
90
|
+
File.expand_path("templates/report.html.erb", __dir__)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.default_output_path
|
|
94
|
+
root = Capybara::Screenshot.root || Pathname.pwd
|
|
95
|
+
root / Capybara::Screenshot.save_path / "snap_diff_report.html"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def failure_entry_for(name, compare)
|
|
101
|
+
difference = compare.difference
|
|
102
|
+
{
|
|
103
|
+
name: name,
|
|
104
|
+
original: resolve_image(compare.base_image_path),
|
|
105
|
+
new: resolve_image(compare.image_path),
|
|
106
|
+
base_diff: resolve_image(compare.reporter.annotated_base_image_path),
|
|
107
|
+
diff: resolve_image(compare.reporter.annotated_image_path),
|
|
108
|
+
heatmap: resolve_image(compare.reporter.heatmap_diff_path),
|
|
109
|
+
diff_level: difference.ratio && (difference.ratio * 100).round(2),
|
|
110
|
+
area_size: difference.region_area_size,
|
|
111
|
+
max_color_distance: difference.meta[:max_color_distance]&.round(1)
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_image(path)
|
|
116
|
+
return unless path
|
|
117
|
+
|
|
118
|
+
pathname = Pathname.new(path).expand_path
|
|
119
|
+
return unless pathname.exist?
|
|
120
|
+
|
|
121
|
+
@embed_images ? data_uri(pathname) : pathname.relative_path_from(output_path.dirname.expand_path).to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def data_uri(pathname)
|
|
125
|
+
ext = pathname.extname.delete_prefix(".")
|
|
126
|
+
mime = (ext == "webp") ? "image/webp" : "image/png"
|
|
127
|
+
"data:#{mime};base64,#{Base64.strict_encode64(pathname.binread)}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def write_report
|
|
131
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
132
|
+
File.write(output_path, render)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Auto-register reporter.
|
|
139
|
+
# Framework adapters (Minitest, RSpec, Cucumber) call finalize_reporters! via native hooks.
|
|
140
|
+
# For custom frameworks, call CapybaraScreenshotDiff.finalize_reporters! manually.
|
|
141
|
+
unless CapybaraScreenshotDiff.reporters.any?(SnapDiff::Reporters::HTML)
|
|
142
|
+
CapybaraScreenshotDiff.reporters << SnapDiff::Reporters::HTML.new(embed_images: !!ENV["CI"])
|
|
143
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
# Process-global reporter lifecycle: registration, per-test notification,
|
|
5
|
+
# end-of-suite finalization. One list of reporters for the whole process,
|
|
6
|
+
# guarded by one mutex.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately separate from the per-test session lifecycle (the
|
|
9
|
+
# thread-local AssertionRegistry reached through
|
|
10
|
+
# CapybaraScreenshotDiff.registry): reporters outlive any single test,
|
|
11
|
+
# the registry does not. CapybaraScreenshotDiff keeps its public
|
|
12
|
+
# reporters/reporters_mutex/finalize_reporters! methods as thin shims
|
|
13
|
+
# over this module.
|
|
14
|
+
module Reporting
|
|
15
|
+
@reporters = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
attr_reader :reporters, :mutex
|
|
20
|
+
|
|
21
|
+
# Delivers a finished test's assertions to every registered reporter.
|
|
22
|
+
# Iterates over a snapshot so a reporter mutating the list mid-notify
|
|
23
|
+
# cannot affect the current round. A raising reporter is warned about
|
|
24
|
+
# and skipped; the rest are still notified.
|
|
25
|
+
def notify(assertions)
|
|
26
|
+
return if assertions.nil? || assertions.empty?
|
|
27
|
+
|
|
28
|
+
reporters_snapshot = @mutex.synchronize { @reporters.dup }
|
|
29
|
+
return if reporters_snapshot.empty?
|
|
30
|
+
|
|
31
|
+
reporters_snapshot.each do |reporter|
|
|
32
|
+
reporter.record(assertions)
|
|
33
|
+
rescue => e
|
|
34
|
+
warn "[capybara-screenshot-diff] Reporter failed: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# End-of-suite hook: finalizes each reporter and prints its summary.
|
|
39
|
+
# A raising reporter is warned about and skipped; the rest are still
|
|
40
|
+
# finalized.
|
|
41
|
+
def finalize!
|
|
42
|
+
@mutex.synchronize { @reporters.dup }.each do |reporter|
|
|
43
|
+
reporter.finalize
|
|
44
|
+
if (msg = reporter.summary)
|
|
45
|
+
$stdout.puts msg
|
|
46
|
+
end
|
|
47
|
+
rescue => e
|
|
48
|
+
warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "screenshot_namer"
|
|
5
|
+
|
|
6
|
+
module SnapDiff
|
|
7
|
+
class ScreenshotAssertion
|
|
8
|
+
attr_reader :name, :args
|
|
9
|
+
attr_accessor :compare, :caller
|
|
10
|
+
|
|
11
|
+
def initialize(name, **args)
|
|
12
|
+
@name = name
|
|
13
|
+
@args = args
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# One-line debugging summary. Never triggers the (expensive, file-touching)
|
|
17
|
+
# comparison itself: an unprocessed comparison shows as "pending".
|
|
18
|
+
def inspect
|
|
19
|
+
return "#<#{self.class.name} #{name.inspect} (no comparison)>" unless compare
|
|
20
|
+
|
|
21
|
+
state = if compare.processed?
|
|
22
|
+
compare.difference.different? ? "different" : "matches"
|
|
23
|
+
else
|
|
24
|
+
"pending"
|
|
25
|
+
end
|
|
26
|
+
"#<#{self.class.name} #{name.inspect} #{state} new=#{compare.image_path} base=#{compare.base_image_path}>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate
|
|
30
|
+
return unless compare
|
|
31
|
+
|
|
32
|
+
if compare.different?
|
|
33
|
+
"Screenshot does not match for '#{name}': #{compare.error_message}\n#{caller.join("\n")}"
|
|
34
|
+
else
|
|
35
|
+
archive_baseline!
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Commits the baseline after a passing comparison: the base image is
|
|
41
|
+
# moved over the actual image, so the captured screenshot becomes the
|
|
42
|
+
# recorded baseline again. This is the file-mutating half of the verify
|
|
43
|
+
# flow, kept explicit and separate from the pure "are they different?"
|
|
44
|
+
# question. Idempotent: a second call is a no-op.
|
|
45
|
+
def archive_baseline!
|
|
46
|
+
return unless compare && !compare.different? && compare.base_image_path.exist?
|
|
47
|
+
|
|
48
|
+
FileUtils.mv(compare.base_image_path, compare.image_path, force: true)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate!
|
|
52
|
+
error_msg = validate
|
|
53
|
+
|
|
54
|
+
if error_msg
|
|
55
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Verifies that all scheduled screenshots do not show any unintended differences.
|
|
60
|
+
#
|
|
61
|
+
# @param screenshots [Array(Array(Array(String), String, Comparison))] The list of match screenshots jobs. Defaults to all screenshots taken during the test.
|
|
62
|
+
# @return [Array, nil] Returns an array of error messages if there are screenshot differences, otherwise nil.
|
|
63
|
+
# @note This method is typically called at the end of a test to assert all screenshots are as expected.
|
|
64
|
+
def self.verify_screenshots!(screenshots)
|
|
65
|
+
return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
|
|
66
|
+
|
|
67
|
+
test_screenshot_errors = screenshots.map do |assertion|
|
|
68
|
+
assertion.validate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test_screenshot_errors.compact!
|
|
72
|
+
|
|
73
|
+
test_screenshot_errors.empty? ? nil : test_screenshot_errors
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Asserts that an image has not changed compared to its baseline.
|
|
77
|
+
#
|
|
78
|
+
# @param backtrace [Array(String)] The caller context, used for error reporting.
|
|
79
|
+
# @param name [String] The name of the screenshot being verified.
|
|
80
|
+
# @param comparison [Object] The comparison object containing the result and details of the comparison.
|
|
81
|
+
# @return [String, nil] Returns an error message if the screenshot differs from the baseline, otherwise nil.
|
|
82
|
+
# @note Legacy entry point; delegates to the instance verify flow
|
|
83
|
+
# (pure question + explicit #archive_baseline! on pass).
|
|
84
|
+
def self.assert_image_not_changed(backtrace, name, comparison)
|
|
85
|
+
assertion = new(name)
|
|
86
|
+
assertion.caller = backtrace
|
|
87
|
+
assertion.compare = comparison
|
|
88
|
+
assertion.validate
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class AssertionRegistry
|
|
93
|
+
attr_reader :assertions, :screenshot_namer, :new_screenshots
|
|
94
|
+
|
|
95
|
+
def initialize
|
|
96
|
+
@assertions = []
|
|
97
|
+
@new_screenshots = []
|
|
98
|
+
@screenshot_namer = SnapDiff::ScreenshotNamer.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_assertion(assertion)
|
|
102
|
+
return unless assertion&.compare
|
|
103
|
+
|
|
104
|
+
@assertions.push(assertion)
|
|
105
|
+
|
|
106
|
+
assertion
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def assertions_present?
|
|
110
|
+
!@assertions.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_new_screenshot(name)
|
|
114
|
+
@new_screenshots.push(name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def new_screenshots_present?
|
|
118
|
+
!@new_screenshots.empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def verify(screenshots = CapybaraScreenshotDiff.assertions)
|
|
122
|
+
return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
|
|
123
|
+
|
|
124
|
+
failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions
|
|
125
|
+
failed_screenshot = failed_assertions.first
|
|
126
|
+
result = ScreenshotAssertion.verify_screenshots!(screenshots)
|
|
127
|
+
|
|
128
|
+
if result
|
|
129
|
+
raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failed_assertions
|
|
134
|
+
assertions.filter { |screenshot_assert| screenshot_assert.compare&.different? }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reset
|
|
138
|
+
@assertions.clear
|
|
139
|
+
@new_screenshots.clear
|
|
140
|
+
@screenshot_namer = SnapDiff::ScreenshotNamer.new
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|