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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parallel_specs'
|
|
4
|
+
require 'parallel_specs/rspec/logger_base'
|
|
5
|
+
|
|
6
|
+
class ParallelSpecs::RSpec::RuntimeLogger < ParallelSpecs::RSpec::LoggerBase
|
|
7
|
+
RSpec::Core::Formatters.register(self, :example_group_started, :example_group_finished, :start_dump)
|
|
8
|
+
|
|
9
|
+
def initialize(*args)
|
|
10
|
+
super
|
|
11
|
+
@example_times = Hash.new(0)
|
|
12
|
+
@group_nesting = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def example_group_started(example_group)
|
|
16
|
+
@time = ParallelSpecs.now if @group_nesting.zero?
|
|
17
|
+
@group_nesting += 1
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def example_group_finished(notification)
|
|
22
|
+
@group_nesting -= 1
|
|
23
|
+
if @group_nesting.zero?
|
|
24
|
+
@example_times[notification.group.file_path] += ParallelSpecs.now - @time
|
|
25
|
+
end
|
|
26
|
+
super if defined?(super)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def seed(*); end
|
|
30
|
+
def dump_summary(*); end
|
|
31
|
+
def dump_failures(*); end
|
|
32
|
+
def dump_failure(*); end
|
|
33
|
+
def dump_pending(*); end
|
|
34
|
+
|
|
35
|
+
def start_dump(*)
|
|
36
|
+
return unless ENV['TEST_ENV_NUMBER']
|
|
37
|
+
|
|
38
|
+
lock_output do
|
|
39
|
+
@example_times.sort_by(&:last).reverse_each do |file, time|
|
|
40
|
+
relative_path = file.sub(%r{^#{Regexp.escape(Dir.pwd)}/}, '').sub(%r{^\./}, '')
|
|
41
|
+
@output.puts "#{relative_path}:#{[time, 0].max}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
@output.flush
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parallel_specs'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module ParallelSpecs
|
|
7
|
+
module Test
|
|
8
|
+
class Runner
|
|
9
|
+
RuntimeLogTooSmallError = Class.new(StandardError)
|
|
10
|
+
RuntimeLogParseError = Class.new(StandardError)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def tests_in_groups(tests, num_groups, options = {})
|
|
14
|
+
ParallelSpecs::Grouper.in_even_groups_by_size(tests_with_size(tests, options), num_groups)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tests_with_size(tests, options)
|
|
18
|
+
tests = find_tests(tests, options)
|
|
19
|
+
|
|
20
|
+
case options[:group_by]
|
|
21
|
+
when :found
|
|
22
|
+
tests.map! { |test| [test, 1] }
|
|
23
|
+
when :runtime
|
|
24
|
+
sort_by_runtime(
|
|
25
|
+
tests,
|
|
26
|
+
runtimes(tests, options),
|
|
27
|
+
options.merge(allowed_missing: (options[:allowed_missing_percent] || 50) / 100.0)
|
|
28
|
+
)
|
|
29
|
+
when :filesize
|
|
30
|
+
sort_by_filesize(tests)
|
|
31
|
+
when nil
|
|
32
|
+
begin
|
|
33
|
+
known_runtimes = runtimes(tests, options)
|
|
34
|
+
rescue Errno::ENOENT
|
|
35
|
+
warn "parallel_specs: runtime log #{runtime_log_path(options)} was not found; falling back to filesize grouping" if options[:runtime_log]
|
|
36
|
+
known_runtimes = {}
|
|
37
|
+
rescue RuntimeLogParseError => e
|
|
38
|
+
warn "parallel_specs: unable to use runtime log #{runtime_log_path(options)}: #{e.message}; falling back to filesize grouping"
|
|
39
|
+
known_runtimes = {}
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
warn "parallel_specs: unable to load runtime log #{runtime_log_path(options)}: #{e.class}: #{e.message}"
|
|
42
|
+
raise
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if known_runtimes.size * 1.5 > tests.size
|
|
46
|
+
puts 'Using recorded test runtime'
|
|
47
|
+
sort_by_runtime(tests, known_runtimes)
|
|
48
|
+
else
|
|
49
|
+
sort_by_filesize(tests)
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "Unsupported option #{options[:group_by]}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
tests
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute_command(cmd, process_number, num_processes, options)
|
|
59
|
+
env = {
|
|
60
|
+
'TEST_ENV_NUMBER' => test_env_number(process_number),
|
|
61
|
+
'PARALLEL_SPECS_GROUPS' => num_processes.to_s,
|
|
62
|
+
'PARALLEL_SPECS_PID_FILE' => ParallelSpecs.pid_file_path
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (dashboard_event_files = options[:dashboard_event_files])
|
|
66
|
+
env['PARALLEL_SPECS_DASHBOARD_EVENT_LOG'] = dashboard_event_files.fetch(process_number)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
execute_command_and_capture_output(env, cmd, options)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute_command_and_capture_output(env, cmd, options)
|
|
73
|
+
pid = nil
|
|
74
|
+
output = IO.popen(env, cmd, err: [:child, :out]) do |io|
|
|
75
|
+
pid = io.pid
|
|
76
|
+
ParallelSpecs.pids.add(pid)
|
|
77
|
+
capture_output(io, options[:dashboard])
|
|
78
|
+
ensure
|
|
79
|
+
ParallelSpecs.pids.delete(pid) if pid
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
status = $?
|
|
83
|
+
exit_status = if status.exitstatus
|
|
84
|
+
status.exitstatus
|
|
85
|
+
elsif status.termsig
|
|
86
|
+
status.termsig + 128
|
|
87
|
+
else
|
|
88
|
+
1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{ env: env, stdout: output, exit_status: exit_status, command: cmd, seed: seed_from(output) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_command(command, env = {})
|
|
95
|
+
env_string = rerun_env(env).map { |key, value| "#{key}=#{Shellwords.escape(value)}" }.join(' ')
|
|
96
|
+
command_string = Shellwords.shelljoin(command)
|
|
97
|
+
puts [env_string, command_string].reject(&:empty?).join(' ')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rerun_command(command, seed: nil)
|
|
101
|
+
seed ? command_with_seed(command, seed) : command
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def command_with_seed(command, seed)
|
|
105
|
+
[*remove_command_arguments(command, '--seed'), '--seed', seed]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def find_results(test_output)
|
|
109
|
+
test_output.lines.filter_map do |line|
|
|
110
|
+
line = line.chomp.gsub(/\e\[\d+m/, '')
|
|
111
|
+
line if line_is_result?(line)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def summarize_results(results)
|
|
116
|
+
sum_up_results(results).sort.map { |word, count| "#{count} #{word}#{'s' if count != 1}" }.join(', ')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
protected
|
|
120
|
+
|
|
121
|
+
def capture_output(out, dashboard)
|
|
122
|
+
result = +''
|
|
123
|
+
begin
|
|
124
|
+
loop do
|
|
125
|
+
chunk = out.readpartial(1_000_000)
|
|
126
|
+
chunk = chunk.force_encoding(Encoding.default_internal) if Encoding.default_internal
|
|
127
|
+
result << chunk
|
|
128
|
+
next if dashboard
|
|
129
|
+
|
|
130
|
+
$stdout.print(chunk)
|
|
131
|
+
$stdout.flush
|
|
132
|
+
end
|
|
133
|
+
rescue EOFError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def seed_from(output)
|
|
140
|
+
output.to_s[/seed (\d+)/, 1]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sort_by_runtime(tests, runtimes, options = {})
|
|
144
|
+
allowed_missing = tests.size * (options[:allowed_missing] || 1.0)
|
|
145
|
+
tests.sort!
|
|
146
|
+
tests.map! do |test|
|
|
147
|
+
time = runtimes[test]
|
|
148
|
+
allowed_missing -= 1 unless time
|
|
149
|
+
if allowed_missing.negative?
|
|
150
|
+
log = options[:runtime_log] || runtime_log
|
|
151
|
+
raise RuntimeLogTooSmallError, "Runtime log file '#{log}' does not contain sufficient data to sort #{tests.size} test files, please update or remove it."
|
|
152
|
+
end
|
|
153
|
+
[test, time]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
set_unknown_runtime(tests, options)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def runtimes(tests, options)
|
|
160
|
+
path = runtime_log_path(options)
|
|
161
|
+
File.read(path).split("\n").each_with_index.each_with_object({}) do |(line, index), times|
|
|
162
|
+
next if line.empty?
|
|
163
|
+
|
|
164
|
+
test, separator, time = line.rpartition(':')
|
|
165
|
+
raise RuntimeLogParseError, "Invalid runtime log line #{index + 1} in #{path}: #{line.inspect}" if separator.empty? || test.empty? || time.empty?
|
|
166
|
+
|
|
167
|
+
times[test] = Float(time) if tests.include?(test)
|
|
168
|
+
rescue ArgumentError
|
|
169
|
+
raise RuntimeLogParseError, "Invalid runtime value on line #{index + 1} in #{path}: #{line.inspect}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def runtime_log_path(options)
|
|
174
|
+
options[:runtime_log] || runtime_log
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def sort_by_filesize(tests)
|
|
178
|
+
tests.sort!
|
|
179
|
+
tests.map! { |test| [test, File.stat(test).size] }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def find_tests(tests, options = {})
|
|
183
|
+
tests.flat_map do |file_or_folder|
|
|
184
|
+
if File.directory?(file_or_folder)
|
|
185
|
+
filter_files(Dir[File.join(file_or_folder, '**/*_spec.rb')].uniq.sort, options)
|
|
186
|
+
else
|
|
187
|
+
filter_files([file_or_folder], options)
|
|
188
|
+
end
|
|
189
|
+
end.uniq
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def filter_files(files, options)
|
|
193
|
+
files = files.grep(options[:pattern]) if options[:pattern]
|
|
194
|
+
files = files.reject { |file| file.match?(options[:exclude_pattern]) } if options[:exclude_pattern]
|
|
195
|
+
files
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def remove_command_arguments(command, *args)
|
|
199
|
+
remove_next = false
|
|
200
|
+
command.reject do |arg|
|
|
201
|
+
if remove_next
|
|
202
|
+
remove_next = false
|
|
203
|
+
true
|
|
204
|
+
elsif args.include?(arg)
|
|
205
|
+
remove_next = true
|
|
206
|
+
true
|
|
207
|
+
elsif args.any? { |option| arg.start_with?("#{option}=") }
|
|
208
|
+
true
|
|
209
|
+
else
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def rerun_env(env)
|
|
216
|
+
env.slice('TEST_ENV_NUMBER', 'PARALLEL_SPECS_GROUPS').reject { |_key, value| value.to_s.empty? }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def test_env_number(process_number)
|
|
220
|
+
process_number.zero? ? '' : (process_number + 1).to_s
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def sum_up_results(results)
|
|
224
|
+
results.join(' ').gsub(/s\b/, '').scan(/(\d+) (\w+)/).each_with_object(Hash.new(0)) do |(number, word), sum|
|
|
225
|
+
sum[word] += number.to_i
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def set_unknown_runtime(tests, options)
|
|
232
|
+
known, unknown = tests.partition(&:last)
|
|
233
|
+
return tests if unknown.empty?
|
|
234
|
+
|
|
235
|
+
unknown_runtime = options[:unknown_runtime] || (known.empty? ? 1 : known.map!(&:last).sum / known.size)
|
|
236
|
+
unknown.each { |entry| entry[1] = unknown_runtime }
|
|
237
|
+
tests
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parallel'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module ParallelSpecs
|
|
8
|
+
WINDOWS = (RbConfig::CONFIG['host_os'] =~ /cygwin|mswin|mingw|bccwin|wince|emx/)
|
|
9
|
+
RUBY_BINARY = File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name'])
|
|
10
|
+
|
|
11
|
+
autoload :CLI, 'parallel_specs/cli'
|
|
12
|
+
autoload :VERSION, 'parallel_specs/version'
|
|
13
|
+
autoload :Grouper, 'parallel_specs/grouper'
|
|
14
|
+
autoload :Pids, 'parallel_specs/pids'
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def determine_number_of_processes(count)
|
|
18
|
+
Integer([
|
|
19
|
+
count,
|
|
20
|
+
ENV['PARALLEL_SPECS_PROCESSORS'],
|
|
21
|
+
Parallel.processor_count
|
|
22
|
+
].detect { |value| !value.to_s.strip.empty? })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_pid_file
|
|
26
|
+
previous_pid_file = ENV['PARALLEL_SPECS_PID_FILE']
|
|
27
|
+
Tempfile.open('parallel_specs-pidfile') do |file|
|
|
28
|
+
ENV['PARALLEL_SPECS_PID_FILE'] = file.path
|
|
29
|
+
@pids = pids
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
ENV['PARALLEL_SPECS_PID_FILE'] = previous_pid_file
|
|
33
|
+
@pids = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pids
|
|
38
|
+
@pids ||= Pids.new(pid_file_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pid_file_available?
|
|
42
|
+
!ENV['PARALLEL_SPECS_PID_FILE'].to_s.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pid_file_path
|
|
46
|
+
ENV.fetch('PARALLEL_SPECS_PID_FILE')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stop_all_processes
|
|
50
|
+
return false unless pid_file_available?
|
|
51
|
+
|
|
52
|
+
tracked_pids = pids.all
|
|
53
|
+
return false if tracked_pids.empty?
|
|
54
|
+
|
|
55
|
+
signal_delivered = false
|
|
56
|
+
tracked_pids.each do |pid|
|
|
57
|
+
Process.kill(:INT, pid)
|
|
58
|
+
signal_delivered = true
|
|
59
|
+
rescue Errno::ESRCH, Errno::EPERM => e
|
|
60
|
+
warn "parallel_specs: unable to interrupt worker pid #{pid}: #{e.class}: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
signal_delivered
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bundler_enabled?
|
|
66
|
+
return true if Object.const_defined?(:Bundler)
|
|
67
|
+
|
|
68
|
+
previous = nil
|
|
69
|
+
current = File.expand_path(Dir.pwd)
|
|
70
|
+
until !File.directory?(current) || current == previous
|
|
71
|
+
return true if File.exist?(File.join(current, 'Gemfile'))
|
|
72
|
+
|
|
73
|
+
previous = current
|
|
74
|
+
current = File.expand_path('..', current)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def with_ruby_binary(command)
|
|
81
|
+
WINDOWS ? [RUBY_BINARY, '--', command] : [command]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def now
|
|
85
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delta
|
|
89
|
+
before = now.to_f
|
|
90
|
+
yield
|
|
91
|
+
now.to_f - before
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: parallel_specs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.9.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Scott Watermasysk
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: parallel
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.28'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '1.28'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: rspec-core
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.13'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '4'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3.13'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '4'
|
|
53
|
+
description:
|
|
54
|
+
email: scottwater@gmail.com
|
|
55
|
+
executables:
|
|
56
|
+
- parallel_specs
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- LICENSE
|
|
61
|
+
- README.md
|
|
62
|
+
- bin/parallel_specs
|
|
63
|
+
- lib/parallel_specs.rb
|
|
64
|
+
- lib/parallel_specs/cli.rb
|
|
65
|
+
- lib/parallel_specs/cli/dashboard.rb
|
|
66
|
+
- lib/parallel_specs/grouper.rb
|
|
67
|
+
- lib/parallel_specs/pids.rb
|
|
68
|
+
- lib/parallel_specs/rspec/dashboard_logger.rb
|
|
69
|
+
- lib/parallel_specs/rspec/logger_base.rb
|
|
70
|
+
- lib/parallel_specs/rspec/runner.rb
|
|
71
|
+
- lib/parallel_specs/rspec/runtime_logger.rb
|
|
72
|
+
- lib/parallel_specs/test/runner.rb
|
|
73
|
+
- lib/parallel_specs/version.rb
|
|
74
|
+
homepage: https://github.com/scottwater/parallel_specs
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata: {}
|
|
78
|
+
post_install_message:
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 3.2.0
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 3.4.19
|
|
94
|
+
signing_key:
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: Parallel RSpec with a live dashboard, plain CI output, and runtime balancing
|
|
97
|
+
test_files: []
|