snap_diff-capybara 2.0.0.beta1 → 2.0.0.beta2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/docs/UPGRADING.md +19 -4
  4. data/docs/architecture.md +38 -22
  5. data/docs/ci-integration.md +13 -3
  6. data/docs/configuration.md +31 -2
  7. data/docs/drivers.md +6 -0
  8. data/docs/framework-setup.md +21 -3
  9. data/docs/reporters.md +20 -5
  10. data/docs/snapdiff.md +326 -0
  11. data/docs/thread_safety.md +4 -3
  12. data/lib/capybara/screenshot/diff/config_legacy.rb +26 -72
  13. data/lib/capybara/screenshot/diff/image_compare.rb +5 -2
  14. data/lib/capybara/screenshot/diff/region.rb +3 -105
  15. data/lib/capybara/screenshot/diff/reporters/default.rb +4 -106
  16. data/lib/capybara_screenshot_diff/screenshot_assertion.rb +12 -24
  17. data/lib/capybara_screenshot_diff.rb +10 -4
  18. data/lib/snap_diff/area_calculator.rb +1 -3
  19. data/lib/snap_diff/browser_helpers.rb +1 -3
  20. data/lib/snap_diff/capture/viewport.rb +6 -7
  21. data/lib/snap_diff/comparison.rb +15 -28
  22. data/lib/snap_diff/config.rb +151 -29
  23. data/lib/snap_diff/deprecation.rb +26 -8
  24. data/lib/snap_diff/drivers.rb +20 -0
  25. data/lib/snap_diff/dsl.rb +12 -12
  26. data/lib/snap_diff/errors.rb +19 -0
  27. data/lib/snap_diff/integrations/cucumber.rb +5 -4
  28. data/lib/snap_diff/integrations/minitest.rb +11 -12
  29. data/lib/snap_diff/integrations/rspec.rb +7 -6
  30. data/lib/snap_diff/legacy_shims.rb +18 -0
  31. data/lib/snap_diff/region.rb +117 -0
  32. data/lib/snap_diff/reporters/default.rb +107 -0
  33. data/lib/snap_diff/reporters/html.rb +7 -9
  34. data/lib/snap_diff/reporting.rb +13 -4
  35. data/lib/snap_diff/screenshot_assertion.rb +37 -4
  36. data/lib/snap_diff/screenshot_matcher.rb +4 -4
  37. data/lib/snap_diff/screenshoter.rb +1 -1
  38. data/lib/snap_diff/snap_manager.rb +1 -2
  39. data/lib/snap_diff/stable_screenshoter.rb +2 -2
  40. data/lib/snap_diff/utils.rb +5 -3
  41. data/lib/snap_diff/version.rb +1 -1
  42. data/lib/snap_diff.rb +44 -15
  43. metadata +5 -1
data/lib/snap_diff/dsl.rb CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # No require of "capybara_screenshot_diff" here: every Capybara::Screenshot
4
- # / CapybaraScreenshotDiff reference below is inside a method body, resolved
5
- # lazily at call time, not at this file's own load time -- so this unit has
6
- # no eager dependency on the umbrella entry point, and requiring it here
7
- # would create a require cycle back to capybara_screenshot_diff.rb (which
8
- # requires this file's old-path forwarder).
4
+ # reference below is inside a method body, resolved lazily at call time, not
5
+ # at this file's own load time -- so this unit has no eager dependency on
6
+ # the umbrella entry point, and requiring it here would create a require
7
+ # cycle back to capybara_screenshot_diff.rb (which requires this file's
8
+ # old-path forwarder).
9
9
  # DSL includes Capybara::DSL directly below, so it needs the base gem
10
10
  # loaded regardless of what pulled this file in.
11
11
  require "capybara/dsl"
@@ -59,14 +59,14 @@ module SnapDiff
59
59
  # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
60
60
  # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
61
61
  # @return [Boolean] True if the screenshot was successfully captured and processed.
62
- # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
63
- # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
64
- # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
62
+ # @raise [SnapDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
63
+ # @raise [SnapDiff::UnstableImage] If the image comparison is unstable.
64
+ # @raise [SnapDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
65
65
  def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
