minitest-heat 0.0.9 → 0.0.13

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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ # Convenience methods for determining the file and line number where the problem occurred.
6
+ # There are several layers of specificity to help make it easy to communicate the relative
7
+ # location of the failure:
8
+ # - 'final' represents the final line of the backtrace regardless of where it is
9
+ # - 'test_definition' represents where the test is defined
10
+ # - 'test_failure' represents the last line from the project's tests. It is further differentiated by
11
+ # the line where the test is defined and the actual line of code in the test that geneated
12
+ # the failure or exception
13
+ # - 'source_code' represents the last line from the project's source code
14
+ # - 'project' represents the last source line, but falls back to the last test line
15
+ # - 'most_relevant' represents the most specific file to investigate starting with the source
16
+ # code and then looking to the test code with final line of the backtrace as a fallback
17
+ class Locations
18
+ attr_reader :test_definition, :backtrace
19
+
20
+ def initialize(test_definition_location, backtrace = [])
21
+ test_definition_pathname, test_definition_line_number = test_definition_location
22
+ @test_definition = ::Minitest::Heat::Location.new(pathname: test_definition_pathname, line_number: test_definition_line_number)
23
+
24
+ @backtrace = Backtrace.new(backtrace)
25
+ end
26
+
27
+ # Prints the pathname and line number of the location most likely to be the source of the
28
+ # test failure
29
+ #
30
+ # @return [String] ex. 'path/to/file.rb:12'
31
+ def to_s
32
+ "#{most_relevant.absolute_filename}:#{most_relevant.line_number}"
33
+ end
34
+
35
+ # Knows if the failure is contained within the test. For example, if there's bad code in a
36
+ # test, and it raises an exception, then it's really a broken test rather than a proper
37
+ # faiure.
38
+ #
39
+ # @return [Boolean] true if final file in the backtrace is the same as the test location file
40
+ def broken_test?
41
+ !test_failure.nil? && test_failure == final
42
+ end
43
+
44
+ # Knows if the failure occurred in the actual project source code—as opposed to the test or
45
+ # an external piece of code like a gem.
46
+ #
47
+ # @return [Boolean] true if there's a non-test project file in the stacktrace but it's not
48
+ # a result of a broken test
49
+ def proper_failure?
50
+ !source_code.nil? && !broken_test?
51
+ end
52
+
53
+ # The file most likely to be the source of the underlying problem. Often, the most recent
54
+ # backtrace files will be a gem or external library that's failing indirectly as a result
55
+ # of a problem with local source code (not always, but frequently). In that case, the best
56
+ # first place to focus is on the code you control.
57
+ #
58
+ # @return [Array] file and line number of the most likely source of the problem
59
+ def most_relevant
60
+ [
61
+ source_code,
62
+ test_failure,
63
+ final
64
+ ].compact.first
65
+ end
66
+
67
+ def freshest
68
+ backtrace.recently_modified_locations.first
69
+ end
70
+
71
+ # Returns the final test location based on the backtrace if present. Otherwise falls back to
72
+ # the test location which represents the test definition. The `test_definition` attribute
73
+ # provides the location of where the test is defined. `test_failure` represents the actual
74
+ # line from within the test where the problem occurred
75
+ #
76
+ # @return [Location] the final location from the test files
77
+ def test_failure
78
+ backtrace.test_locations.any? ? backtrace.test_locations.first : test_definition
79
+ end
80
+
81
+ # Returns the final source code location based on the backtrace
82
+ #
83
+ # @return [Location] the final location from the source code files
84
+ def source_code
85
+ backtrace.source_code_locations.first
86
+ end
87
+
88
+ # Returns the final project location based on the backtrace if present. Otherwise falls back
89
+ # to the test location which represents the test definition.
90
+ #
91
+ # @return [Location] the final location from the project files
92
+ def project
93
+ backtrace.project_locations.any? ? backtrace.project_locations.first : test_definition
94
+ end
95
+
96
+ # The line number from within the `test_file` test definition where the failure occurred
97
+ #
98
+ # @return [Location] the last location from the backtrace or the test location if a backtrace
99
+ # was not passed to the initializer
100
+ def final
101
+ backtrace.locations.any? ? backtrace.locations.first : test_definition
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,7 @@
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
 
