branchproof 0.9.0 → 0.10.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.
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/version"
4
+
5
+ # Rails policy validation intentionally stays in one adapter-neutral module.
6
+ # rubocop:disable Metrics/ModuleLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
7
+
3
8
  module Branchproof
4
9
  # Boots the selected Rails application after Branchproof's load hook is active.
5
10
  module RailsSupport
11
+ SUPPORTED_RAILS = Gem::Version.new("8.1").freeze
12
+ SUPPORTED_RSPEC_RAILS_MAJOR = 8
13
+ SUPPORTED_CAPYBARA_DRIVER = "rack_test"
14
+
6
15
  class Error < StandardError; end
7
16
 
8
17
  module_function
9
18
 
19
+ # Boot the Rails application for the native Minitest adapter.
10
20
  def boot(project:)
11
21
  environment = environment_path(project)
12
22
  raise Error, "Rails project is missing #{environment}" unless File.file?(environment)
@@ -26,6 +36,9 @@ module Branchproof
26
36
  File.join(root, "config", "environment.rb")
27
37
  end
28
38
 
39
+ # Require the application and validate the process-wide Rails policy.
40
+ # This method is public so adapter-specific helpers can own the remaining
41
+ # test framework setup while sharing the exact same Rails boot checks.
29
42
  def boot_environment(environment)
30
43
  require environment
31
44
  validate_application!
@@ -43,6 +56,90 @@ module Branchproof
43
56
  nil
44
57
  end
45
58
 
59
+ # Validate the seam after an RSpec Rails helper has loaded. RSpec owns
60
+ # requiring rspec/rails and configuring its example groups; this method
61
+ # only checks that that setup is attached to the same, already-validated
62
+ # Rails application.
63
+ def validate_rspec!(project:)
64
+ # A focused spec_helper-only run may live in a Rails project without
65
+ # loading Rails. Once Rails is loaded, all boot checks still apply.
66
+ return nil unless defined?(Rails)
67
+
68
+ kind = project[:kind] || project["kind"]
69
+ raise Error, "Rails was loaded for a non-Rails project" if kind.to_s != "rails"
70
+
71
+ validate_application!
72
+ raise Error, "RSpec Rails was not loaded; require rspec/rails from rails_helper" unless defined?(RSpec::Rails)
73
+
74
+ expected_root = File.expand_path(project.fetch(:root).to_s)
75
+ actual_root = Rails.root && File.expand_path(Rails.root.to_s)
76
+ if actual_root != expected_root
77
+ raise Error,
78
+ "Rails application root #{actual_root.inspect} does not match project root #{expected_root.inspect}"
79
+ end
80
+
81
+ rails_metadata = metadata.merge(rspec_rails_version: rspec_rails_version)
82
+ validate_version_tuple!(rails_metadata)
83
+ install_rspec_driver_guard!
84
+ rails_metadata
85
+ end
86
+
87
+ def install_rspec_driver_guard!
88
+ return unless defined?(Capybara::Session)
89
+ return if Capybara::Session.instance_variable_defined?(:@branchproof_driver_guard)
90
+
91
+ support = self
92
+ Capybara::Session.prepend(Module.new do
93
+ define_method(:initialize) do |driver, *args, &block|
94
+ begin
95
+ support.validate_rspec_driver!(driver: driver)
96
+ rescue Branchproof::RailsSupport::Error => e
97
+ adapter = (Branchproof::RSpecAdapter.active_adapter if defined?(Branchproof::RSpecAdapter))
98
+ adapter ? adapter.reject_execution!(e.message) : raise
99
+ end
100
+ super(driver, *args, &block)
101
+ end
102
+ end)
103
+ Capybara::Session.instance_variable_set(:@branchproof_driver_guard, true)
104
+ end
105
+
106
+ # RSpec Rails invokes this at example execution time, before the example
107
+ # can ask Capybara to launch a browser. The adapter converts this explicit
108
+ # rejection into an incomplete/error execution result.
109
+ def validate_rspec_driver!(example_metadata)
110
+ metadata = example_metadata.respond_to?(:to_h) ? example_metadata.to_h : {}
111
+ driver = metadata[:driver] || metadata["driver"]
112
+ threaded = metadata[:threaded] || metadata["threaded"] || metadata[:parallel] || metadata["parallel"]
113
+ unsupported = metadata[:js] == true || metadata["js"] == true || threaded ||
114
+ (driver && driver.to_s != SUPPORTED_CAPYBARA_DRIVER)
115
+ return true unless unsupported
116
+
117
+ selected = driver || (threaded ? "threaded" : "browser")
118
+ raise Error, "unsupported RSpec driver #{selected.inspect}; only in-process rack_test is supported"
119
+ end
120
+
121
+ def validate_version_tuple!(rails_metadata)
122
+ rails_version = Gem::Version.new(rails_metadata.fetch(:rails_version).to_s)
123
+ ruby_version = Gem::Version.new(rails_metadata.fetch(:ruby_version).to_s)
124
+ rspec_version = Gem::Version.new(rails_metadata.fetch(:rspec_rails_version).to_s)
125
+ return true if rails_version.segments.first(2) == SUPPORTED_RAILS.segments.first(2) &&
126
+ ruby_version.segments.first(2) == [3, 4] &&
127
+ rspec_version.segments.first == SUPPORTED_RSPEC_RAILS_MAJOR
128
+
129
+ raise Error,
130
+ "unsupported Rails/Ruby/RSpec Rails tuple: Rails #{rails_version}, Ruby #{ruby_version}, " \
131
+ "RSpec Rails #{rspec_version} (supported Rails 8.1, Ruby 3.4.x, RSpec Rails 8.x)"
132
+ end
133
+
134
+ def rspec_rails_version
135
+ require "rspec/rails/version" if defined?(RSpec::Rails) && !defined?(RSpec::Rails::Version::STRING)
136
+ return RSpec::Rails::Version::STRING if defined?(RSpec::Rails::Version::STRING)
137
+ return RSpec::Rails::VERSION if defined?(RSpec::Rails::VERSION)
138
+ return Gem.loaded_specs.fetch("rspec-rails").version.to_s if Gem.loaded_specs.key?("rspec-rails")
139
+
140
+ "unknown"
141
+ end
142
+
46
143
  def rails_application
