parallel_tests 1.1.0 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ require 'parallel_tests/rspec/failures_logger'
2
+
3
+ class ParallelTests::RSpec::SummaryLogger < ParallelTests::RSpec::LoggerBase
4
+ if RSPEC_3
5
+ RSpec::Core::Formatters.register self, :dump_failures
6
+ end
7
+
8
+ if RSPEC_1
9
+ def dump_failure(*args)
10
+ lock_output { super }
11
+ @output.flush
12
+ end
13
+ else
14
+ def dump_failures(*args)
15
+ lock_output { super }
16
+ @output.flush
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ require "parallel_tests/gherkin/runner"
2
+
3
+ module ParallelTests
4
+ module Spinach
5
+ class Runner < ParallelTests::Gherkin::Runner
6
+ class << self
7
+ def name
8
+ 'spinach'
9
+ end
10
+
11
+ def runtime_logging
12
+ #Not Yet Supported
13
+ ""
14
+ end
15
+
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,157 @@
1
+ require 'rake'
2
+
3
+ module ParallelTests
4
+ module Tasks
5
+ class << self
6
+ def rails_env
7
+ ENV['RAILS_ENV'] || 'test'
8
+ end
9
+
10
+ def run_in_parallel(cmd, options={})
11
+ count = " -n #{options[:count]}" unless options[:count].to_s.empty?
12
+ executable = File.expand_path("../../../bin/parallel_test", __FILE__)
13
+ command = "#{executable} --exec '#{cmd}'#{count}#{' --non-parallel' if options[:non_parallel]}"
14
+ abort unless system(command)
15
+ end
16
+
17
+ # this is a crazy-complex solution for a very simple problem:
18
+ # removing certain lines from the output without chaning the exit-status
19
+ # normally I'd not do this, but it has been lots of fun and a great learning experience :)
20
+ #
21
+ # - sed does not support | without -r
22
+ # - grep changes 0 exitstatus to 1 if nothing matches
23
+ # - sed changes 1 exitstatus to 0
24
+ # - pipefail makes pipe fail with exitstatus of first failed command
25
+ # - pipefail is not supported in (zsh)
26
+ # - defining a new rake task like silence_schema would force users to load parallel_tests in test env
27
+ # - do not use ' since run_in_parallel uses them to quote stuff
28
+ # - simple system "set -o pipefail" returns nil even though set -o pipefail exists with 0
29
+ def suppress_output(command, ignore_regex)
30
+ activate_pipefail = "set -o pipefail"
31
+ remove_ignored_lines = %Q{(grep -v "#{ignore_regex}" || test 1)}
32
+
33
+ if File.executable?('/bin/bash') && system('/bin/bash', '-c', "#{activate_pipefail} 2>/dev/null && test 1")
34
+ # We need to shell escape single quotes (' becomes '"'"') because
35
+ # run_in_parallel wraps command in single quotes
36
+ %Q{/bin/bash -c '"'"'#{activate_pipefail} && (#{command}) | #{remove_ignored_lines}'"'"'}
37
+ else
38
+ command
39
+ end
40
+ end
41
+
42
+ def check_for_pending_migrations
43
+ ["db:abort_if_pending_migrations", "app:db:abort_if_pending_migrations"].each do |abort_migrations|
44
+ if Rake::Task.task_defined?(abort_migrations)
45
+ Rake::Task[abort_migrations].invoke
46
+ break
47
+ end
48
+ end
49
+ end
50
+
51
+ # parallel:spec[:count, :pattern, :options]
52
+ def parse_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
+ pattern = args.shift
62
+ options = args.shift
63
+
64
+ [num_processes, pattern.to_s, options.to_s]
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ namespace :parallel do
71
+ desc "create test databases via db:create --> parallel:create[num_cpus]"
72
+ task :create, :count do |t,args|
73
+ ParallelTests::Tasks.run_in_parallel("rake db:create RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
74
+ end
75
+
76
+ desc "drop test databases via db:drop --> parallel:drop[num_cpus]"
77
+ task :drop, :count do |t,args|
78
+ ParallelTests::Tasks.run_in_parallel("rake db:drop RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
79
+ end
80
+
81
+ desc "update test databases by dumping and loading --> parallel:prepare[num_cpus]"
82
+ task(:prepare, [:count]) do |t,args|
83
+ ParallelTests::Tasks.check_for_pending_migrations
84
+ if defined?(ActiveRecord) && ActiveRecord::Base.schema_format == :ruby
85
+ # dump then load in parallel
86
+ Rake::Task['db:schema:dump'].invoke
87
+ Rake::Task['parallel:load_schema'].invoke(args[:count])
88
+ else
89
+ # there is no separate dump / load for schema_format :sql -> do it safe and slow
90
+ args = args.to_hash.merge(:non_parallel => true) # normal merge returns nil
91
+ taskname = Rake::Task.task_defined?('db:test:prepare') ? 'db:test:prepare' : 'app:db:test:prepare'
92
+ ParallelTests::Tasks.run_in_parallel("rake #{taskname}", args)
93
+ end
94
+ end
95
+
96
+ # when dumping/resetting takes too long
97
+ desc "update test databases via db:migrate --> parallel:migrate[num_cpus]"
98
+ task :migrate, :count do |t,args|
99
+ ParallelTests::Tasks.run_in_parallel("rake db:migrate RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
100
+ end
101
+
102
+ # just load the schema (good for integration server <-> no development db)
103
+ desc "load dumped schema for test databases via db:schema:load --> parallel:load_schema[num_cpus]"
104
+ task :load_schema, :count do |t,args|
105
+ command = "rake db:schema:load RAILS_ENV=#{ParallelTests::Tasks.rails_env}"
106
+ ParallelTests::Tasks.run_in_parallel(ParallelTests::Tasks.suppress_output(command, "^ ->\\|^-- "), args)
107
+ end
108
+
109
+ # load the structure from the structure.sql file
110
+ desc "load structure for test databases via db:structure:load --> parallel:load_structure[num_cpus]"
111
+ task :load_structure, :count do |t,args|
112
+ ParallelTests::Tasks.run_in_parallel("rake db:structure:load RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
113
+ end
114
+
115
+ desc "load the seed data from db/seeds.rb via db:seed --> parallel:seed[num_cpus]"
116
+ task :seed, :count do |t,args|
117
+ ParallelTests::Tasks.run_in_parallel("rake db:seed RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
118
+ end
119
+
120
+ desc "launch given rake command in parallel"
121
+ task :rake, :command do |t, args|
122
+ ParallelTests::Tasks.run_in_parallel("RAILS_ENV=#{ParallelTests::Tasks.rails_env} rake #{args.command}")
123
+ end
124
+
125
+ ['test', 'spec', 'features', 'features-spinach'].each do |type|
126
+ desc "run #{type} in parallel with parallel:#{type}[num_cpus]"
127
+ task type, [:count, :pattern, :options] do |t, args|
128
+ ParallelTests::Tasks.check_for_pending_migrations
129
+
130
+ $LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..'))
131
+ require "parallel_tests"
132
+
133
+ count, pattern, options = ParallelTests::Tasks.parse_args(args)
134
+ test_framework = {
135
+ 'spec' => 'rspec',
136
+ 'test' => 'test',
137
+ 'features' => 'cucumber',
138
+ 'features-spinach' => 'spinach',
139
+ }[type]
140
+
141
+ if test_framework == 'spinach'
142
+ type = 'features'
143
+ end
144
+ executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallel_test')
145
+
146
+ command = "#{executable} #{type} --type #{test_framework} " \
147
+ "-n #{count} " \
148
+ "--pattern '#{pattern}' " \
149
+ "--test-options '#{options}'"
150
+ if ParallelTests::WINDOWS
151
+ ruby_binary = File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name'])
152
+ command = "#{ruby_binary} #{command}"
153
+ end
154
+ abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,187 @@
1
+ require 'parallel_tests'
2
+
3
+ module ParallelTests
4
+ module Test
5
+ class Runner
6
+ NAME = 'Test'
7
+
8
+ class << self
9
+ # --- usually overwritten by other runners
10
+
11
+ def name
12
+ NAME
13
+ end
14
+
15
+ def runtime_log
16
+ 'tmp/parallel_runtime_test.log'
17
+ end
18
+
19
+ def test_suffix
20
+ /_(test|spec).rb$/
21
+ end
22
+
23
+ def test_file_name
24
+ "test"
25
+ end
26
+
27
+ def run_tests(test_files, process_number, num_processes, options)
28
+ require_list = test_files.map { |filename| %{"#{File.expand_path filename}"} }.join(",")
29
+ cmd = "#{executable} -Itest -e '[#{require_list}].each {|f| require f }' -- #{options[:test_options]}"
30
+ execute_command(cmd, process_number, num_processes, options)
31
+ end
32
+
33
+ def line_is_result?(line)
34
+ line.gsub!(/[.F*]/,'')
35
+ line =~ /\d+ failure/
36
+ end
37
+
38
+ # --- usually used by other runners
39
+
40
+ # finds all tests and partitions them into groups
41
+ def tests_in_groups(tests, num_groups, options={})
42
+ tests = find_tests(tests, options)
43
+
44
+ tests = if options[:group_by] == :found
45
+ tests.map { |t| [t, 1] }
46
+ elsif options[:group_by] == :filesize
47
+ with_filesize_info(tests)
48
+ else
49
+ with_runtime_info(tests, options)
50
+ end
51
+ Grouper.in_even_groups_by_size(tests, num_groups, options)
52
+ end
53
+
54
+ def execute_command(cmd, process_number, num_processes, options)
55
+ env = (options[:env] || {}).merge(
56
+ "TEST_ENV_NUMBER" => test_env_number(process_number),
57
+ "PARALLEL_TEST_GROUPS" => num_processes
58
+ )
59
+ cmd = "nice #{cmd}" if options[:nice]
60
+ cmd = "#{cmd} 2>&1" if options[:combine_stderr]
61
+ puts cmd if options[:verbose]
62
+
63
+ execute_command_and_capture_output(env, cmd, options[:serialize_stdout])
64
+ end
65
+
66
+ def execute_command_and_capture_output(env, cmd, silence)
67
+ # make processes descriptive / visible in ps -ef
68
+ separator = (WINDOWS ? ' & ' : ';')
69
+ exports = env.map do |k,v|
70
+ if WINDOWS
71
+ "(SET \"#{k}=#{v}\")"
72
+ else
73
+ "#{k}=#{v};export #{k}"
74
+ end
75
+ end.join(separator)
76
+ cmd = "#{exports}#{separator}#{cmd}"
77
+
78
+ output = open("|#{cmd}", "r") { |output| capture_output(output, silence) }
79
+ exitstatus = $?.exitstatus
80
+
81
+ {:stdout => output, :exit_status => exitstatus}
82
+ end
83
+
84
+ def find_results(test_output)
85
+ test_output.split("\n").map {|line|
86
+ line.gsub!(/\e\[\d+m/,'')
87
+ next unless line_is_result?(line)
88
+ line
89
+ }.compact
90
+ end
91
+
92
+ def test_env_number(process_number)
93
+ process_number == 0 ? '' : process_number + 1
94
+ end
95
+
96
+ def summarize_results(results)
97
+ sums = sum_up_results(results)
98
+ sums.sort.map{|word, number| "#{number} #{word}#{'s' if number != 1}" }.join(', ')
99
+ end
100
+
101
+ protected
102
+
103
+ def executable
104
+ ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
105
+ end
106
+
107
+ def determine_executable
108
+ "ruby"
109
+ end
110
+
111
+ def sum_up_results(results)
112
+ results = results.join(' ').gsub(/s\b/,'') # combine and singularize results
113
+ counts = results.scan(/(\d+) (\w+)/)
114
+ counts.inject(Hash.new(0)) do |sum, (number, word)|
115
+ sum[word] += number.to_i
116
+ sum
117
+ end
118
+ end
119
+
120
+ # read output of the process and print it in chunks
121
+ def capture_output(out, silence)
122
+ result = ""
123
+ loop do
124
+ begin
125
+ read = out.readpartial(1000000) # read whatever chunk we can get
126
+ if Encoding.default_internal
127
+ read = read.force_encoding(Encoding.default_internal)
128
+ end
129
+ result << read
130
+ unless silence
131
+ $stdout.print read
132
+ $stdout.flush
133
+ end
134
+ end
135
+ end rescue EOFError
136
+ result
137
+ end
138
+
139
+ def with_runtime_info(tests, options = {})
140
+ log = options[:runtime_log] || runtime_log
141
+ lines = File.read(log).split("\n") rescue []
142
+
143
+ # use recorded test runtime if we got enough data
144
+ if lines.size * 1.5 > tests.size
145
+ puts "Using recorded test runtime: #{log}"
146
+ times = Hash.new(1)
147
+ lines.each do |line|
148
+ test, time = line.split(":")
149
+ next unless test and time
150
+ times[File.expand_path(test)] = time.to_f
151
+ end
152
+ tests.sort.map{|test| [test, times[File.expand_path(test)]] }
153
+ else # use file sizes
154
+ with_filesize_info(tests)
155
+ end
156
+ end
157
+
158
+ def with_filesize_info(tests)
159
+ # use filesize to group files
160
+ tests.sort.map { |test| [test, File.stat(test).size] }
161
+ end
162
+
163
+ def find_tests(tests, options = {})
164
+ (tests || []).map do |file_or_folder|
165
+ if File.directory?(file_or_folder)
166
+ files = files_in_folder(file_or_folder, options)
167
+ files.grep(test_suffix).grep(options[:pattern]||//)
168
+ else
169
+ file_or_folder
170
+ end
171
+ end.flatten.uniq
172
+ end
173
+
174
+ def files_in_folder(folder, options={})
175
+ pattern = if options[:symlinks] == false # not nil or true
176
+ "**/*"
177
+ else
178
+ # follow one symlink and direct children
179
+ # http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
180
+ "**{,/*/**}/*"
181
+ end
182
+ Dir[File.join(folder, pattern)].uniq
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,98 @@
1
+ require 'parallel_tests'
2
+ require 'parallel_tests/test/runner'
3
+
4
+ module ParallelTests
5
+ module Test
6
+ class RuntimeLogger
7
+ @@has_started = false
8
+
9
+ class << self
10
+ def log(test, start_time, end_time)
11
+ return if test.is_a? ::Test::Unit::TestSuite # don't log for suites-of-suites
12
+
13
+ if !@@has_started # make empty log file
14
+ File.open(logfile, 'w'){}
15
+ @@has_started = true
16
+ end
17
+
18
+ locked_appending_to(logfile) do |file|
19
+ file.puts(message(test, start_time, end_time))
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def message(test, start_time, end_time)
26
+ delta = "%.2f" % (end_time.to_f-start_time.to_f)
27
+ filename = class_directory(test.class) + class_to_filename(test.class) + ".rb"
28
+ "#{filename}:#{delta}"
29
+ end
30
+
31
+ # Note: this is a best guess at conventional test directory structure, and may need
32
+ # tweaking / post-processing to match correctly for any given project
33
+ def class_directory(suspect)
34
+ result = "test/"
35
+
36
+ if defined?(Rails)
37
+ result += case suspect.superclass.name
38
+ when "ActionDispatch::IntegrationTest"
39
+ "integration/"
40
+ when "ActionDispatch::PerformanceTest"
41
+ "performance/"
42
+ when "ActionController::TestCase"
43
+ "functional/"
44
+ when "ActionView::TestCase"
45
+ "unit/helpers/"
46
+ else
47
+ "unit/"
48
+ end
49
+ end
50
+ result
51
+ end
52
+
53
+ # based on https://github.com/grosser/single_test/blob/master/lib/single_test.rb#L117
54
+ def class_to_filename(suspect)
55
+ word = suspect.to_s.dup
56
+ return word unless word.match /^[A-Z]/ and not word.match %r{/[a-z]}
57
+
58
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
59
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
60
+ word.gsub!(/\:\:/, '/')
61
+ word.tr!("-", "_")
62
+ word.downcase!
63
+ word
64
+ end
65
+
66
+ def locked_appending_to(file)
67
+ File.open(file, 'a') do |f|
68
+ begin
69
+ f.flock File::LOCK_EX
70
+ yield f
71
+ ensure
72
+ f.flock File::LOCK_UN
73
+ end
74
+ end
75
+ end
76
+
77
+ def logfile
78
+ ParallelTests::Test::Runner.runtime_log
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ require 'test/unit/testsuite'
86
+ class ::Test::Unit::TestSuite
87
+ alias :run_without_timing :run unless defined? @@timing_installed
88
+
89
+ def run(result, &progress_block)
90
+ first_test = self.tests.first
91
+ start_time = ParallelTests.now
92
+ run_without_timing(result, &progress_block)
93
+ end_time = ParallelTests.now
94
+ ParallelTests::Test::RuntimeLogger.log(first_test, start_time, end_time)
95
+ end
96
+
97
+ @@timing_installed = true
98
+ end