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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f70f4709b98df150e006e7fd0240e4b33841e6806889314c3f3c8b3070541479
|
|
4
|
+
data.tar.gz: 17e15803e40837a3935bd78ea762bf0c67123bf080e731d26a2349e7ac1cc903
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f996c9021c462dae5e6ba97fd76e31efd1db6bc73718f8ce67bc221e0bb40590d30dd13d711f8b92f5e6d21a796dc08dd8249da4bd7bec6575c5a0550e2836eb
|
|
7
|
+
data.tar.gz: 95cda2c88b42ff0ab60bc1cfb1afda22f76644c7782eb1d2ef3e52efc9ed488cf95816ca676b63489bbf260dafcc3fc7af2f162b566aabb4dba81fef8280abcf
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# rspec-risky
|
|
2
|
+
|
|
3
|
+
Runtime risky-test detection for RSpec.
|
|
4
|
+
|
|
5
|
+
`rspec-risky` reports examples that pass without executing any expectations, and examples that write unexpected output to `$stdout` or `$stderr`.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add the gem to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "rspec-risky"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then require and configure it from your spec helper:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "rspec/risky"
|
|
19
|
+
|
|
20
|
+
RSpec.configure do |config|
|
|
21
|
+
RSpec::Risky.configure(config) do |risky|
|
|
22
|
+
risky.rules = %i[unknown_test redundant_print]
|
|
23
|
+
risky.unknown_test.severity = :risky
|
|
24
|
+
risky.unknown_test.count_mocks = true
|
|
25
|
+
risky.unknown_test.adapters << ->(_example) { 0 }
|
|
26
|
+
risky.redundant_print.severity = :risky
|
|
27
|
+
risky.redundant_print.capture = :both
|
|
28
|
+
risky.redundant_print.allow_warn = false
|
|
29
|
+
risky.redundant_print.strict = false
|
|
30
|
+
risky.redundant_print.capture_loggers = false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can also require `rspec/risky/autorun` to install the default configuration.
|
|
36
|
+
|
|
37
|
+
Use the formatter when you want risky examples to appear as `R` in progress output:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::Formatter
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
To use `--risky-exit-code`, use the wrapper executable or preload the gem before RSpec parses options:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
ruby -S rspec-risky --risky-exit-code 7 --format RSpec::Risky::Formatter
|
|
47
|
+
ruby -rrspec/risky/autorun -S rspec --risky-exit-code 7 --format RSpec::Risky::Formatter
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `RSpec::Risky::JsonFormatter` to emit machine-readable verdicts:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::JsonFormatter
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use `RSpec::Risky::JsonEventFormatter` to emit newline-delimited verdict events:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::JsonEventFormatter
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Intentional smoke tests can opt out per example:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
it "boots", risky: { allow: [:unknown_test] } do
|
|
66
|
+
App.boot!
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Output that is explicitly asserted with RSpec's `output` matcher is not reported:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
it { expect { task.run }.to output(/done/).to_stdout }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Custom expectation libraries can call `RSpec::Risky.record_expectation` when they run a check.
|
|
77
|
+
|
|
78
|
+
## Minitest
|
|
79
|
+
|
|
80
|
+
Require the plugin from a Minitest suite:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
require "minitest/autorun"
|
|
84
|
+
require "minitest/risky_plugin"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use `--risky-fail` to fail the process for risky Minitest results. Use `--risky-allow-warn` to ignore `Kernel#warn` output.
|
|
88
|
+
|
|
89
|
+
## Evaluation Tasks
|
|
90
|
+
|
|
91
|
+
The gem includes local evaluation helpers:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bundle exec rake risky:static[spec]
|
|
95
|
+
bundle exec rake risky:compare[static.json,dynamic.json]
|
|
96
|
+
bundle exec rake risky:label[dynamic.json]
|
|
97
|
+
bundle exec rake risky:precision[labels.json]
|
|
98
|
+
bundle exec rake risky:study[dynamic.json,mutation.json]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/exe/rspec-risky
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require "rspec/risky/minitest/plugin"
|
|
5
|
+
|
|
6
|
+
module Minitest
|
|
7
|
+
register_plugin :risky
|
|
8
|
+
|
|
9
|
+
def self.plugin_risky_options(opts, options)
|
|
10
|
+
options[:risky] = {
|
|
11
|
+
allow_warn: false,
|
|
12
|
+
fail: false,
|
|
13
|
+
redundant_print: true,
|
|
14
|
+
unknown_test: true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
opts.on("--risky-fail", "Fail the run when risky tests are detected.") do
|
|
18
|
+
options[:risky][:fail] = true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
opts.on("--risky-no-output", "Disable stdout/stderr risky detection.") do
|
|
22
|
+
options[:risky][:redundant_print] = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
opts.on("--risky-allow-warn", "Do not flag Kernel#warn output.") do
|
|
26
|
+
options[:risky][:allow_warn] = true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.plugin_risky_init(options)
|
|
31
|
+
RSpec::Risky::Minitest::Plugin.install(options.fetch(:risky))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
class Configuration
|
|
6
|
+
VALID_RULES = %i[unknown_test redundant_print].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :unknown_test, :redundant_print
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@rules = VALID_RULES.dup
|
|
12
|
+
@unknown_test = UnknownTestConfig.new
|
|
13
|
+
@redundant_print = RedundantPrintConfig.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rules
|
|
17
|
+
@rules.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def rules=(rules)
|
|
21
|
+
selected_rules = Array(rules).map(&:to_sym)
|
|
22
|
+
unknown_rules = selected_rules - VALID_RULES
|
|
23
|
+
raise ArgumentError, "unknown risky rules: #{unknown_rules.join(", ")}" if unknown_rules.any?
|
|
24
|
+
|
|
25
|
+
@rules = selected_rules
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rule_enabled?(rule)
|
|
29
|
+
@rules.include?(rule.to_sym)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class RuleConfig
|
|
34
|
+
VALID_SEVERITIES = %i[risky fail].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :severity
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@severity = :risky
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def severity=(severity)
|
|
43
|
+
value = severity.to_sym
|
|
44
|
+
unless VALID_SEVERITIES.include?(value)
|
|
45
|
+
raise ArgumentError, "severity must be one of: #{VALID_SEVERITIES.join(", ")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@severity = value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class UnknownTestConfig < RuleConfig
|
|
53
|
+
attr_accessor :adapters, :count_mocks
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
super
|
|
57
|
+
@adapters = []
|
|
58
|
+
@count_mocks = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def adapter_count(example)
|
|
62
|
+
adapters.sum do |adapter|
|
|
63
|
+
Integer(adapter.call(example))
|
|
64
|
+
rescue StandardError
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class RedundantPrintConfig < RuleConfig
|
|
71
|
+
VALID_CAPTURE = %i[stdout stderr both].freeze
|
|
72
|
+
|
|
73
|
+
attr_accessor :allow_warn, :capture_loggers, :logger_ignore_paths, :passthrough, :strict
|
|
74
|
+
attr_reader :capture
|
|
75
|
+
|
|
76
|
+
def initialize
|
|
77
|
+
super
|
|
78
|
+
@capture = :both
|
|
79
|
+
@allow_warn = false
|
|
80
|
+
@capture_loggers = false
|
|
81
|
+
@logger_ignore_paths = ["log/test.log"]
|
|
82
|
+
@passthrough = true
|
|
83
|
+
@strict = false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def capture=(capture)
|
|
87
|
+
value = capture.to_sym
|
|
88
|
+
unless VALID_CAPTURE.include?(value)
|
|
89
|
+
raise ArgumentError, "capture must be one of: #{VALID_CAPTURE.join(", ")}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@capture = value
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def captures?(stream_name)
|
|
96
|
+
capture == :both || capture == stream_name.to_sym
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def captures_logger?(path)
|
|
100
|
+
capture_loggers && !ignored_logger_path?(path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def ignored_logger_path?(path)
|
|
106
|
+
return false unless path
|
|
107
|
+
|
|
108
|
+
logger_ignore_paths.any? { |ignored_path| path.end_with?(ignored_path) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Risky
|
|
7
|
+
module Evaluation
|
|
8
|
+
class << self
|
|
9
|
+
def compare(static_findings:, dynamic_payload:)
|
|
10
|
+
static = static_findings.map { |finding| normalize_static(finding) }
|
|
11
|
+
dynamic = dynamic_payload.fetch("examples", []).flat_map { |example| normalize_dynamic(example) }
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
dynamic_only: dynamic - static,
|
|
15
|
+
dynamic_total: dynamic.length,
|
|
16
|
+
overlap: dynamic & static,
|
|
17
|
+
static_only: static - dynamic,
|
|
18
|
+
static_total: static.length
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def assertion_density(dynamic_payload)
|
|
23
|
+
counts = dynamic_payload.fetch("examples", []).map do |example|
|
|
24
|
+
example.fetch("expectation_count", 0) +
|
|
25
|
+
example.fetch("mock_expectation_count", 0) +
|
|
26
|
+
example.fetch("custom_expectation_count", 0)
|
|
27
|
+
end
|
|
28
|
+
return {} if counts.empty?
|
|
29
|
+
|
|
30
|
+
sorted = counts.sort
|
|
31
|
+
{
|
|
32
|
+
average: counts.sum.fdiv(counts.length),
|
|
33
|
+
max: sorted.last,
|
|
34
|
+
min: sorted.first,
|
|
35
|
+
p50: percentile(sorted, 0.50),
|
|
36
|
+
p90: percentile(sorted, 0.90)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def label_template(dynamic_payload)
|
|
41
|
+
dynamic_payload.fetch("examples", []).flat_map do |example|
|
|
42
|
+
example.fetch("verdicts", []).map do |verdict|
|
|
43
|
+
{
|
|
44
|
+
location: example.fetch("location"),
|
|
45
|
+
rule: verdict.fetch("rule").to_s,
|
|
46
|
+
label: nil,
|
|
47
|
+
allowed_labels: %w[intentional_smoke true_missing_oracle false_positive],
|
|
48
|
+
description: example["description"]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mutation_study(dynamic_payload, mutation_payload = nil)
|
|
55
|
+
density = assertion_density(dynamic_payload)
|
|
56
|
+
scores = mutation_payload ? mutation_scores(dynamic_payload, mutation_payload) : []
|
|
57
|
+
return { assertion_density: density, mutation_correlation: nil, mutation_samples: 0 } if scores.empty?
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
assertion_density: density,
|
|
61
|
+
mutation_correlation: pearson(scores.map(&:first), scores.map(&:last)),
|
|
62
|
+
mutation_samples: scores.length
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def precision(labels_payload)
|
|
67
|
+
labels = labels_payload.fetch("labels", labels_payload)
|
|
68
|
+
total = labels.length
|
|
69
|
+
return { total: 0, precision: nil } if total.zero?
|
|
70
|
+
|
|
71
|
+
false_positives = labels.count { |label| label.fetch("label", label[:label]) == "false_positive" }
|
|
72
|
+
true_positives = total - false_positives
|
|
73
|
+
{
|
|
74
|
+
false_positives: false_positives,
|
|
75
|
+
precision: true_positives.fdiv(total),
|
|
76
|
+
total: total,
|
|
77
|
+
true_positives: true_positives
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def normalize_static(finding)
|
|
84
|
+
hash = finding.respond_to?(:to_h) ? finding.to_h : finding
|
|
85
|
+
{ location: hash.fetch(:location, hash["location"]), rule: hash.fetch(:rule, hash["rule"]).to_s }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_dynamic(example)
|
|
89
|
+
example.fetch("verdicts", []).map do |verdict|
|
|
90
|
+
{ location: example.fetch("location"), rule: verdict.fetch("rule").to_s }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def percentile(sorted, quantile)
|
|
95
|
+
sorted[((sorted.length - 1) * quantile).ceil]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mutation_scores(dynamic_payload, mutation_payload)
|
|
99
|
+
by_location = mutation_payload.fetch("examples", mutation_payload).to_h do |entry|
|
|
100
|
+
[entry.fetch("location"), entry.fetch("mutation_score").to_f]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
dynamic_payload.fetch("examples", []).filter_map do |example|
|
|
104
|
+
score = by_location[example.fetch("location")]
|
|
105
|
+
next unless score
|
|
106
|
+
|
|
107
|
+
[
|
|
108
|
+
example.fetch("expectation_count", 0) +
|
|
109
|
+
example.fetch("mock_expectation_count", 0) +
|
|
110
|
+
example.fetch("custom_expectation_count", 0),
|
|
111
|
+
score
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def pearson(xs, ys)
|
|
117
|
+
x_mean = xs.sum.fdiv(xs.length)
|
|
118
|
+
y_mean = ys.sum.fdiv(ys.length)
|
|
119
|
+
numerator = xs.zip(ys).sum { |x, y| (x - x_mean) * (y - y_mean) }
|
|
120
|
+
x_denominator = Math.sqrt(xs.sum { |x| (x - x_mean)**2 })
|
|
121
|
+
y_denominator = Math.sqrt(ys.sum { |y| (y - y_mean)**2 })
|
|
122
|
+
return nil if x_denominator.zero? || y_denominator.zero?
|
|
123
|
+
|
|
124
|
+
numerator / (x_denominator * y_denominator)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/core/formatters/base_text_formatter"
|
|
5
|
+
require "rspec/core/formatters/console_codes"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Risky
|
|
9
|
+
class Formatter < ::RSpec::Core::Formatters::BaseTextFormatter
|
|
10
|
+
::RSpec::Core::Formatters.register self,
|
|
11
|
+
:example_passed,
|
|
12
|
+
:example_pending,
|
|
13
|
+
:example_failed,
|
|
14
|
+
:start_dump,
|
|
15
|
+
:dump_summary
|
|
16
|
+
|
|
17
|
+
def initialize(output)
|
|
18
|
+
super
|
|
19
|
+
@examples = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def example_passed(notification)
|
|
23
|
+
@examples << notification.example
|
|
24
|
+
marker = risky?(notification.example) ? "R" : "."
|
|
25
|
+
color = risky?(notification.example) ? :pending : :success
|
|
26
|
+
output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap(marker, color)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def example_pending(notification)
|
|
30
|
+
@examples << notification.example
|
|
31
|
+
output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap("*", :pending)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def example_failed(notification)
|
|
35
|
+
@examples << notification.example
|
|
36
|
+
output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap("F", :failure)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start_dump(_notification)
|
|
40
|
+
output.puts
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dump_summary(summary)
|
|
44
|
+
dump_risky_examples
|
|
45
|
+
dump_assertion_density
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def risky?(example)
|
|
52
|
+
risky_verdicts(example).any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def risky_verdicts(example)
|
|
56
|
+
example.metadata.dig(:rspec_risky, :verdicts) || []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def dump_risky_examples
|
|
60
|
+
risky_examples = @examples.select { |example| risky?(example) }
|
|
61
|
+
return if risky_examples.empty?
|
|
62
|
+
|
|
63
|
+
output.puts
|
|
64
|
+
output.puts "Risky examples:"
|
|
65
|
+
risky_examples.each do |example|
|
|
66
|
+
risky_verdicts(example).each do |verdict|
|
|
67
|
+
output.puts " #{example.location} #{verdict.message}"
|
|
68
|
+
output.puts " #{format_evidence(verdict)}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
output.puts "Risky summary: #{format_rule_counts(risky_examples)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dump_assertion_density
|
|
75
|
+
counts = @examples.filter_map do |example|
|
|
76
|
+
result = example.metadata[:rspec_risky]
|
|
77
|
+
next unless result
|
|
78
|
+
|
|
79
|
+
result[:expectation_count] + result[:mock_expectation_count]
|
|
80
|
+
end
|
|
81
|
+
return if counts.empty?
|
|
82
|
+
|
|
83
|
+
sorted = counts.sort
|
|
84
|
+
average = counts.sum.fdiv(counts.length)
|
|
85
|
+
|
|
86
|
+
output.puts
|
|
87
|
+
output.puts format(
|
|
88
|
+
"Assertion density: min=%<min>d p50=%<p50>d p90=%<p90>d max=%<max>d avg=%<avg>.2f",
|
|
89
|
+
min: sorted.first,
|
|
90
|
+
p50: percentile(sorted, 0.50),
|
|
91
|
+
p90: percentile(sorted, 0.90),
|
|
92
|
+
max: sorted.last,
|
|
93
|
+
avg: average
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def percentile(sorted, quantile)
|
|
98
|
+
sorted[((sorted.length - 1) * quantile).ceil]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def format_rule_counts(examples)
|
|
102
|
+
counts = Hash.new(0)
|
|
103
|
+
examples.each do |example|
|
|
104
|
+
risky_verdicts(example).each { |verdict| counts[verdict.rule] += 1 }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
counts.sort_by { |rule, _count| rule.to_s }.map { |rule, count| "#{rule}=#{count}" }.join(", ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_evidence(verdict)
|
|
111
|
+
case verdict.rule
|
|
112
|
+
when :unknown_test
|
|
113
|
+
"expectations=#{verdict.evidence.fetch(:expectation_count)}, " \
|
|
114
|
+
"mocks=#{verdict.evidence.fetch(:mock_expectation_count)}, " \
|
|
115
|
+
"custom=#{verdict.evidence.fetch(:custom_expectation_count)}"
|
|
116
|
+
when :redundant_print
|
|
117
|
+
verdict.evidence.map do |stream_name, evidence|
|
|
118
|
+
location = evidence[:first_location] || "unknown"
|
|
119
|
+
sample = evidence[:first_sample].to_s.inspect
|
|
120
|
+
"#{stream_name} #{evidence[:write_count]} writes, #{evidence[:byte_count]} bytes; first at #{location} #{sample}"
|
|
121
|
+
end.join("; ")
|
|
122
|
+
else
|
|
123
|
+
verdict.evidence.inspect
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rspec/core"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Risky
|
|
8
|
+
class JsonEventFormatter
|
|
9
|
+
::RSpec::Core::Formatters.register self, :example_finished
|
|
10
|
+
|
|
11
|
+
def initialize(output)
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def example_finished(notification)
|
|
16
|
+
result = notification.example.metadata[:rspec_risky]
|
|
17
|
+
return unless result
|
|
18
|
+
|
|
19
|
+
result[:verdicts].each do |verdict|
|
|
20
|
+
@output.write(JSON.generate(event_payload(notification.example, result, verdict)))
|
|
21
|
+
@output.write("\n")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def event_payload(example, result, verdict)
|
|
28
|
+
{
|
|
29
|
+
event: "rspec_risky.verdict",
|
|
30
|
+
example_id: example.id,
|
|
31
|
+
description: example.full_description,
|
|
32
|
+
location: example.location,
|
|
33
|
+
expectation_count: result[:expectation_count],
|
|
34
|
+
mock_expectation_count: result[:mock_expectation_count],
|
|
35
|
+
custom_expectation_count: result[:custom_expectation_count],
|
|
36
|
+
verdict: verdict.to_h
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rspec/core"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Risky
|
|
8
|
+
class JsonFormatter
|
|
9
|
+
::RSpec::Core::Formatters.register self, :dump_summary
|
|
10
|
+
|
|
11
|
+
def initialize(output)
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dump_summary(summary)
|
|
16
|
+
@output.write(JSON.pretty_generate(payload(summary)))
|
|
17
|
+
@output.write("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def payload(summary)
|
|
23
|
+
risky_examples = RSpec::Risky.risky_results.select { |_example, result| result[:verdicts].any? }
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
summary: summary_payload(summary, risky_examples),
|
|
27
|
+
examples: risky_examples.map { |example, result| example_payload(example, result) }
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summary_payload(summary, risky_examples)
|
|
32
|
+
{
|
|
33
|
+
duration: summary.duration,
|
|
34
|
+
example_count: summary.example_count,
|
|
35
|
+
failure_count: summary.failure_count,
|
|
36
|
+
pending_count: summary.pending_count,
|
|
37
|
+
risky_count: risky_examples.length,
|
|
38
|
+
risky_rules: rule_counts(risky_examples)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def example_payload(example, result)
|
|
43
|
+
{
|
|
44
|
+
id: example.id,
|
|
45
|
+
description: example.full_description,
|
|
46
|
+
location: example.location,
|
|
47
|
+
custom_expectation_count: result[:custom_expectation_count],
|
|
48
|
+
expectation_count: result[:expectation_count],
|
|
49
|
+
mock_expectation_count: result[:mock_expectation_count],
|
|
50
|
+
verdicts: result[:verdicts].map(&:to_h)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rule_counts(risky_examples)
|
|
55
|
+
risky_examples.each_with_object(Hash.new(0)) do |(_example, result), counts|
|
|
56
|
+
result[:verdicts].each { |verdict| counts[verdict.rule] += 1 }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|