snap_diff-capybara 2.0.0.beta2 → 2.0.0.beta4

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +471 -0
  3. data/README.md +330 -0
  4. data/docs/UPGRADING.md +232 -34
  5. data/docs/architecture.md +9 -7
  6. data/docs/ci-integration.md +42 -11
  7. data/docs/configuration.md +176 -10
  8. data/docs/drivers.md +48 -5
  9. data/docs/framework-setup.md +10 -3
  10. data/docs/migration-guide.md +24 -13
  11. data/docs/reporters.md +70 -1
  12. data/docs/snapdiff.md +41 -8
  13. data/docs/thread_safety.md +77 -86
  14. data/lib/capybara/screenshot/diff/annotation_service.rb +1 -3
  15. data/lib/capybara/screenshot/diff/area_calculator.rb +1 -3
  16. data/lib/capybara/screenshot/diff/browser_helpers.rb +1 -3
  17. data/lib/capybara/screenshot/diff/config_legacy.rb +15 -60
  18. data/lib/capybara/screenshot/diff/cucumber.rb +5 -1
  19. data/lib/capybara/screenshot/diff/difference.rb +1 -4
  20. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +3 -3
  21. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +2 -3
  22. data/lib/capybara/screenshot/diff/drivers.rb +4 -5
  23. data/lib/capybara/screenshot/diff/image_compare.rb +12 -11
  24. data/lib/capybara/screenshot/diff/image_preprocessor.rb +1 -3
  25. data/lib/capybara/screenshot/diff/os.rb +4 -11
  26. data/lib/capybara/screenshot/diff/region.rb +2 -2
  27. data/lib/capybara/screenshot/diff/reporters/default.rb +4 -3
  28. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +1 -3
  29. data/lib/capybara/screenshot/diff/screenshoter.rb +1 -3
  30. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +1 -3
  31. data/lib/capybara/screenshot/diff/utils.rb +1 -3
  32. data/lib/capybara/screenshot/diff/vcs.rb +1 -3
  33. data/lib/capybara/screenshot/diff/version.rb +8 -13
  34. data/lib/capybara-screenshot-diff.rb +10 -1
  35. data/lib/capybara_screenshot_diff/attempts_reporter.rb +1 -3
  36. data/lib/capybara_screenshot_diff/dsl.rb +5 -0
  37. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +1 -4
  38. data/lib/capybara_screenshot_diff/reporters/html.rb +1 -3
  39. data/lib/capybara_screenshot_diff/screenshot_namer.rb +1 -3
  40. data/lib/capybara_screenshot_diff/snap.rb +1 -3
  41. data/lib/capybara_screenshot_diff/snap_manager.rb +1 -3
  42. data/lib/capybara_screenshot_diff/static.rb +4 -0
  43. data/lib/capybara_screenshot_diff.rb +17 -21
  44. data/lib/snap_diff/browser_helpers.rb +26 -5
  45. data/lib/snap_diff/capture/viewport.rb +2 -5
  46. data/lib/snap_diff/comparison.rb +54 -3
  47. data/lib/snap_diff/comparison_result.rb +3 -1
  48. data/lib/snap_diff/config.rb +189 -93
  49. data/lib/snap_diff/deprecation.rb +89 -28
  50. data/lib/snap_diff/driver.rb +18 -0
  51. data/lib/snap_diff/drivers/vips_driver.rb +12 -6
  52. data/lib/snap_diff/drivers.rb +94 -12
  53. data/lib/snap_diff/dsl.rb +51 -45
  54. data/lib/snap_diff/errors.rb +7 -1
  55. data/lib/snap_diff/integrations/cucumber.rb +1 -1
  56. data/lib/snap_diff/integrations/minitest.rb +45 -9
  57. data/lib/snap_diff/integrations/rspec.rb +11 -0
  58. data/lib/snap_diff/legacy_shims.rb +283 -28
  59. data/lib/snap_diff/removal.rb +159 -0
  60. data/lib/snap_diff/reporters/default.rb +86 -23
  61. data/lib/snap_diff/reporters/html.rb +29 -13
  62. data/lib/snap_diff/reporting.rb +330 -4
  63. data/lib/snap_diff/screenshot_assertion.rb +28 -23
  64. data/lib/snap_diff/screenshot_matcher.rb +121 -14
  65. data/lib/snap_diff/screenshot_namer.rb +1 -19
  66. data/lib/snap_diff/screenshoter.rb +5 -7
  67. data/lib/snap_diff/snap.rb +6 -1
  68. data/lib/snap_diff/snap_manager.rb +2 -3
  69. data/lib/snap_diff/stable_screenshoter.rb +2 -2
  70. data/lib/snap_diff/static.rb +1 -1
  71. data/lib/snap_diff/utils.rb +35 -17
  72. data/lib/snap_diff/vcs.rb +40 -7
  73. data/lib/snap_diff/version.rb +1 -1
  74. data/lib/snap_diff-capybara.rb +38 -0
  75. data/lib/snap_diff.rb +40 -39
  76. metadata +13 -10
  77. data/CODE_OF_CONDUCT.md +0 -129
  78. data/Rakefile +0 -65
  79. data/capybara-screenshot-diff.gemspec +0 -29
  80. data/docs/RELEASE_PREP.md +0 -44
  81. data/docs/docker-testing.md +0 -24
  82. data/gems.rb +0 -39
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # SnapDiff.silence_deprecations? -- the one switch that silences BOTH halves
4
+ # of the story -- lives in snap_diff/removal.rb, not here: the other half
5
+ # (the driver features 2.1 removes) is announced from core files that outlive
6
+ # this one, and they cannot depend on a file the same deletion removes.
7
+ require "snap_diff/removal"
8
+
3
9
  module SnapDiff
