minitest-heat 0.0.6 → 0.0.10

Sign up to get free protection for your applications and to get access to all the features.
@@ -22,10 +22,10 @@ module Minitest
22
22
  end
23
23
  end
24
24
 
25
- attr_reader :test_location, :backtrace
25
+ attr_reader :test_definition_location, :backtrace
26
26
 
27
- def initialize(test_location, backtrace = [])
28
- @test_location = TestDefinition.new(*test_location)
27
+ def initialize(test_definition_location, backtrace = [])
28
+ @test_definition_location = TestDefinition.new(*test_definition_location)
29
29
  @backtrace = Backtrace.new(backtrace)
30
30
  end
31
31
 
@@ -45,9 +45,9 @@ module Minitest
45
45
  # test, and it raises an exception, then it's really a broken test rather than a proper
46
46
  # faiure.
47
47
  #
48
- # @return [Boolean] true if most relevant file is the same as the test location file
48
+ # @return [Boolean] true if final file in the backtrace is the same as the test location file
49
49
  def broken_test?
50
- !test_file.nil? && test_file == most_relevant_file
50
+ !test_file.nil? && test_file == final_file
51
51
  end
52
52
 
53
53
  # Knows if the failure occurred in the actual project source code—as opposed to the test or
@@ -59,6 +59,15 @@ module Minitest
59
59
  !source_code_file.nil? && !broken_test?
60
60
  end
61
61
 
62
+
63
+
64
+ # The final location of the stacktrace regardless of whether it's from within the project
65
+ #
66
+ # @return [String] the relative path to the file from the project root
67
+ def final_file
68
+ Pathname(final_location.pathname)
69
+ end
70
+
62
71
  # The file most likely to be the source of the underlying problem. Often, the most recent
63
72
  # backtrace files will be a gem or external library that's failing indirectly as a result
64
73
  # of a problem with local source code (not always, but frequently). In that case, the best
@@ -69,78 +78,76 @@ module Minitest
69
78
  Pathname(most_relevant_location.pathname)
70
79
  end
71
80
 
72
- # The line number of the `most_relevant_file` where the failure originated
81
+ # The final location from the stacktrace that is a test file
73
82
  #
74
- # @return [Integer] line number
75
- def most_relevant_failure_line
76
- most_relevant_location.line_number
83
+ # @return [String, nil] the relative path to the file from the project root
84
+ def test_file
85
+ Pathname(final_test_location.pathname)
77
86
  end
78
87
 
79
- # The final location of the stacktrace regardless of whether it's from within the project
88
+ # The final location from the stacktrace that is within the project directory
80
89
  #
81
- # @return [String] the relative path to the file from the project root
82
- def final_file
83
- Pathname(final_location.pathname)
84
- end
90
+ # @return [String, nil] the relative path to the file from the project root
91
+ def source_code_file
92
+ return nil if final_source_code_location.nil?
85
93
 
86
- # The line number of the `final_file` where the failure originated
87
- #
88
- # @return [Integer] line number
89
- def final_failure_line
90
- final_location.line_number
94
+ Pathname(final_source_code_location.pathname)
91
95
  end
92
96
 
93
- # The final location of the stacktrace regardless of whether it's from within the project
97
+ # The final location of the stacktrace from within the project (source code or test code)
94
98
  #
95
99
  # @return [String] the relative path to the file from the project root
96
100
  def project_file
97
- broken_test? ? test_file : source_code_file
101
+ return nil if project_location.nil?
102
+
103
+ Pathname(project_location.pathname)
98
104
  end
99
105
 
100
- # The line number of the `project_file` where the failure originated
106
+
107
+ # The line number of the `final_file` where the failure originated
101
108
  #
102
109
  # @return [Integer] line number
103
- def project_failure_line
104
- broken_test? ? test_failure_line || test_definition_line : source_code_failure_line
110
+ def final_failure_line
111
+ final_location.line_number
105
112
  end
106
113
 
