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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +27 -11
- data/lib/rspec/conductor/ansi.rb +106 -0
- data/lib/rspec/conductor/cli.rb +20 -2
- data/lib/rspec/conductor/ext/rspec.rb +13 -0
- data/lib/rspec/conductor/formatters/ci.rb +14 -15
- data/lib/rspec/conductor/formatters/fancy.rb +47 -76
- data/lib/rspec/conductor/formatters/plain.rb +7 -12
- data/lib/rspec/conductor/results.rb +80 -0
- data/lib/rspec/conductor/rspec_subscriber.rb +74 -0
- data/lib/rspec/conductor/server.rb +87 -75
- data/lib/rspec/conductor/version.rb +1 -1
- data/lib/rspec/conductor/worker.rb +36 -110
- data/lib/rspec/conductor/worker_process.rb +13 -0
- data/lib/rspec/conductor.rb +4 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50d787ec617be586c7f66328850d62f2253bd5f1a50d1effb6417ccfdb4ee3a2
|
|
4
|
+
data.tar.gz: d71b0b92aa35eb18991ae91a2d3c95d532da79beb80e304e227da87dd214656a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|

|
|
14
14
|
|
|
15
|
-
##
|
|
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
|
-
|
|
23
|
-
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'rspec-conductor'
|
|
24
21
|
```
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
## Usage
|
|
27
24
|
|
|
28
|
-
```
|
|
25
|
+
```bash
|
|
29
26
|
rspec-conductor <OPTIONS> -- <RSPEC_OPTIONS> <SPEC_PATHS>
|
|
30
|
-
rspec-conductor --workers 10 -- --tag '~@flaky'
|
|
31
|
-
|
|
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
|
data/lib/rspec/conductor/cli.rb
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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(
|
|
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 + @
|
|
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
|
-
|
|
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
|
|
22
|
-
puts "#{results
|
|
23
|
-
if results
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
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(
|
|
26
|
-
@
|
|
27
|
-
public_send(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(
|
|
32
|
-
@dots << { char: ".", color:
|
|
27
|
+
def example_passed(_worker_process, _message)
|
|
28
|
+
@dots << { char: ".", color: :green }
|
|
33
29
|
end
|
|
34
30
|
|
|
35
|
-
def example_failed(
|
|
36
|
-
@dots << { char: "F", color:
|
|
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(
|
|
41
|
-
@dots << { char: "R", color:
|
|
36
|
+
def example_retried(_worker_process, _message)
|
|
37
|
+
@dots << { char: "R", color: :magenta }
|
|
42
38
|
end
|
|
43
39
|
|
|
44
|
-
def example_pending(
|
|
45
|
-
@dots << { char: "*", color:
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
print_cursor_down(1)
|
|
66
62
|
else
|
|
67
|
-
|
|
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
|
-
|
|
70
|
+
print_clear_line
|
|
75
71
|
puts
|
|
76
72
|
end
|
|
77
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
prefix = colorize("Worker #{
|
|
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
|
|
96
|
+
if worker_process.status == :shut_down
|
|
91
97
|
prefix + "(finished)"
|
|
92
|
-
elsif
|
|
93
|
-
prefix + colorize("(terminated)",
|
|
94
|
-
elsif
|
|
95
|
-
prefix + truncate(relative_path(
|
|
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:",
|
|
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
|
-
|
|
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
|
|
169
|
-
$stdout.tty?
|
|
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
|
|
181
|
-
print(
|
|
151
|
+
def print_cursor_down(n_lines)
|
|
152
|
+
print cursor_down(n_lines) if $stdout.tty?
|
|
182
153
|
end
|
|
183
154
|
|
|
184
|
-
def
|
|
185
|
-
$stdout.tty?
|
|
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
|
-
|
|
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(
|
|
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 ".",
|
|
12
|
+
print ".", :green
|
|
18
13
|
end
|
|
19
14
|
|
|
20
15
|
def example_failed(_message)
|
|
21
|
-
print "F",
|
|
16
|
+
print "F", :red
|
|
22
17
|
end
|
|
23
18
|
|
|
24
19
|
def example_retried(_message)
|
|
25
|
-
print "R",
|
|
20
|
+
print "R", :magenta
|
|
26
21
|
end
|
|
27
22
|
|
|
28
23
|
def example_pending(_message)
|
|
29
|
-
print "*",
|
|
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(
|
|
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 %
|
|
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
|
-
@
|
|
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 =
|
|
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
|
-
|
|
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?(
|
|
69
|
-
debug "Preloading
|
|
70
|
-
require
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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
|
-
@
|
|
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(@
|
|
162
|
+
)
|
|
163
|
+
assign_work(@worker_processes[pid])
|
|
151
164
|
end
|
|
152
165
|
|
|
153
166
|
def run_event_loop
|
|
154
|
-
until @
|
|
155
|
-
|
|
156
|
-
readable_ios, = IO.select(
|
|
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(
|
|
168
|
-
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 #{
|
|
179
|
+
debug "Worker #{worker_process.number}: #{message[:type]}"
|
|
172
180
|
|
|
173
181
|
case message[:type].to_sym
|
|
174
182
|
when :example_passed
|
|
175
|
-
@results
|
|
183
|
+
@results.example_passed
|
|
176
184
|
when :example_failed
|
|
177
|
-
@results
|
|
178
|
-
@results[:errors] << message
|
|
185
|
+
@results.example_failed(message)
|
|
179
186
|
|
|
180
|
-
if @fail_fast_after && @results
|
|
181
|
-
debug "Shutting after #{@results
|
|
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
|
|
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
|
|
192
|
-
|
|
193
|
-
assign_work(
|
|
198
|
+
@results.spec_file_complete
|
|
199
|
+
worker_process.current_spec = nil
|
|
200
|
+
assign_work(worker_process)
|
|
194
201
|
when :spec_error
|
|
195
|
-
@results
|
|
202
|
+
@results.spec_file_error(message)
|
|
196
203
|
debug "Spec error details: #{message[:error]}"
|
|
197
|
-
|
|
198
|
-
assign_work(
|
|
204
|
+
worker_process.current_spec = nil
|
|
205
|
+
assign_work(worker_process)
|
|
199
206
|
when :spec_interrupted
|
|
200
207
|
debug "Spec interrupted: #{message[:file]}"
|
|
201
|
-
|
|
208
|
+
worker_process.current_spec = nil
|
|
202
209
|
end
|
|
203
|
-
@formatter.handle_worker_message(
|
|
210
|
+
@formatter.handle_worker_message(worker_process, message, @results)
|
|
204
211
|
end
|
|
205
212
|
|
|
206
|
-
def assign_work(
|
|
207
|
-
if @spec_queue.empty? || @shutting_down
|
|
208
|
-
debug "No more work for worker #{
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
@
|
|
219
|
+
@results.spec_file_assigned
|
|
213
220
|
spec_file = @spec_queue.shift
|
|
214
|
-
|
|
215
|
-
debug "Assigning #{spec_file} to worker #{
|
|
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
|
-
|
|
218
|
-
@formatter.handle_worker_message(
|
|
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
|
|
223
|
-
@
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
@formatter.handle_worker_message(
|
|
227
|
-
Process.wait(
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 "
|
|
249
|
-
puts "
|
|
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
|
|
260
|
+
if @results.errors.any?
|
|
252
261
|
puts "\nFailures:\n\n"
|
|
253
|
-
@results
|
|
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 "
|
|
266
|
-
puts "
|
|
267
|
-
puts "
|
|
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
|
-
|
|
272
|
-
Kernel.exit(success ? 0 : 1)
|
|
284
|
+
Kernel.exit(@results.success? ? 0 : 1)
|
|
273
285
|
end
|
|
274
286
|
|
|
275
287
|
def debug(message)
|
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
data/lib/rspec/conductor.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|