snap_diff-capybara 2.0.0.beta3 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +438 -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/difference.rb +1 -4
  19. data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +3 -3
  20. data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +2 -3
  21. data/lib/capybara/screenshot/diff/drivers.rb +4 -5
  22. data/lib/capybara/screenshot/diff/image_compare.rb +11 -22
  23. data/lib/capybara/screenshot/diff/image_preprocessor.rb +1 -3
  24. data/lib/capybara/screenshot/diff/os.rb +4 -11
  25. data/lib/capybara/screenshot/diff/region.rb +2 -2
  26. data/lib/capybara/screenshot/diff/reporters/default.rb +4 -17
  27. data/lib/capybara/screenshot/diff/screenshot_matcher.rb +1 -3
  28. data/lib/capybara/screenshot/diff/screenshoter.rb +1 -3
  29. data/lib/capybara/screenshot/diff/stable_screenshoter.rb +1 -3
  30. data/lib/capybara/screenshot/diff/utils.rb +1 -3
  31. data/lib/capybara/screenshot/diff/vcs.rb +1 -3
  32. data/lib/capybara/screenshot/diff/version.rb +8 -13
  33. data/lib/capybara-screenshot-diff.rb +10 -1
  34. data/lib/capybara_screenshot_diff/attempts_reporter.rb +1 -3
  35. data/lib/capybara_screenshot_diff/dsl.rb +5 -0
  36. data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +1 -4
  37. data/lib/capybara_screenshot_diff/reporters/html.rb +1 -3
  38. data/lib/capybara_screenshot_diff/screenshot_namer.rb +1 -3
  39. data/lib/capybara_screenshot_diff/snap.rb +1 -3
  40. data/lib/capybara_screenshot_diff/snap_manager.rb +1 -3
  41. data/lib/capybara_screenshot_diff.rb +17 -21
  42. data/lib/snap_diff/browser_helpers.rb +26 -5
  43. data/lib/snap_diff/capture/viewport.rb +2 -5
  44. data/lib/snap_diff/comparison.rb +54 -3
  45. data/lib/snap_diff/comparison_result.rb +3 -1
  46. data/lib/snap_diff/config.rb +189 -93
  47. data/lib/snap_diff/deprecation.rb +89 -28
  48. data/lib/snap_diff/driver.rb +18 -0
  49. data/lib/snap_diff/drivers/vips_driver.rb +12 -6
  50. data/lib/snap_diff/drivers.rb +94 -12
  51. data/lib/snap_diff/dsl.rb +45 -47
  52. data/lib/snap_diff/integrations/cucumber.rb +1 -1
  53. data/lib/snap_diff/integrations/minitest.rb +45 -9
  54. data/lib/snap_diff/integrations/rspec.rb +11 -0
  55. data/lib/snap_diff/legacy_shims.rb +285 -27
  56. data/lib/snap_diff/removal.rb +159 -0
  57. data/lib/snap_diff/reporters/default.rb +86 -23
  58. data/lib/snap_diff/reporters/html.rb +29 -13
  59. data/lib/snap_diff/reporting.rb +329 -3
  60. data/lib/snap_diff/screenshot_assertion.rb +28 -23
  61. data/lib/snap_diff/screenshot_matcher.rb +121 -14
  62. data/lib/snap_diff/screenshot_namer.rb +1 -19
  63. data/lib/snap_diff/screenshoter.rb +5 -7
  64. data/lib/snap_diff/snap.rb +6 -1
  65. data/lib/snap_diff/snap_manager.rb +2 -3
  66. data/lib/snap_diff/stable_screenshoter.rb +2 -2
  67. data/lib/snap_diff/static.rb +1 -1
  68. data/lib/snap_diff/utils.rb +35 -17
  69. data/lib/snap_diff/vcs.rb +40 -7
  70. data/lib/snap_diff/version.rb +1 -1
  71. data/lib/snap_diff-capybara.rb +33 -3
  72. data/lib/snap_diff.rb +27 -37
  73. metadata +12 -10
  74. data/CODE_OF_CONDUCT.md +0 -129
  75. data/Rakefile +0 -65
  76. data/capybara-screenshot-diff.gemspec +0 -29
  77. data/docs/RELEASE_PREP.md +0 -44
  78. data/docs/docker-testing.md +0 -24
  79. data/gems.rb +0 -39
