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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +77 -18
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/Project.md +4 -1
- data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
- data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
- data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
- data/doc/Branchproof/RSpecAdapter.md +47 -0
- data/doc/Branchproof/RailsSupport.md +35 -2
- data/doc/Branchproof/Report.md +3 -0
- data/doc/Branchproof/Runtime.md +3 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +8 -1
- data/doc/CHANGELOG.md +19 -0
- data/doc/README.md +77 -18
- data/lib/branchproof/cli.rb +74 -28
- data/lib/branchproof/comparison.rb +52 -6
- data/lib/branchproof/evidence.rb +4 -0
- data/lib/branchproof/focused_report.rb +5 -0
- data/lib/branchproof/project.rb +49 -2
- data/lib/branchproof/rails_support.rb +101 -0
- data/lib/branchproof/report.rb +21 -1
- data/lib/branchproof/rspec_adapter.rb +423 -0
- data/lib/branchproof/runtime.rb +4 -0
- data/lib/branchproof/saved_report.rb +9 -2
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +1 -0
- data/llms.txt +8 -1
- data/sig/branchproof.rbs +28 -1
- metadata +10 -2
|
@@ -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
|
data/lib/branchproof/report.rb
CHANGED
|
@@ -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}
|
|
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
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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")
|
data/lib/branchproof/version.rb
CHANGED