rspec-conductor 1.0.2 → 1.0.3

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: 50d787ec617be586c7f66328850d62f2253bd5f1a50d1effb6417ccfdb4ee3a2
4
- data.tar.gz: d71b0b92aa35eb18991ae91a2d3c95d532da79beb80e304e227da87dd214656a
3
+ metadata.gz: '082ad795d53684a33439d9c56e8d4ff175460d6b335bf25dfbc0114f913a764e'
4
+ data.tar.gz: ddedba7528e3cd7eaa045b55814c1a4d85a09bb69d9dcceb57c843d00244cec8
5
5
  SHA512:
6
- metadata.gz: fe26f4f57787171577683b762e57ac8360b52d9949fbe1c6f4b6b8161da7747815e1b1296b1098ae05f9d32a605089847a0bb1c2f6f12f1273c50e7398118099
7
- data.tar.gz: 6377b5235c007f274ad83778809319f7eee8e4cc484187202fb8919b8ef8e9ca7122e96e10b1948b6c9b7ca7db2e639bccc8d0e58c5781f17017cb40b119f1bd
6
+ metadata.gz: 9620094891ec8e360d4427f001711ea4b9e2393020f83706ba86840e135d636d04cb13f2442e97b71a8cbc4bbe12b75354ae86c7e3899fa06035a553b2a29377
7
+ data.tar.gz: 6842b752e4d66f10b5fed3070aa7fcb7edc7470c3b873fc5da9c8ec86f113e36d1743b0d4193c2966c80acb6638c4cb1b3626b1a2060e813334c2c62392b8d14
data/CHANGELOG.md CHANGED
@@ -1,4 +1,11 @@
1
+ ## [1.0.3] - 2026-02-08
2
+
3
+ - rake tasks for database preparation
4
+ - some internal retooling for terminal ui inner machinery (mostly affecting the `fancy` formatter)
5
+
6
+
1
7
  ## [1.0.2] - 2026-01-09
8
+
2
9
  - Fix --postfork-require options
3
10
  - Fix worker crashes counter
4
11
 
data/Gemfile CHANGED
@@ -10,3 +10,4 @@ if ENV["RSPEC_VERSION"].to_s.empty?
10
10
  else
11
11
  gem "rspec", "~> #{ENV["RSPEC_VERSION"]}"
12
12
  end
13
+ gem "benchmark-ips"
data/README.md CHANGED
@@ -31,20 +31,23 @@ rspec-conductor --workers 10 spec
31
31
 
32
32
  `--verbose` flag is especially useful for troubleshooting.
33
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
34
+ ## Mechanics
35
35
 
36
- ```bash
37
- rails 'parallel:drop[10]' 'parallel:setup[10]'
36
+ 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 yet.
38
37
 
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
- ```
38
+ ## Setting up the databases in Rails
42
39
 
43
- I might consider porting that rake task in the near future.
40
+ In order to bootstrap the test environment, there is a rake task:
44
41
 
45
- ## Mechanics
42
+ ```bash
43
+ # Recreate and seed test databases with TEST_ENV_NUMBER 1 to 10
44
+ rails rspec_conductor:setup[10]
45
+
46
+ # If you like the first-is-1 mode, keeping your parallel test envs separate from your regular env:
47
+ RSPEC_CONDUCTOR_FIRST_IS_1=1 rails rspec_conductor:setup[10]
48
+ ```
46
49
 
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.
50
+ You can also set the env variable `RSPEC_CONDUCTOR_DEFAULT_WORKER_COUNT` to change the default worker count to avoid typing the quotes for the rake task arguments in zsh.
48
51
 
49
52
  ## Development notes
50
53
 
@@ -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 CI
5
- include Conductor::ANSI
7
+ include Util::ANSI
6
8
 
7
9
  DEFAULT_PRINTOUT_INTERVAL = 10
8
10
 
@@ -1,160 +1,121 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "pathname"
2
- require "set"
3
4
 
4
5
  module RSpec
5
6
  module Conductor
6
7
  module Formatters
7
8
  class Fancy
8
- include Conductor::ANSI
9
+ include Util::ANSI
9
10
 
10
11
  def self.recommended?
11
12
  $stdout.tty? && $stdout.winsize[0] >= 30 && $stdout.winsize[1] >= 80
12
13
  end
13
14
 
14
- def initialize
15
- @worker_processes = Set.new
15
+ def initialize(worker_count:)
16
+ @worker_processes = {}
17
+ @terminal = Util::Terminal.new
16
18
  @last_rendered_lines = []
17
- @dots = []
19
+ @dots_string = +""
18
20
  @last_error = nil