66
66
  return false unless Capybara::Screenshot.active?
67
67
 
68
68
  # Get the full name with section and group information
69
- full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
69
+ full_name = SnapDiff.session.screenshot_namer.full_name(name)
70
70
 
71
71
  # Build the screenshot assertion
72
72
  assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
@@ -77,7 +77,7 @@ module SnapDiff
77
77
  delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
78
78
 
79
79
  if delayed
80
- CapybaraScreenshotDiff.add_assertion(assertion)
80
+ SnapDiff.session.add_assertion(assertion)
81
81
  else
82
82
  assertion.validate!
83
83
  end
@@ -104,7 +104,7 @@ module SnapDiff
104
104
  def capture_screenshot(name, **options)
105
105
  return false unless Capybara::Screenshot.active?
106
106
 
107
- full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
107
+ full_name = SnapDiff.session.screenshot_namer.full_name(name)
108
108
  SnapDiff::ScreenshotMatcher.new(full_name, options).capture
109
109
 
110
110
  true
@@ -137,7 +137,7 @@ module SnapDiff
137
137
  end
138
138
 
139
139
  def screenshot_namer
140
- CapybaraScreenshotDiff.screenshot_namer
140
+ SnapDiff.session.screenshot_namer
141
141
  end
142
142
  end
143
143
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snap_diff/error_with_filtered_backtrace"
4
+
5
+ # ADR-008 step 2: the gem's error classes live under SnapDiff. The old
6
+ # CapybaraScreenshotDiff names (capybara_screenshot_diff.rb) are EAGER
7
+ # same-object aliases of these classes -- deliberately not const_missing
8
+ # shims, because rescue clauses and defined?/const_defined? feature
9
+ # detection in adopter code must keep behaving exactly as before
10
+ # (const_defined? never triggers const_missing).
11
+ module SnapDiff
12
+ class Error < ErrorWithFilteredBacktrace; end
13
+
14
+ class ExpectationNotMet < Error; end
15
+
16
+ class UnstableImage < Error; end
17
+
18
+ class WindowSizeMismatchError < ErrorWithFilteredBacktrace; end
19
+ end
@@ -2,7 +2,8 @@
2
2
 
3
3
  # See the matching comment in integrations/minitest.rb.
4
4
  require_relative "../dsl"
5
- require "capybara_screenshot_diff/screenshot_assertion"
5
+ require "snap_diff/screenshot_assertion"
6
+ require "snap_diff/reporting"
6
7
 
7
8
  World(::SnapDiff::DSL)
8
9
 
@@ -12,11 +13,11 @@ Before do
12
13
  end
13
14
 
14
15
  After do |scenario|
15
- if !scenario.failed? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
16
+ if !scenario.failed? && (msg = SnapDiff.pending_screenshots_message)
16
17
  skip_this_scenario(msg)
17
18
  end
18
19
  ensure
19
- CapybaraScreenshotDiff.reset
20
+ SnapDiff.reset
20
21
  end
21
22
 
22
- AfterAll { CapybaraScreenshotDiff.finalize_reporters! }
23
+ AfterAll { SnapDiff::Reporting.finalize! }
@@ -2,12 +2,11 @@
2
2
 
3
3
  require "minitest"
4
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"
5
+ # SnapDiff.session/.reset/.pending_screenshots_message live in
6
+ # snap_diff/screenshot_assertion, SnapDiff::Reporting.finalize! in
7
+ # snap_diff/reporting -- neither is pulled in by requiring dsl.rb alone.
8
+ require "snap_diff/screenshot_assertion"
9
+ require "snap_diff/reporting"
11
10
 
12
11
  used_deprecated_entrypoint = caller.any? do |path|
13
12
  path.include?("capybara-screenshot-diff.rb") || path.include?("capybara/screenshot/diff.rb")
@@ -29,7 +28,7 @@ module SnapDiff
29
28
  self.assertions += 1
30
29
 
31
30
  super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
