minitest-heat 0.0.7 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,11 +3,13 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
- class Issue
7
- attr_accessor :issue
6
+ # Formats issues to output based on the issue type
7
+ class Issue # rubocop:disable Metrics/ClassLength
8
+ attr_accessor :issue, :locations
8
9
 
9
10
  def initialize(issue)
10
11
  @issue = issue
12
+ @locations = issue.locations
11
13
  end
12
14
 
13
15
  def tokens
@@ -27,7 +29,6 @@ module Minitest
27
29
  [
28
30
  headline_tokens,
29
31
  test_location_tokens,
30
- location_tokens,
31
32
  summary_tokens,
32
33
  *backtrace_tokens,
33
34
  newline_tokens
@@ -78,93 +79,100 @@ module Minitest
78
79
  end
79
80
 
80
81
  def headline_tokens
81
- [[issue.type, issue.label], spacer_token, [:default, issue.test_name]]
82
+ [[issue.type, label(issue)], spacer_token, [:default, test_name(issue)]]
83
+ end
84
+
85
+ def test_name(issue)
86
+ test_prefix = 'test_'
87
+ identifier = issue.test_identifier
88
+
89
+ if identifier.start_with?(test_prefix)
90
+ identifier.delete_prefix(test_prefix).gsub('_', ' ').capitalize
91
+ else
92
+ identifier
93
+ end
94
+ end
95
+
96
+ def label(issue) # rubocop:disable Metrics
97
+ if issue.error? && issue.in_test?
98
+ # When the exception came out of the test itself, that's a different kind of exception
99
+ # that really only indicates there's a problem with the code in the test. It's kind of
100
+ # between an error and a test.
101
+ 'Broken Test'
102
+ elsif issue.error?
103
+ 'Error'
104
+ elsif issue.skipped?
105
+ 'Skipped'
106
+ elsif issue.painful?
107
+ 'Passed but Very Slow'
108
+ elsif issue.slow?
109
+ 'Passed but Slow'
110
+ elsif !issue.passed?
111
+ 'Failure'
112
+ else
113
+ 'Success'
114
+ end
82
115
  end
83
116
 
84
117
  def test_name_and_class_tokens
85
- [[:default, issue.test_class], *test_location_tokens ]
118
+ [[:default, issue.test_class], *test_location_tokens]
86
119
  end
87
120
 
88
121
  def backtrace_tokens
89
- backtrace = ::Minitest::Heat::Output::Backtrace.new(issue.location)
90
-
91
- backtrace.tokens
122
+ @backtrace_tokens ||= ::Minitest::Heat::Output::Backtrace.new(locations).tokens
92
123
  end
93
124
 
94
125
  def test_location_tokens
95
- [[:default, test_file_short_location], [:muted, ':'], [:default, issue.test_definition_line], arrow_token, [:default, issue.test_failure_line], [:muted, test_line_source]]
126
+ [
127
+ [:default, locations.test_definition.relative_filename],
128
+ [:muted, ':'],
129
+ [:default, locations.test_definition.line_number],
130
+ arrow_token,
131
+ [:default, locations.test_failure.line_number],
132
+ [:muted, "\n #{locations.test_failure.source_code.line.strip}"]
133
+ ]
96
134
  end
97
135
 
98
136
  def location_tokens
99
- [[:default, most_relevant_short_location], [:muted, ':'], [:default, issue.location.most_relevant_failure_line], [:muted, most_relevant_line_source]]
137
+ [
138
+ [:default, locations.most_relevant.relative_filename],
139
+ [:muted, ':'],
140
+ [:default, locations.most_relevant.line_number],
141
+ [:muted, "\n #{locations.most_relevant.source_code.line.strip}"]
142
+ ]
100
143
  end
101
144
 
102
145
  def source_tokens
103
- filename = issue.location.project_file
104
- line_number = issue.location.project_failure_line
105
-
106
- # source_code = ::Minitest::Heat::Output::SourceCode.new(filename, line_number, max_line_count: 1)
107
- # source_code.tokens
108
-
146
+ filename = locations.project.filename
147
+ line_number = locations.project.line_number
109
148
  source = Minitest::Heat::Source.new(filename, line_number: line_number)
149
+
110
150
  [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]]
