rspec-conductor 1.0.0 → 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: 17242ccb102c17f1a469bf4e105d62f2469372059d9416bcf4ab87df4f698143
4
- data.tar.gz: ab29ad25bded7e52af8b762c532f2d30d9c5451a954abfcd3330430840dda107
3
+ metadata.gz: 50d787ec617be586c7f66328850d62f2253bd5f1a50d1effb6417ccfdb4ee3a2
4
+ data.tar.gz: d71b0b92aa35eb18991ae91a2d3c95d532da79beb80e304e227da87dd214656a
5
5
  SHA512:
6
- metadata.gz: 7d7aa04766f0b086c747c2c7046e6dc3050c6376389c05b68db1a83a99570c65e5d0e993df8bcd4b2379acb68d6047f4ec15dfba01423714eda39f8a1cb4da89
7
- data.tar.gz: c801bac92877545bef4083eb0875efb291e58ad1af80961f7ece72fee86e2abbf3f3916c43d823eaeaefb81f605254d22e96a1ae8eddda046e6fd846fe1237de
6
+ metadata.gz: fe26f4f57787171577683b762e57ac8360b52d9949fbe1c6f4b6b8161da7747815e1b1296b1098ae05f9d32a605089847a0bb1c2f6f12f1273c50e7398118099
7
+ data.tar.gz: 6377b5235c007f274ad83778809319f7eee8e4cc484187202fb8919b8ef8e9ca7122e96e10b1948b6c9b7ca7db2e639bccc8d0e58c5781f17017cb40b119f1bd
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [1.0.2] - 2026-01-09
2
+ - Fix --postfork-require options
3
+ - Fix worker crashes counter
4
+
5
+ ## [1.0.1] - 2025-12-21
6
+
7
+ - Fix spec_helper/rails_helper path finding [Thanks @diego-aslz]
8
+ - Add --prefork-require and --no-prefork-require CLI options for non-rails apps or rails setups where loading config/application.rb is not entirely safe
9
+ - Add --postfork-require and --no-postfork-require CLI options for flexibility
10
+
1
11
  ## [1.0.0] - 2025-12-21
2
12
 
3
13
  - Initial release
data/README.md CHANGED
@@ -12,27 +12,36 @@ User experience was designed to serve as a simple, almost drop-in, replacement f
12
12
 
13
13
  ![rspec-conductor demo](https://github.com/user-attachments/assets/2b598635-3192-4aa0-bb39-2af01b93bb4a)
14
14
 
15
- ## Usage
16
-
17
- Set up the databases:
15
+ ## Installation
18
16
 
19
- ```
20
- rails 'parallel:drop[10]' 'parallel:setup[10]'
17
+ Add to your Gemfile:
21
18
 
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]'
19
+ ```ruby
20
+ gem 'rspec-conductor'
24
21
  ```
25
22
 
26
- Then launch the CLI app (see also `bin/rspec-conductor --help`):
23
+ ## Usage
27
24
 
28
- ```
25
+ ```bash
29
26
  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
27
+ rspec-conductor --workers 10 -- --tag '~@flaky' spec
28
+ # shorthand for setting the paths when there are no rspec options is also supported
29
+ rspec-conductor --workers 10 spec
32
30
  ```
33
31
 
34
32
  `--verbose` flag is especially useful for troubleshooting.
35
33
 
34
+ To set up the databases (if you are using this with rails) you can use a rake task from the parallel_tests gem
35
+
36
+ ```bash
37
+ rails 'parallel:drop[10]' 'parallel:setup[10]'
38
+
39
+ # if you like the first-is-1 mode, keeping your parallel test envs separate from your regular env:
40
+ PARALLEL_TEST_FIRST_IS_1=true rails 'parallel:drop[10]' 'parallel:setup[10]'
41
+ ```
42
+
43
+ I might consider porting that rake task in the near future.
44
+
36
45
  ## Mechanics
37
46
 
38
47
  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.
@@ -41,6 +50,13 @@ Server process preloads the `rails_helper`, prepares a list of files to work, th
41
50
 
42
51
  * In order to make the CLI executable load and run fast, do not add any dependencies. That includes `active_support`.
43
52
 
53
+ ## Troubleshooting
54
+
55
+ * `+[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called.` on M-based Mac machines
56
+ * This is a common issue with ruby code, compiled libraries and forking. Set `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` environment variable to work around this
57
+ * Something gets loaded that shouldn't get loaded, or in a different order
58
+ * There are two simple ways to hook into preloads, exposed as CLI flags, `--prefork-require` (defaults to `config/application.rb`) and `--postfork-require` (defaults to either `rails_helper.rb` or `spec_helper.rb`, whichever is present on your machine). You can set any of those to whatever you need and control the load order
59
+
44
60
  ## FAQ
45
61
 
46
62
  * Why not preload the whole rails environment before spawning the workers instead of just `rails_helper`?
@@ -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
@@ -13,6 +13,8 @@ module RSpec
13
13
  fail_fast_after: nil,
14
14
  verbose: false,
15
15
  display_retry_backtraces: false,
16
+ prefork_require: 'config/application.rb',
17
+ postfork_require: :spec_helper,
16
18
  }.freeze
