minitest-heat 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|