Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/parallel_matrix_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@
require_relative 'parallel_matrix_formatter/output/suppressor'
require_relative 'parallel_matrix_formatter/rendering/ansi_color'
require_relative 'parallel_matrix_formatter/rendering/update_renderer'
require_relative 'parallel_matrix_formatter/failed_example_collector'
require_relative 'parallel_matrix_formatter/summary_data_builder'
require_relative 'parallel_matrix_formatter/formatter_initializer'
require_relative 'parallel_matrix_formatter/formatter_notifier'
require_relative 'parallel_matrix_formatter/formatter_dump_methods'
require_relative 'parallel_matrix_formatter/process_tracker'
require_relative 'parallel_matrix_formatter/summary_collector'
require_relative 'parallel_matrix_formatter/consolidated_summary_renderer'
require_relative 'parallel_matrix_formatter/summary_waiter'
require_relative 'parallel_matrix_formatter/orchestrator_message_handler'
require_relative 'parallel_matrix_formatter/buffered_message_processor'
require_relative 'parallel_matrix_formatter/orchestrator_initializer'
require_relative 'parallel_matrix_formatter/blank_orchestrator'
require_relative 'parallel_matrix_formatter/orchestrator'

require_relative 'parallel_matrix_formatter/formatter'
Expand Down
13 changes: 13 additions & 0 deletions lib/parallel_matrix_formatter/blank_orchestrator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module ParallelMatrixFormatter
# The BlankOrchestrator is a no-op orchestrator used when the current process
# is not the primary process (i.e., `test_env_number` is not 1).
class BlankOrchestrator
def initialize(*); end
def puts(*); end
def start(*); end
def close(*); end
def all_processes_complete?; true; end
end
end
26 changes: 26 additions & 0 deletions lib/parallel_matrix_formatter/buffered_message_processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module ParallelMatrixFormatter
# Processes buffered messages for the Orchestrator
class BufferedMessageProcessor
def initialize(output)
@output = output
@buffered_messages = []
end

def buffer_message(message)
@buffered_messages << message
end

def process_if_complete(process_tracker)
return unless process_tracker.all_processes_complete?

@buffered_messages.each { |msg| @output.puts(msg) }
@buffered_messages.clear
end

def buffered_messages
@buffered_messages
end
end
end
96 changes: 96 additions & 0 deletions lib/parallel_matrix_formatter/consolidated_summary_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

module ParallelMatrixFormatter
# Renders consolidated summary from all parallel test processes
class ConsolidatedSummaryRenderer
def initialize(output, start_time)
@output = output
@start_time = start_time
end

def render(process_summaries)
totals = calculate_totals(process_summaries)

@output.puts "\n"
render_failures(totals[:failed_examples])
render_summary_line(totals)
render_timing(totals[:total_process_time])
end

private

def calculate_totals(process_summaries)
{
total_examples: sum_total_examples(process_summaries),
failed_examples: collect_failed_examples(process_summaries),
total_pending: sum_pending_count(process_summaries),
total_process_time: sum_duration(process_summaries)
}
end

def sum_total_examples(process_summaries)
process_summaries.values.sum { |summary| summary['total_examples'] }
end

def collect_failed_examples(process_summaries)
process_summaries.values.flat_map { |summary| summary['failed_examples'] }
end

def sum_pending_count(process_summaries)
process_summaries.values.sum { |summary| summary['pending_count'] }
end

def sum_duration(process_summaries)
process_summaries.values.sum { |summary| summary['duration'] }
end

def render_failures(failed_examples)
return if failed_examples.empty?

@output.puts "Failures:"
@output.puts
render_individual_failures(failed_examples)
end

def render_individual_failures(failed_examples)
failed_examples.each_with_index do |failure, index|
render_failure_details(failure, index + 1)
end
end

def render_failure_details(failure, index)
@output.puts " #{index}) #{failure['description']}"
@output.puts " #{failure['location']}" if failure['location']
@output.puts " #{failure['message']}" if failure['message']
@output.puts " #{failure['formatted_backtrace']}" if failure['formatted_backtrace']
@output.puts
end

def render_summary_line(totals)
failure_count = totals[:failed_examples].length
summary_line = format_summary_line(totals[:total_examples], failure_count, totals[:total_pending])
@output.puts summary_line
end

def render_timing(total_process_time)
wall_clock_time = Time.now - @start_time
timing_line = "Finished in #{format_duration(wall_clock_time)} (files took #{format_duration(total_process_time)} to load)"
@output.puts timing_line
end

def format_summary_line(total, failures, pending)
parts = ["#{total} example#{'s' if total != 1}"]
parts << "#{failures} failure#{'s' if failures != 1}" if failures > 0
parts << "#{pending} pending" if pending > 0
parts.join(', ')
end

def format_duration(seconds)
return "#{seconds.round(2)} seconds" if seconds < 60

minutes = (seconds / 60).floor
remaining_seconds = seconds % 60
"#{minutes} minute#{'s' if minutes != 1} #{remaining_seconds.round(2)} seconds"
end
end
end
53 changes: 53 additions & 0 deletions lib/parallel_matrix_formatter/failed_example_collector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module ParallelMatrixFormatter
# Collects and formats failed example details from test notifications
class FailedExampleCollector
def initialize
@failed_examples = []
end

def collect(notification)
failed_example = build_failed_example(notification)
@failed_examples << failed_example
end

def failed_examples
@failed_examples
end

private

def build_failed_example(notification)
{
description: extract_description(notification),
location: extract_location(notification),
message: extract_message(notification),
formatted_backtrace: extract_backtrace(notification)
}
end

def extract_description(notification)
notification.respond_to?(:description) ? notification.description : 'Unknown example'
end

