minitest-heat 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbfeed7b313b60332751ad13c01750b56452e6cac80f98346d4f7233ae807cef
4
- data.tar.gz: 7a734d467343b7da882c8a5f62e1005664ea9b2ab889d766416d8f21fce2299b
3
+ metadata.gz: c760c15a45811d5773dee18e32aadc063ed61c3eb04447c637719d5d3d46178a
4
+ data.tar.gz: 90c345c56cbd2589719eef8a0264a5973f77a8104c91149f4e4ad2320067a49a
5
5
  SHA512:
6
- metadata.gz: '07468b2090eef7a6475382db46e9bc150d2fc30e54d54d6744c0c12d6c33364f6d39aff6938baf4647c0e64fe7c754d0c1ad8b42aebf7b87e8edce8a1c35542f'
7
- data.tar.gz: f1f961dc9bcf258d09fec7184dc0cb778d5b9fbc865ad2ed2c9d2f68628c4329990febc45b41770582826ab26576236c41657ced44c0ddb494b1b7fb71403c38
6
+ metadata.gz: 47be296804a2171023bfcdb5c4192376698349bd76c4c3a2991d9accd9f631b88d2ffb867d821f2479c41bd6fd2cf919af325ecde975f98ee4592277b3b09432
7
+ data.tar.gz: 79a180260e9a1d8c55d898883f13969f2576ceb6450dc9e080050e55674c73fc7979202a2c8a78542958491fce91fee2a3cb1b66fd2705edc4a8d432f5e28fcc
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- minitest-heat (0.0.4)
4
+ minitest-heat (0.0.5)
5
5
  minitest
6
6
 
7
7
  GEM
@@ -6,11 +6,23 @@ module Minitest
6
6
  class Backtrace
7
7
  Line = Struct.new(:path, :file, :number, :container, :mtime, keyword_init: true) do
8
8
  def to_s
9
- "#{path}/#{file}:#{line} in `#{container}` #{age_in_seconds}"
9
+ "#{location} in `#{container}`"
10
10
  end
11
11
 
12
- def to_file
13
- "#{path}/#{file}"
12
+ def pathname
13
+ Pathname("#{path}/#{file}")
14
+ end
15
+
16
+ def location
17
+ "#{pathname.to_s}:#{number}"
18
+ end
19
+
20
+ def short_pathname
21
+ pathname.delete_prefix(Dir.pwd)
22
+ end
23
+
24
+ def short_location
25
+ "#{pathname.basename.to_s}:#{number}"
14
26
  end
15
27
 
16
28
  def age_in_seconds
@@ -18,6 +30,16 @@ module Minitest
18
30
  end
19
31
  end
20
32
 
33
+ UNREADABLE_FILE_ATTRIBUTES = {
34
+ path: '(Unknown Path)',
35
+ file: '(Unknown File)',
36
+ number: '(Unknown Line Number)',
37
+ container: '(Unknown Method)',
38
+ mtime: '(Unknown Modification Time)'
39
+ }
40
+
41
+ UNREADABLE_LINE = Line.new(UNREADABLE_FILE_ATTRIBUTES)
42
+
21
43
  attr_reader :raw_backtrace
22
44
 
23
45
  def initialize(raw_backtrace)
@@ -29,37 +51,45 @@ module Minitest
29
51
  end
30
52
 
31
53
  def final_location
32
- parsed.first
54
+ parsed_lines.first
33
55
  end
34
56
 
35
57
  def final_project_location
36
- project.first
58
+ project_lines.first
59
+ end
60
+
61
+ def freshest_project_location
62
+ recently_modified_lines.first
63
+ end
64
+
65
+ def final_source_code_location
66
+ source_code_lines.first
37
67
  end
38
68
 
39
69
  def final_test_location
40
- tests.first
70
+ test_lines.first
41
71
  end
42
72
 
43
- def freshest_project_location
44
- recently_modified.first
73
+ def project_lines
74
+ @project_lines ||= parsed_lines.select { |line| line[:path].include?(Dir.pwd) }
45
75
  end
46
76
 
47
- def project
48
- @project ||= parsed.select { |line| line[:path].include?(Dir.pwd) }
77
+ def recently_modified_lines
78
+ @recently_modified_lines ||= project_lines.sort_by { |line| line[:mtime] }.reverse
49
79
  end
50
80
 
51
- def tests
52
- @tests ||= project.select { |line| test_file?(line) }
81
+ def test_lines
82
+ @tests_lines ||= project_lines.select { |line| test_file?(line) }
53
83
  end
54
84
 
55
- def recently_modified
56
- @recently_modified ||= project.sort_by { |line| line[:mtime] }.reverse
85
+ def source_code_lines
86
+ @source_code_lines ||= project_lines - test_lines
57
87
  end
58
88
 
59
- def parsed
89
+ def parsed_lines
60
90
  return [] if raw_backtrace.nil?
61
91
 
62
- @parsed ||= raw_backtrace.map { |line| parse(line) }
92
+ @parsed_lines ||= raw_backtrace.map { |line| parse(line) }
63
93
  end
64
94
 
65
95
  private
@@ -83,6 +113,8 @@ module Minitest
83
113
  container: reduce_container(parts[2]),
