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
@@ -9,9 +9,14 @@ module RSpec
9
9
  class ParallelMerger
10
10
  Result = Struct.new(:report, :workers, :missing, :write_result, keyword_init: true)
11
11
 
12
- def initialize(registry:, config: RSpec::Signal.configuration)
12
+ def initialize(registry:, config: RSpec::Signal.configuration, run_id: nil)
13
13
  @registry = registry
14
14
  @config = config
15
+ @run_id = run_id
16
+ end
17
+
18
+ def run_id
19
+ @run_id ||= ENV.fetch(ParallelRun::RUN_ID, nil)
15
20
  end
16
21
 
17
22
  def call
@@ -25,7 +30,7 @@ module RSpec
25
30
  report: report,
26
31
  workers: payloads.size,
27
32
  missing: missing,
28
- write_result: Writer.new(@config).write(report)
33
+ write_result: Writer.new(@config).write(compared(report))
29
34
  )
30
35
  # Only once we have successfully produced a report: a worker JSON that
31
36
  # failed to parse should stay on disk for a human to look at, not be
@@ -36,6 +41,19 @@ module RSpec
36
41
 
37
42
  private
38
43
 
44
+ # The merged report is the only one that has seen the whole run, so it is
45
+ # the one that gets compared against, and recorded for the next run.
46
+ def compared(report)
47
+ return report unless @config.track_history
48
+
49
+ history = History.new(@config)
50
+ report.comparison = history.compare(report, run_id: report.run_id)
51
+ history.record(report, run_id: report.run_id)
52
+ report
53
+ rescue StandardError
54
+ report
55
+ end
56
+
39
57
  # Worker payloads are per-run (`workers/<run-id>/<worker-id>/signal.json`),
40
58
  # so once merged into the top-level report they would otherwise sit in
41
59
  # the project forever: a green run's own hygiene guarantee (stale
@@ -57,36 +75,78 @@ module RSpec
57
75
  pending_count: sum(summaries, "pending"),
58
76
  duration: summaries.filter_map { |item| item["duration_seconds"] }.max,
59
77
  environment: payloads.first&.fetch("environment", {}) || {},
60
- errors_outside_examples: sum(summaries, "errors_outside_examples"), relate_failures: @config.relate_failures
78
+ errors_outside_examples: sum(summaries, "errors_outside_examples"),
79
+ relate_failures: @config.relate_failures, code_path_depth: @config.code_path_depth,
80
+ outside_example_failures: load_outside(payloads), run_id: run_id,
81
+ census: load_census(payloads), causal_analysis: @config.causal_analysis,
82
+ selection: merge_selection(payloads)
61
83
  )
62
84
  end
63
85
 
86
+ def merge_selection(payloads)
87
+ selections = payloads.filter_map { |payload| Selection.from_h(payload["selection"]) }
88
+ return if selections.empty?
89
+ return selections.first if selections.all? { |selection| selection.equivalent?(selections.first) }
90
+
91
+ nil
92
+ end
93
+
94
+ def load_outside(payloads)
95
+ payloads.flat_map do |payload|
96
+ payload.fetch("outside_examples", []).map do |item|
97
+ load_failure(item).tap { |failure| failure.evidence ||= Causal::Evidence.new(phase: "outside") }
98
+ end
99
+ end
100
+ end
101
+
64
102
  def load_failures(payloads)
65
103
  payloads.flat_map do |payload|
66
- payload.fetch("failures", []).map { |failure| load_failure(failure) }
104
+ payload.fetch("failures", []).map { |failure| load_failure(failure, worker: payload["worker"]) }
67
105
  end
68
106
  end
69
107
 
70
- def load_failure(data)
108
+ # Each worker counted only what it ran; the parent needs the whole run.
109
+ def load_census(payloads)
110
+ payloads.each_with_object(Causal::Census.new) { |payload, census| census.merge(payload["census"]) }
111
+ end
112
+
113
+ # Exception-object tokens are only unique within one process, so they are
114
+ # qualified by worker before two workers' tokens can meet.
115
+ def load_evidence(data, worker)
116
+ evidence = Causal::Evidence.from_h(data)
117
+ return nil unless evidence
118
+
119
+ evidence.identities = evidence.identities.map { |token| "w#{worker}:#{token}" }
120
+ evidence
121
+ end
122
+
123
+ def load_failure(data, worker: nil)
71
124
  entries = data.fetch("trace", []).map { |entry| load_entry(entry) }
