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,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The v1 umbrella, and the file every legacy entry point except
4
+ # capybara_screenshot_diff/dsl reaches. Marked here rather than in each of
5
+ # them; snap_diff-capybara.rb (which loads this umbrella for canonical
6
+ # users) claims the process first, so this stays quiet for them.
7
+ require "snap_diff/deprecation"
8
+ SnapDiff::Deprecation.legacy_entry_point!
9
+
3
10
  require "capybara/dsl"
4
11
  require "capybara/screenshot/diff/config_legacy"
5
12
  require "capybara/screenshot/diff/version"
@@ -24,24 +31,15 @@ require "capybara/screenshot/diff/reporters/default"
24
31
  require "capybara_screenshot_diff/error_with_filtered_backtrace"
25
32
  require "snap_diff/errors"
26
33
 
27
- module CapybaraScreenshotDiff
28
- # RED_RGBA / ORANGE_RGBA moved to SnapDiff (snap_diff/annotation_service)
29
- # so the bare "snap_diff" entry gets them too; the old names resolve via
30
- # snap_diff/legacy_shims with a deprecation warning.
31
-
32
- # ADR-008 step 2: the error classes moved to SnapDiff (snap_diff/errors).
33
- # These are EAGER same-object aliases -- deliberately not const_missing
34
- # shims -- so rescue-by-old-name and defined?/const_defined? feature
35
- # detection keep working exactly as before (const_defined? never
36
- # triggers const_missing).
37
- CapybaraScreenshotDiffError = SnapDiff::Error
38
-
39
- ExpectationNotMet = SnapDiff::ExpectationNotMet
40
-
41
- UnstableImage = SnapDiff::UnstableImage
42
-
43
- WindowSizeMismatchError = SnapDiff::WindowSizeMismatchError
44
- end
34
+ # RED_RGBA / ORANGE_RGBA moved to SnapDiff (snap_diff/annotation_service) so
35
+ # the bare "snap_diff" entry gets them too; the old names resolve via
36
+ # snap_diff/legacy_shims with a deprecation warning.
37
+ #
38
+ # The four error classes (CapybaraScreenshotDiffError, ExpectationNotMet,
39
+ # UnstableImage, WindowSizeMismatchError) used to be assigned here as EAGER
40
+ # same-object aliases. They still are eager -- just from
41
+ # snap_diff/legacy_shims, so a canonical-only require gets them too.
42
+ require "snap_diff/legacy_shims"
45
43
 
46
44
  require "capybara_screenshot_diff/dsl"
47
45
 
@@ -50,7 +48,5 @@ require "capybara_screenshot_diff/dsl"
50
48
  # any registered `autoload :SnapDiff` before it ever fires (Ruby resolves
51
49
  # the constant the first time anything reopens it, autoload or not) --
52
50
  # so SnapDiff.start/.compare/.config would silently never be defined
53
- # without this. Safe to do eagerly, unlike before this file's units
54
- # moved: snap_diff.rb no longer requires this file back (it only needs
55
- # the leaf config_legacy.rb + image_compare.rb), so there is no cycle.
51
+ # without this. Safe eagerly: snap_diff.rb never requires this file back.
56
52
  require "snap_diff"
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "snap_diff/region"
4
+ require "snap_diff/reporting"
4
5
 
5
6
  module SnapDiff
6
7
  module BrowserHelpers
7
8
  def self.resize_window_if_needed
8
- if ::Capybara::Screenshot.respond_to?(:window_size) && ::Capybara::Screenshot.window_size
9
- resize_to(::Capybara::Screenshot.window_size)
10
- end
9
+ # The respond_to? guard this replaced existed because the legacy
10
+ # mattr_accessor might not be installed yet; Config always has the
11
+ # attribute, so only the value matters now.
12
+ window_size = SnapDiff.config.window_size
13
+ resize_to(window_size) if window_size
11
14
  end
12
15
 
13
16
  def self.resize_to(window_size)
@@ -27,9 +30,16 @@ module SnapDiff
27
30
  session.driver.browser.manage.window.size != ::Selenium::WebDriver::Dimension.new(*expected_window_size)
28
31
  end
29
32
 
