minitest-heat 0.0.4 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 || backtrace.final_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,55 +3,28 @@
3
3
  module Minitest
4
4
  module Heat
5
5
  class Map
6
- attr_reader :hits
6
+ MAXIMUM_FILES_TO_SHOW = 5
7
7
 
8
- # So we can sort hot spots by liklihood of being the most important spot to check out before
9
- # trying to fix something. These are ranked based on the possibility they represent ripple
10
- # effects where fixing one problem could potentially fix multiple other failures.
11
- #
12
- # For example, if there's an exception in the file, start there. Broken code can't run. If a
13
- # test is broken (i.e. raising an exception), that's a special sort of failure that would be
14
- # misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
15
- 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
- painful: 0, # slow tests aren't failures, but they shouldn't be ignored
21
- slow: 0,
22
- }
8
+ attr_reader :hits
23
9
 
24
10
  def initialize
25
11
  @hits = {}
26
12
  end
27
13
 
28
14
  def add(filename, line_number, type)
29
- @hits[filename] ||= { weight: 0, total: 0 }
30
- @hits[filename][:total] += 1
31
- @hits[filename][:weight] += WEIGHTS[type]
15
+ @hits[filename] ||= Hit.new(filename)
32
16
 
33
- @hits[filename][type] ||= []
34
- @hits[filename][type] << line_number
17
+ @hits[filename].log(type, line_number)
35
18
  end
36
19
 
37
- def files
38
- hot_files
39
- .sort_by { |filename, weight| weight }
40
- .reverse
41
- .take(5)
20
+ def file_hits
21
+ hot_files.take(MAXIMUM_FILES_TO_SHOW)
42
22
  end
43
23
 
44
24
  private
45
25
 
46
26
  def hot_files
47
- files = {}
48
- @hits.each_pair do |filename, details|
49
- # Can't really be a "hot spot" with just a single issue
50
- next unless details[:weight] > 1
51
-
52
- files[filename] = details[:weight]
53
- end
54
- files
27
+ hits.values.sort_by(&:weight).reverse
55
28
  end
56
29
  end
