rspec-conductor 1.0.8 → 1.0.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91793bc12c3384342322b331ddbc5738dd448b92b18517f3de1a5f27b45b464d
4
- data.tar.gz: 5daf14136401f25142f9d0393f8f427fd785245baff4eb6a026823549afa0e34
3
+ metadata.gz: 9de36557f5b670ac37493d920d186e4dcbcaca0c958d59914448b91aa5e6e488
4
+ data.tar.gz: 9d2371b5598a12adbc6ac907d80dfc4fbbe5f9c7d1c2b0f75cb17337345f51e4
5
5
  SHA512:
6
- metadata.gz: 48cc6ad36c65d3686da3a03bd87261c66a00d3d77c1e7156a4504eb6c2e2e1b16b2c6df999163e02aa8f603674fdbe8c081765a0ad3983e426e9f9e40f03be9a
7
- data.tar.gz: f93a55a25e1a890d13b29216f2c454ccd8c8d2cb01dda643ff24ba514dbdcfc85331cfed134f3bd9ba9fc87491b79dbf70fab43e5037383425a522dc00af3d7e
6
+ metadata.gz: cb67874bb0e5f84176ac601588d7931c97d70e3f8f6caff111e7cc1607b15007fcbc17f0053ff2c92f8e438cdad343530c91667de4d99f4fc7b95ac504f868fd
7
+ data.tar.gz: 4cb10a52cf0a0a6f9948c36287f2b6c5d35183df0ce6370196f9342e67c29ce5332780d8da8172d585be264170958fcbb317d77f03f3c23cb4ff70394b6e4cbe
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [1.0.10] - 2026-03-08
2
+
3
+ - Add --print-slowest cli param to display the slowest specs in the suite
4
+
5
+ ## [1.0.9] - 2026-03-01
6
+
7
+ - Handle workers stdout/stderr better. It is no longer necessary to use --verbose to see worker output. Verbose now only controls whether you see the debug output of the workers
8
+ - For the fancy formatter, allocate one line per stdout/stderr. Not ideal, but I'm not sure what layout I'm even looking for here, since allowing to freely put stuff into stdout/stderr breaks the TUI completely
9
+ - Disable echo when running (also breaks the TUI). The side effect of this is that you probably lose ability to tactically use binding.irb in your specs, but you might want to drop into the regular rspec to do that anyway
10
+ - Way better handling of SIGINT / Ctrl-C. Child processes used to just crash when terminated via signal, now they're safely terminating
11
+ - Support double Ctrl-C to force-kill the workers (same as rspec)
12
+ - When there are spec failures, include a rerun command for failed examples (e.g. `rspec spec/some_spec.rb:28 spec/other_spec.rb:42`). It should also be possible to use rspec-conductor for those, but in my personal practice, I prefer rspec because I also want to have some interactive console during the spec run, which is not going to be possible with forked children
13
+
1
14
  ## [1.0.8] - 2026-02-18
2
15
 
3
16
  - When --postfork-require is provided, use current dir instead of spec/
@@ -15,6 +15,7 @@ module RSpec
15
15
  display_retry_backtraces: false,
16
16
  prefork_require: 'config/application.rb',
17
17
  postfork_require: :spec_helper,
18
+ print_slowest_count: nil,
18
19
  }.freeze
19
20
 
20
21
  def self.run(argv)
@@ -97,6 +98,10 @@ module RSpec
97
98
  @conductor_options[:display_retry_backtraces] = true
98
99
  end
99
100
 
101
+ opts.on("--print-slowest COUNT", Integer, "Print slowest specs with their execution times") do |n|
102
+ @conductor_options[:print_slowest_count] = n
103
+ end
104
+
100
105
  opts.on("--verbose", "Enable debug output") do
101
106
  @conductor_options[:verbose] = true
102
107
  end
@@ -120,6 +125,7 @@ module RSpec
120
125
  rspec_args: @rspec_args,
121
126
  formatter: @conductor_options[:formatter],
122
127
  display_retry_backtraces: @conductor_options[:display_retry_backtraces],
128
+ print_slowest_count: @conductor_options[:print_slowest_count],
123
129
  verbose: @conductor_options[:verbose],
124
130
  ).run
125
131
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # RSpec doesn't provide us with a good way to handle before/after suite hooks,
2
4
  # doing what we can here
3
5
  class RSpec::Core::Configuration
@@ -2,65 +2,89 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- class Base
6
- include Util::ANSI
5
+ module Formatters
6
+ class Base
7
+ include Util::ANSI
7
8
 
8
- def initialize(**_kwargs)
9
- end
9
+ def initialize(**_kwargs)
10
+ end
10
11
 
11
- def handle_worker_message(_worker_process, _message, _results)
12
- end
12
+ def handle_worker_message(_worker_process, _message, _suite_run)
13
+ end
13
14
 
14
- def print_startup_banner(worker_count:, seed:, spec_files_count:)
15
- puts "RSpec Conductor starting with #{worker_count} workers (seed: #{seed})"
16
- puts "Running #{spec_files_count} spec files\n\n"
17
- end
15
+ def print_startup_banner(worker_count:, seed:, spec_files_count:)
16
+ puts "RSpec Conductor starting with #{worker_count} workers (seed: #{seed})"
17
+ puts "Running #{spec_files_count} spec files\n\n"
18
+ end
18
19
 
