lib/minitest/reporters/html_reporter.rb



require 'builder'
require 'fileutils'
require 'erb'

module Minitest
  module Reporters
    # A reporter for generating HTML test reports

    # This is recommended to be used with a CI server, where the report is kept as an artifact and is accessible via

    # a shared link

    #

    # The reporter sorts the results alphabetically and then by results

    # so that failing and skipped tests are at the top.

    #

    # When using Minitest Specs, the number prefix is dropped from the name of the test so that it reads well

    #

    # On each test run all files in the reports directory are deleted, this prevents a build up of old reports

    #

    # The report is generated using ERB. A custom ERB template can be provided but it is not required

    # The default ERB template uses JQuery and Bootstrap, both of these are included by referencing the CDN sites

    class HtmlReporter < BaseReporter
      # The title of the report

      attr_reader :title

      # The number of tests that passed

      def passes
        count - failures - errors - skips
      end

      # The percentage of tests that passed, calculated in a way that avoids rounding errors

      def percent_passes
        100 - percent_skipps - percent_errors_failures
      end

      # The percentage of tests that were skipped

      def percent_skipps
        (skips / count.to_f * 100).to_i
      end

      # The percentage of tests that failed

      def percent_errors_failures
        ((errors + failures) / count.to_f * 100).to_i
      end

      # Trims off the number prefix on test names when using Minitest Specs

      def friendly_name(test)
        groups = test.name.scan(/(test_\d+_)(.*)/i)
        return test.name if groups.empty?
        "it #{groups[0][1]}"
      end

      # The constructor takes a hash, and uses the following keys:

      # :title - the title that will be used in the report, defaults to 'Test Results'

      # :reports_dir - the directory the reports should be written to, defaults to 'test/html_reports'

      # :erb_template - the path to a custom ERB template, defaults to the supplied ERB template

      # :mode - Useful for debugging, :terse suppresses errors and is the default, :verbose lets errors bubble up

      # :output_filename - the report's filename, defaults to 'index.html'

      def initialize(args = {})
        super({})

        defaults = {
          :title           => 'Test Results',
          :erb_template    => "#{File.dirname(__FILE__)}/../templates/index.html.erb",
          :reports_dir     => 'test/html_reports',
          :mode            => :safe,
          :output_filename => 'index.html',
        }

        settings = defaults.merge(args)

        @mode = settings[:mode]
        @title = settings[:title]
        @erb_template = settings[:erb_template]
        @output_filename = settings[:output_filename]
        reports_dir = settings[:reports_dir]

        @reports_path = File.absolute_path(reports_dir)
      end

      def start
        super

        puts "Emptying #{@reports_path}"
        FileUtils.mkdir_p(@reports_path)
        File.delete(html_file) if File.exist?(html_file)
      end

      # Called by the framework to generate the report

      def report
        super

        begin
          puts "Writing HTML reports to #{@reports_path}"
          erb_str = File.read(@erb_template)
          renderer = ERB.new(erb_str)

          tests_by_suites = tests.group_by { |test| test_class(test) } # taken from the JUnit reporter


          suites = tests_by_suites.map do |suite, tests|
            suite_summary = summarize_suite(suite, tests)
            suite_summary[:tests] = tests.sort { |a, b| compare_tests(a, b) }
            suite_summary
          end

          suites.sort! { |a, b| compare_suites(a, b) }

          result = renderer.result(binding)
          File.open(html_file, 'w') do |f|
            f.write(result)
          end

        # rubocop:disable Lint/RescueException

        rescue Exception => e
          puts 'There was an error writing the HTML report'
          puts 'This may have been caused by cancelling the test run'
          puts 'Use mode => :verbose in the HTML reporters constructor to see more detail' if @mode == :terse
          puts 'Use mode => :terse in the HTML reporters constructor to see less detail' if @mode != :terse
          raise e if @mode != :terse
        end
        # rubocop:enable Lint/RescueException

      end

      private

      def html_file
        "#{@reports_path}/#{@output_filename}"
      end

      def compare_suites_by_name(suite_a, suite_b)
        suite_a[:name] <=> suite_b[:name]
      end

      def compare_tests_by_name(test_a, test_b)
        friendly_name(test_a) <=> friendly_name(test_b)
      end

      # Test suites are first ordered by evaluating the results of the tests, then by test suite name

      # Test suites which have failing tests are given highest order

      # Tests suites which have skipped tests are given second highest priority

      def compare_suites(suite_a, suite_b)
        return compare_suites_by_name(suite_a, suite_b) if suite_a[:has_errors_or_failures] && suite_b[:has_errors_or_failures]
        return -1 if suite_a[:has_errors_or_failures] && !suite_b[:has_errors_or_failures]
        return 1 if !suite_a[:has_errors_or_failures] && suite_b[:has_errors_or_failures]

        return compare_suites_by_name(suite_a, suite_b) if suite_a[:has_skipps] && suite_b[:has_skipps]
        return -1 if suite_a[:has_skipps] && !suite_b[:has_skipps]
        return 1 if !suite_a[:has_skipps] && suite_b[:has_skipps]

        compare_suites_by_name(suite_a, suite_b)
      end

      # Tests are first ordered by evaluating the results of the tests, then by tests names

      # Tess which fail are given highest order

      # Tests which are skipped are given second highest priority

      def compare_tests(test_a, test_b)
        return compare_tests_by_name(test_a, test_b) if test_fail_or_error?(test_a) && test_fail_or_error?(test_b)

        return -1 if test_fail_or_error?(test_a) && !test_fail_or_error?(test_b)
        return 1 if !test_fail_or_error?(test_a) && test_fail_or_error?(test_b)

        return compare_tests_by_name(test_a, test_b) if test_a.skipped? && test_b.skipped?
        return -1 if test_a.skipped? && !test_b.skipped?
        return 1 if !test_a.skipped? && test_b.skipped?

        compare_tests_by_name(test_a, test_b)
      end

      def test_fail_or_error?(test)
        test.error? || test.failure
      end

      # based on analyze_suite from the JUnit reporter

      def summarize_suite(suite, tests)
        summary = Hash.new(0)
        summary[:name] = suite.to_s
        tests.each do |test|
          summary[:"#{result(test)}_count"] += 1
          summary[:assertion_count] += test.assertions
          summary[:test_count] += 1
          summary[:time] += test.time
        end
        summary[:has_errors_or_failures] = (summary[:fail_count] + summary[:error_count]) > 0
        summary[:has_skipps] = summary[:skip_count] > 0
        summary
      end

      # based on message_for(test) from the JUnit reporter

      def message_for(test)
        suite = test.class
        name = test.name
        e = test.failure

        if test.passed?
          nil
        elsif test.skipped?
          "Skipped:\n#{name}(#{suite}) [#{location(e)}]:\n#{e.message}\n"
        elsif test.failure
          "Failure:\n#{name}(#{suite}) [#{location(e)}]:\n#{e.message}\n"
        elsif test.error?
          "Error:\n#{name}(#{suite}):\n#{e.message}"
        end
      end

      # taken from the JUnit reporter

      def location(exception)
        last_before_assertion = ''
        exception.backtrace.reverse_each do |s|
          break if s =~ /in .(assert|refute|flunk|pass|fail|raise|must|wont)/
          last_before_assertion = s
        end
        last_before_assertion.sub(/:in .*$/, '')
      end

      def total_time_to_hms
        return ('%.2fs' % total_time) if total_time < 1

        hours = (total_time / (60 * 60)).round
        minutes = ((total_time / 60) % 60).round.to_s.rjust(2, '0')
        seconds = (total_time % 60).round.to_s.rjust(2, '0')

        "#{hours}h#{minutes}m#{seconds}s"
      end
    end
  end
end