rspec-command 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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