4
10
  # @api private
5
11
  #
@@ -25,26 +31,98 @@ module SnapDiff
25
31
  # caller who has customized +Warning+ behavior.
26
32
  MUTEX = Mutex.new
27
33
  @seen = {}
34
+ @notified = false
35
+ @notice_suppressed = false
36
+ @canonical_entry = false
37
+
38
+ # The ONE line a v1 user gets, whichever door they came through. Most of
39
+ # the v1 surface cannot warn per use -- the config accessors are plain
40
+ # delegators and the eager aliases never reach const_missing -- so
41
+ # without this a 2.x app is completely silent right up to the bare
42
+ # NameError it gets on 2.1. Deliberately generic and once per process:
43
+ # an actionable signal, not per-call stderr noise.
44
+ MIGRATION_NOTICE =
45
+ "[snap_diff deprecation] This process uses the v1 `Capybara::Screenshot*` / " \
46
+ "`CapybaraScreenshotDiff*` API. It still works in 2.0 and is REMOVED in 2.1 -- " \
47
+ "see docs/UPGRADING.md for the SnapDiff replacements. Silence with " \
48
+ "`SnapDiff.silence_deprecations = true` or SNAP_DIFF_SILENCE_DEPRECATIONS=1. " \
49
+ "(shown once per process)"
28
50
 
29
51
  class << self
52
+ # Emit {MIGRATION_NOTICE}, exactly once per process. Called from every
53
+ # v1 entry that can be hooked: the const_missing shims (via {.warn}),
54
+ # the generated legacy config accessors, and `include
55
+ # Capybara::Screenshot[::Diff]`.
56
+ # @return [void]
57
+ def notice
58
+ return if @notified || @notice_suppressed || SnapDiff.silence_deprecations?
59
+
60
+ first_time = MUTEX.synchronize { @notified ? false : (@notified = true) }
61
+ Kernel.warn(MIGRATION_NOTICE) if first_time
62
+ end
63
+
64
+ # @api private
65
+ #
66
+ # Called at the TOP of every v1-NAMED entry file, before that file's
67
+ # own requires. Requiring one of those paths IS use of the v1 API,
68
+ # and it is the ONE door a suite that merely calls `screenshot` goes
69
+ # through: every other door ({LEGACY_DOORS} in the probe test) needs
70
+ # the user to call something, which is how beta3 shipped a v1-only
71
+ # app that produced zero deprecation output.
72
+ #
73
+ # Position matters. The marker must run before the file's requires,
74
+ # because lib/capybara-screenshot-diff.rb reaches the canonical entry
75
+ # point below on its way in, and a marker placed after that require
76
+ # would be swallowed by {canonical_entry_point!}.
77
+ # @return [void]
78
+ def legacy_entry_point!
79
+ notice unless @canonical_entry
80
+ end
81
+
82
+ # @api private
83
+ #
84
+ # Claimed by lib/snap_diff-capybara.rb -- the canonical gem-name
85
+ # entry -- which loads the v1 umbrella itself. Without this, "the v1
86
+ # files got loaded" would be indistinguishable from "a v1 user", and
87
+ # every `gem "snap_diff-capybara"` app would be told to migrate off
88
+ # an API it never touched.
89
+ # @return [void]
90
+ def canonical_entry_point!
91
+ @canonical_entry = true
92
+ end
93
+
94
+ # @api private
95
+ #
96
+ # Suppresses {MIGRATION_NOTICE} for the rest of the process, without
97
+ # touching the per-constant warnings. For hosts that ARE the v1
98
+ # surface rather than users of it -- this gem's own test suite, which
99
+ # configures through `Capybara::Screenshot.*` by design and would
100
+ # otherwise print the notice on every run. Deliberately survives
101
+ # {reset!}, which exists to give a single test a clean slate.
102
+ # @return [void]
103
+ def suppress_migration_notice!
104
+ MUTEX.synchronize { @notice_suppressed = true }
105
+ end
106
+
30
107
  # Emit a deprecation warning for +subject+, exactly once per unique
