parallelized_specs 0.0.5 → 0.0.6
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 +0 -10
- data/Rakefile +1 -1
- data/VERSION +1 -1
- data/bin/parallelized_spec +94 -1
- data/lib/parallelized_specs.rb +161 -17
- data/lib/{parallelized_tests → parallelized_specs}/grouper.rb +1 -1
- data/lib/{parallelized_tests → parallelized_specs}/railtie.rb +2 -2
- data/lib/parallelized_specs/runtime_logger.rb +26 -0
- data/lib/{parallelized_tests → parallelized_specs}/tasks.rb +17 -19
- data/lib/tasks/parallelized_specs.rake +1 -0
- data/parallelized_specs.gemspec +8 -16
- metadata +11 -30
- data/bin/parallelized_test +0 -96
- data/lib/parallelized_tests.rb +0 -163
- data/lib/parallelized_tests/runtime_logger.rb +0 -78
- data/lib/tasks/parallelized_tests.rake +0 -1
- data/spec/integration_spec.rb +0 -132
- data/spec/parallelized_tests/runtime_logger_spec.rb +0 -74
- data/spec/parallelized_tests_spec.rb +0 -229
data/Gemfile
CHANGED
data/Rakefile
CHANGED
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0.
|
1
|
+
0.0.6
|
data/bin/parallelized_spec
CHANGED
@@ -1,2 +1,95 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
|
2
|
+
require 'rubygems'
|
3
|
+
require 'optparse'
|
4
|
+
require 'parallel'
|
5
|
+
raise "please ' gem install parallel '" if Gem::Version.new(Parallel::VERSION) < Gem::Version.new('0.4.2')
|
6
|
+
$LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
|
7
|
+
require "parallelized_specs"
|
8
|
+
|
9
|
+
options = {}
|
10
|
+
OptionParser.new do |opts|
|
11
|
+
opts.banner = <<BANNER
|
12
|
+
Run all tests in parallel, giving each process ENV['TEST_ENV_NUMBER'] ('', '2', '3', ...)
|
13
|
+
|
14
|
+
[optional] Only run selected files & folders:
|
15
|
+
parallelized_spec test/bar test/baz/xxx_text_spec.rb
|
16
|
+
|
17
|
+
Options are:
|
18
|
+
BANNER
|
19
|
+
opts.on("-n [PROCESSES]", Integer, "How many processes to use, default: available CPUs"){|n| options[:count] = n }
|
20
|
+
opts.on("-p", '--pattern [PATTERN]', "run tests matching this pattern"){|pattern| options[:pattern] = pattern }
|
21
|
+
opts.on("--no-sort", "do not sort files before running them"){ |no_sort| options[:no_sort] = no_sort }
|
22
|
+
opts.on("-m [FLOAT]", "--multiply-processes [FLOAT]", Float, "use given number as a multiplier of processes to run"){ |multiply| options[:multiply] = multiply }
|
23
|
+
opts.on("-r", '--root [PATH]', "execute test commands from this path"){|path| options[:root] = path }
|
24
|
+
opts.on("-s [PATTERN]", "--single [PATTERN]", "Run all matching files in only one process") do |pattern|
|
25
|
+
options[:single_process] ||= []
|
26
|
+
options[:single_process] << /#{pattern}/
|
27
|
+
end
|
28
|
+
opts.on("-e", '--exec [COMMAND]', "execute this code parallel and with ENV['TEST_ENV_NUM']"){|path| options[:execute] = path }
|
29
|
+
opts.on("-o", "--test-options '[OPTIONS]'", "execute test commands with those options"){|arg| options[:test_options] = arg }
|
30
|
+
opts.on("-t", "--type [TYPE]", "which type of tests to run? test, spec or features"){|type| options[:type] = type }
|
31
|
+
opts.on("--non-parallel", "execute same commands but do not in parallel, needs --exec"){ options[:non_parallel] = true }
|
32
|
+
opts.on("--chunk-timeout [TIMEOUT]", "timeout before re-printing the output of a child-process"){|timeout| options[:chunk_timeout] = timeout.to_f }
|
33
|
+
opts.on('-v', '--version', 'Show Version'){ puts ParallelizedSpecs::VERSION; exit}
|
34
|
+
opts.on("-h", "--help", "Show this.") { puts opts; exit }
|
35
|
+
end.parse!
|
36
|
+
|
37
|
+
raise "--no-sort and --single-process are not supported" if options[:no_sort] and options[:single_process]
|
38
|
+
|
39
|
+
# get files to run from arguments
|
40
|
+
options[:files] = ARGV if ARGV.size > 0
|
41
|
+
|
42
|
+
num_processes = options[:count] || Parallel.processor_count
|
43
|
+
num_processes = num_processes * (options[:multiply] || 1)
|
44
|
+
|
45
|
+
if options[:execute]
|
46
|
+
runs = (0...num_processes).to_a
|
47
|
+
results = if options[:non_parallel]
|
48
|
+
runs.map do |i|
|
49
|
+
ParallelizedSpecs.execute_command(options[:execute], i, options)
|
50
|
+
end
|
51
|
+
else
|
52
|
+
Parallel.map(runs, :in_processes => num_processes) do |i|
|
53
|
+
ParallelizedSpecs.execute_command(options[:execute], i, options)
|
54
|
+
end
|
55
|
+
end.flatten
|
56
|
+
abort if results.any?{|r| r[:exit_status] != 0 }
|
57
|
+
else
|
58
|
+
lib, name, task = {
|
59
|
+
'spec' => %w(specs spec spec),
|
60
|
+
}[options[:type]||'spec']
|
61
|
+
|
62
|
+
require "parallelized_#{lib}"
|
63
|
+
klass = eval("Parallelized#{lib.capitalize}")
|
64
|
+
|
65
|
+
start = Time.now
|
66
|
+
|
67
|
+
tests_folder = task
|
68
|
+
tests_folder = File.join(options[:root], tests_folder) unless options[:root].to_s.empty?
|
69
|
+
|
70
|
+
groups = klass.tests_in_groups(options[:files] || tests_folder, num_processes, options)
|
71
|
+
num_processes = groups.size
|
72
|
+
|
73
|
+
#adjust processes to groups
|
74
|
+
abort "no #{name}s found!" if groups.size == 0
|
75
|
+
|
76
|
+
num_tests = groups.inject(0){|sum,item| sum + item.size }
|
77
|
+
puts "#{num_processes} processes for #{num_tests} #{name}s, ~ #{num_tests / groups.size} #{name}s per process"
|
78
|
+
|
79
|
+
test_results = Parallel.map(groups, :in_processes => num_processes) do |group|
|
80
|
+
klass.run_tests(group, groups.index(group), options)
|
81
|
+
end
|
82
|
+
|
83
|
+
#parse and print results
|
84
|
+
results = klass.find_results(test_results.map{|result| result[:stdout] }*"")
|
85
|
+
puts ""
|
86
|
+
puts klass.summarize_results(results)
|
87
|
+
|
88
|
+
#report total time taken
|
89
|
+
puts ""
|
90
|
+
puts "Took #{Time.now - start} seconds"
|
91
|
+
|
92
|
+
#exit with correct status code so rake parallel:test && echo 123 works
|
93
|
+
failed = test_results.any?{|result| result[:exit_status] != 0 }
|
94
|
+
abort "#{name.capitalize}s Failed" if failed
|
95
|
+
end
|
data/lib/parallelized_specs.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
|
-
require '
|
1
|
+
require 'parallel'
|
2
|
+
require 'parallelized_specs/grouper'
|
3
|
+
require 'parallelized_specs/railtie'
|
4
|
+
|
5
|
+
class ParallelizedSpecs
|
6
|
+
VERSION = File.read(File.join(File.dirname(__FILE__), '..', 'VERSION')).strip
|
2
7
|
|
3
|
-
class ParallelizedSpecs < ParallelizedTests
|
4
8
|
def self.run_tests(test_files, process_number, options)
|
5
9
|
exe = executable # expensive, so we cache
|
6
10
|
version = (exe =~ /\brspec\b/ ? 2 : 1)
|
@@ -10,24 +14,18 @@ class ParallelizedSpecs < ParallelizedTests
|
|
10
14
|
|
11
15
|
def self.executable
|
12
16
|
cmd = if File.file?("script/spec")
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
"script/spec"
|
18
|
+
elsif bundler_enabled?
|
19
|
+
cmd = (run("bundle show rspec") =~ %r{/rspec-1[^/]+$} ? "spec" : "rspec")
|
20
|
+
"bundle exec #{cmd}"
|
21
|
+
else
|
22
|
+
%w[spec rspec].detect { |cmd| system "#{cmd} --version > /dev/null 2>&1" }
|
23
|
+
end
|
20
24
|
cmd or raise("Can't find executables rspec or spec")
|
21
25
|
end
|
22
26
|
|
23
|
-
# legacy <-> people log to this file using rspec options
|
24
|
-
def self.runtime_log
|
25
|
-
'tmp/parallelized_profile.log'
|
26
|
-
end
|
27
|
-
|
28
27
|
protected
|
29
|
-
|
30
|
-
# so it can be stubbed....
|
28
|
+
#so it can be stubbed....
|
31
29
|
def self.run(cmd)
|
32
30
|
`#{cmd}`
|
33
31
|
end
|
@@ -41,7 +39,7 @@ class ParallelizedSpecs < ParallelizedTests
|
|
41
39
|
end
|
42
40
|
|
43
41
|
def self.spec_opts(rspec_version)
|
44
|
-
options_file =
|
42
|
+
options_file = %w(spec/parallelized_spec.opts spec/spec.opts).detect { |f| File.file?(f) }
|
45
43
|
return unless options_file
|
46
44
|
"-O #{options_file}"
|
47
45
|
end
|
@@ -49,4 +47,150 @@ class ParallelizedSpecs < ParallelizedTests
|
|
49
47
|
def self.test_suffix
|
50
48
|
"_spec.rb"
|
51
49
|
end
|
50
|
+
|
51
|
+
# parallel:spec[:count, :pattern, :options]
|
52
|
+
def self.parse_rake_args(args)
|
53
|
+
# order as given by user
|
54
|
+
args = [args[:count], args[:pattern], args[:options]]
|
55
|
+
|
56
|
+
# count given or empty ?
|
57
|
+
# parallel:spec[2,models,options]
|
58
|
+
# parallel:spec[,models,options]
|
59
|
+
count = args.shift if args.first.to_s =~ /^\d*$/
|
60
|
+
num_processes = count.to_i unless count.to_s.empty?
|
61
|
+
num_processes ||= ENV['PARALLEL_TEST_PROCESSORS'].to_i if ENV['PARALLEL_TEST_PROCESSORS']
|
62
|
+
num_processes ||= Parallel.processor_count
|
63
|
+
|
64
|
+
pattern = args.shift
|
65
|
+
options = args.shift
|
66
|
+
|
67
|
+
[num_processes.to_i, pattern.to_s, options.to_s]
|
68
|
+
end
|
69
|
+
|
70
|
+
# finds all tests and partitions them into groups
|
71
|
+
def self.tests_in_groups(root, num_groups, options={})
|
72
|
+
tests = find_tests(root, options)
|
73
|
+
if options[:no_sort]
|
74
|
+
Grouper.in_groups(tests, num_groups)
|
75
|
+
else
|
76
|
+
tests = with_runtime_info(tests)
|
77
|
+
Grouper.in_even_groups_by_size(tests, num_groups, options)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.execute_command(cmd, process_number, options)
|
82
|
+
cmd = "TEST_ENV_NUMBER=#{test_env_number(process_number)} ; export TEST_ENV_NUMBER; #{cmd}"
|
83
|
+
f = open("|#{cmd}", 'r')
|
84
|
+
output = fetch_output(f, options)
|
85
|
+
f.close
|
86
|
+
{:stdout => output, :exit_status => $?.exitstatus}
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.find_results(test_output)
|
90
|
+
test_output.split("\n").map { |line|
|
91
|
+
line = line.gsub(/\.|F|\*/, '')
|
92
|
+
next unless line_is_result?(line)
|
93
|
+
line
|
94
|
+
}.compact
|
95
|
+
end
|
96
|
+
|
97
|
+
def self.test_env_number(process_number)
|
98
|
+
process_number == 0 ? '' : process_number + 1
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.runtime_log
|
102
|
+
'tmp/parallelized_runtime_test.log'
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.summarize_results(results)
|
106
|
+
results = results.join(' ').gsub(/s\b/, '') # combine and singularize results
|
107
|
+
counts = results.scan(/(\d+) (\w+)/)
|
108
|
+
sums = counts.inject(Hash.new(0)) do |sum, (number, word)|
|
109
|
+
sum[word] += number.to_i
|
110
|
+
sum
|
111
|
+
end
|
112
|
+
sums.sort.map { |word, number| "#{number} #{word}#{'s' if number != 1}" }.join(', ')
|
113
|
+
end
|
114
|
+
|
115
|
+
protected
|
116
|
+
|
117
|
+
# read output of the process and print in in chucks
|
118
|
+
def self.fetch_output(process, options)
|
119
|
+
all = ''
|
120
|
+
buffer = ''
|
121
|
+
timeout = options[:chunk_timeout] || 0.2
|
122
|
+
flushed = Time.now.to_f
|
123
|
+
|
124
|
+
while (char = process.getc)
|
125
|
+
char = (char.is_a?(Fixnum) ? char.chr : char) # 1.8 <-> 1.9
|
126
|
+
all << char
|
127
|
+
|
128
|
+
# print in chunks so large blocks stay together
|
129
|
+
now = Time.now.to_f
|
130
|
+
buffer << char
|
131
|
+
if flushed + timeout < now
|
132
|
+
print buffer
|
133
|
+
STDOUT.flush
|
134
|
+
buffer = ''
|
135
|
+
flushed = now
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# print the remainder
|
140
|
+
print buffer
|
141
|
+
STDOUT.flush
|
142
|
+
|
143
|
+
all
|
144
|
+
end
|
145
|
+
|
146
|
+
# copied from http://github.com/carlhuda/bundler Bundler::SharedHelpers#find_gemfile
|
147
|
+
def self.bundler_enabled?
|
148
|
+
return true if Object.const_defined?(:Bundler)
|
149
|
+
|
150
|
+
previous = nil
|
151
|
+
current = File.expand_path(Dir.pwd)
|
152
|
+
|
153
|
+
until !File.directory?(current) || current == previous
|
154
|
+
filename = File.join(current, "Gemfile")
|
155
|
+
return true if File.exists?(filename)
|
156
|
+
current, previous = File.expand_path("..", current), current
|
157
|
+
end
|
158
|
+
|
159
|
+
false
|
160
|
+
end
|
161
|
+
|
162
|
+
def self.line_is_result?(line)
|
163
|
+
line =~ /\d+ failure/
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.with_runtime_info(tests)
|
167
|
+
lines = File.read(runtime_log).split("\n") rescue []
|
168
|
+
|
169
|
+
# use recorded test runtime if we got enough data
|
170
|
+
if lines.size * 1.5 > tests.size
|
171
|
+
puts "Using recorded test runtime"
|
172
|
+
times = Hash.new(1)
|
173
|
+
lines.each do |line|
|
174
|
+
test, time = line.split(":")
|
175
|
+
next unless test and time
|
176
|
+
times[File.expand_path(test)] = time.to_f
|
177
|
+
end
|
178
|
+
tests.sort.map { |test| [test, times[test]] }
|
179
|
+
else # use file sizes
|
180
|
+
tests.sort.map { |test| [test, File.stat(test).size] }
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def self.find_tests(root, options={})
|
185
|
+
if root.is_a?(Array)
|
186
|
+
root
|
187
|
+
else
|
188
|
+
# follow one symlink and direct children
|
189
|
+
# http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
|
190
|
+
files = Dir["#{root}/**{,/*/**}/*#{test_suffix}"].uniq
|
191
|
+
files = files.map { |f| f.sub(root+'/', '') }
|
192
|
+
files = files.grep(/#{options[:pattern]}/)
|
193
|
+
files.map { |f| "#{root}/#{f}" }
|
194
|
+
end
|
195
|
+
end
|
52
196
|
end
|
@@ -1,9 +1,9 @@
|
|
1
1
|
# add rake tasks if we are inside Rails
|
2
2
|
if defined?(Rails::Railtie)
|
3
|
-
class
|
3
|
+
class ParallelizedSpecs
|
4
4
|
class Railtie < ::Rails::Railtie
|
5
5
|
rake_tasks do
|
6
|
-
load File.expand_path("
|
6
|
+
load File.expand_path("../tasks/parallelized_specs.rake", __FILE__)
|
7
7
|
end
|
8
8
|
end
|
9
9
|
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
class ParallelizedSpecs::RuntimeLogger
|
2
|
+
@@has_started = false
|
3
|
+
|
4
|
+
def self.log(test, start_time, end_time)
|
5
|
+
|
6
|
+
if !@@has_started # make empty log file
|
7
|
+
File.open(ParallelizedSpecs.runtime_log, 'w') do end
|
8
|
+
@@has_started = true
|
9
|
+
end
|
10
|
+
|
11
|
+
File.open(ParallelizedSpecs.runtime_log, 'a') do |output|
|
12
|
+
begin
|
13
|
+
output.flock File::LOCK_EX
|
14
|
+
output.puts(self.message(test, start_time, end_time))
|
15
|
+
ensure
|
16
|
+
output.flock File::LOCK_UN
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.message(test, start_time, end_time)
|
22
|
+
delta="%.2f" % (end_time.to_f-start_time.to_f)
|
23
|
+
filename=class_directory(test.class) + class_to_filename(test.class) + ".rb"
|
24
|
+
message="#{filename}:#{delta}"
|
25
|
+
end
|
26
|
+
end
|
@@ -1,23 +1,23 @@
|
|
1
1
|
namespace :parallel do
|
2
2
|
def run_in_parallel(cmd, options)
|
3
3
|
count = (options[:count] ? options[:count].to_i : nil)
|
4
|
-
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', '
|
4
|
+
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallelized_spec')
|
5
5
|
command = "#{executable} --exec '#{cmd}' -n #{count} #{'--non-parallel' if options[:non_parallel]}"
|
6
6
|
abort unless system(command)
|
7
7
|
end
|
8
8
|
|
9
9
|
desc "create test databases via db:create --> parallel:create[num_cpus]"
|
10
|
-
task :create, :count do |t,args|
|
10
|
+
task :create, :count do |t, args|
|
11
11
|
run_in_parallel('rake db:create RAILS_ENV=test', args)
|
12
12
|
end
|
13
13
|
|
14
14
|
desc "drop test databases via db:drop --> parallel:drop[num_cpus]"
|
15
|
-
task :drop, :count do |t,args|
|
15
|
+
task :drop, :count do |t, args|
|
16
16
|
run_in_parallel('rake db:drop RAILS_ENV=test', args)
|
17
17
|
end
|
18
18
|
|
19
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|
|
20
|
+
task(:prepare, [:count] => 'db:abort_if_pending_migrations') do |t, args|
|
21
21
|
if defined?(ActiveRecord) && ActiveRecord::Base.schema_format == :ruby
|
22
22
|
# dump then load in parallel
|
23
23
|
Rake::Task['db:schema:dump'].invoke
|
@@ -31,26 +31,24 @@ namespace :parallel do
|
|
31
31
|
|
32
32
|
# when dumping/resetting takes too long
|
33
33
|
desc "update test databases via db:migrate --> parallel:migrate[num_cpus]"
|
34
|
-
task :migrate, :count do |t,args|
|
34
|
+
task :migrate, :count do |t, args|
|
35
35
|
run_in_parallel('rake db:migrate RAILS_ENV=test', args)
|
36
36
|
end
|
37
37
|
|
38
38
|
# just load the schema (good for integration server <-> no development db)
|
39
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|
|
40
|
+
task :load_schema, :count do |t, args|
|
41
41
|
run_in_parallel('rake db:test:load', args)
|
42
42
|
end
|
43
43
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
|
53
|
-
end
|
44
|
+
desc "run spec in parallel with parallel:spec[num_cpus]"
|
45
|
+
task 'spec', :count, :pattern, :options, :arguments do |t, args|
|
46
|
+
$LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..'))
|
47
|
+
require "parallelized_specs"
|
48
|
+
count, pattern, options = ParallelizedSpecs.parse_rake_args(args)
|
49
|
+
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallelized_spec')
|
50
|
+
command = "#{executable} --type 'spec' -n #{count} -p '#{pattern}' -r '#{Rails.root}' -o '#{options}' #{args[:arguments]}"
|
51
|
+
abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
|
54
52
|
end
|
55
53
|
end
|
56
54
|
|
@@ -60,20 +58,20 @@ end
|
|
60
58
|
#test:parallel
|
61
59
|
namespace :spec do
|
62
60
|
namespace :parallel do
|
63
|
-
task :prepare, :count do |t,args|
|
61
|
+
task :prepare, :count do |t, args|
|
64
62
|
$stderr.puts "WARNING -- Deprecated! use parallel:prepare"
|
65
63
|
Rake::Task['parallel:prepare'].invoke(args[:count])
|
66
64
|
end
|
67
65
|
end
|
68
66
|
|
69
|
-
task :parallel, :count, :pattern do |t,args|
|
67
|
+
task :parallel, :count, :pattern do |t, args|
|
70
68
|
$stderr.puts "WARNING -- Deprecated! use parallel:spec"
|
71
69
|
Rake::Task['parallel:spec'].invoke(args[:count], args[:pattern])
|
72
70
|
end
|
73
71
|
end
|
74
72
|
|
75
73
|
namespace :test do
|
76
|
-
task :parallel, :count, :pattern do |t,args|
|
74
|
+
task :parallel, :count, :pattern do |t, args|
|
77
75
|
$stderr.puts "WARNING -- Deprecated! use parallel:test"
|
78
76
|
Rake::Task['parallel:test'].invoke(args[:count], args[:pattern])
|
79
77
|
end
|