From 03e0482b495add2447f3b97e04578611dd2422d7 Mon Sep 17 00:00:00 2001 From: Alexander Bayandin Date: Sun, 6 Dec 2015 15:33:03 +0000 Subject: [PATCH] Initial commit --- .gitignore | 5 ++ Gemfile | 3 + Gemfile.lock | 35 +++++++++++ LICENSE.md | 22 +++++++ README.md | 1 + bin/parallel_cucumber | 9 +++ lib/parallel_cucumber.rb | 55 +++++++++++++++++ lib/parallel_cucumber/cli.rb | 46 +++++++++++++++ lib/parallel_cucumber/feature_grouper.rb | 67 +++++++++++++++++++++ lib/parallel_cucumber/result_formatter.rb | 72 +++++++++++++++++++++++ lib/parallel_cucumber/runner.rb | 50 ++++++++++++++++ lib/parallel_cucumber/version.rb | 3 + parallel_cucumber.gemspec | 19 ++++++ 13 files changed, 387 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE.md create mode 100644 README.md create mode 100755 bin/parallel_cucumber create mode 100644 lib/parallel_cucumber.rb create mode 100644 lib/parallel_cucumber/cli.rb create mode 100644 lib/parallel_cucumber/feature_grouper.rb create mode 100644 lib/parallel_cucumber/result_formatter.rb create mode 100644 lib/parallel_cucumber/runner.rb create mode 100644 lib/parallel_cucumber/version.rb create mode 100644 parallel_cucumber.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e7225b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +## IDE ## +.idea/ + +## DEV ## +parallel_cucumber-*.gem diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..0e5f3ca --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,35 @@ +PATH + remote: . + specs: + parallel_cucumber (0.1.0) + parallel (~> 1.6) + +GEM + remote: https://rubygems.org/ + specs: + builder (3.2.2) + cucumber (2.1.0) + builder (>= 2.1.2) + cucumber-core (~> 1.3.0) + diff-lcs (>= 1.1.3) + gherkin3 (~> 3.1.0) + multi_json (>= 1.7.5, < 2.0) + multi_test (>= 0.1.2) + cucumber-core (1.3.0) + gherkin3 (~> 3.1.0) + diff-lcs (1.2.5) + gherkin3 (3.1.2) + multi_json (1.11.2) + multi_test (0.1.2) + parallel (1.6.1) + +PLATFORMS + ruby + +DEPENDENCIES + bundler (~> 1.10) + cucumber + parallel_cucumber! + +BUNDLED WITH + 1.10.6 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..51e2a74 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +Copyright © 2015 Alexander Bayandin + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5dc5f3 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Parallel Cucumber diff --git a/bin/parallel_cucumber b/bin/parallel_cucumber new file mode 100755 index 0000000..63d2a86 --- /dev/null +++ b/bin/parallel_cucumber @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby + +# Enable local usage from cloned repo +root = File.expand_path('../..', __FILE__) +$LOAD_PATH << "#{root}/lib" if File.exist?("#{root}/Gemfile") + +require 'parallel_cucumber' + +ParallelCucumber::Cli.run(ARGV) diff --git a/lib/parallel_cucumber.rb b/lib/parallel_cucumber.rb new file mode 100644 index 0000000..34fe71a --- /dev/null +++ b/lib/parallel_cucumber.rb @@ -0,0 +1,55 @@ +require 'rbconfig' + +require 'parallel' + +require 'parallel_cucumber/cli' +require 'parallel_cucumber/feature_grouper' +require 'parallel_cucumber/result_formatter' +require 'parallel_cucumber/runner' +require 'parallel_cucumber/version' + +module ParallelCucumber + WINDOWS = (RbConfig::CONFIG['host_os'] =~ /cygwin|mswin|mingw|bccwin|wince|emx/) + + class << self + def run_tests_in_parallel(options) + number_of_processes = options[:n] || 1 + test_results = nil + + report_time_taken do + groups = FeatureGrouper.feature_groups(options, number_of_processes) + threads = groups.size + completed = [] + + on_finish = lambda do |_item, index, _result| + completed.push(index) + remaining_threads = (0..threads - 1).to_a - completed + puts "Remain #{remaining_threads.count} threads: #{remaining_threads.join(', ')}" + end + + test_results = Parallel.map_with_index( + groups, + in_threads: threads, + finish: on_finish + ) do |group, index| + Runner.run_tests(group, index, options) + end + puts 'All threads are complete' + ResultFormatter.report_results(test_results) + end + exit(1) if any_test_failed?(test_results) + end + + def any_test_failed?(test_results) + test_results.any? { |result| result[:exit_status] != 0 } + end + + def report_time_taken + start = Time.now + yield + time_in_sec = Time.now - start + mm, ss = time_in_sec.divmod(60) + puts "\nTook #{mm} Minutes, #{ss.round(2)} Seconds" + end + end # self +end # ParallelCucumber diff --git a/lib/parallel_cucumber/cli.rb b/lib/parallel_cucumber/cli.rb new file mode 100644 index 0000000..c1a4b76 --- /dev/null +++ b/lib/parallel_cucumber/cli.rb @@ -0,0 +1,46 @@ +require 'optparse' + +module ParallelCucumber + module Cli + class << self + def run(argv) + options = parse_options!(argv) + + ParallelCucumber.run_tests_in_parallel(options) + end + + private + + def parse_options!(argv) + options = {} + option_parser = OptionParser.new do |opts| + opts.banner = [ + 'Usage: parallel_cucumber [options] [ [FILE|DIR|URL][:LINE[:LINE]*] ]', + 'Example: parallel_cucumber ... ' + ].join("\n") + opts.on('-h', '--help', 'Show this') do + puts opts + exit 0 + end + opts.on('-v', '--version', 'Show version') do + puts ParallelCucumber::VERSION + exit 0 + end + opts.on('-o', '--cucumber_options "[OPTIONS]"', 'Run cucumber with these options') do |cucumber_options| + options[:cucumber_options] = cucumber_options + end + opts.on('-n [PROCESSES]', Integer, 'How many processes to use') { |n| options[:n] = n } + end + + option_parser.parse!(argv) + options[:cucumber_args] = argv + + options + rescue OptionParser::InvalidOption => e + puts "Unknown option #{e}" + puts option_parser.help + exit 1 + end + end # self + end # Cli +end # ParallelCucumber diff --git a/lib/parallel_cucumber/feature_grouper.rb b/lib/parallel_cucumber/feature_grouper.rb new file mode 100644 index 0000000..f60136b --- /dev/null +++ b/lib/parallel_cucumber/feature_grouper.rb @@ -0,0 +1,67 @@ +require 'json' + +module ParallelCucumber + class FeatureGrouper + class << self + def feature_groups(options, group_size) + scenario_groups(group_size, options) + end + + def scenario_groups(group_size, options) + dry_run_report = generate_dry_run_report(options) + distribution_data = begin + JSON.parse(dry_run_report) + rescue JSON::ParserError + raise("Can't parse JSON from dry run:\n\t#{dry_run_report}") + end + all_runnable_scenarios = distribution_data.map do |feature| + next if feature['elements'].nil? + feature['elements'].map do |scenario| + if scenario['keyword'] == 'Scenario' + "#{feature['uri']}:#{scenario['line']}" + elsif scenario['keyword'] == 'Scenario Outline' + if scenario['examples'] + scenario['examples'].map do |example| + example['rows'].drop(1).map do |row| # Drop the first row with column names + "#{feature['uri']}:#{row['line']}" + end + end + else + "#{feature['uri']}:#{scenario['line']}" # Cope with --expand + end + end + end + end.flatten.compact + group_creator(group_size, all_runnable_scenarios) + end + + def generate_dry_run_report(options) + cmd = "cucumber #{options[:cucumber_options]} --dry-run --format json #{options[:cucumber_args].join(' ')}" + result = `#{cmd} 2>/dev/null` + exit_status = $?.exitstatus + if exit_status != 0 || result.empty? + cmd = "bundle exec #{cmd}" if ENV['BUNDLE_BIN_PATH'] + fail("Can't generate dry run report, command exited with #{exit_status}:\n\t#{cmd}") + end + result + end + + def group_creator(group_size, items) + items_per_group = items.size / group_size + groups = Array.new(group_size) { [] } + if items_per_group > 0 + groups.each do |group| + group.push(*items[0..items_per_group - 1]) + items = items.drop(items_per_group) + end + end + unless items.empty? + items.each_with_index do |item, index| + groups[index] << item + end + end + groups.reject(&:empty?) + end + end # self + end # FeatureGrouper +end # ParallelCucumber diff --git a/lib/parallel_cucumber/result_formatter.rb b/lib/parallel_cucumber/result_formatter.rb new file mode 100644 index 0000000..72c575d --- /dev/null +++ b/lib/parallel_cucumber/result_formatter.rb @@ -0,0 +1,72 @@ +module ParallelCucumber + class ResultFormatter + class << self + def report_results(test_results) + results = find_results(test_results.map { |result| result[:stdout] }.join('')) + puts '' + puts summarize_results(results) + end + + def find_results(test_output) + test_output.split("\n").map do |line| + line.gsub!(/\e\[\d+m/, '') + next unless line_is_result?(line) + line + end.compact + end + + def line_is_result?(line) + line =~ scenario_or_step_result_regex || line =~ failing_scenario_regex + end + + def summarize_results(results) + output = ["\n\n************ FINAL SUMMARY ************"] + + failing_scenarios = results.grep(failing_scenario_regex) + if failing_scenarios.any? + failing_scenarios.unshift('Failing Scenarios:') + output << failing_scenarios.join("\n") + end + + output << summary(results) + + output.join("\n\n") + end + + def summary(results) + sort_order = %w(scenario step failed undefined skipped pending passed) + + %w(scenario step).map do |group| + group_results = results.grep(/^\d+ #{group}/) + next if group_results.empty? + + sums = sum_up_results(group_results) + sums = sums.sort_by { |word, _| sort_order.index(word) || 999 } + sums.map! do |word, number| + plural = 's' if word == group && number != 1 + "#{number} #{word}#{plural}" + end + "#{sums[0]} (#{sums[1..-1].join(', ')})" + end.compact.join("\n") + end + + def sum_up_results(results) + results = results.join(' ').gsub(/s\b/, '') # combine and singularize results + counts = results.scan(/(\d+) (\w+)/) + counts.each_with_object(Hash.new(0)) do |(number, word), sum| + sum[word] += number.to_i + end + end + + private + + def scenario_or_step_result_regex + /^\d+ (steps?|scenarios?)/ + end + + def failing_scenario_regex + /^cucumber .*features\/.+:\d+/ + end + end # self + end # ResultFormatter +end # ParallelCucumber diff --git a/lib/parallel_cucumber/runner.rb b/lib/parallel_cucumber/runner.rb new file mode 100644 index 0000000..72fa3e0 --- /dev/null +++ b/lib/parallel_cucumber/runner.rb @@ -0,0 +1,50 @@ +require 'fileutils' +require 'find' + +module ParallelCucumber + module Runner + class << self + def execute_command_for_process(process_number, cmd) + output = open("|#{cmd} 2>&1", 'r') { |stdout| show_output(stdout, process_number) } + exit_status = $?.exitstatus + + puts "\n****** PROCESS #{process_number} COMPLETED ******\n\n" + { stdout: output, exit_status: exit_status } + end + + def show_output(stream, process_number) + result = '' + begin + loop do + begin + read = stream.readline + $stdout.print "#{process_number}> #{read}" + $stdout.flush + result << read + end + end + rescue EOFError + end + result + end + + def run_tests(test_files, process_number, options) + cmd = command_for_test(process_number, "#{options[:cucumber_options]}", test_files) + $stdout.print("#{process_number}> Command: #{cmd}\n") + $stdout.flush + execute_command_for_process(process_number, cmd) + end + + def command_for_test(process_number, cucumber_options, cucumber_args) + cmd = ['cucumber', cucumber_options, *cucumber_args].compact * ' ' + env = { + AUTOTEST: 1, + TEST_PROCESS_NUMBER: process_number + } + separator = (WINDOWS ? ' & ' : ';') + exports = env.map { |k, v| WINDOWS ? "(SET \"#{k}=#{v}\")" : "#{k}=#{v};export #{k}" }.join(separator) + "#{exports}#{separator} #{cmd}" + end + end # self + end # Runner +end # ParallelCucumber diff --git a/lib/parallel_cucumber/version.rb b/lib/parallel_cucumber/version.rb new file mode 100644 index 0000000..d0f828c --- /dev/null +++ b/lib/parallel_cucumber/version.rb @@ -0,0 +1,3 @@ +module ParallelCucumber + VERSION = '0.1.0' +end # ParallelCucumber diff --git a/parallel_cucumber.gemspec b/parallel_cucumber.gemspec new file mode 100644 index 0000000..63be969 --- /dev/null +++ b/parallel_cucumber.gemspec @@ -0,0 +1,19 @@ +name = 'parallel_cucumber' +require "./lib/#{name}/version" + +Gem::Specification.new name, ParallelCucumber::VERSION do |spec| + spec.name = 'parallel_cucumber' + spec.authors = 'Alexander Bayandin' + spec.email = 'a.bayandin@gmail.com' + spec.summary = 'Run cucumber in parallel' + spec.homepage = 'https://github.com/bayandin/parallel_cucumber' + spec.license = 'MIT' + + spec.files = Dir['{lib}/**/*.rb', 'bin/*', 'README.md'] + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.require_paths = 'lib' + + spec.add_runtime_dependency 'parallel', '~> 1.6' + spec.add_development_dependency 'bundler', '~> 1.10' + spec.add_development_dependency 'cucumber' +end