72
125
  frames = entries.select(&:frame?)
73
126
  reduced = Backtrace::Reduced.new(entries: entries, total: frames.size + data.fetch("omitted_frames", 0),
74
127
  omitted: { serialized: data.fetch("omitted_frames", 0) })
75
128
  fingerprint = data["fingerprint"] || {}
76
- Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint))
129
+ Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint),
130
+ evidence: load_evidence(data["evidence"], worker))
77
131
  end
78
132
 
79
133
  def failure_attributes(data, reduced, frames)
80
134
  { description: data.fetch("description"), spec_location: data.fetch("location"),
81
135
  rerun: data["rerun"], example_id: data["id"], exception_class: data.fetch("exception"),
82
136
  message: load_message(data.fetch("message", [])), reduced: reduced, frames: frames,
83
- diagnostics: data.fetch("diagnostics", {}), raw: data["raw"] }
137
+ diagnostics: data.fetch("diagnostics", {}), raw: data["raw"],
138
+ shared_group_locations: data.fetch("shared_groups", []) }
84
139
  end
85
140
 
141
+ # The message is not in the payload, so both digests are carried
142
+ # verbatim. A payload without a loose digest (an older worker) falls back
143
+ # to the exact digest -- never to a key derived from a placeholder
144
+ # message, which every signature of the same class would share.
86
145
  def load_fingerprint(data)
87
146
  Fingerprint.new(exception_class: data["exception"], message: "worker", culprit: data["culprit"],
88
147
  app_context: data["app_context"]).tap do |fingerprint|
89
148
  fingerprint.instance_variable_set(:@digest, data["digest"])
149
+ fingerprint.instance_variable_set(:@loose_digest, data["loose"] || data["digest"])
90
150
  end
91
151
  end
92
152
 
@@ -25,7 +25,7 @@ module RSpec
25
25
  directory = File.join(config.output_path, "workers", ENV.fetch(RUN_ID), worker_id)
26
26
  FileUtils.mkdir_p(directory)
27
27
  path = File.join(directory, "signal.json")
28
- payload = report.worker_h(write_full: config.write_full)
28
+ payload = report.worker_h(write_full: config.write_full, config: config)
29
29
  .merge(worker: worker_id, configuration: configuration_h(config))
30
30
  atomic_write(path, "#{JSON.pretty_generate(payload)}\n")
31
31
  register(path)
@@ -33,8 +33,9 @@ module RSpec
33
33
 
34
34
  def configuration_h(config)
35
35
  %i[output_dir project_root max_frames max_external_context max_project_frames fallback_frames
36
- max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups relate_failures
37
- max_clusters max_cluster_specs write_json write_full write_gitignore terminal_summary capture_capybara
36
+ max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups
37
+ relate_failures max_clusters max_cluster_specs code_path_depth max_code_paths causal_analysis
38
+ write_json write_full write_gitignore track_history terminal_summary capture_capybara
38
39
  capture_page_html].to_h { |name| [name, config.public_send(name)] }
39
40
  end
40
41
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # The single line repainted in place while a quiet run is in progress.
6
+ #
7
+ # Quiet mode prints nothing per example, which on a long suite is
8
+ # indistinguishable from a hang. This is the smallest thing that fixes
9
+ # that: bounded, repainted with a carriage return, and only ever drawn to a
10
+ # terminal, so a redirected stream never accumulates control sequences.
11
+ class ProgressBar
12
+ WIDTH = 20
13
+
14
+ # @return [ProgressBar, nil] nil whenever a bar would be inappropriate
15
+ def self.for(output, total)
16
+ return nil unless output.respond_to?(:tty?) && output.tty?
17
+ return nil unless total.to_i.positive?
18
+
19
+ new(output, total.to_i)
20
+ end
21
+
22
+ def initialize(output, total)
23
+ @output = output
24
+ @total = total
25
+ @completed = 0
26
+ render
27
+ end
28
+
29
+ def advance
30
+ @completed = [@completed + 1, @total].min
31
+ render
32
+ end
33
+
34
+ def finish
35
+ @output.puts
36
+ end
37
+
38
+ private
39
+
40
+ def render
41
+ percentage = (@completed * 100) / @total
42
+ filled = (@completed * WIDTH) / @total
43
+ bar = ("█" * filled) + ("░" * (WIDTH - filled))
44
+ @output.print "\rsignal [#{bar}] #{percentage}% #{@completed}/#{@total}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -4,15 +4,31 @@ module RSpec
4
4
  module Signal