19
- def print_summary(results, seed:)
20
- puts "\n\n"
21
- puts "Randomized with seed #{seed}"
22
- puts "#{colorize("#{results.passed} passed", :green)}, #{colorize("#{results.failed} failed", :red)}, #{colorize("#{results.pending} pending", :yellow)}"
23
- puts colorize("Worker crashes: #{results.worker_crashes}", :red) if results.worker_crashes.positive?
20
+ def print_summary(suite_run, seed:, success:)
21
+ puts "\n\n"
22
+ puts "Randomized with seed #{seed}"
23
+ puts "#{colorize("#{suite_run.examples_passed} passed", :green)}, #{colorize("#{suite_run.examples_failed} failed", :red)}, #{colorize("#{suite_run.examples_pending} pending", :yellow)}"
24
+ puts colorize("Worker crashes: #{suite_run.worker_crashes}", :red) if suite_run.worker_crashes.positive?
24
25
 
25
- if results.errors.any?
26
- puts "\nFailures:\n\n"
27
- results.errors.each_with_index do |error, i|
28
- puts " #{i + 1}) #{error[:description]}"
29
- puts colorize(" #{error[:message]}", :red) if error[:message]
30
- puts colorize(" #{error[:location]}", :cyan)
31
- if error[:backtrace]&.any?
32
- puts " Backtrace:"
33
- error[:backtrace].each { |line| puts " #{line}" }
26
+ if suite_run.errors.any?
27
+ puts "\nFailures:\n\n"
28
+ suite_run.errors.each_with_index do |error, i|
29
+ puts " #{i + 1}) #{error[:description]}"
30
+ puts colorize(" #{error[:message]}", :red) if error[:message]
31
+ puts colorize(" #{error[:location]}", :cyan)
32
+ if error[:backtrace]&.any?
33
+ puts " Backtrace:"
34
+ error[:backtrace].each { |line| puts " #{line}" }
35
+ end
36
+ puts
34
37
  end
35
- puts
38
+ end
39
+
40
+ puts "Specs took: #{suite_run.specs_runtime.round(2)}s"
41
+ puts "Total runtime: #{suite_run.total_runtime.round(2)}s"
42
+ puts "Suite: #{success ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
43
+
44
+ if suite_run.errors.any?
45
+ puts ""
46
+ puts "To rerun failed examples:"
47
+ puts " rspec #{suite_run.errors.map { |e| e[:location] }.join(" ")}"
36
48
  end
37
49
  end
38
50
 
39
- puts "Specs took: #{results.specs_runtime.round(2)}s"
40
- puts "Total runtime: #{results.total_runtime.round(2)}s"
41
- puts "Suite: #{results.success? ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
42
- end
51
+ def print_slowest(suite_run, n)
52
+ puts "\n\n"
53
+ puts "Slowest #{n} specs:"
54
+ suite_run.example_stats.sort_by { |e| -e[:run_time] }.take(n).each_with_index do |e, i|
55
+ puts "%3d. (%8.2fms) %s @ %s" % [i + 1, e[:run_time] * 1000, colorize(e[:description], :dim), colorize(e[:location], :cyan)]
56
+ end
57
+ end
43
58
 
44
- def print_debug(string)
45
- $stderr.puts string
46
- end
59
+ def handle_worker_stdout(worker_number, string)
60
+ puts "[worker #{worker_number}] #{string}"
61
+ end
47
62
 
48
- def print_retry_message(message)
49
- puts <<~EOM
50
- \nRetried: #{message[:description]}
51
- #{message[:location]}
52
- #{message[:exception_class]}: #{message[:message]}
53
- Backtrace:
54
- #{message[:backtrace].map { " #{_1}" }.join("\n")}
55
- EOM
56
- end
63
+ def handle_worker_stderr(worker_number, string)
64
+ $stderr.puts "[worker #{worker_number}] #{string}"
65
+ end
57
66
 
58
- def print_shut_down_banner
59
- puts "Shutting down..."
60
- end
67
+ def print_debug(string)
68
+ $stderr.puts string
69
+ end
61
70
 
62
- def colorize(string, colors, **kwargs)
63
- $stdout.tty? ? super(string, colors, **kwargs) : string
71
+ def print_retry_message(message)
72
+ puts <<~EOM
73
+ \nRetried: #{message[:description]}
74
+ #{message[:location]}
75
+ #{message[:exception_class]}: #{message[:message]}
76
+ Backtrace:
77
+ #{message[:backtrace].map { " #{_1}" }.join("\n")}
78
+ EOM
79
+ end
80
+
81
+ def print_shutdown_banner
82
+ puts "Shutting down... (press ctrl-c again to force quit)"
83
+ end
84
+
85
+ def colorize(string, colors, **kwargs)
86
+ $stdout.tty? ? super(string, colors, **kwargs) : string
87
+ end
64
88
  end
65
89
  end
66
90
  end
@@ -14,22 +14,22 @@ module RSpec
14
14
  super(**kwargs)
15
15
  end
16
16
 
17
- def handle_worker_message(_worker_process, message, results)
17
+ def handle_worker_message(_worker_process, message, suite_run)
18
18
  public_send(message[:type], message) if respond_to?(message[:type])
19
- print_status(results) if @last_printout + @printout_interval < Time.now
19
+ print_status(suite_run) if @last_printout + @printout_interval < Time.now
20
20
  end
21
21
 
22
- def print_status(results)
22
+ def print_status(suite_run)
23
23
  @last_printout = Time.now
24
- pct = results.spec_file_processed_percentage
24
+ pct = suite_run.spec_file_processed_percentage
25
25
 