@@ -8,7 +8,7 @@ require "json"
8
8
  # The auto-registration block at the bottom of this file registers with
9
9
  # SnapDiff::Reporting.
10
10
  require "snap_diff/reporting"
11
- require "capybara/screenshot/diff/config_legacy"
11
+ require "snap_diff/config"
12
12
 
13
13
  module SnapDiff
14
14
  module Reporters
@@ -64,20 +64,36 @@ module SnapDiff
64
64
  @output_path ||= Pathname.new(@explicit_output_path || self.class.default_output_path)
65
65
  end
66
66
 
67
+ # Fork-parallel handoff (issue #258). A forked worker never reaches
68
+ # `Minitest.after_run`, so it hands its records to the parent as
69
+ # plain data instead. JSON on purpose -- boring, and a half-written
70
+ # file is discarded rather than half-read. Image paths were resolved
71
+ # against `output_path`, which is the same in both processes.
72
+ def dump_state
73
+ @mutex.synchronize { {"total" => @total, "failures" => @failures} }
74
+ end
75
+
76
+ def merge_state!(state)
77
+ @mutex.synchronize do
78
+ @total += state["total"]
79
+ @failures.concat(state["failures"].map { |entry| entry.transform_keys(&:to_sym) })
80
+ end
81
+ end
82
+
67
83
  def passed = total - failures.size
68
84
  def failed = failures.size
69
85
 
86
+ # Both customer personas named this path as the best output in the
87
+ # product, so it gets its own line -- but only when a report was
88
+ # actually written.
89
+ #
90
+ # The counts this used to carry moved to SnapDiff::Reporting (issue
91
+ # #269): they are printed for every user, and this file is not. See
92
+ # Reporting.counts_summary.
93
+ #
94
+ # @return [String, nil] nil when no report was written
70
95
  def summary
71
- return if total.zero?
72
-
73
- screenshots_label = (total == 1) ? "1 screenshot" : "#{total} screenshots"
74
-
75
- if failures.empty?
76
- "[snap_diff] #{screenshots_label} compared, no failures."
77
- else
78
- failures_label = (failures.size == 1) ? "1 failure" : "#{failures.size} failures"
79
- "[snap_diff] #{screenshots_label} compared, #{failures_label}. Report: #{output_path}"
80
- end
96
+ "[snap_diff] Report: #{output_path}" if @finalized
81
97
  end
82
98
 
83
99
  def render
@@ -89,8 +105,8 @@ module SnapDiff
89
105
  end
90
106
 
91
107
  def self.default_output_path
92
- root = Capybara::Screenshot.root || Pathname.pwd
93
- root / Capybara::Screenshot.save_path / "snap_diff_report.html"
108
+ root = SnapDiff.config.root || Pathname.pwd
109
+ root / SnapDiff.config.save_path / "snap_diff_report.html"
94
110
  end
95
111
 
96
112
  private
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+ require "tmpdir"
6
+
3
7
  module SnapDiff
4
8
  # Process-global reporter lifecycle: registration, per-test notification,
5
9
  # end-of-suite finalization. One list of reporters for the whole process,
@@ -13,10 +17,88 @@ module SnapDiff
13
17
  module Reporting
14
18
  @reporters = []
15
19
  @mutex = Mutex.new
20
+ @missing_baselines = Set.new
21
+ @rerecorded_baselines = Set.new
22
+ @matched_selectors = Set.new
23
+ @unmatched_selectors = Set.new
24
+ @verified = 0
25
+ @changed = 0
16
26
 
17
27
  class << self
18
28
  attr_reader :reporters, :mutex
19
29
 
