rspec-conductor 1.0.1 → 1.0.2

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: e35dd999b2161d2767b67b1c798dba7e89595ba8a5e6376780498c57c9bd1405
4
- data.tar.gz: 2b93eb4f86463e46ab97cf00c8fa619b4dc386e4f31febb006542ff9d3c02a49
3
+ metadata.gz: 50d787ec617be586c7f66328850d62f2253bd5f1a50d1effb6417ccfdb4ee3a2
4
+ data.tar.gz: d71b0b92aa35eb18991ae91a2d3c95d532da79beb80e304e227da87dd214656a
5
5
  SHA512:
6
- metadata.gz: ef4fc413c0198396cba25765275c27dfd57968d8abc2286989817edfdc94b1b8e63370c8b12cd5dd747b8684e386042f61ecc8e9770a9bb780ac9ef8043e875c
7
- data.tar.gz: 00e4eb9136dd92fa375dd441f056d759e847c48fb0d422f39acb458cfa60d1569caf662af94e95e059dca2b3cfd6b23fc660df66ee3d72241b4457da8c65bf35
6
+ metadata.gz: fe26f4f57787171577683b762e57ac8360b52d9949fbe1c6f4b6b8161da7747815e1b1296b1098ae05f9d32a605089847a0bb1c2f6f12f1273c50e7398118099
7
+ data.tar.gz: 6377b5235c007f274ad83778809319f7eee8e4cc484187202fb8919b8ef8e9ca7122e96e10b1948b6c9b7ca7db2e639bccc8d0e58c5781f17017cb40b119f1bd
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## [1.0.2] - 2026-01-09
2
+ - Fix --postfork-require options
3
+ - Fix worker crashes counter
4
+
1
5
  ## [1.0.1] - 2025-12-21
2
6
 
3
7
  - Fix spec_helper/rails_helper path finding [Thanks @diego-aslz]
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ module ANSI
6
+ module_function
7
+
8
+ COLOR_CODES = {
9
+ # Reset
10
+ reset: "0",
11
+
12
+ # Styles
13
+ bold: "1",
14
+ dim: "2",
15
+ italic: "3",
16
+ underline: "4",
17
+ blink: "5",
18
+ inverse: "7",
19
+ hidden: "8",
20
+ strikethrough: "9",
21
+
22
+ # Foreground colors
23
+ black: "30",
24
+ red: "31",
25
+ green: "32",
26
+ yellow: "33",
27
+ blue: "34",
28
+ magenta: "35",
29
+ cyan: "36",
30
+ white: "37",
31
+
32
+ # Bright foreground colors
33
+ bright_black: "90",
34
+ bright_red: "91",
35
+ bright_green: "92",
36
+ bright_yellow: "93",
37
+ bright_blue: "94",
38
+ bright_magenta: "95",
39
+ bright_cyan: "96",
40
+ bright_white: "97",
41
+
42
+ # Background colors
43
+ bg_black: "40",
44
+ bg_red: "41",
45
+ bg_green: "42",
46
+ bg_yellow: "43",
47
+ bg_blue: "44",
48
+ bg_magenta: "45",
49
+ bg_cyan: "46",
50
+ bg_white: "47",
51
+
52
+ # Bright background colors
53
+ bg_bright_black: "100",
54
+ bg_bright_red: "101",
55
+ bg_bright_green: "102",
56
+ bg_bright_yellow: "103",
57
+ bg_bright_blue: "104",
58
+ bg_bright_magenta: "105",
59
+ bg_bright_cyan: "106",
60
+ bg_bright_white: "107",
61
+ }.freeze
62
+
63
+ def colorize(string, colors, reset: true)
64
+ [
65
+ "\e[",
66
+ Array(colors).map { |color| color_code(color) }.join(";"),
67
+ "m",
68
+ string,
69
+ reset ? "\e[#{color_code(:reset)}m" : nil,
70
+ ].join
71
+ end
72
+
73
+ def color_code(color)
74
+ COLOR_CODES.fetch(color, COLOR_CODES[:reset])
75
+ end
76
+
77
+ def cursor_up(n_lines)
78
+ n_lines.positive? ? "\e[#{n_lines}A" : ""
79
+ end
80
+
81
+ def cursor_down(n_lines)
82
+ n_lines.positive? ? "\e[#{n_lines}B" : ""
83
+ end
84
+
85
+ def clear_line
86
+ "\e[2K\r"
87
+ end
88
+
89
+ # sticks invisible characters to visible ones when splitting (so that an ansi color code doesn"t get split mid-way)
90
+ def split_visible_char_groups(string)
91
+ invisible = "(?:\\e\\[[0-9;]*[a-zA-Z])"
92
+ visible = "(?:[^\\e])"
93
+ scan_regex = Regexp.new("#{invisible}*#{visible}#{invisible}*|#{invisible}+")
94
+ string.scan(scan_regex)
95
+ end
96
+
97
+ def visible_chars(string)
98
+ string.gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
99
+ end
100
+
101
+ def tty_width
102
+ $stdout.tty? ? $stdout.winsize[1] : 80
103
+ end
104
+ end
105
+ end
106
+ end
@@ -119,6 +119,7 @@ module RSpec
119
119
  worker_count: @conductor_options[:workers],
