rspec-multiprocess_runner 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +6 -0
- data/TODO.md +11 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/exe/multirspec +16 -0
- data/lib/rspec/multiprocess_runner/command_line_options.rb +98 -0
- data/lib/rspec/multiprocess_runner/coordinator.rb +197 -0
- data/lib/rspec/multiprocess_runner/rake_task.rb +114 -0
- data/lib/rspec/multiprocess_runner/reporting_formatter.rb +77 -0
- data/lib/rspec/multiprocess_runner/version.rb +5 -0
- data/lib/rspec/multiprocess_runner/worker.rb +262 -0
- data/lib/rspec/multiprocess_runner.rb +6 -0
- data/rspec-multiprocess_runner.gemspec +26 -0
- metadata +143 -0
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 Collaborative Drug Discovery
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
# Rspec::MultiprocessRunner
|
2
|
+
|
3
|
+
This gem provides a mechanism for running a suite of RSpec tests in multiple
|
4
|
+
processes on the same machine, potentially allowing substantial performance
|
5
|
+
improvements.
|
6
|
+
|
7
|
+
It differs from `parallel-tests` in that it uses a coordinator process to manage
|
8
|
+
the workers, hand off work to them, and receive results. This means it can
|
9
|
+
dynamically balance the workload among the processors. It also means it can
|
10
|
+
provide consolidated results in the console.
|
11
|
+
|
12
|
+
It does follow parallel-tests' `TEST_ENV_NUMBER` convention so it's easy to
|
13
|
+
switch.
|
14
|
+
|
15
|
+
## Benefits
|
16
|
+
|
17
|
+
* Running slow (IO-bound) specs in parallel can greatly reduce the wall-clock
|
18
|
+
time needed to run a suite. Even CPU-bound specs can be aided
|
19
|
+
* Provides detailed logging of each example as it completes, including the
|
20
|
+
failure message (you don't have to wait until the end to see the failure
|
21
|
+
reason).
|
22
|
+
* Detects, kills, and reports spec files that take longer than expected (five
|
23
|
+
minutes by default).
|
24
|
+
* Detects and reports spec files that crash (without interrupting the
|
25
|
+
remainder of the suite).
|
26
|
+
|
27
|
+
## Limitations
|
28
|
+
|
29
|
+
* Only works with RSpec 2. Does not work with RSpec 3.
|
30
|
+
* Does not work on Windows or JRuby. Since it relies on `fork(2)`, it probably
|
31
|
+
never will.
|
32
|
+
* Does not support RSpec custom formatters.
|
33
|
+
* The built-in output format is very verbose — it's intended for CI servers,
|
34
|
+
where more logging is better.
|
35
|
+
* Intermediate-quality code. Happy path works, and workers are
|
36
|
+
managed/restarted, but:
|
37
|
+
* There's no test coverage of the runner itself, only auxiliaries.
|
38
|
+
* Does not handle the coordinator process dying (e.g., from `^C`).
|
39
|
+
|
40
|
+
## Installation
|
41
|
+
|
42
|
+
Add this line to your application's Gemfile:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
gem 'rspec-multiprocess_runner'
|
46
|
+
```
|
47
|
+
|
48
|
+
And then execute:
|
49
|
+
|
50
|
+
$ bundle
|
51
|
+
|
52
|
+
Or install it yourself as:
|
53
|
+
|
54
|
+
$ gem install rspec-multiprocess_runner
|
55
|
+
|
56
|
+
## Usage
|
57
|
+
|
58
|
+
### Command line
|
59
|
+
|
60
|
+
Use `multirspec` to run a bunch of spec files:
|
61
|
+
|
62
|
+
$ multirspec spec
|
63
|
+
|
64
|
+
Runs three workers by default — use `-w` to chose another count. `--help` will
|
65
|
+
detail the other options.
|
66
|
+
|
67
|
+
You can provide options that will be passed to the separate RSpec processes by
|
68
|
+
including them after a `--`:
|
69
|
+
|
70
|
+
$ multirspec -w 5 spec -- -b -I ./lib
|
71
|
+
|
72
|
+
In this case, each RSpec process would receive the options `-b -I ./lib`. Note
|
73
|
+
that not that many RSpec options really make sense to pass this way. In
|
74
|
+
particular, file selection and output formatting options are unlikely to work
|
75
|
+
the way you expect.
|
76
|
+
|
77
|
+
### Rake
|
78
|
+
|
79
|
+
There is a rake task wrapper for `multirspec`:
|
80
|
+
|
81
|
+
require 'rspec/multiprocess_runner/rake_task'
|
82
|
+
|
83
|
+
RSpec::MultiprocessRunner::RakeTask.new(:spec) do |t|
|
84
|
+
t.worker_count = 5
|
85
|
+
t.pattern = "spec/**/*_spec.rb"
|
86
|
+
end
|
87
|
+
|
88
|
+
See its source for the full list of options.
|
89
|
+
|
90
|
+
### Code
|
91
|
+
|
92
|
+
Create a coordinator and tell it to run:
|
93
|
+
|
94
|
+
require 'rspec/multiprocess_runner/coordinator'
|
95
|
+
|
96
|
+
worker_count = 4
|
97
|
+
per_file_timeout = 5 * 60 # 5 minutes in seconds
|
98
|
+
rspec_args = %w(--backtrace)
|
99
|
+
files = Dir['**/*_spec.rb']
|
100
|
+
|
101
|
+
coordinator = RSpec::MultiprocessRunner::Coordinator(worker_count, per_file_timeout, rspec_args, files)
|
102
|
+
coordinator.run
|
103
|
+
|
104
|
+
## Contributing
|
105
|
+
|
106
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/cdd/rspec-multiprocess_runner.
|
107
|
+
|
108
|
+
|
109
|
+
## License
|
110
|
+
|
111
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/TODO.md
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# Tests
|
2
|
+
|
3
|
+
Tricky things which I am manually testing:
|
4
|
+
|
5
|
+
* Timing out slow files (as_spec.rb)
|
6
|
+
* Recognizing and handling workers that die (ax_spec.rb, x*_spec.rb)
|
7
|
+
* Preserving the configuration from spec_helper, etc., across runs (w*_spec.rb)
|
8
|
+
|
9
|
+
Need to write automated tests for these (in addition to tests for all the basic
|
10
|
+
behavior). Maybe use Cucumber (like RSpec does for acceptance tests) to avoid
|
11
|
+
the weirdness of testing rspec within rspec.
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "rspec/multiprocess_runner"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/exe/multirspec
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rspec/multiprocess_runner/command_line_options'
|
4
|
+
require 'rspec/multiprocess_runner/coordinator'
|
5
|
+
|
6
|
+
options = RSpec::MultiprocessRunner::CommandLineOptions.new.parse(ARGV.dup)
|
7
|
+
exit(2) unless options
|
8
|
+
|
9
|
+
success = RSpec::MultiprocessRunner::Coordinator.new(
|
10
|
+
options.worker_count,
|
11
|
+
options.file_timeout_seconds,
|
12
|
+
options.rspec_options,
|
13
|
+
options.files_to_run
|
14
|
+
).run
|
15
|
+
|
16
|
+
exit(success ? 0 : 1)
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require 'rspec/multiprocess_runner'
|
2
|
+
require 'optparse'
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
module RSpec::MultiprocessRunner
|
6
|
+
# @private
|
7
|
+
class CommandLineOptions
|
8
|
+
attr_accessor :worker_count, :file_timeout_seconds, :rspec_options,
|
9
|
+
:explicit_files_or_directories, :pattern
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
self.worker_count = 3
|
13
|
+
self.file_timeout_seconds = 300
|
14
|
+
self.pattern = "**/*_spec.rb"
|
15
|
+
self.rspec_options = []
|
16
|
+
end
|
17
|
+
|
18
|
+
def parse(command_line_args, error_stream=$stderr)
|
19
|
+
args = command_line_args.dup
|
20
|
+
parser = build_parser
|
21
|
+
|
22
|
+
begin
|
23
|
+
parser.parse!(args)
|
24
|
+
rescue OptionParser::ParseError => e
|
25
|
+
error_stream.puts e.to_s
|
26
|
+
error_stream.puts parser
|
27
|
+
return nil
|
28
|
+
end
|
29
|
+
|
30
|
+
if help_requested?
|
31
|
+
error_stream.puts parser
|
32
|
+
return nil
|
33
|
+
end
|
34
|
+
|
35
|
+
extract_files_and_rspec_options(args)
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def files_to_run
|
40
|
+
self.explicit_files_or_directories = %w(.) unless explicit_files_or_directories
|
41
|
+
relative_root = Pathname.new('.')
|
42
|
+
explicit_files_or_directories.map { |path| Pathname.new(path) }.flat_map do |path|
|
43
|
+
if path.file?
|
44
|
+
path.to_s
|
45
|
+
else
|
46
|
+
Dir[path.join(pattern).relative_path_from(relative_root)]
|
47
|
+
end
|
48
|
+
end.sort_by { |path| path.downcase }
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def help_requested!
|
54
|
+
@help_requested = true
|
55
|
+
end
|
56
|
+
|
57
|
+
def help_requested?
|
58
|
+
@help_requested
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_parser
|
62
|
+
OptionParser.new do |parser|
|
63
|
+
parser.banner = "#{File.basename $0} [options] [files or directories] [-- rspec options]"
|
64
|
+
|
65
|
+
parser.on("-w", "--worker-count COUNT", Integer, "Number of workers to run (default: #{worker_count})") do |n|
|
66
|
+
self.worker_count = n
|
67
|
+
end
|
68
|
+
|
69
|
+
parser.on("-t", "--file-timeout SECONDS", Integer, "Maximum time to allow any single file to run (default: #{file_timeout_seconds})") do |s|
|
70
|
+
self.file_timeout_seconds = s
|
71
|
+
end
|
72
|
+
|
73
|
+
parser.on("-P", "--pattern PATTERN", "A glob to use to select files to run (default: #{pattern})") do |pattern|
|
74
|
+
self.pattern = pattern
|
75
|
+
end
|
76
|
+
|
77
|
+
parser.on_tail("-h", "--help", "Prints this help") do
|
78
|
+
help_requested!
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def extract_files_and_rspec_options(remaining_args)
|
84
|
+
files = []
|
85
|
+
rspec_options = []
|
86
|
+
target = files
|
87
|
+
remaining_args.each do |arg|
|
88
|
+
if arg[0] == '-'
|
89
|
+
target = rspec_options
|
90
|
+
end
|
91
|
+
target << arg
|
92
|
+
end
|
93
|
+
|
94
|
+
self.explicit_files_or_directories = files unless files.empty?
|
95
|
+
self.rspec_options = rspec_options unless rspec_options.empty?
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'rspec/multiprocess_runner'
|
3
|
+
require 'rspec/multiprocess_runner/worker'
|
4
|
+
|
5
|
+
module RSpec::MultiprocessRunner
|
6
|
+
class Coordinator
|
7
|
+
def initialize(worker_count, file_timeout, rspec_options, files)
|
8
|
+
@worker_count = worker_count
|
9
|
+
@file_timeout = file_timeout
|
10
|
+
@rspec_options = rspec_options
|
11
|
+
@spec_files = files
|
12
|
+
@workers = []
|
13
|
+
@deactivated_workers = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def run
|
17
|
+
@start_time = Time.now
|
18
|
+
expected_worker_numbers.each do |n|
|
19
|
+
create_and_start_worker_if_necessary(n)
|
20
|
+
end
|
21
|
+
run_loop
|
22
|
+
quit_all_workers
|
23
|
+
print_summary
|
24
|
+
|
25
|
+
!failed?
|
26
|
+
end
|
27
|
+
|
28
|
+
def failed?
|
29
|
+
!@deactivated_workers.empty? || any_example_failed?
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def worker_sockets
|
35
|
+
@workers.map(&:socket)
|
36
|
+
end
|
37
|
+
|
38
|
+
def run_loop
|
39
|
+
loop do
|
40
|
+
act_on_available_worker_messages(0.3)
|
41
|
+
reap_stalled_workers
|
42
|
+
start_missing_workers
|
43
|
+
break unless @workers.detect(&:working?)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def quit_all_workers
|
48
|
+
quit_threads = @workers.map do |worker|
|
49
|
+
Thread.new do
|
50
|
+
worker.quit
|
51
|
+
worker.wait_until_quit
|
52
|
+
end
|
53
|
+
end
|
54
|
+
quit_threads.each(&:join)
|
55
|
+
end
|
56
|
+
|
57
|
+
def work_left_to_do?
|
58
|
+
!@spec_files.empty?
|
59
|
+
end
|
60
|
+
|
61
|
+
def act_on_available_worker_messages(timeout)
|
62
|
+
while (select_result = IO.select(worker_sockets, nil, nil, timeout))
|
63
|
+
select_result.first.each do |readable_socket|
|
64
|
+
ready_worker = @workers.detect { |worker| worker.socket == readable_socket }
|
65
|
+
worker_status = ready_worker.receive_and_act_on_message_from_worker
|
66
|
+
if worker_status == :dead
|
67
|
+
reap_one_worker(ready_worker, "died")
|
68
|
+
elsif work_left_to_do? && !ready_worker.working?
|
69
|
+
ready_worker.run_file(@spec_files.shift)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def reap_one_worker(worker, reason)
|
76
|
+
worker.reap
|
77
|
+
@deactivated_workers << worker
|
78
|
+
worker.deactivation_reason = reason
|
79
|
+
@workers.reject! { |w| w == worker }
|
80
|
+
end
|
81
|
+
|
82
|
+
def reap_stalled_workers
|
83
|
+
@workers.select(&:stalled?).each do |stalled_worker|
|
84
|
+
reap_one_worker(stalled_worker, "stalled")
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def expected_worker_numbers
|
89
|
+
(1..@worker_count).to_a
|
90
|
+
end
|
91
|
+
|
92
|
+
def create_and_start_worker_if_necessary(n)
|
93
|
+
if work_left_to_do?
|
94
|
+
$stderr.puts "(Re)starting worker #{n}"
|
95
|
+
new_worker = Worker.new(n, @file_timeout, @rspec_options)
|
96
|
+
@workers << new_worker
|
97
|
+
new_worker.start
|
98
|
+
new_worker.run_file(@spec_files.shift)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def start_missing_workers
|
103
|
+
if @workers.size < @worker_count && work_left_to_do?
|
104
|
+
running_process_numbers = @workers.map(&:environment_number)
|
105
|
+
missing_process_numbers = expected_worker_numbers - running_process_numbers
|
106
|
+
missing_process_numbers.each do |n|
|
107
|
+
create_and_start_worker_if_necessary(n)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def print_summary
|
113
|
+
elapsed = Time.now - @start_time
|
114
|
+
by_status_and_time = combine_example_results.each_with_object({}) do |result, idx|
|
115
|
+
(idx[result.status] ||= []) << result
|
116
|
+
end
|
117
|
+
print_pending_example_details(by_status_and_time["pending"])
|
118
|
+
print_failed_example_details(by_status_and_time["failed"])
|
119
|
+
print_failed_process_details
|
120
|
+
puts
|
121
|
+
print_elapsed_time(elapsed)
|
122
|
+
puts failed? ? "FAILURE" : "SUCCESS"
|
123
|
+
print_example_counts(by_status_and_time)
|
124
|
+
end
|
125
|
+
|
126
|
+
def combine_example_results
|
127
|
+
(@workers + @deactivated_workers).flat_map(&:example_results).sort_by { |r| r.time_finished }
|
128
|
+
end
|
129
|
+
|
130
|
+
def any_example_failed?
|
131
|
+
(@workers + @deactivated_workers).detect { |w| w.example_results.detect { |r| r.status == "failed" } }
|
132
|
+
end
|
133
|
+
|
134
|
+
def print_pending_example_details(pending_example_results)
|
135
|
+
return if pending_example_results.nil?
|
136
|
+
puts
|
137
|
+
puts "Pending:"
|
138
|
+
pending_example_results.each do |pending|
|
139
|
+
puts
|
140
|
+
puts pending.details.sub(/^\s*Pending:\s*/, '')
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def print_failed_example_details(failed_example_results)
|
145
|
+
return if failed_example_results.nil?
|
146
|
+
puts
|
147
|
+
puts "Failures:"
|
148
|
+
failed_example_results.each_with_index do |failure, i|
|
149
|
+
puts
|
150
|
+
puts " #{i.next}) #{failure.description}"
|
151
|
+
puts failure.details
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# Copied from RSpec
|
156
|
+
def pluralize(count, string)
|
157
|
+
"#{count} #{string}#{'s' unless count.to_f == 1}"
|
158
|
+
end
|
159
|
+
|
160
|
+
def print_example_counts(by_status_and_time)
|
161
|
+
example_count = by_status_and_time.map { |status, results| results.size }.inject(0) { |sum, ct| sum + ct }
|
162
|
+
failure_count = by_status_and_time["failed"] ? by_status_and_time["failed"].size : 0
|
163
|
+
pending_count = by_status_and_time["pending"] ? by_status_and_time["pending"].size : 0
|
164
|
+
process_failure_count = @deactivated_workers.size
|
165
|
+
|
166
|
+
# Copied from RSpec
|
167
|
+
summary = pluralize(example_count, "example")
|
168
|
+
summary << ", " << pluralize(failure_count, "failure")
|
169
|
+
summary << ", #{pending_count} pending" if pending_count > 0
|
170
|
+
summary << ", " << pluralize(process_failure_count, "failed proc") if process_failure_count > 0
|
171
|
+
puts summary
|
172
|
+
end
|
173
|
+
|
174
|
+
def print_failed_process_details
|
175
|
+
return if @deactivated_workers.empty?
|
176
|
+
puts
|
177
|
+
puts "Failed processes:"
|
178
|
+
@deactivated_workers.each do |worker|
|
179
|
+
puts " - #{worker.pid} (env #{worker.environment_number}) #{worker.deactivation_reason} on #{worker.current_file}"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def print_elapsed_time(seconds_elapsed)
|
184
|
+
minutes = seconds_elapsed.to_i / 60
|
185
|
+
seconds = seconds_elapsed % 60
|
186
|
+
m =
|
187
|
+
if minutes > 0
|
188
|
+
"%d minute%s" % [minutes, minutes == 1 ? '' : 's']
|
189
|
+
end
|
190
|
+
s =
|
191
|
+
if seconds > 0
|
192
|
+
"%.2f second%s" % [seconds, seconds == 1 ? '' : 's']
|
193
|
+
end
|
194
|
+
puts "Finished in #{[m, s].compact.join(", ")}"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require 'rspec/multiprocess_runner'
|
2
|
+
require 'rake'
|
3
|
+
require 'rake/tasklib'
|
4
|
+
require 'shellwords'
|
5
|
+
|
6
|
+
module RSpec::MultiprocessRunner
|
7
|
+
# Rake task to invoke `multispec`. Lots of it is copied from RSpec::Core::RakeTask.
|
8
|
+
#
|
9
|
+
# @see Rakefile
|
10
|
+
class RakeTask < ::Rake::TaskLib
|
11
|
+
include ::Rake::DSL if defined?(::Rake::DSL)
|
12
|
+
|
13
|
+
# Default path to the multirspec executable.
|
14
|
+
DEFAULT_MULTIRSPEC_PATH = File.expand_path('../../../../exe/multirspec', __FILE__)
|
15
|
+
|
16
|
+
# Name of task. Defaults to `:multispec`.
|
17
|
+
attr_accessor :name
|
18
|
+
|
19
|
+
# Files matching this pattern will be loaded.
|
20
|
+
# Defaults to `'**/*_spec.rb'`.
|
21
|
+
attr_accessor :pattern
|
22
|
+
|
23
|
+
# File search will be limited to these directories or specific files.
|
24
|
+
# Defaults to nil.
|
25
|
+
attr_accessor :files_or_directories
|
26
|
+
alias_method :files=, :files_or_directories=
|
27
|
+
alias_method :directories=, :files_or_directories=
|
28
|
+
|
29
|
+
# The number of workers to run. Defaults to 3.
|
30
|
+
attr_accessor :worker_count
|
31
|
+
|
32
|
+
# The maximum number of seconds to allow a single spec file to run
|
33
|
+
# before killing it. Defaults to 300.
|
34
|
+
attr_accessor :file_timeout_seconds
|
35
|
+
|
36
|
+
# Whether or not to fail Rake when an error occurs (typically when
|
37
|
+
# examples fail). Defaults to `true`.
|
38
|
+
attr_accessor :fail_on_error
|
39
|
+
|
40
|
+
# A message to print to stderr when there are failures.
|
41
|
+
attr_accessor :failure_message
|
42
|
+
|
43
|
+
# Use verbose output. If this is set to true, the task will print the
|
44
|
+
# executed spec command to stdout. Defaults to `true`.
|
45
|
+
attr_accessor :verbose
|
46
|
+
|
47
|
+
# Path to the multispec executable. Defaults to the absolute path to the
|
48
|
+
# rspec binary from the loaded rspec-core gem.
|
49
|
+
attr_accessor :multirspec_path
|
50
|
+
|
51
|
+
# Command line options to pass to the RSpec workers. Defaults to `nil`.
|
52
|
+
attr_accessor :rspec_opts
|
53
|
+
|
54
|
+
def initialize(*args, &task_block)
|
55
|
+
@name = args.shift || :multispec
|
56
|
+
@verbose = true
|
57
|
+
@fail_on_error = true
|
58
|
+
@multirspec_path = DEFAULT_MULTIRSPEC_PATH
|
59
|
+
|
60
|
+
define(args, &task_block)
|
61
|
+
end
|
62
|
+
|
63
|
+
# @private
|
64
|
+
def run_task(verbose)
|
65
|
+
command = spec_command
|
66
|
+
puts Shellwords.shelljoin(command) if verbose
|
67
|
+
|
68
|
+
return if system(*command)
|
69
|
+
puts failure_message if failure_message
|
70
|
+
|
71
|
+
return unless fail_on_error
|
72
|
+
$stderr.puts "#{command} failed" if verbose
|
73
|
+
exit $?.exitstatus
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# @private
|
79
|
+
def define(args, &task_block)
|
80
|
+
desc "Run RSpec code examples" unless ::Rake.application.last_comment
|
81
|
+
|
82
|
+
task name, *args do |_, task_args|
|
83
|
+
RakeFileUtils.__send__(:verbose, verbose) do
|
84
|
+
task_block.call(*[self, task_args].slice(0, task_block.arity)) if task_block
|
85
|
+
run_task verbose
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def spec_command
|
91
|
+
cmd_parts = []
|
92
|
+
cmd_parts << RUBY
|
93
|
+
cmd_parts << multirspec_path
|
94
|
+
if worker_count
|
95
|
+
cmd_parts << '--worker-count' << worker_count.to_s
|
96
|
+
end
|
97
|
+
if file_timeout_seconds
|
98
|
+
cmd_parts << '--file-timeout' << file_timeout_seconds.to_s
|
99
|
+
end
|
100
|
+
if pattern
|
101
|
+
cmd_parts << '--pattern' << pattern
|
102
|
+
end
|
103
|
+
if files_or_directories
|
104
|
+
cmd_parts.concat(files_or_directories)
|
105
|
+
end
|
106
|
+
if rspec_opts
|
107
|
+
cmd_parts << '--'
|
108
|
+
cmd_parts.concat(Shellwords.shellsplit rspec_opts)
|
109
|
+
end
|
110
|
+
|
111
|
+
cmd_parts
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'rspec/multiprocess_runner'
|
3
|
+
require 'rspec/core/formatters/base_text_formatter'
|
4
|
+
|
5
|
+
module RSpec::MultiprocessRunner
|
6
|
+
##
|
7
|
+
# RSpec formatter used by workers to communicate spec execution to the
|
8
|
+
# coordinator.
|
9
|
+
#
|
10
|
+
# @private
|
11
|
+
class ReportingFormatter < RSpec::Core::Formatters::BaseTextFormatter
|
12
|
+
class << self
|
13
|
+
# The worker to which to report spec status. This has to be a class-level
|
14
|
+
# attribute because you can't access the formatter instance used by
|
15
|
+
# RSpec's runner.
|
16
|
+
attr_accessor :worker
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(*ignored_args)
|
20
|
+
super(StringIO.new)
|
21
|
+
@current_example_groups = []
|
22
|
+
end
|
23
|
+
|
24
|
+
def example_group_started(example_group)
|
25
|
+
super(example_group)
|
26
|
+
|
27
|
+
@current_example_groups.push(example_group.description.strip)
|
28
|
+
end
|
29
|
+
|
30
|
+
def example_group_finished(example_group)
|
31
|
+
@current_example_groups.pop
|
32
|
+
end
|
33
|
+
|
34
|
+
def example_passed(example)
|
35
|
+
super(example)
|
36
|
+
report_example_result(:passed, current_example_description(example))
|
37
|
+
end
|
38
|
+
|
39
|
+
def example_pending(example)
|
40
|
+
super(example)
|
41
|
+
details = capture_output { dump_pending }
|
42
|
+
pending_examples.clear
|
43
|
+
report_example_result(:pending, current_example_description(example), details)
|
44
|
+
end
|
45
|
+
|
46
|
+
def example_failed(example)
|
47
|
+
super(example)
|
48
|
+
details = capture_output {
|
49
|
+
dump_failure_info(example)
|
50
|
+
dump_backtrace(example)
|
51
|
+
}
|
52
|
+
report_example_result(:failed, current_example_description(example), details)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def capture_output
|
58
|
+
output.string = ""
|
59
|
+
yield
|
60
|
+
captured = output.string
|
61
|
+
output.string = ""
|
62
|
+
captured
|
63
|
+
end
|
64
|
+
|
65
|
+
def worker
|
66
|
+
self.class.worker
|
67
|
+
end
|
68
|
+
|
69
|
+
def current_example_description(example)
|
70
|
+
(@current_example_groups + [example.description.strip]).join('·')
|
71
|
+
end
|
72
|
+
|
73
|
+
def report_example_result(example_status, description, details=nil)
|
74
|
+
worker.report_example_result(example_status, description, details)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,262 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require "rspec/multiprocess_runner"
|
3
|
+
require "rspec/multiprocess_runner/reporting_formatter"
|
4
|
+
|
5
|
+
require "rspec/core"
|
6
|
+
require "rspec/core/runner"
|
7
|
+
|
8
|
+
require "socket"
|
9
|
+
require "json"
|
10
|
+
require "timeout"
|
11
|
+
|
12
|
+
module RSpec::MultiprocessRunner
|
13
|
+
##
|
14
|
+
# This object has several roles:
|
15
|
+
# - It forks the worker process
|
16
|
+
# - In the coordinator process, it is used to send messages to the worker and
|
17
|
+
# track the worker's status, completed specs, and example results.
|
18
|
+
# - In the worker process, it is used to send messages to the coordinator and
|
19
|
+
# actually run specs.
|
20
|
+
#
|
21
|
+
# @private
|
22
|
+
class Worker
|
23
|
+
attr_reader :pid, :environment_number, :example_results, :current_file
|
24
|
+
attr_accessor :deactivation_reason
|
25
|
+
|
26
|
+
COMMAND_QUIT = "quit"
|
27
|
+
COMMAND_RUN_FILE = "run_file"
|
28
|
+
|
29
|
+
STATUS_EXAMPLE_COMPLETE = "example_complete"
|
30
|
+
STATUS_RUN_COMPLETE = "run_complete"
|
31
|
+
|
32
|
+
def initialize(environment_number, file_timeout, rspec_arguments=[])
|
33
|
+
@environment_number = environment_number
|
34
|
+
@worker_socket, @coordinator_socket = Socket.pair(:UNIX, :STREAM)
|
35
|
+
@rspec_arguments = rspec_arguments + ["--format", ReportingFormatter.to_s]
|
36
|
+
@file_timeout = file_timeout
|
37
|
+
@example_results = []
|
38
|
+
|
39
|
+
# Use a single configuration and world across all individual runs
|
40
|
+
# This will not be necessary to do manually in RSpec 3 — it does not
|
41
|
+
# reset the globals after each run.
|
42
|
+
@rspec_configuration = RSpec.configuration
|
43
|
+
@rspec_world = RSpec.world
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# Workers can be found in the coordinator process by their coordinator socket.
|
48
|
+
def ==(other)
|
49
|
+
case other
|
50
|
+
when Socket
|
51
|
+
other == @coordinator_socket
|
52
|
+
else
|
53
|
+
super
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
##
|
58
|
+
# Forks the worker process. In the parent, returns the PID.
|
59
|
+
def start
|
60
|
+
pid = fork
|
61
|
+
if pid
|
62
|
+
@worker_socket.close
|
63
|
+
@pid = pid
|
64
|
+
else
|
65
|
+
@coordinator_socket.close
|
66
|
+
@pid = Process.pid
|
67
|
+
ENV["TEST_ENV_NUMBER"] = environment_number.to_s
|
68
|
+
set_process_name
|
69
|
+
run_loop
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def socket
|
74
|
+
if self.pid == Process.pid
|
75
|
+
@worker_socket
|
76
|
+
else
|
77
|
+
@coordinator_socket
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
###### COORDINATOR METHODS
|
82
|
+
## These are methods that the coordinator process calls on its copy of
|
83
|
+
## the workers.
|
84
|
+
|
85
|
+
public
|
86
|
+
|
87
|
+
def quit
|
88
|
+
send_message_to_worker(command: COMMAND_QUIT)
|
89
|
+
end
|
90
|
+
|
91
|
+
def wait_until_quit
|
92
|
+
Process.wait(self.pid)
|
93
|
+
end
|
94
|
+
|
95
|
+
def run_file(filename)
|
96
|
+
send_message_to_worker(command: COMMAND_RUN_FILE, filename: filename)
|
97
|
+
@current_file = filename
|
98
|
+
@current_file_started_at = Time.now
|
99
|
+
end
|
100
|
+
|
101
|
+
def working?
|
102
|
+
@current_file
|
103
|
+
end
|
104
|
+
|
105
|
+
def stalled?
|
106
|
+
working? && (Time.now - @current_file_started_at > @file_timeout)
|
107
|
+
end
|
108
|
+
|
109
|
+
def reap
|
110
|
+
begin
|
111
|
+
Timeout.timeout(4) do
|
112
|
+
$stderr.puts "Reaping troubled process #{environment_number} (#{pid}; #{@current_file}) with QUIT"
|
113
|
+
Process.kill(:QUIT, pid)
|
114
|
+
Process.wait(pid)
|
115
|
+
end
|
116
|
+
rescue Timeout::Error
|
117
|
+
$stderr.puts "Reaping troubled process #{environment_number} (#{pid}) with KILL"
|
118
|
+
Process.kill(:KILL, pid)
|
119
|
+
Process.wait(pid)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def receive_and_act_on_message_from_worker
|
124
|
+
act_on_message_from_worker(receive_message_from_worker)
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
def receive_message_from_worker
|
130
|
+
receive_message(@coordinator_socket)
|
131
|
+
end
|
132
|
+
|
133
|
+
def act_on_message_from_worker(message_hash)
|
134
|
+
return :dead unless message_hash # ignore EOF
|
135
|
+
case message_hash["status"]
|
136
|
+
when STATUS_RUN_COMPLETE
|
137
|
+
@current_file = nil
|
138
|
+
@current_file_started_at = nil
|
139
|
+
when STATUS_EXAMPLE_COMPLETE
|
140
|
+
example_results << ExampleResult.new(message_hash)
|
141
|
+
suffix =
|
142
|
+
case message_hash["example_status"]
|
143
|
+
when "failed"
|
144
|
+
" - FAILED"
|
145
|
+
when "pending"
|
146
|
+
" - pending"
|
147
|
+
end
|
148
|
+
if message_hash["details"]
|
149
|
+
suffix += "\n#{message_hash["details"]}"
|
150
|
+
end
|
151
|
+
$stdout.puts "#{environment_number} (#{pid}): #{message_hash["description"]}#{suffix}"
|
152
|
+
else
|
153
|
+
$stderr.puts "Received unsupported status #{message_hash["status"].inspect} in worker #{pid}"
|
154
|
+
end
|
155
|
+
return :alive
|
156
|
+
end
|
157
|
+
|
158
|
+
def send_message_to_worker(message_hash)
|
159
|
+
send_message(@coordinator_socket, message_hash)
|
160
|
+
end
|
161
|
+
|
162
|
+
###### WORKER METHODS
|
163
|
+
## These are methods that the worker process calls on the copy of this
|
164
|
+
## object that lives in the fork.
|
165
|
+
|
166
|
+
public
|
167
|
+
|
168
|
+
def report_example_result(example_status, description, details)
|
169
|
+
send_message_to_coordinator(
|
170
|
+
status: STATUS_EXAMPLE_COMPLETE,
|
171
|
+
example_status: example_status,
|
172
|
+
description: description,
|
173
|
+
details: details
|
174
|
+
)
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def set_process_name
|
180
|
+
name = "RSpec::MultiprocessRunner::Worker #{environment_number}"
|
181
|
+
status = current_file ? "running #{current_file}" : "idle"
|
182
|
+
$0 = "#{name} #{status}"
|
183
|
+
end
|
184
|
+
|
185
|
+
def run_loop
|
186
|
+
loop do
|
187
|
+
select_result = IO.select([@worker_socket], nil, nil, 1)
|
188
|
+
if select_result
|
189
|
+
readables, _, _ = select_result
|
190
|
+
act_on_message_from_coordinator(
|
191
|
+
receive_message_from_coordinator(readables.first)
|
192
|
+
)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def receive_message_from_coordinator(socket)
|
198
|
+
receive_message(socket)
|
199
|
+
end
|
200
|
+
|
201
|
+
def send_message_to_coordinator(message_hash)
|
202
|
+
send_message(@worker_socket, message_hash)
|
203
|
+
end
|
204
|
+
|
205
|
+
def act_on_message_from_coordinator(message_hash)
|
206
|
+
return unless message_hash # ignore EOF
|
207
|
+
case message_hash["command"]
|
208
|
+
when "quit"
|
209
|
+
exit
|
210
|
+
when "run_file"
|
211
|
+
execute_spec(message_hash["filename"])
|
212
|
+
else
|
213
|
+
$stderr.puts "Received unsupported command #{message_hash["command"].inspect} in worker #{pid}"
|
214
|
+
end
|
215
|
+
set_process_name
|
216
|
+
end
|
217
|
+
|
218
|
+
def execute_spec(spec_file)
|
219
|
+
@current_file = spec_file
|
220
|
+
set_process_name
|
221
|
+
|
222
|
+
RSpec.configuration = @rspec_configuration
|
223
|
+
RSpec.world = @rspec_world
|
224
|
+
# If we don't do this, every previous spec is run every time run is called
|
225
|
+
RSpec.world.example_groups.clear
|
226
|
+
|
227
|
+
ReportingFormatter.worker = self
|
228
|
+
RSpec::Core::Runner.run(@rspec_arguments + [spec_file])
|
229
|
+
send_message_to_coordinator(status: STATUS_RUN_COMPLETE, filename: spec_file)
|
230
|
+
ensure
|
231
|
+
@current_file = nil
|
232
|
+
end
|
233
|
+
|
234
|
+
###### UTILITY FUNCTIONS
|
235
|
+
## Methods that used by both the coordinator and worker processes.
|
236
|
+
|
237
|
+
private
|
238
|
+
|
239
|
+
def receive_message(socket)
|
240
|
+
message_json = socket.gets
|
241
|
+
if message_json
|
242
|
+
JSON.parse(message_json)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def send_message(socket, message_hash)
|
247
|
+
socket.puts(message_hash.to_json)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
# @private
|
252
|
+
class ExampleResult
|
253
|
+
attr_reader :status, :description, :details, :time_finished
|
254
|
+
|
255
|
+
def initialize(example_complete_message)
|
256
|
+
@status = example_complete_message["example_status"]
|
257
|
+
@description = example_complete_message["description"]
|
258
|
+
@details = example_complete_message["details"]
|
259
|
+
@time_finished = Time.now
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'rspec/multiprocess_runner/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "rspec-multiprocess_runner"
|
8
|
+
spec.version = RSpec::MultiprocessRunner::VERSION
|
9
|
+
spec.authors = ["Rhett Sutphin"]
|
10
|
+
spec.email = ["rhett@detailedbalance.net"]
|
11
|
+
|
12
|
+
spec.summary = %q{A runner for RSpec 2 that uses multiple processes to execute specs in parallel}
|
13
|
+
spec.homepage = "https://github.com/cdd/rspec-multiprocess_runner"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|manual_test_specs)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "rspec-core", "~> 2.0", "< 2.99.0"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
24
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rspec-multiprocess_runner
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Rhett Sutphin
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2015-12-15 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rspec-core
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '2.0'
|
22
|
+
- - <
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: 2.99.0
|
25
|
+
type: :runtime
|
26
|
+
prerelease: false
|
27
|
+
version_requirements: !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ~>
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '2.0'
|
33
|
+
- - <
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: 2.99.0
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: bundler
|
38
|
+
requirement: !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ~>
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '1.10'
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: !ruby/object:Gem::Requirement
|
47
|
+
none: false
|
48
|
+
requirements:
|
49
|
+
- - ~>
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: '1.10'
|
52
|
+
- !ruby/object:Gem::Dependency
|
53
|
+
name: rake
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ~>
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '10.0'
|
60
|
+
type: :development
|
61
|
+
prerelease: false
|
62
|
+
version_requirements: !ruby/object:Gem::Requirement
|
63
|
+
none: false
|
64
|
+
requirements:
|
65
|
+
- - ~>
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '10.0'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: rspec
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
none: false
|
72
|
+
requirements:
|
73
|
+
- - ! '>='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
none: false
|
80
|
+
requirements:
|
81
|
+
- - ! '>='
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
description:
|
85
|
+
email:
|
86
|
+
- rhett@detailedbalance.net
|
87
|
+
executables:
|
88
|
+
- multirspec
|
89
|
+
extensions: []
|
90
|
+
extra_rdoc_files: []
|
91
|
+
files:
|
92
|
+
- .gitignore
|
93
|
+
- .rspec
|
94
|
+
- .travis.yml
|
95
|
+
- CHANGELOG.md
|
96
|
+
- Gemfile
|
97
|
+
- LICENSE.txt
|
98
|
+
- README.md
|
99
|
+
- Rakefile
|
100
|
+
- TODO.md
|
101
|
+
- bin/console
|
102
|
+
- bin/setup
|
103
|
+
- exe/multirspec
|
104
|
+
- lib/rspec/multiprocess_runner.rb
|
105
|
+
- lib/rspec/multiprocess_runner/command_line_options.rb
|
106
|
+
- lib/rspec/multiprocess_runner/coordinator.rb
|
107
|
+
- lib/rspec/multiprocess_runner/rake_task.rb
|
108
|
+
- lib/rspec/multiprocess_runner/reporting_formatter.rb
|
109
|
+
- lib/rspec/multiprocess_runner/version.rb
|
110
|
+
- lib/rspec/multiprocess_runner/worker.rb
|
111
|
+
- rspec-multiprocess_runner.gemspec
|
112
|
+
homepage: https://github.com/cdd/rspec-multiprocess_runner
|
113
|
+
licenses:
|
114
|
+
- MIT
|
115
|
+
post_install_message:
|
116
|
+
rdoc_options: []
|
117
|
+
require_paths:
|
118
|
+
- lib
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
120
|
+
none: false
|
121
|
+
requirements:
|
122
|
+
- - ! '>='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
segments:
|
126
|
+
- 0
|
127
|
+
hash: -1609184745420465239
|
128
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ! '>='
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
segments:
|
135
|
+
- 0
|
136
|
+
hash: -1609184745420465239
|
137
|
+
requirements: []
|
138
|
+
rubyforge_project:
|
139
|
+
rubygems_version: 1.8.25
|
140
|
+
signing_key:
|
141
|
+
specification_version: 3
|
142
|
+
summary: A runner for RSpec 2 that uses multiple processes to execute specs in parallel
|
143
|
+
test_files: []
|