parallelized_specs 0.0.5 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -1,10 +0,0 @@
1
- source :rubygems
2
-
3
- gem 'parallel'
4
-
5
- group :dev do
6
- gem 'test-unit', :platform => :ruby_19
7
- gem 'rspec', '>=2.4'
8
- gem 'rake'
9
- gem 'jeweler'
10
- end
data/Rakefile CHANGED
@@ -12,7 +12,7 @@ begin
12
12
  gem.email = "jake@instructure.com"
13
13
  gem.homepage = "http://github.com/jakesorce/#{gem.name}"
14
14
  gem.authors = "Jake Sorce, Bryan Madsen"
15
- gem.version = "0.0.5"
15
+ gem.version = "0.0.6"
16
16
  end
17
17
 
18
18
  Jeweler::GemcutterTasks.new
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.5
1
+ 0.0.6
@@ -1,2 +1,95 @@
1
1
  #!/usr/bin/env ruby
2
- exec "#{File.join(File.dirname(__FILE__), 'parallelized_test')} -t spec #{ARGV * ' '}"
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
@@ -1,6 +1,10 @@
1
- require 'parallelized_tests'
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
- "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
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 = ['spec/parallelized_spec.opts', 'spec/spec.opts'].detect{|f| File.file?(f) }
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,4 +1,4 @@
1
- class ParallelizedTests
1
+ class ParallelizedSpecs
2
2
  class Grouper
3
3
  def self.in_groups(items, num_groups)
4
4
  groups = Array.new(num_groups){ [] }
@@ -1,9 +1,9 @@
1
1
  # add rake tasks if we are inside Rails
2
2
  if defined?(Rails::Railtie)
3
- class ParallelizedTests
3
+ class ParallelizedSpecs
4
4
  class Railtie < ::Rails::Railtie
5
5
  rake_tasks do
6
- load File.expand_path("../../tasks/parallelized_tests.rake", __FILE__)
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', 'parallelized_test')
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
- ['test', 'spec', 'features'].each do |type|
45
- desc "run #{type} in parallel with parallel:#{type}[num_cpus]"
46
- task type, :count, :pattern, :options, :arguments do |t,args|
47
- $LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..'))
48
- require "parallelized_tests"
49
- count, pattern, options = ParallelizedTests.parse_rake_args(args)
50
- executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallelized_test')
51
- command = "#{executable} --type #{type} -n #{count} -p '#{pattern}' -r '#{Rails.root}' -o '#{options}' #{args[:arguments]}"
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