120
120
  worker_number_offset: @conductor_options[:offset],
121
121
  prefork_require: @conductor_options[:prefork_require],
122
+ postfork_require: @conductor_options[:postfork_require],
122
123
  first_is_1: @conductor_options[:first_is_1],
123
124
  seed: @conductor_options[:seed],
124
125
  fail_fast_after: @conductor_options[:fail_fast_after],
@@ -0,0 +1,13 @@
1
+ # RSpec doesn't provide us with a good way to handle before/after suite hooks,
2
+ # doing what we can here
3
+ class RSpec::Core::Configuration
4
+ def __run_before_suite_hooks
5
+ RSpec.current_scope = :before_suite_hook if RSpec.respond_to?(:current_scope=)
6
+ run_suite_hooks("a `before(:suite)` hook", @before_suite_hooks) if respond_to?(:run_suite_hooks)
7
+ end
8
+
9
+ def __run_after_suite_hooks
10
+ RSpec.current_scope = :after_suite_hook if RSpec.respond_to?(:current_scope=)
11
+ run_suite_hooks("an `after(:suite)` hook", @after_suite_hooks) if respond_to?(:run_suite_hooks)
12
+ end
13
+ end
@@ -2,27 +2,32 @@ module RSpec
2
2
  module Conductor
3
3
  module Formatters
4
4
  class CI
5
- def initialize(frequency: 10)
6
- @frequency = frequency
5
+ include Conductor::ANSI
6
+
7
+ DEFAULT_PRINTOUT_INTERVAL = 10
8
+
9
+ # @option printout_interval how often a printout happens, in seconds
10
+ def initialize(printout_interval: DEFAULT_PRINTOUT_INTERVAL)
11
+ @printout_interval = printout_interval
7
12
  @last_printout = Time.now
8
13
  end
9
14
 
10
- def handle_worker_message(_worker, message, results)
15
+ def handle_worker_message(_worker_process, message, results)
11
16
  public_send(message[:type], message) if respond_to?(message[:type])
12
- print_status(results) if @last_printout + @frequency < Time.now
17
+ print_status(results) if @last_printout + @printout_interval < Time.now
13
18
  end
14
19
 
15
20
  def print_status(results)
16
21
  @last_printout = Time.now
17
- pct_done = results[:spec_files_total].positive? ? results[:spec_files_processed].to_f / results[:spec_files_total] : 0
22
+ pct = results.spec_file_processed_percentage
18
23
 
19
24
  puts "-" * tty_width
20
25
  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?
26
+ puts "Processed: #{results.spec_files_processed} / #{results.spec_files_total} (#{(pct * 100).floor}%)"
27
+ puts "#{results.passed} passed, #{results.failed} failed, #{results.pending} pending"
28
+ if results.errors.any?
24
29
  puts "Failures:\n"
25
- results[:errors].each_with_index do |error, i|
30
+ results.errors.each_with_index do |error, i|
26
31
  puts " #{i + 1}) #{error[:description]}"
27
32
  puts " #{error[:location]}"
28
33
  puts " #{error[:message]}" if error[:message]
@@ -35,12 +40,6 @@ module RSpec
35
40
  end
36
41
  puts "-" * tty_width
37
42
  end
38
-
39
- private
40
-
41
- def tty_width
42
- $stdout.tty? ? $stdout.winsize[1] : 80
43
- end
44
43
  end
45
44
  end
46
45
  end
@@ -1,54 +1,50 @@
1
1
  require "pathname"
2
+ require "set"
2
3
 
3
4
  module RSpec
4
5
  module Conductor
5
6
  module Formatters
6
7
  class Fancy
7
- RED = 31
8
- GREEN = 32
9
- YELLOW = 33
10
- MAGENTA = 35
11
- CYAN = 36
12
- NORMAL = 0
8
+ include Conductor::ANSI
13
9
 
14
10
  def self.recommended?
15
11
  $stdout.tty? && $stdout.winsize[0] >= 30 && $stdout.winsize[1] >= 80
16
12
  end
17
13
 
18
14
  def initialize
19
- @workers = Hash.new { |h, k| h[k] = {} }
15
+ @worker_processes = Set.new
20
16
  @last_rendered_lines = []
21
17
  @dots = []
22
18
  @last_error = nil
23
19
  end
24
20
 
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])
21
+ def handle_worker_message(worker_process, message, results)
22
+ @worker_processes << worker_process
23
+ public_send(message[:type], worker_process, message) if respond_to?(message[:type])
28
24
  redraw(results)
29
25
  end
30
26
 
31
- def example_passed(_worker, _message)
32
- @dots << { char: ".", color: GREEN }
27
+ def example_passed(_worker_process, _message)
28
+ @dots << { char: ".", color: :green }
33
29
  end