26
26
  puts "-" * tty_width
27
27
  puts "Current status [#{Time.now.strftime("%H:%M:%S")}]:"
28
- puts "Processed: #{results.spec_files_processed} / #{results.spec_files_total} (#{(pct * 100).floor}%)"
29
- puts "#{results.passed} passed, #{results.failed} failed, #{results.pending} pending"
30
- if results.errors.any?
28
+ puts "Processed: #{suite_run.spec_files_processed} / #{suite_run.spec_files_total} (#{(pct * 100).floor}%)"
29
+ puts "#{suite_run.examples_passed} passed, #{suite_run.examples_failed} failed, #{suite_run.examples_pending} pending"
30
+ if suite_run.errors.any?
31
31
  puts "Failures:\n"
32
- results.errors.each_with_index do |error, i|
32
+ suite_run.errors.each_with_index do |error, i|
33
33
  puts " #{i + 1}) #{error[:description]}"
34
34
  puts " #{error[:location]}"
35
35
  puts " #{error[:message]}" if error[:message]
@@ -11,9 +11,7 @@ module RSpec
11
11
  end
12
12
 
13
13
  def initialize(worker_count:, **kwargs)
14
- @worker_processes = {}
15
14
  @terminal = Util::Terminal.new
16
- @last_rendered_lines = []
17
15
  @dots_string = +""
18
16
  @last_error = nil
19
17
 
@@ -25,13 +23,16 @@ module RSpec
25
23
  @dots_line = @terminal.line(truncate: false)
26
24
  @terminal.puts
27
25
  @last_error_line = @terminal.line(truncate: false)
26
+ @stdout_line = @terminal.puts nil
27
+ @stderr_line = @terminal.puts nil
28
+ @shutdown_line = @terminal.puts nil
28
29
 
29
30
  super(**kwargs)
30
31
  end
31
32
 
32
- def handle_worker_message(worker_process, message, results)
33
+ def handle_worker_message(worker_process, message, suite_run)
33
34
  public_send(message[:type], worker_process, message) if respond_to?(message[:type])
34
- redraw(worker_process, results)
35
+ redraw(worker_process, suite_run)
35
36
  end
36
37
 
37
38
  def example_passed(_worker_process, _message)
@@ -51,11 +52,23 @@ module RSpec
51
52
  dot "*", :yellow
52
53
  end
53
54
 
55
+ def handle_worker_stdout(worker_number, string)
56
+ @stdout_line.update("STDOUT: [worker #{worker_number}]: #{string}")
57
+ end
58
+
59
+ def handle_worker_stderr(worker_number, string)
60
+ @stderr_line.update("STDERR: [worker #{worker_number}]: #{string}")
61
+ end
62
+
63
+ def print_shutdown_banner
64
+ @shutdown_line.update("Shutting down... (press ctrl-c again to force quit)")
65
+ end
66
+
54
67
  private
55
68
 
56
- def redraw(worker_process, results)
69
+ def redraw(worker_process, suite_run)
57
70
  update_worker_status_line(worker_process)
58
- update_results_line(results)
71
+ update_suite_run_line(suite_run)
59
72
  update_errors_line
60
73
  @terminal.redraw
61
74
  @terminal.scroll_to_bottom
@@ -81,13 +94,13 @@ module RSpec
81
94
  @worker_lines[worker_process.number].update(status, redraw: false)
82
95
  end
83
96
 
84
- def update_results_line(results)
85
- pct = results.spec_file_processed_percentage
97
+ def update_suite_run_line(suite_run)
98
+ pct = suite_run.spec_file_processed_percentage
86
99
  bar_width = [tty_width - 20, 20].max
87
100
  filled = (pct * bar_width).floor
88
101
  empty = bar_width - filled
89
102
 
90
- percentage = " %3d%% (%d/%d)" % [(pct * 100).floor, results.spec_files_processed, results.spec_files_total]
103
+ percentage = " %3d%% (%d/%d)" % [(pct * 100).floor, suite_run.spec_files_processed, suite_run.spec_files_total]
91
104
  bar = colorize("[", :reset) + colorize("▓", :green) * filled + colorize(" ", :reset) * empty + colorize("]", :reset)
92
105
 
93
106
  @progress_bar_line.update(bar + percentage, redraw: false)
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RSpec
2
4
  module Conductor
3
5
  module Formatters
4
6
  class Plain < Base
5
- def handle_worker_message(_worker_process, message, _results)
7
+ def handle_worker_message(_worker_process, message, _suite_run)
6
8
  public_send(message[:type], message) if respond_to?(message[:type])
7
9
  end
8
10
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RSpec
2
4
  module Conductor
3
5
  # Technically this is a **Formatter**, as in RSpec Formatter, but that was too confusing,
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
- require "socket"
5
- require "json"
6
-
7
3
  module RSpec
8
4
  module Conductor
9
5
  class Server
@@ -21,6 +17,7 @@ module RSpec
21
17
  # @option formatter [String] Use a certain formatter
22
18
  # @option verbose [Boolean] Use especially verbose output
23
19
  # @option display_retry_backtraces [Boolean] Display backtraces for specs retried via rspec-retry
20
+ # @option print_slowest_count [Integer] Print slowest specs in the end of the suite
24
21
  def initialize(worker_count:, rspec_args:, **opts)
25
22
  @worker_count = worker_count
26
23
  @worker_number_offset = opts.fetch(:worker_number_offset, 0)