84
114
  mtime: pathname.exist? ? pathname.mtime : nil
85
115
  }
116
+ rescue
117
+ UNREADABLE_FILE_ATTRIBUTES
86
118
  end
87
119
 
88
120
  def test_file?(line)
@@ -40,10 +40,14 @@ module Minitest
40
40
  @location = Location.new(result.source_location, @failure&.backtrace)
41
41
  end
42
42
 
43
+ def short_location
44
+ location.to_s.delete_prefix(Dir.pwd)
45
+ end
46
+
43
47
  def to_hit
44
48
  [
45
- location.source_file,
46
- location.source_failure_line,
49
+ location.project_file.to_s,
50
+ location.project_failure_line,
47
51
  type
48
52
  ]
49
53
  end
@@ -87,15 +91,15 @@ module Minitest
87
91
  end
88
92
 
89
93
  def in_test?
90
- location.failure_in_test?
94
+ location.broken_test?
91
95
  end
92
96
 
93
97
  def in_source?
94
- location.failure_in_source?
98
+ location.proper_failure?
95
99
  end
96
100
 
97
101
  def test_class
98
- result.klass.delete_prefix('Minitest::')
102
+ result.klass
99
103
  end
100
104
 
101
105
  def test_identifier
@@ -2,6 +2,17 @@
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
6
17
  attr_reader :test_location, :backtrace
7
18
 
@@ -10,48 +21,152 @@ module Minitest
10
21
  @backtrace = Backtrace.new(backtrace)
11
22
  end
12
23
 
24
+ # Prints the pathname and line number of the location most likely to be the source of the
25
+ # test failure
26
+ #
27
+ # @return [String] ex. 'path/to/file.rb:12'
13
28
  def to_s
14
- "#{source_file}:#{source_failure_line}"
29
+ "#{most_relevant_file}:#{most_relevant_failure_line}"
15
30
  end
16
31
 
17
- def failure_in_test?
18
- !test_file.nil? && test_file == source_file
32
+ def local?
33
+ broken_test? || proper_failure?
19
34
  end
20
35
 
21
- def failure_in_source?
22
- !failure_in_test?
36
+ # Knows if the failure is contained within the test. For example, if there's bad code in a
37
+ # test, and it raises an exception, then it's really a broken test rather than a proper
38
+ # faiure.
39
+ #
40
+ # @return [Boolean] true if most relevant file is the same as the test location file
41
+ def broken_test?
42
+ !test_file.nil? && test_file == most_relevant_file
23
43
  end
24
44
 
45
+ # Knows if the failure occurred in the actual project source code—as opposed to the test or
46
+ # an external piece of code like a gem.
47
+ #
48
+ # @return [Boolean] true if there's a non-test project file in the stacktrace but it's not
49
+ # a result of a broken test
50
+ def proper_failure?
51
+ !source_code_file.nil? && !broken_test?
52
+ end
53
+
54
+ # The file most likely to be the source of the underlying problem. Often, the most recent
55
+ # backtrace files will be a gem or external library that's failing indirectly as a result
56
+ # of a problem with local source code (not always, but frequently). In that case, the best
57
+ # first place to focus is on the code you control.
58
+ #
59
+ # @return [String] the relative path to the file from the project root
60
+ def most_relevant_file
61
+ Pathname(most_relevant_location[0])
62
+ end
63
+
64
+ # The line number of the `most_relevant_file` where the failure originated
65
+ #
66
+ # @return [Integer] line number
67
+ def most_relevant_failure_line
68
+ most_relevant_location[1]
69
+ end
70
+
71
+ # The final location of the stacktrace regardless of whether it's from within the project
72
+ #
73
+ # @return [String] the relative path to the file from the project root
74
+ def final_file
75
+ Pathname(final_location[0])
76
+ end
77
+
78
+ # The line number of the `final_file` where the failure originated
79
+ #
80
+ # @return [Integer] line number
81
+ def final_failure_line
82
+ final_location[1]
83
+ end
84
+
85
+ # The final location of the stacktrace regardless of whether it's from within the project
86
+ #
87
+ # @return [String] the relative path to the file from the project root
88
+ def project_file
89
+ broken_test? ? test_file : source_code_file
90
+ end
91
+
92
+ # The line number of the `project_file` where the failure originated
93
+ #
94
+ # @return [Integer] line number
95
+ def project_failure_line
96
+ broken_test? ? test_failure_line || test_definition_line : source_code_failure_line
97
+ end
98
+
99
+ # The final location from the stacktrace that is within the project directory
100
+ #
101
+ # @return [String, nil] the relative path to the file from the project root
102
+ def source_code_file
103
+ return nil unless backtrace.source_code_lines.any?
104
+
105
+ backtrace.final_source_code_location.pathname
106
+ end
107
+
108
+ # The line number of the `source_code_file` where the failure originated
109
+ #
110
+ # @return [Integer] line number
111
+ def source_code_failure_line
112
+ return nil unless backtrace.source_code_lines.any?
113
+
114
+ backtrace.final_source_code_location.number
115
+ end
116
+
117
+ # The final location from the stacktrace that is within the project's test directory
118
+ #
119
+ # @return [String, nil] the relative path to the file from the project root
25
120
  def test_file
