parallel_tests 1.1.0 → 1.1.1

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,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