21
+
22
+ @progress_bar_line = @terminal.line
23
+ @terminal.puts
24
+ @workers_box = @terminal.box
25
+ @worker_lines = worker_count.times.to_h { |i| [i + 1, @workers_box.line] }
26
+ @terminal.puts
27
+ @dots_line = @terminal.line(truncate: false)
28
+ @terminal.puts
29
+ @last_error_line = @terminal.line(truncate: false)
19
30
  end
20
31
 
21
32
  def handle_worker_message(worker_process, message, results)
22
- @worker_processes << worker_process
23
33
  public_send(message[:type], worker_process, message) if respond_to?(message[:type])
24
- redraw(results)
34
+ redraw(worker_process, results)
25
35
  end
26
36
 
27
37
  def example_passed(_worker_process, _message)
28
- @dots << { char: ".", color: :green }
38
+ dot ".", :green
29
39
  end
30
40
 
31
41
  def example_failed(_worker_process, message)
32
- @dots << { char: "F", color: :red }
42
+ dot "F", :red
33
43
  @last_error = message.slice(:description, :location, :exception_class, :message, :backtrace)
34
44
  end
35
45
 
36
46
  def example_retried(_worker_process, _message)
37
- @dots << { char: "R", color: :magenta }
47
+ dot "R", :magenta
38
48
  end
39
49
 
40
50
  def example_pending(_worker_process, _message)
41
- @dots << { char: "*", color: :yellow }
51
+ dot "*", :yellow
42
52
  end
43
53
 
44
54
  private
45
55
 
46
- def redraw(results)
47
- print_cursor_up(rewrap_lines(@last_rendered_lines).length)
48
-
49
- lines = []
50
- lines << progress_bar(results)
51
- lines << ""
52
- lines.concat(worker_lines)
53
- lines << ""
54
- lines << @dots.map { |dot| colorize(dot[:char], dot[:color]) }.join
55
- lines << ""
56
- lines.concat(error_lines) if @last_error
57
- lines = rewrap_lines(lines)
58
-
59
- lines.each_with_index do |line, i|
60
- if @last_rendered_lines[i] == line
61
- print_cursor_down(1)
62
- else
63
- print_clear_line
64
- puts line
65
- end
66
- end
56
+ def redraw(worker_process, results)
57
+ update_worker_status_line(worker_process)
58
+ update_results_line(results)
59
+ update_errors_line
60
+ @terminal.redraw
61
+ @terminal.scroll_to_bottom
62
+ end
67
63
 
68
- if @last_rendered_lines.length && lines.length < @last_rendered_lines.length
69
- (@last_rendered_lines.length - lines.length).times do
70
- print_clear_line
71
- puts
72
- end
73
- print_cursor_up(@last_rendered_lines.length - lines.length)
74
- end
64
+ def dot(text, color)
65
+ @dots_string << colorize(text, color)
66
+ @dots_line.update(@dots_string, redraw: false)
67
+ end
75
68
 
76
- @last_rendered_lines = lines
69
+ def update_worker_status_line(worker_process)
70
+ status = colorize("Worker #{worker_process.number}: ", :cyan)
71
+ status << if worker_process.status == :shut_down
72
+ "(finished)"
73
+ elsif worker_process.status == :terminated
74
+ colorize("(terminated)", :red)
75
+ elsif worker_process.current_spec
76
+ relative_path(worker_process.current_spec)
77
+ else
78
+ "(idle)"
79
+ end
80
+
81
+ @worker_lines[worker_process.number].update(status, redraw: false)
77
82
  end
78
83
 
79
- def progress_bar(results)
84
+ def update_results_line(results)
80
85
  pct = results.spec_file_processed_percentage
81
86
  bar_width = [tty_width - 20, 20].max
82
-
83
87
  filled = (pct * bar_width).floor
84
88
  empty = bar_width - filled
85
89
 
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
90
+ percentage = " %3d%% (%d/%d)" % [(pct * 100).floor, results.spec_files_processed, results.spec_files_total]
91
+ bar = colorize("[", :reset) + colorize("▓", :green) * filled + colorize(" ", :reset) * empty + colorize("]", :reset)
91
92
 
92
- def worker_lines
93
- @worker_processes.sort_by(&:number).map do |worker_process|
94
- prefix = colorize("Worker #{worker_process.number}: ", :cyan)
95
-
96
- if worker_process.status == :shut_down
97
- prefix + "(finished)"
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)
102
- else
103
- prefix + "(idle)"
104
- end
105
- end
93
+ @progress_bar_line.update(bar + percentage, redraw: false)
106
94
  end
107
95
 
108
- def error_lines
109
- return [] unless @last_error
96
+ def update_errors_line
97
+ return unless @last_error
110
98
 
