parallel_specs 0.9.0

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.
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require 'pathname'
6
+ require 'shellwords'
7
+ require 'tmpdir'
8
+
9
+ require 'parallel_specs'
10
+ require 'parallel_specs/cli/dashboard'
11
+ require 'parallel_specs/rspec/runner'
12
+
13
+ module ParallelSpecs
14
+ class CLI
15
+ def initialize
16
+ @runner = ParallelSpecs::RSpec::Runner
17
+ end
18
+
19
+ def run(argv)
20
+ Signal.trap('INT') { handle_interrupt }
21
+
22
+ options = parse_options!(argv)
23
+ ENV['DISABLE_SPRING'] ||= '1'
24
+
25
+ num_processes = ParallelSpecs.determine_number_of_processes(options[:count])
26
+ abort 'Process count must be greater than 0' unless num_processes.positive?
27
+
28
+ run_tests_in_parallel(num_processes, options)
29
+ end
30
+
31
+ private
32
+
33
+ def handle_interrupt
34
+ @graceful_shutdown_attempted ||= false
35
+ Kernel.exit if @graceful_shutdown_attempted
36
+
37
+ @graceful_shutdown_attempted = true
38
+ Thread.new do
39
+ case interrupt_action
40
+ when :stop_workers
41
+ Kernel.exit unless ParallelSpecs.stop_all_processes
42
+ when :wait_for_process_group_interrupt
43
+ # Terminal Ctrl-C is delivered to the whole foreground process group.
44
+ # In that case workers have already seen SIGINT, so avoid sending a
45
+ # second signal that could escalate RSpec from graceful shutdown to
46
+ # immediate termination.
47
+ nil
48
+ when :exit
49
+ Kernel.exit
50
+ else
51
+ Kernel.exit
52
+ end
53
+ end
54
+ end
55
+
56
+ def interrupt_action
57
+ return :exit unless ParallelSpecs.pid_file_available?
58
+ tracked_pids = ParallelSpecs.pids.all
59
+ return :exit if tracked_pids.empty?
60
+ return :stop_workers if Gem.win_platform?
61
+
62
+ child_pid = tracked_pids.first
63
+ child_process_group = Process.getpgid(child_pid)
64
+ return :stop_workers unless child_process_group == Process.getpgrp
65
+
66
+ terminal_signal_reaches_process_group? ? :wait_for_process_group_interrupt : :stop_workers
67
+ rescue Errno::ESRCH, Errno::EPERM
68
+ :stop_workers
69
+ rescue KeyError
70
+ :exit
71
+ end
72
+
73
+ def terminal_signal_reaches_process_group?
74
+ $stdout.tty?
75
+ end
76
+
77
+ def run_tests_in_parallel(num_processes, options)
78
+ test_results = nil
79
+ @runtime_log_merge_failed = false
80
+
81
+ runner = lambda do
82
+ groups = @runner.tests_in_groups(options[:files], num_processes, options)
83
+ groups.reject!(&:empty?)
84
+
85
+ with_runtime_log_files(groups, options) do
86
+ if groups.empty?
87
+ report_number_of_tests(groups)
88
+ test_results = []
89
+ report_results(test_results)
90
+ false
91
+ else
92
+ with_dashboard(groups, options) do |dashboard|
93
+ report_number_of_tests(groups) unless dashboard
94
+
95
+ dashboard&.start
96
+ begin
97
+ test_results = execute_in_parallel(groups, groups.size, options) do |group, index|
98
+ @runner.run_tests(group, index, num_processes, options)
99
+ end
100
+ ensure
101
+ dashboard&.stop
102
+ end
103
+
104
+ report_results(test_results)
105
+ report_dashboard_failures(test_results) if dashboard
106
+ report_failure_rerun_commands(test_results)
107
+ runtime_log_mergeable?(test_results)
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ report_time_taken(&runner)
114
+ if any_test_failed?(test_results) || @runtime_log_merge_failed || @graceful_shutdown_attempted
115
+ warn final_fail_message
116
+ exit 1
117
+ end
118
+ end
119
+
120
+ def execute_in_parallel(items, num_processes, options)
121
+ ParallelSpecs.with_pid_file do
122
+ simulate_output_for_ci(plain_dashboard?(options)) do
123
+ Parallel.map_with_index(items, in_threads: num_processes) do |item, index|
124
+ options[:dashboard_runner]&.worker_started(index)
125
+ result = yield(item, index)
126
+ options[:dashboard_runner]&.worker_finished(index, exit_status: result[:exit_status])
127
+ ParallelSpecs.stop_all_processes if options[:fail_fast] && !result[:exit_status].zero?
128
+ result
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ def with_runtime_log_files(groups, options)
135
+ return yield unless options[:record_runtime]
136
+
137
+ runtime_log = options[:runtime_log] || @runner.runtime_log
138
+ should_merge_runtime_logs = false
139
+
140
+ Dir.mktmpdir('parallel_specs-runtime') do |dir|
141
+ runtime_log_files = groups.each_index.to_h do |index|
142
+ [index, File.join(dir, "worker-#{index + 1}.log")]
143
+ end
144
+
145
+ options[:runtime_log_files] = runtime_log_files
146
+ should_merge_runtime_logs = yield
147
+ ensure
148
+ if runtime_log_files && should_merge_runtime_logs
149
+ @runtime_log_merge_failed = true unless merge_runtime_logs(runtime_log_files, runtime_log)
150
+ elsif runtime_log_files
151
+ warn "parallel_specs: not updating runtime log #{runtime_log}; run did not complete successfully"
152
+ end
153
+ options.delete(:runtime_log_files)
154
+ end
155
+ end
156
+
157
+ def merge_runtime_logs(runtime_log_files, runtime_log)
158
+ if runtime_log_files.empty?
159
+ warn "parallel_specs: not updating runtime log #{runtime_log}; no worker runtime logs were produced"
160
+ return false
161
+ end
162
+
163
+ missing_logs = runtime_log_files.values.reject { |path| File.file?(path) }
164
+ unless missing_logs.empty?
165
+ warn "parallel_specs: not updating runtime log #{runtime_log}; missing worker runtime logs: #{missing_logs.join(', ')}"
166
+ return false
167
+ end
168
+
169
+ FileUtils.mkdir_p(File.dirname(runtime_log))
170
+ temporary_runtime_log = "#{runtime_log}.#{Process.pid}.tmp"
171
+ File.open(temporary_runtime_log, 'w') do |output|
172
+ runtime_log_files.each_value do |path|
173
+ File.foreach(path) { |line| output.write(line) }
174
+ end
175
+ end
176
+ FileUtils.mv(temporary_runtime_log, runtime_log)
177
+ true
178
+ ensure
179
+ FileUtils.rm_f(temporary_runtime_log) if temporary_runtime_log && File.exist?(temporary_runtime_log)
180
+ end
181
+
182
+ def with_dashboard(groups, options)
183
+ return yield unless options[:dashboard]
184
+
185
+ Dir.mktmpdir('parallel_specs-dashboard') do |dir|
186
+ event_files = groups.each_index.to_h do |index|
187
+ path = File.join(dir, "worker-#{index + 1}.jsonl")
188
+ File.write(path, '')
189
+ [index, path]
190
+ end
191
+
192
+ options[:dashboard_event_files] = event_files
193
+ options[:dashboard_runner] = ParallelSpecs::CLI::Dashboard.new(
194
+ groups: groups,
195
+ event_files: event_files,
196
+ mode: dashboard_mode,
197
+ use_colors: use_colors?
198
+ )
199
+
200
+ yield options[:dashboard_runner]
201
+ ensure
202
+ options.delete(:dashboard_event_files)
203
+ options.delete(:dashboard_runner)
204
+ end
205
+ end
206
+
207
+ def report_results(test_results)
208
+ results = @runner.find_results(test_results.map { |result| result[:stdout] }.join)
209
+ puts
210
+ puts @runner.summarize_results(results)
211
+ end
212
+
213
+ def report_dashboard_failures(test_results)
214
+ failures = test_results.reject { |result| result[:exit_status].zero? }
215
+ return if failures.empty?
216
+
217
+ puts "\nFailed worker output:\n"
218
+ failures.each do |result|
219
+ worker_label = result.dig(:env, 'TEST_ENV_NUMBER')
220
+ worker_label = '1' if worker_label.to_s.empty?
221
+ puts "--- worker #{worker_label} ---"
222
+ puts result[:stdout]
223
+ end
224
+ end
225
+
226
+ def report_failure_rerun_commands(test_results)
227
+ failures = test_results.reject { |result| result[:exit_status].zero? }
228
+ return if failures.empty?
229
+
230
+ puts "\nRerun failed worker commands:\n"
231
+ failures.each do |result|
232
+ command = @runner.rerun_command(result[:command], seed: result[:seed])
233
+ @runner.print_command(command, result[:env] || {})
234
+ end
235
+ end
236
+
237
+ def report_number_of_tests(groups)
238
+ num_processes = groups.size
239
+ num_tests = groups.map(&:size).sum
240
+ tests_per_process = num_processes.zero? ? 0 : num_tests / num_processes
241
+ puts "#{pluralize(num_processes, 'process')} for #{pluralize(num_tests, @runner.test_file_name)}, ~ #{pluralize(tests_per_process, @runner.test_file_name)} per process"
242
+ end
243
+
244
+ def any_test_failed?(test_results)
245
+ test_results.any? { |result| !result[:exit_status].zero? }
246
+ end
247
+
248
+ def runtime_log_mergeable?(test_results)
249
+ !@graceful_shutdown_attempted && test_results && !test_results.empty? && test_results.all? { |result| result[:exit_status].zero? }
250
+ end
251
+
252
+ def parse_options!(argv)
253
+ newline_padding = 33
254
+ options = { dashboard: true }
255
+
256
+ OptionParser.new do |opts|
257
+ opts.banner = <<~BANNER
258
+ Run RSpec files in parallel with a dashboard locally and plain text output in CI.
259
+
260
+ [optional] Only selected files & folders:
261
+ parallel_specs spec/models spec/services
262
+
263
+ [optional] Pass rspec options and files via `--`:
264
+ parallel_specs -- --tag ~type:system -- spec/models
265
+
266
+ Options are:
267
+ BANNER
268
+
269
+ opts.on('-n PROCESSES', Integer, 'How many processes to use, default: available CPUs') { |n| options[:count] = n }
270
+ opts.on('-o', '--test-options OPTIONS', 'Pass these options to rspec') { |arg| options[:test_options] = Shellwords.shellsplit(arg) }
271
+ opts.on('--group-by TYPE', heredoc(<<~TEXT, newline_padding)) { |type| options[:group_by] = type.to_sym }
272
+ group specs by:
273
+ found - order of finding files
274
+ filesize - by size of the file
275
+ runtime - info from runtime log
276
+ default - runtime when runtime log is filled otherwise filesize
277
+ TEXT
278
+ opts.on('--pattern PATTERN', 'Only run spec files matching PATTERN') { |pattern| options[:pattern] = Regexp.new(pattern) }
279
+ opts.on('--exclude-pattern PATTERN', 'Skip spec files matching PATTERN') { |pattern| options[:exclude_pattern] = Regexp.new(pattern) }
280
+ opts.on('--runtime-log PATH', 'Read spec runtimes from PATH; with --record-runtime, write the completed run there') { |path| options[:runtime_log] = path }
281
+ opts.on('--allowed-missing COUNT', Integer, 'Allowed percentage of missing runtimes (default = 50)') { |percent| options[:allowed_missing_percent] = percent }
282
+ opts.on('--unknown-runtime SECONDS', Float, 'Use given number as unknown runtime (otherwise use average time)') { |time| options[:unknown_runtime] = time }
283
+ opts.on('--record-runtime', 'Record runtimes and replace the runtime log only after a successful complete run') { options[:record_runtime] = true }
284
+ opts.on('--fail-fast', 'Stop remaining workers after one worker fails') { options[:fail_fast] = true }
285
+ opts.on('-v', '--version', 'Show version') { puts ParallelSpecs::VERSION; exit 0 }
286
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
287
+ end.parse!(argv)
288
+
289
+ options[:dashboard] = !options[:record_runtime]
290
+
291
+ files, remaining = extract_file_paths(argv)
292
+ files = [@runner.default_test_folder] if files.empty?
293
+ options[:files] = files.map { |file_path| Pathname.new(file_path).cleanpath.to_s }
294
+ append_test_options(options, remaining)
295
+ options
296
+ end
297
+
298
+ def extract_file_paths(argv)
299
+ dash_index = argv.rindex('--')
300
+ file_args_at = (dash_index || -1) + 1
301
+ [argv[file_args_at..], argv[0...(dash_index || 0)]]
302
+ end
303
+
304
+ def extract_test_options(argv)
305
+ dash_index = argv.index('--') || -1
306
+ argv[dash_index + 1..]
307
+ end
308
+
309
+ def append_test_options(options, argv)
310
+ new_opts = extract_test_options(argv)
311
+ return if new_opts.empty?
312
+
313
+ options[:test_options] ||= []
314
+ options[:test_options].concat(new_opts)
315
+ end
316
+
317
+ def report_time_taken(&block)
318
+ seconds = ParallelSpecs.delta(&block).to_i
319
+ puts "\nTook #{pluralize(seconds, 'second')}#{detailed_duration(seconds)}"
320
+ end
321
+
322
+ def detailed_duration(seconds)
323
+ parts = [seconds / 3600, (seconds % 3600) / 60, seconds % 60].drop_while(&:zero?)
324
+ return if parts.size < 2
325
+
326
+ " (#{parts.map { |part| format('%02d', part) }.join(':').sub(/^0/, '')})"
327
+ end
328
+
329
+ def final_fail_message
330
+ message = 'Specs Failed'
331
+ use_colors? ? "\e[31m#{message}\e[0m" : message
332
+ end
333
+
334
+ def use_colors?
335
+ $stdout.tty?
336
+ end
337
+
338
+ def plain_dashboard?(options)
339
+ options[:dashboard_runner]&.plain?
340
+ end
341
+
342
+ def dashboard_mode
343
+ override = ENV['PARALLEL_SPECS_DASHBOARD_MODE']
344
+ return override.to_sym if %w[interactive plain].include?(override)
345
+
346
+ if ENV['CI'] || !$stdout.tty?
347
+ :plain
348
+ else
349
+ :interactive
350
+ end
351
+ end
352
+
353
+ def simulate_output_for_ci(simulate)
354
+ return yield unless simulate
355
+
356
+ progress_indicator = Thread.new do
357
+ interval = Float(ENV['PARALLEL_SPECS_HEARTBEAT_INTERVAL'] || 60)
358
+ loop do
359
+ sleep interval
360
+ $stdout.print '.'
361
+ $stdout.flush
362
+ end
363
+ end
364
+
365
+ yield
366
+ ensure
367
+ progress_indicator&.exit
368
+ end
369
+
370
+ def heredoc(text, newline_padding)
371
+ text.rstrip.gsub("\n", "\n#{' ' * newline_padding}")
372
+ end
373
+
374
+ def pluralize(number, singular)
375
+ return "1 #{singular}" if number == 1
376
+ return "#{number} #{singular}es" if singular.end_with?('s', 'sh', 'ch', 'x', 'z')
377
+
378
+ "#{number} #{singular}s"
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ParallelSpecs
4
+ class Grouper
5
+ class << self
6
+ def in_even_groups_by_size(items, num_groups, _options = {})
7
+ groups = Array.new(num_groups) { { items: [], size: 0 } }
8
+
9
+ items_to_group(items).each do |item, size|
10
+ group = groups.min_by { |entry| entry[:size] }
11
+ group[:items] << item
12
+ group[:size] += (size || 1)
13
+ end
14
+
15
+ groups.map { |group| group[:items].sort }
16
+ end
17
+
18
+ private
19
+
20
+ def items_to_group(items)
21
+ return items unless items.first&.size == 2
22
+
23
+ sizes = items.map { |(_item, size)| size || 1 }
24
+ return items if sizes.uniq.one?
25
+
26
+ items.sort_by { |(_item, size)| -(size || 1) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ParallelSpecs
6
+ class Pids
7
+ def initialize(file_path)
8
+ @file_path = file_path
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def add(pid)
13
+ mutex.synchronize do
14
+ read
15
+ pids << pid.to_i
16
+ save
17
+ end
18
+ end
19
+
20
+ def delete(pid)
21
+ mutex.synchronize do
22
+ read
23
+ pids.delete(pid.to_i)
24
+ save
25
+ end
26
+ end
27
+
28
+ def all
29
+ mutex.synchronize do
30
+ read
31
+ pids.dup
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :file_path, :mutex
38
+
39
+ def pids
40
+ @pids ||= []
41
+ end
42
+
43
+ def read
44
+ return unless File.exist?(file_path)
45
+
46
+ contents = File.read(file_path)
47
+ @pids = []
48
+ return if contents.empty?
49
+
50
+ @pids = JSON.parse(contents)
51
+ end
52
+
53
+ def save
54
+ File.write(file_path, pids.to_json)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'rspec/core'
6
+ require 'rspec/core/formatters/base_text_formatter'
7
+
8
+ module ParallelSpecs
9
+ module RSpec
10
+ end
11
+ end
12
+
13
+ class ParallelSpecs::RSpec::DashboardLogger < RSpec::Core::Formatters::BaseTextFormatter
14
+ RSpec::Core::Formatters.register(
15
+ self,
16
+ :start,
17
+ :example_started,
18
+ :example_passed,
19
+ :example_pending,
20
+ :example_failed
21
+ )
22
+
23
+ def initialize(output)
24
+ super
25
+
26
+ path = ENV['PARALLEL_SPECS_DASHBOARD_EVENT_LOG']
27
+ raise 'A dashboard event log env var is required for DashboardLogger' if path.to_s.empty?
28
+
29
+ FileUtils.mkdir_p(File.dirname(path))
30
+ @event_output = File.open(path, 'a')
31
+ @event_output.sync = true
32
+ end
33
+
34
+ def start(notification)
35
+ emit(event: 'start', total: notification.count)
36
+ end
37
+
38
+ def example_started(notification)
39
+ emit_example('example_started', notification)
40
+ end
41
+
42
+ def example_passed(notification)
43
+ emit_example('example_passed', notification)
44
+ end
45
+
46
+ def example_pending(notification)
47
+ emit_example('example_pending', notification)
48
+ end
49
+
50
+ def example_failed(notification)
51
+ emit_example('example_failed', notification)
52
+ end
53
+
54
+ def close(*)
55
+ @event_output.close unless @event_output.closed?
56
+ end
57
+
58
+ private
59
+
60
+ def emit_example(event_name, notification)
61
+ emit(event: event_name, example: notification.example.full_description)
62
+ end
63
+
64
+ def emit(payload)
65
+ @event_output.puts(JSON.generate(payload))
66
+ end
67
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'rspec/core'
5
+ require 'rspec/core/formatters/base_text_formatter'
6
+
7
+ module ParallelSpecs
8
+ module RSpec
9
+ end
10
+ end
11
+
12
+ class ParallelSpecs::RSpec::LoggerBase < RSpec::Core::Formatters::BaseTextFormatter
13
+ def initialize(*args)
14
+ super
15
+
16
+ @output ||= args[0]
17
+ case @output
18
+ when String
19
+ FileUtils.mkdir_p(File.dirname(@output))
20
+ File.open(@output, 'w') {}
21
+ @output = File.open(@output, 'a')
22
+ when File
23
+ @output.close
24
+ @output = File.open(@output.path, 'a')
25
+ end
26
+ end
27
+
28
+ def close(*)
29
+ @output.close if IO === @output && @output != $stdout
30
+ end
31
+
32
+ protected
33
+
34
+ def lock_output
35
+ if @output.is_a?(File)
36
+ begin
37
+ @output.flock(File::LOCK_EX)
38
+ yield
39
+ ensure
40
+ @output.flock(File::LOCK_UN)
41
+ end
42
+ else
43
+ yield
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel_specs/test/runner'
4
+
5
+ module ParallelSpecs
6
+ module RSpec
7
+ class Runner < ParallelSpecs::Test::Runner
8
+ class << self
9
+ def run_tests(test_files, process_number, num_processes, options)
10
+ execute_command(
11
+ build_test_command(test_files, process_number, options),
12
+ process_number,
13
+ num_processes,
14
+ options
15
+ )
16
+ end
17
+
18
+ def runtime_log
19
+ 'tmp/parallel_runtime_rspec.log'
20
+ end
21
+
22
+ def default_test_folder
23
+ 'spec'
24
+ end
25
+
26
+ def test_file_name
27
+ 'spec'
28
+ end
29
+
30
+ def line_is_result?(line)
31
+ line =~ /\d+ examples?, \d+ failures?/
32
+ end
33
+
34
+ def summarize_results(results)
35
+ text = super
36
+ return text unless $stdout.tty?
37
+
38
+ sums = send(:sum_up_results, results)
39
+ color_code = if sums['failure'] > 0
40
+ 31
41
+ elsif sums['pending'] > 0
42
+ 33
43
+ else
44
+ 32
45
+ end
46
+ "\e[#{color_code}m#{text}\e[0m"
47
+ end
48
+
49
+ def rerun_command(command, seed: nil)
50
+ command = remove_rerun_only_formatters(command)
51
+ seed ? command_with_seed(command, seed) : command
52
+ end
53
+
54
+ def command_with_seed(command, seed)
55
+ [*remove_command_arguments(command, '--seed', '--order'), '--seed', seed]
56
+ end
57
+
58
+ private
59
+
60
+ def build_test_command(file_list, process_number, options)
61
+ [
62
+ *executable,
63
+ *options.fetch(:test_options, []),
64
+ *color,
65
+ *record_runtime_formatters(process_number, options),
66
+ *dashboard_formatter(options),
67
+ *file_list
68
+ ]
69
+ end
70
+
71
+ def remove_rerun_only_formatters(command)
72
+ remove_formatter(command, 'ParallelSpecs::RSpec::DashboardLogger')
73
+ .then { |cmd| remove_formatter(cmd, 'ParallelSpecs::RSpec::RuntimeLogger') }
74
+ end
75
+
76
+ def remove_formatter(command, formatter)
77
+ cleaned = []
78
+ index = 0
79
+ while index < command.length
80
+ if command[index] == '--format' && command[index + 1] == formatter
81
+ index += 2
82
+ elsif command[index] == '--out' && command[index - 2] == '--format' && command[index - 1] == formatter
83
+ index += 2
84
+ else
85
+ cleaned << command[index]
86
+ index += 1
87
+ end
88
+ end
89
+ cleaned
90
+ end
91
+
92
+ def executable
93
+ if File.exist?('bin/rspec')
94
+ ParallelSpecs.with_ruby_binary('bin/rspec')
95
+ elsif ParallelSpecs.bundler_enabled?
96
+ %w[bundle exec rspec]
97
+ else
98
+ ['rspec']
99
+ end
100
+ end
101
+
102
+ def color
103
+ %w[--color --tty] if $stdout.tty?
104
+ end
105
+
106
+ def dashboard_formatter(options)
107
+ ['--format', 'ParallelSpecs::RSpec::DashboardLogger'] if options[:dashboard]
108
+ end
109
+
110
+ def record_runtime_formatters(process_number, options)
111
+ return [] unless options[:record_runtime]
112
+
113
+ runtime_log_path = options.fetch(:runtime_log_files, {}).fetch(process_number) do
114
+ options[:runtime_log] || runtime_log
115
+ end
116
+
117
+ ['--format', 'progress', '--format', 'ParallelSpecs::RSpec::RuntimeLogger', '--out', runtime_log_path]
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end