17
19
 
18
20
  def self.run(argv)
@@ -60,6 +62,22 @@ module RSpec
60
62
  @conductor_options[:offset] = n
61
63
  end
62
64
 
65
+ opts.on("--prefork-require FILENAME", String, "Require this file before forking (default: config/application.rb)") do |f|
66
+ @conductor_options[:prefork_require] = f
67
+ end
68
+
69
+ opts.on("--no-prefork-require", "Do not preload config/application.rb") do
70
+ @conductor_options[:prefork_require] = nil
71
+ end
72
+
73
+ opts.on("--postfork-require FILENAME", String, "Require this file after forking (default: either rails_helper.rb or spec_helper.rb, whichever is present)") do |f|
74
+ @conductor_options[:postfork_require] = f
75
+ end
76
+
77
+ opts.on("--no-postfork-require", "Do not load anything post-fork") do
78
+ @conductor_options[:postfork_require] = nil
79
+ end
80
+
63
81
  opts.on("--first-is-1", 'ENV["TEST_ENV_NUMBER"] for the worker 1 is "1" rather than ""') do
64
82
  @conductor_options[:first_is_1] = true
65
83
  end
@@ -97,11 +115,11 @@ module RSpec
97
115
  end
98
116
 
99
117
  def start_server
100
- require_relative "server"
101
-
102
118
  Server.new(
103
119
  worker_count: @conductor_options[:workers],
104
120
  worker_number_offset: @conductor_options[:offset],
121
+ prefork_require: @conductor_options[:prefork_require],
122
+ postfork_require: @conductor_options[:postfork_require],
105
123
  first_is_1: @conductor_options[:first_is_1],
106
124
  seed: @conductor_options[:seed],
107
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,9 +8,14 @@ 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
17
+ # @option prefork_require [String] File required prior to forking
18
+ # @option postfork_require [String, Symbol] File required after forking
14
19
  # @option first_is_1 [Boolean] TEST_ENV_NUMBER for the first worker is "1" instead of ""
15
20
  # @option seed [Integer] Set a predefined starting seed
16
21
  # @option fail_fast_after [Integer, NilClass] Shut down the workers after a certain number of failures
@@ -20,17 +25,17 @@ module RSpec
20
25
  def initialize(worker_count:, rspec_args:, **opts)
21
26
  @worker_count = worker_count
22
27
  @worker_number_offset = opts.fetch(:worker_number_offset, 0)
28
+ @prefork_require = opts.fetch(:prefork_require, nil)
29
+ @postfork_require = opts.fetch(:postfork_require, nil)
23
30
  @first_is_one = opts.fetch(:first_is_1, false)
24
- @seed = opts[:seed] || (Random.new_seed % 65_536)
31
+ @seed = opts[:seed] || (Random.new_seed % MAX_SEED)
25
32
  @fail_fast_after = opts[:fail_fast_after]
26
33
  @display_retry_backtraces = opts.fetch(:display_retry_backtraces, false)
27
34
  @verbose = opts.fetch(:verbose, false)
28
35
 
29
36
  @rspec_args = rspec_args
30
- @workers = {}
37
+ @worker_processes = {}
31
38
  @spec_queue = []
32
- @started_at = Time.now
33
- @shutting_down = false
34
39
  @formatter = case opts[:formatter]
35
40
  when "ci"
36
41
  Formatters::CI.new
@@ -39,9 +44,9 @@ module RSpec
39
44
  when "plain"
40
45
  Formatters::Plain.new
41
46
  else
42
- Formatters::Fancy.recommended? ? Formatters::Fancy.new : Formatters::Plain.new
47
+ (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy.new : Formatters::Plain.new
43
48
  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 }
49
+ @results = Results.new
45
50
  end
46
51
 
47
52
  def run
@@ -55,6 +60,7 @@ module RSpec
55
60
 
56
61
  start_workers
57
62
  run_event_loop
63
+ @results.suite_complete
58
64
 
59
65
  print_summary
60
66
  exit_with_status
@@ -63,11 +69,18 @@ module RSpec
63
69
  private
64
70
 
65
71
  def preload_application
66
- application = File.expand_path("config/application", Conductor.root)
72
+ if !@prefork_require
73
+ debug "Prefork require not set, skipping..."
74
+ return
75
+ end
76
+
77
+ preload = File.expand_path(@prefork_require, Conductor.root)
67
78
 
68
- if File.exist?(application)
69
- debug "Preloading config/application.rb..."
70
- require File.expand_path("config/application", Conductor.root)
79
+ if File.exist?(preload)
80
+ debug "Preloading #{@prefork_require}..."
81
+ require preload
82
+ else
83
+ debug "#{@prefork_require} not found, skipping..."
71
84
  end
72
85
 
73
86
  debug "Application preloaded, autoload paths configured"
@@ -76,18 +89,17 @@ module RSpec
76
89
  def setup_signal_handlers
77
90
  %w[INT TERM].each do |signal|
78
91
  Signal.trap(signal) do
79
- @workers.any? ? initiate_shutdown : Kernel.exit(1)
92
+ @worker_processes.any? ? initiate_shutdown : Kernel.exit(1)
80
93
  end
81
94
  end
82
95
  end
83
96
 
84
97
  def initiate_shutdown
85
- return if @shutting_down
86
-
87
- @shutting_down = true
98
+ return if @results.shutting_down?
88
99
 
100
+ @results.shut_down
89
101
  puts "Shutting down..."
90
- @workers.each_value { |w| w[:socket]&.send_message({ type: :shutdown }) }
102
+ @worker_processes.each_value { |w| w.socket&.send_message({ type: :shutdown }) }
91
103
  end
92
104
 
93
105
  def build_spec_queue
@@ -97,7 +109,7 @@ module RSpec
97
109
  config.files_or_directories_to_run = paths
98
110
 
99
111
  @spec_queue = config.files_to_run.shuffle(random: Random.new(@seed))
100
- @results[:spec_files_total] = @spec_queue.size
112
+ @results.spec_files_total = @spec_queue.size
101
113
  end
102
114
 
103
115
  def parsed_rspec_args
@@ -133,111 +145,107 @@ module RSpec
133
145
  worker_number: worker_number,
134
146
  socket: Protocol::Socket.new(child_socket),
135
147
  rspec_args: @rspec_args,
136
- verbose: @verbose
148
+ verbose: @verbose,
149
+ postfork_require: @postfork_require,
137
150
  ).run
