paraspec 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 709786de26ffdac4d238bb0a1c531a654b7183386b0f85f0c523e74958f66f33
4
+ data.tar.gz: aa00e1665689abead274c8259a1ab55e1fc049b9e16029723e8c19269248d884
5
+ SHA512:
6
+ metadata.gz: 70f1e59627cc4d63ae6653b0f945831d9d7abac73cc998c76b1f8c36100e5b1a1f4578731c091904086a159be9ff4d3a16f24702f7847eb46cffac2c39947d4b
7
+ data.tar.gz: 06d5f5746820455ebe7fc0d440cc8f335a8c8ca6177a5d1a36afc0691d0854a9b2df31eb27b87726470be35139e7fdece0d2315e3bbbec07966ba4e7a0ae7801
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+ =====================
3
+
4
+ Copyright (c) 2018 Oleg Pudeyev
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # Paraspec
2
+
3
+ Paraspec is a parallel RSpec test runner.
4
+
5
+ It is built with a producer/consumer architecture. A master process loads
6
+ the entire test suite and sets up a queue to feed the tests to the workers.
7
+ Each worker requests a test from the master, runs it, reports the results
8
+ back to the master and requests the next test until there are no more left.
9
+
10
+ This producer/consumer architecture enables a number of features:
11
+
12
+ 1. The worker load is naturally balanced. If a worker happens to come across
13
+ a slow test, the other workers keep chugging away at faster tests.
14
+ 2. Tests defined in a single file can be executed by multiple workers,
15
+ since paraspec operates on a test by test basis and not on a file by file basis.
16
+ 3. Standard output and error streams can be[*] captured and grouped on a
17
+ test by test basis, avoiding interleaving output of different tests together.
18
+ This output capture can be performed for output generated by C extensions
19
+ as well as plain Ruby code.
20
+ 4. Test results are seamlessly integrated by the master, such that
21
+ a parallel run produces a single progress bar with Fuubar across all workers.
22
+
23
+ [*] This feature is not yet implemented.
24
+
25
+ ## Usage
26
+
27
+ For a test suite with no external dependencies, using paraspec is
28
+ trivially easy. Just run:
29
+
30
+ paraspec
31
+
32
+ To specify concurrency manually:
33
+
34
+ paraspec -c 4
35
+
36
+ To pass options to rspec, for example to filter examples to run:
37
+
38
+ paraspec -- -e 'My test'
39
+ paraspec -- spec/my_spec.rb
40
+
41
+ For a test suite with external dependencies, paraspec sets the
42
+ `TEST_ENV_NUMBER` environment variable, like
43
+ [parallel_tests](https://github.com/grosser/parallel_tests) does.
44
+ The test suite can then configure itself differently in each worker.
45
+
46
+ By default the master process doesn't have `TEST_ENV_NUMBER` set.
47
+ To have that set to `1` use `--master-is-1` option to paraspec:
48
+
49
+ paraspec --master-is-1
50
+
51
+ ## Advanced Usage
52
+
53
+ ### Formatters
54
+
55
+ Paraspec works with any RSpec formatter, and supports multiple formatters
56
+ just like RSpec does. If your test suite is big enough for parallel execution
57
+ to make a difference, chances are the default progress and documentation
58
+ formatters aren't too useful for dealing with its output.
59
+
60
+ I recommend [Fuubar](https://github.com/thekompanee/fuubar) and
61
+ [RSpec JUnit Formatter](https://github.com/sj26/rspec_junit_formatter)
62
+ configured at the same time. Fuubar produces a very nice looking progress bar
63
+ plus it prints failures and exceptions to the terminal as soon as they
64
+ occur. JUnit output, passed through a JUnit XML to HTML converter like
65
+ [junit2html](https://gitlab.com/inorton/junit2html), is much handier
66
+ than going through terminal output when a run produces 100 or 1000
67
+ failing tests.
68
+
69
+ ### Debugging
70
+
71
+ Paraspec offers several debugging aids. The first one is the terminal option:
72
+
73
+ paraspec -T
74
+
75
+ This option makes paraspec stay attached to the terminal it was
76
+ launched in, making it possible to insert e.g. `byebug` calls in supervisor,
77
+ master or worker code as well as anywhere in the test suite being executed
78
+ and have byebug work. Setting this option also removes internal timeouts
79
+ on interprocess waits and sets concurrency to 1, however concurrency
80
+ can be reset with a subsequent `-c` option:
81
+
82
+ paraspec -T -c 2
83
+
84
+ Paraspec can produce copious debugging output in several facilities.
85
+ The debugging output is turned on with `-d`/`--debug` option:
86
+
87
+ paraspec -d state # supervisor, master, worker state transitions
88
+ paraspec -d ipc # IPC requests and responses
89
+ paraspec -d perf # timing & performance information
90
+
91
+ ## Bugs & Patches
92
+
93
+ Please report via issues and pull requests.
94
+
95
+ ## License
96
+
97
+ MIT
data/bin/paraspec ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $: << File.join(File.dirname(__FILE__), '..', 'lib')
4
+
5
+ require 'paraspec'
6
+ require 'optparse'
7
+
8
+ options = {}
9
+ OptionParser.new do |opts|
10
+ opts.banner = 'Usage: paraspec [options] [-- [rspec-options]...]'
11
+
12
+ opts.on('-c', '--concurrency=NUM', 'Number of concurrent workers to use') do |v|
13
+ if v.to_i == 0
14
+ raise "Invalid concurrency value: #{v}"
15
+ end
16
+ options[:concurrency] = v.to_i
17
+ end
18
+
19
+ opts.on('-d', '--debug=SUBSYSTEM', 'Output debugging information for SUBSYSTEM') do |v|
20
+ options[:"debug_#{v}"] = true
21
+ options[:debug] = true
22
+ end
23
+
24
+ opts.on('-T', '--terminal', 'Retain controlling terminal (debug only)') do |v|
25
+ options[:terminal] = v
26
+ options[:concurrency] = 1
27
+ end
28
+
29
+ opts.on('--master-is-1', 'Set TEST_ENV_NUMBER=1 in master process') do |v|
30
+ options[:master_is_1] = v
31
+ end
32
+ end.parse!
33
+
34
+ if options[:debug]
35
+ Paraspec.logger.level = Logger::DEBUG
36
+ end
37
+
38
+ =begin
39
+ files = if ARGV.length > 0
40
+ ARGV
41
+ else
42
+ ['spec']
43
+ end
44
+ RSpec.configuration.files_or_directories_to_run = files
45
+ =end
46
+
47
+ %w(ipc state perf).each do |subsystem|
48
+ if options.delete(:"debug_#{subsystem}")
49
+ Paraspec.logger.send("log_#{subsystem}=", true)
50
+ end
51
+ end
52
+ supervisor = Paraspec::Supervisor.new(options)
53
+ supervisor.run
data/lib/paraspec.rb ADDED
@@ -0,0 +1,21 @@
1
+ require 'paraspec/rspec_patches'
2
+ require 'paraspec/version'
3
+ require 'paraspec/logger'
4
+ require 'paraspec/ipc'
5
+ require 'paraspec/drb_helpers'
6
+ require 'paraspec/process_helpers'
7
+ require 'paraspec/supervisor'
8
+ require 'paraspec/master'
9
+ require 'paraspec/worker'
10
+ require 'paraspec/master_runner'
11
+ require 'paraspec/worker_runner'
12
+ require 'paraspec/worker_formatter'
13
+ require 'paraspec/rspec_facade'
14
+
15
+ module Paraspec
16
+ autoload :HttpClient, 'paraspec/http_client'
17
+ autoload :HttpServer, 'paraspec/http_server'
18
+ autoload :MsgpackClient, 'paraspec/msgpack_client'
19
+ autoload :MsgpackServer, 'paraspec/msgpack_server'
20
+ autoload :MsgpackHelpers, 'paraspec/msgpack_helpers'
21
+ end
@@ -0,0 +1,65 @@
1
+ require 'timeout'
2
+
3
+ module Paraspec
4
+ module DrbHelpers
5
+ WAIT_TIME = 500
6
+
7
+ =begin
8
+ class TimeoutWrapper < BasicObject
9
+ def initialize(target, timeout)
10
+ @target, @timeout = target, timeout
11
+ end
12
+
13
+ def method_missing(m, *args)
14
+ ::Timeout.timeout(@timeout) do
15
+ @target.send(m, *args)
16
+ end
17
+ end
18
+
19
+ def respond_to?(m, *args)
20
+ super(m, *args) || @target.respond_to?(m, *args)
21
+ end
22
+ end
23
+
24
+ # Connects to a DRb service and waits for the connection to start working.
25
+ #
26
+ # Interestingly, even when the remote end is up and running
27
+ # talking to it may fail the first time (or few times?)?
28
+ # Supervisor is able to invoke methods on master and subsequently
29
+ # when a worker connects to the master the DRb calls from worker fail.
30
+ # No idea why this is.
31
+ # Work around this by pinging and retrying each DRb connection
32
+ # prior to using it for real work.
33
+ #
34
+ # It appears that any DRb operation can also hang while producing
35
+ # no exceptions or output of any sort.
36
+ private def drb_connect(uri, timeout: true)
37
+ start_time = Time.now
38
+ Paraspec.logger.debug("#{ident} Connecting to DRb")
39
+ remote = TimeoutWrapper.new(DRbObject.new_with_uri(uri), 2)
40
+ Paraspec.logger.debug("#{ident} Waiting for DRb")
41
+ begin
42
+ # Assumes remote has a ping method
43
+ remote.ping
44
+ rescue DRb::DRbConnError, TypeError
45
+ raise if timeout && Time.now - start_time > WAIT_TIME
46
+ sleep 0.5
47
+ Paraspec.logger.debug("#{ident} Retrying DRb ping")
48
+ retry
49
+ rescue Timeout::Error
50
+ raise if timeout && Time.now - start_time > WAIT_TIME
51
+ Paraspec.logger.debug("#{ident} Reconnecting to DRb")
52
+ remote = TimeoutWrapper.new(DRbObject.new_with_uri(uri), 2)
53
+ retry
54
+ end
55
+ remote
56
+ end
57
+ =end
58
+
59
+ def master_client
60
+ # TODO pass terminal option
61
+ @master_client ||= MsgpackClient.new
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,43 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module Paraspec
5
+ class HttpClient
6
+ def initialize(options={})
7
+ @terminal = options[:terminal]
8
+
9
+ @client = Faraday.new(url: "http://localhost:#{Paraspec::MASTER_APP_PORT}") do |client|
10
+ client.adapter :net_http
11
+ if @terminal
12
+ client.options.timeout = 100000
13
+ else
14
+ client.options.timeout = DrbHelpers::WAIT_TIME
15
+ end
16
+ end
17
+ end
18
+
19
+ def request(action, payload=nil)
20
+ url = '/' + action
21
+ start_time = Time.now
22
+ begin
23
+ resp = @client.post(url) do |req|
24
+ if payload
25
+ req.headers['content-type'] = 'application/json'
26
+ req.body = payload.to_json
27
+ end
28
+ end
29
+ if resp.status != 200
30
+ raise "Request failed: #{url} (#{resp.status})"
31
+ end
32
+ JSON.parse(resp.body)
33
+ rescue Faraday::ConnectionFailed
34
+ if !@terminal && Time.now - start_time > DrbHelpers::WAIT_TIME
35
+ raise
36
+ else
37
+ sleep 0.1
38
+ retry
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,24 @@
1
+ require 'sinatra'
2
+
3
+ module Paraspec
4
+ class HttpServer < Sinatra::Base
5
+ post '/:action' do
6
+ action = params[:action]
7
+ body = request.body.read
8
+ if body.empty?
9
+ args = []
10
+ else
11
+ payload = JSON.parse(body)
12
+ payload = IpcHash.new.merge(payload)
13
+ args = [payload]
14
+ end
15
+
16
+ master = self.class.settings.master
17
+ action = action.gsub('-', '_')
18
+ result = master.send(action, *args)
19
+
20
+ content_type 'application/json'
21
+ (result || {}).to_json
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ require 'hashie'
2
+
3
+ module Paraspec
4
+ #SUPERVISOR_DRB_URI = "druby://localhost:6030"
5
+ MASTER_DRB_URI = "druby://localhost:6031"
6
+ MASTER_APP_PORT = 6031
7
+
8
+ class IpcHash < Hash
9
+ include Hashie::Extensions::IndifferentAccess
10
+ end
11
+ end
@@ -0,0 +1,44 @@
1
+ require 'logger'
2
+
3
+ module Paraspec
4
+ class LoggerWrapper
5
+ def initialize(logger)
6
+ @logger = logger
7
+ end
8
+
9
+ def method_missing(m, *args)
10
+ @logger.send(m, *args)
11
+ end
12
+
13
+ %w(ipc state perf).each do |subsystem|
14
+ define_method "log_#{subsystem}=" do |v|
15
+ @subsystems ||= {}
16
+ @subsystems[subsystem] = v
17
+ end
18
+
19
+ define_method "log_#{subsystem}?" do
20
+ @subsystems && @subsystems[subsystem] or false
21
+ end
22
+
23
+ define_method "debug_#{subsystem}" do |*args|
24
+ if send("log_#{subsystem}?")
25
+ msg = "#{ident || '[?]'} [#{subsystem}] #{args.shift}"
26
+ debug(msg, *args)
27
+ end
28
+ end
29
+ end
30
+
31
+ attr_accessor :ident
32
+ end
33
+
34
+ class << self
35
+ attr_reader :logger
36
+
37
+ def logger=(logger)
38
+ @logger = LoggerWrapper.new(logger)
39
+ end
40
+ end
41
+
42
+ self.logger = Logger.new(STDERR)
43
+ self.logger.level = Logger::WARN
44
+ end
@@ -0,0 +1,219 @@
1
+ module Paraspec
2
+ # The master process has three responsibilities:
3
+ # 1. Load all tests and abort the run if there are errors outside of
4
+ # examples.
5
+ # 2. Maintain the queue of tests to feed the workers. The master
6
+ # process also synchronizes access to this queue.
7
+ # 3. Aggregate test reports from the workers and present them to
8
+ # the outside world in a coherent fashion. The latter means
9
+ # that numbers presented are for the entire suite, not for parts
10
+ # of it as executed by any single worker, and that output from a
11
+ # single test execution is not broken up by output from other test
12
+ # executions.
13
+ class Master
14
+ def initialize(options={})
15
+ @supervisor_pipe = options[:supervisor_pipe]
16
+ #RSpec.configuration.formatter = 'progress'
17
+ if RSpec.world.example_groups.count > 0
18
+ raise 'Example groups loaded too early/spilled across processes'
19
+ end
20
+
21
+ rspec_options = RSpec::Core::ConfigurationOptions.new(ARGV)
22
+ @non_example_exception_count = 0
23
+ begin
24
+ # This can fail if for example a nonexistent formatter is referenced
25
+ rspec_options.configure(RSpec.configuration)
26
+ rescue Exception => e
27
+ puts "#{e.class}: #{e}"
28
+ puts e.backtrace.join("\n")
29
+ # TODO and report this situation as a configuration problem
30
+ # and not a test suite problem
31
+ @non_example_exception_count = 1
32
+ end
33
+
34
+ =begin
35
+ if RSpec.configuration.files_to_run.empty?
36
+ RSpec.configuration.send(:remove_instance_variable, '@files_to_run')
37
+ RSpec.configuration.files_or_directories_to_run = RSpec.configuration.default_path
38
+ RSpec.configuration.files_to_run
39
+ p ['aa1',RSpec.configuration.files_to_run]
40
+ rspec_options.configure(RSpec.configuration)
41
+ RSpec.configuration.load_spec_files
42
+ end
43
+ =end
44
+
45
+ # It seems that load_spec_files sometimes rescues exceptions outside of
46
+ # examples and sometimes does not, handle it both ways
47
+ if @non_example_exception_count == 0
48
+ begin
49
+ RSpec.configuration.load_spec_files
50
+ rescue Exception => e
51
+ puts "#{e.class}: #{e}"
52
+ puts e.backtrace.join("\n")
53
+ @non_example_exception_count = 1
54
+ end
55
+ end
56
+ if @non_example_exception_count == 0
57
+ @non_example_exception_count = RSpec.world.reporter.non_example_exception_count
58
+ end
59
+ @queue = []
60
+ if @non_example_exception_count == 0
61
+ @queue += RSpecFacade.all_example_groups
62
+ puts "#{@queue.length} example groups queued"
63
+ else
64
+ puts "#{@non_example_exception_count} errors outside of examples, aborting"
65
+ end
66
+ end
67
+
68
+ attr :non_example_exception_count
69
+
70
+ def run
71
+ Thread.new do
72
+ #HttpServer.set(:master, self).run!(port: 6031)
73
+ MsgpackServer.new(self).run
74
+ end
75
+ until @stop
76
+ sleep 1
77
+ end
78
+ Paraspec.logger.debug_state("Exiting")
79
+ end
80
+
81
+ def ping
82
+ true
83
+ end
84
+
85
+ def stop
86
+ Paraspec.logger.debug_state("Stopping")
87
+ @stop = true
88
+ end
89
+
90
+ def stop?
91
+ @stop
92
+ end
93
+
94
+ def suite_ok?
95
+ RSpec.configuration.reporter.send(:instance_variable_get,'@non_example_exception_count') == 0
96
+ end
97
+
98
+ def example_count
99
+ RSpecFacade.all_examples.count
100
+ end
101
+
102
+ def get_spec
103
+ while true
104
+ example_group = @queue.shift
105
+ return nil if example_group.nil?
106
+
107
+ # TODO I am still not 100% on what should be filtered and pruned where,
108
+ # but we shouldn't be returning a specification here unless
109
+ # there are tests in it that a worker will run
110
+ pruned_examples = RSpec.configuration.filter_manager.prune(example_group.examples)
111
+ next if pruned_examples.empty?
112
+
113
+ m = example_group.metadata
114
+ return {
115
+ file_path: m[:file_path],
116
+ scoped_id: m[:scoped_id],
117
+ }
118
+ end
119
+ end
120
+
121
+ def example_passed(payload)
122
+ spec = payload[:spec]
123
+ # ExecutionResult
124
+ result = payload['result']
125
+ do_example_passed(spec, result)
126
+ end
127
+
128
+ def notify_example_started(payload)
129
+ example = find_example(payload[:spec])
130
+ reporter.example_started(example)
131
+ end
132
+
133
+ def do_example_passed(spec, execution_result)
134
+ #return
135
+ example = find_example(spec)
136
+ # Can write to example here
137
+ example.metadata[:execution_result] = execution_result
138
+ status = execution_result.status
139
+ m = "example_#{status}"
140
+ reporter.send(m, example)
141
+ nil
142
+ end
143
+
144
+ def find_example(spec)
145
+ if spec.nil?
146
+ #byebug
147
+ raise ArgumentError, 'Nil spec'
148
+ end
149
+ example = (RSpecFacade.all_example_groups + RSpecFacade.all_examples).detect do |example|
150
+ example.metadata[:file_path] == spec[:file_path] &&
151
+ example.metadata[:scoped_id] == spec[:scoped_id]
152
+ end
153
+ unless example
154
+ puts "Not found: #{spec[:file_path]}[#{spec[:scoped_id]}]"
155
+ #byebug
156
+ raise "Not found: #{spec[:file_path]}[#{spec[:scoped_id]}]"
157
+ end
158
+ example
159
+ end
160
+
161
+ def reporter
162
+ @reporter ||= RSpec.configuration.reporter
163
+ end
164
+
165
+ def suite_started
166
+ @start_time = Time.now
167
+
168
+ notification = RSpec::Core::Notifications::StartNotification.new(
169
+ RSpecFacade.all_examples.count, 0
170
+ )
171
+ RSpec.configuration.formatters.each do |f|
172
+ if f.respond_to?(:start)
173
+ f.start(notification)
174
+ end
175
+ end
176
+
177
+ true
178
+ end
179
+
180
+ def dump_summary
181
+ reporter.stop
182
+
183
+ all_examples = RSpecFacade.all_examples
184
+ notification = RSpec::Core::Notifications::SummaryNotification.new(
185
+ @start_time ? Time.now-@start_time : 0,
186
+ all_examples,
187
+ all_examples.select { |e| e.execution_result.status == :failed },
188
+ all_examples.select { |e| e.execution_result.status == :pending },
189
+ 0,
190
+ non_example_exception_count,
191
+ )
192
+ examples_notification = RSpec::Core::Notifications::ExamplesNotification.new(reporter)
193
+ RSpec.configuration.formatters.each do |f|
194
+ if f.respond_to?(:dump_summary)
195
+ f.dump_summary(notification)
196
+ end
197
+ if f.respond_to?(:dump_failures)
198
+ f.dump_failures(examples_notification)
199
+ end
200
+ if f.respond_to?(:dump_pending)
201
+ f.dump_pending(examples_notification)
202
+ end
203
+ end
204
+ nil
205
+ end
206
+
207
+ def status
208
+ if RSpecFacade.all_examples.any? { |example| example.execution_result.status == :failed }
209
+ 1
210
+ else
211
+ 0
212
+ end
213
+ end
214
+
215
+ def ident
216
+ "[m]"
217
+ end
218
+ end
219
+ end