111
- lines = []
112
- lines << colorize("Most recent failure:", :red)
113
- lines << " #{@last_error[:description]}"
114
- lines << " #{@last_error[:location]}"
99
+ error_components = []
100
+ error_components << colorize("Most recent failure:", :red)
101
+ error_components << " #{@last_error[:description]}"
102
+ error_components << " #{@last_error[:location]}"
115
103
 
116
104
  if @last_error[:exception_class] || @last_error[:message]
117
- err_msg = [@last_error[:exception_class], @last_error[:message]].compact.join(": ")
118
- lines << " #{err_msg}"
105
+ error_components << " #{[@last_error[:exception_class], visible_chars(@last_error[:message])].compact.join(": ")}"
119
106
  end
120
107
 
121
108
  if @last_error[:backtrace]&.any?
122
- lines << " Backtrace:"
123
- @last_error[:backtrace].first(10).each { |l| lines << " #{l}" }
109
+ error_components << " Backtrace:"
110
+ @last_error[:backtrace].first(10).each { |l| error_components << " #{l}" }
124
111
  end
125
112
 
126
- lines
127
- end
128
-
129
- def rewrap_lines(lines)
130
- lines.flat_map do |line|
131
- _, indent, body = line.partition(/^\s*/)
132
- max_width = tty_width - indent.size
133
- split_visible_char_groups(body).each_slice(max_width).map { |chars| "#{indent}#{chars.join}" }
134
- end
113
+ @last_error_line.update(error_components.join("\n"), redraw: false)
135
114
  end
136
115
 
137
116
  def relative_path(filename)
138
117
  Pathname(filename).relative_path_from(Conductor.root).to_s
139
118
  end
140
-
141
- def truncate(str, max_length)
142
- return "" unless str
143
-
144
- str.length > max_length ? "...#{str[-(max_length - 3)..]}" : str
145
- end
146
-
147
- def print_cursor_up(n_lines)
148
- print cursor_up(n_lines) if $stdout.tty?
149
- end
150
-
151
- def print_cursor_down(n_lines)
152
- print cursor_down(n_lines) if $stdout.tty?
153
- end
154
-
155
- def print_clear_line
156
- print clear_line if $stdout.tty?
157
- end
158
119
  end
159
120
  end
160
121
  end
@@ -2,7 +2,7 @@ module RSpec
2
2
  module Conductor
3
3
  module Formatters
4
4
  class Plain
5
- include Conductor::ANSI
5
+ include Util::ANSI
6
6
 
7
7
  def handle_worker_message(_worker_process, message, _results)
8
8
  public_send(message[:type], message) if respond_to?(message[:type])
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path("../../../tasks/rspec_conductor.rake", __FILE__)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -3,7 +3,6 @@
3
3
  require "English"
4
4
  require "socket"
5
5
  require "json"
6
- require "io/console"
7
6
 
8
7
  module RSpec
9
8
  module Conductor
@@ -40,11 +39,11 @@ module RSpec
40
39
  when "ci"
41
40
  Formatters::CI.new
42
41
  when "fancy"
43
- Formatters::Fancy.new
42
+ Formatters::Fancy.new(worker_count: worker_count)
44
43
  when "plain"
45
44
  Formatters::Plain.new
46
45
  else