5
5
  # The complete, renderer-independent result of a run.
6
6
  class Report
7
- attr_reader :failures, :groups, :clusters, :example_count, :failure_count, :pending_count,
8
- :duration, :seed, :seed_used, :environment, :errors_outside_examples
7
+ # Bumped when the shape of {#to_h} changes incompatibly. Schema 2 adds
8
+ # `run_id`, `code_paths`, `since_last_run`, and per-signature `rerun`,
9
+ # `summary` and `loose_signature`; every schema 1 field kept its name and
10
+ # meaning.
11
+ SCHEMA = 2
12
+
13
+ # The worker interchange payload is a different document with a different
14
+ # audience (the parent merger, of the same gem version), so it carries its
15
+ # own number rather than sharing the public one.
16
+ WORKER_SCHEMA = 3
17
+
18
+ attr_reader :failures, :groups, :clusters, :code_paths, :example_count, :failure_count,
19
+ :pending_count, :duration, :seed, :seed_used, :environment,
20
+ :errors_outside_examples, :outside_example_failures, :run_id, :census, :selection
21
+ attr_accessor :comparison
9
22
 
10
23
  def initialize(failures:, example_count: 0, failure_count: nil, pending_count: 0,
11
24
  duration: nil, seed: nil, seed_used: false, environment: {},
12
- errors_outside_examples: 0, relate_failures: true)
25
+ errors_outside_examples: 0, relate_failures: true, outside_example_failures: [],
26
+ run_id: nil, code_path_depth: CodePaths::DEFAULT_DEPTH, census: nil,
27
+ causal_analysis: false, selection: nil)
13
28
  @failures = failures
14
29
  @groups = Grouper.call(failures)
15
30
  @clusters = relate_failures ? safely { Clusterer.call(failures) } : []
31
+ @code_paths = safely { CodePaths.call(failures, depth: code_path_depth) }
16
32
  @example_count = example_count
17
33
  @failure_count = failure_count || failures.size
18
34
  @pending_count = pending_count
@@ -21,10 +37,41 @@ module RSpec
21
37
  @seed_used = seed_used
22
38
  @environment = environment
23
39
  @errors_outside_examples = errors_outside_examples
40
+ @outside_example_failures = outside_example_failures
41
+ @run_id = run_id
42
+ @census = census
43
+ @causal_analysis = causal_analysis
44
+ @selection = selection
45
+ end
46
+
47
+ # The experimental relationship analysis, or nil when it is switched off
48
+ # or fails. Computed on first use; like the other analysis layers it can
49
+ # never take the report down.
50
+ def analysis
51
+ return @analysis if defined?(@analysis)
52
+
53
+ @analysis = @causal_analysis ? Causal::Analysis.call(self) : nil
54
+ rescue StandardError
55
+ @analysis = nil
56
+ end
57
+
58
+ # Empty when there is nothing beyond the signatures to say. Rendering is
59
+ # as fail-soft as the analysis: a bug here must not cost the report.
60
+ def relationship_lines
61
+ relationships&.terminal_lines || []
62
+ rescue StandardError
63
+ []
64
+ end
65
+
66
+ def relationship_markdown(signature_positions)
67
+ relationships(signature_positions)&.markdown
68
+ rescue StandardError
69
+ nil
24
70
  end
25
71
 
26
- # Clustering is the newest and least essential stage; a report without it
27
- # is still worth having, so it is never allowed to take the run down.
72
+ # The analysis layers are the newest and least essential stages; a report
73
+ # without them is still worth having, so they are never allowed to take
74
+ # the run down.
28
75
  def safely
29
76
  yield
30
77
  rescue StandardError
@@ -39,10 +86,20 @@ module RSpec
39
86
  clusters.size
40
87
  end
41
88
 
89
+ def code_path_count
90
+ code_paths.size
91
+ end
92
+
42
93
  def any_failures?
43
94
  !failures.empty?
44
95
  end
45
96
 
97
+ # Whether there is anything at all to report. An error outside every
98
+ # example produces no failed example but is emphatically not a green run.
99
+ def reportable?
100
+ any_failures? || errors_outside_examples.positive?
101
+ end
102
+
46
103
  def seed_used?
47
104
  !!@seed_used
48
105
  end
@@ -61,25 +118,22 @@ module RSpec
61
118
  total_frames - omitted_frames
62
119
  end
63
120
 
