constable-rails 0.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 +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Cold cases -- Tier 1 of the unsafe escape hatch, and the whole adoption story.
|
|
5
|
+
#
|
|
6
|
+
# Philosophy point 3: adoption never requires a rewrite. A cold case is an existing
|
|
7
|
+
# RSpec or Minitest file that keeps its original body *verbatim* and still reports
|
|
8
|
+
# into Constable's summary, flake history and CI gate alongside native cases. Two
|
|
9
|
+
# ways in, and both have to work:
|
|
10
|
+
#
|
|
11
|
+
# 1. One-line superclass swap -- the file's wrapper line changes, nothing else:
|
|
12
|
+
#
|
|
13
|
+
# class LegacyUsersSpec < Constable::ColdCase::RSpec
|
|
14
|
+
# describe UsersController do
|
|
15
|
+
# it "creates a user" do ... end
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# 2. Zero file changes -- a `cold_cases:` glob in .constable/config.yml matches the
|
|
20
|
+
# file and Constable wraps it without anyone touching it:
|
|
21
|
+
#
|
|
22
|
+
# cold_cases:
|
|
23
|
+
# - spec/controllers/**/*_spec.rb
|
|
24
|
+
#
|
|
25
|
+
# Nothing here reimplements RSpec or Minitest. The *real* engine runs the file
|
|
26
|
+
# in-process and we translate its own verdicts into Constable::Result objects. That
|
|
27
|
+
# matters for trust: a cold case has to behave exactly as it did before adoption, or
|
|
28
|
+
# "runs untouched from day one" is a lie.
|
|
29
|
+
#
|
|
30
|
+
# Cold cases are exempt from the native rules on purpose, and the exemptions are not
|
|
31
|
+
# oversights:
|
|
32
|
+
#
|
|
33
|
+
# * No shuffling. Native cases get a fresh random order every run because isolation
|
|
34
|
+
# is guaranteed for them. A legacy suite frequently is *not* isolated -- it may
|
|
35
|
+
# lean on before(:all), class-level state or declaration order -- so re-ordering it
|
|
36
|
+
# would manufacture failures that have nothing to do with the code under test.
|
|
37
|
+
# Cold cases keep whatever order their own engine chose.
|
|
38
|
+
# * No transactional wrapper, no state-leak check, no linter. Those enforce rules the
|
|
39
|
+
# file never agreed to.
|
|
40
|
+
#
|
|
41
|
+
# The price of the exemption is visibility (philosophy point 4). Every cold-case file
|
|
42
|
+
# emits exactly one warning, every run, until someone modernizes it -- one per FILE,
|
|
43
|
+
# not per test, because a hundred warnings for one legacy spec is noise, not news.
|
|
44
|
+
module ColdCase
|
|
45
|
+
ENGINES = %i[rspec minitest].freeze
|
|
46
|
+
|
|
47
|
+
# What to require, and which gem the user is missing if it isn't there.
|
|
48
|
+
ENGINE_REQUIRES = { rspec: "rspec/core", minitest: "minitest" }.freeze
|
|
49
|
+
ENGINE_GEMS = { rspec: "rspec-rails", minitest: "minitest" }.freeze
|
|
50
|
+
ENGINE_LABELS = { rspec: "RSpec", minitest: "Minitest" }.freeze
|
|
51
|
+
|
|
52
|
+
# The optional Gemfile group `constable:install` writes. It is optional precisely
|
|
53
|
+
# because these dependencies are temporary: delete the group once the last cold case
|
|
54
|
+
# is gone and both gems drop out of the app.
|
|
55
|
+
GEMFILE_GROUP = <<~RUBY
|
|
56
|
+
group :cold_case do
|
|
57
|
+
gem "rspec-rails"
|
|
58
|
+
gem "minitest"
|
|
59
|
+
end
|
|
60
|
+
RUBY
|
|
61
|
+
|
|
62
|
+
# Base classes a legacy Minitest file is likely to inherit from, used as a last-ditch
|
|
63
|
+
# content sniff when neither the filename nor an explicit ColdCase superclass says.
|
|
64
|
+
MINITEST_SUPERCLASSES = /<\s*(?:::)?(?:Minitest::|ActiveSupport::|ActionDispatch::|ActionController::)/
|
|
65
|
+
|
|
66
|
+
# Raised when a cold case needs an engine the app no longer has installed.
|
|
67
|
+
class EngineMissing < Constable::Error; end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# ---- public API the Runner calls -------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# Runs one cold-case file through its own engine and returns [Constable::Result].
|
|
73
|
+
# Every result has kind: :cold, so downstream code can tell at a glance that this
|
|
74
|
+
# test is not under native rules.
|
|
75
|
+
def run_file(path, config: Constable.config, seed: nil)
|
|
76
|
+
absolute = absolute_path(path, config: config)
|
|
77
|
+
engine = engine_for(absolute)
|
|
78
|
+
|
|
79
|
+
unless engine
|
|
80
|
+
raise Constable::Error,
|
|
81
|
+
"#{relative_path(absolute, config: config)} is registered as a cold case but " \
|
|
82
|
+
"Constable can't tell whether it is RSpec or Minitest. Name it *_spec.rb or " \
|
|
83
|
+
"*_test.rb, or give it a Constable::ColdCase::RSpec / " \
|
|
84
|
+
"Constable::ColdCase::Minitest superclass."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
adapter_for(engine).run_file(absolute, config: config, seed: seed)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Convenience for a whole batch. Files run one at a time and in the given order --
|
|
91
|
+
# engine globals are process-wide, so there is no safe way to interleave them.
|
|
92
|
+
def run_files(paths, config: Constable.config, seed: nil)
|
|
93
|
+
Array(paths).flat_map { |path| run_file(path, config: config, seed: seed) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every file the `cold_cases:` globs match -- the zero-file-change adoption path.
|
|
97
|
+
# Absolute, de-duplicated, sorted so a run's file order is stable.
|
|
98
|
+
def cold_case_files(config: Constable.config)
|
|
99
|
+
root = config.root.to_s
|
|
100
|
+
matched = config.cold_cases.flat_map do |glob|
|
|
101
|
+
pattern = File.absolute_path?(glob.to_s) ? glob.to_s : File.join(root, glob.to_s)
|
|
102
|
+
Dir.glob(pattern, File::FNM_EXTGLOB)
|
|
103
|
+
end
|
|
104
|
+
matched.select { |path| File.file?(path) }.uniq.sort
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# :rspec, :minitest, or nil when we genuinely cannot tell.
|
|
108
|
+
#
|
|
109
|
+
# An explicit Constable::ColdCase::* superclass is the strongest signal -- the user
|
|
110
|
+
# said so in the file. After that, filename convention, then a content sniff, then
|
|
111
|
+
# the directory the file lives in.
|
|
112
|
+
def engine_for(path)
|
|
113
|
+
path = path.to_s
|
|
114
|
+
source = head_of(path)
|
|
115
|
+
|
|
116
|
+
return :rspec if source&.match?(/<\s*(?:::)?Constable::ColdCase::RSpec\b/)
|
|
117
|
+
return :minitest if source&.match?(/<\s*(?:::)?Constable::ColdCase::Minitest\b/)
|
|
118
|
+
|
|
119
|
+
return :rspec if path.end_with?("_spec.rb")
|
|
120
|
+
return :minitest if path.end_with?("_test.rb")
|
|
121
|
+
|
|
122
|
+
if source
|
|
123
|
+
return :rspec if source.match?(/^\s*(?:RSpec\.)?(?:describe|feature|context)\b/)
|
|
124
|
+
return :minitest if source.match?(MINITEST_SUPERCLASSES) || source.match?(/^\s*def\s+test_/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
segments = path.split(File::SEPARATOR)
|
|
128
|
+
return :rspec if segments.include?("spec")
|
|
129
|
+
return :minitest if segments.include?("test")
|
|
130
|
+
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The adapter module that knows how to drive one engine. Accepts an engine symbol
|
|
135
|
+
# or a path. Requiring is lazy: an app with only RSpec cold cases never loads
|
|
136
|
+
# Minitest, and vice versa.
|
|
137
|
+
def adapter_for(engine_or_path)
|
|
138
|
+
engine = ENGINES.include?(engine_or_path) ? engine_or_path : engine_for(engine_or_path)
|
|
139
|
+
raise Constable::Error, "unknown cold-case engine: #{engine_or_path.inspect}" unless engine
|
|
140
|
+
|
|
141
|
+
load_adapter!(engine)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Drops both engines back to a clean slate. The adapters already restore global
|
|
145
|
+
# state around every file; this is the belt-and-braces version for a long-lived
|
|
146
|
+
# process (our own test suite, an editor plugin) that wants nothing left behind.
|
|
147
|
+
def reset_engines!
|
|
148
|
+
ENGINES.each do |engine|
|
|
149
|
+
adapter = adapter_module(engine)
|
|
150
|
+
adapter.reset_engine! if adapter.respond_to?(:reset_engine!)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ---- lazy engine loading ---------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# Requires an engine, or explains -- precisely, and with the fix -- why it can't.
|
|
157
|
+
# Constable itself must keep loading without either gem installed; that is the
|
|
158
|
+
# entire point of the optional :cold_case group.
|
|
159
|
+
def require_engine!(engine, path: nil)
|
|
160
|
+
require ENGINE_REQUIRES.fetch(engine)
|
|
161
|
+
true
|
|
162
|
+
rescue ::LoadError => e
|
|
163
|
+
raise EngineMissing, missing_engine_message(engine, error: e, path: path)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def missing_engine_message(engine, error: nil, path: nil)
|
|
167
|
+
label = ENGINE_LABELS.fetch(engine)
|
|
168
|
+
where = path ? " in #{relative_path(path)}" : ""
|
|
169
|
+
|
|
170
|
+
<<~MESSAGE.strip
|
|
171
|
+
Cold cases#{where} need #{label}, but `require "#{ENGINE_REQUIRES.fetch(engine)}"` failed#{" (#{error.message})" if error}.
|
|
172
|
+
|
|
173
|
+
#{label} (the `#{ENGINE_GEMS.fetch(engine)}` gem) is only needed while cold cases exist, so
|
|
174
|
+
`rails generate constable:install` puts it in an optional Gemfile group.
|
|
175
|
+
Add the group back and run `bundle install`:
|
|
176
|
+
|
|
177
|
+
#{GEMFILE_GROUP.chomp.gsub(/^/, " ")}
|
|
178
|
+
|
|
179
|
+
Delete the group once the suite is fully modernized -- both dependencies drop out
|
|
180
|
+
on their own, because nothing native depends on them.
|
|
181
|
+
MESSAGE
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# ---- shared helpers used by both adapters ----------------------------------------
|
|
185
|
+
|
|
186
|
+
# Exactly ONE warning per cold-case file, never per test. The count is the engine's
|
|
187
|
+
# own real example count, so the summary line reads the way SPEC.md shows it:
|
|
188
|
+
#
|
|
189
|
+
# running as a cold case (Constable::ColdCase::RSpec) -- 12 tests not yet under native rules
|
|
190
|
+
def warn_for_file(path, base_class_name, count, config: Constable.config)
|
|
191
|
+
location = relative_path(path, config: config)
|
|
192
|
+
return if warned?(location)
|
|
193
|
+
|
|
194
|
+
Constable.warn!(
|
|
195
|
+
"running as a cold case (#{base_class_name}) — " \
|
|
196
|
+
"#{count} #{count == 1 ? "test" : "tests"} not yet under native rules",
|
|
197
|
+
location: location,
|
|
198
|
+
kind: :cold_case
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def relative_path(path, config: nil)
|
|
203
|
+
root = (config&.root || Constable.root).to_s
|
|
204
|
+
path.to_s.delete_prefix("#{root}/")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def absolute_path(path, config: Constable.config)
|
|
208
|
+
path = path.to_s
|
|
209
|
+
File.absolute_path?(path) ? path : File.expand_path(path, config.root.to_s)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Cold-case classes announce themselves while their file loads, so a Result can be
|
|
213
|
+
# labelled `LegacyUsersSpec` rather than an anonymous example-group description.
|
|
214
|
+
def note_cold_class(klass)
|
|
215
|
+
@declared_classes&.push(klass)
|
|
216
|
+
klass
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def while_loading(path)
|
|
220
|
+
previous_file = @loading_file
|
|
221
|
+
previous_classes = @declared_classes
|
|
222
|
+
@loading_file = path
|
|
223
|
+
@declared_classes = []
|
|
224
|
+
yield
|
|
225
|
+
ensure
|
|
226
|
+
@loading_file = previous_file
|
|
227
|
+
@declared_classes = previous_classes
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The name of the ColdCase subclass declared by the file we just loaded, if any.
|
|
231
|
+
# Anonymous classes are ignored -- they have nothing useful to display.
|
|
232
|
+
def declared_class_name
|
|
233
|
+
@declared_classes&.map { |k| k.name if k.respond_to?(:name) }&.compact&.first
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
attr_reader :loading_file
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def warned?(location)
|
|
241
|
+
Constable.warnings.any? { |w| w[:kind] == :cold_case && w[:location] == location }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def load_adapter!(engine)
|
|
245
|
+
require_engine!(engine)
|
|
246
|
+
require "constable/cold_case/#{engine}"
|
|
247
|
+
adapter_module(engine)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def adapter_module(engine)
|
|
251
|
+
name = engine == :rspec ? :RSpecAdapter : :MinitestAdapter
|
|
252
|
+
const_defined?(name, false) ? const_get(name, false) : nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Reads just enough of a file to classify it. Cheap, and never blows up on a file
|
|
256
|
+
# that has been deleted between globbing and running.
|
|
257
|
+
def head_of(path, bytes: 8192)
|
|
258
|
+
return nil unless File.file?(path)
|
|
259
|
+
|
|
260
|
+
File.open(path, "rb") { |f| f.read(bytes) }&.force_encoding(Encoding::UTF_8)
|
|
261
|
+
rescue SystemCallError, IOError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Constable::ColdCase::RSpec and ::Minitest are the user-facing base classes, and
|
|
267
|
+
# referencing one is what triggers the lazy require of the engine behind it. Doing
|
|
268
|
+
# it here rather than with `autoload` lets us turn a bare LoadError into the
|
|
269
|
+
# actionable "add the :cold_case group back" message.
|
|
270
|
+
def self.const_missing(name)
|
|
271
|
+
case name
|
|
272
|
+
when :RSpec, :Minitest
|
|
273
|
+
engine = name == :RSpec ? :rspec : :minitest
|
|
274
|
+
load_adapter!(engine)
|
|
275
|
+
return const_get(name, false) if const_defined?(name, false)
|
|
276
|
+
end
|
|
277
|
+
super
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "etc"
|
|
5
|
+
|
|
6
|
+
module Constable
|
|
7
|
+
# Settings, as opposed to code. Everything here comes from .constable/config.yml
|
|
8
|
+
# and may be overridden per-run by CLI flags. Code-level configuration (matchers,
|
|
9
|
+
# tier base classes, one-time global setup) lives in test/case_helper.rb instead.
|
|
10
|
+
class Config
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"cold_cases" => [],
|
|
13
|
+
"storage" => { "adapter" => "sqlite", "path" => ".constable/constable.sqlite3", "url" => nil },
|
|
14
|
+
"warrants" => false,
|
|
15
|
+
"warrant_retries" => 5,
|
|
16
|
+
"auto_relink" => false,
|
|
17
|
+
"parole_period" => 10,
|
|
18
|
+
"coverage" => false,
|
|
19
|
+
"coverage_threshold" => 90,
|
|
20
|
+
"coverage_html" => false,
|
|
21
|
+
"fail_on_warnings" => false,
|
|
22
|
+
"parallel_workers" => "auto",
|
|
23
|
+
"tiers" => {
|
|
24
|
+
"unit" => "test/cases/models/**/*",
|
|
25
|
+
"integration" => "test/cases/controllers/**/*",
|
|
26
|
+
"system" => "test/cases/system/**/*"
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
CONFIG_PATH = ".constable/config.yml"
|
|
31
|
+
|
|
32
|
+
attr_reader :root, :raw
|
|
33
|
+
|
|
34
|
+
def self.load(root: Constable.root, overrides: {})
|
|
35
|
+
path = File.join(root.to_s, CONFIG_PATH)
|
|
36
|
+
raw = File.exist?(path) ? (YAML.safe_load_file(path, permitted_classes: [], aliases: true) || {}) : {}
|
|
37
|
+
new(raw, root: root, overrides: overrides)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(raw = {}, root: Constable.root, overrides: {})
|
|
41
|
+
@root = root.to_s
|
|
42
|
+
@raw = deep_merge(DEFAULTS, stringify(raw || {}))
|
|
43
|
+
@raw = deep_merge(@raw, stringify(overrides || {}))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cold_cases = Array(@raw["cold_cases"])
|
|
47
|
+
def warrants? = truthy(@raw["warrants"])
|
|
48
|
+
def warrant_retries = @raw["warrant_retries"].to_i
|
|
49
|
+
def auto_relink? = truthy(@raw["auto_relink"])
|
|
50
|
+
def parole_period = @raw["parole_period"].to_i
|
|
51
|
+
def coverage? = truthy(@raw["coverage"])
|
|
52
|
+
def coverage_threshold = @raw["coverage_threshold"].to_i
|
|
53
|
+
def coverage_html? = truthy(@raw["coverage_html"])
|
|
54
|
+
def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
|
|
55
|
+
def tiers = @raw["tiers"] || {}
|
|
56
|
+
def storage = @raw["storage"] || {}
|
|
57
|
+
|
|
58
|
+
def storage_adapter = (storage["adapter"] || "sqlite").to_s
|
|
59
|
+
def storage_url = storage["url"]
|
|
60
|
+
|
|
61
|
+
def storage_path
|
|
62
|
+
path = storage["path"] || DEFAULTS["storage"]["path"]
|
|
63
|
+
File.absolute_path?(path) ? path : File.join(@root, path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# "auto" resolves to the machine's processor count, minus a little headroom so a
|
|
67
|
+
# developer's laptop stays usable while the suite runs.
|
|
68
|
+
def parallel_workers
|
|
69
|
+
value = @raw["parallel_workers"]
|
|
70
|
+
return [value.to_i, 1].max unless value.nil? || value.to_s == "auto"
|
|
71
|
+
|
|
72
|
+
[Etc.nprocessors - 1, 1].max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Path-based tier inference. This is the *fallback* -- an explicit `tier :unit`
|
|
76
|
+
# macro or a tiered base class always wins.
|
|
77
|
+
def tier_for(path)
|
|
78
|
+
relative = path.to_s.delete_prefix("#{@root}/")
|
|
79
|
+
tiers.each do |tier, glob|
|
|
80
|
+
next unless glob
|
|
81
|
+
return tier.to_sym if File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
82
|
+
File.fnmatch?(glob.to_s, path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cold_case?(path)
|
|
88
|
+
relative = path.to_s.delete_prefix("#{@root}/")
|
|
89
|
+
cold_cases.any? do |glob|
|
|
90
|
+
File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
91
|
+
File.fnmatch?(glob.to_s, path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def [](key) = @raw[key.to_s]
|
|
96
|
+
def to_h = @raw.dup
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def truthy(value)
|
|
101
|
+
return false if value.nil? || value == false
|
|
102
|
+
return false if value.to_s.strip.downcase == "false"
|
|
103
|
+
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stringify(hash)
|
|
108
|
+
return hash unless hash.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v.is_a?(Hash) ? stringify(v) : v }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deep_merge(base, other)
|
|
114
|
+
base.merge(other) do |_key, old, new|
|
|
115
|
+
if old.is_a?(Hash) && new.is_a?(Hash)
|
|
116
|
+
deep_merge(old, new)
|
|
117
|
+
elsif new.nil?
|
|
118
|
+
old
|
|
119
|
+
else
|
|
120
|
+
new
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|