33
+ # The one seam that knows, per selector, whether it found anything --
34
+ # `all_visible_regions_for` is called once per selector and the caller
35
+ # gets back one flattened list. The run-level tally is fed here (#277b)
36
+ # rather than in AreaCalculator for exactly that reason: by the time
37
+ # the regions are concatenated the attribution is gone.
30
38
  def self.bounds_for_css(*css_selectors)
31
39
  css_selectors.reduce([]) do |regions, selector|
32
- regions.concat(all_visible_regions_for(selector))
40
+ found = all_visible_regions_for(selector)
41
+ SnapDiff::Reporting.record_selector_use(selector, matched: !found.empty?)
42
+ regions.concat(found)
33
43
  end
34
44
  end
35
45
 
@@ -96,8 +106,19 @@ module SnapDiff
96
106
  ]
97
107
  JS
98
108
 
109
+ # `minimum: 0` is load-bearing (issue #272). Capybara's `all` defaults to
110
+ # `minimum: 1` and blocks in `synchronize` until the count is satisfied,
111
+ # so every `skip_area`/`crop` selector matching nothing burned a full
112
+ # `Capybara.default_max_wait_time` -- 5s by default, per selector, per
113
+ # screenshot. One project measured `%w[picture img]` on an image-less
114
+ # page at 10s per screenshot, 44% of their suite.
115
+ #
116
+ # Waiting is the wrong semantic here regardless of the cost: these
117
+ # selectors describe a MASK over whatever is currently on the page. A
118
+ # selector that matches nothing has nothing to mask, and that answer is
119
+ # available immediately.
99
120
  def self.all_visible_regions_for(selector)
100
- BrowserHelpers.session.all(selector, visible: true).map { |el| region_for(el) }
121
+ BrowserHelpers.session.all(selector, visible: true, minimum: 0).map { |el| region_for(el) }
101
122
  end
102
123
 
103
124
  def self.region_for(element)
@@ -7,11 +7,8 @@ module SnapDiff
7
7
  # Per-capture viewport preparation seam.
8
8
  #
9
9
  # Called exactly once per capture, before the screenshoter runs and outside
10
- # any stability retry loop. Today it only validates the window size
11
- # (raise-only, never resizes) — the same guard ScreenshotMatcher carried
12
- # inline before. When v3 adds scroll preservation / element-anchored
13
- # capture it designs its own parameters here; adding an optional kwarg
14
- # later is non-breaking, so none is reserved up front.
10
+ # any stability retry loop. It only validates the window size
11
+ # (raise-only, never resizes).
15
12
  module Viewport
16
13
  module_function
17
14
 
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "snap_diff/comparison_result"
7
7
  require "snap_diff/drivers"
8
8
  require "snap_diff/image_preprocessor"
9
+ require "snap_diff/removal"
9
10
  require "snap_diff/reporters/default"
10
11
 
11
12
  module SnapDiff
@@ -42,6 +43,31 @@ module SnapDiff
42
43
 
43
44
  TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
44
45
 
46
+ # Every key anything downstream of here actually reads -- capture
47
+ # options included, because ScreenshotMatcher hands the same hash to the
48
+ # screenshoter and only carves :crop / :stability_time_limit / :wait out
49
+ # of the copy it passes on.
50
+ #
51
+ # Written out rather than derived from Config#default_options: that hash
52
+ # is what the gem merges in, so deriving from it would make the check
53
+ # agree with itself and validate nothing about the keys a USER adds.
54
+ KNOWN_OPTIONS = %i[
55
+ area_size_limit
56
+ capybara_screenshot_options
57
+ color_distance_limit
58
+ crop
59
+ delayed
60
+ driver
61
+ median_filter_window_size
62
+ perceptual_threshold
63
+ screenshot_format
64
+ shift_distance_limit
65
+ skip_area
66
+ stability_time_limit
67
+ tolerance
68
+ wait
69
+ ].freeze
70
+
45
71
  attr_reader :driver, :driver_options
46
72
  attr_reader :image_path, :base_image_path
47
73
  attr_reader :difference, :error_message
@@ -53,6 +79,20 @@ module SnapDiff
53
79
  ensure_files_exist!
54
80
 
55
81
  @driver_options = options.freeze
