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,37 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "snap_diff/comparison"
4
+ require "snap_diff/config"
3
5
  require "snap_diff/deprecation"
4
6
  require "snap_diff/drivers"
7
+ require "snap_diff/errors"
8
+ require "snap_diff/os"
9
+ require "snap_diff/reporters/default"
10
+ require "snap_diff/version"
5
11
 
6
- # ADR-004 v2 step 6: const_missing-based forwarders for the pre-v2
7
- # namespaces. Every old-name lookup below resolves -- lazily -- to the exact
12
+ # THE v1 compatibility surface, in one file -- and the whole of it that is
13
+ # code. lib/capybara* is alias-only by contract
14
+ # (legacy_tree_is_alias_only_test.rb) and the canonical core names nothing
15
+ # from it (core_tree_has_no_legacy_deps_test.rb), so this file plus those
16
+ # trees is exactly what 3.0 deletes.
17
+ #
18
+ # Three things live here:
19
+ # 1. the const_missing forwarders for the pre-v2 namespaces (below);
20
+ # 2. CONFIG_MAPPING -- the old mattr_accessor surface, generated as thin
21
+ # delegators onto SnapDiff.config (which owns the storage);
22
+ # 3. the derived/config forwarders that used to sit in config_legacy.rb
23
+ # (Screenshot.active?, Diff.configure, SnapDiff.start, ...).
24
+ #
25
+ # 2 and 3 moved here so `require "snap_diff"` can keep offering the full v1
26
+ # surface -- as it always has -- without the core requiring anything from
27
+ # lib/capybara/.
28
+ #
29
+ # const_missing-based forwarders for the pre-v2 namespaces. Every old-name
30
+ # lookup below resolves -- lazily -- to the exact
8
31
  # same object as its SnapDiff:: replacement (identity pinned by
9
32
  # test/unit/namespace_forwarding_test.rb) and emits a deprecation warning,
10
33
  # once per constant per process, silenceable via
11
34
  # SnapDiff.silence_deprecations or SNAP_DIFF_SILENCE_DEPRECATIONS=1.
12
35
  #
13
- # Deliberately eager-and-silent exceptions (plain constants, defined by
14
- # their own forwarder files, never warn):
36
+ # Deliberately eager-and-silent exceptions (plain constants assigned BELOW,
37
+ # never warn individually -- the once-per-process migration notice still
38
+ # fires for the paths that CAN be hooked; see Deprecation.notice):
15
39
  #
16
- # - Capybara::Screenshot::Os and CapybaraScreenshotDiff::DSL (and the
17
- # unmapped CapybaraScreenshotDiff::Minitest::Assertions): advertised
18
- # entry-point constants probed with Object.const_defined? by
19
- # support_load_probe_test.rb -- const_defined? never triggers
20
- # const_missing, so a lazy shim would break that contract.
21
- # - Capybara::Screenshot::Diff::VERSION: the gemspec resolves it at build
22
- # time; a lazy shim would make every `gem build` warn.
40
+ # - Capybara::Screenshot::Os: an advertised entry-point constant probed with
41
+ # Object.const_defined? by support_load_probe_test.rb -- const_defined?
42
+ # never triggers const_missing, so a lazy shim would break that contract.
43
+ # - Capybara::Screenshot::Diff::VERSION, and ::Comparison (the images
44
+ # struct): documented user-facing names that adopters feature-detect with
45
+ # defined?/const_defined?.
46
+ # - Capybara::Screenshot::Diff::Reporters::Default (a documented subclassing
47
+ # extension point): same reasoning.
48
+ # - The CapybaraScreenshotDiff error classes: rescue-by-old-name and
49
+ # defined? feature detection must keep behaving exactly as before.
23
50
  # - Drivers::ChunkyPNGDriver / Drivers::VipsDriver: real constants on the
24
51
  # shared SnapDiff::Drivers module (the Drivers alias is same-object by
25
52
  # contract), so const_missing can never fire for the leaf names;
26
- # resolving them through the old path still warns for ...::Drivers.
53
+ # resolving them through the old path still warns for ...::Drivers. They
54
+ # are `autoload`ed there, so naming one loads it.
27
55
  # - Diff::LOADED_DRIVERS: user code registers custom drivers by mutating