107
- # The final location from the stacktrace that is within the project directory
114
+ # The line number of the `most_relevant_file` where the failure originated
108
115
  #
109
- # @return [String, nil] the relative path to the file from the project root
110
- def source_code_file
111
- return nil unless backtrace.source_code_entries.any?
112
-
113
- backtrace.final_source_code_location.pathname
116
+ # @return [Integer] line number
117
+ def most_relevant_failure_line
118
+ most_relevant_location.line_number
114
119
  end
115
120
 
116
- # The line number of the `source_code_file` where the failure originated
121
+ # The line number of the `test_file` where the test is defined
117
122
  #
118
123
  # @return [Integer] line number
119
- def source_code_failure_line
120
- return nil unless backtrace.source_code_entries.any?
121
-
122
- backtrace.final_source_code_location.line_number
124
+ def test_definition_line
125
+ test_definition_location.line_number
123
126
  end
124
127
 
125
- # The final location from the stacktrace that is within the project's test directory
128
+ # The line number from within the `test_file` test definition where the failure occurred
126
129
  #
127
- # @return [String, nil] the relative path to the file from the project root
128
- def test_file
129
- Pathname(test_location.pathname)
130
+ # @return [Integer] line number
131
+ def test_failure_line
132
+ final_test_location.line_number
130
133
  end
131
134
 
132
- # The line number of the `test_file` where the test is defined
135
+ # The line number of the `source_code_file` where the failure originated
133
136
  #
134
137
  # @return [Integer] line number
135
- def test_definition_line
136
- test_location.line_number
138
+ def source_code_failure_line
139
+ final_source_code_location&.line_number
137
140
  end
138
141
 
139
- # The line number from within the `test_file` test definition where the failure occurred
142
+ # The line number of the `project_file` where the failure originated
140
143
  #
141
144
  # @return [Integer] line number
142
- def test_failure_line
143
- backtrace.final_test_location&.line_number || test_definition_line
145
+ def project_failure_line
146
+ if !broken_test? && !source_code_file.nil?
147
+ source_code_failure_line
148
+ else
149
+ test_failure_line
150
+ end
144
151
  end
145
152
 
146
153
  # The line number from within the `test_file` test definition where the failure occurred
@@ -148,7 +155,7 @@ module Minitest
148
155
  # @return [Location] the last location from the backtrace or the test location if a backtrace
149
156
  # was not passed to the initializer
150
157
  def final_location
151
- backtrace? ? backtrace.final_location : test_location
158
+ backtrace.parsed_entries.any? ? backtrace.final_location : test_definition_location
152
159
  end
153
160
 
154
161
  # The file most likely to be the source of the underlying problem. Often, the most recent
@@ -159,22 +166,33 @@ module Minitest
159
166
  # @return [Array] file and line number of the most likely source of the problem
160
167
  def most_relevant_location
161
168
  [
162
- source_code_location,
163
- test_location,
169
+ final_source_code_location,
170
+ final_test_location,
164
171
  final_location
165
172
  ].compact.first
166
173
  end
167
174
 
168
- def project_location
169
- source_code_location || test_location
175
+ # Returns the final test location based on the backtrace if present. Otherwise falls back to
176
+ # the test location which represents the test definition.
177
+ #
178
+ # @return [Location] the final location from the test files
179
+ def final_test_location
180
+ backtrace.final_test_location || test_definition_location
170
181
  end
171
182
 
172
- def source_code_location
183
+ # Returns the final source code location based on the backtrace
184
+ #
185
+ # @return [Location] the final location from the source code files
186
+ def final_source_code_location
173
187
  backtrace.final_source_code_location
174
188
  end
175
189
 
176
- def backtrace?
177
- backtrace.parsed_entries.any?
190
+ # Returns the final project location based on the backtrace if present. Otherwise falls back
191
+ # to the test location which represents the test definition.
192
+ #
193
+ # @return [Location] the final location from the project files
194
+ def project_location
195
+ backtrace.final_project_location || test_definition_location
178
196
  end
179
197
  end
180
198
  end