111
151
  end
112
152
 
113
153
  def summary_tokens
114
- [[:italicized, issue.summary.delete_suffix("---------------")]]
154
+ [[:italicized, issue.summary.delete_suffix('---------------').strip]]
115
155
  end
116
156
 
117
157
  def slowness_summary_tokens
118
- [[:bold, issue.slowness], spacer_token, [:default, issue.short_location]]
119
- end
120
-
121
- def newline_tokens
122
- []
123
- end
124
-
125
- def most_relevant_short_location
126
- issue.location.most_relevant_file.to_s.delete_prefix("#{Dir.pwd}/")
127
- end
128
-
129
- def test_file_short_location
130
- issue.location.test_file.to_s.delete_prefix("#{Dir.pwd}/")
158
+ [
159
+ [:bold, slowness(issue)],
160
+ spacer_token,
161
+ [:default, locations.test_definition.relative_path],
162
+ [:default, locations.test_definition.filename],
163
+ [:muted, ':'],
164
+ [:default, locations.test_definition.line_number]
165
+ ]
131
166
  end
132
167
 
133
- def most_relevant_line_source
134
- filename = issue.location.project_file
135
- line_number = issue.location.project_failure_line
136
-
137
- source = Minitest::Heat::Source.new(filename, line_number: line_number)
138
- "\n #{source.line.strip}"
168
+ def slowness(issue)
169
+ "#{issue.execution_time.round(2)}s"
139
170
  end
140
171
 
141
- def test_line_source
142
- filename = issue.location.test_file
143
- line_number = issue.location.test_failure_line
144
-
145
- source = Minitest::Heat::Source.new(filename, line_number: line_number)
146
- "\n #{source.line.strip}"
172
+ def newline_tokens
173
+ []
147
174
  end
148
175
 
149
-
150
- # def failure_summary_tokens
151
- # return unless issue_summary_lines.any?
152
-
153
- # # Sometimes, the exception message is multiple lines, so this adjusts the lines to
154
- # # visually group them together a bit
155
- # if issue_summary_lines.one?
156
- # [[[:italicized, issue_summary_lines.first]]]
157
- # else
158
- # issue_summary_lines.map do |line|
159
- # [Output::TOKENS[:muted_lead], [:italicized, line]]
160
- # end
161
- # end
162
- # end
163
-
164
- # def issue_summary_lines
165
- # @issue_summary_lines ||= issue.summary.split("\n")
166
- # end
167
-
168
176
  def spacer_token
169
177
  Output::TOKENS[:spacer]
170
178
  end
@@ -172,7 +180,6 @@ module Minitest
172
180
  def arrow_token
173
181
  Output::TOKENS[:muted_arrow]
174
182
  end
175
-
176
183
  end
177
184
  end
178
185
  end
@@ -3,24 +3,36 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
+ # Generates the tokens to output the resulting heat map
6
7
  class Map
7
- # extend Forwardable
8
+ attr_accessor :results
8
9
 
9
- attr_accessor :map
10
-
11
- # def_delegators :@results, :errors, :brokens, :failures, :slows, :skips, :problems?, :slows?
12
-
13
- def initialize(map)
14
- @map = map
10
+ def initialize(results)
11
+ @results = results
15
12
  @tokens = []
16
13
  end
17
14
 
18
15
  def tokens
19
- map.file_hits.each do |file|
20
- @tokens << [
21
- *pathname(file),
22
- *line_numbers(file)
23
- ]
16
+ results.heat_map.file_hits.each do |hit|
17
+ # If there's legitimate failures or errors, skips and slows aren't relevant
18
+ next unless relevant_issue_types?(hit)
19
+
20
+ @tokens << [[:muted, ""]]
21
+ @tokens << file_summary_tokens(hit)
22
+
23
+ repeats = repeated_line_numbers(hit)
24
+ next unless repeats.any?
25
+
26
+ repeats.each do |line_number|
27
+ @tokens << [[:muted, " Issues on Line #{line_number} initially triggered from these locations:"]]
28
+
29
+ traces = hit.lines[line_number.to_s]
30
+ sorted_traces = traces.sort_by { |trace| trace.locations.last.line_number }
31
+
32
+ sorted_traces.each do |trace|
33
+ @tokens << origination_location_token(trace)
34
+ end
35
+ end
24
36
  end
