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 ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/tmp
10
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.2.3
5
+ before_install: gem install bundler -v 1.10.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # 0.1.0
2
+
3
+ * Initial version
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rspec-multiprocess_runner.gemspec
4
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
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,5 @@
1
+ module RSpec
2
+ module MultiprocessRunner
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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,6 @@
1
+ require "rspec/multiprocess_runner/version"
2
+
3
+ module RSpec
4
+ module MultiprocessRunner
5
+ end
6
+ 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: []