minitest-heat 0.0.8 → 0.0.12

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.
@@ -2,179 +2,178 @@
2
2
 
3
3
  module Minitest
4
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' represents the last line from the project's tests. It is further differentiated by
10
- # the line where the test is defined and the actual line of code in the test that geneated
11
- # the failure or exception
12
- # - 'source_code' represents the last line from the project's source code
13
- # - 'project' represents the last source line, but falls back to the last test line
14
- # - 'most_relevant' represents the most specific file to investigate starting with the source
15
- # code and then looking to the test code with final line of the backtrace as a fallback
5
+ # Consistent structure for extracting information about a given location. In addition to the
6
+ # pathname to the file and the line number in the file, it can also include information about
7
+ # the containing method or block and retrieve source code for the location.
16
8
  class Location
17
- TestDefinition = Struct.new(:pathname, :line_number) do
18
- def initialize(pathname, line_number)
19
- @pathname = Pathname(pathname)
20
- @line_number = Integer(line_number)
21
- super
22
- end
23
- end
9
+ UNRECOGNIZED = '(Unrecognized File)'
10
+ UNKNOWN_MODIFICATION_TIME = Time.at(0)
11
+ UNKNOWN_MODIFICATION_SECONDS = -1
24
12
 
25
- attr_reader :test_location, :backtrace
13
+ attr_accessor :raw_pathname, :raw_line_number, :raw_container
26
14
 
27
- def initialize(test_location, backtrace = [])
28
- @test_location = TestDefinition.new(*test_location)
29
- @backtrace = Backtrace.new(backtrace)
15
+ # Initialize a new Location
16
+ #
17
+ # @param [Pathname, String] pathname: the pathname to the file
18
+ # @param [Integer] line_number: the line number of the location within the file
19
+ # @param [String] container: nil the containing method or block for the issue
20
+ #
21
+ # @return [self]
22
+ def initialize(pathname:, line_number:, container: nil)
23
+ @raw_pathname = pathname
24
+ @raw_line_number = line_number
25
+ @raw_container = container
30
26
  end
31
27
 
32
- # Prints the pathname and line number of the location most likely to be the source of the
33
- # test failure
28
+ # Generates a formatted string describing the line of code similar to the original backtrace
34
29
  #
35
- # @return [String] ex. 'path/to/file.rb:12'
30
+ # @return [String] a consistently-formatted, human-readable string about the line of code
36
31
  def to_s
37
- "#{most_relevant_file}:#{most_relevant_failure_line}"
38
- end
39
-
40
- def local?
41
- broken_test? || proper_failure?
32
+ "#{absolute_path}#{filename}:#{line_number} in `#{container}`"
42
33
  end
43
34
 
44
- # Knows if the failure is contained within the test. For example, if there's bad code in a
45
- # test, and it raises an exception, then it's really a broken test rather than a proper
46
- # faiure.
35
+ # Generates a simplified location array with the pathname and line number
47
36
  #
48
- # @return [Boolean] true if most relevant file is the same as the test location file
49
- def broken_test?
50
- !test_file.nil? && test_file == most_relevant_file
37
+ # @return [Array<Pathname, Integer>] a no-frills location pair
38
+ def to_a
39
+ [
40
+ pathname,
41
+ line_number
42
+ ]
51
43
  end
52
44
 
53
- # Knows if the failure occurred in the actual project source code—as opposed to the test or
54
- # an external piece of code like a gem.
45
+ # A short relative pathname and line number pair
55
46
  #
56
- # @return [Boolean] true if there's a non-test project file in the stacktrace but it's not
57
- # a result of a broken test
58
- def proper_failure?
59
- !source_code_file.nil? && !broken_test?
47
+ # @return [String] the short filename/line number combo. ex. `dir/file.rb:23`
48
+ def short
49
+ "#{relative_filename}:#{line_number}"
60
50
  end
61
51
 