47
144
  if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
48
145
  application = Rails.application
@@ -68,7 +165,11 @@ module Branchproof
68
165
 
69
166
  def metadata
70
167
  { rails_version: Rails::VERSION::STRING,
168
+ ruby_version: RUBY_VERSION,
169
+ environment: Rails.env.to_s,
170
+ reloading: reloading_enabled?(rails_application.config),
71
171
  serial_policy: { mode: "single_process", workers: 1, parallel_workers: 1 } }
72
172
  end
73
173
  end
74
174
  end
175
+ # rubocop:enable Metrics/ModuleLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
@@ -118,6 +118,16 @@ module Branchproof
118
118
  0
119
119
  end
120
120
 
121
+ def rerun_command(test_id)
122
+ test = test_records_by_id[test_id.to_s]
123
+ return unless test && value(test, :adapter).to_s == "rspec"
124
+
125
+ selector = value(test, :example_id).to_s
126
+ return if selector.empty?
127
+
128
+ "bundle exec rspec '#{selector.gsub("'", %q('"'"'))}'"
129
+ end
130
+
121
131
  private
122
132
 
123
133
  def json_document
@@ -507,10 +517,17 @@ module Branchproof
507
517
  end
508
518
 
509
519
  def baseline_test_counts
520
+ noun = rspec_run? ? "examples" : "tests"
510
521
  executed = value(@baseline, :executed_tests)
511
522
  failed = value(@baseline, :failed_tests) || value(@baseline, :failures) || 0
512
523
  skipped = value(@baseline, :skipped_tests) || value(@baseline, :skips) || 0
