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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +326 -38
  4. data/exe/rspec-signal +9 -0
  5. data/exe/rspec-signal-parallel +12 -5
  6. data/lib/rspec/signal/causal/analysis.rb +292 -0
  7. data/lib/rspec/signal/causal/census.rb +86 -0
  8. data/lib/rspec/signal/causal/entities.rb +67 -0
  9. data/lib/rspec/signal/causal/evidence.rb +144 -0
  10. data/lib/rspec/signal/causal/phase.rb +98 -0
  11. data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
  12. data/lib/rspec/signal/code_paths.rb +101 -0
  13. data/lib/rspec/signal/comparison.rb +101 -0
  14. data/lib/rspec/signal/configuration.rb +18 -2
  15. data/lib/rspec/signal/failure.rb +26 -3
  16. data/lib/rspec/signal/failure_builder.rb +17 -2
  17. data/lib/rspec/signal/fingerprint.rb +18 -2
  18. data/lib/rspec/signal/formatter.rb +121 -69
  19. data/lib/rspec/signal/group.rb +20 -3
  20. data/lib/rspec/signal/history.rb +102 -0
  21. data/lib/rspec/signal/message.rb +54 -1
  22. data/lib/rspec/signal/outside_example.rb +148 -0
  23. data/lib/rspec/signal/parallel_merger.rb +67 -7
  24. data/lib/rspec/signal/parallel_run.rb +4 -3
  25. data/lib/rspec/signal/progress_bar.rb +48 -0
  26. data/lib/rspec/signal/report.rb +119 -23
  27. data/lib/rspec/signal/reporters/json_report.rb +3 -2
  28. data/lib/rspec/signal/reporters/markdown.rb +29 -21
  29. data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
  30. data/lib/rspec/signal/reporters/relationships.rb +228 -0
  31. data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
  32. data/lib/rspec/signal/rerun.rb +39 -0
  33. data/lib/rspec/signal/run_status.rb +40 -0
  34. data/lib/rspec/signal/selection.rb +130 -0
  35. data/lib/rspec/signal/terminal_summary.rb +119 -0
  36. data/lib/rspec/signal/version.rb +1 -1
  37. data/lib/rspec/signal/writer.rb +55 -18
  38. data/lib/rspec/signal.rb +18 -0
  39. 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
- PROGRESS_WIDTH = 20
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
- end
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(_notification)
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
- result = writer.write(report)
87
- print_summary(result)
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: @summary.fetch(:errors_outside_examples, 0),
107
- relate_failures: config.relate_failures
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
- def start_progress(total)
114
- return unless config.enabled? && RSpec::Signal.quiet_mode?
115
- return if ParallelRun.worker? || !@output.respond_to?(:tty?) || !@output.tty?
116
- return unless total.to_i.positive?
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
- @progress_total = total.to_i
119
- @progress_completed = 0
120
- render_progress
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 advance_progress
124
- return unless @progress_total
155
+ def capture_outside_example(text)
156
+ return unless OutsideExample.failure?(text)
125
157
 
126
- @progress_completed = [@progress_completed + 1, @progress_total].min
127
- render_progress
158
+ failure = OutsideExample.build(text, config, position: @outside.size + 1)
159
+ @outside << failure if failure
128
160
  end
129
161
 
130
- def render_progress
131
- percentage = (@progress_completed * 100) / @progress_total
132
- filled = (@progress_completed * PROGRESS_WIDTH) / @progress_total
133
- bar = ("█" * filled) + ("░" * (PROGRESS_WIDTH - filled))
134
- @output.print "\rsignal [#{bar}] #{percentage}% #{@progress_completed}/#{@progress_total}"
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 finish_progress
138
- return unless @progress_total
171
+ def echo_messages?
172
+ return @echo_messages if defined?(@echo_messages)
139
173
 
140
- @output.puts
141
- @progress_total = nil
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
- def builder
145
- @builder ||= FailureBuilder.new(config)
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 writer
149
- @writer ||= Writer.new(config)
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 print_summary(result)
153
- return unless config.terminal_summary
199
+ def history_eligible_run?
200
+ @run_status.history_eligible?(outside_errors: outside_example_count)
201
+ end
154
202
 
155
- current = report
156
- if quiet_success?(result)
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
- @output.puts
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 print_rspec_summary(current)
176
- @output.puts "#{current.example_count} examples, #{current.failure_count} failures, " \
177
- "#{current.pending_count} pending"
178
- @output.puts
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 quiet_success?(result)
182
- RSpec::Signal.quiet_mode? && result.summary_path.nil?
220
+ def finish_progress
221
+ @progress&.finish
222
+ @progress = nil
183
223
  end
184
224
 
185
- def cluster_note(current)
186
- return "" unless current.cluster_count.positive?
225
+ def builder
226
+ @builder ||= FailureBuilder.new(config)
227
+ end
187
228
 
188
- ", #{current.cluster_count} related #{current.cluster_count == 1 ? "cluster" : "clusters"}"
229
+ def writer
230
+ @writer ||= Writer.new(config)
189
231
  end
190
232
 
191
- def omission_note(current)
192
- return "" unless current.omitted_frames.positive?
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
- " (#{current.omitted_frames} backtrace frames omitted)"
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)
@@ -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
- representative: representative.to_h,
63
- affected: @failures.map { |failure| { location: failure.spec_location, description: failure.description } }
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
@@ -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(@lines.join(" ")), MAX_FINGERPRINT_CHARS)
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