@@ -30,10 +27,11 @@ module RSpec
30
27
  @seed = opts[:seed] || (Random.new_seed % MAX_SEED)
31
28
  @fail_fast_after = opts[:fail_fast_after]
32
29
  @display_retry_backtraces = opts.fetch(:display_retry_backtraces, false)
30
+ @print_slowest_count = opts.fetch(:print_slowest_count, nil)
33
31
  @verbose = opts.fetch(:verbose, false)
34
32
 
35
33
  @rspec_args = rspec_args
36
- @worker_processes = {}
34
+ @worker_processes = []
37
35
  @spec_queue = []
38
36
  @formatter_class = case opts[:formatter]
39
37
  when "ci"
@@ -46,10 +44,12 @@ module RSpec
46
44
  (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy : Formatters::Plain
47
45
  end
48
46
  @formatter = @formatter_class.new(worker_count: @worker_count)
49
- @results = Results.new
47
+ @suite_run = SuiteRun.new
50
48
 
49
+ $stdout.sync = true
50
+ $stdin.echo = false if $stdin.tty?
51
51
  Dir.chdir(Conductor.root)
52
- ENV['PARALLEL_TEST_GROUPS'] = worker_count.to_s # parallel_tests backward-compatibility
52
+ ENV["PARALLEL_TEST_GROUPS"] = worker_count.to_s # parallel_tests backward-compatibility
53
53
  end
54
54
 
55
55
  def run
@@ -57,53 +57,28 @@ module RSpec
57
57
  build_spec_queue
58
58
  preload_application
59
59
 
60
- $stdout.sync = true
61
60
  @formatter.print_startup_banner(worker_count: @worker_count, seed: @seed, spec_files_count: @spec_queue.size)
62
61
 
63
62
  start_workers
64
63
  run_event_loop
65
- @results.suite_complete
64
+ wait_for_workers_to_exit
65
+ @suite_run.suite_complete
66
66
 
67
- @formatter.print_summary(@results, seed: @seed)
67
+ @formatter.print_summary(@suite_run, seed: @seed, success: success?)
68
+ @formatter.print_slowest(@suite_run, @print_slowest_count) if @print_slowest_count
68
69
  exit_with_status
69
70
  end
70
71
 
71
72
  private
72
73
 
73
- def preload_application
74
- if !@prefork_require
75
- debug "Prefork require not set, skipping..."
76
- return
77
- end
78
-
79
- preload = File.expand_path(@prefork_require)
80
-
81
- if File.exist?(preload)
82
- debug "Preloading #{@prefork_require}..."
83
- require preload
84
- else
85
- debug "#{@prefork_require} not found, skipping..."
86
- end
87
-
88
- debug "Application preloaded, autoload paths configured"
89
- end
90
-
91
74
  def setup_signal_handlers
92
75
  %w[INT TERM].each do |signal|
93
76
  Signal.trap(signal) do
94
- @worker_processes.any? ? initiate_shutdown : Kernel.exit(1)
77
+ @worker_processes.any?(&:running?) ? initiate_shutdown : Kernel.exit(1)
95
78
  end
96
79
  end
97
80
  end
98
81
 
99
- def initiate_shutdown
100
- return if @results.shutting_down?
101
-
102
- @results.shut_down
103
- @formatter.print_shut_down_banner
104
- @worker_processes.each_value { |w| w.socket&.send_message({ type: :shutdown }) }
105
- end
106
-
107
82
  def build_spec_queue
108
83
  config_options = RSpec::Core::ConfigurationOptions.new(@rspec_args)
109
84
  # a bit of a hack, but if they want to require something explicitly, they should use either --prefork-require or --postfork-require,
@@ -118,140 +93,154 @@ module RSpec
118
93
  debug "RSpec config: #{config.inspect}"
119
94
  debug "Files to run: #{config.files_to_run}"
120
95
  @spec_queue = config.files_to_run.shuffle(random: Random.new(@seed))
121
- @results.spec_files_total = @spec_queue.size
96
+ @suite_run.spec_files_total = @spec_queue.size
122
97
  end
123
98
 
124
- def start_workers
125
- @worker_count.times do |i|
126
- spawn_worker(@worker_number_offset + i + 1)
99
+ def preload_application
100
+ if !@prefork_require
101
+ debug "Prefork require not set, skipping..."
102
+ return
127
103
  end
128
- end
129
-
130
- def spawn_worker(worker_number)
131
- parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0)
132
104
 
133
- debug "Spawning worker #{worker_number}"
105
+ preload = File.expand_path(@prefork_require)
134
106
 
135
- pid = fork do
136
- parent_socket.close
137
-
138
- ENV["TEST_ENV_NUMBER"] = if @first_is_1 || worker_number != 1
139
- worker_number.to_s
140
- else
141
- ""
142
- end
143
-
144
- Worker.new(
145
- worker_number: worker_number,
146
- socket: Protocol::Socket.new(child_socket),
147
- rspec_args: @rspec_args,
148
- verbose: @verbose,
149
- postfork_require: @postfork_require,
150
- ).run
107
+ if File.exist?(preload)
108
+ debug "Preloading #{@prefork_require}..."
109
+ require preload
110
+ else
111
+ debug "#{@prefork_require} not found, skipping..."
151
112
  end
152
113
 
153
- child_socket.close
154
- debug "Worker #{worker_number} started with pid #{pid}"
114
+ debug "Application preloaded, autoload paths configured"
115
+ end
155
116
 
