minitest-heat 0.0.1

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