lib/rspec_command.rb



#
# Copyright 2015, Noah Kantrowitz
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require 'fileutils'
require 'tempfile'

require 'rspec'
require 'rspec/its'
require 'mixlib/shellout'


# An RSpec helper module for testing command-line tools.
#
# @api public
# @since 1.0.0
# @example Enable globally
#   RSpec.configure do |config|
#     config.include RSpecCommand
#   end
# @example Enable for a single example group
#   describe 'myapp' do
#     command 'myapp --version'
#     its(:stdout) { it_expected.to include('1.0.0') }
#   end
module RSpecCommand
  extend RSpec::SharedContext

  autoload :MatchFixture, 'rspec_command/match_fixture'
  autoload :Rake, 'rspec_command/rake'

  around do |example|
    Dir.mktmpdir('rspec_command') do |path|
      example.metadata[:rspec_command_temp_path] = path
      example.run
    end
  end

  # @!attribute [r] temp_path
  #   Path to the temporary directory created for the current example.
  #   @return [String]
  let(:temp_path) do |example|
    example.metadata[:rspec_command_temp_path]
  end

  # @!attribute [r] fixture_root
  #   Base path for the fixtures directory. Default value is 'fixtures'.
  #   @return [String]
  #   @example
  #     let(:fixture_root) { 'data' }
  let(:fixture_root) { 'fixtures' }

  # @!attribute [r] _environment
  #   @!visibility private
  #   @api private
  #   Accumulator for environment variables.
  #   @see RSpecCommand.environment
  let(:_environment) { Hash.new }

  # Run a command.
  #
  # @see .command
  # @param cmd [String, Array] Command to run. If passed as an array, no shell
  #   expansion will be performed.
  # @param options [Hash<Symbol, Object>] Options to pass to
  #   Mixlib::ShellOut.new.
  # @option options [Boolean] allow_error If true, don't raise an error on
  #   failed commands.
  # @return [Mixlib::ShellOut]
  # @example
  #   before do
  #     command('git init')
  #   end
  def command(cmd, options={})
    # Try to find a Gemfile
    gemfile_path = ENV['BUNDLE_GEMFILE'] || find_file(self.class.file_path, 'Gemfile')
    gemfile_environment = gemfile_path ? {'BUNDLE_GEMFILE' => gemfile_path} : {}
    # Create the command
    options = options.dup
    allow_error = options.delete(:allow_error)
    full_cmd = if gemfile_path
      if cmd.is_a?(Array)
        %w{bundle exec} + cmd
      else
        "bundle exec #{cmd}"
      end
    else
      cmd
    end
    Mixlib::ShellOut.new(
      full_cmd,
      {
        cwd: temp_path,
        environment: gemfile_environment.merge(_environment),
      }.merge(options),
    ).tap do |cmd_out|
      # Run the command
      cmd_out.run_command
      cmd_out.error! unless allow_error
    end
  end

  # Matcher to compare files or folders from the temporary directory to a
  # fixture.
  #
  # @example
  #   describe 'myapp' do
  #     command 'myapp write'
  #     it { is_expected.to match_fixture('write_data') }
  #   end
  def match_fixture(fixture_path, local_path=nil)
    MatchFixture.new(find_fixture(self.class.file_path), temp_path, fixture_path, local_path)
  end

  # Run a local block with $stdout and $stderr redirected to a strings. Useful
  # for running CLI code in unit tests. The returned string has `#stdout`,
  # `#stderr` and `#exitstatus` attributes to emulate the output from {.command}.
  #
  # @param block [Proc] Code to run.
  # @return [String]
  # @example
  #   describe 'my rake task' do
  #     subject do
  #       capture_output do
  #         Rake::Task['mytask'].invoke
  #       end
  #     end
  #   end
  def capture_output(&block)
    old_stdout = $stdout.dup
    old_stderr = $stderr.dup
    # Potential future improvement is to use IO.pipe instead of temp files, but
    # that would require threads or something to read contiuously since the
    # buffer is only 64k on the kernel side.
    Tempfile.open('capture_stdout') do |tmp_stdout|
      Tempfile.open('capture_stderr') do |tmp_stderr|
        $stdout.reopen(tmp_stdout)
        $stdout.sync = true
        $stderr.reopen(tmp_stderr)
        $stderr.sync = true
        output = nil
        begin
          # Inner block to make sure the ensure happens first.
          begin
            block.call
          ensure
            # Rewind.
            tmp_stdout.seek(0, 0)
            tmp_stderr.seek(0, 0)
            # Read in the output.
            output = OutputString.new(tmp_stdout.read, tmp_stderr.read)
          end
        rescue Exception => e
          if output
            # Try to add the output so far as an attribute on the exception via
            # a closure.
            e.define_singleton_method(:output_so_far) do
              output
            end
          end
          raise
        else
          output
        end
      end
    end
  ensure
    $stdout.reopen(old_stdout)
    $stderr.reopen(old_stderr)
  end

  # String subclass to make string output look kind of like Mixlib::ShellOut.
  #
  # @api private
  # @see capture_stdout
  class OutputString < String
    def initialize(stdout, stderr)
      super(stdout)
      @stderr = stderr
    end

    def stdout
      self
    end

    def stderr
      @stderr
    end

    def exitstatus
      0
    end
  end

  private

  # Search backwards along the working directory looking for a file, a la .git.
  # Either file or block must be given.
  #
  # @param example_path [String] Path of the current example file. Find via
  #   example.file_path.
  # @param file [String] Relative path to search for.
  # @param backstop [String] Path to not search past.
  # @param block [Proc] Block to use as a filter.
  # @return [String, nil]
  def find_file(example_path, file=nil, backstop=nil, &block)
    path = File.dirname(File.expand_path(example_path))
    last_path = nil
    while path != last_path && path != backstop
      if block
        block_val = block.call(path)
        return block_val if block_val
      else
        file_path = File.join(path, file)
        return file_path if File.exists?(file_path)
      end
      last_path = path
      path = File.dirname(path)
    end
    nil
  end

  # Find the base folder of the current gem.
  def find_gem_base(example_path)
    @gem_base ||= begin
      paths = []
      paths << find_file(example_path) do |path|
        spec_path = Dir.entries(path).find do |ent|
          ent.end_with?('.gemspec')
        end
        spec_path = File.join(path, spec_path) if spec_path
        spec_path
      end
      paths << find_file(example_path, 'Gemfile')
      File.dirname(paths.find {|v| v })
    end
  end

  # Find a fixture file or the fixture base folder.
  def find_fixture(example_path, path=nil)
    @fixture_base ||= find_file(example_path, fixture_root, find_gem_base(example_path))
    path ? File.join(@fixture_base, path) : @fixture_base
  end

  # @!classmethods
  module ClassMethods
    # Run a command as the subject of this example. The command can be passed in
    # as a string, array, or block. The subject will be a Mixlib::ShellOut
    # object, all attributes from there will work with rspec-its.
    #
    # @see #command
    # @param cmd [String, Array] Command to run. If passed as an array, no shell
    #   expansion will be performed.
    # @param options [Hash<Symbol, Object>] Options to pass to
    #   Mixlib::ShellOut.new.
    # @param block [Proc] Optional block to return a command to run.
    # @option options [Boolean] allow_error If true, don't raise an error on
    #   failed commands.
    # @example
    #   describe 'myapp' do
    #     command 'myapp show'
    #     its(:stdout) { is_expected.to match(/a thing/) }
    #   end
    def command(cmd=nil, options={}, &block)
      metadata[:command] = true
      subject do |example|
        # If a block is given, use it to get the command.
        cmd = instance_eval(&block) if block
        command(cmd, options)
      end
    end

    # Create a file in the temporary directory for this example.
    #
    # @param path [String] Path within the temporary directory to write to.
    # @param content [String] File data to write.
    # @param block [Proc] Optional block to return file data to write.
    # @example
    #   describe 'myapp' do
    #     command 'myapp read data.txt'
    #     file 'data.txt', <<-EOH
    #   a thing
    #   EOH
    #     its(:exitstatus) { is_expected.to eq 0 }
    #   end
    def file(path, content=nil, &block)
      raise "file path should be relative the the temporary directory." if path == File.expand_path(path)
      before do
        content = instance_eval(&block) if block
        dest_path = File.join(temp_path, path)
        FileUtils.mkdir_p(File.dirname(dest_path))
        IO.write(dest_path, content)
      end
    end

    # Copy fixture data from the spec folder to the temporary directory for this
    # example.
    #
    # @param path [String] Path of the fixture to copy.
    # @param dest [String] Optional destination path. By default the destination
    #   is the same as path.
    # @example
    #   describe 'myapp' do
    #     command 'myapp run test/'
    #     fixture_file 'test'
    #     its(:exitstatus) { is_expected.to eq 0 }
    #   end
    def fixture_file(path, dest=nil)
      raise "file path should be relative the the temporary directory." if path == File.expand_path(path)
      before do |example|
        fixture_path = find_fixture(example.file_path, path)
        dest_path = dest ? File.join(temp_path, dest) : temp_path
        FileUtils.mkdir_p(dest_path)
        file_list = MatchFixture::FileList.new(fixture_path)
        file_list.files.each do |file|
          abs = file_list.absolute(file)
          if File.directory?(abs)
            FileUtils.mkdir_p(File.join(dest_path, file))
          else
            FileUtils.copy(abs , File.join(dest_path, file), preserve: true)
          end
        end
      end
    end

    # Set an environment variable for this example.
    #
    # @param variables [Hash] Key/value pairs to set.
    # @example
    #   describe 'myapp' do
    #     command 'myapp show'
    #     environment DEBUG: true
    #     its(:stderr) { is_expected.to include('[debug]') }
    #   end
    def environment(variables)
      before do
        variables.each do |key, value|
          if value.nil?
            _environment.delete(key.to_s)
          else
            _environment[key.to_s] = value.to_s
          end
        end
      end
    end

    def included(klass)
      super
      klass.extend ClassMethods
    end
  end

  extend ClassMethods
end