156
- @worker_processes[pid] = WorkerProcess.new(
157
- pid: pid,
158
- number: worker_number,
159
- status: :running,
160
- socket: Protocol::Socket.new(parent_socket),
161
- current_spec: nil,
162
- )
163
- assign_work(@worker_processes[pid])
117
+ def start_workers
118
+ @worker_processes = @worker_count.times.map { |i| spawn_worker(@worker_number_offset + i + 1) }
119
+ @worker_processes.each { |wp| assign_work(wp) }
164
120
  end
165
121
 
166
122
  def run_event_loop
167
- until @worker_processes.empty?
168
- worker_processes_by_io = @worker_processes.values.to_h { |w| [w.socket.io, w] }
169
- readable_ios, = IO.select(worker_processes_by_io.keys, nil, nil, WORKER_POLL_INTERVAL)
170
- readable_ios&.each { |io| handle_worker_message(worker_processes_by_io.fetch(io)) }
123
+ until @worker_processes.select(&:running?).empty?
124
+ if @shutdown_status == :initiated_graceful
125
+ @shutdown_status = :shutdown_messages_sent
126
+ @formatter.print_shutdown_banner
127
+ @worker_processes.select(&:running?).each do |worker_process|
128
+ worker_process.send_message({ type: :shutdown })
129
+ cleanup_worker_process(worker_process)
130
+ end
131
+ end
132
+
133
+ WorkerProcess.tick_all(@worker_processes)
171
134
  reap_workers
172
135
  end
173
136
  end
174
137
 
175
- def handle_worker_message(worker_process)
176
- message = worker_process.socket.receive_message
177
- return unless message
138
+ def wait_for_workers_to_exit
139
+ WorkerProcess.wait_all(@worker_processes)
140
+ end
141
+
142
+ def spawn_worker(worker_number)
143
+ debug "Spawning worker #{worker_number}"
144
+
145
+ worker_process = WorkerProcess.spawn(
146
+ number: worker_number,
147
+ test_env_number: (@first_is_1 || worker_number != 1) ? worker_number.to_s : "",
148
+ on_message: ->(worker_process, message) { handle_worker_message(worker_process, message) },
149
+ on_stdout: ->(string) { @formatter.handle_worker_stdout(worker_number, string) },
150
+ on_stderr: ->(string) { @formatter.handle_worker_stderr(worker_number, string) },
151
+ debug_io: @verbose ? $stderr : nil,
152
+ rspec_args: @rspec_args,
153
+ postfork_require: @postfork_require,
154
+ )
155
+ debug "Worker #{worker_number} started with pid #{worker_process.pid}"
156
+ worker_process
157
+ end
178
158
 
159
+ def handle_worker_message(worker_process, message)
179
160
  debug "Worker #{worker_process.number}: #{message[:type]}"
180
161
 
181
162
  case message[:type].to_sym
182
163
  when :example_passed
183
- @results.example_passed
164
+ @suite_run.example_passed(message)
184
165
  when :example_failed
185
- @results.example_failed(message)
166
+ @suite_run.example_failed(message)
186
167
 
187
- if @fail_fast_after && @results.failed >= @fail_fast_after
188
- debug "Shutting down after #{@results.failed} failures"
168
+ if @fail_fast_after && @suite_run.examples_failed >= @fail_fast_after
169
+ debug "Shutting down after #{@suite_run.examples_failed} failures"
189
170
  initiate_shutdown
190
171
  end
191
172
  when :example_pending
192
- @results.example_pending
173
+ @suite_run.example_pending
193
174
  when :example_retried
194
175
  @formatter.print_retry_message(message) if @display_retry_backtraces
195
176
  when :spec_complete
196
- @results.spec_file_complete
177
+ @suite_run.spec_file_complete
197
178
  worker_process.current_spec = nil
198
179
  assign_work(worker_process)
199
180
  when :spec_error
200
- @results.spec_file_error(message)
181
+ @suite_run.spec_file_error(message)
201
182
  debug "Spec error details: #{message[:error]}"
202
183
  worker_process.current_spec = nil
203
184
  assign_work(worker_process)
204
- when :spec_interrupted
205
- debug "Spec interrupted: #{message[:file]}"
206
- worker_process.current_spec = nil
207
185
  end
208
- @formatter.handle_worker_message(worker_process, message, @results)
186
+ @formatter.handle_worker_message(worker_process, message, @suite_run)
209
187
  end
210
188
 
211
189
  def assign_work(worker_process)
212
190
  spec_file = @spec_queue.shift
213
191
 
214
- if @results.shutting_down? || !spec_file
192
+ if shutting_down? || !spec_file
215
193
  debug "No more work for worker #{worker_process.number}, sending shutdown"
216
- worker_process.socket.send_message({ type: :shutdown })
194
+ worker_process.send_message({ type: :shutdown })
217
195
  cleanup_worker_process(worker_process)
218
196
  else
219
- @results.spec_file_assigned
197
+ @suite_run.spec_file_assigned
220
198
  worker_process.current_spec = spec_file
221
199
  debug "Assigning #{spec_file} to worker #{worker_process.number}"
222
200
  message = { type: :worker_assigned_spec, file: spec_file }
223
- worker_process.socket.send_message(message)
224
- @formatter.handle_worker_message(worker_process, message, @results)
201
+ worker_process.send_message(message)
202
+ @formatter.handle_worker_message(worker_process, message, @suite_run)
225
203
  end
226
204
  end
227
205
 
228
206
  def cleanup_worker_process(worker_process, status: :shut_down)