31
- # +subject+ per process.
108
+ # +subject+ per process -- preceded, the first time round, by
109
+ # {MIGRATION_NOTICE}.
32
110
  #
33
111
  # @param subject [String] the deprecated old-namespace name being
34
112
  # referenced, e.g. "Capybara::Screenshot::Diff::ImageCompare"
35
113
  # @param replacement [String] the new-namespace name to use instead
36
- # @param category [Symbol] what kind of thing moved, for the human
37
- # reading the message (e.g. :constant, :config)
38
114
  # @return [void]
39
- def warn(subject, replacement, category:)
115
+ def warn(subject, replacement)
40
116
  return if SnapDiff.silence_deprecations?
41
117
 
118
+ notice
119
+
42
120
  first_time = MUTEX.synchronize do
43
121
  @seen.key?(subject) ? false : (@seen[subject] = true)
44
122
  end
45
123
  return unless first_time
46
124
 
47
- Kernel.warn(message_for(subject, replacement, category, caller_locations(1)))
125
+ Kernel.warn(message_for(subject, replacement, caller_locations(1)))
48
126
  end
49
127
 
50
128
  # @api private
@@ -54,13 +132,16 @@ module SnapDiff
54
132
  # the suite.
55
133
  # @return [void]
56
134
  def reset!
57
- MUTEX.synchronize { @seen.clear }
135
+ MUTEX.synchronize do
136
+ @seen.clear
137
+ @notified = false
138
+ end
58
139
  end
59
140
 
60
141
  private
61
142
 
62
- def message_for(subject, replacement, category, locations)
63
- message = "[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
143
+ def message_for(subject, replacement, locations)
144
+ message = "[snap_diff deprecation] `#{subject}` is deprecated (constant); " \
64
145
  "use `#{replacement}` instead."
65
146
  origin = origin_for(locations)
66
147
  origin ? "#{message} (called from #{origin})" : message
@@ -79,24 +160,4 @@ module SnapDiff
79
160
  end
80
161
  end
81
162
  end
82
-
83
- class << self
84
- # @api private
85
- attr_accessor :silence_deprecations
86
-
87
- # @api private
88
- #
89
- # @return [Boolean] true if deprecation warnings should be suppressed,
90
- # either via the {silence_deprecations} accessor or the
91
- # SNAP_DIFF_SILENCE_DEPRECATIONS env var (truthy = "1"/"true").
92
- def silence_deprecations?
93
- !!silence_deprecations || truthy_env?(ENV["SNAP_DIFF_SILENCE_DEPRECATIONS"])
94
- end
95
-
96
- private
97
-
98
- def truthy_env?(value)
99
- %w[1 true].include?(value.to_s.downcase)
100
- end
101
- end
102
163
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "snap_diff/removal"
4
+
3
5
  module SnapDiff
4
6
  # Shared default behavior for image-processing drivers.
5
7
  #
@@ -10,6 +12,22 @@ module SnapDiff
10
12
  module Driver
11
13
  PNG_EXTENSION = ".png"
12
14
 
15
+ # Including this mixin is what makes a custom driver a driver, so it is
16
+ # where a custom-driver author can be told that 2.1 removes the whole
17
+ # abstraction. Scoped to drivers that are NOT the gem's own two: both
18
+ # bundled drivers include it themselves, and warning there would fire on
19
+ # every plain vips setup -- about code the user does not own.
20
+ def self.included(base)
21
+ return if base.name.to_s.start_with?("SnapDiff::")
22
+
23
+ Removal.warn_once(
24
+ :driver_mixin,
25
+ "`include SnapDiff::Driver` (in #{base.name || base.inspect}) is REMOVED in 2.1: the " \
26
+ "driver abstraction goes away and libvips becomes the only backend, so custom drivers " \
27
+ "stop working. There is no replacement -- see docs/drivers.md."
28
+ )
29
+ end
30
+
13
31
  def same_dimension?(comparison)
14
32
  dimension(comparison.base_image) == dimension(comparison.new_image)
15
33
  end
@@ -18,6 +18,17 @@ module SnapDiff
18
18
  class VipsDriver
19
19
  include SnapDiff::Driver
20
20
 
