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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/docs/RELEASE_PREP.md +6 -6
  4. data/docs/thread_safety.md +15 -0
  5. data/lib/capybara/screenshot/diff/annotation_service.rb +5 -77
  6. data/lib/capybara/screenshot/diff/area_calculator.rb +5 -54
  7. data/lib/capybara/screenshot/diff/browser_helpers.rb +5 -119
  8. data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
  9. data/lib/capybara/screenshot/diff/cucumber.rb +1 -1
  10. data/lib/capybara/screenshot/diff/difference.rb +6 -102
  11. data/lib/capybara/screenshot/diff/drivers/base_driver.rb +7 -39
  12. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +5 -299
  13. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +5 -169
  14. data/lib/capybara/screenshot/diff/drivers.rb +7 -14
  15. data/lib/capybara/screenshot/diff/image_compare.rb +10 -228
  16. data/lib/capybara/screenshot/diff/image_preprocessor.rb +5 -70
  17. data/lib/capybara/screenshot/diff/os.rb +8 -13
  18. data/lib/capybara/screenshot/diff/reporters/default.rb +5 -3
  19. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +5 -129
  20. data/lib/capybara/screenshot/diff/screenshoter.rb +5 -127
  21. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +5 -104
  22. data/lib/capybara/screenshot/diff/utils.rb +5 -40
  23. data/lib/capybara/screenshot/diff/vcs.rb +5 -37
  24. data/lib/capybara/screenshot/diff/version.rb +7 -1
  25. data/lib/capybara_screenshot_diff/attempts_reporter.rb +5 -47
  26. data/lib/capybara_screenshot_diff/cucumber.rb +5 -17
  27. data/lib/capybara_screenshot_diff/dsl.rb +7 -129
  28. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +6 -30
  29. data/lib/capybara_screenshot_diff/minitest.rb +6 -56
  30. data/lib/capybara_screenshot_diff/reporters/html.rb +5 -135
  31. data/lib/capybara_screenshot_diff/rspec.rb +5 -63
  32. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +39 -157
  33. data/lib/capybara_screenshot_diff/screenshot_namer.rb +5 -79
  34. data/lib/capybara_screenshot_diff/snap.rb +5 -64
  35. data/lib/capybara_screenshot_diff/snap_manager.rb +5 -82
  36. data/lib/capybara_screenshot_diff/static.rb +3 -5
  37. data/lib/capybara_screenshot_diff.rb +29 -100
  38. data/lib/snap_diff/annotation_service.rb +84 -0
  39. data/lib/snap_diff/area_calculator.rb +56 -0
  40. data/lib/snap_diff/attempts_reporter.rb +51 -0
  41. data/lib/snap_diff/browser_helpers.rb +121 -0
  42. data/lib/snap_diff/capture/viewport.rb +40 -0
  43. data/lib/snap_diff/comparison.rb +238 -0
  44. data/lib/snap_diff/comparison_result.rb +104 -0
  45. data/lib/snap_diff/config.rb +6 -2
  46. data/lib/snap_diff/deprecation.rb +84 -0
  47. data/lib/snap_diff/driver.rb +37 -0
  48. data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
  49. data/lib/snap_diff/drivers/vips_driver.rb +171 -0
  50. data/lib/snap_diff/drivers.rb +14 -0
  51. data/lib/snap_diff/dsl.rb +143 -0
  52. data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
  53. data/lib/snap_diff/image_preprocessor.rb +68 -0
  54. data/lib/snap_diff/integrations/cucumber.rb +22 -0
  55. data/lib/snap_diff/integrations/minitest.rb +70 -0
  56. data/lib/snap_diff/integrations/rspec.rb +68 -0
  57. data/lib/snap_diff/legacy_shims.rb +99 -0
  58. data/lib/snap_diff/os.rb +17 -0
  59. data/lib/snap_diff/reporters/html.rb +143 -0
  60. data/lib/snap_diff/reporting.rb +53 -0
  61. data/lib/snap_diff/screenshot_assertion.rb +143 -0
  62. data/lib/snap_diff/screenshot_matcher.rb +113 -0
  63. data/lib/snap_diff/screenshot_namer.rb +81 -0
  64. data/lib/snap_diff/screenshoter.rb +129 -0
  65. data/lib/snap_diff/snap.rb +66 -0
  66. data/lib/snap_diff/snap_manager.rb +125 -0
  67. data/lib/snap_diff/stable_screenshoter.rb +103 -0
  68. data/lib/snap_diff/static.rb +11 -0
  69. data/lib/snap_diff/utils.rb +38 -0
  70. data/lib/snap_diff/vcs.rb +35 -0
  71. data/lib/snap_diff/version.rb +5 -0
  72. data/lib/snap_diff.rb +22 -6
  73. metadata +36 -2
  74. /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)
@@ -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