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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # A set of failures that share a fingerprint.
6
+ class Group
7
+ attr_reader :fingerprint, :failures, :first_seen
8
+
9
+ def initialize(fingerprint:, first_seen:)
10
+ @fingerprint = fingerprint
11
+ @first_seen = first_seen
12
+ @failures = []
13
+ end
14
+
15
+ def <<(failure)
16
+ @failures << failure
17
+ self
18
+ end
19
+
20
+ def size = @failures.size
21
+
22
+ # The failure shown in full. We pick the one carrying the most first-party
23
+ # frames, because that is the one whose trace is most useful; ties break on
24
+ # run order so the choice is stable across runs.
25
+ def representative
26
+ @representative ||= @failures.each_with_index.max_by do |failure, index|
27
+ [failure.reduced.project_frames.size, failure.reduced.kept_count, -index]
28
+ end.first
29
+ end
30
+
31
+ def exception_class = representative.exception_class
32
+ def message = representative.message
33
+
34
+ # Locations of every example in the group, in run order, deduplicated.
35
+ def affected_locations
36
+ @affected_locations ||= @failures.map(&:spec_location).uniq
37
+ end
38
+
39
+ def others
40
+ @failures.reject { |failure| failure.equal?(representative) }
41
+ end
42
+
43
+ # Total backtrace frames this group's failures dropped.
44
+ def omitted_frames
45
+ @failures.sum { |failure| failure.reduced.omitted_count }
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ signature: fingerprint.digest,
51
+ count: size,
52
+ exception: exception_class,
53
+ culprit: fingerprint.culprit,
54
+ app_context: fingerprint.app_context,
55
+ representative: representative.to_h,
56
+ affected: @failures.map { |failure| { location: failure.spec_location, description: failure.description } }
57
+ }.compact
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # Collapses failures that share a fingerprint into {Group}s.
6
+ #
7
+ # Ordering is deterministic: biggest group first, ties broken by the order
8
+ # the failures were seen, so two runs of the same suite produce byte-identical
9
+ # reports.
10
+ module Grouper
11
+ module_function
12
+
13
+ # @param failures [Array<Failure>]
14
+ # @return [Array<Group>]
15
+ def call(failures)
16
+ groups = {}
17
+
18
+ failures.each_with_index do |failure, index|
19
+ key = failure.fingerprint.digest
20
+ groups[key] ||= Group.new(fingerprint: failure.fingerprint, first_seen: index)
21
+ groups[key] << failure
22
+ end
23
+
24
+ groups.values.sort_by { |group| [-group.size, group.first_seen] }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # Recognises bulk HTML inside a failure message and replaces it with the
6
+ # handful of facts that actually diagnose it.
7
+ #
8
+ # A request spec that expects one sentence and receives a Rails exception
9
+ # page produces a diff several thousand lines long whose opening hundred
10
+ # lines are the exception page's own CSS. Printing the start of that is
11
+ # worse than useless: it is the one part of the response guaranteed to be
12
+ # identical for every failure in the suite. So the expected value is left
13
+ # exactly as it was, the actual value is named as HTML and measured, and the
14
+ # title, headings and leading visible text are pulled out -- which on a
15
+ # Rails error page is the exception class and its message.
16
+ #
17
+ # Regex only, deliberately. A DOM parser would be a new hard dependency for
18
+ # something that never has to be correct, only useful, and which is handed
19
+ # broken markup by definition.
20
+ class HtmlSummary
21
+ MARKER = "[HTML document]"
22
+
23
+ MIN_TAGS = 5
24
+ SCAN_WINDOW = 4_000
25
+ MAX_FACT_CHARS = 160
26
+ MAX_FACTS = 4
27
+
28
+ DOCUMENT_START = /\A\s*(?:<!doctype\s+html|<html[\s>])/i
29
+ TAG = %r{</?[a-z][a-z0-9]*(?:\s[^<>]*?)?/?>}im
30
+ STRUCTURAL = /<(?:html|head|body|div|table|section|main|article|p)\b/i
31
+ QUOTED = /"(?:[^"\\]|\\.)*"/m
32
+ DIFF_LINE = /\A(\s*)([-+])(.*)\z/m
33
+ NOISE = %r{<(script|style)\b[^>]*>.*?</\1>|<!--.*?-->}im
34
+ TITLE = %r{<title[^>]*>(.*?)</title>}im
35
+ H1 = %r{<h1[^>]*>(.*?)</h1>}im
36
+ H2 = %r{<h2[^>]*>(.*?)</h2>}im
37
+ PRE = %r{<pre[^>]*>(.*?)</pre>}im
38
+
39
+ ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", "e" => "\e", '"' => '"', "\\" => "\\" }.freeze
40
+ ENTITIES = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"',
41
+ "apos" => "'", "#39" => "'", "nbsp" => " " }.freeze
42
+
43
+ class << self
44
+ # @param lines [Array<String>] message lines
45
+ # @param threshold [Integer] smallest blob worth summarising
46
+ # @return [Array<String>] the same lines with bulk HTML replaced
47
+ def reduce(lines, threshold:)
48
+ reduce_diff_runs(reduce_inline(lines, threshold), threshold)
49
+ end
50
+
51
+ # @return [HtmlSummary, nil]
52
+ def summarise(text, threshold)
53
+ return nil if text.length < threshold
54
+
55
+ html = unescape(text)
56
+ return nil unless html?(html)
57
+
58
+ new(html)
59
+ end
60
+
61
+ def html?(text)
62
+ return true if DOCUMENT_START.match?(text)
63
+
64
+ window = text[0, SCAN_WINDOW].to_s
65
+ STRUCTURAL.match?(window) && window.scan(TAG).size >= MIN_TAGS
66
+ end
67
+
68
+ private
69
+
70
+ # RSpec renders the actual value with `inspect`, so a whole response
71
+ # body arrives as one enormous line with two-character escapes in it.
72
+ def reduce_inline(lines, threshold)
73
+ lines.flat_map do |line|
74
+ next [line] if line.length < threshold
75
+
76
+ replaced, summaries = replace_blobs(line, threshold)
77
+ next [line] if summaries.empty?
78
+
79
+ [replaced, "", *summaries.flat_map(&:to_lines)]
80
+ end
81
+ end
82
+
83
+ def replace_blobs(line, threshold)
84
+ summaries = []
85
+ replaced = line.gsub(QUOTED) do |quoted|
86
+ summary = summarise(quoted[1..-2].to_s, threshold)
87
+ next quoted unless summary
88
+
89
+ summaries << summary
90
+ MARKER
91
+ end
92
+ return [replaced, summaries] unless summaries.empty?
93
+
94
+ replace_bare_blob(line, threshold)
95
+ end
96
+
97
+ # The same value, but rendered without quotes -- `eq` diffs and some
98
+ # custom matchers do this.
99
+ def replace_bare_blob(line, threshold)
100
+ start = line =~ /<(?:!doctype|html|head|body|div)\b/i
101
+ return [line, []] unless start
102
+
103
+ summary = summarise(line[start..].to_s, threshold)
104
+ return [line, []] unless summary
105
+
106
+ ["#{line[0, start]}#{MARKER}", [summary]]
107
+ end
108
+
109
+ # In a unified diff the response arrives as a run of real lines, all
110
+ # carrying the same +/- marker.
111
+ def reduce_diff_runs(lines, threshold)
112
+ result = []
113
+ index = 0
114
+ while index < lines.length
115
+ finish, summary = diff_run(lines, index, threshold)
116
+ if finish.nil?
117
+ result << lines[index]
118
+ index += 1
119
+ else
120
+ result.concat(summary ? summary.to_lines : lines[index...finish])
121
+ index = finish
122
+ end
123
+ end
124
+ result
125
+ end
126
+
127
+ def diff_run(lines, start, threshold)
128
+ match = DIFF_LINE.match(lines[start].to_s)
129
+ return nil unless match
130
+
131
+ marker = match[2]
132
+ finish = start
133
+ payload = []
134
+ while (line = lines[finish]) && (parts = DIFF_LINE.match(line.to_s)) && parts[2] == marker
135
+ payload << parts[3]
136
+ finish += 1
137
+ end
138
+ [finish, summarise(payload.join("\n"), threshold)]
139
+ end
140
+
141
+ # Only inspected strings carry escapes; a diff run is already real lines.
142
+ def unescape(text)
143
+ return text if text.include?("\n")
144
+
145
+ text.gsub(/\\(.)/) { ESCAPES.fetch(::Regexp.last_match(1), ::Regexp.last_match(0)) }
146
+ end
147
+ end
148
+
149
+ attr_reader :line_count, :byte_size
150
+
151
+ def initialize(html)
152
+ @html = html
153
+ @line_count = html.count("\n") + 1
154
+ @byte_size = html.bytesize
155
+ end
156
+
157
+ # `[["Title", "Action Controller: Exception caught"], ...]`
158
+ def facts
159
+ @facts ||= build_facts
160
+ end
161
+
162
+ def to_lines
163
+ ["[HTML document: #{number(line_count)} #{plural(line_count, "line")}, #{human_size} " \
164
+ "-- markup omitted]",
165
+ *facts.map { |(label, value)| " #{label}: #{value}" }]
166
+ end
167
+
168
+ def to_h
169
+ { lines: line_count, bytes: byte_size }.merge(facts.to_h { |(label, value)| [label.downcase.to_sym, value] })
170
+ end
171
+
172
+ private
173
+
174
+ def build_facts
175
+ body = @html.gsub(NOISE, " ")
176
+ entries = []
177
+ push(entries, "Title", tag_text(@html, TITLE))
178
+ push(entries, "Heading", tag_text(body, H1))
179
+ push(entries, "Message", tag_text(body, H2) || tag_text(body, PRE))
180
+ push(entries, "Text", excerpt(body)) if entries.size < 2
181
+ entries.first(MAX_FACTS)
182
+ end
183
+
184
+ def push(entries, label, value)
185
+ return if value.nil? || value.empty?
186
+ return if entries.any? { |(_, existing)| existing == value }
187
+
188
+ entries << [label, value]
189
+ end
190
+
191
+ def tag_text(source, pattern)
192
+ match = pattern.match(source)
193
+ return nil unless match
194
+
195
+ visible(match[1])
196
+ end
197
+
198
+ def excerpt(body) = visible(body)
199
+
200
+ def visible(fragment)
201
+ text = fragment.to_s.gsub(NOISE, " ").gsub(TAG, " ")
202
+ text = text.gsub(/&(#?\w+);/) { ENTITIES.fetch(::Regexp.last_match(1).downcase, " ") }
203
+ truncate(text.gsub(/\s+/, " ").strip)
204
+ end
205
+
206
+ def truncate(text)
207
+ return text if text.length <= MAX_FACT_CHARS
208
+
209
+ "#{text[0, MAX_FACT_CHARS - 1]}…"
210
+ end
211
+
212
+ def human_size
213
+ return "#{number(byte_size)} bytes" if byte_size < 1024
214
+ return "#{number((byte_size / 1024.0).round)} KB" if byte_size < (1024 * 1024)
215
+
216
+ "#{(byte_size / (1024.0 * 1024)).round(1)} MB"
217
+ end
218
+
219
+ def number(value) = value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
220
+
221
+ def plural(count, word) = count == 1 ? word : "#{word}s"
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RSpec
6
+ module Signal
7
+ module Integrations
8
+ # Captures browser state for failing system/feature examples.
9
+ #
10
+ # A failing system spec whose report says only "Unable to find css" is
11
+ # much less useful than one that also says which URL the browser was on
12
+ # and what the JavaScript console said. Everything here is best effort:
13
+ # any problem simply means the report has less detail.
14
+ module Capybara
15
+ RELEVANT_TYPES = %i[system feature].freeze
16
+ MAX_CONSOLE_LINES = 20
17
+
18
+ module_function
19
+
20
+ def install!(rspec_config, signal_config)
21
+ # `prepend_after` so this runs *before* any example-group-level
22
+ # `after(:each)` hook -- rspec-rails tears the Capybara session down
23
+ # at that level, and capturing after teardown would find nothing.
24
+ rspec_config.prepend_after(:each) do |example|
25
+ RSpec::Signal::Integrations::Capybara.capture(example, signal_config)
26
+ end
27
+ end
28
+
29
+ def capture(example, signal_config)
30
+ return unless example.exception
31
+ return unless relevant?(example)
32
+
33
+ session = existing_session
34
+ return unless session
35
+
36
+ diagnostics = collect(session, signal_config)
37
+ return if diagnostics.empty?
38
+
39
+ example.metadata[:rspec_signal_diagnostics] =
40
+ (example.metadata[:rspec_signal_diagnostics] || {}).merge(diagnostics)
41
+ rescue StandardError, ::LoadError
42
+ nil
43
+ end
44
+
45
+ def relevant?(example)
46
+ metadata = example.metadata
47
+ RELEVANT_TYPES.include?(metadata[:type]) || metadata[:js] == true
48
+ end
49
+
50
+ # Deliberately avoids `Capybara.current_session`, which would *create* a
51
+ # session (and possibly boot a browser) if none exists.
52
+ def existing_session
53
+ return nil unless defined?(::Capybara)
54
+
55
+ pool = ::Capybara.instance_variable_get(:@session_pool)
56
+ return nil if pool.nil? || pool.empty?
57
+
58
+ ::Capybara.current_session
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def collect(session, signal_config)
64
+ diagnostics = {}
65
+ diagnostics[:url] = try { session.current_url }
66
+ diagnostics[:path] = try { session.current_path }
67
+ diagnostics[:title] = try { session.title }
68
+ diagnostics[:status_code] = try { session.status_code }
69
+ diagnostics[:driver] = try { ::Capybara.current_driver }
70
+ diagnostics[:console] = console_messages(session)
71
+ diagnostics[:saved_page] = write_page_html(session, signal_config) if signal_config.capture_page_html
72
+ diagnostics.reject { |_, value| value.nil? || value.to_s.strip.empty? }
73
+ end
74
+
75
+ # Browser console output is small and often contains the actual cause of
76
+ # a JavaScript-driven failure.
77
+ def console_messages(session)
78
+ logs = try { session.driver.browser.logs.get(:browser) }
79
+ return nil if logs.nil? || logs.empty?
80
+
81
+ logs.last(MAX_CONSOLE_LINES).map { |entry| try { "#{entry.level}: #{entry.message}" } }.compact
82
+ end
83
+
84
+ def write_page_html(session, signal_config)
85
+ dir = File.join(signal_config.output_path, "pages")
86
+ FileUtils.mkdir_p(dir)
87
+ path = File.join(dir, "#{Time.now.to_i}-#{rand(1_000_000)}.html")
88
+ File.write(path, session.html)
89
+ signal_config.project.display_path(path)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+
94
+ def try
95
+ yield
96
+ rescue StandardError, ::NotImplementedError
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Signal
5
+ # The human-readable failure message, plus a normalized form used for
6
+ # grouping.
7
+ class Message
8
+ ANSI = /\e\[[0-9;]*[A-Za-z]/
9
+
10
+ # Volatile substrings that would otherwise split identical failures into
11
+ # separate signatures.
12
+ NORMALIZERS = [
13
+ [/0x[0-9a-f]{4,}/i, "0xXXXX"],
14
+ [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<uuid>"],
15
+ [/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/, "<time>"],
16
+ # No `\b` here: there is no word boundary between a space and a slash.
17
+ [%r{(?<![\w/])(?:/tmp|/var/folders|/private/var/folders)/[\w./+-]+}, "<tmppath>"],
18
+ [/\bid[:=]\s*\d+/i, "id=<n>"],
19
+ [/\b\d{4,}\b/, "<n>"],
20
+ [/:\d+:in\s+[`'][^'`]*['`]/, ""],
21
+ # Diff hunk headers count lines; the lines themselves are already in
22
+ # the message, and the counts split otherwise identical failures.
23
+ [/@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, "@@"],
24
+ # An HTML summary's size differs between two renderings of the same
25
+ # broken page; its title and headings do not, and those are the part
26
+ # worth fingerprinting.
27
+ [/\[HTML document:[^\]]*\]/, "[HTML document]"]
28
+ ].freeze
29
+
30
+ MAX_FINGERPRINT_CHARS = 400
31
+
32
+ # Smallest HTML blob worth replacing with a summary. Below this the
33
+ # markup is short enough to read, and reading it is the point.
34
+ DEFAULT_HTML_THRESHOLD = 1_500
35
+
36
+ attr_reader :lines, :cause_lines
37
+
38
+ # @param lines [Array<String>] message lines as RSpec presents them
39
+ # @param redactor [Redactor]
40
+ # @param project [Project]
41
+ # @param html_threshold [Integer, nil] nil disables HTML reduction
42
+ # @param cause_lines [Array<String>] the `Caused by ...` chain, already
43
+ # bounded by {FailureBuilder} -- kept apart from `lines` so neither the
44
+ # line budget in {#body} nor the character budget in {#normalized} can
45
+ # push it out. It is very often the actual answer.
46
+ def initialize(lines, redactor:, project:, html_threshold: DEFAULT_HTML_THRESHOLD, cause_lines: [])
47
+ @redactor = redactor
48
+ @project = project
49
+ @lines = trim(squeeze(reduce_html(normalize(lines), html_threshold)))
50
+ @cause_lines = trim(squeeze(reduce_html(normalize(cause_lines), html_threshold)))
51
+ end
52
+
53
+ def empty? = @lines.all?(&:empty?) && @cause_lines.all?(&:empty?)
54
+
55
+ def text
56
+ return @lines.join("\n") if @cause_lines.empty?
57
+
58
+ "#{@lines.join("\n")}\n\n#{@cause_lines.join("\n")}"
59
+ end
60
+
61
+ # First meaningful line, for headings and one-line summaries.
62
+ def headline(limit = 160)
63
+ line = @lines.find { |l| !l.strip.empty? }.to_s.strip
64
+ line = @lines.reject(&:empty?)[1].to_s.strip if line.empty?
65
+ truncate(line, limit)
66
+ end
67
+
68
+ # The message body for the report, with oversized diffs trimmed.
69
+ #
70
+ # Diffs are the single biggest source of bloat in RSpec output, and the
71
+ # first lines of a diff almost always carry the signal. The cause chain
72
+ # is appended after truncation, never counted against `max_lines`: a
73
+ # verbose wrapper message must not be able to push the root cause out.
74
+ def body(max_lines: 30, max_diff_lines: 20)
75
+ kept = truncated_lines(max_lines: max_lines, max_diff_lines: max_diff_lines)
76
+ return kept if @cause_lines.empty?
77
+
78
+ kept + ["", *@cause_lines]
79
+ end
80
+
81
+ # Stable form used for fingerprinting. The cause chain is appended after
82
+ # the main text is truncated to `MAX_FINGERPRINT_CHARS`, so two
83
+ # otherwise-identical wrapper messages with different causes never
84
+ # collapse into one signature just because the wrapper is long.
85
+ def normalized
86
+ @normalized ||= begin
87
+ main = truncate(normalize_for_fingerprint(@lines.join(" ")), MAX_FINGERPRINT_CHARS)
88
+ cause = normalize_for_fingerprint(@cause_lines.join(" "))
89
+ cause.empty? ? main : "#{main} #{cause}"
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def truncated_lines(max_lines:, max_diff_lines:)
96
+ kept = []
97
+ diff_seen = 0
98
+ in_diff = false
99
+
100
+ @lines.each do |line|
101
+ in_diff = true if line.strip.start_with?("Diff:", "@@")
102
+ if in_diff
103
+ diff_seen += 1
104
+ next if diff_seen > max_diff_lines
105
+ end
106
+ kept << line
107
+ break if kept.size >= max_lines
108
+ end
109
+
110
+ omitted = @lines.size - kept.size
111
+ kept << "[#{omitted} more message line#{"s" unless omitted == 1} omitted]" if omitted.positive?
112
+ kept
113
+ end
114
+
115
+ def normalize_for_fingerprint(text)
116
+ text = @project.relative_to_root(text) if text.include?(@project.root)
117
+ text = text.gsub(@project.root, ".")
118
+ NORMALIZERS.each { |(pattern, replacement)| text = text.gsub(pattern, replacement) }
119
+ text.gsub(/\s+/, " ").strip
120
+ end
121
+
122
+ # Reduction happens once, here, so that every later stage -- the body,
123
+ # the headline in the index table, and the fingerprint -- sees the
124
+ # summary rather than six thousand lines of exception-page CSS. The
125
+ # original text can still be written verbatim to `full.txt` when enabled.
126
+ def reduce_html(lines, threshold)
127
+ return lines unless threshold
128
+
129
+ HtmlSummary.reduce(lines, threshold: threshold)
130
+ rescue StandardError
131
+ lines
132
+ end
133
+
134
+ def normalize(lines)
135
+ Array(lines).flat_map { |line| split_lines(line) }
136
+ .map { |line| @redactor.call(line.gsub(ANSI, "")).rstrip }
137
+ end
138
+
139
+ # `"".split("\n")` returns `[]`, which would silently swallow the blank
140
+ # lines RSpec uses to separate the failing expression from the diff.
141
+ def split_lines(line)
142
+ text = line.to_s
143
+ text.empty? ? [""] : text.split("\n")
144
+ end
145
+
146
+ def squeeze(lines)
147
+ lines.chunk_while { |a, b| a.empty? && b.empty? }.map(&:first)
148
+ end
149
+
150
+ def trim(lines)
151
+ lines.drop_while(&:empty?).reverse.drop_while(&:empty?).reverse
152
+ end
153
+
154
+ def truncate(string, limit)
155
+ return string if string.length <= limit
156
+
157
+ "#{string[0, limit - 1]}…"
158
+ end
159
+ end
160
+ end
161
+ end