21
+ # libvips caches loader operations keyed on filename + mtime, and mtime
22
+ # has ONE-SECOND resolution -- so overwriting a path and re-reading it
23
+ # within the same second hands back the PREVIOUS image. This gem does
24
+ # exactly that: the screenshoter writes `<name>.png`,
25
+ # `checkout_base_screenshot` writes `<name>.base.png` from VCS, and the
26
+ # comparison then reads both.
27
+ #
28
+ # `revalidate: true` tells the loader to skip the cached result (libvips
29
+ # 8.15+).
30
+ REVALIDATE = Vips.at_least_libvips?(8, 15) ? {revalidate: true}.freeze : {}.freeze
31
+
21
32
  def find_difference_region(comparison)
22
33
  new_image, base_image, options = comparison.new_image, comparison.base_image, comparison.options
23
34
 
@@ -86,7 +97,7 @@ module SnapDiff
86
97
  end
87
98
 
88
99
  def from_file(filename)
89
- result = ::Vips::Image.new_from_file(filename.to_s)
100
+ result = ::Vips::Image.new_from_file(filename.to_s, **REVALIDATE)
90
101
 
91
102
  result = result.colourspace(:srgb) if result.bands < 3
92
103
  result = result.bandjoin(255) if result.bands == 3
@@ -115,11 +126,6 @@ module SnapDiff
115
126
  private
116
127
 
117
128
  class << self
118
- def difference_area(old_image, new_image, color_distance: 0)
119
- mask = difference_mask(new_image, old_image, color_distance)
120
- difference_area_size_by(mask)
121
- end
122
-
123
129
  def difference_area_size_by(difference_mask)
124
130
  diff_mask = difference_mask == 0
125
131
  diff_mask.hist_find.to_a[0][0].max
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "snap_diff/removal"
4
+
3
5
  module SnapDiff
6
+ # utils.rb requires THIS file at its top. Requiring it back at load time
7
+ # made Ruby shout "circular require considered harmful" under $VERBOSE --
8
+ # which Rake::TestTask sets by default, i.e. every standard Rails/Minitest
9
+ # suite. Autoload breaks the cycle without narrowing the surface: nothing
10
+ # here touches Utils until a method runs, and requiring this file still
11
+ # leaves SnapDiff::Utils resolvable exactly as the eager require did.
12
+ autoload :Utils, "snap_diff/utils"
13
+
4
14
  # Compare two images and determine if they are equal, different, or within some comparison
5
15
  # range considering color values and difference area size.
6
16
  module Drivers
@@ -11,24 +21,96 @@ module SnapDiff
11
21
  Utils.find_driver_class_for(driver_option).new
12
22
  end
13
23
 
24
+ # @api private
25
+ #
26
+ # The registry itself, unannounced: driver name => driver class, filled
27
+ # lazily by Utils.find_driver_class_for and mutated in place. The gem's
28
+ # own reads go through HERE rather than through .loaded, so the removal
29
+ # warning below stays a signal about USER code -- a gem that warns at
30
+ # itself teaches people to ignore its warnings.
31
+ def self.registry
32
+ @registry ||= {}
33
+ end
34
+
14
35
  # Canonical driver-class cache (ADR-008 step 5b, ex
15
36
  # Capybara::Screenshot::Diff::LOADED_DRIVERS): driver name => driver
16
- # class, filled lazily by Utils.find_driver_class_for. Mutated in
17
- # place -- including by user registration through the legacy constant,
18
- # which legacy_shims pins as an EAGER same-object alias of this hash
19
- # (a lazy copy would silently drop such registrations).
37
+ # class. Mutated in place -- including by user registration through the
38
+ # legacy constant, which legacy_shims pins as an EAGER same-object alias
39
+ # of this hash (a lazy copy would silently drop such registrations).
40
+ #
41
+ # THE documented custom-driver registration point (docs/snapdiff.md), so
42
+ # a custom-driver author has to hear that 2.1 takes it away.
20
43
  def self.loaded
21
- @loaded ||= {}
44
+ Removal.warn_once(
45
+ :drivers_loaded,
46
+ "`SnapDiff::Drivers.loaded` is REMOVED in 2.1 together with the rest of the driver " \
47
+ "abstraction (`SnapDiff::Driver`, `SnapDiff::Drivers.available`, `driver: :auto`): " \
48
+ "libvips becomes the only backend and custom drivers are no longer supported. " \
49
+ "See docs/drivers.md."
50
+ )
51
+ registry
22
52
  end
23
53
 