34
30
 
35
- def example_failed(_worker, message)
36
- @dots << { char: "F", color: RED }
31
+ def example_failed(_worker_process, message)
32
+ @dots << { char: "F", color: :red }
37
33
  @last_error = message.slice(:description, :location, :exception_class, :message, :backtrace)
38
34
  end
39
35
 
40
- def example_retried(_worker, _message)
41
- @dots << { char: "R", color: MAGENTA }
36
+ def example_retried(_worker_process, _message)
37
+ @dots << { char: "R", color: :magenta }
42
38
  end
43
39
 
44
- def example_pending(_worker, _message)
45
- @dots << { char: "*", color: YELLOW }
40
+ def example_pending(_worker_process, _message)
41
+ @dots << { char: "*", color: :yellow }
46
42
  end
47
43
 
48
44
  private
49
45
 
50
46
  def redraw(results)
51
- cursor_up(rewrap_lines(@last_rendered_lines).length)
47
+ print_cursor_up(rewrap_lines(@last_rendered_lines).length)
52
48
 
53
49
  lines = []
54
50
  lines << progress_bar(results)
@@ -62,37 +58,47 @@ module RSpec
62
58
 
63
59
  lines.each_with_index do |line, i|
64
60
  if @last_rendered_lines[i] == line
65
- cursor_down(1)
61
+ print_cursor_down(1)
66
62
  else
67
- clear_line
63
+ print_clear_line
68
64
  puts line
69
65
  end
70
66
  end
71
67
 
72
68
  if @last_rendered_lines.length && lines.length < @last_rendered_lines.length
73
69
  (@last_rendered_lines.length - lines.length).times do
74
- clear_line
70
+ print_clear_line
75
71
  puts
76
72
  end
77
- cursor_up(@last_rendered_lines.length - lines.length)
73
+ print_cursor_up(@last_rendered_lines.length - lines.length)
78
74
  end
79
75
 
80
76
  @last_rendered_lines = lines
81
77
  end
82
78
 
83
- def worker_lines
84
- return [] unless max_worker_num.positive?
79
+ def progress_bar(results)
80
+ pct = results.spec_file_processed_percentage
81
+ bar_width = [tty_width - 20, 20].max
82
+
83
+ filled = (pct * bar_width).floor
84
+ empty = bar_width - filled
85
+
86
+ bar = colorize("[", :reset) + colorize("▓" * filled, :green) + colorize(" " * empty, :reset) + colorize("]", :reset)
87
+ percentage = " #{(pct * 100).floor.to_s.rjust(3)}% (#{results.spec_files_processed}/#{results.spec_files_total})"
88
+
89
+ bar + percentage
90
+ end
85
91
 
86
- (1..max_worker_num).map do |num|
87
- worker = @workers[num]
88
- prefix = colorize("Worker #{num}: ", CYAN)
92
+ def worker_lines
93
+ @worker_processes.sort_by(&:number).map do |worker_process|
94
+ prefix = colorize("Worker #{worker_process.number}: ", :cyan)
89
95
 
90
- if worker[:status] == :shut_down
96
+ if worker_process.status == :shut_down
91
97
  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)
98
+ elsif worker_process.status == :terminated
99
+ prefix + colorize("(terminated)", :red)
100
+ elsif worker_process.current_spec
101
+ prefix + truncate(relative_path(worker_process.current_spec), tty_width - 15)
96
102
  else
97
103
  prefix + "(idle)"
98
104
  end
@@ -103,7 +109,7 @@ module RSpec
103
109
  return [] unless @last_error
104
110
 
105
111
  lines = []
106
- lines << colorize("Most recent failure:", RED)
112
+ lines << colorize("Most recent failure:", :red)
107
113
  lines << " #{@last_error[:description]}"
108
114
  lines << " #{@last_error[:location]}"
109
115
 
@@ -124,37 +130,10 @@ module RSpec
124
130
  lines.flat_map do |line|
125
131
  _, indent, body = line.partition(/^\s*/)
126
132
  max_width = tty_width - indent.size
127
- split_chars_respecting_ansi(body).each_slice(max_width).map { |chars| "#{indent}#{chars.join}" }
133
+ split_visible_char_groups(body).each_slice(max_width).map { |chars| "#{indent}#{chars.join}" }
128
134
  end
129
135
  end
130
136
 
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
137
  def relative_path(filename)
159
138
  Pathname(filename).relative_path_from(Conductor.root).to_s
160
139
  end
@@ -165,24 +144,16 @@ module RSpec
165
144
  str.length > max_length ? "...#{str[-(max_length - 3)..]}" : str
166
145
  end
167
146
 
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?
147
+ def print_cursor_up(n_lines)
148
+ print cursor_up(n_lines) if $stdout.tty?
178
149
  end
179
150
 
180
- def clear_line
181
- print("\e[2K\r") if $stdout.tty?
151
+ def print_cursor_down(n_lines)
152
+ print cursor_down(n_lines) if $stdout.tty?
182
153
  end
