minitest-heat 0.0.6 → 0.0.10

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: 5a8979e12c498f8e4d98e6988356639ab3601b04f1ab38da7af6de1cc6ef8a16
4
- data.tar.gz: b8e7a5ec05630043cd50ecfbfa4e42dcf0c53b2984b95cb971ff806c2e8863f8
3
+ metadata.gz: 6c60c717971a6a1858de4337247b8bfd37f63a63f31a1783de4ecaaad8ca5a8c
4
+ data.tar.gz: c491cb526bd8a243810fe88da7d15679a6c5182b256a5187db4c457bcc9b7a7a
5
5
  SHA512:
6
- metadata.gz: 1df9dc10973d664bdcf0e1690e44c98907f1b59056e7948f16f5bc9d97a4a9b8d989dfebc73d0ad4a88ce35b1253614d340ebd941c451ce12b3be89dad58aabd
7
- data.tar.gz: ff6a30b6e5e37f7efce044307a2f3265c1332654d62270571223392913d85566982bcf11add206eca60c58c58d3adf87e46010714591487a15af882ad7996def
6
+ metadata.gz: abf3a4c476c1041f75fedfa3eac1235f134bc99bbbf1c909e6e407e461380e9028079e3ad927180d7bec4f039c54247172c9a12bee579dec5eab53b444cf3f68
7
+ data.tar.gz: fb7d2373153fdaaf6fe8b3a6ec693baf3e04a8fa8dc5636f75d1b0afb45e5a7b25365f3dbe44b0aa3c53bb79b8ed7b0551c3798ceaa3477ce1d9e122159e116a
data/.rubocop.yml CHANGED
@@ -2,6 +2,7 @@ AllCops:
2
2
  NewCops: enable
3
3
  UseCache: true
4
4
  CacheRootDirectory: './'
5
+ TargetRubyVersion: 2.5.9
5
6
  Exclude:
6
7
  - 'bin/**/*'
7
8
  - 'test/files/source.rb' # An example test file for reading source code
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- minitest-heat (0.0.6)
4
+ minitest-heat (0.0.10)
5
5
  minitest
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -21,7 +21,7 @@ Or install it yourself as:
21
21
 
22
22
  $ gem install minitest-heat
23
23
 
24
- And add this line to your `test/test_helper.rb` file:
24
+ And depending on your usage, you may need to require Minitest Heat in your test suite:
25
25
 
26
26
  ```ruby
27
27
  require 'minitest/heat'
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Minitest
6
+ module Heat
7
+ class Backtrace
8
+ # Represents a line from a backtrace to provide more convenient access to information about
9
+ # the relevant file and line number for displaying in test results
10
+ class Line
11
+ attr_accessor :pathname, :number, :container
12
+ alias line_number number
13
+
14
+ # Creates an instance of a line number reference
15
+ # @param pathname: [Pathname, String] the full pathname to the file
16
+ # @param number: [Integer, String] the line number in question
17
+ # @param container: nil [String] the containing method or block for the line of code
18
+ #
19
+ # @return [self]
20
+ def initialize(pathname:, number:, container: nil)
21
+ @pathname = Pathname(pathname)
22
+ @number = number.to_i
23
+ @container = container.to_s
24
+ end
25
+
26
+ # Parses a line from a backtrace in order to convert it to usable components
27
+ def self.parse_backtrace(raw_text)
28
+ raw_pathname, raw_line_number, raw_container = raw_text.split(':')
29
+ raw_container = raw_container.delete_prefix('in `').delete_suffix("'")
30
+
31
+ new(pathname: raw_pathname, number: raw_line_number, container: raw_container)
32
+ end
33
+
34
+ # Generates a formatted string describing the line of code similar to the original backtrace
35
+ #
36
+ # @return [String] a consistently-formatted, human-readable string about the line of code
37
+ def to_s
38
+ "#{location} in `#{container}`"
39
+ end
40
+
41
+ # A safe interface to getting a string representing the path portion of the file
42
+ #
43
+ # @return [String] either the path/directory portion of the file name or '(Unrecognized File)'
44
+ # if the offending file can't be found for some reason
45
+ def path
46
+ pathname.exist? ? pathname.dirname : UNRECOGNIZED
47
+ end
48
+
49
+ # A safe interface for getting a string representing the filename portion of the file
50
+ #
51
+ # @return [String] either the filename portion of the file or '(Unrecognized File)'
52
+ # if the offending file can't be found for some reason
53
+ def file
54
+ pathname.exist? ? pathname.basename : UNRECOGNIZED
55
+ end
56
+
57
+ # A safe interface to getting the last modified time for the file in question
58
+ #
59
+ # @return [Time] the timestamp for when the file in question was last modified or `Time.at(0)`
60
+ # if the offending file can't be found for some reason
61
+ def mtime
62
+ pathname.exist? ? pathname.mtime : UNKNOWN_MODIFICATION_TIME
63
+ end
64
+
65
+ # A safe interface to getting the number of seconds since the file was modified
66
+ #
67
+ # @return [Integer] the number of seconds since the file was modified or `-1` if the offending
68
+ # file can't be found for some reason
69
+ def age_in_seconds
70
+ pathname.exist? ? seconds_ago : UNKNOWN_MODIFICATION_SECONDS
71
+ end
72
+
73
+ # A convenient method for getting the full location identifier using the full pathname and
74
+ # line number separated by a `:`
75
+ #
76
+ # @return [String] the full pathname and line number
77
+ def location
78
+ "#{pathname}:#{number}"
79
+ end
80
+
81
+ # A convenient method for getting the short location with `Dir.pwd` removed
82
+ #
83
+ # @return [String] the relative pathname and line number
84
+ def short_location
85
+ "#{file}:#{number}"
86
+ end
87
+
88
+ # A convenient method for getting the line of source code for the offending line number
89
+ #
90
+ # @return [String] the source code for the file/line number combination
91
+ def source_code(max_line_count: 1)
92
+ Minitest::Heat::Source.new(
93
+ pathname.to_s,
94
+ line_number: line_number,
95
+ max_line_count: max_line_count
96
+ )
97
+ end
98
+
99
+ # Determines if a given file follows the standard approaching to naming test files.
100
+ #
101
+ # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb`
102
+ def test_file?
103
+ file.to_s.start_with?('test_') || file.to_s.end_with?('_test.rb')
104
+ end
105
+
106
+ private
107
+
108
+ UNRECOGNIZED = '(Unrecognized File)'
109
+ UNKNOWN_MODIFICATION_TIME = Time.at(0)
110
+ UNKNOWN_MODIFICATION_SECONDS = -1
111
+
112
+ def seconds_ago
113
+ (Time.now - mtime).to_i
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,69 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'backtrace/line'
4
+
3
5
  module Minitest
