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.
@@ -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