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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +326 -38
- data/exe/rspec-signal +9 -0
- data/exe/rspec-signal-parallel +12 -5
- data/lib/rspec/signal/causal/analysis.rb +292 -0
- data/lib/rspec/signal/causal/census.rb +86 -0
- data/lib/rspec/signal/causal/entities.rb +67 -0
- data/lib/rspec/signal/causal/evidence.rb +144 -0
- data/lib/rspec/signal/causal/phase.rb +98 -0
- data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
- data/lib/rspec/signal/code_paths.rb +101 -0
- data/lib/rspec/signal/comparison.rb +101 -0
- data/lib/rspec/signal/configuration.rb +18 -2
- data/lib/rspec/signal/failure.rb +26 -3
- data/lib/rspec/signal/failure_builder.rb +17 -2
- data/lib/rspec/signal/fingerprint.rb +18 -2
- data/lib/rspec/signal/formatter.rb +121 -69
- data/lib/rspec/signal/group.rb +20 -3
- data/lib/rspec/signal/history.rb +102 -0
- data/lib/rspec/signal/message.rb +54 -1
- data/lib/rspec/signal/outside_example.rb +148 -0
- data/lib/rspec/signal/parallel_merger.rb +67 -7
- data/lib/rspec/signal/parallel_run.rb +4 -3
- data/lib/rspec/signal/progress_bar.rb +48 -0
- data/lib/rspec/signal/report.rb +119 -23
- data/lib/rspec/signal/reporters/json_report.rb +3 -2
- data/lib/rspec/signal/reporters/markdown.rb +29 -21
- data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
- data/lib/rspec/signal/reporters/relationships.rb +228 -0
- data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
- data/lib/rspec/signal/rerun.rb +39 -0
- data/lib/rspec/signal/run_status.rb +40 -0
- data/lib/rspec/signal/selection.rb +130 -0
- data/lib/rspec/signal/terminal_summary.rb +119 -0
- data/lib/rspec/signal/version.rb +1 -1
- data/lib/rspec/signal/writer.rb +55 -18
- data/lib/rspec/signal.rb +18 -0
- metadata +20 -2
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Causal
|
|
6
|
+
# One exact signature, or one set of identical errors outside examples.
|
|
7
|
+
# Signatures stay authoritative: this layer relates them, never re-cuts
|
|
8
|
+
# them.
|
|
9
|
+
Unit = Struct.new(:key, :failures, :outside, :position, keyword_init: true) do
|
|
10
|
+
def size
|
|
11
|
+
failures.size
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def evidence
|
|
15
|
+
failures.map(&:evidence).compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def representative
|
|
19
|
+
failures.first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exception_class
|
|
23
|
+
representative.exception_class
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A classified set of units.
|
|
28
|
+
#
|
|
29
|
+
# causal structural evidence that the members share an origin.
|
|
30
|
+
# Always "high": nothing weaker is allowed to form one.
|
|
31
|
+
# scope failures unusually concentrated in one file or one
|
|
32
|
+
# `type:`. A fact about where, never a claim about why.
|
|
33
|
+
# independent no relationship found. Not "proven unrelated".
|
|
34
|
+
Relation = Struct.new(:kind, :units, :evidence, :scope, keyword_init: true) do
|
|
35
|
+
def initialize(**)
|
|
36
|
+
super
|
|
37
|
+
self.evidence ||= []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failures
|
|
41
|
+
units.sum(&:size)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def confidence
|
|
45
|
+
kind == :causal ? "high" : nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Relates signatures by structural evidence and reports how much of the
|
|
50
|
+
# run it has classified. No weights, no similarity, no model: every
|
|
51
|
+
# relationship is an equality between facts read from the run.
|
|
52
|
+
class Analysis
|
|
53
|
+
# The three structural relations, in the order their evidence is shown.
|
|
54
|
+
LINKS = { identities: "shared_exception_object", link_key: "same_underlying_exception",
|
|
55
|
+
entities: "missing_entity" }.freeze
|
|
56
|
+
|
|
57
|
+
SETUP = [["setup", "before(:context) hook"], ["setup", "before hook"], %w[body let]].freeze
|
|
58
|
+
|
|
59
|
+
EVIDENCE_ORDER = %w[shared_exception_object missing_entity outside_examples same_underlying_exception
|
|
60
|
+
setup_failure setup_code failed_in_let teardown_failure].freeze
|
|
61
|
+
|
|
62
|
+
attr_reader :relations, :hints, :expected
|
|
63
|
+
|
|
64
|
+
def self.call(report)
|
|
65
|
+
outside = Grouper.call(report.outside_example_failures)
|
|
66
|
+
units = report.groups.map { |group| unit(group, false) } + outside.map { |group| unit(group, true) }
|
|
67
|
+
units.each_with_index { |unit, index| unit.position = index }
|
|
68
|
+
new(units, census: report.census,
|
|
69
|
+
expected: report.failure_count + report.errors_outside_examples)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.unit(group, outside)
|
|
73
|
+
key = outside ? "outside:#{group.fingerprint.digest}" : group.fingerprint.digest
|
|
74
|
+
Unit.new(key: key, failures: group.failures, outside: outside)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(units, census:, expected:)
|
|
78
|
+
@units = units
|
|
79
|
+
@census = census
|
|
80
|
+
@expected = expected
|
|
81
|
+
@hints = []
|
|
82
|
+
causal, residual = relate
|
|
83
|
+
scoped, independent = ScopeAnalysis.call(residual, @census)
|
|
84
|
+
@relations = causal + scoped + independent.map { |unit| Relation.new(kind: :independent, units: [unit]) }
|
|
85
|
+
@hints.concat(same_origin_hints)
|
|
86
|
+
@hints.concat(reused_instance_hints)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def accounted
|
|
90
|
+
@relations.sum(&:failures)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def count(kind)
|
|
94
|
+
@relations.select { |relation| relation.kind == kind }.sum(&:failures)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def not_captured
|
|
98
|
+
[@expected - accounted, 0].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether there is anything to say beyond the signatures themselves.
|
|
102
|
+
def informative?
|
|
103
|
+
@relations.any? { |relation| relation.kind != :independent }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# ---- causal ------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def relate
|
|
111
|
+
links = structural_links
|
|
112
|
+
parent = @units.map(&:position)
|
|
113
|
+
links.map(&:first).each { |units| units.each_cons(2) { |a, b| union(parent, a.position, b.position) } }
|
|
114
|
+
|
|
115
|
+
causal, residual = components(parent, links).partition { |members, evidence| causal?(members, evidence) }
|
|
116
|
+
relations = causal.map { |members, evidence| Relation.new(kind: :causal, units: members, evidence: evidence) }
|
|
117
|
+
[relations.sort_by { |relation| [-relation.failures, relation.units.first.position] },
|
|
118
|
+
residual.flat_map(&:first)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# [units, evidence] for every value of a linking field that more than
|
|
122
|
+
# one unit carries.
|
|
123
|
+
def structural_links
|
|
124
|
+
LINKS.flat_map do |field, type|
|
|
125
|
+
shared(field).map { |value, units| [units, link_evidence(type, value, units)] }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def components(parent, links)
|
|
130
|
+
@units.group_by { |unit| find(parent, unit.position) }.values.map do |members|
|
|
131
|
+
[members, component_evidence(members, links)]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Several related signatures always form a group. One signature forms
|
|
136
|
+
# one only when it carries a fact beyond its own fingerprint.
|
|
137
|
+
def causal?(members, evidence)
|
|
138
|
+
members.size > 1 || (members.first.size > 1 && !evidence.empty?)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Values of one evidence field carried by more than one unit.
|
|
142
|
+
def shared(field)
|
|
143
|
+
index = Hash.new { |hash, key| hash[key] = [] }
|
|
144
|
+
@units.each { |unit| linkable(unit, field).each { |value| index[value] << unit } }
|
|
145
|
+
index.select { |_, units| units.size > 1 }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def linkable(unit, field)
|
|
149
|
+
evidence = field == :identities ? unit.evidence.select { |item| context_hook?(item) } : unit.evidence
|
|
150
|
+
evidence.flat_map { |item| Array(item.public_send(field)) }.uniq
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def component_evidence(members, links)
|
|
154
|
+
linked = links.select { |units, _| members.include?(units.first) }.map(&:last)
|
|
155
|
+
dedupe(linked + members.flat_map { |unit| facts(unit) })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def link_evidence(type, value, units)
|
|
159
|
+
case type
|
|
160
|
+
when "missing_entity" then entity_evidence(value)
|
|
161
|
+
when "same_underlying_exception" then underlying_evidence(units)
|
|
162
|
+
else { "type" => type, "detail" => "one exception object reached #{units.sum(&:size)} examples" }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def underlying_evidence(units)
|
|
167
|
+
wrapped = units.flat_map(&:evidence).map(&:wrapped).compact.uniq
|
|
168
|
+
classes = units.map(&:exception_class).uniq
|
|
169
|
+
{ "type" => "same_underlying_exception", "exception" => wrapped.first || classes.first,
|
|
170
|
+
"raised_as" => classes, "origin" => units.flat_map(&:evidence).map(&:origin).compact.first }.compact
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def entity_evidence(value)
|
|
174
|
+
kind, name = value.split(":", 2)
|
|
175
|
+
{ "type" => "missing_entity", "entity" => value, "kind" => kind, "name" => name }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# RSpec shares one exception object between examples in exactly one
|
|
179
|
+
# case: a before(:context) hook failed. Any other sharing is an
|
|
180
|
+
# exception *instance* being reused -- and Ruby keeps the first raise's
|
|
181
|
+
# backtrace when an instance is raised again, so nothing about it can
|
|
182
|
+
# be trusted as "raised once". That is a hint, never a link.
|
|
183
|
+
def context_hook?(evidence)
|
|
184
|
+
evidence.phase_detail == "before(:context) hook"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def reused_instance_hints
|
|
188
|
+
carriers = Hash.new { |hash, key| hash[key] = [] }
|
|
189
|
+
@units.reject(&:outside).each do |unit|
|
|
190
|
+
unit.evidence.reject { |item| context_hook?(item) }.each do |item|
|
|
191
|
+
item.identities.each { |token| carriers[token] << unit.key }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
carriers.select { |_, owners| owners.size > 1 }.map do |_, owners|
|
|
195
|
+
{ "type" => "reused_exception_instance", "signatures" => owners.uniq,
|
|
196
|
+
"detail" => "one exception instance was reported by #{owners.size} examples outside a " \
|
|
197
|
+
"before(:context) hook; Ruby keeps the first raise's backtrace, so its origin may be stale" }
|
|
198
|
+
end.uniq
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Facts a single unit carries beyond its fingerprint, when every one of
|
|
202
|
+
# its failures carries them.
|
|
203
|
+
def facts(unit)
|
|
204
|
+
return outside_facts(unit) if unit.outside
|
|
205
|
+
|
|
206
|
+
evidence = unit.evidence
|
|
207
|
+
return [] if evidence.empty? || evidence.size < unit.size
|
|
208
|
+
|
|
209
|
+
common = evidence.map(&:entities).reduce(:&) || []
|
|
210
|
+
shared_object_facts(unit, evidence) + phase_facts(evidence) + common.map { |value| entity_evidence(value) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def shared_object_facts(unit, evidence)
|
|
214
|
+
tokens = evidence.select { |item| context_hook?(item) }.flat_map(&:identities)
|
|
215
|
+
return [] if tokens.uniq.size == tokens.size
|
|
216
|
+
|
|
217
|
+
[{ "type" => "shared_exception_object", "detail" => "one exception object reached #{unit.size} examples" }]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Only when every member failed in a phase that says the same thing. A
|
|
221
|
+
# signature failing partly in setup and partly in the body gets none.
|
|
222
|
+
def phase_facts(evidence)
|
|
223
|
+
phases = evidence.map { |item| [item.phase, item.phase_detail] }.uniq
|
|
224
|
+
type = phase_fact(phases)
|
|
225
|
+
return [] unless type
|
|
226
|
+
|
|
227
|
+
reached = { "teardown_failure" => true, "failed_in_let" => true, "setup_failure" => false }[type]
|
|
228
|
+
[{ "type" => type, "body_reached" => reached, "phase_detail" => phases.map(&:last).join(", "),
|
|
229
|
+
"sites" => evidence.map(&:site).compact.uniq.first(3) }.compact]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def phase_fact(phases)
|
|
233
|
+
return "teardown_failure" if phases == [["teardown", "after hook"]]
|
|
234
|
+
return "failed_in_let" if phases == [%w[body let]]
|
|
235
|
+
return nil unless (phases - SETUP).empty?
|
|
236
|
+
|
|
237
|
+
phases.include?(%w[body let]) ? "setup_code" : "setup_failure"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def outside_facts(unit)
|
|
241
|
+
return [] if unit.size < 2
|
|
242
|
+
|
|
243
|
+
files = unit.failures.map(&:rerun).uniq
|
|
244
|
+
[{ "type" => "outside_examples", "files" => files.first(5), "file_count" => files.size,
|
|
245
|
+
"detail" => "the same error was raised outside any example #{unit.size} times" }]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def dedupe(evidence)
|
|
249
|
+
evidence.uniq { |item| [item["type"], item["entity"], item["phase_detail"]] }
|
|
250
|
+
.sort_by { |item| EVIDENCE_ORDER.index(item["type"]) || EVIDENCE_ORDER.size }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def find(parent, index)
|
|
254
|
+
parent[index] = find(parent, parent[index]) unless parent[index] == index
|
|
255
|
+
parent[index]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def union(parent, first, second)
|
|
259
|
+
a = find(parent, first)
|
|
260
|
+
b = find(parent, second)
|
|
261
|
+
parent[[a, b].max] = [a, b].min unless a == b
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# ---- hints -------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
# Two unrelated units raising the same class from the same first-party
|
|
267
|
+
# line, with different messages. Worth knowing; not worth merging --
|
|
268
|
+
# a chokepoint raises for many reasons.
|
|
269
|
+
def same_origin_hints
|
|
270
|
+
grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
271
|
+
@units.reject(&:outside).each do |unit|
|
|
272
|
+
evidence = unit.evidence.first
|
|
273
|
+
next unless evidence&.origin && evidence.link_key
|
|
274
|
+
|
|
275
|
+
grouped[[unit.exception_class, evidence.origin]] << unit
|
|
276
|
+
end
|
|
277
|
+
grouped.filter_map do |(exception, origin), units|
|
|
278
|
+
next if units.size < 2 || same_relation?(units)
|
|
279
|
+
|
|
280
|
+
{ "type" => "same_origin_different_message", "exception" => exception, "origin" => origin,
|
|
281
|
+
"signatures" => units.map(&:key) }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def same_relation?(units)
|
|
286
|
+
owner = units.map { |unit| @relations.index { |relation| relation.units.include?(unit) } }
|
|
287
|
+
owner.uniq.size == 1 && @relations[owner.first].kind == :causal
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Causal
|
|
6
|
+
# How many examples ran, and failed, per spec file and per `type:`.
|
|
7
|
+
# Counters only -- no descriptions, no messages.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately not per parallel worker: parallel_tests assigns files to
|
|
10
|
+
# workers by size, so "every failure ran on worker 2" usually means
|
|
11
|
+
# "every failing file was assigned to worker 2".
|
|
12
|
+
#
|
|
13
|
+
# This is the base rate every other signal lacks: "9 failures in one file"
|
|
14
|
+
# means something only next to "11 examples in that file, 0 of 493
|
|
15
|
+
# elsewhere failed".
|
|
16
|
+
class Census
|
|
17
|
+
DIMENSIONS = %w[file type].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :total, :failed
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@total = 0
|
|
23
|
+
@failed = 0
|
|
24
|
+
@counts = DIMENSIONS.to_h { |dimension| [dimension, Hash.new { |hash, key| hash[key] = [0, 0] }] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Called with the notification for every passed and failed example.
|
|
28
|
+
# Pending examples neither passed nor failed, and are left out of both
|
|
29
|
+
# sides. Never raises: counting must not cost a failure its report.
|
|
30
|
+
def record(notification, failed:)
|
|
31
|
+
example = notification.example
|
|
32
|
+
add({ "file" => Capture.file_of(example), "type" => Capture.type_of(example) }, failed: failed)
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add(keys, failed:)
|
|
38
|
+
@total += 1
|
|
39
|
+
@failed += 1 if failed
|
|
40
|
+
keys.each { |dimension, value| bump(dimension, value, 1, failed ? 1 : 0) }
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array(Integer, Integer)] examples run and failed for one value
|
|
45
|
+
def count(dimension, value)
|
|
46
|
+
@counts.fetch(dimension, {}).fetch(value.to_s, [0, 0])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def values(dimension)
|
|
50
|
+
@counts.fetch(dimension, {}).keys.sort
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty?
|
|
54
|
+
@total.zero?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_h
|
|
58
|
+
{ "total" => @total, "failed" => @failed }.merge(@counts.reject { |_, values| values.empty? })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Adds one parallel worker's census.
|
|
62
|
+
def merge(data)
|
|
63
|
+
return self unless data.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
run = data.fetch("total", 0).to_i
|
|
66
|
+
failures = data.fetch("failed", 0).to_i
|
|
67
|
+
@total += run
|
|
68
|
+
@failed += failures
|
|
69
|
+
%w[file type].each do |dimension|
|
|
70
|
+
data.fetch(dimension, {}).each { |value, (count, failed)| bump(dimension, value, count.to_i, failed.to_i) }
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def bump(dimension, value, run, failures)
|
|
78
|
+
return if value.nil? || value.to_s.empty? || !@counts.key?(dimension)
|
|
79
|
+
|
|
80
|
+
@counts[dimension][value.to_s][0] += run
|
|
81
|
+
@counts[dimension][value.to_s][1] += failures
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Causal
|
|
6
|
+
# Missing definitions a failure names, read from Ruby exception
|
|
7
|
+
# attributes -- never from message text. See {.call}.
|
|
8
|
+
module Entities
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Missing definitions, read from exception attributes rather than
|
|
12
|
+
# message text:
|
|
13
|
+
#
|
|
14
|
+
# env:KEY KeyError whose receiver is ENV itself
|
|
15
|
+
# const:A::B NameError for a constant, qualified by its namespace
|
|
16
|
+
# method:C#m NoMethodError/NameError on an instance of a class
|
|
17
|
+
# defined in this project (never nil, never a core
|
|
18
|
+
# class: two nils are not the same nil)
|
|
19
|
+
def call(error, config)
|
|
20
|
+
case error
|
|
21
|
+
when KeyError then env_entity(error)
|
|
22
|
+
when NoMethodError then method_entity(error, config)
|
|
23
|
+
when NameError then constant_entity(error) || method_entity(error, config)
|
|
24
|
+
else []
|
|
25
|
+
end
|
|
26
|
+
rescue StandardError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def env_entity(error)
|
|
31
|
+
error.receiver.equal?(ENV) ? ["env:#{error.key}"] : []
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def constant_entity(error)
|
|
37
|
+
name = error.name.to_s
|
|
38
|
+
return nil unless /\A[A-Z]/.match?(name)
|
|
39
|
+
|
|
40
|
+
namespace = error.receiver
|
|
41
|
+
qualified = namespace.is_a?(Module) && namespace != Object && namespace.name
|
|
42
|
+
["const:#{qualified ? "#{namespace.name}::#{name}" : name}"]
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def method_entity(error, config)
|
|
48
|
+
receiver = error.receiver
|
|
49
|
+
owner = receiver.is_a?(Module) ? receiver : receiver.class
|
|
50
|
+
return [] unless owner.name && first_party_class?(owner, config)
|
|
51
|
+
|
|
52
|
+
separator = receiver.is_a?(Module) ? "." : "#"
|
|
53
|
+
["method:#{owner.name}#{separator}#{error.name}"]
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def first_party_class?(owner, config)
|
|
59
|
+
file, = Object.const_source_location(owner.name)
|
|
60
|
+
!file.nil? && config.project.first_party?(file)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Signal
|
|
7
|
+
# Experimental causal-evidence layer. See docs/design/causal-failure-intelligence.md.
|
|
8
|
+
#
|
|
9
|
+
# Everything under this namespace is removable: delete the directory, the
|
|
10
|
+
# relationships reporter, and the few lines that call into it.
|
|
11
|
+
module Causal
|
|
12
|
+
# Structural facts about one failure, captured before backtrace reduction
|
|
13
|
+
# throws the evidence away. Plain data: it serializes into worker payloads
|
|
14
|
+
# so the parallel merger reasons over the same facts a serial run does.
|
|
15
|
+
#
|
|
16
|
+
# phase "setup" | "body" | "teardown" | "outside" | "unknown"
|
|
17
|
+
# phase_detail "before(:context) hook", "before hook", "after hook",
|
|
18
|
+
# "around hook", "let", or nil
|
|
19
|
+
# body_reached true / false, or nil when it cannot be known
|
|
20
|
+
# site the first-party line of the hook or `let` that failed
|
|
21
|
+
# origin innermost first-party frame, spec suite included
|
|
22
|
+
# origin_kind "app" | "support" | "spec"
|
|
23
|
+
# link_key identity of the *underlying* exception: innermost cause
|
|
24
|
+
# class, normalized message and origin. nil for assertion
|
|
25
|
+
# failures, which never link on origin.
|
|
26
|
+
# wrapped the innermost cause's class when the failure wraps one
|
|
27
|
+
# entities missing definitions read from exception attributes
|
|
28
|
+
# identities one token per exception object in the chain
|
|
29
|
+
Evidence = Struct.new(:phase, :phase_detail, :body_reached, :site, :origin, :origin_kind,
|
|
30
|
+
:link_key, :wrapped, :entities, :identities, :file, :type,
|
|
31
|
+
keyword_init: true) do
|
|
32
|
+
def to_h
|
|
33
|
+
super.transform_keys(&:to_s).reject { |_, value| value.nil? || value == [] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.from_h(data)
|
|
37
|
+
return nil unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
new(**members.to_h { |name| [name, data[name.to_s]] }).tap do |evidence|
|
|
40
|
+
evidence.entities = Array(evidence.entities)
|
|
41
|
+
evidence.identities = Array(evidence.identities)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reads {Evidence} out of an exception and its parsed frames.
|
|
47
|
+
#
|
|
48
|
+
# Nothing here guesses. Every field is either read from a Ruby exception
|
|
49
|
+
# attribute, from a frame RSpec itself put on the stack, or left nil.
|
|
50
|
+
module Capture
|
|
51
|
+
ASSERTION = /\ARSpec::(?:Expectations|Mocks)::|\ARSpec::Core::MultipleExceptionError/.freeze
|
|
52
|
+
SPEC_FILE = /_(?:spec|test)\.rb\z/.freeze
|
|
53
|
+
TEST_TREE = %r{\A(?:spec|test)/}.freeze
|
|
54
|
+
MAX_CAUSES = 3
|
|
55
|
+
MAX_CAUSE_FRAMES = 60
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# @param exception [Exception]
|
|
60
|
+
# @param frames [Array<Backtrace::Frame>] the full parsed backtrace
|
|
61
|
+
# @param identities [Hash] compare_by_identity map shared across a run
|
|
62
|
+
def call(exception, frames:, config:, identities:, example: nil)
|
|
63
|
+
chain = chain(exception)
|
|
64
|
+
phase, detail, reached, site = Phase.call(frames)
|
|
65
|
+
origin = frames.find(&:project?)
|
|
66
|
+
Evidence.new(
|
|
67
|
+
phase: phase, phase_detail: detail, body_reached: reached, site: site&.location,
|
|
68
|
+
origin: origin&.location, origin_kind: origin && origin_kind(origin),
|
|
69
|
+
link_key: link_key(chain, origin, config),
|
|
70
|
+
wrapped: chain.size > 1 ? class_name(chain.last) : nil,
|
|
71
|
+
entities: chain.flat_map { |error| Entities.call(error, config) }.uniq,
|
|
72
|
+
identities: chain.map { |error| identities[error] ||= "e#{identities.size + 1}" },
|
|
73
|
+
**location_of(example)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def location_of(example)
|
|
78
|
+
example ? { file: file_of(example), type: type_of(example) } : {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def chain(exception)
|
|
82
|
+
chain = [exception]
|
|
83
|
+
while (cause = chain.last.cause) && chain.none? { |seen| seen.equal?(cause) } && chain.size <= MAX_CAUSES
|
|
84
|
+
chain << cause
|
|
85
|
+
end
|
|
86
|
+
chain
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def origin_kind(frame)
|
|
90
|
+
path = frame.display_path.to_s
|
|
91
|
+
return "spec" if SPEC_FILE.match?(path)
|
|
92
|
+
return "support" if TEST_TREE.match?(path)
|
|
93
|
+
|
|
94
|
+
"app"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Failures link on origin only when the *underlying* exception is the
|
|
98
|
+
# same class, with the same normalized message, raised at the same
|
|
99
|
+
# first-party line. That relates a wrapped exception to its bare
|
|
100
|
+
# twin; it deliberately does not relate two different messages that
|
|
101
|
+
# happen to leave one chokepoint.
|
|
102
|
+
#
|
|
103
|
+
# The exception's own message is used, never RSpec's rendering of it:
|
|
104
|
+
# the rendering opens with the failing source line, which differs at
|
|
105
|
+
# every call site.
|
|
106
|
+
def link_key(chain, origin, config)
|
|
107
|
+
innermost = chain.last
|
|
108
|
+
name = class_name(innermost)
|
|
109
|
+
return nil if ASSERTION.match?(class_name(chain.first)) || ASSERTION.match?(name)
|
|
110
|
+
|
|
111
|
+
location = chain.size == 1 ? origin&.location : cause_origin(innermost, config)
|
|
112
|
+
return nil unless location
|
|
113
|
+
|
|
114
|
+
text = Message.new(innermost.message.to_s.split("\n").first(5), redactor: config.redactor,
|
|
115
|
+
project: config.project,
|
|
116
|
+
html_threshold: nil).normalized
|
|
117
|
+
Digest::SHA256.hexdigest([name, text, location].join("\0"))[0, 12]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cause_origin(error, config)
|
|
121
|
+
frames = Backtrace::Parser.parse(Array(error.backtrace).first(MAX_CAUSE_FRAMES), config.classifier)
|
|
122
|
+
frames.find(&:project?)&.location
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def file_of(example)
|
|
126
|
+
example.metadata[:rerun_file_path].to_s.sub(%r{\A\./}, "")
|
|
127
|
+
rescue StandardError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def type_of(example)
|
|
132
|
+
example.metadata[:type]&.to_s
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def class_name(error)
|
|
138
|
+
name = error.class.name.to_s
|
|
139
|
+
name.empty? ? "(anonymous error class)" : name
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Causal
|
|
6
|
+
# Which part of an example a failure happened in, read from the
|
|
7
|
+
# rspec-core frames just outside the failing code. See {.call}.
|
|
8
|
+
module Phase
|
|
9
|
+
CORE_FILE = %r{/lib/rspec/core/(\w+\.rb)\z}.freeze
|
|
10
|
+
|
|
11
|
+
# The hook methods RSpec runs a failing hook's block from. Resolved from
|
|
12
|
+
# the loaded rspec-core rather than hardcoded, so line numbers moving
|
|
13
|
+
# between releases cannot misclassify a frame.
|
|
14
|
+
HOOKS = {
|
|
15
|
+
"before hook" => %i[BeforeHook run],
|
|
16
|
+
"after hook" => %i[AfterHook run],
|
|
17
|
+
"around hook" => %i[AroundHook execute_with]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Where the failure happened relative to the example, read from the
|
|
23
|
+
# rspec-core frames just outside the failing code:
|
|
24
|
+
#
|
|
25
|
+
# Example#instance_exec -> Example#run example body
|
|
26
|
+
# Example#instance_exec -> BeforeHook#run before(:each)
|
|
27
|
+
# Example#instance_exec -> AfterHook#run after(:each)
|
|
28
|
+
# BasicObject#instance_exec -> BeforeHook#run before(:context)
|
|
29
|
+
# (no Example involved)
|
|
30
|
+
#
|
|
31
|
+
# A `let` raising from inside the body is still the body: it began.
|
|
32
|
+
def call(frames)
|
|
33
|
+
core = frames.each_with_index.filter_map { |frame, index| [frame, index] if core_file(frame) }
|
|
34
|
+
anchor = core.index { |frame, _| %w[example.rb hooks.rb].include?(core_file(frame)) }
|
|
35
|
+
return ["unknown", nil, nil, nil] unless anchor
|
|
36
|
+
|
|
37
|
+
anchor_frame, anchor_index = core[anchor]
|
|
38
|
+
memoized = core.first(anchor).any? { |frame, _| core_file(frame) == "memoized_helpers.rb" }
|
|
39
|
+
caller = core.drop(anchor).map(&:first).find { |frame| !frame.label.to_s.end_with?("instance_exec") }
|
|
40
|
+
site = site(frames, anchor_index, memoized)
|
|
41
|
+
classify(caller, core_file(anchor_frame) == "hooks.rb", memoized) + [site]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def classify(caller, context, memoized)
|
|
45
|
+
return ["unknown", nil, nil] unless caller
|
|
46
|
+
return ["body", memoized ? "let" : nil, true] if body_frame?(caller)
|
|
47
|
+
|
|
48
|
+
case hook_kind(caller)
|
|
49
|
+
when "before hook" then context ? ["setup", "before(:context) hook", false] : ["setup", "before hook", false]
|
|
50
|
+
when "after hook" then ["teardown", "after hook", true]
|
|
51
|
+
when "around hook" then ["unknown", "around hook", nil]
|
|
52
|
+
else ["unknown", nil, nil]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def body_frame?(frame)
|
|
57
|
+
core_file(frame) == "example.rb" && /block in (?:\S+#)?run\z/.match?(frame.label.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def hook_kind(frame)
|
|
61
|
+
return nil unless core_file(frame) == "hooks.rb"
|
|
62
|
+
|
|
63
|
+
label = frame.label.to_s
|
|
64
|
+
by_label = HOOKS.find { |_, (klass, method)| label.include?("#{klass}##{method}") }
|
|
65
|
+
return by_label.first if by_label
|
|
66
|
+
|
|
67
|
+
path = File.expand_path(frame.path)
|
|
68
|
+
hook_sites.find { |_, file, line| file == path && frame.line.to_i.between?(line, line + 3) }&.first
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def hook_sites
|
|
72
|
+
@hook_sites ||= HOOKS.filter_map do |name, (klass, method)|
|
|
73
|
+
file, line = ::RSpec::Core::Hooks.const_get(klass).instance_method(method).source_location
|
|
74
|
+
[name, File.expand_path(file), line]
|
|
75
|
+
rescue StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The first-party line that *is* the failing hook or `let`: the frame
|
|
81
|
+
# just inside the memoized helper for a `let`, and the outermost
|
|
82
|
+
# first-party frame inside the runner otherwise.
|
|
83
|
+
def site(frames, anchor_index, memoized)
|
|
84
|
+
inner = frames.first(anchor_index)
|
|
85
|
+
if memoized
|
|
86
|
+
helper = inner.index { |frame| core_file(frame) == "memoized_helpers.rb" }
|
|
87
|
+
return inner.first(helper.to_i).reverse.find(&:project?)
|
|
88
|
+
end
|
|
89
|
+
inner.reverse.find(&:project?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def core_file(frame)
|
|
93
|
+
frame.path.to_s[CORE_FILE, 1]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|