24
- # Canonical read API for the detected-drivers list. The value itself
25
- # stays on Capybara::Screenshot::Diff::AVAILABLE_DRIVERS (assigned in
26
- # config_legacy.rb at load time, exactly when detection historically
27
- # ran); this reads it live rather than caching, because that constant
28
- # is the published stubbing point (image_compare_test stubs it to []
29
- # to exercise the no-drivers error path).
54
+ # Which image drivers this process can actually load, in preference
55
+ # order. Tries the gem first and falls back to `require`, cleaning up
56
+ # the half-defined constant a failed native load leaves behind.
57
+ def self.detect_available
58
+ result = []
59
+ begin
60
+ result << :vips if defined?(Vips) || require("vips")
61
+ rescue LoadError
62
+ # vips not present
63
+ Object.send(:remove_const, :Vips) if defined?(Vips)
64
+ end
65
+ begin
66
+ result << :chunky_png if defined?(ChunkyPNG) || require("chunky_png")
67
+ rescue LoadError
68
+ # chunky_png not present
69
+ Object.send(:remove_const, :ChunkyPNG) if defined?(ChunkyPNG)
70
+ end
71
+ result
72
+ end
73
+
74
+ # Canonical home of the detected-drivers list (3.0 readiness: it used
75
+ # to live only on Capybara::Screenshot::Diff::AVAILABLE_DRIVERS, so
76
+ # `require "snap_diff/drivers"` alone left .available raising
77
+ # NameError). Detection runs HERE, at this file's load, and the legacy
78
+ # constant is now an eager same-object alias of this one.
79
+ AVAILABLE_DRIVERS = detect_available.freeze
80
+
81
+ # The driver classes are documented names (the legacy
82
+ # `...::Drivers::VipsDriver` path is a same-object alias of this one), so
83
+ # naming one has to work without a prior require. Autoload rather than
84
+ # require: naming one must not cost every process the vips/chunky_png
85
+ # load, and Utils.find_driver_class_for still requires them explicitly.
86
+ #
87
+ # Gated on AVAILABLE_DRIVERS, which is why these sit BELOW it. Declaring
88
+ # them unconditionally made `const_defined?(:VipsDriver)` true on a box
89
+ # without ruby-vips (neither driver gem is a runtime dependency), so the
90
+ # documented v1 pattern `Diff.driver = :vips if defined?(...VipsDriver)`
91
+ # took the branch and then blew up on const_get. v1.12.0 loaded
92
+ # vips_driver.rb only from find_driver_class_for, so `defined?` was nil
93
+ # there -- this keeps that.
94
+ autoload :ChunkyPNGDriver, "snap_diff/drivers/chunky_png_driver" if AVAILABLE_DRIVERS.include?(:chunky_png)
95
+ autoload :VipsDriver, "snap_diff/drivers/vips_driver" if AVAILABLE_DRIVERS.include?(:vips)
96
+
97
+ # Canonical read API for the list above. Reads the constant live rather
98
+ # than caching, because the constant is the published stubbing point
99
+ # (image_compare_test stubs it to [] to exercise the no-drivers error
100
+ # path).
101
+ #
102
+ # Detection only exists because there is a choice of backend to detect;
103
+ # 2.1 removes the choice, so it warns. The gem's own callers read
104
+ # AVAILABLE_DRIVERS directly -- same value, same stubbing point, no
105
+ # warning at itself.
30
106
  def self.available
31
- Capybara::Screenshot::Diff::AVAILABLE_DRIVERS
107
+ Removal.warn_once(
108
+ :drivers_available,
109
+ "`SnapDiff::Drivers.available` is REMOVED in 2.1: with libvips the only backend there " \
110
+ "is nothing left to detect. Require the `ruby-vips` gem instead of branching on this " \
111
+ "list. See docs/drivers.md."
112
+ )
113
+ AVAILABLE_DRIVERS
32
114
  end
33
115
  end
34
116
  end
data/lib/snap_diff/dsl.rb CHANGED
@@ -1,18 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # No require of "capybara_screenshot_diff" here: every Capybara::Screenshot
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).
3
+ # The canonical entry point. Everything a user is told to require under
4
+ # snap_diff/ (this file, snap_diff/integrations/*, snap_diff/static) routes
5
+ # through here, so this one line is what makes SnapDiff.configure/.start/
6
+ # .compare/::VERSION -- and the dual-install guard -- present no matter
7
+ # which of those paths the user picked.
8
+ require "snap_diff"
9
+
10
+ # Must NOT require "capybara_screenshot_diff": that would cycle back here via
11
+ # this file's old-path forwarder. Nothing from the v1 trees is required here
12
+ # at all (3.0 readiness): the three requires below used to point at their
13
+ # capybara/screenshot/diff/* forwarders, which made this unit depend on the
14
+ # compatibility tree it is meant to replace.
9
15
  # DSL includes Capybara::DSL directly below, so it needs the base gem
10
16
  # loaded regardless of what pulled this file in.
11
17
  require "capybara/dsl"
