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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +672 -0
  5. data/exe/rspec-signal +11 -0
  6. data/exe/rspec-signal-parallel +52 -0
  7. data/lib/rspec/signal/backtrace/classifier.rb +88 -0
  8. data/lib/rspec/signal/backtrace/frame.rb +31 -0
  9. data/lib/rspec/signal/backtrace/parser.rb +61 -0
  10. data/lib/rspec/signal/backtrace/reducer.rb +244 -0
  11. data/lib/rspec/signal/cluster.rb +68 -0
  12. data/lib/rspec/signal/clusterer.rb +49 -0
  13. data/lib/rspec/signal/configuration.rb +144 -0
  14. data/lib/rspec/signal/failure.rb +62 -0
  15. data/lib/rspec/signal/failure_builder.rb +224 -0
  16. data/lib/rspec/signal/fingerprint.rb +56 -0
  17. data/lib/rspec/signal/formatter.rb +207 -0
  18. data/lib/rspec/signal/group.rb +61 -0
  19. data/lib/rspec/signal/grouper.rb +28 -0
  20. data/lib/rspec/signal/html_summary.rb +224 -0
  21. data/lib/rspec/signal/integrations/capybara.rb +102 -0
  22. data/lib/rspec/signal/message.rb +161 -0
  23. data/lib/rspec/signal/parallel_merger.rb +125 -0
  24. data/lib/rspec/signal/parallel_run.rb +56 -0
  25. data/lib/rspec/signal/project.rb +158 -0
  26. data/lib/rspec/signal/redactor.rb +91 -0
  27. data/lib/rspec/signal/report.rb +88 -0
  28. data/lib/rspec/signal/reporters/full_output.rb +38 -0
  29. data/lib/rspec/signal/reporters/json_report.rb +20 -0
  30. data/lib/rspec/signal/reporters/markdown.rb +271 -0
  31. data/lib/rspec/signal/reporters/related_failures.rb +101 -0
  32. data/lib/rspec/signal/symptom.rb +22 -0
  33. data/lib/rspec/signal/symptoms/exception_class.rb +46 -0
  34. data/lib/rspec/signal/symptoms/http_status.rb +98 -0
  35. data/lib/rspec/signal/symptoms/record.rb +56 -0
  36. data/lib/rspec/signal/symptoms/route.rb +45 -0
  37. data/lib/rspec/signal/symptoms/ruby_error.rb +55 -0
  38. data/lib/rspec/signal/symptoms/selector.rb +72 -0
  39. data/lib/rspec/signal/symptoms.rb +42 -0
  40. data/lib/rspec/signal/version.rb +7 -0
  41. data/lib/rspec/signal/writer.rb +88 -0
  42. data/lib/rspec/signal.rb +156 -0
  43. data/lib/rspec-signal.rb +3 -0
  44. 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