62
- # The file most likely to be the source of the underlying problem. Often, the most recent
63
- # backtrace files will be a gem or external library that's failing indirectly as a result
64
- # of a problem with local source code (not always, but frequently). In that case, the best
65
- # first place to focus is on the code you control.
52
+ # Determine if there is a file and text at the given line number
66
53
  #
67
- # @return [String] the relative path to the file from the project root
68
- def most_relevant_file
69
- Pathname(most_relevant_location.pathname)
54
+ # @return [Boolean] true if the file exists and has text at the given line number
55
+ def exists?
56
+ pathname.exist? && source_code.lines.any?
70
57
  end
71
58
 
72
- # The line number of the `most_relevant_file` where the failure originated
59
+ # The pathanme for the location. Written to be safe and fallbackto the project directory if
60
+ # an exception is raised ocnverting the value to a pathname
73
61
  #
74
- # @return [Integer] line number
75
- def most_relevant_failure_line
76
- most_relevant_location.line_number
62
+ # @return [Pathname] a pathname instance for the relevant file
63
+ def pathname
64
+ Pathname(raw_pathname)
65
+ rescue ArgumentError
66
+ Pathname(Dir.pwd)
77
67
  end
78
68
 
79
- # The final location of the stacktrace regardless of whether it's from within the project
69
+ # A safe interface to getting a string representing the path portion of the file
80
70
  #
81
- # @return [String] the relative path to the file from the project root
82
- def final_file
83
- Pathname(final_location.pathname)
71
+ # @return [String] either the path/directory portion of the file name or '(Unrecognized File)'
72
+ # if the offending file can't be found for some reason
73
+ def path
74
+ pathname.exist? ? pathname.dirname.to_s : UNRECOGNIZED
84
75
  end
85
76
 
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
77
+ def absolute_path
78
+ pathname.exist? ? "#{path}/" : UNRECOGNIZED
91
79
  end
92
80
 
93
- # The final location of the stacktrace regardless of whether it's from within the project
94
- #
95
- # @return [String] the relative path to the file from the project root
96
- def project_file
97
- broken_test? ? test_file : source_code_file
81
+ def relative_path
82
+ pathname.exist? ? absolute_path.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED
98
83
  end
99
84
 
100
- # The line number of the `project_file` where the failure originated
85
+ # A safe interface for getting a string representing the filename portion of the file
101
86
  #
102
- # @return [Integer] line number
103
- def project_failure_line
104
- broken_test? ? test_failure_line || test_definition_line : source_code_failure_line
87
+ # @return [String] either the filename portion of the file or '(Unrecognized File)'
88
+ # if the offending file can't be found for some reason
89
+ def filename
90
+ pathname.exist? ? pathname.basename.to_s : UNRECOGNIZED
105
91
  end
106
92
 
107
- # The final location from the stacktrace that is within the project directory
108
- #
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?
93
+ def absolute_filename
94
+ pathname.exist? ? pathname.to_s : UNRECOGNIZED
95
+ end
112
96
 
113
- backtrace.final_source_code_location.pathname
97
+ def relative_filename
98
+ pathname.exist? ? pathname.to_s.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED
114
99
  end
115
100
 
116
- # The line number of the `source_code_file` where the failure originated
101
+ # Line number identifying the specific line in the file
102
+ #
103
+ # @return [Integer] line number for the file
117
104
  #
118
- # @return [Integer] line number
119
- def source_code_failure_line
120
- return nil unless backtrace.source_code_entries.any?
105
+ def line_number
106
+ Integer(raw_line_number)
107
+ rescue ArgumentError
108
+ 1
109
+ end
121
110
 
122
- backtrace.final_source_code_location.line_number
111
+ # The containing method or block details for the location
112
+ #
113
+ # @return [String] the containing method of the line of code
114
+ def container
115
+ raw_container.nil? ? '(Unknown Container)' : String(raw_container)
123
116
  end
124
117
 
