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,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "English"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
parallel_rspec = Gem.bin_path("parallel_tests", "parallel_rspec")
|
|
11
|
+
rescue Gem::GemNotFoundException
|
|
12
|
+
warn "rspec-signal: parallel_tests is required; add gem \"parallel_tests\" to your test bundle"
|
|
13
|
+
exit 2
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
run_id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%S")}-#{SecureRandom.hex(6)}"
|
|
17
|
+
registry = Dir.mktmpdir("rspec-signal-#{run_id}")
|
|
18
|
+
lib = File.expand_path("../lib", __dir__)
|
|
19
|
+
ENV["RUBYLIB"] = [lib, ENV.fetch("RUBYLIB", nil)].compact.join(File::PATH_SEPARATOR)
|
|
20
|
+
ENV["RSPEC_SIGNAL_RUN_ID"] = run_id
|
|
21
|
+
ENV["RSPEC_SIGNAL_RUN_REGISTRY"] = registry
|
|
22
|
+
ENV["RSPEC_SIGNAL_QUIET"] = "1"
|
|
23
|
+
ENV["PARALLEL_TEST_FIRST_IS_1"] = "true"
|
|
24
|
+
signal_options = "--require rspec/signal --format RSpec::Signal::Formatter"
|
|
25
|
+
ENV["SPEC_OPTS"] = [ENV.fetch("SPEC_OPTS", nil), signal_options].compact.join(" ")
|
|
26
|
+
|
|
27
|
+
system(Gem.ruby, parallel_rspec, *ARGV)
|
|
28
|
+
test_status = $CHILD_STATUS.exitstatus || 1
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
require "rspec/signal"
|
|
32
|
+
result = RSpec::Signal::ParallelMerger.new(registry: registry).call
|
|
33
|
+
report = result.report
|
|
34
|
+
puts
|
|
35
|
+
puts "#{report.example_count} examples, #{report.failure_count} failures across #{result.workers} workers"
|
|
36
|
+
puts "rspec-signal: #{report.group_count} exact signatures, #{report.cluster_count} related clusters, " \
|
|
37
|
+
"#{report.omitted_frames} backtrace frames omitted"
|
|
38
|
+
if result.write_result.summary_path
|
|
39
|
+
puts "Report: #{RSpec::Signal::Writer.new(RSpec::Signal.configuration).relative(result.write_result.summary_path)}"
|
|
40
|
+
end
|
|
41
|
+
if result.missing.any?
|
|
42
|
+
warn "rspec-signal: warning: #{result.missing.size} worker artifacts were missing; report is incomplete"
|
|
43
|
+
test_status = 1
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn "rspec-signal: parallel report aggregation failed (#{e.class}: #{e.message})"
|
|
47
|
+
test_status = 1 if test_status.zero?
|
|
48
|
+
ensure
|
|
49
|
+
FileUtils.rm_rf(registry)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
exit test_status
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Backtrace
|
|
6
|
+
# Decides whether a frame is project code, meaningful library code, or
|
|
7
|
+
# pure test-runner / CLI plumbing.
|
|
8
|
+
#
|
|
9
|
+
# The framework list is deliberately narrow: it contains only code that
|
|
10
|
+
# *runs* tests, loads files, or dispatches a CLI. Application libraries
|
|
11
|
+
# (ActiveRecord, Capybara, Rack, Net::HTTP, ...) are never framework, even
|
|
12
|
+
# though most of their frames still get collapsed by the reducer — they can
|
|
13
|
+
# legitimately explain what operation failed.
|
|
14
|
+
class Classifier
|
|
15
|
+
DEFAULT_FRAMEWORK_PATTERNS = [
|
|
16
|
+
# RSpec itself, however it is vendored
|
|
17
|
+
%r{/lib/rspec/(?:core|support|expectations|mocks|matchers|rails|its|retry)[./]},
|
|
18
|
+
%r{/rspec-(?:core|support|expectations|mocks|rails|its|retry)-[^/]+/},
|
|
19
|
+
%r{/lib/rspec/signal[./]},
|
|
20
|
+
%r{/lib/rspec_junit_formatter[./]},
|
|
21
|
+
# Bundler / RubyGems / CLI plumbing
|
|
22
|
+
%r{/lib/bundler/},
|
|
23
|
+
%r{/bundler-[^/]+/},
|
|
24
|
+
%r{/lib/rubygems/},
|
|
25
|
+
%r{/rubygems/core_ext/kernel_require\.rb},
|
|
26
|
+
%r{/bundled_gems\.rb},
|
|
27
|
+
%r{/thor-[^/]+/}, %r{/lib/thor/},
|
|
28
|
+
%r{/rake-[^/]+/}, %r{/lib/rake/},
|
|
29
|
+
# Ruby internals
|
|
30
|
+
/\A<internal:/,
|
|
31
|
+
%r{/lib/ruby/[^/]+/bundled_gems\.rb},
|
|
32
|
+
# Other test-runner plumbing
|
|
33
|
+
%r{/minitest[-/]}, %r{/lib/minitest/},
|
|
34
|
+
%r{/spring-[^/]+/}, %r{/simplecov[-/]}, %r{/simplecov-html[-/]},
|
|
35
|
+
%r{/parallel_tests-[^/]+/}, %r{/knapsack[-_]},
|
|
36
|
+
%r{/railties-[^/]+/lib/rails/(?:commands|test_unit|app_loader)},
|
|
37
|
+
%r{/lib/rails/(?:commands|test_unit|app_loader)}
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Checked *before* first-party detection. Binstubs live inside the
|
|
41
|
+
# project but are still pure plumbing, and anything a user adds here is
|
|
42
|
+
# taken as authoritative about their own repository.
|
|
43
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
44
|
+
%r{(?:\A|/)(?:bin|exe|\.bundle/bin)/(?:rspec|bundle|rake|spring)(?:\.\w+)?\z},
|
|
45
|
+
# Ruby pseudo-frames: the `-e` script, eval, irb, VM internals.
|
|
46
|
+
/\A-e\z/,
|
|
47
|
+
/\A\((?:eval|irb)[^)]*\)\z/,
|
|
48
|
+
/\A<internal:/
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
def initialize(project:, framework_patterns: DEFAULT_FRAMEWORK_PATTERNS,
|
|
52
|
+
ignore_patterns: DEFAULT_IGNORE_PATTERNS)
|
|
53
|
+
@project = project
|
|
54
|
+
@framework_patterns = framework_patterns
|
|
55
|
+
@ignore_patterns = ignore_patterns
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Mutates the frame in place with :kind, :display and :gem_name.
|
|
59
|
+
def call(frame)
|
|
60
|
+
absolute = @project.absolutize(frame.path)
|
|
61
|
+
frame.display_path = @project.display_path(frame.path)
|
|
62
|
+
frame.gem_name = @project.gem_name(frame.path)
|
|
63
|
+
frame.kind = classify(frame, absolute)
|
|
64
|
+
frame
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# First-party beats the built-in framework list. Code inside the
|
|
70
|
+
# repository is always something the agent can open and fix, and a
|
|
71
|
+
# project directory can easily contain a substring that looks like a gem
|
|
72
|
+
# name -- a checkout at ~/src/rspec-signal-demo must not have every one
|
|
73
|
+
# of its own frames thrown away.
|
|
74
|
+
def classify(frame, absolute)
|
|
75
|
+
return :framework if matches?(@ignore_patterns, absolute, frame.path)
|
|
76
|
+
return :project if @project.first_party?(frame.path)
|
|
77
|
+
return :framework if matches?(@framework_patterns, absolute, frame.path)
|
|
78
|
+
|
|
79
|
+
:external
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def matches?(patterns, absolute, path)
|
|
83
|
+
patterns.any? { |pattern| pattern.match?(absolute) || pattern.match?(path) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Backtrace
|
|
6
|
+
# One parsed backtrace line.
|
|
7
|
+
#
|
|
8
|
+
# `kind` is one of:
|
|
9
|
+
# :project - first-party code the agent can open and edit
|
|
10
|
+
# :external - third-party library code (may explain the failing operation)
|
|
11
|
+
# :framework - test-runner / loader / CLI plumbing that never explains a bug
|
|
12
|
+
Frame = Struct.new(:raw, :path, :line, :label, :kind, :display_path, :gem_name, keyword_init: true) do
|
|
13
|
+
def project? = kind == :project
|
|
14
|
+
def external? = kind == :external
|
|
15
|
+
def framework? = kind == :framework
|
|
16
|
+
|
|
17
|
+
def location
|
|
18
|
+
line ? "#{display_path}:#{line}" : display_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
label && !label.empty? ? "#{location} in `#{label}'" : location
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{ location: location, label: label, kind: kind.to_s, gem: gem_name }.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "frame"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Signal
|
|
7
|
+
module Backtrace
|
|
8
|
+
# Turns raw backtrace strings into {Frame}s.
|
|
9
|
+
#
|
|
10
|
+
# Handles the two shapes Ruby emits:
|
|
11
|
+
# "/path/to/file.rb:12:in `method'"
|
|
12
|
+
# "/path/to/file.rb:12:in 'Klass#method'" (Ruby 3.4+ quoting)
|
|
13
|
+
# and the shapes RSpec emits after its own filtering:
|
|
14
|
+
# "./spec/models/user_spec.rb:12"
|
|
15
|
+
module Parser
|
|
16
|
+
LINE = /
|
|
17
|
+
\A
|
|
18
|
+
(?<path>.*?)
|
|
19
|
+
(?::(?<line>\d+))?
|
|
20
|
+
(?::in\s+[`'](?<label>.*)['`])?
|
|
21
|
+
\s*\z
|
|
22
|
+
/x
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param backtrace [Array<String>, nil]
|
|
27
|
+
# @param classifier [#call] receives a Frame with :kind unset, returns the kind
|
|
28
|
+
# @return [Array<Frame>]
|
|
29
|
+
def parse(backtrace, classifier)
|
|
30
|
+
Array(backtrace).filter_map do |raw|
|
|
31
|
+
frame = parse_line(raw)
|
|
32
|
+
next unless frame
|
|
33
|
+
|
|
34
|
+
classifier.call(frame)
|
|
35
|
+
frame
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_line(raw)
|
|
40
|
+
text = raw.to_s.strip
|
|
41
|
+
return nil if text.empty?
|
|
42
|
+
|
|
43
|
+
match = LINE.match(text)
|
|
44
|
+
return nil unless match
|
|
45
|
+
|
|
46
|
+
path = match[:path].to_s.sub(%r{\A\./}, "")
|
|
47
|
+
return nil if path.empty?
|
|
48
|
+
|
|
49
|
+
Frame.new(
|
|
50
|
+
raw: text,
|
|
51
|
+
path: path,
|
|
52
|
+
line: match[:line]&.to_i,
|
|
53
|
+
label: match[:label],
|
|
54
|
+
kind: :external,
|
|
55
|
+
display_path: path
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "frame"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Signal
|
|
7
|
+
module Backtrace
|
|
8
|
+
# A gap standing in for frames that were dropped.
|
|
9
|
+
Gap = Struct.new(:count, :kind, keyword_init: true) do # rubocop:disable Lint/StructNewOverride
|
|
10
|
+
def frame? = false
|
|
11
|
+
|
|
12
|
+
def to_s
|
|
13
|
+
noun = kind == :framework ? "framework/runtime" : "library"
|
|
14
|
+
"[#{count} #{noun} frame#{"s" unless count == 1} omitted]"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h = { omitted: count, kind: kind.to_s }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Frames and gaps are rendered side by side, so both answer `frame?`.
|
|
21
|
+
class Frame
|
|
22
|
+
def frame? = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The result of reducing one backtrace.
|
|
26
|
+
class Reduced
|
|
27
|
+
attr_reader :entries, :total, :omitted, :fallback
|
|
28
|
+
|
|
29
|
+
def initialize(entries:, total:, omitted:, fallback: false)
|
|
30
|
+
@entries = entries
|
|
31
|
+
@total = total
|
|
32
|
+
@omitted = omitted
|
|
33
|
+
@fallback = fallback
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def frames = entries.select(&:frame?)
|
|
37
|
+
def project_frames = frames.select(&:project?)
|
|
38
|
+
def kept_count = frames.size
|
|
39
|
+
def fallback? = @fallback
|
|
40
|
+
def empty? = frames.empty?
|
|
41
|
+
|
|
42
|
+
def omitted_count = omitted.values.sum
|
|
43
|
+
|
|
44
|
+
# The frame that best identifies where this blew up: the innermost frame
|
|
45
|
+
# that is not test-runner plumbing.
|
|
46
|
+
def culprit = frames.first
|
|
47
|
+
|
|
48
|
+
# The innermost first-party frame — what the agent should open first.
|
|
49
|
+
def primary_location = project_frames.first
|
|
50
|
+
|
|
51
|
+
def to_a = entries.map(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reduces a full backtrace to the frames that carry diagnostic value.
|
|
55
|
+
#
|
|
56
|
+
# Rules, in order:
|
|
57
|
+
#
|
|
58
|
+
# 1. Consecutive repeats of the same file:line collapse into one.
|
|
59
|
+
# 2. Framework frames (test runner, loader, CLI) are always dropped.
|
|
60
|
+
# 3. Every first-party frame is kept.
|
|
61
|
+
# 4. A run of library frames that directly touches first-party code is
|
|
62
|
+
# partially kept: the library entry point the project called, the site
|
|
63
|
+
# that raised, and (budget permitting) their immediate neighbours.
|
|
64
|
+
# This is the "what operation failed" context.
|
|
65
|
+
# 5. Library frames that touch no first-party code at all are dropped.
|
|
66
|
+
# 6. If nothing survives, progressively fall back so that the report is
|
|
67
|
+
# never empty: innermost non-framework frames, then innermost frames.
|
|
68
|
+
class Reducer
|
|
69
|
+
# Relative value of each kind of frame. Anything scoring 0 is dropped.
|
|
70
|
+
SCORE_PROJECT = 100
|
|
71
|
+
SCORE_LIBRARY_ENTRY = 70 # last library frame before first-party code
|
|
72
|
+
SCORE_RAISE_SITE = 65 # innermost frame of an adjacent library run
|
|
73
|
+
SCORE_LIBRARY_NEAR = 40 # neighbours of the two above
|
|
74
|
+
SCORE_LIBRARY_CALLER = 60 # library frame that called into first-party code
|
|
75
|
+
|
|
76
|
+
# Labels that name no method: delegation shims and block frames.
|
|
77
|
+
ANONYMOUS_LABEL = /\A(?:block\s|<(?:top|module|class|main)\b|rescue in\b|ensure in\b)/
|
|
78
|
+
|
|
79
|
+
def initialize(max_frames: 12, max_external_context: 3, max_project_frames: 8, fallback_frames: 6)
|
|
80
|
+
@max_frames = max_frames
|
|
81
|
+
@max_external_context = max_external_context
|
|
82
|
+
@max_project_frames = max_project_frames
|
|
83
|
+
@fallback_frames = fallback_frames
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param frames [Array<Frame>] innermost first, as Ruby emits them
|
|
87
|
+
# @return [Reduced]
|
|
88
|
+
def call(frames)
|
|
89
|
+
frames = collapse_repeats(Array(frames))
|
|
90
|
+
return Reduced.new(entries: [], total: 0, omitted: {}) if frames.empty?
|
|
91
|
+
|
|
92
|
+
scored = score(frames)
|
|
93
|
+
keep = select_keepers(scored)
|
|
94
|
+
return fallback(frames) if keep.empty?
|
|
95
|
+
|
|
96
|
+
build(frames, keep)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Only *identical* frames collapse. A one-line method definition puts
|
|
102
|
+
# several different methods on the same file:line, and those are not
|
|
103
|
+
# repeats -- they are the call chain.
|
|
104
|
+
def collapse_repeats(frames)
|
|
105
|
+
frames.chunk_while { |a, b| a.path == b.path && a.line == b.line && a.label == b.label }.map do |chunk|
|
|
106
|
+
next chunk.first if chunk.size == 1
|
|
107
|
+
|
|
108
|
+
frame = chunk.first.dup
|
|
109
|
+
frame.label = "#{frame.label} (x#{chunk.size})"
|
|
110
|
+
frame
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Hash<Integer, Integer>] frame index => score
|
|
115
|
+
#
|
|
116
|
+
# Adjacency is computed over the backtrace with framework frames removed,
|
|
117
|
+
# so a stray runner frame wedged between a library and the spec file does
|
|
118
|
+
# not hide the library context.
|
|
119
|
+
def score(frames)
|
|
120
|
+
scores = Hash.new(0)
|
|
121
|
+
significant = frames.each_index.reject { |i| frames[i].framework? }
|
|
122
|
+
project_indexes = significant.select { |i| frames[i].project? }
|
|
123
|
+
|
|
124
|
+
return scores if project_indexes.empty?
|
|
125
|
+
|
|
126
|
+
project_indexes.first(@max_project_frames).each { |i| scores[i] = SCORE_PROJECT }
|
|
127
|
+
scores[project_indexes.first] = SCORE_PROJECT + 5
|
|
128
|
+
|
|
129
|
+
positions = significant.each_with_index.to_h
|
|
130
|
+
external_runs(frames, significant).each do |run|
|
|
131
|
+
score_run(frames, run, scores, significant, positions)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
scores
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Contiguous runs of :external frames within the significant subsequence,
|
|
138
|
+
# as arrays of original frame indexes.
|
|
139
|
+
def external_runs(frames, significant)
|
|
140
|
+
significant.chunk { |i| frames[i].external? }.filter_map { |external, run| run if external }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def score_run(frames, run, scores, significant, positions)
|
|
144
|
+
above, below = adjacency(frames, run, significant, positions)
|
|
145
|
+
return unless above || below
|
|
146
|
+
|
|
147
|
+
candidates(frames, run, above).each_with_index do |index, rank|
|
|
148
|
+
scores[index] = [scores[index], run_score(index, run, above, below, rank)].max
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Whether the run was called *by* first-party code (above) and/or calls
|
|
153
|
+
# *into* first-party code (below).
|
|
154
|
+
def adjacency(frames, run, significant, positions)
|
|
155
|
+
first_position = positions.fetch(run.first)
|
|
156
|
+
before = significant[first_position - 1] if first_position.positive?
|
|
157
|
+
after = significant[positions.fetch(run.last) + 1]
|
|
158
|
+
|
|
159
|
+
[after && frames[after].project?, before && frames[before].project?]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The frames of this run worth keeping, best first.
|
|
163
|
+
def candidates(frames, run, above)
|
|
164
|
+
ranked = []
|
|
165
|
+
ranked << entry_point(frames, run) if above # library entry point
|
|
166
|
+
ranked << run.first # raise site, or caller of project code
|
|
167
|
+
ranked << run.last if above
|
|
168
|
+
ranked << run[1] if run.size > 1
|
|
169
|
+
ranked.compact.uniq.first(@max_external_context)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def run_score(index, run, above, below, rank)
|
|
173
|
+
return SCORE_LIBRARY_CALLER if below && index == run.first
|
|
174
|
+
return SCORE_RAISE_SITE if index == run.first
|
|
175
|
+
return SCORE_LIBRARY_ENTRY if above && rank.zero?
|
|
176
|
+
|
|
177
|
+
SCORE_LIBRARY_NEAR - rank
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The frame where project code entered the library. Scanning outwards in
|
|
181
|
+
# for the first *named* method skips delegation shims, whose labels are
|
|
182
|
+
# anonymous blocks and which say nothing about the failing operation:
|
|
183
|
+
# `capybara/dsl.rb:52 in block (2 levels) in <module:DSL>` is noise,
|
|
184
|
+
# `capybara/node/finders.rb:60 in find` is the answer.
|
|
185
|
+
def entry_point(frames, run)
|
|
186
|
+
run.reverse.find { |index| named_label?(frames[index].label) } || run.last
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def named_label?(label)
|
|
190
|
+
text = label.to_s.strip
|
|
191
|
+
!text.empty? && !ANONYMOUS_LABEL.match?(text)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def select_keepers(scores)
|
|
195
|
+
positive = scores.select { |_, value| value.positive? }
|
|
196
|
+
positive.sort_by { |index, value| [-value, index] }.first(@max_frames).map(&:first).sort
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build(frames, keep)
|
|
200
|
+
entries = []
|
|
201
|
+
omitted = Hash.new(0)
|
|
202
|
+
pending = Hash.new(0)
|
|
203
|
+
|
|
204
|
+
frames.each_index do |i|
|
|
205
|
+
if keep.include?(i)
|
|
206
|
+
entries.concat(flush(pending))
|
|
207
|
+
entries << frames[i]
|
|
208
|
+
else
|
|
209
|
+
bucket = frames[i].framework? ? :framework : :external
|
|
210
|
+
pending[bucket] += 1
|
|
211
|
+
omitted[bucket] += 1
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
entries.concat(flush(pending))
|
|
215
|
+
# A gap before the first kept frame is always runner plumbing sitting
|
|
216
|
+
# above the real failure. The header already reports the totals.
|
|
217
|
+
entries.shift while entries.first.is_a?(Gap) && entries.first.kind == :framework
|
|
218
|
+
|
|
219
|
+
Reduced.new(entries: entries, total: frames.size, omitted: omitted)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Gaps are emitted framework-last so the trailing note reads naturally.
|
|
223
|
+
def flush(pending)
|
|
224
|
+
%i[external framework].filter_map do |kind|
|
|
225
|
+
next if pending[kind].zero?
|
|
226
|
+
|
|
227
|
+
count = pending[kind]
|
|
228
|
+
pending[kind] = 0
|
|
229
|
+
Gap.new(count: count, kind: kind)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Never return an empty trace: something is always better than nothing.
|
|
234
|
+
def fallback(frames)
|
|
235
|
+
keep = frames.each_index.reject { |i| frames[i].framework? }.first(@fallback_frames)
|
|
236
|
+
keep = frames.each_index.first(@fallback_frames) if keep.empty?
|
|
237
|
+
|
|
238
|
+
reduced = build(frames, keep)
|
|
239
|
+
Reduced.new(entries: reduced.entries, total: reduced.total, omitted: reduced.omitted, fallback: true)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# A set of failures that share one diagnostic symptom.
|
|
6
|
+
#
|
|
7
|
+
# Weaker than a {Group} on purpose. A group asserts that its failures are
|
|
8
|
+
# the same failure; a cluster asserts only that they share a strong symptom
|
|
9
|
+
# and are worth looking at together. The report says so in those words, so
|
|
10
|
+
# nothing downstream mistakes one for the other.
|
|
11
|
+
class Cluster
|
|
12
|
+
Member = Struct.new(:failure, :detail)
|
|
13
|
+
|
|
14
|
+
attr_reader :symptom, :first_seen
|
|
15
|
+
|
|
16
|
+
def initialize(symptom:, first_seen:)
|
|
17
|
+
@symptom = symptom
|
|
18
|
+
@first_seen = first_seen
|
|
19
|
+
@members = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(failure, symptom)
|
|
23
|
+
@members << Member.new(failure, symptom.detail)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def failures = @members.map(&:failure)
|
|
28
|
+
def size = @members.size
|
|
29
|
+
def kind = @symptom.kind
|
|
30
|
+
def key = @symptom.key
|
|
31
|
+
def label = @symptom.label
|
|
32
|
+
|
|
33
|
+
# The distinct wordings the symptom took, most common first, ties broken
|
|
34
|
+
# by the order they were seen so the list is stable.
|
|
35
|
+
def symptom_counts
|
|
36
|
+
@symptom_counts ||= begin
|
|
37
|
+
counts = @members.each_with_object(Hash.new(0)) { |member, tally| tally[member.detail] += 1 }
|
|
38
|
+
seen = counts.keys.each_with_index.to_h
|
|
39
|
+
counts.sort_by { |detail, count| [-count, seen[detail]] }.to_h
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The exact signatures this cluster spans. More than one is what makes a
|
|
44
|
+
# cluster worth printing at all.
|
|
45
|
+
def signatures
|
|
46
|
+
@signatures ||= failures.map { |failure| failure.fingerprint.digest }.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def signature_count = signatures.size
|
|
50
|
+
|
|
51
|
+
def spec_files
|
|
52
|
+
@spec_files ||= failures.map { |failure| failure.spec_location.sub(/:\d+\z/, "") }.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{
|
|
57
|
+
kind: kind,
|
|
58
|
+
key: key,
|
|
59
|
+
label: label,
|
|
60
|
+
count: size,
|
|
61
|
+
signatures: signatures,
|
|
62
|
+
symptoms: symptom_counts,
|
|
63
|
+
specs: spec_files
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# Builds the related-failure clusters, and refuses to build most of them.
|
|
6
|
+
#
|
|
7
|
+
# Two rules do the work:
|
|
8
|
+
#
|
|
9
|
+
# * a cluster needs at least two failures, and
|
|
10
|
+
# * a cluster needs at least two *exact signatures*.
|
|
11
|
+
#
|
|
12
|
+
# The second is the important one. If every failure sharing a symptom is
|
|
13
|
+
# already the same signature, the signature section says it better and
|
|
14
|
+
# saying it twice is noise. A cluster therefore only ever appears when it
|
|
15
|
+
# tells the reader something the authoritative grouping could not: that
|
|
16
|
+
# failures RSpec, and rspec-signal, consider distinct nevertheless share a
|
|
17
|
+
# symptom.
|
|
18
|
+
#
|
|
19
|
+
# Ordering is deterministic -- biggest first, then most signatures spanned,
|
|
20
|
+
# then run order -- so two runs of the same suite produce the same report.
|
|
21
|
+
module Clusterer
|
|
22
|
+
MIN_FAILURES = 2
|
|
23
|
+
MIN_SIGNATURES = 2
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param failures [Array<Failure>]
|
|
28
|
+
# @return [Array<Cluster>]
|
|
29
|
+
def call(failures)
|
|
30
|
+
clusters = {}
|
|
31
|
+
|
|
32
|
+
failures.each_with_index do |failure, index|
|
|
33
|
+
symptom = failure.symptom
|
|
34
|
+
next unless symptom
|
|
35
|
+
|
|
36
|
+
clusters[symptom.key] ||= Cluster.new(symptom: symptom, first_seen: index)
|
|
37
|
+
clusters[symptom.key].add(failure, symptom)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
clusters.values.select { |cluster| worth_reporting?(cluster) }
|
|
41
|
+
.sort_by { |cluster| [-cluster.size, -cluster.signature_count, cluster.first_seen] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def worth_reporting?(cluster)
|
|
45
|
+
cluster.size >= MIN_FAILURES && cluster.signature_count >= MIN_SIGNATURES
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|