def extract_location(notification)
return 'Unknown location' unless notification.respond_to?(:example)
return 'Unknown location' unless notification.example.respond_to?(:location)

notification.example.location
end

def extract_message(notification)
return 'No message' unless notification.respond_to?(:message_lines)

notification.message_lines.join("\n")
end

def extract_backtrace(notification)
return 'No backtrace' unless notification.respond_to?(:formatted_backtrace)

notification.formatted_backtrace.join("\n")
end
end
end
108 changes: 52 additions & 56 deletions lib/parallel_matrix_formatter/formatter.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# frozen_string_literal: true

require 'rspec/core/formatters/base_formatter'
require_relative 'output/suppressor'
require_relative 'rendering/update_renderer'
require_relative 'ipc/client'
require_relative 'formatter_initializer'
require_relative 'failed_example_collector'
require_relative 'summary_data_builder'
require_relative 'formatter_dump_methods'

module ParallelMatrixFormatter
# The Formatter class is the main RSpec formatter for the ParallelMatrixFormatter gem.
Expand All @@ -13,30 +14,21 @@ module ParallelMatrixFormatter
# utilizes the `UpdateRenderer` for displaying real-time progress and status.
class Formatter < RSpec::Core::Formatters::BaseFormatter
def initialize(output, test_env_number = ENV['TEST_ENV_NUMBER'], config = ParallelMatrixFormatter::Config.new)
# Suppress output immediately to prevent race condition leakage
output_suppressor = ParallelMatrixFormatter::Output::Suppressor.new(config.output_suppressor)
output_suppressor.suppress
output_suppressor.notify(output)
initializer = FormatterInitializer.new(output, test_env_number, config)
initializer.initialize_formatter

@test_env_number = (test_env_number && !test_env_number.empty? ? test_env_number : '1').to_i
renderer = ParallelMatrixFormatter::Rendering::UpdateRenderer.new(@test_env_number, config.update_renderer)
total_processes = Object.const_defined?('ParallelSplitTest') ? ParallelSplitTest.processes : 1 # TODO: handle this better
@orchestrator = Orchestrator.build(total_processes, @test_env_number, output, renderer)

@total_examples = 0
@current_example = 0

# IPC client will be created in start() method to ensure server is ready
@ipc = nil
@test_env_number = initializer.test_env_number
@orchestrator = initializer.orchestrator
@notifier = FormatterNotifier.new(@test_env_number)
@dump_methods = FormatterDumpMethods.new(@orchestrator)

initialize_state
end

def start(start_notification)
@start_time = Time.now
@orchestrator.start

# Create IPC client after orchestrator is started to ensure server is ready
# Use faster retry parameters for better synchronization
@ipc = ParallelMatrixFormatter::Ipc::Client.new(retries: 30, delay: 0.1)

@notifier.initialize_ipc
@total_examples = start_notification.count
end

Expand All @@ -45,62 +37,66 @@ def example_started(notification)
end

def example_passed(notification)
return unless @ipc

@ipc.notify(
@test_env_number,
{
status: :passed,
progress: @total_examples.zero? ? 0.0 : @current_example.to_f / @total_examples
}
)
@notifier.notify_status(:passed, calculate_progress)
end

def example_failed(notification)
return unless @ipc

@ipc.notify(
@test_env_number,
{
status: :failed,
progress: @total_examples.zero? ? 0.0 : @current_example.to_f / @total_examples
}
)
@failed_example_collector.collect(notification)
@notifier.notify_status(:failed, calculate_progress)
end

def example_pending(notification)
return unless @ipc

@ipc.notify(
@test_env_number,
{
status: :pending,
progress: @total_examples.zero? ? 0.0 : @current_example.to_f / @total_examples
}
)
@pending_count += 1
@notifier.notify_status(:pending, calculate_progress)
end

def dump_summary(_summary_notification)
@orchestrator.puts("\ndump_summary")
def dump_summary(summary_notification)
duration = calculate_duration(summary_notification)
summary_data = build_summary_data(summary_notification, duration)
@notifier.notify_summary(summary_data)
end

def dump_failures(_failures_notification)
@orchestrator.puts("\ndump_failures")
@dump_methods.dump_failures
end

def dump_pending(_pending_notification)
@orchestrator.puts("\ndump_pending")
@dump_methods.dump_pending
end

def dump_profile(_profile_notification)
@orchestrator.puts("\ndump_profile")
@dump_methods.dump_profile
end

def stop(_stop_notification)
end
def stop(_stop_notification); end

def close(_close_notification)
@orchestrator.close
end

private

def initialize_state
@total_examples = 0
@current_example = 0
@failed_example_collector = FailedExampleCollector.new
@pending_count = 0
@start_time = nil
end

def calculate_progress
@total_examples.zero? ? 0.0 : @current_example.to_f / @total_examples
end

def calculate_duration(summary_notification)
return Time.now - @start_time if @start_time
return summary_notification.duration if summary_notification.respond_to?(:duration)
0.0
end

def build_summary_data(summary_notification, duration)
builder = SummaryDataBuilder.new(@test_env_number)
builder.build(summary_notification, @failed_example_collector.failed_examples, @pending_count, duration)
end
end
end
22 changes: 22 additions & 0 deletions lib/parallel_matrix_formatter/formatter_dump_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module ParallelMatrixFormatter
# Handles dump methods for the Formatter
class FormatterDumpMethods
def initialize(orchestrator)
@orchestrator = orchestrator
end

def dump_failures
@orchestrator.puts("\ndump_failures")
end

def dump_pending
@orchestrator.puts("\ndump_pending")
end

def dump_profile
@orchestrator.puts("\ndump_profile")
end
end
end
Loading