12
- require "capybara/screenshot/diff/config_legacy"
13
- require "capybara/screenshot/diff/drivers"
18
+ require "snap_diff/config"
19
+ require "snap_diff/drivers"
14
20
  require "snap_diff/comparison"
15
- require "capybara/screenshot/diff/screenshot_matcher"
21
+ require "snap_diff/screenshot_matcher"
16
22
  require_relative "screenshot_namer"
17
23
  require_relative "screenshot_assertion"
18
24
 
@@ -27,11 +33,11 @@ module SnapDiff
27
33
  include Capybara::DSL
28
34
 
29
35
  def screenshot_section(name)
30
- screenshot_namer.section = name
36
+ SnapDiff.session.screenshot_namer.section = name
31
37
  end
32
38
 
33
39
  def screenshot_group(name)
34
- screenshot_namer.group = name
40
+ SnapDiff.session.screenshot_namer.group = name
35
41
  end
36
42
 
37
43
  # Takes a screenshot and compares it against a baseline image.
@@ -45,7 +51,7 @@ module SnapDiff
45
51
  # @param name [String] The base name of the screenshot, used to generate the filename.
46
52
  # @param skip_stack_frames [Integer] The number of stack frames to skip when reporting errors.
47
53
  # @param options [Hash] Additional options for taking the screenshot and comparison.
48
- # @option options [Boolean] :delayed (Capybara::Screenshot::Diff.delayed)
54
+ # @option options [Boolean] :delayed (SnapDiff.config.delayed)
49
55
  # Whether to validate the screenshot immediately or delay validation.
50
56
  # @option options [Array<Integer>] :crop [left, top, right, bottom] Edge coordinates to crop the screenshot to.
51
57
  # @option options [Array<Array<Integer>>] :skip_area Array of [left, top, right, bottom] edge coordinates to ignore.
@@ -58,23 +64,40 @@ module SnapDiff
58
64
  # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance for pixels.
59
65
  # @option options [Numeric] :area_size_limit Maximum allowed difference area size in pixels.
60
66
  # @option options [Symbol] :driver (:auto) The image processing driver to use (:auto, :chunky_png, :vips).
67
+ # @yield Optional readiness block: work that must happen before the page
68
+ # is captured -- settling lazy-loaded images, `document.fonts.ready`,
69
+ # waiting on a widget. Runs AFTER the `active?` guard and before the
70
+ # capture, exactly once per assertion (not once per stability
71
+ # attempt), and an error raised inside it propagates unchanged.
72
+ #
73
+ # The point is not ergonomics -- while screenshots are on, the block
74
+ # does nothing a line above the call could not. The point is what
75
+ # happens when they are OFF: this method returns at the guard above,
76
+ # so a preceding `preload_all_images` still costs its browser
77
+ # round-trips while the block costs nothing. Readiness work belongs
78
+ # inside the same switch as the capture it serves.
61
79
  # @return [Boolean] True if the screenshot was successfully captured and processed.
62
80
  # @raise [SnapDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
63
81
  # @raise [SnapDiff::UnstableImage] If the image comparison is unstable.
64
82
  # @raise [SnapDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
65
83
  def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
66
- return false unless Capybara::Screenshot.active?
84
+ return false unless SnapDiff.config.active?
85
+
86
+ yield if block_given?
67
87
 
68
88
  # Get the full name with section and group information
69
89
  full_name = SnapDiff.session.screenshot_namer.full_name(name)
70
90
 
71
- # Build the screenshot assertion
72
- assertion = build_screenshot_assertion(full_name, options, skip_stack_frames: skip_stack_frames + 1)
91
+ # Build the screenshot assertion; the actual comparison is deferred
92
+ # until ScreenshotAssertion#validate! runs.
93
+ assertion = SnapDiff::ScreenshotMatcher
94
+ .new(full_name, options)
95
+ .build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
73
96
 
74
97
  return false unless assertion
75
98
 
76
99
  # Determine if validation should be delayed or immediate
77
- delayed = options.fetch(:delayed, Capybara::Screenshot::Diff.delayed)
100
+ delayed = options.fetch(:delayed, SnapDiff.config.delayed)
78
101
 
79
102
  if delayed
80
103
  SnapDiff.session.add_assertion(assertion)
@@ -87,22 +110,28 @@ module SnapDiff
87
110
 
88
111
  # Convenience wrapper around {#assert_matches_screenshot} and {#capture_screenshot}.
89
112
  # @param compare [Boolean] When false, only captures the screenshot without comparing it to a baseline.
113
+ # @yield Forwarded to whichever of the two it delegates to. A delegator
114
+ # that swallowed the block would give the user a readiness block that
115
+ # silently never runs.
90
116
  # @see #assert_matches_screenshot
