Created
August 12, 2019 12:29
-
-
Save satooshi/ea154c0bed2dd6504519ddd904451222 to your computer and use it in GitHub Desktop.
Run rspec in parallel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
require 'concurrent' | |
require 'coverage' | |
require 'parallel' | |
require 'rspec/core' | |
# serializable notifications inter processes | |
class FailedExampleNotification | |
attr_reader :exception, :description, :message_lines, :colorized_message_lines, | |
:formatted_backtrace, :colorized_formatted_backtrace, :fully_formatted, :fully_formatted_lines | |
def initialize(example) | |
execution_result = example.execution_result | |
if execution_result.status == :failed | |
colorizer=::RSpec::Core::Formatters::ConsoleCodes | |
exception_presenter = RSpec::Core::Formatters::ExceptionPresenter::Factory.new(example).build | |
@exception = exception_presenter.exception | |
@description = exception_presenter.description | |
@message_lines = exception_presenter.message_lines | |
@colorized_message_lines = exception_presenter.colorized_message_lines(colorizer) | |
@formatted_backtrace = exception_presenter.formatted_backtrace | |
@colorized_formatted_backtrace = exception_presenter.colorized_formatted_backtrace(colorizer) | |
# TODO: Fix formatter not to have an example instance. | |
failure_number = 1 | |
@fully_formatted = exception_presenter.fully_formatted(failure_number, colorizer) | |
@fully_formatted_lines = exception_presenter.fully_formatted_lines(failure_number, colorizer) | |
end | |
end | |
end | |
class SkippedExampleNotification | |
def initialize(example) | |
colorizer=::RSpec::Core::Formatters::ConsoleCodes | |
@formatted_caller = RSpec.configuration.backtrace_formatter.backtrace_line(example.location) | |
@full_description = example.full_description | |
@pending_detail = ::RSpec::Core::Formatters::ExceptionPresenter::PENDING_DETAIL_FORMATTER.call(example, colorizer) | |
end | |
def fully_formatted(pending_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) | |
[ | |
colorizer.wrap("\n #{pending_number}) #{@full_description}", :pending), | |
"\n ", | |
@pending_detail, | |
"\n", | |
colorizer.wrap(" # #{@formatted_caller}\n", :detail) | |
].join("") | |
end | |
end | |
class ExamplesNotification | |
attr_reader :failure_notifications, :pending_notifications | |
def initialize(failure_notifications:, pending_notifications:) | |
@failure_notifications = failure_notifications | |
@pending_notifications = pending_notifications | |
end | |
def fully_formatted_failed_examples(colorizer=::RSpec::Core::Formatters::ConsoleCodes) | |
formatted = "\nFailures:\n" | |
failure_notifications.each_with_index do |failure, index| | |
# TODO: Fix formatter not to have an example instance. | |
# formatted += failure.fully_formatted(index.next, colorizer) | |
formatted += failure.fully_formatted | |
end | |
formatted | |
end | |
def fully_formatted_pending_examples(colorizer=::RSpec::Core::Formatters::ConsoleCodes) | |
formatted = "\nPending: (Failures listed here are expected and do not affect your suite's status)\n".dup | |
pending_notifications.each_with_index do |notification, index| | |
formatted << notification.fully_formatted(index.next, colorizer) | |
end | |
formatted | |
end | |
end | |
class SummaryNotification | |
attr_reader :duration, :load_time, :errors_outside_of_examples_count, :example_count, :failure_count, :pending_count | |
def initialize(duration:, load_time:, errors_outside_of_examples_count:, example_count:, failure_count:, pending_count:) | |
@duration = duration | |
@load_time = load_time | |
@errors_outside_of_examples_count = errors_outside_of_examples_count | |
@example_count = example_count | |
@failure_count = failure_count | |
@pending_count = pending_count | |
end | |
def totals_line | |
summary = RSpec::Core::Formatters::Helpers.pluralize(example_count, "example") + | |
", " + RSpec::Core::Formatters::Helpers.pluralize(failure_count, "failure") | |
summary += ", #{pending_count} pending" if pending_count > 0 | |
if errors_outside_of_examples_count > 0 | |
summary += ( | |
", " + | |
RSpec::Core::Formatters::Helpers.pluralize(errors_outside_of_examples_count, "error") + | |
" occurred outside of examples" | |
) | |
end | |
summary | |
end | |
def colorized_totals_line(colorizer=::RSpec::Core::Formatters::ConsoleCodes) | |
if failure_count > 0 || errors_outside_of_examples_count > 0 | |
colorizer.wrap(totals_line, RSpec.configuration.failure_color) | |
elsif pending_count > 0 | |
colorizer.wrap(totals_line, RSpec.configuration.pending_color) | |
else | |
colorizer.wrap(totals_line, RSpec.configuration.success_color) | |
end | |
end | |
def formatted_duration | |
::RSpec::Core::Formatters::Helpers.format_duration(duration) | |
end | |
def formatted_load_time | |
::RSpec::Core::Formatters::Helpers.format_duration(load_time) | |
end | |
def fully_formatted(colorizer=::RSpec::Core::Formatters::ConsoleCodes) | |
"\nFinished in #{formatted_duration} " \ | |
"(files took #{formatted_load_time} to load)\n" \ | |
"#{colorized_totals_line(colorizer)}\n" | |
end | |
end | |
class CoverageNotification | |
def initialize(suite_results) | |
@suite_results = suite_results | |
end | |
def fully_formatted | |
"#{hit_lines} / #{passed_lines} LOC (%.2f%%) covered." % total_coverage | |
end | |
def coverage | |
@coverage ||= per_source.map { |file, covered_lines| | |
# nil means an empty line or a comment line | |
source_line_length = covered_lines.reject(&:nil?).length | |
hit_lines = covered_lines.reject(&:nil?).count { |hit_count| hit_count != 0 } | |
percentage = source_line_length == 0 ? 0 : hit_lines * 100 / source_line_length | |
[ file, { source_line_length: source_line_length, hit_lines: hit_lines, percentage: percentage } ] | |
}.to_h | |
end | |
def passed_lines | |
@passed_lines ||= coverage.values.inject(0) { |sum, c| sum + c[:source_line_length]} | |
end | |
def hit_lines | |
@hit_lines ||= coverage.values.inject(0) { |sum, c| sum + c[:hit_lines] } | |
end | |
def total_coverage | |
passed_lines == 0 ? 0 : hit_lines.to_f * 100 / passed_lines.to_f | |
end | |
private | |
def per_source | |
results_per_source ||= {} | |
@suite_results.each do |result| | |
result[:coverage].each do |file, covered_lines| | |
lines = | |
if covered_lines.is_a?(Hash) | |
if covered_lines[:oneshot_lines] | |
stub = Coverage.line_stub(file) | |
covered_lines[:oneshot_lines].each do |line_num| | |
stub[line_num -1] = 1 | |
end | |
stub | |
elsif covered_lines[:lines] | |
covered_lines[:lines] | |
else | |
[] | |
end | |
else | |
covered_lines | |
end | |
results_per_source[file] ||= Array.new(lines.length) | |
lines.each_with_index do |hit_count, index| | |
if results_per_source[file][index].nil? | |
results_per_source[file][index] = hit_count | |
elsif !hit_count.nil? | |
results_per_source[file][index] += hit_count | |
end | |
end | |
end | |
end | |
results_per_source | |
end | |
end | |
class CoverageFormatter | |
LIGHT_GREEN = "\033[0;92;49m" | |
LIGHT_YELLOW = "\033[0;93;49m" | |
LIGHT_RED = "\033[0;91;49m" | |
COLOR_END = "\033[0m" | |
def initialize(notification) | |
@notification = notification | |
end | |
def format | |
output = [] | |
@notification.coverage.sort_by { |file, c| file }.each do |file, covered_data| | |
# output: | |
# ' 0.0% /app/app_services/approval_flow_service.rb' | |
line = [] | |
line << coverage(covered_data[:percentage]) | |
line << ' ' | |
line << shortened_filename(file) | |
output << line.join('') | |
end | |
output | |
end | |
private | |
def coverage(percentage) | |
output = [] | |
output << coverage_color(percentage.to_i) | |
output << percentage.round(1).to_s.rjust(5) | |
output << '%' | |
output << COLOR_END | |
output.join('') | |
end | |
def coverage_color(percentage) | |
if percentage > 90 | |
LIGHT_GREEN | |
elsif percentage > 80 | |
LIGHT_YELLOW | |
else | |
LIGHT_RED | |
end | |
end | |
def shortened_filename(filename) | |
if filename.start_with?(ENV['APP_DIR']) | |
filename[ENV['APP_DIR'].length..-1] | |
else | |
filename | |
end | |
end | |
end | |
class Timer | |
attr_reader :duration, :load_time | |
def initialize(configuration, time=RSpec::Core::Time.now) | |
@duration = nil | |
@start = time | |
@load_time = (@start - configuration.start_time).to_f | |
end | |
def stop | |
@duration = (RSpec::Core::Time.now - @start).to_f if @start | |
end | |
end | |
class QueueRunner < RSpec::Core::Runner | |
def queue_reporter | |
formatter_loader = RSpec::Core::Formatters::Loader.new(RSpec::Core::Reporter.new(@configuration)) | |
output_wrapper = RSpec::Core::OutputWrapper.new(@configuration.output_stream) | |
formatter_loader.prepare_default(output_wrapper, @configuration.deprecation_stream) | |
formatter_loader.reporter | |
end | |
def possible_source_locations(described_class) | |
possible_locations = methods(described_class).map(&:source_location).compact.map { |h| h[0] }.uniq.compact.sort | |
exclude_paths = (Gem.path + Gem.default_path + [Gem.default_dir]).uniq | |
include_paths = [ENV['APP_DIR']] | |
possible_locations.map { |location| | |
unless exclude_paths.map { |exclude_path| location.include?(exclude_path) }.any? | |
location if include_paths.map { |include_path| location.include?(include_path) }.all? | |
end | |
}.compact | |
end | |
def methods(klass) | |
return [] if klass.nil? | |
klass.methods.map { |m| klass.method(m) } + klass.instance_methods.map { |m| klass.instance_method(m) } | |
end | |
def when_coverage_enabled | |
if RUBY_VERSION >= '2.6.0' | |
yield | |
end | |
end | |
def run_specs(example_groups) | |
timer = Timer.new(@configuration) | |
examples_count = @world.example_count(example_groups) | |
success = @configuration.with_suite_hooks do | |
if examples_count == 0 && @configuration.fail_if_no_examples | |
return @configuration.failure_exit_code | |
end | |
results = Parallel.map(example_groups, in_process: num_workers) { |example_group| | |
when_coverage_enabled { Coverage.result(stop: false, clear: true) } | |
in_queue_reporter = queue_reporter | |
status = example_group.run(in_queue_reporter) | |
result = { | |
status: status, | |
described_class: example_group.described_class&.name, | |
non_example_exception_count: in_queue_reporter.instance_variable_get(:@non_example_exception_count), | |
examples: in_queue_reporter.examples.map { |example| | |
{ | |
execution_result: example.execution_result, | |
example_group: example.example_group, | |
description: example.description, | |
} | |
}, | |
failure_notifications: in_queue_reporter.failed_examples.map { |example| FailedExampleNotification.new(example) }, | |
pending_notifications: in_queue_reporter.pending_examples.map { |example| SkippedExampleNotification.new(example) }, | |
} | |
when_coverage_enabled { | |
coverage_result = Coverage.result(stop: false, clear: true) | |
source_locations = possible_source_locations(example_group.described_class) | |
result[:coverage] = coverage_result.select { |file, covered| source_locations.include?(file) } | |
} | |
result | |
} | |
timer.stop | |
non_example_exception_count = results.map { |result| result[:non_example_exception_count] }.sum | |
examples = results.map { |result| result[:examples] }.flatten | |
failure_notifications = results.map { |result| result[:failure_notifications] }.flatten | |
pending_notifications = results.map { |result| result[:pending_notifications] }.flatten | |
notification = ExamplesNotification.new( | |
failure_notifications: failure_notifications, | |
pending_notifications: pending_notifications, | |
) | |
puts notification.fully_formatted_failed_examples if failure_notifications.length != 0 | |
puts notification.fully_formatted_pending_examples if pending_notifications.length != 0 | |
puts '' | |
summary = SummaryNotification.new( | |
duration: timer.duration, | |
load_time: timer.load_time, | |
errors_outside_of_examples_count: non_example_exception_count, | |
example_count: examples.length, | |
failure_count: failure_notifications.length, | |
pending_count: pending_notifications.length, | |
) | |
puts summary.fully_formatted | |
when_coverage_enabled { | |
coverage_notification = CoverageNotification.new(results) | |
coverage_formatter = CoverageFormatter.new(coverage_notification) | |
puts coverage_formatter.format | |
puts coverage_notification.fully_formatted | |
} | |
results.map { |result| result[:status] }.all? | |
end && !@world.non_example_failure | |
success ? 0 : @configuration.failure_exit_code | |
end | |
# @todo Enable to configure number of worker processes | |
def num_workers | |
Concurrent.processor_count | |
end | |
end | |
QueueRunner.invoke |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment