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.
@@ -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,10 @@
1
+ # add rake tasks if we are inside Rails
2
+ if defined?(Rails::Railtie)
3
+ class ParallelTests
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("../../tasks/parallel_tests.rake", __FILE__)
7
+ end
8
+ end
9
+ end
10
+ 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