47
- (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy.new : Formatters::Plain.new
46
+ (!@verbose && Formatters::Fancy.recommended?) ? Formatters::Fancy.new(worker_count: worker_count) : Formatters::Plain.new
48
47
  end
49
48
  @results = Results.new
50
49
  end
@@ -185,7 +184,7 @@ module RSpec
185
184
  @results.example_failed(message)
186
185
 
187
186
  if @fail_fast_after && @results.failed >= @fail_fast_after
188
- debug "Shutting after #{@results.failed} failures"
187
+ debug "Shutting down after #{@results.failed} failures"
189
188
  initiate_shutdown
190
189
  end
191
190
  when :example_pending
@@ -211,13 +210,14 @@ module RSpec
211
210
  end
212
211
 
213
212
  def assign_work(worker_process)
214
- if @spec_queue.empty? || @results.shutting_down?
213
+ spec_file = @spec_queue.shift
214
+
215
+ if @results.shutting_down? || !spec_file
215
216
  debug "No more work for worker #{worker_process.number}, sending shutdown"
216
217
  worker_process.socket.send_message({ type: :shutdown })
217
218
  cleanup_worker_process(worker_process)
218
219
  else
219
220
  @results.spec_file_assigned
220
- spec_file = @spec_queue.shift
221
221
  worker_process.current_spec = spec_file
222
222
  debug "Assigning #{spec_file} to worker #{worker_process.number}"
223
223
  message = { type: :worker_assigned_spec, file: spec_file }
@@ -277,7 +277,7 @@ module RSpec
277
277
  end
278
278
 
279
279
  def colorize(string, color)
280
- $stdout.tty? ? ANSI.colorize(string, color) : string
280
+ $stdout.tty? ? Util::ANSI.colorize(string, color) : string
281
281
  end
282
282
 
283
283
  def exit_with_status
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module RSpec
6
+ module Conductor
7
+ module Util
8
+ module ANSI
9
+ module_function
10
+
11
+ ANSI_SEQUENCE_REGEX = /\e\[[0-9;]*[a-zA-Z]/
12
+ VISIBLE_CHAR_GROUP_REGEX = /#{ANSI_SEQUENCE_REGEX}*[^\e]|#{ANSI_SEQUENCE_REGEX}+/
13
+ COLOR_CODES = {
14
+ # Reset
15
+ reset: "0",
16
+
17
+ # Styles
18
+ bold: "1",
19
+ dim: "2",
20
+ italic: "3",
21
+ underline: "4",
22
+ blink: "5",
23
+ inverse: "7",
24
+ hidden: "8",
25
+ strikethrough: "9",
26
+
27
+ # Foreground colors
28
+ black: "30",
29
+ red: "31",
30
+ green: "32",
31
+ yellow: "33",
32
+ blue: "34",
33
+ magenta: "35",
34
+ cyan: "36",
35
+ white: "37",
36
+
37
+ # Bright foreground colors
38
+ bright_black: "90",
39
+ bright_red: "91",
40
+ bright_green: "92",
41
+ bright_yellow: "93",
42
+ bright_blue: "94",
43
+ bright_magenta: "95",
44
+ bright_cyan: "96",
45
+ bright_white: "97",
46
+
47
+ # Background colors
48
+ bg_black: "40",
49
+ bg_red: "41",
50
+ bg_green: "42",
51
+ bg_yellow: "43",
52
+ bg_blue: "44",
53
+ bg_magenta: "45",
54
+ bg_cyan: "46",
55
+ bg_white: "47",
56
+
57
+ # Bright background colors
58
+ bg_bright_black: "100",
59
+ bg_bright_red: "101",
60
+ bg_bright_green: "102",
61
+ bg_bright_yellow: "103",
62
+ bg_bright_blue: "104",
63
+ bg_bright_magenta: "105",
64
+ bg_bright_cyan: "106",
65
+ bg_bright_white: "107",
66
+ }.freeze
67
+
68
+ def colorize(string, colors, reset: true)
69
+ [
70
+ "\e[",
71
+ Array(colors).map { |color| color_code(color) }.join(";"),
72
+ "m",
73
+ string,
74
+ reset ? "\e[#{color_code(:reset)}m" : nil,
75
+ ].join
76
+ end
77
+
78
+ def color_code(color)
79
+ COLOR_CODES.fetch(color, COLOR_CODES[:reset])
80
+ end
81
+
82
+ def cursor_up(n = 1)
83
+ n.positive? ? "\e[#{n}A" : ""
84
+ end
85
+
86
+ def cursor_down(n = 1)
87
+ n.positive? ? "\e[#{n}B" : ""
88
+ end
89
+
90
+ def cursor_forward(n = 1)
91
+ n.positive? ? "\e[#{n}C" : ""
92
+ end
93
+
94
+ def cursor_back(n = 1)
95
+ n.positive? ? "\e[#{n}D" : ""
96
+ end
97
+
98
+ def cursor_column(col)
99
+ "\e[#{col}G"
100
+ end
101
+
102
+ def cursor_end_of_line
103
+ "\e[999C"
104
+ end
105
+
106
+ def clear_line
107
+ "\e[2K\r"
108
+ end
109
+
110
+ def clear_line_forward
111
+ "\e[K"
112
+ end
113
+
114
+ # sticks invisible characters to visible ones when splitting (so that an ansi color code doesn't get split mid-way)
115
+ def split_visible_char_groups(string)
116
+ string.scan(VISIBLE_CHAR_GROUP_REGEX)
117
+ end
118
+
119
+ def visible_chars(string)
120
+ return unless string
121
+
122
+ string.gsub(ANSI_SEQUENCE_REGEX, '')
123
+ end
124
+
125
+ def tty_width(tty = $stdout)
126
+ return 80 unless tty.tty?
127
+
128
+ tty.winsize[1]
129
+ end
130
+
131
+ def tty_height(tty = $stdout)
132
+ return 50 unless tty.tty?
133
+
134
+ tty.winsize[0]
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ module Util
6
+ class ChildProcess
7
+ attr_reader :pid, :exit_status
8
+
9
+ def self.fork(**args, &block)
10
+ new(**args).fork(&block)
11
+ end
12
+
13
+ def self.wait_all(processes)
14
+ 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) }
22
+ end
23
+
24
+ processes.each(&:finalize)
25
+ end
26
+
27
+ def initialize(on_stdout: nil, on_stderr: nil)
28
+ @on_stdout = on_stdout
29
+ @on_stderr = on_stderr
30
+ @pid = nil
31
+ @exit_status = nil
32
+ @stdout_pipe = nil
33
+ @stderr_pipe = nil
34
+ @stdout_buffer = +""
35
+ @stderr_buffer = +""
36
+ @done = false
37
+ end
38
+
39
+ def pipes
40
+ [@stdout_pipe, @stderr_pipe].compact
41
+ end
42
+
43
+ def fork(&block)
44
+ raise ArgumentError, '.fork should be called with a block' unless block_given?
45
+
46
+ stdout_read, stdout_write = IO.pipe
47
+ stderr_read, stderr_write = IO.pipe
48
+
49
+ @stdout_pipe = stdout_read
50
+ @stderr_pipe = stderr_read
51
+
52
+ @pid = Kernel.fork do
53
+ stdout_read.close
54
+ stderr_read.close
55
+
56
+ $stdout = stdout_write
57
+ $stderr = stderr_write
58
+ STDOUT.reopen(stdout_write)
59
+ STDERR.reopen(stderr_write)
60
+
61
+ begin
62
+ yield
63
+ rescue => e
64
+ stderr_write.puts "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
65
+ exit 1
66
+ ensure
67
+ stdout_write.close
68
+ stderr_write.close
69
+ end
70
+
71
+ exit 0
72
+ end
73
+
74
+ stdout_write.close
75
+ stderr_write.close
76
+
77
+ self
78
+ end
79
+
80
+ def done?
81
+ @done
82
+ end
83
+
84
+ def read_available(pipe)
85
+ return if done?
86
+ return if pipe.closed?
87
+
88
+ buffer, callback = if pipe == @stdout_pipe
89
+ [@stdout_buffer, @on_stdout]
90
+ elsif pipe == @stderr_pipe
91
+ [@stderr_buffer, @on_stderr]
92
+ else
93
+ return
94
+ end
95
+
96
+ begin
97
+ data = pipe.read_nonblock(4096, exception: false)
98
+ if data == :wait_readable
99
+ return
100
+ elsif data.nil? || data.empty?
101
+ pipe.close
102
+ else
103
+ buffer << data
104
+ process_buffer(buffer, callback)
105
+ end
106
+ rescue IOError, EOFError
107
+ pipe.close
108
+ end
109
+ end
110
+
111
+ def finalize
112
+ return if done?
113
+
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
+ @done = true
120
+ self
121
+ end
122
+
123
+ def wait
124
+ self.class.wait_all([self])
125
+ end
126
+
127
+ def success?
128
+ @exit_status == 0
129
+ end
130
+
131
+ private
132
+
133
+ def process_buffer(buffer, callback, partial: false)
134
+ return unless callback
135
+
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
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ module Util
6
+ class ScreenBuffer
7
+ include Util::ANSI
8
+
9
+ def initialize(output = $stdout)
10
+ @output = output
11
+ @lines = []
12
+ @cursor_row = 0
13
+ @cursor_col = 0
14
+ @height = 1
15
+ end
16
+
17
+ # Accepts new state as an array of strings.
18
+ # Computes the minimal diff and writes ANSI escape sequences to @output.
19
+ def update(new_lines)
20
+ unless @output.tty?
21
+ @output.puts Array(new_lines).map { |line| visible_chars(line) }
22
+ return
23
+ end
24
+
25
+ new_lines = Array(new_lines)
26
+ ops = lines_diff(new_lines)
27
+ unless ops.empty?
28
+ @output.print(ops)
29
+ @output.flush
30
+ end
31
+ @lines = new_lines.dup
32
+ end
33
+
34
+ def scroll_to_bottom
35
+ @output.print move_cursor(@height, 0, resize_height: false)
36
+ end
37
+
38
+ private
39
+
40
+ def lines_diff(new_lines)
41
+ buf = +""
42
+
43
+ [new_lines.length, @lines.length].max.times do |row|
44
+ old_line = @lines[row] || ""
45
+ new_line = new_lines[row] || ""
46
+
47
+ next if old_line == new_line
48
+
49
+ old_line_char_groups = split_visible_char_groups(old_line)
50
+ new_line_char_groups = split_visible_char_groups(new_line)
51
+ first_diff_index = new_line_char_groups.size.times.detect { |i| new_line_char_groups[i] != old_line_char_groups[i] } || new_line_char_groups.size
52
+
53
+ changed_part = new_line_char_groups[first_diff_index..-1]
54
+ buf << move_cursor(row, first_diff_index)
55
+ buf << changed_part.join
56
+ buf << clear_line_forward if old_line_char_groups.size > new_line_char_groups.size
57
+
58
+ @cursor_col = new_line_char_groups.size
59
+ end
60
+
61
+ buf
62
+ end
63
+
64
+ def move_cursor(row, col, resize_height: true)
65
+ buf = +""
66
+
67
+ if row < @cursor_row
68
+ buf << cursor_up(@cursor_row - row)
69
+ elsif row > @cursor_row
70
+ # if our current screen buffer is shorter than the row we want to go to,
71
+ # then we need to output new lines until we reach the right height
72
+ buf << cursor_down([row - @cursor_row, @height - @cursor_row - 1].min)
73
+ newlines = row - @height + 1
74
+ if newlines > 0
75
+ buf << "\n" * newlines
76
+ @cursor_col = 0
77
+ end
78
+ end
79
+
80
+ buf << cursor_column(col + 1) if @cursor_col != col
81
+ @height = [@height, row + 1].max if resize_height
82
+ @cursor_row = row
83
+ @cursor_col = col
84
+ buf
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen_buffer"
4
+
5
+ module RSpec
6
+ module Conductor
7
+ module Util
8
+ class Terminal
9
+ include Util::ANSI
10
+
11
+ INDENTATION_REGEX = /^(\s+)(.*)$/
12
+
13
+ class Line
14
+ attr_reader :content, :truncate
15
+
16
+ def initialize(terminal, content, truncate: true, redraw: true)
17
+ @terminal = terminal
18
+ @truncate = truncate
19
+ yield self if block_given?
20
+ update(content, redraw: redraw)
21
+ end
22
+
23
+ def update(new_content, redraw: true)
24
+ @content = new_content
25
+ @terminal.redraw if redraw
26
+ end
27
+
28
+ def to_s
29
+ @content
30
+ end
31
+
32
+ def lines
33
+ [self]
34
+ end
35
+ end
36
+
37
+ class Box
38
+ def initialize(terminal)
39
+ @terminal = terminal
40
+ @contents = []
41
+ yield self if block_given?
42
+ end
43
+
44
+ def line(content = "", truncate: true, redraw: true)
45
+ Line.new(@terminal, content, truncate: truncate, redraw: redraw) { |l| @contents << l }
46
+ end
47
+
48
+ def puts(content = "", redraw: true)
49
+ line(content, truncate: false, redraw: redraw)
50
+ end
51
+
52
+ def box
53
+ Box.new(@terminal) { |b| @contents << b }
54
+ end
55
+
56
+ def lines
57
+ @contents.flat_map(&:lines)
58
+ end
59
+ end
60
+
61
+ def initialize(output = $stdout, screen_buffer = ScreenBuffer.new(output))
62
+ @output = output
63
+ @screen_buffer = screen_buffer
64
+ @wrapper_box = Box.new(self)
65
+ end
66
+
67
+ def line(content = "", **kwargs)
68
+ @wrapper_box.line(content, **kwargs)
69
+ end
70
+
71
+ def puts(content = "", **kwargs)
72
+ @wrapper_box.puts(content, **kwargs)
73
+ end
74
+
75
+ def box
76
+ @wrapper_box.box
77
+ end
78
+
79
+ def scroll_to_bottom
80
+ @screen_buffer.scroll_to_bottom
81
+ end
82
+
83
+ def redraw
84
+ screen_lines = @wrapper_box.lines.flat_map { |line| line.truncate ? truncate_to_tty_width(line.content) : rewrap_to_tty_width(line.content) }
85
+ @screen_buffer.update(screen_lines.take(tty_height(@output) - 1))
86
+ end
87
+
88
+ private
89
+
90
+ def truncate_to_tty_width(string)
91
+ return string unless tty?
92
+
93
+ split_visible_char_groups(string).take(tty_width(@output)).join
94
+ end
95
+
96
+ def rewrap_to_tty_width(string)
97
+ return string unless tty?
98
+
99
+ string.split("\n").flat_map do |line|
100
+ indent, body = line.match(INDENTATION_REGEX)&.captures || ["", line]
101
+ max_width = tty_width(@output) - indent.size
102
+ split_visible_char_groups(body).each_slice(max_width).map { |chars| "#{indent}#{chars.join}" }
103
+ end
104
+ end
105
+
106
+ def tty?
107
+ @output.tty?
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RSpec
4
4
  module Conductor