26
- reduced_path(test_location[0])
121
+ Pathname(test_location[0])
27
122
  end
28
123
 
124
+ # The line number of the `test_file` where the test is defined
125
+ #
126
+ # @return [Integer] line number
29
127
  def test_definition_line
30
128
  test_location[1].to_s
31
129
  end
32
130
 
131
+ # The line number from within the `test_file` test definition where the failure occurred
132
+ #
133
+ # @return [Integer] line number
33
134
  def test_failure_line
34
- @backtrace.final_test_location.number
135
+ backtrace.final_test_location&.number || test_definition_line
35
136
  end
36
137
 
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}")
138
+ # The line number from within the `test_file` test definition where the failure occurred
139
+ #
140
+ # @return [Location] the last location from the backtrace or the test location if a backtrace
141
+ # was not passed to the initializer
142
+ def final_location
143
+ backtrace? ? backtrace.final_location : test_location
43
144
  end
44
145
 
45
- def source_failure_line
46
- return test_definition_line if backtrace.empty?
146
+ # The file most likely to be the source of the underlying problem. Often, the most recent
147
+ # backtrace files will be a gem or external library that's failing indirectly as a result
148
+ # of a problem with local source code (not always, but frequently). In that case, the best
149
+ # first place to focus is on the code you control.
150
+ #
151
+ # @return [Array] file and line number of the most likely source of the problem
152
+ def most_relevant_location
153
+ [
154
+ source_code_location,
155
+ test_location,
156
+ final_location
157
+ ].compact.first
158
+ end
47
159
 
48
- backtrace.final_project_location.number
160
+ def project_location
161
+ source_code_location || test_location
49
162
  end
50
163
 
51
- private
164
+ def source_code_location
165
+ backtrace.final_source_code_location
166
+ end
52
167
 
53
- def reduced_path(path)
54
- path.delete_prefix(Dir.pwd)
168
+ def backtrace?
169
+ backtrace.parsed_lines.any?
55
170
  end
56
171
  end
57
172
  end
@@ -47,7 +47,7 @@ module Minitest
47
47
  files = {}
48
48
  @hits.each_pair do |filename, details|
49
49
  # Can't really be a "hot spot" with just a single issue
50
- next unless details[:weight] > 1
50
+ # next unless details[:weight] > 1
51
51
 
52
52
  files[filename] = details[:weight]
53
53
  end
