minitest-heat 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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