@@ -11,18 +12,29 @@ module Minitest
11
12
  @hits = {}
12
13
  end
13
14
 
14
- def add(filename, line_number, type)
15
- @hits[filename] ||= Hit.new(filename)
16
-
17
- @hits[filename].log(type, line_number)
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]
21
+ def add(pathname, line_number, type, backtrace: [])
22
+ @hits[pathname.to_s] ||= Hit.new(pathname)
23
+ @hits[pathname.to_s].log(type.to_sym, line_number, backtrace: backtrace)
18
24
  end
19
25
 
26
+ # Returns a subset of affected files to keep the list from being overwhelming
27
+ #
28
+ # @return [Array] the list of files and the line numbers for each encountered issue type
20
29
  def file_hits
21
30
  hot_files.take(MAXIMUM_FILES_TO_SHOW)
22
31
  end
23
32
 
24
33
  private
25
34
 
35
+ # Sorts the files by hit "weight" so that the most problematic files are at the beginning
36
+ #
37
+ # @return [Array] the collection of files that encountred issues
26
38
  def hot_files
27
39
  hits.values.sort_by(&:weight).reverse
28
40
  end
@@ -3,110 +3,137 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Output
6
- # Builds the collection of tokens for a backtrace when an exception occurs
6
+ # Builds the collection of tokens for displaying a backtrace when an exception occurs
7
7
  class Backtrace
8
8
  DEFAULT_LINE_COUNT = 10
9
9
  DEFAULT_INDENTATION_SPACES = 2
10
10
 
11
- attr_accessor :location, :backtrace
11
+ attr_accessor :locations, :backtrace
12
12
 
13
- def initialize(location)
14
- @location = location
15
- @backtrace = location.backtrace
13
+ def initialize(locations)
14
+ @locations = locations
15
+ @backtrace = locations.backtrace
16
16
  @tokens = []
17
17
  end
18
18
 
19
19
  def tokens
20
- # There could be option to expand and display more than one line of source code for the
21
- # final backtrace line if it might be relevant/helpful?
22
-
23
20
  # Iterate over the selected lines from the backtrace
24
- backtrace_entries.each do |backtrace_entry|
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_tokens(backtrace_entry),
30
- source_code_line_token(backtrace_entry.source_code)
31
- ]
32
-
33
- parts << file_freshness(backtrace_entry) if most_recently_modified?(backtrace_entry)
34
-
35
- @tokens << parts
36
- end
37
-
38
- @tokens
21
+ @tokens = backtrace_locations.map { |location| backtrace_location_tokens(location) }
39
22
  end
40
23
 
24
+ # Determines the number of lines to display from the backtrace.
25
+ #
26
+ # @return [Integer] the number of lines to limit the backtrace to
41
27
  def line_count
28
+ # Defined as a method instead of using the constant directlyr in order to easily support
29
+ # adding options for controlling how many lines are displayed from a backtrace.
30
+ #
31
+ # For example, instead of a fixed number, the backtrace could dynamically calculate how
32
+ # many lines it should displaye in order to get to the origination point. Or it could have
33
+ # a default, but inteligently go back further if the backtrace meets some criteria for
34
+ # displaying more lines.
42
35
  DEFAULT_LINE_COUNT
43
36
  end
44
37
 