30
+ # How many screenshots were compared to a committed baseline, and how
31
+ # many of those differed.
32
+ #
33
+ # These counters live HERE, not in a reporter (issue #269). Counting
34
+ # is core honesty; writing an HTML file is a feature. The summary
35
+ # exists to catch the failure modes no per-assertion rule can see -- a
36
+ # run where zero system tests executed, or where an inherited GIT_DIR
37
+ # redirected every baseline lookup -- and `0 verified` is the only
38
+ # signal for either. It shipped inside Reporters::HTML, the gem's one
39
+ # and only `register` call site, so the documented Rails setup (which
40
+ # requires just the Minitest integration) printed nothing at all.
41
+ attr_reader :verified, :changed
42
+
43
+ # Remembers a screenshot that had no COMMITTED baseline and was
44
+ # therefore never compared.
45
+ #
46
+ # @return [Boolean] true the first time this name is seen -- the
47
+ # "warn once per screenshot" gate for ScreenshotMatcher, and the
48
+ # tally behind {finalize!}'s summary line. Kept here rather than in
49
+ # the matcher because the end-of-run summary is this module's job.
50
+ def record_missing_baseline(name)
51
+ @mutex.synchronize { !!@missing_baselines.add?(name) }
52
+ end
53
+
54
+ # How many screenshots were captured but never compared. The "new"
55
+ # count on the end-of-run summary line, and live state: it is the
56
+ # number of screenshots that actually went down the no-baseline path
57
+ # this run, not a number derived from what the run should have done.
58
+ def missing_baselines_count
59
+ @mutex.synchronize { @missing_baselines.size }
60
+ end
61
+
62
+ # Remembers whether a CSS selector (`skip_area`, `crop`) found
63
+ # anything the one time it was resolved. Fed at the point of use, so
64
+ # a selector that was configured but never reached cannot be named.
65
+ #
66
+ # Two sets rather than a counter: the question the summary answers is
67
+ # "did this selector match ANYWHERE in the run", and under
68
+ # fork-parallel the hits and the misses arrive from different
69
+ # processes. Subtracting at read time is the only shape that survives
70
+ # that merge (#266).
71
+ def record_selector_use(selector, matched:)
72
+ @mutex.synchronize do
73
+ (matched ? @matched_selectors : @unmatched_selectors) << selector
74
+ end
75
+ end
76
+
77
+ # @api private
78
+ # Per-test isolation for this gem's own suite: everything {finalize!}
79
+ # reports, cleared in one call. One surface rather than one reset per
80
+ # tally, so a tally added later cannot be forgotten at the call site.
81
+ def reset_run_totals!
82
+ @mutex.synchronize do
83
+ @missing_baselines.clear
84
+ @rerecorded_baselines.clear
85
+ @matched_selectors.clear
86
+ @unmatched_selectors.clear
87
+ @verified = 0
88
+ @changed = 0
89
+ end
90
+ end
91
+
92
+ # Remembers a screenshot re-recorded by `record: :all` -- captured as
93
+ # the new baseline with nothing compared against it. A separate tally
94
+ # from {record_missing_baseline} on purpose: "there was no baseline"
95
+ # and "there was one and we accepted the new rendering over it" are
96
+ # different facts, and the summary must not claim the first when the
97
+ # second happened.
98
+ def record_rerecorded_baseline(name)
99
+ @mutex.synchronize { !!@rerecorded_baselines.add?(name) }
100
+ end
101
+
20
102
  # Registers a reporter for the rest of the process. The canonical way
21
103
  # in: the append happens under the mutex, so concurrent registrations
22
104
  # cannot lose one (issue #217 item 2). `reporters` stays public and
@@ -34,6 +116,18 @@ module SnapDiff
34
116
  def notify(assertions)
35
117
  return if assertions.nil? || assertions.empty?
36
118
 
119
+ # Warned about and skipped, never raised: `notify` runs inside every
120
+ # test's teardown (SnapDiff.reset), and a raise here would abort the
121
+ # reset before it clears the registry -- leaking one test's
122
+ # assertions into the next. A tally must not be able to take a
123
+ # user's suite down. Same contract the reporter loop below applies,
124
+ # and just as loud: unconditional, not DEBUG-gated.
125
+ begin
126
+ count(assertions)
127
+ rescue => e
128
+ warn "[snap_diff] Could not tally the run (#{e.class}: #{e.message})"
129
+ end
130
+
37
131
  reporters_snapshot = @mutex.synchronize { @reporters.dup }
38
132
  return if reporters_snapshot.empty?
39
133
 
@@ -44,10 +138,70 @@ module SnapDiff
44
138
  end
45
139
  end
46
140
 
