branchproof 0.8.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 +37 -0
- data/README.md +120 -28
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -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 +59 -1
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +24 -3
- data/doc/CHANGELOG.md +37 -0
- data/doc/README.md +120 -28
- data/lib/branchproof/cli.rb +74 -28
- data/lib/branchproof/comparison.rb +52 -6
- data/lib/branchproof/decision_syntax.rb +19 -9
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +87 -1
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +36 -7
- data/lib/branchproof/focused_report.rb +5 -0
- data/lib/branchproof/instrumenter.rb +50 -5
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/loader.rb +6 -1
- 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 +14 -0
- data/lib/branchproof/saved_report.rb +9 -2
- data/lib/branchproof/source.rb +59 -32
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +1 -0
- data/llms.txt +24 -3
- data/sig/branchproof.rbs +45 -1
- metadata +36 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Keep matching and iteration in the original lexical scope.
|
|
5
|
+
module IterationInstrumentation
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# rubocop:disable Metrics/MethodLength
|
|
9
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
10
|
+
metadata = decision.fetch(:instrumentation)
|
|
11
|
+
case metadata[:type]
|
|
12
|
+
when "iteration"
|
|
13
|
+
render_iteration(bytes, decision, nested, encloses)
|
|
14
|
+
when "required_pattern"
|
|
15
|
+
expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
|
|
16
|
+
runtime = self.class::RUNTIME
|
|
17
|
+
identifier = decision[:id].inspect
|
|
18
|
+
"(begin; #{runtime}.enter(#{identifier}); begin; #{expression}; " \
|
|
19
|
+
"#{runtime}.flow_select(#{identifier}, 0); nil; rescue ::NoMatchingPatternError; " \
|
|
20
|
+
"#{runtime}.flow_select(#{identifier}, 1); raise; ensure; #{runtime}.leave(#{identifier}); end; end)"
|
|
21
|
+
else
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:enable Metrics/MethodLength
|
|
27
|
+
|
|
28
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- nested exception callback seam
|
|
29
|
+
def render_iteration(bytes, decision, nested, encloses)
|
|
30
|
+
metadata = decision.fetch(:instrumentation)
|
|
31
|
+
identifier = decision[:id].inspect
|
|
32
|
+
runtime = self.class::RUNTIME
|
|
33
|
+
alternative_count = metadata[:lazy] ? 1 : 2
|
|
34
|
+
marker = "#{runtime}.flow_iteration_callback(#{identifier}, #{alternative_count})"
|
|
35
|
+
suffix = metadata[:empty] ? "nil; " : ""
|
|
36
|
+
callback = "; #{marker}; #{suffix}"
|
|
37
|
+
exception_child = nested.find do |child|
|
|
38
|
+
child[:instrumentation]&.fetch(:type, nil) == "exception" &&
|
|
39
|
+
child[:instrumentation].fetch(:implicit, false) &&
|
|
40
|
+
contains?(child[:byte_start], child[:byte_length], metadata[:insert_at], 0)
|
|
41
|
+
end
|
|
42
|
+
if exception_child
|
|
43
|
+
instrumentation = exception_child.fetch(:instrumentation)
|
|
44
|
+
callback_child = exception_child.merge(instrumentation: instrumentation.merge(
|
|
45
|
+
iteration_callback: { insert_at: metadata[:insert_at], text: callback }
|
|
46
|
+
))
|
|
47
|
+
nested = nested.map { |child| child.equal?(exception_child) ? callback_child : child }
|
|
48
|
+
encloses = encloses.merge(callback_child => encloses[exception_child])
|
|
49
|
+
end
|
|
50
|
+
edits = exception_child ? [] : [{ start: metadata[:insert_at], length: 0, text: callback }]
|
|
51
|
+
if metadata[:receiver]
|
|
52
|
+
receiver = metadata.fetch(:receiver)
|
|
53
|
+
edits << flow_replacement(bytes, receiver, nested, encloses) do |expression|
|
|
54
|
+
"#{runtime}.flow_iteration_begin(#{identifier}, (#{expression}), #{alternative_count})"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
expression = flow_fragments(bytes, decision, nested, edits, encloses)
|
|
58
|
+
if metadata[:receiver]
|
|
59
|
+
"(begin; begin; #{runtime}.flow_iteration_finish(#{identifier}, (#{expression}), 0); ensure; " \
|
|
60
|
+
"#{runtime}.flow_iteration_leave(#{identifier}); end; end)"
|
|
61
|
+
else
|
|
62
|
+
flow_frame(decision[:id], expression, default_path: 0)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Runtime support for callback based iteration, including lazy receivers
|
|
5
|
+
# whose callbacks execute after the constructing call has returned.
|
|
6
|
+
module IterationRuntime
|
|
7
|
+
def flow_iteration_begin(decision_id, receiver, alternative_count = 2)
|
|
8
|
+
# Case equality bypasses an application's overridden `is_a?` method.
|
|
9
|
+
# rubocop:disable-next Style/CaseEquality
|
|
10
|
+
return receiver if alternative_count == 1 || ::Enumerator::Lazy === receiver
|
|
11
|
+
|
|
12
|
+
enter(decision_id)
|
|
13
|
+
receiver
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
17
|
+
def flow_iteration_callback(decision_id, alternative_count = 2)
|
|
18
|
+
frame = iteration_frame(decision_id)
|
|
19
|
+
if frame
|
|
20
|
+
return nil if frame[:finished]
|
|
21
|
+
|
|
22
|
+
iteration_select(frame, alternative_count.to_i == 1 ? 0 : 1, alternative_count)
|
|
23
|
+
else
|
|
24
|
+
enter(decision_id)
|
|
25
|
+
begin
|
|
26
|
+
if alternative_count.to_i == 1
|
|
27
|
+
flow_select(decision_id, 0)
|
|
28
|
+
else
|
|
29
|
+
flow_path(decision_id, 1)
|
|
30
|
+
end
|
|
31
|
+
ensure
|
|
32
|
+
leave(decision_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def flow_iteration_finish(decision_id, value, default_path = nil)
|
|
39
|
+
frame = iteration_frame(decision_id)
|
|
40
|
+
iteration_select(frame, default_path, 2) if frame && !frame[:finished] && !default_path.nil?
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def flow_iteration_leave(decision_id)
|
|
45
|
+
frame = iteration_frame(decision_id)
|
|
46
|
+
frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
|
|
47
|
+
return nil unless frame && frames&.last.equal?(frame)
|
|
48
|
+
|
|
49
|
+
leave(decision_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# `current_frame` deliberately latches a diagnostic when a helper probes
|
|
55
|
+
# while another decision is active. Iteration callbacks may be deferred or
|
|
56
|
+
# nested, so an absent matching frame is an ordinary state here.
|
|
57
|
+
def iteration_frame(decision_id)
|
|
58
|
+
frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
|
|
59
|
+
index = frames ? frames.length - 1 : -1
|
|
60
|
+
while index >= 0
|
|
61
|
+
frame = frames[index]
|
|
62
|
+
return frame if frame[:decision_id] == decision_id
|
|
63
|
+
|
|
64
|
+
index -= 1
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def iteration_select(frame, index, alternative_count)
|
|
70
|
+
frame[:observations] = if alternative_count == 1
|
|
71
|
+
[[0, true]]
|
|
72
|
+
else
|
|
73
|
+
[[0, index.zero?], [1, index == 1]]
|
|
74
|
+
end
|
|
75
|
+
frame[:outcome] = true
|
|
76
|
+
frame[:finished] = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Observe entry into source-visible callback bodies, not library internals.
|
|
5
|
+
module IterationSyntax
|
|
6
|
+
ITERATORS = %i[each each_with_index times upto downto step map collect select filter reject filter_map
|
|
7
|
+
find detect find_index take_while drop_while partition count all? any? none? one? fetch].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def flow_decision_node?(node)
|
|
12
|
+
iteration_node?(node) || node.is_a?(Prism::MatchRequiredNode) || super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def iteration_node?(node)
|
|
16
|
+
node.is_a?(Prism::ForNode) ||
|
|
17
|
+
(node.is_a?(Prism::CallNode) && ITERATORS.include?(node.name) &&
|
|
18
|
+
node.block.is_a?(Prism::BlockNode) && !node.safe_navigation?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
22
|
+
def flow_details(node, bytes, encoding)
|
|
23
|
+
if node.is_a?(Prism::MatchRequiredNode)
|
|
24
|
+
alternatives = %w[matched mismatch].map { |label| iteration_alternative(node, label) }
|
|
25
|
+
return ["pattern", "required_pattern", alternatives,
|
|
26
|
+
{ type: "required_pattern", range: byte_range(node.location) }, unsupported_reasons(node, bytes)]
|
|
27
|
+
end
|
|
28
|
+
return super unless iteration_node?(node)
|
|
29
|
+
|
|
30
|
+
body = node.is_a?(Prism::ForNode) ? node.statements : node.block.body
|
|
31
|
+
closing = node.is_a?(Prism::ForNode) ? node.end_keyword_loc : node.block.closing_loc
|
|
32
|
+
lazy = node.is_a?(Prism::CallNode) && lazy_receiver?(node.receiver)
|
|
33
|
+
fallback = node.is_a?(Prism::CallNode) && node.name == :fetch
|
|
34
|
+
context = if lazy
|
|
35
|
+
"lazy_callback"
|
|
36
|
+
else
|
|
37
|
+
(fallback ? "fetch_fallback" : "iteration")
|
|
38
|
+
end
|
|
39
|
+
labels = if lazy
|
|
40
|
+
["callback entered"]
|
|
41
|
+
else
|
|
42
|
+
(fallback ? ["value present", "fallback entered"] : %w[empty entered])
|
|
43
|
+
end
|
|
44
|
+
metadata = { type: "iteration", range: byte_range(node.location), lazy: lazy,
|
|
45
|
+
insert_at: iteration_insert_at(body, closing),
|
|
46
|
+
empty: body.nil? }
|
|
47
|
+
metadata[:receiver] = byte_range(node.receiver.location) if node.is_a?(Prism::CallNode) && node.receiver
|
|
48
|
+
[lazy ? "multiway" : "implicit", context, labels.map { |label| iteration_alternative(node, label) },
|
|
49
|
+
metadata, unsupported_reasons(node, bytes)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
53
|
+
|
|
54
|
+
def lazy_receiver?(node)
|
|
55
|
+
node.is_a?(Prism::CallNode) && (node.name == :lazy || lazy_receiver?(node.receiver))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Prism gives a block-level rescue/ensure BeginNode a location beginning at
|
|
59
|
+
# the `do` keyword. Inserting there would produce `call; callback; do`, so
|
|
60
|
+
# anchor the callback after the block parameters and before its statements.
|
|
61
|
+
def iteration_insert_at(body, closing)
|
|
62
|
+
return closing.start_offset unless body
|
|
63
|
+
|
|
64
|
+
if body.is_a?(Prism::BeginNode)
|
|
65
|
+
statements = body.statements
|
|
66
|
+
return statements.location.start_offset if statements&.location
|
|
67
|
+
end
|
|
68
|
+
body.location.start_offset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def iteration_alternative(node, label)
|
|
72
|
+
{ expression: label, byte_start: node.location.start_offset, byte_length: node.location.length }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/branchproof/loader.rb
CHANGED
|
@@ -47,7 +47,12 @@ module Branchproof
|
|
|
47
47
|
add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
|
|
48
48
|
return nil
|
|
49
49
|
end
|
|
50
|
-
|
|
50
|
+
begin
|
|
51
|
+
rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
add_diagnostic("rewrite_failure", "#{e.class}: #{e.message}", unit[:source_id], severity: "error")
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
51
56
|
Array(rewritten[:diagnostics]).each do |diagnostic|
|
|
52
57
|
add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
|
|
53
58
|
end
|
data/lib/branchproof/project.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Branchproof
|
|
|
4
4
|
# Resolves the project policy used by the isolated analysis worker.
|
|
5
5
|
class Project
|
|
6
6
|
MODES = %w[auto ruby rails].freeze
|
|
7
|
+
FRAMEWORKS = %w[auto minitest rspec].freeze
|
|
7
8
|
RAILS_ENVIRONMENT = {
|
|
8
9
|
"RAILS_ENV" => "test",
|
|
9
10
|
"RACK_ENV" => "test",
|
|
@@ -12,19 +13,24 @@ module Branchproof
|
|
|
12
13
|
"DISABLE_SPRING" => "1"
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
|
-
def initialize(root:, mode: "auto")
|
|
16
|
+
def initialize(root:, mode: "auto", framework: "auto")
|
|
16
17
|
@root = File.expand_path(root)
|
|
17
18
|
@mode = mode.to_s
|
|
19
|
+
@framework = framework.to_s
|
|
18
20
|
raise ArgumentError, "project must be auto, ruby, or rails" unless MODES.include?(@mode)
|
|
21
|
+
raise ArgumentError, "framework must be auto, minitest, or rspec" unless FRAMEWORKS.include?(@framework)
|
|
19
22
|
|
|
20
23
|
validate_root!
|
|
24
|
+
resolve_framework!
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def to_h
|
|
24
28
|
{
|
|
25
29
|
kind: kind,
|
|
26
30
|
root: @root,
|
|
27
|
-
|
|
31
|
+
framework: framework,
|
|
32
|
+
test_patterns: test_patterns,
|
|
33
|
+
load_paths: load_paths,
|
|
28
34
|
environment: kind == "rails" ? RAILS_ENVIRONMENT.dup : {}
|
|
29
35
|
}
|
|
30
36
|
end
|
|
@@ -43,6 +49,47 @@ module Branchproof
|
|
|
43
49
|
File.file?(File.join(@root, "config", "environment.rb"))
|
|
44
50
|
end
|
|
45
51
|
|
|
52
|
+
def framework
|
|
53
|
+
@resolved_framework
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_framework!
|
|
57
|
+
rspec = rspec_markers?
|
|
58
|
+
minitest = minitest_markers?
|
|
59
|
+
if @framework == "auto"
|
|
60
|
+
raise_both_frameworks if rspec && minitest
|
|
61
|
+
@resolved_framework = rspec ? "rspec" : "minitest"
|
|
62
|
+
else
|
|
63
|
+
@resolved_framework = @framework
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rspec_markers?
|
|
68
|
+
File.file?(File.join(@root, ".rspec")) || !Dir.glob(File.join(@root, "spec", "**", "*_spec.rb")).empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def raise_both_frameworks
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"project contains both RSpec and Minitest markers; specify --framework rspec or --framework minitest"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def minitest_markers?
|
|
77
|
+
!Dir.glob(File.join(@root, "test", "**", "*_test.rb")).empty? ||
|
|
78
|
+
!Dir.glob(File.join(@root, "test", "**", "test_*.rb")).empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_patterns
|
|
82
|
+
if framework == "rspec"
|
|
83
|
+
%w[spec/**/*_spec.rb].freeze
|
|
84
|
+
else
|
|
85
|
+
%w[test/**/*_test.rb test/**/test_*.rb].freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_paths
|
|
90
|
+
[File.join(@root, "lib"), File.join(@root, framework == "rspec" ? "spec" : "test")]
|
|
91
|
+
end
|
|
92
|
+
|
|
46
93
|
def validate_root!
|
|
47
94
|
raise ArgumentError, "project root does not exist: #{@root}" unless File.directory?(@root)
|
|
48
95
|
return unless @mode == "rails" && !rails_files?
|
|
@@ -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
|
|