82
+ # THE no-silent-no-op check (ADR-010). Every option hash in the gem
83
+ # reaches this constructor, so a key nothing reads is caught here
84
+ # whichever entry point produced it. Frozen-but-unvalidated is how a
85
+ # misspelt `tolerence:` bought a green suite that compared nothing.
86
+ (options.keys - KNOWN_OPTIONS).each do |key|
87
+ Removal.warn_once(:"unknown_option_#{key}", Removal.unknown_option(key))
88
+ end
89
+ # The per-comparison half of the shift_distance_limit removal (the
90
+ # global half is Config#shift_distance_limit=). Presence is not enough:
91
+ # config.default_options carries the key on EVERY comparison, nil for
92
+ # everyone who never set it.
93
+ if options[:shift_distance_limit]
94
+ Removal.warn_once(:shift_distance_limit, Removal::SHIFT_DISTANCE_LIMIT_REMOVED)
95
+ end
56
96
  @driver = Drivers.for(@driver_options)
57
97
  @without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
58
98
  end
@@ -68,9 +108,7 @@ module SnapDiff
68
108
  #
69
109
  # @note This method will raise ArgumentError if either image file is missing.
70
110
  def quick_equal?
71
- if base_image_path.size == image_path.size
72
- return true if files_identical?(base_image_path, image_path)
73
- end
111
+ return true if identical_files?
74
112
 
75
113
  result, difference = find_difference(quick_mode: true)
76
114
  self.difference = difference
@@ -134,6 +172,13 @@ module SnapDiff
134
172
  # Validate images exist
135
173
  return build_null_difference("missing_image") unless images_exist?
136
174
 
175
+ # Step 1 of the layered strategy documented on this class: byte-identical
176
+ # files cannot have different pixels. quick_equal? has always checked
177
+ # this; the full path did not, and paid a decode of BOTH PNGs (11ms at
178
+ # 1440x900, 25ms at 2880x1800) to reach the same "no difference" answer
179
+ # on every screenshot that matches its baseline byte for byte.
180
+ return build_null_difference if !quick_mode && identical_files?
181
+
137
182
  # Create comparison with preprocessed images
138
183
  comparison = load_comparison(base_image_path, image_path, driver_options)
139
184
 
@@ -215,6 +260,12 @@ module SnapDiff
215
260
  base_image_path.exist? && image_path.exist?
216
261
  end
217
262
 
263
+ # Are the two screenshots the same bytes? Sizes first: a stat each rules
264
+ # out most pairs without reading either file.
265
+ def identical_files?
266
+ base_image_path.size == image_path.size && files_identical?(base_image_path, image_path)
267
+ end
268
+
218
269
  # Check if files are identical by content
219
270
  def files_identical?(file1, file2)
220
271
  FileUtils.compare_file(file1, file2)
@@ -67,8 +67,10 @@ module SnapDiff
67
67
  meta[:difference_level]
68
68
  end
69
69
 
70
+ # Serializable difference metrics. The raw diff mask image is excluded —
71
+ # it is an image object, not a metric, and is reachable via #diff_mask.
70
72
  def to_h
71
- {area_size: region_area_size, region: coordinates}.merge!(meta)
73
+ {area_size: region_area_size, region: coordinates}.merge!(meta.except(:diff_mask))
72
74
  end
73
75
 
74
76
  def coordinates
@@ -4,21 +4,15 @@ require "pathname"
4
4
 
5
5
  # This file is the LEAF of the config require graph (ADR-008 step 1):
6
6
  # config_legacy.rb requires it, so it must never require config_legacy nor
7
- # anything that leads back to either entry point. The MAPPING below needs
8
- # the legacy module constants to exist at class-body eval time, so the empty
9
- # skeleton is predefined here (same technique as legacy_shims.rb);
10
- # config_legacy.rb reopens these modules and installs the delegating
11
- # accessors from MAPPING.
12
- module Capybara
13
- module Screenshot
14
- module Diff
15
- end
16
- end
17
- end
7
+ # anything that leads back to either entry point. It also names nothing from
8
+ # the v1 trees at all (3.0 readiness): which legacy accessor each setting is
9
+ # exposed as is snap_diff/legacy_shims' business, and that file is deleted
10
+ # together with lib/capybara* -- see LegacyShims::CONFIG_MAPPING.
18
11
 
19
12
  # Referenced by Config#initialize (screenshoter/manager defaults), which