138
151
  end
139
152
 
140
153
  child_socket.close
141
154
  debug "Worker #{worker_number} started with pid #{pid}"
142
155
 
143
- @workers[pid] = {
156
+ @worker_processes[pid] = WorkerProcess.new(
144
157
  pid: pid,
145
158
  number: worker_number,
146
159
  status: :running,
147
160
  socket: Protocol::Socket.new(parent_socket),
148
161
  current_spec: nil,
149
- }
150
- assign_work(@workers[pid])
162
+ )
163
+ assign_work(@worker_processes[pid])
151
164
  end
152
165
 
153
166
  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
-
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)) }
163
171
  reap_workers
164
172
  end
165
173
  end
166
174
 
167
- def handle_worker_message(worker)
168
- message = worker[:socket].receive_message
175
+ def handle_worker_message(worker_process)
176
+ message = worker_process.socket.receive_message
169
177
  return unless message
170
178
 
171
- debug "Worker #{worker[:number]}: #{message[:type]}"
179
+ debug "Worker #{worker_process.number}: #{message[:type]}"
172
180
 
173
181
  case message[:type].to_sym
174
182
  when :example_passed
175
- @results[:passed] += 1
183
+ @results.example_passed
176
184
  when :example_failed
177
- @results[:failed] += 1
178
- @results[:errors] << message
185
+ @results.example_failed(message)
179
186
 
