rspec/rspec-core

View on GitHub
lib/rspec/core/formatters/exception_presenter.rb

Summary

Maintainability
C
1 day
Test Coverage
# encoding: utf-8
RSpec::Support.require_rspec_core "formatters/console_codes"
RSpec::Support.require_rspec_core "formatters/snippet_extractor"
RSpec::Support.require_rspec_core 'formatters/syntax_highlighter'
RSpec::Support.require_rspec_support "encoded_string"

module RSpec
  module Core
    module Formatters
      # @private
      class ExceptionPresenter
        attr_reader :exception, :example, :description, :message_color,
                    :detail_formatter, :extra_detail_formatter, :backtrace_formatter
        private :message_color, :detail_formatter, :extra_detail_formatter, :backtrace_formatter

        def initialize(exception, example, options={})
          @exception               = exception
          @example                 = example
          @message_color           = options.fetch(:message_color)          { RSpec.configuration.failure_color }
          @description             = options.fetch(:description)            { example.full_description }
          @detail_formatter        = options.fetch(:detail_formatter)       { Proc.new {} }
          @extra_detail_formatter  = options.fetch(:extra_detail_formatter) { Proc.new {} }
          @backtrace_formatter     = options.fetch(:backtrace_formatter)    { RSpec.configuration.backtrace_formatter }
          @indentation             = options.fetch(:indentation, 2)
          @skip_shared_group_trace = options.fetch(:skip_shared_group_trace, false)
          @failure_lines           = options[:failure_lines]
        end

        def message_lines
          add_shared_group_lines(failure_lines, Notifications::NullColorizer)
        end

        def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          add_shared_group_lines(failure_lines, colorizer).map do |line|
            colorizer.wrap line, message_color
          end
        end

        def formatted_backtrace(exception=@exception)
          backtrace_formatter.format_backtrace(exception.backtrace, example.metadata) +
            formatted_cause(exception)
        end

        if RSpec::Support::RubyFeatures.supports_exception_cause?
          def formatted_cause(exception)
            last_cause = final_exception(exception, [exception])
            cause = []

            if exception.cause
              cause << '------------------'
              cause << '--- Caused by: ---'
              cause << "#{exception_class_name(last_cause)}:" unless exception_class_name(last_cause) =~ /RSpec/

              encoded_string(last_cause.message.to_s).split("\n").each do |line|
                cause << "  #{line}"
              end

              unless last_cause.backtrace.empty?
                cause << ("  #{backtrace_formatter.format_backtrace(last_cause.backtrace, example.metadata).first}")
              end
            end

            cause
          end
        else
          # :nocov:
          def formatted_cause(_)
            []
          end
          # :nocov:
        end

        def colorized_formatted_backtrace(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          formatted_backtrace.map do |backtrace_info|
            colorizer.wrap "# #{backtrace_info}", RSpec.configuration.detail_color
          end
        end

        def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes)
          lines = fully_formatted_lines(failure_number, colorizer)
          lines.join("\n") << "\n"
        end

        def fully_formatted_lines(failure_number, colorizer)
          lines = [
            encoded_description(description),
            detail_formatter.call(example, colorizer),
            formatted_message_and_backtrace(colorizer),
            extra_detail_formatter.call(failure_number, colorizer),
          ].compact.flatten

          lines = indent_lines(lines, failure_number)
          lines.unshift("")
          lines
        end

      private

        def final_exception(exception, previous=[])
          cause = exception.cause

          if cause && Exception === cause && !previous.include?(cause)
            previous << cause
            final_exception(cause, previous)
          else
            exception
          end
        end

        if String.method_defined?(:encoding)
          def encoding_of(string)
            string.encoding
          end

          def encoded_string(string)
            RSpec::Support::EncodedString.new(string, Encoding.default_external)
          end
        else # for 1.8.7
          # :nocov:
          def encoding_of(_string)
          end

          def encoded_string(string)
            RSpec::Support::EncodedString.new(string)
          end
          # :nocov:
        end

        def indent_lines(lines, failure_number)
          alignment_basis = ' ' * @indentation
          alignment_basis <<  "#{failure_number}) " if failure_number
          indentation = ' ' * alignment_basis.length

          lines.each_with_index.map do |line, index|
            if index == 0
              "#{alignment_basis}#{line}"
            elsif line.empty?
              line
            else
              "#{indentation}#{line}"
            end
          end
        end

        def exception_class_name(exception=@exception)
          name = exception.class.name.to_s
          name = "(anonymous error class)" if name == ''
          name
        end

        def failure_lines
          @failure_lines ||= [].tap do |lines|
            lines.concat(failure_slash_error_lines)

            sections = [failure_slash_error_lines, exception_lines]
            if sections.any? { |section| section.size > 1 } && !exception_lines.first.empty?
              lines << ''
            end

            lines.concat(exception_lines)
            lines.concat(extra_failure_lines)
          end
        end

        def failure_slash_error_lines
          lines = read_failed_lines
          if lines.count == 1
            lines[0] = "Failure/Error: #{lines[0].strip}"
          else
            least_indentation = SnippetExtractor.least_indentation_from(lines)
            lines = lines.map { |line| line.sub(/^#{least_indentation}/, '  ') }
            lines.unshift('Failure/Error:')
          end
          lines
        end

        def exception_lines
          @exception_lines ||= begin
            lines = []
            lines << "#{exception_class_name}:" unless exception_class_name =~ /RSpec/
            encoded_string(exception.message.to_s).split("\n").each do |line|
              lines << (line.empty? ? line : "  #{line}")
            end
            lines
          end
        end

        def extra_failure_lines
          @extra_failure_lines ||= begin
            lines = Array(example.metadata[:extra_failure_lines])
            unless lines.empty?
              lines.unshift('')
              lines.push('')
            end
            lines
          end
        end

        def add_shared_group_lines(lines, colorizer)
          return lines if @skip_shared_group_trace

          example.metadata[:shared_group_inclusion_backtrace].each do |frame|
            lines << colorizer.wrap(frame.description, RSpec.configuration.default_color)
          end

          lines
        end

        def read_failed_lines
          matching_line = find_failed_line
          unless matching_line
            return ["Unable to find matching line from backtrace"]
          end

          file_and_line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)

          unless file_and_line_number
            return ["Unable to infer file and line number from backtrace"]
          end

          file_path, line_number = file_and_line_number[1..2]
          max_line_count = RSpec.configuration.max_displayed_failure_line_count
          lines = SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i, max_line_count)
          RSpec.world.syntax_highlighter.highlight(lines)
        rescue SnippetExtractor::NoSuchFileError
          ["Unable to find #{file_path} to read failed line"]
        rescue SnippetExtractor::NoSuchLineError
          ["Unable to find matching line in #{file_path}"]
        rescue SecurityError
          ["Unable to read failed line"]
        end

        def find_failed_line
          line_regex = RSpec.configuration.in_project_source_dir_regex
          loaded_spec_files = RSpec.configuration.loaded_spec_files

          exception_backtrace.find do |line|
            next unless (line_path = line[/(.+?):(\d+)(|:\d+)/, 1])
            path = File.expand_path(line_path)
            loaded_spec_files.include?(path) || path =~ line_regex
          end || exception_backtrace.first
        end

        def formatted_message_and_backtrace(colorizer)
          lines = colorized_message_lines(colorizer) + colorized_formatted_backtrace(colorizer)
          encoding = encoding_of("")
          lines.map do |line|
            RSpec::Support::EncodedString.new(line, encoding)
          end
        end

        if  String.method_defined?(:encoding)
          def encoded_description(description)
            return if description.nil?
            encoded_string(description)
          end
        else # for 1.8.7
          def encoded_description(description)
            description
          end
        end

        def exception_backtrace
          exception.backtrace || []
        end

        # @private
        # Configuring the `ExceptionPresenter` with the right set of options to handle
        # pending vs failed vs skipped and aggregated (or not) failures is not simple.
        # This class takes care of building an appropriate `ExceptionPresenter` for the
        # provided example.
        class Factory
          def build
            ExceptionPresenter.new(@exception, @example, options)
          end

        private

          def initialize(example)
            @example          = example
            @execution_result = example.execution_result
            @exception        = if @execution_result.status == :pending
                                  @execution_result.pending_exception
                                else
                                  @execution_result.exception
                                end
          end

          def options
            with_multiple_error_options_as_needed(@exception, pending_options || {})
          end

          def pending_options
            if @execution_result.pending_fixed?
              {
                :description   => "#{@example.full_description} FIXED",
                :message_color => RSpec.configuration.fixed_color,
                :failure_lines => [
                  "Expected pending '#{@execution_result.pending_message}' to fail. No error was raised."
                ]
              }
            elsif @execution_result.status == :pending
              {
                :message_color    => RSpec.configuration.pending_color,
                :detail_formatter => PENDING_DETAIL_FORMATTER
              }
            end
          end

          def with_multiple_error_options_as_needed(exception, options)
            return options unless multiple_exceptions_error?(exception)

            options = options.merge(
              :failure_lines          => [],
              :extra_detail_formatter => sub_failure_list_formatter(exception, options[:message_color]),
              :detail_formatter       => multiple_exception_summarizer(exception,
                                                                       options[:detail_formatter],
                                                                       options[:message_color])
            )

            return options unless exception.aggregation_metadata[:hide_backtrace]
            options[:backtrace_formatter] = EmptyBacktraceFormatter
            options
          end

          def multiple_exceptions_error?(exception)
            MultipleExceptionError::InterfaceTag === exception
          end

          def multiple_exception_summarizer(exception, prior_detail_formatter, color)
            lambda do |example, colorizer|
              summary = if exception.aggregation_metadata[:hide_backtrace]
                          # Since the backtrace is hidden, the subfailures will come
                          # immediately after this, and using `:` will read well.
                          "Got #{exception.exception_count_description}:"
                        else
                          # The backtrace comes after this, so using a `:` doesn't make sense
                          # since the failures may be many lines below.
                          "#{exception.summary}."
                        end

              summary = colorizer.wrap(summary, color || RSpec.configuration.failure_color)
              return summary unless prior_detail_formatter
              [
                prior_detail_formatter.call(example, colorizer),
                summary
              ]
            end
          end

          def sub_failure_list_formatter(exception, message_color)
            common_backtrace_truncater = CommonBacktraceTruncater.new(exception)

            lambda do |failure_number, colorizer|
              FlatMap.flat_map(exception.all_exceptions.each_with_index) do |failure, index|
                options = with_multiple_error_options_as_needed(
                  failure,
                  :description             => nil,
                  :indentation             => 0,
                  :message_color           => message_color || RSpec.configuration.failure_color,
                  :skip_shared_group_trace => true
                )

                failure   = common_backtrace_truncater.with_truncated_backtrace(failure)
                presenter = ExceptionPresenter.new(failure, @example, options)
                presenter.fully_formatted_lines(
                  "#{failure_number ? "#{failure_number}." : ''}#{index + 1}",
                  colorizer
                )
              end
            end
          end

          # @private
          # Used to prevent a confusing backtrace from showing up from the `aggregate_failures`
          # block declared for `:aggregate_failures` metadata.
          module EmptyBacktraceFormatter
            def self.format_backtrace(*)
              []
            end
          end

          # @private
          class CommonBacktraceTruncater
            def initialize(parent)
              @parent = parent
            end

            def with_truncated_backtrace(child)
              child_bt  = child.backtrace
              parent_bt = @parent.backtrace
              return child if child_bt.nil? || child_bt.empty? || parent_bt.nil?

              index_before_first_common_frame = -1.downto(-child_bt.size).find do |index|
                parent_bt[index] != child_bt[index]
              end

              return child if index_before_first_common_frame.nil?
              return child if index_before_first_common_frame == -1

              child = child.dup
              child.set_backtrace(child_bt[0..index_before_first_common_frame])
              child
            end
          end
        end

        # @private
        PENDING_DETAIL_FORMATTER = Proc.new do |example, colorizer|
          colorizer.wrap("# #{example.execution_result.pending_message}", :detail)
        end
      end
    end

    # Provides a single exception instance that provides access to
    # multiple sub-exceptions. This is used in situations where a single
    # individual spec has multiple exceptions, such as one in the `it` block
    # and one in an `after` block.
    class MultipleExceptionError < StandardError
      # @private
      # Used so there is a common module in the ancestor chain of this class
      # and `RSpec::Expectations::MultipleExpectationsNotMetError`, which allows
      # code to detect exceptions that are instances of either, without first
      # checking to see if rspec-expectations is loaded.
      module InterfaceTag
        # Appends the provided exception to the list.
        # @param exception [Exception] Exception to append to the list.
        # @private
        def add(exception)
          # `PendingExampleFixedError` can be assigned to an example that initially has no
          # failures, but when the `aggregate_failures` around hook completes, it notifies of
          # a failure. If we do not ignore `PendingExampleFixedError` it would be surfaced to
          # the user as part of a multiple exception error, which is undesirable. While it's
          # pretty weird we handle this here, it's the best solution I've been able to come
          # up with, and `PendingExampleFixedError` always represents the _lack_ of any exception
          # so clearly when we are transitioning to a `MultipleExceptionError`, it makes sense to
          # ignore it.
          return if Pending::PendingExampleFixedError === exception

          return if exception == self

          all_exceptions << exception

          if exception.class.name =~ /RSpec/
            failures << exception
          else
            other_errors << exception
          end
        end

        # Provides a way to force `ex` to be something that satisfies the multiple
        # exception error interface. If it already satisfies it, it will be returned;
        # otherwise it will wrap it in a `MultipleExceptionError`.
        # @private
        def self.for(ex)
          return ex if self === ex
          MultipleExceptionError.new(ex)
        end
      end

      include InterfaceTag

      # @return [Array<Exception>] The list of failures.
      attr_reader :failures

      # @return [Array<Exception>] The list of other errors.
      attr_reader :other_errors

      # @return [Array<Exception>] The list of failures and other exceptions, combined.
      attr_reader :all_exceptions

      # @return [Hash] Metadata used by RSpec for formatting purposes.
      attr_reader :aggregation_metadata

      # @return [nil] Provided only for interface compatibility with
      #   `RSpec::Expectations::MultipleExpectationsNotMetError`.
      attr_reader :aggregation_block_label

      # @param exceptions [Array<Exception>] The initial list of exceptions.
      def initialize(*exceptions)
        super()

        @failures                = []
        @other_errors            = []
        @all_exceptions          = []
        @aggregation_metadata    = { :hide_backtrace => true }
        @aggregation_block_label = nil

        exceptions.each { |e| add e }
      end

      # @return [String] Combines all the exception messages into a single string.
      # @note RSpec does not actually use this -- instead it formats each exception
      #   individually.
      def message
        all_exceptions.map(&:message).join("\n\n")
      end

      # @return [String] A summary of the failure, including the block label and a count of failures.
      def summary
        "Got #{exception_count_description}"
      end

      # return [String] A description of the failure/error counts.
      def exception_count_description
        failure_count = Formatters::Helpers.pluralize(failures.size, "failure")
        return failure_count if other_errors.empty?
        error_count = Formatters::Helpers.pluralize(other_errors.size, "other error")
        "#{failure_count} and #{error_count}"
      end
    end
  end
end