rspec-signal 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/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +672 -0
- data/exe/rspec-signal +11 -0
- data/exe/rspec-signal-parallel +52 -0
- data/lib/rspec/signal/backtrace/classifier.rb +88 -0
- data/lib/rspec/signal/backtrace/frame.rb +31 -0
- data/lib/rspec/signal/backtrace/parser.rb +61 -0
- data/lib/rspec/signal/backtrace/reducer.rb +244 -0
- data/lib/rspec/signal/cluster.rb +68 -0
- data/lib/rspec/signal/clusterer.rb +49 -0
- data/lib/rspec/signal/configuration.rb +144 -0
- data/lib/rspec/signal/failure.rb +62 -0
- data/lib/rspec/signal/failure_builder.rb +224 -0
- data/lib/rspec/signal/fingerprint.rb +56 -0
- data/lib/rspec/signal/formatter.rb +207 -0
- data/lib/rspec/signal/group.rb +61 -0
- data/lib/rspec/signal/grouper.rb +28 -0
- data/lib/rspec/signal/html_summary.rb +224 -0
- data/lib/rspec/signal/integrations/capybara.rb +102 -0
- data/lib/rspec/signal/message.rb +161 -0
- data/lib/rspec/signal/parallel_merger.rb +125 -0
- data/lib/rspec/signal/parallel_run.rb +56 -0
- data/lib/rspec/signal/project.rb +158 -0
- data/lib/rspec/signal/redactor.rb +91 -0
- data/lib/rspec/signal/report.rb +88 -0
- data/lib/rspec/signal/reporters/full_output.rb +38 -0
- data/lib/rspec/signal/reporters/json_report.rb +20 -0
- data/lib/rspec/signal/reporters/markdown.rb +271 -0
- data/lib/rspec/signal/reporters/related_failures.rb +101 -0
- data/lib/rspec/signal/symptom.rb +22 -0
- data/lib/rspec/signal/symptoms/exception_class.rb +46 -0
- data/lib/rspec/signal/symptoms/http_status.rb +98 -0
- data/lib/rspec/signal/symptoms/record.rb +56 -0
- data/lib/rspec/signal/symptoms/route.rb +45 -0
- data/lib/rspec/signal/symptoms/ruby_error.rb +55 -0
- data/lib/rspec/signal/symptoms/selector.rb +72 -0
- data/lib/rspec/signal/symptoms.rb +42 -0
- data/lib/rspec/signal/version.rb +7 -0
- data/lib/rspec/signal/writer.rb +88 -0
- data/lib/rspec/signal.rb +156 -0
- data/lib/rspec-signal.rb +3 -0
- metadata +117 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Everything tunable, with defaults chosen to need no tuning.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Where artifacts are written, relative to the project root unless absolute.
|
|
8
|
+
attr_accessor :output_dir
|
|
9
|
+
|
|
10
|
+
# Backtrace reduction budgets.
|
|
11
|
+
attr_accessor :max_frames, :max_external_context, :max_project_frames, :fallback_frames
|
|
12
|
+
|
|
13
|
+
# Message budgets. `max_html_chars` is the smallest HTML blob replaced by
|
|
14
|
+
# a summary; `reduce_html` turns that off entirely.
|
|
15
|
+
attr_accessor :max_message_lines, :max_diff_lines, :reduce_html, :max_html_chars
|
|
16
|
+
|
|
17
|
+
# Report budgets. `max_affected_examples` caps the per-group list of other
|
|
18
|
+
# failing examples; `max_groups` caps how many signatures are rendered in
|
|
19
|
+
# full (nil means all).
|
|
20
|
+
attr_accessor :max_affected_examples, :max_groups
|
|
21
|
+
|
|
22
|
+
# Related-failure clustering. `relate_failures` turns the whole layer off;
|
|
23
|
+
# the budgets cap how much of it reaches the Markdown report.
|
|
24
|
+
attr_accessor :relate_failures, :max_clusters, :max_cluster_specs
|
|
25
|
+
|
|
26
|
+
# Secret scrubbing.
|
|
27
|
+
attr_accessor :redact, :redaction_patterns, :redaction_filter
|
|
28
|
+
|
|
29
|
+
# Artifacts.
|
|
30
|
+
attr_accessor :write_json, :write_full, :write_gitignore
|
|
31
|
+
|
|
32
|
+
# Behaviour.
|
|
33
|
+
attr_accessor :enabled, :terminal_summary, :capture_capybara, :capture_page_html
|
|
34
|
+
|
|
35
|
+
# Classification.
|
|
36
|
+
attr_accessor :project_root, :extra_first_party, :framework_patterns, :ignore_patterns
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
default_budgets
|
|
40
|
+
default_artifacts
|
|
41
|
+
default_classification
|
|
42
|
+
|
|
43
|
+
@output_dir = ENV.fetch("RSPEC_SIGNAL_OUTPUT_DIR", "tmp/rspec-signal")
|
|
44
|
+
@enabled = !truthy?(ENV.fetch("RSPEC_SIGNAL_DISABLE", nil))
|
|
45
|
+
@terminal_summary = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enabled? = !!@enabled
|
|
49
|
+
def redact? = !!@redact
|
|
50
|
+
|
|
51
|
+
# Nil means "leave HTML alone", which is what {Message} expects.
|
|
52
|
+
def html_threshold = reduce_html ? max_html_chars : nil
|
|
53
|
+
|
|
54
|
+
def root
|
|
55
|
+
@root ||= File.expand_path(@project_root || default_root)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def output_path
|
|
59
|
+
@output_path ||= File.expand_path(@output_dir, root)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def project
|
|
63
|
+
@project ||= Project.new(root: root, extra_first_party: extra_first_party)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def redactor
|
|
67
|
+
@redactor ||= Redactor.new(
|
|
68
|
+
enabled: redact?,
|
|
69
|
+
extra_patterns: redaction_patterns,
|
|
70
|
+
filter: redaction_filter
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def classifier
|
|
75
|
+
@classifier ||= Backtrace::Classifier.new(
|
|
76
|
+
project: project,
|
|
77
|
+
framework_patterns: framework_patterns,
|
|
78
|
+
ignore_patterns: ignore_patterns
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reducer
|
|
83
|
+
@reducer ||= Backtrace::Reducer.new(
|
|
84
|
+
max_frames: max_frames,
|
|
85
|
+
max_external_context: max_external_context,
|
|
86
|
+
max_project_frames: max_project_frames,
|
|
87
|
+
fallback_frames: fallback_frames
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Memoized collaborators must be rebuilt if the user reconfigures.
|
|
92
|
+
def reset_memoized!
|
|
93
|
+
@root = @output_path = @project = @redactor = @classifier = @reducer = nil
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def default_budgets
|
|
100
|
+
@max_frames = 12
|
|
101
|
+
@max_external_context = 3
|
|
102
|
+
@max_project_frames = 8
|
|
103
|
+
@fallback_frames = 6
|
|
104
|
+
@max_message_lines = 30
|
|
105
|
+
@max_diff_lines = 20
|
|
106
|
+
@max_html_chars = Message::DEFAULT_HTML_THRESHOLD
|
|
107
|
+
@max_affected_examples = 25
|
|
108
|
+
@max_groups = nil
|
|
109
|
+
@max_clusters = 10
|
|
110
|
+
@max_cluster_specs = 6
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def default_artifacts
|
|
114
|
+
@reduce_html = true
|
|
115
|
+
@relate_failures = true
|
|
116
|
+
@redact = true
|
|
117
|
+
@redaction_patterns = []
|
|
118
|
+
@redaction_filter = nil
|
|
119
|
+
@write_json = true
|
|
120
|
+
@write_full = false
|
|
121
|
+
@write_gitignore = true
|
|
122
|
+
@capture_capybara = true
|
|
123
|
+
@capture_page_html = false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_classification
|
|
127
|
+
@project_root = nil
|
|
128
|
+
@extra_first_party = []
|
|
129
|
+
@framework_patterns = Backtrace::Classifier::DEFAULT_FRAMEWORK_PATTERNS.dup
|
|
130
|
+
@ignore_patterns = Backtrace::Classifier::DEFAULT_IGNORE_PATTERNS.dup
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def default_root
|
|
134
|
+
return ::Rails.root.to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
135
|
+
|
|
136
|
+
Dir.pwd
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def truthy?(value)
|
|
140
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# One failed example, normalized and reduced. Pure data -- it knows nothing
|
|
6
|
+
# about RSpec, so every downstream stage is trivially testable.
|
|
7
|
+
class Failure
|
|
8
|
+
attr_reader :description, :spec_location, :rerun, :example_id,
|
|
9
|
+
:exception_class, :message, :reduced, :frames,
|
|
10
|
+
:diagnostics, :shared_group_locations, :raw
|
|
11
|
+
|
|
12
|
+
def initialize(description:, spec_location:, exception_class:, message:, reduced:, frames:,
|
|
13
|
+
rerun: nil, example_id: nil, diagnostics: {}, shared_group_locations: [],
|
|
14
|
+
raw: nil, fingerprint: nil)
|
|
15
|
+
@description = description
|
|
16
|
+
@spec_location = spec_location
|
|
17
|
+
@exception_class = exception_class
|
|
18
|
+
@message = message
|
|
19
|
+
@reduced = reduced
|
|
20
|
+
@frames = frames
|
|
21
|
+
@rerun = rerun || spec_location
|
|
22
|
+
@example_id = example_id
|
|
23
|
+
@diagnostics = diagnostics
|
|
24
|
+
@shared_group_locations = shared_group_locations
|
|
25
|
+
@raw = raw
|
|
26
|
+
@fingerprint = fingerprint
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fingerprint
|
|
30
|
+
@fingerprint ||= Fingerprint.for(
|
|
31
|
+
exception_class: exception_class,
|
|
32
|
+
message: message.normalized,
|
|
33
|
+
frames: frames,
|
|
34
|
+
fallback_location: spec_location
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The one diagnostic characteristic this failure clusters on, if any.
|
|
39
|
+
# `nil` is the common and safe answer.
|
|
40
|
+
def symptom
|
|
41
|
+
return @symptom if defined?(@symptom)
|
|
42
|
+
|
|
43
|
+
@symptom = Symptoms.for(self)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
description: description,
|
|
49
|
+
location: spec_location,
|
|
50
|
+
rerun: rerun,
|
|
51
|
+
id: example_id,
|
|
52
|
+
exception: exception_class,
|
|
53
|
+
message: message.body,
|
|
54
|
+
trace: reduced.entries.map(&:to_h),
|
|
55
|
+
omitted_frames: reduced.omitted_count,
|
|
56
|
+
diagnostics: diagnostics,
|
|
57
|
+
symptom: symptom&.to_h
|
|
58
|
+
}.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Turns an RSpec `FailedExampleNotification` into a plain {Failure}.
|
|
6
|
+
#
|
|
7
|
+
# This is the only place that touches RSpec's notification API, which keeps
|
|
8
|
+
# the reduction, grouping and rendering stages testable without booting a
|
|
9
|
+
# suite.
|
|
10
|
+
class FailureBuilder
|
|
11
|
+
SCREENSHOT = /\[Screenshot(?:\s+Image)?\]:\s*(\S+)/i
|
|
12
|
+
MAX_CAUSE_DEPTH = 3
|
|
13
|
+
MAX_CAUSE_MESSAGE_LINES = 5
|
|
14
|
+
MAX_CAUSE_SCAN = 40
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param notification [RSpec::Core::Notifications::FailedExampleNotification]
|
|
21
|
+
# @param position [Integer] 1-based failure number, used for the raw dump
|
|
22
|
+
# @return [Failure]
|
|
23
|
+
def call(notification, position: nil)
|
|
24
|
+
example = notification.example
|
|
25
|
+
exception = notification.exception
|
|
26
|
+
extra = Array(example.metadata[:extra_failure_lines])
|
|
27
|
+
frames = Backtrace::Parser.parse(backtrace_for(exception), @config.classifier)
|
|
28
|
+
|
|
29
|
+
Failure.new(
|
|
30
|
+
**identity(example),
|
|
31
|
+
exception_class: exception_class_name(exception),
|
|
32
|
+
message: message_for(notification, exception, extra),
|
|
33
|
+
reduced: @config.reducer.call(frames),
|
|
34
|
+
frames: frames,
|
|
35
|
+
diagnostics: diagnostics(example, extra),
|
|
36
|
+
shared_group_locations: shared_group_locations(example),
|
|
37
|
+
raw: raw_output(notification, position)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def identity(example)
|
|
44
|
+
{
|
|
45
|
+
description: example.full_description,
|
|
46
|
+
spec_location: display_location(example),
|
|
47
|
+
rerun: safe(example) { example.location_rerun_argument&.sub(%r{\A\./}, "") },
|
|
48
|
+
example_id: safe(example) { example.id }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message_for(notification, exception, extra)
|
|
53
|
+
appended = extra + shared_group_descriptions(notification.example)
|
|
54
|
+
Message.new(message_lines_for(notification, appended),
|
|
55
|
+
redactor: @config.redactor, project: @config.project,
|
|
56
|
+
html_threshold: @config.html_threshold, cause_lines: cause_lines(exception))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# We use the exception's own backtrace rather than RSpec's filtered one:
|
|
60
|
+
# RSpec's filter is all-or-nothing per line and we need to make the
|
|
61
|
+
# keep/drop decision ourselves.
|
|
62
|
+
#
|
|
63
|
+
# An aggregated failure is the exception: its own backtrace is the
|
|
64
|
+
# aggregator's internals and explains nothing, while each sub-failure
|
|
65
|
+
# carries the real one.
|
|
66
|
+
def backtrace_for(exception)
|
|
67
|
+
sub_exceptions(exception).first&.backtrace || exception.backtrace || []
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sub_exceptions(exception)
|
|
71
|
+
return [] unless exception.respond_to?(:all_exceptions)
|
|
72
|
+
|
|
73
|
+
Array(exception.all_exceptions)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# For an aggregated failure RSpec deliberately empties `message_lines` and
|
|
79
|
+
# moves the detail into formatter-only callbacks, so fall back to the
|
|
80
|
+
# rendered output and strip the parts we render ourselves.
|
|
81
|
+
def message_lines_for(notification, appended)
|
|
82
|
+
lines = strip_appended(notification.message_lines, appended)
|
|
83
|
+
return lines unless lines.all? { |line| line.to_s.strip.empty? }
|
|
84
|
+
|
|
85
|
+
rendered_message_lines(notification)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rendered_message_lines(notification)
|
|
89
|
+
text = notification.fully_formatted(nil, ::RSpec::Core::Notifications::NullColorizer)
|
|
90
|
+
lines = text.to_s.split("\n")
|
|
91
|
+
.reject { |line| line.strip.start_with?("# ") } # RSpec's own backtrace
|
|
92
|
+
.drop_while { |line| line.strip.empty? }
|
|
93
|
+
lines.shift if lines.first.to_s.strip == notification.example.full_description
|
|
94
|
+
dedent(lines)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dedent(lines)
|
|
100
|
+
present = lines.reject { |line| line.strip.empty? }
|
|
101
|
+
return lines if present.empty?
|
|
102
|
+
|
|
103
|
+
indent = present.map { |line| line[/\A */].length }.min
|
|
104
|
+
lines.map { |line| line[indent..] || "" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# RSpec puts the "Caused by" chain in the *backtrace*, not the message, so
|
|
108
|
+
# it disappears the moment you reduce a backtrace. The root cause is very
|
|
109
|
+
# often the actual answer -- a PG::UniqueViolation behind a bland
|
|
110
|
+
# RuntimeError -- so it is folded into the message, where it also becomes
|
|
111
|
+
# part of the fingerprint.
|
|
112
|
+
def cause_lines(exception)
|
|
113
|
+
causes(exception).flat_map do |cause|
|
|
114
|
+
lines = ["", "Caused by #{exception_class_name(cause)}:"]
|
|
115
|
+
lines.concat(cause.message.to_s.split("\n").first(MAX_CAUSE_MESSAGE_LINES).map { |line| " #{line}" })
|
|
116
|
+
origin = cause_origin(cause)
|
|
117
|
+
lines << " at #{origin}" if origin
|
|
118
|
+
lines
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError
|
|
121
|
+
[]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def causes(exception)
|
|
125
|
+
chain = []
|
|
126
|
+
seen = [exception]
|
|
127
|
+
current = exception
|
|
128
|
+
|
|
129
|
+
while (current = current.cause) && !seen.include?(current) && chain.size < MAX_CAUSE_DEPTH
|
|
130
|
+
seen << current
|
|
131
|
+
chain << current
|
|
132
|
+
end
|
|
133
|
+
chain
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The first frame of the cause that belongs to the project, so the reader
|
|
137
|
+
# knows where to look without us rendering a second full trace.
|
|
138
|
+
def cause_origin(cause)
|
|
139
|
+
frames = Backtrace::Parser.parse(Array(cause.backtrace).first(MAX_CAUSE_SCAN), @config.classifier)
|
|
140
|
+
frame = frames.find(&:project?) || frames.reject(&:framework?).first
|
|
141
|
+
frame&.location
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def exception_class_name(exception)
|
|
145
|
+
name = exception.class.name.to_s
|
|
146
|
+
name.empty? ? "(anonymous error class)" : name
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def display_location(example)
|
|
150
|
+
location = safe(example) { example.location } || ""
|
|
151
|
+
@config.project.display_path(location.sub(/:(\d+)\z/, "")) + location[/:(\d+)\z/].to_s
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def shared_group_descriptions(example)
|
|
155
|
+
Array(example.metadata[:shared_group_inclusion_backtrace]).map { |frame| frame.description.to_s }
|
|
156
|
+
rescue StandardError
|
|
157
|
+
[]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def shared_group_locations(example)
|
|
161
|
+
Array(example.metadata[:shared_group_inclusion_backtrace]).filter_map do |frame|
|
|
162
|
+
next unless frame.respond_to?(:inclusion_location)
|
|
163
|
+
|
|
164
|
+
location = frame.inclusion_location.to_s.sub(/:in\s+[`'].*\z/, "")
|
|
165
|
+
parsed = Backtrace::Parser.parse_line(location)
|
|
166
|
+
next unless parsed
|
|
167
|
+
|
|
168
|
+
rendered = "#{@config.project.display_path(parsed.path)}:#{parsed.line}"
|
|
169
|
+
"#{frame.shared_group_name.inspect} at #{rendered}"
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# RSpec appends screenshot output and shared-group breadcrumbs to
|
|
176
|
+
# `message_lines`. We surface those separately, so peel them back off.
|
|
177
|
+
def strip_appended(lines, appended)
|
|
178
|
+
result = Array(lines).dup
|
|
179
|
+
drop = appended.map(&:to_s).map(&:rstrip).reject(&:empty?)
|
|
180
|
+
while result.any?
|
|
181
|
+
last = result.last.to_s.rstrip
|
|
182
|
+
break unless last.empty? || drop.include?(last)
|
|
183
|
+
|
|
184
|
+
result.pop
|
|
185
|
+
end
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def diagnostics(example, extra_failure_lines)
|
|
190
|
+
captured = example.metadata[:rspec_signal_diagnostics]
|
|
191
|
+
diagnostics = captured.is_a?(Hash) ? captured.dup : {}
|
|
192
|
+
|
|
193
|
+
screenshots = extra_failure_lines.flat_map { |line| line.to_s.scan(SCREENSHOT).flatten }
|
|
194
|
+
diagnostics[:screenshot] ||= relative(screenshots.first) if screenshots.any?
|
|
195
|
+
|
|
196
|
+
diagnostics = diagnostics.transform_values { |value| scrub(value) }
|
|
197
|
+
diagnostics.reject { |_, value| value.nil? || value.to_s.strip.empty? }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def scrub(value)
|
|
201
|
+
return value.map { |item| @config.redactor.call(item.to_s) } if value.is_a?(Array)
|
|
202
|
+
|
|
203
|
+
@config.redactor.call(value.to_s)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def relative(path)
|
|
207
|
+
@config.project.display_path(path.to_s)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def raw_output(notification, position)
|
|
211
|
+
text = notification.fully_formatted(position, ::RSpec::Core::Notifications::NullColorizer)
|
|
212
|
+
@config.redactor.call(text)
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
" #{position}) [rspec-signal could not render the original output: #{e.class}]"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def safe(_example)
|
|
218
|
+
yield
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Signal
|
|
7
|
+
# A deterministic identity for a failure, used to collapse repeats.
|
|
8
|
+
#
|
|
9
|
+
# Four components, in decreasing order of how often they matter:
|
|
10
|
+
#
|
|
11
|
+
# exception_class ArgumentError, Capybara::ElementNotFound, ...
|
|
12
|
+
# message normalized (ids, addresses, timestamps and paths masked)
|
|
13
|
+
# culprit innermost frame that is not test-runner plumbing --
|
|
14
|
+
# the code that actually raised
|
|
15
|
+
# app_context innermost first-party frame outside the spec suite --
|
|
16
|
+
# nil for pure matcher failures, decisive when two
|
|
17
|
+
# different call sites produce the same error
|
|
18
|
+
#
|
|
19
|
+
# Notably absent: the example description and the example's own location.
|
|
20
|
+
# Fourteen specs that all trip over the same missing DOM node are one
|
|
21
|
+
# problem, not fourteen.
|
|
22
|
+
Fingerprint = Struct.new(:exception_class, :message, :culprit, :app_context, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# Constants inside a `Struct.new` block would land on the enclosing module,
|
|
25
|
+
# so define this one explicitly.
|
|
26
|
+
Fingerprint::DEFAULT_SPEC_PATTERNS = [%r{\Aspec/}, %r{\Atest/}, /_spec\.rb\z/, /_test\.rb\z/].freeze
|
|
27
|
+
|
|
28
|
+
# Construction and rendering for the fingerprint value object above.
|
|
29
|
+
class Fingerprint
|
|
30
|
+
def self.for(exception_class:, message:, frames:, fallback_location:, spec_patterns: DEFAULT_SPEC_PATTERNS)
|
|
31
|
+
significant = frames.reject(&:framework?)
|
|
32
|
+
culprit = significant.first
|
|
33
|
+
app_context = significant.find do |frame|
|
|
34
|
+
frame.project? && spec_patterns.none? { |pattern| pattern.match?(frame.display_path) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
new(
|
|
38
|
+
exception_class: exception_class.to_s,
|
|
39
|
+
message: message.to_s,
|
|
40
|
+
culprit: culprit&.location || fallback_location,
|
|
41
|
+
app_context: app_context&.location
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Joined on NUL so that shifting text from one component into the next
|
|
46
|
+
# cannot produce the same digest.
|
|
47
|
+
def digest
|
|
48
|
+
@digest ||= Digest::SHA256.hexdigest(to_a.join("\0"))[0, 12]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{ exception: exception_class, culprit: culprit, app_context: app_context, digest: digest }.compact
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/core/formatters"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Signal
|
|
8
|
+
# The RSpec formatter. Collects failures as they happen, then writes the
|
|
9
|
+
# artifacts once the run is over.
|
|
10
|
+
#
|
|
11
|
+
# When selected explicitly it is the only formatter, suppressing RSpec's
|
|
12
|
+
# verbose failure renderer. When auto-installed it restores the default
|
|
13
|
+
# formatter so requiring the gem does not change normal human output.
|
|
14
|
+
class Formatter
|
|
15
|
+
::RSpec::Core::Formatters.register self, :start, :example_passed, :example_failed,
|
|
16
|
+
:example_pending, :dump_summary, :seed, :close
|
|
17
|
+
|
|
18
|
+
PROGRESS_WIDTH = 20
|
|
19
|
+
|
|
20
|
+
attr_reader :output
|
|
21
|
+
|
|
22
|
+
def initialize(output)
|
|
23
|
+
@output = output
|
|
24
|
+
@failures = []
|
|
25
|
+
@errors = []
|
|
26
|
+
@summary = {}
|
|
27
|
+
@seed = nil
|
|
28
|
+
@seed_used = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def config = RSpec::Signal.configuration
|
|
32
|
+
|
|
33
|
+
def start(notification)
|
|
34
|
+
# Adding a formatter suppresses RSpec's default one. When rspec-signal
|
|
35
|
+
# installed itself, the user never asked for that, so put it back.
|
|
36
|
+
RSpec::Signal.restore_default_formatter! if RSpec::Signal.auto_installed? && !RSpec::Signal.quiet_mode?
|
|
37
|
+
start_progress(notification.count)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def example_passed(_notification)
|
|
41
|
+
advance_progress
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def example_pending(_notification)
|
|
45
|
+
advance_progress
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def example_failed(notification)
|
|
49
|
+
return unless config.enabled?
|
|
50
|
+
|
|
51
|
+
@failures << builder.call(notification, position: @failures.size + 1)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
record_error(e)
|
|
54
|
+
ensure
|
|
55
|
+
advance_progress
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def dump_summary(notification)
|
|
59
|
+
@summary = {
|
|
60
|
+
example_count: notification.example_count,
|
|
61
|
+
failure_count: notification.failure_count,
|
|
62
|
+
pending_count: notification.pending_count,
|
|
63
|
+
duration: notification.duration,
|
|
64
|
+
errors_outside_examples: notification.errors_outside_of_examples_count
|
|
65
|
+
}
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
record_error(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def seed(notification)
|
|
71
|
+
@seed = notification.seed
|
|
72
|
+
@seed_used = notification.seed_used?
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
record_error(e)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def close(_notification)
|
|
78
|
+
return unless config.enabled?
|
|
79
|
+
|
|
80
|
+
finish_progress
|
|
81
|
+
if ParallelRun.worker?
|
|
82
|
+
ParallelRun.write_worker(report, config)
|
|
83
|
+
else
|
|
84
|
+
result = writer.write(report)
|
|
85
|
+
print_summary(result)
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
record_error(e)
|
|
89
|
+
ensure
|
|
90
|
+
warn_about_errors
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Report] exposed for testing and for tools that embed the gem.
|
|
94
|
+
def report
|
|
95
|
+
Report.new(
|
|
96
|
+
failures: @failures,
|
|
97
|
+
example_count: @summary.fetch(:example_count, 0),
|
|
98
|
+
failure_count: @summary.fetch(:failure_count, @failures.size),
|
|
99
|
+
pending_count: @summary.fetch(:pending_count, 0),
|
|
100
|
+
duration: @summary[:duration],
|
|
101
|
+
seed: @seed,
|
|
102
|
+
seed_used: @seed_used,
|
|
103
|
+
environment: RSpec::Signal.environment,
|
|
104
|
+
errors_outside_examples: @summary.fetch(:errors_outside_examples, 0),
|
|
105
|
+
relate_failures: config.relate_failures
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def start_progress(total)
|
|
112
|
+
return unless config.enabled? && RSpec::Signal.quiet_mode?
|
|
113
|
+
return if ParallelRun.worker? || !@output.respond_to?(:tty?) || !@output.tty?
|
|
114
|
+
return unless total.to_i.positive?
|
|
115
|
+
|
|
116
|
+
@progress_total = total.to_i
|
|
117
|
+
@progress_completed = 0
|
|
118
|
+
render_progress
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def advance_progress
|
|
122
|
+
return unless @progress_total
|
|
123
|
+
|
|
124
|
+
@progress_completed = [@progress_completed + 1, @progress_total].min
|
|
125
|
+
render_progress
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render_progress
|
|
129
|
+
percentage = (@progress_completed * 100) / @progress_total
|
|
130
|
+
filled = (@progress_completed * PROGRESS_WIDTH) / @progress_total
|
|
131
|
+
bar = ("█" * filled) + ("░" * (PROGRESS_WIDTH - filled))
|
|
132
|
+
@output.print "\rsignal [#{bar}] #{percentage}% #{@progress_completed}/#{@progress_total}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def finish_progress
|
|
136
|
+
return unless @progress_total
|
|
137
|
+
|
|
138
|
+
@output.puts
|
|
139
|
+
@progress_total = nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def builder = @builder ||= FailureBuilder.new(config)
|
|
143
|
+
def writer = @writer ||= Writer.new(config)
|
|
144
|
+
|
|
145
|
+
def print_summary(result)
|
|
146
|
+
return unless config.terminal_summary
|
|
147
|
+
|
|
148
|
+
current = report
|
|
149
|
+
if quiet_success?(result)
|
|
150
|
+
@output.puts
|
|
151
|
+
print_rspec_summary(current)
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
return if @failures.empty? && result.summary_path.nil?
|
|
155
|
+
|
|
156
|
+
@output.puts
|
|
157
|
+
print_rspec_summary(current) if RSpec::Signal.quiet_mode?
|
|
158
|
+
@output.puts "rspec-signal: #{current.failure_count} " \
|
|
159
|
+
"#{current.failure_count == 1 ? "failure" : "failures"} in " \
|
|
160
|
+
"#{current.group_count} distinct " \
|
|
161
|
+
"#{current.group_count == 1 ? "signature" : "signatures"}" \
|
|
162
|
+
"#{cluster_note(current)}#{omission_note(current)}"
|
|
163
|
+
@output.puts "Report: #{writer.relative(result.summary_path)}" if result.summary_path
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
record_error(e)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def print_rspec_summary(current)
|
|
169
|
+
@output.puts "#{current.example_count} examples, #{current.failure_count} failures, " \
|
|
170
|
+
"#{current.pending_count} pending"
|
|
171
|
+
@output.puts
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def quiet_success?(result)
|
|
175
|
+
RSpec::Signal.quiet_mode? && result.summary_path.nil?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def cluster_note(current)
|
|
179
|
+
return "" unless current.cluster_count.positive?
|
|
180
|
+
|
|
181
|
+
", #{current.cluster_count} related #{current.cluster_count == 1 ? "cluster" : "clusters"}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def omission_note(current)
|
|
185
|
+
return "" unless current.omitted_frames.positive?
|
|
186
|
+
|
|
187
|
+
" (#{current.omitted_frames} backtrace frames omitted)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def record_error(error)
|
|
191
|
+
@errors << error
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def warn_about_errors
|
|
195
|
+
return if @errors.empty?
|
|
196
|
+
|
|
197
|
+
first = @errors.first
|
|
198
|
+
@output.puts "rspec-signal: #{@errors.size} internal " \
|
|
199
|
+
"#{@errors.size == 1 ? "error" : "errors"} while building the report " \
|
|
200
|
+
"(#{first.class}: #{first.message})"
|
|
201
|
+
@output.puts first.backtrace.first(5).map { |line| " #{line}" }.join("\n") if ENV["RSPEC_SIGNAL_DEBUG"]
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|