friendlyfashion-parallel_tests 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +44 -0
- data/Rakefile +6 -0
- data/Readme.md +232 -0
- data/ReadmeRails2.md +48 -0
- data/bin/parallel_cucumber +2 -0
- data/bin/parallel_rspec +2 -0
- data/bin/parallel_test +6 -0
- data/lib/parallel_tests.rb +30 -0
- data/lib/parallel_tests/cli.rb +159 -0
- data/lib/parallel_tests/cucumber/gherkin_listener.rb +60 -0
- data/lib/parallel_tests/cucumber/runner.rb +90 -0
- data/lib/parallel_tests/cucumber/runtime_logger.rb +58 -0
- data/lib/parallel_tests/grouper.rb +53 -0
- data/lib/parallel_tests/railtie.rb +8 -0
- data/lib/parallel_tests/rspec/failures_logger.rb +44 -0
- data/lib/parallel_tests/rspec/logger_base.rb +52 -0
- data/lib/parallel_tests/rspec/runner.rb +59 -0
- data/lib/parallel_tests/rspec/runtime_logger.rb +34 -0
- data/lib/parallel_tests/rspec/summary_logger.rb +19 -0
- data/lib/parallel_tests/tasks.rb +134 -0
- data/lib/parallel_tests/test/runner.rb +134 -0
- data/lib/parallel_tests/test/runtime_logger.rb +92 -0
- data/lib/parallel_tests/version.rb +3 -0
- data/parallel_tests.gemspec +14 -0
- data/spec/integration_spec.rb +244 -0
- data/spec/parallel_tests/cli_spec.rb +36 -0
- data/spec/parallel_tests/cucumber/gherkin_listener_spec.rb +48 -0
- data/spec/parallel_tests/cucumber/runner_spec.rb +173 -0
- data/spec/parallel_tests/grouper_spec.rb +52 -0
- data/spec/parallel_tests/rspec/failure_logger_spec.rb +82 -0
- data/spec/parallel_tests/rspec/runner_spec.rb +178 -0
- data/spec/parallel_tests/rspec/runtime_logger_spec.rb +76 -0
- data/spec/parallel_tests/rspec/summary_logger_spec.rb +37 -0
- data/spec/parallel_tests/tasks_spec.rb +151 -0
- data/spec/parallel_tests/test/runner_spec.rb +273 -0
- data/spec/parallel_tests/test/runtime_logger_spec.rb +84 -0
- data/spec/parallel_tests_spec.rb +73 -0
- data/spec/spec_helper.rb +151 -0
- metadata +109 -0
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'gherkin'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Cucumber
|
5
|
+
class GherkinListener
|
6
|
+
attr_reader :collect
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@steps, @uris = [], []
|
10
|
+
@collect = {}
|
11
|
+
reset_counters!
|
12
|
+
end
|
13
|
+
|
14
|
+
def background(*args)
|
15
|
+
@background = 1
|
16
|
+
end
|
17
|
+
|
18
|
+
def scenario(*args)
|
19
|
+
@scenarios += 1
|
20
|
+
@outline = @background = 0
|
21
|
+
end
|
22
|
+
|
23
|
+
def scenario_outline(*args)
|
24
|
+
@outline = 1
|
25
|
+
end
|
26
|
+
|
27
|
+
def step(*args)
|
28
|
+
if @background == 1
|
29
|
+
@background_steps += 1
|
30
|
+
elsif @outline > 0
|
31
|
+
@outline_steps += 1
|
32
|
+
else
|
33
|
+
@collect[@uri] += 1
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def uri(path)
|
38
|
+
@uri = path
|
39
|
+
@collect[@uri] = 0
|
40
|
+
end
|
41
|
+
|
42
|
+
def examples(*args)
|
43
|
+
@examples += 1
|
44
|
+
end
|
45
|
+
|
46
|
+
def eof(*args)
|
47
|
+
@collect[@uri] += (@background_steps * @scenarios) + (@outline_steps * @examples)
|
48
|
+
reset_counters!
|
49
|
+
end
|
50
|
+
|
51
|
+
def reset_counters!
|
52
|
+
@examples = @outline = @outline_steps = @background = @background_steps = @scenarios = 0
|
53
|
+
end
|
54
|
+
|
55
|
+
# ignore lots of other possible callbacks ...
|
56
|
+
def method_missing(*args)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'parallel_tests/test/runner'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module Cucumber
|
5
|
+
class Runner < ParallelTests::Test::Runner
|
6
|
+
def self.run_tests(test_files, process_number, options)
|
7
|
+
color = ($stdout.tty? ? 'AUTOTEST=1 ; export AUTOTEST ;' : '')#display color when we are in a terminal
|
8
|
+
runtime_logging = " --format ParallelTests::Cucumber::RuntimeLogger --out #{runtime_log}"
|
9
|
+
cmd = [
|
10
|
+
color,
|
11
|
+
executable,
|
12
|
+
(runtime_logging if File.directory?(File.dirname(runtime_log))),
|
13
|
+
cucumber_opts(options[:test_options]),
|
14
|
+
*test_files
|
15
|
+
].compact.join(" ")
|
16
|
+
execute_command(cmd, process_number, options)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.executable
|
20
|
+
if ParallelTests.bundler_enabled?
|
21
|
+
"bundle exec cucumber"
|
22
|
+
elsif File.file?("script/cucumber")
|
23
|
+
"script/cucumber"
|
24
|
+
else
|
25
|
+
"cucumber"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.runtime_log
|
30
|
+
'tmp/parallel_runtime_cucumber.log'
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.test_file_name
|
34
|
+
"feature"
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.test_suffix
|
38
|
+
".feature"
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.line_is_result?(line)
|
42
|
+
line =~ /^\d+ (steps?|scenarios?)/
|
43
|
+
end
|
44
|
+
|
45
|
+
# cucumber has 2 result lines per test run, that cannot be added
|
46
|
+
# 1 scenario (1 failed)
|
47
|
+
# 1 step (1 failed)
|
48
|
+
def self.summarize_results(results)
|
49
|
+
sort_order = %w[scenario step failed undefined skipped pending passed]
|
50
|
+
|
51
|
+
%w[scenario step].map do |group|
|
52
|
+
group_results = results.grep /^\d+ #{group}/
|
53
|
+
next if group_results.empty?
|
54
|
+
|
55
|
+
sums = sum_up_results(group_results)
|
56
|
+
sums = sums.sort_by { |word, _| sort_order.index(word) || 999 }
|
57
|
+
sums.map! do |word, number|
|
58
|
+
plural = "s" if word == group and number != 1
|
59
|
+
"#{number} #{word}#{plural}"
|
60
|
+
end
|
61
|
+
"#{sums[0]} (#{sums[1..-1].join(", ")})"
|
62
|
+
end.compact.join("\n")
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.cucumber_opts(given)
|
66
|
+
if given =~ /--profile/ or given =~ /(^|\s)-p /
|
67
|
+
given
|
68
|
+
else
|
69
|
+
[given, profile_from_config].compact.join(" ")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.profile_from_config
|
74
|
+
# copied from https://github.com/cucumber/cucumber/blob/master/lib/cucumber/cli/profile_loader.rb#L85
|
75
|
+
config = Dir.glob('{,.config/,config/}cucumber{.yml,.yaml}').first
|
76
|
+
if config && File.read(config) =~ /^parallel:/
|
77
|
+
"--profile parallel"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.tests_in_groups(tests, num_groups, options={})
|
82
|
+
if options[:group_by] == :steps
|
83
|
+
Grouper.by_steps(find_tests(tests, options), num_groups)
|
84
|
+
else
|
85
|
+
super
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module ParallelTests
|
2
|
+
module Cucumber
|
3
|
+
class RuntimeLogger
|
4
|
+
def initialize(step_mother, path_or_io, options=nil)
|
5
|
+
@io = prepare_io(path_or_io)
|
6
|
+
@example_times = Hash.new(0)
|
7
|
+
end
|
8
|
+
|
9
|
+
def before_feature(_)
|
10
|
+
@start_at = Time.now.to_f
|
11
|
+
end
|
12
|
+
|
13
|
+
def after_feature(feature)
|
14
|
+
@example_times[feature.file] += Time.now.to_f - @start_at
|
15
|
+
end
|
16
|
+
|
17
|
+
def after_features(*args)
|
18
|
+
lock_output do
|
19
|
+
@io.puts @example_times.map { |file, time| "#{file}:#{time}" }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def prepare_io(path_or_io)
|
26
|
+
if path_or_io.respond_to?(:write)
|
27
|
+
path_or_io
|
28
|
+
else # its a path
|
29
|
+
File.open(path_or_io, 'w').close # clean out the file
|
30
|
+
file = File.open(path_or_io, 'a')
|
31
|
+
|
32
|
+
at_exit do
|
33
|
+
unless file.closed?
|
34
|
+
file.flush
|
35
|
+
file.close
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
file
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# do not let multiple processes get in each others way
|
44
|
+
def lock_output
|
45
|
+
if File === @io
|
46
|
+
begin
|
47
|
+
@io.flock File::LOCK_EX
|
48
|
+
yield
|
49
|
+
ensure
|
50
|
+
@io.flock File::LOCK_UN
|
51
|
+
end
|
52
|
+
else
|
53
|
+
yield
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module ParallelTests
|
2
|
+
class Grouper
|
3
|
+
def self.in_even_groups_by_size(items_with_sizes, num_groups, options = {})
|
4
|
+
groups = Array.new(num_groups) { {:items => [], :size => 0} }
|
5
|
+
|
6
|
+
# add all files that should run in a single process to one group
|
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) }
|
10
|
+
end
|
11
|
+
|
12
|
+
groups_to_fill = (options[:isolate] ? groups[1..-1] : groups)
|
13
|
+
|
14
|
+
# add all other files
|
15
|
+
largest_first(items_with_sizes).each do |item, size|
|
16
|
+
smallest = smallest_group(groups_to_fill)
|
17
|
+
add_to_group(smallest, item, size)
|
18
|
+
end
|
19
|
+
|
20
|
+
groups.map!{|g| g[:items].sort }
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.largest_first(files)
|
24
|
+
files.sort_by{|item, size| size }.reverse
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def self.smallest_group(groups)
|
30
|
+
groups.min_by{|g| g[:size] }
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.add_to_group(group, item, size)
|
34
|
+
group[:items] << item
|
35
|
+
group[:size] += size
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.by_steps(tests, num_groups)
|
39
|
+
features_with_steps = build_features_with_steps(tests)
|
40
|
+
in_even_groups_by_size(features_with_steps, num_groups)
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.build_features_with_steps(tests)
|
44
|
+
require 'parallel_tests/cucumber/gherkin_listener'
|
45
|
+
listener = Cucumber::GherkinListener.new
|
46
|
+
parser = Gherkin::Parser::Parser.new(listener, true, 'root')
|
47
|
+
tests.each{|file|
|
48
|
+
parser.parse(File.read(file), file, 0)
|
49
|
+
}
|
50
|
+
listener.collect.sort_by{|_,value| -value }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'parallel_tests/rspec/logger_base'
|
2
|
+
require 'parallel_tests/rspec/runner'
|
3
|
+
|
4
|
+
class ParallelTests::RSpec::FailuresLogger < ParallelTests::RSpec::LoggerBase
|
5
|
+
# RSpec 1: does not keep track of failures, so we do
|
6
|
+
def example_failed(example, *args)
|
7
|
+
if RSPEC_1
|
8
|
+
@failed_examples ||= []
|
9
|
+
@failed_examples << example
|
10
|
+
else
|
11
|
+
super
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# RSpec 1: dumps 1 failed spec
|
16
|
+
def dump_failure(*args)
|
17
|
+
end
|
18
|
+
|
19
|
+
# RSpec 2: dumps all failed specs
|
20
|
+
def dump_failures(*args)
|
21
|
+
end
|
22
|
+
|
23
|
+
def dump_summary(*args)
|
24
|
+
lock_output do
|
25
|
+
if RSPEC_1
|
26
|
+
dump_commands_to_rerun_failed_examples_rspec_1
|
27
|
+
else
|
28
|
+
dump_commands_to_rerun_failed_examples
|
29
|
+
end
|
30
|
+
end
|
31
|
+
@output.flush
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def dump_commands_to_rerun_failed_examples_rspec_1
|
37
|
+
(@failed_examples||[]).each do |example|
|
38
|
+
file, line = example.location.to_s.split(':')
|
39
|
+
next unless file and line
|
40
|
+
file.gsub!(%r(^.*?/spec/), './spec/')
|
41
|
+
@output.puts "#{ParallelTests::RSpec::Runner.executable} #{file}:#{line} # #{example.description}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,52 @@
|
|
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
|
+
|
19
|
+
def initialize(*args)
|
20
|
+
super
|
21
|
+
|
22
|
+
@output ||= args[1] || args[0] # rspec 1 has output as second argument
|
23
|
+
|
24
|
+
if String === @output # a path ?
|
25
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
26
|
+
File.open(@output, 'w'){} # overwrite previous results
|
27
|
+
@output = File.open(@output, 'a')
|
28
|
+
elsif File === @output # close and restart in append mode
|
29
|
+
@output.close
|
30
|
+
@output = File.open(@output.path, 'a')
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
#stolen from Rspec
|
35
|
+
def close
|
36
|
+
@output.close if (IO === @output) & (@output != $stdout)
|
37
|
+
end
|
38
|
+
|
39
|
+
# do not let multiple processes get in each others way
|
40
|
+
def lock_output
|
41
|
+
if File === @output
|
42
|
+
begin
|
43
|
+
@output.flock File::LOCK_EX
|
44
|
+
yield
|
45
|
+
ensure
|
46
|
+
@output.flock File::LOCK_UN
|
47
|
+
end
|
48
|
+
else
|
49
|
+
yield
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'parallel_tests/test/runner'
|
2
|
+
|
3
|
+
module ParallelTests
|
4
|
+
module RSpec
|
5
|
+
class Runner < ParallelTests::Test::Runner
|
6
|
+
def self.run_tests(test_files, process_number, options)
|
7
|
+
exe = executable # expensive, so we cache
|
8
|
+
version = (exe =~ /\brspec\b/ ? 2 : 1)
|
9
|
+
cmd = "#{rspec_1_color if version == 1}#{exe} #{options[:test_options]} #{rspec_2_color if version == 2}#{spec_opts} #{test_files*' '}"
|
10
|
+
execute_command(cmd, process_number, options)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.executable
|
14
|
+
cmd = if File.file?("script/spec")
|
15
|
+
"script/spec"
|
16
|
+
elsif ParallelTests.bundler_enabled?
|
17
|
+
cmd = (run("bundle show rspec") =~ %r{/rspec-1[^/]+$} ? "spec" : "rspec")
|
18
|
+
"bundle exec #{cmd}"
|
19
|
+
else
|
20
|
+
%w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
|
21
|
+
end
|
22
|
+
cmd or raise("Can't find executables rspec or spec")
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.runtime_log
|
26
|
+
'tmp/parallel_runtime_rspec.log'
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.test_file_name
|
30
|
+
"spec"
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.test_suffix
|
34
|
+
"_spec.rb"
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# so it can be stubbed....
|
40
|
+
def self.run(cmd)
|
41
|
+
`#{cmd}`
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.rspec_1_color
|
45
|
+
'RSPEC_COLOR=1 ; export RSPEC_COLOR ;' if $stdout.tty?
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.rspec_2_color
|
49
|
+
'--color --tty ' if $stdout.tty?
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.spec_opts
|
53
|
+
options_file = ['.rspec_parallel', 'spec/parallel_spec.opts', 'spec/spec.opts'].detect{|f| File.file?(f) }
|
54
|
+
return unless options_file
|
55
|
+
"-O #{options_file}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'parallel_tests/rspec/logger_base'
|
2
|
+
|
3
|
+
class ParallelTests::RSpec::RuntimeLogger < ParallelTests::RSpec::LoggerBase
|
4
|
+
def initialize(*args)
|
5
|
+
super
|
6
|
+
@example_times = Hash.new(0)
|
7
|
+
end
|
8
|
+
|
9
|
+
def example_started(*args)
|
10
|
+
@time = Time.now
|
11
|
+
end
|
12
|
+
|
13
|
+
def example_passed(example)
|
14
|
+
file = example.location.split(':').first
|
15
|
+
@example_times[file] += Time.now - @time
|
16
|
+
end
|
17
|
+
|
18
|
+
def dump_summary(*args);end
|
19
|
+
def dump_failures(*args);end
|
20
|
+
def dump_failure(*args);end
|
21
|
+
def dump_pending(*args);end
|
22
|
+
|
23
|
+
def start_dump(*args)
|
24
|
+
return unless ENV['TEST_ENV_NUMBER'] #only record when running in parallel
|
25
|
+
# TODO: Figure out why sometimes time can be less than 0
|
26
|
+
lock_output do
|
27
|
+
@example_times.each do |file, time|
|
28
|
+
relative_path = file.sub(/^#{Regexp.escape Dir.pwd}\//,'')
|
29
|
+
@output.puts "#{relative_path}:#{time > 0 ? time : 0}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
@output.flush
|
33
|
+
end
|
34
|
+
end
|