64
- def to_h
121
+ def to_h(config = nil, include_analysis: true)
122
+ budgets = message_budgets(config)
65
123
  {
66
- schema: 1,
124
+ schema: SCHEMA,
67
125
  generated_by: "rspec-signal #{VERSION}",
68
- summary: {
69
- examples: example_count,
70
- failures: failure_count,
71
- pending: pending_count,
72
- signatures: group_count,
73
- related_clusters: cluster_count.positive? ? cluster_count : nil,
74
- duration_seconds: duration&.round(3),
75
- seed: seed_used? ? seed : nil,
76
- errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
77
- }.compact,
126
+ run_id: run_id,
127
+ summary: summary_h,
128
+ since_last_run: comparison&.to_h,
78
129
  environment: environment,
79
130
  backtrace_reduction: { total_frames: total_frames, kept_frames: kept_frames, omitted_frames: omitted_frames },
80
- signatures: groups.map(&:to_h),
81
- related: clusters.map(&:to_h)
82
- }
131
+ signatures: groups.map { |group| group.to_h(**budgets) },
132
+ related: clusters.map(&:to_h),
133
+ code_paths: code_paths.map(&:to_h),
134
+ outside_examples: outside_example_failures.map { |failure| failure.to_h(**budgets) },
135
+ analysis: include_analysis ? analysis_h : nil
136
+ }.compact
83
137
  end
84
138
 
85
139
  # Worker interchange data. Unlike the public report summary this retains
@@ -88,13 +142,55 @@ module RSpec
88
142
  # `raw` -- the unreduced formatter output -- is included only when
89
143
  # `write_full` is on, since it is otherwise discarded on merge and would
90
144
  # just bloat every worker payload for no reason.
91
- def worker_h(write_full: false)
145
+ def worker_h(write_full: false, config: nil)
146
+ budgets = message_budgets(config)
92
147
  serialized = failures.map do |failure|
93
- attributes = failure.to_h.merge(fingerprint: failure.fingerprint.to_h)
148
+ attributes = failure.to_h(**budgets).merge(fingerprint: failure.fingerprint.to_h)
94
149
  attributes[:raw] = failure.raw if write_full
150
+ attributes[:evidence] = failure.evidence.to_h if failure.evidence
95
151
  attributes
96
152
  end
97
- to_h.merge(schema: 2, failures: serialized)
153
+ # The parent analyses the whole run from every worker's evidence and
154
+ # census; a worker's own partial analysis would only mislead.
155
+ payload = to_h(config, include_analysis: false).merge(schema: WORKER_SCHEMA, failures: serialized)
156
+ payload[:selection] = selection&.to_h
157
+ census ? payload.merge(census: census.to_h) : payload
158
+ end
159
+
160
+ private
161
+
162
+ def relationships(signature_positions = {})
163
+ analysis && Reporters::Relationships.new(analysis, signature_positions)
164
+ end
165
+
166
+ def analysis_h
167
+ relationships(signature_positions)&.to_h
168
+ rescue StandardError
169
+ nil
170
+ end
171
+
172
+ def signature_positions
173
+ groups.each_with_index.to_h { |group, index| [group.fingerprint.digest, index + 1] }
174
+ end
175
+
176
+ def summary_h
177
+ {
178
+ examples: example_count,
179
+ failures: failure_count,
180
+ pending: pending_count,
181
+ signatures: group_count,
182
+ related_clusters: cluster_count.positive? ? cluster_count : nil,
183
+ shared_code_paths: code_path_count.positive? ? code_path_count : nil,
184
+ duration_seconds: duration&.round(3),
185
+ seed: seed_used? ? seed : nil,
186
+ errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
187
+ }.compact
188
+ end
189
+
190
+ def message_budgets(config)
191
+ return { max_message_lines: 30, max_diff_lines: 20 } unless config
192
+
193
+ { max_message_lines: config.max_message_lines, max_diff_lines: config.max_diff_lines }
98
194
  end
99
195
  end
100
196
  end
@@ -7,12 +7,13 @@ module RSpec
7
7
  module Reporters
8
8
  # Machine-readable twin of the Markdown report, for tooling and CI.
9
9
  class JsonReport
10
- def initialize(report, _config = nil)
10
+ def initialize(report, config = nil)
11
11
  @report = report
12
+ @config = config
12
13
  end
13
14
 
14
15
  def render
15
- "#{JSON.pretty_generate(@report.to_h)}\n"
16
+ "#{JSON.pretty_generate(@report.to_h(@config))}\n"
16
17
  end
