minitest-heat 0.0.2 → 0.0.6

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,59 +2,180 @@
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
16
  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
24
+
6
25
  attr_reader :test_location, :backtrace
7
26
 
8
27
  def initialize(test_location, backtrace = [])
9
- @test_location = test_location
28
+ @test_location = TestDefinition.new(*test_location)
10
29
  @backtrace = Backtrace.new(backtrace)
11
30
  end
12
31
 
32
+ # Prints the pathname and line number of the location most likely to be the source of the
33
+ # test failure
34
+ #
35
+ # @return [String] ex. 'path/to/file.rb:12'
13
36
  def to_s
14
- "#{source_file}:#{source_failure_line}"
37
+ "#{most_relevant_file}:#{most_relevant_failure_line}"
38
+ end
39
+
40
+ def local?
41
+ broken_test? || proper_failure?
42
+ end
43
+
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.
47
+ #
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
51
+ end
52
+
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.
55
+ #
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?
60
+ end
61
+
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.
66
+ #
67
+ # @return [String] the relative path to the file from the project root
68
+ def most_relevant_file
69
+ Pathname(most_relevant_location.pathname)
70
+ end
71
+
72
+ # The line number of the `most_relevant_file` where the failure originated
73
+ #
74
+ # @return [Integer] line number
75
+ def most_relevant_failure_line
76
+ most_relevant_location.line_number
77
+ end
78
+
79
+ # The final location of the stacktrace regardless of whether it's from within the project
80
+ #
81
+ # @return [String] the relative path to the file from the project root
82
+ def final_file
83
+ Pathname(final_location.pathname)
15
84
  end
16
85
 
17
- def failure_in_test?
18
- !test_file.nil? && test_file == source_file
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
19
91
  end
20
92
 
21
- def failure_in_source?
22
- !failure_in_test?
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
23
98
  end
24
99
 
100
+ # The line number of the `project_file` where the failure originated
101
+ #
102
+ # @return [Integer] line number
103
+ def project_failure_line
104
+ broken_test? ? test_failure_line || test_definition_line : source_code_failure_line
105
+ end
106
+
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?
112
+
113
+ backtrace.final_source_code_location.pathname
114
+ end
115
+
116
+ # The line number of the `source_code_file` where the failure originated
117
+ #
118
+ # @return [Integer] line number
119
+ def source_code_failure_line
120
+ return nil unless backtrace.source_code_entries.any?
121
+
122
+ backtrace.final_source_code_location.line_number
123
+ end
124
+
125
+ # The final location from the stacktrace that is within the project's test directory
126
+ #
127
+ # @return [String, nil] the relative path to the file from the project root
25
128
  def test_file
26
- reduced_path(test_location[0])
129
+ Pathname(test_location.pathname)
27
130
  end
28
131
 
132
+ # The line number of the `test_file` where the test is defined
133
+ #
134
+ # @return [Integer] line number
29
135
  def test_definition_line
30
- test_location[1].to_s
136
+ test_location.line_number
31
137
  end
32
138
 
139
+ # The line number from within the `test_file` test definition where the failure occurred
140
+ #
141
+ # @return [Integer] line number
33
142
  def test_failure_line
34
- @backtrace.final_test_location.number
143
+ backtrace.final_test_location&.line_number || test_definition_line
35
144
  end
36
145
 
37
- def source_file
38
- return test_file if backtrace.empty?
39
-
40
- source_line = backtrace.final_project_location
41
-
42
- reduced_path("#{source_line.path}/#{source_line.file}")
146
+ # The line number from within the `test_file` test definition where the failure occurred
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
43
152
  end
44
153
 
45
- def source_failure_line
46
- return test_definition_line if backtrace.empty?
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.
158
+ #
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
166
+ end
47
167
 
48
- backtrace.final_project_location.number
168
+ def project_location
169
+ source_code_location || test_location
49
170
  end
50
171
 
51
- private
172
+ def source_code_location
173
+ backtrace.final_source_code_location
174
+ end
52
175
 
53
- def reduced_path(path)
54
- path.delete_prefix(Dir.pwd)
176
+ def backtrace?
177
+ backtrace.parsed_entries.any?
55
178
  end
56
179
  end
57
180
  end
58
181
  end
59
-
60
-
@@ -3,6 +3,8 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Map
6
+ MAXIMUM_FILES_TO_SHOW = 5
7
+
6
8
  attr_reader :hits
7
9
 
8
10
  # So we can sort hot spots by liklihood of being the most important spot to check out before
@@ -13,44 +15,32 @@ module Minitest
13
15
  # test is broken (i.e. raising an exception), that's a special sort of failure that would be
14
16
  # misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