513
- "#{executed || 0} tests, #{failed} failed, #{skipped} skipped"
524
+ "#{executed || 0} #{noun}, #{failed} failed, #{skipped} skipped"
525
+ end
526
+
527
+ def rspec_run?
528
+ framework = value(@run_metadata, :framework) || value(@run_metadata, :project_framework) ||
529
+ value(@baseline, :framework)
530
+ framework.to_s.downcase == "rspec"
514
531
  end
515
532
 
516
533
  def terminal_coverage_label
@@ -584,6 +601,9 @@ module Branchproof
584
601
  end
585
602
  return short_id(id) if name.empty? || name == id
586
603
 
604
+ command = rerun_command(id)
605
+ return "#{name} (rerun: #{command})" if command
606
+
587
607
  duplicates = test_records_by_name[name]
588
608
  return name if duplicates.length == 1
589
609
 
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records" unless defined?(Branchproof::Records)
4
+
5
+ module Branchproof
6
+ # Bridges one serial RSpec run to Runtime lifecycle ownership.
7
+ class RSpecAdapter
8
+ # Identifies runner capabilities that cannot produce supported evidence.
9
+ class UnsupportedRunner < ArgumentError
10
+ def diagnostic_code = "unsupported_runner"
11
+ end
12
+
13
+ # Restores CLI directory discovery when RSpec runs inside the worker.
14
+ module DefaultDiscovery
15
+ def configure(configuration)
16
+ options[:files_or_directories_to_run] = [options[:default_path] || configuration.default_path].compact
17
+ super
18
+ end
19
+ end
20
+
21
+ # Rejects additional class-level runner entry points.
22
+ module ClassRunnerGuard
23
+ def run(*args, &)
24
+ RSpecAdapter.runner_adapter&.reject_execution!("nested or repeated RSpec runs are unsupported")
25
+ super
26
+ end
27
+ end
28
+
29
+ # Allows only the worker-owned native runner instance.
30
+ module RunnerGuard
31
+ def run(*args, &)
32
+ RSpecAdapter.runner_adapter&.enter_runner!(self)
33
+ super
34
+ end
35
+ end
36
+
37
+ # Assigns observation phases while the reporter owns test registration.
38
+ module ExampleLifecycle
39
+ def run(*args, &)
40
+ adapter = RSpecAdapter.active_adapter
41
+ return super unless adapter
42
+
43
+ adapter.send(:begin_example, self)
44
+ super
45
+ ensure
46
+ adapter&.send(:unattributed)
47
+ end
48
+
49
+ private
50
+
51
+ def run_before_example(*args, &)
52
+ adapter = RSpecAdapter.active_adapter
53
+ adapter&.send(:begin_attempt, self)
54
+ result = super
55
+ adapter&.send(:phase, self, "body")
56
+ result
57
+ end
58
+
59
+ def run_after_example(*args, &)
60
+ RSpecAdapter.active_adapter&.send(:phase, self, "teardown")
61
+ super
62
+ end
63
+ end
64
+
65
+ # Keeps group-level hooks outside individual example ownership.
66
+ module ContextLifecycle
67
+ def run_before_context_hooks(*args, &)
68
+ adapter = RSpecAdapter.active_adapter
69
+ return super unless adapter
70
+
71
+ adapter.send(:without_owner) { super }
72
+ end
73
+
74
+ def run_after_context_hooks(*args, &)
75
+ adapter = RSpecAdapter.active_adapter
76
+ return super unless adapter
77
+
78
+ adapter.send(:without_owner) { super }
79
+ end
80
+ end
81
+
82
+ class << self
83
+ attr_accessor :active_adapter, :runner_adapter
84
+ end
85
+
86
+ FORBIDDEN_OPTIONS = {
87
+ dry_run: "dry-run",
88
+ bisect: "bisect",
89
+ drb: "DRb",
90
+ runner: "custom runner"
91
+ }.freeze
92
+
93
+ attr_reader :tests, :late_execution_error
94
+
95
+ def initialize(runtime:)
96
+ @runtime = runtime
97
+ @tests = {}
98
+ @example_records = {}.compare_by_identity
99
+ @file_digests = {}
100
+ @running = false
101
+ @completed = false
102
+ @completion_started = false
103
+ @registered = false
104
+ @current_context = nil
105
+ end
106
+
107
+ # rubocop:disable-next Metrics/ParameterLists
108
+ def run(test_files:, runner_args:, on_complete:, before_load: nil, after_load: nil,
109
+ test_selection_explicit: false)
110
+ raise ArgumentError, "test_files must be an Array" unless test_files.is_a?(Array)
111
+ raise ArgumentError, "runner_args must be an Array" unless runner_args.is_a?(Array)
112
+ raise ArgumentError, "on_complete must respond to call" unless on_complete.respond_to?(:call)
113
+ raise UnsupportedRunner, "RSpec adapter cannot run more than once" if @running || @registered
114
+ raise UnsupportedRunner, "nested RSpec runs are unsupported" if self.class.active_adapter
115
+ raise UnsupportedRunner, "TEST_ENV_NUMBER is unsupported" if ENV.key?("TEST_ENV_NUMBER")
116
+
117
+ require "rspec/core"
118
+ require "rspec/core/runner"
119
+ unless Gem::Requirement.new("~> 3.13.0").satisfied_by?(Gem::Version.new(RSpec::Core::Version::STRING))
120
+ raise UnsupportedRunner, "unsupported RSpec version #{RSpec::Core::Version::STRING}; use 3.13.x"
121
+ end
122
+
123
+ RSpec::Core::Runner.disable_autorun!
124
+ options = RSpec::Core::ConfigurationOptions.new(runner_args)
125
+ configured_files = Array(options.options[:files_or_directories_to_run]).map(&:to_s)
126
+ if test_selection_explicit && configured_files.any?
127
+ raise ArgumentError, "explicit test selection conflicts with configured RSpec selectors"
128
+ end
129
+
130
+ options.options[:files_or_directories_to_run] = test_files.map(&:to_s) if test_selection_explicit
131
+ options.singleton_class.prepend(DefaultDiscovery) if !test_selection_explicit && configured_files.empty?
132
+ reject_options!(options)
133
+
134
+ @running = true
135
+
136
+ @registered = true
137
+ @completion_callback = on_complete
138
+ @unmatched_test_selection = test_selection_explicit && test_files.empty?
139
+ if @unmatched_test_selection
140
+ complete(status: 0, finalized: true)
141
+ return 0
142
+ end
143
+ self.class.active_adapter = self
144
+ self.class.runner_adapter = self
145
+ install_lifecycle_hooks
146
+ install_runner_guard
147
+ runner = RSpec::Core::Runner.new(options)
148
+ @native_runner = runner
149
+ adapter = self
150
+ runner.define_singleton_method(:setup) do |*setup_args|
151
+ super(*setup_args)
152
+ return if world.wants_to_quit
153
+
154
+ adapter.validate_runner!
155
+ after_load&.call
156
+ adapter.send(:install_reporter_listener, configuration.reporter)
157
+ end
158
+ before_load&.call
159
+ status = runner.run($stderr, $stdout)
160
+ capture_runner_state(runner)
161
+ complete(status: status, finalized: true)
162
+ status
163
+ rescue StandardError => e
164
+ raise unless @running
165
+ raise if @completion_started
166
+
167
+ @run_error = e
168
+ capture_runner_state(runner) if runner
169
+ status = 2
170
+ complete(status: status, finalized: false)
171
+ status
172
+ ensure
173
+ if self.class.active_adapter.equal?(self)
174
+ unattributed
175
+ self.class.active_adapter = nil
176
+ end
177
+ @running = false
178
+ end
179
+
180
+ def validate_runner!
181
+ return nil unless defined?(RSpec.configuration)
182
+
183
+ configuration = RSpec.configuration
184
+ reject_configuration!(configuration)
185
+ if configuration.respond_to?(:parallelize) && configuration.parallelize
186
+ raise UnsupportedRunner,
187
+ "parallel test scheduling is unsupported"
188
+ end
189
+
190
+ nil
191
+ end
192
+
193
+ def example_started(notification)
194
+ example = notification.example
195
+ record = test_record(example)
196
+ record[:status] = "running"
197
+ register_test(record)
198
+ end
199
+
200
+ def example_finished(notification)
201
+ example = notification.example
202
+ record = test_record(example)
203
+ record[:name] = example.full_description.to_s
204
+ record[:status] = status_for(example)
205
+ register_test(record)
206
+ end
207
+
208
+ def reject_execution!(message)
209
+ @run_error ||= UnsupportedRunner.new(message)
210
+ @late_execution_error = @run_error if @completion_started
211
+ raise @run_error
212
+ end
213
+
214
+ def enter_runner!(runner)
215
+ unless runner.equal?(@native_runner) && !@runner_entered
216
+ reject_execution!("nested or repeated RSpec runs are unsupported")
217
+ end
218
+ @runner_entered = true
219
+ end
220
+
221
+ private
222
+
223
+ def capture_runner_state(runner)
224
+ @selected_test_files = runner.configuration.loaded_spec_files.map { |path| File.expand_path(path.to_s) }
225
+ @selected_example_ids = runner.world.filtered_examples.values.flatten.map(&:id)
226
+ @seed = runner.configuration.seed
227
+ @non_example_failure = runner.world.non_example_failure
228
+ end
229
+
230
+ def reject_options!(options)
231
+ FORBIDDEN_OPTIONS.each do |key, label|
232
+ value = options.options[key]
233
+ next unless value && value != false && value != []
234
+
235
+ raise UnsupportedRunner, "#{label} is unsupported"
236
+ end
237
+ end
238
+
239
+ def reject_configuration!(configuration)
240
+ if configuration.respond_to?(:dry_run?) && configuration.dry_run?
241
+ raise UnsupportedRunner,
242
+ "dry-run is unsupported"
243
+ end
244
+ raise UnsupportedRunner, "bisect is unsupported" if configuration.respond_to?(:bisect) && configuration.bisect
245
+ raise UnsupportedRunner, "DRb is unsupported" if defined?(DRb) && RSpec::Core::Runner.send(:running_in_drb?)
246
+ end
247
+
248
+ def install_reporter_listener(reporter)
249
+ @defined_example_count = RSpec.world.filtered_examples.keys.sum { |group| group.examples.length }
250
+ listener = self
251
+ reporter.register_listener(listener, :example_started, :example_finished)
252
+ end
253
+
254
+ def install_runner_guard
255
+ return if RSpec::Core::Runner.singleton_class.instance_variable_defined?(:@branchproof_runner_guard)
256
+
257
+ RSpec::Core::Runner.singleton_class.prepend(ClassRunnerGuard)
258
+ RSpec::Core::Runner.prepend(RunnerGuard)
259
+ RSpec::Core::Runner.singleton_class.instance_variable_set(:@branchproof_runner_guard, true)
260
+ end
261
+
262
+ def install_lifecycle_hooks
263
+ return if RSpec::Core::Example.instance_variable_defined?(:@branchproof_lifecycle_hooks)
264
+
265
+ RSpec::Core::Example.prepend(ExampleLifecycle)
266
+ RSpec::Core::ExampleGroup.singleton_class.prepend(ContextLifecycle)
267
+ RSpec::Core::Example.instance_variable_set(:@branchproof_lifecycle_hooks, true)
268
+ end
269
+
270
+ def begin_example(example)
271
+ validate_runner!
272
+ seen = (@seen_examples ||= {}.compare_by_identity)
273
+ reject_execution!("repeated RSpec example attempts are unsupported") if seen[example]
274
+
275
+ seen[example] = true
276
+ phase(example, "setup")
277
+ rescue ArgumentError => e
278
+ reject_execution!(e.message)
279
+ end
280
+
281
+ def begin_attempt(example)
282
+ @attempts ||= {}
283
+ id = test_id(example)
284
+ if @attempts[id]
285
+ @run_error ||= UnsupportedRunner.new("repeated RSpec example attempts are unsupported")
286
+ raise @run_error
287
+ end
288
+ @attempts[id] = true
289
+ end
290
+
291
+ def without_owner
292
+ previous = @current_context
293
+ unattributed
294
+ yield
295
+ ensure
296
+ @current_context = previous
297
+ @runtime.context(test_id: previous&.first, phase: previous&.last || "unattributed")
298
+ end
299
+
300
+ def status_for(example)
301
+ result = example.execution_result
302
+ status = result.status&.to_sym
303
+ return "failed" if (result.respond_to?(:pending_fixed?) && result.pending_fixed?) ||
304
+ (example.respond_to?(:pending?) && example.pending? && status == :failed)
305
+ return "failed" if status == :failed
306
+ return "skipped" if example.respond_to?(:skipped?) && example.skipped?
307
+ return "skipped" if status == :pending
308
+
309
+ status == :passed ? "passed" : "running"
310
+ end
311
+
312
+ def test_record(example)
313
+ @example_records[example] ||= begin
314
+ source = source_for(example)
315
+ id = Branchproof::Records.id(adapter: "rspec", example_id: example.id.to_s, source: source)
316
+ @tests[id] = {
317
+ id: id, adapter: "rspec", name: example.full_description.to_s,
318
+ example_id: example.id.to_s, class_name: nil, method_name: nil,
319
+ spec_digest: file_digest(example.id.to_s.sub(/\[[\d:]+\]\z/, "")),
320
+ source_digest: file_digest(source[:path]),
321
+ source: source, status: "running", phase_counts: {}
322
+ }
323
+ end
324
+ end
325
+
326
+ def test_id(example)
327
+ test_record(example)[:id]
328
+ end
329
+
330
+ def file_digest(path)
331
+ expanded = File.expand_path(path)
332
+ return @file_digests[expanded] if @file_digests.key?(expanded)
333
+
334
+ @file_digests[expanded] = File.file?(expanded) ? Digest::SHA256.file(expanded).hexdigest : nil
335
+ end
336
+
337
+ def source_for(example)
338
+ path = example.metadata[:absolute_file_path] || example.file_path
339
+ { path: File.expand_path(path.to_s), line: example.metadata[:line_number].to_i }
340
+ end
341
+
342
+ def phase(example, name)
343
+ id = test_id(example)
344
+ @current_context = [id, name]
345
+ @runtime.context(test_id: id, phase: name)
346
+ end
347
+
348
+ def unattributed
349
+ @current_context = nil
350
+ @runtime.context(test_id: nil, phase: "unattributed")
351
+ end
352
+
353
+ def register_test(test)
354
+ @runtime.register_test(test: test)
355
+ end
356
+
357
+ def complete(status:, finalized:)
358
+ return if @completed || @completion_started
359
+
360
+ @completion_started = true
361
+ apply_canonical_phase_counts
362
+ tests = @tests.values
363
+ failed = tests.count { |test| test[:status] == "failed" }
364
+ skipped = tests.count { |test| test[:status] == "skipped" }
365
+ diagnostics = completion_diagnostics(status)
366
+ errored = diagnostics.any? { |diagnostic| diagnostic[:severity] == "error" }
367
+ result = {
368
+ status: if errored
369
+ "ERROR"
370
+ elsif tests.empty?
371
+ "INCOMPLETE"
372
+ elsif failed.zero?
373
+ "PASSED"
374
+ else
375
+ "FAILED"
376
+ end,
377
+ executed_tests: tests.length, failed_tests: failed, skipped_tests: skipped,
378
+ exit_status: status.to_i, finalized: finalized && !errored, seed: @seed, diagnostics: diagnostics,
379
+ selected_test_files: @selected_test_files || [], selected_example_ids: @selected_example_ids || [], tests: tests
380
+ }
381
+ @completion_callback.call(result)
382
+ @completed = true
383
+ end
384
+
385
+ def completion_diagnostics(native_status)
386
+ if @run_error
387
+ code = if defined?(Branchproof::RailsSupport::Error) && @run_error.is_a?(Branchproof::RailsSupport::Error)
388
+ "rails_boot"
389
+ elsif @run_error.respond_to?(:diagnostic_code)
390
+ @run_error.diagnostic_code
391
+ else
392
+ "rspec_run"
393
+ end
394
+ return [{ code: code, severity: "error", message: "#{@run_error.class}: #{@run_error.message}" }]
395
+ end
396
+ if @non_example_failure || @tests.values.any? { |test| test[:status] == "running" } ||
397
+ (native_status.to_i != 0 && @tests.values.none? { |test| test[:status] == "failed" })
398
+ return [{ code: "rspec_run", severity: "error",
399
+ message: "RSpec failed outside examples; see runner output" }]
400
+ end
401
+
402
+ return [] unless @tests.empty?
403
+
404
+ code, message = if @unmatched_test_selection
405
+ ["unmatched_test_selection", "No files matched --test; check the path or glob"]
406
+ elsif @defined_example_count.to_i.positive?
407
+ ["empty_example_selection",
408
+ "RSpec filters selected zero examples; check tags, descriptions, and selectors"]
409
+ else
410
+ ["empty_suite", "RSpec discovered no examples; check the default path and configured patterns"]
411
+ end
412
+ [{ code: code, severity: "warning", message: message }]
413
+ end
414
+
415
+ def apply_canonical_phase_counts
416
+ canonical_by_id = @runtime.test_phase_counts
417
+ @tests.each do |id, test|
418
+ counts = canonical_by_id[id]
419
+ test[:phase_counts] = counts unless counts.nil?
420
+ end
421
+ end
422
+ end
423
+ end
@@ -107,6 +107,10 @@ module Branchproof
107
107
  nil
