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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +471 -0
- data/README.md +330 -0
- data/docs/UPGRADING.md +232 -34
- data/docs/architecture.md +9 -7
- data/docs/ci-integration.md +42 -11
- data/docs/configuration.md +176 -10
- data/docs/drivers.md +48 -5
- data/docs/framework-setup.md +10 -3
- data/docs/migration-guide.md +24 -13
- data/docs/reporters.md +70 -1
- data/docs/snapdiff.md +41 -8
- data/docs/thread_safety.md +77 -86
- data/lib/capybara/screenshot/diff/annotation_service.rb +1 -3
- data/lib/capybara/screenshot/diff/area_calculator.rb +1 -3
- data/lib/capybara/screenshot/diff/browser_helpers.rb +1 -3
- data/lib/capybara/screenshot/diff/config_legacy.rb +15 -60
- data/lib/capybara/screenshot/diff/cucumber.rb +5 -1
- data/lib/capybara/screenshot/diff/difference.rb +1 -4
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +3 -3
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +2 -3
- data/lib/capybara/screenshot/diff/drivers.rb +4 -5
- data/lib/capybara/screenshot/diff/image_compare.rb +12 -11
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +1 -3
- data/lib/capybara/screenshot/diff/os.rb +4 -11
- data/lib/capybara/screenshot/diff/region.rb +2 -2
- data/lib/capybara/screenshot/diff/reporters/default.rb +4 -3
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +1 -3
- data/lib/capybara/screenshot/diff/screenshoter.rb +1 -3
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +1 -3
- data/lib/capybara/screenshot/diff/utils.rb +1 -3
- data/lib/capybara/screenshot/diff/vcs.rb +1 -3
- data/lib/capybara/screenshot/diff/version.rb +8 -13
- data/lib/capybara-screenshot-diff.rb +10 -1
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +1 -3
- data/lib/capybara_screenshot_diff/dsl.rb +5 -0
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +1 -4
- data/lib/capybara_screenshot_diff/reporters/html.rb +1 -3
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +1 -3
- data/lib/capybara_screenshot_diff/snap.rb +1 -3
- data/lib/capybara_screenshot_diff/snap_manager.rb +1 -3
- data/lib/capybara_screenshot_diff/static.rb +4 -0
- data/lib/capybara_screenshot_diff.rb +17 -21
- data/lib/snap_diff/browser_helpers.rb +26 -5
- data/lib/snap_diff/capture/viewport.rb +2 -5
- data/lib/snap_diff/comparison.rb +54 -3
- data/lib/snap_diff/comparison_result.rb +3 -1
- data/lib/snap_diff/config.rb +189 -93
- data/lib/snap_diff/deprecation.rb +89 -28
- data/lib/snap_diff/driver.rb +18 -0
- data/lib/snap_diff/drivers/vips_driver.rb +12 -6
- data/lib/snap_diff/drivers.rb +94 -12
- data/lib/snap_diff/dsl.rb +51 -45
- data/lib/snap_diff/errors.rb +7 -1
- data/lib/snap_diff/integrations/cucumber.rb +1 -1
- data/lib/snap_diff/integrations/minitest.rb +45 -9
- data/lib/snap_diff/integrations/rspec.rb +11 -0
- data/lib/snap_diff/legacy_shims.rb +283 -28
- data/lib/snap_diff/removal.rb +159 -0
- data/lib/snap_diff/reporters/default.rb +86 -23
- data/lib/snap_diff/reporters/html.rb +29 -13
- data/lib/snap_diff/reporting.rb +330 -4
- data/lib/snap_diff/screenshot_assertion.rb +28 -23
- data/lib/snap_diff/screenshot_matcher.rb +121 -14
- data/lib/snap_diff/screenshot_namer.rb +1 -19
- data/lib/snap_diff/screenshoter.rb +5 -7
- data/lib/snap_diff/snap.rb +6 -1
- data/lib/snap_diff/snap_manager.rb +2 -3
- data/lib/snap_diff/stable_screenshoter.rb +2 -2
- data/lib/snap_diff/static.rb +1 -1
- data/lib/snap_diff/utils.rb +35 -17
- data/lib/snap_diff/vcs.rb +40 -7
- data/lib/snap_diff/version.rb +1 -1
- data/lib/snap_diff-capybara.rb +38 -0
- data/lib/snap_diff.rb +40 -39
- metadata +13 -10
- data/CODE_OF_CONDUCT.md +0 -129
- data/Rakefile +0 -65
- data/capybara-screenshot-diff.gemspec +0 -29
- data/docs/RELEASE_PREP.md +0 -44
- data/docs/docker-testing.md +0 -24
- 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 "
|
|
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
|
-
|
|
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 =
|
|
93
|
-
root /
|
|
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
|
data/lib/snap_diff/reporting.rb
CHANGED
|
@@ -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,20 +116,92 @@ 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
|
|
|
40
134
|
reporters_snapshot.each do |reporter|
|
|
41
135
|
reporter.record(assertions)
|
|
42
136
|
rescue => e
|
|
43
|
-
warn "[
|
|
137
|
+
warn "[snap_diff] Reporter #{reporter.class} failed (#{e.class}: #{e.message})"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
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
|
|
44
159
|
end
|
|
45
160
|
end
|
|
46
161
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|