28
56
  # this hash in place, so it must be the exact same object as the
29
57
  # canonical SnapDiff::Drivers.loaded -- a lazy warn-once shim could not
30
58
  # keep a mutable alias, and warning on a supported registration surface
31
59
  # 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.
60
+ # - Diff::AVAILABLE_DRIVERS: stays a real constant, aliased BELOW from the
61
+ # canonical SnapDiff::Drivers::AVAILABLE_DRIVERS (same object;
62
+ # SnapDiff::Drivers.available is the canonical reader, and its constant is
63
+ # the stubbing point -- stubbing this alias only rebinds the alias).
64
+ #
65
+ # All of them are assigned HERE rather than in their own forwarder files
66
+ # under lib/capybara*. Those forwarders are loaded only by the LEGACY entry
67
+ # points, so a partially migrated app -- one that swapped its `require` line
68
+ # for a canonical `snap_diff*` one first, exactly as UPGRADING.md tells it
69
+ # to, and has not renamed its constants yet -- lost every one of them and
70
+ # died on `uninitialized constant Capybara::Screenshot::Os`. This file is
71
+ # required by every entry point, canonical and legacy, so it is the only
72
+ # place the eager exceptions can actually be eager.
73
+ #
74
+ # CapybaraScreenshotDiff::DSL and ::Minitest::Assertions are the two that
75
+ # CANNOT be eager here: snap_diff/dsl requires "snap_diff" (which requires
76
+ # this file), and snap_diff/integrations/minitest pulls in the minitest gem,
77
+ # which no canonical entry point should force on a process. They are mapped
78
+ # lazily below instead, and stay eager under the legacy entry points that
79
+ # load their forwarder files.
80
+
81
+ # The v1 namespaces, predefined empty so CONFIG_MAPPING can name them at
82
+ # class-body eval time. Everything below reopens them.
83
+ module Capybara
84
+ module Screenshot
85
+ module Diff
86
+ end
87
+ end
88
+ end
89
+
35
90
  module SnapDiff
36
91
  # @api private
37
92
  module LegacyShims
@@ -45,20 +100,207 @@ module SnapDiff
45
100
  target = mapping[name]
46
101
  return super(name) unless target
47
102
 
