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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/bin/parallel_specs +9 -0
- data/lib/parallel_specs/cli/dashboard.rb +415 -0
- data/lib/parallel_specs/cli.rb +381 -0
- data/lib/parallel_specs/grouper.rb +30 -0
- data/lib/parallel_specs/pids.rb +57 -0
- data/lib/parallel_specs/rspec/dashboard_logger.rb +67 -0
- data/lib/parallel_specs/rspec/logger_base.rb +46 -0
- data/lib/parallel_specs/rspec/runner.rb +122 -0
- data/lib/parallel_specs/rspec/runtime_logger.rb +46 -0
- data/lib/parallel_specs/test/runner.rb +242 -0
- data/lib/parallel_specs/version.rb +5 -0
- data/lib/parallel_specs.rb +94 -0
- metadata +97 -0
|
@@ -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
|