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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. 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