├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .travis.yml ├── .gitignore ├── lib └── minitest │ ├── heat │ ├── version.rb │ ├── configuration.rb │ ├── backtrace │ │ ├── line_parser.rb │ │ └── line_count.rb │ ├── output │ │ ├── marker.rb │ │ ├── token.rb │ │ ├── results.rb │ │ ├── issue.rb │ │ ├── source_code.rb │ │ ├── backtrace.rb │ │ └── map.rb │ ├── map.rb │ ├── backtrace.rb │ ├── results.rb │ ├── timer.rb │ ├── hit.rb │ ├── locations.rb │ ├── source.rb │ ├── output.rb │ ├── location.rb │ └── issue.rb │ ├── heat_plugin.rb │ ├── heat.rb │ └── heat_reporter.rb ├── bin ├── setup └── console ├── test ├── minitest │ ├── heat │ │ ├── output │ │ │ ├── issue_test.rb │ │ │ ├── map_test.rb │ │ │ ├── results_test.rb │ │ │ ├── marker_test.rb │ │ │ ├── backtrace_test.rb │ │ │ ├── token_test.rb │ │ │ └── source_code_test.rb │ │ ├── results_test.rb │ │ ├── output_test.rb │ │ ├── backtrace │ │ │ ├── line_parser_test.rb │ │ │ └── line_count_test.rb │ │ ├── hit_test.rb │ │ ├── configuration_test.rb │ │ ├── map_test.rb │ │ ├── timer_test.rb │ │ ├── backtrace_test.rb │ │ ├── locations_test.rb │ │ ├── source_test.rb │ │ ├── issue_test.rb │ │ └── location_test.rb │ ├── heat_test.rb │ ├── contrived_locations.rb │ ├── contrived_issue.rb │ ├── contrived_setup_failures_test.rb │ ├── contrived_code.rb │ ├── contrived_skips_and_slows_test.rb │ ├── contrived_heat_map_test.rb │ └── contrived_examples_test.rb ├── files │ └── source.rb └── test_helper.rb ├── Gemfile ├── Rakefile ├── .rubocop.yml ├── LICENSE.txt ├── CHANGELOG.md ├── Gemfile.lock ├── minitest-heat.gemspec ├── README.md └── CODE_OF_CONDUCT.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: garrettdimon 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.2 6 | before_install: gem install bundler -v 2.1.4 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /rubocop_cache/ 10 | -------------------------------------------------------------------------------- /lib/minitest/heat/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | VERSION = '1.2.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /test/minitest/heat/output/issue_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::IssueTest < Minitest::Test 6 | end 7 | -------------------------------------------------------------------------------- /test/minitest/heat/output/map_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::MapTest < Minitest::Test 6 | end 7 | -------------------------------------------------------------------------------- /test/minitest/heat/output/results_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::ResultsTest < Minitest::Test 6 | end 7 | -------------------------------------------------------------------------------- /test/minitest/heat/results_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::ResultsTest < Minitest::Test 6 | def setup; end 7 | end 8 | -------------------------------------------------------------------------------- /test/files/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | line = if true 4 | :four 5 | else 6 | :six 7 | end 8 | 9 | raise line 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in minitest-heat.gemspec 6 | gemspec 7 | 8 | gem 'minitest', '~> 5.0' 9 | gem 'rake', '~> 12.0' 10 | -------------------------------------------------------------------------------- /test/minitest/heat/output_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::OutputTest < Minitest::Test 6 | def test_something 7 | assert true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/minitest/heat_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::HeatTest < Minitest::Test 6 | def test_that_it_has_a_version_number 7 | refute_nil ::Minitest::Heat::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << 'test' 8 | t.libs << 'lib' 9 | t.test_files = FileList['test/**/*_test.rb'] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "minitest/heat" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /test/minitest/heat/output/marker_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::MarkerTest < Minitest::Test 6 | def test_token 7 | marker = Minitest::Heat::Output::Marker.new(:error) 8 | assert_equal [:error, 'E'], marker.token 9 | end 10 | 11 | def test_for_unknown_issue_type 12 | marker = Minitest::Heat::Output::Marker.new(:fake_type) 13 | assert_equal [:default, '?'], marker.token 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push,pull_request] 4 | 5 | env: 6 | CI: true 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest] 13 | ruby: [2.7.6, 3.0.4, 3.1.2, 3.2.0-preview2] 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | bundler-cache: true 22 | - name: Run Tests 23 | run: bundle exec rake 24 | -------------------------------------------------------------------------------- /test/minitest/contrived_locations.rb: -------------------------------------------------------------------------------- 1 | # Re-open these classes to add bogus methods that raise errors from inside the source rather than 2 | # directly from the tests. These need to be in a separate file from `contrived_examples_test.rb` 3 | # so Minitest::Heat doesn't perceive them as occuring from a test file since it treats exceptions 4 | # from test files and source files differently. 5 | module Minitest 6 | module Heat 7 | class Locations 8 | def self.raise_example_error_in_location 9 | raise StandardError, 'Invalid Location Exception' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/minitest/heat_plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'heat_reporter' 4 | 5 | module Minitest # rubocop:disable Style/Documentation 6 | def self.plugin_heat_init(options) 7 | io = options.fetch(:io, $stdout) 8 | 9 | reporter.reporters.reject! do |reporter| 10 | # Minitest Heat acts as a unified Progress *and* Summary reporter. Using other reporters of 11 | # those types in conjunction with it creates some overly-verbose output 12 | reporter.is_a?(ProgressReporter) || reporter.is_a?(SummaryReporter) 13 | end 14 | 15 | # Hook up Reviewer 16 | self.reporter.reporters << HeatReporter.new(io, options) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/minitest/heat/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Minitest 6 | module Heat 7 | # For managing configuration options on how Minitest Heat should handle results 8 | class Configuration 9 | DEFAULTS = { 10 | slow_threshold: 1.0, 11 | painfully_slow_threshold: 3.0 12 | }.freeze 13 | 14 | attr_accessor :slow_threshold, 15 | :painfully_slow_threshold 16 | 17 | def initialize 18 | @slow_threshold = DEFAULTS[:slow_threshold] 19 | @painfully_slow_threshold = DEFAULTS[:painfully_slow_threshold] 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | UseCache: true 4 | CacheRootDirectory: './' 5 | TargetRubyVersion: 2.5.9 6 | Exclude: 7 | - 'bin/**/*' 8 | - 'test/files/source.rb' # An example test file for reading source code 9 | 10 | # Let's aim for 80, but we don't need to be nagged if we judiciously go over. 11 | Layout/LineLength: 12 | Enabled: false 13 | 14 | # One case statement in a single method isn't complex. 15 | Metrics/CyclomaticComplexity: 16 | IgnoredMethods: ['case'] 17 | 18 | # 10 is a good goal but a little draconian 19 | Metrics/MethodLength: 20 | CountAsOne: ['array', 'hash', 'heredoc'] 21 | Max: 15 22 | 23 | Style/ClassAndModuleChildren: 24 | Enabled: false 25 | -------------------------------------------------------------------------------- /test/minitest/contrived_issue.rb: -------------------------------------------------------------------------------- 1 | # Re-open these classes to add bogus methods that raise errors from inside the source rather than 2 | # directly from the tests. These need to be in a separate file from `contrived_examples_test.rb` 3 | # so Minitest::Heat doesn't perceive them as occuring from a test file since it treats exceptions 4 | # from test files and source files differently. 5 | module Minitest 6 | module Heat 7 | class Issue 8 | def self.raise_example_error_from_issue 9 | Locations.raise_example_error_in_location 10 | end 11 | 12 | def self.raise_another_example_error_from_issue 13 | Locations.raise_example_error_in_location 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/minitest/heat/backtrace/line_parser_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Backtrace::LineParserParserTest < Minitest::Test 6 | def setup 7 | @filename = __FILE__ 8 | @line_number = 23 9 | @container = 'method_name' 10 | backtrace_line = "#{@filename}:#{@line_number}:in `#{@container}'" 11 | 12 | @location = Minitest::Heat::Backtrace::LineParser.read(backtrace_line) 13 | end 14 | 15 | def test_parsing_extracts_pathname 16 | assert_equal Pathname(@filename), @location.pathname 17 | end 18 | 19 | def test_parsing_extracts_line_number 20 | assert_equal @line_number, @location.line_number 21 | end 22 | 23 | def test_parsing_extracts_container 24 | assert_equal @container, @location.container 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/minitest/heat/output/backtrace_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::BacktraceTest < Minitest::Test 6 | def setup 7 | @test_location = ["#{Dir.pwd}/test/minitest/heat_test.rb", 23] 8 | @raw_backtrace = [ 9 | "#{Dir.pwd}/lib/minitest/heat.rb:29:in `method_name'", 10 | "#{Dir.pwd}/test/minitest/heat_test.rb:27:in `other_method_name'", 11 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:98:in `block (3 levels) in run'", 12 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:195:in `capture_exceptions'", 13 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:95:in `block (2 levels) in run'" 14 | ] 15 | 16 | @location = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 17 | 18 | @backtrace = ::Minitest::Heat::Output::Backtrace.new(@location) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/minitest/heat/backtrace/line_parser.rb: -------------------------------------------------------------------------------- 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 | module LineParser 11 | # Parses a line from a backtrace in order to convert it to usable components 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("'") 15 | 16 | ::Minitest::Heat::Location.new( 17 | pathname: raw_pathname, 18 | line_number: raw_line_number, 19 | container: raw_container 20 | ) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/minitest/heat/backtrace/line_count.rb: -------------------------------------------------------------------------------- 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 23 | end 24 | 25 | def limit 26 | [ 27 | DEFAULT_LINE_COUNT, 28 | earliest_project_location, 29 | max_location 30 | ].compact.min 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/minitest/contrived_setup_failures_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require_relative 'contrived_code' 6 | 7 | # rubocop:disable 8 | 9 | # This set of tests and related code only exists to force a range of failure types for improving the 10 | # visual presentation of the various errors based on different contexts 11 | if ENV['FORCE_FAILURES'] || ENV['IMPLODE'] 12 | 13 | class Minitest::ContrivedSetupFailuresTest < Minitest::Test 14 | def setup 15 | @example_lambda = -> { raise StandardError, 'This happened in the setup' } 16 | end 17 | 18 | def test_trigger_the_first_exception 19 | assert true 20 | @example_lambda.call 21 | end 22 | 23 | def test_trigger_the_second_exception 24 | refute false 25 | @example_lambda.call 26 | end 27 | 28 | def test_trigger_the_third_exception 29 | @example_lambda.call 30 | refute false 31 | end 32 | end 33 | end 34 | 35 | # rubocop:enable 36 | -------------------------------------------------------------------------------- /test/minitest/contrived_code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'contrived_issue' 4 | require_relative 'contrived_locations' 5 | 6 | # Re-open these classes to add bogus methods that raise errors from inside the source rather than 7 | # directly from the tests. These need to be in a separate file from `contrived_examples_test.rb` 8 | # so Minitest::Heat doesn't perceive them as occuring from a test file since it treats exceptions 9 | # from test files and source files differently. 10 | module Minitest 11 | module Heat 12 | def self.raise_example_error 13 | Issue.raise_example_error_from_issue 14 | end 15 | 16 | def self.raise_another_example_error 17 | Issue.raise_another_example_error_from_issue 18 | end 19 | 20 | def self.increase_the_stack_level 21 | increase_the_stack_level_more 22 | end 23 | 24 | def self.increase_the_stack_level_more 25 | increase_the_stack_level_even_more 26 | end 27 | 28 | def self.increase_the_stack_level_even_more 29 | increase_the_stack_level 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV['COVERAGE'] 4 | require 'simplecov' 5 | 6 | SimpleCov.print_error_status = false 7 | SimpleCov.start do 8 | enable_coverage :branch 9 | minimum_coverage 100 10 | minimum_coverage_by_file 100 11 | refuse_coverage_drop 12 | end 13 | 14 | if ENV['CI'] == 'true' 15 | require 'codecov' 16 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 17 | else 18 | # With the JSON formatter, Reviewwer can look at the results and show guidance without needing 19 | # to open the HTML view 20 | formatters = [ 21 | SimpleCov::Formatter::SimpleFormatter, 22 | SimpleCov::Formatter::HTMLFormatter 23 | ] 24 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new(formatters) 25 | end 26 | end 27 | 28 | require 'debug' 29 | 30 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 31 | 32 | require 'awesome_print' 33 | require 'minitest/heat' 34 | require 'minitest/autorun' 35 | 36 | Minitest::Heat.configure do |config| 37 | config.slow_threshold = 0.0005 38 | config.painfully_slow_threshold = 0.01 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Garrett Dimon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/minitest/heat/hit_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::HitTest < Minitest::Test 6 | def setup 7 | @filename = __FILE__ 8 | @type = :error 9 | @line_number = 23 10 | 11 | @pathname = Pathname(@filename) 12 | 13 | @hit = ::Minitest::Heat::Hit.new(@filename) 14 | end 15 | 16 | def test_starts_with_empty_values 17 | assert_equal 0, @hit.weight 18 | assert_equal 0, @hit.count 19 | assert_empty @hit.issues 20 | assert_empty @hit.line_numbers 21 | end 22 | 23 | def test_logs_issues 24 | @hit.log(@type, @line_number) 25 | 26 | refute_empty @hit.issues 27 | end 28 | 29 | def test_calculates_hit_weight 30 | @hit.log(@type, @line_number) 31 | 32 | expected_weight = ::Minitest::Heat::Hit::WEIGHTS[@type] 33 | assert_equal expected_weight, @hit.weight 34 | end 35 | 36 | def test_calculates_hit_count 37 | @hit.log(@type, @line_number) 38 | 39 | assert_equal 1, @hit.count 40 | end 41 | 42 | def test_tracks_line_numbers 43 | @hit.log(@type, @line_number) 44 | 45 | assert_includes @hit.line_numbers, @line_number 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | Nothing at the moment. 4 | 5 | ## [1.2.0] - 2022-10-31 6 | 7 | Mainly some improvements to make test failures more resilient and improve the formatting when there are issues. 8 | 9 | - Don't consider binstubs project files when colorizing the stacktrace. 10 | - Switch debugging from Pry to debug 11 | - Ensure overly-long exception messages are truncated to reduce excessive scrolling 12 | - Make backtrace display smarter about how many lines to display 13 | - Fix bug that was incorrectly deleting the bin directory 14 | - Prepare for better handling of "stack level too deep" traces 15 | 16 | ## [1.1.0] - 2021-12-09 17 | 18 | The biggest update is that the slow thresholds are now configurable. 19 | 20 | - Configurable Thresholds 21 | - Fixed a bug where `vendor/bundle` gem files were considered project source code files 22 | - Set up [GitHub Actions](https://github.com/garrettdimon/minitest-heat/actions) to ensure tests are run on Ubuntu latest and macOs for the [latest Ruby versions](https://github.com/garrettdimon/minitest-heat/blob/main/.github/workflows/main.yml) 23 | - Fixed some tests that were accidentally left path-dependent and couldn't pass on other machines 24 | 25 | ## [1.0.0] - 2021-12-01 26 | 27 | Initial release. 28 | 29 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/marker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Friendly API for printing consistent markers for the various issue types 7 | class Marker 8 | SYMBOLS = { 9 | success: '·', 10 | slow: '♦', 11 | painful: '♦', 12 | broken: 'B', 13 | error: 'E', 14 | skipped: 'S', 15 | failure: 'F', 16 | reporter: '✖' 17 | }.freeze 18 | 19 | STYLES = { 20 | success: :success, 21 | slow: :slow, 22 | painful: :painful, 23 | broken: :error, 24 | error: :error, 25 | skipped: :skipped, 26 | failure: :failure, 27 | reporter: :error 28 | }.freeze 29 | 30 | attr_accessor :issue_type 31 | 32 | def initialize(issue_type) 33 | @issue_type = issue_type 34 | end 35 | 36 | def token 37 | [style, symbol] 38 | end 39 | 40 | private 41 | 42 | def style 43 | STYLES.fetch(issue_type, :default) 44 | end 45 | 46 | def symbol 47 | SYMBOLS.fetch(issue_type, '?') 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/minitest/heat/configuration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::ConfigurationTest < Minitest::Test 6 | def setup 7 | Minitest::Heat.reset 8 | 9 | # Make the tests read a little less messy 10 | @defaults = Minitest::Heat::Configuration::DEFAULTS 11 | @config = Minitest::Heat.configuration 12 | end 13 | 14 | def test_default_slow_thresholds 15 | assert_equal @defaults[:slow_threshold], @config.slow_threshold 16 | assert_equal @defaults[:painfully_slow_threshold], @config.painfully_slow_threshold 17 | end 18 | 19 | def test_slow_thresholds_can_be_configured 20 | slow_threshold = @config.slow_threshold 21 | painfully_slow_threshold = @config.painfully_slow_threshold 22 | 23 | # Change the settings to verify they get set appropriately 24 | Minitest::Heat.configure do |config| 25 | config.slow_threshold += 1.0 26 | config.painfully_slow_threshold += 1.0 27 | end 28 | 29 | assert_equal (slow_threshold + 1.0), @config.slow_threshold 30 | assert_equal (painfully_slow_threshold + 1.0), @config.painfully_slow_threshold 31 | 32 | # Return the settings to the previous values for the rest of the tests 33 | Minitest::Heat.configure do |config| 34 | config.slow_threshold -= 1.0 35 | config.painfully_slow_threshold -= 1.0 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/minitest/contrived_skips_and_slows_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require_relative 'contrived_code' 6 | 7 | # rubocop:disable 8 | 9 | # This set of tests and related code only exists to force a range of failure types for improving the 10 | # visual presentation of the various errors based on different contexts 11 | if ENV['FORCE_SKIPS'] || ENV['FORCE_SLOWS'] || ENV['IMPLODE'] 12 | 13 | class Minitest::ContrivedSkipsAndSlowsTest < Minitest::Test 14 | def test_something_that_is_not_ready_yet 15 | return if ENV['FORCE_SLOWS'] 16 | 17 | skip 'The test was explicitly skipped' 18 | end 19 | 20 | def test_something_temporarily_broken 21 | return if ENV['FORCE_SLOWS'] 22 | 23 | skip 'The test is temporarily broken' 24 | end 25 | 26 | def test_exposes_when_tests_are_slow 27 | sleep Minitest::Heat::Configuration.slow_threshold + 0.1 28 | assert true 29 | end 30 | 31 | def test_exposes_when_tests_are_top_three_slow 32 | sleep Minitest::Heat::Configuration.painfully_slow_threshold + 0.1 33 | assert true 34 | end 35 | 36 | def test_exposes_when_tests_are_slow_but_not_top_three 37 | sleep Minitest::Heat::Configuration.slow_threshold + 0.05 38 | assert true 39 | end 40 | 41 | private 42 | 43 | def raise_example_error(message) 44 | -> { raise StandardError, message } 45 | end 46 | end 47 | end 48 | 49 | # rubocop:enable 50 | -------------------------------------------------------------------------------- /lib/minitest/heat/map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | # Structured approach to collecting the locations of issues for generating a heat map 6 | class Map 7 | MAXIMUM_FILES_TO_SHOW = 5 8 | 9 | attr_reader :hits 10 | 11 | def initialize 12 | @hits = {} 13 | end 14 | 15 | # Records a hit to the list of files and issue types 16 | # @param filename [String] the unique path and file name for recordings hits 17 | # @param line_number [Integer] the line number where the issue was encountered 18 | # @param type [Symbol] the type of issue that was encountered (i.e. :failure, :error, etc.) 19 | # 20 | # @return [void] 21 | def add(pathname, line_number, type, backtrace: []) 22 | @hits[pathname.to_s] ||= Hit.new(pathname) 23 | @hits[pathname.to_s].log(type.to_sym, line_number, backtrace: backtrace) 24 | end 25 | 26 | # Returns a subset of affected files to keep the list from being overwhelming 27 | # 28 | # @return [Array] the list of files and the line numbers for each encountered issue type 29 | def file_hits 30 | hot_files.take(MAXIMUM_FILES_TO_SHOW) 31 | end 32 | 33 | private 34 | 35 | # Sorts the files by hit "weight" so that the most problematic files are at the beginning 36 | # 37 | # @return [Array] the collection of files that encountred issues 38 | def hot_files 39 | hits.values.sort_by(&:weight).reverse 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/minitest/heat/map_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::MapTest < Minitest::Test 6 | def setup 7 | @map = Minitest::Heat::Map.new 8 | @filename = 'dir/file.rb' 9 | end 10 | 11 | def test_initializes_hits 12 | assert_equal({}, @map.hits) 13 | end 14 | 15 | def test_initializes_new_file_entries_total 16 | @map.add(@filename, 5, :error) 17 | assert_equal 1, @map.hits[@filename].count 18 | end 19 | 20 | def test_initializes_new_file_entries_with_affected_type_line_number_entry 21 | @map.add(@filename, 5, :error) 22 | assert_includes @map.hits[@filename].issues[:error], 5 23 | end 24 | 25 | def test_returns_sorted_list_of_files # rubocop:disable Metrics/AbcSize 26 | 4.times { @map.add("four_#{@filename}", 1, :error) } 27 | 2.times { @map.add("two_#{@filename}", 1, :error) } 28 | 3.times { @map.add("three_#{@filename}", 1, :error) } 29 | 3.times { @map.add("three_#{@filename}", 1, :failure) } 30 | 2.times { @map.add("two_#{@filename}", 1, :failure) } 31 | 32 | files = @map.file_hits 33 | 34 | assert_equal 3, files.size 35 | largest_weight = files[0].weight 36 | smallest_weight = files[2].weight 37 | assert largest_weight > smallest_weight 38 | end 39 | 40 | def test_logs_issues 41 | @map.add(@filename, 5, :error) 42 | @map.add(@filename, 8, :error) 43 | 44 | hit = @map.hits[@filename] 45 | 46 | assert_equal 10, hit.weight 47 | assert_equal 2, hit.count 48 | assert_equal [5, 8], hit.line_numbers 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/minitest/heat/output/token_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::TokenTest < Minitest::Test 6 | def setup 7 | @token = ::Minitest::Heat::Output::Token.new(:success, 'Success') 8 | end 9 | 10 | def test_converts_token_params_to_a_nice_string 11 | assert_equal "\e[0;32mSuccess\e[0m", @token.to_s 12 | end 13 | 14 | def test_converts_token_params_to_vanilla_string_when_styles_disabled 15 | assert_equal 'Success', @token.to_s(:bland) 16 | end 17 | 18 | def test_raises_error_for_unrecognized_styles 19 | token = ::Minitest::Heat::Output::Token.new(:missing_style, 'Success') 20 | 21 | assert_raises ::Minitest::Heat::Output::Token::InvalidStyle do 22 | token.to_s 23 | end 24 | end 25 | 26 | def test_considers_tokens_equivalent_with_same_style_and_content 27 | token = ::Minitest::Heat::Output::Token.new(:success, 'Success') 28 | other_token = ::Minitest::Heat::Output::Token.new(:success, 'Success') 29 | 30 | assert_equal token, other_token 31 | end 32 | 33 | def test_considers_tokens_different_with_different_styles 34 | token = ::Minitest::Heat::Output::Token.new(:success, 'Success') 35 | other_token = ::Minitest::Heat::Output::Token.new(:failure, 'Success') 36 | 37 | refute_equal token, other_token 38 | end 39 | 40 | def test_considers_tokens_different_with_different_content 41 | token = ::Minitest::Heat::Output::Token.new(:success, 'Success') 42 | other_token = ::Minitest::Heat::Output::Token.new(:success, 'Failure') 43 | 44 | refute_equal token, other_token 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/minitest/heat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'heat/configuration' 4 | require_relative 'heat/backtrace' 5 | require_relative 'heat/hit' 6 | require_relative 'heat/issue' 7 | require_relative 'heat/location' 8 | require_relative 'heat/locations' 9 | require_relative 'heat/map' 10 | require_relative 'heat/output' 11 | require_relative 'heat/results' 12 | require_relative 'heat/source' 13 | require_relative 'heat/timer' 14 | require_relative 'heat/version' 15 | 16 | module Minitest 17 | # Custom Minitest reporter focused on generating output designed around efficiently identifying 18 | # issues and potential solutions 19 | # - Colorize the Output 20 | # - What files had the most errors? 21 | # - Show the most impacted areas first. 22 | # - Show lowest-level (most nested code) frist. 23 | # 24 | # Pulls from existing reporters: 25 | # https://github.com/seattlerb/minitest/blob/master/lib/minitest.rb#L554 26 | # 27 | # Lots of insight from: 28 | # http://www.monkeyandcrow.com/blog/reading_ruby_minitest_plugin_system/ 29 | # 30 | # And a good example available at: 31 | # https://github.com/adamsanderson/minitest-snail 32 | # 33 | # Pulls from minitest-color as well: 34 | # https://github.com/teoljungberg/minitest-color/blob/master/lib/minitest/color_plugin.rb 35 | module Heat 36 | class << self 37 | attr_writer :configuration 38 | end 39 | 40 | def self.configuration 41 | @configuration ||= Configuration.new 42 | end 43 | 44 | def self.reset 45 | @configuration = Configuration.new 46 | end 47 | 48 | def self.configure 49 | yield(configuration) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/minitest/heat/output/source_code_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Output::SourceCodeTest < Minitest::Test 6 | def setup 7 | @filename = __FILE__ 8 | @line_number = 3 9 | @source_code = ::Minitest::Heat::Output::SourceCode.new(@filename, @line_number) 10 | end 11 | 12 | def test_knows_max_line_number_digits 13 | @source_code = ::Minitest::Heat::Output::SourceCode.new(@filename, 3) 14 | assert_equal 1, @source_code.max_line_number_digits 15 | 16 | @source_code = ::Minitest::Heat::Output::SourceCode.new(@filename, 10) 17 | assert_equal 2, @source_code.max_line_number_digits 18 | end 19 | 20 | def test_defaults_to_three_lines_of_code 21 | # One line specified, so we should only have one line of tokens 22 | assert_equal 3, @source_code.max_line_count 23 | assert_equal 3, @source_code.tokens.size 24 | end 25 | 26 | def test_limits_lines_of_code_to_max_line_count 27 | @source_code = ::Minitest::Heat::Output::SourceCode.new(@filename, @line_number, max_line_count: 1) 28 | 29 | # One line specified, so we should only have one line of tokens 30 | assert_equal 1, @source_code.max_line_count 31 | assert_equal 1, @source_code.tokens.size 32 | end 33 | 34 | def test_builds_tokens_for_lines_of_code 35 | @source_code = ::Minitest::Heat::Output::SourceCode.new(@filename, @line_number, max_line_count: 1) 36 | 37 | # Line number token has spacing 38 | expected_line_number_token = [:default, " #{@line_number} "] 39 | expected_line_of_code_token = [:default, "require 'test_helper'"] 40 | 41 | line = @source_code.tokens.first 42 | assert_equal expected_line_number_token, line[0] 43 | assert_equal expected_line_of_code_token, line[1] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | minitest-heat (1.2.0) 5 | minitest 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.2) 11 | awesome_print (1.9.2) 12 | dead_end (4.0.0) 13 | debug (1.6.2) 14 | irb (>= 1.3.6) 15 | reline (>= 0.3.1) 16 | docile (1.4.0) 17 | io-console (0.5.11) 18 | irb (1.4.1) 19 | reline (>= 0.3.0) 20 | json (2.6.2) 21 | minitest (5.16.3) 22 | parallel (1.22.1) 23 | parser (3.1.2.1) 24 | ast (~> 2.4.1) 25 | rainbow (3.1.1) 26 | rake (12.3.3) 27 | regexp_parser (2.5.0) 28 | reline (0.3.1) 29 | io-console (~> 0.5) 30 | rexml (3.2.5) 31 | rubocop (1.36.0) 32 | json (~> 2.3) 33 | parallel (~> 1.10) 34 | parser (>= 3.1.2.1) 35 | rainbow (>= 2.2.2, < 4.0) 36 | regexp_parser (>= 1.8, < 3.0) 37 | rexml (>= 3.2.5, < 4.0) 38 | rubocop-ast (>= 1.20.1, < 2.0) 39 | ruby-progressbar (~> 1.7) 40 | unicode-display_width (>= 1.4.0, < 3.0) 41 | rubocop-ast (1.21.0) 42 | parser (>= 3.1.1.0) 43 | rubocop-minitest (0.22.1) 44 | rubocop (>= 0.90, < 2.0) 45 | rubocop-rake (0.6.0) 46 | rubocop (~> 1.0) 47 | ruby-progressbar (1.11.0) 48 | simplecov (0.21.2) 49 | docile (~> 1.1) 50 | simplecov-html (~> 0.11) 51 | simplecov_json_formatter (~> 0.1) 52 | simplecov-html (0.12.3) 53 | simplecov_json_formatter (0.1.4) 54 | unicode-display_width (2.3.0) 55 | 56 | PLATFORMS 57 | arm64-darwin-21 58 | x86_64-linux 59 | 60 | DEPENDENCIES 61 | awesome_print 62 | dead_end 63 | debug 64 | minitest (~> 5.0) 65 | minitest-heat! 66 | rake (~> 12.0) 67 | rubocop 68 | rubocop-minitest 69 | rubocop-rake 70 | simplecov 71 | 72 | BUNDLED WITH 73 | 2.3.22 74 | -------------------------------------------------------------------------------- /test/minitest/contrived_heat_map_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require_relative 'contrived_code' 6 | 7 | # rubocop:disable 8 | 9 | # This set of tests and related code only exists to force a range of failure types for improving the 10 | # visual presentation of the various errors based on different contexts 11 | if ENV['FORCE_FAILURES'] || ENV['IMPLODE'] 12 | 13 | class Minitest::ContrivedHeatMapTest < Minitest::Test 14 | def test_trigger_the_first_exception 15 | example_indirect_code(raise_error: true) 16 | assert true 17 | end 18 | 19 | def test_trigger_the_second_exception 20 | other_example_indirect_code(raise_error: true) 21 | refute false 22 | end 23 | 24 | def test_trigger_the_third_exception 25 | even_more_example_indirect_code(raise_error: true) 26 | refute false 27 | end 28 | 29 | private 30 | 31 | # Both tests call this method which then calls a different method 32 | def example_indirect_code(raise_error: false) 33 | return unless raise_error 34 | 35 | raise_error_indirectly 36 | end 37 | 38 | # Both tests call this method which then calls a different method 39 | def other_example_indirect_code(raise_error: false) 40 | return unless raise_error 41 | 42 | even_more_example_indirect_code(raise_error: raise_error) 43 | end 44 | 45 | # Both tests call this method which then calls a different method 46 | def even_more_example_indirect_code(raise_error: false) 47 | return unless raise_error 48 | 49 | raise_error_indirectly 50 | end 51 | 52 | def raise_error_indirectly 53 | # Both tests should end up here and thus have duplicate entries in the heat map 54 | raise StandardError, 'Here is an indirectly-raised exception' 55 | end 56 | end 57 | end 58 | 59 | # rubocop:enable 60 | -------------------------------------------------------------------------------- /test/minitest/heat/timer_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::TimerTest < Minitest::Test 6 | def setup 7 | @timer = ::Minitest::Heat::Timer.new 8 | end 9 | 10 | def test_starting_timer 11 | assert_nil @timer.start_time 12 | @timer.start! 13 | refute_nil @timer.start_time 14 | end 15 | 16 | def test_stopping_timer 17 | assert_nil @timer.stop_time 18 | @timer.stop! 19 | refute_nil @timer.stop_time 20 | end 21 | 22 | def test_total_time 23 | # For fixing the timing to be exactly 10 seconds 24 | fixed_start_time = 1_000_000.012345 25 | fixed_stop_time = 1_000_010.012345 26 | fixed_delta = fixed_stop_time - fixed_start_time 27 | 28 | @timer.stub(:start_time, fixed_start_time) do 29 | @timer.stub(:stop_time, fixed_stop_time) do 30 | assert_equal fixed_delta, @timer.total_time 31 | end 32 | end 33 | end 34 | 35 | def test_updating_counts 36 | assert_equal 0, @timer.test_count 37 | assert_equal 0, @timer.assertion_count 38 | @timer.increment_counts(3) 39 | assert_equal 1, @timer.test_count 40 | assert_equal 3, @timer.assertion_count 41 | end 42 | 43 | def test_tests_per_second 44 | assertion_count = 1 45 | @timer.start! 46 | @timer.increment_counts(assertion_count) 47 | @timer.stop! 48 | 49 | # 1 assertion and 1 test, so the rates should be equal 50 | assert_equal @timer.tests_per_second, @timer.assertions_per_second 51 | 52 | expected_rate = (1 / @timer.total_time).round(2) 53 | assert_equal expected_rate, @timer.tests_per_second 54 | end 55 | 56 | def test_assertions_per_second 57 | assertion_count = 3 58 | @timer.start! 59 | @timer.increment_counts(assertion_count) 60 | @timer.stop! 61 | 62 | # 3 assertions but 1 test, so the rates should be different 63 | refute_equal @timer.tests_per_second, @timer.assertions_per_second 64 | 65 | expected_rate = (assertion_count / @timer.total_time).round(2) 66 | assert_equal expected_rate, @timer.assertions_per_second 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /minitest-heat.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/minitest/heat/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'minitest-heat' 7 | spec.version = Minitest::Heat::VERSION 8 | spec.authors = ['Garrett Dimon'] 9 | spec.email = ['email@garrettdimon.com'] 10 | 11 | spec.summary = 'Presents test results in a visual manner to guide you to where to look first.' 12 | spec.description = 'Presents test results in a visual manner to guide you to where to look first.' 13 | spec.homepage = 'https://github.com/garrettdimon/minitest-heat' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = Gem::Requirement.new('>= 2.7.6') 16 | 17 | spec.metadata['homepage_uri'] = spec.homepage 18 | spec.metadata['bug_tracker_uri'] = 'https://github.com/garrettdimon/minitest-heat/issues' 19 | spec.metadata['changelog_uri'] = 'https://github.com/garrettdimon/minitest-heat/CHANGELOG.md' 20 | spec.metadata['documentation_uri'] = 'https://www.rubydoc.info/gems/minitest-heat' 21 | spec.metadata['source_code_uri'] = 'https://github.com/garrettdimon/minitest-heat' 22 | spec.metadata['wiki_uri'] = 'https://github.com/garrettdimon/minitest-heat/wiki' 23 | 24 | # Specify which files should be added to the gem when it is released. 25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 26 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 27 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 28 | end 29 | spec.bindir = 'exe' 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ['lib'] 32 | 33 | spec.add_runtime_dependency 'minitest' 34 | 35 | spec.add_development_dependency 'awesome_print' 36 | spec.add_development_dependency 'dead_end' 37 | spec.add_development_dependency 'debug' 38 | spec.add_development_dependency 'rubocop' 39 | spec.add_development_dependency 'rubocop-minitest' 40 | spec.add_development_dependency 'rubocop-rake' 41 | spec.add_development_dependency 'simplecov' 42 | end 43 | -------------------------------------------------------------------------------- /test/minitest/heat/backtrace_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::BacktraceTest < Minitest::Test 6 | def setup 7 | project_dir = Dir.pwd 8 | gem_dir = Gem.dir 9 | 10 | @source_code_line = "#{project_dir}/lib/minitest/heat.rb:29:in `method_name'" 11 | @test_line = "#{project_dir}/test/minitest/heat_test.rb:27:in `other_method_name'" 12 | 13 | @raw_backtrace = [ 14 | @source_code_line, 15 | @test_line, 16 | "#{gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:98:in `block (3 levels) in run'", 17 | "#{gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:195:in `capture_exceptions'", 18 | "#{gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:95:in `block (2 levels) in run'" 19 | ] 20 | @key_file = @raw_backtrace.first.split(':').first 21 | 22 | @backtrace = Minitest::Heat::Backtrace.new(@raw_backtrace) 23 | end 24 | 25 | def test_fails_gracefully_when_it_cannot_read_a_file 26 | @raw_backtrace = ["/file/does/not/exist.rb:5:in `capture_exceptions'"] 27 | @backtrace = Minitest::Heat::Backtrace.new(@raw_backtrace) 28 | 29 | refute_nil @backtrace.locations.first 30 | end 31 | 32 | def test_keeping_only_project_locations 33 | refute_equal @backtrace.locations, @backtrace.project_locations 34 | assert_equal @backtrace.locations.first, @backtrace.project_locations.first 35 | refute_equal @backtrace.locations.last, @backtrace.project_locations.last 36 | end 37 | 38 | def test_keeping_only_source_code_locations 39 | refute_equal @backtrace.locations, @backtrace.source_code_locations 40 | assert_equal @backtrace.locations.first, @backtrace.source_code_locations.first 41 | refute_equal @backtrace.locations.last, @backtrace.source_code_locations.last 42 | end 43 | 44 | def test_sorting_locations_by_modified_time 45 | # Ensure the first file was recently updated 46 | FileUtils.touch(@backtrace.locations.first.pathname) 47 | sorted_locations = @backtrace.project_locations.sort_by(&:mtime).reverse 48 | 49 | assert sorted_locations.first.mtime >= sorted_locations.last.mtime, "#{sorted_locations.first.mtime} was not greater than #{sorted_locations.last.mtime}" 50 | assert_equal sorted_locations, @backtrace.recently_modified_locations 51 | end 52 | 53 | def test_keeping_only_test_locations 54 | assert_equal 1, @backtrace.test_locations.size 55 | refute_equal @backtrace.project_locations, @backtrace.test_locations 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/minitest/heat/backtrace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'backtrace/line_count' 4 | require_relative 'backtrace/line_parser' 5 | 6 | module Minitest 7 | module Heat 8 | # Wrapper for separating backtrace into component parts 9 | class Backtrace 10 | attr_reader :raw_backtrace 11 | 12 | # Creates a more flexible backtrace data structure by parsing the lines of the backtrace to 13 | # extract specific subsets of lines for investigating the offending files and line numbers 14 | # @param raw_backtrace [Array] the array of lines from the backtrace 15 | # 16 | # @return [self] 17 | def initialize(raw_backtrace) 18 | @raw_backtrace = Array(raw_backtrace) 19 | end 20 | 21 | # Determines if the raw backtrace has values in it 22 | # 23 | # @return [Boolean] true if there's no backtrace or it's empty 24 | def empty? 25 | raw_backtrace.empty? 26 | end 27 | 28 | # All lines of the backtrace converted to Backtrace::LineParser's 29 | # 30 | # @return [Array] the full set of backtrace lines parsed as Location instances 31 | def locations 32 | return [] if raw_backtrace.nil? 33 | 34 | @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) } 35 | end 36 | 37 | # All entries from the backtrace within the project and sorted with the most recently modified 38 | # files at the beginning 39 | # 40 | # @return [Array] the sorted backtrace lines from the project 41 | def recently_modified_locations 42 | @recently_modified_locations ||= project_locations.sort_by(&:mtime).reverse 43 | end 44 | 45 | # All entries from the backtrace that are files within the project 46 | # 47 | # @return [Array] the backtrace lines from within the project 48 | def project_locations 49 | @project_locations ||= locations.select(&:project_file?) 50 | end 51 | 52 | # All entries from the backtrace within the project tests 53 | # 54 | # @return [Array] the backtrace lines from within the tests 55 | def test_locations 56 | @test_locations ||= project_locations.select(&:test_file?) 57 | end 58 | 59 | # All source code entries from the backtrace (i.e. excluding tests) 60 | # 61 | # @return [Array] the backtrace lines from within the source code 62 | def source_code_locations 63 | @source_code_locations ||= project_locations.select(&:source_code_file?) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/minitest/heat/backtrace/line_count_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::Backtrace::LineCountTest < Minitest::Test 6 | def setup 7 | @test_location = ["#{Dir.pwd}/test/minitest/heat_test.rb", 23] 8 | @raw_backtrace = [ 9 | "#{Dir.pwd}/lib/minitest/heat.rb:29:in `method_name'", 10 | "#{Dir.pwd}/test/minitest/heat_test.rb:27:in `other_method_name'", 11 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:98:in `block (3 levels) in run'", 12 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:195:in `capture_exceptions'", 13 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:95:in `block (2 levels) in run'", 14 | "#{Dir.pwd}/lib/minitest/heat.rb:29:in `method_name'", 15 | "#{Dir.pwd}/test/minitest/heat_test.rb:27:in `other_method_name'", 16 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:98:in `block (3 levels) in run'", 17 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:195:in `capture_exceptions'", 18 | "#{Gem.dir}/gems/minitest-5.14.4/lib/minitest/test.rb:95:in `block (2 levels) in run'", 19 | "/file.rb:123: in `block'", 20 | "/file.rb:123: in `block'", 21 | "/file.rb:123: in `block'" 22 | ] 23 | 24 | @locations = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 25 | @line_count = ::Minitest::Heat::Backtrace::LineCount.new(@locations.backtrace.locations) 26 | end 27 | 28 | def test_earliest_project_location 29 | assert_equal 6, @line_count.earliest_project_location 30 | end 31 | 32 | def test_max_location 33 | assert_equal 12, @line_count.max_location 34 | end 35 | 36 | def test_uses_earliest_project_location_if_present 37 | assert_equal 6, @line_count.limit 38 | end 39 | 40 | def test_uses_default_line_count_if_lots_of_non_project_locations 41 | @raw_backtrace = [] 42 | 25.times do 43 | @raw_backtrace << "/file.rb:123: in `block'" 44 | end 45 | 46 | @locations = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 47 | @line_count = ::Minitest::Heat::Backtrace::LineCount.new(@locations.backtrace.locations) 48 | 49 | assert_equal 20, @line_count.limit 50 | end 51 | 52 | def test_uses_max_if_no_project_files_and_not_enough_for_default 53 | @raw_backtrace = [] 54 | 5.times do 55 | @raw_backtrace << "/file.rb:123: in `block'" 56 | end 57 | 58 | @locations = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 59 | @line_count = ::Minitest::Heat::Backtrace::LineCount.new(@locations.backtrace.locations) 60 | 61 | assert_equal @line_count.max_location, @line_count.limit 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/minitest/heat/results.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | # A collection of test failures 6 | class Results 7 | attr_reader :issues, :heat_map 8 | 9 | def initialize 10 | @issues = [] 11 | @heat_map = Heat::Map.new 12 | end 13 | 14 | # Logs an issue to the results for later reporting 15 | # @param issue [Issue] the issue generated from a given test result 16 | # 17 | # @return [type] [description] 18 | def record(issue) 19 | # Record everything—even if it's a success 20 | @issues.push(issue) 21 | 22 | # If it's not a genuine problem, we're done here... 23 | return unless issue.hit? 24 | 25 | # ...otherwise update the heat map 26 | update_heat_map(issue) 27 | end 28 | 29 | def update_heat_map(issue) 30 | # For heat map purposes, only the project backtrace lines are interesting 31 | pathname, line_number = issue.locations.project.to_a 32 | 33 | # A backtrace is only relevant for exception-generating issues (i.e. errors), not slows or skips 34 | # However, while assertion failures won't have a backtrace, there can still be repeated line 35 | # numbers if the tests reference a shared method with an assertion in it. So in those cases, 36 | # the backtrace is simply the test definition 37 | backtrace = if issue.error? 38 | # With errors, we have a backtrace 39 | issue.locations.backtrace.project_locations 40 | else 41 | # With failures, the test definition is the most granular backtrace equivalent 42 | location = issue.locations.test_definition 43 | location.raw_container = issue.test_identifier 44 | 45 | [location] 46 | end 47 | 48 | @heat_map.add(pathname, line_number, issue.type, backtrace: backtrace) 49 | end 50 | 51 | def problems? 52 | errors.any? || brokens.any? || failures.any? 53 | end 54 | 55 | def errors 56 | @errors ||= select_issues(:error) 57 | end 58 | 59 | def brokens 60 | @brokens ||= select_issues(:broken) 61 | end 62 | 63 | def failures 64 | @failures ||= select_issues(:failure) 65 | end 66 | 67 | def skips 68 | @skips ||= select_issues(:skipped) 69 | end 70 | 71 | def painfuls 72 | @painfuls ||= select_issues(:painful).sort_by(&:execution_time).reverse 73 | end 74 | 75 | def slows 76 | @slows ||= select_issues(:slow).sort_by(&:execution_time).reverse 77 | end 78 | 79 | private 80 | 81 | def select_issues(issue_type) 82 | issues.select { |issue| issue.type == issue_type } 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Provides a convenient interface for creating console-friendly output while ensuring 7 | # consistency in the applied styles. 8 | class Token 9 | class InvalidStyle < ArgumentError; end 10 | 11 | STYLES = { 12 | success: %i[default green], 13 | slow: %i[default green], 14 | painful: %i[bold green], 15 | error: %i[bold red], 16 | broken: %i[bold red], 17 | failure: %i[default red], 18 | skipped: %i[default yellow], 19 | warning_light: %i[light yellow], 20 | italicized: %i[italic gray], 21 | bold: %i[bold default], 22 | default: %i[default default], 23 | muted: %i[light gray] 24 | }.freeze 25 | 26 | attr_accessor :style_key, :content 27 | 28 | def initialize(style_key, content) 29 | @style_key = style_key 30 | @content = content 31 | end 32 | 33 | def to_s(format = :styled) 34 | return content unless format == :styled 35 | 36 | [ 37 | style_string, 38 | content, 39 | reset_string 40 | ].join 41 | end 42 | 43 | def eql?(other) 44 | style_key == other.style_key && content == other.content 45 | end 46 | alias :== eql? 47 | 48 | private 49 | 50 | ESC_SEQUENCE = "\e[" 51 | END_SEQUENCE = 'm' 52 | 53 | WEIGHTS = { 54 | default: 0, 55 | bold: 1, 56 | light: 2, 57 | italic: 3 58 | }.freeze 59 | 60 | COLORS = { 61 | black: 30, 62 | red: 31, 63 | green: 32, 64 | yellow: 33, 65 | blue: 34, 66 | magenta: 35, 67 | cyan: 36, 68 | gray: 37, 69 | default: 39 70 | }.freeze 71 | 72 | def style_string 73 | "#{ESC_SEQUENCE}#{weight};#{color}#{END_SEQUENCE}" 74 | end 75 | 76 | def reset_string 77 | "#{ESC_SEQUENCE}0#{END_SEQUENCE}" 78 | end 79 | 80 | def weight_key 81 | style_components[0] 82 | end 83 | 84 | def color_key 85 | style_components[1] 86 | end 87 | 88 | def weight 89 | WEIGHTS.fetch(weight_key) 90 | end 91 | 92 | def color 93 | COLORS.fetch(color_key) 94 | end 95 | 96 | def style_components 97 | STYLES.fetch(style_key) { raise InvalidStyle, "'#{style_key}' is not a valid style option for tokens" } 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/minitest/heat/timer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | # Provides a timer to keep track of the full test suite duration and provide convenient methods 6 | # for calculating tests/second and assertions/second 7 | class Timer 8 | attr_reader :test_count, :assertion_count, :start_time, :stop_time 9 | 10 | # Creates an instance of a timer to be used for the duration of a test suite run 11 | # 12 | # @return [self] 13 | def initialize 14 | @test_count = 0 15 | @assertion_count = 0 16 | 17 | @start_time = nil 18 | @stop_time = nil 19 | end 20 | 21 | # Records the start time for the full test suite using `Minitest.clock_time` 22 | # 23 | # @return [Float] the Minitest.clock_time 24 | def start! 25 | @start_time = Minitest.clock_time 26 | end 27 | 28 | # Records the stop time for the full test suite using `Minitest.clock_time` 29 | # 30 | # @return [Float] the Minitest.clock_time 31 | def stop! 32 | @stop_time = Minitest.clock_time 33 | end 34 | 35 | # Calculates the total time take for the full test suite to run while ensuring it never 36 | # returns a zero that would be problematic as a denomitor in calculating average times 37 | # 38 | # @return [Float] the clocktime duration of the test suite run in seconds 39 | def total_time 40 | # Don't return 0. The time can end up being 0 for a new or realy fast test suite, and 41 | # dividing by 0 doesn't go well when determining average time, so this ensures it uses a 42 | # close-enough-but-not-zero value. 43 | delta.zero? ? 0.01 : delta 44 | end 45 | 46 | # Records the test and assertion counts for a given test outcome 47 | # @param count [Integer] the number of assertions from the test 48 | # 49 | # @return [void] 50 | def increment_counts(count) 51 | @test_count += 1 52 | @assertion_count += count 53 | end 54 | 55 | # Provides a nice rounded answer for about how many tests were completed per second 56 | # 57 | # @return [Float] the average number of tests completed per second 58 | def tests_per_second 59 | (test_count / total_time).round(2) 60 | end 61 | 62 | # Provides a nice rounded answer for about how many assertions were completed per second 63 | # 64 | # @return [Float] the average number of assertions completed per second 65 | def assertions_per_second 66 | (assertion_count / total_time).round(2) 67 | end 68 | 69 | private 70 | 71 | # The total time the test suite was running. 72 | # 73 | # @return [Float] the time in seconds elapsed between starting the timer and stopping it 74 | def delta 75 | return 0 unless start_time && stop_time 76 | 77 | stop_time - start_time 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/minitest/heat/locations_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::LocationsTest < Minitest::Test 6 | def setup 7 | @project_dir = Dir.pwd 8 | @gem_dir = Gem.dir 9 | 10 | @test_location = ["#{@project_dir}/test/minitest/heat_test.rb", 23] 11 | @raw_backtrace = [ 12 | "#{@project_dir}/lib/minitest/heat.rb:29:in `method_name'", 13 | "#{@project_dir}/test/minitest/heat_test.rb:27:in `other_method_name'", 14 | "#{@gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:98:in `block (3 levels) in run'", 15 | "#{@gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:195:in `capture_exceptions'", 16 | "#{@gem_dir}/gems/minitest-5.14.4/lib/minitest/test.rb:95:in `block (2 levels) in run'" 17 | ] 18 | 19 | @location = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 20 | end 21 | 22 | def test_can_be_initialized_without_backtrace 23 | location = Minitest::Heat::Locations.new(@test_location) 24 | refute location.backtrace.locations.any? 25 | assert_nil location.source_code 26 | refute_nil location.project.filename 27 | refute_nil location.test_failure.filename 28 | refute_nil location.final.filename 29 | end 30 | 31 | def test_knows_test_file_and_lines 32 | assert_equal 'heat_test.rb', @location.test_failure.filename 33 | assert_equal @location.test_definition.filename, @location.test_failure.filename 34 | assert_equal 23, @location.test_definition.line_number 35 | assert_equal 27, @location.test_failure.line_number 36 | end 37 | 38 | def test_knows_source_code_file_and_line 39 | assert_equal 'heat.rb', @location.source_code.filename 40 | assert_equal 29, @location.source_code.line_number 41 | end 42 | 43 | def test_knows_when_problem_is_in_source 44 | assert @location.proper_failure? 45 | end 46 | 47 | def test_knows_when_problem_is_in_test 48 | # Remove the project source line so the test is the last location 49 | @raw_backtrace.shift 50 | @location = Minitest::Heat::Locations.new(@test_location, @raw_backtrace) 51 | 52 | assert @location.backtrace.locations.any? 53 | assert @location.broken_test? 54 | end 55 | 56 | def test_backtrace_without_source_code_lines 57 | # Remove the project source line so the test is the last location 58 | @raw_backtrace.shift 59 | assert_nil @location.source_code 60 | refute_nil @location.project.filename 61 | refute_nil @location.test_failure.filename 62 | refute_nil @location.final.filename 63 | end 64 | 65 | def test_backtrace_without_source_or_test_lines 66 | # Remove the project source line so the test is the last location 67 | @raw_backtrace.shift 68 | 69 | # Remove the project test line so an external file is the last location 70 | @raw_backtrace.shift 71 | 72 | assert_nil @location.source_code 73 | refute_nil @location.test_failure.filename 74 | refute_nil @location.final.filename 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/minitest/heat/source_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::SourceTest < Minitest::Test 6 | def setup 7 | @filename = "#{Dir.pwd}/test/files/source.rb" 8 | @source = Minitest::Heat::Source.new(@filename, line_number: 5) 9 | @file_lines = File.readlines(@filename, chomp: true) 10 | end 11 | 12 | def test_fails_gracefully_when_it_cannot_read_a_file 13 | @filename = '/file/does/not/exist.rb' 14 | @source = Minitest::Heat::Source.new(@filename, line_number: 1) 15 | 16 | assert_equal [], @source.file_lines 17 | end 18 | 19 | def test_converts_to_hash 20 | @source.max_line_count = 1 21 | source_hash = { '5' => ' else' } 22 | assert_equal source_hash, @source.to_h 23 | end 24 | 25 | def test_chomps_lines 26 | raw_file_lines = File.readlines(@filename, chomp: false) 27 | assert_equal 14, raw_file_lines.length 28 | assert_equal 9, @source.file_lines.length 29 | end 30 | 31 | def test_retrieves_source_line 32 | assert_equal ' else', @source.line 33 | end 34 | 35 | def test_retrieves_array_of_one_line_by_default 36 | assert_equal [@source.line], @source.lines 37 | end 38 | 39 | def test_includes_two_surrounding_lines 40 | @source.max_line_count = 3 41 | assert_equal [4, 5, 6], @source.line_numbers 42 | assert_equal @file_lines[3..5], @source.lines 43 | end 44 | 45 | def test_includes_two_preceding_lines 46 | @source.max_line_count = 3 47 | @source.context = :before 48 | assert_equal [3, 4, 5], @source.line_numbers 49 | assert_equal @file_lines[2..4], @source.lines 50 | end 51 | 52 | def test_limits_first_line_to_first_line_of_file 53 | @source.line_number = 1 54 | @source.max_line_count = 3 55 | @source.context = :before 56 | assert_equal [1, 2, 3], @source.line_numbers 57 | assert_equal @file_lines[0..2], @source.lines 58 | end 59 | 60 | def test_includes_one_surrounding_line_on_either_side 61 | @source.max_line_count = 3 62 | @source.context = :around 63 | assert_equal [4, 5, 6], @source.line_numbers 64 | assert_equal @file_lines[3..5], @source.lines 65 | end 66 | 67 | def test_limits_lines_to_maximum_in_file 68 | @source.line_number = 1 69 | @source.max_line_count = 20 70 | @source.context = :around 71 | assert_equal (1..9).to_a, @source.line_numbers 72 | assert_equal @file_lines[0..8], @source.lines 73 | end 74 | 75 | def test_includes_two_following_lines 76 | @source.max_line_count = 3 77 | @source.context = :after 78 | assert_equal [5, 6, 7], @source.line_numbers 79 | assert_equal @file_lines[4..6], @source.lines 80 | end 81 | 82 | def test_limits_last_line_to_last_line_of_file 83 | @source.line_number = @source.file_lines.length 84 | @source.max_line_count = 3 85 | @source.context = :after 86 | assert_equal [7, 8, 9], @source.line_numbers 87 | assert_equal @file_lines[6..8], @source.lines 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/minitest/heat/issue_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::IssueTest < Minitest::Test 6 | def setup 7 | @source_filename = "#{Dir.pwd}/test/files/source.rb" 8 | @test_filename = __FILE__ # This is a test file, so it works 9 | 10 | @location = [@test_filename, 1] 11 | 12 | @source_backtrace = [ 13 | "#{@source_filename}:1:in `method_name'", 14 | "#{@test_filename}:1:in `other_method_name'" 15 | ] 16 | 17 | # This creates a version with the test file first 18 | @test_backtrace = @source_backtrace.reverse 19 | end 20 | 21 | def test_full_initialization 22 | # Raise, rescue, and assign an exception instance to ensure the full context 23 | issue = ::Minitest::Heat::Issue.new( 24 | assertions: 1, 25 | message: '', 26 | backtrace: @source_backtrace, 27 | test_location: @location, 28 | test_class: 'Minitest::ClassName', 29 | test_identifier: 'Test Name', 30 | execution_time: 1.1, 31 | passed: false, 32 | error: false, 33 | skipped: false 34 | ) 35 | refute_nil issue 36 | end 37 | 38 | def test_broken_test_issue 39 | issue = ::Minitest::Heat::Issue.new( 40 | backtrace: @test_backtrace, 41 | test_location: @location, 42 | error: true 43 | ) 44 | 45 | assert_equal :broken, issue.type 46 | assert issue.hit? 47 | refute issue.passed? 48 | assert issue.in_test? 49 | refute issue.in_source? 50 | end 51 | 52 | def test_error_issue 53 | issue = ::Minitest::Heat::Issue.new( 54 | backtrace: @source_backtrace, 55 | test_location: @location, 56 | error: true 57 | ) 58 | 59 | assert_equal :error, issue.type 60 | assert issue.error? 61 | refute issue.passed? 62 | assert issue.hit? 63 | refute issue.in_test? 64 | assert issue.in_source? 65 | end 66 | 67 | def test_skipped_issue 68 | issue = ::Minitest::Heat::Issue.new(skipped: true) 69 | 70 | assert_equal :skipped, issue.type 71 | assert issue.skipped? 72 | refute issue.passed? 73 | assert issue.hit? 74 | end 75 | 76 | def test_failure_issue 77 | issue = ::Minitest::Heat::Issue.new 78 | 79 | assert_equal :failure, issue.type 80 | refute issue.passed? 81 | assert issue.hit? 82 | end 83 | 84 | def test_painfully_slow_issue 85 | painful_time = Minitest::Heat.configuration.painfully_slow_threshold + 1.0 86 | 87 | issue = ::Minitest::Heat::Issue.new( 88 | execution_time: painful_time, 89 | passed: true 90 | ) 91 | 92 | assert_equal :painful, issue.type 93 | assert issue.passed? 94 | assert issue.hit? 95 | refute issue.slow? 96 | assert issue.painful? 97 | end 98 | 99 | def test_slow_issue 100 | slow_time = Minitest::Heat.configuration.slow_threshold 101 | issue = ::Minitest::Heat::Issue.new( 102 | execution_time: slow_time, 103 | passed: true 104 | ) 105 | 106 | assert_equal :slow, issue.type 107 | assert issue.passed? 108 | assert issue.hit? 109 | assert issue.slow? 110 | refute issue.painful? 111 | end 112 | 113 | def test_success_issue_is_not_a_hit 114 | issue = ::Minitest::Heat::Issue.new(passed: true) 115 | 116 | refute issue.hit? 117 | assert issue.passed? 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/minitest/contrived_examples_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | require_relative 'contrived_code' 6 | 7 | # rubocop:disable 8 | 9 | # This set of tests and related code only exists to force a range of failure types for improving the 10 | # visual presentation of the various errors based on different contexts 11 | class Minitest::ContrivedExamplesTest < Minitest::Test 12 | if ENV['FORCE_FAILURES'] || ENV['IMPLODE'] 13 | def test_incorrect_assertion_failure 14 | assert false 15 | end 16 | 17 | def test_failing_assertion_with_custom_error_message 18 | assert false, 'This custom error messages explains why this is bad.' 19 | end 20 | 21 | def test_match_assertion_failure 22 | assert_match(/gnirts/, 'string') 23 | end 24 | 25 | def test_emptiness_assertion_failure 26 | assert_empty [1] 27 | end 28 | 29 | def test_respond_to_assertion_failure 30 | assert_respond_to nil, :nope? 31 | end 32 | 33 | def test_sameness_assertion_failure 34 | assert_same 1, Integer('1') 35 | end 36 | 37 | def test_delta_assertion_failure 38 | assert_in_delta 3, (3 + 2), 1 39 | end 40 | 41 | def test_includes_assertion_failure 42 | assert_includes [1], 2 43 | end 44 | 45 | def test_instance_of_assertion_failure 46 | assert_instance_of Integer, 1.0 47 | end 48 | 49 | def test_nil_assertion_failure 50 | assert_nil 1 51 | end 52 | 53 | def test_equality_assertion_failure 54 | hash_one = { 55 | one: 1 56 | } 57 | hash_two = { 58 | one: 1, 59 | two: 2 60 | } 61 | assert_equal hash_one, hash_two 62 | end 63 | 64 | def test_yesterday_should_be_after_today 65 | seconds_in_a_day = 24 * 60 * 60 66 | time = Time.now - seconds_in_a_day 67 | fail_after time.year, time.month, time.day, "This explicitly failed because it was after #{time}" 68 | end 69 | 70 | def test_explicitly_flunked_example 71 | flunk 'The test was explicitly flunked' 72 | end 73 | end 74 | 75 | if ENV['FORCE_EXCEPTIONS'] || ENV['IMPLODE'] 76 | def test_raises_an_exception_from_directly_in_a_test 77 | raise StandardError, 'Testing Errors Raised Directly from a Test' 78 | end 79 | 80 | def test_raises_a_different_exception_than_the_one_expected 81 | assert_raises SystemExit do 82 | ::Minitest::Heat.raise_example_error 83 | end 84 | end 85 | 86 | def test_does_better_with_deep_stack_levels 87 | ::Minitest::Heat.increase_the_stack_level 88 | end 89 | 90 | def test_fails_after 91 | now = Time.now 92 | fail_after(now.year, now.month, now.day, 'This should explicitly fail because today is after the date') 93 | end 94 | 95 | def test_throws_assertion 96 | assert_throws :error? do 97 | throw :problem? 98 | end 99 | end 100 | 101 | def test_raises_exception_from_a_location_in_source_code_rather_than_test 102 | ::Minitest::Heat.raise_example_error 103 | end 104 | 105 | def test_raises_another_exception_from_a_different_location 106 | ::Minitest::Heat.raise_another_example_error 107 | end 108 | end 109 | 110 | private 111 | 112 | def raise_example_error(message) 113 | -> { raise StandardError, message } 114 | end 115 | end 116 | 117 | # rubocop:enable 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔥 Minitest Heat 🔥 2 | Minitest Heat helps you identify problems faster so you can more efficiently resolve test failures by generating a heat map that shows where failures are concentrated. 3 | 4 | For a more detailed explanation of Minitest Heat with screenshots, [head over to the wiki for the full story](https://github.com/garrettdimon/minitest-heat/wiki). 5 | 6 | Or for some additional insight about priorities and how it works, this [Twitter thread](https://twitter.com/garrettdimon/status/1432703746526560266) is a good read. 7 | 8 | ## Installation 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'minitest-heat' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle install 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install minitest-heat 22 | 23 | And depending on your usage, you may need to require Minitest Heat in your test suite: 24 | 25 | ```ruby 26 | require 'minitest/heat' 27 | ``` 28 | 29 | ## Configuration 30 | Minitest Heat doesn't currently offer a significant set of configuration options, but the thresholds for "Slow" and "Painfully Slow" tests can be adjusted. By default, it considers anything over 1.0s to be 'slow' and anything over 3.0s to be 'painfully slow'. 31 | 32 | You can add a configuration block to your `test_helper.rb` file after the `require 'minitest/heat'` line. 33 | 34 | For example: 35 | 36 | ```ruby 37 | Minitest::Heat.configure do |config| 38 | config.slow_threshold = 0.01 39 | config.painfully_slow_threshold = 0.5 40 | end 41 | ``` 42 | 43 | ## Development 44 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 45 | 46 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 47 | 48 | ### Forcing Test Failures 49 | In order to easily see how Minitest Heat handles different combinations of different types of failures, the following environment variables can be used to force failures. 50 | 51 | ```bash 52 | IMPLODE=true # Every possible type of failure, skip, and slow is generated 53 | FORCE_EXCEPTIONS=true # Only exception-triggered failures 54 | FORCE_FAILURES=true # Only standard assertion failures 55 | FORCE_SKIPS=true # No errors, just the skipped tests 56 | FORCE_SLOWS=true # No errors or skipped tests, just slow tests 57 | ``` 58 | 59 | So to see the full context of a test suite, `IMPLODE=true bundle exec rake` will work its magic. 60 | 61 | ## Contributing 62 | Bug reports and pull requests are welcome on GitHub at https://github.com/garrettdimon/minitest-heat. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/minitest-heat/blob/master/CODE_OF_CONDUCT.md). 63 | 64 | ## License 65 | 66 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 67 | 68 | ## Code of Conduct 69 | Everyone interacting in the Minitest::Heat project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/minitest-heat/blob/master/CODE_OF_CONDUCT.md). 70 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at email@garrettdimon.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/minitest/heat/hit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Minitest 6 | module Heat 7 | # Kind of like an issue, but instead of focusing on a failing test, it covers all issues for a 8 | # given file to build a heat map of the affected files and line numbers 9 | class Hit 10 | Trace = Struct.new(:type, :line_number, :locations) 11 | 12 | # So we can sort hot spots by liklihood of being the most important spot to check out before 13 | # trying to fix something. These are ranked based on the possibility they represent ripple 14 | # effects where fixing one problem could potentially fix multiple other failures. 15 | # 16 | # For example, if there's an exception in the file, start there. Broken code can't run. If a 17 | # test is broken (i.e. raising an exception), that's a special sort of failure that would be 18 | # misleading. It doesn't represent a proper failure, but rather a test that doesn't work. 19 | WEIGHTS = { 20 | error: 5, # exceptions from source code have the highest likelihood of a ripple effect 21 | broken: 4, # broken tests won't have ripple effects but can't help if they can't run 22 | failure: 3, # failures are kind of the whole point, and they could have ripple effects 23 | skipped: 2, # skips aren't failures, but they shouldn't go ignored 24 | painful: 1, # slow tests aren't failures, but they shouldn't be ignored 25 | slow: 0 26 | }.freeze 27 | 28 | attr_reader :pathname, :issues, :lines 29 | 30 | # Creates an instance of a Hit for the given pathname. It must be the full pathname to 31 | # uniquely identify the file or we could run into collisions that muddy the water and 32 | # obscure which files had which errors on which line numbers 33 | # @param pathname [Pathname,String] the full pathname to the file 34 | # 35 | # @return [self] 36 | def initialize(pathname) 37 | @pathname = Pathname(pathname) 38 | @issues = {} 39 | @lines = {} 40 | end 41 | 42 | # Adds a record of a given issue type for the line number 43 | # @param type [Symbol] one of Issue::TYPES 44 | # @param line_number [Integer,String] the line number to record the issue on 45 | # @param backtrace: nil [Array] the project locations from the backtrace 46 | # 47 | # @return [void] 48 | def log(type, line_number, backtrace: []) 49 | line_number = Integer(line_number) 50 | issue_type = type.to_sym 51 | 52 | # Store issues by issue type with an array of line numbers 53 | @issues[issue_type] ||= [] 54 | @issues[issue_type] << line_number 55 | 56 | # Store issues by line number with an array of Traces 57 | @lines[line_number.to_s] ||= [] 58 | @lines[line_number.to_s] << Trace.new(issue_type, line_number, backtrace) 59 | end 60 | 61 | # Calcuates an approximate weight to serve as a proxy for which files are most likely to be 62 | # the most problematic across the various issue types 63 | # 64 | # @return [Integer] the problem weight for the file 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 71 | end 72 | 73 | # The total issue count for the file across all issue types. Includes duplicates if they exist 74 | # 75 | # @return [Integer] the sum of the counts for all line numbers for all issue types 76 | def count 77 | count = 0 78 | issues.each_pair do |_type, values| 79 | count += values.size 80 | end 81 | count 82 | end 83 | 84 | # The full set of unique line numbers across all issue types 85 | # 86 | # @return [Array] the full set of unique offending line numbers for the hit 87 | def line_numbers 88 | line_numbers = [] 89 | issues.each_pair do |_type, values| 90 | line_numbers += values 91 | end 92 | line_numbers.uniq.sort 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/results.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Generates the output tokens to display the results summary 7 | class Results 8 | extend Forwardable 9 | 10 | attr_accessor :results, :timer 11 | 12 | def_delegators :@results, :issues, :errors, :brokens, :failures, :skips, :painfuls, :slows, :problems? 13 | 14 | def initialize(results, timer) 15 | @results = results 16 | @timer = timer 17 | @tokens = [] 18 | end 19 | 20 | def tokens 21 | # Only show the issue type counts if there are issues 22 | @tokens << [*issue_counts_tokens] if issue_counts_tokens&.any? 23 | 24 | @tokens << [ 25 | timing_token, spacer_token, 26 | test_count_token, tests_performance_token, join_token, 27 | assertions_count_token, assertions_performance_token 28 | ] 29 | 30 | @tokens 31 | end 32 | 33 | private 34 | 35 | def pluralize(count, singular) 36 | singular_style = "#{count} #{singular}" 37 | 38 | # Given the narrow scope, pluralization can be relatively naive here 39 | count > 1 ? "#{singular_style}s" : singular_style 40 | end 41 | 42 | def issue_counts_tokens 43 | return unless issues.any? 44 | 45 | counts = [ 46 | error_count_token, 47 | broken_count_token, 48 | failure_count_token, 49 | skip_count_token, 50 | painful_count_token, 51 | slow_count_token 52 | ].compact 53 | 54 | # # Create an array of separator tokens one less than the total number of issue count tokens 55 | spacer_tokens = Array.new(counts.size, spacer_token) 56 | 57 | counts_with_separators = counts 58 | .zip(spacer_tokens) # Add separators between the counts 59 | .flatten(1) # Flatten the zipped separators, but no more 60 | 61 | counts_with_separators.pop # Remove the final trailing zipped separator that's not needed 62 | 63 | counts_with_separators 64 | end 65 | 66 | def error_count_token 67 | issue_count_token(:error, errors) 68 | end 69 | 70 | def broken_count_token 71 | issue_count_token(:broken, brokens) 72 | end 73 | 74 | def failure_count_token 75 | issue_count_token(:failure, failures) 76 | end 77 | 78 | def skip_count_token 79 | style = problems? ? :muted : :skipped 80 | issue_count_token(style, skips, name: 'Skip') 81 | end 82 | 83 | def painful_count_token 84 | style = problems? || skips.any? ? :muted : :painful 85 | issue_count_token(style, painfuls, name: 'Painfully Slow') 86 | end 87 | 88 | def slow_count_token 89 | style = problems? || skips.any? ? :muted : :slow 90 | issue_count_token(style, slows, name: 'Slow') 91 | end 92 | 93 | def test_count_token 94 | [:default, pluralize(timer.test_count, 'test').to_s] 95 | end 96 | 97 | def tests_performance_token 98 | [:default, " (#{timer.tests_per_second}/s)"] 99 | end 100 | 101 | def assertions_count_token 102 | [:default, pluralize(timer.assertion_count, 'assertion').to_s] 103 | end 104 | 105 | def assertions_performance_token 106 | [:default, " (#{timer.assertions_per_second}/s)"] 107 | end 108 | 109 | def timing_token 110 | [:bold, "#{timer.total_time.round(2)}s"] 111 | end 112 | 113 | def issue_count_token(type, collection, name: type.capitalize) 114 | return nil if collection.empty? 115 | 116 | [type, pluralize(collection.size, name)] 117 | end 118 | 119 | def spacer_token 120 | Output::TOKENS[:spacer] 121 | end 122 | 123 | def join_token 124 | [:default, ' with '] 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/minitest/heat_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'heat' 4 | 5 | module Minitest 6 | # Custom minitest reporter to proactively identify likely culprits in test failures by focusing on 7 | # the files and line numbers with the most issues and that were most recently modified. It also 8 | # visually emphasizes results based on the most significant problems. 9 | # 1. Errors - Anything that raised an exception could have significant ripple effects. 10 | # 2. Failures - Assuming no exceptions, these are kind of important. 11 | # -- Everything else... 12 | # 3. Coverage (If using Simplecov) - If things are passing but coverage isn't up to par 13 | # 4. Skips - Don't want to skip tests. 14 | # 5. Slows (If everything good, but there's ) 15 | # - Colorize the Output 16 | # - What files had the most errors? 17 | # - Show the most impacted areas first. 18 | # - Show lowest-level (most nested code) frist. 19 | # 20 | # Pulls from existing reporters: 21 | # https://github.com/seattlerb/minitest/blob/master/lib/minitest.rb#L554 22 | # 23 | # Lots of insight from: 24 | # http://www.monkeyandcrow.com/blog/reading_ruby_minitest_plugin_system/ 25 | # 26 | # And a good example available at: 27 | # https://github.com/adamsanderson/minitest-snail 28 | # 29 | # Pulls from minitest-color as well: 30 | # https://github.com/teoljungberg/minitest-color/blob/master/lib/minitest/color_plugin.rb 31 | class HeatReporter < AbstractReporter 32 | attr_reader :output, 33 | :options, 34 | :timer, 35 | :results 36 | 37 | def initialize(io = $stdout, options = {}) 38 | super() 39 | 40 | @options = options 41 | 42 | @timer = Heat::Timer.new 43 | @results = Heat::Results.new 44 | @output = Heat::Output.new(io) 45 | end 46 | 47 | # Starts reporting on the run. 48 | def start 49 | timer.start! 50 | 51 | # A couple of blank lines to create some breathing room 52 | output.newline 53 | output.newline 54 | end 55 | 56 | # About to start running a test. This allows a reporter to show that it is starting or that we 57 | # are in the middle of a test run. 58 | def prerecord(klass, name); end 59 | 60 | # Records the data from a result. 61 | # 62 | # Minitest::Result source: 63 | # https://github.com/seattlerb/minitest/blob/f4f57afaeb3a11bd0b86ab0757704cb78db96cf4/lib/minitest.rb#L504 64 | def record(result) 65 | # Convert a Minitest Result into an "issue" to more consistently expose the data needed to 66 | # adjust the failure output to the type of failure 67 | issue = Heat::Issue.from_result(result) 68 | 69 | # Note the number of assertions for the performance summary 70 | timer.increment_counts(issue.assertions) 71 | 72 | # Record the issue to show details later 73 | results.record(issue) 74 | 75 | # Show the marker 76 | output.marker(issue.type) 77 | rescue StandardError => e 78 | display_exception_guidance(e) 79 | end 80 | 81 | def display_exception_guidance(exception) 82 | output.newline 83 | puts 'Sorry, but Minitest Heat encountered an exception recording an issue. Disabling Minitest Heat will get you back on track.' 84 | puts 'Please use the following exception details to submit an issue at https://github.com/garrettdimon/minitest-heat/issues' 85 | puts "#{exception.message}:" 86 | exception.backtrace.each do |line| 87 | puts " #{line}" 88 | end 89 | output.newline 90 | end 91 | 92 | # Outputs the summary of the run. 93 | def report 94 | timer.stop! 95 | 96 | # The list of individual issues and their associated details 97 | output.issues_list(results) 98 | 99 | # Display a short summary of the total issue counts for each category as well as performance 100 | # details for the test suite as a whole 101 | output.compact_summary(results, timer) 102 | 103 | # If there were issues, shows a short heat map summary of which files and lines were the most 104 | # common sources of issues 105 | output.heat_map(results) 106 | 107 | output.newline 108 | output.newline 109 | end 110 | 111 | # Did this run pass? 112 | def passed? 113 | results.errors.empty? && results.failures.empty? 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/minitest/heat/location_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class Minitest::Heat::LocationTest < Minitest::Test 6 | def setup 7 | @raw_pathname = __FILE__ 8 | @raw_line_number = 8 9 | @container = 'setup' 10 | 11 | @location = ::Minitest::Heat::Location.new(pathname: @raw_pathname, line_number: @raw_line_number, container: @container) 12 | end 13 | 14 | def test_full_initialization 15 | assert_equal Pathname(@raw_pathname), @location.pathname 16 | assert_equal Integer(@raw_line_number), @location.line_number 17 | assert_equal @container, @location.container 18 | refute_nil @location.source_code 19 | assert @location.exists? 20 | end 21 | 22 | def test_no_container 23 | @location.raw_container = nil 24 | assert_equal Pathname(@raw_pathname), @location.pathname 25 | assert_equal Integer(@raw_line_number), @location.line_number 26 | assert_equal '(Unknown Container)', @location.container 27 | refute_empty @location.source_code.lines 28 | assert @location.exists? 29 | end 30 | 31 | def test_non_existent_file 32 | fake_file_name = 'fake_file.rb' 33 | @location.raw_pathname = fake_file_name 34 | 35 | assert_equal Pathname(fake_file_name), @location.pathname 36 | assert_empty @location.source_code.lines 37 | refute @location.exists? 38 | end 39 | 40 | def test_non_existent_line_number 41 | fake_line_number = 1_000_000 42 | @location.raw_line_number = fake_line_number 43 | 44 | assert_equal fake_line_number, @location.line_number 45 | assert_empty @location.source_code.lines 46 | refute @location.exists? 47 | end 48 | 49 | def test_extracting_path 50 | assert_equal "#{Dir.pwd}/test/minitest/heat", @location.path 51 | end 52 | 53 | def test_extracting_filename 54 | assert_equal 'location_test.rb', @location.filename 55 | end 56 | 57 | def test_absolute_path 58 | assert_equal "#{Dir.pwd}/test/minitest/heat/", @location.absolute_path 59 | end 60 | 61 | def test_relative_path 62 | assert_equal 'test/minitest/heat/', @location.relative_path 63 | end 64 | 65 | def test_casts_to_string 66 | assert_equal "#{@location.pathname}:#{@location.line_number} in `#{@location.container}`", @location.to_s 67 | end 68 | 69 | def test_knows_if_test_file 70 | # Root path is not a test file and should be recognized as one 71 | @location.raw_pathname = '/' 72 | refute @location.test_file? 73 | 74 | # This is a test file and should be recognized as one 75 | @location.raw_pathname = @raw_pathname 76 | assert @location.test_file? 77 | assert @location.project_file? 78 | end 79 | 80 | def test_knows_if_source_code_file 81 | # Root path is not a project file and should be recognized as one 82 | @location.raw_pathname = '/' 83 | refute @location.source_code_file? 84 | 85 | # Set up a project source code file 86 | @location.raw_pathname = "#{Dir.pwd}/lib/minitest/heat.rb" 87 | assert @location.source_code_file? 88 | assert @location.project_file? 89 | end 90 | 91 | def test_knows_if_bundled_file 92 | # Root path is not a project file and should be recognized as one 93 | @location.raw_pathname = '/' 94 | refute @location.bundled_file? 95 | 96 | # Manually create a file in vendor/bundle 97 | directory = "#{Dir.pwd}/vendor/bundle" 98 | filename = "heat.rb" 99 | pathname = "#{directory}/#{filename}" 100 | FileUtils.mkdir_p(directory) 101 | FileUtils.touch(pathname) 102 | 103 | @location.raw_pathname = pathname 104 | refute @location.binstub_file? 105 | assert @location.bundled_file? 106 | refute @location.source_code_file? 107 | refute @location.project_file? 108 | 109 | # Get rid of the manually-created file and directory 110 | FileUtils.rm_rf(directory) 111 | end 112 | 113 | def test_knows_if_binstub_file 114 | # Root path is not a project file and should be recognized as one 115 | @location.raw_pathname = '/' 116 | refute @location.bundled_file? 117 | 118 | # Manually create a file in vendor/bundle 119 | directory = "#{Dir.pwd}/bin" 120 | filename = "stub" 121 | pathname = "#{directory}/#{filename}" 122 | FileUtils.mkdir_p(directory) 123 | FileUtils.touch(pathname) 124 | 125 | @location.raw_pathname = pathname 126 | assert @location.binstub_file? 127 | refute @location.bundled_file? 128 | refute @location.source_code_file? 129 | refute @location.project_file? 130 | 131 | # Get rid of the manually-created file and directory 132 | FileUtils.rm_rf(pathname) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/minitest/heat/locations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 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_definition' represents where the test is defined 10 | # - 'test_failure' represents the last line from the project's tests. It is further differentiated by 11 | # the line where the test is defined and the actual line of code in the test that geneated 12 | # the failure or exception 13 | # - 'source_code' represents the last line from the project's source code 14 | # - 'project' represents the last source line, but falls back to the last test line 15 | # - 'most_relevant' represents the most specific file to investigate starting with the source 16 | # code and then looking to the test code with final line of the backtrace as a fallback 17 | class Locations 18 | attr_reader :test_definition, :backtrace 19 | 20 | def initialize(test_definition_location, backtrace = []) 21 | test_definition_pathname, test_definition_line_number = test_definition_location 22 | @test_definition = ::Minitest::Heat::Location.new(pathname: test_definition_pathname, line_number: test_definition_line_number) 23 | 24 | @backtrace = Backtrace.new(backtrace) 25 | end 26 | 27 | # Prints the pathname and line number of the location most likely to be the source of the 28 | # test failure 29 | # 30 | # @return [String] ex. 'path/to/file.rb:12' 31 | def to_s 32 | "#{most_relevant.absolute_filename}:#{most_relevant.line_number}" 33 | end 34 | 35 | # Knows if the failure is contained within the test. For example, if there's bad code in a 36 | # test, and it raises an exception, then it's really a broken test rather than a proper 37 | # faiure. 38 | # 39 | # @return [Boolean] true if final file in the backtrace is the same as the test location file 40 | def broken_test? 41 | !test_failure.nil? && test_failure == final 42 | end 43 | 44 | # Knows if the failure occurred in the actual project source code—as opposed to the test or 45 | # an external piece of code like a gem. 46 | # 47 | # @return [Boolean] true if there's a non-test project file in the stacktrace but it's not 48 | # a result of a broken test 49 | def proper_failure? 50 | !source_code.nil? && !broken_test? 51 | end 52 | 53 | # The file most likely to be the source of the underlying problem. Often, the most recent 54 | # backtrace files will be a gem or external library that's failing indirectly as a result 55 | # of a problem with local source code (not always, but frequently). In that case, the best 56 | # first place to focus is on the code you control. 57 | # 58 | # @return [Array] file and line number of the most likely source of the problem 59 | def most_relevant 60 | [ 61 | source_code, 62 | test_failure, 63 | final 64 | ].compact.first 65 | end 66 | 67 | def freshest 68 | backtrace.recently_modified_locations.first 69 | end 70 | 71 | # Returns the final test location based on the backtrace if present. Otherwise falls back to 72 | # the test location which represents the test definition. The `test_definition` attribute 73 | # provides the location of where the test is defined. `test_failure` represents the actual 74 | # line from within the test where the problem occurred 75 | # 76 | # @return [Location] the final location from the test files 77 | def test_failure 78 | backtrace.test_locations.any? ? backtrace.test_locations.first : test_definition 79 | end 80 | 81 | # Returns the final source code location based on the backtrace 82 | # 83 | # @return [Location] the final location from the source code files 84 | def source_code 85 | backtrace.source_code_locations.first 86 | end 87 | 88 | # Returns the final project location based on the backtrace if present. Otherwise falls back 89 | # to the test location which represents the test definition. 90 | # 91 | # @return [Location] the final location from the project files 92 | def project 93 | backtrace.project_locations.any? ? backtrace.project_locations.first : test_definition 94 | end 95 | 96 | # The line number from within the `test_file` test definition where the failure occurred 97 | # 98 | # @return [Location] the last location from the backtrace or the test location if a backtrace 99 | # was not passed to the initializer 100 | def final 101 | backtrace.locations.any? ? backtrace.locations.first : test_definition 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/minitest/heat/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | # Gets the most relevant lines of code surrounding the specified line of code 6 | class Source 7 | attr_reader :filename 8 | 9 | attr_accessor :line_number, :max_line_count, :context 10 | 11 | CONTEXTS = %i[before around after].freeze 12 | 13 | def initialize(filename, line_number:, max_line_count: 1, context: :around) 14 | @filename = filename 15 | @line_number = Integer(line_number) 16 | @max_line_count = max_line_count 17 | @context = context 18 | end 19 | 20 | # Returns relevant lines as a hash with line numbers as the keys 21 | # 22 | # @return [Hash] hash of relevant lines with line numbers as keys 23 | def to_h 24 | line_numbers.map(&:to_s).zip(lines).to_h 25 | end 26 | 27 | # Looks up the line of code referenced 28 | # 29 | # @return [String] the line of code at filename:line_number 30 | def line 31 | file_lines[line_number - 1] 32 | end 33 | 34 | # Looks up the available lines of code around the referenced line number 35 | # 36 | # @return [Array] the range of lines of code around 37 | def lines 38 | return [line].compact if max_line_count == 1 39 | 40 | file_lines[(line_numbers.first - 1)..(line_numbers.last - 1)] 41 | end 42 | 43 | # Line numbers for the returned lines 44 | # 45 | # @return [Array] the line numbers corresponding to the lines returned 46 | def line_numbers 47 | (first_line_number..last_line_number).to_a.uniq 48 | end 49 | 50 | # Reads (and chomps) the lines of the target file 51 | # 52 | # @return [type] [description] 53 | def file_lines 54 | @raw_lines ||= File.readlines(filename, chomp: true) 55 | @raw_lines.pop while @raw_lines.last.strip.empty? 56 | 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 | [] 63 | end 64 | 65 | private 66 | 67 | # The largest possible value for line numbers 68 | # 69 | # @return [Integer] the last line number of the file 70 | def max_line_number 71 | file_lines.length 72 | end 73 | 74 | # The number of the first line of code to return 75 | # 76 | # @return [Integer] line number 77 | def first_line_number 78 | target = line_number - first_line_offset - leftover_trailing_lines_count 79 | 80 | # Can't go earlier than the first line 81 | target < 1 ? 1 : target 82 | end 83 | 84 | # The number of the last line of code to return 85 | # 86 | # @return [Integer] line number 87 | def last_line_number 88 | target = line_number + last_line_offset + leftover_preceding_lines_count 89 | 90 | # Can't go past the end of the file 91 | target > max_line_number ? max_line_number : target 92 | end 93 | 94 | # The target number of preceding lines to include 95 | # 96 | # @return [Integer] number of preceding lines to include 97 | def first_line_offset 98 | case context 99 | when :before then other_lines_count 100 | when :around then preceding_lines_split_count 101 | when :after then 0 102 | end 103 | end 104 | 105 | # The target number of trailing lines to include 106 | # 107 | # @return [Integer] number of trailing lines to include 108 | def last_line_offset 109 | case context 110 | when :before then 0 111 | when :around then trailing_lines_split_count 112 | when :after then other_lines_count 113 | end 114 | end 115 | 116 | # If the preceding lines offset takes_it past the beginning of the file, this provides the 117 | # total number of lines that weren't used 118 | # 119 | # @return [Integer] number of preceding lines that don't exist 120 | def leftover_preceding_lines_count 121 | target_line_number = line_number - first_line_offset 122 | 123 | target_line_number < 1 ? target_line_number.abs + 1 : 0 124 | end 125 | 126 | # If the trailing lines offset takes_it past the end of the file, this provides the total 127 | # number of lines that weren't used 128 | # 129 | # @return [Integer] number of trailing lines that don't exist 130 | def leftover_trailing_lines_count 131 | target_line_number = line_number + last_line_offset 132 | 133 | target_line_number > max_line_number ? target_line_number - max_line_number : 0 134 | end 135 | 136 | # The total number of lines to include in addition to the primary line 137 | def other_lines_count 138 | max_line_count - 1 139 | end 140 | 141 | def preceding_lines_split_count 142 | # Round up preceding lines if it's uneven because preceding lines are more likely to be 143 | # helpful when debugging 144 | (other_lines_count / 2).round(0, half: :up) 145 | end 146 | 147 | def trailing_lines_split_count 148 | # Round down preceding lines because they provide context in the file but don't contribute 149 | # in terms of the code that led to the error 150 | (other_lines_count / 2).round(0, half: :down) 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/issue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Formats issues to output based on the issue type 7 | class Issue # rubocop:disable Metrics/ClassLength 8 | attr_accessor :issue, :locations 9 | 10 | def initialize(issue) 11 | @issue = issue 12 | @locations = issue.locations 13 | end 14 | 15 | def tokens 16 | case issue.type 17 | when :error, :broken then exception_tokens 18 | when :failure then failure_tokens 19 | when :skipped then skipped_tokens 20 | when :painful, :slow then slow_tokens 21 | end 22 | end 23 | 24 | private 25 | 26 | def exception_tokens 27 | [ 28 | headline_tokens, 29 | test_location_tokens, 30 | summary_tokens, 31 | *backtrace_tokens, 32 | newline_tokens 33 | ] 34 | end 35 | 36 | def failure_tokens 37 | [ 38 | headline_tokens, 39 | test_location_tokens, 40 | summary_tokens, 41 | newline_tokens 42 | ] 43 | end 44 | 45 | def skipped_tokens 46 | [ 47 | headline_tokens, 48 | test_location_tokens, 49 | newline_tokens 50 | ] 51 | end 52 | 53 | def slow_tokens 54 | [ 55 | headline_tokens, 56 | slowness_summary_tokens, 57 | newline_tokens 58 | ] 59 | end 60 | 61 | def headline_tokens 62 | [label_token(issue), spacer_token, [:default, test_name(issue)]] 63 | end 64 | 65 | # Creates a display-friendly version of the test name with underscores removed and the 66 | # first letter capitalized regardless of the formatt used for the test definition 67 | # @param issue [Issue] the issue to use to generate the test name 68 | # 69 | # @return [String] the cleaned up version of the test name 70 | def test_name(issue) 71 | test_prefix = 'test_' 72 | identifier = issue.test_identifier 73 | 74 | if identifier.start_with?(test_prefix) 75 | identifier.delete_prefix(test_prefix).gsub('_', ' ').capitalize 76 | else 77 | identifier 78 | end 79 | end 80 | 81 | def label_token(issue) 82 | [issue.type, issue_label(issue.type)] 83 | end 84 | 85 | def test_name_and_class_tokens 86 | [[:default, issue.test_class], *test_location_tokens] 87 | end 88 | 89 | def test_location_tokens 90 | [ 91 | [:default, locations.test_definition.relative_filename], 92 | [:muted, ':'], 93 | [:default, locations.test_definition.line_number], 94 | arrow_token, 95 | [:default, locations.test_failure.line_number], 96 | [:muted, "\n #{locations.test_failure.source_code.line.strip}"] 97 | ] 98 | end 99 | 100 | def location_tokens 101 | [ 102 | [:default, locations.most_relevant.relative_filename], 103 | [:muted, ':'], 104 | [:default, locations.most_relevant.line_number], 105 | [:muted, "\n #{locations.most_relevant.source_code.line.strip}"] 106 | ] 107 | end 108 | 109 | def source_tokens 110 | filename = locations.project.filename 111 | line_number = locations.project.line_number 112 | source = Minitest::Heat::Source.new(filename, line_number: line_number) 113 | 114 | [[:muted, " #{Output::SYMBOLS[:arrow]} `#{source.line.strip}`"]] 115 | end 116 | 117 | def summary_tokens 118 | [[:italicized, issue.summary.delete_suffix('---------------').strip]] 119 | end 120 | 121 | def slowness_summary_tokens 122 | [ 123 | [:bold, slowness(issue)], 124 | spacer_token, 125 | [:default, locations.test_definition.relative_path], 126 | [:default, locations.test_definition.filename], 127 | [:muted, ':'], 128 | [:default, locations.test_definition.line_number] 129 | ] 130 | end 131 | 132 | def slowness(issue) 133 | "#{issue.execution_time.round(2)}s" 134 | end 135 | 136 | def newline_tokens 137 | [] 138 | end 139 | 140 | def spacer_token 141 | Output::TOKENS[:spacer] 142 | end 143 | 144 | def arrow_token 145 | Output::TOKENS[:muted_arrow] 146 | end 147 | 148 | def backtrace_tokens 149 | @backtrace_tokens ||= ::Minitest::Heat::Output::Backtrace.new(locations).tokens 150 | end 151 | 152 | # The string to use to describe the failure type when displaying results/ 153 | # @param issue_type [Symbol] the symbol representing the issue's failure type 154 | # 155 | # @return [String] the display-friendly string describing the failure reason 156 | def issue_label(issue_type) 157 | case issue_type 158 | when :error then 'Error' 159 | when :broken then 'Broken Test' 160 | when :failure then 'Failure' 161 | when :skipped then 'Skipped' 162 | when :slow then 'Passed but Slow' 163 | when :painful then 'Passed but Very Slow' 164 | when :passed then 'Success' 165 | else 'Unknown' 166 | end 167 | end 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/minitest/heat/output.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'output/backtrace' 4 | require_relative 'output/issue' 5 | require_relative 'output/map' 6 | require_relative 'output/marker' 7 | require_relative 'output/results' 8 | require_relative 'output/source_code' 9 | require_relative 'output/token' 10 | 11 | module Minitest 12 | module Heat 13 | # Friendly API for printing nicely-formatted output to the console 14 | class Output # rubocop:disable Metrics/ClassLength 15 | SYMBOLS = { 16 | middot: '·', 17 | arrow: '➜', 18 | lead: '|' 19 | }.freeze 20 | 21 | TOKENS = { 22 | spacer: [:muted, " #{SYMBOLS[:middot]} "], 23 | muted_arrow: [:muted, " #{SYMBOLS[:arrow]} "], 24 | muted_lead: [:muted, "#{SYMBOLS[:lead]} "] 25 | }.freeze 26 | 27 | attr_reader :stream 28 | 29 | def initialize(stream = $stdout) 30 | @stream = stream.tap do |str| 31 | # If the IO channel supports flushing the output immediately, then ensure it's enabled 32 | str.sync = str.respond_to?(:sync=) 33 | end 34 | end 35 | 36 | def print(*args) 37 | stream.print(*args) 38 | end 39 | 40 | def puts(*args) 41 | stream.puts(*args) 42 | end 43 | alias newline puts 44 | 45 | def issues_list(results) 46 | # A couple of blank lines to create some breathing room 47 | newline 48 | newline 49 | 50 | # Issues start with the least critical and go up to the most critical so that the most 51 | # pressing issues are displayed at the bottom of the report in order to reduce scrolling. 52 | # 53 | # This way, as you fix issues, the list gets shorter, and eventually the least critical 54 | # issues will be displayed without scrolling once more problematic issues are resolved. 55 | %i[slows painfuls skips failures brokens errors].each do |issue_category| 56 | # Only show categories for the most pressing issues after the suite runs, otherwise, 57 | # suppress them until the more critical issues are resolved. 58 | next unless show?(issue_category, results) 59 | 60 | issues = results.send(issue_category) 61 | 62 | issues 63 | .sort_by { |issue| issue.locations.most_relevant.to_a } 64 | .each { |issue| issue_details(issue) } 65 | end 66 | rescue StandardError => e 67 | message = "Sorry, but Minitest Heat couldn't display the details of any failures." 68 | exception_guidance(message, e) 69 | end 70 | 71 | def issue_details(issue) 72 | print_tokens Minitest::Heat::Output::Issue.new(issue).tokens 73 | rescue StandardError => e 74 | message = "Sorry, but Minitest Heat couldn't display output for a specific failure." 75 | exception_guidance(message, e) 76 | end 77 | 78 | def marker(issue_type) 79 | print_token Minitest::Heat::Output::Marker.new(issue_type).token 80 | end 81 | 82 | def compact_summary(results, timer) 83 | newline 84 | print_tokens ::Minitest::Heat::Output::Results.new(results, timer).tokens 85 | rescue StandardError => e 86 | message = "Sorry, but Minitest Heat couldn't display the summary." 87 | exception_guidance(message, e) 88 | end 89 | 90 | def heat_map(map) 91 | newline 92 | print_tokens ::Minitest::Heat::Output::Map.new(map).tokens 93 | newline 94 | rescue StandardError => e 95 | message = "Sorry, but Minitest Heat couldn't display the heat map." 96 | exception_guidance(message, e) 97 | end 98 | 99 | private 100 | 101 | # Displays some guidance related to exceptions generated by Minitest Heat in order to help 102 | # people get back on track (and ideally submit issues) 103 | # @param message [String] a slightly more specific explanation of which part of minitest-heat 104 | # caused the failure 105 | # @param exception [Exception] the exception that caused the problem 106 | # 107 | # @return [void] displays the guidance to the console 108 | def exception_guidance(message, exception) 109 | newline 110 | puts "#{message} Disabling Minitest Heat can get you back on track until the problem can be fixed." 111 | puts 'Please use the following exception details to submit an issue at https://github.com/garrettdimon/minitest-heat/issues' 112 | puts "#{exception.message}:" 113 | exception.backtrace.each do |line| 114 | puts " #{line}" 115 | end 116 | newline 117 | end 118 | 119 | def no_problems?(results) 120 | !results.problems? 121 | end 122 | 123 | def no_problems_or_skips?(results) 124 | !results.problems? && results.skips.none? 125 | end 126 | 127 | def show?(issue_category, results) 128 | case issue_category 129 | when :skips then no_problems?(results) 130 | when :painfuls, :slows then no_problems_or_skips?(results) 131 | else true 132 | end 133 | end 134 | 135 | def style_enabled? 136 | stream.tty? 137 | end 138 | 139 | def text(style, content) 140 | token = Token.new(style, content) 141 | print token.to_s(token_format) 142 | end 143 | 144 | def token_format 145 | style_enabled? ? :styled : :unstyled 146 | end 147 | 148 | def print_token(token) 149 | print Token.new(*token).to_s(token_format) 150 | end 151 | 152 | def print_tokens(lines_of_tokens) 153 | lines_of_tokens.each do |tokens| 154 | tokens.each do |token| 155 | begin 156 | print Token.new(*token).to_s(token_format) 157 | rescue 158 | puts token.inspect 159 | end 160 | end 161 | newline 162 | end 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/source_code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Generates the 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>] 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] 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 | %i[default default] 105 | else 106 | %i[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 132 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/backtrace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Builds the collection of tokens for displaying a backtrace when an exception occurs 7 | class Backtrace 8 | DEFAULT_INDENTATION_SPACES = 2 9 | 10 | attr_accessor :locations, :backtrace 11 | 12 | def initialize(locations) 13 | @locations = locations 14 | @backtrace = locations.backtrace 15 | @tokens = [] 16 | end 17 | 18 | def tokens 19 | # Iterate over the selected lines from the backtrace 20 | @tokens = backtrace_locations.map { |location| backtrace_location_tokens(location) } 21 | end 22 | 23 | # Determines the number of lines to display from the backtrace. 24 | # 25 | # @return [Integer] the number of lines to limit the backtrace to 26 | def line_count 27 | # Defined as a method instead of using the constant directly in order to easily support 28 | # adding options for controlling how many lines are displayed from a backtrace. 29 | # 30 | # For example, instead of a fixed number, the backtrace could dynamically calculate how 31 | # many lines it should displaye in order to get to the origination point. Or it could have 32 | # a default, but inteligently go back further if the backtrace meets some criteria for 33 | # displaying more lines. 34 | ::Minitest::Heat::Backtrace::LineCount.new(backtrace.locations).limit 35 | end 36 | 37 | # A subset of parsed lines from the backtrace. 38 | # 39 | # @return [Array] the backtrace locations determined to be most relevant to the 40 | # context of the underlying issue 41 | def backtrace_locations 42 | # This could eventually have additional intelligence to determine what lines are most 43 | # relevant for a given type of issue. For now, it simply takes the line numbers, but the 44 | # idea is that long-term, it could adjust that on the fly to keep the line count as low 45 | # as possible but expand it if necessary to ensure enough context is displayed. 46 | # 47 | # - If there's no clear cut details about the source of the error from within the project, 48 | # it could display the entire backtrace without filtering anything. 49 | # - It could scan the backtrace to the first appearance of project files and then display 50 | # all of the lines that occurred after that instance 51 | # - It could filter the lines differently whether the issue originated from a test or from 52 | # the source code. 53 | # - It could allow supporting a "compact" or "robust" reporter style so that someone on 54 | # a smaller screen could easily reduce the information shown so that the results could 55 | # be higher density even if it means truncating some occasionally useful details 56 | # - It could be smarter about displaying context/guidance when the full backtrace is from 57 | # outside the project's code 58 | # 59 | # But for now. It just grabs some lines. 60 | backtrace.locations.take(line_count) 61 | end 62 | 63 | private 64 | 65 | def backtrace_location_tokens(location) 66 | [ 67 | indentation_token, 68 | path_token(location), 69 | *file_and_line_number_tokens(location), 70 | containining_element_token(location), 71 | source_code_line_token(location), 72 | most_recently_modified_token(location), 73 | ].compact 74 | end 75 | 76 | # Determines if all lines to be displayed are from within the project directory 77 | # 78 | # @return [Boolean] true if all lines of the backtrace being displayed are from the project 79 | def all_backtrace_from_project? 80 | backtrace_locations.all?(&:project_file?) 81 | end 82 | 83 | # Determines if the file referenced by a backtrace line is the most recently modified file 84 | # of all the files referenced in the visible backtrace locations. 85 | # 86 | # @param [Location] location the location to examine 87 | # 88 | # @return [] 89 | # 90 | def most_recently_modified?(location) 91 | # If there's more than one line being displayed (otherwise, with one line, of course it's 92 | # the most recently modified because there_aren't any others) and the current line is the 93 | # same as the freshest location in the backtrace 94 | backtrace_locations.size > 1 && location == locations.freshest 95 | end 96 | 97 | def indentation_token 98 | [:default, ' ' * indentation] 99 | end 100 | 101 | def path_token(location) 102 | # If the line is a project file, help it stand out from the backtrace noise 103 | style = location.project_file? ? :default : :muted 104 | 105 | # If *all* of the backtrace lines are from the project, no point in the added redundant 106 | # noise of showing the project root directory over and over again 107 | path_format = all_backtrace_from_project? ? :relative_path : :absolute_path 108 | 109 | [style, location.send(path_format)] 110 | end 111 | 112 | def file_and_line_number_tokens(location) 113 | style = location.to_s.include?(Dir.pwd) ? :bold : :muted 114 | [ 115 | [style, location.filename], 116 | [:muted, ':'], 117 | [style, location.line_number] 118 | ] 119 | end 120 | 121 | def source_code_line_token(location) 122 | return nil unless location.project_file? 123 | 124 | [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"] 125 | end 126 | 127 | def containining_element_token(location) 128 | return nil if !location.project_file? || location.container.nil? || location.container.empty? 129 | 130 | [:muted, " in #{location.container}"] 131 | end 132 | 133 | def most_recently_modified_token(location) 134 | return nil unless most_recently_modified?(location) 135 | 136 | [:default, " #{Output::SYMBOLS[:middot]} Most Recently Modified File"] 137 | end 138 | 139 | # The number of spaces each line of code should be indented. Currently defaults to 2 in 140 | # order to provide visual separation between test failures, but in the future, it could 141 | # be configurable in order to save horizontal space and create more compact output. For 142 | # example, it could be smart based on line length and total available horizontal terminal 143 | # space, or there could be higher-level "display" setting that could have a `:compact` 144 | # option that would reduce the space used. 145 | # 146 | # @return [type] [description] 147 | def indentation 148 | DEFAULT_INDENTATION_SPACES 149 | end 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /lib/minitest/heat/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | # Consistent structure for extracting information about a given location. In addition to the 6 | # pathname to the file and the line number in the file, it can also include information about 7 | # the containing method or block and retrieve source code for the location. 8 | class Location 9 | UNRECOGNIZED = '(Unrecognized File)' 10 | UNKNOWN_MODIFICATION_TIME = Time.at(0) 11 | UNKNOWN_MODIFICATION_SECONDS = -1 12 | 13 | attr_accessor :raw_pathname, :raw_line_number, :raw_container 14 | 15 | # Initialize a new Location 16 | # 17 | # @param [Pathname, String] pathname: the pathname to the file 18 | # @param [Integer] line_number: the line number of the location within the file 19 | # @param [String] container: nil the containing method or block for the issue 20 | # 21 | # @return [self] 22 | def initialize(pathname:, line_number:, container: nil) 23 | @raw_pathname = pathname 24 | @raw_line_number = line_number 25 | @raw_container = container 26 | end 27 | 28 | # Generates a formatted string describing the line of code similar to the original backtrace 29 | # 30 | # @return [String] a consistently-formatted, human-readable string about the line of code 31 | def to_s 32 | "#{absolute_path}#{filename}:#{line_number} in `#{container}`" 33 | end 34 | 35 | # Generates a simplified location array with the pathname and line number 36 | # 37 | # @return [Array] a no-frills location pair 38 | def to_a 39 | [ 40 | pathname, 41 | line_number 42 | ] 43 | end 44 | 45 | # A short relative pathname and line number pair 46 | # 47 | # @return [String] the short filename/line number combo. ex. `dir/file.rb:23` 48 | def short 49 | "#{relative_filename}:#{line_number}" 50 | end 51 | 52 | # Determine if there is a file and text at the given line number 53 | # 54 | # @return [Boolean] true if the file exists and has text at the given line number 55 | def exists? 56 | pathname.exist? && source_code.lines.any? 57 | end 58 | 59 | # The pathanme for the location. Written to be safe and fallbackto the project directory if 60 | # an exception is raised ocnverting the value to a pathname 61 | # 62 | # @return [Pathname] a pathname instance for the relevant file 63 | def pathname 64 | Pathname(raw_pathname) 65 | rescue ArgumentError 66 | Pathname(Dir.pwd) 67 | end 68 | 69 | # A safe interface to getting a string representing the path portion of the file 70 | # 71 | # @return [String] either the path/directory portion of the file name or '(Unrecognized File)' 72 | # if the offending file can't be found for some reason 73 | def path 74 | pathname.exist? ? pathname.dirname.to_s : UNRECOGNIZED 75 | end 76 | 77 | def absolute_path 78 | pathname.exist? ? "#{path}/" : UNRECOGNIZED 79 | end 80 | 81 | def relative_path 82 | pathname.exist? ? absolute_path.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED 83 | end 84 | 85 | # A safe interface for getting a string representing the filename portion of the file 86 | # 87 | # @return [String] either the filename portion of the file or '(Unrecognized File)' 88 | # if the offending file can't be found for some reason 89 | def filename 90 | pathname.exist? ? pathname.basename.to_s : UNRECOGNIZED 91 | end 92 | 93 | def absolute_filename 94 | pathname.exist? ? pathname.to_s : UNRECOGNIZED 95 | end 96 | 97 | def relative_filename 98 | pathname.exist? ? pathname.to_s.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED 99 | end 100 | 101 | # Line number identifying the specific line in the file 102 | # 103 | # @return [Integer] line number for the file 104 | # 105 | def line_number 106 | Integer(raw_line_number) 107 | rescue ArgumentError 108 | 1 109 | end 110 | 111 | # The containing method or block details for the location 112 | # 113 | # @return [String] the containing method of the line of code 114 | def container 115 | raw_container.nil? ? '(Unknown Container)' : String(raw_container) 116 | end 117 | 118 | # Looks up the source code for the location. Can return multiple lines of source code from 119 | # the surrounding lines of code for the primary line 120 | # 121 | # @param [Integer] max_line_count: 1 the maximum number of lines to return from the source 122 | # 123 | # @return [Source] an instance of Source for accessing lines and their line numbers 124 | def source_code(max_line_count: 1) 125 | Minitest::Heat::Source.new( 126 | pathname.to_s, 127 | line_number: line_number, 128 | max_line_count: max_line_count 129 | ) 130 | end 131 | 132 | # Determines if a given file is from the project directory 133 | # 134 | # @return [Boolean] true if the file is in the project (source code or test) but not vendored 135 | def project_file? 136 | path.include?(project_root_dir) && !bundled_file? && !binstub_file? 137 | end 138 | 139 | # Determines if the file is in the project `vendor/bundle` directory. 140 | # 141 | # @return [Boolean] true if the file is in `/vendor/bundle 142 | def bundled_file? 143 | path.include?("#{project_root_dir}/vendor/bundle") 144 | end 145 | 146 | # Determines if the file is in the project `bin` directory. With binstub'd gems, they'll 147 | # appear to be source code because the code is located in the project directory. This helps 148 | # make sure the backtraces don't think that's the case 149 | # 150 | # @return [Boolean] true if the file is in `/bin 151 | def binstub_file? 152 | path.include?("#{project_root_dir}/bin") 153 | end 154 | 155 | # Determines if a given file follows the standard approaching to naming test files. 156 | # 157 | # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb` 158 | def test_file? 159 | filename.to_s.start_with?('test_') || filename.to_s.end_with?('_test.rb') 160 | end 161 | 162 | # Determines if a given file is a non-test file from the project directory 163 | # 164 | # @return [Boolean] true if the file is in the project but not a test file or vendored file 165 | def source_code_file? 166 | project_file? && !test_file? 167 | end 168 | 169 | # A safe interface to getting the last modified time for the file in question 170 | # 171 | # @return [Time] the timestamp for when the file in question was last modified or `Time.at(0)` 172 | # if the offending file can't be found for some reason 173 | def mtime 174 | pathname.exist? ? pathname.mtime : UNKNOWN_MODIFICATION_TIME 175 | end 176 | 177 | # A safe interface to getting the number of seconds since the file was modified 178 | # 179 | # @return [Integer] the number of seconds since the file was modified or `-1` if the offending 180 | # file can't be found for some reason 181 | def age_in_seconds 182 | pathname.exist? ? seconds_ago : UNKNOWN_MODIFICATION_SECONDS 183 | end 184 | 185 | private 186 | 187 | def project_root_dir 188 | Dir.pwd 189 | end 190 | 191 | def seconds_ago 192 | (Time.now - mtime).to_i 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/minitest/heat/output/map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Heat 5 | class Output 6 | # Generates the tokens to output the resulting heat map 7 | class Map 8 | attr_accessor :results 9 | 10 | def initialize(results) 11 | @results = results 12 | @tokens = [] 13 | end 14 | 15 | def tokens 16 | results.heat_map.file_hits.each do |hit| 17 | # Focus on the relevant issues based on most significant problems. i.e. If there are 18 | # legitimate failures or errors, skips and slows aren't relevant 19 | next unless relevant_issue_types?(hit) 20 | 21 | # Add a new line 22 | @tokens << [[:muted, ""]] 23 | 24 | # Build the summary line for the file 25 | @tokens << file_summary_tokens(hit) 26 | 27 | # Get the set of line numbers that appear more than once 28 | repeated_line_numbers = find_repeated_line_numbers_in(hit) 29 | 30 | # Only display more details if the same line number shows up more than once 31 | next unless repeated_line_numbers.any? 32 | 33 | repeated_line_numbers.each do |line_number| 34 | # Get the backtraces for the given line numbers 35 | traces = hit.lines[line_number.to_s] 36 | 37 | # If there aren't any traces there's no way to provide additional details 38 | break unless traces.any? 39 | 40 | # A short summary explaining the details that will follow 41 | @tokens << [[:default, " Line #{line_number}"], [:muted, ' issues triggered from:']] 42 | 43 | # The last relevant location for each error's backtrace 44 | @tokens += origination_sources(traces) 45 | end 46 | end 47 | 48 | @tokens 49 | end 50 | 51 | private 52 | 53 | def origination_sources(traces) 54 | # 1. Only pull the traces that have proper locations 55 | # 2. Sort the traces by the most recent line number so they're displayed in numeric order 56 | # 3. Get the final relevant location from the trace 57 | traces. 58 | select { |trace| trace.locations.any? }. 59 | sort_by { |trace| trace.locations.last.line_number }. 60 | map { |trace| origination_location_token(trace) } 61 | end 62 | 63 | def file_summary_tokens(hit) 64 | pathname_tokens = pathname(hit) 65 | line_number_list_tokens = line_number_tokens_for_hit(hit) 66 | 67 | [*pathname_tokens, *line_number_list_tokens] 68 | end 69 | 70 | def origination_location_token(trace) 71 | # The earliest project line from the backtrace—this is probabyl wholly incorrect in terms 72 | # of what would be the most helpful line to display, but it's a start. Otherwise, the 73 | # logic will need to compare all traces for the issue and find the unique origination 74 | # lines 75 | location = trace.locations.last 76 | 77 | [ 78 | [:muted, " #{Output::SYMBOLS[:arrow]} "], 79 | [:default, location.relative_filename], 80 | [:muted, ':'], 81 | [:default, location.line_number], 82 | [:muted, " in #{location.container}"], 83 | [:muted, " #{Output::SYMBOLS[:arrow]} `#{location.source_code.line.strip}`"], 84 | ] 85 | end 86 | 87 | def relevant_issue_types 88 | # These are always relevant. 89 | issue_types = %i[error broken failure] 90 | 91 | # These are only relevant if there aren't more serious isues. 92 | issue_types << :skipped unless results.problems? 93 | issue_types << :painful unless results.problems? || results.skips.any? 94 | issue_types << :slow unless results.problems? || results.skips.any? 95 | 96 | issue_types 97 | end 98 | 99 | def relevant_issue_types?(hit) 100 | # The intersection of which issue types are relevant based on the context and which issues 101 | # matc those issue types 102 | intersection_issue_types = relevant_issue_types & hit.issues.keys 103 | 104 | intersection_issue_types.any? 105 | end 106 | 107 | def find_repeated_line_numbers_in(hit) 108 | repeated_line_numbers = [] 109 | 110 | hit.lines.each_pair do |line_number, traces| 111 | # If there aren't multiple traces for a line number, it's not a repeat 112 | next unless traces.size > 1 113 | 114 | repeated_line_numbers << Integer(line_number) 115 | end 116 | 117 | repeated_line_numbers.sort 118 | end 119 | 120 | def pathname(hit) 121 | directory = hit.pathname.dirname.to_s.delete_prefix("#{Dir.pwd}/") 122 | filename = hit.pathname.basename.to_s 123 | 124 | [ 125 | [:default, "#{directory}/"], 126 | [:bold, filename], 127 | [:default, ' · '] 128 | ] 129 | end 130 | 131 | # Gets the list of line numbers for a given hit location (i.e. file) so they can be 132 | # displayed after the file name to show which lines were problematic 133 | # @param hit [Hit] the instance to extract line numbers from 134 | # 135 | # @return [Array] [description] 136 | def line_number_tokens_for_hit(hit) 137 | line_number_tokens = [] 138 | 139 | relevant_issue_types.each do |issue_type| 140 | # Retrieve any line numbers for the issue type 141 | line_numbers_for_issue_type = hit.issues.fetch(issue_type) { [] } 142 | 143 | # Build the list of tokens representing styled line numbers 144 | line_numbers_for_issue_type.uniq.sort.each do |line_number| 145 | frequency = line_numbers_for_issue_type.count(line_number) 146 | 147 | line_number_tokens += line_number_token(issue_type, line_number, frequency) 148 | end 149 | end 150 | 151 | line_number_tokens.compact 152 | end 153 | 154 | # Builds a token representing a styled line number 155 | # 156 | # @param style [Symbol] the relevant display style for the issue 157 | # @param line_number [Integer] the affected line number 158 | # 159 | # @return [Array] array token representing the line number and issue type 160 | def line_number_token(style, line_number, frequency) 161 | if frequency > 1 162 | [ 163 | [style, "#{line_number}"], 164 | [:muted, "✕#{frequency} "] 165 | ] 166 | else 167 | [[style, "#{line_number} "]] 168 | end 169 | end 170 | 171 | # # Sorts line number tokens so that line numbers are displayed in order regardless of their 172 | # # underlying issue type 173 | # # 174 | # # @param hit [Hit] the instance of the hit file details to build the heat map entry 175 | # # 176 | # # @return [Array] the arrays representing the line number tokens to display next to a file 177 | # # name in the heat map. ex [[:error, 12], [:falure, 13]] 178 | # def sorted_line_number_list(hit) 179 | # # Sort the collected group of line number hits so they're in order 180 | # line_number_tokens_for_hit(hit).sort do |a, b| 181 | # # Ensure the line numbers are integers for sorting (otherwise '100' comes before '12') 182 | # first_line_number = Integer(a[1].strip) 183 | # second_line_number = Integer(b[1].strip) 184 | 185 | # first_line_number <=> second_line_number 186 | # end 187 | # end 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/minitest/heat/issue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Minitest 6 | module Heat 7 | # Wrapper for Result to provide a more natural-language approach to result details 8 | class Issue 9 | extend Forwardable 10 | 11 | TYPES = %i[error broken failure skipped painful slow].freeze 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 19 | 20 | attr_reader :assertions, 21 | :locations, 22 | :message, 23 | :test_class, 24 | :test_identifier, 25 | :execution_time, 26 | :passed, 27 | :error, 28 | :skipped 29 | 30 | def_delegators :@locations, :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 | test_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] the array of backtrace lines from an exception 60 | # @param test_location: nil [Array] the locations 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, test_location: ['Unrecognized Test File', 1], backtrace: [], execution_time: 0.0, message: nil, test_class: nil, test_identifier: nil, passed: false, error: false, skipped: false) 70 | @message = message 71 | 72 | @assertions = Integer(assertions) 73 | @locations = Locations.new(test_location, backtrace) 74 | 75 | @test_class = test_class 76 | @test_identifier = test_identifier 77 | @execution_time = Float(execution_time) 78 | 79 | @passed = passed 80 | @error = error 81 | @skipped = skipped 82 | end 83 | 84 | # Classifies different issue types so they can be categorized, organized, and prioritized. 85 | # Primarily helps add some nuance to issue types. For example, an exception that arises from 86 | # the project's source code is a genuine exception. But if the exception arose directly from 87 | # the test, then it's more likely that there's just a simple syntax issue in the test. 88 | # Similarly, the difference between a moderately slow test and a painfully slow test can be 89 | # significant. A test that takes half a second is slow, but a test that takes 10 seconds is 90 | # painfully slow and should get more attention. 91 | # 92 | # @return [Symbol] issue type for classifying issues and reporting 93 | def type # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 94 | if error? && in_test? 95 | :broken 96 | elsif error? 97 | :error 98 | elsif skipped? 99 | :skipped 100 | elsif !passed? 101 | :failure 102 | elsif passed? && painful? 103 | :painful 104 | elsif passed? && slow? 105 | :slow 106 | else 107 | :success 108 | end 109 | end 110 | 111 | # Determines if the issue is a proper 'hit' which is anything that doesn't pass or is slow. 112 | # (Because slow tests still pass and wouldn't otherwise be considered an issue.) 113 | # 114 | # @return [Boolean] true if the test did not pass or if it was slow 115 | def hit? 116 | !passed? || slow? || painful? 117 | end 118 | 119 | # The number, in seconds, for a test to be considered "slow" 120 | # 121 | # @return [Float] number of seconds after which a test is considered slow 122 | def slow_threshold 123 | Minitest::Heat.configuration.slow_threshold 124 | end 125 | 126 | # The number, in seconds, for a test to be considered "painfully slow" 127 | # 128 | # @return [Float] number of seconds after which a test is considered painfully slow 129 | def painfully_slow_threshold 130 | Minitest::Heat.configuration.painfully_slow_threshold 131 | end 132 | 133 | # Determines if a test should be considered slow by comparing it to the low end definition of 134 | # what is considered slow. 135 | # 136 | # @return [Boolean] true if the test took longer to run than `slow_threshold` 137 | def slow? 138 | execution_time >= slow_threshold && execution_time < painfully_slow_threshold 139 | end 140 | 141 | # Determines if a test should be considered painfully slow by comparing it to the high end 142 | # definition of what is considered slow. 143 | # 144 | # @return [Boolean] true if the test took longer to run than `painfully_slow_threshold` 145 | def painful? 146 | execution_time >= painfully_slow_threshold 147 | end 148 | 149 | # Determines if the issue is an exception that was raised from directly within a test 150 | # definition. In these cases, it's more likely to be a quick fix. 151 | # 152 | # @return [Boolean] true if the final locations of the stacktrace was a test file 153 | def in_test? 154 | locations.broken_test? 155 | end 156 | 157 | # Determines if the issue is an exception that was raised from directly within the project 158 | # codebase. 159 | # 160 | # @return [Boolean] true if the final locations of the stacktrace was a file from the project 161 | # (as opposed to a dependency or Ruby library) 162 | def in_source? 163 | locations.proper_failure? 164 | end 165 | 166 | # Was the result a pass? i.e. Skips aren't passes or failures. Slows are still passes. So this 167 | # is purely a measure of whether the test explicitly passed all assertions 168 | # 169 | # @return [Boolean] false for errors, failures, or skips, true for passes (including slows) 170 | def passed? 171 | passed 172 | end 173 | 174 | # Was there an exception that triggered a failure? 175 | # 176 | # @return [Boolean] true if there's an exception 177 | def error? 178 | error 179 | end 180 | 181 | # Was the test skipped? 182 | # 183 | # @return [Boolean] true if the test was explicitly skipped, false otherwise 184 | def skipped? 185 | skipped 186 | end 187 | 188 | # The more nuanced detail of the failure. If it's an error, digs into the exception. Otherwise 189 | # uses the message from the result 190 | # 191 | # @return [String] a more detailed explanation of the issue 192 | def summary 193 | # When there's an exception, use the first line from the exception message. Otherwise, the 194 | # message represents explanation for a test failure, and should be used in full 195 | error? ? first_line_of_exception_message : message 196 | end 197 | 198 | # Returns the first line of an exception message when the issue is from a proper exception 199 | # failure since exception messages can be long and cumbersome. 200 | # 201 | # @return [String] the first line of the exception message 202 | def first_line_of_exception_message 203 | text = message.split("\n")[0] 204 | 205 | text.size > exception_message_limit ? "#{text[0..exception_message_limit]}..." : text 206 | end 207 | 208 | def exception_message_limit 209 | 200 210 | end 211 | end 212 | end 213 | end 214 | --------------------------------------------------------------------------------