4
6
  module Heat
5
7
  # Wrapper for separating backtrace into component parts
6
8
  class Backtrace
7
9
  attr_reader :raw_backtrace
8
10
 
11
+ # Creates a more flexible backtrace data structure by parsing the lines of the backtrace to
12
+ # extract individual elements for investigating the offending files and line numbers
13
+ # @param raw_backtrace [Array] the array of lines from the backtrace
14
+ #
15
+ # @return [self]
9
16
  def initialize(raw_backtrace)
10
- @raw_backtrace = raw_backtrace
17
+ @raw_backtrace = Array(raw_backtrace)
11
18
  end
12
19
 
20
+ # Determines if the raw backtrace has values in it
21
+ #
22
+ # @return [Boolean] true if there's no backtrace or it's empty
13
23
  def empty?
14
- raw_backtrace.nil? || raw_backtrace.empty?
24
+ raw_backtrace.empty?
15
25
  end
16
26
 
27
+ # The final location exposed in the backtrace. Could be a line from the project or from a
28
+ # dependency or the Ruby core libraries
29
+ #
30
+ # @return [Line] the final location from the backtrace parsed as a Backtrace::Line
17
31
  def final_location
18
32
  parsed_entries.first
19
33
  end
20
34
 
35
+ # The final location from within the project exposed in the backtrace. Could be test files or
36
+ # source code files
37
+ #
38
+ # @return [Line] the final project location from the backtrace parsed as a Backtrace::Line
21
39
  def final_project_location
22
40
  project_entries.first
23
41
  end
24
42
 
43
+ # The most recently modified location from within the project
44
+ #
45
+ # @return [Line] the most recently modified project location from the backtrace parsed as a
46
+ # Backtrace::Line
25
47
  def freshest_project_location
26
48
  recently_modified_entries.first
27
49
  end
28
50
 
51
+ # The final location from within the project source code (i.e. excluding tests)
52
+ #
53
+ # @return [Line] the final source code location from the backtrace parsed as a Backtrace::Line
29
54
  def final_source_code_location
30
55
  source_code_entries.first
31
56
  end
32
57
 
58
+ # The final location from within the project's tests (i.e. excluding source code)
59
+ #
60
+ # @return [Line] the final test location from the backtrace parsed as a Backtrace::Line
33
61
  def final_test_location
34
62
  test_entries.first
35
63
  end
36
64
 
