rspec/rspec-core

View on GitHub
lib/rspec/core/notifications.rb

Summary

Maintainability
A
2 hrs
Test Coverage
RSpec::Support.require_rspec_core "formatters/console_codes"
RSpec::Support.require_rspec_core "formatters/exception_presenter"
RSpec::Support.require_rspec_core "formatters/helpers"
RSpec::Support.require_rspec_core "shell_escape"

module RSpec::Core
  # Notifications are value objects passed to formatters to provide them
  # with information about a particular event of interest.
  module Notifications
    # @private
    module NullColorizer
      module_function

      def wrap(line, _code_or_symbol)
        line
      end
    end

    # The `StartNotification` represents a notification sent by the reporter
    # when the suite is started. It contains the expected amount of examples
    # to be executed, and the load time of RSpec.
    #
    # @attr count [Fixnum] the number counted
    # @attr load_time [Float] the number of seconds taken to boot RSpec
    #                         and load the spec files
    StartNotification = Struct.new(:count, :load_time)

    # The `ExampleNotification` represents notifications sent by the reporter
    # which contain information about the current (or soon to be) example.
    # It is used by formatters to access information about that example.
    #
    # @example
    #   def example_started(notification)
    #     puts "Hey I started #{notification.example.description}"
    #   end
    #
    # @attr example [RSpec::Core::Example] the current example
    ExampleNotification = Struct.new(:example)
    class ExampleNotification
      # @private
      def self.for(example)
        execution_result = example.execution_result

        return SkippedExampleNotification.new(example) if execution_result.example_skipped?
        return new(example) unless execution_result.status == :pending || execution_result.status == :failed

        klass = if execution_result.pending_fixed?
                  PendingExampleFixedNotification
                elsif execution_result.status == :pending
                  PendingExampleFailedAsExpectedNotification
                else
                  FailedExampleNotification
                end

        klass.new(example)
      end

      private_class_method :new
    end

    # The `ExamplesNotification` represents notifications sent by the reporter
    # which contain information about the suites examples.
    #
    # @example
    #   def stop(notification)
    #     puts "Hey I ran #{notification.examples.size}"
    #   end
    #
    class ExamplesNotification
      def initialize(reporter)
        @reporter = reporter
      end

      # @return [Array<RSpec::Core::Example>] list of examples
      def examples
        @reporter.examples
      end

      # @return [Array<RSpec::Core::Example>] list of failed examples
      def failed_examples
        @reporter.failed_examples
      end

      # @return [Array<RSpec::Core::Example>] list of pending examples
      def pending_examples
        @reporter.pending_examples
      end

      # @return [Array<RSpec::Core::Notifications::ExampleNotification>]
      #         returns examples as notifications
      def notifications
        @notifications ||= format_examples(examples)
      end

      # @return [Array<RSpec::Core::Notifications::FailedExampleNotification>]
      #         returns failed examples as notifications
      def failure_notifications
        @failed_notifications ||= format_examples(failed_examples)
      end

      # @return [Array<RSpec::Core::Notifications::SkippedExampleNotification,
      #                 RSpec::Core::Notifications::PendingExampleFailedAsExpectedNotification>]
      #         returns pending examples as notifications
      def pending_notifications
        @pending_notifications ||= format_examples(pending_examples)
      end

      # @return [String] The list of failed examples, fully formatted in the way
      #   that RSpec's built-in formatters emit.
      def fully_formatted_failed_examples(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        formatted = "\nFailures:\n"

        failure_notifications.each_with_index do |failure, index|
          formatted += failure.fully_formatted(index.next, colorizer)
        end

        formatted
      end

      # @return [String] The list of pending examples, fully formatted in the
      #   way that RSpec's built-in formatters emit.
      def fully_formatted_pending_examples(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        formatted = "\nPending: (Failures listed here are expected and do not affect your suite's status)\n".dup

        pending_notifications.each_with_index do |notification, index|
          formatted << notification.fully_formatted(index.next, colorizer)
        end

        formatted
      end

    private

      def format_examples(examples)
        examples.map do |example|
          ExampleNotification.for(example)
        end
      end
    end

    # The `FailedExampleNotification` extends `ExampleNotification` with
    # things useful for examples that have failure info -- typically a
    # failed or pending spec.
    #
    # @example
    #   def example_failed(notification)
    #     puts "Hey I failed :("
    #     puts "Here's my stack trace"
    #     puts notification.exception.backtrace.join("\n")
    #   end
    #
    # @attr [RSpec::Core::Example] example the current example
    # @see ExampleNotification
    class FailedExampleNotification < ExampleNotification
      public_class_method :new

      # @return [Exception] The example failure
      def exception
        @exception_presenter.exception
      end

      # @return [String] The example description
      def description
        @exception_presenter.description
      end

      # Returns the message generated for this failure line by line.
      #
      # @return [Array<String>] The example failure message
      def message_lines
        @exception_presenter.message_lines
      end

      # Returns the message generated for this failure colorized line by line.
      #
      # @param colorizer [#wrap] An object to colorize the message_lines by
      # @return [Array<String>] The example failure message colorized
      def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        @exception_presenter.colorized_message_lines(colorizer)
      end

      # Returns the failures formatted backtrace.
      #
      # @return [Array<String>] the examples backtrace lines
      def formatted_backtrace
        @exception_presenter.formatted_backtrace
      end

      # Returns the failures colorized formatted backtrace.
      #
      # @param colorizer [#wrap] An object to colorize the message_lines by
      # @return [Array<String>] the examples colorized backtrace lines
      def colorized_formatted_backtrace(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        @exception_presenter.colorized_formatted_backtrace(colorizer)
      end

      # @return [String] The failure information fully formatted in the way that
      #   RSpec's built-in formatters emit.
      def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        @exception_presenter.fully_formatted(failure_number, colorizer)
      end

      # @return [Array<string>] The failure information fully formatted in the way that
      #   RSpec's built-in formatters emit, split by line.
      def fully_formatted_lines(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        @exception_presenter.fully_formatted_lines(failure_number, colorizer)
      end

    private

      def initialize(example, exception_presenter=Formatters::ExceptionPresenter::Factory.new(example).build)
        @exception_presenter = exception_presenter
        super(example)
      end
    end

    # @deprecated Use {FailedExampleNotification} instead.
    class PendingExampleFixedNotification < FailedExampleNotification; end

    # @deprecated Use {FailedExampleNotification} instead.
    class PendingExampleFailedAsExpectedNotification < FailedExampleNotification; end

    # The `SkippedExampleNotification` extends `ExampleNotification` with
    # things useful for specs that are skipped.
    #
    # @attr [RSpec::Core::Example] example the current example
    # @see ExampleNotification
    class SkippedExampleNotification < ExampleNotification
      public_class_method :new

      # @return [String] The pending detail fully formatted in the way that
      #   RSpec's built-in formatters emit.
      def fully_formatted(pending_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        formatted_caller = RSpec.configuration.backtrace_formatter.backtrace_line(example.location)

        [
          colorizer.wrap("\n  #{pending_number}) #{example.full_description}", :pending),
          "\n     ",
          Formatters::ExceptionPresenter::PENDING_DETAIL_FORMATTER.call(example, colorizer),
          "\n",
          colorizer.wrap("     # #{formatted_caller}\n", :detail)
        ].join("")
      end
    end

    # The `GroupNotification` represents notifications sent by the reporter
    # which contain information about the currently running (or soon to be)
    # example group. It is used by formatters to access information about that
    # group.
    #
    # @example
    #   def example_group_started(notification)
    #     puts "Hey I started #{notification.group.description}"
    #   end
    # @attr group [RSpec::Core::ExampleGroup] the current group
    GroupNotification = Struct.new(:group)

    # The `MessageNotification` encapsulates generic messages that the reporter
    # sends to formatters.
    #
    # @attr message [String] the message
    MessageNotification = Struct.new(:message)

    # The `SeedNotification` holds the seed used to randomize examples and
    # whether that seed has been used or not.
    #
    # @attr seed [Fixnum] the seed used to randomize ordering
    # @attr used [Boolean] whether the seed has been used or not
    SeedNotification = Struct.new(:seed, :used)
    class SeedNotification
      # @api
      # @return [Boolean] has the seed been used?
      def seed_used?
        !!used
      end
      private :used

      # @return [String] The seed information fully formatted in the way that
      #   RSpec's built-in formatters emit.
      def fully_formatted
        "\nRandomized with seed #{seed}\n"
      end
    end

    # The `SummaryNotification` holds information about the results of running
    # a test suite. It is used by formatters to provide information at the end
    # of the test run.
    #
    # @attr duration [Float] the time taken (in seconds) to run the suite
    # @attr examples [Array<RSpec::Core::Example>] the examples run
    # @attr failed_examples [Array<RSpec::Core::Example>] the failed examples
    # @attr pending_examples [Array<RSpec::Core::Example>] the pending examples
    # @attr load_time [Float] the number of seconds taken to boot RSpec
    #                         and load the spec files
    # @attr errors_outside_of_examples_count [Integer] the number of errors that
    #                                                  have occurred processing
    #                                                  the spec suite
    SummaryNotification = Struct.new(:duration, :examples, :failed_examples,
                                     :pending_examples, :load_time,
                                     :errors_outside_of_examples_count)
    class SummaryNotification
      # @api
      # @return [Fixnum] the number of examples run
      def example_count
        @example_count ||= examples.size
      end

      # @api
      # @return [Fixnum] the number of failed examples
      def failure_count
        @failure_count ||= failed_examples.size
      end

      # @api
      # @return [Fixnum] the number of pending examples
      def pending_count
        @pending_count ||= pending_examples.size
      end

      # @api
      # @return [String] A line summarising the result totals of the spec run.
      def totals_line
        summary = Formatters::Helpers.pluralize(example_count, "example") +
          ", " + Formatters::Helpers.pluralize(failure_count, "failure")
        summary += ", #{pending_count} pending" if pending_count > 0
        if errors_outside_of_examples_count > 0
          summary += (
            ", " +
            Formatters::Helpers.pluralize(errors_outside_of_examples_count, "error") +
            " occurred outside of examples"
          )
        end
        summary
      end

      # @api public
      #
      # Wraps the results line with colors based on the configured
      # colors for failure, pending, and success. Defaults to red,
      # yellow, green accordingly.
      #
      # @param colorizer [#wrap] An object which supports wrapping text with
      #                          specific colors.
      # @return [String] A colorized results line.
      def colorized_totals_line(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        if failure_count > 0 || errors_outside_of_examples_count > 0
          colorizer.wrap(totals_line, RSpec.configuration.failure_color)
        elsif pending_count > 0
          colorizer.wrap(totals_line, RSpec.configuration.pending_color)
        else
          colorizer.wrap(totals_line, RSpec.configuration.success_color)
        end
      end

      # @api public
      #
      # Formats failures into a rerunable command format.
      #
      # @param colorizer [#wrap] An object which supports wrapping text with
      #                          specific colors.
      # @return [String] A colorized summary line.
      def colorized_rerun_commands(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        "\nFailed examples:\n\n" +
        failed_examples.map do |example|
          colorizer.wrap("rspec #{rerun_argument_for(example)}", RSpec.configuration.failure_color) + " " +
          colorizer.wrap("# #{example.full_description}",   RSpec.configuration.detail_color)
        end.join("\n")
      end

      # @return [String] a formatted version of the time it took to run the
      #   suite
      def formatted_duration
        Formatters::Helpers.format_duration(duration)
      end

      # @return [String] a formatted version of the time it took to boot RSpec
      #   and load the spec files
      def formatted_load_time
        Formatters::Helpers.format_duration(load_time)
      end

      # @return [String] The summary information fully formatted in the way that
      #   RSpec's built-in formatters emit.
      def fully_formatted(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
        formatted = "\nFinished in #{formatted_duration} " \
                    "(files took #{formatted_load_time} to load)\n" \
                    "#{colorized_totals_line(colorizer)}\n"

        unless failed_examples.empty?
          formatted += (colorized_rerun_commands(colorizer) + "\n")
        end

        formatted
      end

    private

      include RSpec::Core::ShellEscape

      def rerun_argument_for(example)
        location = example.location_rerun_argument
        return location unless duplicate_rerun_locations.include?(location)
        conditionally_quote(example.id)
      end

      def duplicate_rerun_locations
        @duplicate_rerun_locations ||= begin
          locations = RSpec.world.all_examples.map(&:location_rerun_argument)

          Set.new.tap do |s|
            locations.group_by { |l| l }.each do |l, ls|
              s << l if ls.count > 1
            end
          end
        end
      end
    end

    # The `ProfileNotification` holds information about the results of running a
    # test suite when profiling is enabled. It is used by formatters to provide
    # information at the end of the test run for profiling information.
    #
    # @attr duration [Float] the time taken (in seconds) to run the suite
    # @attr examples [Array<RSpec::Core::Example>] the examples run
    # @attr number_of_examples [Fixnum] the number of examples to profile
    # @attr example_groups [Array<RSpec::Core::Profiler>] example groups run
    class ProfileNotification
      def initialize(duration, examples, number_of_examples, example_groups)
        @duration = duration
        @examples = examples
        @number_of_examples = number_of_examples
        @example_groups = example_groups
      end
      attr_reader :duration, :examples, :number_of_examples

      # @return [Array<RSpec::Core::Example>] the slowest examples
      def slowest_examples
        @slowest_examples ||=
          examples.sort_by do |example|
            -example.execution_result.run_time
          end.first(number_of_examples)
      end

      # @return [Float] the time taken (in seconds) to run the slowest examples
      def slow_duration
        @slow_duration ||=
          slowest_examples.inject(0.0) do |i, e|
            i + e.execution_result.run_time
          end
      end

      # @return [String] the percentage of total time taken
      def percentage
        @percentage ||=
          begin
            time_taken = slow_duration / duration
            '%.1f' % ((time_taken.nan? ? 0.0 : time_taken) * 100)
          end
      end

      # @return [Array<RSpec::Core::Example>] the slowest example groups
      def slowest_groups
        @slowest_groups ||= calculate_slowest_groups
      end

    private

      def calculate_slowest_groups
        # stop if we've only one example group
        return {} if @example_groups.keys.length <= 1

        @example_groups.each_value do |hash|
          hash[:average] = hash[:total_time].to_f / hash[:count]
        end

        groups = @example_groups.sort_by { |_, hash| -hash[:average] }.first(number_of_examples)
        groups.map { |group, data| [group.location, data] }
      end
    end

    # The `DeprecationNotification` is issued by the reporter when a deprecated
    # part of RSpec is encountered. It represents information about the
    # deprecated call site.
    #
    # @attr message [String] A custom message about the deprecation
    # @attr deprecated [String] A custom message about the deprecation (alias of
    #   message)
    # @attr replacement [String] An optional replacement for the deprecation
    # @attr call_site [String] An optional call site from which the deprecation
    #   was issued
    DeprecationNotification = Struct.new(:deprecated, :message, :replacement, :call_site)
    class DeprecationNotification
      private_class_method :new

      # @api
      # Convenience way to initialize the notification
      def self.from_hash(data)
        new data[:deprecated], data[:message], data[:replacement], data[:call_site]
      end
    end

    # `NullNotification` represents a placeholder value for notifications that
    # currently require no information, but we may wish to extend in future.
    class NullNotification
    end

    # `CustomNotification` is used when sending custom events to formatters /
    # other registered listeners, it creates attributes based on supplied hash
    # of options.
    class CustomNotification < Struct
      # @param options [Hash] A hash of method / value pairs to create on this notification
      # @return [CustomNotification]
      #
      # Build a custom notification based on the supplied option key / values.
      def self.for(options={})
        return NullNotification if options.keys.empty?
        new(*options.keys).new(*options.values)
      end
    end
  end
end