spec/lib/gdk/shellout_spec.rb (300 lines of code) (raw):

# frozen_string_literal: true RSpec.describe GDK::Shellout do let(:command) { 'echo foo' } let(:opts) { {} } let(:tmp_directory) { File.realpath('/tmp') } subject { described_class.new(command, **opts) } describe '#args' do let(:command_as_array) { %w[echo foo] } context 'when command is a String' do it 'parses correctly' do expect(subject.args).to eq([command]) end end context 'when command is an Array' do let(:command) { command_as_array } it 'parses correctly' do expect(subject.args).to eq(command) end end context 'when command is a series of arguments' do subject { described_class.new('echo', 'foo') } it 'parses correctly' do expect(subject.args).to eq(command_as_array) end end end describe '#command' do let(:command_as_array) { %w[echo foo] } it 'returns command as a string' do expect(subject.command).to eq('echo foo') end end describe '#exit_code' do describe '#run has not yet been executed' do it 'returns nil' do expect(subject.exit_code).to be_nil end end describe '#run has been executed' do before do subject.run end context 'when command is successful' do it 'returns 0' do expect(subject.exit_code).to be(0) end end context 'when command is not successful' do let(:command) { 'echo error 1>&2; exit 1' } it 'returns 1' do expect(subject.exit_code).to be(1) end end end end describe '#execute' do it 'returns self', :hide_stdout do expect(subject.execute).to eq(subject) end context 'by default' do it 'streams the output' do expect { subject.execute }.to output("foo\n").to_stdout end it 'logs the result, including success status, stdout, stderr, and duration', :hide_stdout do allow(GDK::Output).to receive(:debug) allow(Benchmark).to receive(:realtime).and_yield.and_return(2.0) expect(GDK::Output).to receive(:debug).with('result: success?=[true], stdout=[foo], stderr=[], duration=[2.0 seconds]') subject.execute end end context 'with display_output: false' do it 'does not stream the output' do expect { subject.execute(display_output: false) }.not_to output("foo\n").to_stdout end end context 'with display_error: false' do let(:command) { 'ls /doesntexist' } before do # Stubbing ENV crashes in Ruby 3.0: https://bugs.ruby-lang.org/issues/18164 skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1.0') end it 'does not display the command failed message' do stub_no_color_env('true') expect { subject.execute(display_error: false) }.not_to output(%r{ERROR: 'ls /doesntexist' failed.}).to_stderr expect { subject.execute(display_error: true) }.to output(%r{ERROR: 'ls /doesntexist' failed.}).to_stderr end end context 'when the command fails completely' do shared_examples 'a command that fails' do it 'is unsuccessful', :hide_output do subject.execute expect(subject.success?).to be_falsey end it 'displays output and errors' do expect(GDK::Output).to receive(:print).with(expected_command_stderr_puts, stderr: true) expect(GDK::Output).to receive(:error).with(expected_command_error, GDK::Shellout::ShelloutBaseError) subject.execute end end shared_examples 'a command that does not retry' do it 'does not retry', :hide_output do expect(Kernel).not_to receive(:retry) subject.execute end end shared_examples 'a command that retries and fails' do it 'retries', :hide_output do expect(subject).to receive(:sleep).with(2).twice.and_return(true) expect(subject).to receive(expected_execute_method).exactly(3).times # 1 for the first run + 2 retries expect(subject).to receive(:success?).exactly(6).times.and_return(false) expect(GDK::Output).to receive(:error).with("'#{command}' failed. Retrying in 2 secs..", GDK::Shellout::ExecuteCommandFailedError).twice expect(GDK::Output).to receive(:error).with("'#{command}' failed.", GDK::Shellout::ExecuteCommandFailedError) subject.execute(display_output: display_output, retry_attempts: 2) end it 'is unsuccessful', :hide_output do allow(subject).to receive(:sleep).with(2).twice.and_return(true) subject.execute(display_output: display_output, retry_attempts: 2) expect(subject.success?).to be_falsey end end context 'when the command does not exist' do let(:command) { 'blah' } let(:expected_command_stderr_puts) { 'No such file or directory - blah' } let(:expected_command_error) { "'blah' failed." } it_behaves_like 'a command that fails' it_behaves_like 'a command that does not retry' end context 'when the command does exist, but fails' do let(:command) { 'ls /doesntexist' } let(:opts) { {} } let(:expected_command_stderr_puts) { "ls: cannot access '/doesntexist': No such file or directory\n" } let(:expected_command_error) { "'ls /doesntexist' failed." } before do stderr = StringIO.new(expected_command_stderr_puts) stdout = StringIO.new('') stdin = instance_double(StringIO, write: '', close: nil) allow(stdout).to receive(:wait_readable).and_return(true) allow(stderr).to receive(:wait_readable).and_return(true) allow(Open3).to receive(:popen3).with({}, command, opts).and_yield(stdin, stdout, stderr, Thread.new { nil }) end it_behaves_like 'a command that fails' it_behaves_like 'a command that does not retry' context 'with display_output: true' do let(:display_output) { true } let(:expected_execute_method) { :stream } context 'with a retry specified' do it_behaves_like 'a command that fails' it_behaves_like 'a command that retries and fails' end end context 'with display_output: false' do let(:opts) { { err: '/dev/null' } } let(:display_output) { false } let(:expected_execute_method) { :try_run } context 'with a retry specified' do it_behaves_like 'a command that fails' it_behaves_like 'a command that retries and fails' end end end end context 'when the command fails once but ultimately succeeds' do let(:command) { 'ls /fakedir' } it 'fails once but then succeeds', :hide_output do allow(subject).to receive(:sleep).with(2).and_return(true) expect(subject).to receive(:success?).twice.and_return(false) expect(subject).to receive(:success?).twice.and_return(true) expect(GDK::Output).to receive(:success).with("'#{command}' succeeded after retry.") subject.execute(retry_attempts: 1) end end end describe '#stream' do let(:task_logger) { nil } it 'returns output of shell command', :hide_stdout do expect(subject.stream).to eq('foo') end it 'send output to stdout' do expect { subject.stream }.to output("foo\n").to_stdout end context 'with non UTF-8 output' do let(:mixed_encoding) { "🐤🐤🐤🐤\xF0\x9F\x90".dup.force_encoding('ASCII-8BIT') } # rubocop:disable Performance/UnfreezeString -- This doesn't work with frozen_string_literal set let(:clean_string) { GDK::Output.ensure_utf8(mixed_encoding) } let(:command) { "echo '#{mixed_encoding}'" } it 'returns cleaned UTF-8 output of shell command', :hide_stdout do expect(subject.stream).to eq(clean_string) end it 'sends cleaned UTF-8 output to stdout' do expect { subject.stream }.to output("#{clean_string}\n").to_stdout end end context 'when chdir: is specified' do let(:command) { 'pwd' } let(:opts) { { chdir: tmp_directory } } it 'changes into the specified directory before executing' do expect { expect(subject.stream).to eq(tmp_directory) }.to output("#{tmp_directory}\n").to_stdout end end context 'with a task logger' do let(:logger) { nil } before do allow(Support::Rake::TaskLogger).to receive(:current).and_return(task_logger) end it 'sets the task logger to nil by default' do expect(Support::Rake::TaskLogger).to receive(:set_current!).with(task_logger).twice expect { subject.stream }.to output("foo\n").to_stdout end context 'when the logger is set' do let(:task_logger) { instance_double(Support::Rake::TaskLogger) } it 'prints to the task logger instead of stdout' do buffer = StringIO.new expect(Support::Rake::TaskLogger).to receive(:set_current!).with(task_logger).twice expect(task_logger).to receive(:file).and_return(buffer) expect(task_logger).to receive(:record_input).with("foo\n") expect { subject.stream }.not_to output.to_stdout expect(buffer.string).to eq("foo\n") end end end end describe '#readlines' do let(:command) { 'seq 10' } context 'when limit is not provided' do it 'reads all lines' do expect(subject.readlines.count).to eq(10) end end context 'when limit is provided' do it 'reads the number of lines given' do expect(subject.readlines(3).count).to eq(3) end end end describe '#run' do it 'returns output of shell command' do expect(subject.run).to eq('foo') end context 'when chdir: is specified' do let(:command) { 'pwd' } let(:opts) { { chdir: tmp_directory } } it 'changes into the specified directory before executing' do expect(subject.run).to eq(tmp_directory) end end end describe '#try_run' do let(:command) { 'foo bar' } it 'returns empty string' do expect(subject.try_run).to eq('') end it 'does not raise error' do expect { subject.try_run }.not_to raise_error end context 'when chdir: is specified' do let(:command) { 'pwd' } let(:opts) { { chdir: tmp_directory } } it 'changes into the specified directory before executing' do expect(subject.try_run).to eq(tmp_directory) end end end describe '#read_stdout' do before do subject.run end it 'returns stdout of shell command' do expect(subject.read_stdout).to eq('foo') end end describe '#read_stderr' do let(:command) { 'echo error 1>&2; exit 1' } before do subject.run end it 'returns stdout of shell command' do expect(subject.read_stderr).to eq('error') end end describe '#success?' do describe '#run has not yet been executed' do it 'returns false' do expect(subject.success?).to be false end end describe '#run has been executed' do before do subject.run end context 'when command is successful' do it 'returns true' do expect(subject.success?).to be true end end context 'when command is not successful' do let(:command) { 'echo error 1>&2; exit 1' } it 'returns false' do expect(subject.success?).to be false end end end end end