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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CODE_OF_CONDUCT.md +129 -0
  4. data/Rakefile +29 -1
  5. data/capybara-screenshot-diff.gemspec +4 -3
  6. data/docs/RELEASE_PREP.md +42 -0
  7. data/docs/UPGRADING.md +390 -0
  8. data/docs/architecture.md +270 -0
  9. data/docs/ci-integration.md +238 -0
  10. data/docs/configuration.md +395 -0
  11. data/docs/docker-testing.md +24 -0
  12. data/docs/drivers.md +102 -0
  13. data/docs/framework-setup.md +87 -0
  14. data/docs/images/snap_diff_annotated.png +0 -0
  15. data/docs/images/snap_diff_web_ui.png +0 -0
  16. data/docs/migration-guide.md +286 -0
  17. data/docs/organization.md +204 -0
  18. data/docs/reporters.md +46 -0
  19. data/docs/thread_safety.md +97 -0
  20. data/gems.rb +2 -1
  21. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -1
  22. data/lib/capybara/screenshot/diff/browser_helpers.rb +14 -1
  23. data/lib/capybara/screenshot/diff/comparison.rb +3 -0
  24. data/lib/capybara/screenshot/diff/difference.rb +40 -3
  25. data/lib/capybara/screenshot/diff/difference_finder.rb +97 -0
  26. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +4 -0
  27. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +22 -24
  28. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +40 -27
  29. data/lib/capybara/screenshot/diff/image_compare.rb +112 -123
  30. data/lib/capybara/screenshot/diff/image_preprocessor.rb +72 -0
  31. data/lib/capybara/screenshot/diff/reporters/default.rb +10 -11
  32. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +77 -36
  33. data/lib/capybara/screenshot/diff/screenshoter.rb +9 -8
  34. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -9
  35. data/lib/capybara/screenshot/diff/vcs.rb +19 -52
  36. data/lib/capybara/screenshot/diff/version.rb +1 -1
  37. data/lib/capybara_screenshot_diff/backtrace_filter.rb +20 -0
  38. data/lib/capybara_screenshot_diff/cucumber.rb +11 -0
  39. data/lib/capybara_screenshot_diff/dsl.rb +123 -7
  40. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +15 -0
  41. data/lib/capybara_screenshot_diff/minitest.rb +10 -5
  42. data/lib/capybara_screenshot_diff/reporters/html.rb +137 -0
  43. data/lib/capybara_screenshot_diff/reporters/templates/report.html.erb +463 -0
  44. data/lib/capybara_screenshot_diff/rspec.rb +20 -4
  45. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +73 -22
  46. data/lib/capybara_screenshot_diff/screenshot_namer.rb +81 -0
  47. data/lib/capybara_screenshot_diff/snap.rb +14 -3
  48. data/lib/capybara_screenshot_diff/snap_manager.rb +10 -2
  49. data/lib/capybara_screenshot_diff/static.rb +11 -0
  50. data/lib/capybara_screenshot_diff.rb +31 -5
  51. metadata +51 -8
  52. 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
- driver = Diff::Drivers.for(@comparison_options)
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, attempts_reporter.generate
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
- if svn?(root)
11
- restore_svn_revision(screenshot_path, checkout_path)
12
- else
13
- restore_git_revision(screenshot_path, checkout_path, root: root)
14
- end
15
- end
16
-
17
- def self.svn?(root)
18
- (root / ".svn").exist?
19
- end
20
-
21
- SILENCE_ERRORS = Os::ON_WINDOWS ? "2>nul" : "2>/dev/null"
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
- end
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
- true
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
- def self.restore_svn_revision(screenshot_path, checkout_path)
49
- committed_file_name = screenshot_path + "../.svn/text-base/" + "#{screenshot_path.basename}.svn-base"
50
- if committed_file_name.exist?
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
- false
34
+ true
68
35
  end
69
36
  end
70
37
  end
@@ -3,7 +3,7 @@
3
3
  module Capybara
4
4
  module Screenshot
5
5
  module Diff
6
- VERSION = "1.10.3.1"
6
+ VERSION = "1.13.0"
7
7
  end
8
8
  end
9
9
  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/test_methods"
5
- require_relative "screenshot_assertion"
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
- alias_method :_screenshot, :screenshot
13
- def screenshot(name, **args)
14
- assertion = CapybaraScreenshotDiff::ScreenshotAssertion.new(name, **args) { _screenshot(name, **args) }
15
- CapybaraScreenshotDiff.add_assertion(assertion)
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 screenshot(*args, skip_stack_frames: 0, **opts)
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 + 3, **opts)
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