minitest-heat 0.0.3 → 0.0.7

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
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_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,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