15
17
  WEIGHTS = {
16
- error: 3, # exceptions from source code have the highest liklihood of a ripple effect
17
- broken: 1, # broken tests won't have ripple effects but can't help if they can't run
18
- failure: 1, # failures are kind of the whole point, and they could have ripple effects
19
- skipped: 0, # skips aren't failures, but they shouldn't go ignored
20
- slow: 0 # slow tests aren't failures, but they shouldn't be ignored
21
- }
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
22
25
 
23
26
  def initialize
24
27
  @hits = {}
25
28
  end
26
29
 
27
30
  def add(filename, line_number, type)
28
- @hits[filename] ||= { weight: 0, total: 0 }
29
- @hits[filename][:total] += 1
30
- @hits[filename][:weight] += WEIGHTS[type]
31
+ @hits[filename] ||= Hit.new(filename)
31
32
 
32
- @hits[filename][type] ||= []
33
- @hits[filename][type] << line_number
33
+ @hits[filename].log(type, line_number)
34
34
  end
35
35
 
36
- def files
37
- hot_files
38
- .sort_by { |filename, weight| weight }
39
- .reverse
40
- .take(5)
36
+ def file_hits
37
+ hot_files.take(MAXIMUM_FILES_TO_SHOW)
41
38
  end
42
39
 
43
40
  private
44
41
 
45
42
  def hot_files
46
- files = {}
47
- @hits.each_pair do |filename, details|
48
- # Can't really be a "hot spot" with just a single issue
49
- next unless details[:total] > 1
50
-
51
- files[filename] = details[:weight]
52
- end
53
- files
43
+ hits.values.sort_by(&:weight).reverse
54
44
  end
55
45
  end