47
- # End-of-suite hook: finalizes each reporter and prints its summary.
48
- # A raising reporter is warned about and skipped; the rest are still
49
- # finalized.
141
+ # Tallies a finished test's assertions. An assertion with no
142
+ # `compare` never reached a baseline, so it is neither verified nor
143
+ # changed -- it is counted, if at all, by {record_missing_baseline}.
144
+ def count(assertions)
145
+ verified = 0
146
+ changed = 0
147
+
148
+ assertions.each do |assertion|
149
+ compare = assertion.compare
150
+ next unless compare
151
+
152
+ verified += 1
153
+ changed += 1 if compare.difference&.different?
154
+ end
155
+
156
+ @mutex.synchronize do
157
+ @verified += verified
158
+ @changed += changed
159
+ end
160
+ end
161
+
162
+ # The last line of the run, and the only place it says what it
163
+ # actually did:
164
+ #
165
+ # verified -- a committed baseline existed and was compared
166
+ # changed -- of those, the ones that differed
167
+ # new -- captured but NOT compared, for want of a committed
168
+ # baseline: neither a pass nor a failure
169
+ #
170
+ # Printed on every run, passing or failing, reporter or no reporter,
171
+ # and never nil. "N screenshots compared" counted only what it
172
+ # compared, so it was silent about exactly the screenshots it did not
173
+ # -- and silent altogether when it compared nothing, which is the one
174
+ # case worth shouting about.
175
+ def counts_summary
176
+ verified, changed, new_count, rerecorded = @mutex.synchronize {
177
+ [@verified, @changed, @missing_baselines.size, @rerecorded_baselines.size]
178
+ }
179
+ line = "[snap_diff] #{verified} verified, #{changed} changed, #{new_count} new (not verified)."
180
+
181
+ # `record: :all` (#274) accepts the rendering as the new baseline
182
+ # without comparing, so those are neither verified nor changed --
183
+ # and not "new" either, which is a different fact. Only shown when
184
+ # it happened; the names are on their own line below.
185
+ line += " #{rerecorded} re-recorded (not verified)." if rerecorded.positive?
186
+
187
+ # The shout is for an UNEXPLAINED zero -- a suite that ran no system
188
+ # tests, a GIT_DIR pointed at the wrong repository. Re-recording
189
+ # explains it, and the user asked for it: shouting there is a false
190
+ # alarm, and false alarms are how the real one stops being read.
191
+ if verified.zero? && rerecorded.zero?
192
+ return "#{line} NOTHING WAS VERIFIED -- no screenshot was compared to a committed baseline."
193
+ end
194
+
195
+ line
196
+ end
197
+
198
+ # End-of-suite hook: prints the counts, then finalizes each reporter
199
+ # and prints its summary. A raising reporter is warned about and
200
+ # skipped; the rest are still finalized -- and the counts line is
201
+ # already out, so no reporter can take it down with it.
50
202
  def finalize!
203
+ $stdout.puts counts_summary
204
+
51
205
  @mutex.synchronize { @reporters.dup }.each do |reporter|
52
206
  reporter.finalize
53
207
  if (msg = reporter.summary)
@@ -56,6 +210,178 @@ module SnapDiff
56
210
  rescue => e
57
211
  warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
58
212
  end
