phene-parallel_tests 0.6.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.
- data/Gemfile +9 -0
- data/Gemfile.lock +28 -0
- data/Rakefile +20 -0
- data/Readme.md +219 -0
- data/VERSION +1 -0
- data/bin/parallel_cucumber +2 -0
- data/bin/parallel_spec +2 -0
- data/bin/parallel_test +91 -0
- data/lib/parallel_cucumber.rb +36 -0
- data/lib/parallel_cucumber/runtime_logger.rb +57 -0
- data/lib/parallel_specs.rb +51 -0
- data/lib/parallel_specs/spec_failures_logger.rb +25 -0
- data/lib/parallel_specs/spec_logger_base.rb +72 -0
- data/lib/parallel_specs/spec_runtime_logger.rb +28 -0
- data/lib/parallel_specs/spec_summary_logger.rb +47 -0
- data/lib/parallel_tests.rb +160 -0
- data/lib/parallel_tests/grouper.rb +31 -0
- data/lib/parallel_tests/railtie.rb +10 -0
- data/lib/parallel_tests/tasks.rb +80 -0
- data/lib/tasks/parallel_tests.rake +1 -0
- data/parallel_tests.gemspec +60 -0
- data/spec/integration_spec.rb +113 -0
- data/spec/parallel_cucumber_spec.rb +72 -0
- data/spec/parallel_specs_spec.rb +264 -0
- data/spec/parallel_tests_spec.rb +212 -0
- data/spec/spec_helper.rb +115 -0
- metadata +106 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'parallel_tests')
|
2
|
+
|
3
|
+
class ParallelSpecs < ParallelTests
|
4
|
+
def self.run_tests(test_files, process_number, options)
|
5
|
+
exe = executable # expensive, so we cache
|
6
|
+
version = (exe =~ /\brspec\b/ ? 2 : 1)
|
7
|
+
cmd = "#{rspec_1_color if version == 1}#{exe} #{options[:test_options]} #{rspec_2_color if version == 2}#{spec_opts(version)} #{test_files*' '}"
|
8
|
+
execute_command(cmd, process_number, options)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.executable
|
12
|
+
cmd = if File.file?("script/spec")
|
13
|
+
"script/spec"
|
14
|
+
elsif bundler_enabled?
|
15
|
+
cmd = (run("bundle show rspec") =~ %r{/rspec-1[^/]+$} ? "spec" : "rspec")
|
16
|
+
"bundle exec #{cmd}"
|
17
|
+
else
|
18
|
+
%w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
|
19
|
+
end
|
20
|
+
cmd or raise("Can't find executables rspec or spec")
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.runtime_log
|
24
|
+
'tmp/parallel_profile.log'
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
# so it can be stubbed....
|
30
|
+
def self.run(cmd)
|
31
|
+
`#{cmd}`
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.rspec_1_color
|
35
|
+
'RSPEC_COLOR=1 ; export RSPEC_COLOR ;' if $stdout.tty?
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.rspec_2_color
|
39
|
+
'--color --tty ' if $stdout.tty?
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.spec_opts(rspec_version)
|
43
|
+
options_file = ['spec/parallel_spec.opts', 'spec/spec.opts'].detect{|f| File.file?(f) }
|
44
|
+
return unless options_file
|
45
|
+
"-O #{options_file}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.test_suffix
|
49
|
+
"_spec.rb"
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'parallel_specs'
|
2
|
+
require File.join(File.dirname(__FILE__), 'spec_logger_base')
|
3
|
+
|
4
|
+
class ParallelSpecs::SpecFailuresLogger < ParallelSpecs::SpecLoggerBase
|
5
|
+
def initialize(options, output=nil)
|
6
|
+
super
|
7
|
+
@failed_examples = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def example_failed(example, count, failure)
|
11
|
+
@failed_examples << example
|
12
|
+
end
|
13
|
+
|
14
|
+
def dump_failure(*args)
|
15
|
+
lock_output do
|
16
|
+
@failed_examples.each.with_index do | example, i |
|
17
|
+
spec_file = example.location.scan(/^[^:]+/)[0]
|
18
|
+
spec_file.gsub!(%r(^.*?/spec/), './spec/')
|
19
|
+
@output.puts "#{ParallelSpecs.executable} #{spec_file} -e \"#{example.description}\""
|
20
|
+
end
|
21
|
+
end
|
22
|
+
@output.flush
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'parallel_specs'
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'rspec/core/formatters/progress_formatter'
|
5
|
+
base = RSpec::Core::Formatters::ProgressFormatter
|
6
|
+
rescue LoadError
|
7
|
+
require 'spec/runner/formatter/progress_bar_formatter'
|
8
|
+
base = Spec::Runner::Formatter::BaseTextFormatter
|
9
|
+
end
|
10
|
+
ParallelSpecs::SpecLoggerBaseBase = base
|
11
|
+
|
12
|
+
class ParallelSpecs::SpecLoggerBase < ParallelSpecs::SpecLoggerBaseBase
|
13
|
+
def initialize(options, output=nil)
|
14
|
+
output ||= options # rspec 2 has output as first argument
|
15
|
+
|
16
|
+
if String === output
|
17
|
+
FileUtils.mkdir_p(File.dirname(output))
|
18
|
+
File.open(output, 'w'){} # overwrite previous results
|
19
|
+
@output = File.open(output, 'a')
|
20
|
+
elsif File === output
|
21
|
+
output.close # close file opened with 'w'
|
22
|
+
@output = File.open(output.path, 'a')
|
23
|
+
else
|
24
|
+
@output = output
|
25
|
+
end
|
26
|
+
|
27
|
+
@failed_examples = [] # only needed for rspec 2
|
28
|
+
end
|
29
|
+
|
30
|
+
def example_started(*args)
|
31
|
+
end
|
32
|
+
|
33
|
+
def example_passed(example)
|
34
|
+
end
|
35
|
+
|
36
|
+
def example_pending(*args)
|
37
|
+
end
|
38
|
+
|
39
|
+
def example_failed(*args)
|
40
|
+
end
|
41
|
+
|
42
|
+
def start_dump(*args)
|
43
|
+
end
|
44
|
+
|
45
|
+
def dump_summary(*args)
|
46
|
+
end
|
47
|
+
|
48
|
+
def dump_pending(*args)
|
49
|
+
end
|
50
|
+
|
51
|
+
def dump_failure(*args)
|
52
|
+
end
|
53
|
+
|
54
|
+
#stolen from Rspec
|
55
|
+
def close
|
56
|
+
@output.close if (IO === @output) & (@output != $stdout)
|
57
|
+
end
|
58
|
+
|
59
|
+
# do not let multiple processes get in each others way
|
60
|
+
def lock_output
|
61
|
+
if File === @output
|
62
|
+
begin
|
63
|
+
@output.flock File::LOCK_EX
|
64
|
+
yield
|
65
|
+
ensure
|
66
|
+
@output.flock File::LOCK_UN
|
67
|
+
end
|
68
|
+
else
|
69
|
+
yield
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'parallel_specs'
|
2
|
+
require File.join(File.dirname(__FILE__), 'spec_logger_base')
|
3
|
+
|
4
|
+
class ParallelSpecs::SpecRuntimeLogger < ParallelSpecs::SpecLoggerBase
|
5
|
+
def initialize(options, output=nil)
|
6
|
+
super
|
7
|
+
@example_times = Hash.new(0)
|
8
|
+
end
|
9
|
+
|
10
|
+
def example_started(*args)
|
11
|
+
@time = Time.now
|
12
|
+
end
|
13
|
+
|
14
|
+
def example_passed(example)
|
15
|
+
file = example.location.split(':').first
|
16
|
+
@example_times[file] += Time.now - @time
|
17
|
+
end
|
18
|
+
|
19
|
+
def start_dump(*args)
|
20
|
+
return unless ENV['TEST_ENV_NUMBER'] #only record when running in parallel
|
21
|
+
# TODO: Figure out why sometimes time can be less than 0
|
22
|
+
lock_output do
|
23
|
+
@output.puts @example_times.map { |file, time| "#{file}:#{time > 0 ? time : 0}" }
|
24
|
+
end
|
25
|
+
@output.flush
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'parallel_specs'
|
2
|
+
require File.join(File.dirname(__FILE__), 'spec_logger_base')
|
3
|
+
|
4
|
+
class ParallelSpecs::SpecSummaryLogger < ParallelSpecs::SpecLoggerBase
|
5
|
+
def initialize(options, output=nil)
|
6
|
+
super
|
7
|
+
@passed_examples = []
|
8
|
+
@pending_examples = []
|
9
|
+
@failed_examples = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def example_passed(example)
|
13
|
+
@passed_examples << example
|
14
|
+
end
|
15
|
+
|
16
|
+
def example_pending(*args)
|
17
|
+
@pending_examples << args
|
18
|
+
end
|
19
|
+
|
20
|
+
def example_failed(example, count, failure)
|
21
|
+
@failed_examples << failure
|
22
|
+
end
|
23
|
+
|
24
|
+
def dump_summary(duration, example_count, failure_count, pending_count)
|
25
|
+
lock_output do
|
26
|
+
@output.puts "#{ @passed_examples.size } examples passed"
|
27
|
+
end
|
28
|
+
@output.flush
|
29
|
+
end
|
30
|
+
|
31
|
+
def dump_failure(*args)
|
32
|
+
lock_output do
|
33
|
+
@output.puts "#{ @failed_examples.size } examples failed:"
|
34
|
+
@failed_examples.each.with_index do | failure, i |
|
35
|
+
@output.puts "#{ i + 1 })"
|
36
|
+
@output.puts failure.header
|
37
|
+
@output.puts failure.exception.to_s
|
38
|
+
failure.exception.backtrace.each do | caller |
|
39
|
+
@output.puts caller
|
40
|
+
end
|
41
|
+
@output.puts ''
|
42
|
+
end
|
43
|
+
end
|
44
|
+
@output.flush
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
require 'parallel'
|
2
|
+
require 'parallel_tests/grouper'
|
3
|
+
require 'parallel_tests/railtie'
|
4
|
+
|
5
|
+
class ParallelTests
|
6
|
+
VERSION = File.read( File.join(File.dirname(__FILE__),'..','VERSION') ).strip
|
7
|
+
|
8
|
+
# parallel:spec[:count, :pattern, :options]
|
9
|
+
def self.parse_rake_args(args)
|
10
|
+
# order as given by user
|
11
|
+
args = [args[:count], args[:pattern], args[:options]]
|
12
|
+
|
13
|
+
# count given or empty ?
|
14
|
+
# parallel:spec[2,models,options]
|
15
|
+
# parallel:spec[,models,options]
|
16
|
+
count = args.shift if args.first.to_s =~ /^\d*$/
|
17
|
+
num_processes = (count.to_s.empty? ? Parallel.processor_count : count.to_i)
|
18
|
+
|
19
|
+
pattern = args.shift
|
20
|
+
options = args.shift
|
21
|
+
|
22
|
+
[num_processes.to_i, pattern.to_s, options.to_s]
|
23
|
+
end
|
24
|
+
|
25
|
+
# finds all tests and partitions them into groups
|
26
|
+
def self.tests_in_groups(root, num_groups, options={})
|
27
|
+
tests = find_tests(root, options)
|
28
|
+
if options[:no_sort] == true
|
29
|
+
Grouper.in_groups(tests, num_groups)
|
30
|
+
else
|
31
|
+
tests = with_runtime_info(tests)
|
32
|
+
Grouper.in_even_groups_by_size(tests, num_groups)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.run_tests(test_files, process_number, options)
|
37
|
+
require_list = test_files.map { |filename| "\"#{filename}\"" }.join(",")
|
38
|
+
cmd = "ruby -Itest -e '[#{require_list}].each {|f| require f }' - #{options[:test_options]}"
|
39
|
+
execute_command(cmd, process_number, options)
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.execute_command(cmd, process_number, options)
|
43
|
+
cmd = "TEST_ENV_NUMBER=#{test_env_number(process_number)} ; export TEST_ENV_NUMBER; #{cmd}"
|
44
|
+
f = open("|#{cmd}", 'r')
|
45
|
+
output = fetch_output(f, options)
|
46
|
+
f.close
|
47
|
+
{:stdout => output, :exit_status => $?.exitstatus}
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.find_results(test_output)
|
51
|
+
test_output.split("\n").map {|line|
|
52
|
+
line = line.gsub(/\.|F|\*/,'')
|
53
|
+
next unless line_is_result?(line)
|
54
|
+
line
|
55
|
+
}.compact
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.test_env_number(process_number)
|
59
|
+
process_number == 0 ? '' : process_number + 1
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.runtime_log
|
63
|
+
'__foo__'
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.summarize_results(results)
|
67
|
+
results = results.join(' ').gsub(/s\b/,'') # combine and singularize results
|
68
|
+
counts = results.scan(/(\d+) (\w+)/)
|
69
|
+
sums = counts.inject(Hash.new(0)) do |sum, (number, word)|
|
70
|
+
sum[word] += number.to_i
|
71
|
+
sum
|
72
|
+
end
|
73
|
+
sums.sort.map{|word, number| "#{number} #{word}#{'s' if number != 1}" }.join(', ')
|
74
|
+
end
|
75
|
+
|
76
|
+
protected
|
77
|
+
|
78
|
+
# read output of the process and print in in chucks
|
79
|
+
def self.fetch_output(process, options)
|
80
|
+
all = ''
|
81
|
+
buffer = ''
|
82
|
+
timeout = options[:chunk_timeout] || 0.2
|
83
|
+
flushed = Time.now.to_f
|
84
|
+
|
85
|
+
while char = process.getc
|
86
|
+
char = (char.is_a?(Fixnum) ? char.chr : char) # 1.8 <-> 1.9
|
87
|
+
all << char
|
88
|
+
|
89
|
+
# print in chunks so large blocks stay together
|
90
|
+
now = Time.now.to_f
|
91
|
+
buffer << char
|
92
|
+
if flushed + timeout < now
|
93
|
+
print buffer
|
94
|
+
STDOUT.flush
|
95
|
+
buffer = ''
|
96
|
+
flushed = now
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# print the remainder
|
101
|
+
print buffer
|
102
|
+
STDOUT.flush
|
103
|
+
|
104
|
+
all
|
105
|
+
end
|
106
|
+
|
107
|
+
# copied from http://github.com/carlhuda/bundler Bundler::SharedHelpers#find_gemfile
|
108
|
+
def self.bundler_enabled?
|
109
|
+
return true if Object.const_defined?(:Bundler)
|
110
|
+
|
111
|
+
previous = nil
|
112
|
+
current = File.expand_path(Dir.pwd)
|
113
|
+
|
114
|
+
until !File.directory?(current) || current == previous
|
115
|
+
filename = File.join(current, "Gemfile")
|
116
|
+
return true if File.exists?(filename)
|
117
|
+
current, previous = File.expand_path("..", current), current
|
118
|
+
end
|
119
|
+
|
120
|
+
false
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.line_is_result?(line)
|
124
|
+
line =~ /\d+ failure/
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.test_suffix
|
128
|
+
"_test.rb"
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.with_runtime_info(tests)
|
132
|
+
lines = File.read(runtime_log).split("\n") rescue []
|
133
|
+
|
134
|
+
# use recorded test runtime if we got enough data
|
135
|
+
if lines.size * 1.5 > tests.size
|
136
|
+
puts "Using recorded test runtime"
|
137
|
+
times = Hash.new(1)
|
138
|
+
lines.each do |line|
|
139
|
+
test, time = line.split(":")
|
140
|
+
times[test] = time.to_f
|
141
|
+
end
|
142
|
+
tests.sort.map{|test| [test, times[test]] }
|
143
|
+
else # use file sizes
|
144
|
+
tests.sort.map{|test| [test, File.stat(test).size] }
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def self.find_tests(root, options={})
|
149
|
+
if root.is_a?(Array)
|
150
|
+
root
|
151
|
+
else
|
152
|
+
# follow one symlink and direct children
|
153
|
+
# http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
|
154
|
+
files = Dir["#{root}/**{,/*/**}/*#{test_suffix}"].uniq
|
155
|
+
files = files.map{|f| f.sub(root+'/','') }
|
156
|
+
files = files.grep(/#{options[:pattern]}/)
|
157
|
+
files.map{|f| "#{root}/#{f}" }
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class ParallelTests
|
2
|
+
class Grouper
|
3
|
+
def self.in_groups(items, num_groups)
|
4
|
+
[].tap do |groups|
|
5
|
+
while ! items.empty?
|
6
|
+
(0...num_groups).map do |group_number|
|
7
|
+
groups[group_number] ||= []
|
8
|
+
groups[group_number] << items.shift
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.in_even_groups_by_size(items_with_sizes, num_groups)
|
15
|
+
items_with_size = smallest_first(items_with_sizes)
|
16
|
+
groups = Array.new(num_groups){{:items => [], :size => 0}}
|
17
|
+
items_with_size.each do |item, size|
|
18
|
+
# always add to smallest group
|
19
|
+
smallest = groups.sort_by{|g| g[:size] }.first
|
20
|
+
smallest[:items] << item
|
21
|
+
smallest[:size] += size
|
22
|
+
end
|
23
|
+
|
24
|
+
groups.map{|g| g[:items] }
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.smallest_first(files)
|
28
|
+
files.sort_by{|item, size| size }.reverse
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
namespace :parallel do
|
2
|
+
def run_in_parallel(cmd, options)
|
3
|
+
count = (options[:count] ? options[:count].to_i : nil)
|
4
|
+
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallel_test')
|
5
|
+
command = "#{executable} --exec '#{cmd}' -n #{count} #{'--non-parallel' if options[:non_parallel]}"
|
6
|
+
abort unless system(command)
|
7
|
+
end
|
8
|
+
|
9
|
+
desc "create test databases via db:create --> parallel:create[num_cpus]"
|
10
|
+
task :create, :count do |t,args|
|
11
|
+
run_in_parallel('rake db:create RAILS_ENV=test', args)
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "drop test databases via db:drop --> parallel:drop[num_cpus]"
|
15
|
+
task :drop, :count do |t,args|
|
16
|
+
run_in_parallel('rake db:drop RAILS_ENV=test', args)
|
17
|
+
end
|
18
|
+
|
19
|
+
desc "update test databases by dumping and loading --> parallel:prepare[num_cpus]"
|
20
|
+
task(:prepare, [:count] => 'db:abort_if_pending_migrations') do |t,args|
|
21
|
+
if defined?(ActiveRecord) && ActiveRecord::Base.schema_format == :ruby
|
22
|
+
# dump then load in parallel
|
23
|
+
Rake::Task['db:schema:dump'].invoke
|
24
|
+
Rake::Task['parallel:load_schema'].invoke(args[:count])
|
25
|
+
else
|
26
|
+
# there is no separate dump / load for schema_format :sql -> do it safe and slow
|
27
|
+
args = args.to_hash.merge(:non_parallel => true) # normal merge returns nil
|
28
|
+
run_in_parallel('rake db:test:prepare --trace', args)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# when dumping/resetting takes too long
|
33
|
+
desc "update test databases via db:migrate --> parallel:migrate[num_cpus]"
|
34
|
+
task :migrate, :count do |t,args|
|
35
|
+
run_in_parallel('rake db:migrate RAILS_ENV=test', args)
|
36
|
+
end
|
37
|
+
|
38
|
+
# just load the schema (good for integration server <-> no development db)
|
39
|
+
desc "load dumped schema for test databases via db:schema:load --> parallel:load_schema[num_cpus]"
|
40
|
+
task :load_schema, :count do |t,args|
|
41
|
+
run_in_parallel('rake db:test:load', args)
|
42
|
+
end
|
43
|
+
|
44
|
+
['test', 'spec', 'features'].each do |type|
|
45
|
+
desc "run #{type} in parallel with parallel:#{type}[num_cpus]"
|
46
|
+
task type, :count, :pattern, :options do |t,args|
|
47
|
+
$LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..'))
|
48
|
+
require "parallel_tests"
|
49
|
+
count, pattern, options = ParallelTests.parse_rake_args(args)
|
50
|
+
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallel_test')
|
51
|
+
command = "#{executable} --type #{type} -n #{count} -p '#{pattern}' -r '#{Rails.root}' -o '#{options}'"
|
52
|
+
abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
#backwards compatability
|
58
|
+
#spec:parallel:prepare
|
59
|
+
#spec:parallel
|
60
|
+
#test:parallel
|
61
|
+
namespace :spec do
|
62
|
+
namespace :parallel do
|
63
|
+
task :prepare, :count do |t,args|
|
64
|
+
$stderr.puts "WARNING -- Deprecated! use parallel:prepare"
|
65
|
+
Rake::Task['parallel:prepare'].invoke(args[:count])
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
task :parallel, :count, :pattern do |t,args|
|
70
|
+
$stderr.puts "WARNING -- Deprecated! use parallel:spec"
|
71
|
+
Rake::Task['parallel:spec'].invoke(args[:count], args[:pattern])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
namespace :test do
|
76
|
+
task :parallel, :count, :pattern do |t,args|
|
77
|
+
$stderr.puts "WARNING -- Deprecated! use parallel:test"
|
78
|
+
Rake::Task['parallel:test'].invoke(args[:count], args[:pattern])
|
79
|
+
end
|
80
|
+
end
|