paraspec 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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