parallel_tests 0.13.3 → 0.14.0
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +1 -0
- data/Gemfile.lock +8 -2
- data/Rakefile +5 -1
- data/Readme.md +5 -2
- data/bin/parallel_spinach +5 -0
- data/lib/parallel_tests.rb +40 -38
- data/lib/parallel_tests/cli.rb +2 -2
- data/lib/parallel_tests/cucumber/failures_logger.rb +2 -2
- data/lib/parallel_tests/cucumber/runner.rb +5 -88
- data/lib/parallel_tests/{cucumber → gherkin}/io.rb +1 -1
- data/lib/parallel_tests/{cucumber/gherkin_listener.rb → gherkin/listener.rb} +2 -2
- data/lib/parallel_tests/gherkin/runner.rb +102 -0
- data/lib/parallel_tests/{cucumber → gherkin}/runtime_logger.rb +2 -2
- data/lib/parallel_tests/grouper.rb +41 -41
- data/lib/parallel_tests/rspec/failures_logger.rb +1 -1
- data/lib/parallel_tests/rspec/runner.rb +50 -48
- data/lib/parallel_tests/spinach/runner.rb +19 -0
- data/lib/parallel_tests/tasks.rb +7 -3
- data/lib/parallel_tests/test/runner.rb +125 -123
- data/lib/parallel_tests/test/runtime_logger.rb +57 -53
- data/lib/parallel_tests/version.rb +1 -1
- data/parallel_tests.gemspec +2 -2
- data/spec/integration_spec.rb +61 -0
- data/spec/parallel_tests/cucumber/failure_logger_spec.rb +1 -1
- data/spec/parallel_tests/cucumber/runner_spec.rb +5 -172
- data/spec/parallel_tests/{cucumber/gherkin_listener_spec.rb → gherkin/listener_spec.rb} +3 -3
- data/spec/parallel_tests/gherkin/runner_behaviour.rb +177 -0
- data/spec/parallel_tests/rspec/{failure_logger_spec.rb → failures_logger_spec.rb} +0 -0
- data/spec/parallel_tests/spinach/runner_spec.rb +12 -0
- data/spec/parallel_tests/test/runtime_logger_spec.rb +1 -1
- data/spec/parallel_tests_spec.rb +2 -2
- data/spec/spec_helper.rb +1 -1
- metadata +16 -10
@@ -1,56 +1,56 @@
|
|
1
1
|
module ParallelTests
|
2
2
|
class Grouper
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
(options[:single_process] || []).each do |pattern|
|
8
|
-
matched, items_with_sizes = items_with_sizes.partition { |item, size| item =~ pattern }
|
9
|
-
matched.each { |item, size| add_to_group(groups.first, item, size) }
|
3
|
+
class << self
|
4
|
+
def by_steps(tests, num_groups, options)
|
5
|
+
features_with_steps = build_features_with_steps(tests, options)
|
6
|
+
in_even_groups_by_size(features_with_steps, num_groups)
|
10
7
|
end
|
11
8
|
|
12
|
-
|
9
|
+
def in_even_groups_by_size(items_with_sizes, num_groups, options = {})
|
10
|
+
groups = Array.new(num_groups) { {:items => [], :size => 0} }
|
13
11
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
12
|
+
# add all files that should run in a single process to one group
|
13
|
+
(options[:single_process] || []).each do |pattern|
|
14
|
+
matched, items_with_sizes = items_with_sizes.partition { |item, size| item =~ pattern }
|
15
|
+
matched.each { |item, size| add_to_group(groups.first, item, size) }
|
16
|
+
end
|
19
17
|
|
20
|
-
|
21
|
-
end
|
18
|
+
groups_to_fill = (options[:isolate] ? groups[1..-1] : groups)
|
22
19
|
|
23
|
-
|
24
|
-
|
25
|
-
|
20
|
+
# add all other files
|
21
|
+
largest_first(items_with_sizes).each do |item, size|
|
22
|
+
smallest = smallest_group(groups_to_fill)
|
23
|
+
add_to_group(smallest, item, size)
|
24
|
+
end
|
26
25
|
|
27
|
-
|
26
|
+
groups.map!{|g| g[:items].sort }
|
27
|
+
end
|
28
28
|
|
29
|
-
|
30
|
-
groups.min_by{|g| g[:size] }
|
31
|
-
end
|
29
|
+
private
|
32
30
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
end
|
31
|
+
def largest_first(files)
|
32
|
+
files.sort_by{|item, size| size }.reverse
|
33
|
+
end
|
37
34
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
end
|
35
|
+
def smallest_group(groups)
|
36
|
+
groups.min_by{|g| g[:size] }
|
37
|
+
end
|
42
38
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
39
|
+
def add_to_group(group, item, size)
|
40
|
+
group[:items] << item
|
41
|
+
group[:size] += size
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_features_with_steps(tests, options)
|
45
|
+
require 'parallel_tests/gherkin/listener'
|
46
|
+
listener = ParallelTests::Gherkin::Listener.new
|
47
|
+
listener.ignore_tag_pattern = Regexp.compile(options[:ignore_tag_pattern]) if options[:ignore_tag_pattern]
|
48
|
+
parser = ::Gherkin::Parser::Parser.new(listener, true, 'root')
|
49
|
+
tests.each{|file|
|
50
|
+
parser.parse(File.read(file), file, 0)
|
51
|
+
}
|
52
|
+
listener.collect.sort_by{|_,value| -value }
|
53
|
+
end
|
54
54
|
end
|
55
55
|
end
|
56
56
|
end
|
@@ -38,7 +38,7 @@ class ParallelTests::RSpec::FailuresLogger < ParallelTests::RSpec::LoggerBase
|
|
38
38
|
file, line = example.location.to_s.split(':')
|
39
39
|
next unless file and line
|
40
40
|
file.gsub!(%r(^.*?/spec/), './spec/')
|
41
|
-
@output.puts "#{ParallelTests::RSpec::Runner.executable} #{file}:#{line} # #{example.description}"
|
41
|
+
@output.puts "#{ParallelTests::RSpec::Runner.send(:executable)} #{file}:#{line} # #{example.description}"
|
42
42
|
end
|
43
43
|
end
|
44
44
|
end
|
@@ -5,65 +5,67 @@ module ParallelTests
|
|
5
5
|
class Runner < ParallelTests::Test::Runner
|
6
6
|
NAME = 'RSpec'
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
def self.determine_executable
|
17
|
-
cmd = case
|
18
|
-
when File.exists?("bin/rspec")
|
19
|
-
"bin/rspec"
|
20
|
-
when File.file?("script/spec")
|
21
|
-
"script/spec"
|
22
|
-
when ParallelTests.bundler_enabled?
|
23
|
-
cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
|
24
|
-
"bundle exec #{cmd}"
|
25
|
-
else
|
26
|
-
%w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
|
8
|
+
class << self
|
9
|
+
def run_tests(test_files, process_number, num_processes, options)
|
10
|
+
exe = executable # expensive, so we cache
|
11
|
+
version = (exe =~ /\brspec\b/ ? 2 : 1)
|
12
|
+
cmd = [exe, options[:test_options], (rspec_2_color if version == 2), spec_opts, *test_files].compact.join(" ")
|
13
|
+
options = options.merge(:env => rspec_1_color) if version == 1
|
14
|
+
execute_command(cmd, process_number, num_processes, options)
|
27
15
|
end
|
28
16
|
|
29
|
-
|
30
|
-
|
17
|
+
def determine_executable
|
18
|
+
cmd = case
|
19
|
+
when File.exists?("bin/rspec")
|
20
|
+
"bin/rspec"
|
21
|
+
when File.file?("script/spec")
|
22
|
+
"script/spec"
|
23
|
+
when ParallelTests.bundler_enabled?
|
24
|
+
cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
|
25
|
+
"bundle exec #{cmd}"
|
26
|
+
else
|
27
|
+
%w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
|
28
|
+
end
|
31
29
|
|
32
|
-
|
33
|
-
|
34
|
-
end
|
30
|
+
cmd or raise("Can't find executables rspec or spec")
|
31
|
+
end
|
35
32
|
|
36
|
-
|
37
|
-
|
38
|
-
|
33
|
+
def runtime_log
|
34
|
+
'tmp/parallel_runtime_rspec.log'
|
35
|
+
end
|
39
36
|
|
40
|
-
|
41
|
-
|
42
|
-
|
37
|
+
def test_file_name
|
38
|
+
"spec"
|
39
|
+
end
|
43
40
|
|
44
|
-
|
41
|
+
def test_suffix
|
42
|
+
"_spec.rb"
|
43
|
+
end
|
45
44
|
|
46
|
-
|
47
|
-
def self.run(cmd)
|
48
|
-
`#{cmd}`
|
49
|
-
end
|
45
|
+
private
|
50
46
|
|
51
|
-
|
52
|
-
|
53
|
-
{
|
54
|
-
else
|
55
|
-
{}
|
47
|
+
# so it can be stubbed....
|
48
|
+
def run(cmd)
|
49
|
+
`#{cmd}`
|
56
50
|
end
|
57
|
-
end
|
58
51
|
|
59
|
-
|
60
|
-
|
61
|
-
|
52
|
+
def rspec_1_color
|
53
|
+
if $stdout.tty?
|
54
|
+
{'RSPEC_COLOR' => "1"}
|
55
|
+
else
|
56
|
+
{}
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def rspec_2_color
|
61
|
+
'--color --tty' if $stdout.tty?
|
62
|
+
end
|
62
63
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
64
|
+
def spec_opts
|
65
|
+
options_file = ['.rspec_parallel', 'spec/parallel_spec.opts', 'spec/spec.opts'].detect{|f| File.file?(f) }
|
66
|
+
return unless options_file
|
67
|
+
"-O #{options_file}"
|
68
|
+
end
|
67
69
|
end
|
68
70
|
end
|
69
71
|
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
|
data/lib/parallel_tests/tasks.rb
CHANGED
@@ -111,7 +111,7 @@ namespace :parallel do
|
|
111
111
|
ParallelTests::Tasks.run_in_parallel("RAILS_ENV=#{ParallelTests::Tasks.rails_env} rake #{args.command}")
|
112
112
|
end
|
113
113
|
|
114
|
-
['test', 'spec', 'features'].each do |type|
|
114
|
+
['test', 'spec', 'features', 'features-spinach'].each do |type|
|
115
115
|
desc "run #{type} in parallel with parallel:#{type}[num_cpus]"
|
116
116
|
task type, [:count, :pattern, :options] do |t, args|
|
117
117
|
ParallelTests::Tasks.check_for_pending_migrations
|
@@ -123,15 +123,19 @@ namespace :parallel do
|
|
123
123
|
test_framework = {
|
124
124
|
'spec' => 'rspec',
|
125
125
|
'test' => 'test',
|
126
|
-
'features' => 'cucumber'
|
126
|
+
'features' => 'cucumber',
|
127
|
+
'features-spinach' => 'spinach',
|
127
128
|
}[type]
|
128
129
|
|
130
|
+
if test_framework == 'spinach'
|
131
|
+
type = 'features'
|
132
|
+
end
|
129
133
|
executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallel_test')
|
134
|
+
|
130
135
|
command = "#{executable} #{type} --type #{test_framework} " \
|
131
136
|
"-n #{count} " \
|
132
137
|
"--pattern '#{pattern}' " \
|
133
138
|
"--test-options '#{options}'"
|
134
|
-
|
135
139
|
abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
|
136
140
|
end
|
137
141
|
end
|
@@ -5,160 +5,162 @@ module ParallelTests
|
|
5
5
|
class Runner
|
6
6
|
NAME = 'Test'
|
7
7
|
|
8
|
-
|
8
|
+
class << self
|
9
|
+
# --- usually overwritten by other runners
|
9
10
|
|
10
|
-
|
11
|
-
|
12
|
-
|
11
|
+
def name
|
12
|
+
NAME
|
13
|
+
end
|
13
14
|
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
def runtime_log
|
16
|
+
'tmp/parallel_runtime_test.log'
|
17
|
+
end
|
17
18
|
|
18
|
-
|
19
|
-
|
20
|
-
|
19
|
+
def test_suffix
|
20
|
+
"_test.rb"
|
21
|
+
end
|
21
22
|
|
22
|
-
|
23
|
-
|
24
|
-
|
23
|
+
def test_file_name
|
24
|
+
"test"
|
25
|
+
end
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
31
32
|
|
32
|
-
|
33
|
-
|
34
|
-
|
33
|
+
def line_is_result?(line)
|
34
|
+
line =~ /\d+ failure/
|
35
|
+
end
|
35
36
|
|
36
|
-
|
37
|
+
# --- usually used by other runners
|
37
38
|
|
38
|
-
|
39
|
-
|
40
|
-
|
39
|
+
# finds all tests and partitions them into groups
|
40
|
+
def tests_in_groups(tests, num_groups, options={})
|
41
|
+
tests = find_tests(tests, options)
|
41
42
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
43
|
+
tests = if options[:group_by] == :found
|
44
|
+
tests.map { |t| [t, 1] }
|
45
|
+
else
|
46
|
+
with_runtime_info(tests)
|
47
|
+
end
|
48
|
+
Grouper.in_even_groups_by_size(tests, num_groups, options)
|
46
49
|
end
|
47
|
-
Grouper.in_even_groups_by_size(tests, num_groups, options)
|
48
|
-
end
|
49
50
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
51
|
+
def execute_command(cmd, process_number, num_processes, options)
|
52
|
+
env = (options[:env] || {}).merge(
|
53
|
+
"TEST_ENV_NUMBER" => test_env_number(process_number),
|
54
|
+
"PARALLEL_TEST_GROUPS" => num_processes
|
55
|
+
)
|
56
|
+
cmd = "nice #{cmd}" if options[:nice]
|
57
|
+
execute_command_and_capture_output(env, cmd, options[:serialize_stdout])
|
58
|
+
end
|
58
59
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
60
|
+
def execute_command_and_capture_output(env, cmd, silence)
|
61
|
+
# make processes descriptive / visible in ps -ef
|
62
|
+
exports = env.map do |k,v|
|
63
|
+
"#{k}=#{v};export #{k}"
|
64
|
+
end.join(";")
|
65
|
+
cmd = "#{exports};#{cmd}"
|
65
66
|
|
66
|
-
|
67
|
-
|
67
|
+
output = open("|#{cmd}", "r") { |output| capture_output(output, silence) }
|
68
|
+
exitstatus = $?.exitstatus
|
68
69
|
|
69
|
-
|
70
|
-
|
70
|
+
{:stdout => output, :exit_status => exitstatus}
|
71
|
+
end
|
71
72
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
73
|
+
def find_results(test_output)
|
74
|
+
test_output.split("\n").map {|line|
|
75
|
+
line = line.gsub(/\.|F|\*/,'').gsub(/\e\[\d+m/,'')
|
76
|
+
next unless line_is_result?(line)
|
77
|
+
line
|
78
|
+
}.compact
|
79
|
+
end
|
79
80
|
|
80
|
-
|
81
|
-
|
82
|
-
|
81
|
+
def test_env_number(process_number)
|
82
|
+
process_number == 0 ? '' : process_number + 1
|
83
|
+
end
|
83
84
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
85
|
+
def summarize_results(results)
|
86
|
+
sums = sum_up_results(results)
|
87
|
+
sums.sort.map{|word, number| "#{number} #{word}#{'s' if number != 1}" }.join(', ')
|
88
|
+
end
|
88
89
|
|
89
|
-
|
90
|
+
protected
|
90
91
|
|
91
|
-
|
92
|
-
|
93
|
-
|
92
|
+
def executable
|
93
|
+
ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
|
94
|
+
end
|
94
95
|
|
95
|
-
|
96
|
-
|
97
|
-
|
96
|
+
def determine_executable
|
97
|
+
"ruby"
|
98
|
+
end
|
98
99
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
100
|
+
def sum_up_results(results)
|
101
|
+
results = results.join(' ').gsub(/s\b/,'') # combine and singularize results
|
102
|
+
counts = results.scan(/(\d+) (\w+)/)
|
103
|
+
counts.inject(Hash.new(0)) do |sum, (number, word)|
|
104
|
+
sum[word] += number.to_i
|
105
|
+
sum
|
106
|
+
end
|
105
107
|
end
|
106
|
-
end
|
107
108
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
109
|
+
# read output of the process and print it in chunks
|
110
|
+
def capture_output(out, silence)
|
111
|
+
result = ""
|
112
|
+
loop do
|
113
|
+
begin
|
114
|
+
read = out.readpartial(1000000) # read whatever chunk we can get
|
115
|
+
result << read
|
116
|
+
unless silence
|
117
|
+
$stdout.print read
|
118
|
+
$stdout.flush
|
119
|
+
end
|
118
120
|
end
|
119
|
-
end
|
120
|
-
|
121
|
-
|
122
|
-
end
|
121
|
+
end rescue EOFError
|
122
|
+
result
|
123
|
+
end
|
123
124
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
125
|
+
def with_runtime_info(tests)
|
126
|
+
lines = File.read(runtime_log).split("\n") rescue []
|
127
|
+
|
128
|
+
# use recorded test runtime if we got enough data
|
129
|
+
if lines.size * 1.5 > tests.size
|
130
|
+
puts "Using recorded test runtime"
|
131
|
+
times = Hash.new(1)
|
132
|
+
lines.each do |line|
|
133
|
+
test, time = line.split(":")
|
134
|
+
next unless test and time
|
135
|
+
times[File.expand_path(test)] = time.to_f
|
136
|
+
end
|
137
|
+
tests.sort.map{|test| [test, times[File.expand_path(test)]] }
|
138
|
+
else # use file sizes
|
139
|
+
tests.sort.map{|test| [test, File.stat(test).size] }
|
135
140
|
end
|
136
|
-
tests.sort.map{|test| [test, times[File.expand_path(test)]] }
|
137
|
-
else # use file sizes
|
138
|
-
tests.sort.map{|test| [test, File.stat(test).size] }
|
139
141
|
end
|
140
|
-
end
|
141
142
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
143
|
+
def find_tests(tests, options = {})
|
144
|
+
(tests || []).map do |file_or_folder|
|
145
|
+
if File.directory?(file_or_folder)
|
146
|
+
files = files_in_folder(file_or_folder, options)
|
147
|
+
files.grep(/#{Regexp.escape test_suffix}$/).grep(options[:pattern]||//)
|
148
|
+
else
|
149
|
+
file_or_folder
|
150
|
+
end
|
151
|
+
end.flatten.uniq
|
152
|
+
end
|
153
|
+
|
154
|
+
def files_in_folder(folder, options={})
|
155
|
+
pattern = if options[:symlinks] == false # not nil or true
|
156
|
+
"**/*"
|
147
157
|
else
|
148
|
-
|
158
|
+
# follow one symlink and direct children
|
159
|
+
# http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
|
160
|
+
"**{,/*/**}/*"
|
149
161
|
end
|
150
|
-
|
151
|
-
end
|
152
|
-
|
153
|
-
def self.files_in_folder(folder, options={})
|
154
|
-
pattern = if options[:symlinks] == false # not nil or true
|
155
|
-
"**/*"
|
156
|
-
else
|
157
|
-
# follow one symlink and direct children
|
158
|
-
# http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
|
159
|
-
"**{,/*/**}/*"
|
162
|
+
Dir[File.join(folder, pattern)].uniq
|
160
163
|
end
|
161
|
-
Dir[File.join(folder, pattern)].uniq
|
162
164
|
end
|
163
165
|
end
|
164
166
|
end
|