183
154
 
184
- def tty_width
185
- $stdout.tty? ? $stdout.winsize[1] : 80
155
+ def print_clear_line
156
+ print clear_line if $stdout.tty?
186
157
  end
187
158
  end
188
159
  end
@@ -2,38 +2,33 @@ module RSpec
2
2
  module Conductor
3
3
  module Formatters
4
4
  class Plain
5
- # TTY standard colors
6
- RED = 31
7
- GREEN = 32
8
- YELLOW = 33
9
- MAGENTA = 35
10
- NORMAL = 0
5
+ include Conductor::ANSI
11
6
 
12
- def handle_worker_message(_worker, message, _results)
7
+ def handle_worker_message(_worker_process, message, _results)
13
8
  public_send(message[:type], message) if respond_to?(message[:type])
14
9
  end
15
10
 
16
11
  def example_passed(_message)
17
- print ".", GREEN
12
+ print ".", :green
18
13
  end
19
14
 
20
15
  def example_failed(_message)
21
- print "F", RED
16
+ print "F", :red
22
17
  end
23
18
 
24
19
  def example_retried(_message)
25
- print "R", MAGENTA
20
+ print "R", :magenta
26
21
  end
27
22
 
28
23
  def example_pending(_message)
29
- print "*", YELLOW
24
+ print "*", :yellow
30
25
  end
31
26
 
32
27
  private
33
28
 
34
29
  def print(string, color)
35
30
  if $stdout.tty?
36
- $stdout.print("\e[#{color}m#{string}\e[#{NORMAL}m")
31
+ $stdout.print(colorize(string, color))
37
32
  else
38
33
  $stdout.print(string)
39
34
  end