57
30
  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_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
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
+ all_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_tokens(backtrace_entry)
96
+ [[:default, backtrace_entry.file], [:muted, ':'], [:default, backtrace_entry.line_number]]
97
+ end
98
+
99
+ def source_code_line_token(source_code)
100
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_code.line.strip}`"]
101
+ end
102
+
103
+ def file_freshness(_line)
104
+ [:default, " #{Output::SYMBOLS[:middot]} Most Recently Modified File"]
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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Issue
7
+ attr_accessor :issue
8
+
9
+ def initialize(issue)
10
+ @issue = issue
11
+ end
12
+
13
+ def tokens
14
+ 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
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def error_tokens
27
+ [
28
+ headline_tokens,
29
+ test_location_tokens,
30
+ location_tokens,
31
+ summary_tokens,
32
+ *backtrace_tokens,
33
+ newline_tokens
34
+ ]
35
+ end
36
+
37
+ def broken_tokens
38
+ [
39
+ headline_tokens,
40
+ test_location_tokens,
41
+ summary_tokens,
42
+ *backtrace_tokens,
43
+ newline_tokens
44
+ ]
45
+ end
46
+
47
+ def failure_tokens
48
+ [
49
+ headline_tokens,
50
+ test_location_tokens,
51
+ summary_tokens,
52
+ newline_tokens
53
+ ]
54
+ end
55
+
56
+ def skipped_tokens
57
+ [
58
+ headline_tokens,
59
+ test_location_tokens,
60
+ newline_tokens
61
+ ]
62
+ end
63
+
64
+ def painful_tokens
65
+ [
66
+ headline_tokens,
67
+ slowness_summary_tokens,
68
+ newline_tokens
69
+ ]
70
+ end
71
+
72
+ def slow_tokens
73
+ [
74
+ headline_tokens,
75
+ slowness_summary_tokens,
76
+ newline_tokens
77
+ ]
78
+ end
79
+
80
+ def headline_tokens
81
+ [[issue.type, issue.label], spacer_token, [:default, issue.test_name]]
82
+ end
83
+
84
+ def test_name_and_class_tokens
85
+ [[:default, issue.test_class], *test_location_tokens ]
86
+ end
87
+
88
+ def backtrace_tokens
89
+ backtrace = ::Minitest::Heat::Output::Backtrace.new(issue.location)
90
+
91
+ backtrace.tokens
92
+ end
93
+
94
+ def test_location_tokens
95
+ [[:default, test_file_short_location], [:muted, ':'], [:default, issue.test_definition_line], arrow_token, [:default, issue.test_failure_line], [:muted, test_line_source]]
96
+ end
97
+
98
+ def location_tokens
99
+ [[:default, most_relevant_short_location], [:muted, ':'], [:default, issue.location.most_relevant_failure_line], [:muted, most_relevant_line_source]]
100
+ end
101
+
102
+ def source_tokens
103
+ filename = issue.location.project_file
104
+ line_number = issue.location.project_failure_line
105
+
106
+ # source_code = ::Minitest::Heat::Output::SourceCode.new(filename, line_number, max_line_count: 1)
107
+ # source_code.tokens
108
+
109
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
110
+ [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]]
111
+ end
112
+
113
+ def summary_tokens
114
+ [[:italicized, issue.summary.delete_suffix("---------------")]]
115
+ end
116
+
117
+ def slowness_summary_tokens
118
+ [[:bold, issue.slowness], spacer_token, [:default, issue.short_location]]
119
+ end
120
+
121
+ def newline_tokens
122
+ []
123
+ end
124
+
125
+ def most_relevant_short_location
126
+ issue.location.most_relevant_file.to_s.delete_prefix("#{Dir.pwd}/")
127
+ end
128
+
129
+ def test_file_short_location
130
+ issue.location.test_file.to_s.delete_prefix("#{Dir.pwd}/")
131
+ end
132
+
133
+ def most_relevant_line_source
134
+ filename = issue.location.project_file
135
+ line_number = issue.location.project_failure_line
136
+
137
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
138
+ "\n #{source.line.strip}"
139
+ end
140
+
141
+ def test_line_source
142
+ filename = issue.location.test_file
143
+ line_number = issue.location.test_failure_line
144
+
145
+ source = Minitest::Heat::Source.new(filename, line_number: line_number)
146
+ "\n #{source.line.strip}"
147
+ end
148
+
149
+
150
+ # def failure_summary_tokens
151
+ # return unless issue_summary_lines.any?
152
+
153
+ # # Sometimes, the exception message is multiple lines, so this adjusts the lines to
154
+ # # visually group them together a bit
155
+ # if issue_summary_lines.one?
156
+ # [[[:italicized, issue_summary_lines.first]]]
157
+ # else
158
+ # issue_summary_lines.map do |line|
159
+ # [Output::TOKENS[:muted_lead], [:italicized, line]]
160
+ # end
161
+ # end
162
+ # end
163
+
164
+ # def issue_summary_lines
165
+ # @issue_summary_lines ||= issue.summary.split("\n")
166
+ # end
167
+
168
+ def spacer_token
169
+ Output::TOKENS[:spacer]
170
+ end
171
+
172
+ def arrow_token
173
+ Output::TOKENS[:muted_arrow]
174
+ end
175
+
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Map
7
+ attr_accessor :results
8
+
9
+ def initialize(results)
10
+ @results = results
11
+ @tokens = []
12
+ end
13
+
14
+ def tokens
15
+ map.file_hits.each do |hit|
16
+ file_tokens = pathname(hit)
17
+ line_number_tokens = line_numbers(hit)
18
+
19
+ next if line_number_tokens.empty?
20
+
21
+ @tokens << [
22
+ *file_tokens,
23
+ *line_number_tokens
24
+ ]
25
+ end
26
+
27
+ @tokens
28
+ end
29
+
30
+ private
31
+
32
+ def map
33
+ results.heat_map
34
+ end
35
+
36
+ def relevant_issue_types
37
+ issue_types = %i[error broken failure]
38
+
39
+ issue_types << :skipped unless results.problems?
40
+ issue_types << :painful unless results.problems? || results.skips.any?
41
+ issue_types << :slow unless results.problems? || results.skips.any?
42
+
43
+ issue_types
44
+ end
45
+
46
+ def pathname(file)
47
+ directory = "#{file.pathname.dirname.to_s.delete_prefix(Dir.pwd)}/"
48
+ filename = file.pathname.basename.to_s
49
+
50
+ [
51
+ [:default, directory],
52
+ [:bold, filename],
53
+ [:default, ' · ']
54
+ ]
55
+ end
56
+
57
+ def hit_line_numbers(file, issue_type)
58
+ numbers = []
59
+ line_numbers_for_issue_type = file.issues.fetch(issue_type) { [] }
60
+ line_numbers_for_issue_type.sort.map do |line_number|
61
+ numbers << [issue_type, "#{line_number} "]
62
+ end
63
+ numbers
64
+ end
65
+
66
+ def line_numbers(file)
67
+ line_number_tokens = []
68
+ relevant_issue_types.each do |issue_type|
69
+ line_number_tokens += hit_line_numbers(file, issue_type)
70
+ end
71
+ line_number_tokens.compact.sort_by { |number_token| number_token[1] }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ 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