65
+ # All entries from the backtrace that are files within the project
66
+ #
67
+ # @return [Line] the backtrace lines from within the project parsed as Backtrace::Line's
37
68
  def project_entries
38
69
  @project_entries ||= parsed_entries.select { |entry| entry.path.to_s.include?(Dir.pwd) }
39
70
  end
40
71
 
72
+ # All entries from the backtrace within the project and sorted with the most recently modified
73
+ # files at the beginning
74
+ #
75
+ # @return [Line] the sorted backtrace lines from the project parsed as Backtrace::Line's
41
76
  def recently_modified_entries
42
77
  @recently_modified_entries ||= project_entries.sort_by(&:mtime).reverse
43
78
  end
44
79
 
80
+ # All entries from the backtrace within the project tests
81
+ #
82
+ # @return [Line] the backtrace lines from within the project tests parsed as Backtrace::Line's
45
83
  def test_entries
46
- @tests_entries ||= project_entries.select { |entry| test_file?(entry) }
84
+ @test_entries ||= project_entries.select(&:test_file?)
47
85
  end
48
86
 
87
+ # All source code entries from the backtrace (i.e. excluding tests)
88
+ #
89
+ # @return [Line] the backtrace lines from within the source code parsed as Backtrace::Line's
49
90
  def source_code_entries
50
91
  @source_code_entries ||= project_entries - test_entries
51
92
  end
52
93
 
94
+ # All lines of the backtrace converted to Backtrace::Line's
95
+ #
96
+ # @return [Line] the full set of backtrace lines parsed as Backtrace::Line instances
53
97
  def parsed_entries
54
98
  return [] if raw_backtrace.nil?
55
99
 
56
- @parsed_entries ||= raw_backtrace.map { |entry| Line.parse_backtrace(entry) }
57
- end
58
-
59
- private
60
-
61
- def parse(entry)
62
- Line.parse_backtrace(entry)
63
- end
64
-
65
- def test_file?(entry)
66
- entry.file.to_s.end_with?('_test.rb') || entry.file.to_s.start_with?('test_')
100
+ @parsed_entries ||= raw_backtrace.map { |entry| Backtrace::Line.parse_backtrace(entry) }
67
101
  end
68
102
  end
69
103
  end
@@ -5,7 +5,7 @@ require 'forwardable'
5
5
  module Minitest
6
6
  module Heat
7
7
  # Kind of like an issue, but instead of focusing on a failing test, it covers all issues for a
8
- # given file
8
+ # given file to build a heat map of the affected files
9
9
  class Hit
10
10
  # So we can sort hot spots by liklihood of being the most important spot to check out before
11
11
  # trying to fix something. These are ranked based on the possibility they represent ripple
@@ -15,46 +15,41 @@ module Minitest
15
15
  # test is broken (i.e. raising an exception), that's a special sort of failure that would be
16
16
  # misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
17
17
  WEIGHTS = {
18
- error: 3, # exceptions from source code have the highest likelihood 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
18
+ error: 5, # exceptions from source code have the highest likelihood of a ripple effect
19
+ broken: 4, # broken tests won't have ripple effects but can't help if they can't run
20
+ failure: 3, # failures are kind of the whole point, and they could have ripple effects
21
+ skipped: 2, # skips aren't failures, but they shouldn't go ignored
22
+ painful: 1, # slow tests aren't failures, but they shouldn't be ignored
23
23
  slow: 0
24
24
  }.freeze
25
25
 
26
26
  attr_reader :pathname, :issues
27
27
 
28
+ # Creates an instance of a Hit for the given pathname. It must be the full pathname to
29
+ # uniquely identify the file or we could run into collisions that muddy the water and
30
+ # obscure which files had which errors on which line numbers
31
+ # @param pathname [Pathname,String] the full pathname to the file
32
+ #
33
+ # @return [self]
28
34
  def initialize(pathname)
29
35
  @pathname = Pathname(pathname)
30
36
  @issues = {}
31
37
  end
32
38
 
39
+ # Adds a record of a given issue type for the line number
40
+ # @param type [Symbol] one of Issue::TYPES
41
+ # @param line_number [Integer,String] the line number to record the issue on
42
+ #
43
+ # @return [type] [description]
33
44
  def log(type, line_number)
34
45
  @issues[type] ||= []
35
- @issues[type] << line_number
36
- end
37
-
38
- def mtime
39
- pathname.mtime
40
- end
41
-
42
- def age_in_seconds
43
- (Time.now - mtime).to_i
44
- end
45
-
46
- def critical_issues?
47
- issues[:error].any? || issues[:broken].any? || issues[:failure].any?
48
- end
49
-
50
- def issue_count
51
- count = 0
52
- Issue::TYPES.each do |issue_type|
53
- count += issues.fetch(issue_type) { [] }.size
54
- end
55
- count
46
+ @issues[type] << Integer(line_number)
56
47
  end