@@ -0,0 +1,80 @@
1
+ module RSpec
2
+ module Conductor
3
+ class Results
4
+ attr_accessor :passed, :failed, :pending, :worker_crashes, :errors, :started_at, :spec_files_total, :spec_files_processed
5
+
6
+ def initialize
7
+ @passed = 0
8
+ @failed = 0
9
+ @pending = 0
10
+ @worker_crashes = 0
11
+ @errors = []
12
+ @started_at = Time.now
13
+ @specs_started_at = nil
14
+ @specs_completed_at = nil
15
+ @spec_files_total = 0
16
+ @spec_files_processed = 0
17
+ @shutting_down = false
18
+ end
19
+
20
+ def success?
21
+ @failed.zero? && @errors.empty? && @worker_crashes.zero? && !shutting_down?
22
+ end
23
+
24
+ def example_passed
25
+ @passed += 1
26
+ end
27
+
28
+ def example_failed(message)
29
+ @failed += 1
30
+ @errors << message
31
+ end
32
+
33
+ def example_pending
34
+ @pending += 1
35
+ end
36
+
37
+ def spec_file_assigned
38
+ @specs_started_at ||= Time.now
39
+ end
40
+
41
+ def spec_file_complete
42
+ @spec_files_processed += 1
43
+ end
44
+
45
+ def spec_file_error(message)
46
+ @errors << message
47
+ end
48
+
49
+ def spec_file_processed_percentage
50
+ return 0.0 if @spec_files_total.zero?
51
+
52
+ @spec_files_processed.to_f / @spec_files_total
53
+ end
54
+
55
+ def worker_crashed
56
+ @worker_crashes += 1
57
+ end
58
+
59
+ def shut_down
60
+ @shutting_down = true
61
+ end
62
+
63
+ def shutting_down?
64
+ @shutting_down
65
+ end
66
+
67
+ def suite_complete
68
+ @specs_completed_at ||= Time.now
69
+ end
70
+
71
+ def specs_runtime
72
+ ((@specs_completed_at || Time.now) - (@specs_started_at || @started_at)).to_f
73
+ end
74
+
75
+ def total_runtime
76
+ ((@specs_completed_at || Time.now) - @started_at).to_f
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,74 @@
1
+ module RSpec
2
+ module Conductor
3
+ # Technically this is a **Formatter**, as in RSpec Formatter, but that was too confusing,
4
+ # and there is another thing called formatter in this library. Hence, Subscriber.
5
+ class RSpecSubscriber
6
+ RSpec::Core::Formatters.register self,
7
+ :example_passed,
8
+ :example_failed,
9
+ :example_pending
10
+
11
+ def initialize(socket, file, shutdown_check)
12
+ @socket = socket
13
+ @file = file
14
+ @shutdown_check = shutdown_check
15
+ end
16
+
17
+ def example_passed(notification)
18
+ @socket.send_message(
19
+ type: :example_passed,
20
+ file: @file,
21
+ description: notification.example.full_description,
22
+ location: notification.example.location,
23
+ run_time: notification.example.execution_result.run_time
24
+ )
25
+ @shutdown_check.call
26
+ end
27
+
28
+ def example_failed(notification)
29
+ ex = notification.example
30
+ @socket.send_message(
31
+ type: :example_failed,
32
+ file: @file,
33
+ description: ex.full_description,
34
+ location: ex.location,
35
+ run_time: ex.execution_result.run_time,
36
+ exception_class: ex.execution_result.exception&.class&.name,
37
+ message: ex.execution_result.exception&.message,
38
+ backtrace: format_backtrace(ex.execution_result.exception&.backtrace, ex.metadata)
39
+ )
40
+ @shutdown_check.call
41
+ end
42
+
43
+ def example_pending(notification)
44
+ ex = notification.example
45
+ @socket.send_message(
46
+ type: :example_pending,
47
+ file: @file,
48
+ description: ex.full_description,
49
+ location: ex.location,
50
+ pending_message: ex.execution_result.pending_message
51
+ )
52
+ @shutdown_check.call
53
+ end
54
+
55
+ # This one is invoked by rspec-retry, hence the slightly different api from example_* methods
56
+ def retry(ex)
57
+ @socket.send_message(
58
+ type: :example_retried,
59
+ description: ex.full_description,
60
+ location: ex.location,
61
+ exception_class: ex.exception&.class&.name,
62
+ message: ex.exception&.message,
63
+ backtrace: format_backtrace(ex.exception&.backtrace, ex.metadata)
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def format_backtrace(backtrace, example_metadata = nil)
70
+ RSpec::Core::BacktraceFormatter.new.format_backtrace(backtrace || [], example_metadata || {})
71
+ end
72
+ end
73
+ end
74
+ end
@@ -8,6 +8,9 @@ require "io/console"
8
8
  module RSpec
9
9
  module Conductor
10
10
  class Server
11
+ MAX_SEED = 2**16
12
+ WORKER_POLL_INTERVAL = 0.01
13
+
11
14
  # @option worker_count [Integer] How many workers to spin
12
15
  # @option rspec_args [Array<String>] A list of rspec options
13
16
  # @option worker_number_offset [Integer] Start worker numbering with an offset
@@ -25,16 +28,14 @@ module RSpec
25
28
  @prefork_require = opts.fetch(:prefork_require, nil)
26
29
  @postfork_require = opts.fetch(:postfork_require, nil)
27
30
  @first_is_one = opts.fetch(:first_is_1, false)
28
- @seed = opts[:seed] || (Random.new_seed % 65_536)
31
+ @seed = opts[:seed] || (Random.new_seed % MAX_SEED)
29
32
  @fail_fast_after = opts[:fail_fast_after]
30
33
  @display_retry_backtraces = opts.fetch(:display_retry_backtraces, false)
31
34
  @verbose = opts.fetch(:verbose, false)
32
35
 
33
36
  @rspec_args = rspec_args
34
- @workers = {}
37
+ @worker_processes = {}
35
38
  @spec_queue = []
36
- @started_at = Time.now
37
- @shutting_down = false
38
39
  @formatter = case opts[:formatter]
39
40
  when "ci"
40
41
  Formatters::CI.new
@@ -45,7 +46,7 @@ module RSpec
45
46
  else
46
47
  (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy.new : Formatters::Plain.new
47
48
  end
48
- @results = { passed: 0, failed: 0, pending: 0, errors: [], worker_crashes: 0, started_at: @started_at, spec_files_total: 0, spec_files_processed: 0 }
49
+ @results = Results.new
49
50
  end
50
51
 
51
52
  def run
@@ -59,6 +60,7 @@ module RSpec
59
60
 
60
61
  start_workers
61
62
  run_event_loop
63
+ @results.suite_complete
62
64
 
63
65
  print_summary
64
66
  exit_with_status
@@ -87,18 +89,17 @@ module RSpec
87
89
  def setup_signal_handlers
88
90
  %w[INT TERM].each do |signal|
89
91
  Signal.trap(signal) do
90
- @workers.any? ? initiate_shutdown : Kernel.exit(1)
92
+ @worker_processes.any? ? initiate_shutdown : Kernel.exit(1)
91
93
  end
92
94
  end
93
95
  end
94
96
 
95
97
  def initiate_shutdown
96
- return if @shutting_down
97
-
98
- @shutting_down = true
98
+ return if @results.shutting_down?
99
99
 
100
+ @results.shut_down
100
101
  puts "Shutting down..."
101
- @workers.each_value { |w| w[:socket]&.send_message({ type: :shutdown }) }
102
+ @worker_processes.each_value { |w| w.socket&.send_message({ type: :shutdown }) }
102
103
  end
103
104
 
104
105
  def build_spec_queue
@@ -108,7 +109,7 @@ module RSpec
108
109
  config.files_or_directories_to_run = paths
109
110
 
110
111
  @spec_queue = config.files_to_run.shuffle(random: Random.new(@seed))
111
- @results[:spec_files_total] = @spec_queue.size
112
+ @results.spec_files_total = @spec_queue.size
112
113
  end
113
114
 
114
115
  def parsed_rspec_args
@@ -152,104 +153,99 @@ module RSpec
152
153
  child_socket.close
153
154
  debug "Worker #{worker_number} started with pid #{pid}"
154
155
 
155
- @workers[pid] = {
156
+ @worker_processes[pid] = WorkerProcess.new(
156
157
  pid: pid,
157
158
  number: worker_number,
158
159
  status: :running,
159
160
  socket: Protocol::Socket.new(parent_socket),
160
161
  current_spec: nil,
161
- }
162
- assign_work(@workers[pid])
162
+ )
163
+ assign_work(@worker_processes[pid])
163
164
  end
164
165
 
165
166
  def run_event_loop
166
- until @workers.empty?
167
- workers_by_io = @workers.values.to_h { |w| [w[:socket].io, w] }
168
- readable_ios, = IO.select(workers_by_io.keys, nil, nil, 0.01)
169
-
170
- readable_ios&.each do |io|
171
- worker = workers_by_io.fetch(io)
172
- handle_worker_message(worker)
173
- end
174
-
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)) }
175
171
  reap_workers