5
- VERSION = "1.0.2"
5
+ VERSION = "1.0.3"
6
6
  end
7
7
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "rspec/core"
4
4
 
5
+ require_relative "conductor/util/ansi"
6
+ require_relative "conductor/util/terminal"
5
7
  require_relative "conductor/version"
6
8
  require_relative "conductor/protocol"
7
9
  require_relative "conductor/server"
@@ -9,7 +11,6 @@ require_relative "conductor/worker"
9
11
  require_relative "conductor/results"
10
12
  require_relative "conductor/worker_process"
11
13
  require_relative "conductor/cli"
12
- require_relative "conductor/ansi"
13
14
  require_relative "conductor/rspec_subscriber"
14
15
  require_relative "conductor/formatters/plain"
15
16
  require_relative "conductor/formatters/ci"
@@ -26,3 +27,7 @@ module RSpec
26
27
  end
27
28
  end
28
29
  end
30
+
31
+ if defined?(Rails)
32
+ require_relative "conductor/railtie"
33
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Conductor
5
+ module DatabaseTasks
6
+ class << self
7
+ def default_worker_count
8
+ ENV['RSPEC_CONDUCTOR_DEFAULT_WORKER_COUNT']&.to_i || 4
9
+ end
10
+
11
+ def create_databases(count)
12
+ run_for_each_database(count, "Creating") do
13
+ db_configs.each { |config| ActiveRecord::Tasks::DatabaseTasks.create(config) }
14
+ end
15
+ end
16
+
17
+ def drop_databases(count)
18
+ run_for_each_database(count, "Dropping") do
19
+ db_configs.each { |config| ActiveRecord::Tasks::DatabaseTasks.drop(config) }
20
+ end
21
+ end
22
+
23
+ def setup_databases(count)
24
+ schema_format, schema_file = schema_format_and_file
25
+
26
+ run_for_each_database(count, "Setting up") do
27
+ puts "Dropping database(s)"
28
+ db_configs.each { |config| ActiveRecord::Tasks::DatabaseTasks.drop(config) }
29
+
30
+ puts "Creating database(s)"
31
+ db_configs.each { |config| ActiveRecord::Tasks::DatabaseTasks.create(config) }
32
+
33
+ puts "Loading schema"
34
+ db_configs.each { |config| ActiveRecord::Tasks::DatabaseTasks.load_schema(config, schema_format, schema_file) }
35
+
36
+ puts "Loading seed"
37
+ ActiveRecord::Tasks::DatabaseTasks.load_seed
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def first_is_1?
44
+ ENV["RSPEC_CONDUCTOR_FIRST_IS_1"] == "1"
45
+ end
46
+
47
+ def db_configs
48
+ reload_database_configuration!
49
+
50
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
51
+ raise ArgumentError, "could not find or parse configuration for the env #{Rails.env}" unless configs.any?
52
+
53
+ configs
54
+ end
55
+
56
+ def run_for_each_database(count, action)
57
+ raise ArgumentError, "count must be positive" if count < 1
58
+
59
+ puts "#{action} #{count} test databases in parallel..."
60
+ # Close connections before forking to avoid sharing file descriptors
61
+ ActiveRecord::Base.connection_pool.disconnect!
62
+
63
+ terminal = Conductor::Util::Terminal.new
64
+ children = count.times.map do |i|
65
+ worker_number = i + 1
66
+ env_number = (first_is_1? || worker_number != 1) ? worker_number.to_s: ""
67
+ line = terminal.line("#{worker_number}: starting...")
68
+ stderr_buffer = +""
69
+
70
+ on_stdout = ->(text) do
71
+ line.update("#{worker_number}: #{text}")
72
+ end
73
+ on_stderr = ->(text) do
74
+ stderr_buffer << "#{text}\n"
75
+ line.update("#{worker_number}: [STDERR] #{text}")
76
+ end
77
+
78
+ process = Conductor::Util::ChildProcess.fork(on_stdout: on_stdout, on_stderr: on_stderr) do
79
+ ENV["TEST_ENV_NUMBER"] = env_number
80
+ puts "#{action} test database #{worker_number} of #{count} (TEST_ENV_NUMBER=#{env_number.inspect})"
81
+ yield
82
+ end
83
+
84
+ { process: process, worker_number: worker_number, stderr: stderr_buffer }
85
+ end
86
+ Conductor::Util::ChildProcess.wait_all(children.map { |v| v[:process] })
87
+ terminal.scroll_to_bottom
88
+
89
+ failed_children = children.reject { |child| child[:process].success? }
90
+ if failed_children.none?
91
+ puts "\nSuccessfully completed #{action.downcase} for #{count} database(s)"
92
+ else
93
+ puts "\nCompleted with #{failed_children.length} error(s):"
94
+ failed_children.each do |child|
95
+ puts "Process #{child[:worker_number]}"
96
+ puts "STDERR output:"
97
+ child[:stderr].each_line { |line| puts " #{line}" }
98
+ puts
99
+ end
100
+
101
+ raise "Database operation failed for #{failed_children.length} worker(s)"
102
+ end
103
+ end
104
+
105
+ def reload_database_configuration!
106
+ parsed_yaml = Rails.application.config.load_database_yaml
107
+ raise ArgumentError, "could not find database yaml or the yaml is empty" if parsed_yaml.empty?
108
+
109
+ ActiveRecord::Base.configurations = ActiveRecord::DatabaseConfigurations.new(parsed_yaml)
110
+ end
111
+
112
+ def schema_format_and_file
113
+ ruby_schema = File.join(Rails.root, "db", "schema.rb")
114
+ sql_schema = File.join(Rails.root, "db", "structure.sql")
115
+
116
+ if File.exist?(ruby_schema)
117
+ [:ruby, ruby_schema]
118
+ elsif File.exist?(sql_schema)
119
+ [:sql, sql_schema]
120
+ else
121
+ raise ArgumentError, "Neither db/schema.rb nor db/structure.sql found"
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ namespace :rspec_conductor do
130
+ desc "Create parallel test databases (default: #{RSpec::Conductor::DatabaseTasks.default_worker_count})"
131
+ task :create, [:count] => %w(set_rails_env_to_test environment) do |_t, args|
132
+ count = (args[:count] || RSpec::Conductor::DatabaseTasks.default_worker_count).to_i
133
+ RSpec::Conductor::DatabaseTasks.create_databases(count)
134
+ end
135
+
136
+ desc "Drop parallel test databases (default: #{RSpec::Conductor::DatabaseTasks.default_worker_count})"
137
+ task :drop, [:count] => %w(set_rails_env_to_test environment) do |_t, args|
138
+ count = (args[:count] || RSpec::Conductor::DatabaseTasks.default_worker_count).to_i
139
+ RSpec::Conductor::DatabaseTasks.drop_databases(count)
140
+ end
141
+
142
+ desc "Setup parallel test databases (drop + create + schema load + seed) (default: #{RSpec::Conductor::DatabaseTasks.default_worker_count})"
143
+ task :setup, [:count] => %w(set_rails_env_to_test environment) do |_t, args|
144
+ count = (args[:count] || RSpec::Conductor::DatabaseTasks.default_worker_count).to_i
145
+ RSpec::Conductor::DatabaseTasks.setup_databases(count)
146
+ end
147
+
148
+ # When RAILS_ENV is not set, Rails.env can default to development,
149
+ # which would have reaching consequences for our setup script.
150
+ # That's why we're forcing RAILS_ENV=test and spawning the rails task again.
151
+ task :set_rails_env_to_test do
152
+ if ENV['RAILS_ENV']
153
+ require_relative "../rspec/conductor/util/terminal"
154
+ require_relative "../rspec/conductor/util/child_process"
155
+ else
156
+ system({ "RAILS_ENV" => "test" }, "rake", *Rake.application.top_level_tasks)
157
+ exit
158
+ end
159
+ end
160
+ end
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.2
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Abramov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-10 00:00:00.000000000 Z
11
+ date: 2026-02-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec-core
@@ -40,19 +40,24 @@ files:
40
40
  - Rakefile