56
46
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ # Builds the collection of tokens for a backtrace when an exception occurs
7
+ class Backtrace
8
+ DEFAULT_LINE_COUNT = 5
9
+ DEFAULT_INDENTATION_SPACES = 2
10
+
11
+ attr_accessor :location, :backtrace
12
+
13
+ def initialize(location)
14
+ @location = location
15
+ @backtrace = location.backtrace
16
+ @tokens = []
17
+ end
18
+
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
+ # 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_token(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
39
+ end
40
+
41
+ def line_count
42
+ DEFAULT_LINE_COUNT
43
+ end
44
+
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
+ project_entries
56
+ end
57
+
58
+ private
59
+
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)
70
+ end
71
+
72
+ def all_entries
73
+ backtrace.parsed_entries.take(line_count)
74
+ end
75
+
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
79
+ end
80
+
81
+ def indentation_token
82
+ [:default, ' ' * indentation]
83
+ end
84
+
85
+ def path_token(line)
86
+ path = "#{line.path}/"
87
+
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?
91
+
92
+ [:muted, path]
93
+ end
94
+
95
+ def file_and_line_number_token(backtrace_entry)
96
+ [:default, "#{backtrace_entry.file}:#{backtrace_entry.line_number}"]
97
+ end
98
+
99
+ def source_code_line_token(source_code)
100
+ [:muted, " `#{source_code.line.strip}`"]
101
+ end
102
+
103
+ def file_freshness(_line)
104
+ [:bold, ' < Most Recently Modified']
105
+ end
106
+
107
+ # The number of spaces each line of code should be indented. Currently defaults to 2 in
108
+ # order to provide visual separation between test failures, but in the future, it could
109
+ # be configurable in order to save horizontal space and create more compact output. For
110
+ # example, it could be smart based on line length and total available horizontal terminal
111
+ # space, or there could be higher-level "display" setting that could have a `:compact`
112
+ # option that would reduce the space used.
113
+ #
114
+ # @return [type] [description]
115
+ def indentation
116
+ DEFAULT_INDENTATION_SPACES
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Issue
7
+ SHARED_SYMBOLS = {
8
+ spacer: ' · ',
9
+ arrow: ' > '
10
+ }.freeze
11
+
12
+ attr_accessor :issue
13
+
14
+ def initialize(issue)
15
+ @issue = issue
16
+ end
17
+
18
+ def tokens
19
+ case issue.type
20
+ when :error then error_tokens
21
+ when :broken then broken_tokens
22
+ when :failure then failure_tokens
23
+ when :skipped then skipped_tokens
24
+ when :painful then painful_tokens
25
+ when :slow then slow_tokens
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def error_tokens
32
+ [
33
+ headline_tokens,
34
+ summary_tokens,
35
+ *backtrace_tokens,
36
+ newline_tokens
37
+ ]
38
+ end
39
+
40
+ def broken_tokens
41
+ [
42
+ headline_tokens,
43
+ summary_tokens,
44
+ *backtrace_tokens,
45
+ newline_tokens
46
+ ]
47
+ end
48
+
49
+ def failure_tokens
50
+ [
51
+ headline_tokens,
52
+ summary_tokens,
53
+ location_tokens,
54
+ *source_tokens,
55
+ newline_tokens
56
+ ]
57
+ end
58
+
59
+ def skipped_tokens
60
+ [
61
+ headline_tokens,
62
+ summary_tokens,
63
+ newline_tokens
64
+ ]
65
+ end
66
+
67
+ def painful_tokens
68
+ [
69
+ headline_tokens,
70
+ slowness_tokens,
71
+ newline_tokens
72
+ ]
73
+ end
74
+
75
+ def slow_tokens
76
+ [
77
+ headline_tokens,
78
+ slowness_tokens,
79
+ newline_tokens
80
+ ]
81
+ end
82
+
83
+ def headline_tokens
84
+ [[issue.type, issue.label], [:muted, spacer], [:default, issue.test_name], [:muted, spacer], [:muted, issue.test_class]]
85
+ end
86
+
87
+ def summary_tokens
88
+ [[:italicized, issue.summary]]
89
+ end
90
+
91
+ def backtrace_tokens
92
+ backtrace = ::Minitest::Heat::Output::Backtrace.new(issue.location)
93
+
94
+ backtrace.tokens
95
+ end
96
+
97
+ def location_tokens
98
+ [[:muted, issue.short_location]]
99
+ end
100
+
101
+ def source_tokens
102
+ filename = issue.location.project_file
103
+ line_number = issue.location.project_failure_line
104
+
105
+ source_code = ::Minitest::Heat::Output::SourceCode.new(filename, line_number)
106
+
107
+ source_code.tokens
108
+ end
109
+
110
+ def slowness_tokens
111
+ [[:bold, issue.slowness], [:muted, spacer], [:default, issue.short_location] ]
112
+ end
113
+
114
+ def newline_tokens
115
+ []
116
+ end
117
+
118
+ def spacer
119
+ SHARED_SYMBOLS[:spacer]
120
+ end
121
+
122
+ def arrow
123
+ SHARED_SYMBOLS[:arrow]
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Map
7
+ # extend Forwardable
8
+
9
+ attr_accessor :map
10
+
11
+ # def_delegators :@results, :errors, :brokens, :failures, :slows, :skips, :problems?, :slows?
12
+
13
+ def initialize(map)
14
+ @map = map
15
+ @tokens = []
16
+ end
17
+
18
+ def tokens
19
+ map.file_hits.each do |file|
20
+ @tokens << [
21
+ *pathname(file),
22
+ *line_numbers(file)
23
+ ]
24
+ end
25
+
26
+ @tokens
27
+ end
28
+
29
+ private
30
+
31
+ def pathname(file)
32
+ directory = "#{file.pathname.dirname.to_s.delete_prefix(Dir.pwd)}/"
33
+ filename = file.pathname.basename.to_s
34
+
35
+ [
36
+ [:default, directory],
37
+ [:bold, filename],
38
+ [:default, ' · ']
39
+ ]
40
+ end
41
+
42
+ def hit_line_numbers(file, issue_type)
43
+ line_numbers_for_issue_type = file.issues.fetch(issue_type) { [] }
44
+
45
+ return nil if line_numbers_for_issue_type.empty?
46
+
47
+ numbers = []
48
+ line_numbers_for_issue_type.sort.map do |line_number|
49
+ numbers << [issue_type, "#{line_number} "]
50
+ end
51
+ numbers
52
+ end
53
+
54
+ def line_numbers(file)
55
+ [
56
+ *hit_line_numbers(file, :error),
57
+ *hit_line_numbers(file, :broken),
58
+ *hit_line_numbers(file, :failure),
59
+ *hit_line_numbers(file, :skipped),
60
+ *hit_line_numbers(file, :painful),
61
+ *hit_line_numbers(file, :slow)
62
+ ].compact.sort_by { |number_token| number_token[1] }
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ # Friendly API for printing nicely-formatted output to the console
6
+ class Output
7
+ class Marker
8
+ SYMBOLS = {
9
+ success: '·',
10
+ slow: '♦',
11
+ painful: '♦',
12
+ broken: 'B',
13
+ error: 'E',
14
+ skipped: 'S',
15
+ failure: 'F'
16
+ }.freeze
17
+
18
+ STYLES = {
19
+ success: :success,
20
+ slow: :slow,
21
+ painful: :painful,
22
+ broken: :error,
23
+ error: :error,
24
+ skipped: :skipped,
25
+ failure: :failure
26
+ }.freeze
27
+
28
+ attr_accessor :issue_type
29
+
30
+ def initialize(issue_type)
31
+ @issue_type = issue_type
32
+ end
33
+
34
+ def token
35
+ [style, symbol]
36
+ end
37
+
38
+ private
39
+
40
+ def style
41
+ STYLES.fetch(issue_type, :default)
42
+ end
43
+
44
+ def symbol
45
+ SYMBOLS.fetch(issue_type, '?')
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end