229
- @worker_processes.delete(worker_process.pid)
230
- worker_process.socket.close
231
- worker_process.status = status
232
- @formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @results)
233
- Process.wait(worker_process.pid)
234
- rescue Errno::ECHILD
235
- nil
207
+ worker_process.shut_down(status)
208
+ @formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @suite_run)
236
209
  end
237
210
 
238
211
  def reap_workers
239
- dead_worker_processes = @worker_processes.each_with_object([]) do |(pid, worker), memo|
240
- result = Process.waitpid(pid, Process::WNOHANG)
241
- memo << [worker, $CHILD_STATUS] if result
212
+ dead_worker_processes = @worker_processes.select(&:running?).each_with_object([]) do |worker_process, memo|
213
+ result, status = Process.waitpid2(worker_process.pid, Process::WNOHANG)
214
+ memo << [worker_process, status] if result
215
+ rescue Errno::ECHILD
242
216
  end
243
217
 
244
218
  dead_worker_processes.each do |worker_process, exitstatus|
245
219
  cleanup_worker_process(worker_process, status: :terminated)
246
- @results.worker_crashed
220
+ @suite_run.worker_crashed
247
221
  debug "Worker #{worker_process.number} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
248
222
  end
249
- rescue Errno::ECHILD
250
- nil
223
+ end
224
+
225
+ def shutting_down?
226
+ !@shutdown_status.nil?
227
+ end
228
+
229
+ def initiate_shutdown
230
+ if @shutdown_status.nil?
231
+ @shutdown_status = :initiated_graceful
232
+ elsif @shutdown_status != :initiated_forced && @worker_processes.any?(&:running?)
233
+ @shutdown_status = :initiated_forced
234
+ Process.kill(:TERM, *@worker_processes.select(&:running?).map(&:pid))
235
+ end
236
+ end
237
+
238
+ def success?
239
+ @suite_run.success? && !shutting_down?
251
240
  end
252
241
 
253
242
  def exit_with_status
254
- Kernel.exit(@results.success? ? 0 : 1)
243
+ Kernel.exit(success? ? 0 : 1)
255
244
  end
256
245
 
257
246
  def debug(message)
@@ -1,12 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RSpec
2
4
  module Conductor
3
- class Results
4
- attr_accessor :passed, :failed, :pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed
5
+ class SuiteRun
6
+ attr_accessor :examples_passed, :examples_failed, :examples_pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed, :example_stats
5
7
 
6
8
  def initialize
7
- @passed = 0
8
- @failed = 0
9
- @pending = 0
9
+ @examples_passed = 0
10
+ @examples_failed = 0
11
+ @examples_pending = 0
10
12
  @worker_crashes = 0
11
13
  @errors = []
12
14
  @started_at = Time.now
@@ -14,24 +16,26 @@ module RSpec
14
16
  @specs_completed_at = nil
15
17
  @spec_files_total = 0
16
18
  @spec_files_processed = 0
17
- @shutting_down = false
19
+ @example_stats = []
18
20
  end
19
21
 
20
22
  def success?
21
- @failed.zero? && @errors.empty? && @worker_crashes.zero? && !shutting_down?
23
+ @examples_failed.zero? && @errors.empty? && @worker_crashes.zero? && @spec_files_total == @spec_files_processed
22
24
  end
23
25
 
24
- def example_passed
25
- @passed += 1
26
+ def example_passed(message)
27
+ @example_stats << message.slice(:location, :run_time, :description)
28
+ @examples_passed += 1
26
29
  end
27
30
 
28
31
  def example_failed(message)
29
- @failed += 1
32
+ @example_stats << message.slice(:location, :run_time, :description)
33
+ @examples_failed += 1
30
34
  @errors << message
31
35
  end
32
36
 
33
37
  def example_pending
34
- @pending += 1
38
+ @examples_pending += 1
35
39
  end
36
40
 
37
41
  def spec_file_assigned
@@ -56,14 +60,6 @@ module RSpec
56
60
  @worker_crashes += 1
57
61
  end
58
62
 
59
- def shut_down
60
- @shutting_down = true
61
- end
62
-
63
- def shutting_down?
64
- @shutting_down
65
- end
66
-
67
63
  def suite_complete
68
64
  @specs_completed_at ||= Time.now
69
65
  end
@@ -4,6 +4,8 @@ module RSpec
4
4
  module Conductor
5
5
  module Util
6
6
  class ChildProcess
7
+ POLL_INTERVAL = 0.01
8
+
7
9
  attr_reader :pid, :exit_status
8
10
 
9
11
  def self.fork(**args, &block)
@@ -12,18 +14,24 @@ module RSpec
12
14
 
13
15
  def self.wait_all(processes)
14
16
  until processes.all?(&:done?)
15
- pipe_to_process = processes.each_with_object({}) do |process, memo|
16
- process.pipes.reject(&:closed?).each { |pipe| memo[pipe] = process }
17
- end
18
- break if pipe_to_process.empty?
19
-
20
- ready, = IO.select(pipe_to_process.keys, nil, nil, 0.1)
21
- ready&.each { |pipe| pipe_to_process[pipe].read_available(pipe) }
17
+ break unless tick_all(processes)
22
18
  end
23
19
 
24
20
  processes.each(&:finalize)
25
21
  end
26
22
 
