minitest-heat 1.1.0 → 1.3.0

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.
data/RELEASING.md ADDED
@@ -0,0 +1,191 @@
1
+ # Releasing Minitest Heat
2
+
3
+ ## Quick Reference
4
+
5
+ ```bash
6
+ # 1. Update version and changelog
7
+ # 2. Commit and push
8
+ git add -A && git commit -m "Release vX.Y.Z" && git push
9
+
10
+ # 3. Tag and push
11
+ git tag vX.Y.Z && git push origin vX.Y.Z
12
+
13
+ # Done - automation handles the rest
14
+ ```
15
+
16
+ ## How It Works
17
+
18
+ Releases are fully automated via GitHub Actions:
19
+
20
+ 1. **Branch protection** requires CI to pass before merging to `main`
21
+ 2. When you push a version tag, the release workflow:
22
+ - Validates the tag points to a commit on `main` (ensures CI passed)
23
+ - Builds and publishes the gem to RubyGems
24
+ - Creates a GitHub Release with changelog excerpt
25
+
26
+ No redundant test runs. If it's on `main`, it already passed CI.
27
+
28
+ ## Release Steps
29
+
30
+ ### 1. Update Version
31
+
32
+ Edit `lib/minitest/heat/version.rb`:
33
+
34
+ ```ruby
35
+ VERSION = 'X.Y.Z'
36
+ ```
37
+
38
+ ### 2. Update CHANGELOG
39
+
40
+ Move items from `[Unreleased]` to a new version section:
41
+
42
+ ```markdown
43
+ ## [Unreleased]
44
+
45
+ ## [X.Y.Z] - YYYY-MM-DD
46
+
47
+ ### Added
48
+ - New feature description
49
+
50
+ ### Fixed
51
+ - Bug fix description
52
+ ```
53
+
54
+ ### 3. Commit, Tag, Push
55
+
56
+ ```bash
57
+ git add lib/minitest/heat/version.rb CHANGELOG.md
58
+ git commit -m "Release vX.Y.Z"
59
+ git push origin main
60
+ git tag vX.Y.Z
61
+ git push origin vX.Y.Z
62
+ ```
63
+
64
+ ### 4. Verify
65
+
66
+ - Watch the [Actions tab](https://github.com/garrettdimon/minitest-heat/actions) for workflow completion
67
+ - Check [RubyGems](https://rubygems.org/gems/minitest-heat) for the new version
68
+ - Check [GitHub Releases](https://github.com/garrettdimon/minitest-heat/releases) for the release page
69
+
70
+ ## Versioning Policy
71
+
72
+ Follow [Semantic Versioning](https://semver.org/):
73
+
74
+ - **MAJOR** (x.0.0): Breaking changes to public API or configuration
75
+ - **MINOR** (0.x.0): New features, deprecations
76
+ - **PATCH** (0.0.x): Bug fixes, documentation
77
+
78
+ ### What's a Breaking Change?
79
+
80
+ - Removing or renaming public classes/methods
81
+ - Changing method signatures incompatibly
82
+ - Changing default configuration behavior
83
+ - Dropping Ruby version support
84
+
85
+ ## Local Tools
86
+
87
+ ### Full Preflight Check
88
+
89
+ Run all checks before pushing:
90
+
91
+ ```bash
92
+ bundle exec rake release:preflight
93
+ ```
94
+
95
+ This runs tests, security audit, and release validation in sequence.
96
+
97
+ ### Individual Tasks
98
+
99
+ | Task | Purpose |
100
+ |------|---------|
101
+ | `rake release:preflight` | Run all checks (test, audit, check) |
102
+ | `rake release:check` | Validate version format, changelog entry, git state |
103
+ | `rake release:audit` | Check for vulnerable dependencies |
104
+ | `rake release:dry_run` | Build gem locally, show contents and size |
105
+ | `rake test` | Run test suite |
106
+
107
+ ### Preview a Release
108
+
109
+ Before tagging, preview what the gem will contain:
110
+
111
+ ```bash
112
+ bundle exec rake release:dry_run
113
+ ```
114
+
115
+ This builds the gem, displays its contents and size, then cleans up.
116
+
117
+ ## One-Time Setup
118
+
119
+ ### RubyGems Trusted Publishing
120
+
121
+ 1. Go to [rubygems.org/profile/oidc/pending_trusted_publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
122
+ 2. Add trusted publisher:
123
+ - **Gem name:** `minitest-heat`
124
+ - **Repository owner:** `garrettdimon`
125
+ - **Repository name:** `minitest-heat`
126
+ - **Workflow filename:** `release.yml`
127
+ - **Environment:** `rubygems`
128
+
129
+ ### GitHub Environment
130
+
131
+ 1. Go to repository Settings > Environments
132
+ 2. Create environment named `rubygems`
133
+ 3. (Optional) Add required reviewers for extra safety
134
+
135
+ ### Repository Ruleset
136
+
137
+ 1. Go to repository Settings > Rules > Rulesets
138
+ 2. Edit the `main` ruleset (or create one targeting the default branch)
139
+ 3. Enable "Require status checks to pass" with these checks:
140
+ - `Security`
141
+ - `Test (Ruby 3.1)`
142
+ - `Test (Ruby 3.2)`
143
+ - `Test (Ruby 3.3)`
144
+ - `Test (Ruby 3.4)`
145
+ - `Test (Ruby 4.0)`
146
+ - `Changelog`
147
+ - `Version`
148
+
149
+ ## Troubleshooting
150
+
151
+ ### Release workflow fails with "must point to commit on main"
152
+
153
+ You tagged a commit that isn't on the main branch. Delete the tag and re-tag a commit on main:
154
+
155
+ ```bash
156
+ git tag -d vX.Y.Z # Delete local tag
157
+ git push origin :vX.Y.Z # Delete remote tag
158
+ git checkout main
159
+ git pull
160
+ git tag vX.Y.Z
161
+ git push origin vX.Y.Z
162
+ ```
163
+
164
+ ### "You do not have permission to push to this gem"
165
+
166
+ The RubyGems trusted publisher isn't configured, or the environment name doesn't match. Check the one-time setup steps above.
167
+
168
+ ### Forgot to update CHANGELOG
169
+
170
+ Delete the tag, update CHANGELOG, amend the commit, re-tag:
171
+
172
+ ```bash
173
+ git tag -d vX.Y.Z
174
+ git push origin :vX.Y.Z
175
+ # Update CHANGELOG.md
176
+ git add CHANGELOG.md
177
+ git commit --amend --no-edit
178
+ git push --force-with-lease origin main
179
+ git tag vX.Y.Z
180
+ git push origin vX.Y.Z
181
+ ```
182
+
183
+ ## Manual Release (Fallback)
184
+
185
+ If automation fails and you need to publish manually:
186
+
187
+ ```bash
188
+ bundle exec rake release
189
+ ```
190
+
191
+ This builds the gem and pushes to RubyGems using your local credentials.
data/Rakefile CHANGED
@@ -10,3 +10,120 @@ Rake::TestTask.new(:test) do |t|
10
10
  end
11
11
 
12
12
  task default: :test
13
+
14
+ # rubocop:disable Metrics/BlockLength
15
+ namespace :release do
16
+ desc 'Run bundle-audit to check for vulnerable dependencies'
17
+ task :audit do
18
+ puts 'Running security audit...'
19
+ sh 'bundle exec bundle-audit check --update'
20
+ end
21
+
22
+ desc 'Validate version, changelog, and git state before release'
23
+ task :check do
24
+ require_relative 'lib/minitest/heat/version'
25
+ errors = ReleaseChecker.new(Minitest::Heat::VERSION).validate
26
+ if errors.any?
27
+ puts "\nRelease check failed:"
28
+ errors.each { |e| puts " - #{e}" }
29
+ exit 1
30
+ else
31
+ puts 'All release checks passed.'
32
+ end
33
+ end
34
+
35
+ desc 'Run all pre-release checks (tests, audit, release:check)'
36
+ task preflight: %i[test audit check] do
37
+ puts "\nAll preflight checks passed. Ready to release."
38
+ end
39
+
40
+ desc 'Build gem locally and show contents (dry run)'
41
+ task :dry_run do
42
+ require_relative 'lib/minitest/heat/version'
43
+ DryRun.new(Minitest::Heat::VERSION).run
44
+ end
45
+ end
46
+ # rubocop:enable Metrics/BlockLength
47
+
48
+ # Validates release readiness
49
+ class ReleaseChecker
50
+ def initialize(version)
51
+ @version = version
52
+ @errors = []
53
+ end
54
+
55
+ def validate
56
+ puts "Checking release readiness for v#{@version}..."
57
+ check_version_format
58
+ check_changelog
59
+ check_git_clean
60
+ check_main_branch
61
+ @errors
62
+ end
63
+
64
+ private
65
+
66
+ def check_version_format
67
+ return if @version.match?(/\A\d+\.\d+\.\d+\z/)
68
+
69
+ @errors << "Version '#{@version}' is not valid semver (expected X.Y.Z)"
70
+ end
71
+
72
+ def check_changelog
73
+ changelog = File.read('CHANGELOG.md')
74
+ return if changelog.include?("[#{@version}]")
75
+
76
+ @errors << "CHANGELOG.md has no entry for version #{@version}"
77
+ end
78
+
79
+ def check_git_clean
80
+ return if `git status --porcelain`.empty?
81
+
82
+ @errors << 'Working directory has uncommitted changes'
83
+ end
84
+
85
+ def check_main_branch
86
+ current_branch = `git branch --show-current`.strip
87
+ return if current_branch == 'main'
88
+
89
+ @errors << "Not on main branch (currently on '#{current_branch}')"
90
+ end
91
+ end
92
+
93
+ # Builds gem and displays contents without publishing
94
+ class DryRun
95
+ def initialize(version)
96
+ @version = version
97
+ @gem_file = "minitest-heat-#{version}.gem"
98
+ end
99
+
100
+ def run
101
+ build || abort('Gem build failed')
102
+ show_contents
103
+ show_size
104
+ ensure
105
+ cleanup if File.exist?(@gem_file)
106
+ end
107
+
108
+ private
109
+
110
+ def build
111
+ puts "Building #{@gem_file}..."
112
+ system 'gem build minitest-heat.gemspec --silent'
113
+ end
114
+
115
+ def show_contents
116
+ puts "\nGem contents:"
117
+ system "tar -tf #{@gem_file}"
118
+ end
119
+
120
+ def show_size
121
+ size = File.size(@gem_file)
122
+ puts "\nGem size: #{(size / 1024.0).round(1)} KB"
123
+ end
124
+
125
+ def cleanup
126
+ File.delete(@gem_file)
127
+ puts "Cleaned up #{@gem_file}"
128
+ end
129
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Minitest
4
+ module Heat
5
+ class Backtrace
6
+ # Determines an optimal line count for backtrace locations in order to have relevant
7
+ # information but keep the backtrace as compact as possible
8
+ class LineCount
9
+ DEFAULT_LINE_COUNT = 20
10
+
11
+ attr_accessor :locations
12
+
13
+ def initialize(locations)
14
+ @locations = locations
15
+ end
16
+
17
+ def earliest_project_location
18
+ locations.rindex { |element| element.project_file? }
19
+ end
20
+
21
+ def max_location
22
+ [locations.size - 1, 0].max
23
+ end
24
+
25
+ def limit
26
+ return 0 if locations.empty?
27
+
28
+ # Find the minimum relevant index, then add 1 to convert from index to count
29
+ [
30
+ DEFAULT_LINE_COUNT - 1,
31
+ earliest_project_location,
32
+ max_location
33
+ ].compact.min + 1
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -10,8 +10,10 @@ module Minitest
10
10
  module LineParser
11
11
  # Parses a line from a backtrace in order to convert it to usable components
12
12
  def self.read(raw_text)
13
- raw_pathname, raw_line_number, raw_container = raw_text.split(':')
14
- raw_container = raw_container.delete_prefix('in `').delete_suffix("'")
13
+ return nil if raw_text.nil? || raw_text.empty?
14
+
15
+ raw_pathname, raw_line_number, raw_container = raw_text.to_s.split(':')
16
+ raw_container = raw_container&.delete_prefix('in `')&.delete_suffix("'")
15
17
 
16
18
  ::Minitest::Heat::Location.new(
17
19
  pathname: raw_pathname,
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'backtrace/line_count'
3
4
  require_relative 'backtrace/line_parser'
4
5
 
5
6
  module Minitest
@@ -30,7 +31,7 @@ module Minitest
30
31
  def locations
31
32
  return [] if raw_backtrace.nil?
32
33
 
33
- @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) }
34
+ @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) }.compact
34
35
  end
35
36
 
36
37
  # All entries from the backtrace within the project and sorted with the most recently modified
@@ -6,12 +6,17 @@ module Minitest
6
6
  module Heat
7
7
  # For managing configuration options on how Minitest Heat should handle results
8
8
  class Configuration
9
+ DEFAULTS = {
10
+ slow_threshold: 1.0,
11
+ painfully_slow_threshold: 3.0
12
+ }.freeze
13
+
9
14
  attr_accessor :slow_threshold,
10
15
  :painfully_slow_threshold
11
16
 
12
17
  def initialize
13
- @slow_threshold = 1.0
14
- @painfully_slow_threshold = 3.0
18
+ @slow_threshold = DEFAULTS[:slow_threshold]
19
+ @painfully_slow_threshold = DEFAULTS[:painfully_slow_threshold]
15
20
  end
16
21
  end
17
22
  end
@@ -63,33 +63,49 @@ module Minitest
63
63
  #
64
64
  # @return [Integer] the problem weight for the file
65
65
  def weight
66
- weight = 0
67
- issues.each_pair do |type, values|
68
- weight += values.size * WEIGHTS.fetch(type, 0)
69
- end
70
- weight
66
+ issues.sum { |type, values| values.size * WEIGHTS.fetch(type, 0) }
71
67
  end
72
68
 
73
69
  # The total issue count for the file across all issue types. Includes duplicates if they exist
74
70
  #
75
71
  # @return [Integer] the sum of the counts for all line numbers for all issue types
76
72
  def count
77
- count = 0
78
- issues.each_pair do |_type, values|
79
- count += values.size
80
- end
81
- count
73
+ issues.sum { |_type, values| values.size }
82
74
  end
83
75
 
84
76
  # The full set of unique line numbers across all issue types
85
77
  #
86
78
  # @return [Array<Integer>] the full set of unique offending line numbers for the hit
87
79
  def line_numbers
88
- line_numbers = []
89
- issues.each_pair do |_type, values|
90
- line_numbers += values
80
+ issues.values.flatten.uniq.sort
81
+ end
82
+
83
+ # Generates a hash representation for JSON serialization
84
+ #
85
+ # @return [Hash] hit data with file, weight, and line details
86
+ def to_h
87
+ {
88
+ file: relative_path,
89
+ weight: weight,
90
+ lines: lines_summary
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def relative_path
97
+ pathname.to_s.delete_prefix("#{Dir.pwd}/")
98
+ end
99
+
100
+ def lines_summary
101
+ line_numbers.map do |line_num|
102
+ traces = lines[line_num.to_s] || []
103
+ {
104
+ line: line_num,
105
+ types: traces.map(&:type).uniq,
106
+ count: traces.size
107
+ }
91
108
  end
92
- line_numbers.uniq.sort
93
109
  end
94
110
  end
95
111
  end
@@ -10,12 +10,12 @@ module Minitest
10
10
 
11
11
  TYPES = %i[error broken failure skipped painful slow].freeze
12
12
 
13
- # Long-term, these could be configurable so that people can determine their own thresholds of
14
- # pain for slow tests
15
- SLOW_THRESHOLDS = {
16
- slow: 1.0,
17
- painful: 3.0
18
- }.freeze
13
+ # # Long-term, these could be configurable so that people can determine their own thresholds of
14
+ # # pain for slow tests
15
+ # SLOW_THRESHOLDS = {
16
+ # slow: 1.0,
17
+ # painful: 3.0
18
+ # }.freeze
19
19
 
20
20
  attr_reader :assertions,
21
21
  :locations,
@@ -120,14 +120,14 @@ module Minitest
120
120
  #
121
121
  # @return [Float] number of seconds after which a test is considered slow
122
122
  def slow_threshold
123
- Minitest::Heat.configuration.slow_threshold || SLOW_THRESHOLDS[:slow]
123
+ Minitest::Heat.configuration.slow_threshold
124
124
  end
125
125
 
126
126
  # The number, in seconds, for a test to be considered "painfully slow"
127
127
  #
128
128
  # @return [Float] number of seconds after which a test is considered painfully slow
129
129
  def painfully_slow_threshold
130
- Minitest::Heat.configuration.painfully_slow_threshold || SLOW_THRESHOLDS[:painful]
130
+ Minitest::Heat.configuration.painfully_slow_threshold
131
131
  end
132
132
 
133
133
  # Determines if a test should be considered slow by comparing it to the low end definition of
@@ -200,7 +200,41 @@ module Minitest
200
200
  #
201
201
  # @return [String] the first line of the exception message
202
202
  def first_line_of_exception_message
203
- message.split("\n")[0]
203
+ return '' if message.nil? || message.empty?
204
+
205
+ text = message.split("\n")[0].to_s
206
+
207
+ text.size > exception_message_limit ? "#{text[0..exception_message_limit]}..." : text
208
+ end
209
+
210
+ def exception_message_limit
211
+ 200
212
+ end
213
+
214
+ # Generates a hash representation for JSON serialization
215
+ #
216
+ # @return [Hash] issue data
217
+ def to_h
218
+ {
219
+ type: type,
220
+ test_class: test_class,
221
+ test_name: test_identifier,
222
+ execution_time: execution_time,
223
+ assertions: assertions,
224
+ message: message,
225
+ test_location: locations.test_definition&.to_h,
226
+ failure_location: failure_location_hash
227
+ }
228
+ end
229
+
230
+ private
231
+
232
+ def failure_location_hash
233
+ location = locations.most_relevant
234
+ return nil if location.nil?
235
+ return nil if location == locations.test_definition
236
+
237
+ location.to_h
204
238
  end
205
239
  end
206
240
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module Minitest
4
6
  module Heat
5
7
  # Consistent structure for extracting information about a given location. In addition to the
@@ -42,6 +44,17 @@ module Minitest
42
44
  ]
43
45
  end
44
46
 
47
+ # Generates a hash representation for JSON serialization
48
+ #
49
+ # @return [Hash] location data with file, line, and container
50
+ def to_h
51
+ {
52
+ file: relative_filename,
53
+ line: line_number,
54
+ container: container
55
+ }
56
+ end
57
+
45
58
  # A short relative pathname and line number pair
46
59
  #
47
60
  # @return [String] the short filename/line number combo. ex. `dir/file.rb:23`
@@ -133,7 +146,7 @@ module Minitest
133
146
  #
134
147
  # @return [Boolean] true if the file is in the project (source code or test) but not vendored
135
148
  def project_file?
136
- path.include?(project_root_dir) && !bundled_file?
149
+ path.include?(project_root_dir) && !bundled_file? && !binstub_file?
137
150
  end
138
151
 
139
152
  # Determines if the file is in the project `vendor/bundle` directory.
@@ -143,6 +156,15 @@ module Minitest
143
156
  path.include?("#{project_root_dir}/vendor/bundle")
144
157
  end
145
158
 
159
+ # Determines if the file is in the project `bin` directory. With binstub'd gems, they'll
160
+ # appear to be source code because the code is located in the project directory. This helps
161
+ # make sure the backtraces don't think that's the case
162
+ #
163
+ # @return [Boolean] true if the file is in `<project_root>/bin
164
+ def binstub_file?
165
+ path.include?("#{project_root_dir}/bin")
166
+ end
167
+
146
168
  # Determines if a given file follows the standard approaching to naming test files.
147
169
  #
148
170
  # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb`
@@ -154,7 +176,7 @@ module Minitest
154
176
  #
155
177
  # @return [Boolean] true if the file is in the project but not a test file or vendored file
156
178
  def source_code_file?
157
- project_file? && !test_file? && !bundled_file?
179
+ project_file? && !test_file?
158
180
  end
159
181
 
160
182
  # A safe interface to getting the last modified time for the file in question
@@ -30,6 +30,13 @@ module Minitest
30
30
  hot_files.take(MAXIMUM_FILES_TO_SHOW)
31
31
  end
32
32
 
33
+ # Generates a hash representation for JSON serialization
34
+ #
35
+ # @return [Array<Hash>] array of hit hashes sorted by weight (highest first)
36
+ def to_h
37
+ hot_files.map(&:to_h)
38
+ end
39
+
33
40
  private
34
41
 
35
42
  # Sorts the files by hit "weight" so that the most problematic files are at the beginning
@@ -5,7 +5,6 @@ module Minitest
5
5
  class Output
6
6
  # Builds the collection of tokens for displaying a backtrace when an exception occurs
7
7
  class Backtrace
8
- DEFAULT_LINE_COUNT = 10
9
8
  DEFAULT_INDENTATION_SPACES = 2
10
9
 
11
10
  attr_accessor :locations, :backtrace
@@ -25,14 +24,14 @@ module Minitest
25
24
  #
26
25
  # @return [Integer] the number of lines to limit the backtrace to
27
26
  def line_count
28
- # Defined as a method instead of using the constant directlyr in order to easily support
27
+ # Defined as a method instead of using the constant directly in order to easily support
29
28
  # adding options for controlling how many lines are displayed from a backtrace.
30
29
  #
31
30
  # For example, instead of a fixed number, the backtrace could dynamically calculate how
32
31
  # many lines it should displaye in order to get to the origination point. Or it could have
33
32
  # a default, but inteligently go back further if the backtrace meets some criteria for
34
33
  # displaying more lines.
35
- DEFAULT_LINE_COUNT
34
+ ::Minitest::Heat::Backtrace::LineCount.new(backtrace.locations).limit
36
35
  end
37
36
 
38
37
  # A subset of parsed lines from the backtrace.
@@ -49,7 +48,7 @@ module Minitest
49
48
  # it could display the entire backtrace without filtering anything.
50
49
  # - It could scan the backtrace to the first appearance of project files and then display
51
50
  # all of the lines that occurred after that instance
52
- # - It coudl filter the lines differently whether the issue originated from a test or from
51
+ # - It could filter the lines differently whether the issue originated from a test or from
53
52
  # the source code.
54
53
  # - It could allow supporting a "compact" or "robust" reporter style so that someone on
55
54
  # a smaller screen could easily reduce the information shown so that the results could
@@ -122,7 +121,8 @@ module Minitest
122
121
  def source_code_line_token(location)
123
122
  return nil unless location.project_file?
124
123
 
125
- [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"]
124
+ source_line = location.source_code.line
125
+ [:muted, " #{Output::SYMBOLS[:arrow]} `#{source_line&.strip || '(source unavailable)'}`"]
126
126
  end
127
127
 
128
128
  def containining_element_token(location)