@@ -0,0 +1,131 @@
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_lines.each do |backtrace_line|
25
+ # Get the source code for the line from the backtrace
26
+ source_code = source_code_for(backtrace_line)
27
+
28
+ parts = [
29
+ indentation_token,
30
+ path_token(backtrace_line),
31
+ file_and_line_number_token(backtrace_line),
32
+ source_code_line_token(source_code)
33
+ ]
34
+
35
+ parts << file_freshness(backtrace_line) if most_recently_modified?(backtrace_line)
36
+
37
+ @tokens << parts
38
+
39
+
40
+ end
41
+
42
+ @tokens
43
+ end
44
+
45
+ def line_count
46
+ DEFAULT_LINE_COUNT
47
+ end
48
+
49
+ # This should probably be smart about what lines are displayed in a backtrace.
50
+ # Maybe...
51
+ # ...it could intelligently display the full back trace?
52
+ # ...only the backtrace from the first/last line of project source?
53
+ # ...it behaves a little different when it's a broken test vs. a true exception?
54
+ # ...it could be smart about subtly flagging the lines that show up in the heat map frequently?
55
+ # ...it could be influenced by a "compact" or "robust" reporter super-style?
56
+ # ...it's smart about exceptions that were raised outside of the project?
57
+ # ...it's smart about highlighting lines of code differently based on whether it's source code, test code, or external code?
58
+ def backtrace_lines
59
+ project_lines
60
+ end
61
+
62
+ private
63
+
64
+ def all_backtrace_lines_from_project?
65
+ backtrace_lines.all? { |line| line.path.include?(project_root_dir) }
66
+ end
67
+
68
+ def project_root_dir
69
+ Dir.pwd
70
+ end
71
+
72
+ def project_lines
73
+ backtrace.project_lines.take(line_count)
74
+ end
75
+
76
+ def all_lines
77
+ backtrace.parsed_lines.take(line_count)
78
+ end
79
+
80
+ def source_code_for(line)
81
+ filename = "#{line.path}/#{line.file}"
82
+
83
+ Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
84
+ end
85
+
86
+ def most_recently_modified?(line)
87
+ # If there's more than one line being displayed, and the current line is the freshest
88
+ backtrace_lines.size > 1 && line == backtrace.freshest_project_location
89
+ end
90
+
91
+ def indentation_token
92
+ [:default, ' ' * indentation]
93
+ end
94
+
95
+ def path_token(line)
96
+ path = "#{line.path}/"
97
+
98
+ # If all of the backtrace lines are from the project, no point in the added redundant
99
+ # noise of showing the project root directory over and over again
100
+ path = path.delete_prefix(project_root_dir) if all_backtrace_lines_from_project?
101
+
102
+ [:muted, path]
103
+ end
104
+
105
+ def file_and_line_number_token(backtrace_line)
106
+ [:default, "#{backtrace_line.file}:#{backtrace_line.number}"]
107
+ end
108
+
109
+ def source_code_line_token(source_code)
110
+ [:muted, " `#{source_code.line.strip}`"]
111
+ end
112
+
113
+ def file_freshness(line)
114
+ [:bold, " < Most Recently Modified"]
115
+ end
116
+
117
+ # The number of spaces each line of code should be indented. Currently defaults to 2 in
118
+ # order to provide visual separation between test failures, but in the future, it could
119
+ # be configurable in order to save horizontal space and create more compact output. For
120
+ # example, it could be smart based on line length and total available horizontal terminal
121
+ # space, or there could be higher-level "display" setting that could have a `:compact`
122
+ # option that would reduce the space used.
123
+ #
124
+ # @return [type] [description]
125
+ def indentation
126
+ DEFAULT_INDENTATION_SPACES
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,18 @@
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
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Location
7
+ attr_accessor :location
8
+
9
+ def initialize(location)
10
+ @location = location
11
+ end
12
+
13
+ def tokens
14
+ end
15
+
16
+ private
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Map
7
+ attr_accessor :map
8
+
9
+ def initialize(map)
10
+ @map = map
11
+ end
12
+
13
+ def tokens
14
+ end
15
+
16
+ private
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ class Results
7
+ extend Forwardable
8
+
9
+ attr_accessor :results
10
+
11
+ def_delegators :@results, :errors, :brokens, :failures, :slows, :skips, :problems?, :slows?
12
+
13
+ def initialize(results)
14
+ @results = results
15
+ @tokens = []
16
+ end
17
+
18
+ def tokens
19
+ @tokens << [*issue_counts_tokens] if issue_counts_tokens&.any?
20
+ @tokens << [assertions_count_token, test_count_token]
21
+ @tokens << [assertions_performance_token, tests_performance_token, timing_token]
22
+
23
+ @tokens
24
+ end
25
+
26
+ private
27
+
28
+ def pluralize(count, singular)
29
+ singular_style = "#{count} #{singular}"
30
+
31
+ # Given the narrow scope, pluralization can be relatively naive here
32
+ count > 1 ? "#{singular_style}s" : singular_style
33
+ end
34
+
35
+ def issue_counts_tokens
36
+ return unless problems? || slows?
37
+
38
+ counts = [error_count_token, broken_count_token, failure_count_token, skip_count_token, slow_count_token].compact
39
+
40
+ # # Create an array of separator tokens one less than the total number of issue count tokens
41
+ separator_tokens = Array.new(counts.size, separator_token)
42
+
43
+ counts_with_separators = counts
44
+ .zip(separator_tokens) # Add separators between the counts
45
+ .flatten(1) # Flatten the zipped separators, but no more
46
+
47
+ counts_with_separators.pop # Remove the final trailing zipped separator that's not needed
48
+
49
+ counts_with_separators
50
+ end
51
+
52
+ def error_count_token
53
+ issue_count_token(:error, errors)
54
+ end
55
+
56
+ def broken_count_token
57
+ issue_count_token(:broken, brokens)
58
+ end
59
+
60
+ def failure_count_token
61
+ issue_count_token(:failure, failures)
62
+ end
63
+
64
+ def skip_count_token
65
+ style = problems? ? :muted : :skipped
66
+ issue_count_token(style, skips, name: 'Skip')
67
+ end
68
+
69
+ def slow_count_token
70
+ style = problems? ? :muted : :slow
71
+ issue_count_token(style, slows, name: 'Slow')
72
+ end
73
+
74
+ def assertions_performance_token
75
+ [:bold, "#{results.assertions_per_second} assertions/s"]
76
+ end
77
+
78
+ def tests_performance_token
79
+ [:default, " and #{results.tests_per_second} tests/s"]
80
+ end
81
+
82
+ def timing_token
83
+ [:default, " in #{results.total_time.round(2)}s"]
84
+ end
85
+
86
+ def assertions_count_token
87
+ [:muted, pluralize(results.assertion_count, 'Assertion')]
88
+ end
89
+
90
+ def test_count_token
91
+ [:muted, " across #{pluralize(results.test_count, 'Test')}"]
92
+ end
93
+
94
+ def issue_count_token(type, collection, name: type.capitalize)
95
+ return nil if collection.empty?
96
+
97
+ [type, pluralize(collection.size, name)]
98
+ end
99
+
100
+ def separator_token
101
+ [:muted, ' · ']
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Output
6
+ # Builds the collection of tokens representing a specific set of source code lines
7
+ class SourceCode
8
+ DEFAULT_LINE_COUNT = 3
9
+ DEFAULT_INDENTATION_SPACES = 2
10
+ HIGHLIGHT_KEY_LINE = true
11
+
12
+ attr_reader :filename, :line_number, :max_line_count
13
+
14
+ # Provides a collection of tokens representing the output of source code
15
+ # @param filename [String] the absolute path to the file containing the source code
16
+ # @param line_number [Integer, String] the primary line number of interest for the file
17
+ # @param max_line_count: DEFAULT_LINE_COUNT [Integer] maximum total number of lines to
18
+ # retrieve around the target line (including the target line)
19
+ #
20
+ # @return [self]
21
+ def initialize(filename, line_number, max_line_count: DEFAULT_LINE_COUNT)
22
+ @filename = filename
23
+ @line_number = line_number.to_s
24
+ @max_line_count = max_line_count
25
+ @tokens = []
26
+ end
27
+
28
+ # The collection of style content tokens to print
29
+ #
30
+ # @return [Array<Array<Token>>] an array of arrays of tokens where each top-level array
31
+ # represents a line where the first element is the line_number and the second is the line
32
+ # of code to display
33
+ def tokens
34
+ source.lines.each_index do |i|
35
+ current_line_number = source.line_numbers[i]
36
+ current_line_of_code = source.lines[i]
37
+
38
+ number_style, line_style = styles_for(current_line_of_code)
39
+
40
+ @tokens << [
41
+ line_number_token(number_style, current_line_number),
42
+ line_of_code_token(line_style, current_line_of_code)
43
+ ]
44
+ end
45
+ @tokens
46
+ end
47
+
48
+ # The number of digits for the largest line number returned. This is used for formatting and
49
+ # text justification so that line numbers are right-aligned
50
+ #
51
+ # @return [Integer] the number of digits in the longest line number returned
52
+ def max_line_number_digits
53
+ source
54
+ .line_numbers
55
+ .map(&:to_s)
56
+ .map(&:length)
57
+ .max
58
+ end
59
+
60
+ # Whether to visually highlight the target line when displaying the source code. Currently
61
+ # defauls to true, but long-term, this is a likely candidate to be configurable. For
62
+ # example, in the future, highlighting could only be used if the source includes more than
63
+ # three lines. Or it could be something end users could disable in order to reduce noise.
64
+ #
65
+ # @return [Boolean] true if the target line should be highlighted
66
+ def highlight_key_line?
67
+ HIGHLIGHT_KEY_LINE
68
+ end
69
+
70
+ # The number of spaces each line of code should be indented. Currently defaults to 2 in
71
+ # order to provide visual separation between test failures, but in the future, it could
72
+ # be configurable in order to save horizontal space and create more compact output. For
73
+ # example, it could be smart based on line length and total available horizontal terminal
74
+ # space, or there could be higher-level "display" setting that could have a `:compact`
75
+ # option that would reduce the space used.
76
+ #
77
+ # @return [type] [description]
78
+ def indentation
79
+ DEFAULT_INDENTATION_SPACES
80
+ end
81
+
82
+ private
83
+
84
+ # The source instance for retrieving the relevant lines of source code
85
+ #
86
+ # @return [Source] a Minitest::Heat::Source instance
87
+ def source
88
+ @source ||= Minitest::Heat::Source.new(
89
+ filename,
90
+ line_number: line_number,
91
+ max_line_count: max_line_count
92
+ )
93
+ end
94
+
95
+ # Determines how to style a given line of code token. For now, it's only used for
96
+ # highlighting the targeted line of code, but it could also be adjusted to mute the line
97
+ # number or otherwise change the styling of how lines of code are displayed
98
+ # @param line_of_code [String] the content representing the line of code we're currently
99
+ # generating a token for
100
+ #
101
+ # @return [Array<Symbol>] the Token styles for the line number and line of code
102
+ def styles_for(line_of_code)
103
+ if line_of_code == source.line && highlight_key_line?
104
+ [:default, :default]
105
+ else
106
+ [:muted, :muted]
107
+ end
108
+ end
109
+
110
+ # The token representing a given line number. Adds the appropriate indention and
111
+ # justification to right align the line numbers
112
+ # @param style [Symbol] the symbol representing the style for the line number token
113
+ # @param line_number [Integer,String] the digits representing the line number
114
+ #
115
+ # @return [Array] the style/content token for the current line number
116
+ def line_number_token(style, line_number)
117
+ [style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_digits)} "]
118
+ end
119
+
120
+ # The token representing the content of a given line of code.
121
+ # @param style [Symbol] the symbol representing the style for the line of code token
122
+ # @param line_number [Integer,String] the content of the line of code
123
+ #
124
+ # @return [Array] the style/content token for the current line of code
125
+ def line_of_code_token(style, line_of_code)
126
+ [style, line_of_code]
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,99 @@
1
+ module Minitest
2
+ module Heat
3
+ # Friendly API for printing nicely-formatted output to the console
4
+ class Output
5
+ class Token
6
+ class InvalidStyle < ArgumentError; end
7
+
8
+ STYLES = {
9
+ success: %i[default green],
10
+ slow: %i[bold green],
11
+ painful: %i[bold green],
12
+ error: %i[bold red],
13
+ broken: %i[bold red],
14
+ failure: %i[default red],
15
+ skipped: %i[default yellow],
16
+ warning_light: %i[light yellow],
17
+ italicized: %i[italic gray],
18
+ bold: %i[bold default],
19
+ default: %i[default default],
20
+ muted: %i[light gray]
21
+ }.freeze
22
+
23
+ attr_accessor :style_key, :content
24
+
25
+ def initialize(style_key, content)
26
+ @style_key = style_key
27
+ @content = content
28
+ end
29
+
30
+ def to_s(format = :styled)
31
+ return content unless format == :styled
32
+
33
+ [
34
+ style_string,
35
+ content,
36
+ reset_string
37
+ ].join
38
+ end
39
+
40
+ def eql?(other)
41
+ style_key == other.style_key && content == other.content
42
+ end
43
+ alias :== eql?
44
+
45
+ private
46
+
47
+ ESC_SEQUENCE = "\e["
48
+ END_SEQUENCE = "m"
49
+
50
+ WEIGHTS = {
51
+ default: 0,
52
+ bold: 1,
53
+ light: 2,
54
+ italic: 3
55
+ }.freeze
56
+
57
+ COLORS = {
58
+ black: 30,
59
+ red: 31,
60
+ green: 32,
61
+ yellow: 33,
62
+ blue: 34,
63
+ magenta: 35,
64
+ cyan: 36,
65
+ gray: 37,
66
+ default: 39
67
+ }.freeze
68
+
69
+ def style_string
70
+ "#{ESC_SEQUENCE}#{weight};#{color}#{END_SEQUENCE}"
71
+ end
72
+
73
+ def reset_string
74
+ "#{ESC_SEQUENCE}0#{END_SEQUENCE}"
75
+ end
76
+
77
+ def weight_key
78
+ style_components[0]
79
+ end
80
+
81
+ def color_key
82
+ style_components[1]
83
+ end
84
+
85
+ def weight
86
+ WEIGHTS.fetch(weight_key)
87
+ end
88
+
89
+ def color
90
+ COLORS.fetch(color_key)
91
+ end
92
+
93
+ def style_components
94
+ STYLES.fetch(style_key) { raise InvalidStyle, "'#{style_key}' is not a valid style option for tokens" }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -1,102 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'output/backtrace'
4
+ require_relative 'output/issue'
5
+ require_relative 'output/location'
6
+ require_relative 'output/map'
7
+ require_relative 'output/results'
8
+ require_relative 'output/source_code'
9
+ require_relative 'output/token'
10
+
3
11
  module Minitest