23
+ def self.tick_all(processes, poll_interval: POLL_INTERVAL)
24
+ processes_by_io = processes.each_with_object({}) do |process, memo|
25
+ process.pipes.reject(&:closed?).each { |pipe| memo[pipe] = process }
26
+ end
27
+ return false if processes_by_io.empty?
28
+
29
+ ready, = IO.select(processes_by_io.keys, nil, nil, poll_interval)
30
+ ready&.each { |pipe| processes_by_io[pipe].read_available(pipe) }
31
+
32
+ true
33
+ end
34
+
27
35
  def initialize(on_stdout: nil, on_stderr: nil)
28
36
  @on_stdout = on_stdout
29
37
  @on_stderr = on_stderr
@@ -55,11 +63,13 @@ module RSpec
55
63
 
56
64
  $stdout = stdout_write
57
65
  $stderr = stderr_write
58
- STDOUT.reopen(stdout_write)
59
- STDERR.reopen(stderr_write)
66
+ $stdin = File.open("/dev/null")
67
+ STDOUT.reopen($stdout)
68
+ STDERR.reopen($stderr)
69
+ STDIN.reopen($stdin)
60
70
 
61
71
  begin
62
- yield
72
+ yield self
63
73
  rescue => e
64
74
  stderr_write.puts "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
65
75
  exit 1
@@ -111,12 +121,17 @@ module RSpec
111
121
  def finalize
112
122
  return if done?
113
123
 
114
- process_buffer(@stdout_buffer, @on_stdout, partial: true)
115
- process_buffer(@stderr_buffer, @on_stderr, partial: true)
116
-
117
- _, status = Process.wait2(@pid)
118
- @exit_status = status.exitstatus
119
124
  @done = true
125
+
126
+ process_buffer(@stdout_buffer, @on_stdout, drain_remaining: true)
127
+ process_buffer(@stderr_buffer, @on_stderr, drain_remaining: true)
128
+
129
+ begin
130
+ _, status = Process.waitpid2(@pid)
131
+ @exit_status = status.exitstatus
132
+ rescue Errno::ECHILD
133
+ end
134
+
120
135
  self
121
136
  end
122
137
 
@@ -130,21 +145,17 @@ module RSpec
130
145
 
131
146
  private
132
147
 
133
- def process_buffer(buffer, callback, partial: false)
134
- return unless callback
148
+ def process_buffer(buffer, callback, drain_remaining: false)
149
+ while (newline_pos = buffer.index("\n"))
150
+ # String#slice! seems like it was invented specifically for this scenario,
151
+ # when you need to cut out a string fragment destructively
152
+ line = buffer.slice!(0..newline_pos).chomp
153
+ callback&.call(line)
154
+ end
135
155
 
136
- if partial
137
- unless buffer.empty?
138
- callback.call(buffer.chomp)
139
- buffer.clear
140
- end
141
- else
142
- while (newline_pos = buffer.index("\n"))
143
- # String#slice! seems like it was invented specifically for this scenario,
144
- # when you need to cut out a string fragment destructively
145
- line = buffer.slice!(0..newline_pos).chomp
146
- callback.call(line)
147
- end
156
+ if drain_remaining && !buffer.empty?
157
+ callback&.call(buffer.chomp)
158
+ buffer.clear
148
159
  end
149
160
  end
150
161
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "screen_buffer"
4
-
5
3
  module RSpec
6
4
  module Conductor
7
5
  module Util
@@ -30,7 +28,7 @@ module RSpec
30
28
  end
31
29
 
32
30
  def lines
33
- [self]
31
+ @content ? [self] : []
34
32
  end
35
33
  end
36
34
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.8"
5
+ VERSION = "1.0.10"
6
6
  end
7
7
  end
@@ -1,23 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'ext/rspec'
4
-
5
3
  module RSpec
6
4
  module Conductor
7
5
  class Worker
8
- def initialize(worker_number:, socket:, rspec_args: [], verbose: false, postfork_require: :spec_helper)
6
+ def initialize(worker_number:, socket:, rspec_args: [], postfork_require: :spec_helper, debug_io: nil)
9
7
  @worker_number = worker_number
10
8
  @socket = socket
11
9
  @rspec_args = rspec_args
12
- @verbose = verbose
13
10
  @postfork_require = postfork_require
14
-
11
+ @debug_io = debug_io
15
12
  @message_queue = []
16
13
  end
17
14
 
18
15
  def run
19
- suppress_output unless @verbose
20
16
  debug "Worker #{@worker_number} starting"
17
+ setup_signal_handlers
21
18
  setup_load_path
22
19
  require_postfork_preloads
23
20
 
@@ -57,6 +54,11 @@ module RSpec
57
54
 
58
55
  private
59
56
 
57
+ def setup_signal_handlers
58
+ Signal.trap(:INT, :IGNORE)
59
+ Signal.trap(:TERM, :EXIT)
60
+ end
61
+
60
62
  def setup_load_path
61
63
  parsed_options.configure(RSpec.configuration)
62
64
  @default_path = RSpec.configuration.default_path || "spec"
@@ -75,12 +77,6 @@ module RSpec
75
77
  $LOAD_PATH.unshift(path)
76
78
  end
77
79
 
78
- def suppress_output
79
- $stdout.reopen(null_io_out)
80
- $stderr.reopen(null_io_out)
81
- $stdin.reopen(null_io_in)
82
- end
83
-
84
80
  def require_postfork_preloads
85
81
  if @postfork_require == :spec_helper
86
82
  rails_helper = File.expand_path("rails_helper.rb", @default_full_path)
@@ -172,16 +168,12 @@ module RSpec
172
168
  end
173
169
 
174
170
  def debug(message)
