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/cli.rb
CHANGED
|
@@ -17,7 +17,10 @@ module Constable
|
|
|
17
17
|
# wrong", and CI needs to. Commands return a status; this turns it into one.
|
|
18
18
|
def self.dispatch!(argv)
|
|
19
19
|
start(argv)
|
|
20
|
-
|
|
20
|
+
# Thor::Error is a malformed command; Constable::Error is a wrong path, an unknown
|
|
21
|
+
# tier, an unreadable config. Both are the user's mistake rather than a crash, and
|
|
22
|
+
# both deserve one sentence and a usage status instead of a backtrace.
|
|
23
|
+
rescue Thor::Error, Constable::Error => e
|
|
21
24
|
warn e.message
|
|
22
25
|
exit(EXIT_USAGE)
|
|
23
26
|
rescue Interrupt
|
|
@@ -42,6 +45,9 @@ module Constable
|
|
|
42
45
|
option :workers, type: :numeric, desc: "Parallel workers (default: config, or auto)"
|
|
43
46
|
option :verbose, type: :boolean, default: false, desc: "Stream log/test.log to stdout"
|
|
44
47
|
option :tier, type: :string, desc: "Run one tier only: unit, integration or system"
|
|
48
|
+
option :output, type: :string, desc: "Live stream detail: concise (default) or expanded"
|
|
49
|
+
option :expanded, type: :boolean, default: false, desc: "Shorthand for --output=expanded"
|
|
50
|
+
option :concise, type: :boolean, default: false, desc: "Shorthand for --output=concise"
|
|
45
51
|
def test(*paths)
|
|
46
52
|
config = load_config
|
|
47
53
|
LogRouter.route!(verbose: options[:verbose])
|
|
@@ -299,7 +305,9 @@ module Constable
|
|
|
299
305
|
no_commands do
|
|
300
306
|
def act(locator)
|
|
301
307
|
jail = Jail.new(config: Constable.config, storage: Constable.storage)
|
|
302
|
-
|
|
308
|
+
refuse_ambiguous(locator, jail.candidates(locator), "on the docket")
|
|
309
|
+
|
|
310
|
+
identity = jail.resolve(locator)
|
|
303
311
|
unless identity
|
|
304
312
|
warn "Nothing on the docket at #{locator}"
|
|
305
313
|
exit(EXIT_USAGE)
|
|
@@ -307,6 +315,18 @@ module Constable
|
|
|
307
315
|
yield jail, identity
|
|
308
316
|
end
|
|
309
317
|
|
|
318
|
+
# A bare path naming several tests is a question. Answer it with the list rather
|
|
319
|
+
# than acting on whichever row the database happened to return first.
|
|
320
|
+
def refuse_ambiguous(locator, candidates, noun)
|
|
321
|
+
return if candidates.size <= 1
|
|
322
|
+
|
|
323
|
+
warn "#{locator} matches #{candidates.size} tests #{noun}. Name one:"
|
|
324
|
+
candidates.sort_by { |entry| entry.line.to_i }.each do |entry|
|
|
325
|
+
warn " #{entry.location} #{entry.label}"
|
|
326
|
+
end
|
|
327
|
+
exit(EXIT_USAGE)
|
|
328
|
+
end
|
|
329
|
+
|
|
310
330
|
def repeat_note(entry)
|
|
311
331
|
count = entry.times_jailed
|
|
312
332
|
count > 1 ? " (this is its #{Reporter.ordinalize(count)} time in jail)" : ""
|
|
@@ -344,12 +364,14 @@ module Constable
|
|
|
344
364
|
desc "release PATH:LINE", "Clear a warrant by hand"
|
|
345
365
|
def release(locator)
|
|
346
366
|
warrants = Warrants.new(config: Constable.config, storage: Constable.storage)
|
|
347
|
-
|
|
367
|
+
JailCommand.new.send(:refuse_ambiguous, locator, warrants.candidates(locator), "under warrant")
|
|
368
|
+
|
|
369
|
+
identity = warrants.resolve(locator)
|
|
348
370
|
unless identity
|
|
349
371
|
warn "No warrant at #{locator}"
|
|
350
372
|
exit(EXIT_USAGE)
|
|
351
373
|
end
|
|
352
|
-
warrants.
|
|
374
|
+
warrants.release(identity)
|
|
353
375
|
say "Warrant cleared."
|
|
354
376
|
end
|
|
355
377
|
end
|
|
@@ -377,6 +399,48 @@ module Constable
|
|
|
377
399
|
end
|
|
378
400
|
end
|
|
379
401
|
|
|
402
|
+
desc "prune", "Forget docket rows and warrants for tests that no longer exist"
|
|
403
|
+
long_desc <<~DESC
|
|
404
|
+
A test's key is a content hash of its body, so editing a jailed test gives it a new
|
|
405
|
+
identity and leaves the old row behind -- pointing at a file:line that may now hold
|
|
406
|
+
something else. That is identity working as designed; this is the broom.
|
|
407
|
+
|
|
408
|
+
Loads the whole suite first, because which tests still exist is only knowable once
|
|
409
|
+
every case file has been read. Cold cases are never pruned while their file exists:
|
|
410
|
+
their tests cannot be enumerated without running their own engine, so silence about
|
|
411
|
+
them means nothing.
|
|
412
|
+
DESC
|
|
413
|
+
option :dry_run, type: :boolean, default: false, desc: "List what would go, change nothing"
|
|
414
|
+
def prune
|
|
415
|
+
config = load_config
|
|
416
|
+
known = Runner.identities(config: config)
|
|
417
|
+
cold = ->(path) { config.cold_case?(path) }
|
|
418
|
+
|
|
419
|
+
jail = Jail.new(config: config, storage: Constable.storage)
|
|
420
|
+
warrants = Warrants.new(config: config, storage: Constable.storage)
|
|
421
|
+
|
|
422
|
+
stale_rows = jail.stale_entries(known, cold_case: cold)
|
|
423
|
+
stale_warrants = warrants.stale_entries(known, cold_case: cold)
|
|
424
|
+
|
|
425
|
+
if stale_rows.empty? && stale_warrants.empty?
|
|
426
|
+
say "Nothing to prune -- every row on the docket still names a test that exists."
|
|
427
|
+
return 0
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
report_prunable("docket", stale_rows)
|
|
431
|
+
report_prunable("warrants", stale_warrants)
|
|
432
|
+
|
|
433
|
+
if options[:dry_run]
|
|
434
|
+
say "Dry run: nothing was changed."
|
|
435
|
+
return 0
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
stale_rows.each { |entry| jail.forget(entry.identity) }
|
|
439
|
+
stale_warrants.each { |entry| warrants.release(entry.identity) }
|
|
440
|
+
say "Pruned #{stale_rows.size + stale_warrants.size} row(s). Flake history is left alone."
|
|
441
|
+
0
|
|
442
|
+
end
|
|
443
|
+
|
|
380
444
|
desc "jail SUBCOMMAND", "The jail docket"
|
|
381
445
|
subcommand "jail", JailCommand
|
|
382
446
|
|
|
@@ -387,6 +451,16 @@ module Constable
|
|
|
387
451
|
subcommand "history", HistoryCommand
|
|
388
452
|
|
|
389
453
|
no_commands do
|
|
454
|
+
def report_prunable(heading, entries)
|
|
455
|
+
return if entries.empty?
|
|
456
|
+
|
|
457
|
+
say "#{heading} (#{entries.size}):"
|
|
458
|
+
entries.sort_by { |entry| [entry.file.to_s, entry.line.to_i] }.each do |entry|
|
|
459
|
+
say " #{entry.location} #{entry.label}"
|
|
460
|
+
end
|
|
461
|
+
say ""
|
|
462
|
+
end
|
|
463
|
+
|
|
390
464
|
def load_config
|
|
391
465
|
Constable.config
|
|
392
466
|
end
|
|
@@ -400,7 +474,18 @@ module Constable
|
|
|
400
474
|
end
|
|
401
475
|
|
|
402
476
|
def reporter(config)
|
|
403
|
-
Reporter.new(io: $stdout, config: config, color: color
|
|
477
|
+
Reporter.new(io: $stdout, config: config, color: color?, mode: output_mode)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# nil means "the config file decides". The explicit --output wins over the two
|
|
481
|
+
# shorthands, and --concise wins over --expanded if somebody passes both -- the
|
|
482
|
+
# quieter of two contradictory instructions is the safer one to obey.
|
|
483
|
+
def output_mode
|
|
484
|
+
return options[:output] if options[:output]
|
|
485
|
+
return :concise if options[:concise]
|
|
486
|
+
return :expanded if options[:expanded]
|
|
487
|
+
|
|
488
|
+
nil
|
|
404
489
|
end
|
|
405
490
|
|
|
406
491
|
def say_table(heading, entries)
|
|
@@ -120,7 +120,12 @@ module Constable
|
|
|
120
120
|
with_engine do
|
|
121
121
|
collector = Collector.new
|
|
122
122
|
load_error = capture_load(path)
|
|
123
|
-
|
|
123
|
+
unless load_error
|
|
124
|
+
# After the file has loaded -- that load is what registers them -- and
|
|
125
|
+
# before its examples run. See #run_pending_before_suite_hooks.
|
|
126
|
+
run_pending_before_suite_hooks(::RSpec.configuration)
|
|
127
|
+
run_world(collector)
|
|
128
|
+
end
|
|
124
129
|
|
|
125
130
|
ColdCase.warn_for_file(path, base_class_name, collector.examples.size, config: config)
|
|
126
131
|
results = build_results(path, collector.examples, config: config, seed: seed,
|
|
@@ -134,14 +139,101 @@ module Constable
|
|
|
134
139
|
# configuration -- which also means any RSpec.configure hooks a rails_helper
|
|
135
140
|
# installed are gone, so this is a teardown call, not a between-files call.
|
|
136
141
|
def reset_engine!
|
|
142
|
+
# Inside the global-state swap, not outside it. Building a SuiteHookContext
|
|
143
|
+
# makes rspec-core lazily construct a world and a configuration, so running
|
|
144
|
+
# these hooks bare would leave both behind in a host process that had none --
|
|
145
|
+
# exactly the leak #with_engine exists to prevent.
|
|
146
|
+
with_engine { run_after_suite_hooks! } if after_suite_hooks_pending?
|
|
147
|
+
|
|
137
148
|
@session_world = nil
|
|
138
149
|
@session_configuration = nil
|
|
139
150
|
@session_prepared = false
|
|
151
|
+
@ran_before_suite_hooks = nil
|
|
140
152
|
nil
|
|
141
153
|
end
|
|
142
154
|
|
|
143
155
|
private
|
|
144
156
|
|
|
157
|
+
# --- Suite hooks ------------------------------------------------------------
|
|
158
|
+
#
|
|
159
|
+
# RSpec's own Runner wraps its group loop in `configuration.with_suite_hooks`.
|
|
160
|
+
# We drive the groups directly (see #run_world), so without this a cold case
|
|
161
|
+
# never fires `before(:suite)` -- and that is exactly where webmock/rspec calls
|
|
162
|
+
# `WebMock.enable!`, where VCR and DatabaseCleaner install themselves, and where
|
|
163
|
+
# SimpleCov starts. Skipping them fails *open*: a spec that stubs HTTP opens a
|
|
164
|
+
# real socket instead of erroring, which is the worst direction for a testing
|
|
165
|
+
# tool to be wrong in.
|
|
166
|
+
#
|
|
167
|
+
# `with_suite_hooks` itself is the wrong shape here. It is a bracket around one
|
|
168
|
+
# block, but "suite" means the whole run rather than one file: wrapping each file
|
|
169
|
+
# would fire `after(:suite)` after file one and hand file two the wreckage. Nor
|
|
170
|
+
# can the hooks all be run up front, because a legacy file's own
|
|
171
|
+
# `require "rails_helper"` is what registers them -- before the first load there
|
|
172
|
+
# is nothing to run.
|
|
173
|
+
#
|
|
174
|
+
# So each hook runs exactly once, the first time we see it: after a file has
|
|
175
|
+
# loaded, before its examples. `after(:suite)` runs once, from #reset_engine!.
|
|
176
|
+
def run_pending_before_suite_hooks(configuration)
|
|
177
|
+
pending = suite_hooks(configuration, :@before_suite_hooks) - ran_before_suite_hooks
|
|
178
|
+
return if pending.empty?
|
|
179
|
+
|
|
180
|
+
ran_before_suite_hooks.concat(pending)
|
|
181
|
+
invoke_suite_hooks(configuration, "a `before(:suite)` hook", pending,
|
|
182
|
+
scope: :before_suite_hook)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Only worth running if we ever ran the matching before(:suite) half -- otherwise
|
|
186
|
+
# this is a teardown for setup that never happened.
|
|
187
|
+
def run_after_suite_hooks!
|
|
188
|
+
configuration = @session_configuration
|
|
189
|
+
return if configuration.nil? || ran_before_suite_hooks.empty?
|
|
190
|
+
|
|
191
|
+
hooks = suite_hooks(configuration, :@after_suite_hooks)
|
|
192
|
+
return if hooks.empty?
|
|
193
|
+
|
|
194
|
+
invoke_suite_hooks(configuration, "an `after(:suite)` hook", hooks,
|
|
195
|
+
scope: :after_suite_hook)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def ran_before_suite_hooks
|
|
199
|
+
@ran_before_suite_hooks ||= []
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Nothing was set up, so there is nothing to tear down -- and no reason to build
|
|
203
|
+
# an RSpec world to discover that.
|
|
204
|
+
def after_suite_hooks_pending?
|
|
205
|
+
!@session_configuration.nil? &&
|
|
206
|
+
ran_before_suite_hooks.any? &&
|
|
207
|
+
suite_hooks(@session_configuration, :@after_suite_hooks).any?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# RSpec keeps these in plain ivars with no public reader. Read them defensively:
|
|
211
|
+
# a missing ivar means a version that stores them elsewhere, and running no suite
|
|
212
|
+
# hooks is the behavior we already had.
|
|
213
|
+
def suite_hooks(configuration, ivar)
|
|
214
|
+
return [] unless configuration.respond_to?(:instance_variable_defined?)
|
|
215
|
+
return [] unless configuration.instance_variable_defined?(ivar)
|
|
216
|
+
|
|
217
|
+
Array(configuration.instance_variable_get(ivar))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# `run_suite_hooks` is private on Configuration, and it is the part that builds a
|
|
221
|
+
# SuiteHookContext and keeps one failing before-hook from running the rest. Use it
|
|
222
|
+
# when it is there, and fall back to driving the hooks ourselves when it is not.
|
|
223
|
+
def invoke_suite_hooks(configuration, description, hooks, scope:)
|
|
224
|
+
previous = ::RSpec.current_scope if ::RSpec.respond_to?(:current_scope)
|
|
225
|
+
::RSpec.current_scope = scope if ::RSpec.respond_to?(:current_scope=)
|
|
226
|
+
|
|
227
|
+
if configuration.respond_to?(:run_suite_hooks, true)
|
|
228
|
+
configuration.send(:run_suite_hooks, description, hooks)
|
|
229
|
+
else
|
|
230
|
+
context = ::RSpec::Core::SuiteHookContext.new(description, configuration.reporter)
|
|
231
|
+
hooks.each { |hook| hook.run(context) }
|
|
232
|
+
end
|
|
233
|
+
ensure
|
|
234
|
+
::RSpec.current_scope = previous if previous && ::RSpec.respond_to?(:current_scope=)
|
|
235
|
+
end
|
|
236
|
+
|
|
145
237
|
# Running RSpec in-process is a global-state problem: RSpec.world holds every
|
|
146
238
|
# registered example group and RSpec.configuration holds every hook. We swap in a
|
|
147
239
|
# session world/configuration for the duration of a file and put whatever was
|
data/lib/constable/config.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Constable
|
|
|
19
19
|
"coverage_threshold" => 90,
|
|
20
20
|
"coverage_html" => false,
|
|
21
21
|
"fail_on_warnings" => false,
|
|
22
|
+
"output" => "concise",
|
|
22
23
|
"parallel_workers" => "auto",
|
|
23
24
|
"tiers" => {
|
|
24
25
|
"unit" => "test/cases/models/**/*",
|
|
@@ -33,8 +34,27 @@ module Constable
|
|
|
33
34
|
|
|
34
35
|
def self.load(root: Constable.root, overrides: {})
|
|
35
36
|
path = File.join(root.to_s, CONFIG_PATH)
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
new(read_file(path), root: root, overrides: overrides)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A typo in config.yml used to surface as a raw Psych::SyntaxError, or -- for a file
|
|
41
|
+
# that parsed but wasn't a mapping -- as "no implicit conversion of Array into Hash"
|
|
42
|
+
# from somewhere deep in the merge. Neither says which file to open.
|
|
43
|
+
def self.read_file(path)
|
|
44
|
+
return {} unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: true)
|
|
47
|
+
return {} if loaded.nil?
|
|
48
|
+
|
|
49
|
+
unless loaded.is_a?(Hash)
|
|
50
|
+
raise Constable::Error,
|
|
51
|
+
"#{CONFIG_PATH} must be a mapping of settings, but it parsed as " \
|
|
52
|
+
"#{loaded.class.name.downcase}. Check the indentation."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
loaded
|
|
56
|
+
rescue Psych::SyntaxError => e
|
|
57
|
+
raise Constable::Error, "#{CONFIG_PATH} is not valid YAML: #{e.problem} at line #{e.line}."
|
|
38
58
|
end
|
|
39
59
|
|
|
40
60
|
def initialize(raw = {}, root: Constable.root, overrides: {})
|
|
@@ -43,23 +63,83 @@ module Constable
|
|
|
43
63
|
@raw = deep_merge(@raw, stringify(overrides || {}))
|
|
44
64
|
end
|
|
45
65
|
|
|
66
|
+
# Merges values set in Ruby (Constable.configure) over the ones read from the file.
|
|
67
|
+
# Called once, after case_helper.rb has been loaded -- which is the first moment those
|
|
68
|
+
# values exist.
|
|
69
|
+
def apply_overrides!(overrides)
|
|
70
|
+
return self if overrides.nil? || overrides.empty?
|
|
71
|
+
|
|
72
|
+
@raw = deep_merge(@raw, stringify(overrides))
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
46
76
|
def cold_cases = Array(@raw["cold_cases"])
|
|
47
77
|
def warrants? = truthy(@raw["warrants"])
|
|
48
|
-
|
|
78
|
+
# Negative retries are a typo for "off", not an instruction to count backwards.
|
|
79
|
+
def warrant_retries = [@raw["warrant_retries"].to_i, 0].max
|
|
49
80
|
def auto_relink? = truthy(@raw["auto_relink"])
|
|
50
|
-
|
|
81
|
+
|
|
82
|
+
# Clamped here rather than at each call site: Jail already refused a period of zero
|
|
83
|
+
# ("release on sight" is not parole), but the reporter read the raw value and would
|
|
84
|
+
# cheerfully print "Day 1 of 0 -- 0 clean runs to go" while the docket waited for 10.
|
|
85
|
+
def parole_period
|
|
86
|
+
period = @raw["parole_period"].to_i
|
|
87
|
+
period.positive? ? period : DEFAULTS["parole_period"]
|
|
88
|
+
end
|
|
89
|
+
|
|
51
90
|
def coverage? = truthy(@raw["coverage"])
|
|
52
|
-
|
|
91
|
+
# Clamped: a threshold above 100 is a build that can never go green, and a negative
|
|
92
|
+
# one is a gate that can never fail. Both are typos rather than intentions.
|
|
93
|
+
def coverage_threshold = @raw["coverage_threshold"].to_i.clamp(0, 100)
|
|
53
94
|
def coverage_html? = truthy(@raw["coverage_html"])
|
|
54
95
|
def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
|
|
55
96
|
def tiers = @raw["tiers"] || {}
|
|
97
|
+
|
|
98
|
+
# How much the live stream says while the suite runs.
|
|
99
|
+
#
|
|
100
|
+
# concise one glyph per test, grouped into a run per case. The default: a
|
|
101
|
+
# 1,000-test suite stays inside one screen.
|
|
102
|
+
# expanded a line per test -- glyph, name, duration. Slower to read in bulk,
|
|
103
|
+
# but you can see which test is hanging without waiting for the summary.
|
|
104
|
+
#
|
|
105
|
+
# The summary itself is identical either way. This only affects the live stream.
|
|
106
|
+
OUTPUT_MODES = %i[concise expanded].freeze
|
|
107
|
+
|
|
108
|
+
def output_mode
|
|
109
|
+
mode = @raw["output"].to_s.strip.downcase.to_sym
|
|
110
|
+
OUTPUT_MODES.include?(mode) ? mode : :concise
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def expanded_output? = output_mode == :expanded
|
|
56
114
|
def storage = @raw["storage"] || {}
|
|
57
115
|
|
|
58
|
-
|
|
59
|
-
|
|
116
|
+
# Storage is the one setting that cannot be written in Ruby -- the blotter is opened
|
|
117
|
+
# before test/case_helper.rb loads, so `constable jail` and `constable status` can read
|
|
118
|
+
# the docket without booting the app. That would leave .constable/config.yml mandatory
|
|
119
|
+
# for anyone not on the default SQLite, so the environment can say it instead:
|
|
120
|
+
#
|
|
121
|
+
# CONSTABLE_STORAGE_URL=postgres://user:pass@host/constable_metadata
|
|
122
|
+
# CONSTABLE_STORAGE_PATH=/var/lib/constable/blotter.sqlite3
|
|
123
|
+
#
|
|
124
|
+
# Which is also the right shape for CI, where the value is a secret and differs per
|
|
125
|
+
# machine. The environment wins over the file, as an environment usually should.
|
|
126
|
+
def storage_url = env_or("CONSTABLE_STORAGE_URL", storage["url"])
|
|
127
|
+
|
|
128
|
+
def storage_adapter
|
|
129
|
+
explicit = env_or("CONSTABLE_STORAGE_ADAPTER", storage["adapter"])
|
|
130
|
+
return explicit.to_s if explicit
|
|
131
|
+
|
|
132
|
+
# A URL with no adapter names its own: postgres://... can only mean postgres.
|
|
133
|
+
scheme = storage_url.to_s[%r{\A([a-z][a-z0-9+.-]*)://}, 1]
|
|
134
|
+
return "postgres" if %w[postgres postgresql].include?(scheme)
|
|
135
|
+
return "mysql" if %w[mysql mysql2].include?(scheme)
|
|
136
|
+
|
|
137
|
+
"sqlite"
|
|
138
|
+
end
|
|
60
139
|
|
|
61
140
|
def storage_path
|
|
62
|
-
path =
|
|
141
|
+
path = env_or("CONSTABLE_STORAGE_PATH", storage["path"]) ||
|
|
142
|
+
DEFAULTS["storage"]["path"]
|
|
63
143
|
File.absolute_path?(path) ? path : File.join(@root, path)
|
|
64
144
|
end
|
|
65
145
|
|
|
@@ -97,6 +177,13 @@ module Constable
|
|
|
97
177
|
|
|
98
178
|
private
|
|
99
179
|
|
|
180
|
+
# An empty environment variable is not a value. `CONSTABLE_STORAGE_URL=` in a CI
|
|
181
|
+
# config means "unset", not "connect to the empty string".
|
|
182
|
+
def env_or(name, fallback)
|
|
183
|
+
value = ENV.fetch(name, nil)
|
|
184
|
+
value.nil? || value.strip.empty? ? fallback : value.strip
|
|
185
|
+
end
|
|
186
|
+
|
|
100
187
|
def truthy(value)
|
|
101
188
|
return false if value.nil? || value == false
|
|
102
189
|
return false if value.to_s.strip.downcase == "false"
|
data/lib/constable/identity.rb
CHANGED
|
@@ -33,6 +33,25 @@ module Constable
|
|
|
33
33
|
digest("cold:#{relative}:#{description}")
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Two investigations with byte-identical bodies hash to the same key, which would
|
|
37
|
+
# make them one test as far as the blotter is concerned: jail one and the other goes
|
|
38
|
+
# with it, and their flake histories merge into a single misleading record.
|
|
39
|
+
#
|
|
40
|
+
# Bodies repeat more often than the "content hash" idea suggests --
|
|
41
|
+
# `attest(build(:thing, name: nil)).not_to be_valid` is the same handful of tokens in
|
|
42
|
+
# every model case, and the model generator writes an identical first investigation
|
|
43
|
+
# into every file it touches. So the collision is routine, not theoretical.
|
|
44
|
+
#
|
|
45
|
+
# When it happens, the colliding tests are re-keyed on the body *plus* their class
|
|
46
|
+
# and description. The rename-survival promise is weaker for exactly those tests --
|
|
47
|
+
# rewording one of them starts its history over -- which is the right trade: a
|
|
48
|
+
# history that belongs to two tests at once is worse than one that resets.
|
|
49
|
+
def disambiguate(base, case_name:, description:, ordinal: nil)
|
|
50
|
+
key = "#{base}:#{case_name}:#{description}"
|
|
51
|
+
key = "#{key}:#{ordinal}" unless ordinal.nil?
|
|
52
|
+
digest(key)
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
def digest(string)
|
|
37
56
|
Digest::SHA256.hexdigest(string)[0, LENGTH]
|
|
38
57
|
end
|
|
@@ -508,6 +508,8 @@ module Constable
|
|
|
508
508
|
"stub object.")
|
|
509
509
|
end
|
|
510
510
|
|
|
511
|
+
flag_unknown_matcher(node, args.first) if %i[to not_to to_not].include?(name)
|
|
512
|
+
|
|
511
513
|
if MOCK_ENTRY_POINTS.include?(name) && receiver.nil?
|
|
512
514
|
note_untouched(:rspec_mocks, node,
|
|
513
515
|
"`#{first_line(node)}` uses rspec-mocks. Constable has no equivalent; convert it by hand.")
|
|
@@ -532,6 +534,14 @@ module Constable
|
|
|
532
534
|
end
|
|
533
535
|
when :should, :should_not
|
|
534
536
|
flag(:should_syntax, node, "`#{name}` is RSpec's monkey-patched expectation syntax. Use `attest(...).to`.")
|
|
537
|
+
when :helper
|
|
538
|
+
if receiver.nil? && args.empty?
|
|
539
|
+
flag(:rspec_helper_object, node,
|
|
540
|
+
"`helper` is RSpec's helper-spec proxy and has no Constable equivalent. " \
|
|
541
|
+
"A helper is a plain module: `include YourHelper` in the case and call " \
|
|
542
|
+
"the method directly -- which is exactly what `rails generate helper` " \
|
|
543
|
+
"writes.")
|
|
544
|
+
end
|
|
535
545
|
when :described_class
|
|
536
546
|
if receiver.nil?
|
|
537
547
|
flag(:described_class, node,
|
|
@@ -558,6 +568,36 @@ module Constable
|
|
|
558
568
|
record_converted(:helper_require, node, value, replaced)
|
|
559
569
|
end
|
|
560
570
|
|
|
571
|
+
# Constable's matcher set is deliberately smaller than RSpec's, and the rewrite
|
|
572
|
+
# carries any matcher name straight across. Without this check the first anyone
|
|
573
|
+
# hears about it is a NoMethodError at runtime, naming the matcher -- or worse, an
|
|
574
|
+
# internal deferred class -- rather than the line that needs a decision.
|
|
575
|
+
def flag_unknown_matcher(node, matcher_node)
|
|
576
|
+
name = root_matcher_name(matcher_node)
|
|
577
|
+
return if name.nil?
|
|
578
|
+
return if Constable::Matchers.matcher_name?(name)
|
|
579
|
+
|
|
580
|
+
flag(:unknown_matcher, node,
|
|
581
|
+
"`#{name}` is not one of Constable's matchers. Define it in " \
|
|
582
|
+
"test/support/matchers.rb with `Constable::Matchers.define(:#{name})`, or " \
|
|
583
|
+
"rewrite the assertion.")
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# `contain_exactly(1, 2)` -> :contain_exactly. `be_within(0.5).of(10)` -> :be_within.
|
|
587
|
+
# `change { x }.by(1)` -> :change. Anything that is not ultimately a bare method
|
|
588
|
+
# call -- a local variable holding a matcher, a constant -- returns nil and is left
|
|
589
|
+
# alone, because we cannot know what it is.
|
|
590
|
+
def root_matcher_name(node)
|
|
591
|
+
return nil unless node.is_a?(::Parser::AST::Node)
|
|
592
|
+
|
|
593
|
+
current = node
|
|
594
|
+
current = current.children.first while current.type == :send && current.children.first
|
|
595
|
+
|
|
596
|
+
return nil unless current.type == :send && current.children.first.nil?
|
|
597
|
+
|
|
598
|
+
current.children[1]
|
|
599
|
+
end
|
|
600
|
+
|
|
561
601
|
def mock_expectation?(node)
|
|
562
602
|
return false unless node.is_a?(::Parser::AST::Node)
|
|
563
603
|
|
|
@@ -38,6 +38,16 @@ module Constable
|
|
|
38
38
|
@identity ||= Identity.for_block(@block)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Re-keys this investigation because another one has the same body. Called by the
|
|
42
|
+
# registry once the whole suite is loaded, which is the first moment a collision can
|
|
43
|
+
# be seen. See Identity.disambiguate.
|
|
44
|
+
def disambiguate!(ordinal: nil)
|
|
45
|
+
@identity = Identity.disambiguate(identity, case_name: case_name,
|
|
46
|
+
description: full_description,
|
|
47
|
+
ordinal: ordinal)
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
def location
|
|
42
52
|
"#{relative_file}:#{@line}"
|
|
43
53
|
end
|
data/lib/constable/jail.rb
CHANGED
|
@@ -241,6 +241,11 @@ module Constable
|
|
|
241
241
|
# `constable jail release PATH:LINE`. Off the docket entirely, no supervision.
|
|
242
242
|
def release(identity) = @storage.release(identity.to_s) ? true : false
|
|
243
243
|
|
|
244
|
+
# Pruning is not release. Release says "this test is trusted again"; prune says "this
|
|
245
|
+
# row is about a test that no longer exists". The distinction matters because release
|
|
246
|
+
# is a judgement someone made and prune is bookkeeping.
|
|
247
|
+
def forget(identity) = @storage.release(identity.to_s) ? true : false
|
|
248
|
+
|
|
244
249
|
# `jail run` never auto-releases and never auto-paroles. One green run proves nothing;
|
|
245
250
|
# it only earns a mention. A human reads this list and decides.
|
|
246
251
|
#
|
|
@@ -267,13 +272,20 @@ module Constable
|
|
|
267
272
|
# Call it *before* writing the result to flake history (the flip check reads the
|
|
268
273
|
# previous status) and *after* Warrants has had its say (a warranted result is not a
|
|
269
274
|
# failure, so it never reaches the docket).
|
|
270
|
-
|
|
275
|
+
# `systemic:` says this run failed for a reason that has nothing to do with any
|
|
276
|
+
# individual test -- see Runner#systemic_failure. The failures still stand and the
|
|
277
|
+
# build still goes red, but they are not *evidence*: nothing moves through the state
|
|
278
|
+
# machine, because "the database was locked for the whole run" is not a fact about a
|
|
279
|
+
# test and must not put one on the docket.
|
|
280
|
+
def adjudicate(result, jail_mode: false, systemic: false)
|
|
271
281
|
return result if result.nil?
|
|
272
282
|
|
|
273
283
|
docket = entry(result.identity)
|
|
274
284
|
|
|
285
|
+
return mark_jailed(result) if docket&.jailed?
|
|
286
|
+
return result if systemic
|
|
287
|
+
|
|
275
288
|
return record_result(result) if docket&.paroled?
|
|
276
|
-
return mark_jailed(result) if docket&.jailed?
|
|
277
289
|
return result unless result.failed?
|
|
278
290
|
|
|
279
291
|
if jail_mode
|
|
@@ -331,17 +343,70 @@ module Constable
|
|
|
331
343
|
# test that is not on the docket yet.
|
|
332
344
|
#
|
|
333
345
|
# Returns an identity String, or nil when nothing matches.
|
|
334
|
-
|
|
346
|
+
# Every docket row a target could mean.
|
|
347
|
+
#
|
|
348
|
+
# The interesting case is a bare path. "test/cases/users_case.rb" with three tests
|
|
349
|
+
# on the docket is a question, not an instruction: picking one silently acts on a
|
|
350
|
+
# test the user never named -- and not even the first one, since the order is
|
|
351
|
+
# whatever storage returns. Callers ask for the candidates and refuse to guess.
|
|
352
|
+
def candidates(target)
|
|
335
353
|
text = target.to_s.strip
|
|
336
|
-
return
|
|
337
|
-
|
|
354
|
+
return [] if text.empty?
|
|
355
|
+
|
|
356
|
+
if identity_like?(text) && (row = entry(text))
|
|
357
|
+
return [row]
|
|
358
|
+
end
|
|
338
359
|
|
|
339
360
|
file, line = self.class.split_target(text)
|
|
340
|
-
return
|
|
361
|
+
return [] if file.empty?
|
|
341
362
|
|
|
342
363
|
matches = entries.select { |e| self.class.same_path?(e.file, file) }
|
|
343
364
|
matches = matches.select { |e| e.line == line } if line
|
|
344
|
-
|
|
365
|
+
matches
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Rows whose test no longer exists.
|
|
369
|
+
#
|
|
370
|
+
# A test's key is a content hash of its body, so editing a jailed test gives it a new
|
|
371
|
+
# identity and leaves the old row behind -- pointing at a file:line that may now hold
|
|
372
|
+
# something else entirely. That is content-hash identity working as designed, but over
|
|
373
|
+
# a few months the docket fills with tests nobody can find.
|
|
374
|
+
#
|
|
375
|
+
# `known` is every identity the loaded suite registered, so this is only meaningful
|
|
376
|
+
# after a full load. Two things are deliberately conservative about it:
|
|
377
|
+
#
|
|
378
|
+
# * A known identity is never stale, even when the file recorded beside it is gone.
|
|
379
|
+
# The path is a display label; the identity is the truth. A test that moved file
|
|
380
|
+
# has an out-of-date label, not a missing test.
|
|
381
|
+
# * A cold case is never pruned while its file exists. Cold-case tests cannot be
|
|
382
|
+
# enumerated without running their own engine, so absence from `known` says
|
|
383
|
+
# nothing about them.
|
|
384
|
+
def stale_entries(known, cold_case: nil)
|
|
385
|
+
known = Array(known)
|
|
386
|
+
entries.reject { |entry| known.include?(entry.identity) }
|
|
387
|
+
.select { |entry| gone?(entry, cold_case) }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def gone?(entry, cold_case)
|
|
391
|
+
relative = entry.file.to_s
|
|
392
|
+
return true if relative.empty?
|
|
393
|
+
|
|
394
|
+
path = File.absolute_path?(relative) ? relative : File.join(Constable.root, relative)
|
|
395
|
+
return true unless File.exist?(path)
|
|
396
|
+
return false if cold_case&.call(relative)
|
|
397
|
+
|
|
398
|
+
true
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# An identity String, or nil when nothing matches -- or when more than one does.
|
|
402
|
+
# Ambiguity is the caller's to report, with the candidates in hand.
|
|
403
|
+
def resolve(target)
|
|
404
|
+
matches = candidates(target)
|
|
405
|
+
return matches.first.identity if matches.size == 1
|
|
406
|
+
return nil unless matches.empty?
|
|
407
|
+
|
|
408
|
+
file, line = self.class.split_target(target.to_s.strip)
|
|
409
|
+
return nil if file.empty?
|
|
345
410
|
|
|
346
411
|
self.class.registry_identity(file, line)
|
|
347
412
|
end
|