capybara-screenshot-diff 1.10.3.1 → 1.13.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 +77 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/Rakefile +29 -1
- data/capybara-screenshot-diff.gemspec +4 -3
- data/docs/RELEASE_PREP.md +42 -0
- data/docs/UPGRADING.md +390 -0
- data/docs/architecture.md +270 -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 +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 +77 -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 +11 -0
- data/lib/capybara_screenshot_diff/dsl.rb +123 -7
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
- data/lib/capybara_screenshot_diff/minitest.rb +10 -5
- 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 +20 -4
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
- 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 +31 -5
- metadata +51 -8
- data/lib/capybara/screenshot/diff/test_methods.rb +0 -157
|
@@ -25,8 +25,7 @@ module Capybara
|
|
|
25
25
|
|
|
26
26
|
@comparison_options = comparison_options
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
@screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), driver)
|
|
28
|
+
@screenshoter = Diff.screenshoter.new(capture_options.except(:stability_time_limit), @comparison_options)
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Takes a comparison screenshot ensuring page stability
|
|
@@ -51,7 +50,7 @@ module Capybara
|
|
|
51
50
|
snapshot.commit_last_attempt
|
|
52
51
|
|
|
53
52
|
# cleanup all previous attempts
|
|
54
|
-
snapshot.cleanup_attempts
|
|
53
|
+
snapshot.cleanup_attempts!
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
def take_stable_screenshot(snapshot)
|
|
@@ -59,16 +58,15 @@ module Capybara
|
|
|
59
58
|
deadline_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
|
|
60
59
|
|
|
61
60
|
# Cleanup all previous attempts for sure
|
|
62
|
-
snapshot.cleanup_attempts
|
|
63
|
-
|
|
64
|
-
0.step do |i|
|
|
65
|
-
# FIXME: it should be wait, and wait should be replaced with stability_time_limit
|
|
66
|
-
sleep(stability_time_limit) unless i == 0 # test prev_attempt_path is nil
|
|
61
|
+
snapshot.cleanup_attempts!
|
|
67
62
|
|
|
63
|
+
loop do
|
|
68
64
|
attempt_next_screenshot(snapshot)
|
|
69
65
|
|
|
70
66
|
return true if attempt_successful?(snapshot)
|
|
71
67
|
return false if timeout?(deadline_at)
|
|
68
|
+
|
|
69
|
+
sleep(stability_time_limit)
|
|
72
70
|
end
|
|
73
71
|
end
|
|
74
72
|
|
|
@@ -100,7 +98,7 @@ module Capybara
|
|
|
100
98
|
attempts_reporter = CapybaraScreenshotDiff::AttemptsReporter.new(snapshot, @comparison_options, {wait: wait, stability_time_limit: stability_time_limit})
|
|
101
99
|
|
|
102
100
|
# TODO: Move fail to the queue after tests passed
|
|
103
|
-
raise CapybaraScreenshotDiff::UnstableImage
|
|
101
|
+
raise CapybaraScreenshotDiff::UnstableImage.new(attempts_reporter.generate, caller)
|
|
104
102
|
end
|
|
105
103
|
end
|
|
106
104
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
3
4
|
require_relative "os"
|
|
4
5
|
|
|
5
6
|
module Capybara
|
|
@@ -7,64 +8,30 @@ module Capybara
|
|
|
7
8
|
module Diff
|
|
8
9
|
module Vcs
|
|
9
10
|
def self.checkout_vcs(root, screenshot_path, checkout_path)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def self.restore_git_revision(screenshot_path, checkout_path = screenshot_path, root:)
|
|
24
|
-
vcs_file_path = screenshot_path.relative_path_from(root)
|
|
25
|
-
redirect_target = "#{checkout_path} #{SILENCE_ERRORS}"
|
|
26
|
-
show_command = "git show HEAD~0:./#{vcs_file_path}"
|
|
27
|
-
|
|
28
|
-
Dir.chdir(root) do
|
|
29
|
-
if Screenshot.use_lfs
|
|
30
|
-
system("#{show_command} > #{checkout_path}.tmp #{SILENCE_ERRORS}", exception: !!ENV["DEBUG"])
|
|
31
|
-
|
|
32
|
-
`git lfs smudge < #{checkout_path}.tmp > #{redirect_target}` if $CHILD_STATUS == 0
|
|
33
|
-
|
|
34
|
-
File.delete "#{checkout_path}.tmp"
|
|
35
|
-
else
|
|
36
|
-
system("#{show_command} > #{redirect_target}", exception: !!ENV["DEBUG"])
|
|
11
|
+
root_path = root.to_s
|
|
12
|
+
git_root, _, status = Open3.capture3("git", "-C", root_path, "rev-parse", "--show-toplevel")
|
|
13
|
+
return false unless status.success?
|
|
14
|
+
|
|
15
|
+
git_root = git_root.chomp
|
|
16
|
+
vcs_file_path = Pathname.new(screenshot_path).expand_path.relative_path_from(Pathname.new(git_root)).to_s
|
|
17
|
+
|
|
18
|
+
if Screenshot.use_lfs
|
|
19
|
+
tmp_path = "#{checkout_path}.tmp"
|
|
20
|
+
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: tmp_path, err: File::NULL)
|
|
21
|
+
if success
|
|
22
|
+
system("git", "-C", root_path, "lfs", "smudge", in: tmp_path, out: checkout_path.to_s, err: File::NULL)
|
|
37
23
|
end
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if $CHILD_STATUS != 0
|
|
41
|
-
checkout_path.delete if checkout_path.exist?
|
|
42
|
-
false
|
|
24
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
43
25
|
else
|
|
44
|
-
|
|
26
|
+
success = system("git", "-C", root_path, "show", "HEAD:#{vcs_file_path}", out: checkout_path.to_s, err: File::NULL)
|
|
45
27
|
end
|
|
46
|
-
end
|
|
47
28
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
FileUtils.cp(committed_file_name, checkout_path)
|
|
52
|
-
return true
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
svn_info = `svn info #{screenshot_path} #{SILENCE_ERRORS}`
|
|
56
|
-
unless svn_info.empty?
|
|
57
|
-
wc_root = svn_info.slice(/(?<=Working Copy Root Path: ).*$/)
|
|
58
|
-
checksum = svn_info.slice(/(?<=Checksum: ).*$/)
|
|
59
|
-
|
|
60
|
-
if checksum
|
|
61
|
-
committed_file_name = "#{wc_root}/.svn/pristine/#{checksum[0..1]}/#{checksum}.svn-base"
|
|
62
|
-
FileUtils.cp(committed_file_name, checkout_path)
|
|
63
|
-
return true
|
|
64
|
-
end
|
|
29
|
+
unless success
|
|
30
|
+
checkout_path.delete if checkout_path.exist?
|
|
31
|
+
return false
|
|
65
32
|
end
|
|
66
33
|
|
|
67
|
-
|
|
34
|
+
true
|
|
68
35
|
end
|
|
69
36
|
end
|
|
70
37
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CapybaraScreenshotDiff
|
|
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
|
+
@lib_directory = lib_directory
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Filters out any backtrace lines originating from the library directory or from gems such as ActiveSupport, Minitest, and Railties
|
|
12
|
+
# @param backtrace [Array<String>]
|
|
13
|
+
# @return [Array<String>]
|
|
14
|
+
def filtered(backtrace)
|
|
15
|
+
backtrace
|
|
16
|
+
.reject { |location| File.expand_path(location).start_with?(@lib_directory) }
|
|
17
|
+
.reject { |l| l =~ /gems\/(activesupport|minitest|railties)/ }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -8,3 +8,14 @@ Before do
|
|
|
8
8
|
Capybara::Screenshot::Diff.delayed = false
|
|
9
9
|
Capybara::Screenshot::BrowserHelpers.resize_window_if_needed
|
|
10
10
|
end
|
|
11
|
+
|
|
12
|
+
After do |scenario|
|
|
13
|
+
if !scenario.failed? && Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
|
|
14
|
+
names = CapybaraScreenshotDiff.new_screenshots
|
|
15
|
+
skip_this_scenario("No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them.")
|
|
16
|
+
end
|
|
17
|
+
ensure
|
|
18
|
+
CapybaraScreenshotDiff.reset
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
AfterAll { CapybaraScreenshotDiff.finalize_reporters! }
|
|
@@ -1,18 +1,134 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "capybara_screenshot_diff"
|
|
4
|
-
require "capybara/screenshot/diff/
|
|
5
|
-
|
|
4
|
+
require "capybara/screenshot/diff/drivers"
|
|
5
|
+
require "capybara/screenshot/diff/image_compare"
|
|
6
|
+
require "capybara/screenshot/diff/screenshot_matcher"
|
|
7
|
+
require "capybara_screenshot_diff/screenshot_namer"
|
|
8
|
+
require "capybara_screenshot_diff/screenshot_assertion"
|
|
6
9
|
|
|
7
10
|
module CapybaraScreenshotDiff
|
|
11
|
+
# DSL for taking screenshots and making assertions in Capybara tests.
|
|
12
|
+
# This module provides methods for taking screenshots, comparing them against baselines,
|
|
13
|
+
# and managing the comparison process with various configuration options.
|
|
14
|
+
#
|
|
15
|
+
# The DSL is designed to be included in your test context (e.g., RSpec, Minitest)
|
|
16
|
+
# to provide screenshot comparison capabilities.
|
|
8
17
|
module DSL
|
|
9
18
|
include Capybara::DSL
|
|
10
|
-
include Capybara::Screenshot::Diff::TestMethods
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
def screenshot_section(name)
|
|
21
|
+
screenshot_namer.section = name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def screenshot_group(name)
|
|
25
|
+
screenshot_namer.group = name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Takes a screenshot and compares it against a baseline image.
|
|
29
|
+
#
|
|
30
|
+
# The method follows a layered optimization strategy for comparison:
|
|
31
|
+
# 1. First checks if screenshot functionality is active
|
|
32
|
+
# 2. Builds a full screenshot name using the current context
|
|
33
|
+
# 3. Creates a screenshot assertion object
|
|
34
|
+
# 4. Either validates immediately or defers validation based on options
|
|
35
|
+
#
|
|
36
|
+
# @param name [String] The base name of the screenshot, used to generate the filename.
|
|
37
|
+
# @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors.
|
|
38
|
+
# @param options [Hash] Additional options for taking the screenshot and comparison.
|
|
39
|
+
# @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed)
|
|
40
|
+
# Whether to validate the screenshot immediately or delay validation.
|
|
41
|
+
# @option options [Array<Integer>] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to.
|
|
42
|
+
# @option options [Array<Array<Integer>>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore.
|
|
43
|
+
# @option options [Numeric] :tolerance (0.001 for :vips driver) Color tolerance for comparison.
|
|
44
|
+
# Represents the maximum allowed ratio of different pixels (0.0-1.0 scale).
|
|
45
|
+
# @option options [Numeric] :color_distance_limit Maximum allowed color distance between pixels.
|
|
46
|
+
# Uses Euclidean RGBA distance (0-510 scale). Mutually exclusive with :perceptual_threshold.
|
|
47
|
+
# @option options [Numeric] :perceptual_threshold Maximum perceptual color difference (CIE dE00).
|
|
48
|
+
# Uses human perception-based scale (0-100+). VIPS only. Takes priority over :color_distance_limit if both set.
|
|
49
|
+
# @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels.
|
|
50
|
+
# @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
|
|
51
|
+
# @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
|
|
52
|
+
# @return [Boolean] True if the screenshot was successfully captured and processed.
|
|
53
|
+
# @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
|
|
54
|
+
# @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
|
|
55
|
+
# @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
|
|
56
|
+
def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
|
|
57
|
+
return false unless Capybara::Screenshot.active?
|
|
58
|
+
|
|
59
|
+
# Get the full name with section and group information
|
|
60
|
+
full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
|
|
61
|
+
|
|
62
|
+
# Build the screenshot assertion
|
|
63
|
+
assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
|
|
64
|
+
|
|
65
|
+
return false unless assertion
|
|
66
|
+
|
|
67
|
+
# Determine if validation should be delayed or immediate
|
|
68
|
+
delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
|
|
69
|
+
|
|
70
|
+
if delayed
|
|
71
|
+
CapybaraScreenshotDiff.add_assertion(assertion)
|
|
72
|
+
else
|
|
73
|
+
assertion.validate!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convenience wrapper around {#assert_matches_screenshot} and {#capture_screenshot}.
|
|
80
|
+
# @param compare [Boolean] When false, only captures the screenshot without comparing it to a baseline.
|
|
81
|
+
# @see #assert_matches_screenshot
|
|
82
|
+
# @see #capture_screenshot
|
|
83
|
+
def screenshot(name, skip_stack_frames: 0, compare: true, **options)
|
|
84
|
+
if compare
|
|
85
|
+
assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options)
|
|
86
|
+
else
|
|
87
|
+
capture_screenshot(name, **options)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Captures a screenshot without comparing it to a baseline.
|
|
92
|
+
# @param name [String] The base name of the screenshot, used to generate the filename.
|
|
93
|
+
# @param options [Hash] Additional options for taking the screenshot. See {#assert_matches_screenshot}.
|
|
94
|
+
# @return [Boolean] True if the screenshot was successfully captured.
|
|
95
|
+
def capture_screenshot(name, **options)
|
|
96
|
+
return false unless Capybara::Screenshot.active?
|
|
97
|
+
|
|
98
|
+
full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
|
|
99
|
+
Capybara::Screenshot::Diff::ScreenshotMatcher.new(full_name, options).capture
|
|
100
|
+
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Asserts the current page has no visual changes from the baseline.
|
|
105
|
+
# Override in your base test class to add project-specific behavior
|
|
106
|
+
# (e.g., waiting for Turbo, default skip areas).
|
|
107
|
+
def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
|
|
108
|
+
assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Builds a screenshot assertion object that can be validated immediately or later.
|
|
114
|
+
#
|
|
115
|
+
# This method constructs a screenshot assertion that encapsulates the comparison logic.
|
|
116
|
+
# The actual comparison is deferred until {ScreenshotAssertion#validate!} is called.
|
|
117
|
+
#
|
|
118
|
+
# @param name [String] The full name of the screenshot, including any section/group context.
|
|
119
|
+
# @param options [Hash] Options for screenshot taking and comparison.
|
|
120
|
+
# See {#assert_matches_screenshot} for available options.
|
|
121
|
+
# @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
|
|
122
|
+
# @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
|
|
123
|
+
# @see ScreenshotAssertion
|
|
124
|
+
def build_screenshot_assertion(name, options, skip_stack_frames: 0)
|
|
125
|
+
Capybara::Screenshot::Diff::ScreenshotMatcher
|
|
126
|
+
.new(name, options)
|
|
127
|
+
.build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def screenshot_namer
|
|
131
|
+
CapybaraScreenshotDiff.screenshot_namer
|
|
16
132
|
end
|
|
17
133
|
end
|
|
18
134
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara_screenshot_diff/backtrace_filter"
|
|
4
|
+
|
|
5
|
+
module CapybaraScreenshotDiff
|
|
6
|
+
# @private
|
|
7
|
+
class ErrorWithFilteredBacktrace < StandardError
|
|
8
|
+
# @private
|
|
9
|
+
def initialize(message = nil, backtrace = [])
|
|
10
|
+
super(message)
|
|
11
|
+
filter = BacktraceFilter.new
|
|
12
|
+
set_backtrace(filter.filtered(backtrace))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -19,16 +19,14 @@ module CapybaraScreenshotDiff
|
|
|
19
19
|
module Assertions
|
|
20
20
|
include ::CapybaraScreenshotDiff::DSL
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def assert_matches_screenshot(*args, skip_stack_frames: 0, **opts)
|
|
23
23
|
self.assertions += 1
|
|
24
24
|
|
|
25
|
-
super(*args, skip_stack_frames: skip_stack_frames +
|
|
25
|
+
super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
|
|
26
26
|
rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e
|
|
27
27
|
raise ::Minitest::Assertion, e.message
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
alias_method :assert_matches_screenshot, :screenshot
|
|
31
|
-
|
|
32
30
|
def setup
|
|
33
31
|
super
|
|
34
32
|
::Capybara::Screenshot::BrowserHelpers.resize_window_if_needed
|
|
@@ -37,9 +35,14 @@ module CapybaraScreenshotDiff
|
|
|
37
35
|
def before_teardown
|
|
38
36
|
super
|
|
39
37
|
CapybaraScreenshotDiff.verify
|
|
38
|
+
|
|
39
|
+
if ::Capybara::Screenshot::Diff.pending_if_new && CapybaraScreenshotDiff.new_screenshots_present?
|
|
40
|
+
names = CapybaraScreenshotDiff.new_screenshots
|
|
41
|
+
skip "No baseline for: #{names.join(", ")}. Commit the captured screenshots to record them."
|
|
42
|
+
end
|
|
40
43
|
rescue CapybaraScreenshotDiff::ExpectationNotMet => e
|
|
41
44
|
assertion = ::Minitest::Assertion.new(e)
|
|
42
|
-
assertion.set_backtrace
|
|
45
|
+
assertion.set_backtrace(e.backtrace)
|
|
43
46
|
failures << assertion
|
|
44
47
|
ensure
|
|
45
48
|
CapybaraScreenshotDiff.reset
|
|
@@ -47,3 +50,5 @@ module CapybaraScreenshotDiff
|
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
end
|
|
53
|
+
|
|
54
|
+
::Minitest.after_run { CapybaraScreenshotDiff.finalize_reporters! } if ::Minitest.respond_to?(:after_run)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module CapybaraScreenshotDiff
|
|
10
|
+
module Reporters
|
|
11
|
+
class HTML
|
|
12
|
+
attr_reader :failures, :total
|
|
13
|
+
|
|
14
|
+
def initialize(output_path: nil, embed_images: false)
|
|
15
|
+
@explicit_output_path = output_path
|
|
16
|
+
@embed_images = embed_images
|
|
17
|
+
@failures = []
|
|
18
|
+
@total = 0
|
|
19
|
+
@finalized = false
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(assertions)
|
|
24
|
+
return if @finalized
|
|
25
|
+
|
|
26
|
+
failures = []
|
|
27
|
+
total = 0
|
|
28
|
+
|
|
29
|
+
assertions.each do |assertion|
|
|
30
|
+
compare = assertion.compare
|
|
31
|
+
next unless compare
|
|
32
|
+
|
|
33
|
+
total += 1
|
|
34
|
+
next unless compare.difference&.different?
|
|
35
|
+
|
|
36
|
+
failures << failure_entry_for(assertion.name, compare)
|
|
37
|
+
rescue => e
|
|
38
|
+
warn "[snap_diff] Reporter skipped '#{assertion.name}': #{e.message}" if ENV["DEBUG"]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
return if @finalized
|
|
43
|
+
@total += total
|
|
44
|
+
@failures.concat(failures)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def finalize
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
return if @finalized
|
|
51
|
+
return if failures.empty?
|
|
52
|
+
|
|
53
|
+
write_report
|
|
54
|
+
@finalized = true
|
|
55
|
+
output_path
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def output_path
|
|
60
|
+
@output_path ||= Pathname.new(@explicit_output_path || self.class.default_output_path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def passed = total - failures.size
|
|
64
|
+
def failed = failures.size
|
|
65
|
+
|
|
66
|
+
def summary
|
|
67
|
+
return if total.zero?
|
|
68
|
+
|
|
69
|
+
screenshots_label = (total == 1) ? "1 screenshot" : "#{total} screenshots"
|
|
70
|
+
|
|
71
|
+
if failures.empty?
|
|
72
|
+
"[snap_diff] #{screenshots_label} compared, no failures."
|
|
73
|
+
else
|
|
74
|
+
failures_label = (failures.size == 1) ? "1 failure" : "#{failures.size} failures"
|
|
75
|
+
"[snap_diff] #{screenshots_label} compared, #{failures_label}. Report: #{output_path}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render
|
|
80
|
+
ERB.new(File.read(self.class.template_path)).result(binding)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.template_path
|
|
84
|
+
File.expand_path("templates/report.html.erb", __dir__)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.default_output_path
|
|
88
|
+
root = Capybara::Screenshot.root || Pathname.pwd
|
|
89
|
+
root / Capybara::Screenshot.save_path / "snap_diff_report.html"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def failure_entry_for(name, compare)
|
|
95
|
+
difference = compare.difference
|
|
96
|
+
{
|
|
97
|
+
name: name,
|
|
98
|
+
original: resolve_image(compare.base_image_path),
|
|
99
|
+
new: resolve_image(compare.image_path),
|
|
100
|
+
base_diff: resolve_image(compare.reporter.annotated_base_image_path),
|
|
101
|
+
diff: resolve_image(compare.reporter.annotated_image_path),
|
|
102
|
+
heatmap: resolve_image(compare.reporter.heatmap_diff_path),
|
|
103
|
+
diff_level: difference.ratio && (difference.ratio * 100).round(2),
|
|
104
|
+
area_size: difference.region_area_size,
|
|
105
|
+
max_color_distance: difference.meta[:max_color_distance]&.round(1)
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_image(path)
|
|
110
|
+
return unless path
|
|
111
|
+
|
|
112
|
+
pathname = Pathname.new(path).expand_path
|
|
113
|
+
return unless pathname.exist?
|
|
114
|
+
|
|
115
|
+
@embed_images ? data_uri(pathname) : pathname.relative_path_from(output_path.dirname.expand_path).to_s
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def data_uri(pathname)
|
|
119
|
+
ext = pathname.extname.delete_prefix(".")
|
|
120
|
+
mime = (ext == "webp") ? "image/webp" : "image/png"
|
|
121
|
+
"data:#{mime};base64,#{Base64.strict_encode64(pathname.binread)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_report
|
|
125
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
126
|
+
File.write(output_path, render)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Auto-register reporter.
|
|
133
|
+
# Framework adapters (Minitest, RSpec, Cucumber) call finalize_reporters! via native hooks.
|
|
134
|
+
# For custom frameworks, call CapybaraScreenshotDiff.finalize_reporters! manually.
|
|
135
|
+
unless CapybaraScreenshotDiff.reporters.any?(CapybaraScreenshotDiff::Reporters::HTML)
|
|
136
|
+
CapybaraScreenshotDiff.reporters << CapybaraScreenshotDiff::Reporters::HTML.new(embed_images: !!ENV["CI"])
|
|
137
|
+
end
|