20
13
  # runs at the eager Config.new at the bottom of this file, so they must be
21
14
  # real, already-loaded classes first. Neither requires back here.
15
+ require "snap_diff/removal"
22
16
  require "snap_diff/screenshoter"
23
17
  require "snap_diff/snap_manager"
24
18
 
@@ -30,71 +24,101 @@ module SnapDiff
30
24
  # Storage ownership (ADR-008 step 1, inverted from the original v2
31
25
  # consolidation): Config IS the single storage. The legacy accessors on
32
26
  # +Capybara::Screenshot+ / +Capybara::Screenshot::Diff+ are thin
33
- # delegators installed by config_legacy.rb from {MAPPING} -- one storage,
34
- # two views, so a write through either surface is visible through the
35
- # other structurally, not by synchronization.
27
+ # delegators generated by snap_diff/legacy_shims from its CONFIG_MAPPING
28
+ # -- one storage, two views, so a write through either surface is visible
29
+ # through the other structurally, not by synchronization.
36
30
  #
37
31
  # Default timing contract (pinned by config_default_timing_test.rb):
38
- # every default below is evaluated ONCE, in #initialize, which runs at
39
- # require time of this file (the eager +Config.new+ at the bottom) -- the
40
- # same load moment the old +mattr_accessor+ default blocks evaluated at.
41
- # In particular +fail_if_new+ (from <tt>ENV["CI"]</tt>) and +root+ (from
42
- # +Rails.root+ / pwd) must never become lazy read-time defaults, memoized
43
- # or not. The one deliberately LIVE value, +default_options[:wait]+, is
44
- # not storage at all: it stays a method-body read of
45
- # +Capybara.default_max_wait_time+ in +#default_options+.
32
+ # every stored default below is evaluated ONCE, in #initialize, which runs
33
+ # at require time of this file (the eager +Config.new+ at the bottom) --
34
+ # the same load moment the old +mattr_accessor+ default blocks evaluated
35
+ # at. In particular +root+ (from +Rails.root+ / pwd) must never become a
36
+ # lazy read-time default, memoized or not.
37
+ #
38
+ # Two values are deliberately LIVE and are not storage at all:
39
+ # +default_options[:wait]+ stays a method-body read of
40
+ # +Capybara.default_max_wait_time+ in +#default_options+, and
41
+ # +fail_if_new+ falls back to <tt>ENV["CI"]</tt> in its reader whenever
42
+ # nothing explicit was set -- see {#fail_if_new} for why freezing that
43
+ # sniff into storage let the environment outrank the user.
46
44
  class Config
47
- # config attr name => [legacy module, legacy accessor name].
45
+ # Every setting this object stores, in the order the two legacy holders
46
+ # used to declare them.
48
47
  #
