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,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Causal
|
|
6
|
+
# Failure concentration: where the failures that nothing structural
|
|
7
|
+
# related are unusually dense. Evidence about *where*, never *why*, so it
|
|
8
|
+
# is kept apart from the causal analysis and never carries a confidence:
|
|
9
|
+
# "9 of 11 examples in this file failed" is exactly what it claims.
|
|
10
|
+
#
|
|
11
|
+
# It sees only what the causal pass left unrelated, and only the census.
|
|
12
|
+
class ScopeAnalysis
|
|
13
|
+
# The only thresholds in the relationship analysis. Internal constants
|
|
14
|
+
# while the layer is experimental, never configuration.
|
|
15
|
+
MIN_FAILURES = 3
|
|
16
|
+
INSIDE = Rational(2, 3) # at least this share of the scope failed
|
|
17
|
+
OUTSIDE = Rational(1, 20) # and less than this share of the rest did
|
|
18
|
+
DIMENSIONS = %w[file type].freeze
|
|
19
|
+
|
|
20
|
+
# @param residual [Array<Unit>] units no causal relation claimed
|
|
21
|
+
# @param census [Census, nil]
|
|
22
|
+
# @return [Array(Array<Relation>, Array<Unit>)] scope groups, and what is left
|
|
23
|
+
def self.call(residual, census)
|
|
24
|
+
return [[], residual] if census.nil? || census.empty?
|
|
25
|
+
|
|
26
|
+
new(census).call(residual)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(census)
|
|
30
|
+
@census = census
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Most specific scope first, until none qualifies.
|
|
34
|
+
def call(residual)
|
|
35
|
+
scoped = []
|
|
36
|
+
loop do
|
|
37
|
+
best = candidates(residual).min_by { |candidate| candidate.values_at(:run, :order, :value) }
|
|
38
|
+
break unless best
|
|
39
|
+
|
|
40
|
+
scoped << Relation.new(kind: :scope, units: best[:units], scope: best[:stats])
|
|
41
|
+
residual -= best[:units]
|
|
42
|
+
end
|
|
43
|
+
[scoped, residual]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def candidates(residual)
|
|
49
|
+
examples = residual.reject(&:outside)
|
|
50
|
+
DIMENSIONS.each_with_index.flat_map do |dimension, order|
|
|
51
|
+
@census.values(dimension).filter_map do |value|
|
|
52
|
+
inside = examples.select { |unit| within?(unit, dimension, value) }
|
|
53
|
+
stats = concentration(dimension, value)
|
|
54
|
+
next unless stats && inside.size >= 2 && inside.sum(&:size) >= MIN_FAILURES
|
|
55
|
+
|
|
56
|
+
{ units: inside, stats: stats, run: stats["examples"], order: order, value: value }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def within?(unit, dimension, value)
|
|
62
|
+
evidence = unit.evidence
|
|
63
|
+
evidence.size == unit.size && evidence.all? { |item| item.public_send(dimension).to_s == value }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def concentration(dimension, value)
|
|
67
|
+
run, failed = @census.count(dimension, value)
|
|
68
|
+
elsewhere_run = @census.total - run
|
|
69
|
+
elsewhere_failed = @census.failed - failed
|
|
70
|
+
return nil if run.zero? || elsewhere_run < run
|
|
71
|
+
return nil if Rational(failed, run) < INSIDE
|
|
72
|
+
return nil unless Rational(elsewhere_failed, elsewhere_run) < OUTSIDE
|
|
73
|
+
|
|
74
|
+
{ "dimension" => dimension, "value" => value, "failed" => failed, "examples" => run,
|
|
75
|
+
"failed_elsewhere" => elsewhere_failed, "examples_elsewhere" => elsewhere_run }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# One line of first-party code that several distinct signatures run through.
|
|
6
|
+
class CodePath
|
|
7
|
+
attr_reader :location, :first_seen, :failures
|
|
8
|
+
|
|
9
|
+
def initialize(location:, first_seen:)
|
|
10
|
+
@location = location
|
|
11
|
+
@first_seen = first_seen
|
|
12
|
+
@failures = []
|
|
13
|
+
# Insertion-ordered set. A location crossed by hundreds of failures
|
|
14
|
+
# would otherwise rescan the whole list on every one of them.
|
|
15
|
+
@seen = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(failure)
|
|
19
|
+
@failures << failure
|
|
20
|
+
@seen[failure.fingerprint.digest] = true
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The distinct signatures crossing this line, in the order first seen.
|
|
25
|
+
def signatures
|
|
26
|
+
@seen.keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def size
|
|
30
|
+
@failures.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def signature_count
|
|
34
|
+
@seen.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def file
|
|
38
|
+
@file ||= location.sub(/:\d+\z/, "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The method names the frame was seen under, most common first. Useful
|
|
42
|
+
# when one file:line is reached through several call paths.
|
|
43
|
+
def labels
|
|
44
|
+
@labels ||= @failures.flat_map { |failure| failure.application_frames.select { |f| f.location == location } }
|
|
45
|
+
.map(&:label).compact.reject(&:empty?).uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ location: location, signatures: signatures, signature_count: signature_count,
|
|
50
|
+
examples: size, labels: labels.first(3) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Finds the first-party lines that more than one signature passes through.
|
|
55
|
+
#
|
|
56
|
+
# Every other clustering signal in this gem reads the failure *message*.
|
|
57
|
+
# This one reads the *stack*, which is the only thing that can relate a
|
|
58
|
+
# `KeyError` to the `expect { }.not_to raise_error` that swallowed it. Both
|
|
59
|
+
# traces run through `app/pricing.rb:4`; nothing about their messages says
|
|
60
|
+
# so.
|
|
61
|
+
#
|
|
62
|
+
# It claims nothing about causation. It reports a measured fact -- these N
|
|
63
|
+
# signatures execute this line -- and the report says it in those words.
|
|
64
|
+
#
|
|
65
|
+
# Two rules keep it honest, and they are the ones the symptom clusterer
|
|
66
|
+
# already uses:
|
|
67
|
+
#
|
|
68
|
+
# * only first-party frames outside the spec suite are indexed, and only
|
|
69
|
+
# the innermost few, because the outer end of a stack is shared
|
|
70
|
+
# plumbing (`application_controller.rb`) while the inner end is the bug;
|
|
71
|
+
# * a location is reported only when two or more *distinct signatures*
|
|
72
|
+
# cross it. One signature crossing it is already reported as that
|
|
73
|
+
# signature's app context, and repeating it would be noise.
|
|
74
|
+
module CodePaths
|
|
75
|
+
MIN_SIGNATURES = 2
|
|
76
|
+
|
|
77
|
+
# How far in from the raise site to look. Deep enough to pass through a
|
|
78
|
+
# couple of application layers, shallow enough to stay off the framework
|
|
79
|
+
# boundary.
|
|
80
|
+
DEFAULT_DEPTH = 5
|
|
81
|
+
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
# @param failures [Array<Failure>]
|
|
85
|
+
# @return [Array<CodePath>] most signatures spanned first; deterministic
|
|
86
|
+
def call(failures, depth: DEFAULT_DEPTH, min_signatures: MIN_SIGNATURES)
|
|
87
|
+
paths = {}
|
|
88
|
+
|
|
89
|
+
failures.each_with_index do |failure, index|
|
|
90
|
+
failure.application_frames(depth).map(&:location).uniq.each do |location|
|
|
91
|
+
paths[location] ||= CodePath.new(location: location, first_seen: index)
|
|
92
|
+
paths[location].add(failure)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
paths.values.select { |path| path.signature_count >= min_signatures }
|
|
97
|
+
.sort_by { |path| [-path.signature_count, -path.size, path.first_seen] }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# What changed between the previous run and this one.
|
|
6
|
+
#
|
|
7
|
+
# Four buckets, from two keys per signature:
|
|
8
|
+
#
|
|
9
|
+
# digest the full fingerprint (exception, message, culprit, app context)
|
|
10
|
+
# loose exception and message only -- independent of line numbers
|
|
11
|
+
#
|
|
12
|
+
# persistent paired entries with the same digest
|
|
13
|
+
# resolved unpaired old entries after loose matching
|
|
14
|
+
# new unpaired new entries after loose matching
|
|
15
|
+
# changed remaining old/new entries paired by loose key
|
|
16
|
+
#
|
|
17
|
+
# The fourth bucket is what makes the other three trustworthy. Editing the
|
|
18
|
+
# file that raises shifts every culprit line, which without it turns every
|
|
19
|
+
# persistent failure into a spurious resolved/new pair -- and an agent that
|
|
20
|
+
# reads "28 resolved" when it resolved nothing is worse off than one told
|
|
21
|
+
# nothing at all.
|
|
22
|
+
class Comparison
|
|
23
|
+
Change = Struct.new(:digest, :loose, :exception, :count, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
|
|
24
|
+
def to_h
|
|
25
|
+
{ signature: digest, exception: exception, examples: count }.compact
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :previous_run_id, :previous_at, :previous_failures, :failure_count,
|
|
30
|
+
:persistent, :resolved, :new_signatures, :changed
|
|
31
|
+
|
|
32
|
+
def initialize(previous:, current:)
|
|
33
|
+
@previous_run_id = previous["run_id"]
|
|
34
|
+
@previous_at = previous["at"]
|
|
35
|
+
@previous_failures = previous.fetch("failures", 0)
|
|
36
|
+
@failure_count = current.fetch("failures", 0)
|
|
37
|
+
classify(entries(previous), entries(current))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def any?
|
|
41
|
+
[persistent, resolved, new_signatures, changed].any? { |bucket| !bucket.empty? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads as a sentence and fits on one terminal line.
|
|
45
|
+
def headline
|
|
46
|
+
parts = []
|
|
47
|
+
parts << "#{resolved.size} resolved" unless resolved.empty?
|
|
48
|
+
parts << "#{new_signatures.size} new" unless new_signatures.empty?
|
|
49
|
+
parts << "#{persistent.size} persistent" unless persistent.empty?
|
|
50
|
+
parts << "#{changed.size} changed" unless changed.empty?
|
|
51
|
+
return nil if parts.empty?
|
|
52
|
+
|
|
53
|
+
"Signatures: #{parts.join(", ")}; failures: #{previous_failures} -> #{failure_count}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
{
|
|
58
|
+
previous_run_id: previous_run_id,
|
|
59
|
+
previous_at: previous_at,
|
|
60
|
+
previous_failures: previous_failures,
|
|
61
|
+
resolved: resolved.map(&:to_h),
|
|
62
|
+
new: new_signatures.map(&:to_h),
|
|
63
|
+
persistent: persistent.map(&:to_h),
|
|
64
|
+
changed: changed.map(&:to_h)
|
|
65
|
+
}.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def entries(run)
|
|
71
|
+
Array(run["signatures"]).map do |item|
|
|
72
|
+
Change.new(digest: item["digest"], loose: item["loose"],
|
|
73
|
+
exception: item["exception"], count: item["count"])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Pairing is cardinality-aware so signatures that share a loose key do
|
|
78
|
+
# not hide resolved or new failures. Hash-backed queues keep the work
|
|
79
|
+
# linear even for suites with many signatures.
|
|
80
|
+
def classify(before, after)
|
|
81
|
+
@persistent, gone, arrived = pair(before, after, :digest)
|
|
82
|
+
@changed, @resolved, @new_signatures = pair(gone, arrived, :loose)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def pair(before, after, attribute)
|
|
86
|
+
available = before.group_by { |entry| entry.public_send(attribute) }
|
|
87
|
+
matched_before = {}.compare_by_identity
|
|
88
|
+
|
|
89
|
+
matched_after, unmatched_after = after.partition do |entry|
|
|
90
|
+
match = available[entry.public_send(attribute)]&.shift
|
|
91
|
+
matched_before[match] = true if match
|
|
92
|
+
match
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unmatched_before = before.reject { |entry| matched_before.key?(entry) }
|
|
96
|
+
|
|
97
|
+
[matched_after, unmatched_before, unmatched_after]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -23,11 +23,23 @@ module RSpec
|
|
|
23
23
|
# the budgets cap how much of it reaches the Markdown report.
|
|
24
24
|
attr_accessor :relate_failures, :max_clusters, :max_cluster_specs
|
|
25
25
|
|
|
26
|
+
# Shared code-path analysis. `code_path_depth` is how far in from the
|
|
27
|
+
# raise site first-party frames are indexed; `max_code_paths` caps how
|
|
28
|
+
# many reach the Markdown report.
|
|
29
|
+
attr_accessor :code_path_depth, :max_code_paths
|
|
30
|
+
|
|
31
|
+
# Experimental relationships between signatures (causal / scope /
|
|
32
|
+
# independent groups). Off by default while it is being dogfooded; its
|
|
33
|
+
# thresholds are internal constants, not configuration.
|
|
34
|
+
# See docs/design/causal-failure-intelligence.md.
|
|
35
|
+
attr_accessor :causal_analysis
|
|
36
|
+
|
|
26
37
|
# Secret scrubbing.
|
|
27
38
|
attr_accessor :redact, :redaction_patterns, :redaction_filter
|
|
28
39
|
|
|
29
|
-
# Artifacts.
|
|
30
|
-
|
|
40
|
+
# Artifacts. `track_history` keeps a small record of recent runs beside
|
|
41
|
+
# them so a run can say what changed since the last one.
|
|
42
|
+
attr_accessor :write_json, :write_full, :write_gitignore, :track_history
|
|
31
43
|
|
|
32
44
|
# Behaviour.
|
|
33
45
|
attr_accessor :enabled, :terminal_summary, :capture_capybara, :capture_page_html
|
|
@@ -115,17 +127,21 @@ module RSpec
|
|
|
115
127
|
@max_groups = nil
|
|
116
128
|
@max_clusters = 10
|
|
117
129
|
@max_cluster_specs = 6
|
|
130
|
+
@code_path_depth = CodePaths::DEFAULT_DEPTH
|
|
131
|
+
@max_code_paths = 5
|
|
118
132
|
end
|
|
119
133
|
|
|
120
134
|
def default_artifacts
|
|
121
135
|
@reduce_html = true
|
|
122
136
|
@relate_failures = true
|
|
137
|
+
@causal_analysis = false
|
|
123
138
|
@redact = true
|
|
124
139
|
@redaction_patterns = []
|
|
125
140
|
@redaction_filter = nil
|
|
126
141
|
@write_json = true
|
|
127
142
|
@write_full = false
|
|
128
143
|
@write_gitignore = true
|
|
144
|
+
@track_history = true
|
|
129
145
|
@capture_capybara = true
|
|
130
146
|
@capture_page_html = false
|
|
131
147
|
end
|
data/lib/rspec/signal/failure.rb
CHANGED
|
@@ -9,9 +9,13 @@ module RSpec
|
|
|
9
9
|
:exception_class, :message, :reduced, :frames,
|
|
10
10
|
:diagnostics, :shared_group_locations, :raw
|
|
11
11
|
|
|
12
|
+
# Structural facts for the experimental relationship analysis
|
|
13
|
+
# ({Causal::Evidence}); nil when they could not be captured.
|
|
14
|
+
attr_accessor :evidence
|
|
15
|
+
|
|
12
16
|
def initialize(description:, spec_location:, exception_class:, message:, reduced:, frames:,
|
|
13
17
|
rerun: nil, example_id: nil, diagnostics: {}, shared_group_locations: [],
|
|
14
|
-
raw: nil, fingerprint: nil)
|
|
18
|
+
raw: nil, fingerprint: nil, evidence: nil)
|
|
15
19
|
@description = description
|
|
16
20
|
@spec_location = spec_location
|
|
17
21
|
@exception_class = exception_class
|
|
@@ -24,6 +28,22 @@ module RSpec
|
|
|
24
28
|
@shared_group_locations = shared_group_locations
|
|
25
29
|
@raw = raw
|
|
26
30
|
@fingerprint = fingerprint
|
|
31
|
+
@evidence = evidence
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The argument that reruns exactly this example and nothing else.
|
|
35
|
+
# RSpec's example id when we have one; the location, which selects every
|
|
36
|
+
# example defined on that line, only as a fallback.
|
|
37
|
+
def rerun_argument
|
|
38
|
+
example_id || rerun
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The first-party frames worth attributing this failure to, innermost
|
|
42
|
+
# first and excluding the spec suite itself. This is what the
|
|
43
|
+
# cross-signature code-path analysis indexes on.
|
|
44
|
+
def application_frames(limit = nil)
|
|
45
|
+
frames = reduced.project_frames.reject { |frame| Fingerprint.spec_frame?(frame) }
|
|
46
|
+
limit ? frames.first(limit) : frames
|
|
27
47
|
end
|
|
28
48
|
|
|
29
49
|
def fingerprint
|
|
@@ -43,17 +63,20 @@ module RSpec
|
|
|
43
63
|
@symptom = Symptoms.for(self)
|
|
44
64
|
end
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
# @param max_message_lines [Integer] mirrors `config.max_message_lines`
|
|
67
|
+
# @param max_diff_lines [Integer] mirrors `config.max_diff_lines`
|
|
68
|
+
def to_h(max_message_lines: 30, max_diff_lines: 20)
|
|
47
69
|
{
|
|
48
70
|
description: description,
|
|
49
71
|
location: spec_location,
|
|
50
72
|
rerun: rerun,
|
|
51
73
|
id: example_id,
|
|
52
74
|
exception: exception_class,
|
|
53
|
-
message: message.body,
|
|
75
|
+
message: message.body(max_lines: max_message_lines, max_diff_lines: max_diff_lines),
|
|
54
76
|
trace: reduced.entries.map(&:to_h),
|
|
55
77
|
omitted_frames: reduced.omitted_count,
|
|
56
78
|
diagnostics: diagnostics,
|
|
79
|
+
shared_groups: shared_group_locations,
|
|
57
80
|
symptom: symptom&.to_h
|
|
58
81
|
}.reject { |_, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
59
82
|
end
|
|
@@ -25,16 +25,18 @@ module RSpec
|
|
|
25
25
|
exception = notification.exception
|
|
26
26
|
extra = Array(example.metadata[:extra_failure_lines])
|
|
27
27
|
frames = Backtrace::Parser.parse(backtrace_for(exception), @config.classifier)
|
|
28
|
+
message = message_for(notification, exception, extra)
|
|
28
29
|
|
|
29
30
|
Failure.new(
|
|
30
31
|
**identity(example),
|
|
31
32
|
exception_class: exception_class_name(exception),
|
|
32
|
-
message:
|
|
33
|
+
message: message,
|
|
33
34
|
reduced: @config.reducer.call(frames),
|
|
34
35
|
frames: frames,
|
|
35
36
|
diagnostics: diagnostics(example, extra),
|
|
36
37
|
shared_group_locations: shared_group_locations(example),
|
|
37
|
-
raw: raw_output(notification, position)
|
|
38
|
+
raw: raw_output(notification, position),
|
|
39
|
+
evidence: evidence_for(exception, frames, example)
|
|
38
40
|
)
|
|
39
41
|
end
|
|
40
42
|
|
|
@@ -49,6 +51,19 @@ module RSpec
|
|
|
49
51
|
}
|
|
50
52
|
end
|
|
51
53
|
|
|
54
|
+
# Read from the full backtrace, before reduction discards the runner
|
|
55
|
+
# frames that say which phase failed. Experimental, and never allowed to
|
|
56
|
+
# cost the report.
|
|
57
|
+
def evidence_for(exception, frames, example)
|
|
58
|
+
return nil unless @config.causal_analysis
|
|
59
|
+
|
|
60
|
+
primary = sub_exceptions(exception).first || exception
|
|
61
|
+
@identities ||= {}.compare_by_identity
|
|
62
|
+
Causal::Capture.call(primary, frames: frames, config: @config, identities: @identities, example: example)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
def message_for(notification, exception, extra)
|
|
53
68
|
appended = extra + shared_group_descriptions(notification.example)
|
|
54
69
|
Message.new(message_lines_for(notification, appended),
|
|
@@ -31,7 +31,7 @@ module RSpec
|
|
|
31
31
|
significant = frames.reject(&:framework?)
|
|
32
32
|
culprit = significant.first
|
|
33
33
|
app_context = significant.find do |frame|
|
|
34
|
-
frame.project? &&
|
|
34
|
+
frame.project? && !spec_frame?(frame, spec_patterns)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
new(
|
|
@@ -42,6 +42,21 @@ module RSpec
|
|
|
42
42
|
)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Whether a frame belongs to the spec suite rather than the application.
|
|
46
|
+
# Shared by fingerprinting and by the code-path analysis, which both need
|
|
47
|
+
# to look past the example that happened to trip over the bug.
|
|
48
|
+
def self.spec_frame?(frame, spec_patterns = DEFAULT_SPEC_PATTERNS)
|
|
49
|
+
spec_patterns.any? { |pattern| pattern.match?(frame.display_path) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A line-number-independent identity: the exception and its normalized
|
|
53
|
+
# message, without the frames. Two runs of the same broken code report
|
|
54
|
+
# the same digest even after an edit shifts the raise site, which is what
|
|
55
|
+
# lets a run comparison tell "moved" apart from "fixed, and a new one".
|
|
56
|
+
def loose_digest
|
|
57
|
+
@loose_digest ||= Digest::SHA256.hexdigest([exception_class, message].join("\0"))[0, 12]
|
|
58
|
+
end
|
|
59
|
+
|
|
45
60
|
# Joined on NUL so that shifting text from one component into the next
|
|
46
61
|
# cannot produce the same digest.
|
|
47
62
|
def digest
|
|
@@ -49,7 +64,8 @@ module RSpec
|
|
|
49
64
|
end
|
|
50
65
|
|
|
51
66
|
def to_h
|
|
52
|
-
{ exception: exception_class, culprit: culprit, app_context: app_context, digest: digest
|
|
67
|
+
{ exception: exception_class, culprit: culprit, app_context: app_context, digest: digest,
|
|
68
|
+
loose: loose_digest }.compact
|
|
53
69
|
end
|
|
54
70
|
end
|
|
55
71
|
end
|