lib/rspec/expectations/failure_aggregator.rb
module RSpec module Expectations # @private class FailureAggregator attr_reader :block_label, :metadata # @private class AggregatedFailure # :nocov: # `inspect` was apparently used by some versions early in ruby 3 while constructing # NoMethodError, but seems to be no longer. # # @private MESSAGE = 'AggregatedFailure: This method caused a failure which has been ' \ 'suppressed to be aggregated into our failure report by returning ' \ 'this value, further errors can be ignored.' def inspect MESSAGE end # :nocov: end AGGREGATED_FAILURE = AggregatedFailure.new def aggregate RSpec::Support.with_failure_notifier(self) do begin yield rescue ExpectationNotMetError => e # Normally, expectation failures will be notified via the `call` method, below, # but since the failure notifier uses a thread local variable, failing expectations # in another thread will still raise. We handle that here and categorize it as part # of `failures` rather than letting it fall through and be categorized as part of # `other_errors`. failures << e rescue Support::AllExceptionsExceptOnesWeMustNotRescue => e # While it is normally a bad practice to rescue `Exception`, it's important we do # so here. It's low risk (`notify_aggregated_failures` below will re-raise the exception, # or raise a `MultipleExpectationsNotMetError` that includes the exception), and it's # essential that the user is notified of expectation failures that may have already # occurred in the `aggregate_failures` block. Those expectation failures may provide # important diagnostics for understanding why this exception occurred, and if we simply # allowed this exception to be raised as-is, it would (wrongly) suggest to the user # that the expectation passed when it did not, which would be quite confusing. other_errors << e end end notify_aggregated_failures end def failures @failures ||= [] end def other_errors @other_errors ||= [] end # This method is defined to satisfy the callable interface # expected by `RSpec::Support.with_failure_notifier`. def call(failure, options) source_id = options[:source_id] return if source_id && @seen_source_ids.key?(source_id) @seen_source_ids[source_id] = true assign_backtrace(failure) unless failure.backtrace failures << failure AGGREGATED_FAILURE end private if RSpec::Support::Ruby.jruby? && RSpec::Support::Ruby.jruby_version < '9.2.0.0' # On JRuby 9.1.x.x and before, `caller` and `raise` produce different backtraces with # regards to `.java` stack frames. It's important that we use `raise` for JRuby to produce # a backtrace that has a continuous common section with the raised `MultipleExpectationsNotMetError`, # so that rspec-core's truncation logic can work properly on it to list the backtrace # relative to the `aggregate_failures` block. # :nocov: def assign_backtrace(failure) raise failure rescue failure.class => e failure.set_backtrace(e.backtrace) end # :nocov: else # Using `caller` performs better (and is simpler) than `raise` on most Rubies. def assign_backtrace(failure) failure.set_backtrace(caller) end end def initialize(block_label, metadata) @block_label = block_label @metadata = metadata @seen_source_ids = {} # don't want to load stdlib set end def notify_aggregated_failures all_errors = failures + other_errors case all_errors.size when 0 then return true when 1 then RSpec::Support.notify_failure all_errors.first else RSpec::Support.notify_failure MultipleExpectationsNotMetError.new(self) end end end # Exception raised from `aggregate_failures` when multiple expectations fail. class MultipleExpectationsNotMetError # @return [String] The fully formatted exception message. def message @message ||= (["#{summary}:"] + enumerated_failures + enumerated_errors).join("\n\n") end # @return [Array<RSpec::Expectations::ExpectationNotMetError>] The list of expectation failures. def failures @failure_aggregator.failures end # @return [Array<Exception>] The list of other exceptions. def other_errors @failure_aggregator.other_errors end # @return [Array<Exception>] The list of expectation failures and other exceptions, combined. attr_reader :all_exceptions # @return [String] The user-assigned label for the aggregation block. def aggregation_block_label @failure_aggregator.block_label end # @return [Hash] The metadata hash passed to `aggregate_failures`. def aggregation_metadata @failure_aggregator.metadata end # @return [String] A summary of the failure, including the block label and a count of failures. def summary "Got #{exception_count_description} from failure aggregation " \ "block#{block_description}" end # return [String] A description of the failure/error counts. def exception_count_description failure_count = pluralize("failure", failures.size) return failure_count if other_errors.empty? error_count = pluralize("other error", other_errors.size) "#{failure_count} and #{error_count}" end private def initialize(failure_aggregator) @failure_aggregator = failure_aggregator @all_exceptions = failures + other_errors end def block_description return "" unless aggregation_block_label " #{aggregation_block_label.inspect}" end def pluralize(noun, count) "#{count} #{noun}#{'s' unless count == 1}" end def enumerated(exceptions, index_offset) exceptions.each_with_index.map do |exception, index| index += index_offset formatted_message = "#{yield exception}\n#{format_backtrace(exception.backtrace).first}" "#{index_label index}#{indented formatted_message, index}" end end def exclusion_patterns patterns = %w[/lib\d*/ruby/ bin/ exe/rspec /lib/bundler/ /exe/bundle:] patterns << "org/jruby/" if RSpec::Support::Ruby.jruby? patterns.map! { |s| Regexp.new(s.gsub('/', File::SEPARATOR)) } end def format_backtrace(backtrace) backtrace.map { |l| backtrace_line(l) }.compact.tap { |filtered| filtered.concat backtrace if filtered.empty? } end def backtrace_line(line) return if [Regexp.union(RSpec::CallerFilter::IGNORE_REGEX, *exclusion_patterns)].any? { |p| line =~ p } # It changes the current path that is relative to # system root to be relative to the project root. line.sub(/(\A|\s)#{File.expand_path('.')}(#{File::SEPARATOR}|\s|\Z)/, '\\1.\\2'.freeze).sub(/\A([^:]+:\d+)$/, '\\1'.freeze) end def enumerated_failures enumerated(failures, 0, &:message) end def enumerated_errors enumerated(other_errors, failures.size) do |error| "#{error.class}: #{error.message}" end end def indented(failure_message, index) line_1, *rest = failure_message.strip.lines.to_a first_line_indentation = ' ' * (longest_index_label_width - width_of_label(index)) first_line_indentation + line_1 + rest.map do |line| line =~ /\S/ ? indentation + line : line end.join end def indentation @indentation ||= ' ' * longest_index_label_width end def longest_index_label_width @longest_index_label_width ||= width_of_label(failures.size) end def width_of_label(index) index_label(index).chars.count end def index_label(index) " #{index + 1}) " end end end end