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 +4 -4
- data/CHANGELOG.md +7 -0
- data/Gemfile +1 -0
- data/README.md +12 -9
- data/lib/rspec/conductor/formatters/ci.rb +3 -1
- data/lib/rspec/conductor/formatters/fancy.rb +59 -98
- data/lib/rspec/conductor/formatters/plain.rb +1 -1
- data/lib/rspec/conductor/railtie.rb +11 -0
- data/lib/rspec/conductor/server.rb +7 -7
- data/lib/rspec/conductor/util/ansi.rb +139 -0
- data/lib/rspec/conductor/util/child_process.rb +153 -0
- data/lib/rspec/conductor/util/screen_buffer.rb +89 -0
- data/lib/rspec/conductor/util/terminal.rb +112 -0
- data/lib/rspec/conductor/version.rb +1 -1
- data/lib/rspec/conductor.rb +6 -1
- data/lib/tasks/rspec_conductor.rake +160 -0
- metadata +8 -3
- data/lib/rspec/conductor/ansi.rb +0 -106
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '082ad795d53684a33439d9c56e8d4ff175460d6b335bf25dfbc0114f913a764e'
|
|
4
|
+
data.tar.gz: ddedba7528e3cd7eaa045b55814c1a4d85a09bb69d9dcceb57c843d00244cec8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
-
|
|
34
|
+
## Mechanics
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
In order to bootstrap the test environment, there is a rake task:
|
|
44
41
|
|
|
45
|
-
|
|
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
|
-
|
|
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,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
|
|
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 =
|
|
15
|
+
def initialize(worker_count:)
|
|
16
|
+
@worker_processes = {}
|
|
17
|
+
@terminal = Util::Terminal.new
|
|
16
18
|
@last_rendered_lines = []
|
|
17
|
-
@
|
|
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
|
-
|
|
38
|
+
dot ".", :green
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
def example_failed(_worker_process, message)
|
|
32
|
-
|
|
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
|
-
|
|
47
|
+
dot "R", :magenta
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
def example_pending(_worker_process, _message)
|
|
41
|
-
|
|
51
|
+
dot "*", :yellow
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
private
|
|
45
55
|
|
|
46
|
-
def redraw(results)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
return
|
|
96
|
+
def update_errors_line
|
|
97
|
+
return unless @last_error
|
|
110
98
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
@last_error[:backtrace].first(10).each { |l|
|
|
109
|
+
error_components << " Backtrace:"
|
|
110
|
+
@last_error[:backtrace].first(10).each { |l| error_components << " #{l}" }
|
|
124
111
|
end
|
|
125
112
|
|
|
126
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
data/lib/rspec/conductor.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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:
|
data/lib/rspec/conductor/ansi.rb
DELETED
|
@@ -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
|