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.
- 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
@@ -0,0 +1,116 @@
|
|
1
|
+
require "parallel_tests/test/runner"
|
2
|
+
require 'shellwords'
|
3
|
+
|
4
|
+
module ParallelTests
|
5
|
+
module Gherkin
|
6
|
+
class Runner < ParallelTests::Test::Runner
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def run_tests(test_files, process_number, num_processes, options)
|
10
|
+
combined_scenarios = test_files
|
11
|
+
|
12
|
+
if options[:group_by] == :scenarios
|
13
|
+
grouped = test_files.map { |t| t.split(':') }.group_by(&:first)
|
14
|
+
combined_scenarios = grouped.map {|file,files_and_lines| "#{file}:#{files_and_lines.map(&:last).join(':')}" }
|
15
|
+
end
|
16
|
+
|
17
|
+
sanitized_test_files = combined_scenarios.map { |val| WINDOWS ? "\"#{val}\"" : Shellwords.escape(val) }
|
18
|
+
|
19
|
+
options[:env] ||= {}
|
20
|
+
options[:env] = options[:env].merge({'AUTOTEST' => '1'}) if $stdout.tty? # display color when we are in a terminal
|
21
|
+
|
22
|
+
cmd = [
|
23
|
+
executable,
|
24
|
+
(runtime_logging if File.directory?(File.dirname(runtime_log))),
|
25
|
+
cucumber_opts(options[:test_options]),
|
26
|
+
*sanitized_test_files
|
27
|
+
].compact.join(' ')
|
28
|
+
execute_command(cmd, process_number, num_processes, options)
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_file_name
|
32
|
+
@test_file_name || 'feature'
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_suffix
|
36
|
+
/\.feature$/
|
37
|
+
end
|
38
|
+
|
39
|
+
def line_is_result?(line)
|
40
|
+
line =~ /^\d+ (steps?|scenarios?)/
|
41
|
+
end
|
42
|
+
|
43
|
+
# cucumber has 2 result lines per test run, that cannot be added
|
44
|
+
# 1 scenario (1 failed)
|
45
|
+
# 1 step (1 failed)
|
46
|
+
def summarize_results(results)
|
47
|
+
sort_order = %w[scenario step failed undefined skipped pending passed]
|
48
|
+
|
49
|
+
%w[scenario step].map do |group|
|
50
|
+
group_results = results.grep(/^\d+ #{group}/)
|
51
|
+
next if group_results.empty?
|
52
|
+
|
53
|
+
sums = sum_up_results(group_results)
|
54
|
+
sums = sums.sort_by { |word, _| sort_order.index(word) || 999 }
|
55
|
+
sums.map! do |word, number|
|
56
|
+
plural = "s" if word == group and number != 1
|
57
|
+
"#{number} #{word}#{plural}"
|
58
|
+
end
|
59
|
+
"#{sums[0]} (#{sums[1..-1].join(", ")})"
|
60
|
+
end.compact.join("\n")
|
61
|
+
end
|
62
|
+
|
63
|
+
def cucumber_opts(given)
|
64
|
+
if given =~ /--profile/ or given =~ /(^|\s)-p /
|
65
|
+
given
|
66
|
+
else
|
67
|
+
[given, profile_from_config].compact.join(" ")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def profile_from_config
|
72
|
+
# copied from https://github.com/cucumber/cucumber/blob/master/lib/cucumber/cli/profile_loader.rb#L85
|
73
|
+
config = Dir.glob("{,.config/,config/}#{name}{.yml,.yaml}").first
|
74
|
+
if config && File.read(config) =~ /^parallel:/
|
75
|
+
"--profile parallel"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def tests_in_groups(tests, num_groups, options={})
|
80
|
+
if options[:group_by] == :scenarios
|
81
|
+
@test_file_name = "scenario"
|
82
|
+
end
|
83
|
+
method = "by_#{options[:group_by]}"
|
84
|
+
if Grouper.respond_to?(method)
|
85
|
+
Grouper.send(method, find_tests(tests, options), num_groups, options)
|
86
|
+
else
|
87
|
+
super
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
def runtime_logging
|
93
|
+
" --format ParallelTests::Gherkin::RuntimeLogger --out #{runtime_log}"
|
94
|
+
end
|
95
|
+
|
96
|
+
def runtime_log
|
97
|
+
"tmp/parallel_runtime_#{name}.log"
|
98
|
+
end
|
99
|
+
|
100
|
+
def determine_executable
|
101
|
+
case
|
102
|
+
when File.exists?("bin/#{name}")
|
103
|
+
"bin/#{name}"
|
104
|
+
when ParallelTests.bundler_enabled?
|
105
|
+
"bundle exec #{name}"
|
106
|
+
when File.file?("script/#{name}")
|
107
|
+
"script/#{name}"
|
108
|
+
else
|
109
|
+
"#{name}"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'parallel_tests/gherkin/io'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Gherkin
|
5
|
+
class RuntimeLogger
|
6
|
+
include Io
|
7
|
+
|
8
|
+
def initialize(step_mother, path_or_io, options=nil)
|
9
|
+
@io = prepare_io(path_or_io)
|
10
|
+
@example_times = Hash.new(0)
|
11
|
+
end
|
12
|
+
|
13
|
+
def before_feature(_)
|
14
|
+
@start_at = ParallelTests.now.to_f
|
15
|
+
end
|
16
|
+
|
17
|
+
def after_feature(feature)
|
18
|
+
@example_times[feature.file] += ParallelTests.now.to_f - @start_at
|
19
|
+
end
|
20
|
+
|
21
|
+
def after_features(*args)
|
22
|
+
lock_output do
|
23
|
+
@io.puts @example_times.map { |file, time| "#{file}:#{time}" }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module ParallelTests
|
2
|
+
class Grouper
|
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)
|
7
|
+
end
|
8
|
+
|
9
|
+
def by_scenarios(tests, num_groups, options={})
|
10
|
+
scenarios = group_by_scenarios(tests, options)
|
11
|
+
in_even_groups_by_size(scenarios, num_groups)
|
12
|
+
end
|
13
|
+
|
14
|
+
def in_even_groups_by_size(items, num_groups, options= {})
|
15
|
+
groups = Array.new(num_groups) { {:items => [], :size => 0} }
|
16
|
+
|
17
|
+
# add all files that should run in a single process to one group
|
18
|
+
(options[:single_process] || []).each do |pattern|
|
19
|
+
matched, items = items.partition { |item, size| item =~ pattern }
|
20
|
+
matched.each { |item, size| add_to_group(groups.first, item, size) }
|
21
|
+
end
|
22
|
+
|
23
|
+
groups_to_fill = (options[:isolate] ? groups[1..-1] : groups)
|
24
|
+
group_features_by_size(items_to_group(items), groups_to_fill)
|
25
|
+
|
26
|
+
groups.map!{|g| g[:items].sort }
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def largest_first(files)
|
32
|
+
files.sort_by{|item, size| size }.reverse
|
33
|
+
end
|
34
|
+
|
35
|
+
def smallest_group(groups)
|
36
|
+
groups.min_by{|g| g[:size] }
|
37
|
+
end
|
38
|
+
|
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
|
+
|
55
|
+
def group_by_scenarios(tests, options={})
|
56
|
+
require 'parallel_tests/cucumber/scenarios'
|
57
|
+
ParallelTests::Cucumber::Scenarios.all(tests, options)
|
58
|
+
end
|
59
|
+
|
60
|
+
def group_features_by_size(items, groups_to_fill)
|
61
|
+
items.each do |item, size|
|
62
|
+
size ||= 1
|
63
|
+
smallest = smallest_group(groups_to_fill)
|
64
|
+
add_to_group(smallest, item, size)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def items_to_group(items)
|
69
|
+
items.first && items.first.size == 2 ? largest_first(items) : items
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'parallel_tests/rspec/logger_base'
|
2
|
+
require 'parallel_tests/rspec/runner'
|
3
|
+
|
4
|
+
class ParallelTests::RSpec::FailuresLogger < ParallelTests::RSpec::LoggerBase
|
5
|
+
if RSPEC_3
|
6
|
+
RSpec::Core::Formatters.register self, :dump_failures, :dump_summary
|
7
|
+
end
|
8
|
+
|
9
|
+
# RSpec 1: does not keep track of failures, so we do
|
10
|
+
def example_failed(example, *args)
|
11
|
+
if RSPEC_1
|
12
|
+
@failed_examples ||= []
|
13
|
+
@failed_examples << example
|
14
|
+
else
|
15
|
+
super
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
if RSPEC_1
|
20
|
+
def dump_failure(*args)
|
21
|
+
end
|
22
|
+
else
|
23
|
+
def dump_failures(*args)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def dump_summary(*args)
|
28
|
+
lock_output do
|
29
|
+
if RSPEC_1
|
30
|
+
dump_commands_to_rerun_failed_examples_rspec_1
|
31
|
+
elsif RSPEC_3
|
32
|
+
notification = args.first
|
33
|
+
unless notification.failed_examples.empty?
|
34
|
+
colorizer = ::RSpec::Core::Formatters::ConsoleCodes
|
35
|
+
output.puts notification.colorized_rerun_commands(colorizer)
|
36
|
+
end
|
37
|
+
else
|
38
|
+
dump_commands_to_rerun_failed_examples
|
39
|
+
end
|
40
|
+
end
|
41
|
+
@output.flush
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def dump_commands_to_rerun_failed_examples_rspec_1
|
47
|
+
(@failed_examples||[]).each do |example|
|
48
|
+
file, line = example.location.to_s.split(':')
|
49
|
+
next unless file and line
|
50
|
+
file.gsub!(%r(^.*?/spec/), './spec/')
|
51
|
+
@output.puts "#{ParallelTests::RSpec::Runner.send(:executable)} #{file}:#{line} # #{example.description}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module ParallelTests
|
2
|
+
module RSpec
|
3
|
+
end
|
4
|
+
end
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'rspec/core/formatters/base_text_formatter'
|
8
|
+
base = RSpec::Core::Formatters::BaseTextFormatter
|
9
|
+
rescue LoadError
|
10
|
+
require 'spec/runner/formatter/base_text_formatter'
|
11
|
+
base = Spec::Runner::Formatter::BaseTextFormatter
|
12
|
+
end
|
13
|
+
|
14
|
+
ParallelTests::RSpec::LoggerBaseBase = base
|
15
|
+
|
16
|
+
class ParallelTests::RSpec::LoggerBase < ParallelTests::RSpec::LoggerBaseBase
|
17
|
+
RSPEC_1 = !defined?(RSpec::Core::Formatters::BaseTextFormatter) # do not test for Spec, this will trigger deprecation warning in rspec 2
|
18
|
+
RSPEC_3 = !RSPEC_1 && RSpec::Core::Version::STRING.start_with?('3')
|
19
|
+
|
20
|
+
def initialize(*args)
|
21
|
+
super
|
22
|
+
|
23
|
+
@output ||= args[1] || args[0] # rspec 1 has output as second argument
|
24
|
+
|
25
|
+
if String === @output # a path ?
|
26
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
27
|
+
File.open(@output, 'w'){} # overwrite previous results
|
28
|
+
@output = File.open(@output, 'a')
|
29
|
+
elsif File === @output # close and restart in append mode
|
30
|
+
@output.close
|
31
|
+
@output = File.open(@output.path, 'a')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
#stolen from Rspec
|
36
|
+
def close(*args)
|
37
|
+
@output.close if (IO === @output) & (@output != $stdout)
|
38
|
+
end
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
# do not let multiple processes get in each others way
|
43
|
+
def lock_output
|
44
|
+
if File === @output
|
45
|
+
begin
|
46
|
+
@output.flock File::LOCK_EX
|
47
|
+
yield
|
48
|
+
ensure
|
49
|
+
@output.flock File::LOCK_UN
|
50
|
+
end
|
51
|
+
else
|
52
|
+
yield
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require "parallel_tests/test/runner"
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module RSpec
|
5
|
+
class Runner < ParallelTests::Test::Runner
|
6
|
+
DEV_NULL = (WINDOWS ? "NUL" : "/dev/null")
|
7
|
+
NAME = 'RSpec'
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def run_tests(test_files, process_number, num_processes, options)
|
11
|
+
exe = executable # expensive, so we cache
|
12
|
+
version = (exe =~ /\brspec\b/ ? 2 : 1)
|
13
|
+
cmd = [exe, options[:test_options], (rspec_2_color if version == 2), spec_opts, *test_files].compact.join(" ")
|
14
|
+
options = options.merge(:env => rspec_1_color) if version == 1
|
15
|
+
execute_command(cmd, process_number, num_processes, options)
|
16
|
+
end
|
17
|
+
|
18
|
+
def determine_executable
|
19
|
+
cmd = case
|
20
|
+
when File.exists?("bin/rspec")
|
21
|
+
WINDOWS ? "ruby bin/rspec" : "bin/rspec"
|
22
|
+
when File.file?("script/spec")
|
23
|
+
"script/spec"
|
24
|
+
when ParallelTests.bundler_enabled?
|
25
|
+
cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
|
26
|
+
"bundle exec #{cmd}"
|
27
|
+
else
|
28
|
+
%w[spec rspec].detect{|cmd| system "#{cmd} --version > #{DEV_NULL} 2>&1" }
|
29
|
+
end
|
30
|
+
|
31
|
+
cmd or raise("Can't find executables rspec or spec")
|
32
|
+
end
|
33
|
+
|
34
|
+
def runtime_log
|
35
|
+
'tmp/parallel_runtime_rspec.log'
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_file_name
|
39
|
+
"spec"
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_suffix
|
43
|
+
/_spec\.rb$/
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# so it can be stubbed....
|
49
|
+
def run(cmd)
|
50
|
+
`#{cmd}`
|
51
|
+
end
|
52
|
+
|
53
|
+
def rspec_1_color
|
54
|
+
if $stdout.tty?
|
55
|
+
{'RSPEC_COLOR' => "1"}
|
56
|
+
else
|
57
|
+
{}
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def rspec_2_color
|
62
|
+
'--color --tty' if $stdout.tty?
|
63
|
+
end
|
64
|
+
|
65
|
+
def spec_opts
|
66
|
+
options_file = ['.rspec_parallel', 'spec/parallel_spec.opts', 'spec/spec.opts'].detect{|f| File.file?(f) }
|
67
|
+
return unless options_file
|
68
|
+
"-O #{options_file}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'parallel_tests'
|
2
|
+
require 'parallel_tests/rspec/logger_base'
|
3
|
+
|
4
|
+
class ParallelTests::RSpec::RuntimeLogger < ParallelTests::RSpec::LoggerBase
|
5
|
+
def initialize(*args)
|
6
|
+
super
|
7
|
+
@example_times = Hash.new(0)
|
8
|
+
@group_nesting = 0 unless RSPEC_1
|
9
|
+
end
|
10
|
+
|
11
|
+
if RSPEC_3
|
12
|
+
RSpec::Core::Formatters.register self, :example_group_started, :example_group_finished, :start_dump
|
13
|
+
end
|
14
|
+
|
15
|
+
if RSPEC_1
|
16
|
+
def example_started(*args)
|
17
|
+
@time = ParallelTests.now
|
18
|
+
super
|
19
|
+
end
|
20
|
+
|
21
|
+
def example_passed(example)
|
22
|
+
file = example.location.split(':').first
|
23
|
+
@example_times[file] += ParallelTests.now - @time
|
24
|
+
super
|
25
|
+
end
|
26
|
+
else
|
27
|
+
def example_group_started(example_group)
|
28
|
+
@time = ParallelTests.now if @group_nesting == 0
|
29
|
+
@group_nesting += 1
|
30
|
+
super
|
31
|
+
end
|
32
|
+
|
33
|
+
def example_group_finished(notification)
|
34
|
+
@group_nesting -= 1
|
35
|
+
if @group_nesting == 0
|
36
|
+
path = (RSPEC_3 ? notification.group.file_path : notification.file_path)
|
37
|
+
@example_times[path] += ParallelTests.now - @time
|
38
|
+
end
|
39
|
+
super if defined?(super)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def dump_summary(*args);end
|
44
|
+
def dump_failures(*args);end
|
45
|
+
def dump_failure(*args);end
|
46
|
+
def dump_pending(*args);end
|
47
|
+
|
48
|
+
def start_dump(*args)
|
49
|
+
return unless ENV['TEST_ENV_NUMBER'] #only record when running in parallel
|
50
|
+
# TODO: Figure out why sometimes time can be less than 0
|
51
|
+
lock_output do
|
52
|
+
@example_times.each do |file, time|
|
53
|
+
relative_path = file.sub(/^#{Regexp.escape Dir.pwd}\//,'')
|
54
|
+
@output.puts "#{relative_path}:#{time > 0 ? time : 0}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
@output.flush
|
58
|
+
end
|
59
|
+
end
|