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
|
@@ -9,9 +9,14 @@ module RSpec
|
|
|
9
9
|
class ParallelMerger
|
|
10
10
|
Result = Struct.new(:report, :workers, :missing, :write_result, keyword_init: true)
|
|
11
11
|
|
|
12
|
-
def initialize(registry:, config: RSpec::Signal.configuration)
|
|
12
|
+
def initialize(registry:, config: RSpec::Signal.configuration, run_id: nil)
|
|
13
13
|
@registry = registry
|
|
14
14
|
@config = config
|
|
15
|
+
@run_id = run_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run_id
|
|
19
|
+
@run_id ||= ENV.fetch(ParallelRun::RUN_ID, nil)
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def call
|
|
@@ -25,7 +30,7 @@ module RSpec
|
|
|
25
30
|
report: report,
|
|
26
31
|
workers: payloads.size,
|
|
27
32
|
missing: missing,
|
|
28
|
-
write_result: Writer.new(@config).write(report)
|
|
33
|
+
write_result: Writer.new(@config).write(compared(report))
|
|
29
34
|
)
|
|
30
35
|
# Only once we have successfully produced a report: a worker JSON that
|
|
31
36
|
# failed to parse should stay on disk for a human to look at, not be
|
|
@@ -36,6 +41,19 @@ module RSpec
|
|
|
36
41
|
|
|
37
42
|
private
|
|
38
43
|
|
|
44
|
+
# The merged report is the only one that has seen the whole run, so it is
|
|
45
|
+
# the one that gets compared against, and recorded for the next run.
|
|
46
|
+
def compared(report)
|
|
47
|
+
return report unless @config.track_history
|
|
48
|
+
|
|
49
|
+
history = History.new(@config)
|
|
50
|
+
report.comparison = history.compare(report, run_id: report.run_id)
|
|
51
|
+
history.record(report, run_id: report.run_id)
|
|
52
|
+
report
|
|
53
|
+
rescue StandardError
|
|
54
|
+
report
|
|
55
|
+
end
|
|
56
|
+
|
|
39
57
|
# Worker payloads are per-run (`workers/<run-id>/<worker-id>/signal.json`),
|
|
40
58
|
# so once merged into the top-level report they would otherwise sit in
|
|
41
59
|
# the project forever: a green run's own hygiene guarantee (stale
|
|
@@ -57,36 +75,78 @@ module RSpec
|
|
|
57
75
|
pending_count: sum(summaries, "pending"),
|
|
58
76
|
duration: summaries.filter_map { |item| item["duration_seconds"] }.max,
|
|
59
77
|
environment: payloads.first&.fetch("environment", {}) || {},
|
|
60
|
-
errors_outside_examples: sum(summaries, "errors_outside_examples"),
|
|
78
|
+
errors_outside_examples: sum(summaries, "errors_outside_examples"),
|
|
79
|
+
relate_failures: @config.relate_failures, code_path_depth: @config.code_path_depth,
|
|
80
|
+
outside_example_failures: load_outside(payloads), run_id: run_id,
|
|
81
|
+
census: load_census(payloads), causal_analysis: @config.causal_analysis,
|
|
82
|
+
selection: merge_selection(payloads)
|
|
61
83
|
)
|
|
62
84
|
end
|
|
63
85
|
|
|
86
|
+
def merge_selection(payloads)
|
|
87
|
+
selections = payloads.filter_map { |payload| Selection.from_h(payload["selection"]) }
|
|
88
|
+
return if selections.empty?
|
|
89
|
+
return selections.first if selections.all? { |selection| selection.equivalent?(selections.first) }
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load_outside(payloads)
|
|
95
|
+
payloads.flat_map do |payload|
|
|
96
|
+
payload.fetch("outside_examples", []).map do |item|
|
|
97
|
+
load_failure(item).tap { |failure| failure.evidence ||= Causal::Evidence.new(phase: "outside") }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
def load_failures(payloads)
|
|
65
103
|
payloads.flat_map do |payload|
|
|
66
|
-
payload.fetch("failures", []).map { |failure| load_failure(failure) }
|
|
104
|
+
payload.fetch("failures", []).map { |failure| load_failure(failure, worker: payload["worker"]) }
|
|
67
105
|
end
|
|
68
106
|
end
|
|
69
107
|
|
|
70
|
-
|
|
108
|
+
# Each worker counted only what it ran; the parent needs the whole run.
|
|
109
|
+
def load_census(payloads)
|
|
110
|
+
payloads.each_with_object(Causal::Census.new) { |payload, census| census.merge(payload["census"]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Exception-object tokens are only unique within one process, so they are
|
|
114
|
+
# qualified by worker before two workers' tokens can meet.
|
|
115
|
+
def load_evidence(data, worker)
|
|
116
|
+
evidence = Causal::Evidence.from_h(data)
|
|
117
|
+
return nil unless evidence
|
|
118
|
+
|
|
119
|
+
evidence.identities = evidence.identities.map { |token| "w#{worker}:#{token}" }
|
|
120
|
+
evidence
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def load_failure(data, worker: nil)
|
|
71
124
|
entries = data.fetch("trace", []).map { |entry| load_entry(entry) }
|
|
72
125
|
frames = entries.select(&:frame?)
|
|
73
126
|
reduced = Backtrace::Reduced.new(entries: entries, total: frames.size + data.fetch("omitted_frames", 0),
|
|
74
127
|
omitted: { serialized: data.fetch("omitted_frames", 0) })
|
|
75
128
|
fingerprint = data["fingerprint"] || {}
|
|
76
|
-
Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint)
|
|
129
|
+
Failure.new(**failure_attributes(data, reduced, frames), fingerprint: load_fingerprint(fingerprint),
|
|
130
|
+
evidence: load_evidence(data["evidence"], worker))
|
|
77
131
|
end
|
|
78
132
|
|
|
79
133
|
def failure_attributes(data, reduced, frames)
|
|
80
134
|
{ description: data.fetch("description"), spec_location: data.fetch("location"),
|
|
81
135
|
rerun: data["rerun"], example_id: data["id"], exception_class: data.fetch("exception"),
|
|
82
136
|
message: load_message(data.fetch("message", [])), reduced: reduced, frames: frames,
|
|
83
|
-
diagnostics: data.fetch("diagnostics", {}), raw: data["raw"]
|
|
137
|
+
diagnostics: data.fetch("diagnostics", {}), raw: data["raw"],
|
|
138
|
+
shared_group_locations: data.fetch("shared_groups", []) }
|
|
84
139
|
end
|
|
85
140
|
|
|
141
|
+
# The message is not in the payload, so both digests are carried
|
|
142
|
+
# verbatim. A payload without a loose digest (an older worker) falls back
|
|
143
|
+
# to the exact digest -- never to a key derived from a placeholder
|
|
144
|
+
# message, which every signature of the same class would share.
|
|
86
145
|
def load_fingerprint(data)
|
|
87
146
|
Fingerprint.new(exception_class: data["exception"], message: "worker", culprit: data["culprit"],
|
|
88
147
|
app_context: data["app_context"]).tap do |fingerprint|
|
|
89
148
|
fingerprint.instance_variable_set(:@digest, data["digest"])
|
|
149
|
+
fingerprint.instance_variable_set(:@loose_digest, data["loose"] || data["digest"])
|
|
90
150
|
end
|
|
91
151
|
end
|
|
92
152
|
|
|
@@ -25,7 +25,7 @@ module RSpec
|
|
|
25
25
|
directory = File.join(config.output_path, "workers", ENV.fetch(RUN_ID), worker_id)
|
|
26
26
|
FileUtils.mkdir_p(directory)
|
|
27
27
|
path = File.join(directory, "signal.json")
|
|
28
|
-
payload = report.worker_h(write_full: config.write_full)
|
|
28
|
+
payload = report.worker_h(write_full: config.write_full, config: config)
|
|
29
29
|
.merge(worker: worker_id, configuration: configuration_h(config))
|
|
30
30
|
atomic_write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
31
31
|
register(path)
|
|
@@ -33,8 +33,9 @@ module RSpec
|
|
|
33
33
|
|
|
34
34
|
def configuration_h(config)
|
|
35
35
|
%i[output_dir project_root max_frames max_external_context max_project_frames fallback_frames
|
|
36
|
-
max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups
|
|
37
|
-
max_clusters max_cluster_specs
|
|
36
|
+
max_message_lines max_diff_lines reduce_html max_html_chars max_affected_examples max_groups
|
|
37
|
+
relate_failures max_clusters max_cluster_specs code_path_depth max_code_paths causal_analysis
|
|
38
|
+
write_json write_full write_gitignore track_history terminal_summary capture_capybara
|
|
38
39
|
capture_page_html].to_h { |name| [name, config.public_send(name)] }
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
# The single line repainted in place while a quiet run is in progress.
|
|
6
|
+
#
|
|
7
|
+
# Quiet mode prints nothing per example, which on a long suite is
|
|
8
|
+
# indistinguishable from a hang. This is the smallest thing that fixes
|
|
9
|
+
# that: bounded, repainted with a carriage return, and only ever drawn to a
|
|
10
|
+
# terminal, so a redirected stream never accumulates control sequences.
|
|
11
|
+
class ProgressBar
|
|
12
|
+
WIDTH = 20
|
|
13
|
+
|
|
14
|
+
# @return [ProgressBar, nil] nil whenever a bar would be inappropriate
|
|
15
|
+
def self.for(output, total)
|
|
16
|
+
return nil unless output.respond_to?(:tty?) && output.tty?
|
|
17
|
+
return nil unless total.to_i.positive?
|
|
18
|
+
|
|
19
|
+
new(output, total.to_i)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(output, total)
|
|
23
|
+
@output = output
|
|
24
|
+
@total = total
|
|
25
|
+
@completed = 0
|
|
26
|
+
render
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def advance
|
|
30
|
+
@completed = [@completed + 1, @total].min
|
|
31
|
+
render
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finish
|
|
35
|
+
@output.puts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render
|
|
41
|
+
percentage = (@completed * 100) / @total
|
|
42
|
+
filled = (@completed * WIDTH) / @total
|
|
43
|
+
bar = ("█" * filled) + ("░" * (WIDTH - filled))
|
|
44
|
+
@output.print "\rsignal [#{bar}] #{percentage}% #{@completed}/#{@total}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/rspec/signal/report.rb
CHANGED
|
@@ -4,15 +4,31 @@ module RSpec
|
|
|
4
4
|
module Signal
|
|
5
5
|
# The complete, renderer-independent result of a run.
|
|
6
6
|
class Report
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# Bumped when the shape of {#to_h} changes incompatibly. Schema 2 adds
|
|
8
|
+
# `run_id`, `code_paths`, `since_last_run`, and per-signature `rerun`,
|
|
9
|
+
# `summary` and `loose_signature`; every schema 1 field kept its name and
|
|
10
|
+
# meaning.
|
|
11
|
+
SCHEMA = 2
|
|
12
|
+
|
|
13
|
+
# The worker interchange payload is a different document with a different
|
|
14
|
+
# audience (the parent merger, of the same gem version), so it carries its
|
|
15
|
+
# own number rather than sharing the public one.
|
|
16
|
+
WORKER_SCHEMA = 3
|
|
17
|
+
|
|
18
|
+
attr_reader :failures, :groups, :clusters, :code_paths, :example_count, :failure_count,
|
|
19
|
+
:pending_count, :duration, :seed, :seed_used, :environment,
|
|
20
|
+
:errors_outside_examples, :outside_example_failures, :run_id, :census, :selection
|
|
21
|
+
attr_accessor :comparison
|
|
9
22
|
|
|
10
23
|
def initialize(failures:, example_count: 0, failure_count: nil, pending_count: 0,
|
|
11
24
|
duration: nil, seed: nil, seed_used: false, environment: {},
|
|
12
|
-
errors_outside_examples: 0, relate_failures: true
|
|
25
|
+
errors_outside_examples: 0, relate_failures: true, outside_example_failures: [],
|
|
26
|
+
run_id: nil, code_path_depth: CodePaths::DEFAULT_DEPTH, census: nil,
|
|
27
|
+
causal_analysis: false, selection: nil)
|
|
13
28
|
@failures = failures
|
|
14
29
|
@groups = Grouper.call(failures)
|
|
15
30
|
@clusters = relate_failures ? safely { Clusterer.call(failures) } : []
|
|
31
|
+
@code_paths = safely { CodePaths.call(failures, depth: code_path_depth) }
|
|
16
32
|
@example_count = example_count
|
|
17
33
|
@failure_count = failure_count || failures.size
|
|
18
34
|
@pending_count = pending_count
|
|
@@ -21,10 +37,41 @@ module RSpec
|
|
|
21
37
|
@seed_used = seed_used
|
|
22
38
|
@environment = environment
|
|
23
39
|
@errors_outside_examples = errors_outside_examples
|
|
40
|
+
@outside_example_failures = outside_example_failures
|
|
41
|
+
@run_id = run_id
|
|
42
|
+
@census = census
|
|
43
|
+
@causal_analysis = causal_analysis
|
|
44
|
+
@selection = selection
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The experimental relationship analysis, or nil when it is switched off
|
|
48
|
+
# or fails. Computed on first use; like the other analysis layers it can
|
|
49
|
+
# never take the report down.
|
|
50
|
+
def analysis
|
|
51
|
+
return @analysis if defined?(@analysis)
|
|
52
|
+
|
|
53
|
+
@analysis = @causal_analysis ? Causal::Analysis.call(self) : nil
|
|
54
|
+
rescue StandardError
|
|
55
|
+
@analysis = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Empty when there is nothing beyond the signatures to say. Rendering is
|
|
59
|
+
# as fail-soft as the analysis: a bug here must not cost the report.
|
|
60
|
+
def relationship_lines
|
|
61
|
+
relationships&.terminal_lines || []
|
|
62
|
+
rescue StandardError
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def relationship_markdown(signature_positions)
|
|
67
|
+
relationships(signature_positions)&.markdown
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
24
70
|
end
|
|
25
71
|
|
|
26
|
-
#
|
|
27
|
-
# is still worth having, so
|
|
72
|
+
# The analysis layers are the newest and least essential stages; a report
|
|
73
|
+
# without them is still worth having, so they are never allowed to take
|
|
74
|
+
# the run down.
|
|
28
75
|
def safely
|
|
29
76
|
yield
|
|
30
77
|
rescue StandardError
|
|
@@ -39,10 +86,20 @@ module RSpec
|
|
|
39
86
|
clusters.size
|
|
40
87
|
end
|
|
41
88
|
|
|
89
|
+
def code_path_count
|
|
90
|
+
code_paths.size
|
|
91
|
+
end
|
|
92
|
+
|
|
42
93
|
def any_failures?
|
|
43
94
|
!failures.empty?
|
|
44
95
|
end
|
|
45
96
|
|
|
97
|
+
# Whether there is anything at all to report. An error outside every
|
|
98
|
+
# example produces no failed example but is emphatically not a green run.
|
|
99
|
+
def reportable?
|
|
100
|
+
any_failures? || errors_outside_examples.positive?
|
|
101
|
+
end
|
|
102
|
+
|
|
46
103
|
def seed_used?
|
|
47
104
|
!!@seed_used
|
|
48
105
|
end
|
|
@@ -61,25 +118,22 @@ module RSpec
|
|
|
61
118
|
total_frames - omitted_frames
|
|
62
119
|
end
|
|
63
120
|
|
|
64
|
-
def to_h
|
|
121
|
+
def to_h(config = nil, include_analysis: true)
|
|
122
|
+
budgets = message_budgets(config)
|
|
65
123
|
{
|
|
66
|
-
schema:
|
|
124
|
+
schema: SCHEMA,
|
|
67
125
|
generated_by: "rspec-signal #{VERSION}",
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
pending: pending_count,
|
|
72
|
-
signatures: group_count,
|
|
73
|
-
related_clusters: cluster_count.positive? ? cluster_count : nil,
|
|
74
|
-
duration_seconds: duration&.round(3),
|
|
75
|
-
seed: seed_used? ? seed : nil,
|
|
76
|
-
errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
|
|
77
|
-
}.compact,
|
|
126
|
+
run_id: run_id,
|
|
127
|
+
summary: summary_h,
|
|
128
|
+
since_last_run: comparison&.to_h,
|
|
78
129
|
environment: environment,
|
|
79
130
|
backtrace_reduction: { total_frames: total_frames, kept_frames: kept_frames, omitted_frames: omitted_frames },
|
|
80
|
-
signatures: groups.map(
|
|
81
|
-
related: clusters.map(&:to_h)
|
|
82
|
-
|
|
131
|
+
signatures: groups.map { |group| group.to_h(**budgets) },
|
|
132
|
+
related: clusters.map(&:to_h),
|
|
133
|
+
code_paths: code_paths.map(&:to_h),
|
|
134
|
+
outside_examples: outside_example_failures.map { |failure| failure.to_h(**budgets) },
|
|
135
|
+
analysis: include_analysis ? analysis_h : nil
|
|
136
|
+
}.compact
|
|
83
137
|
end
|
|
84
138
|
|
|
85
139
|
# Worker interchange data. Unlike the public report summary this retains
|
|
@@ -88,13 +142,55 @@ module RSpec
|
|
|
88
142
|
# `raw` -- the unreduced formatter output -- is included only when
|
|
89
143
|
# `write_full` is on, since it is otherwise discarded on merge and would
|
|
90
144
|
# just bloat every worker payload for no reason.
|
|
91
|
-
def worker_h(write_full: false)
|
|
145
|
+
def worker_h(write_full: false, config: nil)
|
|
146
|
+
budgets = message_budgets(config)
|
|
92
147
|
serialized = failures.map do |failure|
|
|
93
|
-
attributes = failure.to_h.merge(fingerprint: failure.fingerprint.to_h)
|
|
148
|
+
attributes = failure.to_h(**budgets).merge(fingerprint: failure.fingerprint.to_h)
|
|
94
149
|
attributes[:raw] = failure.raw if write_full
|
|
150
|
+
attributes[:evidence] = failure.evidence.to_h if failure.evidence
|
|
95
151
|
attributes
|
|
96
152
|
end
|
|
97
|
-
|
|
153
|
+
# The parent analyses the whole run from every worker's evidence and
|
|
154
|
+
# census; a worker's own partial analysis would only mislead.
|
|
155
|
+
payload = to_h(config, include_analysis: false).merge(schema: WORKER_SCHEMA, failures: serialized)
|
|
156
|
+
payload[:selection] = selection&.to_h
|
|
157
|
+
census ? payload.merge(census: census.to_h) : payload
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def relationships(signature_positions = {})
|
|
163
|
+
analysis && Reporters::Relationships.new(analysis, signature_positions)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def analysis_h
|
|
167
|
+
relationships(signature_positions)&.to_h
|
|
168
|
+
rescue StandardError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def signature_positions
|
|
173
|
+
groups.each_with_index.to_h { |group, index| [group.fingerprint.digest, index + 1] }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def summary_h
|
|
177
|
+
{
|
|
178
|
+
examples: example_count,
|
|
179
|
+
failures: failure_count,
|
|
180
|
+
pending: pending_count,
|
|
181
|
+
signatures: group_count,
|
|
182
|
+
related_clusters: cluster_count.positive? ? cluster_count : nil,
|
|
183
|
+
shared_code_paths: code_path_count.positive? ? code_path_count : nil,
|
|
184
|
+
duration_seconds: duration&.round(3),
|
|
185
|
+
seed: seed_used? ? seed : nil,
|
|
186
|
+
errors_outside_examples: errors_outside_examples.positive? ? errors_outside_examples : nil
|
|
187
|
+
}.compact
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def message_budgets(config)
|
|
191
|
+
return { max_message_lines: 30, max_diff_lines: 20 } unless config
|
|
192
|
+
|
|
193
|
+
{ max_message_lines: config.max_message_lines, max_diff_lines: config.max_diff_lines }
|
|
98
194
|
end
|
|
99
195
|
end
|
|
100
196
|
end
|
|
@@ -7,12 +7,13 @@ module RSpec
|
|
|
7
7
|
module Reporters
|
|
8
8
|
# Machine-readable twin of the Markdown report, for tooling and CI.
|
|
9
9
|
class JsonReport
|
|
10
|
-
def initialize(report,
|
|
10
|
+
def initialize(report, config = nil)
|
|
11
11
|
@report = report
|
|
12
|
+
@config = config
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def render
|
|
15
|
-
"#{JSON.pretty_generate(@report.to_h)}\n"
|
|
16
|
+
"#{JSON.pretty_generate(@report.to_h(@config))}\n"
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -24,9 +24,11 @@ module RSpec
|
|
|
24
24
|
|
|
25
25
|
def render
|
|
26
26
|
sections = [header]
|
|
27
|
-
sections
|
|
27
|
+
sections.concat(OutsideExamples.new(@report, @config).render)
|
|
28
28
|
# Themes before the inventory: what an agent needs first is the
|
|
29
29
|
# possibility that thirty-five signatures are five problems.
|
|
30
|
+
sections << relationships_section
|
|
31
|
+
sections << code_paths_section
|
|
30
32
|
sections << related_section
|
|
31
33
|
sections << index if @report.group_count > 1
|
|
32
34
|
sections.concat(rendered_groups)
|
|
@@ -39,6 +41,7 @@ module RSpec
|
|
|
39
41
|
def header
|
|
40
42
|
lines = ["# RSpec Signal", "", headline]
|
|
41
43
|
lines << reduction_line if @report.total_frames.positive?
|
|
44
|
+
lines << comparison_line if comparison_line
|
|
42
45
|
lines << "" << meta_line
|
|
43
46
|
lines << "" << conventions
|
|
44
47
|
lines.join("\n")
|
|
@@ -68,6 +71,15 @@ module RSpec
|
|
|
68
71
|
"(#{number(@report.omitted_frames)} framework/library #{plural(@report.omitted_frames, "frame")} omitted)."
|
|
69
72
|
end
|
|
70
73
|
|
|
74
|
+
# What changed since the previous run. The most useful line in the
|
|
75
|
+
# report when there was a previous run, and absent when there was not.
|
|
76
|
+
def comparison_line
|
|
77
|
+
return @comparison_line if defined?(@comparison_line)
|
|
78
|
+
|
|
79
|
+
headline = @report.comparison&.headline
|
|
80
|
+
@comparison_line = headline && "Since the last run: #{headline}."
|
|
81
|
+
end
|
|
82
|
+
|
|
71
83
|
def meta_line
|
|
72
84
|
bits = []
|
|
73
85
|
bits << "seed `#{@report.seed}`" if @report.seed_used?
|
|
@@ -85,7 +97,7 @@ module RSpec
|
|
|
85
97
|
def index
|
|
86
98
|
rows = groups.each_with_index.map do |group, position|
|
|
87
99
|
"| #{position + 1} | #{group.size} | `#{group.exception_class}` | " \
|
|
88
|
-
"#{escape(group.fingerprint.culprit)} | #{escape(one_line(group.message.
|
|
100
|
+
"#{escape(group.fingerprint.culprit)} | #{escape(one_line(group.message.summary(90)))} |"
|
|
89
101
|
end
|
|
90
102
|
["## Signatures", "",
|
|
91
103
|
"| # | Examples | Exception | Raised in | Message |",
|
|
@@ -97,26 +109,20 @@ module RSpec
|
|
|
97
109
|
RelatedFailures.new(@report.clusters, signature_positions, @config).render
|
|
98
110
|
end
|
|
99
111
|
|
|
112
|
+
def relationships_section
|
|
113
|
+
@report.relationship_markdown(signature_positions)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def code_paths_section
|
|
117
|
+
SharedCodePaths.new(@report.code_paths, signature_positions, @config).render
|
|
118
|
+
end
|
|
119
|
+
|
|
100
120
|
# Cluster members point back at the numbered sections below, so the
|
|
101
121
|
# reader can go straight from a symptom to the failures carrying it.
|
|
102
122
|
def signature_positions
|
|
103
123
|
@signature_positions ||= @report.groups.each_with_index.to_h { |group, i| [group.fingerprint.digest, i + 1] }
|
|
104
124
|
end
|
|
105
125
|
|
|
106
|
-
# A `before(:suite)` blow-up produces no failed examples at all. RSpec
|
|
107
|
-
# only reports those through its message stream, which a formatter
|
|
108
|
-
# cannot capture without swallowing every other message, so say plainly
|
|
109
|
-
# what happened and where to look.
|
|
110
|
-
def outside_examples_notice
|
|
111
|
-
return nil unless @report.errors_outside_examples.positive?
|
|
112
|
-
|
|
113
|
-
count = @report.errors_outside_examples
|
|
114
|
-
["## Errors outside examples", "",
|
|
115
|
-
"#{number(count)} #{plural(count, "error")} occurred outside of any example " \
|
|
116
|
-
"(a `before(:suite)` hook, a spec file that failed to load, or similar). " \
|
|
117
|
-
"rspec-signal cannot capture their detail; the full text is in RSpec's own output."].join("\n")
|
|
118
|
-
end
|
|
119
|
-
|
|
120
126
|
def rendered_groups
|
|
121
127
|
rendered = groups.each_with_index.map { |group, position| group_section(group, position + 1) }
|
|
122
128
|
if truncated_groups.positive?
|
|
@@ -151,12 +157,14 @@ module RSpec
|
|
|
151
157
|
|
|
152
158
|
# The representative on its own, plus the whole signature when that is a
|
|
153
159
|
# different command and short enough to be worth typing.
|
|
160
|
+
#
|
|
161
|
+
# Example ids, not locations: a location selects every example defined
|
|
162
|
+
# on that line, which for a loop-generated `it` is all of them and for
|
|
163
|
+
# two failures sharing a line is both of them. See {Rerun}.
|
|
154
164
|
def rerun_commands(group)
|
|
155
|
-
commands = [
|
|
156
|
-
arguments = group.
|
|
157
|
-
if arguments.size > 1 && arguments.size <= MAX_RERUN_ARGUMENTS
|
|
158
|
-
commands << "bundle exec rspec #{arguments.join(" ")}"
|
|
159
|
-
end
|
|
165
|
+
commands = [Rerun.command([group.representative.rerun_argument])]
|
|
166
|
+
arguments = group.rerun_arguments
|
|
167
|
+
commands << Rerun.command(arguments) if arguments.size > 1 && arguments.size <= MAX_RERUN_ARGUMENTS
|
|
160
168
|
commands
|
|
161
169
|
end
|
|
162
170
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Signal
|
|
5
|
+
module Reporters
|
|
6
|
+
# The "Errors outside examples" section of the Markdown report.
|
|
7
|
+
#
|
|
8
|
+
# A spec file that would not load, or a `before(:suite)` hook that blew
|
|
9
|
+
# up, produces no failed example at all -- RSpec reports it on its message
|
|
10
|
+
# stream instead. It goes first in the report because nothing below it
|
|
11
|
+
# ran: a suite that reports two failures and one unloadable file has not
|
|
12
|
+
# told you about two failures, it has told you about however many that
|
|
13
|
+
# file contains, plus two.
|
|
14
|
+
class OutsideExamples
|
|
15
|
+
def initialize(report, config)
|
|
16
|
+
@report = report
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] sections, empty when the run had none
|
|
21
|
+
def render
|
|
22
|
+
return [] unless @report.errors_outside_examples.positive?
|
|
23
|
+
|
|
24
|
+
captured = @report.outside_example_failures
|
|
25
|
+
# A helper every spec file requires fails once per file, identically.
|
|
26
|
+
# One section per distinct error, naming every file it stopped.
|
|
27
|
+
[heading(captured.size)] +
|
|
28
|
+
Grouper.call(captured).each_with_index.map { |group, index| section(group, index + 1) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def heading(captured)
|
|
34
|
+
count = @report.errors_outside_examples
|
|
35
|
+
lines = ["## Errors outside examples", "",
|
|
36
|
+
"#{count} #{plural(count, "error")} occurred outside of any example " \
|
|
37
|
+
"(a spec file that failed to load, a `before(:suite)` hook, or similar). " \
|
|
38
|
+
"No example in the affected files ran."]
|
|
39
|
+
lines << "" << uncaptured_note(count - captured) if captured < count
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def uncaptured_note(remaining)
|
|
44
|
+
"#{remaining} of #{remaining == 1 ? "them" : "those"} could not be captured; " \
|
|
45
|
+
"the full text is in RSpec's own output."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def section(group, position)
|
|
49
|
+
failure = group.failures.first
|
|
50
|
+
[
|
|
51
|
+
["### E#{position}. #{failure.exception_class}#{count_suffix(group)}", "",
|
|
52
|
+
"> #{one_line(failure.description)}"].join("\n"),
|
|
53
|
+
fenced(failure.message.body(max_lines: @config.max_message_lines,
|
|
54
|
+
max_diff_lines: @config.max_diff_lines)),
|
|
55
|
+
locators(group),
|
|
56
|
+
labelled("Trace", fenced(trace(failure))),
|
|
57
|
+
labelled("Rerun", fenced([Rerun.command(group.rerun_arguments)], "bash"))
|
|
58
|
+
].join("\n\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def count_suffix(group)
|
|
62
|
+
group.size == 1 ? "" : " -- #{group.size} times"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def locators(group)
|
|
66
|
+
lines = ["- Raised in `#{group.failures.first.spec_location}`"]
|
|
67
|
+
return lines.join("\n") if group.size == 1
|
|
68
|
+
|
|
69
|
+
files = group.rerun_arguments.map { |file| "`#{file.sub(%r{\A\./}, "")}`" }
|
|
70
|
+
lines << if group.failures.all? { |failure| failure.description.include?("while loading") }
|
|
71
|
+
"- Stopped #{files.size} spec files from loading: #{files.join(", ")}"
|
|
72
|
+
else
|
|
73
|
+
"- Reported #{group.size} times, from: #{files.join(", ")}"
|
|
74
|
+
end
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def trace(failure)
|
|
79
|
+
entries = failure.reduced.entries.map(&:to_s)
|
|
80
|
+
entries.empty? ? ["#{failure.spec_location} (no backtrace available)"] : entries
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def labelled(label, body)
|
|
84
|
+
"**#{label}**\n\n#{body}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fenced(lines, language = "text")
|
|
88
|
+
["```#{language}", *Array(lines), "```"].join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def one_line(text)
|
|
92
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def plural(count, word)
|
|
96
|
+
count == 1 ? word : "#{word}s"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|