25
37
 
26
38
  @tokens
@@ -28,38 +40,109 @@ module Minitest
28
40
 
29
41
  private
30
42
 
31
- def pathname(file)
32
- directory = "#{file.pathname.dirname.to_s.delete_prefix(Dir.pwd)}/"
33
- filename = file.pathname.basename.to_s
43
+ def file_summary_tokens(hit)
44
+ pathname_tokens = pathname(hit)
45
+ line_number_list_tokens = sorted_line_number_list(hit)
46
+
47
+ [*pathname_tokens, *line_number_list_tokens]
48
+ end
49
+
50
+ def origination_location_token(trace)
51
+ # The earliest project line from the backtrace—this is probabyl wholly incorrect in terms
52
+ # of what would be the most helpful line to display, but it's a start.
53
+ location = trace.locations.last
54
+
55
+ [
56
+ [:muted, " #{Output::SYMBOLS[:arrow]} "],
57
+ [:default, location.relative_filename],
58
+ [:muted, ':'],
59
+ [:default, location.line_number],
60
+ [:muted, " in #{location.container}"],
61
+ [:muted, " #{Output::SYMBOLS[:arrow]} `location.source_code.line.strip`"],
62
+ ]
63
+ end
64
+
65
+ def relevant_issue_types
66
+ # These are always relevant.
67
+ issue_types = %i[error broken failure]
68
+
69
+ # These are only relevant if there aren't more serious isues.
70
+ issue_types << :skipped unless results.problems?
71
+ issue_types << :painful unless results.problems? || results.skips.any?
72
+ issue_types << :slow unless results.problems? || results.skips.any?
73
+
74
+ issue_types
75
+ end
76
+
77
+ def relevant_issue_types?(hit)
78
+ intersection_issue_types = relevant_issue_types & hit.issues.keys
79
+
80
+ intersection_issue_types.any?
81
+ end
82
+
83
+ def repeated_line_numbers(hit)
84
+ repeated_line_numbers = []
85
+
86
+ hit.lines.each_pair do |line_number, traces|
87
+ # If there aren't multiple traces for a line number, it's not a repeat, right?
88
+ next unless traces.size > 1
89
+
90
+ repeated_line_numbers << Integer(line_number)
91
+ end
92
+
93
+ repeated_line_numbers.sort
94
+ end
95
+
96
+ def repeated_line_numbers?(hit)
97
+ repeated_line_numbers(hit).any?
98
+ end
99
+
100
+ def pathname(hit)
101
+ directory = hit.pathname.dirname.to_s.delete_prefix("#{Dir.pwd}/")
102
+ filename = hit.pathname.basename.to_s
34
103
 
35
104
  [
36
- [:default, directory],
105
+ [:default, "#{directory}/"],
37
106
  [:bold, filename],
38
107
  [:default, ' · ']
39
108
  ]
40
109
  end
41
110
 
42
- def hit_line_numbers(file, issue_type)
43
- line_numbers_for_issue_type = file.issues.fetch(issue_type) { [] }
111
+ def line_number_tokens_for_hit(hit)
112
+ line_number_tokens = []
44
113
 
45
- return nil if line_numbers_for_issue_type.empty?
114
+ relevant_issue_types.each do |issue_type|
115
+ # Retrieve any line numbers for the issue type
116
+ line_numbers_for_issue_type = hit.issues.fetch(issue_type) { [] }
46
117
 