180
- if @fail_fast_after && @results[:failed] >= @fail_fast_after && !@shutting_down
181
- debug "Shutting after #{@results[:failed]} failures"
187
+ if @fail_fast_after && @results.failed >= @fail_fast_after
188
+ debug "Shutting after #{@results.failed} failures"
182
189
  initiate_shutdown
183
190
  end
184
191
  when :example_pending
185
- @results[:pending] += 1
192
+ @results.example_pending
186
193
  when :example_retried
187
194
  if @display_retry_backtraces
188
195
  puts "\nExample #{message[:description]} retried:\n #{message[:location]}\n #{message[:exception_class]}: #{message[:message]}\n#{message[:backtrace].map { " #{_1}" }.join("\n")}\n"
189
196
  end
190
197
  when :spec_complete
191
- @results[:spec_files_processed] += 1
192
- worker[:current_spec] = nil
193
- assign_work(worker)
198
+ @results.spec_file_complete
199
+ worker_process.current_spec = nil
200
+ assign_work(worker_process)
194
201
  when :spec_error
195
- @results[:errors] << message
202
+ @results.spec_file_error(message)
196
203
  debug "Spec error details: #{message[:error]}"
197
- worker[:current_spec] = nil
198
- assign_work(worker)
204
+ worker_process.current_spec = nil
205
+ assign_work(worker_process)
199
206
  when :spec_interrupted
200
207
  debug "Spec interrupted: #{message[:file]}"
201
- worker[:current_spec] = nil
208
+ worker_process.current_spec = nil
202
209
  end
203
- @formatter.handle_worker_message(worker, message, @results)
210
+ @formatter.handle_worker_message(worker_process, message, @results)
204
211
  end
205
212
 
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)
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)
211
218
  else
212
- @specs_started_at ||= Time.now
219
+ @results.spec_file_assigned
213
220
  spec_file = @spec_queue.shift
214
- worker[:current_spec] = spec_file
215
- 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}"
216
223
  message = { type: :worker_assigned_spec, file: spec_file }
217
- worker[:socket].send_message(message)
218
- @formatter.handle_worker_message(worker, message, @results)
224
+ worker_process.socket.send_message(message)
225
+ @formatter.handle_worker_message(worker_process, message, @results)
219
226
  end
220
227
  end
221
228
 
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])
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)
228
235
  rescue Errno::ECHILD
229
236
  nil
230
237
  end
231
238
 
232
239
  def reap_workers
233
- dead_workers = @workers.each_with_object([]) do |(pid, worker), memo|
240
+ dead_worker_processes = @worker_processes.each_with_object([]) do |(pid, worker), memo|
234
241
  result = Process.waitpid(pid, Process::WNOHANG)
235
242
  memo << [worker, $CHILD_STATUS] if result
236
243
  end
237
244
 
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}"
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}"
241
249
  end
242
250
  rescue Errno::ECHILD
243
251
  nil
@@ -245,12 +253,13 @@ module RSpec
245
253
 
246
254
  def print_summary
247
255
  puts "\n\n"
248
- puts "=" * ($stdout.tty? ? $stdout.winsize[1] : 80)
249
- 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?
250
259
 
251
- if @results[:errors].any?
260
+ if @results.errors.any?
252
261
  puts "\nFailures:\n\n"
253
- @results[:errors].each_with_index do |error, i|
262
+ @results.errors.each_with_index do |error, i|
254
263
  puts " #{i + 1}) #{error[:description]}"
255
264
  puts " #{error[:location]}"
256
265
  puts " #{error[:message]}" if error[:message]
@@ -262,14 +271,17 @@ module RSpec
262
271
  end
263
272
  end
264
273
 
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"
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
268
281
  end
269
282
 
270
283
  def exit_with_status