@@ -2,43 +2,40 @@
2
2
 
3
3
  module Minitest
4
4
  module Heat
5
+ # Structured approach to collecting the locations of issues for generating a heat map
5
6
  class Map
6
7
  MAXIMUM_FILES_TO_SHOW = 5
7
8
 
8
9
  attr_reader :hits
9
10
 
10
- # So we can sort hot spots by liklihood of being the most important spot to check out before
11
- # trying to fix something. These are ranked based on the possibility they represent ripple
12
- # effects where fixing one problem could potentially fix multiple other failures.
13
- #
14
- # For example, if there's an exception in the file, start there. Broken code can't run. If a
15
- # test is broken (i.e. raising an exception), that's a special sort of failure that would be
16
- # misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
17
- WEIGHTS = {
18
- error: 3, # exceptions from source code have the highest liklihood of a ripple effect
19
- broken: 2, # broken tests won't have ripple effects but can't help if they can't run
20
- failure: 1, # failures are kind of the whole point, and they could have ripple effects
21
- skipped: 0, # skips aren't failures, but they shouldn't go ignored
22
- painful: 0, # slow tests aren't failures, but they shouldn't be ignored
23
- slow: 0
24
- }.freeze
25
-
26
11
  def initialize
27
12
  @hits = {}
28
13
  end
29
14
 
15
+ # Records a hit to the list of files and issue types
16
+ # @param filename [String] the unique path and file name for recordings hits
17
+ # @param line_number [Integer] the line number where the issue was encountered
18
+ # @param type [Symbol] the type of issue that was encountered (i.e. :failure, :error, etc.)
19
+ #
20
+ # @return [void]
30
21
  def add(filename, line_number, type)
31
22
  @hits[filename] ||= Hit.new(filename)
32
23
 
33
- @hits[filename].log(type, line_number)
24
+ @hits[filename].log(type.to_sym, line_number)
34
25
  end
35
26
 
27
+ # Returns a subset of affected files to keep the list from being overwhelming
28
+ #
29
+ # @return [Array] the list of files and the line numbers for each encountered issue type
36
30
  def file_hits
37
31
  hot_files.take(MAXIMUM_FILES_TO_SHOW)
38
32
  end
39
33
 
40
34
  private
41
35
 
36
+ # Sorts the files by hit "weight" so that the most problematic files are at the beginning
37
+ #
38
+ # @return [Array] the collection of files that encountred issues
42
39
  def hot_files
43
40
  hits.values.sort_by(&:weight).reverse
44
41
  end
@@ -5,7 +5,7 @@ module Minitest
5
5
  class Output
6
6
  # Builds the collection of tokens for a backtrace when an exception occurs
7
7
  class Backtrace
8
- DEFAULT_LINE_COUNT = 5
8
+ DEFAULT_LINE_COUNT = 10
9
9
  DEFAULT_INDENTATION_SPACES = 2
10
10
 
11
11
  attr_accessor :location, :backtrace
@@ -23,13 +23,9 @@ module Minitest
23
23
  # Iterate over the selected lines from the backtrace
24
24
  backtrace_entries.each do |backtrace_entry|
25
25
  # Get the source code for the line from the backtrace
26
- parts = [
27
- indentation_token,
28
- path_token(backtrace_entry),
29
- file_and_line_number_token(backtrace_entry),
30
- source_code_line_token(backtrace_entry.source_code)
31
- ]
26
+ parts = backtrace_line_tokens(backtrace_entry)
32
27
 
28
+ # If it's the most recently modified file in the trace, add the token for that
33
29
  parts << file_freshness(backtrace_entry) if most_recently_modified?(backtrace_entry)
34
30
 
35
31
  @tokens << parts
@@ -52,11 +48,20 @@ module Minitest
52
48
  # ...it's smart about exceptions that were raised outside of the project?
53
49
  # ...it's smart about highlighting lines of code differently based on whether it's source code, test code, or external code?
54
50
  def backtrace_entries
55
- project_entries
51
+ all_entries
56
52
  end
