parallel_tests 0.13.3 → 0.14.0
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.
- 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
|