├── spec ├── fixtures │ ├── data.txt │ ├── sub │ │ ├── sub1.txt │ │ └── sub2.txt │ └── sub_nested │ │ ├── sub_nested.txt │ │ └── sub_inner │ │ ├── sub_inner1.txt │ │ └── sub_inner2.txt ├── spec_helper.rb ├── file_list_spec.rb ├── rake_spec.rb ├── match_fixture_spec.rb └── command_spec.rb ├── .gitignore ├── .yardopts ├── .travis.yml ├── lib ├── rspec-command.rb ├── rspec_command │ ├── version.rb │ ├── rake.rb │ └── match_fixture.rb └── rspec_command.rb ├── Gemfile ├── Rakefile ├── rspec-command.gemspec ├── README.md └── LICENSE /spec/fixtures/data.txt: -------------------------------------------------------------------------------- 1 | Fixture data. 2 | -------------------------------------------------------------------------------- /spec/fixtures/sub/sub1.txt: -------------------------------------------------------------------------------- 1 | Subfixture 1. 2 | -------------------------------------------------------------------------------- /spec/fixtures/sub/sub2.txt: -------------------------------------------------------------------------------- 1 | Subfixture 2. 2 | -------------------------------------------------------------------------------- /spec/fixtures/sub_nested/sub_nested.txt: -------------------------------------------------------------------------------- 1 | Subfixture nested. 2 | -------------------------------------------------------------------------------- /spec/fixtures/sub_nested/sub_inner/sub_inner1.txt: -------------------------------------------------------------------------------- 1 | Subfixture inner 1. 2 | -------------------------------------------------------------------------------- /spec/fixtures/sub_nested/sub_inner/sub_inner2.txt: -------------------------------------------------------------------------------- 1 | Subfixture inner 2. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .yardoc/ 3 | doc/ 4 | coverage/ 5 | pkg/ 6 | build/ 7 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --plugin classmethods 2 | --markup markdown 3 | --hide-void-return 4 | --hide-api private 5 | --exclude lib/[^/]+/version.rb 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | cache: bundler 3 | language: ruby 4 | rvm: 5 | - '2.0' 6 | - '2.1' 7 | - '2.2' 8 | script: bundle exec rake test 9 | env: 10 | global: 11 | - secure: EZt9sRP8CzvkF0uFUinzElrUfuTUT8hOk6iu67JV7JkgzfCAZFj3xP2ZK209ObleRWfNlZ/WBGscFfw4Ipfpagnok+I2HnuDqf/Y/asdnTfWwMN0p4Fa4w1LpHztaIXdOwFAhA8osKHwYVAUbEYeQLmxDfqwvAGT4yfBkPNAVZE= 12 | - secure: Xvquws40AzLRRsqhUbMpYCRldbk/zEnpcUXjf8ZKlbaz/kDW/lVhTK/GEPcARuO/H2k/xrgH0tJPlpr1znNLw0W28lDbxHs8RTDJVFALl57ZLn10WsfAzXurWDb/fj0YqN5u1IVxpXuvf07QUBMEq+cVkFau8ncIQjzbU1colJQ= 13 | -------------------------------------------------------------------------------- /lib/rspec-command.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'rspec_command' 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | source 'https://rubygems.org/' 18 | 19 | gemspec 20 | -------------------------------------------------------------------------------- /lib/rspec_command/version.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | module RSpecCommand 19 | # RSpec-command gem version. 20 | VERSION = '1.0.4.pre' 21 | end 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'bundler/gem_tasks' 18 | 19 | require 'rspec/core/rake_task' 20 | RSpec::Core::RakeTask.new(:spec, :tag) do |t, args| 21 | t.rspec_opts = [].tap do |a| 22 | a << '--color' 23 | a << '--format Fuubar' 24 | a << '--backtrace' if ENV['DEBUG'] 25 | a << "--tag #{args[:tag]}" if args[:tag] 26 | end.join(' ') 27 | end 28 | 29 | desc 'Run all tests' 30 | task :test => [:spec] 31 | 32 | task :default => [:test] 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'rspec' 18 | require 'simplecov' 19 | 20 | # Check for coverage stuffs 21 | formatters = [] 22 | if ENV['CODECLIMATE_REPO_TOKEN'] 23 | require 'codeclimate-test-reporter' 24 | formatters << CodeClimate::TestReporter::Formatter 25 | end 26 | 27 | if ENV['CODECOV_TOKEN'] 28 | require 'codecov' 29 | formatters << SimpleCov::Formatter::Codecov 30 | end 31 | 32 | unless formatters.empty? 33 | SimpleCov.formatters = formatters 34 | end 35 | 36 | SimpleCov.start do 37 | # Don't get coverage on the test cases themselves. 38 | add_filter '/spec/' 39 | add_filter '/test/' 40 | # Codecov doesn't automatically ignore vendored files. 41 | add_filter '/vendor/' 42 | end 43 | 44 | require 'rspec_command' 45 | 46 | RSpec.configure do |config| 47 | # Basic configuraiton 48 | config.run_all_when_everything_filtered = true 49 | config.filter_run(:focus) 50 | 51 | # Run specs in random order to surface order dependencies. If you find an 52 | # order dependency and want to debug it, you can fix the order by providing 53 | # the seed, which is printed after each run. 54 | # --seed 1234 55 | config.order = 'random' 56 | 57 | config.include RSpecCommand 58 | end 59 | -------------------------------------------------------------------------------- /rspec-command.gemspec: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | lib = File.expand_path('../lib', __FILE__) 18 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 19 | require 'rspec_command/version' 20 | 21 | Gem::Specification.new do |spec| 22 | spec.name = 'rspec-command' 23 | spec.version = RSpecCommand::VERSION 24 | spec.authors = ['Noah Kantrowitz'] 25 | spec.email = %w{noah@coderanger.net} 26 | spec.description = 'An RSpec helper module for testing command-line tools.' 27 | spec.summary = spec.description 28 | spec.homepage = 'https://github.com/coderanger/rspec-command' 29 | spec.license = 'Apache 2.0' 30 | 31 | spec.files = `git ls-files`.split($/) 32 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 33 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 34 | spec.require_paths = %w{lib} 35 | 36 | spec.add_dependency 'rspec', '~> 3.2' 37 | spec.add_dependency 'rspec-its', '~> 1.2' 38 | spec.add_dependency 'mixlib-shellout', '~> 2.0' 39 | 40 | spec.add_development_dependency 'rake', '~> 10.4' 41 | spec.add_development_dependency 'fuubar', '~> 2.0' 42 | spec.add_development_dependency 'simplecov', '~> 0.9' 43 | spec.add_development_dependency 'yard', '~> 0.8' 44 | spec.add_development_dependency 'yard-classmethods', '~> 1.0' 45 | spec.add_development_dependency 'codeclimate-test-reporter', '~> 0.4' 46 | spec.add_development_dependency 'codecov', '~> 0.0', '>= 0.0.2' 47 | spec.add_development_dependency 'pry' 48 | end 49 | -------------------------------------------------------------------------------- /spec/file_list_spec.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'fileutils' 18 | 19 | require 'spec_helper' 20 | 21 | describe RSpecCommand::MatchFixture::FileList do 22 | let(:path) { nil } 23 | subject { described_class.new(temp_path, path) } 24 | def write(path) 25 | path = File.join(temp_path, path) 26 | FileUtils.mkdir_p(File.dirname(path)) 27 | IO.write(path, '') 28 | end 29 | 30 | context 'with a single file' do 31 | let(:path) { 'data.txt' } 32 | before { write('data.txt') } 33 | its(:full_path) { is_expected.to eq File.join(temp_path, 'data.txt') } 34 | its(:files) { is_expected.to eq ['data.txt'] } 35 | its(:full_files) { is_expected.to eq [File.join(temp_path, 'data.txt')] } 36 | end # /context with a single file 37 | 38 | context 'with a non-existent file' do 39 | let(:path) { 'data.txt' } 40 | its(:full_path) { is_expected.to eq File.join(temp_path, 'data.txt') } 41 | its(:files) { is_expected.to eq [] } 42 | its(:full_files) { is_expected.to eq [] } 43 | end # /context with a non-existent file 44 | 45 | context 'with a folder' do 46 | let(:path) { 'sub' } 47 | before { write('sub/one.txt'); write('sub/two.txt') } 48 | its(:full_path) { is_expected.to eq File.join(temp_path, 'sub') } 49 | its(:files) { is_expected.to eq ['one.txt', 'two.txt'] } 50 | its(:full_files) do 51 | is_expected.to eq [ 52 | File.join(temp_path, 'sub/one.txt'), 53 | File.join(temp_path, 'sub/two.txt'), 54 | ] 55 | end 56 | end # /context with a folder 57 | 58 | describe '#relative' do 59 | let(:file) { } 60 | let(:root) { File.join(temp_path, file) } 61 | let(:path) { nil } 62 | before { write(file) } 63 | subject { described_class.new(root, path).relative(File.join(temp_path, file)) } 64 | 65 | context 'with a single file' do 66 | let(:file) { 'data.txt' } 67 | it { is_expected.to eq 'data.txt' } 68 | end # /context with a single file 69 | 70 | context 'with a nested file' do 71 | let(:file) { 'data/inner.txt' } 72 | it { is_expected.to eq 'inner.txt' } 73 | end # /context with a nested file 74 | 75 | context 'with a folder root' do 76 | let(:file) { 'data/inner.txt' } 77 | let(:root) { temp_path } 78 | it { is_expected.to eq 'data/inner.txt' } 79 | end # /context with a folder root 80 | end # /describe #relative 81 | 82 | describe '#absolute' do 83 | let(:file) { } 84 | let(:root) { File.join(temp_path, file) } 85 | let(:path) { nil } 86 | before { write(file) } 87 | subject { described_class.new(root, path).absolute(file) } 88 | 89 | context 'with a single file' do 90 | let(:file) { 'data.txt' } 91 | it { is_expected.to eq File.join(temp_path, 'data.txt') } 92 | end # /context with a single file 93 | 94 | context 'with a nested file' do 95 | let(:file) { 'inner.txt' } 96 | let(:root) { File.join(temp_path, 'data', 'inner.txt') } 97 | it { is_expected.to eq File.join(temp_path, 'data', 'inner.txt') } 98 | end # /context with a nested file 99 | 100 | context 'with a folder root' do 101 | let(:file) { 'data/inner.txt' } 102 | let(:root) { temp_path } 103 | it { is_expected.to eq File.join(temp_path, 'data', 'inner.txt') } 104 | end # /context with a folder root 105 | end # /describe #absolute 106 | end 107 | -------------------------------------------------------------------------------- /spec/rake_spec.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'spec_helper' 18 | 19 | describe RSpecCommand::Rake do 20 | include RSpecCommand::Rake 21 | 22 | describe '#rakefile' do 23 | rakefile "task 'mytask'\n" 24 | it { expect(File.exists?(File.join(temp_path, 'Rakefile'))).to eq true } 25 | end # /describe #rakefile 26 | 27 | describe '#rake_task' do 28 | context 'with a simple task' do 29 | rakefile <<-EOH 30 | task 'mytask' do 31 | puts 'complete' 32 | end 33 | EOH 34 | rake_task 'mytask' 35 | its(:stdout) { is_expected.to eq "complete\n" } 36 | its(:stderr) { is_expected.to eq '' } 37 | its(:exitstatus) { is_expected.to eq 0 } 38 | end # /context with a simple task 39 | 40 | context 'with an environment variable' do 41 | rakefile <<-EOH 42 | task 'mytask' do 43 | puts ENV['MYVAR'] 44 | end 45 | EOH 46 | environment MYVAR: 'envvar' 47 | rake_task 'mytask' 48 | its(:stdout) { is_expected.to eq "envvar\n" } 49 | its(:stderr) { is_expected.to eq '' } 50 | its(:exitstatus) { is_expected.to eq 0 } 51 | it { expect(ENV['MYVAR']).to be_nil } 52 | end # /context with an environment variable 53 | 54 | context 'with no rakefile' do 55 | rake_task 'mytask' 56 | its(:stderr) { is_expected.to include 'No Rakefile found' } 57 | its(:exitstatus) { is_expected.to eq 1 } 58 | end # /context with no rakefile 59 | 60 | context 'with a non-existent task' do 61 | rakefile '' 62 | rake_task 'mytask' 63 | its(:stderr) { is_expected.to include "Don't know how to build task 'mytask'" } 64 | its(:exitstatus) { is_expected.to eq 1 } 65 | end # /context with a non-existent task 66 | 67 | context 'with a task with arguments' do 68 | rakefile <<-'EOH' 69 | task 'mytask', %w{arg1 arg2} do |t, args| 70 | args.with_defaults(arg2: 'default') 71 | puts "#{args[:arg1]} #{args[:arg2]}" 72 | end 73 | EOH 74 | rake_task 'mytask', 'one' 75 | its(:stdout) { is_expected.to eq "one default\n" } 76 | its(:stderr) { is_expected.to eq '' } 77 | its(:exitstatus) { is_expected.to eq 0 } 78 | end # /context with a task with arguments 79 | 80 | context 'with a task that fails' do 81 | rakefile <<-EOH 82 | task 'failure' do 83 | puts 'before' 84 | raise "OMG" 85 | puts 'after' 86 | end 87 | EOH 88 | rake_task 'failure' 89 | its(:stdout) { is_expected.to eq "before\n" } 90 | its(:stderr) { is_expected.to include "Rakefile:3:in `block in ': OMG (RuntimeError)" } 91 | its(:exitstatus) { is_expected.to eq 1 } 92 | end # /context with a task that fails 93 | 94 | context 'with a task that fails with a specific exitstatus' do 95 | rakefile <<-EOH 96 | task 'specific_failure' do 97 | puts 'specific before' 98 | Kernel.exit(42) 99 | puts 'specific after' 100 | end 101 | EOH 102 | rake_task 'specific_failure' 103 | its(:stdout) { is_expected.to eq "specific before\n" } 104 | its(:stderr) { is_expected.to eq '' } 105 | its(:exitstatus) { is_expected.to eq 42 } 106 | end # /context with a task that fails with a specific exitstatus 107 | 108 | context 'regression test for require-based Rakefiles and multiple tests' do 109 | file 'mytask.rb', 'task :mytask do puts "complete" end' 110 | rakefile '$:.unshift(File.dirname(__FILE__)); require "mytask"' 111 | rake_task 'mytask' 112 | # Run twice to force the bug. 113 | its(:stdout) { is_expected.to include "complete\n" } 114 | its(:stdout) { is_expected.to include "complete\n" } 115 | end 116 | end # /describe #rake_task 117 | end 118 | -------------------------------------------------------------------------------- /lib/rspec_command/rake.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'rake' 18 | require 'rspec' 19 | 20 | require 'rspec_command' 21 | 22 | 23 | module RSpecCommand 24 | # An RSpec helper module for testing Rake tasks without running them in a 25 | # full subprocess. This improves test speed while still giving you most of 26 | # the benefits of integration testing. 27 | # 28 | # @api public 29 | # @since 1.0.0 30 | # @example 31 | # RSpec.configure do |config| 32 | # config.include RSpecCommand::Rake 33 | # end 34 | # @example Enable for a single example group 35 | # describe 'mytask' do 36 | # rakefile <<-EOH 37 | # ... 38 | # EOH 39 | # rake_task 'mytask' 40 | # its(:stdout) { it_expected.to include('1.0.0') } 41 | # end 42 | module Rake 43 | # @!classmethods 44 | module ClassMethods 45 | # Run a Rake task as the subject of this example group. The subject will 46 | # be a string returned by {#capture_output}. 47 | # 48 | # @param name [String] Name of the task to execute. 49 | # @param args [Array] Arguments to pass to the task. 50 | # @return [void] 51 | # @example 52 | # describe 'mytask' do 53 | # rakefile 'require "myapp/rake_tasks"' 54 | # rake_task 'mytask' 55 | # its(:stdout) { is_expected.to include 'Complete!' } 56 | # end 57 | def rake_task(name, *args) 58 | metadata[:rake] = true 59 | subject do 60 | exitstatus = [] 61 | capture_output do 62 | Process.waitpid fork { 63 | # This has to be nocov because simplecov doesn't track across fork. 64 | # :nocov: 65 | # Defang SimpleCov so it doesn't print its stuff. Can be removed 66 | # when https://github.com/colszowka/simplecov/pull/377 is in a 67 | # released version. 68 | if defined?(SimpleCov) 69 | SimpleCov.at_exit { SimpleCov.instance_variable_set(:@result, nil) } 70 | end 71 | # Because #init reads from ARGV and will try to parse rspec's flags. 72 | ARGV.replace([]) 73 | Dir.chdir(temp_path) 74 | ENV.update(_environment) 75 | rake = ::Rake::Application.new.tap do |rake| 76 | ::Rake.application = rake 77 | rake.init 78 | rake.load_rakefile 79 | end 80 | rake[name].invoke(*args) 81 | } 82 | exitstatus << $?.exitstatus 83 | # :nocov: 84 | end.tap do |output| 85 | output.define_singleton_method(:exitstatus) { exitstatus.first } 86 | end 87 | end 88 | end 89 | 90 | # Write out a Rakefile to the temporary directory for this example group. 91 | # Content can be passed as either a string or a block. 92 | # 93 | # @param content [String] Rakefile content. 94 | # @param block [Proc] Optional block to return the Rakefile content. 95 | # @return [void] 96 | # @example 97 | # describe 'mytask' do 98 | # rakefile <<-EOH 99 | # task 'mytask' do 100 | # ... 101 | # end 102 | # EOH 103 | # rake_task 'mytask' 104 | # its(:stdout) { is_expected.to include 'Complete!' } 105 | # end 106 | def rakefile(content=nil, &block) 107 | file('Rakefile', content, &block) 108 | end 109 | 110 | def included(klass) 111 | super 112 | # Pull this in as a dependency. 113 | klass.send(:include, RSpecCommand) 114 | klass.extend ClassMethods 115 | end 116 | end 117 | 118 | extend ClassMethods 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/rspec_command/match_fixture.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | 18 | module RSpecCommand 19 | # @api private 20 | # @since 1.0.0 21 | class MatchFixture 22 | # Create a new matcher for a fixture. 23 | # 24 | # @param fixture_root [String] Absolute path to the fixture folder. 25 | # @param local_root [String] Absolute path to test folder to compare against. 26 | # @param fixture_path [String] Relative path to the fixture to compare against. 27 | # @param local_path [String] Optional relative path to the test data to compare against. 28 | def initialize(fixture_root, local_root, fixture_path, local_path=nil) 29 | @fixture = FileList.new(fixture_root, fixture_path) 30 | @local = FileList.new(local_root, local_path) 31 | end 32 | 33 | # Primary callback for RSpec matcher API. 34 | # 35 | # @param cmd Ignored. 36 | # @return [Boolean] 37 | def matches?(cmd) 38 | files_match? && file_content_match? 39 | end 40 | 41 | # Callback for RSpec. Returns a human-readable description for the matcher. 42 | # 43 | # @return [String] 44 | def description 45 | "match fixture #{@fixture.path}" 46 | end 47 | 48 | # Callback fro RSpec. Returns a human-readable failure message. 49 | # 50 | # @return [String] 51 | def failure_message 52 | matching_files = @fixture.files & @local.files 53 | fixture_only_files = @fixture.files - @local.files 54 | local_only_files = @local.files - @fixture.files 55 | buf = "expected fixture #{@fixture.path} to match files:\n" 56 | (@fixture.files | @local.files).sort.each do |file| 57 | if matching_files.include?(file) 58 | local_file = @local.absolute(file) 59 | fixture_file = @fixture.absolute(file) 60 | if File.directory?(local_file) && File.directory?(fixture_file) 61 | # Do nothing 62 | elsif File.directory?(fixture_file) 63 | buf << " #{file} should be a directory\n" 64 | elsif File.directory?(local_file) 65 | buf << " #{file} should not be a directory" 66 | else 67 | actual = IO.read(local_file) 68 | expected = IO.read(fixture_file) 69 | if actual != expected 70 | # Show a diff 71 | buf << " #{file} does not match fixture:" 72 | buf << differ.diff(actual, expected).split(/\n/).map {|line| ' '+line }.join("\n") 73 | end 74 | end 75 | elsif fixture_only_files.include?(file) 76 | buf << " #{file} is not found\n" 77 | elsif local_only_files.include?(file) 78 | buf << " #{file} should not exist\n" 79 | end 80 | end 81 | buf 82 | end 83 | 84 | private 85 | 86 | # Do the file entries match? Doesn't check content. 87 | # 88 | # @return [Boolean] 89 | def files_match? 90 | @fixture.files == @local.files 91 | end 92 | 93 | # Do the file contents match? 94 | # 95 | # @return [Boolean] 96 | def file_content_match? 97 | @fixture.full_files.zip(@local.full_files).all? do |fixture_file, local_file| 98 | if File.directory?(fixture_file) 99 | File.directory?(local_file) 100 | else 101 | !File.directory?(local_file) && IO.read(fixture_file) == IO.read(local_file) 102 | end 103 | end 104 | end 105 | 106 | # Return a Differ object to make diffs. 107 | # 108 | # @note This is using a nominally private API. It could break in the future. 109 | # @return [RSpec::Support::Differ] 110 | # @example 111 | # differ.diff(actual, expected) 112 | def differ 113 | RSpec::Expectations.differ 114 | end 115 | 116 | class FileList 117 | attr_reader :root, :path 118 | 119 | # @param root [String] Absolute path to the root of the files. 120 | # @param path [String] Relative path to the specific files. 121 | def initialize(root, path=nil) 122 | @root = root 123 | @path = path 124 | end 125 | 126 | # Absolute path to the target. 127 | def full_path 128 | @full_path ||= path ? File.join(root, path) : root 129 | end 130 | 131 | # Absolute paths to target files that exist. 132 | def full_files 133 | @full_files ||= if File.directory?(full_path) 134 | Dir.glob(File.join(full_path, '**', '*'), File::FNM_DOTMATCH).sort.reject {|p| relative(p) == '.' } 135 | else 136 | [full_path].select {|path| File.exist?(path) } 137 | end 138 | end 139 | 140 | # Relative paths to the target files that exist. 141 | def files 142 | @files ||= full_files.map {|file| relative(file) } 143 | end 144 | 145 | # Convert an absolute path to a relative one 146 | def relative(file) 147 | if File.directory?(full_path) 148 | file[full_path.length+1..-1] 149 | else 150 | File.basename(file) 151 | end 152 | end 153 | 154 | # Convert a relative path to an absolute one. 155 | def absolute(file) 156 | if File.directory?(full_path) 157 | File.join(full_path, file) 158 | else 159 | full_path 160 | end 161 | end 162 | end 163 | 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSpec-Command 2 | 3 | [![Build Status](https://img.shields.io/travis/coderanger/rspec-command.svg)](https://travis-ci.org/coderanger/rspec-command) 4 | [![Gem Version](https://img.shields.io/gem/v/rspec-command.svg)](https://rubygems.org/gems/rspec-command) 5 | [![Coverage](https://img.shields.io/codecov/c/github/coderanger/rspec-command.svg)](https://codecov.io/github/coderanger/rspec-command) 6 | [![Gemnasium](https://img.shields.io/gemnasium/coderanger/rspec-command.svg)](https://gemnasium.com/coderanger/rspec-command) 7 | [![License](https://img.shields.io/badge/license-Apache_2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) 8 | 9 | `rspec-command` is a helper module for using RSpec to test command-line 10 | applications. 11 | 12 | ## Quick Start 13 | 14 | Add `gem 'rspec-command'` to your `Gemfile` and then configure it in your 15 | `spec_helper.rb`: 16 | 17 | ```ruby 18 | require 'rspec_command' 19 | 20 | RSpec.configure do |config| 21 | config.include RSpecCommand 22 | end 23 | ``` 24 | 25 | You can then use the helpers in your specs: 26 | 27 | ```ruby 28 | require 'spec_helper' 29 | 30 | describe 'myapp' do 31 | command 'myapp --version' 32 | its(:stdout) { is_expected.to include('1.0.0') } 33 | end 34 | ``` 35 | 36 | ## command 37 | 38 | The core helper is `command`. It takes a command to run and sets it as the 39 | subject for the example group. The command can be given as a string, array, or 40 | block. If the command is given as an array, no shell processing is done before 41 | running it. If the gem you are running inside has a Gemfile, all commands will 42 | be run inside a `bundle exec`. Each command is run in a new temporary directory 43 | so the results of one test won't affect others. 44 | 45 | `command` also optionally takes a hash of options to pass through to 46 | `Mixlib::ShellOut.new`. Some common options include `:input` to provide data on 47 | stdin and `:timeout` to change the execution timeout. The option `allow_error` 48 | is not passed through to the underlying `ShellOut`, but should be set to `true` 49 | to avoid raising an exception if the command fails. 50 | 51 | The subject will be set to a `Mixlib::ShellOut` object so you can use 52 | `rspec-its` to check individual attributes: 53 | 54 | ```ruby 55 | describe 'myapp' do 56 | command 'myapp --version' 57 | its(:stdout) { is_expected.to include '1.0.0' } 58 | its(:stderr) { is_expected.to eq '' } 59 | its(:exitstatus) { is_expected.to eq 0 } 60 | end 61 | ``` 62 | 63 | ## file 64 | 65 | The `file` method writes a file in to the temporary directory. You can provide 66 | the file content as either a string or a block: 67 | 68 | ```ruby 69 | describe 'myapp' do 70 | command 'myapp read data1.txt data2.txt' 71 | file 'data1.txt', <<-EOH 72 | a thing 73 | EOH 74 | file 'data2.txt' do 75 | "another thing #{Time.now}" 76 | end 77 | its(:stdout) { is_expected.to include '2 files imported' } 78 | end 79 | ``` 80 | 81 | ## fixture_file 82 | 83 | The `fixture_file` method copies a file or folder from a fixture to the 84 | temporary directory: 85 | 86 | ```ruby 87 | describe 'myapp' do 88 | command 'myapp read entries/' 89 | fixture_file 'entries' 90 | its(:stdout) { is_expected.to include '4 files imported' } 91 | end 92 | ``` 93 | 94 | These fixtures are generally kept in `spec/fixtures` but it can be customized 95 | by redefining `let(:fixture_root)`. 96 | 97 | ## environment 98 | 99 | The `environment` method sets environment variables for subprocesses run by 100 | `command`: 101 | 102 | ```ruby 103 | describe 'myapp' do 104 | command 'myapp show' 105 | environment MYAPP_DEBUG: true 106 | its(:stderr) { is_expected.to include '[debug]' } 107 | end 108 | ``` 109 | 110 | ## match_fixture 111 | 112 | The `match_fixture` matcher lets you check the files created by a command 113 | against a fixture: 114 | 115 | ```ruby 116 | describe 'myapp' do 117 | command 'myapp write' 118 | it { is_expected.to match_fixture 'write_data' } 119 | end 120 | ``` 121 | 122 | ## capture_output 123 | 124 | The `capture_output` helper lets you redirect the stdout and stderr of a block 125 | of code to strings. This includes any subprocesses or non-Ruby output. This can 126 | help with integration testing of CLI code without the overhead of running a full 127 | subprocess. 128 | 129 | The returned object behaves like a string containing the stdout output, but has 130 | `stdout`, `stderr`, and `exitstatus` attributes to simulate the object used by 131 | `command`. `exitstatus` will always be `0`. 132 | 133 | ```ruby 134 | describe 'myapp' do 135 | subject do 136 | capture_output do 137 | MyApp::CLI.run('show') 138 | end 139 | end 140 | its(:stdout) { is_expected.to include 'Entry:' } 141 | end 142 | ``` 143 | 144 | ## RSpecCommand::Rake 145 | 146 | The `RSpecCommand::Rake` helper is an optional module you can include in your 147 | example groups to test Rake tasks without the overhead of running Rake in a full 148 | subprocess. 149 | 150 | ```ruby 151 | require 'rspec_command' 152 | 153 | RSpec.configure do |config| 154 | config.include RSpecCommand::Rake 155 | end 156 | 157 | describe 'rake myapp' do 158 | rake_task 'myapp' 159 | rakefile <<-EOH 160 | require 'myapp' 161 | task 'myapp' do 162 | MyApp.rake_task 163 | end 164 | EOH 165 | its(:stdout) { is_expected.to include 'Initialized new project' } 166 | end 167 | ``` 168 | 169 | ## License 170 | 171 | Copyright 2015, Noah Kantrowitz 172 | 173 | Licensed under the Apache License, Version 2.0 (the "License"); 174 | you may not use this file except in compliance with the License. 175 | You may obtain a copy of the License at 176 | 177 | http://www.apache.org/licenses/LICENSE-2.0 178 | 179 | Unless required by applicable law or agreed to in writing, software 180 | distributed under the License is distributed on an "AS IS" BASIS, 181 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 182 | See the License for the specific language governing permissions and 183 | limitations under the License. 184 | -------------------------------------------------------------------------------- /spec/match_fixture_spec.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'spec_helper' 18 | 19 | describe RSpecCommand::MatchFixture do 20 | def write(path, content) 21 | path = File.join(temp_path, path) 22 | FileUtils.mkdir_p(File.dirname(path)) 23 | IO.write(path, content) 24 | end 25 | 26 | describe 'in an example' do 27 | subject { nil } 28 | 29 | context 'with a single file' do 30 | before { write('data.txt', "Fixture data.\n") } 31 | it { is_expected.to match_fixture('data.txt') } 32 | end # /context with a single file 33 | 34 | context 'with a non-existent file' do 35 | it { is_expected.to_not match_fixture('data.txt') } 36 | end # /context with a non-existent file 37 | 38 | context 'with a single file that does not match' do 39 | before { write('data.txt', "Other data.\n") } 40 | it { is_expected.to_not match_fixture('data.txt') } 41 | end # /context with a single file that does not match 42 | 43 | context 'with a single file in a folder' do 44 | before { write('sub1.txt', "Subfixture 1.\n") } 45 | it { is_expected.to match_fixture('sub/sub1.txt') } 46 | end # /context with a single file in a folder 47 | 48 | context 'with a folder' do 49 | before do 50 | write('sub1.txt', "Subfixture 1.\n") 51 | write('sub2.txt', "Subfixture 2.\n") 52 | end 53 | it { is_expected.to match_fixture('sub') } 54 | end # /context with a folder 55 | 56 | context 'with a folder with an extra file' do 57 | before do 58 | write('sub1.txt', "Subfixture 1.\n") 59 | write('sub2.txt', "Subfixture 2.\n") 60 | write('sub3.txt', "Subfixture 3.\n") 61 | end 62 | it { is_expected.to_not match_fixture('sub') } 63 | end # /context with a folder with an extra file 64 | 65 | context 'with a folder with a missing file' do 66 | before do 67 | write('sub1.txt', "Subfixture 1.\n") 68 | end 69 | it { is_expected.to_not match_fixture('sub') } 70 | end # /context with a folder with a missing file 71 | 72 | context 'with a folder that does not match' do 73 | before do 74 | write('sub1.txt', "Subfixture 1.\n") 75 | write('sub2.txt', "Subfixture 3.\n") 76 | end 77 | it { is_expected.to_not match_fixture('sub') } 78 | end # /context with a folder with a missing file 79 | 80 | context 'with a nested folder' do 81 | before do 82 | write('sub_nested.txt', "Subfixture nested.\n") 83 | write('sub_inner/sub_inner1.txt', "Subfixture inner 1.\n") 84 | write('sub_inner/sub_inner2.txt', "Subfixture inner 2.\n") 85 | end 86 | it { is_expected.to match_fixture('sub_nested') } 87 | end # /context with a nested folder 88 | 89 | context 'with a nested folder with an extra file' do 90 | before do 91 | write('sub_nested.txt', "Subfixture nested.\n") 92 | write('sub_inner/sub_inner1.txt', "Subfixture inner 1.\n") 93 | write('sub_inner/sub_inner2.txt', "Subfixture inner 2.\n") 94 | write('sub_inner/sub_inner3.txt', "Subfixture inner 2.\n") 95 | end 96 | it { is_expected.to_not match_fixture('sub_nested') } 97 | end # /context with a nested folder with an extra file 98 | 99 | context 'with a nested folder with a missing file' do 100 | before do 101 | write('sub_nested.txt', "Subfixture nested.\n") 102 | write('sub_inner/sub_inner1.txt', "Subfixture inner 1.\n") 103 | end 104 | it { is_expected.to_not match_fixture('sub_nested') } 105 | end # /context with a nested folder with a missing file 106 | 107 | context 'with a nested folder that does not match' do 108 | before do 109 | write('sub_nested.txt', "Subfixture nested.\n") 110 | write('sub_inner/sub_inner1.txt', "Subfixture inner 1.\n") 111 | write('sub_inner/sub_inner2.txt', "Subfixture inner 3.\n") 112 | end 113 | it { is_expected.to_not match_fixture('sub_nested') } 114 | end # /context with a nested folder that does not match 115 | end # /describe in an example 116 | 117 | describe '#failure_message' do 118 | let(:path) { nil } 119 | subject { described_class.new(File.expand_path('../fixtures', __FILE__), temp_path, path).failure_message } 120 | 121 | context 'with a non-existent file' do 122 | let(:path) { 'data.txt' } 123 | it { is_expected.to include('data.txt is not found') } 124 | end # /context with a non-existent file 125 | 126 | context 'with a single file that does not match' do 127 | let(:path) { 'data.txt' } 128 | before { write('data.txt', "Other data.\n") } 129 | it { is_expected.to include('data.txt does not match fixture:') } 130 | it { is_expected.to include('-Fixture data.') } 131 | it { is_expected.to include('+Other data.') } 132 | end # /context with a single file that does not match 133 | 134 | context 'with a folder with an extra file' do 135 | let(:path) { 'sub' } 136 | before do 137 | write('sub1.txt', "Subfixture 1.\n") 138 | write('sub2.txt', "Subfixture 2.\n") 139 | write('sub3.txt', "Subfixture 3.\n") 140 | end 141 | it { is_expected.to include('sub3.txt should not exist') } 142 | end # /context with a folder with an extra file 143 | 144 | context 'with a folder with a missing file' do 145 | let(:path) { 'sub' } 146 | before do 147 | write('sub1.txt', "Subfixture 1.\n") 148 | end 149 | it { is_expected.to include('sub2.txt is not found') } 150 | end # /context with a folder with a missing file 151 | 152 | context 'with a folder that does not match' do 153 | let(:path) { 'sub' } 154 | before do 155 | write('sub1.txt', "Subfixture 1.\n") 156 | write('sub2.txt', "Subfixture 3.\n") 157 | end 158 | it { is_expected.to include('sub2.txt does not match fixture:') } 159 | it { is_expected.to include('-Subfixture 2.') } 160 | it { is_expected.to include('+Subfixture 3.') } 161 | end # /context with a folder that does not match 162 | 163 | context 'with a file that is a folder' do 164 | let(:path) { 'sub_nested' } 165 | before do 166 | FileUtils.mkdir_p(File.join(temp_path, 'sub_nested.txt')) 167 | end 168 | it { is_expected.to include('sub_nested.txt should not be a directory') } 169 | end # /context with a file that is a folder 170 | 171 | context 'with a folder that is a file' do 172 | let(:path) { 'sub_nested' } 173 | before do 174 | write('sub_inner', '') 175 | end 176 | it { is_expected.to include('sub_inner should be a directory') } 177 | end # /context with a folder that is a file 178 | end # /describe #failure_message 179 | 180 | describe '#differ' do 181 | subject { described_class.new(nil, nil, nil, nil).send(:differ) } 182 | # Basically just check that it isn't throwing errors 183 | it { is_expected.to_not be_nil} 184 | it { is_expected.to respond_to(:diff) } 185 | end # /describe #differ 186 | end 187 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'spec_helper' 18 | 19 | describe RSpecCommand do 20 | def read_temp(path) 21 | IO.read(File.join(temp_path, path)) 22 | end 23 | 24 | describe '#command' do 25 | context 'true' do 26 | command 'true' 27 | its(:exitstatus) { is_expected.to eq 0 } 28 | end # /context true 29 | 30 | context 'false' do 31 | command 'false' 32 | it { expect { subject }.to raise_error(Mixlib::ShellOut::ShellCommandFailed) } 33 | end # /context false 34 | 35 | context 'with a block' do 36 | command { 'true' } 37 | its(:exitstatus) { is_expected.to eq 0 } 38 | end # /context with a block 39 | 40 | context 'with allow_error' do 41 | command 'false', allow_error: true 42 | its(:exitstatus) { is_expected.to eq 1 } 43 | end # /context with allow_error 44 | 45 | context 'with input' do 46 | command 'cat', input: "I'm a little teapot" 47 | its(:stdout) { is_expected.to eq "I'm a little teapot" } 48 | end # /context with input 49 | 50 | context 'check gemfile' do 51 | command 'env' 52 | its(:stdout) { is_expected.to include("BUNDLE_GEMFILE=#{File.expand_path('../../Gemfile', __FILE__)}") } 53 | end # /context check gemfile 54 | 55 | context 'echo * with shell' do 56 | command 'echo *' 57 | before { IO.write(File.join(temp_path, 'file'), '') } 58 | its(:stdout) { is_expected.to eq "file\n" } 59 | end # /context echo * with shell 60 | 61 | context 'echo * without shell' do 62 | command %w{echo *} 63 | before { IO.write(File.join(temp_path, 'file'), '') } 64 | its(:stdout) { is_expected.to eq "*\n" } 65 | end # /context echo * without shell 66 | 67 | context 'without a Gemfile' do 68 | command 'env' 69 | before { allow(self).to receive(:find_file).and_return(nil) } 70 | around do |example| 71 | begin 72 | old_gemfile = ENV.delete('BUNDLE_GEMFILE') 73 | example.run 74 | ensure 75 | ENV['BUNDLE_GEMFILE'] = old_gemfile if old_gemfile 76 | end 77 | end 78 | its(:stdout) { is_expected.to_not include('BUNDLE_GEMFILE') } 79 | end # /context without a Gemfile 80 | end # /describe #command 81 | 82 | describe '#file' do 83 | context 'with a simple file' do 84 | file 'data', 'Short and stout' 85 | subject { read_temp('data') } 86 | it { is_expected.to eq 'Short and stout' } 87 | end # /context with a simple file 88 | 89 | context 'with a block' do 90 | file 'data' do 91 | 'Here is my handle' 92 | end 93 | subject { read_temp('data') } 94 | it { is_expected.to eq 'Here is my handle' } 95 | end # /context with a block 96 | 97 | context 'with a subfolder' do 98 | file 'sub/data', 'Here is my spout' 99 | subject { read_temp('sub/data') } 100 | it { is_expected.to eq 'Here is my spout' } 101 | end # /context with a subfolder 102 | 103 | context 'with an absolute path' do 104 | it { expect { self.class.file('/data', '') }.to raise_error RuntimeError } 105 | end # /context with an absolute path 106 | end # /describe #file 107 | 108 | describe '#fixture_file' do 109 | context 'with a single file fixture' do 110 | fixture_file 'data.txt' 111 | subject { read_temp('data.txt') } 112 | it { is_expected.to eq "Fixture data.\n" } 113 | end # /context with a single file fixture 114 | 115 | context 'with a directory fixture' do 116 | fixture_file 'sub' 117 | it { expect(read_temp('sub1.txt')).to eq "Subfixture 1.\n" } 118 | it { expect(read_temp('sub2.txt')).to eq "Subfixture 2.\n" } 119 | end # /context with a directory fixture 120 | 121 | context 'with a different dest' do 122 | fixture_file 'sub', 'other' 123 | it { expect(read_temp('other/sub1.txt')).to eq "Subfixture 1.\n" } 124 | it { expect(read_temp('other/sub2.txt')).to eq "Subfixture 2.\n" } 125 | end # /context with a different dest 126 | 127 | context 'with an absolute path' do 128 | it { expect { self.class.fixture_file('/data', '') }.to raise_error RuntimeError } 129 | end # /context with an absolute path 130 | 131 | context 'with a nested directory fixture' do 132 | fixture_file 'sub_nested' 133 | it { expect(read_temp('sub_nested.txt')).to eq "Subfixture nested.\n" } 134 | it { expect(read_temp('sub_inner/sub_inner1.txt')).to eq "Subfixture inner 1.\n" } 135 | it { expect(read_temp('sub_inner/sub_inner2.txt')).to eq "Subfixture inner 2.\n" } 136 | end # /context with a nested directory fixture 137 | end # /describe #fixture_file 138 | 139 | describe '#environment' do 140 | context 'with a single variable' do 141 | environment MY_KEY: 'true' 142 | command 'env' 143 | its(:stdout) { is_expected.to include("MY_KEY=true") } 144 | end # /context with a single variable 145 | 146 | context 'with a two variables' do 147 | environment MY_KEY: 'true' 148 | environment OTHER_KEY: '1' 149 | command 'env' 150 | its(:stdout) { is_expected.to include("MY_KEY=true") } 151 | its(:stdout) { is_expected.to include("OTHER_KEY=1") } 152 | end # /context with a two variables 153 | end # /describe #environment 154 | 155 | describe '#temp_path' do 156 | subject { temp_path } 157 | it { is_expected.to be_a(String) } 158 | end # /describe #temp_path 159 | 160 | describe '#fixture_root' do 161 | let(:fixture_root) { 'fixtures/sub' } 162 | fixture_file 'sub1.txt' 163 | subject { read_temp('sub1.txt') } 164 | it { is_expected.to eq "Subfixture 1.\n" } 165 | end # /describe #fixture_root 166 | 167 | describe '#find_file' do 168 | context 'with Gemfile' do 169 | subject { find_file(__FILE__, 'Gemfile') } 170 | it { is_expected.to eq File.expand_path('../../Gemfile', __FILE__) } 171 | end # /context with Gemfile 172 | 173 | context 'with a block' do 174 | subject { find_file(__FILE__) {|p| p } } 175 | it { is_expected.to eq File.dirname(__FILE__) } 176 | end # /context with a block 177 | 178 | context 'with a non-existant file' do 179 | subject { find_file(__FILE__, 'NOPE.GIF') } 180 | it { is_expected.to be_nil } 181 | end # /context with a non-existant file 182 | 183 | # This is important to check because otherwise the backstop test might be a 184 | # false negative. 185 | context 'with the gem root' do 186 | subject { find_file(__FILE__, 'rspec-command') } 187 | it { is_expected.to eq File.expand_path('../..', __FILE__) } 188 | end # /context with the gem root 189 | 190 | context 'with a backstop' do 191 | subject { find_file(__FILE__, 'rspec-command', File.expand_path('..', __FILE__)) } 192 | it { is_expected.to be_nil } 193 | end # /context with a backstop 194 | end # /describe #find_file 195 | 196 | describe '#find_gem_base' do 197 | subject { find_gem_base(__FILE__) } 198 | it { is_expected.to eq File.expand_path('../..', __FILE__) } 199 | end # /describe #find_gem_base 200 | 201 | describe '#find_fixture' do 202 | context 'with a fixture file' do 203 | subject { find_fixture(__FILE__, 'data.txt') } 204 | it { is_expected.to eq File.expand_path('../fixtures/data.txt', __FILE__) } 205 | end # /context with a fixture file 206 | 207 | context 'with no fixture path' do 208 | subject { find_fixture(__FILE__) } 209 | it { is_expected.to eq File.expand_path('../fixtures', __FILE__) } 210 | end # /context with no fixture path 211 | end # /describe #find_fixture 212 | 213 | describe '#capture_output' do 214 | context 'with puts' do 215 | subject do 216 | capture_output { puts 'test' } 217 | end 218 | it { is_expected.to eq "test\n" } 219 | its(:stdout) { is_expected.to eq "test\n" } 220 | its(:stderr) { is_expected.to eq '' } 221 | its(:exitstatus) { is_expected.to eq 0 } 222 | end # /context with puts 223 | 224 | context 'with STDERR.puts' do 225 | subject do 226 | capture_output { STDERR.puts 'test' } 227 | end 228 | it { is_expected.to eq '' } 229 | its(:stdout) { is_expected.to eq '' } 230 | its(:stderr) { is_expected.to eq "test\n" } 231 | its(:exitstatus) { is_expected.to eq 0 } 232 | end # /context with STDERR.puts 233 | 234 | context 'with $stderr.puts' do 235 | subject do 236 | capture_output { $stderr.puts 'test' } 237 | end 238 | it { is_expected.to eq '' } 239 | its(:stdout) { is_expected.to eq '' } 240 | its(:stderr) { is_expected.to eq "test\n" } 241 | its(:exitstatus) { is_expected.to eq 0 } 242 | end # /context with $stderr.puts 243 | 244 | context 'with a subproc' do 245 | subject do 246 | capture_output do 247 | # Can't use `` because that already captures stdout 248 | if pid = Process.fork 249 | Process.waitpid(pid) 250 | else 251 | exec('echo test') 252 | end 253 | end 254 | end 255 | it { is_expected.to eq "test\n" } 256 | its(:stdout) { is_expected.to eq "test\n" } 257 | its(:stderr) { is_expected.to eq '' } 258 | its(:exitstatus) { is_expected.to eq 0 } 259 | end # /context with a subproc 260 | 261 | context 'with a subproc to stderr' do 262 | subject do 263 | capture_output { `echo test >&2` } 264 | end 265 | it { is_expected.to eq '' } 266 | its(:stdout) { is_expected.to eq '' } 267 | its(:stderr) { is_expected.to eq "test\n" } 268 | its(:exitstatus) { is_expected.to eq 0 } 269 | end # /context with a subproc 270 | 271 | context 'with a block that raises an exception' do 272 | subject do 273 | capture_output do 274 | puts 'before' 275 | raise 'OMG' 276 | puts 'after' 277 | end 278 | end 279 | it { expect { subject }.to raise_error RuntimeError } 280 | it do 281 | begin 282 | subject 283 | rescue Exception => e 284 | expect(e).to respond_to(:output_so_far) 285 | expect(e.output_so_far).to eq "before\n" 286 | else 287 | raise 'Subject did not raise exception' 288 | end 289 | end 290 | end # /context with a block that raises an exception 291 | end # /describe #capture_output 292 | 293 | describe RSpecCommand::OutputString do 294 | subject { described_class.new('testout', 'testerr') } 295 | it { is_expected.to be_a String } 296 | it { is_expected.to eq 'testout' } 297 | its(:stdout) { is_expected.to eq 'testout' } 298 | its(:stderr) { is_expected.to eq 'testerr' } 299 | its(:exitstatus) { is_expected.to eq 0 } 300 | end # /describe RSpecCommand::OutputString 301 | end 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /lib/rspec_command.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2015, Noah Kantrowitz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | require 'fileutils' 18 | require 'tempfile' 19 | 20 | require 'rspec' 21 | require 'rspec/its' 22 | require 'mixlib/shellout' 23 | 24 | 25 | # An RSpec helper module for testing command-line tools. 26 | # 27 | # @api public 28 | # @since 1.0.0 29 | # @example Enable globally 30 | # RSpec.configure do |config| 31 | # config.include RSpecCommand 32 | # end 33 | # @example Enable for a single example group 34 | # describe 'myapp' do 35 | # command 'myapp --version' 36 | # its(:stdout) { it_expected.to include('1.0.0') } 37 | # end 38 | module RSpecCommand 39 | extend RSpec::SharedContext 40 | 41 | autoload :MatchFixture, 'rspec_command/match_fixture' 42 | autoload :Rake, 'rspec_command/rake' 43 | 44 | around do |example| 45 | Dir.mktmpdir('rspec_command') do |path| 46 | example.metadata[:rspec_command_temp_path] = path 47 | example.run 48 | end 49 | end 50 | 51 | # @!attribute [r] temp_path 52 | # Path to the temporary directory created for the current example. 53 | # @return [String] 54 | let(:temp_path) do |example| 55 | example.metadata[:rspec_command_temp_path] 56 | end 57 | 58 | # @!attribute [r] fixture_root 59 | # Base path for the fixtures directory. Default value is 'fixtures'. 60 | # @return [String] 61 | # @example 62 | # let(:fixture_root) { 'data' } 63 | let(:fixture_root) { 'fixtures' } 64 | 65 | # @!attribute [r] _environment 66 | # @!visibility private 67 | # @api private 68 | # Accumulator for environment variables. 69 | # @see RSpecCommand.environment 70 | let(:_environment) { Hash.new } 71 | 72 | # Run a command. 73 | # 74 | # @see .command 75 | # @param cmd [String, Array] Command to run. If passed as an array, no shell 76 | # expansion will be performed. 77 | # @param options [Hash] Options to pass to 78 | # Mixlib::ShellOut.new. 79 | # @option options [Boolean] allow_error If true, don't raise an error on 80 | # failed commands. 81 | # @return [Mixlib::ShellOut] 82 | # @example 83 | # before do 84 | # command('git init') 85 | # end 86 | def command(cmd, options={}) 87 | # Try to find a Gemfile 88 | gemfile_path = ENV['BUNDLE_GEMFILE'] || find_file(self.class.file_path, 'Gemfile') 89 | gemfile_environment = gemfile_path ? {'BUNDLE_GEMFILE' => gemfile_path} : {} 90 | # Create the command 91 | options = options.dup 92 | allow_error = options.delete(:allow_error) 93 | full_cmd = if gemfile_path 94 | if cmd.is_a?(Array) 95 | %w{bundle exec} + cmd 96 | else 97 | "bundle exec #{cmd}" 98 | end 99 | else 100 | cmd 101 | end 102 | Mixlib::ShellOut.new( 103 | full_cmd, 104 | { 105 | cwd: temp_path, 106 | environment: gemfile_environment.merge(_environment), 107 | }.merge(options), 108 | ).tap do |cmd_out| 109 | # Run the command 110 | cmd_out.run_command 111 | cmd_out.error! unless allow_error 112 | end 113 | end 114 | 115 | # Matcher to compare files or folders from the temporary directory to a 116 | # fixture. 117 | # 118 | # @example 119 | # describe 'myapp' do 120 | # command 'myapp write' 121 | # it { is_expected.to match_fixture('write_data') } 122 | # end 123 | def match_fixture(fixture_path, local_path=nil) 124 | MatchFixture.new(find_fixture(self.class.file_path), temp_path, fixture_path, local_path) 125 | end 126 | 127 | # Run a local block with $stdout and $stderr redirected to a strings. Useful 128 | # for running CLI code in unit tests. The returned string has `#stdout`, 129 | # `#stderr` and `#exitstatus` attributes to emulate the output from {.command}. 130 | # 131 | # @param block [Proc] Code to run. 132 | # @return [String] 133 | # @example 134 | # describe 'my rake task' do 135 | # subject do 136 | # capture_output do 137 | # Rake::Task['mytask'].invoke 138 | # end 139 | # end 140 | # end 141 | def capture_output(&block) 142 | old_stdout = $stdout.dup 143 | old_stderr = $stderr.dup 144 | # Potential future improvement is to use IO.pipe instead of temp files, but 145 | # that would require threads or something to read contiuously since the 146 | # buffer is only 64k on the kernel side. 147 | Tempfile.open('capture_stdout') do |tmp_stdout| 148 | Tempfile.open('capture_stderr') do |tmp_stderr| 149 | $stdout.reopen(tmp_stdout) 150 | $stdout.sync = true 151 | $stderr.reopen(tmp_stderr) 152 | $stderr.sync = true 153 | output = nil 154 | begin 155 | # Inner block to make sure the ensure happens first. 156 | begin 157 | block.call 158 | ensure 159 | # Rewind. 160 | tmp_stdout.seek(0, 0) 161 | tmp_stderr.seek(0, 0) 162 | # Read in the output. 163 | output = OutputString.new(tmp_stdout.read, tmp_stderr.read) 164 | end 165 | rescue Exception => e 166 | if output 167 | # Try to add the output so far as an attribute on the exception via 168 | # a closure. 169 | e.define_singleton_method(:output_so_far) do 170 | output 171 | end 172 | end 173 | raise 174 | else 175 | output 176 | end 177 | end 178 | end 179 | ensure 180 | $stdout.reopen(old_stdout) 181 | $stderr.reopen(old_stderr) 182 | end 183 | 184 | # String subclass to make string output look kind of like Mixlib::ShellOut. 185 | # 186 | # @api private 187 | # @see capture_stdout 188 | class OutputString < String 189 | def initialize(stdout, stderr) 190 | super(stdout) 191 | @stderr = stderr 192 | end 193 | 194 | def stdout 195 | self 196 | end 197 | 198 | def stderr 199 | @stderr 200 | end 201 | 202 | def exitstatus 203 | 0 204 | end 205 | end 206 | 207 | private 208 | 209 | # Search backwards along the working directory looking for a file, a la .git. 210 | # Either file or block must be given. 211 | # 212 | # @param example_path [String] Path of the current example file. Find via 213 | # example.file_path. 214 | # @param file [String] Relative path to search for. 215 | # @param backstop [String] Path to not search past. 216 | # @param block [Proc] Block to use as a filter. 217 | # @return [String, nil] 218 | def find_file(example_path, file=nil, backstop=nil, &block) 219 | path = File.dirname(File.expand_path(example_path)) 220 | last_path = nil 221 | while path != last_path && path != backstop 222 | if block 223 | block_val = block.call(path) 224 | return block_val if block_val 225 | else 226 | file_path = File.join(path, file) 227 | return file_path if File.exists?(file_path) 228 | end 229 | last_path = path 230 | path = File.dirname(path) 231 | end 232 | nil 233 | end 234 | 235 | # Find the base folder of the current gem. 236 | def find_gem_base(example_path) 237 | @gem_base ||= begin 238 | paths = [] 239 | paths << find_file(example_path) do |path| 240 | spec_path = Dir.entries(path).find do |ent| 241 | ent.end_with?('.gemspec') 242 | end 243 | spec_path = File.join(path, spec_path) if spec_path 244 | spec_path 245 | end 246 | paths << find_file(example_path, 'Gemfile') 247 | File.dirname(paths.find {|v| v }) 248 | end 249 | end 250 | 251 | # Find a fixture file or the fixture base folder. 252 | def find_fixture(example_path, path=nil) 253 | @fixture_base ||= find_file(example_path, fixture_root, find_gem_base(example_path)) 254 | path ? File.join(@fixture_base, path) : @fixture_base 255 | end 256 | 257 | # @!classmethods 258 | module ClassMethods 259 | # Run a command as the subject of this example. The command can be passed in 260 | # as a string, array, or block. The subject will be a Mixlib::ShellOut 261 | # object, all attributes from there will work with rspec-its. 262 | # 263 | # @see #command 264 | # @param cmd [String, Array] Command to run. If passed as an array, no shell 265 | # expansion will be performed. 266 | # @param options [Hash] Options to pass to 267 | # Mixlib::ShellOut.new. 268 | # @param block [Proc] Optional block to return a command to run. 269 | # @option options [Boolean] allow_error If true, don't raise an error on 270 | # failed commands. 271 | # @example 272 | # describe 'myapp' do 273 | # command 'myapp show' 274 | # its(:stdout) { is_expected.to match(/a thing/) } 275 | # end 276 | def command(cmd=nil, options={}, &block) 277 | metadata[:command] = true 278 | subject do |example| 279 | # If a block is given, use it to get the command. 280 | cmd = instance_eval(&block) if block 281 | command(cmd, options) 282 | end 283 | end 284 | 285 | # Create a file in the temporary directory for this example. 286 | # 287 | # @param path [String] Path within the temporary directory to write to. 288 | # @param content [String] File data to write. 289 | # @param block [Proc] Optional block to return file data to write. 290 | # @example 291 | # describe 'myapp' do 292 | # command 'myapp read data.txt' 293 | # file 'data.txt', <<-EOH 294 | # a thing 295 | # EOH 296 | # its(:exitstatus) { is_expected.to eq 0 } 297 | # end 298 | def file(path, content=nil, &block) 299 | raise "file path should be relative the the temporary directory." if path == File.expand_path(path) 300 | before do 301 | content = instance_eval(&block) if block 302 | dest_path = File.join(temp_path, path) 303 | FileUtils.mkdir_p(File.dirname(dest_path)) 304 | IO.write(dest_path, content) 305 | end 306 | end 307 | 308 | # Copy fixture data from the spec folder to the temporary directory for this 309 | # example. 310 | # 311 | # @param path [String] Path of the fixture to copy. 312 | # @param dest [String] Optional destination path. By default the destination 313 | # is the same as path. 314 | # @example 315 | # describe 'myapp' do 316 | # command 'myapp run test/' 317 | # fixture_file 'test' 318 | # its(:exitstatus) { is_expected.to eq 0 } 319 | # end 320 | def fixture_file(path, dest=nil) 321 | raise "file path should be relative the the temporary directory." if path == File.expand_path(path) 322 | before do |example| 323 | fixture_path = find_fixture(example.file_path, path) 324 | dest_path = dest ? File.join(temp_path, dest) : temp_path 325 | FileUtils.mkdir_p(dest_path) 326 | file_list = MatchFixture::FileList.new(fixture_path) 327 | file_list.files.each do |file| 328 | abs = file_list.absolute(file) 329 | if File.directory?(abs) 330 | FileUtils.mkdir_p(File.join(dest_path, file)) 331 | else 332 | FileUtils.copy(abs , File.join(dest_path, file), preserve: true) 333 | end 334 | end 335 | end 336 | end 337 | 338 | # Set an environment variable for this example. 339 | # 340 | # @param variables [Hash] Key/value pairs to set. 341 | # @example 342 | # describe 'myapp' do 343 | # command 'myapp show' 344 | # environment DEBUG: true 345 | # its(:stderr) { is_expected.to include('[debug]') } 346 | # end 347 | def environment(variables) 348 | before do 349 | variables.each do |key, value| 350 | if value.nil? 351 | _environment.delete(key.to_s) 352 | else 353 | _environment[key.to_s] = value.to_s 354 | end 355 | end 356 | end 357 | end 358 | 359 | def included(klass) 360 | super 361 | klass.extend ClassMethods 362 | end 363 | end 364 | 365 | extend ClassMethods 366 | end 367 | --------------------------------------------------------------------------------