minitest-heat 0.0.2 → 0.0.6

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