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.
@@ -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,43 +2,39 @@
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
 
30
- def add(filename, line_number, type)
31
- @hits[filename] ||= Hit.new(filename)
32
-
33
- @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)
34
24
  end
35
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
36
29
  def file_hits
37
30
  hot_files.take(MAXIMUM_FILES_TO_SHOW)
38
31
  end
39
32
 
40
33
  private
41
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
42
38
  def hot_files
43
39
  hits.values.sort_by(&:weight).reverse
44
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