48
- Deprecation.warn("#{old_prefix}::#{name}", target, category: :constant)
49
- Object.const_get(target)
103
+ Deprecation.warn("#{old_prefix}::#{name}", target)
104
+ LegacyShims.resolve("#{old_prefix}::#{name}", target)
105
+ end
106
+ end
107
+
108
+ # The handful of replacements whose file name does not follow the gem's
109
+ # own convention (SnapDiff::AreaCalculator -> snap_diff/area_calculator).
110
+ REQUIRE_PATHS = {
111
+ "SnapDiff::Minitest::Assertions" => "snap_diff/integrations/minitest"
112
+ }.freeze
113
+
114
+ # Resolving an old name has to LOAD the replacement, not merely name it.
115
+ # The v1 entry points required the whole gem, so v1 code could say
116
+ # `Capybara::Screenshot::Diff::Utils` with nothing else required; the
117
+ # canonical entry points are lean, so the shim used to resolve its
118
+ # mapping and then die on a bare "uninitialized constant SnapDiff::Utils"
119
+ # -- an internal name the reader has no way to act on.
120
+ def self.resolve(old_name, target)
121
+ require_unit(target) unless Object.const_defined?(target)
122
+ Object.const_get(target)
123
+ rescue NameError
124
+ # Deliberately does NOT advise "reference #{target} directly": we just
125
+ # failed to load it, so that name does not exist either.
126
+ raise NameError, "`#{old_name}` maps to `#{target}`, which this process cannot load. " \
127
+ "See docs/UPGRADING.md for the v1 -> SnapDiff name map."
128
+ end
129
+
130
+ def self.require_unit(target)
131
+ require(REQUIRE_PATHS[target] || target
132
+ .gsub("::", "/")
133
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
134
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
135
+ .downcase)
136
+ rescue LoadError
137
+ # No file of its own -- the name lives inside another unit
138
+ # (SnapDiff::RED_RGBA, ::BacktraceFilter). The const_get above decides
139
+ # whether it is already loaded.
140
+ end
141
+
142
+ # config attr name => [legacy module, legacy accessor name].
143
+ # The keys are exactly SnapDiff::Config::SETTINGS; this hash only says
144
+ # which of the two legacy holders each one used to hang off, and under
145
+ # what name (only +screenshot_enabled+ differs -- see Config::SETTINGS).
146
+ CONFIG_MAPPING = {
147
+ # Capybara::Screenshot
148
+ add_driver_path: [Capybara::Screenshot, :add_driver_path],
149
+ add_os_path: [Capybara::Screenshot, :add_os_path],
150
+ blur_active_element: [Capybara::Screenshot, :blur_active_element],
151
+ screenshot_enabled: [Capybara::Screenshot, :enabled],
152
+ hide_caret: [Capybara::Screenshot, :hide_caret],
153
+ disable_animations: [Capybara::Screenshot, :disable_animations],
154
+ root: [Capybara::Screenshot, :root],
155
+ stability_time_limit: [Capybara::Screenshot, :stability_time_limit],
156
+ window_size: [Capybara::Screenshot, :window_size],
157
+ save_path: [Capybara::Screenshot, :save_path],
158
+ use_lfs: [Capybara::Screenshot, :use_lfs],
159
+ screenshot_format: [Capybara::Screenshot, :screenshot_format],
160
+ capybara_screenshot_options: [Capybara::Screenshot, :capybara_screenshot_options],
161
+ # Capybara::Screenshot::Diff
162
+ delayed: [Capybara::Screenshot::Diff, :delayed],
163
+ area_size_limit: [Capybara::Screenshot::Diff, :area_size_limit],
164
+ # New in 2.0 and it has no v1 history, but it is storage on the one
165
+ # Config like everything else, and the two-views invariant is
166
+ # all-or-nothing: an unmapped setting is storage the v1 surface cannot
167
+ # see. Mapping it also means a user still on the old namespace reaches
168
+ # the accept workflow without migrating first.
169
+ record: [Capybara::Screenshot::Diff, :record],
170
+ fail_if_new: [Capybara::Screenshot::Diff, :fail_if_new],
171
+ pending_if_new: [Capybara::Screenshot::Diff, :pending_if_new],
172
+ fail_on_difference: [Capybara::Screenshot::Diff, :fail_on_difference],
173
+ color_distance_limit: [Capybara::Screenshot::Diff, :color_distance_limit],
174
+ enabled: [Capybara::Screenshot::Diff, :enabled],
175
+ shift_distance_limit: [Capybara::Screenshot::Diff, :shift_distance_limit],
176
+ skip_area: [Capybara::Screenshot::Diff, :skip_area],
177
+ driver: [Capybara::Screenshot::Diff, :driver],
178
+ tolerance: [Capybara::Screenshot::Diff, :tolerance],
179
+ perceptual_threshold: [Capybara::Screenshot::Diff, :perceptual_threshold],
180
+ screenshoter: [Capybara::Screenshot::Diff, :screenshoter],
181
+ manager: [Capybara::Screenshot::Diff, :manager]
182
+ }.freeze
183
+
184
+ # Installs the old mattr_accessor surface onto the legacy modules,
185
+ # delegating to the single storage in SnapDiff.config. mattr_accessor
186
+ # used to define both singleton and instance accessors (the instance
187
+ # ones are what `include Capybara::Screenshot::Diff` picks up), so both
188
+ # are installed. root keeps its historical asymmetry -- readable
189
+ # everywhere, writable only at module level (it was mattr_reader plus a
190
+ # custom module-level writer) -- with the Pathname coercion living in
191
+ # Config#root=.
192
+ def self.install_config_accessors
193
+ CONFIG_MAPPING.each do |name, (mod, mattr)|
194
+ [mod, mod.singleton_class].each do |target|
195
+ target.define_method(mattr) do
196
+ Deprecation.notice
197
+ SnapDiff.config.public_send(name)
198
+ end
199
+ next if name == :root && target == mod
200
+
201
+ target.define_method(:"#{mattr}=") do |value|
202
+ Deprecation.notice
203
+ SnapDiff.config.public_send(:"#{name}=", value)
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ # `include Capybara::Screenshot::Diff` is the third way into the v1
210
+ # surface (it picks up the instance-level accessors installed above) and
211
+ # resolves no deprecated constant of its own, so it needs its own hook
212
+ # for the once-per-process notice.
213
+ def self.install_include_notice(mod)
214
+ mod.define_singleton_method(:included) do |base|
215
+ Deprecation.notice
216
+ super(base)
50
217
  end