47
- numbers = []
48
- line_numbers_for_issue_type.sort.map do |line_number|
49
- numbers << [issue_type, "#{line_number} "]
118
+ # Build the list of tokens representing styled line numbers
119
+ line_numbers_for_issue_type.each do |line_number|
120
+ line_number_tokens << line_number_token(issue_type, line_number)
121
+ end
50
122
  end
51
- numbers
123
+
124
+ line_number_tokens.compact
52
125
  end
53
126
 
54
- def line_numbers(file)
55
- [
56
- *hit_line_numbers(file, :error),
57
- *hit_line_numbers(file, :broken),
58
- *hit_line_numbers(file, :failure),
59
- *hit_line_numbers(file, :skipped),
60
- *hit_line_numbers(file, :painful),
61
- *hit_line_numbers(file, :slow)
62
- ].compact.sort_by { |number_token| number_token[1] }
127
+ def line_number_token(style, line_number)
128
+ [style, "#{line_number} "]
129
+ end
130
+
131
+ # Generates the line number tokens styled based on their error type
132
+ #
133
+ # @param [Hit] hit the instance of the hit file details to build the heat map entry
134
+ #
135
+ # @return [Array] the arrays representing the line number tokens to display next to a file
136
+ # name in the heat map
137
+ def sorted_line_number_list(hit)
138
+ # Sort the collected group of line number hits so they're in order
139
+ line_number_tokens_for_hit(hit).sort do |a, b|
140
+ # Ensure the line numbers are integers for sorting (otherwise '100' comes before '12')
141
+ first_line_number = Integer(a[1].strip)
142
+ second_line_number = Integer(b[1].strip)
143
+
144
+ first_line_number <=> second_line_number
145
+ end
63
146
  end
64
147
  end
65
148
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Minitest
4
4
  module Heat
5
- # Friendly API for printing nicely-formatted output to the console
6
5
  class Output
6
+ # Friendly API for printing consistent markers for the various issue types
7
7
  class Marker
8
8
  SYMBOLS = {
9
9
  success: '·',
@@ -12,7 +12,8 @@ module Minitest
12
12
  broken: 'B',
13
13
  error: 'E',
14
14
  skipped: 'S',
15
- failure: 'F'
15
+ failure: 'F',
16
+ reporter: '✖'
16
17
  }.freeze
17
18
 
18
19
  STYLES = {
@@ -22,7 +23,8 @@ module Minitest
22
23
  broken: :error,
23
24
  error: :error,
24
25
  skipped: :skipped,
25
- failure: :failure
26
+ failure: :failure,
27
+ reporter: :error
26
28
  }.freeze
27
29
 
28
30
  attr_accessor :issue_type
@@ -3,6 +3,7 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
+ # Generates the output tokens to display the results summary
6
7
  class Results
7
8
  extend Forwardable
8
9
 
@@ -90,7 +91,7 @@ module Minitest
90
91
  end
91
92
 
92
93
  def test_count_token
93
- [:default, "#{pluralize(timer.test_count, 'test')}"]
94
+ [:default, pluralize(timer.test_count, 'test').to_s]
94
95
  end
95
96
 
96
97
  def tests_performance_token
@@ -98,7 +99,7 @@ module Minitest
98
99
  end
99
100
 
100
101
  def assertions_count_token
101
- [:default, "#{pluralize(timer.assertion_count, 'assertion')}"]
102
+ [:default, pluralize(timer.assertion_count, 'assertion').to_s]
102
103
  end
103
104
 
104
105
  def assertions_performance_token
@@ -3,7 +3,7 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
- # Builds the collection of tokens representing a specific set of source code lines
6
+ # Generates the tokens representing a specific set of source code lines
7
7
  class SourceCode
8
8
  DEFAULT_LINE_COUNT = 3
9
9
  DEFAULT_INDENTATION_SPACES = 2
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Minitest
4
4
  module Heat
5
- # Friendly API for printing nicely-formatted output to the console
6
5
  class Output
6
+ # Provides a convenient interface for creating console-friendly output while ensuring
7
+ # consistency in the applied styles.
7
8
  class Token
8
9
  class InvalidStyle < ArgumentError; end