32
- rescue ::CapybaraScreenshotDiff::ExpectationNotMet => e
31
+ rescue ::SnapDiff::ExpectationNotMet => e
33
32
  raise ::Minitest::Assertion, e.message
34
33
  end
35
34
 
@@ -40,18 +39,18 @@ module SnapDiff
40
39
 
41
40
  def before_teardown
42
41
  super
43
- CapybaraScreenshotDiff.verify
42
+ SnapDiff.session.verify
44
43
 
45
44
  # Computed here (before teardown/reset), but the actual `skip` is
46
45
  # deferred to `after_teardown` so a real error raised by the user's
47
46
  # `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
47
+ @capybara_screenshot_diff_pending_message = SnapDiff.pending_screenshots_message
48
+ rescue SnapDiff::ExpectationNotMet => e
50
49
  assertion = ::Minitest::Assertion.new(e)
51
50
  assertion.set_backtrace(e.backtrace)
52
51
  failures << assertion
53
52
  ensure
54
- CapybaraScreenshotDiff.reset
53
+ SnapDiff.reset
55
54
  end
56
55
 
57
56
  def after_teardown
@@ -67,4 +66,4 @@ module SnapDiff
67
66
  end
68
67
  end
69
68
 
70
- ::Minitest.after_run { CapybaraScreenshotDiff.finalize_reporters! } if ::Minitest.respond_to?(:after_run)
69
+ ::Minitest.after_run { SnapDiff::Reporting.finalize! } if ::Minitest.respond_to?(:after_run)
@@ -3,7 +3,8 @@
3
3
  require "rspec/core"
4
4
  require_relative "../dsl"
5
5
  # See the matching comment in integrations/minitest.rb.
6
- require "capybara_screenshot_diff/screenshot_assertion"
6
+ require "snap_diff/screenshot_assertion"
7
+ require "snap_diff/reporting"
7
8
 
8
9
  RSpec::Matchers.define :match_screenshot do |name, **options|
9
10
  description { "match screenshot '#{name}'" }
@@ -45,7 +46,7 @@ RSpec.configure do |config|
45
46
  config.append_after do |example|
46
47
  if self.class.include?(SnapDiff::DSL)
47
48
  begin
48
- CapybaraScreenshotDiff.verify
49
+ SnapDiff.session.verify
49
50
 
50
51
  # Never mask a real failure with a pending marker. Kept as
51
52
  # defense-in-depth: `append_after` observes failures from plain
@@ -53,16 +54,16 @@ RSpec.configure do |config|
53
54
  # so a user `append_after` registered after this gem still runs
54
55
  # later than us — RSpec has no "run absolutely last" construct.
55
56
  # Mitigation for such consumers: require this gem last.
56
- if example.exception.nil? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
57
+ if example.exception.nil? && (msg = SnapDiff.pending_screenshots_message)
57
58
  skip(msg)
58
59
  end
59
- rescue CapybaraScreenshotDiff::ExpectationNotMet => e
60
+ rescue SnapDiff::ExpectationNotMet => e
60
61
  raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
61
62
  ensure
62
- CapybaraScreenshotDiff.reset
63
+ SnapDiff.reset
63
64
  end
64
65
  end
65
66
  end
66
67
 
67
- config.after(:suite) { CapybaraScreenshotDiff.finalize_reporters! }
68
+ config.after(:suite) { SnapDiff::Reporting.finalize! }
68
69
  end
@@ -24,6 +24,14 @@ require "snap_diff/drivers"
24
24
  # shared SnapDiff::Drivers module (the Drivers alias is same-object by
25
25
  # contract), so const_missing can never fire for the leaf names;
26
26
  # resolving them through the old path still warns for ...::Drivers.
27
+ # - Diff::LOADED_DRIVERS: user code registers custom drivers by mutating
28
+ # this hash in place, so it must be the exact same object as the
29
+ # canonical SnapDiff::Drivers.loaded -- a lazy warn-once shim could not
30
+ # keep a mutable alias, and warning on a supported registration surface
31
+ # would be noise. Assigned eagerly below.
32
+ # - Diff::AVAILABLE_DRIVERS: stays a real constant defined by
33
+ # config_legacy.rb (detection runs at that load moment);
34
+ # SnapDiff::Drivers.available is the canonical reader.
27
35
  module SnapDiff