49
- # The two names differ only for +screenshot_enabled+:
50
- # +Capybara::Screenshot.enabled+ and +Capybara::Screenshot::Diff.enabled+
51
- # are independent settings (see +Capybara::Screenshot.active?+, which
52
- # reads both) that happen to share a bare name in their own modules. A
53
- # flat Config can't expose two attributes both called +enabled+, so the
54
- # Screenshot-side one is renamed here; Diff's keeps the bare +enabled+
55
- # name since it's the one most existing configuration touches directly.
56
- MAPPING = {
57
- # Capybara::Screenshot
58
- add_driver_path: [Capybara::Screenshot, :add_driver_path],
59
- add_os_path: [Capybara::Screenshot, :add_os_path],
60
- blur_active_element: [Capybara::Screenshot, :blur_active_element],
61
- screenshot_enabled: [Capybara::Screenshot, :enabled],
62
- hide_caret: [Capybara::Screenshot, :hide_caret],
63
- disable_animations: [Capybara::Screenshot, :disable_animations],
64
- root: [Capybara::Screenshot, :root],
65
- stability_time_limit: [Capybara::Screenshot, :stability_time_limit],
66
- window_size: [Capybara::Screenshot, :window_size],
67
- save_path: [Capybara::Screenshot, :save_path],
68
- use_lfs: [Capybara::Screenshot, :use_lfs],
69
- screenshot_format: [Capybara::Screenshot, :screenshot_format],
70
- capybara_screenshot_options: [Capybara::Screenshot, :capybara_screenshot_options],
71
- # Capybara::Screenshot::Diff
72
- delayed: [Capybara::Screenshot::Diff, :delayed],
73
- area_size_limit: [Capybara::Screenshot::Diff, :area_size_limit],
74
- fail_if_new: [Capybara::Screenshot::Diff, :fail_if_new],
75
- pending_if_new: [Capybara::Screenshot::Diff, :pending_if_new],
76
- fail_on_difference: [Capybara::Screenshot::Diff, :fail_on_difference],
77
- color_distance_limit: [Capybara::Screenshot::Diff, :color_distance_limit],
78
- enabled: [Capybara::Screenshot::Diff, :enabled],
79
- shift_distance_limit: [Capybara::Screenshot::Diff, :shift_distance_limit],
80
- skip_area: [Capybara::Screenshot::Diff, :skip_area],
81
- driver: [Capybara::Screenshot::Diff, :driver],
82
- tolerance: [Capybara::Screenshot::Diff, :tolerance],
83
- perceptual_threshold: [Capybara::Screenshot::Diff, :perceptual_threshold],
84
- screenshoter: [Capybara::Screenshot::Diff, :screenshoter],
85
- manager: [Capybara::Screenshot::Diff, :manager]
86
- }.freeze
87
-
88
- attr_accessor(*(MAPPING.keys - [:root]))
89
- attr_reader :root
48
+ # +screenshot_enabled+ is the one name that differs from its legacy
49
+ # spelling: +Capybara::Screenshot.enabled+ and
50
+ # +Capybara::Screenshot::Diff.enabled+ are independent settings (see
51
+ # {#active?}, which reads both) that happened to share a bare name in
52
+ # their own modules. A flat Config can't expose two attributes both
53
+ # called +enabled+, so the Screenshot-side one is renamed here; Diff's
54
+ # keeps the bare +enabled+ name since it's the one most existing
55
+ # configuration touches directly.
56
+ SETTINGS = %i[
57
+ add_driver_path
58
+ add_os_path
59
+ blur_active_element
60
+ screenshot_enabled
61
+ hide_caret
62
+ disable_animations
63
+ root
64
+ stability_time_limit
65
+ window_size
66
+ save_path
67
+ use_lfs
68
+ screenshot_format
69
+ capybara_screenshot_options
70
+ delayed
71
+ area_size_limit
72
+ record
73
+ fail_if_new
74
+ pending_if_new
75
+ fail_on_difference
76
+ color_distance_limit
77
+ enabled
78
+ shift_distance_limit
79
+ skip_area
80
+ driver
81
+ tolerance
82
+ perceptual_threshold
83
+ screenshoter
84
+ manager
85
+ ].freeze
86
+
87
+ # The record modes, in the order they escalate: do nothing new, refuse
88
+ # to record, record everything. See {#record}.
89
+ RECORD_MODES = %i[once none all].freeze
90
+
91
+ # @api private
92
+ #
93
+ # Shared by {#record=} and the per-screenshot `record:` option, which is
94
+ # resolved in {SnapDiff::ScreenshotMatcher}. A misspelt mode must not
95
+ # read back as "nobody said" and silently mean today's behaviour --
96
+ # that is the exact silent no-op ADR-010 exists to stop.
97
+ def self.validate_record_mode!(mode)
98
+ return mode if mode.nil? || RECORD_MODES.include?(mode)
99
+
100
+ raise ArgumentError,
101
+ "unknown record mode #{mode.inspect} -- one of #{RECORD_MODES.map(&:inspect).join(", ")} (or nil)"
102
+ end
103
+
104
+ # shift_distance_limit, driver and the three older new-screenshot
105
+ # booleans are excluded from the generated writers and hand written
106
+ # below (they announce their 2.1 removal, or validate); fail_if_new and
107
+ # record are excluded from the generated READER (both fall back rather
108
+ # than returning storage). Generating them here too would print Ruby's
109
+ # "method redefined" warning on every load.
110
+ attr_accessor(*(SETTINGS - %i[
111
+ root shift_distance_limit driver record fail_if_new pending_if_new fail_on_difference
112
+ ]))
113
+ attr_reader :root, :shift_distance_limit, :driver, :pending_if_new, :fail_on_difference
90
114
 
91
115
  def initialize