51
218
  end
52
219
  end
53
220
  end
54
221
 
222
+ SnapDiff::LegacyShims.install_config_accessors
223
+ SnapDiff::LegacyShims.install_include_notice(Capybara::Screenshot)
224
+ SnapDiff::LegacyShims.install_include_notice(Capybara::Screenshot::Diff)
225
+
226
+ module SnapDiff
227
+ # v1-style configuration: yields the two legacy accessor holders
228
+ # (+Capybara::Screenshot+, +Capybara::Screenshot::Diff+) exactly as
229
+ # +Capybara::Screenshot::Diff.configure+ always has -- and, since ADR-008
230
+ # step 7b, this is where that yield actually happens; Diff.configure
231
+ # forwards here. Both names stay identical in call shape.
232
+ #
233
+ # SnapDiff.start do |screenshot, diff|
234
+ # screenshot.window_size = [1280, 1024]
235
+ # diff.tolerance = 0.0005
236
+ # end
237
+ #
238
+ # Defined in this file, not snap_diff.rb, because the two holders it
239
+ # yields ARE the v1 surface: it cannot outlive them. The consolidated
240
+ # shape, SnapDiff.configure, is the canonical one and lives in the core.
241
+ def self.start
242
+ yield Capybara::Screenshot, Capybara::Screenshot::Diff
243
+ end
244
+ end
245
+
55
246
  module Capybara
56
247
  module Screenshot
248
+ # EAGER same-object alias (see header): the only place it can be eager
249
+ # for a canonical-only require, which is what a half-migrated app has.
250
+ Os = SnapDiff::Os
251
+
252
+ # Derived config, ex config_legacy.rb: one-line forwarders onto the
253
+ # canonical implementations in SnapDiff::Config (ADR-008 step 7b).
254
+ class << self
255
+ def active?
256
+ SnapDiff.config.active?
257
+ end
258
+
259
+ def screenshot_area
260
+ SnapDiff.config.screenshot_area
261
+ end
262
+
263
+ def screenshot_area_abs
264
+ SnapDiff.config.screenshot_area_abs
265
+ end
266
+ end
267
+
57
268
  module Diff
58
- # EAGER same-object alias of the canonical driver cache (see header).
59
- LOADED_DRIVERS = SnapDiff::Drivers.loaded
269
+ # EAGER same-object aliases of canonical values (see header for why
270
+ # each one is eager rather than a warn-once const_missing shim).
271
+ # .registry, not .loaded: this alias is assigned at load time by the
272
+ # gem itself, and .loaded announces its own 2.1 removal. Same object
273
+ # either way -- which is the whole point of the alias.
274
+ LOADED_DRIVERS = SnapDiff::Drivers.registry
275
+ AVAILABLE_DRIVERS = SnapDiff::Drivers::AVAILABLE_DRIVERS
276
+ Comparison = SnapDiff::Comparison::Images
277
+ VERSION = SnapDiff::VERSION
60
278
 
61
279
  module Reporters
280
+ Default = SnapDiff::Reporters::Default
281
+ end
282
+
283
+ # Configure screenshot and diff settings in one block.
284
+ #
285
+ # Capybara::Screenshot::Diff.configure do |screenshot, diff|
286
+ # screenshot.window_size = [1280, 1024]
287
+ # screenshot.stability_time_limit = 1
288
+ # diff.driver = :vips
289
+ # diff.tolerance = 0.0005
290
+ # end
291
+ # The bare `yield` (rather than an explicit &block) keeps this
292
+ # method's published arity byte-identical to what it always had.
293
+ def self.configure
294
+ SnapDiff.start { |screenshot, diff| yield screenshot, diff }
295
+ end
296
+
297
+ def self.compare(baseline_path, current_path, **options)
298
+ SnapDiff.compare(baseline_path, current_path, **options)
299
+ end
300
+
301
+ def self.default_options
302
+ SnapDiff::Deprecation.notice
303
+ SnapDiff.config.default_options
62
304
  end
63
305
  end
64
306
  end