9
10
 
@@ -11,18 +11,18 @@ require_relative 'output/token'
11
11
  module Minitest
12
12
  module Heat
13
13
  # Friendly API for printing nicely-formatted output to the console
14
- class Output
14
+ class Output # rubocop:disable Metrics/ClassLength
15
15
  SYMBOLS = {
16
16
  middot: '·',
17
17
  arrow: '➜',
18
- lead: '|',
18
+ lead: '|'
19
19
  }.freeze
20
20
 
21
21
  TOKENS = {
22
- spacer: [:muted, " #{SYMBOLS[:middot]} "],
22
+ spacer: [:muted, " #{SYMBOLS[:middot]} "],
23
23
  muted_arrow: [:muted, " #{SYMBOLS[:arrow]} "],
24
- muted_lead: [:muted, "#{SYMBOLS[:lead]} "],
25
- }
24
+ muted_lead: [:muted, "#{SYMBOLS[:lead]} "]
25
+ }.freeze
26
26
 
27
27
  attr_reader :stream
28
28
 
@@ -42,8 +42,30 @@ module Minitest
42
42
  end
43
43
  alias newline puts
44
44
 
45
+ def issues_list(results)
46
+ # A couple of blank lines to create some breathing room
47
+ newline
48
+ newline
49
+
50
+ # Issues start with the least critical and go up to the most critical so that the most
51
+ # pressing issues are displayed at the bottom of the report in order to reduce scrolling.
52
+ # This way, as you fix issues, the list gets shorter, and eventually the least critical
53
+ # issues will be displayed without scrolling once more problematic issues are resolved.
54
+ %i[slows painfuls skips failures brokens errors].each do |issue_category|
55
+ next unless show?(issue_category, results)
56
+
57
+ results.send(issue_category).each { |issue| issue_details(issue) }
58
+ end
59
+ rescue StandardError => e
60
+ message = "Sorry, but Minitest Heat couldn't display the details of any failures."
61
+ exception_guidance(message, e)
62
+ end
63
+
45
64
  def issue_details(issue)
46
65
  print_tokens Minitest::Heat::Output::Issue.new(issue).tokens
66
+ rescue StandardError => e
67
+ message = "Sorry, but Minitest Heat couldn't display output for a failure."
68
+ exception_guidance(message, e)
47
69
  end
48
70
 
49
71
  def marker(issue_type)
@@ -53,15 +75,49 @@ module Minitest
53
75
  def compact_summary(results, timer)
54
76
  newline
55
77
  print_tokens ::Minitest::Heat::Output::Results.new(results, timer).tokens
78
+ rescue StandardError => e
79
+ message = "Sorry, but Minitest Heat couldn't display the summary."
80
+ exception_guidance(message, e)
56
81
  end
57
82
 
58
83
  def heat_map(map)
59
84
  newline
60
85
  print_tokens ::Minitest::Heat::Output::Map.new(map).tokens
86
+ newline
87
+ rescue StandardError => e
88
+ message = "Sorry, but Minitest Heat couldn't display the heat map."
89
+ exception_guidance(message, e)
90
+ end
91
+
92
+ def exception_guidance(message, exception)
93
+ newline
94
+ puts "#{message} Disabling Minitest Heat can get you back on track until the problem can be fixed."
95
+ puts 'Please use the following exception details to submit an issue at https://github.com/garrettdimon/minitest-heat/issues'
96
+ puts "#{exception.message}:"
97
+ exception.backtrace.each do |line|
98
+ puts " #{line}"
99
+ end
100
+ newline
61
101
  end
62
102
 
63
103
  private
64
104
 
105
+ def no_problems?(results)
106
+ !results.problems?
107
+ end
108
+
109
+ def no_problems_or_skips?(results)
110
+ !results.problems? && results.skips.none?
111
+ end
112
+
113
+ def show?(issue_category, results)
114
+ case issue_category
115
+ when :skips then no_problems?(results)
116
+ when :painfuls, :slows then no_problems_or_skips?(results)
117
+ else true
118
+ end
119
+ end
120
+
65
121
  def style_enabled?
