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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +672 -0
- data/exe/rspec-signal +11 -0
- data/exe/rspec-signal-parallel +52 -0
- data/lib/rspec/signal/backtrace/classifier.rb +88 -0
- data/lib/rspec/signal/backtrace/frame.rb +31 -0
- data/lib/rspec/signal/backtrace/parser.rb +61 -0
- data/lib/rspec/signal/backtrace/reducer.rb +244 -0
- data/lib/rspec/signal/cluster.rb +68 -0
- data/lib/rspec/signal/clusterer.rb +49 -0
- data/lib/rspec/signal/configuration.rb +144 -0
- data/lib/rspec/signal/failure.rb +62 -0
- data/lib/rspec/signal/failure_builder.rb +224 -0
- data/lib/rspec/signal/fingerprint.rb +56 -0
- data/lib/rspec/signal/formatter.rb +207 -0
- data/lib/rspec/signal/group.rb +61 -0
- data/lib/rspec/signal/grouper.rb +28 -0
- data/lib/rspec/signal/html_summary.rb +224 -0
- data/lib/rspec/signal/integrations/capybara.rb +102 -0
- data/lib/rspec/signal/message.rb +161 -0
- data/lib/rspec/signal/parallel_merger.rb +125 -0
- data/lib/rspec/signal/parallel_run.rb +56 -0
- data/lib/rspec/signal/project.rb +158 -0
- data/lib/rspec/signal/redactor.rb +91 -0
- data/lib/rspec/signal/report.rb +88 -0
- data/lib/rspec/signal/reporters/full_output.rb +38 -0
- data/lib/rspec/signal/reporters/json_report.rb +20 -0
- data/lib/rspec/signal/reporters/markdown.rb +271 -0
- data/lib/rspec/signal/reporters/related_failures.rb +101 -0
- data/lib/rspec/signal/symptom.rb +22 -0
- data/lib/rspec/signal/symptoms/exception_class.rb +46 -0
- data/lib/rspec/signal/symptoms/http_status.rb +98 -0
- data/lib/rspec/signal/symptoms/record.rb +56 -0
- data/lib/rspec/signal/symptoms/route.rb +45 -0
- data/lib/rspec/signal/symptoms/ruby_error.rb +55 -0
- data/lib/rspec/signal/symptoms/selector.rb +72 -0
- data/lib/rspec/signal/symptoms.rb +42 -0
- data/lib/rspec/signal/version.rb +7 -0
- data/lib/rspec/signal/writer.rb +88 -0
- data/lib/rspec/signal.rb +156 -0
- data/lib/rspec-signal.rb +3 -0
- 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
|