41
41
  - exe/rspec-conductor
42
42
  - lib/rspec/conductor.rb
43
- - lib/rspec/conductor/ansi.rb
44
43
  - lib/rspec/conductor/cli.rb
45
44
  - lib/rspec/conductor/ext/rspec.rb
46
45
  - lib/rspec/conductor/formatters/ci.rb
47
46
  - lib/rspec/conductor/formatters/fancy.rb
48
47
  - lib/rspec/conductor/formatters/plain.rb
49
48
  - lib/rspec/conductor/protocol.rb
49
+ - lib/rspec/conductor/railtie.rb
50
50
  - lib/rspec/conductor/results.rb
51
51
  - lib/rspec/conductor/rspec_subscriber.rb
52
52
  - lib/rspec/conductor/server.rb
53
+ - lib/rspec/conductor/util/ansi.rb
54
+ - lib/rspec/conductor/util/child_process.rb
55
+ - lib/rspec/conductor/util/screen_buffer.rb
56
+ - lib/rspec/conductor/util/terminal.rb
53
57
  - lib/rspec/conductor/version.rb
54
58
  - lib/rspec/conductor/worker.rb
55
59
  - lib/rspec/conductor/worker_process.rb
60
+ - lib/tasks/rspec_conductor.rake
56
61
  - rspec-conductor.gemspec
57
62
  homepage: https://github.com/markiz/rspec-conductor
58
63
  licenses:
@@ -1,106 +0,0 @@
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