57
48
 
49
+ # Calcuates an approximate weight to serve as a proxy for which files are most likely to be
50
+ # the most problematic across the various issue types
51
+ #
52
+ # @return [Integer] the problem weight for the file
58
53
  def weight
59
54
  weight = 0
60
55
  issues.each_pair do |type, values|
@@ -63,6 +58,9 @@ module Minitest
63
58
  weight
64
59
  end
65
60
 
61
+ # The total issue count for the file across all issue types. Includes duplicates if they exist
62
+ #
63
+ # @return [Integer] the sum of the counts for all line numbers for all issue types
66
64
  def count
67
65
  count = 0
68
66
  issues.each_pair do |_type, values|
@@ -71,6 +69,9 @@ module Minitest
71
69
  count
72
70
  end
73
71
 
72
+ # The full set of unique line numbers across all issue types
73
+ #
74
+ # @return [Array<Integer>] the full set of unique offending line numbers for the hit
74
75
  def line_numbers
75
76
  line_numbers = []
76
77
  issues.each_pair do |_type, values|
@@ -17,36 +17,68 @@ module Minitest
17
17
  painful: 3.0
18
18
  }.freeze
19
19
 
20
- attr_reader :result, :location, :failure
21
-
22
- def_delegators :@result, :passed?, :error?, :skipped?
23
- def_delegators :@location, :backtrace
20
+ attr_reader :assertions,
21
+ :location,
22
+ :message,
23
+ :test_class,
24
+ :test_identifier,
25
+ :execution_time,
26
+ :passed,
27
+ :error,
28
+ :skipped
29
+
30
+ def_delegators :@location, :backtrace, :test_definition_line, :test_failure_line
31
+
32
+ # Extracts the necessary data from result.
33
+ # @param result [Minitest::Result] the instance of Minitest::Result to examine
34
+ #
35
+ # @return [Issue] the instance of the issue to use for examining the result
36
+ def self.from_result(result)
37
+ # Not all results are failures, so we use the safe navigation operator
38
+ exception = result.failure&.exception
39
+
40
+ new(
41
+ assertions: result.assertions,
42
+ location: result.source_location,
43
+ test_class: result.klass,
44
+ test_identifier: result.name,
45
+ execution_time: result.time,
46
+ passed: result.passed?,
47
+ error: result.error?,
48
+ skipped: result.skipped?,
49
+ message: exception&.message,
50
+ backtrace: exception&.backtrace,
51
+ )
52
+ end
53
+
54
+ # Creates an instance of Issue. In general, the `from_result` approach will be more convenient
55
+ # for standard usage, but for lower-level purposes like testing, the initializer provides3
56
+ # more fine-grained control
57
+ # @param assertions: 1 [Integer] the number of assertions in the result
58
+ # @param message: nil [String] exception if there is one
59
+ # @param backtrace: [] [Array<String>] the array of backtrace lines from an exception
60
+ # @param location: nil [String] the location identifier for a test
61
+ # @param test_class: nil [String] the class name for the test result's containing class
62
+ # @param test_identifier: nil [String] the name of the test
63
+ # @param execution_time: nil [Float] the time it took to run the test
64
+ # @param passed: false [Boolean] true if the test explicitly passed, false otherwise
65
+ # @param error: false [Boolean] true if the test raised an exception
66
+ # @param skipped: false [Boolean] true if the test was skipped
67
+ #
68
+ # @return [type] [description]
69
+ def initialize(assertions: 1, location: ['unknown', 1], backtrace: [], execution_time: 0.0, message: nil, test_class: nil, test_identifier: nil, passed: false, error: false, skipped: false)
70
+ @message = message
24
71
 
25
- def initialize(result)
26
- @result = result
72
+ @assertions = Integer(assertions)
73
+ @location = Location.new(location, backtrace)
27
74
 
28
- @failure = result.failures.any? ? result.failures.first : nil
29
- @location = Location.new(result.source_location, @failure&.backtrace)
30
- end
31
-
32
- # Returns the primary location of the issue with the present working directory removed from
33
- # the string for conciseness
34
- #
35
- # @return [String] the pathname for the file relative to the present working directory
36
- def short_location
37
- location.to_s.delete_prefix(Dir.pwd)
38
- end
75
+ @test_class = test_class
76
+ @test_identifier = test_identifier
77
+ @execution_time = Float(execution_time)
39
78
 
