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,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Only ever reached through Constable::ColdCase, which requires the engine first and
|
|
4
|
+
# turns a missing gem into an actionable message. Never require this file directly.
|
|
5
|
+
require "minitest"
|
|
6
|
+
|
|
7
|
+
module Constable
|
|
8
|
+
module ColdCase
|
|
9
|
+
# The one-line superclass swap for a Minitest file:
|
|
10
|
+
#
|
|
11
|
+
# class UsersControllerTest < Constable::ColdCase::Minitest # was: < Minitest::Test
|
|
12
|
+
# def test_creates_a_user
|
|
13
|
+
# post users_path, params: valid_params
|
|
14
|
+
# assert_response :created
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# There is no DSL forwarding to do here, and that is the point: this really is a
|
|
19
|
+
# Minitest::Test subclass, so `def test_*`, `setup`, `teardown`, every assertion and
|
|
20
|
+
# every plugin behave identically to the day before the line changed. Constable only
|
|
21
|
+
# needs to know which classes belong to a cold-case file and to collect their results.
|
|
22
|
+
#
|
|
23
|
+
# It also covers the verbatim-wrapper form, where the original file (including its own
|
|
24
|
+
# `class FooTest < ActiveSupport::TestCase`) is nested inside this class -- nested
|
|
25
|
+
# classes register themselves with Minitest::Runnable exactly as they always did.
|
|
26
|
+
class Minitest < ::Minitest::Test
|
|
27
|
+
def self.cold_case_engine = :minitest
|
|
28
|
+
|
|
29
|
+
def self.inherited(subclass)
|
|
30
|
+
super
|
|
31
|
+
ColdCase.note_cold_class(subclass)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Drives Minitest over one file and translates its verdicts into Constable Results.
|
|
36
|
+
module MinitestAdapter
|
|
37
|
+
# Minitest hands every outcome to a reporter. We plug in one that keeps the
|
|
38
|
+
# Minitest::Result objects instead of printing them -- pass, fail, error and skip
|
|
39
|
+
# all arrive through #record, in the engine's own order.
|
|
40
|
+
class Collector < ::Minitest::AbstractReporter
|
|
41
|
+
attr_reader :results
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
super
|
|
45
|
+
@results = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record(result)
|
|
49
|
+
@results << result
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
def engine = :minitest
|
|
55
|
+
def base_class_name = "Constable::ColdCase::Minitest"
|
|
56
|
+
|
|
57
|
+
def run_file(path, config: Constable.config, seed: nil)
|
|
58
|
+
ColdCase.require_engine!(:minitest, path: path)
|
|
59
|
+
disable_autorun!
|
|
60
|
+
|
|
61
|
+
collector = Collector.new
|
|
62
|
+
load_error = nil
|
|
63
|
+
# Minitest's runnable registry is a single process-wide array that every
|
|
64
|
+
# Test subclass appends itself to at definition time. Snapshot it, let the file
|
|
65
|
+
# add whatever it adds, run only the difference, then put the snapshot back --
|
|
66
|
+
# otherwise a cold-case class would still be registered for the next file's run
|
|
67
|
+
# (and for anything else in this process that later asks Minitest to run).
|
|
68
|
+
registry = ::Minitest::Runnable.runnables
|
|
69
|
+
snapshot = registry.dup
|
|
70
|
+
|
|
71
|
+
ColdCase.while_loading(path) do
|
|
72
|
+
without_rspecs_global_dsl do
|
|
73
|
+
load_error = capture_load(path)
|
|
74
|
+
run_runnables(discover_runnables(registry, snapshot, path), collector, seed: seed) unless load_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
ColdCase.warn_for_file(path, base_class_name, collector.results.size, config: config)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
build_results(path, collector.results, config: config, seed: seed, load_error: load_error)
|
|
81
|
+
ensure
|
|
82
|
+
registry&.replace(snapshot) if snapshot
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Nothing to tear down: the registry is restored around every file and the
|
|
86
|
+
# autorun hook is deliberately left claimed. @known_runnables is intentionally
|
|
87
|
+
# kept -- it is a discovery index of classes we have already seen, not engine
|
|
88
|
+
# state, and forgetting it can only make discovery worse, never make something
|
|
89
|
+
# run that shouldn't (a class still has to own methods defined in the file).
|
|
90
|
+
def reset_engine!
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# `require "minitest/autorun"` installs an at_exit hook that runs every registered
|
|
97
|
+
# runnable and then calls `exit` with its own status. Inside a Constable run that
|
|
98
|
+
# would re-run the whole cold-case docket at process exit and clobber Constable's
|
|
99
|
+
# exit code. Minitest guards the hook with @@installed_at_exit, so claiming the
|
|
100
|
+
# flag before we load anything makes a legacy file's `require "minitest/autorun"`
|
|
101
|
+
# the no-op it needs to be. Deliberately not restored: a *later* cold-case file
|
|
102
|
+
# requiring autorun would install the very hook we are preventing.
|
|
103
|
+
def disable_autorun!
|
|
104
|
+
return unless ::Minitest.class_variable_defined?(:@@installed_at_exit)
|
|
105
|
+
|
|
106
|
+
::Minitest.class_variable_set(:@@installed_at_exit, true) # rubocop:disable Style/ClassVars
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def capture_load(path)
|
|
110
|
+
load path
|
|
111
|
+
nil
|
|
112
|
+
rescue ScriptError, StandardError => e
|
|
113
|
+
e
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Both engines want the bare word `describe`, and in a mixed suite both are
|
|
117
|
+
# loaded at once. Minitest puts its version on Kernel; RSpec puts its version
|
|
118
|
+
# directly on the top-level `main` object's singleton, which wins every lookup.
|
|
119
|
+
# A Minitest::Spec cold case loaded in that state would silently register RSpec
|
|
120
|
+
# example groups and report zero tests.
|
|
121
|
+
#
|
|
122
|
+
# So for the duration of a Minitest file -- and only then -- RSpec's top-level
|
|
123
|
+
# aliases are lifted off `main` and put back exactly as they were, visibility and
|
|
124
|
+
# all. rspec-core's own remove_globally! is not usable here: it uses undef_method,
|
|
125
|
+
# which blocks Kernel#describe as well and leaves neither engine reachable.
|
|
126
|
+
def without_rspecs_global_dsl
|
|
127
|
+
singleton = rspec_top_level_singleton
|
|
128
|
+
return yield unless singleton
|
|
129
|
+
|
|
130
|
+
owned = rspec_global_dsl_methods.select { |m| singleton.method_defined?(m, false) }
|
|
131
|
+
return yield if owned.empty?
|
|
132
|
+
|
|
133
|
+
saved = owned.to_h { |m| [m, singleton.instance_method(m)] }
|
|
134
|
+
owned.each { |m| singleton.send(:remove_method, m) }
|
|
135
|
+
begin
|
|
136
|
+
yield
|
|
137
|
+
ensure
|
|
138
|
+
saved.each { |m, unbound| singleton.send(:define_method, m, unbound) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rspec_top_level_singleton
|
|
143
|
+
return nil unless defined?(::RSpec::Core::DSL) && ::RSpec::Core::DSL.respond_to?(:top_level)
|
|
144
|
+
|
|
145
|
+
top = ::RSpec::Core::DSL.top_level
|
|
146
|
+
top&.singleton_class
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def rspec_global_dsl_methods
|
|
152
|
+
dsl = ::RSpec::Core::DSL
|
|
153
|
+
aliases = dsl.respond_to?(:example_group_aliases) ? dsl.example_group_aliases : []
|
|
154
|
+
Array(aliases).map(&:to_sym) + %i[shared_examples shared_examples_for shared_context]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Which runnable classes belong to the file we just loaded?
|
|
158
|
+
#
|
|
159
|
+
# The obvious answer -- whatever appeared in the registry during the load -- is
|
|
160
|
+
# right exactly once per class per process. `load` on a file whose class constant
|
|
161
|
+
# already exists *reopens* that class instead of creating one, so Runnable.inherited
|
|
162
|
+
# never fires a second time and the registry diff comes back empty. That happens
|
|
163
|
+
# whenever one process runs the same cold case twice (`jail run` after a normal
|
|
164
|
+
# run) and constantly in this repo's own suite.
|
|
165
|
+
#
|
|
166
|
+
# So the diff is the first source and a source-location scan is the second: any
|
|
167
|
+
# runnable class, seen now or on an earlier file, whose test methods were defined
|
|
168
|
+
# in this file. Newly registered classes win on a name collision, which is what
|
|
169
|
+
# keeps a Minitest::Spec `describe` (a brand new anonymous class every load) from
|
|
170
|
+
# running once for its current definition and again for its predecessor.
|
|
171
|
+
def discover_runnables(registry, snapshot, path)
|
|
172
|
+
@known_runnables ||= []
|
|
173
|
+
|
|
174
|
+
newly = (registry - snapshot).select { |klass| runnable_class?(klass) }
|
|
175
|
+
names = newly.map(&:to_s)
|
|
176
|
+
scanned = (registry | @known_runnables).select do |klass|
|
|
177
|
+
runnable_class?(klass) && !newly.include?(klass) && !names.include?(klass.to_s) &&
|
|
178
|
+
defines_methods_in?(klass, path)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
found = newly + scanned
|
|
182
|
+
@known_runnables |= found
|
|
183
|
+
found
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def runnable_class?(klass)
|
|
187
|
+
klass.respond_to?(:runnable_methods) && klass.respond_to?(:name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def defines_methods_in?(klass, path)
|
|
191
|
+
klass.runnable_methods.any? do |method_name|
|
|
192
|
+
location = klass.instance_method(method_name).source_location
|
|
193
|
+
location && File.expand_path(location.first) == path
|
|
194
|
+
end
|
|
195
|
+
rescue StandardError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def run_runnables(runnables, collector, seed: nil)
|
|
200
|
+
reporter = ::Minitest::CompositeReporter.new
|
|
201
|
+
reporter << collector
|
|
202
|
+
reporter.start
|
|
203
|
+
|
|
204
|
+
# Constable never re-orders a cold case, so nothing here touches Minitest's
|
|
205
|
+
# own ordering: classes run in declaration order and each class orders its
|
|
206
|
+
# own methods however `test_order` says it should.
|
|
207
|
+
with_minitest_seed(seed) do
|
|
208
|
+
runnables.each do |klass|
|
|
209
|
+
next unless klass.respond_to?(:runnable_methods)
|
|
210
|
+
|
|
211
|
+
run_suite(klass, reporter)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
reporter.report
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Minitest::Test#runnable_methods shuffles with `srand Minitest.seed`, and
|
|
219
|
+
# Minitest.seed is only ever set by Minitest.run -- which we deliberately never
|
|
220
|
+
# call. Left nil it raises a TypeError before a single test runs.
|
|
221
|
+
#
|
|
222
|
+
# Feeding it Constable's own seed is not Constable imposing an order: Minitest
|
|
223
|
+
# was always going to randomize, and this just supplies the number it would
|
|
224
|
+
# otherwise have made up, which is what makes `constable test PATH --seed N`
|
|
225
|
+
# replay a cold case exactly. Both the seed and the global RNG go back afterwards.
|
|
226
|
+
def with_minitest_seed(seed)
|
|
227
|
+
previous_seed = ::Minitest.seed
|
|
228
|
+
::Minitest.seed = (seed || previous_seed || (Random.new_seed % 0xFFFF)).to_i
|
|
229
|
+
previous_rand = srand(::Minitest.seed)
|
|
230
|
+
yield
|
|
231
|
+
ensure
|
|
232
|
+
::Minitest.seed = previous_seed
|
|
233
|
+
srand(previous_rand) if previous_rand
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# minitest 6 renamed the "run every method of this class" entry point from
|
|
237
|
+
# Runnable.run to Runnable.run_suite (Runnable.run now runs a single method).
|
|
238
|
+
def run_suite(klass, reporter)
|
|
239
|
+
if klass.respond_to?(:run_suite)
|
|
240
|
+
klass.run_suite(reporter, {})
|
|
241
|
+
else
|
|
242
|
+
klass.run(reporter, {})
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def build_results(path, minitest_results, config:, seed:, load_error:)
|
|
247
|
+
relative = ColdCase.relative_path(path, config: config)
|
|
248
|
+
class_name = ColdCase.declared_class_name
|
|
249
|
+
tier = config.tier_for(path)
|
|
250
|
+
|
|
251
|
+
results = minitest_results.map do |outcome|
|
|
252
|
+
result_for(outcome, path: path, relative: relative, class_name: class_name,
|
|
253
|
+
config: config, tier: tier, seed: seed)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if load_error
|
|
257
|
+
results << load_failure_result(path, relative, load_error, config: config, tier: tier,
|
|
258
|
+
seed: seed)
|
|
259
|
+
end
|
|
260
|
+
results
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def result_for(outcome, path:, relative:, class_name:, config:, tier:, seed:)
|
|
264
|
+
description = description_for(outcome)
|
|
265
|
+
file, line = location_of(outcome, relative, config: config)
|
|
266
|
+
|
|
267
|
+
result = Constable::Result.new(
|
|
268
|
+
identity: Constable::Identity.for_cold_case(path, description, root: config.root),
|
|
269
|
+
case_name: outcome.klass.to_s.empty? ? (class_name || relative) : outcome.klass.to_s,
|
|
270
|
+
description: description,
|
|
271
|
+
file: file,
|
|
272
|
+
line: line,
|
|
273
|
+
kind: :cold,
|
|
274
|
+
tier: tier,
|
|
275
|
+
status: status_for(outcome),
|
|
276
|
+
duration: outcome.time.to_f,
|
|
277
|
+
failure: failure_for(outcome)
|
|
278
|
+
)
|
|
279
|
+
result.seed = seed
|
|
280
|
+
result
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Minitest::Spec turns `it "creates a user"` into the method name
|
|
284
|
+
# test_0001_creates a user. The ordinal is positional -- adding an example above
|
|
285
|
+
# renumbers everything below it -- so stripping it is what keeps a cold case's
|
|
286
|
+
# flake history attached to the right test across an ordinary edit.
|
|
287
|
+
def description_for(outcome)
|
|
288
|
+
outcome.name.to_s.sub(/\Atest_\d{4}_/, "")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def status_for(outcome)
|
|
292
|
+
return :skipped if outcome.skipped?
|
|
293
|
+
return :passed if outcome.failures.empty?
|
|
294
|
+
return :errored if outcome.failures.any?(::Minitest::UnexpectedError)
|
|
295
|
+
|
|
296
|
+
:failed
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def failure_for(outcome)
|
|
300
|
+
return nil if outcome.failures.empty? || outcome.skipped?
|
|
301
|
+
|
|
302
|
+
failure = outcome.failures.first
|
|
303
|
+
# UnexpectedError is a wrapper Minitest puts around a raised exception; the
|
|
304
|
+
# real one underneath is what a developer needs to see.
|
|
305
|
+
failure = failure.error if failure.is_a?(::Minitest::UnexpectedError) && failure.respond_to?(:error)
|
|
306
|
+
|
|
307
|
+
message = outcome.failures.size > 1 ? outcome.failures.map(&:message).join("\n\n") : failure.message
|
|
308
|
+
Constable::Failure.new(
|
|
309
|
+
message: message.to_s,
|
|
310
|
+
backtrace: Constable::Backtrace.clean(failure.backtrace),
|
|
311
|
+
exception_class: failure.class.name
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def load_failure_result(path, relative, error, config:, tier:, seed:)
|
|
316
|
+
description = "failed to load"
|
|
317
|
+
result = Constable::Result.new(
|
|
318
|
+
identity: Constable::Identity.for_cold_case(path, description, root: config.root),
|
|
319
|
+
case_name: relative,
|
|
320
|
+
description: description,
|
|
321
|
+
file: relative,
|
|
322
|
+
line: 1,
|
|
323
|
+
kind: :cold,
|
|
324
|
+
tier: tier,
|
|
325
|
+
status: :errored,
|
|
326
|
+
duration: 0.0,
|
|
327
|
+
failure: Constable::Failure.from_exception(error)
|
|
328
|
+
)
|
|
329
|
+
result.seed = seed
|
|
330
|
+
result
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def location_of(outcome, fallback_relative, config:)
|
|
334
|
+
file, line = outcome.source_location if outcome.respond_to?(:source_location)
|
|
335
|
+
return [fallback_relative, 1] if file.nil? || file.to_s == "unknown"
|
|
336
|
+
|
|
337
|
+
[ColdCase.relative_path(file, config: config), (line || 1).to_i]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Only ever reached through Constable::ColdCase, which requires the engine first and
|
|
4
|
+
# turns a missing gem into an actionable message. Never require this file directly.
|
|
5
|
+
require "rspec/core"
|
|
6
|
+
require "stringio"
|
|
7
|
+
|
|
8
|
+
module Constable
|
|
9
|
+
module ColdCase
|
|
10
|
+
# The one-line superclass swap. The file below keeps its original body character for
|
|
11
|
+
# character; only the wrapper changed:
|
|
12
|
+
#
|
|
13
|
+
# class LegacyUsersSpec < Constable::ColdCase::RSpec
|
|
14
|
+
# describe UsersController do
|
|
15
|
+
# it "creates a user" do
|
|
16
|
+
# post users_path, params: valid_params
|
|
17
|
+
# expect(response).to have_http_status(:created)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# A Ruby class body executes with `self` set to the class, so the bare `describe`,
|
|
23
|
+
# `context`, `it`, `let` and `before` calls in that body land on *this* class's
|
|
24
|
+
# singleton. All we do is forward them into the real RSpec engine:
|
|
25
|
+
#
|
|
26
|
+
# * group-creating calls (describe/context/shared_examples/feature) go to the
|
|
27
|
+
# top level, so descriptions read "UsersController creates a user" and not
|
|
28
|
+
# "LegacyUsersSpec UsersController creates a user" -- the file reports exactly
|
|
29
|
+
# as it did before adoption;
|
|
30
|
+
# * anything else (it/let/before/after/subject/around/...) goes to an implicit
|
|
31
|
+
# top-level group named after the class, so a file with no `describe` at all
|
|
32
|
+
# still runs.
|
|
33
|
+
#
|
|
34
|
+
# No RSpec behaviour is reimplemented here. Every one of these calls ends up in
|
|
35
|
+
# RSpec::Core, which is the only way a cold case can be trusted to behave the way it
|
|
36
|
+
# did the day before someone changed one line.
|
|
37
|
+
class RSpec
|
|
38
|
+
# Calls that create an example group of their own.
|
|
39
|
+
GROUP_METHODS = %i[
|
|
40
|
+
describe context xdescribe xcontext fdescribe fcontext
|
|
41
|
+
example_group feature xfeature ffeature
|
|
42
|
+
shared_examples shared_examples_for shared_context
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def cold_case_engine = :rspec
|
|
47
|
+
|
|
48
|
+
def inherited(subclass)
|
|
49
|
+
super
|
|
50
|
+
ColdCase.note_cold_class(subclass)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
GROUP_METHODS.each do |method_name|
|
|
54
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
55
|
+
ColdCase.require_engine!(:rspec)
|
|
56
|
+
target = ::RSpec.respond_to?(method_name) ? ::RSpec : cold_case_group
|
|
57
|
+
target.public_send(method_name, *args, **kwargs, &block)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `it`, `let`, `before`, `subject`, `around`, `pending`, custom aliases a user
|
|
62
|
+
# registered with RSpec.configure -- all of them, without us having to keep a
|
|
63
|
+
# list in sync with rspec-core's.
|
|
64
|
+
def method_missing(method_name, ...)
|
|
65
|
+
ColdCase.require_engine!(:rspec)
|
|
66
|
+
return super unless ::RSpec::Core::ExampleGroup.respond_to?(method_name)
|
|
67
|
+
|
|
68
|
+
cold_case_group.public_send(method_name, ...)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
72
|
+
(defined?(::RSpec::Core::ExampleGroup) &&
|
|
73
|
+
::RSpec::Core::ExampleGroup.respond_to?(method_name)) || super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The implicit group, created on first use only. Memoized against the RSpec world
|
|
77
|
+
# it was created in: the adapter installs a fresh world per file, and a class body
|
|
78
|
+
# re-executed by a second `load` must not append examples to a group that belongs
|
|
79
|
+
# to a world nobody is running any more.
|
|
80
|
+
def cold_case_group
|
|
81
|
+
world = ::RSpec.world
|
|
82
|
+
return @cold_case_group if @cold_case_group && @cold_case_group_world.equal?(world)
|
|
83
|
+
|
|
84
|
+
@cold_case_group_world = world
|
|
85
|
+
@cold_case_group = ::RSpec.describe(cold_case_description)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cold_case_description
|
|
89
|
+
name || "cold case"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Drives rspec-core over one file and translates its verdicts into Constable Results.
|
|
95
|
+
module RSpecAdapter
|
|
96
|
+
# A listener rather than a formatter: we want the examples themselves, not text.
|
|
97
|
+
# :example_finished fires once for every example whatever its outcome, so passes,
|
|
98
|
+
# failures and pendings all arrive through one hook in the engine's own order.
|
|
99
|
+
class Collector
|
|
100
|
+
attr_reader :examples
|
|
101
|
+
|
|
102
|
+
def initialize
|
|
103
|
+
@examples = []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def example_finished(notification)
|
|
107
|
+
@examples << notification.example
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class << self
|
|
112
|
+
def engine = :rspec
|
|
113
|
+
def base_class_name = "Constable::ColdCase::RSpec"
|
|
114
|
+
|
|
115
|
+
def run_file(path, config: Constable.config, seed: nil)
|
|
116
|
+
ColdCase.require_engine!(:rspec, path: path)
|
|
117
|
+
|
|
118
|
+
results = []
|
|
119
|
+
ColdCase.while_loading(path) do
|
|
120
|
+
with_engine do
|
|
121
|
+
collector = Collector.new
|
|
122
|
+
load_error = capture_load(path)
|
|
123
|
+
run_world(collector) unless load_error
|
|
124
|
+
|
|
125
|
+
ColdCase.warn_for_file(path, base_class_name, collector.examples.size, config: config)
|
|
126
|
+
results = build_results(path, collector.examples, config: config, seed: seed,
|
|
127
|
+
load_error: load_error)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
results
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Forgets the cold-case session entirely. The next run_file builds a fresh
|
|
134
|
+
# configuration -- which also means any RSpec.configure hooks a rails_helper
|
|
135
|
+
# installed are gone, so this is a teardown call, not a between-files call.
|
|
136
|
+
def reset_engine!
|
|
137
|
+
@session_world = nil
|
|
138
|
+
@session_configuration = nil
|
|
139
|
+
@session_prepared = false
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Running RSpec in-process is a global-state problem: RSpec.world holds every
|
|
146
|
+
# registered example group and RSpec.configuration holds every hook. We swap in a
|
|
147
|
+
# session world/configuration for the duration of a file and put whatever was
|
|
148
|
+
# there back afterwards, so a host process that has its own RSpec state (or, more
|
|
149
|
+
# often, our own Minitest suite, which has none) is left exactly as we found it.
|
|
150
|
+
#
|
|
151
|
+
# The configuration is deliberately kept ALIVE between files while the world is
|
|
152
|
+
# cleared between them. A legacy spec's `require "rails_helper"` only executes
|
|
153
|
+
# once per process; if we threw the configuration away after each file, every hook
|
|
154
|
+
# and inclusion that rails_helper registered would vanish for file number two.
|
|
155
|
+
def with_engine
|
|
156
|
+
outer_world = ::RSpec.instance_variable_get(:@world)
|
|
157
|
+
outer_config = ::RSpec.instance_variable_get(:@configuration)
|
|
158
|
+
|
|
159
|
+
::RSpec.instance_variable_set(:@world, @session_world)
|
|
160
|
+
::RSpec.instance_variable_set(:@configuration, @session_configuration)
|
|
161
|
+
prepare_session_configuration(::RSpec.configuration)
|
|
162
|
+
clear_examples
|
|
163
|
+
|
|
164
|
+
yield
|
|
165
|
+
ensure
|
|
166
|
+
@session_world = ::RSpec.instance_variable_get(:@world)
|
|
167
|
+
@session_configuration = ::RSpec.instance_variable_get(:@configuration)
|
|
168
|
+
clear_examples
|
|
169
|
+
# Only safe to drop the generated group constants when nobody else owned an
|
|
170
|
+
# RSpec world before us; otherwise they may still be naming someone's groups.
|
|
171
|
+
::RSpec::ExampleGroups.remove_all_constants if outer_world.nil?
|
|
172
|
+
::RSpec.instance_variable_set(:@world, outer_world)
|
|
173
|
+
::RSpec.instance_variable_set(:@configuration, outer_config)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# stdout belongs to Constable's reporter alone (SPEC.md: "stdout is results
|
|
177
|
+
# only"), so the engine's own progress dots and failure dumps go to a buffer we
|
|
178
|
+
# throw away. Set once, so a spec file's own RSpec.configure can still override.
|
|
179
|
+
def prepare_session_configuration(configuration)
|
|
180
|
+
return if @session_prepared
|
|
181
|
+
|
|
182
|
+
configuration.output_stream = StringIO.new
|
|
183
|
+
configuration.deprecation_stream = StringIO.new
|
|
184
|
+
configuration.error_stream = StringIO.new if configuration.respond_to?(:error_stream=)
|
|
185
|
+
configuration.color_mode = :off if configuration.respond_to?(:color_mode=)
|
|
186
|
+
# rspec-core's own default; restated because rspec-rails turns it off and a
|
|
187
|
+
# verbatim legacy file may well open with a bare `describe`.
|
|
188
|
+
configuration.expose_dsl_globally = true if configuration.respond_to?(:expose_dsl_globally=)
|
|
189
|
+
@session_prepared = true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def clear_examples
|
|
193
|
+
world = ::RSpec.instance_variable_get(:@world)
|
|
194
|
+
world.reset if world.respond_to?(:reset)
|
|
195
|
+
|
|
196
|
+
configuration = ::RSpec.instance_variable_get(:@configuration)
|
|
197
|
+
return unless configuration
|
|
198
|
+
|
|
199
|
+
configuration.reset_reporter if configuration.respond_to?(:reset_reporter)
|
|
200
|
+
configuration.reset_filters if configuration.respond_to?(:reset_filters)
|
|
201
|
+
configuration.start_time = ::RSpec::Core::Time.now if configuration.respond_to?(:start_time=)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A file that won't even parse is news, not a crash. Report it as one errored
|
|
205
|
+
# result so the run keeps going and the summary names the file.
|
|
206
|
+
def capture_load(path)
|
|
207
|
+
load path
|
|
208
|
+
nil
|
|
209
|
+
rescue ScriptError, StandardError => e
|
|
210
|
+
e
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def run_world(collector)
|
|
214
|
+
configuration = ::RSpec.configuration
|
|
215
|
+
world = ::RSpec.world
|
|
216
|
+
reporter = configuration.reporter
|
|
217
|
+
reporter.register_listener(collector, :example_finished)
|
|
218
|
+
|
|
219
|
+
# ordered_example_groups applies RSpec's *own* ordering, which defaults to
|
|
220
|
+
# declaration order. Constable never shuffles a cold case: random order is a
|
|
221
|
+
# native-case guarantee, and imposing it on a suite that was never isolated
|
|
222
|
+
# would invent failures.
|
|
223
|
+
reporter.report(world.example_count) do |rep|
|
|
224
|
+
world.ordered_example_groups.each { |group| group.run(rep) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_results(path, examples, config:, seed:, load_error:)
|
|
229
|
+
relative = ColdCase.relative_path(path, config: config)
|
|
230
|
+
class_name = ColdCase.declared_class_name
|
|
231
|
+
tier = config.tier_for(path)
|
|
232
|
+
|
|
233
|
+
results = examples.map do |example|
|
|
234
|
+
result_for(example, path: path, relative: relative, class_name: class_name,
|
|
235
|
+
config: config, tier: tier, seed: seed)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if load_error
|
|
239
|
+
results << load_failure_result(path, relative, load_error, config: config, tier: tier,
|
|
240
|
+
seed: seed)
|
|
241
|
+
end
|
|
242
|
+
results
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def result_for(example, path:, relative:, class_name:, config:, tier:, seed:)
|
|
246
|
+
execution = example.execution_result
|
|
247
|
+
description = example.full_description.to_s
|
|
248
|
+
file, line = location_of(example, relative, config: config)
|
|
249
|
+
|
|
250
|
+
result = Constable::Result.new(
|
|
251
|
+
identity: Constable::Identity.for_cold_case(path, description, root: config.root),
|
|
252
|
+
case_name: class_name || top_group_description(example) || relative,
|
|
253
|
+
description: description,
|
|
254
|
+
file: file,
|
|
255
|
+
line: line,
|
|
256
|
+
kind: :cold,
|
|
257
|
+
tier: tier,
|
|
258
|
+
status: status_for(execution),
|
|
259
|
+
duration: execution.run_time.to_f,
|
|
260
|
+
failure: failure_for(execution)
|
|
261
|
+
)
|
|
262
|
+
result.seed = seed
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def status_for(execution)
|
|
267
|
+
case execution.status
|
|
268
|
+
when :passed then :passed
|
|
269
|
+
when :pending then :skipped
|
|
270
|
+
else
|
|
271
|
+
expectation_failure?(execution.exception) ? :failed : :errored
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def expectation_failure?(exception)
|
|
276
|
+
return false unless exception
|
|
277
|
+
return true if defined?(::RSpec::Expectations::ExpectationNotMetError) &&
|
|
278
|
+
exception.is_a?(::RSpec::Expectations::ExpectationNotMetError)
|
|
279
|
+
return true if defined?(::RSpec::Expectations::MultipleExpectationsNotMetError) &&
|
|
280
|
+
exception.is_a?(::RSpec::Expectations::MultipleExpectationsNotMetError)
|
|
281
|
+
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def failure_for(execution)
|
|
286
|
+
return nil if execution.status == :passed
|
|
287
|
+
|
|
288
|
+
exception = execution.exception
|
|
289
|
+
return nil unless exception
|
|
290
|
+
|
|
291
|
+
Constable::Failure.from_exception(exception)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def load_failure_result(path, relative, error, config:, tier:, seed:)
|
|
295
|
+
description = "failed to load"
|
|
296
|
+
result = Constable::Result.new(
|
|
297
|
+
identity: Constable::Identity.for_cold_case(path, description, root: config.root),
|
|
298
|
+
case_name: relative,
|
|
299
|
+
description: description,
|
|
300
|
+
file: relative,
|
|
301
|
+
line: 1,
|
|
302
|
+
kind: :cold,
|
|
303
|
+
tier: tier,
|
|
304
|
+
status: :errored,
|
|
305
|
+
duration: 0.0,
|
|
306
|
+
failure: Constable::Failure.from_exception(error)
|
|
307
|
+
)
|
|
308
|
+
result.seed = seed
|
|
309
|
+
result
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# RSpec knows exactly which line the `it` block sits on -- that is the line a
|
|
313
|
+
# developer wants in the summary, not a frame from inside the engine.
|
|
314
|
+
def location_of(example, fallback_relative, config:)
|
|
315
|
+
metadata = example.metadata || {}
|
|
316
|
+
file = metadata[:absolute_file_path] || metadata[:file_path]
|
|
317
|
+
line = metadata[:line_number]
|
|
318
|
+
return [fallback_relative, 1] unless file
|
|
319
|
+
|
|
320
|
+
[ColdCase.relative_path(file, config: config), (line || 1).to_i]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def top_group_description(example)
|
|
324
|
+
group = example.example_group
|
|
325
|
+
return nil unless group.respond_to?(:parent_groups)
|
|
326
|
+
|
|
327
|
+
outermost = group.parent_groups.last || group
|
|
328
|
+
description = outermost.description.to_s
|
|
329
|
+
description.empty? ? nil : description
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|