91
117
  # @see #capture_screenshot
92
- def screenshot(name, skip_stack_frames: 0, compare: true, **options)
118
+ def screenshot(name, skip_stack_frames: 0, compare: true, **options, &readiness)
93
119
  if compare
94
- assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options)
120
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options, &readiness)
95
121
  else
96
- capture_screenshot(name, **options)
122
+ capture_screenshot(name, **options, &readiness)
97
123
  end
98
124
  end
99
125
 
100
126
  # Captures a screenshot without comparing it to a baseline.
101
127
  # @param name [String] The base name of the screenshot, used to generate the filename.
102
128
  # @param options [Hash] Additional options for taking the screenshot. See {#assert_matches_screenshot}.
129
+ # @yield Optional readiness block. See {#assert_matches_screenshot}.
103
130
  # @return [Boolean] True if the screenshot was successfully captured.
104
131
  def capture_screenshot(name, **options)
105
- return false unless Capybara::Screenshot.active?
132
+ return false unless SnapDiff.config.active?
133
+
134
+ yield if block_given?
106
135
 
107
136
  full_name = SnapDiff.session.screenshot_namer.full_name(name)
108
137
  SnapDiff::ScreenshotMatcher.new(full_name, options).capture
@@ -113,31 +142,8 @@ module SnapDiff
113
142
  # Asserts the current page has no visual changes from the baseline.
114
143
  # Override in your base test class to add project-specific behavior
115
144
  # (e.g., waiting for Turbo, default skip areas).
116
- def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
117
- assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
118
- end
119
-
120
- private
121
-
122
- # Builds a screenshot assertion object that can be validated immediately or later.
123
- #
124
- # This method constructs a screenshot assertion that encapsulates the comparison logic.
125
- # The actual comparison is deferred until {ScreenshotAssertion#validate!} is called.
126
- #
127
- # @param name [String] The full name of the screenshot, including any section/group context.
128
- # @param options [Hash] Options for screenshot taking and comparison.
129
- # See {#assert_matches_screenshot} for available options.
130
- # @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
131
- # @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
132
- # @see ScreenshotAssertion
133
- def build_screenshot_assertion(name, options, skip_stack_frames: 0)
134
- SnapDiff::ScreenshotMatcher
135
- .new(name, options)
136
- .build_screenshot_assertion(skip_stack_frames: skip_stack_frames + 1)
137
- end
138
-
139
- def screenshot_namer
140
- SnapDiff.session.screenshot_namer
145
+ def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts, &readiness)
146
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts, &readiness)
141
147
  end
142
148
  end
143
149
  end
@@ -8,6 +8,12 @@ require "snap_diff/error_with_filtered_backtrace"
8
8
  # shims, because rescue clauses and defined?/const_defined? feature
9
9
  # detection in adopter code must keep behaving exactly as before
10
10
  # (const_defined? never triggers const_missing).
11
+ #
12
+ # Error is the catch-all docs/snapdiff.md advertises: EVERY error this gem
13
+ # raises inherits it, so `rescue SnapDiff::Error` really does catch them all
14
+ # (pinned by test/unit/errors_alias_test.rb, which discovers the classes
15
+ # rather than listing them). ErrorWithFilteredBacktrace is plumbing, not a
16
+ # second root.
11
17
  module SnapDiff
12
18
  class Error < ErrorWithFilteredBacktrace; end
13
19
 
@@ -15,5 +21,5 @@ module SnapDiff
15
21
 
16
22
  class UnstableImage < Error; end
17
23
 
18
- class WindowSizeMismatchError < ErrorWithFilteredBacktrace; end
24
+ class WindowSizeMismatchError < Error; end
19
25
  end
@@ -8,7 +8,7 @@ require "snap_diff/reporting"
8
8
  World(::SnapDiff::DSL)
9
9
 
10
10
  Before do
11
- Capybara::Screenshot::Diff.delayed = false
11
+ SnapDiff.config.delayed = false
12
12
  SnapDiff::BrowserHelpers.resize_window_if_needed
13
13
  end
14
14
 
@@ -8,14 +8,21 @@ require_relative "../dsl"
8
8
  require "snap_diff/screenshot_assertion"
9
9
  require "snap_diff/reporting"
10
10
 