176
172
  end
177
173
  end
178
174
 
179
- def handle_worker_message(worker)
180
- message = worker[:socket].receive_message
175
+ def handle_worker_message(worker_process)
176
+ message = worker_process.socket.receive_message
181
177
  return unless message
182
178
 
183
- debug "Worker #{worker[:number]}: #{message[:type]}"
179
+ debug "Worker #{worker_process.number}: #{message[:type]}"
184
180
 
185
181
  case message[:type].to_sym
186
182
  when :example_passed
187
- @results[:passed] += 1
183
+ @results.example_passed
188
184
  when :example_failed
189
- @results[:failed] += 1
190
- @results[:errors] << message
185
+ @results.example_failed(message)
191
186
 
192
- if @fail_fast_after && @results[:failed] >= @fail_fast_after && !@shutting_down
193
- debug "Shutting after #{@results[:failed]} failures"
187
+ if @fail_fast_after && @results.failed >= @fail_fast_after
188
+ debug "Shutting after #{@results.failed} failures"
194
189
  initiate_shutdown
195
190
  end
196
191
  when :example_pending
197
- @results[:pending] += 1
192
+ @results.example_pending
198
193
  when :example_retried
199
194
  if @display_retry_backtraces
200
195
  puts "\nExample #{message[:description]} retried:\n #{message[:location]}\n #{message[:exception_class]}: #{message[:message]}\n#{message[:backtrace].map { " #{_1}" }.join("\n")}\n"
201
196
  end
202
197
  when :spec_complete
203
- @results[:spec_files_processed] += 1
204
- worker[:current_spec] = nil
205
- assign_work(worker)
198
+ @results.spec_file_complete
199
+ worker_process.current_spec = nil
200
+ assign_work(worker_process)
206
201
  when :spec_error
207
- @results[:errors] << message
202
+ @results.spec_file_error(message)
208
203
  debug "Spec error details: #{message[:error]}"
209
- worker[:current_spec] = nil
210
- assign_work(worker)
204
+ worker_process.current_spec = nil
205
+ assign_work(worker_process)
211
206
  when :spec_interrupted
212
207
  debug "Spec interrupted: #{message[:file]}"
213
- worker[:current_spec] = nil
208
+ worker_process.current_spec = nil
214
209
  end
215
- @formatter.handle_worker_message(worker, message, @results)
210
+ @formatter.handle_worker_message(worker_process, message, @results)
216
211
  end
217
212
 
218
- def assign_work(worker)
219
- if @spec_queue.empty? || @shutting_down
220
- debug "No more work for worker #{worker[:number]}, sending shutdown"
221
- worker[:socket].send_message({ type: :shutdown })
222
- cleanup_worker(worker)
213
+ def assign_work(worker_process)
214
+ if @spec_queue.empty? || @results.shutting_down?
215
+ debug "No more work for worker #{worker_process.number}, sending shutdown"
216
+ worker_process.socket.send_message({ type: :shutdown })
217
+ cleanup_worker_process(worker_process)
223
218
  else
224
- @specs_started_at ||= Time.now
219
+ @results.spec_file_assigned
225
220
  spec_file = @spec_queue.shift
226
- worker[:current_spec] = spec_file
227
- debug "Assigning #{spec_file} to worker #{worker[:number]}"
221
+ worker_process.current_spec = spec_file
222
+ debug "Assigning #{spec_file} to worker #{worker_process.number}"
228
223
  message = { type: :worker_assigned_spec, file: spec_file }
229
- worker[:socket].send_message(message)
230
- @formatter.handle_worker_message(worker, message, @results)
224
+ worker_process.socket.send_message(message)
225
+ @formatter.handle_worker_message(worker_process, message, @results)
231
226
  end
232
227
  end
233
228
 