57
53
 
58
54
  private
59
55
 
56
+ def backtrace_line_tokens(backtrace_entry)
57
+ [
58
+ indentation_token,
59
+ path_token(backtrace_entry),
60
+ *file_and_line_number_tokens(backtrace_entry),
61
+ source_code_line_token(backtrace_entry.source_code)
62
+ ]
63
+ end
64
+
60
65
  def all_backtrace_entries_from_project?
61
66
  backtrace_entries.all? { |line| line.path.to_s.include?(project_root_dir) }
62
67
  end
@@ -83,25 +88,31 @@ module Minitest
83
88
  end
84
89
 
85
90
  def path_token(line)
91
+ style = line.to_s.include?(Dir.pwd) ? :default : :muted
86
92
  path = "#{line.path}/"
87
93
 
88
94
  # If all of the backtrace lines are from the project, no point in the added redundant
89
95
  # noise of showing the project root directory over and over again
90
96
  path = path.delete_prefix(project_root_dir) if all_backtrace_entries_from_project?
91
97
 
92
- [:muted, path]
98
+ [style, path]
93
99
  end
94
100
 
95
- def file_and_line_number_token(backtrace_entry)
96
- [:default, "#{backtrace_entry.file}:#{backtrace_entry.line_number}"]
101
+ def file_and_line_number_tokens(backtrace_entry)
102
+ style = backtrace_entry.to_s.include?(Dir.pwd) ? :bold : :muted
103
+ [
104
+ [style, backtrace_entry.file],
105
+ [:muted, ':'],
106
+ [style, backtrace_entry.line_number]
107
+ ]
97
108
  end
98
109
 
99
110
  def source_code_line_token(source_code)