45
- # This should probably be smart about what lines are displayed in a backtrace.
46
- # Maybe...
47
- # ...it could intelligently display the full back trace?
48
- # ...only the backtrace from the first/last line of project source?
49
- # ...it behaves a little different when it's a broken test vs. a true exception?
50
- # ...it could be smart about subtly flagging the lines that show up in the heat map frequently?
51
- # ...it could be influenced by a "compact" or "robust" reporter super-style?
52
- # ...it's smart about exceptions that were raised outside of the project?
53
- # ...it's smart about highlighting lines of code differently based on whether it's source code, test code, or external code?
54
- def backtrace_entries
55
- all_entries
38
+ # A subset of parsed lines from the backtrace.
39
+ #
40
+ # @return [Array<Location>] the backtrace locations determined to be most relevant to the
41
+ # context of the underlying issue
42
+ def backtrace_locations
43
+ # This could eventually have additional intelligence to determine what lines are most
44
+ # relevant for a given type of issue. For now, it simply takes the line numbers, but the
45
+ # idea is that long-term, it could adjust that on the fly to keep the line count as low
46
+ # as possible but expand it if necessary to ensure enough context is displayed.
47
+ #
48
+ # - If there's no clear cut details about the source of the error from within the project,
49
+ # it could display the entire backtrace without filtering anything.
50
+ # - It could scan the backtrace to the first appearance of project files and then display
51
+ # all of the lines that occurred after that instance
52
+ # - It coudl filter the lines differently whether the issue originated from a test or from
53
+ # the source code.
54
+ # - It could allow supporting a "compact" or "robust" reporter style so that someone on
55
+ # a smaller screen could easily reduce the information shown so that the results could
56
+ # be higher density even if it means truncating some occasionally useful details
57
+ # - It could be smarter about displaying context/guidance when the full backtrace is from
58
+ # outside the project's code
59
+ #
60
+ # But for now. It just grabs some lines.
61
+ backtrace.locations.take(line_count)
56
62
  end
57
63
 
58
64
  private
59
65
 
60
- def all_backtrace_entries_from_project?
61
- backtrace_entries.all? { |line| line.path.to_s.include?(project_root_dir) }
62
- end
63
-
64
- def project_root_dir
65
- Dir.pwd
66
- end
67
-
68
- def project_entries
69
- backtrace.project_entries.take(line_count)
66
+ def backtrace_location_tokens(location)
67
+ [
68
+ indentation_token,
69
+ path_token(location),
70
+ *file_and_line_number_tokens(location),
71
+ containining_element_token(location),
72
+ source_code_line_token(location),
73
+ most_recently_modified_token(location),
74
+ ].compact
70
75
  end
71
76
 
72
- def all_entries
73
- backtrace.parsed_entries.take(line_count)
77
+ # Determines if all lines to be displayed are from within the project directory
78
+ #
79
+ # @return [Boolean] true if all lines of the backtrace being displayed are from the project
80
+ def all_backtrace_from_project?
81
+ backtrace_locations.all?(&:project_file?)
74
82
  end
75
83
 
76
- def most_recently_modified?(line)
77
- # If there's more than one line being displayed, and the current line is the freshest
78
- backtrace_entries.size > 1 && line == backtrace.freshest_project_location
84
+ # Determines if the file referenced by a backtrace line is the most recently modified file
85
+ # of all the files referenced in the visible backtrace locations.
86
+ #
87
+ # @param [Location] location the location to examine
88
+ #
89
+ # @return [<type>] <description>
90
+ #
91
+ def most_recently_modified?(location)
92
+ # If there's more than one line being displayed (otherwise, with one line, of course it's
93
+ # the most recently modified because there_aren't any others) and the current line is the
94
+ # same as the freshest location in the backtrace
95
+ backtrace_locations.size > 1 && location == locations.freshest
79
96
  end
80
97
 
81
98
  def indentation_token
82
99
  [:default, ' ' * indentation]
83
100
  end
84
101
 
85
- def path_token(line)
86
- style = line.to_s.include?(Dir.pwd) ? :default : :muted
87
- path = "#{line.path}/"
102
+ def path_token(location)
103
+ # If the line is a project file, help it stand out from the backtrace noise
104
+ style = location.project_file? ? :default : :muted
88
105
 
89
- # If all of the backtrace lines are from the project, no point in the added redundant
90
- # noise of showing the project root directory over and over again
91
- path = path.delete_prefix(project_root_dir) if all_backtrace_entries_from_project?
106
+ # If *all* of the backtrace lines are from the project, no point in the added redundant
107
+ # noise of showing the project root directory over and over again
108
+ path_format = all_backtrace_from_project? ? :relative_path : :absolute_path
92
109
 
93
- [style, path]
110
+ [style, location.send(path_format)]
94
111
  end
95
112
 
96
- def file_and_line_number_tokens(backtrace_entry)
97
- style = backtrace_entry.to_s.include?(Dir.pwd) ? :bold : :muted
113
+ def file_and_line_number_tokens(location)
114
+ style = location.to_s.include?(Dir.pwd) ? :bold : :muted
98
115
  [
99
- [style, backtrace_entry.file],
116
+ [style, location.filename],
100
117
  [:muted, ':'],
101
- [style, backtrace_entry.line_number]
118
+ [style, location.line_number]
102
119
  ]