213
+
214
+ if (msg = missing_baselines_summary)
215
+ $stdout.puts msg
216
+ end
217
+
218
+ if (msg = rerecorded_baselines_summary)
219
+ $stdout.puts msg
220
+ end
221
+
222
+ if (msg = never_matched_selectors_summary)
223
+ $stdout.puts msg
224
+ end
225
+ end
226
+
227
+ # --- fork-parallel reports (issue #258) ---------------------------
228
+ #
229
+ # Under Rails' default `parallelize(workers: N)` the tests run in
230
+ # forked children, and Minitest skips `after_run` in a forked child
231
+ # (`allow_fork = false`, minitest.rb:64/79). So every worker holds
232
+ # records and never finalizes, while the parent finalizes and holds
233
+ # none: no report, no summary line. Pass/fail is unaffected -- the
234
+ # failures marshal back over DRb -- which is what makes it quiet.
235
+ #
236
+ # Rails runs `run_cleanup_hooks` INSIDE the worker just before it
237
+ # exits (parallelization/worker.rb:31), so each worker dumps its
238
+ # records there, and the parent merges the fragments in the
239
+ # `Minitest.after_run` it does reach.
240
+
241
+ # Registers the worker-side dump with Rails, once per process.
242
+ #
243
+ # Feature-detected twice over: the gem must load without Rails at
244
+ # all, and with `Bundler.require` it loads BEFORE
245
+ # ActiveSupport::TestCase exists, so the caller retries from
246
+ # `ActiveSupport.on_load`.
247
+ #
248
+ # @return [Boolean] true when the hook is registered (now or already)
249
+ def install_parallel_hooks!
250
+ return true if @parallel_owner_pid
251
+ return false unless defined?(::ActiveSupport::Testing::Parallelization)
252
+
253
+ @parallel_owner_pid = Process.pid
254
+ ::ActiveSupport::Testing::Parallelization.run_cleanup_hook { dump_parallel_fragment }
255
+ true
256
+ end
257
+
258
+ # Outside the repository on purpose: it holds run-scoped scratch, and
259
+ # the alternative -- somewhere under `save_path` -- is a directory
260
+ # users `git add`.
261
+ #
262
+ # Keyed by the pid recorded at install time, which happens in the
263
+ # parent before any fork: `Process.pid` here would give each worker a
264
+ # directory of its own that the parent never looks in.
265
+ def parallel_fragments_dir
266
+ File.join(Dir.tmpdir, "snap_diff-fragments-#{@parallel_owner_pid}")
267
+ end
268
+
269
+ # Worker side. Writes to a `.tmp` name and renames it into place, so
270
+ # a worker killed mid-write leaves nothing the merge will read.
271
+ def dump_parallel_fragment
272
+ payload = {
273
+ "missing_baselines" => @mutex.synchronize { @missing_baselines.to_a },
274
+ "rerecorded_baselines" => @mutex.synchronize { @rerecorded_baselines.to_a },
275
+ # Both halves, not the subtraction: `img` may match in this worker
276
+ # and miss in the next, and only the parent that merged every
277
+ # fragment can tell whether it matched anywhere in the run.
278
+ "matched_selectors" => @mutex.synchronize { @matched_selectors.to_a },
279
+ "unmatched_selectors" => @mutex.synchronize { @unmatched_selectors.to_a },
280
+ "verified" => @verified,
281
+ "changed" => @changed,
282
+ "reporters" => @mutex.synchronize { @reporters.dup }
283
+ .map { |reporter| reporter.dump_state if reporter.respond_to?(:dump_state) }
284
+ }
285
+
286
+ FileUtils.mkdir_p(parallel_fragments_dir)
287
+ tmp = File.join(parallel_fragments_dir, "#{Process.pid}.json.tmp")
288
+ File.write(tmp, JSON.generate(payload))
289
+ File.rename(tmp, File.join(parallel_fragments_dir, "#{Process.pid}.json"))
290
+ end
291
+
292
+ # Parent side, called just before {finalize!}. A no-op when nothing
293
+ # forked, which is what keeps serial and `with: :threads` -- both of
294
+ # which record in the process that finalizes -- exactly as they were.
295
+ #
296
+ # Reporters are matched by position: registration happens at require
297
+ # time, before any fork, so the list is identical in every process.
298
+ def merge_parallel_fragments!
299
+ return unless @parallel_owner_pid == Process.pid
300
+
301
+ Dir[File.join(parallel_fragments_dir, "*.json")].sort.each do |fragment|
302
+ payload = JSON.parse(File.read(fragment))
303
+
304
+ # Only "missing_baselines" is read without a default: it is the
305
+ # one key every version of this fragment has ever written. Every
306
+ # key added since is `fetch`ed with one, because the fragments
307
+ # directory is keyed by pid under the system temp dir -- a
308
+ # recycled pid can hand this merge a fragment left behind by an
309
+ # older version of the gem, and a partial payload must not take
310
+ # the run down.
311
+ @mutex.synchronize do
312
+ payload["missing_baselines"].each { |name| @missing_baselines << name }
313
+ payload.fetch("rerecorded_baselines", []).each { |name| @rerecorded_baselines << name }
314
+ payload.fetch("matched_selectors", []).each { |selector| @matched_selectors << selector }
315
+ payload.fetch("unmatched_selectors", []).each { |selector| @unmatched_selectors << selector }
316
+ @verified += payload.fetch("verified", 0)
317
+ @changed += payload.fetch("changed", 0)
318
+ end
319
+
320
+ reporters_snapshot = @mutex.synchronize { @reporters.dup }
321
+ payload["reporters"].each_with_index do |state, index|
322
+ reporter = reporters_snapshot[index]
323
+ reporter.merge_state!(state) if state && reporter.respond_to?(:merge_state!)
324
+ end
325
+ end
326
+
327
+ FileUtils.rm_rf(parallel_fragments_dir)
328
+ end
329
+
330
+ # The reporters' summary line carries the COUNT of screenshots that
331
+ # were captured without a committed baseline ("N new (not
332
+ # verified)"); this line names them, so the next thing the reader
333
+ # does is `git add` the right files.
334
+ #
335
+ # @return [String, nil] nil when every screenshot had a baseline
336
+ def missing_baselines_summary
337
+ names = @mutex.synchronize { @missing_baselines.to_a }
338
+ return if names.empty?
339
+
340
+ label = (names.size == 1) ? "1 screenshot" : "#{names.size} screenshots"
341
+ "[snap_diff] #{label} had no committed baseline and #{(names.size == 1) ? "was" : "were"} NOT compared: " \
342
+ "#{names.join(", ")}. Commit the captured file(s) to enable comparison."
343
+ end
344
+
345
+ # The other half of "nothing was compared", and the louder one:
346
+ # `record: :all` accepts whatever the page rendered as the new
347
+ # baseline. Names the screenshots that really went down that path this
348
+ # run, so `git add` lands on the right files -- and so nobody commits
349
+ # forty accepted regressions without being told they were accepted.
350
+ #
351
+ # @return [String, nil] nil when nothing was re-recorded
352
+ def rerecorded_baselines_summary
353
+ names = @mutex.synchronize { @rerecorded_baselines.to_a }
354
+ return if names.empty?
355
+
356
+ label = (names.size == 1) ? "1 screenshot" : "#{names.size} screenshots"
357
+ "[snap_diff] record: :all re-recorded #{label} WITHOUT comparing: #{names.join(", ")}. " \
358
+ "Review the result before committing -- an unintended change is accepted just as silently."
359
+ end
360
+
361
+ # The selectors that matched nothing in EVERY screenshot of the run.
362
+ #
363
+ # Since #272 a `skip_area` selector is resolved without waiting: it
364
+ # masks what is on the page at assertion time, and one that matches
365
+ # nothing produces an empty mask -- the unstable region is compared
366
+ # and the test flakes, silently. #275 declined to warn per screenshot
367
+ # because the gem cannot tell a typo from a legitimately image-less
368
+ # page, and the legitimate case would fire on every screenshot.
369
+ #
370
+ # A run-level tally has no such problem. A selector that matched
371
+ # SOMEWHERE is doing its job and is never mentioned; one that matched
372
+ # NOWHERE, all run, is a typo or a stale selector with high
373
+ # probability. Silent when the set is empty, on purpose: a line that
374
+ # prints on every run is a line users learn to skip.
375
+ #
376
+ # @return [String, nil] nil when every selector used matched somewhere
377
+ def never_matched_selectors_summary
378
+ names = @mutex.synchronize { (@unmatched_selectors - @matched_selectors).to_a }
379
+ return if names.empty?
380
+
381
+ label = (names.size == 1) ? "1 selector" : "#{names.size} selectors"
382
+ "[snap_diff] #{label} never matched anything in this run: " \
383
+ "#{names.map(&:inspect).join(", ")}. " \
384
+ "A selector that matches nothing masks nothing -- check for a typo or a stale selector."
59
385
  end