100
- [:muted, " `#{source_code.line.strip}`"]
111
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_code.line.strip}`"]
101
112
  end
102
113
 
103
114
  def file_freshness(_line)
104
- [:bold, ' < Most Recently Modified']
115
+ [:default, " #{Output::SYMBOLS[:middot]} Most Recently Modified File"]
105
116
  end
106
117
 
107
118
  # The number of spaces each line of code should be indented. Currently defaults to 2 in
@@ -115,6 +126,10 @@ module Minitest
115
126
  def indentation
116
127
  DEFAULT_INDENTATION_SPACES
117
128
  end
129
+
130
+ def style_for(path)
131
+ path.to_s.include?(Dir.pwd) ? :default : :muted
132
+ end
118
133
  end
119
134
  end
120
135
  end
@@ -3,12 +3,8 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
- class Issue
7
- SHARED_SYMBOLS = {
8
- spacer: ' · ',
9
- arrow: ' > '
10
- }.freeze
11
-
6
+ # Formats issues to output based on the issue type
7
+ class Issue # rubocop:disable Metrics/ClassLength
12
8
  attr_accessor :issue
13
9
 
14
10
  def initialize(issue)
@@ -31,6 +27,7 @@ module Minitest
31
27
  def error_tokens
32
28
  [
33
29
  headline_tokens,
30
+ test_location_tokens,
34
31
  summary_tokens,
35
32
  *backtrace_tokens,
36
33
  newline_tokens
@@ -40,6 +37,7 @@ module Minitest
40
37
  def broken_tokens
41
38
  [
42
39
  headline_tokens,
40
+ test_location_tokens,
43
41
  summary_tokens,
44
42
  *backtrace_tokens,
45
43
  newline_tokens
@@ -49,9 +47,8 @@ module Minitest
49
47
  def failure_tokens
50
48
  [
51
49
  headline_tokens,
50
+ test_location_tokens,
52
51
  summary_tokens,
53
- location_tokens,
54
- *source_tokens,
55
52
  newline_tokens
56
53
  ]
57
54
  end
@@ -59,7 +56,7 @@ module Minitest
59
56
  def skipped_tokens
60
57
  [
61
58
  headline_tokens,
62
- summary_tokens,
59
+ test_location_tokens,
63
60
  newline_tokens
64
61
  ]
65
62
  end
@@ -67,7 +64,7 @@ module Minitest
67
64
  def painful_tokens
68
65
  [
69
66
  headline_tokens,
70
- slowness_tokens,
67
+ slowness_summary_tokens,
71
68
  newline_tokens
72
69
  ]
73
70
  end
@@ -75,17 +72,49 @@ module Minitest
75
72
  def slow_tokens
76
73
  [
77
74
  headline_tokens,
78
- slowness_tokens,
75
+ slowness_summary_tokens,
79
76
  newline_tokens
80
77
  ]
81
78
  end
82
79
 
83
80
  def headline_tokens
84
- [[issue.type, issue.label], [:muted, spacer], [:default, issue.test_name], [:muted, spacer], [:muted, issue.test_class]]
81
+ [[issue.type, label(issue)], spacer_token, [:default, test_name(issue)]]
85
82
  end
86
83
 
87
- def summary_tokens
88
- [[:italicized, issue.summary]]
84
+ def test_name(issue)
85
+ test_prefix = 'test_'
86
+ identifier = issue.test_identifier
87
+
88
+ if identifier.start_with?(test_prefix)
89
+ identifier.delete_prefix(test_prefix).gsub('_', ' ').capitalize
90
+ else
91
+ identifier
92
+ end
93
+ end
94
+
95
+ def label(issue)
96
+ if issue.error? && issue.in_test?
97
+ # When the exception came out of the test itself, that's a different kind of exception
98
+ # that really only indicates there's a problem with the code in the test. It's kind of
99
+ # between an error and a test.
100
+ 'Broken Test'
101
+ elsif issue.error?
102
+ 'Error'
103
+ elsif issue.skipped?
104
+ 'Skipped'
105
+ elsif issue.painful?
106
+ 'Passed but Very Slow'
107
+ elsif issue.slow?
108
+ 'Passed but Slow'
109
+ elsif !issue.passed?
110
+ 'Failure'
111
+ else
112
+ 'Success'
113
+ end
114
+ end
115
+
116
+ def test_name_and_class_tokens
117
+ [[:default, issue.test_class], *test_location_tokens]
89
118
  end
90
119
 
91
120
  def backtrace_tokens
@@ -94,33 +123,74 @@ module Minitest
94
123
  backtrace.tokens
95
124
  end
96
125
 
126
+ def test_location_tokens
127
+ [[:default, test_file_short_location], [:muted, ':'], [:default, issue.test_definition_line], arrow_token, [:default, issue.test_failure_line], [:muted, test_line_source]]
128
+ end
129
+
97
130
  def location_tokens
98
- [[:muted, issue.short_location]]
131
+ [[:default, most_relevant_short_location], [:muted, ':'], [:default, issue.location.most_relevant_failure_line], [:muted, most_relevant_line_source]]
99
132
  end
100
133
 
101
134
  def source_tokens
102
135
  filename = issue.location.project_file
103
136
  line_number = issue.location.project_failure_line
104
137
 
105
- source_code = ::Minitest::Heat::Output::SourceCode.new(filename, line_number)
138
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
139
+ [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]]
140
+ end
141
+
142
+ def summary_tokens
143
+ [[:italicized, issue.summary.delete_suffix('---------------').strip]]
144
+ end
106
145
 
107
- source_code.tokens
146
+ def slowness_summary_tokens
147
+ [
148
+ [:bold, slowness(issue)],
149
+ spacer_token,
150
+ [:default, issue.location.test_file.to_s.delete_prefix(Dir.pwd)],
151
+ [:muted, ':'],
152
+ [:default, issue.location.test_definition_line]
153
+ ]
108
154
  end
109
155
 
110
- def slowness_tokens
111
- [[:bold, issue.slowness], [:muted, spacer], [:default, issue.short_location] ]
156
+ def slowness(issue)
157
+ "#{issue.execution_time.round(2)}s"
112
158
  end
113
159
 
114
160
  def newline_tokens
115
161
  []
116
162
  end
117
163
 
118
- def spacer
119
- SHARED_SYMBOLS[:spacer]
164
+ def most_relevant_short_location
165
+ issue.location.most_relevant_file.to_s.delete_prefix("#{Dir.pwd}/")
166
+ end
167
+
168
+ def test_file_short_location
169
+ issue.location.test_file.to_s.delete_prefix("#{Dir.pwd}/")
170
+ end
171
+
172
+ def most_relevant_line_source
173
+ filename = issue.location.project_file
174
+ line_number = issue.location.project_failure_line
175
+
176
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
177
+ "\n #{source.line.strip}"
178
+ end
179
+
180
+ def test_line_source
181
+ filename = issue.location.test_file
182
+ line_number = issue.location.test_failure_line
183
+
184
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
185
+ "\n #{source.line.strip}"
186
+ end
187
+
188
+ def spacer_token
189
+ Output::TOKENS[:spacer]
120
190
  end
121
191
 
122
- def arrow
123
- SHARED_SYMBOLS[:arrow]
192
+ def arrow_token
193
+ Output::TOKENS[:muted_arrow]
124
194
  end
125
195
  end
126
196
  end
@@ -3,23 +3,25 @@
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|
16
+ map.file_hits.each do |hit|
17
+ file_tokens = pathname(hit)
18
+ line_number_tokens = line_numbers(hit)
19
+
20
+ next if line_number_tokens.empty?
21
+
20
22
  @tokens << [
21
- *pathname(file),
22
- *line_numbers(file)
23
+ *file_tokens,
24
+ *line_number_tokens
23
25
  ]
24
26
  end
25
27
 
@@ -28,8 +30,24 @@ module Minitest
28
30
 
29
31
  private
30
32
 
33
+ def map
34
+ results.heat_map
35
+ end
36
+
37
+ def relevant_issue_types
38
+ # These are always relevant.
39
+ issue_types = %i[error broken failure]
40
+
41
+ # These are only relevant if there aren't more serious isues.
42
+ issue_types << :skipped unless results.problems?
43
+ issue_types << :painful unless results.problems? || results.skips.any?
44
+ issue_types << :slow unless results.problems? || results.skips.any?
45
+
46
+ issue_types
47
+ end
48
+
31
49
  def pathname(file)
32
- directory = "#{file.pathname.dirname.to_s.delete_prefix(Dir.pwd)}/"
50
+ directory = "#{file.pathname.dirname.to_s.delete_prefix(Dir.pwd)}/".delete_prefix('/')
33
51
  filename = file.pathname.basename.to_s
34
52
 
35
53
  [
@@ -40,26 +58,31 @@ module Minitest
40
58
  end
41
59
 
42
60
  def hit_line_numbers(file, issue_type)
43
- line_numbers_for_issue_type = file.issues.fetch(issue_type) { [] }
44
-
45
- return nil if line_numbers_for_issue_type.empty?
46
-
47
61
  numbers = []
48
- line_numbers_for_issue_type.sort.map do |line_number|
62
+ line_numbers_for_issue_type = file.issues.fetch(issue_type) { [] }
63
+ line_numbers_for_issue_type.map do |line_number|
49
64
  numbers << [issue_type, "#{line_number} "]
50
65
  end
66
+
51
67
  numbers
52
68
  end
53
69
 
54
70
  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] }
71
+ line_number_tokens = []
72
+
73
+ # Merge the hits for all issue types into one list
74
+ relevant_issue_types.each do |issue_type|
75
+ line_number_tokens += hit_line_numbers(file, issue_type)
76
+ end
77
+
78
+ # Sort the collected group of line number hits so they're in order
79
+ line_number_tokens.compact.sort do |a, b|
80
+ # Ensure the line numbers are integers for sorting (otherwise '100' comes before '12')
81
+ first_line_number = Integer(a[1].strip)
82
+ second_line_number = Integer(b[1].strip)
83
+
84
+ first_line_number <=> second_line_number
85
+ end
63
86
  end
64
87
  end
65
88
  end