17
18
  end
18
19
  end
@@ -24,9 +24,11 @@ module RSpec
24
24
 
25
25
  def render
26
26
  sections = [header]
27
- sections << outside_examples_notice
27
+ sections.concat(OutsideExamples.new(@report, @config).render)
28
28
  # Themes before the inventory: what an agent needs first is the
29
29
  # possibility that thirty-five signatures are five problems.
30
+ sections << relationships_section
31
+ sections << code_paths_section
30
32
  sections << related_section
31
33
  sections << index if @report.group_count > 1
32
34
  sections.concat(rendered_groups)
@@ -39,6 +41,7 @@ module RSpec
39
41
  def header
40
42
  lines = ["# RSpec Signal", "", headline]
41
43
  lines << reduction_line if @report.total_frames.positive?
44
+ lines << comparison_line if comparison_line
42
45
  lines << "" << meta_line
43
46
  lines << "" << conventions
44
47
  lines.join("\n")
@@ -68,6 +71,15 @@ module RSpec
68
71
  "(#{number(@report.omitted_frames)} framework/library #{plural(@report.omitted_frames, "frame")} omitted)."
69
72
  end
70
73
 
74
+ # What changed since the previous run. The most useful line in the
75
+ # report when there was a previous run, and absent when there was not.
76
+ def comparison_line
77
+ return @comparison_line if defined?(@comparison_line)
78
+
79
+ headline = @report.comparison&.headline
80
+ @comparison_line = headline && "Since the last run: #{headline}."
81
+ end
82
+
71
83
  def meta_line
72
84
  bits = []
73
85
  bits << "seed `#{@report.seed}`" if @report.seed_used?
@@ -85,7 +97,7 @@ module RSpec
85
97
  def index
86
98
  rows = groups.each_with_index.map do |group, position|
87
99
  "| #{position + 1} | #{group.size} | `#{group.exception_class}` | " \
88
- "#{escape(group.fingerprint.culprit)} | #{escape(one_line(group.message.headline(90)))} |"
100
+ "#{escape(group.fingerprint.culprit)} | #{escape(one_line(group.message.summary(90)))} |"
89
101
  end