28
36
  # @api private
29
37
  module LegacyShims
@@ -47,6 +55,11 @@ end
47
55
  module Capybara
48
56
  module Screenshot
49
57
  module Diff
58
+ # EAGER same-object alias of the canonical driver cache (see header).
59
+ LOADED_DRIVERS = SnapDiff::Drivers.loaded
60
+
61
+ module Reporters
62
+ end
50
63
  end
51
64
  end
52
65
  end
@@ -71,9 +84,14 @@ SnapDiff::LegacyShims.install(Capybara::Screenshot::Diff, "Capybara::Screenshot:
71
84
  ScreenshotMatcher: "SnapDiff::ScreenshotMatcher",
72
85
  Drivers: "SnapDiff::Drivers",
73
86
  ImageCompare: "SnapDiff::Comparison",
87
+ Comparison: "SnapDiff::Comparison::Images",
74
88
  Difference: "SnapDiff::ComparisonResult"
75
89
  }.freeze)
76
90
 
91
+ SnapDiff::LegacyShims.install(Capybara::Screenshot::Diff::Reporters, "Capybara::Screenshot::Diff::Reporters", {
92
+ Default: "SnapDiff::Reporters::Default"
93
+ }.freeze)
94
+
77
95
  SnapDiff::LegacyShims.install(CapybaraScreenshotDiff, "CapybaraScreenshotDiff", {
78
96
  RED_RGBA: "SnapDiff::RED_RGBA",
79
97
  ORANGE_RGBA: "SnapDiff::ORANGE_RGBA",
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ class Region
5
+ attr_accessor :x, :y, :width, :height
6
+
7
+ def initialize(x, y, width, height)
8
+ @x, @y, @width, @height = x, y, width, height
9
+ end
10
+
11
+ def self.from_edge_coordinates(left, top, right, bottom)
12
+ return nil unless left && top && right && bottom
13
+ return nil if right < left || bottom < top
14
+
15
+ Region.new(left, top, right - left, bottom - top)
16
+ end
17
+
18
+ def to_edge_coordinates
19
+ [left, top, right, bottom]
20
+ end
21
+
22
+ def to_top_left_corner_coordinates
23
+ [x, y, width, height]
24
+ end
25
+
26
+ def top
27
+ y
28
+ end
29
+
30
+ def bottom
31
+ y + height
32
+ end
33
+
34
+ def left
35
+ x
36
+ end
37
+
38
+ def right
39
+ x + width
40
+ end
41
+
42
+ def size
43
+ return 0 if width < 0 || height < 0
44
+
45
+ result = width * height
46
+ result.zero? ? 1 : result
47
+ end
48
+
49
+ def to_a
50
+ [@x, @y, @width, @height]
51
+ end
52
+
53
+ def find_intersect_with(region)
54
+ return nil unless intersect?(region)
55
+
56
+ new_left = [x, region.x].max
57
+ new_top = [y, region.y].max
58
+
59
+ Region.new(new_left, new_top, [right, region.right].min - new_left, [bottom, region.bottom].min - new_top)
60
+ end
61
+
62
+ def intersect?(region)
63
+ left <= region.right && right >= region.left && top <= region.bottom && bottom >= region.top
64
+ end
65
+
66
+ def move_by(right_by, down_by)
67
+ Region.new(x + right_by, y + down_by, width, height)
68
+ end
69
+
70
+ def find_relative_intersect(region)
71
+ intersect = find_intersect_with(region)
72
+ return nil unless intersect
73
+
74
+ intersect.move_by(-x, -y)
75
+ end
76
+
77
+ def cover?(x, y)
78
+ x.between?(left, right) && y.between?(top, bottom)
79
+ end
80
+
81
+ def empty?
82
+ width.zero? || height.zero?
83
+ end
84
+
85
+ def blank?
86
+ empty?
87
+ end
88
+
89
+ def present?
90
+ !empty?
91
+ end
92
+
93
+ def inspect
94
+ "Region(x: #{x}, y: #{y}, width: #{width}, height: #{height})"
95
+ end
96
+
97
+ # need to add this method to make it work with assert_equal
98
+ def ==(other)
99
+ case other
100
+ when Region
101
+ x == other.x && y == other.y && width == other.width && height == other.height
102
+ when Array
103
+ to_a == other
104
+ else
105
+ false
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ # Compatibility alias -- EAGER and same-object on purpose, mirroring the
112
+ # ADR-008 step 2 rationale: user configs construct skip_area entries with
113
+ # top-level `Region.new(...)` and SnapDiff internals check
114
+ # `is_a?(SnapDiff::Region)`, so both names must be the exact same class;
115
+ # and `defined?(Region)` feature detection must be truthy as soon as this
116
+ # file loads (a lazy const_missing shim would return nil there).
117
+ Region = SnapDiff::Region
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snap_diff/annotation_service"
4
+
5
+ module SnapDiff
6
+ module Reporters
7
+ class Default
8
+ attr_reader :difference
9
+
10
+ def initialize(difference)
11
+ @difference = difference
12
+ @annotation_service = SnapDiff::AnnotationService.new(difference)
13
+ end
14
+
15
+ def annotated_image_path
16
+ annotation_service.annotated_image_path
17
+ end
18
+
19
+ def annotated_base_image_path
20
+ annotation_service.annotated_base_image_path
21
+ end
22
+
23
+ def heatmap_diff_path
24
+ annotation_service.heatmap_diff_path
25
+ end
26
+
27
+ def generate
28
+ if difference.equal?
29
+ # NOTE: Delete previous run runtime files
30
+ clean_tmp_files
31
+ return nil
32
+ end
33
+
34
+ if difference.failed? && difference.failed_by[:different_dimensions]
35
+ return build_error_for_different_dimensions
36
+ end
37
+
38
+ annotate_and_save_images
39
+ build_error_message
40
+ end
41
+
42
+ def clean_tmp_files
43
+ annotation_service.clean_tmp_files
44
+ end
45
+
46
+ def annotate_and_save_images
47
+ annotation_service.annotate_and_save_images
48
+ end
49
+
50
+ def save_annotation_for(image, image_path)
51
+ annotation_service.save_annotation_for(image, image_path)
52
+ end
53
+
54
+ def annotate_difference(image, region)
55
+ annotation_service.annotate_difference(image, region)
56
+ end
57
+
58
+ def annotate_skip_areas(image, skip_areas)
59
+ annotation_service.annotate_skip_areas(image, skip_areas)
60
+ end
61
+
62
+ def save(image, image_path)
63
+ annotation_service.save(image, image_path)
64
+ end
65
+
66
+ def build_error_for_different_dimensions
67
+ change_msg = [comparison.base_image, comparison.new_image]
68
+ .map { |image| driver.dimension(image).join("x") }
69
+ .join(" => ")
70
+
71
+ "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
72
+ end
73
+
74
+ NEW_LINE = "\n"
75
+
76
+ def build_error_message
77
+ [
78
+ "(#{difference.to_h.to_json})",
79
+ image_path.to_path,
80
+ annotated_base_image_path.to_path,
81
+ annotated_image_path.to_path,
82
+ heatmap_diff_path.to_path
83
+ ].join(NEW_LINE)
84
+ end
85
+
86
+ private
87
+
88
+ attr_reader :annotation_service
89
+
90
+ def base_image_path
91
+ comparison.base_image_path
92
+ end
93
+
94
+ def image_path
95
+ comparison.new_image_path
96
+ end
97
+
98
+ def driver
99
+ @_driver ||= comparison.driver
100
+ end
101
+
102
+ def comparison
103
+ @_comparison ||= difference.comparison
104
+ end
105
+ end
106
+ end
107
+ end
@@ -5,11 +5,9 @@ require "erb"
5
5
  require "fileutils"
6
6
  require "pathname"
7
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"
8
+ # The auto-registration block at the bottom of this file registers with
9
+ # SnapDiff::Reporting.
10
+ require "snap_diff/reporting"
13
11
  require "capybara/screenshot/diff/config_legacy"
14
12
 
15
13
  module SnapDiff
@@ -136,8 +134,8 @@ module SnapDiff
136
134
  end
137
135
 
138
136
  # 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"])
137
+ # Framework adapters (Minitest, RSpec, Cucumber) call SnapDiff::Reporting.finalize! via native hooks.
138
+ # For custom frameworks, call SnapDiff::Reporting.finalize! manually.
139
+ unless SnapDiff::Reporting.reporters.any?(SnapDiff::Reporters::HTML)
140
+ SnapDiff::Reporting.register(SnapDiff::Reporters::HTML.new(embed_images: !!ENV["CI"]))
143
141
  end
@@ -5,10 +5,9 @@ module SnapDiff
5
5
  # end-of-suite finalization. One list of reporters for the whole process,
6
6
  # guarded by one mutex.
7
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
8
+ # Deliberately separate from the per-test session lifecycle
9
+ # (SnapDiff.session): reporters outlive any single test, the session does
10
+ # not. CapybaraScreenshotDiff keeps its public
12
11
  # reporters/reporters_mutex/finalize_reporters! methods as thin shims
13
12
  # over this module.
14
13
  module Reporting
@@ -18,6 +17,16 @@ module SnapDiff
18
17
  class << self
19
18
  attr_reader :reporters, :mutex
20
19
 
20
+ # Registers a reporter for the rest of the process. The canonical way
21
+ # in: the append happens under the mutex, so concurrent registrations
22
+ # cannot lose one (issue #217 item 2). `reporters` stays public and
23
+ # mutable for compatibility -- appending to it directly still works,
24
+ # it just skips the lock.
25
+ def register(reporter)
26
+ @mutex.synchronize { @reporters << reporter }
27
+ reporter
28
+ end
29
+
21
30
  # Delivers a finished test's assertions to every registered reporter.
22
31
  # Iterates over a snapshot so a reporter mutating the list mid-notify
23
32
  # cannot affect the current round. A raising reporter is warned about
@@ -2,8 +2,42 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "screenshot_namer"
5
+ require_relative "reporting"
5
6
 
6
7
  module SnapDiff
8
+ # --- Session lifecycle (per-test) ---
9
+ #
10
+ # The canonical home of the per-test session: the AssertionRegistry
11
+ # holding the assertions and new-screenshot names of the test running
12
+ # here. CapybaraScreenshotDiff.registry / .reset /
13
+ # .pending_screenshots_message are thin forwarders over these.
14
+ #
15
+ # Note: Thread.current[] is *fiber*-local, so a session is per fiber, not
16
+ # per thread. That is pre-existing, documented behaviour (issue #217);
17
+ # ADR-008 step 6 relocates the accessor without touching the semantics.
18
+ def self.session
19
+ Thread.current[:capybara_screenshot_diff_registry] ||= AssertionRegistry.new
20
+ end
21
+
22
+ # Ends a test: hands the finished session's assertions to the reporters,
23
+ # then clears it. The one deliberate bridge between the process-global
24
+ # reporter lifecycle (SnapDiff::Reporting) and the per-test session.
25
+ def self.reset
26
+ Reporting.notify(session.assertions)
27
+ session.reset
28
+ end
29
+
30
+ # Message to skip the test with when a new screenshot has no baseline yet
31
+ # and `pending_if_new` is enabled. Adapters call this after verifying
32
+ # screenshots, and skip the test with the returned message when present.
33
+ #
34
+ # @return [String, nil] the pending message, or nil when there is nothing to report
35
+ def self.pending_screenshots_message
36
+ return unless ::Capybara::Screenshot::Diff.pending_if_new && session.new_screenshots_present?
37
+
38
+ "No baseline for: #{session.new_screenshots.join(", ")}. Commit the captured screenshots to record them."
39
+ end
40
+
7
41
  class ScreenshotAssertion
8
42
  attr_reader :name, :args
9
43
  attr_accessor :compare, :caller
@@ -52,7 +86,7 @@ module SnapDiff
52
86
  error_msg = validate
53
87
 
54
88
  if error_msg
55
- raise CapybaraScreenshotDiff::ExpectationNotMet.new(error_msg, caller)
89
+ raise SnapDiff::ExpectationNotMet.new(error_msg, caller)
56
90
  end
57
91
  end
58
92
 
@@ -118,15 +152,14 @@ module SnapDiff
118
152
  !@new_screenshots.empty?
119
153
  end
120
154
 
121
- def verify(screenshots = CapybaraScreenshotDiff.assertions)
155
+ def verify(screenshots = assertions)
122
156
  return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
123
157
 
124
- failed_assertions = CapybaraScreenshotDiff.registry.failed_assertions
125
158
  failed_screenshot = failed_assertions.first
126
159
  result = ScreenshotAssertion.verify_screenshots!(screenshots)
127
160
 
128
161
  if result
129
- raise CapybaraScreenshotDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
162
+ raise SnapDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
130
163
  end
131
164
  end
132
165
 
@@ -21,7 +21,7 @@ module SnapDiff
21
21
  end
22
22
 
23
23
  def build_screenshot_assertion(skip_stack_frames: 0)
24
- Capture::Viewport.prepare!(Capybara::Screenshot.window_size, anchor: nil)
24
+ Capture::Viewport.prepare!(Capybara::Screenshot.window_size)
25
25
  prepare_screenshot_options
26
26
  check_base_screenshot
27
27
 
@@ -32,7 +32,7 @@ module SnapDiff
32
32
  # Pre-computation: No need to compare without base screenshot
33
33
  # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
34
34
  unless need_to_compare?
35
- CapybaraScreenshotDiff.record_new_screenshot(screenshot_full_name)
35
+ SnapDiff.session.record_new_screenshot(screenshot_full_name)
36
36
  return
37
37
  end
38
38
 
@@ -41,7 +41,7 @@ module SnapDiff
41
41
 
42
42
  # Captures a screenshot without comparing it to a baseline.
43
43
  def capture
44
- Capture::Viewport.prepare!(Capybara::Screenshot.window_size, anchor: nil)
44
+ Capture::Viewport.prepare!(Capybara::Screenshot.window_size)
45
45
  prepare_screenshot_options
46
46
 
47
47
  capture_options, comparison_options = extract_capture_and_comparison_options(driver_options)
@@ -68,7 +68,7 @@ module SnapDiff
68
68
  @snapshot.checkout_base_screenshot
69
69
 
70
70
  if Capybara::Screenshot::Diff.fail_if_new && !@snapshot.base_path.exist?
71
- raise CapybaraScreenshotDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
71
+ raise SnapDiff::ExpectationNotMet.new(<<~ERROR.chomp, caller)
72
72
  No existing screenshot found for #{@snapshot.base_path}!
73
73
  To record baselines: RECORD_SCREENSHOTS=1 bundle exec rake test
74
74
  To allow new screenshots: Capybara::Screenshot::Diff.fail_if_new = false
@@ -88,7 +88,7 @@ module SnapDiff
88
88
  break unless pending_image
89
89
 
90
90
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline_at
91
- raise CapybaraScreenshotDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
91
+ raise SnapDiff::ExpectationNotMet.new("Images have not been loaded after #{timeout}s: #{pending_image.inspect}", caller)
92
92
  end
93
93
 
94
94
  sleep 0.025
@@ -3,7 +3,6 @@
3
3
  require "fileutils"
4
4
 
5
5
  require_relative "vcs"
6
- require "active_support/core_ext/module/attribute_accessors"
7
6
 
8
7
  require_relative "snap"
9
8
 
@@ -100,7 +99,7 @@ module SnapDiff
100
99
  # manager class or screenshot root changes (tests re-root per example).
101
100
  #
102
101
  # Thread.current[] is fiber-local — same construct as
103
- # CapybaraScreenshotDiff.registry; both split per fiber and must migrate
102
+ # SnapDiff.session; both split per fiber and must migrate
104
103
  # together if async support ever lands.
105
104
  #
106
105
  # Scope of the fix: default-root unit tests and before_setup re-rooters