@@ -67,6 +309,19 @@ end
67
309
  module CapybaraScreenshotDiff
68
310
  module Reporters
69
311
  end
312
+
313
+ # Predefined so the mapping below has a namespace to hang const_missing
314
+ # on; capybara_screenshot_diff/minitest reopens it with the eager alias.
315
+ module Minitest
316
+ end
317
+
318
+ # EAGER same-object aliases (see header): rescue-by-old-name and
319
+ # defined?/const_defined? feature detection must behave as they always
320
+ # have, under canonical and legacy requires alike.
321
+ CapybaraScreenshotDiffError = SnapDiff::Error
322
+ ExpectationNotMet = SnapDiff::ExpectationNotMet
323
+ UnstableImage = SnapDiff::UnstableImage
324
+ WindowSizeMismatchError = SnapDiff::WindowSizeMismatchError
70
325
  end
71
326
 
72
327
  SnapDiff::LegacyShims.install(Capybara::Screenshot, "Capybara::Screenshot", {
@@ -84,14 +339,9 @@ SnapDiff::LegacyShims.install(Capybara::Screenshot::Diff, "Capybara::Screenshot:
84
339
  ScreenshotMatcher: "SnapDiff::ScreenshotMatcher",
85
340
  Drivers: "SnapDiff::Drivers",
86
341
  ImageCompare: "SnapDiff::Comparison",
87
- Comparison: "SnapDiff::Comparison::Images",
88
342
  Difference: "SnapDiff::ComparisonResult"
89
343
  }.freeze)
90
344
 
91
- SnapDiff::LegacyShims.install(Capybara::Screenshot::Diff::Reporters, "Capybara::Screenshot::Diff::Reporters", {
92
- Default: "SnapDiff::Reporters::Default"
93
- }.freeze)
94
-
95
345
  SnapDiff::LegacyShims.install(CapybaraScreenshotDiff, "CapybaraScreenshotDiff", {
96
346
  RED_RGBA: "SnapDiff::RED_RGBA",
97
347
  ORANGE_RGBA: "SnapDiff::ORANGE_RGBA",
@@ -102,16 +352,21 @@ SnapDiff::LegacyShims.install(CapybaraScreenshotDiff, "CapybaraScreenshotDiff",
102
352
  BacktraceFilter: "SnapDiff::BacktraceFilter",
103
353
  ErrorWithFilteredBacktrace: "SnapDiff::ErrorWithFilteredBacktrace",
104
354
  ScreenshotAssertion: "SnapDiff::ScreenshotAssertion",
105
- AssertionRegistry: "SnapDiff::AssertionRegistry"
355
+ AssertionRegistry: "SnapDiff::AssertionRegistry",
356
+ DSL: "SnapDiff::DSL"
106
357
  }.freeze)
107
358
 
108
359
  SnapDiff::LegacyShims.install(CapybaraScreenshotDiff::Reporters, "CapybaraScreenshotDiff::Reporters", {
109
360
  HTML: "SnapDiff::Reporters::HTML"
110
361
  }.freeze)
111
362
 
112
- # BaseDriver dissolved into the SnapDiff::Driver mixin (v2 step 4); the
113
- # Drivers alias is same-object, so the hook has to live on SnapDiff::Drivers
114
- # itself. `class MyDriver < BaseDriver` becomes `include SnapDiff::Driver`.
363
+ SnapDiff::LegacyShims.install(CapybaraScreenshotDiff::Minitest, "CapybaraScreenshotDiff::Minitest", {
364
+ Assertions: "SnapDiff::Minitest::Assertions"
365
+ }.freeze)
366
+
367
+ # BaseDriver dissolved into the SnapDiff::Driver mixin; the Drivers alias is
368
+ # same-object, so the hook has to live on SnapDiff::Drivers itself.
369
+ # `class MyDriver < BaseDriver` becomes `include SnapDiff::Driver`.
115
370
  SnapDiff::LegacyShims.install(SnapDiff::Drivers, "Capybara::Screenshot::Diff::Drivers", {
116
371
  BaseDriver: "SnapDiff::Driver"
117
372
  }.freeze)
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnapDiff
4
+ # @api private
5
+ #
6
+ # Announces what 2.1 REMOVES, from the 2.0 line that still supports it:
7
+ # the chunky_png driver, +shift_distance_limit+ (chunky-only, it dies with
8
+ # it) and the driver abstraction (+SnapDiff::Driver+,
9
+ # +SnapDiff::Drivers.loaded+ / +.available+, <tt>driver: :auto</tt>) --
10
+ # libvips becomes the only backend. 2.0 is the transitional release: the
11
+ # contract is published before it is enforced.
12
+ #
13
+ # Same shape and same silencing switches as {SnapDiff::Deprecation}, which
14
+ # announces the other half (the v1 namespaces), but a file of its own: the
15
+ # legacy shims and their deprecation channel are themselves part of what is
16
+ # removed, while the call sites here -- utils, config, drivers -- are core
17
+ # files that outlive them, so they cannot depend on a doomed file. That is
18
+ # also why {SnapDiff.silence_deprecations} lives HERE rather than in
19
+ # deprecation.rb: it is the one switch that silences both halves.
20
+ #
21
+ # Deliberately not a warn-per-call channel: one line per subject per
22
+ # process is an actionable signal, N lines per comparison is noise people
23
+ # learn to filter out.
24
+ module Removal
25
+ # Everything under lib/ is "the gem"; the first caller frame outside it
26
+ # is the user code that touched the doomed API.
27
+ GEM_LIB_DIR = File.expand_path("..", __dir__) + File::SEPARATOR
28
+
29
+ # Appended to every message, so the individual messages can stay about
30
+ # the thing being removed.
31
+ SILENCE_HINT =
32
+ "Silence with `SnapDiff.silence_deprecations = true` or " \
33
+ "SNAP_DIFF_SILENCE_DEPRECATIONS=1. (shown once per process)"
34
+
35
+ # The one message with two call sites -- the setting's writer (Config)
36
+ # and the per-comparison option (Comparison) -- so it lives here rather
37
+ # than in either of them. One subject, one warning, whichever fires.
38
+ SHIFT_DISTANCE_LIMIT_REMOVED =
39
+ "`shift_distance_limit` is REMOVED in 2.1: it is implemented only by the chunky_png " \
40
+ "driver, which is removed with it. libvips has no shift-distance comparison -- drop the " \
41
+ "option and tune `tolerance` / `color_distance_limit` instead. See docs/configuration.md."
42
+
43
+ # Also one subject with several call sites -- the setting's writer
44
+ # (Config) and the raw per-screenshot / per-compare option hashes.
45
+ # Deliberately value-blind: `driver: :vips` warns too. It is the knob
46
+ # that picks between implementations, and 2.1 leaves one implementation,
47
+ # so the line goes whatever it currently says.
48
+ DRIVER_REMOVED =
49
+ "The `driver` setting and the per-screenshot `driver:` option are REMOVED in 2.1: libvips " \
50
+ "becomes the only backend, so there is nothing left to select. Drop the option and depend " \
51
+ "on the `ruby-vips` gem instead. See docs/drivers.md."
52
+
53
+ # --- the new-screenshot booleans, superseded by `record:` (#259) -----
54
+ #
55
+ # Three separate settings, each answering some part of "what happens
56
+ # when there is no baseline / when there is a difference", none of them
57
+ # naming the action the user actually wants. 2.0 adds the verb --
58
+ # `SnapDiff.config.record` -- and 2.1 removes the booleans. Each message
59
+ # names the mode that replaces THAT setting, and nothing more: `record:`
60
+ # supersedes the missing-baseline half squarely and the other two only
61
+ # obliquely, and a message that overclaims is a message that misroutes.
62
+
63
+ FAIL_IF_NEW_REMOVED =
64
+ "`fail_if_new` is REMOVED in 2.1: the record modes replace it. `SnapDiff.config.record = :none` " \
65
+ "is `fail_if_new = true`, `= :once` is `fail_if_new = false` -- and unlike the boolean, a mode " \
66
+ "means the same thing on CI and off it. See docs/configuration.md."
67
+
68
+ PENDING_IF_NEW_REMOVED =
69
+ "`pending_if_new` is REMOVED in 2.1: it skips the test instead of saying what to do about the " \
70
+ "missing baseline. `SnapDiff.config.record = :none` fails with the `git add` command attached; " \
71
+ "`= :once` records it and reports it in the end-of-run summary. See docs/configuration.md."
72
+
73
+ FAIL_ON_DIFFERENCE_REMOVED =
74
+ "`fail_on_difference` is REMOVED in 2.1: a screenshot that differs from its baseline fails, " \
75
+ "which is what the gem is for. To ACCEPT a difference, re-record it -- " \
76
+ "`SnapDiff.config.record = :all` -- and commit the result. See docs/configuration.md."
77
+
78
+ # Not a removal: 2.0 announces it and 2.1 turns it into an ArgumentError.
79
+ # The whole reason unrecognised keys need announcing is that the options
80
+ # hash was frozen but never validated, so a typo -- or a v1 option that
81
+ # no longer exists -- configured nothing, silently, forever.
82
+ def self.unknown_option(key)
83
+ "`#{key.inspect}` is not a recognised screenshot option, so it does nothing. 2.1 raises " \
84
+ "ArgumentError for it. Check the spelling against the option list in docs/configuration.md."
85
+ end
86
+
87
+ MUTEX = Mutex.new
88
+ @seen = {}
89
+ @suppressed = false
90
+
91
+ class << self
92
+ # Emit +message+ once per +subject+ per process, via Kernel#warn (so
93
+ # anything hooking +Warning.warn+ sees it like any other Ruby warning).
94
+ #
95
+ # @param subject [Symbol] dedup key -- the doomed API, not the call site
96
+ # @param message [String] what is removed, when, and what to do instead
97
+ # @return [void]
98
+ def warn_once(subject, message)
99
+ return if @suppressed || SnapDiff.silence_deprecations?
100
+
101
+ first_time = MUTEX.synchronize { @seen.key?(subject) ? false : (@seen[subject] = true) }
102
+ return unless first_time
103
+
104
+ Kernel.warn(with_origin("[snap_diff deprecation] #{message} #{SILENCE_HINT}", caller_locations(1)))
105
+ end
106
+
107
+ # @api private
108
+ #
109
+ # Silences these warnings for the rest of the process, without touching
110
+ # the v1-namespace ones. For hosts that exercise the doomed APIs BY
111
+ # DESIGN rather than depending on them -- this gem's own suite runs the
112
+ # whole comparison matrix on chunky_png and sets shift_distance_limit,
113
+ # and its test_helper raises on any deprecation output.
114
+ # @return [void]
115
+ def suppress!
116
+ MUTEX.synchronize { @suppressed = true }
117
+ end
118
+
119
+ private
120
+
121
+ def with_origin(message, locations)
122
+ origin = origin_for(locations)
123
+ origin ? "#{message} (called from #{origin})" : message
124
+ end
125
+
126
+ # First frame outside the gem's lib dir, formatted "file:line"; nil
127
+ # when every frame is internal (or paths are unavailable).
128
+ def origin_for(locations)
129
+ (locations || []).each do |location|
130
+ path = location.absolute_path || location.path
131
+ next if path.nil? || path.start_with?(GEM_LIB_DIR)
132
+
133
+ return "#{path}:#{location.lineno}"
134
+ end
135
+ nil
136
+ end
137
+ end
138
+ end
139
+
140
+ class << self
141
+ # @api private
142
+ attr_accessor :silence_deprecations
143
+
144
+ # @api private
145
+ #
146
+ # @return [Boolean] true if deprecation warnings should be suppressed,
147
+ # either via the {silence_deprecations} accessor or the
148
+ # SNAP_DIFF_SILENCE_DEPRECATIONS env var (truthy = "1"/"true").
149
+ def silence_deprecations?
150
+ !!silence_deprecations || truthy_env?(ENV["SNAP_DIFF_SILENCE_DEPRECATIONS"])
151
+ end
152
+
153
+ private
154
+
155
+ def truthy_env?(value)
156
+ %w[1 true].include?(value.to_s.downcase)
157
+ end
158
+ end
159
+ end
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  require "snap_diff/annotation_service"
6
+ # For SnapDiff.config.root, the base for the relative artifact paths below.
7
+ require "snap_diff/config"
4
8
 
5
9
  module SnapDiff
6
10
  module Reporters
@@ -47,22 +51,6 @@ module SnapDiff
47
51
  annotation_service.annotate_and_save_images
48
52
  end
49
53
 
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
54
  def build_error_for_different_dimensions
67
55
  change_msg = [comparison.base_image, comparison.new_image]
68
56
  .map { |image| driver.dimension(image).join("x") }
@@ -73,20 +61,95 @@ module SnapDiff
73
61
 
74
62
  NEW_LINE = "\n"
75
63
 
64
+ # The thresholds a comparison is judged against, in the order they read
65
+ # best. Only the ones actually set are printed -- see #thresholds.
66
+ THRESHOLDS = [
67
+ :tolerance,
68
+ :area_size_limit,
69
+ :color_distance_limit,
70
+ :shift_distance_limit,
71
+ :perceptual_threshold
72
+ ].freeze
73
+
74
+ # The five artifacts a comparison can leave on disk, in the order a
75
+ # reader wants them: the two inputs first, then what we drew on them.
76
+ ARTIFACT_LABELS = {
77
+ "baseline" => :base_image_path,
78
+ "actual" => :image_path,
79
+ "baseline annotated" => :annotated_base_image_path,
80
+ "actual annotated" => :annotated_image_path,
81
+ "heatmap" => :heatmap_diff_path
82
+ }.freeze
83
+
76
84
  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)
85
+ [headline, *metric_lines, *artifact_lines].join(NEW_LINE)
84
86
  end
85
87
 
86
88
  private
87
89
 
88
90
  attr_reader :annotation_service
89
91
 
92
+ # Reads as the tail of "Screenshot does not match for 'name': ".
93
+ def headline
94
+ width, height = driver.dimension(comparison.base_image)
95
+ total_pixels = width * height
96
+ area = difference.region_area_size
97
+
98
+ "the change spans #{area.round} of #{total_pixels} px " \
99
+ "(#{percent(area.to_f / total_pixels)} of the #{width}x#{height} image)"
100
+ end
101
+
102
+ def metric_lines
103
+ lines = [" changed region: #{difference.coordinates.to_json} (left,top,right,bottom edges)"]
104
+ # difference_level is the changed share of the image area -- the
105
+ # number `tolerance` is compared against. Only computed when a
106
+ # tolerance is set, so only printed then.
107
+ if difference.ratio
108
+ lines << " difference level: #{difference.ratio} (#{percent(difference.ratio)} of the image area)"
109
+ end
110
+ max_color_distance = difference.meta[:max_color_distance]
111
+ lines << " max color distance: #{max_color_distance}" if max_color_distance&.positive?
112
+ max_shift_distance = difference.meta[:max_shift_distance]
113
+ lines << " max shift distance: #{max_shift_distance} px" if max_shift_distance&.positive?
114
+ lines << " judged against: #{thresholds}"
115
+ end
116
+
117
+ def thresholds
118
+ applied = THRESHOLDS.filter_map do |name|
119
+ value = difference.options[name]
120
+ "#{name} #{value}" if value
121
+ end
122
+
123
+ applied.empty? ? "no tolerance thresholds configured (any difference fails)" : applied.join(", ")
124
+ end
125
+
126
+ # Only what is on disk gets a line: the heatmap exists solely for
127
+ # drivers that produce a diff mask, and a comparison can be reported
128
+ # before either input has been written out.
129
+ def artifact_lines
130
+ present = ARTIFACT_LABELS.filter_map do |label, path_method|
131
+ path = send(path_method)
132
+ [label, path] if path.exist?
133
+ end
134
+ width = present.map { |label, _path| label.length }.max.to_i
135
+
136
+ present.map { |label, path| " #{"#{label}:".ljust(width + 1)} #{display_path(path)}" }
137
+ end
138
+
139
+ # Relative to the configured root: shorter to read, and still
140
+ # click-through-able in terminals that resolve paths against the
141
+ # working directory. Anything outside the root stays absolute, because
142
+ # a "../../.." path is neither.
143
+ def display_path(path)
144
+ relative = path.expand_path.relative_path_from(SnapDiff.config.root).to_path
145
+ relative.start_with?("..") ? path.to_path : relative
146
+ end
147
+
148
+ def percent(fraction)
149
+ value = fraction * 100
150
+ (value.positive? && value < 0.01) ? "<0.01%" : format("%.2f%%", value)
151
+ end
152
+
90
153
  def base_image_path
91
154
  comparison.base_image_path
92
155
  end