11
- used_deprecated_entrypoint = caller.any? do |path|
12
- path.include?("capybara-screenshot-diff.rb") || path.include?("capybara/screenshot/diff.rb")
13
- end
14
-
15
- if used_deprecated_entrypoint
11
+ # Only the v1 NAMESPACE entry is a deprecated choice: requiring
12
+ # "capybara/screenshot/diff" is a line in the user's own file, and changing
13
+ # it is the fix. The gem-NAME file (lib/capybara-screenshot-diff.rb) is not:
14
+ # `Bundler.require` requires the gem's own name, so it loads for everyone
15
+ # with the gem in their Gemfile whatever they require explicitly -- keying
16
+ # the warning off it shouted at every user on every run, with no action
17
+ # available to silence it. See test/legacy/minitest_activation_warning_test.rb.
18
+ #
19
+ # Silenceable through the documented switch (SnapDiff.silence_deprecations /
20
+ # SNAP_DIFF_SILENCE_DEPRECATIONS): it was a bare Kernel#warn, so the one knob
21
+ # the docs offer did not reach it.
22
+ if !SnapDiff.silence_deprecations? && caller.any? { |path| path.include?("capybara/screenshot/diff.rb") }
16
23
  warn <<~MSG
17
- [DEPRECATION] The default activation of `capybara_screenshot_diff/minitest` will be removed.
18
- Please `require "capybara_screenshot_diff/minitest"` explicitly.
24
+ [DEPRECATION] `require "capybara/screenshot/diff"` activates the Minitest assertions for you; that will be removed.
25
+ Please `require "snap_diff/integrations/minitest"` explicitly.
19
26
  MSG
20
27
  end
21
28
 
@@ -24,8 +31,20 @@ module SnapDiff
24
31
  module Assertions
25
32
  include ::SnapDiff::DSL
26
33
 
34
+ # The `if` is the whole point (issue #270). `super` returns false
35
+ # immediately when screenshots are disabled -- nothing captured,
36
+ # nothing compared -- so counting unconditionally reported
37
+ # `1 runs, 1 assertions, 0 failures` over a test that asserted
38
+ # nothing at all.
39
+ #
40
+ # Getting the count right hands the alarm to Rails for free: it
41
+ # prepends ActiveSupport::Testing::TestsWithoutAssertions into every
42
+ # ActiveSupport::TestCase (test_case.rb:205), which warns
43
+ # "Test is missing assertions: `test_x`" on exactly the tests whose
44
+ # sole assertion was a disabled screenshot -- and stays quiet for
45
+ # tests that assert something else.
27
46
  def assert_matches_screenshot(*args, skip_stack_frames: 0, **opts)
28
- self.assertions += 1
47
+ self.assertions += 1 if SnapDiff.config.active?
29
48
 
30
49
  super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
31
50
  rescue ::SnapDiff::ExpectationNotMet => e
@@ -66,4 +85,21 @@ module SnapDiff
66
85
  end
67
86
  end
68
87
 
69
- ::Minitest.after_run { SnapDiff::Reporting.finalize! } if ::Minitest.respond_to?(:after_run)
88
+ # Under Rails' `parallelize(workers: N)` the tests run in forked children
89
+ # that never reach `after_run`, so the report and the summary line were
90
+ # lost on every suite past the parallelization threshold (issue #258).
91
+ # Registering the worker-side hook has to happen before `parallelize`
92
+ # forks; with `Bundler.require` this file loads before
93
+ # ActiveSupport::TestCase exists, so try now and again when it appears.
94
+ unless SnapDiff::Reporting.install_parallel_hooks!
95
+ if defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:on_load)
96
+ ::ActiveSupport.on_load(:active_support_test_case) { SnapDiff::Reporting.install_parallel_hooks! }
97
+ end
98
+ end
99
+
100
+ if ::Minitest.respond_to?(:after_run)
101
+ ::Minitest.after_run do
102
+ SnapDiff::Reporting.merge_parallel_fragments!
103
+ SnapDiff::Reporting.finalize!
104
+ end
105
+ end
@@ -9,6 +9,17 @@ require "snap_diff/reporting"
9
9
  RSpec::Matchers.define :match_screenshot do |name, **options|
10
10
  description { "match screenshot '#{name}'" }
11
11
 
12
+ # The literal `true` is deliberate, not the Minitest miscount of issue
13
+ # #270. `assert_matches_screenshot` returns false when screenshots are
14
+ # disabled, and returning that here would FAIL the example for a config
15
+ # switch the user set on purpose. A real mismatch does not come back as
16
+ # false either -- it raises SnapDiff::ExpectationNotMet, or is deferred
17
+ # to the append_after hook below.
18
+ #
19
+ # There is nothing to hand off to the way Minitest hands off to Rails'
20
+ # TestsWithoutAssertions: RSpec has no assertion count, so a disabled
21
+ # screenshot leaves an example that passed having checked nothing, and
22
+ # only the end-of-run `0 verified` line can see it.
12
23
  match do |_page|
13
24
  assert_matches_screenshot(name, **options)
14
25
  true