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.
- checksums.yaml +4 -4
- data/lib/parallel_tests/cli.rb +207 -0
- data/lib/parallel_tests/cucumber/failures_logger.rb +25 -0
- data/lib/parallel_tests/cucumber/runner.rb +37 -0
- data/lib/parallel_tests/cucumber/scenario_line_logger.rb +51 -0
- data/lib/parallel_tests/cucumber/scenarios.rb +34 -0
- data/lib/parallel_tests/gherkin/io.rb +41 -0
- data/lib/parallel_tests/gherkin/listener.rb +87 -0
- data/lib/parallel_tests/gherkin/runner.rb +116 -0
- data/lib/parallel_tests/gherkin/runtime_logger.rb +28 -0
- data/lib/parallel_tests/grouper.rb +73 -0
- data/lib/parallel_tests/railtie.rb +8 -0
- data/lib/parallel_tests/rspec/failures_logger.rb +54 -0
- data/lib/parallel_tests/rspec/logger_base.rb +55 -0
- data/lib/parallel_tests/rspec/runner.rb +73 -0
- data/lib/parallel_tests/rspec/runtime_logger.rb +59 -0
- data/lib/parallel_tests/rspec/summary_logger.rb +19 -0
- data/lib/parallel_tests/spinach/runner.rb +19 -0
- data/lib/parallel_tests/tasks.rb +157 -0
- data/lib/parallel_tests/test/runner.rb +187 -0
- data/lib/parallel_tests/test/runtime_logger.rb +98 -0
- data/lib/parallel_tests/version.rb +3 -0
- metadata +22 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d9936778844bea92756c550ca9ca6c224406ce07
|
4
|
+
data.tar.gz: 2e410f1e0d4b8e76b5eb0558bfc5fa01117650ee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eff8a7aa09428d7ac292d767b4ab79ff5a590c03e11d87e57764da3809bc933084d6ab34a23a848d519021e22fa220e33ff660f0f6eb681eb62c59c18153314a
|
7
|
+
data.tar.gz: aaf9d2d50339745e6c5f6a80d70000fd2b6f26f4ed36196a8dbc1e97b8e781e373195669cf127377d7264054d4988797076720c13e18a75f67e5ef165c279240
|
@@ -0,0 +1,207 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'tempfile'
|
3
|
+
require 'parallel_tests'
|
4
|
+
|
5
|
+
module ParallelTests
|
6
|
+
class CLI
|
7
|
+
def run(argv)
|
8
|
+
options = parse_options!(argv)
|
9
|
+
|
10
|
+
num_processes = ParallelTests.determine_number_of_processes(options[:count])
|
11
|
+
num_processes = num_processes * (options[:multiply] || 1)
|
12
|
+
|
13
|
+
if options[:execute]
|
14
|
+
execute_shell_command_in_parallel(options[:execute], num_processes, options)
|
15
|
+
else
|
16
|
+
run_tests_in_parallel(num_processes, options)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def execute_in_parallel(items, num_processes, options)
|
23
|
+
Tempfile.open 'parallel_tests-lock' do |lock|
|
24
|
+
return Parallel.map(items, :in_threads => num_processes) do |item|
|
25
|
+
result = yield(item)
|
26
|
+
report_output(result, lock) if options[:serialize_stdout]
|
27
|
+
result
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def run_tests_in_parallel(num_processes, options)
|
33
|
+
test_results = nil
|
34
|
+
|
35
|
+
report_time_taken do
|
36
|
+
groups = @runner.tests_in_groups(options[:files], num_processes, options)
|
37
|
+
|
38
|
+
test_results = if options[:only_group]
|
39
|
+
groups_to_run = options[:only_group].collect{|i| groups[i - 1]}
|
40
|
+
report_number_of_tests(groups_to_run)
|
41
|
+
execute_in_parallel(groups_to_run, groups_to_run.size, options) do |group|
|
42
|
+
run_tests(group, groups_to_run.index(group), 1, options)
|
43
|
+
end
|
44
|
+
else
|
45
|
+
report_number_of_tests(groups)
|
46
|
+
|
47
|
+
execute_in_parallel(groups, groups.size, options) do |group|
|
48
|
+
run_tests(group, groups.index(group), num_processes, options)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
report_results(test_results)
|
53
|
+
end
|
54
|
+
|
55
|
+
abort final_fail_message if any_test_failed?(test_results)
|
56
|
+
end
|
57
|
+
|
58
|
+
def run_tests(group, process_number, num_processes, options)
|
59
|
+
if group.empty?
|
60
|
+
{:stdout => '', :exit_status => 0}
|
61
|
+
else
|
62
|
+
@runner.run_tests(group, process_number, num_processes, options)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def report_output(result, lock)
|
67
|
+
lock.flock File::LOCK_EX
|
68
|
+
$stdout.puts result[:stdout]
|
69
|
+
$stdout.flush
|
70
|
+
ensure
|
71
|
+
lock.flock File::LOCK_UN
|
72
|
+
end
|
73
|
+
|
74
|
+
def report_results(test_results)
|
75
|
+
results = @runner.find_results(test_results.map { |result| result[:stdout] }*"")
|
76
|
+
puts ""
|
77
|
+
puts @runner.summarize_results(results)
|
78
|
+
end
|
79
|
+
|
80
|
+
def report_number_of_tests(groups)
|
81
|
+
name = @runner.test_file_name
|
82
|
+
num_processes = groups.size
|
83
|
+
num_tests = groups.map(&:size).inject(:+)
|
84
|
+
puts "#{num_processes} processes for #{num_tests} #{name}s, ~ #{num_tests / groups.size} #{name}s per process"
|
85
|
+
end
|
86
|
+
|
87
|
+
#exit with correct status code so rake parallel:test && echo 123 works
|
88
|
+
def any_test_failed?(test_results)
|
89
|
+
test_results.any? { |result| result[:exit_status] != 0 }
|
90
|
+
end
|
91
|
+
|
92
|
+
def parse_options!(argv)
|
93
|
+
options = {}
|
94
|
+
OptionParser.new do |opts|
|
95
|
+
opts.banner = <<-BANNER.gsub(/^ /, '')
|
96
|
+
Run all tests in parallel, giving each process ENV['TEST_ENV_NUMBER'] ('', '2', '3', ...)
|
97
|
+
|
98
|
+
[optional] Only run selected files & folders:
|
99
|
+
parallel_test test/bar test/baz/xxx_text.rb
|
100
|
+
|
101
|
+
Options are:
|
102
|
+
BANNER
|
103
|
+
opts.on("-n [PROCESSES]", Integer, "How many processes to use, default: available CPUs") { |n| options[:count] = n }
|
104
|
+
opts.on("-p", "--pattern [PATTERN]", "run tests matching this pattern") { |pattern| options[:pattern] = /#{pattern}/ }
|
105
|
+
opts.on("--group-by [TYPE]", <<-TEXT.gsub(/^ /, '')
|
106
|
+
group tests by:
|
107
|
+
found - order of finding files
|
108
|
+
steps - number of cucumber/spinach steps
|
109
|
+
scenarios - individual cucumber scenarios
|
110
|
+
filesize - by size of the file
|
111
|
+
default - runtime or filesize
|
112
|
+
TEXT
|
113
|
+
) { |type| options[:group_by] = type.to_sym }
|
114
|
+
opts.on("-m [FLOAT]", "--multiply-processes [FLOAT]", Float, "use given number as a multiplier of processes to run") { |multiply| options[:multiply] = multiply }
|
115
|
+
|
116
|
+
opts.on("-s [PATTERN]", "--single [PATTERN]",
|
117
|
+
"Run all matching files in the same process") do |pattern|
|
118
|
+
|
119
|
+
options[:single_process] ||= []
|
120
|
+
options[:single_process] << /#{pattern}/
|
121
|
+
end
|
122
|
+
|
123
|
+
opts.on("-i", "--isolate",
|
124
|
+
"Do not run any other tests in the group used by --single(-s)") do |pattern|
|
125
|
+
|
126
|
+
options[:isolate] = true
|
127
|
+
end
|
128
|
+
|
129
|
+
opts.on("--only-group INT[, INT]", Array) { |groups| options[:only_group] = groups.map(&:to_i) }
|
130
|
+
|
131
|
+
opts.on("-e", "--exec [COMMAND]", "execute this code parallel and with ENV['TEST_ENV_NUM']") { |path| options[:execute] = path }
|
132
|
+
opts.on("-o", "--test-options '[OPTIONS]'", "execute test commands with those options") { |arg| options[:test_options] = arg }
|
133
|
+
opts.on("-t", "--type [TYPE]", "test(default) / rspec / cucumber / spinach") do |type|
|
134
|
+
begin
|
135
|
+
@runner = load_runner(type)
|
136
|
+
rescue NameError, LoadError => e
|
137
|
+
puts "Runner for `#{type}` type has not been found! (#{e})"
|
138
|
+
abort
|
139
|
+
end
|
140
|
+
end
|
141
|
+
opts.on("--serialize-stdout", "Serialize stdout output, nothing will be written until everything is done") { options[:serialize_stdout] = true }
|
142
|
+
opts.on("--combine-stderr", "Combine stderr into stdout, useful in conjunction with --serialize-stdout") { options[:combine_stderr] = true }
|
143
|
+
opts.on("--non-parallel", "execute same commands but do not in parallel, needs --exec") { options[:non_parallel] = true }
|
144
|
+
opts.on("--no-symlinks", "Do not traverse symbolic links to find test files") { options[:symlinks] = false }
|
145
|
+
opts.on('--ignore-tags [PATTERN]', 'When counting steps ignore scenarios with tags that match this pattern') { |arg| options[:ignore_tag_pattern] = arg }
|
146
|
+
opts.on("--nice", "execute test commands with low priority.") { options[:nice] = true }
|
147
|
+
opts.on("--runtime-log [PATH]", "Location of previously recorded test runtimes") { |path| options[:runtime_log] = path }
|
148
|
+
opts.on("--verbose", "Print more output") { options[:verbose] = true }
|
149
|
+
opts.on("-v", "--version", "Show Version") { puts ParallelTests::VERSION; exit }
|
150
|
+
opts.on("-h", "--help", "Show this.") { puts opts; exit }
|
151
|
+
end.parse!(argv)
|
152
|
+
|
153
|
+
if options[:count] == 0
|
154
|
+
options.delete(:count)
|
155
|
+
options[:non_parallel] = true
|
156
|
+
end
|
157
|
+
|
158
|
+
options[:files] = argv
|
159
|
+
|
160
|
+
options[:group_by] ||= :filesize if options[:only_group]
|
161
|
+
|
162
|
+
raise "--group-by found and --single-process are not supported" if options[:group_by] == :found and options[:single_process]
|
163
|
+
raise "--group-by filesize is required for --only-group" if options[:group_by] != :filesize and options[:only_group]
|
164
|
+
|
165
|
+
options
|
166
|
+
end
|
167
|
+
|
168
|
+
def load_runner(type)
|
169
|
+
require "parallel_tests/#{type}/runner"
|
170
|
+
runner_classname = type.split("_").map(&:capitalize).join.sub("Rspec", "RSpec")
|
171
|
+
klass_name = "ParallelTests::#{runner_classname}::Runner"
|
172
|
+
klass_name.split('::').inject(Object) { |x, y| x.const_get(y) }
|
173
|
+
end
|
174
|
+
|
175
|
+
def execute_shell_command_in_parallel(command, num_processes, options)
|
176
|
+
runs = (0...num_processes).to_a
|
177
|
+
results = if options[:non_parallel]
|
178
|
+
runs.map do |i|
|
179
|
+
ParallelTests::Test::Runner.execute_command(command, i, num_processes, options)
|
180
|
+
end
|
181
|
+
else
|
182
|
+
execute_in_parallel(runs, num_processes, options) do |i|
|
183
|
+
ParallelTests::Test::Runner.execute_command(command, i, num_processes, options)
|
184
|
+
end
|
185
|
+
end.flatten
|
186
|
+
|
187
|
+
abort if results.any? { |r| r[:exit_status] != 0 }
|
188
|
+
end
|
189
|
+
|
190
|
+
def report_time_taken
|
191
|
+
start = Time.now
|
192
|
+
yield
|
193
|
+
puts "\nTook #{Time.now - start} seconds"
|
194
|
+
end
|
195
|
+
|
196
|
+
def final_fail_message
|
197
|
+
fail_message = "#{@runner.name}s Failed"
|
198
|
+
fail_message = "\e[31m#{fail_message}\e[0m" if use_colors?
|
199
|
+
|
200
|
+
fail_message
|
201
|
+
end
|
202
|
+
|
203
|
+
def use_colors?
|
204
|
+
$stdout.tty?
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'cucumber/formatter/rerun'
|
2
|
+
require 'parallel_tests/gherkin/io'
|
3
|
+
|
4
|
+
module ParallelTests
|
5
|
+
module Cucumber
|
6
|
+
class FailuresLogger < ::Cucumber::Formatter::Rerun
|
7
|
+
include ParallelTests::Gherkin::Io
|
8
|
+
|
9
|
+
def initialize(runtime, path_or_io, options)
|
10
|
+
@io = prepare_io(path_or_io)
|
11
|
+
end
|
12
|
+
|
13
|
+
def after_feature(feature)
|
14
|
+
unless @lines.empty?
|
15
|
+
lock_output do
|
16
|
+
@lines.each do |line|
|
17
|
+
@io.puts "#{feature.file}:#{line}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require "parallel_tests/gherkin/runner"
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Cucumber
|
5
|
+
class Runner < ParallelTests::Gherkin::Runner
|
6
|
+
class << self
|
7
|
+
def name
|
8
|
+
'cucumber'
|
9
|
+
end
|
10
|
+
|
11
|
+
def line_is_result?(line)
|
12
|
+
super or line =~ failing_scenario_regex
|
13
|
+
end
|
14
|
+
|
15
|
+
def summarize_results(results)
|
16
|
+
output = []
|
17
|
+
|
18
|
+
failing_scenarios = results.grep(failing_scenario_regex)
|
19
|
+
if failing_scenarios.any?
|
20
|
+
failing_scenarios.unshift("Failing Scenarios:")
|
21
|
+
output << failing_scenarios.join("\n")
|
22
|
+
end
|
23
|
+
|
24
|
+
output << super
|
25
|
+
|
26
|
+
output.join("\n\n")
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def failing_scenario_regex
|
32
|
+
/^cucumber features\/.+:\d+/
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'gherkin/tag_expression'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Cucumber
|
5
|
+
module Formatters
|
6
|
+
class ScenarioLineLogger
|
7
|
+
attr_reader :scenarios
|
8
|
+
|
9
|
+
def initialize(tag_expression = ::Gherkin::TagExpression.new([]))
|
10
|
+
@scenarios = []
|
11
|
+
@tag_expression = tag_expression
|
12
|
+
end
|
13
|
+
|
14
|
+
def visit_feature_element(feature_element)
|
15
|
+
return unless @tag_expression.evaluate(feature_element.source_tags)
|
16
|
+
|
17
|
+
case feature_element
|
18
|
+
when ::Cucumber::Ast::Scenario
|
19
|
+
line = if feature_element.respond_to?(:line)
|
20
|
+
feature_element.line
|
21
|
+
else
|
22
|
+
feature_element.instance_variable_get(:@line)
|
23
|
+
end
|
24
|
+
@scenarios << [feature_element.feature.file, line].join(":")
|
25
|
+
when ::Cucumber::Ast::ScenarioOutline
|
26
|
+
sections = feature_element.instance_variable_get(:@example_sections)
|
27
|
+
sections.each { |section|
|
28
|
+
rows = if section[1].respond_to?(:rows)
|
29
|
+
section[1].rows
|
30
|
+
else
|
31
|
+
section[1].instance_variable_get(:@rows)
|
32
|
+
end
|
33
|
+
rows.each_with_index { |row, index|
|
34
|
+
next if index == 0 # slices didn't work with jruby data structure
|
35
|
+
line = if row.respond_to?(:line)
|
36
|
+
row.line
|
37
|
+
else
|
38
|
+
row.instance_variable_get(:@line)
|
39
|
+
end
|
40
|
+
@scenarios << [feature_element.feature.file, line].join(":")
|
41
|
+
}
|
42
|
+
}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def method_missing(*args)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'gherkin/tag_expression'
|
2
|
+
require 'cucumber/runtime'
|
3
|
+
require 'cucumber'
|
4
|
+
require 'parallel_tests/cucumber/scenario_line_logger'
|
5
|
+
require 'parallel_tests/gherkin/listener'
|
6
|
+
|
7
|
+
module ParallelTests
|
8
|
+
module Cucumber
|
9
|
+
class Scenarios
|
10
|
+
class << self
|
11
|
+
def all(files, options={})
|
12
|
+
tags = []
|
13
|
+
tags.concat options[:ignore_tag_pattern].to_s.split(/\s*,\s*/).map {|tag| "~#{tag}" }
|
14
|
+
tags.concat options[:test_options].to_s.scan(/(?:-t|--tags) (~?@[\w,~@]+)/).flatten
|
15
|
+
split_into_scenarios files, tags.uniq
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def split_into_scenarios(files, tags=[])
|
21
|
+
tag_expression = ::Gherkin::TagExpression.new(tags)
|
22
|
+
scenario_line_logger = ParallelTests::Cucumber::Formatters::ScenarioLineLogger.new(tag_expression)
|
23
|
+
loader = ::Cucumber::Runtime::FeaturesLoader.new(files, [], tag_expression)
|
24
|
+
|
25
|
+
loader.features.each do |feature|
|
26
|
+
feature.accept(scenario_line_logger)
|
27
|
+
end
|
28
|
+
|
29
|
+
scenario_line_logger.scenarios
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'parallel_tests'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Gherkin
|
5
|
+
module Io
|
6
|
+
|
7
|
+
def prepare_io(path_or_io)
|
8
|
+
if path_or_io.respond_to?(:write)
|
9
|
+
path_or_io
|
10
|
+
else # its a path
|
11
|
+
File.open(path_or_io, 'w').close # clean out the file
|
12
|
+
file = File.open(path_or_io, 'a')
|
13
|
+
|
14
|
+
at_exit do
|
15
|
+
unless file.closed?
|
16
|
+
file.flush
|
17
|
+
file.close
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
file
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# do not let multiple processes get in each others way
|
26
|
+
def lock_output
|
27
|
+
if File === @io
|
28
|
+
begin
|
29
|
+
@io.flock File::LOCK_EX
|
30
|
+
yield
|
31
|
+
ensure
|
32
|
+
@io.flock File::LOCK_UN
|
33
|
+
end
|
34
|
+
else
|
35
|
+
yield
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'gherkin'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Gherkin
|
5
|
+
class Listener
|
6
|
+
attr_reader :collect
|
7
|
+
|
8
|
+
attr_writer :ignore_tag_pattern
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@steps, @uris = [], []
|
12
|
+
@collect = {}
|
13
|
+
reset_counters!
|
14
|
+
end
|
15
|
+
|
16
|
+
def feature(feature)
|
17
|
+
@feature = feature
|
18
|
+
end
|
19
|
+
|
20
|
+
def background(*args)
|
21
|
+
@background = 1
|
22
|
+
end
|
23
|
+
|
24
|
+
def scenario(scenario)
|
25
|
+
@outline = @background = 0
|
26
|
+
return if should_ignore(scenario)
|
27
|
+
@scenarios += 1
|
28
|
+
end
|
29
|
+
|
30
|
+
def scenario_outline(outline)
|
31
|
+
return if should_ignore(outline)
|
32
|
+
@outline = 1
|
33
|
+
end
|
34
|
+
|
35
|
+
def step(*args)
|
36
|
+
return if @ignoring
|
37
|
+
if @background == 1
|
38
|
+
@background_steps += 1
|
39
|
+
elsif @outline > 0
|
40
|
+
@outline_steps += 1
|
41
|
+
else
|
42
|
+
@collect[@uri] += 1
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def uri(path)
|
47
|
+
@uri = path
|
48
|
+
@collect[@uri] = 0
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# @param [Gherkin::Formatter::Model::Examples] examples
|
53
|
+
#
|
54
|
+
def examples(examples)
|
55
|
+
if examples.rows.size > 0
|
56
|
+
@collect[@uri] += (@outline_steps * examples.rows.size)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def eof(*args)
|
61
|
+
@collect[@uri] += (@background_steps * @scenarios)
|
62
|
+
reset_counters!
|
63
|
+
end
|
64
|
+
|
65
|
+
def reset_counters!
|
66
|
+
@outline = @outline_steps = @background = @background_steps = @scenarios = 0
|
67
|
+
@ignoring = nil
|
68
|
+
end
|
69
|
+
|
70
|
+
# ignore lots of other possible callbacks ...
|
71
|
+
def method_missing(*args)
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
# Return a combination of tags declared on this scenario/outline and the feature it belongs to
|
77
|
+
def all_tags(scenario)
|
78
|
+
(scenario.tags || []) + ((@feature && @feature.tags) || [])
|
79
|
+
end
|
80
|
+
|
81
|
+
# Set @ignoring if we should ignore this scenario/outline based on its tags
|
82
|
+
def should_ignore(scenario)
|
83
|
+
@ignoring = @ignore_tag_pattern && all_tags(scenario).find{ |tag| @ignore_tag_pattern === tag.name }
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|