4
12
  module Heat
5
13
  # Friendly API for printing nicely-formatted output to the console
6
14
  class Output
7
- ESC = "\e["
8
-
9
- Token = Struct.new(:style, :content) do
10
- def to_s
11
- [
12
- style_string,
13
- content,
14
- reset_string
15
- ].join
16
- end
17
-
18
- private
19
-
20
- def style_string
21
- "#{ESC}#{weight};#{color}m"
22
- end
23
-
24
- def reset_string
25
- "#{ESC}0m"
26
- end
27
-
28
- def weight_key
29
- style_components[0]
30
- end
31
-
32
- def color_key
33
- style_components[1]
34
- end
35
-
36
- def weight
37
- {
38
- default: 0,
39
- bold: 1,
40
- light: 2,
41
- italic: 3
42
- }.fetch(weight_key)
43
- end
44
-
45
- def color
46
- {
47
- black: 30,
48
- red: 31,
49
- green: 32,
50
- yellow: 33,
51
- blue: 34,
52
- magenta: 35,
53
- cyan: 36,
54
- gray: 37,
55
- default: 39
56
- }.fetch(color_key)
57
- end
58
-
59
- def style_components
60
- {
61
- success: %i[default green],
62
- slow: %i[bold green],
63
- error: %i[bold red],
64
- broken: %i[bold red],
65
- failure: %i[default red],
66
- skipped: %i[default yellow],
67
- warning_light: %i[light yellow],
68
- source: %i[italic default],
69
- bold: %i[bold default],
70
- default: %i[default default],
71
- muted: %i[light gray]
72
- }.fetch(style)
73
- end
74
- end
75
-
76
15
  FORMATTERS = {
77
16
  error: [
78
- [ %i[error label], %i[muted arrow], %i[default test_name] ],
79
- [ %i[default summary], ],
17
+ [ %i[error label], %i[muted spacer], %i[default test_name] ],
18
+ [ %i[italicized summary], ],
80
19
  [ %i[default backtrace_summary] ],
81
20
  ],
82
21
  broken: [
83
- [ %i[broken label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
84
- [ %i[default summary], ],
22
+ [ %i[broken label], %i[muted spacer], %i[default test_name], %i[muted spacer], %i[muted test_class] ],
23
+ [ %i[italicized summary], ],
85
24
  [ %i[default backtrace_summary] ],
86
25
  ],
87
26
  failure: [
88
- [ %i[failure label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class] ],
89
- [ %i[default summary] ],
90
- [ %i[muted location], ],
27
+ [ %i[failure label], %i[muted spacer], %i[default test_name], %i[muted spacer], %i[muted test_class] ],
28
+ [ %i[italicized summary] ],
29
+ [ %i[muted short_location], ],
91
30
  [ %i[default source_summary], ],
92
31
  ],
93
32
  skipped: [
94
- [ %i[skipped label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name] ],
95
- [ %i[default summary] ],
33
+ [ %i[skipped label], %i[muted spacer], %i[default test_name], %i[muted spacer], %i[muted test_class] ],
34
+ [ %i[italicized summary] ],
96
35
  [], # New Line
97
36
  ],
98
37
  slow: [
99
- [ %i[slow label], %i[muted spacer], %i[default test_class], %i[muted arrow], %i[default test_name], %i[muted spacer], %i[muted class], ],
38
+ [ %i[slow label], %i[muted spacer], %i[default test_name], %i[muted spacer], %i[default test_class] ],
100
39
  [ %i[bold slowness], %i[muted spacer], %i[default location], ],
101
40
  [], # New Line
102
41
  ]
@@ -120,6 +59,9 @@ module Minitest
120
59
  end
121
60
  alias newline puts
122
61
 
62
+ # TOOD: Convert to output class
63
+ # - This should likely live in the output/issue class
64
+ # - Add a 'fail_fast' option that shows the issue as soon as the failure occurs
123
65
  def marker(value)
124
66
  case value
125
67
  when 'E' then text(:error, value)
@@ -130,6 +72,9 @@ module Minitest
130
72
  end
131
73
  end
132
74
 
75
+ # TOOD: Convert to output class
76
+ # - This should likely live in the output/issue class
77
+ # - There may be justification for creating different "strategies" for the various types
133
78
  def issue_details(issue)
134
79
  formatter = FORMATTERS[issue.type]
135
80
 
@@ -150,23 +95,31 @@ module Minitest
150
95
  end
151
96
  end
152
97
 
98
+ # TOOD: Convert to output class
153
99
  def heat_map(map)
154
- # text(:default, "🔥 Hot Spots 🔥\n")
155
100
  map.files.each do |file|
156
- file = file[0]
157
- values = map.hits[file]
101
+ pathname = Pathname(file[0])
102
+
103
+ path = pathname.dirname.to_s
104
+ filename = pathname.basename.to_s
105
+
106
+ values = map.hits[pathname.to_s]
158
107
 
159
- filename = file.split('/').last
160
- path = file.delete_suffix(filename)
161
108
 
162
- text(:error, 'E' * values[:error].size) if values[:error]&.any?
163
- text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
109
+ text(:error, 'E' * values[:error].size) if values[:error]&.any?
110
+ text(:broken, 'B' * values[:broken].size) if values[:broken]&.any?
164
111
  text(:failure, 'F' * values[:failure].size) if values[:failure]&.any?
165
- text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
166
- text(:muted, ' ') if values[:error]&.any? || values[:broken]&.any? || values[:failure]&.any? || values[:skipped]&.any?
167
112
 
168
- text(:muted, "#{path.delete_prefix('/')}")
169
- text(:default, "#{filename}")
113
+ unless values[:error]&.any? || values[:broken]&.any? || values[:failure]&.any?
114
+ text(:skipped, 'S' * values[:skipped].size) if values[:skipped]&.any?
115
+ text(:painful, '—' * values[:painful].size) if values[:painful]&.any?
116
+ text(:slow, '–' * values[:slow].size) if values[:slow]&.any?
117
+ end
118
+
119
+ text(:muted, ' ') if map.hits.any?
120
+
121
+ text(:muted, "#{path.delete_prefix(Dir.pwd)}/")
122
+ text(:default, filename)
170
123
 
171
124
  text(:muted, ':')
172
125
 
@@ -180,96 +133,56 @@ module Minitest
180
133
  newline
181
134
  end
182
135
 
183
- def compact_summary(results)
184
- error_count = results.errors.size
185
- broken_count = results.brokens.size
186
- failure_count = results.failures.size
187
- slow_count = results.slows.size
188
- skip_count = results.skips.size
189
-
190
- counts = []
191
- counts << pluralize(error_count, 'Error') if error_count.positive?
192
- counts << pluralize(broken_count, 'Broken') if broken_count.positive?
193
- counts << pluralize(failure_count, 'Failure') if failure_count.positive?
194
- counts << pluralize(skip_count, 'Skip') if skip_count.positive?
195
- counts << pluralize(slow_count, 'Slow') if slow_count.positive?
196
- text(:default, counts.join(', '))
197
-
198
- newline
199
- text(:muted, "#{results.tests_per_second} tests/s and #{results.assertions_per_second} assertions/s ")
136
+ # TOOD: Convert to output class
137
+ def test_name_summary(issue)
138
+ text(:default, "#{issue.test_class} > #{issue.test_name}")
139
+ end
200
140
 
201
- newline
202
- text(:muted, pluralize(results.test_count, 'Test') + ' & ')
203
- text(:muted, pluralize(results.assertion_count, 'Assertion'))
204
- text(:muted, " in #{results.total_time.round(2)}s")
141
+ def compact_summary(results)
142
+ results_tokens = ::Minitest::Heat::Output::Results.new(results).tokens
205
143
 
206
144
  newline
145
+ print_tokens(results_tokens)
207
146
  newline
208
147
  end
209
148
 
210
- private
211
-
212
- def test_name_summary(issue)
213
- text(:default, "#{issue.test_class} > #{issue.test_name}")
214
- end
215
-
216
149
  def backtrace_summary(issue)
217
- backtrace_lines = issue.backtrace.project
218
-
219
- backtrace_line = backtrace_lines.first
220
- filename = "#{backtrace_line.path.delete_prefix(Dir.pwd)}/#{backtrace_line.file}"
221
-
222
- backtrace_lines.take(3).each do |line|
223
- source = Minitest::Heat::Source.new(filename, line_number: line.number, max_line_count: 1)
150
+ location = issue.location
224
151
 
225
- text(:muted, " #{line.path.delete_prefix("#{Dir.pwd}/")}/")
226
- text(:muted, "#{line.file}:#{line.number}")
227
- text(:source, " `#{source.line.strip}`")
228
-
229
- newline
230
- end
152
+ backtrace_tokens = ::Minitest::Heat::Output::Backtrace.new(location).tokens
153
+ print_tokens(backtrace_tokens)
231
154
  end
232
155
 
233
156
  def source_summary(issue)
234
- filename = issue.location.source_file
235
- line_number = issue.location.source_failure_line
157
+ filename = issue.location.project_file
158
+ line_number = issue.location.project_failure_line
236
159
 
237
- source = Minitest::Heat::Source.new(filename, line_number: line_number, max_line_count: 3)
238
- show_source(source, highlight_line: true, indentation: 2)
160
+ source_code_tokens = ::Minitest::Heat::Output::SourceCode.new(filename, line_number).tokens
161
+ print_tokens(source_code_tokens)
239
162
  end
240
163
 
241
- def show_source(source, indentation: 0, highlight_line: false)
242
- max_line_number_length = source.line_numbers.map(&:to_s).map(&:length).max
243
- source.lines.each_index do |i|
244
- line_number = source.line_numbers[i]
245
- line = source.lines[i]
246
-
247
- number_style, line_style = if line == source.line && highlight_line
248
- [:default, :default]
249
- else
250
- [:muted, :muted]
251
- end
252
- text(number_style, "#{' ' * indentation}#{line_number.to_s.rjust(max_line_number_length)} ")
253
- text(line_style, line)
254
- puts
255
- end
256
- end
164
+ private
257
165
 
258
166
  def style_enabled?
259
167
  stream.tty?
260
168
  end
261
169
 
262
- def pluralize(count, singular)
263
- singular_style = "#{count} #{singular}"
264
-
265
- # Given the narrow scope, pluralization can be relatively naive here
266
- count > 1 ? "#{singular_style}s" : singular_style
170
+ def text(style, content)
171
+ token = Token.new(style, content)
172
+ print token.to_s(token_format)
267
173
  end
268
174
 
269
- def text(style, content)
270
- formatted_content = style_enabled? ? Token.new(style, content).to_s : content
175
+ def token_format
176
+ style_enabled? ? :styled : :unstyled
177
+ end
271
178
 
272
- print formatted_content
179
+ def print_tokens(lines_of_tokens)
180
+ lines_of_tokens.each do |tokens|
181
+ tokens.each do |token|
182
+ print Token.new(*token).to_s(token_format)
183
+ end
184
+ newline
185
+ end
273
186
  end
274
187
  end
275
188
  end
@@ -93,6 +93,10 @@ module Minitest
93
93
  skips.any?
94
94
  end
95
95
 
96
+ def slows?
97
+ slows.any?
98
+ end
99
+
96
100
  def record(issue)
97
101
  @test_count += 1
98
102
  @assertion_count += issue.result.assertions
@@ -51,10 +51,15 @@ module Minitest
51
51
  #
52
52
  # @return [type] [description]
53
53
  def file_lines
54
- @raw_lines ||= File.readlines("#{Dir.pwd}#{filename}", chomp: true)
54
+ @raw_lines ||= File.readlines(filename, chomp: true)
55
55
  @raw_lines.pop while @raw_lines.last.strip.empty?
56
56
 
57
57
  @raw_lines
58
+ rescue Errno::ENOENT
59
+ # Occasionally, for a variety of reasons, a file can't be read. In those cases, it's best to
60
+ # return no source code lines rather than have the test suite raise an error unrelated to
61
+ # the code being tested becaues that gets confusing.
62
+ []
58
63
  end
59
64
 
60
65
  private
@@ -1,5 +1,5 @@
1
1
  module Minitest
2
2
  module Heat
3
- VERSION = "0.0.4"
3
+ VERSION = "0.0.5"
4
4
  end
5
5
  end
@@ -62,7 +62,6 @@ module Minitest
62
62
  issue = Heat::Issue.new(result)
63
63
 
64
64
  @results.record(issue)
65
-
66
65
  @map.add(*issue.to_hit) if issue.hit?
67
66
 
68
67
  output.marker(issue.marker)
@@ -92,7 +91,6 @@ module Minitest
92
91
  results.errors.each { |issue| output.issue_details(issue) }
93
92
 
94
93
  output.compact_summary(results)
95
-
96
94
  output.heat_map(map)
97
95
  end
98
96
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minitest-heat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.4
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garrett Dimon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-08-31 00:00:00.000000000 Z
11
+ date: 2021-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -90,6 +90,13 @@ files:
90
90
  - lib/minitest/heat/location.rb
91
91
  - lib/minitest/heat/map.rb
92
92
  - lib/minitest/heat/output.rb
93
+ - lib/minitest/heat/output/backtrace.rb
94
+ - lib/minitest/heat/output/issue.rb
95
+ - lib/minitest/heat/output/location.rb
96
+ - lib/minitest/heat/output/map.rb
97
+ - lib/minitest/heat/output/results.rb
98
+ - lib/minitest/heat/output/source_code.rb
99
+ - lib/minitest/heat/output/token.rb
93
100
  - lib/minitest/heat/results.rb
94
101
  - lib/minitest/heat/source.rb
95
102
  - lib/minitest/heat/version.rb