103
120
  end
104
121
 
105
- def source_code_line_token(source_code)
106
- [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_code.line.strip}`"]
122
+ def source_code_line_token(location)
123
+ return nil unless location.project_file?
124
+
125
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"]
107
126
  end
108
127
 
109
- def file_freshness(_line)
128
+ def containining_element_token(location)
129
+ return nil if !location.project_file? || location.container.nil? || location.container.empty?
130
+
131
+ [:muted, " in #{location.container}"]
132
+ end
133
+
134
+ def most_recently_modified_token(location)
135
+ return nil unless most_recently_modified?(location)
136
+
110
137
  [:default, " #{Output::SYMBOLS[:middot]} Most Recently Modified File"]
111
138
  end
112
139
 
@@ -121,10 +148,6 @@ module Minitest
121
148
  def indentation
122
149
  DEFAULT_INDENTATION_SPACES
123
150
  end
124
-
125
- def style_for(path)
126
- style = path.to_s.include?(Dir.pwd) ? :default : :muted
127
- end
128
151
  end
129
152
  end
130
153
  end
@@ -3,37 +3,27 @@
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
14
16
  case issue.type
15
- when :error then error_tokens
16
- when :broken then broken_tokens
17
- when :failure then failure_tokens
18
- when :skipped then skipped_tokens
19
- when :painful then painful_tokens
20
- when :slow then slow_tokens
17
+ when :error, :broken then exception_tokens
18
+ when :failure then failure_tokens
19
+ when :skipped then skipped_tokens
20
+ when :painful, :slow then slow_tokens
21
21
  end
22
22
  end
23
23
 
24
24
  private
25
25
 
26
- def error_tokens
27
- [
28
- headline_tokens,
29
- test_location_tokens,
30
- summary_tokens,
31
- *backtrace_tokens,
32
- newline_tokens
33
- ]
34
- end
35
-
36
- def broken_tokens
26
+ def exception_tokens
37
27
  [
38
28
  headline_tokens,
39
29
  test_location_tokens,
@@ -60,14 +50,6 @@ module Minitest
60
50
  ]
61
51
  end
62
52
 
63
- def painful_tokens
64
- [
65
- headline_tokens,
66
- slowness_summary_tokens,
67
- newline_tokens
68
- ]
69
- end
70
-
71
53
  def slow_tokens
72
54
  [
73
55
  headline_tokens,
@@ -77,75 +59,82 @@ module Minitest
77
59
  end
78
60
 
79
61
  def headline_tokens
80
- [[issue.type, issue.label], spacer_token, [:default, issue.test_name]]
62
+ [label_token(issue), spacer_token, [:default, test_name(issue)]]
63
+ end
64
+
65
+ # Creates a display-friendly version of the test name with underscores removed and the
66
+ # first letter capitalized regardless of the formatt used for the test definition
67
+ # @param issue [Issue] the issue to use to generate the test name
68
+ #
69
+ # @return [String] the cleaned up version of the test name
70
+ def test_name(issue)
71
+ test_prefix = 'test_'
72
+ identifier = issue.test_identifier
73
+
74
+ if identifier.start_with?(test_prefix)
75
+ identifier.delete_prefix(test_prefix).gsub('_', ' ').capitalize
76
+ else
77
+ identifier
78
+ end
81
79
  end
82
80
 
83
- def test_name_and_class_tokens
84
- [[:default, issue.test_class], *test_location_tokens ]
81
+ def label_token(issue)
82
+ [issue.type, issue_label(issue.type)]
85
83
  end
86
84
 
87
- def backtrace_tokens
88
- backtrace = ::Minitest::Heat::Output::Backtrace.new(issue.location)
89
-
90
- backtrace.tokens
85
+ def test_name_and_class_tokens
86
+ [[:default, issue.test_class], *test_location_tokens]
91
87
  end
92
88
 
93
89
  def test_location_tokens
94
- [[:default, test_file_short_location], [:muted, ':'], [:default, issue.test_definition_line], arrow_token, [:default, issue.test_failure_line], [:muted, test_line_source]]
90
+ [
91
+ [:default, locations.test_definition.relative_filename],
92
+ [:muted, ':'],
93
+ [:default, locations.test_definition.line_number],
94
+ arrow_token,
95
+ [:default, locations.test_failure.line_number],
96
+ [:muted, "\n #{locations.test_failure.source_code.line.strip}"]
97
+ ]
95
98
  end
96
99
 
97
100
  def location_tokens
98
- [[:default, most_relevant_short_location], [:muted, ':'], [:default, issue.location.most_relevant_failure_line], [:muted, most_relevant_line_source]]
101
+ [
102
+ [:default, locations.most_relevant.relative_filename],
103
+ [:muted, ':'],
104
+ [:default, locations.most_relevant.line_number],
105
+ [:muted, "\n #{locations.most_relevant.source_code.line.strip}"]
106
+ ]
99
107
  end
100
108
 
101
109
  def source_tokens
102
- filename = issue.location.project_file
103
- line_number = issue.location.project_failure_line
104
-
110
+ filename = locations.project.filename
111
+ line_number = locations.project.line_number
105
112
  source = Minitest::Heat::Source.new(filename, line_number: line_number)
113
+
106
114
  [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]]
107
115
  end
108
116
 
109
117
  def summary_tokens
110
- [[:italicized, issue.summary.delete_suffix("---------------")]]
118
+ [[:italicized, issue.summary.delete_suffix('---------------').strip]]
111
119
  end
112
120
 
113
121
  def slowness_summary_tokens
114
122
  [
115
- [:bold, issue.slowness],
123
+ [:bold, slowness(issue)],
116
124
  spacer_token,
117
- [:default, issue.location.test_file.to_s.delete_prefix(Dir.pwd)],
125
+ [:default, locations.test_definition.relative_path],
126
+ [:default, locations.test_definition.filename],
118
127
  [:muted, ':'],
119
- [:default, issue.location.test_definition_line]
128
+ [:default, locations.test_definition.line_number]
120
129
  ]
121
130
  end
122
131
 
123
- def newline_tokens
124
- []
125
- end
126
-
127
- def most_relevant_short_location
128
- issue.location.most_relevant_file.to_s.delete_prefix("#{Dir.pwd}/")
129
- end
130
-
131
- def test_file_short_location
132
- issue.location.test_file.to_s.delete_prefix("#{Dir.pwd}/")
132
+ def slowness(issue)
133
+ "#{issue.execution_time.round(2)}s"
133
134
  end
134
135
 
135
- def most_relevant_line_source
136
- filename = issue.location.project_file
137
- line_number = issue.location.project_failure_line
138
-
139
- source = Minitest::Heat::Source.new(filename, line_number: line_number)
140
- "\n #{source.line.strip}"
141
- end
142
-
143
- def test_line_source
144
- filename = issue.location.test_file
145
- line_number = issue.location.test_failure_line
146
-
147
- source = Minitest::Heat::Source.new(filename, line_number: line_number)
148
- "\n #{source.line.strip}"
136
+ def newline_tokens
137
+ []
149
138
  end
150
139
 
151
140
  def spacer_token
@@ -156,6 +145,26 @@ module Minitest
156
145
  Output::TOKENS[:muted_arrow]
157
146
  end
158
147
 
148
+ def backtrace_tokens
149
+ @backtrace_tokens ||= ::Minitest::Heat::Output::Backtrace.new(locations).tokens
150
+ end
151
+
152
+ # The string to use to describe the failure type when displaying results/
153
+ # @param issue_type [Symbol] the symbol representing the issue's failure type
154
+ #
155
+ # @return [String] the display-friendly string describing the failure reason
156
+ def issue_label(issue_type)
157
+ case issue_type
158
+ when :error then 'Error'
159
+ when :broken then 'Broken Test'
160
+ when :failure then 'Failure'
161
+ when :skipped then 'Skipped'
162
+ when :slow then 'Passed but Slow'
163
+ when :painful then 'Passed but Very Slow'
164
+ when :passed then 'Success'
165
+ else 'Unknown'
166
+ end
167
+ end
159
168
  end
160
169
  end
161
170
  end