constable-rails 0.1.0 → 1.1.0
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 +277 -1
- data/README.md +174 -13
- data/lib/constable/case.rb +33 -0
- data/lib/constable/cli.rb +90 -5
- data/lib/constable/cold_case/rspec.rb +93 -1
- data/lib/constable/config.rb +95 -8
- data/lib/constable/identity.rb +19 -0
- data/lib/constable/importer/modernizer.rb +40 -0
- data/lib/constable/investigation.rb +10 -0
- data/lib/constable/jail.rb +72 -7
- data/lib/constable/matchers.rb +206 -2
- data/lib/constable/registry.rb +24 -0
- data/lib/constable/reporter.rb +232 -3
- data/lib/constable/runner.rb +153 -3
- data/lib/constable/selection.rb +23 -1
- data/lib/constable/storage/sqlite_adapter.rb +21 -1
- data/lib/constable/version.rb +1 -1
- data/lib/constable/warrants.rb +56 -5
- data/lib/constable/worker_databases.rb +83 -0
- data/lib/constable.rb +48 -1
- data/lib/generators/constable/install_generator.rb +140 -9
- data/lib/generators/constable/templates/case_helper.rb.tt +77 -23
- data/lib/generators/constable/templates/config.yml.tt +32 -7
- metadata +4 -3
data/lib/constable/runner.rb
CHANGED
|
@@ -11,6 +11,14 @@ module Constable
|
|
|
11
11
|
# chose, since reordering someone's untouched legacy file is exactly the kind of surprise
|
|
12
12
|
# the cold-case story exists to avoid.
|
|
13
13
|
class Runner
|
|
14
|
+
# Thresholds for "this run is broken, not these tests" -- see #systemic_failure.
|
|
15
|
+
SYSTEMIC_MINIMUM = 5 # below this it is cheaper to believe the tests
|
|
16
|
+
SYSTEMIC_SHARE = 0.25 # of the whole run
|
|
17
|
+
SYSTEMIC_AGREEMENT = 0.8 # of the failures, failing identically
|
|
18
|
+
|
|
19
|
+
# `failed` rather than `count` or `tally`, both of which override an Enumerable method.
|
|
20
|
+
Systemic = Struct.new(:exception_class, :failed, :total, keyword_init: true)
|
|
21
|
+
|
|
14
22
|
# One unit of work. Native items are a single investigation; cold items are a whole
|
|
15
23
|
# file, because their engine owns the granularity inside it.
|
|
16
24
|
class Item
|
|
@@ -56,6 +64,19 @@ module Constable
|
|
|
56
64
|
def jail_run? = @jail_run
|
|
57
65
|
def coverage? = @coverage_requested
|
|
58
66
|
|
|
67
|
+
# Loads every case file and hands back the identities the suite actually defines,
|
|
68
|
+
# without running anything. `constable prune` needs this: which tests still exist is
|
|
69
|
+
# only knowable once the whole suite has been loaded.
|
|
70
|
+
def self.identities(config: Constable.config)
|
|
71
|
+
selection = Selection.new([], config: config, root: Constable.root, full: true)
|
|
72
|
+
runner = new(selection: selection, config: config,
|
|
73
|
+
reporter: Reporter.new(io: StringIO.new, config: config, color: false),
|
|
74
|
+
storage: Constable.storage, workers: 1)
|
|
75
|
+
runner.send(:load_suite!)
|
|
76
|
+
Constable.registry.disambiguate_identities!
|
|
77
|
+
Constable.registry.investigations.map(&:identity)
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
# => Integer exit status (0 clean, 1 failures)
|
|
60
81
|
def call
|
|
61
82
|
# Before anything is loaded: Coverage only counts files required after it starts, so
|
|
@@ -65,7 +86,11 @@ module Constable
|
|
|
65
86
|
Constable::Coverage.start!(config: @config, force: true) if coverage?
|
|
66
87
|
|
|
67
88
|
load_suite!
|
|
89
|
+
# Before anything is keyed on an identity -- selection, the docket, flake history --
|
|
90
|
+
# settle any two tests that happen to share a body.
|
|
91
|
+
Constable.registry.disambiguate_identities!
|
|
68
92
|
items = build_items
|
|
93
|
+
refuse_empty_selection!(items)
|
|
69
94
|
ordered = order(items)
|
|
70
95
|
|
|
71
96
|
run_id = @storage.start_run(seed: @seed, mode: mode_label, full: @selection.full?)
|
|
@@ -91,6 +116,10 @@ module Constable
|
|
|
91
116
|
@results = adjudicate(raw)
|
|
92
117
|
duration = monotonic - started
|
|
93
118
|
|
|
119
|
+
# Cold-case engines hold a live session -- for RSpec that is a configuration
|
|
120
|
+
# carrying `after(:suite)` hooks that have not fired yet. Tear it down before our
|
|
121
|
+
# own after_suite so the engine's cleanup runs inside the suite, not after it.
|
|
122
|
+
ColdCase.reset_engines!
|
|
94
123
|
Constable.configuration.run_after_suite!
|
|
95
124
|
@coverage_report = build_coverage_report if coverage?
|
|
96
125
|
|
|
@@ -127,6 +156,15 @@ module Constable
|
|
|
127
156
|
.find { |p| File.exist?(p) }
|
|
128
157
|
require helper if helper
|
|
129
158
|
|
|
159
|
+
# Between requiring the helper and asking the selection anything.
|
|
160
|
+
#
|
|
161
|
+
# Ordering is the whole point. case_helper.rb is where Constable.configure runs, so
|
|
162
|
+
# its settings do not exist until the line above. But Selection memoizes its targets
|
|
163
|
+
# the first time it is asked for them, and `cold_cases` is one of the settings people
|
|
164
|
+
# will most want to set in Ruby -- ask first and the override arrives too late to
|
|
165
|
+
# matter, silently. Requiring the helper needs no selection, so this fits between.
|
|
166
|
+
@config.apply_overrides!(Constable.configuration.overrides)
|
|
167
|
+
|
|
130
168
|
@selection.native_targets.each { |target| load_case_file(target.path) }
|
|
131
169
|
helper
|
|
132
170
|
end
|
|
@@ -172,6 +210,17 @@ module Constable
|
|
|
172
210
|
end
|
|
173
211
|
end
|
|
174
212
|
|
|
213
|
+
# A run that was *asked* for something specific and found nothing is a usage error, not
|
|
214
|
+
# a pass. `constable test test/cases/typo_case.rb` used to print "0 passed, 0 failed"
|
|
215
|
+
# and exit 0, so a mistyped path in a CI script produced a green build that ran no
|
|
216
|
+
# tests at all. A full run with an empty suite is a different thing and stays quiet.
|
|
217
|
+
def refuse_empty_selection!(items)
|
|
218
|
+
return unless items.empty?
|
|
219
|
+
return unless @selection.explicit?
|
|
220
|
+
|
|
221
|
+
raise Constable::Error, @selection.empty_selection_message
|
|
222
|
+
end
|
|
223
|
+
|
|
175
224
|
def build_items
|
|
176
225
|
native = native_items
|
|
177
226
|
cold = @selection.cold_targets_selected.map { |t| Item.new(path: t.path, kind: :cold) }
|
|
@@ -188,14 +237,29 @@ module Constable
|
|
|
188
237
|
|
|
189
238
|
# PATH:LINE means "the investigation at that line" -- but developers point at any line
|
|
190
239
|
# inside the block, so pick the investigation whose declaration is nearest above it.
|
|
240
|
+
#
|
|
241
|
+
# Bounded by the end of the file. Unbounded, `:999` on a twenty-line file quietly ran
|
|
242
|
+
# the last investigation in it: not the test the user asked for, not an error, and
|
|
243
|
+
# green either way. A line past the end is a typo, and no answer beats a wrong one.
|
|
191
244
|
def narrow_to_line(investigations, line)
|
|
192
245
|
exact = investigations.select { |inv| inv.line == line }
|
|
193
246
|
return exact if exact.any?
|
|
247
|
+
return [] unless line_within_file?(investigations.first, line)
|
|
194
248
|
|
|
195
249
|
nearest = investigations.select { |inv| inv.line <= line }.max_by(&:line)
|
|
196
250
|
nearest ? [nearest] : []
|
|
197
251
|
end
|
|
198
252
|
|
|
253
|
+
def line_within_file?(investigation, line)
|
|
254
|
+
path = investigation&.file
|
|
255
|
+
return false if path.nil?
|
|
256
|
+
|
|
257
|
+
path = File.join(@config.root, path) unless File.exist?(path)
|
|
258
|
+
return false unless File.exist?(path)
|
|
259
|
+
|
|
260
|
+
line <= File.foreach(path).count
|
|
261
|
+
end
|
|
262
|
+
|
|
199
263
|
def order(items)
|
|
200
264
|
native, cold = items.partition(&:native?)
|
|
201
265
|
[*native.shuffle(random: Random.new(@seed)), *cold]
|
|
@@ -205,13 +269,32 @@ module Constable
|
|
|
205
269
|
return [] if items.empty?
|
|
206
270
|
|
|
207
271
|
count = worker_count(items)
|
|
208
|
-
if count > 1 && forkable?
|
|
272
|
+
if count > 1 && forkable? && parallel_safe?
|
|
209
273
|
run_parallel(items, count)
|
|
210
274
|
else
|
|
211
275
|
run_serial(items)
|
|
212
276
|
end
|
|
213
277
|
end
|
|
214
278
|
|
|
279
|
+
# Forking is only safe once each worker has a database of its own. Without that,
|
|
280
|
+
# every worker opens the same one: on SQLite the run dissolves into "database is
|
|
281
|
+
# locked", and on a client/server database the tests quietly see each other's rows,
|
|
282
|
+
# which is worse. An app with no ActiveRecord has nothing to shard and is always safe.
|
|
283
|
+
#
|
|
284
|
+
# When we cannot shard, we run serially and say why. Slow is a trade-off; wrong is not.
|
|
285
|
+
def parallel_safe?
|
|
286
|
+
return true unless WorkerDatabases.active_record?
|
|
287
|
+
return true if WorkerDatabases.shardable?
|
|
288
|
+
|
|
289
|
+
Constable.warn!(
|
|
290
|
+
"parallel workers need one database per worker, and this app's ActiveRecord " \
|
|
291
|
+
"cannot provide them (active_record/test_databases did not load). Running " \
|
|
292
|
+
"serially instead -- pass --workers N once that is available.",
|
|
293
|
+
kind: :parallel
|
|
294
|
+
)
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
|
|
215
298
|
def worker_count(items)
|
|
216
299
|
requested = @workers || @config.parallel_workers
|
|
217
300
|
requested.to_i.clamp(1, items.size)
|
|
@@ -240,7 +323,11 @@ module Constable
|
|
|
240
323
|
# driver rightly complains about it.
|
|
241
324
|
@storage.close
|
|
242
325
|
|
|
243
|
-
|
|
326
|
+
# Same reasoning for the app's own connections: a child that inherits a live
|
|
327
|
+
# handle can corrupt it. Rails does exactly this before its own fork.
|
|
328
|
+
WorkerDatabases.before_fork!
|
|
329
|
+
|
|
330
|
+
buckets.each_with_index do |bucket, worker_index|
|
|
244
331
|
reader, writer = IO.pipe
|
|
245
332
|
# Marshal payloads are binary. Left in text mode, the first byte that isn't valid
|
|
246
333
|
# UTF-8 takes the worker down with an encoding error.
|
|
@@ -248,10 +335,21 @@ module Constable
|
|
|
248
335
|
writer.binmode
|
|
249
336
|
pid = fork do
|
|
250
337
|
reader.close
|
|
338
|
+
|
|
339
|
+
# Before a single test runs: build this worker's own database and point the
|
|
340
|
+
# process at it. Raises rather than falling back to the shared one, because a
|
|
341
|
+
# silent fallback is the bug we are here to prevent.
|
|
342
|
+
WorkerDatabases.after_fork!(worker_index)
|
|
343
|
+
|
|
251
344
|
bucket.each do |item|
|
|
252
345
|
run_item(item).each { |result| write_message(writer, :result, result.to_h) }
|
|
253
346
|
end
|
|
254
347
|
|
|
348
|
+
# A worker owns its own cold-case session, and it dies here. Fire the engine's
|
|
349
|
+
# after(:suite) hooks in the process that ran the before(:suite) half, before
|
|
350
|
+
# coverage is read -- the parent has no hooks to run on its behalf.
|
|
351
|
+
ColdCase.reset_engines!
|
|
352
|
+
|
|
255
353
|
# Ruby's Coverage counts lines in the process that executed them, so a worker's
|
|
256
354
|
# hits would die with it. They ride home on the same pipe as the results.
|
|
257
355
|
write_message(writer, :coverage, Constable::Coverage.peek_raw) if coverage?
|
|
@@ -513,6 +611,9 @@ module Constable
|
|
|
513
611
|
# Turns raw pass/fail into the verdict the build acts on: warrants decide whether a
|
|
514
612
|
# failure is even real, then jail decides whether it blocks.
|
|
515
613
|
def adjudicate(raw)
|
|
614
|
+
@systemic = systemic_failure(raw)
|
|
615
|
+
announce_systemic_failure(@systemic) if @systemic
|
|
616
|
+
|
|
516
617
|
raw.map do |result|
|
|
517
618
|
decided = warrants.adjudicate(
|
|
518
619
|
result,
|
|
@@ -520,10 +621,54 @@ module Constable
|
|
|
520
621
|
subject: investigation_for(result.identity)
|
|
521
622
|
) { |subject, _attempt| rerun_in_isolation(subject) }
|
|
522
623
|
|
|
523
|
-
|
|
624
|
+
next decided if jail_run?
|
|
625
|
+
|
|
626
|
+
jail.adjudicate(decided, jail_mode: jail_mode?, systemic: systemic?(decided))
|
|
524
627
|
end
|
|
525
628
|
end
|
|
526
629
|
|
|
630
|
+
# A run is "systemically broken" when a large share of it failed the same way: the
|
|
631
|
+
# database was down, a worker could not start, a shared fixture never loaded. Thirty
|
|
632
|
+
# tests did not each independently go bad in the same second.
|
|
633
|
+
#
|
|
634
|
+
# This matters because flake history reads "passed last run, failed this run" as
|
|
635
|
+
# evidence about a *test*, and jails it. One bad afternoon on CI could therefore
|
|
636
|
+
# quarantine a third of a healthy suite, and the docket -- which is supposed to be a
|
|
637
|
+
# record of tests worth distrusting -- fills up with tests that were never at fault.
|
|
638
|
+
def systemic_failure(results)
|
|
639
|
+
failures = results.select(&:failed?)
|
|
640
|
+
return nil if failures.size < SYSTEMIC_MINIMUM
|
|
641
|
+
return nil if failures.size < results.size * SYSTEMIC_SHARE
|
|
642
|
+
|
|
643
|
+
grouped = failures.group_by { |result| result.failure&.exception_class.to_s }
|
|
644
|
+
grouped.delete("")
|
|
645
|
+
return nil if grouped.empty?
|
|
646
|
+
|
|
647
|
+
exception_class, sharing = grouped.max_by { |_klass, group| group.size }
|
|
648
|
+
return nil if sharing.size < failures.size * SYSTEMIC_AGREEMENT
|
|
649
|
+
|
|
650
|
+
Systemic.new(exception_class: exception_class, failed: sharing.size, total: results.size)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Only the failures that look like the outage are exempt. A genuine failure that
|
|
654
|
+
# happened to land in the same run is still a genuine failure.
|
|
655
|
+
def systemic?(result)
|
|
656
|
+
return false unless @systemic
|
|
657
|
+
return false unless result.failed?
|
|
658
|
+
|
|
659
|
+
result.failure&.exception_class.to_s == @systemic.exception_class
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def announce_systemic_failure(systemic)
|
|
663
|
+
Constable.warn!(
|
|
664
|
+
"#{systemic.failed} of #{systemic.total} tests failed with the same error " \
|
|
665
|
+
"(#{systemic.exception_class}). That reads as one broken run rather than " \
|
|
666
|
+
"#{systemic.failed} newly flaky tests, so flake history and the jail docket were " \
|
|
667
|
+
"left alone. Fix the cause and run again.",
|
|
668
|
+
kind: :systemic
|
|
669
|
+
)
|
|
670
|
+
end
|
|
671
|
+
|
|
527
672
|
def investigation_for(identity)
|
|
528
673
|
@investigation_index ||= Constable.registry.investigations.to_h { |inv| [inv.identity, inv] }
|
|
529
674
|
@investigation_index[identity]
|
|
@@ -564,6 +709,11 @@ module Constable
|
|
|
564
709
|
|
|
565
710
|
def persist(run_id, results, coverage_report)
|
|
566
711
|
results.each do |result|
|
|
712
|
+
# The blotter is the evidence file. A result produced by an outage is not
|
|
713
|
+
# evidence about the test, so it is not filed -- otherwise the next run reads
|
|
714
|
+
# "failed, then passed" and draws a conclusion from a power cut.
|
|
715
|
+
next if systemic?(result)
|
|
716
|
+
|
|
567
717
|
@storage.record_result(run_id, result)
|
|
568
718
|
@storage.record_duration(result.identity, result.duration)
|
|
569
719
|
end
|
data/lib/constable/selection.rb
CHANGED
|
@@ -33,10 +33,13 @@ module Constable
|
|
|
33
33
|
@root = root.to_s
|
|
34
34
|
@full = full
|
|
35
35
|
@unsafe_only = unsafe_only
|
|
36
|
-
|
|
36
|
+
# Downcased: `--tier UNIT` used to match nothing at all and report a clean run.
|
|
37
|
+
@tier = tier.to_s.strip.downcase.to_sym unless tier.to_s.strip.empty?
|
|
37
38
|
@reason = nil
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
TIERS = %w[unit integration system].freeze
|
|
42
|
+
|
|
40
43
|
def full? = @full
|
|
41
44
|
def unsafe_only? = @unsafe_only
|
|
42
45
|
|
|
@@ -60,6 +63,25 @@ module Constable
|
|
|
60
63
|
end
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# Did the user ask for something in particular? If so, finding nothing is an error
|
|
67
|
+
# rather than a clean run -- see Runner#refuse_empty_selection!.
|
|
68
|
+
def explicit? = @args.any? { |arg| !arg.to_s.strip.empty? } || !@tier.nil?
|
|
69
|
+
|
|
70
|
+
# Says which part of the request came up empty, because "0 tests" on its own does not
|
|
71
|
+
# tell you whether the path was wrong, the tier was, or both.
|
|
72
|
+
def empty_selection_message
|
|
73
|
+
if @tier && !TIERS.include?(@tier.to_s)
|
|
74
|
+
return "unknown tier #{@tier.inspect} -- expected one of #{TIERS.join(", ")}."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
described = @args.reject { |arg| arg.to_s.strip.empty? }
|
|
78
|
+
subject = described.empty? ? "this run" : described.join(", ")
|
|
79
|
+
suffix = @tier ? " in the #{@tier} tier" : ""
|
|
80
|
+
|
|
81
|
+
"no tests matched #{subject}#{suffix}. Check the path, the line number, and " \
|
|
82
|
+
"whether the file is a case or a cold case."
|
|
83
|
+
end
|
|
84
|
+
|
|
63
85
|
def native_targets = targets.select(&:native?)
|
|
64
86
|
def cold_targets_selected = targets.select(&:cold?)
|
|
65
87
|
def empty? = targets.empty?
|
|
@@ -36,16 +36,36 @@ module Constable
|
|
|
36
36
|
|
|
37
37
|
def connect!
|
|
38
38
|
require_driver!
|
|
39
|
+
open_database!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The blotter is the one file Constable owns outright, and it is disposable: it
|
|
43
|
+
# holds flake history, the docket and warrants, never a test. So when it cannot be
|
|
44
|
+
# opened, say that deleting it is a real option -- the raw
|
|
45
|
+
# `SQLite3::NotADatabaseException: file is not a database: PRAGMA journal_mode = WAL`
|
|
46
|
+
# tells a reader nothing about what to do next, and a blotter that got committed to
|
|
47
|
+
# git and then merged is exactly how it ends up unreadable.
|
|
48
|
+
def open_database!
|
|
39
49
|
FileUtils.mkdir_p(File.dirname(path))
|
|
40
50
|
@connection = SQLite3::Database.new(path)
|
|
41
51
|
@connection.results_as_hash = true
|
|
42
52
|
@connection.busy_timeout = BUSY_TIMEOUT_MS
|
|
43
|
-
# Readers never block the writer and the writer never blocks readers.
|
|
53
|
+
# Readers never block the writer and the writer never blocks readers. This is also
|
|
54
|
+
# the first statement to touch the file, so a corrupt blotter surfaces here.
|
|
44
55
|
@connection.execute("PRAGMA journal_mode = WAL")
|
|
45
56
|
# WAL + NORMAL is durable across process crashes, which is the only failure that
|
|
46
57
|
# matters here; a machine losing power mid-run costs us one run's bookkeeping.
|
|
47
58
|
@connection.execute("PRAGMA synchronous = NORMAL")
|
|
48
59
|
@connection
|
|
60
|
+
rescue SystemCallError => e
|
|
61
|
+
raise Constable::Error,
|
|
62
|
+
"Constable cannot open its blotter at #{path} (#{e.class}: #{e.message}). " \
|
|
63
|
+
"Point `storage.path` in .constable/config.yml somewhere writable."
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
raise Constable::Error,
|
|
66
|
+
"Constable's blotter at #{path} is not a readable database " \
|
|
67
|
+
"(#{e.class}). It holds flake history, the jail docket and warrants -- " \
|
|
68
|
+
"never your tests -- so deleting it is safe and starts that record fresh."
|
|
49
69
|
end
|
|
50
70
|
|
|
51
71
|
def require_driver!
|
data/lib/constable/version.rb
CHANGED
data/lib/constable/warrants.rb
CHANGED
|
@@ -222,17 +222,68 @@ module Constable
|
|
|
222
222
|
|
|
223
223
|
# The CLI speaks in file:line, the blotter is keyed by content hash. Warrant rows
|
|
224
224
|
# carry both; loaded investigations are the fallback.
|
|
225
|
-
|
|
225
|
+
# Every docket row a target could mean.
|
|
226
|
+
#
|
|
227
|
+
# The interesting case is a bare path. "test/cases/users_case.rb" with three tests
|
|
228
|
+
# on the docket is a question, not an instruction: picking one silently acts on a
|
|
229
|
+
# test the user never named -- and not even the first one, since the order is
|
|
230
|
+
# whatever storage returns. Callers ask for the candidates and refuse to guess.
|
|
231
|
+
def candidates(target)
|
|
226
232
|
text = target.to_s.strip
|
|
227
|
-
return
|
|
228
|
-
|
|
233
|
+
return [] if text.empty?
|
|
234
|
+
|
|
235
|
+
if text.match?(/\A[0-9a-f]{8,64}\z/) && (row = entry(text))
|
|
236
|
+
return [row]
|
|
237
|
+
end
|
|
229
238
|
|
|
230
239
|
file, line = Jail.split_target(text)
|
|
231
|
-
return
|
|
240
|
+
return [] if file.empty?
|
|
232
241
|
|
|
233
242
|
matches = entries.select { |e| Jail.same_path?(e.file, file) }
|
|
234
243
|
matches = matches.select { |e| e.line == line } if line
|
|
235
|
-
|
|
244
|
+
matches
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Rows whose test no longer exists.
|
|
248
|
+
#
|
|
249
|
+
# A test's key is a content hash of its body, so editing a jailed test gives it a new
|
|
250
|
+
# identity and leaves the old row behind -- pointing at a file:line that may now hold
|
|
251
|
+
# something else entirely. That is content-hash identity working as designed, but over
|
|
252
|
+
# a few months the docket fills with tests nobody can find.
|
|
253
|
+
#
|
|
254
|
+
# `known` is every identity the loaded suite registered, so this is only meaningful
|
|
255
|
+
# after a full load. Two things are deliberately conservative about it:
|
|
256
|
+
#
|
|
257
|
+
# * A known identity is never stale, even when the file recorded beside it is gone.
|
|
258
|
+
# The path is a display label; the identity is the truth. A test that moved file
|
|
259
|
+
# has an out-of-date label, not a missing test.
|
|
260
|
+
# * A cold case is never pruned while its file exists. Cold-case tests cannot be
|
|
261
|
+
# enumerated without running their own engine, so absence from `known` says
|
|
262
|
+
# nothing about them.
|
|
263
|
+
def stale_entries(known, cold_case: nil)
|
|
264
|
+
known = Array(known)
|
|
265
|
+
entries.reject { |entry| known.include?(entry.identity) }
|
|
266
|
+
.select { |entry| gone?(entry, cold_case) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def gone?(entry, cold_case)
|
|
270
|
+
relative = entry.file.to_s
|
|
271
|
+
return true if relative.empty?
|
|
272
|
+
|
|
273
|
+
path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
|
|
274
|
+
return true unless File.exist?(path)
|
|
275
|
+
return false if cold_case&.call(relative)
|
|
276
|
+
|
|
277
|
+
true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def resolve(target)
|
|
281
|
+
matches = candidates(target)
|
|
282
|
+
return matches.first.identity if matches.size == 1
|
|
283
|
+
return nil unless matches.empty?
|
|
284
|
+
|
|
285
|
+
file, line = Jail.split_target(target.to_s.strip)
|
|
286
|
+
return nil if file.empty?
|
|
236
287
|
|
|
237
288
|
Jail.registry_identity(file, line)
|
|
238
289
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Per-worker databases for parallel runs.
|
|
5
|
+
#
|
|
6
|
+
# Forking N workers that all talk to one database is not a speed/safety trade, it is a
|
|
7
|
+
# correctness bug. On SQLite it shows up immediately and honestly -- every worker
|
|
8
|
+
# contends for the same file and the run dissolves into
|
|
9
|
+
# `SQLite3::BusyException: database is locked` -- and on a client/server database it
|
|
10
|
+
# shows up later and far worse, as tests seeing each other's rows.
|
|
11
|
+
#
|
|
12
|
+
# Rails already solved this for `rails test`: each worker gets its own database, named
|
|
13
|
+
# by appending the worker index, rebuilt from schema. This is the same thing, driven by
|
|
14
|
+
# Constable's runner rather than by ActiveSupport::Testing::Parallelization, because
|
|
15
|
+
# Constable does its own forking.
|
|
16
|
+
#
|
|
17
|
+
# Everything here is defensive. Constable runs in apps with no ActiveRecord at all --
|
|
18
|
+
# that is the whole point of the :unit tier -- so every entry point answers "no" rather
|
|
19
|
+
# than raising when the pieces are missing, and the runner falls back to a serial run.
|
|
20
|
+
module WorkerDatabases
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Is there an ActiveRecord in this process whose databases would be shared by forks?
|
|
24
|
+
def active_record?
|
|
25
|
+
defined?(::ActiveRecord::Base) ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Can we actually give each worker its own database? Rails ships the machinery in
|
|
29
|
+
# active_record/test_databases, which is only loaded when someone asks for parallel
|
|
30
|
+
# tests -- so ask for it here rather than assuming.
|
|
31
|
+
def shardable?
|
|
32
|
+
return false unless active_record?
|
|
33
|
+
|
|
34
|
+
load_test_databases!
|
|
35
|
+
defined?(::ActiveRecord::TestDatabases) ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parent side, before the fork. A child inheriting a live connection is a corruption
|
|
39
|
+
# risk in exactly the way an inherited SQLite handle is.
|
|
40
|
+
def before_fork!
|
|
41
|
+
return false unless active_record?
|
|
42
|
+
|
|
43
|
+
::ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
44
|
+
true
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Child side, immediately after the fork and before any test runs. Builds
|
|
50
|
+
# `<database>_<index>` from schema and points this process at it.
|
|
51
|
+
#
|
|
52
|
+
# ENV["VERBOSE"] is silenced the way Rails silences it: schema loading is chatty, and
|
|
53
|
+
# stdout belongs to the reporter.
|
|
54
|
+
def after_fork!(index)
|
|
55
|
+
return false unless shardable?
|
|
56
|
+
|
|
57
|
+
::ActiveRecord::TestDatabases.create_and_load_schema(index, env_name: env_name)
|
|
58
|
+
true
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
# A worker that cannot build its own database would otherwise silently fall back to
|
|
61
|
+
# sharing the parent's, which is the bug this module exists to prevent. Say so, and
|
|
62
|
+
# let the failure be a real one.
|
|
63
|
+
raise Constable::Error, "worker #{index} could not create its own test database: " \
|
|
64
|
+
"#{e.class}: #{e.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def env_name
|
|
68
|
+
if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
|
|
69
|
+
::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
|
|
70
|
+
else
|
|
71
|
+
ENV["RAILS_ENV"] || "test"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_test_databases!
|
|
76
|
+
return if defined?(::ActiveRecord::TestDatabases)
|
|
77
|
+
|
|
78
|
+
require "active_record/test_databases"
|
|
79
|
+
rescue LoadError, StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/constable.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "constable/version"
|
|
4
|
+
require "constable/worker_databases"
|
|
4
5
|
|
|
5
6
|
# Constable -- an opinionated, strict Rails testing framework.
|
|
6
7
|
#
|
|
@@ -129,14 +130,60 @@ module Constable
|
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
# Code-level configuration set from test/case_helper.rb.
|
|
133
|
+
# The Ruby half of configuration, set from test/case_helper.rb.
|
|
134
|
+
#
|
|
135
|
+
# Anything that is *code* -- custom matchers, tier base classes, one-time global setup --
|
|
136
|
+
# can only live here. Everything that is merely a *setting* can live in either place, and
|
|
137
|
+
# this is the one that wins:
|
|
138
|
+
#
|
|
139
|
+
# a CLI flag for one run
|
|
140
|
+
# Constable.configure in case_helper.rb, because it is code and ran deliberately
|
|
141
|
+
# .constable/config.yml the declared default for the project
|
|
142
|
+
# Constable's defaults
|
|
143
|
+
#
|
|
144
|
+
# Until 1.1.0 the four accessors here were read by nothing at all: `c.parallel_workers = 4`
|
|
145
|
+
# was in the generated case_helper.rb, documented, and silently ignored.
|
|
132
146
|
class Configuration
|
|
133
|
-
|
|
147
|
+
# Every setting .constable/config.yml understands, settable in Ruby as well. A nil is
|
|
148
|
+
# "not set here" rather than "set to nothing", so leaving one alone defers to the file.
|
|
149
|
+
SETTINGS = %i[
|
|
150
|
+
cold_cases warrants warrant_retries auto_relink parole_period
|
|
151
|
+
coverage coverage_threshold coverage_html fail_on_warnings parallel_workers
|
|
152
|
+
output tiers
|
|
153
|
+
].freeze
|
|
154
|
+
|
|
155
|
+
# `storage` is the one setting that cannot live here, and the reason is ordering, not
|
|
156
|
+
# preference: the blotter handle is opened before test/case_helper.rb is loaded,
|
|
157
|
+
# because `constable jail`, `warrants`, `watchlist` and `status` all read the docket
|
|
158
|
+
# without booting the app at all. By the time this block runs, it is already open.
|
|
159
|
+
#
|
|
160
|
+
# Raising beats accepting the value and quietly using the old path -- silently
|
|
161
|
+
# ignoring a setting somebody wrote is the failure mode this whole class was fixed for.
|
|
162
|
+
SETTINGS_ONLY_IN_YAML = %i[storage].freeze
|
|
163
|
+
|
|
164
|
+
attr_accessor(*SETTINGS, :seed)
|
|
165
|
+
|
|
166
|
+
def storage=(_value)
|
|
167
|
+
raise ConfigurationError,
|
|
168
|
+
"storage must be set in .constable/config.yml, not Constable.configure. The " \
|
|
169
|
+
"blotter is opened before case_helper.rb loads, so that `constable jail` and " \
|
|
170
|
+
"`constable status` can read the docket without booting the app -- by the " \
|
|
171
|
+
"time this block runs the connection is already open."
|
|
172
|
+
end
|
|
134
173
|
|
|
135
174
|
def initialize
|
|
136
175
|
@before_suite_hooks = []
|
|
137
176
|
@after_suite_hooks = []
|
|
138
177
|
end
|
|
139
178
|
|
|
179
|
+
# Only the settings actually assigned, ready to merge over the file's values.
|
|
180
|
+
def overrides
|
|
181
|
+
SETTINGS.each_with_object({}) do |name, out|
|
|
182
|
+
value = public_send(name)
|
|
183
|
+
out[name.to_s] = value unless value.nil?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
140
187
|
def before_suite(&block) = @before_suite_hooks << block
|
|
141
188
|
def after_suite(&block) = @after_suite_hooks << block
|
|
142
189
|
|