rspec-conductor 1.0.0
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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +58 -0
- data/Rakefile +12 -0
- data/exe/rspec-conductor +8 -0
- data/lib/rspec/conductor/cli.rb +116 -0
- data/lib/rspec/conductor/formatters/ci.rb +47 -0
- data/lib/rspec/conductor/formatters/fancy.rb +190 -0
- data/lib/rspec/conductor/formatters/plain.rb +44 -0
- data/lib/rspec/conductor/protocol.rb +44 -0
- data/lib/rspec/conductor/server.rb +282 -0
- data/lib/rspec/conductor/version.rb +7 -0
- data/lib/rspec/conductor/worker.rb +253 -0
- data/lib/rspec/conductor.rb +24 -0
- data/rspec-conductor.gemspec +34 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 17242ccb102c17f1a469bf4e105d62f2469372059d9416bcf4ab87df4f698143
|
|
4
|
+
data.tar.gz: ab29ad25bded7e52af8b762c532f2d30d9c5451a954abfcd3330430840dda107
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d7aa04766f0b086c747c2c7046e6dc3050c6376389c05b68db1a83a99570c65e5d0e993df8bcd4b2379acb68d6047f4ec15dfba01423714eda39f8a1cb4da89
|
|
7
|
+
data.tar.gz: c801bac92877545bef4083eb0875efb291e58ad1af80961f7ece72fee86e2abbf3f3916c43d823eaeaefb81f605254d22e96a1ae8eddda046e6fd846fe1237de
|
data/.rspec
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) 2025 Mark Abramov
|
|
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,58 @@
|
|
|
1
|
+
# rspec-conductor
|
|
2
|
+
|
|
3
|
+
There is a common issue when running parallel spec runners with parallel-tests: since you have to decide on the list of spec files for each runner before the run starts, you don't have good control over how well the load is distributed. What ends up happening is one runner finishes after 3 minutes, another after 7 minutes, not utilizing the CPU effectively.
|
|
4
|
+
|
|
5
|
+
rspec-conductor uses a different approach, it spawns a bunch of workers, then gives each of them one spec file to run. As soon as a worker finishes, it gives them another spec file, etc.
|
|
6
|
+
|
|
7
|
+
User experience was designed to serve as a simple, almost drop-in, replacement for the parallel_tests gem.
|
|
8
|
+
|
|
9
|
+
## Demo
|
|
10
|
+
|
|
11
|
+
2x sped-up recording of what it looks like in a real project.
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Set up the databases:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
rails 'parallel:drop[10]' 'parallel:setup[10]'
|
|
21
|
+
|
|
22
|
+
# if you like the first-is-1 mode, keeping your parallel test envs separate from your regular env:
|
|
23
|
+
PARALLEL_TEST_FIRST_IS_1=true rails 'parallel:drop[16]' 'parallel:setup[16]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then launch the CLI app (see also `bin/rspec-conductor --help`):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
rspec-conductor <OPTIONS> -- <RSPEC_OPTIONS> <SPEC_PATHS>
|
|
30
|
+
rspec-conductor --workers 10 -- --tag '~@flaky' spec_ether/system/
|
|
31
|
+
rspec-conductor --workers 10 spec_ether/system/ # shorthand when there are no spec options is also supported
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`--verbose` flag is especially useful for troubleshooting.
|
|
35
|
+
|
|
36
|
+
## Mechanics
|
|
37
|
+
|
|
38
|
+
Server process preloads the `rails_helper`, prepares a list of files to work, then spawns the workers, each with `ENV['TEST_ENV_NUMBER'] = <worker_number>` (same as parallel-tests). The two communicate over a standard unix socket. Message format is basically a tuple of `(size, json_payload)`. It should also be possible to run this process over the network, but I haven't found a solid usecase for this.
|
|
39
|
+
|
|
40
|
+
## Development notes
|
|
41
|
+
|
|
42
|
+
* In order to make the CLI executable load and run fast, do not add any dependencies. That includes `active_support`.
|
|
43
|
+
|
|
44
|
+
## FAQ
|
|
45
|
+
|
|
46
|
+
* Why not preload the whole rails environment before spawning the workers instead of just `rails_helper`?
|
|
47
|
+
|
|
48
|
+
Short answer: it's unsafe. Any file descriptors, such as db connections, redis connections and even libcurl environment (which we use for elasticsearch), are shared between all the child processes, leading to hard to debug bugs.
|
|
49
|
+
|
|
50
|
+
* Why not use any of the existing libraries? (see Prior Art section)
|
|
51
|
+
|
|
52
|
+
`test-queue` forks after loading the whole environment rather than just the `rails_helper` (see above). `ci-queue` is deprecated for rspec. `rspecq` I couldn't get working and also I didn't like the design.
|
|
53
|
+
|
|
54
|
+
## Prior Art
|
|
55
|
+
|
|
56
|
+
* [test-queue](https://github.com/tmm1/test-queue)
|
|
57
|
+
* [rspecq](https://github.com/skroutz/rspecq/)
|
|
58
|
+
* [ci-queue](https://github.com/Shopify/ci-queue/)
|
data/Rakefile
ADDED
data/exe/rspec-conductor
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Conductor
|
|
7
|
+
class CLI
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
workers: 4,
|
|
10
|
+
offset: 0,
|
|
11
|
+
first_is_1: false,
|
|
12
|
+
seed: nil,
|
|
13
|
+
fail_fast_after: nil,
|
|
14
|
+
verbose: false,
|
|
15
|
+
display_retry_backtraces: false,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.run(argv)
|
|
19
|
+
new(argv).run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(argv)
|
|
23
|
+
@argv = argv
|
|
24
|
+
@conductor_options = DEFAULTS.dup
|
|
25
|
+
@rspec_args = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
parse_arguments
|
|
30
|
+
start_server
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse_arguments
|
|
36
|
+
separator_index = @argv.index("--")
|
|
37
|
+
|
|
38
|
+
if separator_index
|
|
39
|
+
conductor_args = @argv[0...separator_index]
|
|
40
|
+
@rspec_args = @argv[(separator_index + 1)..]
|
|
41
|
+
else
|
|
42
|
+
conductor_args = @argv
|
|
43
|
+
@rspec_args = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
parse_conductor_options(conductor_args)
|
|
47
|
+
@rspec_args.prepend(*conductor_args) # can use spec paths as positional arguments before -- for convenience
|
|
48
|
+
apply_rspec_defaults
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_conductor_options(args)
|
|
52
|
+
OptionParser.new do |opts|
|
|
53
|
+
opts.banner = "Usage: rspec-conductor [options] -- [rspec options]"
|
|
54
|
+
|
|
55
|
+
opts.on("-w", "--workers NUM", Integer, "Number of workers (default: #{DEFAULTS[:workers]})") do |n|
|
|
56
|
+
@conductor_options[:workers] = n
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("-o", "--offset NUM", Integer, "Worker number offset, if you need to run multiple conductors in parallel (default: 0)") do |n|
|
|
60
|
+
@conductor_options[:offset] = n
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
opts.on("--first-is-1", 'ENV["TEST_ENV_NUMBER"] for the worker 1 is "1" rather than ""') do
|
|
64
|
+
@conductor_options[:first_is_1] = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
opts.on("-s", "--seed NUM", Integer, "Randomization seed") do |n|
|
|
68
|
+
@conductor_options[:seed] = n
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
opts.on("--fail-fast-after NUM", Integer, "Fail the run after a certain number of failed specs") do |n|
|
|
72
|
+
@conductor_options[:fail_fast_after] = n
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opts.on("--formatter FORMATTER", ["plain", "ci", "fancy"], "Use a certain formatter") do |f|
|
|
76
|
+
@conductor_options[:formatter] = f
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
opts.on("--display-retry-backtraces", "Display retried exception backtraces") do
|
|
80
|
+
@conductor_options[:display_retry_backtraces] = true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
opts.on("--verbose", "Enable debug output") do
|
|
84
|
+
@conductor_options[:verbose] = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
opts.on("-h", "--help", "Show this help") do
|
|
88
|
+
puts opts
|
|
89
|
+
Kernel.exit
|
|
90
|
+
end
|
|
91
|
+
end.parse!(args)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_rspec_defaults
|
|
95
|
+
has_paths = @rspec_args.any? { |arg| !arg.start_with?("-") }
|
|
96
|
+
@rspec_args << File.join(Conductor.root, "spec/") unless has_paths
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def start_server
|
|
100
|
+
require_relative "server"
|
|
101
|
+
|
|
102
|
+
Server.new(
|
|
103
|
+
worker_count: @conductor_options[:workers],
|
|
104
|
+
worker_number_offset: @conductor_options[:offset],
|
|
105
|
+
first_is_1: @conductor_options[:first_is_1],
|
|
106
|
+
seed: @conductor_options[:seed],
|
|
107
|
+
fail_fast_after: @conductor_options[:fail_fast_after],
|
|
108
|
+
rspec_args: @rspec_args,
|
|
109
|
+
formatter: @conductor_options[:formatter],
|
|
110
|
+
display_retry_backtraces: @conductor_options[:display_retry_backtraces],
|
|
111
|
+
verbose: @conductor_options[:verbose],
|
|
112
|
+
).run
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Conductor
|
|
3
|
+
module Formatters
|
|
4
|
+
class CI
|
|
5
|
+
def initialize(frequency: 10)
|
|
6
|
+
@frequency = frequency
|
|
7
|
+
@last_printout = Time.now
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def handle_worker_message(_worker, message, results)
|
|
11
|
+
public_send(message[:type], message) if respond_to?(message[:type])
|
|
12
|
+
print_status(results) if @last_printout + @frequency < Time.now
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def print_status(results)
|
|
16
|
+
@last_printout = Time.now
|
|
17
|
+
pct_done = results[:spec_files_total].positive? ? results[:spec_files_processed].to_f / results[:spec_files_total] : 0
|
|
18
|
+
|
|
19
|
+
puts "-" * tty_width
|
|
20
|
+
puts "Current status [#{Time.now.strftime("%H:%M:%S")}]:"
|
|
21
|
+
puts "Processed: #{results[:spec_files_processed]} / #{results[:spec_files_total]} (#{(pct_done * 100).floor}%)"
|
|
22
|
+
puts "#{results[:passed]} passed, #{results[:failed]} failed, #{results[:pending]} pending"
|
|
23
|
+
if results[:errors].any?
|
|
24
|
+
puts "Failures:\n"
|
|
25
|
+
results[:errors].each_with_index do |error, i|
|
|
26
|
+
puts " #{i + 1}) #{error[:description]}"
|
|
27
|
+
puts " #{error[:location]}"
|
|
28
|
+
puts " #{error[:message]}" if error[:message]
|
|
29
|
+
if error[:backtrace]&.any?
|
|
30
|
+
puts " Backtrace:"
|
|
31
|
+
error[:backtrace].each { |line| puts " #{line}" }
|
|
32
|
+
end
|
|
33
|
+
puts
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
puts "-" * tty_width
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def tty_width
|
|
42
|
+
$stdout.tty? ? $stdout.winsize[1] : 80
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Conductor
|
|
5
|
+
module Formatters
|
|
6
|
+
class Fancy
|
|
7
|
+
RED = 31
|
|
8
|
+
GREEN = 32
|
|
9
|
+
YELLOW = 33
|
|
10
|
+
MAGENTA = 35
|
|
11
|
+
CYAN = 36
|
|
12
|
+
NORMAL = 0
|
|
13
|
+
|
|
14
|
+
def self.recommended?
|
|
15
|
+
$stdout.tty? && $stdout.winsize[0] >= 30 && $stdout.winsize[1] >= 80
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@workers = Hash.new { |h, k| h[k] = {} }
|
|
20
|
+
@last_rendered_lines = []
|
|
21
|
+
@dots = []
|
|
22
|
+
@last_error = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def handle_worker_message(worker, message, results)
|
|
26
|
+
@workers[worker[:number]] = worker
|
|
27
|
+
public_send(message[:type], worker, message) if respond_to?(message[:type])
|
|
28
|
+
redraw(results)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def example_passed(_worker, _message)
|
|
32
|
+
@dots << { char: ".", color: GREEN }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def example_failed(_worker, message)
|
|
36
|
+
@dots << { char: "F", color: RED }
|
|
37
|
+
@last_error = message.slice(:description, :location, :exception_class, :message, :backtrace)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def example_retried(_worker, _message)
|
|
41
|
+
@dots << { char: "R", color: MAGENTA }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def example_pending(_worker, _message)
|
|
45
|
+
@dots << { char: "*", color: YELLOW }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def redraw(results)
|
|
51
|
+
cursor_up(rewrap_lines(@last_rendered_lines).length)
|
|
52
|
+
|
|
53
|
+
lines = []
|
|
54
|
+
lines << progress_bar(results)
|
|
55
|
+
lines << ""
|
|
56
|
+
lines.concat(worker_lines)
|
|
57
|
+
lines << ""
|
|
58
|
+
lines << @dots.map { |dot| colorize(dot[:char], dot[:color]) }.join
|
|
59
|
+
lines << ""
|
|
60
|
+
lines.concat(error_lines) if @last_error
|
|
61
|
+
lines = rewrap_lines(lines)
|
|
62
|
+
|
|
63
|
+
lines.each_with_index do |line, i|
|
|
64
|
+
if @last_rendered_lines[i] == line
|
|
65
|
+
cursor_down(1)
|
|
66
|
+
else
|
|
67
|
+
clear_line
|
|
68
|
+
puts line
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if @last_rendered_lines.length && lines.length < @last_rendered_lines.length
|
|
73
|
+
(@last_rendered_lines.length - lines.length).times do
|
|
74
|
+
clear_line
|
|
75
|
+
puts
|
|
76
|
+
end
|
|
77
|
+
cursor_up(@last_rendered_lines.length - lines.length)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@last_rendered_lines = lines
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def worker_lines
|
|
84
|
+
return [] unless max_worker_num.positive?
|
|
85
|
+
|
|
86
|
+
(1..max_worker_num).map do |num|
|
|
87
|
+
worker = @workers[num]
|
|
88
|
+
prefix = colorize("Worker #{num}: ", CYAN)
|
|
89
|
+
|
|
90
|
+
if worker[:status] == :shut_down
|
|
91
|
+
prefix + "(finished)"
|
|
92
|
+
elsif worker[:status] == :terminated
|
|
93
|
+
prefix + colorize("(terminated)", RED)
|
|
94
|
+
elsif worker[:current_spec]
|
|
95
|
+
prefix + truncate(relative_path(worker[:current_spec]), tty_width - 15)
|
|
96
|
+
else
|
|
97
|
+
prefix + "(idle)"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def error_lines
|
|
103
|
+
return [] unless @last_error
|
|
104
|
+
|
|
105
|
+
lines = []
|
|
106
|
+
lines << colorize("Most recent failure:", RED)
|
|
107
|
+
lines << " #{@last_error[:description]}"
|
|
108
|
+
lines << " #{@last_error[:location]}"
|
|
109
|
+
|
|
110
|
+
if @last_error[:exception_class] || @last_error[:message]
|
|
111
|
+
err_msg = [@last_error[:exception_class], @last_error[:message]].compact.join(": ")
|
|
112
|
+
lines << " #{err_msg}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if @last_error[:backtrace]&.any?
|
|
116
|
+
lines << " Backtrace:"
|
|
117
|
+
@last_error[:backtrace].first(10).each { |l| lines << " #{l}" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
lines
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def rewrap_lines(lines)
|
|
124
|
+
lines.flat_map do |line|
|
|
125
|
+
_, indent, body = line.partition(/^\s*/)
|
|
126
|
+
max_width = tty_width - indent.size
|
|
127
|
+
split_chars_respecting_ansi(body).each_slice(max_width).map { |chars| "#{indent}#{chars.join}" }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# sticks invisible characters to visible ones when splitting (so that an ansi color code doesn"t get split mid-way)
|
|
132
|
+
def split_chars_respecting_ansi(body)
|
|
133
|
+
invisible = "(?:\\e\\[[\\d;]*m)"
|
|
134
|
+
visible = "(?:[^\\e])"
|
|
135
|
+
scan_regex = Regexp.new("#{invisible}*#{visible}#{invisible}*|#{invisible}+")
|
|
136
|
+
body.scan(scan_regex)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def progress_bar(results)
|
|
140
|
+
total = results[:spec_files_total]
|
|
141
|
+
processed = results[:spec_files_processed]
|
|
142
|
+
pct = total.positive? ? processed.to_f / total : 0
|
|
143
|
+
bar_width = [tty_width - 60, 20].max
|
|
144
|
+
|
|
145
|
+
filled = (pct * bar_width).floor
|
|
146
|
+
empty = bar_width - filled
|
|
147
|
+
|
|
148
|
+
bar = colorize("[", NORMAL) + colorize("▓" * filled, GREEN) + colorize(" " * empty, NORMAL) + colorize("]", NORMAL)
|
|
149
|
+
percentage = " #{(pct * 100).floor.to_s.rjust(3)}% (#{processed}/#{total})"
|
|
150
|
+
|
|
151
|
+
bar + percentage
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def max_worker_num
|
|
155
|
+
@workers.keys.max || 0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def relative_path(filename)
|
|
159
|
+
Pathname(filename).relative_path_from(Conductor.root).to_s
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def truncate(str, max_length)
|
|
163
|
+
return "" unless str
|
|
164
|
+
|
|
165
|
+
str.length > max_length ? "...#{str[-(max_length - 3)..]}" : str
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def colorize(string, color)
|
|
169
|
+
$stdout.tty? ? "\e[#{color}m#{string}\e[#{NORMAL}m" : string
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def cursor_up(n_lines)
|
|
173
|
+
print("\e[#{n_lines}A") if $stdout.tty? && n_lines.positive?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cursor_down(n_lines)
|
|
177
|
+
print("\e[#{n_lines}B") if $stdout.tty? && n_lines.positive?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clear_line
|
|
181
|
+
print("\e[2K\r") if $stdout.tty?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def tty_width
|
|
185
|
+
$stdout.tty? ? $stdout.winsize[1] : 80
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module RSpec
|
|
2
|
+
module Conductor
|
|
3
|
+
module Formatters
|
|
4
|
+
class Plain
|
|
5
|
+
# TTY standard colors
|
|
6
|
+
RED = 31
|
|
7
|
+
GREEN = 32
|
|
8
|
+
YELLOW = 33
|
|
9
|
+
MAGENTA = 35
|
|
10
|
+
NORMAL = 0
|
|
11
|
+
|
|
12
|
+
def handle_worker_message(_worker, message, _results)
|
|
13
|
+
public_send(message[:type], message) if respond_to?(message[:type])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def example_passed(_message)
|
|
17
|
+
print ".", GREEN
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def example_failed(_message)
|
|
21
|
+
print "F", RED
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def example_retried(_message)
|
|
25
|
+
print "R", MAGENTA
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def example_pending(_message)
|
|
29
|
+
print "*", YELLOW
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def print(string, color)
|
|
35
|
+
if $stdout.tty?
|
|
36
|
+
$stdout.print("\e[#{color}m#{string}\e[#{NORMAL}m")
|
|
37
|
+
else
|
|
38
|
+
$stdout.print(string)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Conductor
|
|
7
|
+
module Protocol
|
|
8
|
+
class Socket
|
|
9
|
+
attr_reader :io
|
|
10
|
+
|
|
11
|
+
def initialize(io)
|
|
12
|
+
@io = io
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def send_message(message)
|
|
16
|
+
json = JSON.generate(message)
|
|
17
|
+
length = [json.bytesize].pack("N")
|
|
18
|
+
io.write(length)
|
|
19
|
+
io.write(json.b)
|
|
20
|
+
io.flush
|
|
21
|
+
rescue Errno::EPIPE, IOError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def receive_message
|
|
26
|
+
length_bytes = io.read(4)
|
|
27
|
+
return nil unless length_bytes&.bytesize == 4
|
|
28
|
+
|
|
29
|
+
length = length_bytes.unpack1("N")
|
|
30
|
+
json = io.read(length)
|
|
31
|
+
return nil unless json&.bytesize == length
|
|
32
|
+
|
|
33
|
+
JSON.parse(json, symbolize_names: true)
|
|
34
|
+
rescue Errno::ECONNRESET, IOError, JSON::ParserError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
io.close unless io.closed?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "json"
|
|
6
|
+
require "io/console"
|
|
7
|
+
|
|
8
|
+
module RSpec
|
|
9
|
+
module Conductor
|
|
10
|
+
class Server
|
|
11
|
+
# @option worker_count [Integer] How many workers to spin
|
|
12
|
+
# @option rspec_args [Array<String>] A list of rspec options
|
|
13
|
+
# @option worker_number_offset [Integer] Start worker numbering with an offset
|
|
14
|
+
# @option first_is_1 [Boolean] TEST_ENV_NUMBER for the first worker is "1" instead of ""
|
|
15
|
+
# @option seed [Integer] Set a predefined starting seed
|
|
16
|
+
# @option fail_fast_after [Integer, NilClass] Shut down the workers after a certain number of failures
|
|
17
|
+
# @option formatter [String] Use a certain formatter
|
|
18
|
+
# @option verbose [Boolean] Use especially verbose output
|
|
19
|
+
# @option display_retry_backtraces [Boolean] Display backtraces for specs retried via rspec-retry
|
|
20
|
+
def initialize(worker_count:, rspec_args:, **opts)
|
|
21
|
+
@worker_count = worker_count
|
|
22
|
+
@worker_number_offset = opts.fetch(:worker_number_offset, 0)
|
|
23
|
+
@first_is_one = opts.fetch(:first_is_1, false)
|
|
24
|
+
@seed = opts[:seed] || (Random.new_seed % 65_536)
|
|
25
|
+
@fail_fast_after = opts[:fail_fast_after]
|
|
26
|
+
@display_retry_backtraces = opts.fetch(:display_retry_backtraces, false)
|
|
27
|
+
@verbose = opts.fetch(:verbose, false)
|
|
28
|
+
|
|
29
|
+
@rspec_args = rspec_args
|
|
30
|
+
@workers = {}
|
|
31
|
+
@spec_queue = []
|
|
32
|
+
@started_at = Time.now
|
|
33
|
+
@shutting_down = false
|
|
34
|
+
@formatter = case opts[:formatter]
|
|
35
|
+
when "ci"
|
|
36
|
+
Formatters::CI.new
|
|
37
|
+
when "fancy"
|
|
38
|
+
Formatters::Fancy.new
|
|
39
|
+
when "plain"
|
|
40
|
+
Formatters::Plain.new
|
|
41
|
+
else
|
|
42
|
+
Formatters::Fancy.recommended? ? Formatters::Fancy.new : Formatters::Plain.new
|
|
43
|
+
end
|
|
44
|
+
@results = { passed: 0, failed: 0, pending: 0, errors: [], worker_crashes: 0, started_at: @started_at, spec_files_total: 0, spec_files_processed: 0 }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
setup_signal_handlers
|
|
49
|
+
build_spec_queue
|
|
50
|
+
preload_application
|
|
51
|
+
|
|
52
|
+
$stdout.sync = true
|
|
53
|
+
puts "RSpec Conductor starting with #{@worker_count} workers (seed: #{@seed})"
|
|
54
|
+
puts "Running #{@spec_queue.size} spec files\n\n"
|
|
55
|
+
|
|
56
|
+
start_workers
|
|
57
|
+
run_event_loop
|
|
58
|
+
|
|
59
|
+
print_summary
|
|
60
|
+
exit_with_status
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def preload_application
|
|
66
|
+
application = File.expand_path("config/application", Conductor.root)
|
|
67
|
+
|
|
68
|
+
if File.exist?(application)
|
|
69
|
+
debug "Preloading config/application.rb..."
|
|
70
|
+
require File.expand_path("config/application", Conductor.root)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
debug "Application preloaded, autoload paths configured"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def setup_signal_handlers
|
|
77
|
+
%w[INT TERM].each do |signal|
|
|
78
|
+
Signal.trap(signal) do
|
|
79
|
+
@workers.any? ? initiate_shutdown : Kernel.exit(1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initiate_shutdown
|
|
85
|
+
return if @shutting_down
|
|
86
|
+
|
|
87
|
+
@shutting_down = true
|
|
88
|
+
|
|
89
|
+
puts "Shutting down..."
|
|
90
|
+
@workers.each_value { |w| w[:socket]&.send_message({ type: :shutdown }) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_spec_queue
|
|
94
|
+
paths = extract_paths_from_args
|
|
95
|
+
|
|
96
|
+
config = RSpec::Core::Configuration.new
|
|
97
|
+
config.files_or_directories_to_run = paths
|
|
98
|
+
|
|
99
|
+
@spec_queue = config.files_to_run.shuffle(random: Random.new(@seed))
|
|
100
|
+
@results[:spec_files_total] = @spec_queue.size
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parsed_rspec_args
|
|
104
|
+
@parsed_rspec_args ||= RSpec::Core::ConfigurationOptions.new(@rspec_args)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_paths_from_args
|
|
108
|
+
files = parsed_rspec_args.options[:files_or_directories_to_run] || []
|
|
109
|
+
files.empty? ? [File.join(Conductor.root, "spec/")] : files
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def start_workers
|
|
113
|
+
@worker_count.times do |i|
|
|
114
|
+
spawn_worker(@worker_number_offset + i + 1)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def spawn_worker(worker_number)
|
|
119
|
+
parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0)
|
|
120
|
+
|
|
121
|
+
debug "Spawning worker #{worker_number}"
|
|
122
|
+
|
|
123
|
+
pid = fork do
|
|
124
|
+
parent_socket.close
|
|
125
|
+
|
|
126
|
+
ENV["TEST_ENV_NUMBER"] = if @first_is_one || worker_number != 1
|
|
127
|
+
worker_number.to_s
|
|
128
|
+
else
|
|
129
|
+
""
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Worker.new(
|
|
133
|
+
worker_number: worker_number,
|
|
134
|
+
socket: Protocol::Socket.new(child_socket),
|
|
135
|
+
rspec_args: @rspec_args,
|
|
136
|
+
verbose: @verbose
|
|
137
|
+
).run
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
child_socket.close
|
|
141
|
+
debug "Worker #{worker_number} started with pid #{pid}"
|
|
142
|
+
|
|
143
|
+
@workers[pid] = {
|
|
144
|
+
pid: pid,
|
|
145
|
+
number: worker_number,
|
|
146
|
+
status: :running,
|
|
147
|
+
socket: Protocol::Socket.new(parent_socket),
|
|
148
|
+
current_spec: nil,
|
|
149
|
+
}
|
|
150
|
+
assign_work(@workers[pid])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def run_event_loop
|
|
154
|
+
until @workers.empty?
|
|
155
|
+
workers_by_io = @workers.values.to_h { |w| [w[:socket].io, w] }
|
|
156
|
+
readable_ios, = IO.select(workers_by_io.keys, nil, nil, 0.01)
|
|
157
|
+
|
|
158
|
+
readable_ios&.each do |io|
|
|
159
|
+
worker = workers_by_io.fetch(io)
|
|
160
|
+
handle_worker_message(worker)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
reap_workers
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle_worker_message(worker)
|
|
168
|
+
message = worker[:socket].receive_message
|
|
169
|
+
return unless message
|
|
170
|
+
|
|
171
|
+
debug "Worker #{worker[:number]}: #{message[:type]}"
|
|
172
|
+
|
|
173
|
+
case message[:type].to_sym
|
|
174
|
+
when :example_passed
|
|
175
|
+
@results[:passed] += 1
|
|
176
|
+
when :example_failed
|
|
177
|
+
@results[:failed] += 1
|
|
178
|
+
@results[:errors] << message
|
|
179
|
+
|
|
180
|
+
if @fail_fast_after && @results[:failed] >= @fail_fast_after && !@shutting_down
|
|
181
|
+
debug "Shutting after #{@results[:failed]} failures"
|
|
182
|
+
initiate_shutdown
|
|
183
|
+
end
|
|
184
|
+
when :example_pending
|
|
185
|
+
@results[:pending] += 1
|
|
186
|
+
when :example_retried
|
|
187
|
+
if @display_retry_backtraces
|
|
188
|
+
puts "\nExample #{message[:description]} retried:\n #{message[:location]}\n #{message[:exception_class]}: #{message[:message]}\n#{message[:backtrace].map { " #{_1}" }.join("\n")}\n"
|
|
189
|
+
end
|
|
190
|
+
when :spec_complete
|
|
191
|
+
@results[:spec_files_processed] += 1
|
|
192
|
+
worker[:current_spec] = nil
|
|
193
|
+
assign_work(worker)
|
|
194
|
+
when :spec_error
|
|
195
|
+
@results[:errors] << message
|
|
196
|
+
debug "Spec error details: #{message[:error]}"
|
|
197
|
+
worker[:current_spec] = nil
|
|
198
|
+
assign_work(worker)
|
|
199
|
+
when :spec_interrupted
|
|
200
|
+
debug "Spec interrupted: #{message[:file]}"
|
|
201
|
+
worker[:current_spec] = nil
|
|
202
|
+
end
|
|
203
|
+
@formatter.handle_worker_message(worker, message, @results)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def assign_work(worker)
|
|
207
|
+
if @spec_queue.empty? || @shutting_down
|
|
208
|
+
debug "No more work for worker #{worker[:number]}, sending shutdown"
|
|
209
|
+
worker[:socket].send_message({ type: :shutdown })
|
|
210
|
+
cleanup_worker(worker)
|
|
211
|
+
else
|
|
212
|
+
@specs_started_at ||= Time.now
|
|
213
|
+
spec_file = @spec_queue.shift
|
|
214
|
+
worker[:current_spec] = spec_file
|
|
215
|
+
debug "Assigning #{spec_file} to worker #{worker[:number]}"
|
|
216
|
+
message = { type: :worker_assigned_spec, file: spec_file }
|
|
217
|
+
worker[:socket].send_message(message)
|
|
218
|
+
@formatter.handle_worker_message(worker, message, @results)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def cleanup_worker(worker, status: :shut_down)
|
|
223
|
+
@workers.delete(worker[:pid])
|
|
224
|
+
worker[:socket].close
|
|
225
|
+
worker[:status] = status
|
|
226
|
+
@formatter.handle_worker_message(worker, { type: :worker_shut_down }, @results)
|
|
227
|
+
Process.wait(worker[:pid])
|
|
228
|
+
rescue Errno::ECHILD
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def reap_workers
|
|
233
|
+
dead_workers = @workers.each_with_object([]) do |(pid, worker), memo|
|
|
234
|
+
result = Process.waitpid(pid, Process::WNOHANG)
|
|
235
|
+
memo << [worker, $CHILD_STATUS] if result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
dead_workers.each do |worker, exitstatus|
|
|
239
|
+
cleanup_worker(worker, status: :terminated)
|
|
240
|
+
debug "Worker #{worker[:number]} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
|
|
241
|
+
end
|
|
242
|
+
rescue Errno::ECHILD
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def print_summary
|
|
247
|
+
puts "\n\n"
|
|
248
|
+
puts "=" * ($stdout.tty? ? $stdout.winsize[1] : 80)
|
|
249
|
+
puts "Results: #{@results[:passed]} passed, #{@results[:failed]} failed, #{@results[:pending]} pending"
|
|
250
|
+
|
|
251
|
+
if @results[:errors].any?
|
|
252
|
+
puts "\nFailures:\n\n"
|
|
253
|
+
@results[:errors].each_with_index do |error, i|
|
|
254
|
+
puts " #{i + 1}) #{error[:description]}"
|
|
255
|
+
puts " #{error[:location]}"
|
|
256
|
+
puts " #{error[:message]}" if error[:message]
|
|
257
|
+
if error[:backtrace]&.any?
|
|
258
|
+
puts " Backtrace:"
|
|
259
|
+
error[:backtrace].each { |line| puts " #{line}" }
|
|
260
|
+
end
|
|
261
|
+
puts
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
puts "Randomized with seed #{@seed}"
|
|
266
|
+
puts "Specs took: #{(Time.now - (@specs_started_at || @started_at)).to_f.round(2)}s"
|
|
267
|
+
puts "Total runtime: #{(Time.now - @started_at).to_f.round(2)}s"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def exit_with_status
|
|
271
|
+
success = @results[:failed].zero? && @results[:errors].empty? && @results[:worker_crashes].zero? && !@shutting_down
|
|
272
|
+
Kernel.exit(success ? 0 : 1)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def debug(message)
|
|
276
|
+
return unless @verbose
|
|
277
|
+
|
|
278
|
+
$stderr.puts "[conductor] #{message}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RSpec doesn't provide us with a good way to handle before/after suite hooks,
|
|
4
|
+
# doing what we can here
|
|
5
|
+
class RSpec::Core::Configuration
|
|
6
|
+
def __run_before_suite_hooks
|
|
7
|
+
RSpec.current_scope = :before_suite_hook if RSpec.respond_to?(:current_scope=)
|
|
8
|
+
run_suite_hooks("a `before(:suite)` hook", @before_suite_hooks) if respond_to?(:run_suite_hooks)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def __run_after_suite_hooks
|
|
12
|
+
RSpec.current_scope = :after_suite_hook if RSpec.respond_to?(:current_scope=)
|
|
13
|
+
run_suite_hooks("an `after(:suite)` hook", @after_suite_hooks) if respond_to?(:run_suite_hooks)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module RSpec
|
|
18
|
+
module Conductor
|
|
19
|
+
class Worker
|
|
20
|
+
def initialize(worker_number:, socket:, rspec_args: [], verbose: false)
|
|
21
|
+
@worker_number = worker_number
|
|
22
|
+
@socket = socket
|
|
23
|
+
@rspec_args = rspec_args
|
|
24
|
+
@verbose = verbose
|
|
25
|
+
@message_queue = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
suppress_output unless @verbose
|
|
30
|
+
debug "Worker #{@worker_number} starting"
|
|
31
|
+
setup_load_path
|
|
32
|
+
initialize_rspec
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
debug "Waiting for message"
|
|
36
|
+
message = @message_queue.shift || @socket.receive_message
|
|
37
|
+
|
|
38
|
+
unless message
|
|
39
|
+
debug "Received nil message, exiting"
|
|
40
|
+
break
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
debug "Received: #{message.inspect}"
|
|
44
|
+
|
|
45
|
+
case message[:type].to_sym
|
|
46
|
+
when :worker_assigned_spec
|
|
47
|
+
debug "Running spec: #{message[:file]}"
|
|
48
|
+
run_spec(message[:file])
|
|
49
|
+
debug "Finished spec: #{message[:file]}"
|
|
50
|
+
break if @shutdown_requested
|
|
51
|
+
when :shutdown
|
|
52
|
+
debug "Shutdown received"
|
|
53
|
+
@shutdown_requested = true
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
debug "Worker #{@worker_number} shutting down, running after(:suite) hooks and exiting"
|
|
59
|
+
RSpec.configuration.__run_after_suite_hooks
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
debug "Worker crashed: #{e.class}: #{e.message}"
|
|
62
|
+
debug e.backtrace.join("\n")
|
|
63
|
+
raise
|
|
64
|
+
ensure
|
|
65
|
+
@socket.close
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def setup_load_path
|
|
71
|
+
parsed_options.configure(RSpec.configuration)
|
|
72
|
+
default_path = RSpec.configuration.default_path || "spec"
|
|
73
|
+
|
|
74
|
+
spec_path = File.expand_path("spec", Conductor.root)
|
|
75
|
+
default_full_path = File.expand_path(default_path, Conductor.root)
|
|
76
|
+
|
|
77
|
+
$LOAD_PATH.unshift(spec_path) if Dir.exist?(spec_path) && !$LOAD_PATH.include?(spec_path)
|
|
78
|
+
|
|
79
|
+
if default_full_path != spec_path && Dir.exist?(default_full_path)
|
|
80
|
+
$LOAD_PATH.unshift(default_full_path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
debug "Load path (spec dirs): #{$LOAD_PATH.grep(/spec/)}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def suppress_output
|
|
87
|
+
$stdout.reopen(null_io_out)
|
|
88
|
+
$stderr.reopen(null_io_out)
|
|
89
|
+
$stdin.reopen(null_io_in)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize_rspec
|
|
93
|
+
rails_helper = File.expand_path("rails_helper.rb", Conductor.root)
|
|
94
|
+
spec_helper = File.expand_path("spec_helper.rb", Conductor.root)
|
|
95
|
+
if File.exist?(rails_helper)
|
|
96
|
+
debug "Requiring rails_helper to boot Rails..."
|
|
97
|
+
require rails_helper
|
|
98
|
+
elsif File.exist?(spec_helper)
|
|
99
|
+
debug "Requiring spec_helper..."
|
|
100
|
+
require spec_helper
|
|
101
|
+
else
|
|
102
|
+
debug "Could detect neither rails_helper nor spec_helper"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
debug "RSpec initialized, running before(:suite) hooks"
|
|
106
|
+
RSpec.configuration.__run_before_suite_hooks
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run_spec(file)
|
|
110
|
+
RSpec.world.reset
|
|
111
|
+
RSpec.configuration.reset_reporter
|
|
112
|
+
RSpec.configuration.files_or_directories_to_run = []
|
|
113
|
+
setup_formatter(ConductorFormatter.new(@socket, file, -> { check_for_shutdown }))
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
debug "Loading spec file: #{file}"
|
|
117
|
+
debug "Exclusion filters: #{RSpec.configuration.exclusion_filter.description}"
|
|
118
|
+
debug "Inclusion filters: #{RSpec.configuration.inclusion_filter.description}"
|
|
119
|
+
load file
|
|
120
|
+
debug "Example groups after load: #{RSpec.world.example_groups.count}"
|
|
121
|
+
|
|
122
|
+
example_groups = RSpec.world.ordered_example_groups
|
|
123
|
+
debug "Example count: #{RSpec.world.example_count}"
|
|
124
|
+
|
|
125
|
+
RSpec.configuration.reporter.report(RSpec.world.example_count) do |reporter|
|
|
126
|
+
example_groups.each { |g| g.run(reporter) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@socket.send_message(
|
|
130
|
+
type: :spec_complete,
|
|
131
|
+
file: file
|
|
132
|
+
)
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
debug "Spec error: #{e.class}: #{e.message}"
|
|
135
|
+
debug "Backtrace: #{e.backtrace.join("\n")}"
|
|
136
|
+
@socket.send_message(
|
|
137
|
+
type: :spec_error,
|
|
138
|
+
file: file,
|
|
139
|
+
error: e.message,
|
|
140
|
+
backtrace: e.backtrace
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_for_shutdown
|
|
146
|
+
return unless @socket.io.wait_readable(0)
|
|
147
|
+
|
|
148
|
+
message = @socket.receive_message
|
|
149
|
+
return unless message
|
|
150
|
+
|
|
151
|
+
if message[:type].to_sym == :shutdown
|
|
152
|
+
debug "Shutdown received mid-spec"
|
|
153
|
+
@shutdown_requested = true
|
|
154
|
+
RSpec.world.wants_to_quit = true
|
|
155
|
+
else
|
|
156
|
+
debug "Non shutdown message: #{message}"
|
|
157
|
+
@message_queue << message
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parsed_options
|
|
162
|
+
@parsed_options ||= RSpec::Core::ConfigurationOptions.new(@rspec_args)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def setup_formatter(conductor_formatter)
|
|
166
|
+
RSpec.configuration.output_stream = null_io_out
|
|
167
|
+
RSpec.configuration.error_stream = null_io_out
|
|
168
|
+
RSpec.configuration.formatter_loader.formatters.clear
|
|
169
|
+
RSpec.configuration.add_formatter(conductor_formatter)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def debug(message)
|
|
173
|
+
$stderr.puts "[worker #{@worker_number}] #{message}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def null_io_out
|
|
177
|
+
@null_io_out ||= File.open(File::NULL, "w")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def null_io_in
|
|
181
|
+
@null_io_in ||= File.open(File::NULL, "r")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
class ConductorFormatter
|
|
186
|
+
RSpec::Core::Formatters.register self,
|
|
187
|
+
:example_passed,
|
|
188
|
+
:example_failed,
|
|
189
|
+
:example_pending
|
|
190
|
+
|
|
191
|
+
def initialize(socket, file, shutdown_check)
|
|
192
|
+
@socket = socket
|
|
193
|
+
@file = file
|
|
194
|
+
@shutdown_check = shutdown_check
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def example_passed(notification)
|
|
198
|
+
@socket.send_message(
|
|
199
|
+
type: :example_passed,
|
|
200
|
+
file: @file,
|
|
201
|
+
description: notification.example.full_description,
|
|
202
|
+
location: notification.example.location,
|
|
203
|
+
run_time: notification.example.execution_result.run_time
|
|
204
|
+
)
|
|
205
|
+
@shutdown_check.call
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def example_failed(notification)
|
|
209
|
+
ex = notification.example
|
|
210
|
+
@socket.send_message(
|
|
211
|
+
type: :example_failed,
|
|
212
|
+
file: @file,
|
|
213
|
+
description: ex.full_description,
|
|
214
|
+
location: ex.location,
|
|
215
|
+
run_time: ex.execution_result.run_time,
|
|
216
|
+
exception_class: ex.execution_result.exception&.class&.name,
|
|
217
|
+
message: ex.execution_result.exception&.message,
|
|
218
|
+
backtrace: format_backtrace(ex.execution_result.exception&.backtrace, ex.metadata)
|
|
219
|
+
)
|
|
220
|
+
@shutdown_check.call
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def example_pending(notification)
|
|
224
|
+
ex = notification.example
|
|
225
|
+
@socket.send_message(
|
|
226
|
+
type: :example_pending,
|
|
227
|
+
file: @file,
|
|
228
|
+
description: ex.full_description,
|
|
229
|
+
location: ex.location,
|
|
230
|
+
pending_message: ex.execution_result.pending_message
|
|
231
|
+
)
|
|
232
|
+
@shutdown_check.call
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def retry(ex)
|
|
236
|
+
@socket.send_message(
|
|
237
|
+
type: :example_retried,
|
|
238
|
+
description: ex.full_description,
|
|
239
|
+
location: ex.location,
|
|
240
|
+
exception_class: ex.exception&.class&.name,
|
|
241
|
+
message: ex.exception&.message,
|
|
242
|
+
backtrace: format_backtrace(ex.exception&.backtrace, ex.metadata)
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def format_backtrace(backtrace, example_metadata = nil)
|
|
249
|
+
RSpec::Core::BacktraceFormatter.new.format_backtrace(backtrace || [], example_metadata || {})
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
|
|
5
|
+
require_relative "conductor/version"
|
|
6
|
+
require_relative "conductor/protocol"
|
|
7
|
+
require_relative "conductor/server"
|
|
8
|
+
require_relative "conductor/worker"
|
|
9
|
+
require_relative "conductor/cli"
|
|
10
|
+
require_relative "conductor/formatters/plain"
|
|
11
|
+
require_relative "conductor/formatters/ci"
|
|
12
|
+
require_relative "conductor/formatters/fancy"
|
|
13
|
+
|
|
14
|
+
module RSpec
|
|
15
|
+
module Conductor
|
|
16
|
+
def self.root
|
|
17
|
+
@root ||= Dir.pwd
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.root=(root)
|
|
21
|
+
@root = root
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/rspec/conductor/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "rspec-conductor"
|
|
7
|
+
spec.version = RSpec::Conductor::VERSION
|
|
8
|
+
spec.authors = ["Mark Abramov"]
|
|
9
|
+
spec.email = ["me@markabramov.me"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Queue-based parallel test runner for rspec"
|
|
12
|
+
spec.description = "Queue-based parallel test runner for rspec"
|
|
13
|
+
spec.homepage = "https://github.com/markiz/rspec-conductor"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 2.6.0"
|
|
16
|
+
|
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/markiz/rspec-conductor/blob/master/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|spec)/|\.(?:git|github))})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
spec.add_dependency "rspec-core", ">= 3.8.0"
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rspec-conductor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mark Abramov
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-12-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec-core
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 3.8.0
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 3.8.0
|
|
27
|
+
description: Queue-based parallel test runner for rspec
|
|
28
|
+
email:
|
|
29
|
+
- me@markabramov.me
|
|
30
|
+
executables:
|
|
31
|
+
- rspec-conductor
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- ".rspec"
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- Gemfile
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- exe/rspec-conductor
|
|
42
|
+
- lib/rspec/conductor.rb
|
|
43
|
+
- lib/rspec/conductor/cli.rb
|
|
44
|
+
- lib/rspec/conductor/formatters/ci.rb
|
|
45
|
+
- lib/rspec/conductor/formatters/fancy.rb
|
|
46
|
+
- lib/rspec/conductor/formatters/plain.rb
|
|
47
|
+
- lib/rspec/conductor/protocol.rb
|
|
48
|
+
- lib/rspec/conductor/server.rb
|
|
49
|
+
- lib/rspec/conductor/version.rb
|
|
50
|
+
- lib/rspec/conductor/worker.rb
|
|
51
|
+
- rspec-conductor.gemspec
|
|
52
|
+
homepage: https://github.com/markiz/rspec-conductor
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
allowed_push_host: https://rubygems.org
|
|
57
|
+
homepage_uri: https://github.com/markiz/rspec-conductor
|
|
58
|
+
source_code_uri: https://github.com/markiz/rspec-conductor
|
|
59
|
+
changelog_uri: https://github.com/markiz/rspec-conductor/blob/master/CHANGELOG.md
|
|
60
|
+
post_install_message:
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 2.6.0
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.3.26
|
|
76
|
+
signing_key:
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Queue-based parallel test runner for rspec
|
|
79
|
+
test_files: []
|