66
122
  stream.tty?
67
123
  end
@@ -82,7 +138,11 @@ module Minitest
82
138
  def print_tokens(lines_of_tokens)
83
139
  lines_of_tokens.each do |tokens|
84
140
  tokens.each do |token|
85
- print Token.new(*token).to_s(token_format)
141
+ begin
142
+ print Token.new(*token).to_s(token_format)
143
+ rescue
144
+ puts token.inspect
145
+ end
86
146
  end
87
147
  newline
88
148
  end
@@ -4,14 +4,36 @@ module Minitest
4
4
  module Heat
5
5
  # A collection of test failures
6
6
  class Results
7
- attr_reader :issues
7
+ attr_reader :issues, :heat_map
8
8
 
9
9
  def initialize
10
10
  @issues = []
11
+ @heat_map = Heat::Map.new
11
12
  end
12
13
 
14
+ # Logs an issue to the results for later reporting
15
+ # @param issue [Issue] the issue generated from a given test result
16
+ #
17
+ # @return [type] [description]
13
18
  def record(issue)
14
- @issues << issue
19
+ # Record everything—even if it's a success
20
+ @issues.push(issue)
21
+
22
+ # If it's not a genuine problem, we're done here...
23
+ return unless issue.hit?
24
+
25
+ # ...otherwise update the heat map
26
+ update_heat_map(issue)
27
+ end
28
+
29
+ def update_heat_map(issue)
30
+ # For heat map purposes, only the project backtrace lines are interesting
31
+ pathname, line_number = issue.locations.project.to_a
32
+
33
+ # Backtrace is only relevant for exception-generating issues, not slows, skips, or failures
34
+ backtrace = issue.error? ? issue.locations.backtrace.project_locations : []
35
+
36
+ @heat_map.add(pathname, line_number, issue.type, backtrace: backtrace)
15
37
  end
16
38
 
17
39
  def problems?
@@ -35,11 +57,11 @@ module Minitest
35
57
  end
36
58
 
37
59
  def painfuls
38
- @painfuls ||= select_issues(:painful).sort_by(&:time).reverse
60
+ @painfuls ||= select_issues(:painful).sort_by(&:execution_time).reverse
39
61
  end
40
62
 
41
63
  def slows
42
- @slows ||= select_issues(:slow).sort_by(&:time).reverse
64
+ @slows ||= select_issues(:slow).sort_by(&:execution_time).reverse
43
65
  end
44
66
 
45
67
  private
@@ -35,7 +35,7 @@ module Minitest
35
35
  #
36
36
  # @return [Array<String>] the range of lines of code around
37
37
  def lines
38
- return [line] if max_line_count == 1
38
+ return [line].compact if max_line_count == 1
39
39
 
40
40
  file_lines[(line_numbers.first - 1)..(line_numbers.last - 1)]
41
41
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Minitest
4
4
  module Heat
5
- VERSION = '0.0.7'
5
+ VERSION = '0.0.11'
6
6
  end
7
7
  end
data/lib/minitest/heat.rb CHANGED
@@ -3,8 +3,8 @@
3
3
  require_relative 'heat/backtrace'
4
4
  require_relative 'heat/hit'
5
5
  require_relative 'heat/issue'
6
- require_relative 'heat/line'
7
6
  require_relative 'heat/location'
7
+ require_relative 'heat/locations'
8
8
  require_relative 'heat/map'
9
9
  require_relative 'heat/output'
10
10
  require_relative 'heat/results'
@@ -13,7 +13,8 @@ require_relative 'heat/timer'
13
13
  require_relative 'heat/version'
14
14
 
15
15
  module Minitest
16
- # Custom minitest reporter just for Reviewer. Focuses on printing directly actionable guidance.
16
+ # Custom Minitest reporter focused on generating output designed around efficiently identifying
17
+ # issues and potential solutions
17
18
  # - Colorize the Output
18
19
  # - What files had the most errors?
19
20
  # - Show the most impacted areas first.