40
- # Converts an issue to the key attributes for recording a 'hit'
41
- #
42
- # @return [Array] the filename, failure line, and issue type for categorizing a 'hit' to
43
- # support generating the heat map
44
- def to_hit
45
- [
46
- location.project_file.to_s,
47
- location.project_failure_line,
48
- type
49
- ]
79
+ @passed = passed
80
+ @error = error
81
+ @skipped = skipped
50
82
  end
51
83
 
52
84
  # Classifies different issue types so they can be categorized, organized, and prioritized.
@@ -58,7 +90,7 @@ module Minitest
58
90
  # painfully slow and should get more attention.
59
91
  #
60
92
  # @return [Symbol] issue type for classifying issues and reporting
61
- def type
93
+ def type # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
62
94
  if error? && in_test?
63
95
  :broken
64
96
  elsif error?
@@ -81,7 +113,7 @@ module Minitest
81
113
  #
82
114
  # @return [Boolean] true if the test did not pass or if it was slow
83
115
  def hit?
84
- !passed? || slow?
116
+ !passed? || slow? || painful?
85
117
  end
86
118
 
87
119
  # Determines if a test should be considered slow by comparing it to the low end definition of
@@ -89,7 +121,7 @@ module Minitest
89
121
  #
90
122
  # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:slow]`
91
123
  def slow?
92
- time >= SLOW_THRESHOLDS[:slow]
124
+ execution_time >= SLOW_THRESHOLDS[:slow] && execution_time < SLOW_THRESHOLDS[:painful]
93
125
  end
94
126
 
95
127
  # Determines if a test should be considered painfully slow by comparing it to the high end
@@ -97,7 +129,7 @@ module Minitest
97
129
  #
98
130
  # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:painful]`
99
131
  def painful?
100
- time >= SLOW_THRESHOLDS[:painful]
132
+ execution_time >= SLOW_THRESHOLDS[:painful]
101
133
  end
102
134
 
103
135
  # Determines if the issue is an exception that was raised from directly within a test
@@ -117,57 +149,44 @@ module Minitest
117
149
  location.proper_failure?
118
150
  end
119
151
 
120
- def test_class
121
- result.klass
122
- end
123
-
124
- def test_identifier
125
- result.name
126
- end
127
-
128
- def test_name
129
- test_identifier.delete_prefix('test_').gsub('_', ' ').capitalize
130
- end
131
-
132
- def exception
133
- failure.exception
134
- end
135
-
136
- def time
137
- result.time
152
+ # Was the result a pass? i.e. Skips aren't passes or failures. Slows are still passes. So this
153
+ # is purely a measure of whether the test explicitly passed all assertions
154
+ #
155
+ # @return [Boolean] false for errors, failures, or skips, true for passes (including slows)
156
+ def passed?
157
+ passed
138
158
  end
139
159
 
140
- def slowness
141
- "#{time.round(2)}s"
160
+ # Was there an exception that triggered a failure?
161
+ #
162
+ # @return [Boolean] true if there's an exception
163
+ def error?
164
+ error
142
165
  end
143
166
 
144
- def label
145
- if error? && in_test?
146
- # When the exception came out of the test itself, that's a different kind of exception
147
- # that really only indicates there's a problem with the code in the test. It's kind of
148
- # between an error and a test.
149
- 'Broken Test'
150
- elsif error? || !passed?
151
- failure.result_label
152
- elsif painful?
153
- 'Passed but Very Slow'
154
- elsif slow?
155
- 'Passed but Slow'
156
- end
167
+ # Was the test skipped?
168
+ #
169
+ # @return [Boolean] true if the test was explicitly skipped, false otherwise
170
+ def skipped?
171
+ skipped
157
172
  end
158
173
 
174
+ # The more nuanced detail of the failure. If it's an error, digs into the exception. Otherwise
175
+ # uses the message from the result
176
+ #
177
+ # @return [String] a more detailed explanation of the issue
159
178
  def summary
160
- error? ? exception_parts[0] : exception.message
179
+ # When there's an exception, use the first line from the exception message. Otherwise, the
180
+ # message represents explanation for a test failure, and should be used in full
181
+ error? ? first_line_of_exception_message : message
161
182
  end
162
183
 
163
- def freshest_file
164
- backtrace.recently_modified.first
165
- end
166
-
167
- private
168
-
169
- def exception_parts
170
- exception.message.split("\n")
184
+ # Returns the first line of an exception message when the issue is from a proper exception
185
+ # failure since exception messages can be long and cumbersome.
186
+ #
187
+ # @return [String] the first line of the exception message
188
+ def first_line_of_exception_message
189
+ message.split("\n")[0]
171
190
  end
172
191
  end
173
192
  end