minitest-heat 0.0.9 → 0.0.13

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