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.
@@ -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