234
- def cleanup_worker(worker, status: :shut_down)
235
- @workers.delete(worker[:pid])
236
- worker[:socket].close
237
- worker[:status] = status
238
- @formatter.handle_worker_message(worker, { type: :worker_shut_down }, @results)
239
- Process.wait(worker[:pid])
229
+ def cleanup_worker_process(worker_process, status: :shut_down)
230
+ @worker_processes.delete(worker_process.pid)
231
+ worker_process.socket.close
232
+ worker_process.status = status
233
+ @formatter.handle_worker_message(worker_process, { type: :worker_shut_down }, @results)
234
+ Process.wait(worker_process.pid)
240
235
  rescue Errno::ECHILD
241
236
  nil
242
237
  end
243
238
 
244
239
  def reap_workers
245
- dead_workers = @workers.each_with_object([]) do |(pid, worker), memo|
240
+ dead_worker_processes = @worker_processes.each_with_object([]) do |(pid, worker), memo|
246
241
  result = Process.waitpid(pid, Process::WNOHANG)
247
242
  memo << [worker, $CHILD_STATUS] if result
248
243
  end
249
244
 
250
- dead_workers.each do |worker, exitstatus|
251
- cleanup_worker(worker, status: :terminated)
252
- debug "Worker #{worker[:number]} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
245
+ dead_worker_processes.each do |worker_process, exitstatus|
246
+ cleanup_worker_process(worker_process, status: :terminated)
247
+ @results.worker_crashed
248
+ debug "Worker #{worker_process.number} exited with status #{exitstatus.exitstatus}, signal #{exitstatus.termsig}"
253
249
  end
254
250
  rescue Errno::ECHILD
255
251
  nil
@@ -257,12 +253,13 @@ module RSpec
257
253
 
258
254
  def print_summary
259
255
  puts "\n\n"
260
- puts "=" * ($stdout.tty? ? $stdout.winsize[1] : 80)
261
- puts "Results: #{@results[:passed]} passed, #{@results[:failed]} failed, #{@results[:pending]} pending"
256
+ puts "Randomized with seed #{@seed}"
257
+ puts "#{colorize("#{@results.passed} passed", :green)}, #{colorize("#{@results.failed} failed", :red)}, #{colorize("#{@results.pending} pending", :yellow)}"
258
+ puts colorize("Worker crashes: #{@results.worker_crashes}", :red) if @results.worker_crashes.positive?
262
259
 
263
- if @results[:errors].any?
260
+ if @results.errors.any?
264
261
  puts "\nFailures:\n\n"
265
- @results[:errors].each_with_index do |error, i|
262
+ @results.errors.each_with_index do |error, i|
266
263
  puts " #{i + 1}) #{error[:description]}"
267
264
  puts " #{error[:location]}"
268
265
  puts " #{error[:message]}" if error[:message]
@@ -274,14 +271,17 @@ module RSpec
274
271
  end
275
272
  end
276
273
 
277
- puts "Randomized with seed #{@seed}"
278
- puts "Specs took: #{(Time.now - (@specs_started_at || @started_at)).to_f.round(2)}s"
279
- puts "Total runtime: #{(Time.now - @started_at).to_f.round(2)}s"
274
+ puts "Specs took: #{@results.specs_runtime.round(2)}s"
275
+ puts "Total runtime: #{@results.total_runtime.round(2)}s"
276
+ puts "Suite: #{@results.success? ? colorize("PASSED", :green) : colorize("FAILED", :red)}"
277
+ end
278
+
279
+ def colorize(string, color)
280
+ $stdout.tty? ? ANSI.colorize(string, color) : string
280
281
  end
281
282
 
282
283
  def exit_with_status
283
- success = @results[:failed].zero? && @results[:errors].empty? && @results[:worker_crashes].zero? && !@shutting_down
284
- Kernel.exit(success ? 0 : 1)
284
+ Kernel.exit(@results.success? ? 0 : 1)
285
285
  end
286
286
 