90
102
  ["## Signatures", "",
91
103
  "| # | Examples | Exception | Raised in | Message |",
@@ -97,26 +109,20 @@ module RSpec
97
109
  RelatedFailures.new(@report.clusters, signature_positions, @config).render
98
110
  end
99
111
 
112
+ def relationships_section
113
+ @report.relationship_markdown(signature_positions)
114
+ end
115
+
116
+ def code_paths_section
117
+ SharedCodePaths.new(@report.code_paths, signature_positions, @config).render
118
+ end
119
+
100
120
  # Cluster members point back at the numbered sections below, so the
101
121
  # reader can go straight from a symptom to the failures carrying it.
102
122
  def signature_positions
103
123
  @signature_positions ||= @report.groups.each_with_index.to_h { |group, i| [group.fingerprint.digest, i + 1] }
104
124
  end
105
125
 
106
- # A `before(:suite)` blow-up produces no failed examples at all. RSpec
107
- # only reports those through its message stream, which a formatter
108
- # cannot capture without swallowing every other message, so say plainly
109
- # what happened and where to look.
110
- def outside_examples_notice
111
- return nil unless @report.errors_outside_examples.positive?
112
-
113
- count = @report.errors_outside_examples
114
- ["## Errors outside examples", "",
115
- "#{number(count)} #{plural(count, "error")} occurred outside of any example " \
116
- "(a `before(:suite)` hook, a spec file that failed to load, or similar). " \
117
- "rspec-signal cannot capture their detail; the full text is in RSpec's own output."].join("\n")
118
- end
119
-
120
126
  def rendered_groups
121
127
  rendered = groups.each_with_index.map { |group, position| group_section(group, position + 1) }
122
128
  if truncated_groups.positive?
@@ -151,12 +157,14 @@ module RSpec
151
157
 
152
158
  # The representative on its own, plus the whole signature when that is a
153
159
  # different command and short enough to be worth typing.
160
+ #
161
+ # Example ids, not locations: a location selects every example defined
162
+ # on that line, which for a loop-generated `it` is all of them and for
163
+ # two failures sharing a line is both of them. See {Rerun}.
154
164
  def rerun_commands(group)
155
- commands = ["bundle exec rspec #{group.representative.rerun}"]
156
- arguments = group.failures.map(&:rerun).uniq
157
- if arguments.size > 1 && arguments.size <= MAX_RERUN_ARGUMENTS
158
- commands << "bundle exec rspec #{arguments.join(" ")}"
159
- end
165
+ commands = [Rerun.command([group.representative.rerun_argument])]
166
+ arguments = group.rerun_arguments
167
+ commands << Rerun.command(arguments) if arguments.size > 1 && arguments.size <= MAX_RERUN_ARGUMENTS
160
168
  commands
161
169
  end
162
170
 
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ module Reporters
6
+ # The "Errors outside examples" section of the Markdown report.
7
+ #
8
+ # A spec file that would not load, or a `before(:suite)` hook that blew
9
+ # up, produces no failed example at all -- RSpec reports it on its message
10
+ # stream instead. It goes first in the report because nothing below it
11
+ # ran: a suite that reports two failures and one unloadable file has not
12
+ # told you about two failures, it has told you about however many that
13
+ # file contains, plus two.
14
+ class OutsideExamples
15
+ def initialize(report, config)
16
+ @report = report
17
+ @config = config
18
+ end
19
+
20
+ # @return [Array<String>] sections, empty when the run had none
21
+ def render
22
+ return [] unless @report.errors_outside_examples.positive?
23
+
24
+ captured = @report.outside_example_failures
25
+ # A helper every spec file requires fails once per file, identically.
26
+ # One section per distinct error, naming every file it stopped.
27
+ [heading(captured.size)] +
28
+ Grouper.call(captured).each_with_index.map { |group, index| section(group, index + 1) }
29
+ end
30
+
31
+ private
32
+
33
+ def heading(captured)
34
+ count = @report.errors_outside_examples
35
+ lines = ["## Errors outside examples", "",
36
+ "#{count} #{plural(count, "error")} occurred outside of any example " \
37
+ "(a spec file that failed to load, a `before(:suite)` hook, or similar). " \
38
+ "No example in the affected files ran."]
39
+ lines << "" << uncaptured_note(count - captured) if captured < count
40
+ lines.join("\n")
41
+ end
42
+
43
+ def uncaptured_note(remaining)
44
+ "#{remaining} of #{remaining == 1 ? "them" : "those"} could not be captured; " \
45
+ "the full text is in RSpec's own output."
46
+ end
47
+
48
+ def section(group, position)
49
+ failure = group.failures.first
50
+ [
51
+ ["### E#{position}. #{failure.exception_class}#{count_suffix(group)}", "",
52
+ "> #{one_line(failure.description)}"].join("\n"),
53
+ fenced(failure.message.body(max_lines: @config.max_message_lines,
54
+ max_diff_lines: @config.max_diff_lines)),
55
+ locators(group),
56
+ labelled("Trace", fenced(trace(failure))),
57
+ labelled("Rerun", fenced([Rerun.command(group.rerun_arguments)], "bash"))
58
+ ].join("\n\n")
59
+ end
60
+
61
+ def count_suffix(group)
62
+ group.size == 1 ? "" : " -- #{group.size} times"
63
+ end
64
+
65
+ def locators(group)
66
+ lines = ["- Raised in `#{group.failures.first.spec_location}`"]
67
+ return lines.join("\n") if group.size == 1
68
+
69
+ files = group.rerun_arguments.map { |file| "`#{file.sub(%r{\A\./}, "")}`" }
70
+ lines << if group.failures.all? { |failure| failure.description.include?("while loading") }
71
+ "- Stopped #{files.size} spec files from loading: #{files.join(", ")}"
72
+ else
73
+ "- Reported #{group.size} times, from: #{files.join(", ")}"
74
+ end
75
+ lines.join("\n")
76
+ end
77
+
78
+ def trace(failure)
79
+ entries = failure.reduced.entries.map(&:to_s)
80
+ entries.empty? ? ["#{failure.spec_location} (no backtrace available)"] : entries
81
+ end
82
+
83
+ def labelled(label, body)
84
+ "**#{label}**\n\n#{body}"
85
+ end
86
+
87
+ def fenced(lines, language = "text")
88
+ ["```#{language}", *Array(lines), "```"].join("\n")
89
+ end
90
+
91
+ def one_line(text)
92
+ text.to_s.gsub(/\s+/, " ").strip
93
+ end
94
+
95
+ def plural(count, word)
96
+ count == 1 ? word : "#{word}s"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end