rspec-command 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
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'
@@ -0,0 +1,361 @@
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<Symbol, Object>] 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 = find_file(self.class.file_path, 'Gemfile')
89
+ gemfile_environment = gemfile_path ? {'BUNDLE_GEMFILE' => gemfile_path} : {}
90
+ # Create the command
91
+ allow_error = options.delete(:allow_error)
92
+ full_cmd = if gemfile_path
93
+ if cmd.is_a?(Array)
94
+ %w{bundle exec} + cmd
95
+ else
96
+ "bundle exec #{cmd}"
97
+ end
98
+ else
99
+ cmd
100
+ end
101
+ Mixlib::ShellOut.new(
102
+ full_cmd,
103
+ {
104
+ cwd: temp_path,
105
+ environment: gemfile_environment.merge(_environment),
106
+ }.merge(options),
107
+ ).tap do |cmd|
108
+ # Run the command
109
+ cmd.run_command
110
+ cmd.error! unless allow_error
111
+ end
112
+ end
113
+
114
+ # Matcher to compare files or folders from the temporary directory to a
115
+ # fixture.
116
+ #
117
+ # @example
118
+ # describe 'myapp' do
119
+ # command 'myapp write'
120
+ # it { is_expected.to match_fixture('write_data') }
121
+ # end
122
+ def match_fixture(fixture_path, local_path=nil)
123
+ MatchFixture.new(find_fixture(self.class.file_path), temp_path, fixture_path, local_path)
124
+ end
125
+
126
+ # Run a local block with $stdout and $stderr redirected to a strings. Useful
127
+ # for running CLI code in unit tests. The returned string has `#stdout`,
128
+ # `#stderr` and `#exitstatus` attributes to emulate the output from {.command}.
129
+ #
130
+ # @param block [Proc] Code to run.
131
+ # @return [String]
132
+ # @example
133
+ # describe 'my rake task' do
134
+ # subject do
135
+ # capture_output do
136
+ # Rake::Task['mytask'].invoke
137
+ # end
138
+ # end
139
+ # end
140
+ def capture_output(&block)
141
+ old_stdout = $stdout.dup
142
+ old_stderr = $stderr.dup
143
+ # Potential future improvement is to use IO.pipe instead of temp files, but
144
+ # that would require threads or something to read contiuously since the
145
+ # buffer is only 64k on the kernel side.
146
+ Tempfile.open('capture_stdout') do |tmp_stdout|
147
+ Tempfile.open('capture_stderr') do |tmp_stderr|
148
+ $stdout.reopen(tmp_stdout)
149
+ $stdout.sync = true
150
+ $stderr.reopen(tmp_stderr)
151
+ $stderr.sync = true
152
+ output = nil
153
+ begin
154
+ # Inner block to make sure the ensure happens first.
155
+ begin
156
+ block.call
157
+ ensure
158
+ # Rewind.
159
+ tmp_stdout.seek(0, 0)
160
+ tmp_stderr.seek(0, 0)
161
+ # Read in the output.
162
+ output = OutputString.new(tmp_stdout.read, tmp_stderr.read)
163
+ end
164
+ rescue Exception => e
165
+ if output
166
+ # Try to add the output so far as an attribute on the exception via
167
+ # a closure.
168
+ e.define_singleton_method(:output_so_far) do
169
+ output
170
+ end
171
+ end
172
+ raise
173
+ else
174
+ output
175
+ end
176
+ end
177
+ end
178
+ ensure
179
+ $stdout.reopen(old_stdout)
180
+ $stderr.reopen(old_stderr)
181
+ end
182
+
183
+ # String subclass to make string output look kind of like Mixlib::ShellOut.
184
+ #
185
+ # @api private
186
+ # @see capture_stdout
187
+ class OutputString < String
188
+ def initialize(stdout, stderr)
189
+ super(stdout)
190
+ @stderr = stderr
191
+ end
192
+
193
+ def stdout
194
+ self
195
+ end
196
+
197
+ def stderr
198
+ @stderr
199
+ end
200
+
201
+ def exitstatus
202
+ 0
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ # Search backwards along the working directory looking for a file, a la .git.
209
+ # Either file or block must be given.
210
+ #
211
+ # @param example_path [String] Path of the current example file. Find via
212
+ # example.file_path.
213
+ # @param file [String] Relative path to search for.
214
+ # @param backstop [String] Path to not search past.
215
+ # @param block [Proc] Block to use as a filter.
216
+ # @return [String, nil]
217
+ def find_file(example_path, file=nil, backstop=nil, &block)
218
+ path = File.dirname(File.expand_path(example_path))
219
+ last_path = nil
220
+ while path != last_path && path != backstop
221
+ if block
222
+ block_val = block.call(path)
223
+ return block_val if block_val
224
+ else
225
+ file_path = File.join(path, file)
226
+ return file_path if File.exists?(file_path)
227
+ end
228
+ last_path = path
229
+ path = File.dirname(path)
230
+ end
231
+ nil
232
+ end
233
+
234
+ # Find the base folder of the current gem.
235
+ def find_gem_base(example_path)
236
+ @gem_base ||= begin
237
+ paths = []
238
+ paths << find_file(example_path) do |path|
239
+ spec_path = Dir.entries(path).find do |ent|
240
+ ent.end_with?('.gemspec')
241
+ end
242
+ spec_path = File.join(path, spec_path) if spec_path
243
+ spec_path
244
+ end
245
+ paths << find_file(example_path, 'Gemfile')
246
+ File.dirname(paths.find {|v| v })
247
+ end
248
+ end
249
+
250
+ # Find a fixture file or the fixture base folder.
251
+ def find_fixture(example_path, path=nil)
252
+ @fixture_base ||= find_file(example_path, fixture_root, find_gem_base(example_path))
253
+ path ? File.join(@fixture_base, path) : @fixture_base
254
+ end
255
+
256
+ # @!classmethods
257
+ module ClassMethods
258
+ # Run a command as the subject of this example. The command can be passed in
259
+ # as a string, array, or block. The subject will be a Mixlib::ShellOut
260
+ # object, all attributes from there will work with rspec-its.
261
+ #
262
+ # @see #command
263
+ # @param cmd [String, Array] Command to run. If passed as an array, no shell
264
+ # expansion will be performed.
265
+ # @param options [Hash<Symbol, Object>] Options to pass to
266
+ # Mixlib::ShellOut.new.
267
+ # @param block [Proc] Optional block to return a command to run.
268
+ # @option options [Boolean] allow_error If true, don't raise an error on
269
+ # failed commands.
270
+ # @example
271
+ # describe 'myapp' do
272
+ # command 'myapp show'
273
+ # its(:stdout) { is_expected.to match(/a thing/) }
274
+ # end
275
+ def command(cmd=nil, options={}, &block)
276
+ metadata[:command] = true
277
+ subject do |example|
278
+ # If a block is given, use it to get the command.
279
+ cmd = instance_eval(&block) if block
280
+ command(cmd, options)
281
+ end
282
+ end
283
+
284
+ # Create a file in the temporary directory for this example.
285
+ #
286
+ # @param path [String] Path within the temporary directory to write to.
287
+ # @param content [String] File data to write.
288
+ # @param block [Proc] Optional block to return file data to write.
289
+ # @example
290
+ # describe 'myapp' do
291
+ # command 'myapp read data.txt'
292
+ # file 'data.txt', <<-EOH
293
+ # a thing
294
+ # EOH
295
+ # its(:exitstatus) { is_expected.to eq 0 }
296
+ # end
297
+ def file(path, content=nil, &block)
298
+ raise "file path should be relative the the temporary directory." if path == File.expand_path(path)
299
+ before do
300
+ content = instance_eval(&block) if block
301
+ dest_path = File.join(temp_path, path)
302
+ FileUtils.mkdir_p(File.dirname(dest_path))
303
+ IO.write(dest_path, content)
304
+ end
305
+ end
306
+
307
+ # Copy fixture data from the spec folder to the temporary directory for this
308
+ # example.
309
+ #
310
+ # @param path [String] Path of the fixture to copy.
311
+ # @param dest [String] Optional destination path. By default the destination
312
+ # is the same as path.
313
+ # @example
314
+ # describe 'myapp' do
315
+ # command 'myapp run test/'
316
+ # fixture_file 'test'
317
+ # its(:exitstatus) { is_expected.to eq 0 }
318
+ # end
319
+ def fixture_file(path, dest=nil)
320
+ raise "file path should be relative the the temporary directory." if path == File.expand_path(path)
321
+ before do |example|
322
+ fixture_path = find_fixture(example.file_path, path)
323
+ dest_path = dest ? File.join(temp_path, dest) : temp_path
324
+ FileUtils.mkdir_p(dest_path)
325
+ file_list = MatchFixture::FileList.new(fixture_path)
326
+ file_list.files.each do |file|
327
+ abs = file_list.absolute(file)
328
+ if File.directory?(abs)
329
+ FileUtils.mkdir_p(File.join(dest_path, file))
330
+ else
331
+ FileUtils.copy(abs , File.join(dest_path, file), preserve: true)
332
+ end
333
+ end
334
+ end
335
+ end
336
+
337
+ # Set an environment variable for this example.
338
+ #
339
+ # @param variables [Hash] Key/value pairs to set.
340
+ # @example
341
+ # describe 'myapp' do
342
+ # command 'myapp show'
343
+ # environment DEBUG: true
344
+ # its(:stderr) { is_expected.to include('[debug]') }
345
+ # end
346
+ def environment(variables)
347
+ before do
348
+ variables.each do |key, value|
349
+ _environment[key.to_s] = value.to_s
350
+ end
351
+ end
352
+ end
353
+
354
+ def included(klass)
355
+ super
356
+ klass.extend ClassMethods
357
+ end
358
+ end
359
+
360
+ extend ClassMethods
361
+ end
@@ -0,0 +1,165 @@
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[File.join(full_path, '**', '*')].sort
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