175
- $stderr.puts "[worker #{@worker_number}] #{message}"
171
+ @debug_io.puts message if @debug_io
176
172
  end
177
173
 
178
174
  def null_io_out
179
175
  @null_io_out ||= File.open(File::NULL, "w")
180
176
  end
181
-
182
- def null_io_in
183
- @null_io_in ||= File.open(File::NULL, "r")
184
- end
185
177
  end
186
178
  end
187
179
  end
@@ -1,6 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
1
5
  module RSpec
2
6
  module Conductor
3
- WorkerProcess = Struct.new(:pid, :number, :status, :socket, :current_spec, keyword_init: true) do
7
+ WorkerProcess = Struct.new(:pid, :child_process, :number, :on_message, :status, :socket, :current_spec, keyword_init: true) do
8
+ def self.spawn(number:, test_env_number:, on_message:, on_stdout: nil, on_stderr: nil, **worker_init_args)
9
+ parent_socket, child_socket = Socket.pair(:UNIX, :STREAM, 0)
10
+ child_process = Util::ChildProcess.fork(on_stdout: on_stdout, on_stderr: on_stderr) do
11
+ ENV["TEST_ENV_NUMBER"] = test_env_number
12
+ parent_socket.close
13
+ Worker.new(
14
+ worker_number: number,
15
+ socket: Protocol::Socket.new(child_socket),
16
+ **worker_init_args
17
+ ).run
18
+ end
19
+ child_socket.close
20
+
21
+ new(
22
+ pid: child_process.pid,
23
+ child_process: child_process,
24
+ on_message: on_message,
25
+ number: number,
26
+ status: :running,
27
+ socket: Protocol::Socket.new(parent_socket),
28
+ current_spec: nil
29
+ )
30
+ end
31
+
32
+ def self.tick_all(worker_processes)
33
+ worker_processes_by_io = worker_processes.select(&:running?).to_h { |w| [w.socket.io, w] }
34
+ readable_ios, = IO.select(worker_processes_by_io.keys, nil, nil, 0)
35
+ readable_ios&.each { |io| worker_processes_by_io.fetch(io).handle_message }
36
+ Util::ChildProcess.tick_all(worker_processes.map(&:child_process))
37
+ end
38
+
39
+ def self.wait_all(worker_processes)
40
+ Util::ChildProcess.wait_all(worker_processes.map(&:child_process))
41
+ end
42
+
43
+ def handle_message
44
+ message = receive_message
45
+ return unless message && on_message
46
+
47
+ on_message.call(self, message)
48
+ end
49
+
50
+ def send_message(message)
51
+ socket.send_message(message)
52
+ end
53
+
54
+ def receive_message
55
+ socket.receive_message
56
+ end
57
+
58
+ def shut_down(status)
59
+ return unless running?
60
+
61
+ self.status = status
62
+ socket.close
63
+ end
64
+
65
+ def running?
66
+ status == :running
67
+ end
68
+
4
69
  def hash
5
70
  [number].hash
6
71
  end
@@ -28,12 +28,14 @@ module RSpec
28
28
  end
29
29
 
30
30
  require_relative "conductor/util/ansi"
31
+ require_relative "conductor/util/screen_buffer"
31
32
  require_relative "conductor/util/terminal"
33
+ require_relative "conductor/util/child_process"
32
34
  require_relative "conductor/version"
33
35
  require_relative "conductor/protocol"
34
36
  require_relative "conductor/server"
35
37
  require_relative "conductor/worker"
36
- require_relative "conductor/results"
38
+ require_relative "conductor/suite_run"
37
39
  require_relative "conductor/worker_process"
38
40
  require_relative "conductor/cli"
39
41
  require_relative "conductor/rspec_subscriber"
@@ -42,6 +44,8 @@ require_relative "conductor/formatters/plain"
42
44
  require_relative "conductor/formatters/ci"
43
45
  require_relative "conductor/formatters/fancy"
44
46
 
47
+ require_relative "conductor/ext/rspec"
48
+
45
49
  if defined?(Rails)
46
50
  require_relative "conductor/railtie"
47
51
  end
@@ -49,7 +49,7 @@ module RSpec
49
49
  configs
50
50
  end
51
51
 
52
- def run_for_each_database(count, action)
52
+ def run_for_each_database(count, action, &block)
53
53
  raise ArgumentError, "count must be positive" if count < 1
54
54
 
55
55
  puts "#{action} #{count} test databases in parallel..."
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-conductor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Abramov
@@ -48,9 +48,9 @@ files:
48
48
  - lib/rspec/conductor/formatters/plain.rb
49
49
  - lib/rspec/conductor/protocol.rb
50
50
  - lib/rspec/conductor/railtie.rb
51
- - lib/rspec/conductor/results.rb
52
51
  - lib/rspec/conductor/rspec_subscriber.rb
53
52
  - lib/rspec/conductor/server.rb
53
+ - lib/rspec/conductor/suite_run.rb
54
54
  - lib/rspec/conductor/util/ansi.rb
55
55
  - lib/rspec/conductor/util/child_process.rb
56
56
  - lib/rspec/conductor/util/screen_buffer.rb
@@ -82,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
82
  - !ruby/object:Gem::Version
83
83
  version: '0'
84
84
  requirements: []
85
- rubygems_version: 4.0.3
85
+ rubygems_version: 4.0.6
86
86
  specification_version: 4
87
87
  summary: Queue-based parallel test runner for rspec
88
88
  test_files: []