60
386
  end
61
387
  end
@@ -33,7 +33,7 @@ module SnapDiff
33
33
  #
34
34
  # @return [String, nil] the pending message, or nil when there is nothing to report
35
35
  def self.pending_screenshots_message
36
- return unless ::Capybara::Screenshot::Diff.pending_if_new && session.new_screenshots_present?
36
+ return unless SnapDiff.config.pending_if_new && session.new_screenshots_present?
37
37
 
38
38
  "No baseline for: #{session.new_screenshots.join(", ")}. Commit the captured screenshots to record them."
39
39
  end
@@ -96,7 +96,7 @@ module SnapDiff
96
96
  # @return [Array, nil] Returns an array of error messages if there are screenshot differences, otherwise nil.
97
97
  # @note This method is typically called at the end of a test to assert all screenshots are as expected.
98
98
  def self.verify_screenshots!(screenshots)
99
- return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
99
+ return unless SnapDiff.config.active? && SnapDiff.config.fail_on_difference
100
100
 
101
101
  test_screenshot_errors = screenshots.map do |assertion|
102
102
  assertion.validate
@@ -106,21 +106,6 @@ module SnapDiff
106
106
 
107
107
  test_screenshot_errors.empty? ? nil : test_screenshot_errors
108
108
  end
109
-
110
- # Asserts that an image has not changed compared to its baseline.
111
- #
112
- # @param backtrace [Array(String)] The caller context, used for error reporting.
113
- # @param name [String] The name of the screenshot being verified.
114
- # @param comparison [Object] The comparison object containing the result and details of the comparison.
115
- # @return [String, nil] Returns an error message if the screenshot differs from the baseline, otherwise nil.
116
- # @note Legacy entry point; delegates to the instance verify flow
117
- # (pure question + explicit #archive_baseline! on pass).
118
- def self.assert_image_not_changed(backtrace, name, comparison)
119
- assertion = new(name)
120
- assertion.caller = backtrace
121
- assertion.compare = comparison
122
- assertion.validate
123
- end
124
109
  end