287
287
  def debug(message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.1"
5
+ VERSION = "1.0.2"
6
6
  end
7
7
  end
@@ -1,18 +1,6 @@
1
1
  # frozen_string_literal: true
2
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
3
+ require_relative 'ext/rspec'
16
4
 
17
5
  module RSpec
18
6
  module Conductor
@@ -120,7 +108,10 @@ module RSpec
120
108
  RSpec.world.reset
121
109
  RSpec.configuration.reset_reporter
122
110
  RSpec.configuration.files_or_directories_to_run = []
123
- setup_formatter(ConductorFormatter.new(@socket, file, -> { check_for_shutdown }))
111
+ RSpec.configuration.output_stream = null_io_out
112
+ RSpec.configuration.error_stream = null_io_out
113
+ RSpec.configuration.formatter_loader.formatters.clear
114
+ RSpec.configuration.add_formatter(RSpecSubscriber.new(@socket, file, -> { check_for_shutdown }))
124
115
 
125
116
  begin
126
117
  debug "Loading spec file: #{file}"
@@ -172,13 +163,6 @@ module RSpec
172
163
  @parsed_options ||= RSpec::Core::ConfigurationOptions.new(@rspec_args)
173
164
  end
174
165
 
175
- def setup_formatter(conductor_formatter)
176
- RSpec.configuration.output_stream = null_io_out
177
- RSpec.configuration.error_stream = null_io_out
178
- RSpec.configuration.formatter_loader.formatters.clear
179
- RSpec.configuration.add_formatter(conductor_formatter)
180
- end
181
-
182
166
  def debug(message)
183
167
  $stderr.puts "[worker #{@worker_number}] #{message}"
184
168
  end
@@ -191,73 +175,5 @@ module RSpec
191
175
  @null_io_in ||= File.open(File::NULL, "r")
192
176
  end
193
177
  end
194
-
195
- class ConductorFormatter
196
- RSpec::Core::Formatters.register self,
197
- :example_passed,
198
- :example_failed,
199
- :example_pending
200
-
201
- def initialize(socket, file, shutdown_check)
202
- @socket = socket
203
- @file = file
204
- @shutdown_check = shutdown_check
205
- end
206
-
207
- def example_passed(notification)
208
- @socket.send_message(
209
- type: :example_passed,
210
- file: @file,
211
- description: notification.example.full_description,
212
- location: notification.example.location,
213
- run_time: notification.example.execution_result.run_time
214
- )
215
- @shutdown_check.call
216
- end
217
-
218
- def example_failed(notification)
219
- ex = notification.example
220
- @socket.send_message(
221
- type: :example_failed,
222
- file: @file,
223
- description: ex.full_description,
224
- location: ex.location,
225
- run_time: ex.execution_result.run_time,
226
- exception_class: ex.execution_result.exception&.class&.name,
227
- message: ex.execution_result.exception&.message,
228
- backtrace: format_backtrace(ex.execution_result.exception&.backtrace, ex.metadata)
229
- )
230
- @shutdown_check.call
231
- end
232
-
233
- def example_pending(notification)
234
- ex = notification.example
235
- @socket.send_message(
236
- type: :example_pending,
237
- file: @file,
238
- description: ex.full_description,
239
- location: ex.location,
240
- pending_message: ex.execution_result.pending_message
241
- )
242
- @shutdown_check.call
243
- end
244
-
245
- def retry(ex)
246
- @socket.send_message(
247
- type: :example_retried,
248
- description: ex.full_description,
249
- location: ex.location,
250
- exception_class: ex.exception&.class&.name,
251
- message: ex.exception&.message,
252
- backtrace: format_backtrace(ex.exception&.backtrace, ex.metadata)
253
- )
254
- end
255
-
256
- private
257
-
258
- def format_backtrace(backtrace, example_metadata = nil)
259
- RSpec::Core::BacktraceFormatter.new.format_backtrace(backtrace || [], example_metadata || {})
260
- end
261
- end
262
178
  end
263
179
  end
@@ -0,0 +1,13 @@
1
+ module RSpec
2
+ module Conductor
3
+ WorkerProcess = Struct.new(:pid, :number, :status, :socket, :current_spec, keyword_init: true) do
4
+ def hash
5
+ [number].hash
6
+ end
7
+
8
+ def eql?(other)
9
+ other.is_a?(self.class) && other.number == number
10
+ end
11
+ end
12
+ end
13
+ end
@@ -6,7 +6,11 @@ require_relative "conductor/version"
6
6
  require_relative "conductor/protocol"
7
7
  require_relative "conductor/server"
8
8
  require_relative "conductor/worker"
9
+ require_relative "conductor/results"
10
+ require_relative "conductor/worker_process"
9
11
  require_relative "conductor/cli"
12
+ require_relative "conductor/ansi"
13
+ require_relative "conductor/rspec_subscriber"
10
14
  require_relative "conductor/formatters/plain"
11
15
  require_relative "conductor/formatters/ci"
12
16
  require_relative "conductor/formatters/fancy"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspec-conductor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Abramov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-22 00:00:00.000000000 Z
11
+ date: 2026-01-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec-core
@@ -40,14 +40,19 @@ files:
40
40
  - Rakefile
41
41
  - exe/rspec-conductor
42
42
  - lib/rspec/conductor.rb
43
+ - lib/rspec/conductor/ansi.rb
43
44
  - lib/rspec/conductor/cli.rb
45
+ - lib/rspec/conductor/ext/rspec.rb
44
46
  - lib/rspec/conductor/formatters/ci.rb
45
47
  - lib/rspec/conductor/formatters/fancy.rb
46
48
  - lib/rspec/conductor/formatters/plain.rb
47
49
  - lib/rspec/conductor/protocol.rb
50
+ - lib/rspec/conductor/results.rb
51
+ - lib/rspec/conductor/rspec_subscriber.rb
48
52
  - lib/rspec/conductor/server.rb
49
53
  - lib/rspec/conductor/version.rb
50
54
  - lib/rspec/conductor/worker.rb
55
+ - lib/rspec/conductor/worker_process.rb
51
56
  - rspec-conductor.gemspec
52
57
  homepage: https://github.com/markiz/rspec-conductor
53
58
  licenses: