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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## [1.0.0] - 2025-12-21
2
+
3
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "rake"
8
+ if ENV["RSPEC_VERSION"].to_s.empty?
9
+ gem "rspec", ">= 3.8.0"
10
+ else
11
+ gem "rspec", "~> #{ENV["RSPEC_VERSION"]}"
12
+ end
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
+ ![rspec-conductor demo](https://github.com/user-attachments/assets/2b598635-3192-4aa0-bb39-2af01b93bb4a)
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task :conductor do
9
+ sh "exe/rspec-conductor", "-w", "4", "spec/"
10
+ end
11
+
12
+ task default: [:spec, :conductor]
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/rspec/conductor"
4
+
5
+ ENV["RAILS_ENV"] = "test"
6
+ ENV["RACK_ENV"] = "test"
7
+
8
+ RSpec::Conductor::CLI.run(ARGV)
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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: []