108
108
  end
109
109
 
110
+ def test_phase_counts
111
+ @evidence.test_phase_counts
112
+ end
113
+
110
114
  def snapshot
111
115
  if @evidence.respond_to?(:snapshot)
112
116
  result = @evidence.snapshot
@@ -638,7 +638,9 @@ module Branchproof
638
638
  end
639
639
 
640
640
  def validate_test(test)
641
- %w[name class_name method_name source_path].each { |field| validate_string_field(test, field, nullable: true) }
641
+ %w[adapter name class_name method_name source_path example_id].each do |field|
642
+ validate_string_field(test, field, nullable: true)
643
+ end
642
644
  validate_integer_field(test, "line", nullable: true)
643
645
  phase_counts = test["phase_counts"]
644
646
  if phase_counts
@@ -726,7 +728,12 @@ module Branchproof
726
728
  string_fields.each do |field|
727
729
  fail_with("run_metadata #{field} must be a string") if metadata.key?(field) && !metadata[field].is_a?(String)
728
730
  end
729
- array_fields = %w[source_patterns test_patterns test_files runner_args]
731
+ nullable_string_fields = %w[framework framework_version rspec_rails_version rails_version]
732
+ nullable_string_fields.each do |field|
733
+ fail_with("run_metadata #{field} must be a string") if metadata.key?(field) &&
734
+ !metadata[field].nil? && !metadata[field].is_a?(String)
735
+ end
736
+ array_fields = %w[source_patterns test_patterns test_files runner_args selected_test_files selected_example_ids]
730
737
  array_fields.each do |field|
731
738
  if metadata.key?(field) && !strings?(metadata[field])
732
739
  fail_with("run_metadata #{field} must be an array of strings")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end