lib/rspec/matchers/built_in/output.rb



# frozen_string_literal: true

require 'stringio'

module RSpec
  module Matchers
    module BuiltIn
      # @api private
      # Provides the implementation for `output`.
      # Not intended to be instantiated directly.
      class Output < BaseMatcher
        def initialize(expected)
          @expected        = expected
          @actual          = ""
          @block           = nil
          @stream_capturer = NullCapture
        end

        def matches?(block)
          @block = block
          @actual = @stream_capturer.capture(block)
          @expected ? values_match?(@expected, @actual) : captured?
        end

        def does_not_match?(block)
          !matches?(block)
        end

        # @api public
        # Tells the matcher to match against stdout.
        # Works only when the main Ruby process prints to stdout
        def to_stdout
          @stream_capturer = CaptureStdout.new
          self
        end

        # @api public
        # Tells the matcher to match against stderr.
        # Works only when the main Ruby process prints to stderr
        def to_stderr
          @stream_capturer = CaptureStderr.new
          self
        end

        # @api public
        # Tells the matcher to match against stdout.
        # Works when subprocesses print to stdout as well.
        # This is significantly (~30x) slower than `to_stdout`
        def to_stdout_from_any_process
          @stream_capturer = CaptureStreamToTempfile.new("stdout", $stdout)
          self
        end

        # @api public
        # Tells the matcher to match against stderr.
        # Works when subprocesses print to stderr as well.
        # This is significantly (~30x) slower than `to_stderr`
        def to_stderr_from_any_process
          @stream_capturer = CaptureStreamToTempfile.new("stderr", $stderr)
          self
        end

        # @api public
        # Tells the matcher to simulate the output stream being a TTY.
        # This is useful to test code like `puts '...' if $stdout.tty?`.
        def as_tty
          raise ArgumentError, '`as_tty` can only be used after `to_stdout` or `to_stderr`' unless @stream_capturer.respond_to?(:as_tty=)

          @stream_capturer.as_tty = true
          self
        end

        # @api public
        # Tells the matcher to simulate the output stream not being a TTY.
        # Note that that's the default behaviour if you don't call `as_tty`
        # (since `StringIO` is not a TTY).
        def as_not_tty
          raise ArgumentError, '`as_not_tty` can only be used after `to_stdout` or `to_stderr`' unless @stream_capturer.respond_to?(:as_tty=)

          @stream_capturer.as_tty = false
          self
        end

        # @api private
        # @return [String]
        def failure_message
          "expected block to #{description}, but #{positive_failure_reason}"
        end

        # @api private
        # @return [String]
        def failure_message_when_negated
          "expected block to not #{description}, but #{negative_failure_reason}"
        end

        # @api private
        # @return [String]
        def description
          if @expected
            "output #{description_of @expected} to #{@stream_capturer.name}"
          else
            "output to #{@stream_capturer.name}"
          end
        end

        # @api private
        # @return [Boolean]
        def diffable?
          true
        end

        # @api private
        # Indicates this matcher matches against a block.
        # @return [True]
        def supports_block_expectations?
          true
        end

        # @api private
        # Indicates this matcher matches against a block only.
        # @return [False]
        def supports_value_expectations?
          false
        end

      private

        def captured?
          @actual.length > 0
        end

        def positive_failure_reason
          return "output #{actual_output_description}" if @expected
          "did not"
        end

        def negative_failure_reason
          "output #{actual_output_description}"
        end

        def actual_output_description
          return "nothing" unless captured?
          actual_formatted
        end
      end

      # @private
      module NullCapture
        def self.name
          "some stream"
        end

        def self.capture(_block)
          raise "You must chain `to_stdout` or `to_stderr` off of the `output(...)` matcher."
        end
      end

      # @private
      class CapturedStream < StringIO
        attr_accessor :as_tty

        def tty?
          return super if as_tty.nil?
          as_tty
        end
      end

      # @private
      class CaptureStdout
        attr_accessor :as_tty

        def name
          'stdout'
        end

        def capture(block)
          captured_stream = CapturedStream.new
          captured_stream.as_tty = as_tty

          original_stream = $stdout
          $stdout = captured_stream

          block.call

          captured_stream.string
        ensure
          $stdout = original_stream
        end
      end

      # @private
      class CaptureStderr
        attr_accessor :as_tty

        def name
          'stderr'
        end

        def capture(block)
          captured_stream = CapturedStream.new
          captured_stream.as_tty = as_tty

          original_stream = $stderr
          $stderr = captured_stream

          block.call

          captured_stream.string
        ensure
          $stderr = original_stream
        end
      end

      # @private
      class CaptureStreamToTempfile < Struct.new(:name, :stream)
        def capture(block)
          # We delay loading tempfile until it is actually needed because
          # we want to minimize stdlibs loaded so that users who use a
          # portion of the stdlib can't have passing specs while forgetting
          # to load it themselves. `CaptureStreamToTempfile` is rarely used
          # and `tempfile` pulls in a bunch of things (delegate, tmpdir,
          # thread, fileutils, etc), so it's worth delaying it until this point.
          require 'tempfile'

          original_stream = stream.clone
          captured_stream = Tempfile.new(name)

          begin
            captured_stream.sync = true
            stream.reopen(captured_stream)
            block.call
            captured_stream.rewind
            captured_stream.read
          ensure
            stream.reopen(original_stream)
            captured_stream.close
            captured_stream.unlink
          end
        end
      end
    end
  end
end