rspec-signal 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +326 -38
- data/exe/rspec-signal +9 -0
- data/exe/rspec-signal-parallel +12 -5
- data/lib/rspec/signal/causal/analysis.rb +292 -0
- data/lib/rspec/signal/causal/census.rb +86 -0
- data/lib/rspec/signal/causal/entities.rb +67 -0
- data/lib/rspec/signal/causal/evidence.rb +144 -0
- data/lib/rspec/signal/causal/phase.rb +98 -0
- data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
- data/lib/rspec/signal/code_paths.rb +101 -0
- data/lib/rspec/signal/comparison.rb +101 -0
- data/lib/rspec/signal/configuration.rb +18 -2
- data/lib/rspec/signal/failure.rb +26 -3
- data/lib/rspec/signal/failure_builder.rb +17 -2
- data/lib/rspec/signal/fingerprint.rb +18 -2
- data/lib/rspec/signal/formatter.rb +121 -69
- data/lib/rspec/signal/group.rb +20 -3
- data/lib/rspec/signal/history.rb +102 -0
- data/lib/rspec/signal/message.rb +54 -1
- data/lib/rspec/signal/outside_example.rb +148 -0
- data/lib/rspec/signal/parallel_merger.rb +67 -7
- data/lib/rspec/signal/parallel_run.rb +4 -3
- data/lib/rspec/signal/progress_bar.rb +48 -0
- data/lib/rspec/signal/report.rb +119 -23
- data/lib/rspec/signal/reporters/json_report.rb +3 -2
- data/lib/rspec/signal/reporters/markdown.rb +29 -21
- data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
- data/lib/rspec/signal/reporters/relationships.rb +228 -0
- data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
- data/lib/rspec/signal/rerun.rb +39 -0
- data/lib/rspec/signal/run_status.rb +40 -0
- data/lib/rspec/signal/selection.rb +130 -0
- data/lib/rspec/signal/terminal_summary.rb +119 -0
- data/lib/rspec/signal/version.rb +1 -1
- data/lib/rspec/signal/writer.rb +55 -18
- data/lib/rspec/signal.rb +18 -0
- metadata +20 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rspec/core"
|
|
4
4
|
require "rspec/core/formatters"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
|
|
6
7
|
module RSpec
|
|
7
8
|
module Signal
|
|
@@ -13,50 +14,69 @@ module RSpec
|
|
|
13
14
|
# formatter so requiring the gem does not change normal human output.
|
|
14
15
|
class Formatter
|
|
15
16
|
::RSpec::Core::Formatters.register self, :start, :example_passed, :example_failed,
|
|
16
|
-
:example_pending, :dump_summary, :seed, :close
|
|
17
|
+
:example_pending, :message, :dump_summary, :seed, :close
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
attr_reader :output
|
|
19
|
+
attr_reader :output, :config
|
|
21
20
|
|
|
22
21
|
def initialize(output)
|
|
23
22
|
@output = output
|
|
23
|
+
@config = RSpec::Signal.configuration
|
|
24
24
|
@failures = []
|
|
25
|
+
@outside = []
|
|
25
26
|
@errors = []
|
|
26
27
|
@summary = {}
|
|
28
|
+
@run_status = RunStatus.new
|
|
27
29
|
@seed = nil
|
|
28
30
|
@seed_used = false
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def config
|
|
32
|
-
RSpec::Signal.configuration
|
|
31
|
+
@run_id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%S")}-#{SecureRandom.hex(4)}"
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
def start(notification)
|
|
36
35
|
# Adding a formatter suppresses RSpec's default one. When rspec-signal
|
|
37
36
|
# installed itself, the user never asked for that, so put it back.
|
|
38
37
|
RSpec::Signal.restore_default_formatter! if RSpec::Signal.auto_installed? && !RSpec::Signal.quiet_mode?
|
|
38
|
+
writer.invalidate_current! if config.enabled? && !dry_run? && !ParallelRun.worker?
|
|
39
|
+
@run_status.selected(notification.count)
|
|
40
|
+
@selection = Selection.from_rspec
|
|
39
41
|
start_progress(notification.count)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def example_passed(
|
|
44
|
+
def example_passed(notification)
|
|
45
|
+
count_example(notification, failed: false)
|
|
46
|
+
@run_status.example_executed
|
|
43
47
|
advance_progress
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def example_pending(_notification)
|
|
51
|
+
@run_status.example_executed
|
|
47
52
|
advance_progress
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def example_failed(notification)
|
|
51
56
|
return unless config.enabled?
|
|
52
57
|
|
|
58
|
+
count_example(notification, failed: true)
|
|
53
59
|
@failures << builder.call(notification, position: @failures.size + 1)
|
|
54
60
|
rescue StandardError => e
|
|
55
61
|
record_error(e)
|
|
56
62
|
ensure
|
|
63
|
+
@run_status.example_executed
|
|
57
64
|
advance_progress
|
|
58
65
|
end
|
|
59
66
|
|
|
67
|
+
# RSpec reports a spec file that would not load, or a `before(:suite)`
|
|
68
|
+
# hook that blew up, on this stream rather than as a failed example.
|
|
69
|
+
# Registering for it is what lets those reach the report at all -- but it
|
|
70
|
+
# also suppresses RSpec's `FallbackMessageFormatter`, so anything we
|
|
71
|
+
# receive and nobody else prints has to be printed here.
|
|
72
|
+
def message(notification)
|
|
73
|
+
text = notification.message.to_s
|
|
74
|
+
capture_outside_example(text) if config.enabled?
|
|
75
|
+
echo_message(text)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
record_error(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def dump_summary(notification)
|
|
61
81
|
@summary = {
|
|
62
82
|
example_count: notification.example_count,
|
|
@@ -65,6 +85,7 @@ module RSpec
|
|
|
65
85
|
duration: notification.duration,
|
|
66
86
|
errors_outside_examples: notification.errors_outside_of_examples_count
|
|
67
87
|
}
|
|
88
|
+
@run_status.summarized(notification.example_count)
|
|
68
89
|
rescue StandardError => e
|
|
69
90
|
record_error(e)
|
|
70
91
|
end
|
|
@@ -80,11 +101,14 @@ module RSpec
|
|
|
80
101
|
return unless config.enabled?
|
|
81
102
|
|
|
82
103
|
finish_progress
|
|
104
|
+
return if dry_run?
|
|
105
|
+
|
|
83
106
|
if ParallelRun.worker?
|
|
84
107
|
ParallelRun.write_worker(report, config)
|
|
85
108
|
else
|
|
86
|
-
|
|
87
|
-
|
|
109
|
+
current = report
|
|
110
|
+
compare_and_record(current)
|
|
111
|
+
print_summary(writer.write(current), current)
|
|
88
112
|
end
|
|
89
113
|
rescue StandardError => e
|
|
90
114
|
record_error(e)
|
|
@@ -92,7 +116,6 @@ module RSpec
|
|
|
92
116
|
warn_about_errors
|
|
93
117
|
end
|
|
94
118
|
|
|
95
|
-
# @return [Report] exposed for testing and for tools that embed the gem.
|
|
96
119
|
def report
|
|
97
120
|
Report.new(
|
|
98
121
|
failures: @failures,
|
|
@@ -103,95 +126,124 @@ module RSpec
|
|
|
103
126
|
seed: @seed,
|
|
104
127
|
seed_used: @seed_used,
|
|
105
128
|
environment: RSpec::Signal.environment,
|
|
106
|
-
errors_outside_examples:
|
|
107
|
-
|
|
129
|
+
errors_outside_examples: outside_example_count,
|
|
130
|
+
outside_example_failures: @outside,
|
|
131
|
+
relate_failures: config.relate_failures,
|
|
132
|
+
code_path_depth: config.code_path_depth,
|
|
133
|
+
run_id: @run_id,
|
|
134
|
+
census: @census,
|
|
135
|
+
causal_analysis: config.causal_analysis,
|
|
136
|
+
selection: @selection
|
|
108
137
|
)
|
|
109
138
|
end
|
|
110
139
|
|
|
111
140
|
private
|
|
112
141
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
142
|
+
# Only when relationships are switched on: disabled, nothing is counted
|
|
143
|
+
# and no census exists. Decided per example, because the formatter is
|
|
144
|
+
# built when the gem is required -- before the project's configure block.
|
|
145
|
+
def count_example(notification, failed:)
|
|
146
|
+
(@census ||= Causal::Census.new).record(notification, failed: failed) if config.causal_analysis
|
|
147
|
+
end
|
|
117
148
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
149
|
+
# RSpec's own count is authoritative when it is at least as large as
|
|
150
|
+
# ours; ours fills in when a run aborts before `dump_summary`.
|
|
151
|
+
def outside_example_count
|
|
152
|
+
[@summary.fetch(:errors_outside_examples, 0), @outside.size].max
|
|
121
153
|
end
|
|
122
154
|
|
|
123
|
-
def
|
|
124
|
-
return unless
|
|
155
|
+
def capture_outside_example(text)
|
|
156
|
+
return unless OutsideExample.failure?(text)
|
|
125
157
|
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
failure = OutsideExample.build(text, config, position: @outside.size + 1)
|
|
159
|
+
@outside << failure if failure
|
|
128
160
|
end
|
|
129
161
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
162
|
+
# Only when no other formatter would print it. Registering `:message`
|
|
163
|
+
# takes RSpec's fallback formatter out of the picture, and silently
|
|
164
|
+
# swallowing a load error is far worse than any output we add.
|
|
165
|
+
def echo_message(text)
|
|
166
|
+
return unless echo_messages?
|
|
167
|
+
|
|
168
|
+
@output.puts(text)
|
|
135
169
|
end
|
|
136
170
|
|
|
137
|
-
def
|
|
138
|
-
return
|
|
171
|
+
def echo_messages?
|
|
172
|
+
return @echo_messages if defined?(@echo_messages)
|
|
139
173
|
|
|
140
|
-
|
|
141
|
-
@
|
|
174
|
+
listeners = ::RSpec.configuration.reporter.registered_listeners(:message)
|
|
175
|
+
@echo_messages = listeners.none? { |listener| !listener.equal?(self) }
|
|
176
|
+
rescue StandardError
|
|
177
|
+
@echo_messages = RSpec::Signal.quiet_mode?
|
|
142
178
|
end
|
|
143
179
|
|
|
144
|
-
|
|
145
|
-
|
|
180
|
+
# A dry run executes nothing, so it has neither failures to report nor
|
|
181
|
+
# the standing to delete the report of the run that did.
|
|
182
|
+
def dry_run?
|
|
183
|
+
::RSpec.configuration.dry_run?
|
|
184
|
+
rescue StandardError
|
|
185
|
+
false
|
|
146
186
|
end
|
|
147
187
|
|
|
148
|
-
def
|
|
149
|
-
|
|
188
|
+
def compare_and_record(current)
|
|
189
|
+
return unless config.track_history
|
|
190
|
+
return unless history_eligible_run?
|
|
191
|
+
|
|
192
|
+
history = History.new(config)
|
|
193
|
+
current.comparison = history.compare(current, run_id: current.run_id)
|
|
194
|
+
history.record(current, run_id: current.run_id)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
record_error(e)
|
|
150
197
|
end
|
|
151
198
|
|
|
152
|
-
def
|
|
153
|
-
|
|
199
|
+
def history_eligible_run?
|
|
200
|
+
@run_status.history_eligible?(outside_errors: outside_example_count)
|
|
201
|
+
end
|
|
154
202
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
@output.puts
|
|
158
|
-
print_rspec_summary(current)
|
|
159
|
-
return
|
|
160
|
-
end
|
|
161
|
-
return if @failures.empty? && result.summary_path.nil?
|
|
203
|
+
def comparison_skipped_reason
|
|
204
|
+
return unless config.track_history
|
|
162
205
|
|
|
163
|
-
@
|
|
164
|
-
print_rspec_summary(current) if RSpec::Signal.quiet_mode?
|
|
165
|
-
@output.puts "rspec-signal: #{current.failure_count} " \
|
|
166
|
-
"#{current.failure_count == 1 ? "failure" : "failures"} in " \
|
|
167
|
-
"#{current.group_count} distinct " \
|
|
168
|
-
"#{current.group_count == 1 ? "signature" : "signatures"}" \
|
|
169
|
-
"#{cluster_note(current)}#{omission_note(current)}"
|
|
170
|
-
@output.puts "Report: #{writer.relative(result.summary_path)}" if result.summary_path
|
|
171
|
-
rescue StandardError => e
|
|
172
|
-
record_error(e)
|
|
206
|
+
@run_status.skipped_reason(outside_errors: outside_example_count)
|
|
173
207
|
end
|
|
174
208
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
209
|
+
def start_progress(total)
|
|
210
|
+
return unless config.enabled? && RSpec::Signal.quiet_mode?
|
|
211
|
+
return if ParallelRun.worker?
|
|
212
|
+
|
|
213
|
+
@progress = ProgressBar.for(@output, total)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def advance_progress
|
|
217
|
+
@progress&.advance
|
|
179
218
|
end
|
|
180
219
|
|
|
181
|
-
def
|
|
182
|
-
|
|
220
|
+
def finish_progress
|
|
221
|
+
@progress&.finish
|
|
222
|
+
@progress = nil
|
|
183
223
|
end
|
|
184
224
|
|
|
185
|
-
def
|
|
186
|
-
|
|
225
|
+
def builder
|
|
226
|
+
@builder ||= FailureBuilder.new(config)
|
|
227
|
+
end
|
|
187
228
|
|
|
188
|
-
|
|
229
|
+
def writer
|
|
230
|
+
@writer ||= Writer.new(config)
|
|
189
231
|
end
|
|
190
232
|
|
|
191
|
-
|
|
192
|
-
|
|
233
|
+
# Stdout is a tool call's return value, so lead with the result and the
|
|
234
|
+
# exact next action; detailed diagnostics belong in the artifacts.
|
|
235
|
+
def print_summary(result, current)
|
|
236
|
+
return unless config.terminal_summary
|
|
237
|
+
|
|
238
|
+
path = writer.relative(result.summary_path) if result.summary_path
|
|
239
|
+
lines = TerminalSummary.new(current, report_path: path, quiet: RSpec::Signal.quiet_mode?,
|
|
240
|
+
comparison_skipped_reason: comparison_skipped_reason).lines
|
|
241
|
+
return if lines.empty?
|
|
193
242
|
|
|
194
|
-
|
|
243
|
+
@output.puts
|
|
244
|
+
lines.each { |line| @output.puts line }
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
record_error(e)
|
|
195
247
|
end
|
|
196
248
|
|
|
197
249
|
def record_error(error)
|
data/lib/rspec/signal/group.rb
CHANGED
|
@@ -38,6 +38,11 @@ module RSpec
|
|
|
38
38
|
representative.message
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Arguments that rerun exactly this group's examples, in run order.
|
|
42
|
+
def rerun_arguments
|
|
43
|
+
@rerun_arguments ||= @failures.map(&:rerun_argument).uniq
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
# Locations of every example in the group, in run order, deduplicated.
|
|
42
47
|
def affected_locations
|
|
43
48
|
@affected_locations ||= @failures.map(&:spec_location).uniq
|
|
@@ -52,17 +57,29 @@ module RSpec
|
|
|
52
57
|
@failures.sum { |failure| failure.reduced.omitted_count }
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
def to_h
|
|
60
|
+
def to_h(max_message_lines: 30, max_diff_lines: 20)
|
|
56
61
|
{
|
|
57
62
|
signature: fingerprint.digest,
|
|
63
|
+
loose_signature: fingerprint.loose_digest,
|
|
58
64
|
count: size,
|
|
59
65
|
exception: exception_class,
|
|
66
|
+
summary: message.summary,
|
|
60
67
|
culprit: fingerprint.culprit,
|
|
61
68
|
app_context: fingerprint.app_context,
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
rerun: Rerun.command([representative.rerun_argument]),
|
|
70
|
+
rerun_ids: rerun_arguments,
|
|
71
|
+
representative: representative.to_h(max_message_lines: max_message_lines,
|
|
72
|
+
max_diff_lines: max_diff_lines),
|
|
73
|
+
affected: @failures.map { |failure| affected_h(failure) }
|
|
64
74
|
}.compact
|
|
65
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def affected_h(failure)
|
|
80
|
+
{ location: failure.spec_location, id: failure.example_id,
|
|
81
|
+
description: failure.description }.compact
|
|
82
|
+
end
|
|
66
83
|
end
|
|
67
84
|
end
|
|
68
85
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Signal
|
|
9
|
+
# A short, deliberately boring record of recent runs, so that a run can say
|
|
10
|
+
# what changed rather than describing itself as if it were the first.
|
|
11
|
+
#
|
|
12
|
+
# Signature digests and counts contain no messages or source. Selection
|
|
13
|
+
# entries add only a digest and normalized scope details so a developer can
|
|
14
|
+
# diagnose why two runs did not compare. The file stays
|
|
15
|
+
# small by construction and is capped at {MAX_RUNS}.
|
|
16
|
+
#
|
|
17
|
+
# It survives a green run on purpose: "42 failures became 0" is the most
|
|
18
|
+
# valuable comparison there is, and the run that deletes the report is
|
|
19
|
+
# exactly the run that should be able to say it.
|
|
20
|
+
#
|
|
21
|
+
# Every operation is best effort. A missing, unreadable or corrupt history
|
|
22
|
+
# simply means this run has nothing to compare against, which is the
|
|
23
|
+
# behaviour of every run before this feature existed.
|
|
24
|
+
class History
|
|
25
|
+
FILE = "history.json"
|
|
26
|
+
# 3 adds selection identity. Older runs cannot safely be compared because
|
|
27
|
+
# they did not record which examples their signatures represent.
|
|
28
|
+
SCHEMA = 3
|
|
29
|
+
MAX_RUNS = 10
|
|
30
|
+
|
|
31
|
+
def initialize(config)
|
|
32
|
+
@config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def path
|
|
36
|
+
File.join(@config.output_path, FILE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Comparison, nil]
|
|
40
|
+
def compare(report, run_id:)
|
|
41
|
+
return nil unless report.selection
|
|
42
|
+
|
|
43
|
+
previous = runs.reverse.find do |entry|
|
|
44
|
+
report.selection.equivalent?(Selection.from_h(entry["selection"]))
|
|
45
|
+
end
|
|
46
|
+
return nil unless previous
|
|
47
|
+
|
|
48
|
+
Comparison.new(previous: previous, current: snapshot(report, run_id: run_id))
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Appends this run and trims the file to the most recent {MAX_RUNS}.
|
|
54
|
+
def record(report, run_id:)
|
|
55
|
+
entry = snapshot(report, run_id: run_id)
|
|
56
|
+
kept = (runs + [entry]).last(MAX_RUNS)
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
File.write(path, "#{JSON.pretty_generate({ "schema" => SCHEMA, "runs" => kept })}\n")
|
|
59
|
+
entry
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def runs
|
|
65
|
+
@runs ||= load
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def load
|
|
71
|
+
return [] unless File.file?(path)
|
|
72
|
+
|
|
73
|
+
document = JSON.parse(File.read(path))
|
|
74
|
+
return [] unless document.is_a?(Hash) && document["schema"] == SCHEMA
|
|
75
|
+
|
|
76
|
+
Array(document["runs"]).grep(Hash)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def snapshot(report, run_id:)
|
|
82
|
+
{
|
|
83
|
+
"run_id" => run_id,
|
|
84
|
+
"at" => Time.now.utc.iso8601,
|
|
85
|
+
"examples" => report.example_count,
|
|
86
|
+
"failures" => report.failure_count,
|
|
87
|
+
"selection" => report.selection&.to_h,
|
|
88
|
+
"signatures" => report.groups.map { |group| signature_h(group) }
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def signature_h(group)
|
|
93
|
+
{
|
|
94
|
+
"digest" => group.fingerprint.digest,
|
|
95
|
+
"loose" => group.fingerprint.loose_digest,
|
|
96
|
+
"exception" => group.exception_class,
|
|
97
|
+
"count" => group.size
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/rspec/signal/message.rb
CHANGED
|
@@ -60,6 +60,17 @@ module RSpec
|
|
|
60
60
|
"#{@lines.join("\n")}\n\n#{@cause_lines.join("\n")}"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# The one line that says what went wrong, for the signature index.
|
|
64
|
+
#
|
|
65
|
+
# RSpec opens every failure with `Failure/Error:` echoing the source
|
|
66
|
+
# expression, which the reader can already see and which is identical
|
|
67
|
+
# across every example that shares a line. The line worth showing is the
|
|
68
|
+
# one after it: `key not found: :title`, not `def render; @rows.map ...`.
|
|
69
|
+
def summary(limit = 160)
|
|
70
|
+
line = diagnostic_line
|
|
71
|
+
line.empty? ? headline(limit) : truncate(line, limit)
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
# First meaningful line, for headings and one-line summaries.
|
|
64
75
|
def headline(limit = 160)
|
|
65
76
|
line = @lines.find { |l| !l.strip.empty? }.to_s.strip
|
|
@@ -84,9 +95,15 @@ module RSpec
|
|
|
84
95
|
# the main text is truncated to `MAX_FINGERPRINT_CHARS`, so two
|
|
85
96
|
# otherwise-identical wrapper messages with different causes never
|
|
86
97
|
# collapse into one signature just because the wrapper is long.
|
|
98
|
+
#
|
|
99
|
+
# RSpec's `Failure/Error:` echo of the failing source line is left out.
|
|
100
|
+
# When the raise site is inside a gem it echoes each example's own call
|
|
101
|
+
# site, so one broken factory would become one signature per `create`
|
|
102
|
+
# line -- and the example's own location is deliberately not part of a
|
|
103
|
+
# failure's identity.
|
|
87
104
|
def normalized
|
|
88
105
|
@normalized ||= begin
|
|
89
|
-
main = truncate(normalize_for_fingerprint(
|
|
106
|
+
main = truncate(normalize_for_fingerprint(identity_lines.join(" ")), MAX_FINGERPRINT_CHARS)
|
|
90
107
|
cause = normalize_for_fingerprint(@cause_lines.join(" "))
|
|
91
108
|
cause.empty? ? main : "#{main} #{cause}"
|
|
92
109
|
end
|
|
@@ -94,6 +111,42 @@ module RSpec
|
|
|
94
111
|
|
|
95
112
|
private
|
|
96
113
|
|
|
114
|
+
# The message without the source echo: the echo block up to the first
|
|
115
|
+
# blank line, or just the echo line when a matcher appended its message
|
|
116
|
+
# straight onto it. A message that is nothing but an echo keeps it,
|
|
117
|
+
# rather than leaving the fingerprint no text at all.
|
|
118
|
+
def identity_lines
|
|
119
|
+
start = @lines.index { |line| !line.strip.empty? }
|
|
120
|
+
return @lines unless start && source_echo?(@lines[start])
|
|
121
|
+
|
|
122
|
+
blank = ((start + 1)...@lines.size).find { |index| @lines[index].strip.empty? }
|
|
123
|
+
rest = blank ? @lines.drop(blank + 1) : @lines.drop(start + 1)
|
|
124
|
+
rest.any? { |line| !line.strip.empty? } ? rest : @lines
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# RSpec separates the source echo from the diagnosis with a blank line
|
|
128
|
+
# when it can. When it cannot -- a matcher that appends its message
|
|
129
|
+
# straight onto the echo -- fall back to dropping just the echo line.
|
|
130
|
+
def diagnostic_line
|
|
131
|
+
blocks = @lines.chunk { |line| line.strip.empty? }.reject(&:first).map(&:last)
|
|
132
|
+
return "" if blocks.empty?
|
|
133
|
+
|
|
134
|
+
candidate = source_echo?(blocks.first.first) && blocks.size > 1 ? blocks[1] : blocks.first
|
|
135
|
+
candidate = candidate.drop(1) if source_echo?(candidate.first)
|
|
136
|
+
candidate = candidate.drop(1) if candidate.size > 1 && class_label?(candidate.first)
|
|
137
|
+
candidate.first.to_s.strip
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def source_echo?(line)
|
|
141
|
+
line.to_s.strip.start_with?("Failure/Error")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A bare `KeyError:` / `ActiveRecord::RecordInvalid:` label line, which
|
|
145
|
+
# duplicates the exception column beside it.
|
|
146
|
+
def class_label?(line)
|
|
147
|
+
/\A[A-Z]\w*(?:::\w+)*:\z/.match?(line.to_s.strip)
|
|
148
|
+
end
|
|
149
|
+
|
|
97
150
|
def truncated_lines(max_lines:, max_diff_lines:)
|
|
98
151
|
kept = []
|
|
99
152
|
diff_seen = 0
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Recovers the failures RSpec reports without an example: a spec file that
|
|
6
|
+
# would not load, a `before(:suite)` hook that blew up, a `--require` that
|
|
7
|
+
# raised.
|
|
8
|
+
#
|
|
9
|
+
# These arrive on RSpec's `message` stream rather than as failed examples
|
|
10
|
+
# (`Reporter#notify_non_example_exception`), already rendered by RSpec's own
|
|
11
|
+
# `ExceptionPresenter`. That rendering is what this parses back apart, so a
|
|
12
|
+
# load error goes through the same reduction, redaction and grouping as any
|
|
13
|
+
# other failure instead of being reported as a bare count.
|
|
14
|
+
#
|
|
15
|
+
# This is the failure mode an agent causes most often -- a bad require, a
|
|
16
|
+
# constant renamed halfway through an edit -- and it was the one the report
|
|
17
|
+
# could not describe.
|
|
18
|
+
module OutsideExample
|
|
19
|
+
# RSpec's own wording, from `Configuration#load_file_handling_errors` and
|
|
20
|
+
# `Runner`. Matching on it keeps every other `message` -- filter
|
|
21
|
+
# announcements, custom `reporter.message` calls -- out of the report.
|
|
22
|
+
CONTEXTS = [
|
|
23
|
+
/\AAn error occurred while loading /,
|
|
24
|
+
/\AAn error occurred (?:in|while running) an? [`'"]?(?:before|after|around)/,
|
|
25
|
+
/\AAn error occurred while (?:shutting down|loading)/
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# `# ./spec/foo_spec.rb:1:in '<top (required)>'`
|
|
29
|
+
BACKTRACE_LINE = /\A\s*#\s(.+)\z/.freeze
|
|
30
|
+
|
|
31
|
+
# RSpec appends a spelling suggestion to the heading, and puts the
|
|
32
|
+
# suggested command on the following lines.
|
|
33
|
+
SUGGESTION = / - Did you mean\?.*\z/.freeze
|
|
34
|
+
|
|
35
|
+
SPEC_FILE = /while loading (\S+?)\.?(?:\s|\z)/.freeze
|
|
36
|
+
|
|
37
|
+
CLASS_LABEL = /\A[A-Z]\w*(?:::\w+)*:\z/.freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param text [String] the fully formatted message RSpec emitted
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def failure?(text)
|
|
44
|
+
first = text.to_s.lstrip.lines.first.to_s.strip
|
|
45
|
+
CONTEXTS.any? { |pattern| pattern.match?(first) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param text [String]
|
|
49
|
+
# @param config [Configuration]
|
|
50
|
+
# @param position [Integer] 1-based, for the raw dump
|
|
51
|
+
# @return [Failure, nil]
|
|
52
|
+
def build(text, config, position: nil)
|
|
53
|
+
lines = text.to_s.split("\n").drop_while { |line| line.strip.empty? }
|
|
54
|
+
return nil if lines.empty?
|
|
55
|
+
|
|
56
|
+
heading = lines.first.to_s.strip
|
|
57
|
+
context = heading.sub(SUGGESTION, "")
|
|
58
|
+
body, backtrace = partition(strip_suggestion(lines.drop(1), heading))
|
|
59
|
+
frames = Backtrace::Parser.parse(backtrace, config.classifier)
|
|
60
|
+
location = location_for(context, frames, config)
|
|
61
|
+
|
|
62
|
+
Failure.new(
|
|
63
|
+
description: context,
|
|
64
|
+
spec_location: location,
|
|
65
|
+
exception_class: exception_class(body),
|
|
66
|
+
message: message_for(body, config),
|
|
67
|
+
reduced: config.reducer.call(frames),
|
|
68
|
+
frames: frames,
|
|
69
|
+
rerun: rerun_for(context, location),
|
|
70
|
+
raw: raw(config, text, position),
|
|
71
|
+
evidence: evidence(frames)
|
|
72
|
+
)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only the phase and origin: the exception object itself never reaches
|
|
78
|
+
# a formatter, so none of its attributes can be read.
|
|
79
|
+
def evidence(frames)
|
|
80
|
+
origin = frames.find(&:project?)
|
|
81
|
+
Causal::Evidence.new(phase: "outside", body_reached: false, origin: origin&.location,
|
|
82
|
+
origin_kind: origin && Causal::Capture.origin_kind(origin))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The suggested rerun command RSpec prints after "Did you mean?" is
|
|
86
|
+
# advice about a different file, and would otherwise open the message.
|
|
87
|
+
def strip_suggestion(lines, heading)
|
|
88
|
+
return lines unless SUGGESTION.match?(heading)
|
|
89
|
+
|
|
90
|
+
rest = lines.drop_while { |line| !line.strip.empty? }
|
|
91
|
+
rest.empty? ? lines : rest.drop(1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def partition(lines)
|
|
95
|
+
body = []
|
|
96
|
+
backtrace = []
|
|
97
|
+
lines.each do |line|
|
|
98
|
+
match = BACKTRACE_LINE.match(line)
|
|
99
|
+
next backtrace << match[1] if match
|
|
100
|
+
|
|
101
|
+
body << line
|
|
102
|
+
end
|
|
103
|
+
[body, backtrace]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `Failure/Error: require "missing"` then a blank line then
|
|
107
|
+
# `LoadError:` then the indented message.
|
|
108
|
+
def exception_class(body)
|
|
109
|
+
label = body.map(&:strip).find { |line| CLASS_LABEL.match?(line) }
|
|
110
|
+
label ? label.chomp(":") : "Error outside examples"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def message_for(body, config)
|
|
114
|
+
Message.new(body, redactor: config.redactor, project: config.project,
|
|
115
|
+
html_threshold: config.html_threshold)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The file RSpec named, so the report points at something openable.
|
|
119
|
+
def spec_file(context)
|
|
120
|
+
context[SPEC_FILE, 1]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The project frame if there is one -- it carries a line number, which is
|
|
124
|
+
# what makes the location worth opening -- and the file RSpec named
|
|
125
|
+
# otherwise.
|
|
126
|
+
def location_for(context, frames, config)
|
|
127
|
+
frame = frames.find(&:project?)
|
|
128
|
+
return frame.location if frame
|
|
129
|
+
|
|
130
|
+
file = spec_file(context)
|
|
131
|
+
file ? config.project.display_path(file) : context
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A load error reruns the file RSpec named. Anything else -- a suite
|
|
135
|
+
# hook, a shutdown error -- has no example to name, so rerun the file it
|
|
136
|
+
# was raised from, without a line number: a `before(:suite)` hook is not
|
|
137
|
+
# on the line you would otherwise target.
|
|
138
|
+
def rerun_for(context, location)
|
|
139
|
+
spec_file(context) || location.to_s.sub(/:\d+\z/, "")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def raw(config, text, position)
|
|
143
|
+
prefix = position ? " #{position}) " : ""
|
|
144
|
+
config.redactor.call("#{prefix}#{text}")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|