92
- # Every mapped setting gets its ivar up front (nil-defaulted ones
93
- # included) so the full set always exists -- test_helper's per-test
94
- # isolation snapshots/restores config by instance variable, and an
95
- # ivar that only appears on first write would escape that snapshot
96
- # and leak between tests.
97
- MAPPING.each_key { |key| instance_variable_set(:"@#{key}", nil) }
116
+ # Every setting gets its ivar up front (nil-defaulted ones included)
117
+ # so the full set always exists -- test_helper's per-test isolation
118
+ # snapshots/restores config by instance variable, and an ivar that
119
+ # only appears on first write would escape that snapshot and leak
120
+ # between tests.
121
+ SETTINGS.each { |key| instance_variable_set(:"@#{key}", nil) }
98
122
  # Capybara::Screenshot side.
99
123
  @blur_active_element = true
100
124
  @hide_caret = true
@@ -106,7 +130,7 @@ module SnapDiff
106
130
  @capybara_screenshot_options = {}
107
131
  # Capybara::Screenshot::Diff side.
108
132
  @delayed = true
109
- @fail_if_new = !ENV["CI"].nil? && !ENV["CI"].empty?
133
+ # No stored default for fail_if_new on purpose -- see the reader.
110
134
  @pending_if_new = false
111
135
  @fail_on_difference = true
112
136
  @enabled = true
@@ -119,12 +143,105 @@ module SnapDiff
119
143
  @root = Pathname(path).expand_path
120
144
  end
121
145
 
146
+ # An explicit setting outranks the environment. nil means nobody said,
147
+ # and only then does the CI sniff answer -- read live, so a CI variable
148
+ # that appears at any point is honoured, not just one that happened to
149
+ # be exported before the gem was required. Assigning nil hands the
150
+ # setting back to the environment.
151
+ #
152
+ # This is a precedence rule, not a change of default: failing only under
153
+ # CI stays (a locally recorded baseline is often worthless across OS).
154
+ # Storing the sniff instead, as this did, made the two indistinguishable
155
+ # -- `fail_if_new = false` and "CI was absent at require time" were the
156
+ # same false, so the environment could win. Same fix as insta#924
157
+ # ("normally, CLI flags take precedence over environment variables") and
158
+ # the inverse of jest#12288.
159
+ def fail_if_new
160
+ @fail_if_new.nil? ? !ENV["CI"].to_s.empty? : @fail_if_new
161
+ end
162
+
163
+ # Announces the 2.1 removal. The writer, not the reader: {#record} reads
164
+ # +fail_if_new+ on every screenshot for everyone, and #initialize stores
165
+ # no default at all, so only a user who sets it hears about it.
166
+ def fail_if_new=(value)
167
+ Removal.warn_once(:fail_if_new, Removal::FAIL_IF_NEW_REMOVED)
168
+ @fail_if_new = value
169
+ end
170
+
171
+ # Same shape, same reason: SnapDiff.pending_screenshots_message reads
172
+ # this for everyone, and #initialize seeds the +false+ default straight
173
+ # into the ivar.
174
+ def pending_if_new=(value)
175
+ Removal.warn_once(:pending_if_new, Removal::PENDING_IF_NEW_REMOVED)
176
+ @pending_if_new = value
177
+ end
178
+
179
+ # Same: ScreenshotAssertion.verify_screenshots! reads it once per test
180
+ # for everyone, and #initialize seeds the +true+ default.
181
+ def fail_on_difference=(value)
182
+ Removal.warn_once(:fail_on_difference, Removal::FAIL_ON_DIFFERENCE_REMOVED)
183
+ @fail_on_difference = value
184
+ end
185
+
186
+ # THE ACCEPT WORKFLOW (#259). What to do about a screenshot whose
187
+ # baseline is missing -- or, for +:all+, about every screenshot there
188
+ # is. VCR-shaped: modes in config, because there is no runner to hang a
189
+ # CLI flag on.
190
+ #
191
+ # +:once+:: (default) record a screenshot that has no committed
192
+ # baseline; compare against the baseline when there is one.
193
+ # +:none+:: strict. A missing baseline always fails.
194
+ # +:all+:: re-record. Every screenshot is written as the new baseline
195
+ # and nothing is compared -- the bulk-accept verb, for the
196
+ # redesign that changed forty screenshots at once. Refused
197
+ # under CI (see {SnapDiff::ScreenshotMatcher}).
198
+ #
199
+ # PRECEDENCE: an explicitly set mode outranks +fail_if_new+; nil means
200
+ # nobody said, and only then does +fail_if_new+ answer -- so a setup
201
+ # with no +record+ line behaves EXACTLY as it did before this setting
202
+ # existed, CI sniff and all. +:none+ is the mode spelling of
203
+ # <tt>fail_if_new = true</tt> and +:once+ of <tt>= false</tt>, which is
204
+ # why the fallback can express today's behaviour without a special case
205
+ # anywhere downstream: the matcher branches on the mode alone.
206
+ #
207
+ # The missing-baseline DEFAULT is deliberately unchanged -- failing only
208
+ # under CI is what Jest, AVA, Vitest, testthat and jest-image-snapshot
209
+ # all chose, and a locally recorded screenshot baseline is often
210
+ # worthless across OS. +:none+ makes strictness an explicit choice
211
+ # instead; that is the whole point of having the mode.
212
+ def record
213
+ @record || (fail_if_new ? :none : :once)
214
+ end
215
+
216
+ def record=(mode)
217
+ @record = Config.validate_record_mode!(mode)
218
+ end
219
+
220
+ # Overrides the generated accessor above to announce the 2.1 removal
221
+ # (chunky_png-only, and chunky_png goes too). The writer, not the reader:
222
+ # the reader runs on every comparison through #default_options, including
223
+ # for the overwhelming majority who never set this. #initialize seeds the
224
+ # ivar directly, so booting the gem stays silent.
225
+ def shift_distance_limit=(value)
226
+ Removal.warn_once(:shift_distance_limit, Removal::SHIFT_DISTANCE_LIMIT_REMOVED) unless value.nil?
227
+ @shift_distance_limit = value
228
+ end
229
+
230
+ # Same shape, same reason: the writer only. #default_options reads
231
+ # +driver+ on every comparison for everyone, and #initialize seeds the
232
+ # +:auto+ default straight into the ivar, so only a user deliberately
233
+ # picking a backend hears about it.
234
+ def driver=(value)
235
+ Removal.warn_once(:driver_setting, Removal::DRIVER_REMOVED)
236
+ @driver = value
237
+ end
238
+
122
239
  # --- Derived config (ADR-008 step 7b) -------------------------------