125
110
 
126
111
  class AssertionRegistry
@@ -129,9 +114,18 @@ module SnapDiff
129
114
  def initialize
130
115
  @assertions = []
131
116
  @new_screenshots = []
117
+ @checked_out_baselines = Set.new
132
118
  @screenshot_namer = SnapDiff::ScreenshotNamer.new
133
119
  end
134
120
 
121
+ # Called by Snap#checkout_base_screenshot when git really handed us a
122
+ # baseline. Same thread as the reading below -- a test's checkout and
123
+ # its comparison happen in one call stack -- so no synchronization is
124
+ # needed or wanted here.
125
+ def record_baseline_checkout(name)
126
+ @checked_out_baselines << name
127
+ end
128
+
135
129
  def add_assertion(assertion)
136
130
  return unless assertion&.compare
137
131
 
@@ -144,7 +138,21 @@ module SnapDiff
144
138
  !@assertions.empty?
145
139
  end
146
140
 
141
+ # "No baseline exists" reaches here for two very different reasons. One
142
+ # is legitimate and warned about: nothing was ever committed for this
143
+ # name. The other is impossible in a correct run -- git gave us a
144
+ # baseline moments ago and it is gone now -- and used to be recorded as
145
+ # if it were the first, leaving the test green having compared nothing.
146
+ # That is the silently-wrong case measured in #217: two concurrent
147
+ # tests asserting the SAME name, where one's archive_baseline! moves
148
+ # the baseline the other just checked out.
147
149
  def record_new_screenshot(name)
150
+ raise SnapDiff::Error.new(<<~ERROR.chomp, caller) if @checked_out_baselines.include?(name)
151
+ The baseline for '#{name}' was checked out and then disappeared before it could be compared -- nothing was verified.
152
+ Every artifact path derives from the screenshot name alone, so two tests asserting '#{name}' at the same time race on one set of files: the one that finishes first archives the baseline the other is still using.
153
+ Give those screenshots distinct names, or do not run them concurrently.
154
+ ERROR
155
+
148
156
  @new_screenshots.push(name)
149
157
  end
150
158
 
@@ -153,14 +161,10 @@ module SnapDiff
153
161
  end
154
162
 
155
163
  def verify(screenshots = assertions)
156
- return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
157
-
158
- failed_screenshot = failed_assertions.first
159
164
  result = ScreenshotAssertion.verify_screenshots!(screenshots)
165
+ return unless result
160
166
 
161
- if result
162
- raise SnapDiff::ExpectationNotMet.new(result.join("\n\n"), failed_screenshot.caller)
163
- end
167
+ raise SnapDiff::ExpectationNotMet.new(result.join("\n\n"), failed_assertions.first.caller)
164
168
  end
165
169
 
166
170
  def failed_assertions
@@ -170,6 +174,7 @@ module SnapDiff
170
174
  def reset
171
175
  @assertions.clear
172
176
  @new_screenshots.clear
177
+ @checked_out_baselines.clear
173
178
  @screenshot_namer = SnapDiff::ScreenshotNamer.new
174
179
  end
175
180
  end