271
- success = @results[:failed].zero? && @results[:errors].empty? && @results[:worker_crashes].zero? && !@shutting_down
272
- Kernel.exit(success ? 0 : 1)
284
+ Kernel.exit(@results.success? ? 0 : 1)
273
285
  end
274
286
 
275
287
  def debug(message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.2"
6
6
  end
7
7
  end
@@ -1,27 +1,17 @@
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
19
7
  class Worker
20
- def initialize(worker_number:, socket:, rspec_args: [], verbose: false)
8
+ def initialize(worker_number:, socket:, rspec_args: [], verbose: false, postfork_require: :spec_helper)
21
9
  @worker_number = worker_number
22
10
  @socket = socket
23
11
  @rspec_args = rspec_args
24
12
  @verbose = verbose
13
+ @postfork_require = postfork_require
14
+
25
15
  @message_queue = []
26
16
  end
27
17
 
@@ -29,7 +19,7 @@ module RSpec
29
19
  suppress_output unless @verbose
30
20
  debug "Worker #{@worker_number} starting"
31
21
  setup_load_path
32
- initialize_rspec
22
+ require_postfork_preloads
33
23
 
34
24
  loop do
35
25
  debug "Waiting for message"
@@ -69,18 +59,14 @@ module RSpec
69
59
 
70
60
  def setup_load_path
71
61
  parsed_options.configure(RSpec.configuration)
72
- default_path = RSpec.configuration.default_path || "spec"
62
+ @default_path = RSpec.configuration.default_path || "spec"
63
+ @default_full_path = File.expand_path(@default_path, Conductor.root)
73
64
 
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)
65
+ if Dir.exist?(@default_full_path) && !$LOAD_PATH.include?(@default_full_path)
66
+ $LOAD_PATH.unshift(@default_full_path)
81
67
  end
82
68
 
83
- debug "Load path (spec dirs): #{$LOAD_PATH.grep(/spec/)}"
69
+ debug "Load path (spec dirs): #{$LOAD_PATH.inspect}"
84
70
  end
85
71
 
86
72
  def suppress_output
@@ -89,17 +75,29 @@ module RSpec
89
75
  $stdin.reopen(null_io_in)
90
76
  end
91
77
 
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
78
+ def require_postfork_preloads
79
+ if @postfork_require == :spec_helper
80
+ rails_helper = File.expand_path("rails_helper.rb", @default_full_path)
81
+ spec_helper = File.expand_path("spec_helper.rb", @default_full_path)
82
+ if File.exist?(rails_helper)
83
+ debug "Requiring rails_helper to boot Rails..."
84
+ require rails_helper
85
+ elsif File.exist?(spec_helper)
86
+ debug "Requiring spec_helper..."
87
+ require spec_helper
88
+ else
89
+ debug "Neither rails_helper, nor spec_helper found, skipping..."
90
+ end
91
+ elsif @postfork_require
92
+ required_file = File.expand_path(@postfork_require, @default_full_path)
93
+ if File.exist?(required_file)
94
+ debug "Requiring #{required_file}..."
95
+ require required_file
96
+ else
97
+ debug "#{required_file} not found, skipping..."
98
+ end
101
99
  else
102
- debug "Could detect neither rails_helper nor spec_helper"
100
+ debug "Skipping postfork require..."
103
101
  end
104
102
 
105
103
  debug "RSpec initialized, running before(:suite) hooks"
@@ -110,7 +108,10 @@ module RSpec
110
108
  RSpec.world.reset
111
109
  RSpec.configuration.reset_reporter
112
110
  RSpec.configuration.files_or_directories_to_run = []
113
- 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 }))
114
115
 
115
116
  begin
116
117
  debug "Loading spec file: #{file}"
@@ -162,13 +163,6 @@ module RSpec
162
163
  @parsed_options ||= RSpec::Core::ConfigurationOptions.new(@rspec_args)
163
164
  end
164
165
 
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
166
  def debug(message)
173
167
  $stderr.puts "[worker #{@worker_number}] #{message}"
174
168
  end
@@ -181,73 +175,5 @@ module RSpec
181
175
  @null_io_in ||= File.open(File::NULL, "r")
182
176
  end
183
177
  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
178
  end
253
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.0
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-21 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: