From e80b4ba05b660a1bfe3e44ba81d8550fb25c5a80 Mon Sep 17 00:00:00 2001 From: James Couball Date: Thu, 21 Nov 2024 16:58:50 -0800 Subject: [PATCH] feat: implement ProcessExecuter.run_command Run a command and return the status including stdout and stderr output See the YARD doc for ProcessExecuter::run_command for full details. --- lib/process_executer.rb | 162 +++++++- lib/process_executer/command.rb | 12 + lib/process_executer/command/errors.rb | 170 ++++++++ lib/process_executer/command/result.rb | 64 +++ lib/process_executer/command/runner.rb | 156 +++++++ lib/process_executer/status.rb | 23 +- spec/process_executer_run_command_spec.rb | 383 ++++++++++++++++++ ...spec.rb => process_executer_spawn_spec.rb} | 4 + spec/spec_helper.rb | 4 + 9 files changed, 970 insertions(+), 8 deletions(-) create mode 100644 lib/process_executer/command.rb create mode 100644 lib/process_executer/command/errors.rb create mode 100644 lib/process_executer/command/result.rb create mode 100644 lib/process_executer/command/runner.rb create mode 100644 spec/process_executer_run_command_spec.rb rename spec/{process_executer_spec.rb => process_executer_spawn_spec.rb} (98%) diff --git a/lib/process_executer.rb b/lib/process_executer.rb index 43e237c..7a9c821 100644 --- a/lib/process_executer.rb +++ b/lib/process_executer.rb @@ -2,8 +2,10 @@ require 'process_executer/monitored_pipe' require 'process_executer/options' +require 'process_executer/command' require 'process_executer/status' +require 'logger' require 'timeout' # Execute a command in a subprocess and optionally capture its output @@ -55,6 +57,162 @@ def self.spawn(*command, **options_hash) wait_for_process(pid, options) end + # Run a command and return the status including stdout and stderr output + # + # @example Run a command given as a single string (uses shell) + # # command must be shell escaped and can have shell expansions and redirections + # command = 'echo "stdout: `pwd`"" && echo "stderr: $HOME" 1>&2' + # result = ProcessExecuter.run_command(command) + # result.success? #=> true + # result.stdout.string #=> "stdout: /Users/james/projects/main-branch/process_executer\n" + # result.stderr.string #=> "stderr: /Users/james\n" + # + # @example Run a command given as an array of strings (does not use shell) + # # command args must be separate strings in the array + # # shell expansions and redirections are not supported + # command = ['git', 'clone', 'https://github.com/main-branch/process_executer'] + # result = ProcessExecuter.run_command(*command) + # result.success? #=> true + # result.stdout.string #=> "" + # result.stderr.string #=> "Cloning into 'process_executer'...\n" + # + # @example Run a command with a timeout + # command = ['sleep', '1'] + # result = ProcessExecuter.run_command(*command, timeout: 0.01) + # #=> raises ProcessExecuter::Command::TimeoutError which contains the command result + # + # @example Run a command which fails + # command = ['exit 1'] + # result = ProcessExecuter.run_command(*command) + # #=> raises ProcessExecuter::Command::FailedError which contains the command result + # + # @example Run a command which exits due to an unhandled signal + # command = ['kill -9 $$'] + # result = ProcessExecuter.run_command(*command) + # #=> raises ProcessExecuter::Command::SignaledError which contains the command result + # + # @example Do not raise errors + # command = ['echo "Some error" 1>&2 && exit 1'] + # result = ProcessExecuter.run_command(*command, raise_errors: false) + # # An error is not raised + # result.success? #=> false + # result.exitstatus #=> 1 + # result.stdout.string #=> "" + # result.stderr.string #=> "Some error\n" + # + # @example Set environment variables + # env = { 'FOO' => 'foo', 'BAR' => 'bar' } + # command = 'echo "$FOO$BAR"' + # result = ProcessExecuter.run_command(env, *command) + # result.stdout.string #=> "foobar\n" + # + # @example Set environment variables when using a command array + # env = { 'GIT_DIR' => '/path/to/.git' } + # command = ['git', 'status'] + # result = ProcessExecuter.run_command(env, *command) + # result.stdout.string #=> "On branch main\nYour branch is ..." + # + # @example Unset environment variables + # env = { 'GIT_DIR' => nil } # setting to nil unsets the variable in the environment + # command = ['git', 'status'] + # result = ProcessExecuter.run_command(env, *command) + # result.stdout.string #=> "On branch main\nYour branch is ..." + # + # @example Reset existing environment variables will adding new ones + # env = { 'PATH' => '/bin' } + # result = ProcessExecuter.run_command(env, 'echo "Home: $HOME" && echo "Path: $PATH"', unsetenv_others: true) + # result.stdout.string #=> "Home: \n/Path: bin\n" + # + # @example Run command in a different directory + # command = ['pwd'] + # result = ProcessExecuter.run_command(*command, chdir: '/tmp') + # result.stdout.string #=> "/tmp\n" + # + # @example Capture stdout and stderr into a single buffer + # command = ['echo "stdout" && echo "stderr" 1>&2'] + # result = ProcessExecuter.run_command(*command, merge: true) + # result.stdout.string #=> "stdout\nstderr\n" + # result.stdout.object_id == result.stderr.object_id #=> true + # + # @example Capture to an explicit buffer + # out = StringIO.new + # err = StringIO.new + # command = ['echo "stdout" && echo "stderr" 1>&2'] + # result = ProcessExecuter.run_command(*command, out: out, err: err) + # out.string #=> "stdout\n" + # err.string #=> "stderr\n" + # result.stdout.object_id == out.object_id #=> true + # result.stderr.object_id == err.object_id #=> true + # + # @example Capture to a file + # # Same technique can be used for stderr + # out = File.open('stdout.txt', 'w') + # command = ['echo "stdout" && echo "stderr" 1>&2'] + # result = ProcessExecuter.run_command(*command, out: out, err: err) + # out.close + # File.read('stdout.txt') #=> "stdout\n" + # # stderr is still captured to a StringIO buffer internally + # result.stderr.string #=> "stderr\n" + # + # @example Capture to multiple writers (e.g. files, buffers, STDOUT, etc.) + # # Same technique can be used for stderr + # out_buffer = StringIO.new + # out_file = File.open('stdout.txt', 'w') + # command = ['echo "stdout" && echo "stderr" 1>&2'] + # result = ProcessExecuter.run_command(*command, out: [out_buffer, out_file]) + # out_file.close + # out_buffer.string #=> "stdout\n" + # File.read('stdout.txt') #=> "stdout\n" + # + # @param command [Array] The command to run + # + # If the first element of command is a Hash, it is added to the ENV of + # the new process. See [Execution Environment](https://ruby-doc.org/3.3.6/Process.html#module-Process-label-Execution+Environment) + # for more details. The env hash is then removed from the command array. + # + # If the first and only (remaining) command element is a string, it is (possibly) passsed + # to a subshell to run. The command is passed to a subshell run if it begins with a shell + # reserved word or special built-in, or if it contains one or more meta characters. + # + # Care must be taken to properly escape shell metacharacters in the command string. + # + # Otherwise, the command is run bypassing the shell. When bypassing the shell, shell expansions + # and redirections are not supported. + # + # @param logger [Logger] The logger to use + # @param options_hash [Hash] Additional options + # @option options_hash [Numeric] :timeout The maximum seconds to wait for the command to complete + # + # If timeout is zero of nil, the command will not time out. If the command + # times out, it is killed via a SIGKILL signal and {ProcessExecuter::Command::TimeoutError} is raised. + # + # If the command does not respond to SIGKILL, it will hang this method. + # + # @option options_hash [#write] :out (nil) The object to write stdout to + # @option options_hash [#write] :err (nil) The object to write stdout to + # @option options_hash [Boolean] :merge (false) Write both stdout and stderr into the buffer for stdout + # @option options_hash [Boolean] :raise_errors (true) Raise an exception if the command fails + # @option options_hash [Boolean] :unsetenv_others (false) If true, unset all environment variables before + # applying the new ones + # @option options_hash [true, Integer, nil] :pgroup (nil) true or 0: new process group; non-zero: join + # the group, nil: existing group + # @option options_hash [Boolean] :new_pgroup (nil) Create a new process group (Windows only) + # @option options_hash [Integer] :rlimit_resource_name (nil) Set resource limits (see Process.setrlimit) + # @option options_hash [Integer] :umask (nil) Set the umask (see File.umask) + # @option options_hash [Boolean] :close_others (false) If true, close non-standard file descriptors + # @option options_hash [String] :chdir (nil) The directory to run the command in + # + # @raise [ProcessExecuter::Command::FailedError] if the command returned a non-zero exit status + # @raise [ProcessExecuter::Command::SignaledError] if the command exited because of an unhandled signal + # @raise [ProcessExecuter::Command::TimeoutError] if the command timed out + # @raise [ProcessExecuter::Command::ProcessIOError] if an exception was raised while collecting subprocess output + # + # @return [ProcessExecuter::Command::Result] The result of running the command + # + def self.run_command(*command, logger: Logger.new(nil), **options_hash) + ProcessExecuter::Command::Runner.new(logger).call(*command, **options_hash) + end + # Wait for process to terminate # # If a timeout is speecified in options, kill the process after options.timeout seconds. @@ -68,10 +226,10 @@ def self.spawn(*command, **options_hash) # private_class_method def self.wait_for_process(pid, options) Timeout.timeout(options.timeout) do - ProcessExecuter::Status.new(Process.wait2(pid).last, false) + ProcessExecuter::Status.new(Process.wait2(pid).last, false, options.timeout) end rescue Timeout::Error Process.kill('KILL', pid) - ProcessExecuter::Status.new(Process.wait2(pid).last, true) + ProcessExecuter::Status.new(Process.wait2(pid).last, true, options.timeout) end end diff --git a/lib/process_executer/command.rb b/lib/process_executer/command.rb new file mode 100644 index 0000000..e82c830 --- /dev/null +++ b/lib/process_executer/command.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ProcessExecuter + # This module contains classes for implementing ProcessExecuter.run_command + module Command; end +end + +require_relative 'command/errors' +require_relative 'command/result' +require_relative 'command/runner' + +# Runs a command and returns the result diff --git a/lib/process_executer/command/errors.rb b/lib/process_executer/command/errors.rb new file mode 100644 index 0000000..f4d3bd8 --- /dev/null +++ b/lib/process_executer/command/errors.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength: + +module ProcessExecuter + module Command + # Base class for all ProcessExecuter::Command errors + # + # It is recommended to rescue `ProcessExecuter::Command::Error` to catch any + # runtime error raised by this gem unless you need more specific error handling. + # + # Custom errors are arranged in the following class heirarchy: + # + # ```text + # ::StandardError + # └─> Error + # ├─> CommandError + # │ ├─> FailedError + # │ └─> SignaledError + # │ └─> TimeoutError + # └─> ProcessIOError + # ``` + # + # | Error Class | Description | + # | --- | --- | + # | `Error` | This catch-all error serves as the base class for other custom errors. | + # | `CommandError` | A subclass of this error is raised when there is a problem executing a command. | + # | `FailedError` | This error is raised when the command exits with a non-zero status code. | + # | `SignaledError` | This error is raised when the command is terminated as a result of receiving a signal. This could happen if the process is forcibly terminated or if there is a serious system error. | + # | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the command times out and is killed via the SIGKILL signal. This happens if the operation takes longer than the timeout duration (if given). | + # | `ProcessIOError` | An error was encountered reading or writing to the command's subprocess. | + # + # @example Rescuing any error + # begin + # ProcessExecuter.run_command('git', 'status') + # rescue ProcessExecuter::Command::Error => e + # puts "An error occurred: #{e.message}" + # end + # + # @example Rescuing a timeout error + # begin + # timeout_duration = 0.1 # seconds + # ProcessExecuter.run_command('sleep', '1', timeout: timeout_duration) + # rescue ProcessExecuter::TimeoutError => e # Catch the more specific error first! + # puts "Command took too long and timed out: #{e}" + # rescue ProcessExecuter::Error => e + # puts "Some other error occured: #{e}" + # end + # + # @api public + # + class Error < ::StandardError; end + + # Raised when a command fails or exits because of an uncaught signal + # + # The command executed, status, stdout, and stderr are available from this + # object. + # + # The Gem will raise a more specific error for each type of failure: + # + # * {FailedError}: when the command exits with a non-zero status + # * {SignaledError}: when the command exits because of an uncaught signal + # * {TimeoutError}: when the command times out + # + # @api public + # + class CommandError < ProcessExecuter::Command::Error + # Create a CommandError object + # + # @example + # `exit 1` # set $? appropriately for this example + # result = ProcessExecuter::Command::Result.new(%w[git status], $?, 'stdout', 'stderr') + # error = ProcessExecuter::Command::CommandError.new(result) + # error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + # + # @param result [Result] the result of the command including the command, + # status, stdout, and stderr + # + def initialize(result) + @result = result + super(error_message) + end + + # The human readable representation of this error + # + # @example + # error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' + # + # @return [String] + # + def error_message + "#{result.command}, status: #{result}, stderr: #{result.stderr_to_s.inspect}" + end + + # @attribute [r] result + # + # The result of the command including the command, its status and its output + # + # @example + # error.result #=> # + # + # @return [Result] + # + attr_reader :result + end + + # This error is raised when the command returns a non-zero exitstatus + # + # @api public + # + class FailedError < ProcessExecuter::Command::CommandError; end + + # This error is raised when the command exits because of an uncaught signal + # + # @api public + # + class SignaledError < ProcessExecuter::Command::CommandError; end + + # This error is raised when the command takes longer than the configured timeout + # + # @example + # result.status.timeout? #=> true + # + # @api public + # + class TimeoutError < ProcessExecuter::Command::SignaledError + # Create a TimeoutError object + # + # @example + # command = %w[sleep 10] + # timeout_duration = 1 + # status = ProcessExecuter.spawn(*command, timeout: timeout_duration) + # result = Result.new(command, status, 'stdout', 'err output') + # error = TimeoutError.new(result, timeout_duration) + # error.error_message + # #=> '["sleep", "10"], status: pid 70144 SIGKILL (signal 9), stderr: "err output", timed out after 1s' + # + # @param result [Result] the result of the command including the git command, + # status, stdout, and stderr + # + # @param timeout_duration [Numeric] the amount of time the subprocess was allowed + # to run before being killed + # + def initialize(result, timeout_duration) + @timeout_duration = timeout_duration + super(result) + end + + # The amount of time the subprocess was allowed to run before being killed + # + # @example + # `kill -9 $$` # set $? appropriately for this example + # result = Result.new(%w[git status], $?, '', "killed") + # error = TimeoutError.new(result, 10) + # error.timeout_duration #=> 10 + # + # @return [Numeric] + # + attr_reader :timeout_duration + end + + # Raised when the output of a git command can not be read + # + # @api public + # + class ProcessIOError < ProcessExecuter::Command::Error; end + end +end + +# rubocop:enable Layout/LineLength: diff --git a/lib/process_executer/command/result.rb b/lib/process_executer/command/result.rb new file mode 100644 index 0000000..f25eac5 --- /dev/null +++ b/lib/process_executer/command/result.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'delegate' + +module ProcessExecuter + module Command + # A wrapper around a ProcessExecuter::Status that also includes command output + # @api public + class Result < SimpleDelegator + # Create a new Result object + # @example + # status = ProcessExecuter.spawn(*command, timeout:, out:, err:) + # Result.new(command, status, out_buffer.string, err_buffer.string) + # @param command [Array] The command that was run + # @param status [ProcessExecuter::Status] The status of the process + # @param stdout [String] The stdout output from the process + # @param stderr [String] The stderr output from the process + def initialize(command, status, stdout, stderr) + super(status) + @command = command + @stdout = stdout + @stderr = stderr + end + + # The command that was run + # @example + # result.command #=> %w[git status] + # @return [Array] + attr_reader :command + + # The stdout output of the process + # @example + # result.stdout #=> "On branch master\nnothing to commit, working tree clean\n" + # @return [String] + attr_reader :stdout + + # The stderr output of the process + # @example + # result.stderr #=> "ERROR: file not found" + # @return [String] + attr_reader :stderr + + # Return the stdout output as a string + # @example When stdout is a StringIO containing "Hello World" + # result.stdout_to_s #=> "Hello World" + # @example When stdout is a File object + # result.stdout_to_s #=> # + # @return [Object] + def stdout_to_s + stdout.respond_to?(:string) ? stdout.string : stdout + end + + # Return the stderr output as a string + # @example When stderr is a StringIO containing "Hello World" + # result.stderr_to_s #=> "Hello World" + # @example When stderr is a File object + # result.stderr_to_s #=> # + # @return [Object] + def stderr_to_s + stderr.respond_to?(:string) ? stderr.string : stderr + end + end + end +end diff --git a/lib/process_executer/command/runner.rb b/lib/process_executer/command/runner.rb new file mode 100644 index 0000000..d23c2a6 --- /dev/null +++ b/lib/process_executer/command/runner.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require_relative 'errors' +require_relative 'result' + +module ProcessExecuter + module Command + # Implements the ProcessExecuter.run_command method + # + # See {ProcessExecuter.run_command} for usage. + # + # @api public + # + class Runner + # Create a new RunCommand instance + # + # @example + # runner = Runner.new() + # status = runner.call('echo', 'hello') + # + # @param logger [Logger] The logger to use + # + def initialize(logger) + @logger = logger || Logger.new(nil) + end + + # The logger to use + # @example + # runner.logger #=> # + # @return [Logger] + attr_reader :logger + + # Run a command and return the status including stdout and stderr output + # + # @example + # command = %w[git status] + # status = run(command) + # status.success? # => true + # status.exitstatus # => 0 + # status.out # => "On branch master\nnothing to commit, working tree clean\n" + # status.err # => "" + # + # @param command [Array] The command to run + # @param out [#write] The object to write stdout to + # @param err [#write] The object to write stdout to + # @param merge [Boolean] Write both stdout and stderr into the buffer for stdout + # @param raise_errors [Boolean] Raise an exception if the command fails + # @param options_hash [Hash] Additional options to pass to Process.spawn + # @option chdir [String] The directory to run the command in + # @option timeout [Numeric] The maximum seconds to wait for the command to complete + # + # + # @return [ProcessExecuter::Command::Result] The result of running + # + def call(*command, out: nil, err: nil, merge: false, raise_errors: true, **options_hash) # rubocop:disable Metrics/ParameterLists + out ||= StringIO.new + err ||= (merge ? out : StringIO.new) + + status = spawn(command, out:, err:, **options_hash) + + process_result(command, status, out, err, options_hash[:timeout], raise_errors) + end + + private + + # Wrap the output buffers in pipes and then execute the command + # + # @param command [Array] the command to execute + # @param out [#write] the object to write stdout to + # @param err [#write] the object to write stderr to + # @param options_hash [Hash] additional options to pass to Process.spawn + # + # @raise [ProcessExecuter::Command::ProcessIOError] if an exception was raised while collecting subprocess output + # @raise [ProcessExecuter::Command::TimeoutError] if the command times out + # + # @return [ProcessExecuter::Status] the status of the completed subprocess + # + # @api private + # + def spawn(command, out:, err:, **options_hash) + out_pipe = ProcessExecuter::MonitoredPipe.new(out) + err_pipe = ProcessExecuter::MonitoredPipe.new(err) + ProcessExecuter.spawn(*command, out: out_pipe, err: err_pipe, **options_hash) + ensure + out_pipe.close + err_pipe.close + raise_pipe_error(command, :stdout, out_pipe) if out_pipe.exception + raise_pipe_error(command, :stderr, err_pipe) if err_pipe.exception + end + + # rubocop:disable Metrics/ParameterLists + + # Process the result of the command and return a ProcessExecuter::Command::Result + # + # Post process output, log the command and result, and raise an error if the + # command failed. + # + # @param command [Array] the git command that was executed + # @param status [Process::Status] the status of the completed subprocess + # @param out [#write] the object that stdout was written to + # @param err [#write] the object that stderr was written to + # @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete + # @param raise_errors [Boolean] raise an exception if the command fails + # + # @return [ProcessExecuter::Command::Result] the result of the command to return to the caller + # + # @raise [ProcessExecuter::Command::FailedError] if the command failed + # @raise [ProcessExecuter::Command::SignaledError] if the command was signaled + # @raise [ProcessExecuter::Command::TimeoutError] if the command times out + # @raise [ProcessExecuter::Command::ProcessIOError] if an exception was raised while collecting subprocess output + # + # @api private + # + def process_result(command, status, out, err, timeout, raise_errors) + Result.new(command, status, out, err).tap do |result| + log_result(result) + + if raise_errors + raise TimeoutError.new(result, timeout) if status.timeout? + raise SignaledError, result if status.signaled? + raise FailedError, result unless status.success? + end + end + end + + # rubocop:enable Metrics/ParameterLists + + # Log the command and result of the subprocess + # @param result [ProcessExecuter::Command::Result] the result of the command including + # the command, status, stdout, and stderr + # @return [void] + # @api private + def log_result(result) + logger.info { "#{result.command} exited with status #{result}" } + logger.debug { "stdout:\n#{result.stdout_to_s.inspect}\nstderr:\n#{result.stderr_to_s.inspect}" } + end + + # Raise an error when there was exception while collecting the subprocess output + # + # @param command [Array] the command that was executed + # @param pipe_name [Symbol] the name of the pipe that raised the exception + # @param pipe [ProcessExecuter::MonitoredPipe] the pipe that raised the exception + # + # @raise [Git::ProcessIOError] + # + # @return [void] this method always raises an error + # + # @api private + # + def raise_pipe_error(command, pipe_name, pipe) + error = ProcessExecuter::Command::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}") + raise(error, cause: pipe.exception) + end + end + end +end diff --git a/lib/process_executer/status.rb b/lib/process_executer/status.rb index 5930ba4..5091788 100644 --- a/lib/process_executer/status.rb +++ b/lib/process_executer/status.rb @@ -15,6 +15,7 @@ class Status < SimpleDelegator # # @param status [Process::Status] the status to delegate to # @param timeout [Boolean] true if the process timed out + # @param timeout_duration [Numeric, nil] The secs the command ran before being killed OR o or nil for no timeout # # @example # status = Process.wait2(pid).last @@ -23,23 +24,33 @@ class Status < SimpleDelegator # # @api public # - def initialize(status, timeout) + def initialize(status, timeout, timeout_duration) super(status) @timeout = timeout + @timeout_duration = timeout_duration end + # The secs the command ran before being killed OR o or nil for no timeout + # @example + # status.timeout_duration #=> 10 + # @return [Numeric, nil] + attr_reader :timeout_duration + # @!attribute [r] timeout? - # # True if the process timed out and was sent the SIGKILL signal - # # @example # status = ProcessExecuter.spawn('sleep 10', timeout: 0.01) # status.timeout? # => true - # # @return [Boolean] # - # @api public - # def timeout? = @timeout + + # Return a string representation of the status + # @example + # status.to_s #=> "pid 70144 SIGKILL (signal 9) timed out after 10s" + # @return [String] + def to_s + "#{super}#{timeout? ? " timed out after #{timeout_duration}s" : ''}" + end end end diff --git a/spec/process_executer_run_command_spec.rb b/spec/process_executer_run_command_spec.rb new file mode 100644 index 0000000..6e59577 --- /dev/null +++ b/spec/process_executer_run_command_spec.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require 'English' +require 'logger' +require 'tmpdir' + +RSpec.describe ProcessExecuter do + describe '.run_command' do + context 'with command given as a single string' do + let(:command) { 'echo $VAR' } + subject { ProcessExecuter.run_command({ 'VAR' => 'test' }, command) } + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(success?: true, exitstatus: 0, signaled?: false, timeout?: false) } + it 'is expected to process the command with the shell' do + expect(subject.stdout.string).to eq("test\n") + end + end + + context 'with command given as an array of args' do + let(:command) { %w[echo $VAR] } + subject { ProcessExecuter.run_command({ 'VAR' => 'test' }, *command) } + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(success?: true, exitstatus: 0, signaled?: false, timeout?: false) } + it 'is expected NOT to process the command with the shell' do + expect(subject.stdout.string).to eq("$VAR\n") + end + end + + let(:result) { ProcessExecuter.run_command(*command, logger: logger, **options) } + let(:logger) { nil } + let(:options) { {} } + + subject { result } + + context 'with a command that returns exitstatus 0' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2' } + + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(success?: true, exitstatus: 0, signaled?: false, timeout?: false) } + + it 'is expected to capture the command output' do + expect(subject.stdout.string).to eq("stdout output\n") + expect(subject.stderr.string).to eq("stderr output\n") + end + end + + context 'with a command that returns exitstatus 1' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2 && exit 1' } + + it 'is expected to raise an command error' do + expect { result }.to raise_error(ProcessExecuter::Command::Error) + end + + context 'the error raised' do + subject { result rescue $ERROR_INFO } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::FailedError) } + + it 'is expected to have the expected error message' do + pid = subject.result.pid + # SimpleCov gives a false positive on the following line under JRuby + # :nocov: + expect(subject.message).to eq( + %(["echo \\"stdout output\\" && echo \\"stderr output\\" 1>&2 && exit 1"], ) \ + "status: pid #{pid} " \ + 'exit 1, stderr: "stderr output\n"' + ) + # :nocov: + end + + context 'the result object contained in the error' do + subject { result rescue $ERROR_INFO.result } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(success?: false, exitstatus: 1) } + it 'is expected have the output from the command' do + expect(subject.stdout.string).to eq("stdout output\n") + expect(subject.stderr.string).to eq("stderr output\n") + end + end + end + end + + context 'with a command that times out' do + let(:command) { 'sleep 1' } + let(:options) { { timeout: 0.01 } } + + it 'is expected to raise an error' do + expect { subject }.to raise_error(ProcessExecuter::Command::Error) + end + + context 'the error raised' do + subject { result rescue $ERROR_INFO } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::TimeoutError) } + + it 'is expected to have the expected error message' do + pid = subject.result.pid + + # :nocov: + expected_message = + if RUBY_ENGINE == 'jruby' + %(["sleep 1"], status: pid #{pid} KILL (signal 9) timed out after 0.01s, stderr: "") + else + %(["sleep 1"], status: pid #{pid} SIGKILL (signal 9) timed out after 0.01s, stderr: "") + end + # :nocov: + + expect(subject.message).to eq expected_message + end + + context 'the result object contained in the error' do + subject { result rescue $ERROR_INFO.result } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(signaled?: true, termsig: 9, timeout?: true) } + end + end + end + + context 'with a command that exits due to an unhandled signal' do + let(:command) { 'echo "Hello world" && kill -9 $$' } + + it 'is expected to raise an error' do + expect { subject }.to raise_error(ProcessExecuter::Command::Error) + end + + context 'the error raised' do + subject { result rescue $ERROR_INFO } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::SignaledError) } + + it 'is expected to have the expected error message' do + pid = subject.result.pid + + # :nocov: + expected_message = + if RUBY_ENGINE == 'jruby' + %(["echo \\"Hello world\\" && kill -9 $$"], status: pid #{pid} KILL (signal 9), stderr: "") + else + %(["echo \\"Hello world\\" && kill -9 $$"], status: pid #{pid} SIGKILL (signal 9), stderr: "") + end + # :nocov: + + expect(subject.message).to eq(expected_message) + end + + context 'the result object contained in the error' do + subject { result rescue $ERROR_INFO.result } # rubocop:disable Style/RescueModifier + + it { is_expected.to be_a(ProcessExecuter::Command::Result) } + it { is_expected.to have_attributes(signaled?: true, termsig: 9) } + end + end + end + + context 'with raise_errors set to false' do + context 'a command that returns exitstatus 1' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2 && exit 1' } + let(:options) { { raise_errors: false } } + + it 'is not expected to raise an error' do + expect { subject }.not_to raise_error + end + + it 'is expected to return an a Result' do + expect(subject).to be_a(ProcessExecuter::Command::Result) + expect(subject).to have_attributes(success?: false, exitstatus: 1) + expect(subject.stdout.string).to eq("stdout output\n") + expect(subject.stderr.string).to eq("stderr output\n") + end + end + + context 'a command that times out' do + let(:command) { 'sleep 1' } + let(:options) { { raise_errors: false, timeout: 0.01 } } + + it 'is not expected to raise an error' do + expect { subject }.not_to raise_error + end + + it 'is expected to return a result' do + expect(subject).to be_a(ProcessExecuter::Command::Result) + expect(subject).to have_attributes(signaled?: true, termsig: 9, timeout?: true) + end + end + + context 'a command that exits due to an unhandled signal' do + let(:command) { 'echo "Hello world" && kill -9 $$' } + let(:options) { { raise_errors: false } } + + it 'is not expected to raise an error' do + expect { subject }.not_to raise_error + end + + it 'is expected to return a result' do + expect(subject).to be_a(ProcessExecuter::Command::Result) + expect(subject).to have_attributes(signaled?: true, termsig: 9) + end + end + end + + context 'with environment variables' do + let(:existing_env_variable) { ENV.find { |_k, v| v.size > 3 && v.size < 10 } } + let(:env) { { 'ENV1' => 'val1', 'ENV2' => 'val2' } } + let(:command) { [env, %(echo "$ENV1 $ENV2 $#{existing_env_variable[0]}")] } + + context 'when adding environment variables' do + it 'is expected to add those variables in the environment' do + expect(subject.stdout.string).to eq("val1 val2 #{existing_env_variable[1]}\n") + end + end + + context 'when removing environment variables' do + let(:env) { { 'ENV1' => 'val1', 'ENV2' => 'val2', existing_env_variable[0] => nil } } + it 'is expected to remove those variables from the environment' do + expect(subject.stdout.string).to eq("val1 val2 \n") + end + end + + context 'when resetting the environment' do + let(:options) { { unsetenv_others: true } } + it 'is expected to remove all existing variables from the environment and add the given variables' do + expect(subject.stdout.string).to eq("val1 val2 \n") + end + end + end + + context 'running the command in a different directory' do + before { @tmpdir = File.realpath(Dir.mktmpdir) } + after { FileUtils.remove_entry(@tmpdir) } + let(:command) { ['ruby', '-e', 'puts Dir.pwd'] } + let(:options) { { chdir: @tmpdir } } + it 'is expected to run the command in the specified directory' do + expect(subject.stdout.string).to eq("#{@tmpdir}\n") + end + end + + context 'merge stdout and stderr' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2' } + let(:options) { { merge: true } } + it 'is expected to merge stdout and stderr' do + expect(subject.stdout.string).to eq("stdout output\nstderr output\n") + expect(subject.stdout.object_id).to eq(subject.stderr.object_id) + end + end + + context 'buffers are given for stdout and stderr' do + let(:out) { StringIO.new } + let(:err) { StringIO.new } + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2' } + let(:options) { { out: out, err: err } } + it 'is expected to capture stdout and stderr to the given buffers' do + subject + expect(out.string).to eq("stdout output\n") + expect(err.string).to eq("stderr output\n") + end + end + + context 'when a file is given to capture stdout and stderr' do + let(:out) { File.open('stdout.txt', 'w') } + let(:err) { File.open('stderr.txt', 'w') } + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2' } + let(:options) { { out: out, err: err } } + + it 'is expected to capture stdout to the file and stderr to the buffer' do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + subject + out.close + err.close + expect(File.read('stdout.txt')).to eq("stdout output\n") + expect(File.read('stderr.txt')).to eq("stderr output\n") + end + end + end + end + + context 'when a pipe exception occurs' do + before do + allow_any_instance_of(ProcessExecuter::MonitoredPipe).to( + receive(:exception).and_return(StandardError.new('pipe error')) + ) + end + + subject { ProcessExecuter.run_command('echo Hello') } + + it 'is expected to raise ProcessExecuter::Command::ProcessIOError' do + expect { subject }.to raise_error(ProcessExecuter::Command::ProcessIOError) + end + end + + context 'when given a logger' do + let(:logger) { Logger.new(log_buffer, level: log_level) } + let(:log_buffer) { StringIO.new } + + context 'a command that returns exitstatus 0' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2' } + + context 'when log level is WARN' do + let(:log_level) { Logger::WARN } + it 'is expected not to log anything' do + subject + expect(log_buffer.string).to be_empty + end + end + + context 'when log level is INFO' do + let(:log_level) { Logger::INFO } + it 'is expected to log the command and its status' do + subject + expect(log_buffer.string).to match(/INFO -- : \[.*?\] exited with status pid \d+ exit 0$/) + expect(log_buffer.string).not_to match(/DEBUG -- : /) + end + end + + context 'when log level is DEBUG' do + let(:log_level) { Logger::DEBUG } + it 'is expected to log the command and its status AND the command stdout and stderr' do + subject + expect(log_buffer.string).to match(/INFO -- : \[.*?\] exited with status pid \d+ exit 0$/) + expect(log_buffer.string).to match(/DEBUG -- : stdout:\n"stdout output\\n"\nstderr:\n"stderr output\\n"$/) + end + end + end + + context 'a command that returns exitstatus 1' do + let(:command) { 'echo "stdout output" && echo "stderr output" 1>&2 && exit 1' } + let(:options) { { raise_errors: false } } + + context 'when log level is WARN' do + let(:log_level) { Logger::WARN } + it 'is expected not to log anything' do + subject + expect(log_buffer.string).to be_empty + end + end + + context 'when log level is INFO' do + let(:log_level) { Logger::INFO } + it 'is expected to log the command and its status' do + subject + expect(log_buffer.string).to match(/INFO -- : \[.*?\] exited with status pid \d+ exit 1$/) + expect(log_buffer.string).not_to match(/DEBUG -- : /) + end + end + end + + context 'a command that times out' do + let(:command) { 'sleep 1' } + let(:options) { { raise_errors: false, timeout: 0.01 } } + + context 'when log level is WARN' do + let(:log_level) { Logger::WARN } + it 'is expected not to log anything' do + subject + expect(log_buffer.string).to be_empty + end + end + + context 'when log level is INFO' do + let(:log_level) { Logger::INFO } + it 'is expected to log the command and its status' do + subject + + # :nocov: + expected_message = + if RUBY_ENGINE == 'jruby' + /INFO -- : \[.*?\] exited with status pid \d+ KILL \(signal 9\) timed out after 0.01s$/ + else + /INFO -- : \[.*?\] exited with status pid \d+ SIGKILL \(signal 9\) timed out after 0.01s$/ + end + # :nocov: + + expect(log_buffer.string).to match(expected_message) + + expect(log_buffer.string).not_to match(/DEBUG -- : /) + end + end + end + end + end +end diff --git a/spec/process_executer_spec.rb b/spec/process_executer_spawn_spec.rb similarity index 98% rename from spec/process_executer_spec.rb rename to spec/process_executer_spawn_spec.rb index 7be9a37..c4f3672 100644 --- a/spec/process_executer_spec.rb +++ b/spec/process_executer_spawn_spec.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +require 'English' +require 'logger' +require 'tmpdir' + RSpec.describe ProcessExecuter do describe '.spawn' do subject { ProcessExecuter.spawn(*command, **options) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 98dc4ae..eceb82d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,6 +12,10 @@ end end +def windows? + RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/ +end + # SimpleCov configuration # require 'simplecov'