minitest-heat 0.0.1
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/.gitignore +8 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/minitest/heat/backtrace.rb +93 -0
- data/lib/minitest/heat/issue.rb +139 -0
- data/lib/minitest/heat/location.rb +64 -0
- data/lib/minitest/heat/map.rb +57 -0
- data/lib/minitest/heat/output.rb +271 -0
- data/lib/minitest/heat/results.rb +112 -0
- data/lib/minitest/heat/source.rb +149 -0
- data/lib/minitest/heat/version.rb +5 -0
- data/lib/minitest/heat.rb +32 -0
- data/lib/minitest/heat_plugin.rb +26 -0
- data/lib/minitest/heat_reporter.rb +99 -0
- data/minitest-heat.gemspec +39 -0
- metadata +114 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Minitest
|
4
|
+
module Heat
|
5
|
+
class Location
|
6
|
+
attr_reader :test_location, :backtrace
|
7
|
+
|
8
|
+
def initialize(test_location, backtrace = [])
|
9
|
+
@test_location = test_location
|
10
|
+
@backtrace = Backtrace.new(backtrace)
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
"#{source_file}:#{source_failure_line}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def failure_in_test?
|
18
|
+
!test_file.nil? && test_file == source_file
|
19
|
+
end
|
20
|
+
|
21
|
+
def failure_in_source?
|
22
|
+
!failure_in_test?
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_file
|
26
|
+
reduced_path(test_location[0])
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_definition_line
|
30
|
+
test_location[1].to_s
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_failure_line
|
34
|
+
@backtrace.final_test_location.number
|
35
|
+
end
|
36
|
+
|
37
|
+
def source_file
|
38
|
+
return test_file if backtrace.empty?
|
39
|
+
|
40
|
+
source_line = backtrace.final_project_location
|
41
|
+
|
42
|
+
reduced_path("#{source_line.path}/#{source_line.file}")
|
43
|
+
end
|
44
|
+
|
45
|
+
def source_failure_line
|
46
|
+
return test_definition_line if backtrace.empty?
|
47
|
+
|
48
|
+
backtrace.final_project_location.number
|
49
|
+
end
|
50
|
+
|
51
|
+
def project_directory_name
|
52
|
+
Dir.pwd.split('/').last
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def reduced_path(path)
|
58
|
+
"/#{path.split("/#{project_directory_name}/").last}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Minitest
|
4
|
+
module Heat
|
5
|
+
class Map
|
6
|
+
attr_reader :hits
|
7
|
+
|
8
|
+
# So we can sort hot spots by liklihood of being the most important spot to check out before
|
9
|
+
# trying to fix something. These are ranked based on the possibility they represent ripple
|
10
|
+
# effects where fixing one problem could potentially fix multiple other failures.
|
11
|
+
#
|
12
|
+
# For example, if there's an exception in the file, start there. Broken code can't run. If a
|
13
|
+
# test is broken (i.e. raising an exception), that's a special sort of failure that would be
|
14
|
+
# misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
|
15
|
+
WEIGHTS = {
|
16
|
+
error: 3, # exceptions from source code have the highest liklihood of a ripple effect
|
17
|
+
broken: 1, # broken tests won't have ripple effects but can't help if they can't run
|
18
|
+
failure: 1, # failures are kind of the whole point, and they could have ripple effects
|
19
|
+
skipped: 0, # skips aren't failures, but they shouldn't go ignored
|
20
|
+
slow: 0 # slow tests aren't failures, but they shouldn't be ignored
|
21
|
+
}
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@hits = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
def add(filename, line_number, type)
|
28
|
+
@hits[filename] ||= { weight: 0, total: 0 }
|
29
|
+
@hits[filename][:total] += 1
|
30
|
+
@hits[filename][:weight] += WEIGHTS[type]
|
31
|
+
|
32
|
+
@hits[filename][type] ||= []
|
33
|
+
@hits[filename][type] << line_number
|
34
|
+
end
|
35
|
+
|
36
|
+
def files
|
37
|
+
hot_files
|
38
|
+
.sort_by { |filename, weight| weight }
|
39
|
+
.reverse
|
40
|
+
.take(5)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def hot_files
|
46
|
+
files = {}
|
47
|
+
@hits.each_pair do |filename, details|
|
48
|
+
# Can't really be a "hot spot" with just a single issue
|
49
|
+
next unless details[:total] > 1
|
50
|
+
|
51
|
+
files[filename] = details[:weight]
|
52
|
+
end
|
53
|
+
files
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,271 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Minitest
|
4
|
+
module Heat
|
5
|
+
# Friendly API for printing nicely-formatted output to the console
|
6
|
+
class Output
|
7
|
+
Token = Struct.new(:style, :content) do
|
8
|
+
STYLES = {
|
9
|
+
error: %i[bold red],
|
10
|
+
broken: %i[bold red],
|
11
|
+
failure: %i[default red],
|
12
|
+
skipped: %i[bold yellow],
|
13
|
+
success: %i[default green],
|
14
|
+
slow: %i[bold green],
|
15
|
+
source: %i[italic default],
|
16
|
+
bold: %i[bold default],
|
17
|
+
default: %i[default default],
|
18
|
+
subtle: %i[light white],
|
19
|
+
muted: %i[light gray],
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
WEIGHTS = {
|
23
|
+
default: 0,
|
24
|
+
bold: 1,
|
25
|
+
light: 2,
|
26
|
+
italic: 3,
|
27
|
+
underline: 4,
|
28
|
+
frame: 51,
|
29
|
+
encircle: 52,
|
30
|
+
overline: 53,
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
COLORS = {
|
34
|
+
black: 30,
|
35
|
+
red: 31,
|
36
|
+
green: 32,
|
37
|
+
yellow: 33,
|
38
|
+
blue: 34,
|
39
|
+
magenta: 35,
|
40
|
+
cyan: 36,
|
41
|
+
gray: 37, white: 97,
|
42
|
+
default: 39,
|
43
|
+
}.freeze
|
44
|
+
|
45
|
+
def to_s
|
46
|
+
"\e[#{weight};#{color}m#{content}#{reset}"
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def weight
|
52
|
+
WEIGHTS.fetch(style_components[0])
|
53
|
+
end
|
54
|
+
|
55
|
+
def color
|
56
|
+
COLORS.fetch(style_components[1])
|
57
|
+
end
|
58
|
+
|
59
|
+
def reset
|
60
|
+
"\e[0m"
|
61
|
+
end
|
62
|
+
|
63
|
+
def style_components
|
64
|
+
STYLES[style]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
FORMATTERS = {
|
69
|
+
error: [
|
70
|
+
[ %i[error label], %i[muted spacer], %i[error class], %i[muted arrow], %i[error test_name] ],
|
71
|
+
[ %i[default summary], ],
|
72
|
+
[ %i[default backtrace_summary] ],
|
73
|
+
],
|
74
|
+
broken: [
|
75
|
+
[ %i[broken label], %i[muted spacer], %i[broken test_class], %i[muted arrow], %i[broken test_name] ],
|
76
|
+
[ %i[default summary], ],
|
77
|
+
[ %i[default backtrace_summary] ],
|
78
|
+
],
|
79
|
+
failure: [
|
80
|
+
[ %i[failure label], %i[muted spacer], %i[failure test_class], %i[muted arrow], %i[failure test_name], %i[muted spacer], %i[muted class] ],
|
81
|
+
[ %i[default summary] ],
|
82
|
+
[ %i[subtle location], ],
|
83
|
+
[ %i[default source_summary], ],
|
84
|
+
],
|
85
|
+
skipped: [
|
86
|
+
[ %i[skipped label], %i[muted spacer], %i[skipped test_class], %i[muted arrow], %i[skipped test_name] ],
|
87
|
+
[ %i[default summary], %i[muted spacer], %i[default class] ],
|
88
|
+
[], # New Line
|
89
|
+
],
|
90
|
+
slow: [
|
91
|
+
[ %i[slow label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class], ],
|
92
|
+
[ %i[bold slowness], %i[muted spacer], %i[default location], ],
|
93
|
+
[], # New Line
|
94
|
+
]
|
95
|
+
}
|
96
|
+
|
97
|
+
attr_reader :stream
|
98
|
+
|
99
|
+
def initialize(stream = $stdout)
|
100
|
+
@stream = stream.tap do |str|
|
101
|
+
# If the IO channel supports flushing the output immediately, then ensure it's enabled
|
102
|
+
str.sync = str.respond_to?(:sync=)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def print(*args)
|
107
|
+
stream.print(*args)
|
108
|
+
end
|
109
|
+
|
110
|
+
def puts(*args)
|
111
|
+
stream.puts(*args)
|
112
|
+
end
|
113
|
+
alias newline puts
|
114
|
+
|
115
|
+
def marker(value)
|
116
|
+
case value
|
117
|
+
when 'E' then text(:error, value)
|
118
|
+
when 'B' then text(:failure, value)
|
119
|
+
when 'F' then text(:failure, value)
|
120
|
+
when 'S' then text(:skipped, value)
|
121
|
+
when 'T' then text(:slow, value)
|
122
|
+
else text(:success, value)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def issue_details(issue)
|
127
|
+
formatter = FORMATTERS[issue.type]
|
128
|
+
|
129
|
+
formatter.each do |lines|
|
130
|
+
lines.each do |tokens|
|
131
|
+
style, content_method = *tokens
|
132
|
+
|
133
|
+
if issue.respond_to?(content_method)
|
134
|
+
# If it's an available method on issue, use that to get the content
|
135
|
+
content = issue.send(content_method)
|
136
|
+
text(style, content)
|
137
|
+
else
|
138
|
+
# Otherwise, fall back to output and pass issue to *it*
|
139
|
+
send(content_method, issue)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
newline
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def heat_map(map)
|
147
|
+
# text(:default, "🔥 Hot Spots 🔥\n")
|
148
|
+
map.files.each do |file|
|
149
|
+
file = file[0]
|
150
|
+
values = map.hits[file]
|
151
|
+
|
152
|
+
filename = file.split('/').last
|
153
|
+
path = file.delete_suffix(filename)
|
154
|
+
|
155
|
+
text(:error, 'E' * values[:error].size) if values[:error]&.any?
|
156
|
+
text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
|
157
|
+
text(:failure, 'F' * values[:failure].size) if values[:failure]&.any?
|
158
|
+
text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
|
159
|
+
text(:slow, 'S' * values[:skipped].size) if values[:skipped]&.any?
|
160
|
+
|
161
|
+
text(:muted, ' ')
|
162
|
+
|
163
|
+
text(:muted, "#{path.delete_prefix('/')}")
|
164
|
+
text(:default, "#{filename}")
|
165
|
+
|
166
|
+
text(:muted, ':')
|
167
|
+
|
168
|
+
all_line_numbers = values.fetch(:error, []) + values.fetch(:failure, [])
|
169
|
+
all_line_numbers += values.fetch(:skipped, [])
|
170
|
+
|
171
|
+
line_numbers = all_line_numbers.compact.uniq.sort
|
172
|
+
line_numbers.each { |line_number| text(:subtle, "#{line_number} ") }
|
173
|
+
newline
|
174
|
+
end
|
175
|
+
newline
|
176
|
+
end
|
177
|
+
|
178
|
+
def compact_summary(results)
|
179
|
+
error_count = results.errors.size
|
180
|
+
broken_count = results.brokens.size
|
181
|
+
failure_count = results.failures.size
|
182
|
+
slow_count = results.slows.size
|
183
|
+
skip_count = results.skips.size
|
184
|
+
|
185
|
+
counts = []
|
186
|
+
counts << pluralize(error_count, 'Error') if error_count.positive?
|
187
|
+
counts << pluralize(broken_count, 'Broken') if broken_count.positive?
|
188
|
+
counts << pluralize(failure_count, 'Failure') if failure_count.positive?
|
189
|
+
counts << pluralize(skip_count, 'Skip') if skip_count.positive?
|
190
|
+
counts << pluralize(slow_count, 'Slow') if slow_count.positive?
|
191
|
+
text(:default, counts.join(', '))
|
192
|
+
|
193
|
+
newline
|
194
|
+
text(:subtle, "#{results.tests_per_second} tests/s and #{results.assertions_per_second} assertions/s ")
|
195
|
+
|
196
|
+
newline
|
197
|
+
text(:muted, pluralize(results.test_count, 'Test') + ' & ')
|
198
|
+
text(:muted, pluralize(results.assertion_count, 'Assertion'))
|
199
|
+
text(:muted, " in #{results.total_time.round(2)}s")
|
200
|
+
|
201
|
+
newline
|
202
|
+
newline
|
203
|
+
end
|
204
|
+
|
205
|
+
private
|
206
|
+
|
207
|
+
def test_name_summary(issue)
|
208
|
+
text(:default, "#{issue.test_class} > #{issue.test_name}")
|
209
|
+
end
|
210
|
+
|
211
|
+
def backtrace_summary(issue)
|
212
|
+
lines = issue.backtrace.project
|
213
|
+
|
214
|
+
line = lines.first
|
215
|
+
filename = "#{line.path.delete_prefix(Dir.pwd)}/#{line.file}"
|
216
|
+
|
217
|
+
lines.take(3).each do |line|
|
218
|
+
source = Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
|
219
|
+
|
220
|
+
text(:muted, " #{line.path.delete_prefix("#{Dir.pwd}/")}/")
|
221
|
+
text(:subtle, "#{line.file}:#{line.number}")
|
222
|
+
text(:source, " `#{source.line.strip}`")
|
223
|
+
|
224
|
+
newline
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def source_summary(issue)
|
229
|
+
filename = issue.location.source_file
|
230
|
+
line_number = issue.location.source_failure_line
|
231
|
+
|
232
|
+
source = Minitest::Heat::Source.new(filename, line_number: line_number, max_line_count: 3)
|
233
|
+
show_source(source, highlight_line: true, indentation: 2)
|
234
|
+
end
|
235
|
+
|
236
|
+
def show_source(source, indentation: 0, highlight_line: false)
|
237
|
+
max_line_number_length = source.line_numbers.map(&:to_s).map(&:length).max
|
238
|
+
source.lines.each_index do |i|
|
239
|
+
line_number = source.line_numbers[i]
|
240
|
+
line = source.lines[i]
|
241
|
+
|
242
|
+
number_style, line_style = if line == source.line && highlight_line
|
243
|
+
[:default, :default]
|
244
|
+
else
|
245
|
+
[:subtle, :subtle]
|
246
|
+
end
|
247
|
+
text(number_style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_length)} ")
|
248
|
+
text(line_style, line)
|
249
|
+
puts
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
def style_enabled?
|
254
|
+
stream.tty?
|
255
|
+
end
|
256
|
+
|
257
|
+
def pluralize(count, singular)
|
258
|
+
singular_style = "#{count} #{singular}"
|
259
|
+
|
260
|
+
# Given the narrow scope, pluralization can be relatively naive here
|
261
|
+
count > 1 ? "#{singular_style}s" : singular_style
|
262
|
+
end
|
263
|
+
|
264
|
+
def text(style, content)
|
265
|
+
formatted_content = style_enabled? ? Token.new(style, content).to_s : content
|
266
|
+
|
267
|
+
print formatted_content
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Minitest
|
4
|
+
module Heat
|
5
|
+
class Results
|
6
|
+
|
7
|
+
attr_reader :test_count,
|
8
|
+
:assertion_count,
|
9
|
+
:success_count,
|
10
|
+
:issues,
|
11
|
+
:start_time,
|
12
|
+
:stop_time
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@test_count = 0
|
16
|
+
@assertion_count = 0
|
17
|
+
@success_count = 0
|
18
|
+
@issues = {
|
19
|
+
error: [],
|
20
|
+
broken: [],
|
21
|
+
failure: [],
|
22
|
+
skipped: [],
|
23
|
+
slow: []
|
24
|
+
}
|
25
|
+
@start_time = nil
|
26
|
+
@stop_time = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
def start_timer!
|
30
|
+
@start_time = Minitest.clock_time
|
31
|
+
end
|
32
|
+
|
33
|
+
def stop_timer!
|
34
|
+
@stop_time = Minitest.clock_time
|
35
|
+
end
|
36
|
+
|
37
|
+
def total_time
|
38
|
+
delta = @stop_time - @start_time
|
39
|
+
|
40
|
+
# Don't return 0
|
41
|
+
delta.zero? ? 0.1 : delta
|
42
|
+
end
|
43
|
+
|
44
|
+
def tests_per_second
|
45
|
+
(assertion_count / total_time).round(2)
|
46
|
+
end
|
47
|
+
|
48
|
+
def assertions_per_second
|
49
|
+
(assertion_count / total_time).round(2)
|
50
|
+
end
|
51
|
+
|
52
|
+
def issues?
|
53
|
+
errors? || failures? || skips?
|
54
|
+
end
|
55
|
+
|
56
|
+
def errors
|
57
|
+
issues.fetch(:error) { [] }
|
58
|
+
end
|
59
|
+
|
60
|
+
def brokens
|
61
|
+
issues.fetch(:broken) { [] }
|
62
|
+
end
|
63
|
+
|
64
|
+
def failures
|
65
|
+
issues.fetch(:failure) { [] }
|
66
|
+
end
|
67
|
+
|
68
|
+
def skips
|
69
|
+
issues.fetch(:skipped) { [] }
|
70
|
+
end
|
71
|
+
|
72
|
+
def slows
|
73
|
+
issues
|
74
|
+
.fetch(:slow) { [] }
|
75
|
+
.sort { |issue| issue.time }
|
76
|
+
.reverse
|
77
|
+
.take(3)
|
78
|
+
end
|
79
|
+
|
80
|
+
def errors?
|
81
|
+
errors.any?
|
82
|
+
end
|
83
|
+
|
84
|
+
def brokens?
|
85
|
+
brokens.any?
|
86
|
+
end
|
87
|
+
|
88
|
+
def failures?
|
89
|
+
failures.any?
|
90
|
+
end
|
91
|
+
|
92
|
+
def skips?
|
93
|
+
skips.any?
|
94
|
+
end
|
95
|
+
|
96
|
+
def count(result)
|
97
|
+
@test_count += 1
|
98
|
+
@assertion_count += result.assertions
|
99
|
+
@success_count += 1 if result.passed?
|
100
|
+
end
|
101
|
+
|
102
|
+
def record_issue(result)
|
103
|
+
issue = Heat::Issue.new(result)
|
104
|
+
|
105
|
+
@issues[issue.type] ||= []
|
106
|
+
@issues[issue.type] << issue
|
107
|
+
|
108
|
+
issue
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|