rspec-risky 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +103 -0
- data/exe/rspec-risky +7 -0
- data/lib/minitest/risky_plugin.rb +33 -0
- data/lib/rspec/risky/autorun.rb +5 -0
- data/lib/rspec/risky/configuration.rb +112 -0
- data/lib/rspec/risky/evaluation.rb +129 -0
- data/lib/rspec/risky/formatter.rb +128 -0
- data/lib/rspec/risky/json_event_formatter.rb +41 -0
- data/lib/rspec/risky/json_formatter.rb +61 -0
- data/lib/rspec/risky/minitest/output_capture.rb +114 -0
- data/lib/rspec/risky/minitest/plugin.rb +103 -0
- data/lib/rspec/risky/minitest/reporter.rb +46 -0
- data/lib/rspec/risky/probe/expectation_probe.rb +176 -0
- data/lib/rspec/risky/probe/fd_capture.rb +61 -0
- data/lib/rspec/risky/probe/output_probe.rb +167 -0
- data/lib/rspec/risky/probe/output_report.rb +72 -0
- data/lib/rspec/risky/probe/recording_io.rb +108 -0
- data/lib/rspec/risky/rake_tasks.rb +55 -0
- data/lib/rspec/risky/rspec_cli.rb +50 -0
- data/lib/rspec/risky/static_detector.rb +166 -0
- data/lib/rspec/risky/verdict.rb +120 -0
- data/lib/rspec/risky/version.rb +7 -0
- data/lib/rspec/risky.rb +128 -0
- data/sig/rspec/risky.rbs +81 -0
- metadata +125 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../probe/output_report"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Risky
|
|
7
|
+
module Minitest
|
|
8
|
+
module Plugin
|
|
9
|
+
class OutputCapture
|
|
10
|
+
ALLOW_WARN_KEY = :__rspec_risky_minitest_allow_warn
|
|
11
|
+
SUPPRESS_KEY = :__rspec_risky_minitest_output_suppressed
|
|
12
|
+
|
|
13
|
+
State = Struct.new(:original_stderr, :original_stdout, :report, :stderr_proxy, :stdout_proxy, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
module KernelWarnPatch
|
|
16
|
+
def warn(*messages, **options)
|
|
17
|
+
if OutputCapture.allow_warn?
|
|
18
|
+
OutputCapture.with_suppressed { super }
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private :warn
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Stream
|
|
28
|
+
def initialize(stream_name, io, report)
|
|
29
|
+
@stream_name = stream_name
|
|
30
|
+
@io = io
|
|
31
|
+
@report = report
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write(data)
|
|
35
|
+
return @io.write(data) if OutputCapture.suppressed?
|
|
36
|
+
|
|
37
|
+
@report.record(@stream_name, data.to_s, caller_locations(1))
|
|
38
|
+
@io.write(data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def <<(data)
|
|
42
|
+
write(data)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def puts(*objects)
|
|
47
|
+
data = objects.empty? ? "\n" : objects.map { |object| "#{object}\n" }.join
|
|
48
|
+
@report.record(@stream_name, data, caller_locations(1)) unless OutputCapture.suppressed?
|
|
49
|
+
@io.puts(*objects)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print(*objects)
|
|
53
|
+
data = objects.empty? ? $_.to_s : objects.map(&:to_s).join($,)
|
|
54
|
+
@report.record(@stream_name, data, caller_locations(1)) unless OutputCapture.suppressed?
|
|
55
|
+
@io.print(*objects)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def method_missing(method_name, *args, &block)
|
|
59
|
+
return super unless @io.respond_to?(method_name)
|
|
60
|
+
|
|
61
|
+
@io.public_send(method_name, *args, &block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
65
|
+
@io.respond_to?(method_name, include_private) || super
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.install
|
|
70
|
+
return if @installed
|
|
71
|
+
|
|
72
|
+
::Kernel.prepend(KernelWarnPatch)
|
|
73
|
+
@installed = true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.start(allow_warn: false)
|
|
77
|
+
report = Probe::OutputProbe::Report.new
|
|
78
|
+
state = State.new(original_stderr: $stderr, original_stdout: $stdout, report: report)
|
|
79
|
+
state.stdout_proxy = Stream.new(:stdout, $stdout, report)
|
|
80
|
+
state.stderr_proxy = Stream.new(:stderr, $stderr, report)
|
|
81
|
+
$stdout = state.stdout_proxy
|
|
82
|
+
$stderr = state.stderr_proxy
|
|
83
|
+
Thread.current[ALLOW_WARN_KEY] = allow_warn
|
|
84
|
+
state
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.finish(state)
|
|
88
|
+
return unless state
|
|
89
|
+
|
|
90
|
+
$stdout = state.original_stdout if $stdout.equal?(state.stdout_proxy)
|
|
91
|
+
$stderr = state.original_stderr if $stderr.equal?(state.stderr_proxy)
|
|
92
|
+
Thread.current[ALLOW_WARN_KEY] = false
|
|
93
|
+
state.report
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.allow_warn?
|
|
97
|
+
Thread.current[ALLOW_WARN_KEY]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.suppressed?
|
|
101
|
+
Thread.current[SUPPRESS_KEY].to_i.positive?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.with_suppressed
|
|
105
|
+
Thread.current[SUPPRESS_KEY] = Thread.current[SUPPRESS_KEY].to_i + 1
|
|
106
|
+
yield
|
|
107
|
+
ensure
|
|
108
|
+
Thread.current[SUPPRESS_KEY] -= 1
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "output_capture"
|
|
7
|
+
require_relative "reporter"
|
|
8
|
+
|
|
9
|
+
module RSpec
|
|
10
|
+
module Risky
|
|
11
|
+
module Minitest
|
|
12
|
+
module Plugin
|
|
13
|
+
METADATA_KEY = "rspec_risky"
|
|
14
|
+
|
|
15
|
+
Result = Struct.new(:rule, :message, :evidence, keyword_init: true) do
|
|
16
|
+
def to_h
|
|
17
|
+
{ rule: rule, message: message, evidence: evidence }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module RunnablePatch
|
|
22
|
+
def run
|
|
23
|
+
config = Plugin.configuration
|
|
24
|
+
output_state = config[:redundant_print] ? OutputCapture.start(allow_warn: config[:allow_warn]) : nil
|
|
25
|
+
|
|
26
|
+
super.tap do |result|
|
|
27
|
+
output_report = OutputCapture.finish(output_state)
|
|
28
|
+
output_state = nil
|
|
29
|
+
verdicts = Plugin.verdicts_for(result, output_report, config)
|
|
30
|
+
next if verdicts.empty?
|
|
31
|
+
|
|
32
|
+
result.metadata[METADATA_KEY] = verdicts.map(&:to_h)
|
|
33
|
+
result.failures << ::Minitest::Assertion.new(verdicts.map(&:message).join("\n")) if config[:fail]
|
|
34
|
+
end
|
|
35
|
+
ensure
|
|
36
|
+
OutputCapture.finish(output_state) if output_state
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
attr_reader :configuration
|
|
42
|
+
|
|
43
|
+
def install(configuration)
|
|
44
|
+
@configuration = configuration
|
|
45
|
+
OutputCapture.install
|
|
46
|
+
install_runner_patch unless @installed
|
|
47
|
+
::Minitest.reporter << Reporter.new(::Minitest.reporter.io, configuration)
|
|
48
|
+
@installed = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verdicts_for(result, output_report, config)
|
|
52
|
+
return [] unless result.passed?
|
|
53
|
+
|
|
54
|
+
verdicts = []
|
|
55
|
+
verdicts << unknown_test_result(result) if config[:unknown_test] && result.assertions.zero?
|
|
56
|
+
if config[:redundant_print] && output_report
|
|
57
|
+
verdicts << redundant_print_result(output_report)
|
|
58
|
+
end
|
|
59
|
+
verdicts.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def install_runner_patch
|
|
65
|
+
if defined?(::Minitest::Test)
|
|
66
|
+
::Minitest::Test.prepend(RunnablePatch)
|
|
67
|
+
else
|
|
68
|
+
::Minitest::Runnable.prepend(RunnablePatch)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def unknown_test_result(result)
|
|
73
|
+
Result.new(
|
|
74
|
+
rule: :unknown_test,
|
|
75
|
+
message: "no assertions were executed",
|
|
76
|
+
evidence: { assertion_count: result.assertions }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def redundant_print_result(output_report)
|
|
81
|
+
config = RedundantPrintConfig.new
|
|
82
|
+
writes = output_report.writes_for(config)
|
|
83
|
+
return if writes.empty?
|
|
84
|
+
|
|
85
|
+
Result.new(
|
|
86
|
+
rule: :redundant_print,
|
|
87
|
+
message: "test wrote to stdout/stderr",
|
|
88
|
+
evidence: writes.transform_values { |stats| stream_evidence(stats) }
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stream_evidence(stats)
|
|
93
|
+
{
|
|
94
|
+
byte_count: stats.byte_count,
|
|
95
|
+
first_sample: stats.first_sample,
|
|
96
|
+
write_count: stats.write_count
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
module Minitest
|
|
6
|
+
module Plugin
|
|
7
|
+
class Reporter < ::Minitest::Reporter
|
|
8
|
+
def initialize(io = $stdout, options = {})
|
|
9
|
+
super
|
|
10
|
+
@risky_results = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record(result)
|
|
14
|
+
verdicts = result.metadata[METADATA_KEY]
|
|
15
|
+
return unless verdicts
|
|
16
|
+
|
|
17
|
+
@risky_results << [result, verdicts]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def report
|
|
21
|
+
return if @risky_results.empty?
|
|
22
|
+
|
|
23
|
+
io.puts
|
|
24
|
+
io.puts "Risky tests:"
|
|
25
|
+
@risky_results.each do |result, verdicts|
|
|
26
|
+
verdicts.each do |verdict|
|
|
27
|
+
io.puts " #{result.location} RISKY (#{verdict.fetch(:rule)}) #{verdict.fetch(:message)}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
io.puts "Risky summary: #{summary}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def summary
|
|
36
|
+
counts = Hash.new(0)
|
|
37
|
+
@risky_results.each do |_result, verdicts|
|
|
38
|
+
verdicts.each { |verdict| counts[verdict.fetch(:rule)] += 1 }
|
|
39
|
+
end
|
|
40
|
+
counts.sort_by { |rule, _count| rule.to_s }.map { |rule, count| "#{rule}=#{count}" }.join(", ")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
require "rspec/mocks"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Risky
|
|
8
|
+
module Probe
|
|
9
|
+
module ExpectationProbe
|
|
10
|
+
THREAD_KEY = :__rspec_risky_expectation_probe_state
|
|
11
|
+
|
|
12
|
+
State = Struct.new(
|
|
13
|
+
:example,
|
|
14
|
+
:expectation_count,
|
|
15
|
+
:mock_expectation_count,
|
|
16
|
+
:custom_expectation_count,
|
|
17
|
+
:previous_state,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
module StandardExpectationPatch
|
|
22
|
+
def to(matcher = nil, message = nil, &block)
|
|
23
|
+
ExpectationProbe.record_expectation if matcher
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def not_to(matcher = nil, message = nil, &block)
|
|
28
|
+
ExpectationProbe.record_expectation if matcher
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_not(matcher = nil, message = nil, &block)
|
|
33
|
+
ExpectationProbe.record_expectation if matcher
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module ReceivePatch
|
|
39
|
+
def setup_expectation(*args, &block)
|
|
40
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def setup_negative_expectation(*args, &block)
|
|
44
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def setup_any_instance_expectation(*args, &block)
|
|
48
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def setup_any_instance_negative_expectation(*args, &block)
|
|
52
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module ReceiveMessagesPatch
|
|
57
|
+
def setup_expectation(*args, &block)
|
|
58
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation(message_count) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def setup_any_instance_expectation(*args, &block)
|
|
62
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation(message_count) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def message_count
|
|
68
|
+
@message_return_value_hash.size
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module ReceiveMessageChainPatch
|
|
73
|
+
def setup_expectation(*args, &block)
|
|
74
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def setup_any_instance_expectation(*args, &block)
|
|
78
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module HaveReceivedPatch
|
|
83
|
+
def matches?(*args, &block)
|
|
84
|
+
ExpectationProbe.record_declared_mock_expectation
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def does_not_match?(*args, &block)
|
|
89
|
+
ExpectationProbe.record_declared_mock_expectation
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
module ShouldSyntaxPatch
|
|
95
|
+
def expect_message(*args, &block)
|
|
96
|
+
super.tap { ExpectationProbe.record_declared_mock_expectation }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
def install
|
|
102
|
+
return if @installed
|
|
103
|
+
|
|
104
|
+
::RSpec::Expectations::ExpectationTarget.prepend(StandardExpectationPatch)
|
|
105
|
+
::RSpec::Mocks::Matchers::Receive.prepend(ReceivePatch)
|
|
106
|
+
::RSpec::Mocks::Matchers::ReceiveMessages.prepend(ReceiveMessagesPatch)
|
|
107
|
+
::RSpec::Mocks::Matchers::ReceiveMessageChain.prepend(ReceiveMessageChainPatch)
|
|
108
|
+
::RSpec::Mocks::Matchers::HaveReceived.prepend(HaveReceivedPatch)
|
|
109
|
+
::RSpec::Mocks.singleton_class.prepend(ShouldSyntaxPatch)
|
|
110
|
+
|
|
111
|
+
@installed = true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def start(example)
|
|
115
|
+
State.new(
|
|
116
|
+
example: example,
|
|
117
|
+
expectation_count: 0,
|
|
118
|
+
mock_expectation_count: 0,
|
|
119
|
+
custom_expectation_count: 0,
|
|
120
|
+
previous_state: current
|
|
121
|
+
).tap { |state| Thread.current[THREAD_KEY] = state }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def finish(state)
|
|
125
|
+
state.mock_expectation_count = [state.mock_expectation_count, verified_mock_expectation_count].max if state
|
|
126
|
+
Thread.current[THREAD_KEY] = state.previous_state if state
|
|
127
|
+
state
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def record_expectation(count = 1)
|
|
131
|
+
return unless current
|
|
132
|
+
|
|
133
|
+
current.expectation_count += count
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def record_mock_expectation(count = 1)
|
|
137
|
+
return unless current
|
|
138
|
+
|
|
139
|
+
current.mock_expectation_count += count
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
alias record_declared_mock_expectation record_mock_expectation
|
|
143
|
+
|
|
144
|
+
def record_custom_expectation(count = 1)
|
|
145
|
+
return unless current
|
|
146
|
+
|
|
147
|
+
current.custom_expectation_count += count
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def current
|
|
153
|
+
Thread.current[THREAD_KEY]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def verified_mock_expectation_count
|
|
157
|
+
mock_proxy_expectations.count { |expectation| expectation.expected_messages_received? }
|
|
158
|
+
rescue StandardError
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def mock_proxy_expectations
|
|
163
|
+
space = ::RSpec::Mocks.space
|
|
164
|
+
proxies = space.respond_to?(:proxies) ? space.proxies.values : []
|
|
165
|
+
proxies.flat_map do |proxy|
|
|
166
|
+
method_doubles = proxy.instance_variable_get(:@method_doubles)
|
|
167
|
+
next [] unless method_doubles
|
|
168
|
+
|
|
169
|
+
method_doubles.values.flat_map { |method_double| method_double.expectations }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
module Probe
|
|
6
|
+
module OutputProbe
|
|
7
|
+
class FdCapture
|
|
8
|
+
READ_SIZE = 4096
|
|
9
|
+
|
|
10
|
+
def initialize(stream_name, io, report, passthrough:)
|
|
11
|
+
@stream_name = stream_name
|
|
12
|
+
@io = io
|
|
13
|
+
@report = report
|
|
14
|
+
@passthrough = passthrough
|
|
15
|
+
@reader = nil
|
|
16
|
+
@original = nil
|
|
17
|
+
@thread = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
@original = @io.dup
|
|
22
|
+
@reader, writer = IO.pipe
|
|
23
|
+
@reader.binmode
|
|
24
|
+
writer.binmode
|
|
25
|
+
@io.reopen(writer)
|
|
26
|
+
@io.sync = true if @io.respond_to?(:sync=)
|
|
27
|
+
writer.close
|
|
28
|
+
start_reader
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def finish
|
|
33
|
+
@io.reopen(@original) if @original
|
|
34
|
+
@thread&.join(1)
|
|
35
|
+
@original&.close
|
|
36
|
+
ensure
|
|
37
|
+
@reader&.close unless @reader&.closed?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def start_reader
|
|
43
|
+
@thread = Thread.new do
|
|
44
|
+
drain_reader
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def drain_reader
|
|
49
|
+
loop do
|
|
50
|
+
chunk = @reader.readpartial(READ_SIZE)
|
|
51
|
+
@report.record(@stream_name, chunk, [])
|
|
52
|
+
@original.write(chunk) if @passthrough
|
|
53
|
+
end
|
|
54
|
+
rescue EOFError, IOError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/matchers/built_in/output"
|
|
4
|
+
|
|
5
|
+
require_relative "fd_capture"
|
|
6
|
+
require_relative "output_report"
|
|
7
|
+
require_relative "recording_io"
|
|
8
|
+
|
|
9
|
+
module RSpec
|
|
10
|
+
module Risky
|
|
11
|
+
module Probe
|
|
12
|
+
module OutputProbe
|
|
13
|
+
THREAD_KEY = :__rspec_risky_output_probe_state
|
|
14
|
+
SUPPRESS_KEY = :__rspec_risky_output_probe_suppressed
|
|
15
|
+
|
|
16
|
+
State = Struct.new(
|
|
17
|
+
:report,
|
|
18
|
+
:original_stdout,
|
|
19
|
+
:original_stderr,
|
|
20
|
+
:stdout_proxy,
|
|
21
|
+
:stderr_proxy,
|
|
22
|
+
:allow_warn,
|
|
23
|
+
:fd_captures,
|
|
24
|
+
:previous_state,
|
|
25
|
+
:rule_config,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
module OutputMatcherPatch
|
|
30
|
+
def matches?(block)
|
|
31
|
+
OutputProbe.with_capture_suppressed { super }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def does_not_match?(block)
|
|
35
|
+
OutputProbe.with_capture_suppressed { super }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module KernelWarnPatch
|
|
40
|
+
def warn(*messages, **options)
|
|
41
|
+
if OutputProbe.suppress_warn?
|
|
42
|
+
OutputProbe.with_capture_suppressed { super }
|
|
43
|
+
else
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private :warn
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module LoggerPatch
|
|
52
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
53
|
+
OutputProbe.record_logger(self, message, progname, caller_locations(1))
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def install
|
|
60
|
+
return if @installed
|
|
61
|
+
|
|
62
|
+
::RSpec::Matchers::BuiltIn::Output.prepend(OutputMatcherPatch)
|
|
63
|
+
::Kernel.prepend(KernelWarnPatch)
|
|
64
|
+
install_logger_patch
|
|
65
|
+
|
|
66
|
+
@installed = true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def start(rule_config)
|
|
70
|
+
report = Report.new
|
|
71
|
+
state = State.new(
|
|
72
|
+
report: report,
|
|
73
|
+
original_stdout: $stdout,
|
|
74
|
+
original_stderr: $stderr,
|
|
75
|
+
allow_warn: rule_config.allow_warn,
|
|
76
|
+
fd_captures: [],
|
|
77
|
+
rule_config: rule_config,
|
|
78
|
+
previous_state: current
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if rule_config.strict
|
|
82
|
+
start_fd_captures(state, rule_config, report)
|
|
83
|
+
elsif rule_config.captures?(:stdout)
|
|
84
|
+
state.stdout_proxy = RecordingIO.new(:stdout, $stdout, report, passthrough: rule_config.passthrough)
|
|
85
|
+
end
|
|
86
|
+
if !rule_config.strict && rule_config.captures?(:stderr)
|
|
87
|
+
state.stderr_proxy = RecordingIO.new(:stderr, $stderr, report, passthrough: rule_config.passthrough)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
$stdout = state.stdout_proxy if state.stdout_proxy
|
|
91
|
+
$stderr = state.stderr_proxy if state.stderr_proxy
|
|
92
|
+
Thread.current[THREAD_KEY] = state
|
|
93
|
+
|
|
94
|
+
state
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def finish(state)
|
|
98
|
+
return unless state
|
|
99
|
+
|
|
100
|
+
state.fd_captures.reverse_each(&:finish)
|
|
101
|
+
$stdout = state.original_stdout if state.stdout_proxy
|
|
102
|
+
$stderr = state.original_stderr if state.stderr_proxy
|
|
103
|
+
Thread.current[THREAD_KEY] = state.previous_state
|
|
104
|
+
|
|
105
|
+
state.report
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def with_capture_suppressed
|
|
109
|
+
Thread.current[SUPPRESS_KEY] = Thread.current[SUPPRESS_KEY].to_i + 1
|
|
110
|
+
yield
|
|
111
|
+
ensure
|
|
112
|
+
Thread.current[SUPPRESS_KEY] -= 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def capture_suppressed?
|
|
116
|
+
Thread.current[SUPPRESS_KEY].to_i.positive?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def suppress_warn?
|
|
120
|
+
current&.allow_warn
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def record_logger(logger, message, progname, locations)
|
|
124
|
+
state = current
|
|
125
|
+
return unless state
|
|
126
|
+
|
|
127
|
+
path = logger_path(logger)
|
|
128
|
+
return unless state.rule_config.captures_logger?(path)
|
|
129
|
+
|
|
130
|
+
sample = message || progname || "logger write"
|
|
131
|
+
state.report.record(:logger, sample.to_s, locations)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def install_logger_patch
|
|
137
|
+
require "logger"
|
|
138
|
+
::Logger.prepend(LoggerPatch)
|
|
139
|
+
rescue LoadError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def start_fd_captures(state, rule_config, report)
|
|
144
|
+
if rule_config.captures?(:stdout)
|
|
145
|
+
state.fd_captures << FdCapture.new(:stdout, $stdout, report, passthrough: rule_config.passthrough).start
|
|
146
|
+
end
|
|
147
|
+
if rule_config.captures?(:stderr)
|
|
148
|
+
state.fd_captures << FdCapture.new(:stderr, $stderr, report, passthrough: rule_config.passthrough).start
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def logger_path(logger)
|
|
153
|
+
logdev = logger.instance_variable_get(:@logdev)
|
|
154
|
+
return unless logdev
|
|
155
|
+
|
|
156
|
+
filename = logdev.instance_variable_get(:@filename)
|
|
157
|
+
filename&.to_s
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def current
|
|
161
|
+
Thread.current[THREAD_KEY]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|