Skip to content

Instantly share code, notes, and snippets.

@satooshi
Created August 12, 2019 12:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save satooshi/ea154c0bed2dd6504519ddd904451222 to your computer and use it in GitHub Desktop.
Save satooshi/ea154c0bed2dd6504519ddd904451222 to your computer and use it in GitHub Desktop.
Run rspec in parallel
#!/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