125
- # The final location from the stacktrace that is within the project's test directory
118
+ # Looks up the source code for the location. Can return multiple lines of source code from
119
+ # the surrounding lines of code for the primary line
126
120
  #
127
- # @return [String, nil] the relative path to the file from the project root
128
- def test_file
129
- Pathname(test_location.pathname)
121
+ # @param [Integer] max_line_count: 1 the maximum number of lines to return from the source
122
+ #
123
+ # @return [Source] an instance of Source for accessing lines and their line numbers
124
+ def source_code(max_line_count: 1)
125
+ Minitest::Heat::Source.new(
126
+ pathname.to_s,
127
+ line_number: line_number,
128
+ max_line_count: max_line_count
129
+ )
130
130
  end
131
131
 
132
- # The line number of the `test_file` where the test is defined
132
+ # Determines if a given file is from the project directory
133
133
  #
134
- # @return [Integer] line number
135
- def test_definition_line
136
- test_location.line_number
134
+ # @return [Boolean] true if the file is in the project (source code or test)
135
+ def project_file?
136
+ path.include?(project_root_dir)
137
137
  end
138
138
 
139
- # The line number from within the `test_file` test definition where the failure occurred
139
+ # Determines if a given file follows the standard approaching to naming test files.
140
140
  #
141
- # @return [Integer] line number
142
- def test_failure_line
143
- backtrace.final_test_location&.line_number || test_definition_line
141
+ # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb`
142
+ def test_file?
143
+ filename.to_s.start_with?('test_') || filename.to_s.end_with?('_test.rb')
144
144
  end
145
145
 
146
- # The line number from within the `test_file` test definition where the failure occurred
146
+ # Determines if a given file is a non-test file from the project directory
147
147
  #
148
- # @return [Location] the last location from the backtrace or the test location if a backtrace
149
- # was not passed to the initializer
150
- def final_location
151
- backtrace? ? backtrace.final_location : test_location
148
+ # @return [Boolean] true if the file is in the project but not a test file
149
+ def source_code_file?
150
+ project_file? && !test_file?
152
151
  end
153
152
 
154
- # The file most likely to be the source of the underlying problem. Often, the most recent
155
- # backtrace files will be a gem or external library that's failing indirectly as a result
156
- # of a problem with local source code (not always, but frequently). In that case, the best
157
- # first place to focus is on the code you control.
153
+ # A safe interface to getting the last modified time for the file in question
158
154
  #
159
- # @return [Array] file and line number of the most likely source of the problem
160
- def most_relevant_location
161
- [
162
- source_code_location,
163
- test_location,
164
- final_location
165
- ].compact.first
155
+ # @return [Time] the timestamp for when the file in question was last modified or `Time.at(0)`
156
+ # if the offending file can't be found for some reason
157
+ def mtime
158
+ pathname.exist? ? pathname.mtime : UNKNOWN_MODIFICATION_TIME
166
159
  end
167
160
 
168
- def project_location
169
- source_code_location || test_location
161
+ # A safe interface to getting the number of seconds since the file was modified
162
+ #
163
+ # @return [Integer] the number of seconds since the file was modified or `-1` if the offending
164
+ # file can't be found for some reason
165
+ def age_in_seconds
166
+ pathname.exist? ? seconds_ago : UNKNOWN_MODIFICATION_SECONDS
170
167
  end
171
168
 
172
- def source_code_location
173
- backtrace.final_source_code_location
169
+ private
170
+
171
+ def project_root_dir
172
+ Dir.pwd
174
173
  end
175
174
 
176
- def backtrace?
177
- backtrace.parsed_entries.any?
175
+ def seconds_ago
176
+ (Time.now - mtime).to_i
178
177
  end
179
178
  end
180
179
  end
@@ -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
@@ -5,14 +5,14 @@ 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
- 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
 
@@ -21,18 +21,8 @@ module Minitest
21
21
  # final backtrace line if it might be relevant/helpful?
22
22
 
23
23
  # 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
24
+ backtrace_locations.each do |location|
25
+ @tokens << backtrace_location_tokens(location)
36
26
  end
37
27
 
38
28
  @tokens
@@ -51,56 +41,74 @@ module Minitest
51
41
  # ...it could be influenced by a "compact" or "robust" reporter super-style?
52
42
  # ...it's smart about exceptions that were raised outside of the project?
53
43
  # ...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
44
+ def backtrace_locations
45
+ backtrace.locations.take(line_count)
56
46
  end
57
47
 
58
48
  private
59
49
 
60
- def all_backtrace_entries_from_project?
61
- backtrace_entries.all? { |line| line.path.to_s.include?(project_root_dir) }
50
+ def backtrace_location_tokens(location)
51
+ [
52
+ indentation_token,
53
+ path_token(location),
54
+ *file_and_line_number_tokens(location),
55
+ containining_element_token(location),
56
+ source_code_line_token(location),
57
+ most_recently_modified_token(location),
58
+ ].compact
62
59
  end
63
60
 
64
- def project_root_dir
65
- Dir.pwd
66
- end
67
-
68
- def project_entries
69
- backtrace.project_entries.take(line_count)
70
- end
71
-
72
- def all_entries
73
- backtrace.parsed_entries.take(line_count)
61
+ # Determines if all lines to be displayed are from within the project directory
62
+ #
63
+ # @return [Boolean] true if all lines of the backtrace being displayed are from the project
64
+ def all_backtrace_from_project?
65
+ backtrace_locations.all?(&:project_file?)
74
66
  end
75
67
 
76
- def most_recently_modified?(line)
68
+ def most_recently_modified?(location)
77
69
  # 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
70
+ backtrace_locations.size > 1 && location == locations.freshest
79
71
  end
80
72
 
81
73
  def indentation_token
82
74
  [:default, ' ' * indentation]
83
75
  end
84
76
 
85
- def path_token(line)
86
- path = "#{line.path}/"
77
+ def path_token(location)
78
+ # If the line is a project file, help it stand out from the backtrace noise
79
+ style = location.project_file? ? :default : :muted
87
80
 
88
- # If all of the backtrace lines are from the project, no point in the added redundant
89
- # noise of showing the project root directory over and over again
90
- path = path.delete_prefix(project_root_dir) if all_backtrace_entries_from_project?
81
+ # If *all* of the backtrace lines are from the project, no point in the added redundant
82
+ # noise of showing the project root directory over and over again
83
+ path_format = all_backtrace_from_project? ? :relative_path : :absolute_path
91
84
 
92
- [:muted, path]
85
+ [style, location.send(path_format)]
93
86
  end
94
87
 
95
- def file_and_line_number_tokens(backtrace_entry)
96
- [[:default, backtrace_entry.file], [:muted, ':'], [:default, backtrace_entry.line_number]]
88
+ def file_and_line_number_tokens(location)
89
+ style = location.to_s.include?(Dir.pwd) ? :bold : :muted
90
+ [
91
+ [style, location.filename],
92
+ [:muted, ':'],
93
+ [style, location.line_number]
94
+ ]
97
95
  end
98
96
 
99
- def source_code_line_token(source_code)
100
- [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_code.line.strip}`"]
97
+ def source_code_line_token(location)
98
+ return nil unless location.project_file?
99
+
100
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"]
101
101
  end
102
102
 
103
- def file_freshness(_line)
103
+ def containining_element_token(location)
104
+ return nil if !location.project_file? || location.container.nil? || location.container.empty?
105
+
106
+ [:muted, " in #{location.container}"]
107
+ end
108
+
109
+ def most_recently_modified_token(location)
110
+ return nil unless most_recently_modified?(location)
111
+
104
112
  [:default, " #{Output::SYMBOLS[:middot]} Most Recently Modified File"]
105
113
  end
106
114