123
240
  # Read-only values computed from the storage above. They used to live
124
241
  # on the legacy modules; those now one-line forward here.
125
242
 
126
243
  # ex +Capybara::Screenshot.active?+. The two +enabled+ settings are
127
- # independent (see {MAPPING}): the Screenshot-side one wins whenever it
244
+ # independent (see {SETTINGS}): the Screenshot-side one wins whenever it
128
245
  # was set at all, and only a nil there falls through to the Diff-side
129
246
  # one.
130
247
  def active?
@@ -176,25 +293,4 @@ module SnapDiff
176
293
  def self.config
177
294
  @config
178
295
  end
179
-
180
- # Installs the old mattr_accessor surface onto the legacy modules,
181
- # delegating to the single storage above. mattr_accessor used to define
182
- # both singleton and instance accessors (the instance ones are what
183
- # `include Capybara::Screenshot::Diff` picks up), so both are installed.
184
- # root keeps its historical asymmetry -- readable everywhere, writable
185
- # only at module level (it was mattr_reader plus a custom module-level
186
- # writer) -- with the Pathname coercion living in Config#root=.
187
- #
188
- # Generated here rather than in config_legacy.rb (ADR-008 step 7b) for
189
- # the same reason legacy_shims.rb generates the legacy constants here:
190
- # the generator is code, and the v1 trees must stay alias-only so 3.0 is
191
- # a `git rm`. Same technique, same side of the fence.
192
- Config::MAPPING.each do |name, (mod, mattr)|
193
- [mod, mod.singleton_class].each do |target|
194
- target.define_method(mattr) { SnapDiff.config.public_send(name) }
195
- next if name == :root && target == mod
196
-
197
- target.define_method(:"#{mattr}=") { |value| SnapDiff.config.public_send(:"#{name}=", value) }
198
- end
199
- end
200
296
  end