diff --git a/lib/parallel_matrix_formatter.rb b/lib/parallel_matrix_formatter.rb index 9342131..712ab27 100644 --- a/lib/parallel_matrix_formatter.rb +++ b/lib/parallel_matrix_formatter.rb @@ -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' diff --git a/lib/parallel_matrix_formatter/blank_orchestrator.rb b/lib/parallel_matrix_formatter/blank_orchestrator.rb new file mode 100644 index 0000000..2b52cbb --- /dev/null +++ b/lib/parallel_matrix_formatter/blank_orchestrator.rb @@ -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 \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/buffered_message_processor.rb b/lib/parallel_matrix_formatter/buffered_message_processor.rb new file mode 100644 index 0000000..282754d --- /dev/null +++ b/lib/parallel_matrix_formatter/buffered_message_processor.rb @@ -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 \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/consolidated_summary_renderer.rb b/lib/parallel_matrix_formatter/consolidated_summary_renderer.rb new file mode 100644 index 0000000..0c7aa46 --- /dev/null +++ b/lib/parallel_matrix_formatter/consolidated_summary_renderer.rb @@ -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 \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/failed_example_collector.rb b/lib/parallel_matrix_formatter/failed_example_collector.rb new file mode 100644 index 0000000..11fab25 --- /dev/null +++ b/lib/parallel_matrix_formatter/failed_example_collector.rb @@ -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 \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/formatter.rb b/lib/parallel_matrix_formatter/formatter.rb index e12e496..db502d5 100644 --- a/lib/parallel_matrix_formatter/formatter.rb +++ b/lib/parallel_matrix_formatter/formatter.rb @@ -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. @@ -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 @@ -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 diff --git a/lib/parallel_matrix_formatter/formatter_dump_methods.rb b/lib/parallel_matrix_formatter/formatter_dump_methods.rb new file mode 100644 index 0000000..19b6fea --- /dev/null +++ b/lib/parallel_matrix_formatter/formatter_dump_methods.rb @@ -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 \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/formatter_initializer.rb b/lib/parallel_matrix_formatter/formatter_initializer.rb new file mode 100644 index 0000000..7e1de23 --- /dev/null +++ b/lib/parallel_matrix_formatter/formatter_initializer.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Handles complex initialization logic for the Formatter + class FormatterInitializer + def initialize(output, test_env_number, config) + @output = output + @test_env_number = normalize_test_env_number(test_env_number) + @config = config + end + + def initialize_formatter + suppress_output + create_orchestrator + initialize_state + end + + def test_env_number + @test_env_number + end + + def orchestrator + @orchestrator + end + + private + + def normalize_test_env_number(test_env_number) + return 1 if test_env_number.nil? || test_env_number.empty? + + test_env_number.to_i + end + + def suppress_output + output_suppressor = create_output_suppressor + output_suppressor.suppress + output_suppressor.notify(@output) + end + + def create_output_suppressor + ParallelMatrixFormatter::Output::Suppressor.new(@config.output_suppressor) + end + + def create_orchestrator + renderer = create_renderer + total_processes = get_total_processes + @orchestrator = Orchestrator.build(total_processes, @test_env_number, @output, renderer) + end + + def create_renderer + ParallelMatrixFormatter::Rendering::UpdateRenderer.new(@test_env_number, @config.update_renderer) + end + + def get_total_processes + Object.const_defined?('ParallelSplitTest') ? ParallelSplitTest.processes : 1 + end + + def initialize_state + { + total_examples: 0, + current_example: 0, + failed_example_collector: FailedExampleCollector.new, + pending_count: 0, + start_time: nil, + ipc: nil + } + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/formatter_notifier.rb b/lib/parallel_matrix_formatter/formatter_notifier.rb new file mode 100644 index 0000000..706f64f --- /dev/null +++ b/lib/parallel_matrix_formatter/formatter_notifier.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Handles IPC notifications for the Formatter + class FormatterNotifier + def initialize(test_env_number) + @test_env_number = test_env_number + @ipc = nil + end + + def initialize_ipc + @ipc = ParallelMatrixFormatter::Ipc::Client.new(retries: 30, delay: 0.1) + end + + def notify_status(status, progress) + return unless @ipc + + @ipc.notify(@test_env_number, { status: status, progress: progress }) + end + + def notify_summary(summary_data) + return unless @ipc + + @ipc.notify(@test_env_number, { type: :summary, data: summary_data }) + end + + def available? + !@ipc.nil? + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/orchestrator.rb b/lib/parallel_matrix_formatter/orchestrator.rb index 421e476..0b8a1f2 100644 --- a/lib/parallel_matrix_formatter/orchestrator.rb +++ b/lib/parallel_matrix_formatter/orchestrator.rb @@ -1,90 +1,54 @@ -require_relative 'ipc/server' +require_relative 'orchestrator_initializer' +require_relative 'blank_orchestrator' module ParallelMatrixFormatter # The Orchestrator class coordinates communication between different test processes - # and the main RSpec formatter. It acts as an IPC server, receiving updates from - # parallel test processes and using the `UpdateRenderer` to display real-time - # progress and status to the console. + # and the main RSpec formatter. class Orchestrator - # 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). It provides - # a null implementation of the orchestrator interface to avoid unnecessary - # IPC server setup and message processing in secondary processes. - class BlankOrchestrator - def initialize(*); end - def puts(*); end - def start(*); end - def close(*); end - def all_processes_complete?; true; end - end - DUMP_PREFIX = "dump_" def self.build(total_processes, test_env_number, output, renderer) - if test_env_number == 1 - self - else - BlankOrchestrator - end.new(total_processes, test_env_number, output, renderer) + orchestrator_class = test_env_number == 1 ? self : BlankOrchestrator + orchestrator_class.new(total_processes, test_env_number, output, renderer) end attr_reader :total_processes def initialize(total_processes, test_env_number, output, renderer) - @ipc = ParallelMatrixFormatter::Ipc::Server.new @total_processes = total_processes @test_env_number = test_env_number @output = output @renderer = renderer @data = {} - @buffered_messages = [] - @process_completion = {} + setup_components(total_processes, test_env_number, output, renderer) end def puts(message) if message&.include?(DUMP_PREFIX) && @total_processes > 1 - @buffered_messages << message - process_buffered_messages_if_complete + @message_processor.buffer_message(message) + @message_processor.process_if_complete(@process_tracker) else @output.puts(message) end end def all_processes_complete? - return false if @process_completion.empty? - expected_processes = (1..@total_processes).to_a - completed_processes = @process_completion.select { |_, complete| complete }.keys - expected_processes.all? { |process| completed_processes.include?(process) } + @process_tracker.all_processes_complete? end def track_process_completion(process_number, progress) - @process_completion[process_number] = progress >= 1.0 + @process_tracker.track_completion(process_number, progress) end def start Thread.new do @ipc.start do |message| - # Track process completion based on progress - if message && message['process_number'] && message['message'] && message['message']['progress'] - track_process_completion(message['process_number'], message['message']['progress']) - end - - update = @renderer.update(message) - @output.print update #if update - - # Process any buffered messages if all processes are complete - process_buffered_messages_if_complete + @message_handler.handle_message(message) + render_summary_if_ready rescue IOError => e - @output.puts "Error in IPC server: #{e.message}" - @output.puts e.backtrace.join("\n") + @message_handler.handle_io_error(e) rescue StandardError => e - # TODO: Error messages and backtraces from the IPC server thread are being - # printed directly to @output, which is the RSpec output stream. This - # could corrupt the formatter's output, making it unreadable. It would - # be safer to log these errors to $stderr or a dedicated log file to - # keep the main output stream clean. - @output.puts "Unexpected error in IPC server: #{e.message}" - @output.puts e.backtrace.join("\n") + @message_handler.handle_standard_error(e) ensure @output.flush end @@ -92,23 +56,48 @@ def start end def close - # Wait for all processes to complete before closing if in multi-process mode - wait_for_completion if @total_processes > 1 + if @total_processes > 1 + wait_for_completion + wait_for_summaries + end @ipc.close end private - def process_buffered_messages_if_complete - return unless all_processes_complete? - @buffered_messages.each { |msg| @output.puts(msg) } - @buffered_messages.clear + def setup_components(total_processes, test_env_number, output, renderer) + initializer = OrchestratorInitializer.new(total_processes, test_env_number, output, renderer) + components = initializer.initialize_components + assign_components(components) + @message_handler = initializer.create_message_handler(components) + end + + def assign_components(components) + @ipc = components[:ipc] + @message_processor = components[:message_processor] + @process_tracker = components[:process_tracker] + @summary_collector = components[:summary_collector] + @start_time = components[:start_time] end def wait_for_completion - # Wait until all processes have completed - # This will wait indefinitely as per requirements sleep 0.1 until all_processes_complete? end + + def wait_for_summaries + renderer = ConsolidatedSummaryRenderer.new(@output, @start_time) + waiter = SummaryWaiter.new(@summary_collector, @output) + waiter.wait_and_render(renderer) + end + + def render_summary_if_ready + return unless @summary_collector.all_summaries_received? + render_consolidated_summary + end + + def render_consolidated_summary + renderer = ConsolidatedSummaryRenderer.new(@output, @start_time) + renderer.render(@summary_collector.process_summaries) + end end end diff --git a/lib/parallel_matrix_formatter/orchestrator_initializer.rb b/lib/parallel_matrix_formatter/orchestrator_initializer.rb new file mode 100644 index 0000000..dc6f924 --- /dev/null +++ b/lib/parallel_matrix_formatter/orchestrator_initializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Handles initialization for the Orchestrator + class OrchestratorInitializer + def initialize(total_processes, test_env_number, output, renderer) + @total_processes = total_processes + @test_env_number = test_env_number + @output = output + @renderer = renderer + end + + def initialize_components + { + ipc: ParallelMatrixFormatter::Ipc::Server.new, + message_processor: BufferedMessageProcessor.new(@output), + process_tracker: ProcessTracker.new(@total_processes), + summary_collector: SummaryCollector.new(@total_processes), + start_time: Time.now + } + end + + def create_message_handler(components) + OrchestratorMessageHandler.new( + @output, + @renderer, + components[:message_processor], + components[:process_tracker], + components[:summary_collector] + ) + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/orchestrator_message_handler.rb b/lib/parallel_matrix_formatter/orchestrator_message_handler.rb new file mode 100644 index 0000000..2d5b788 --- /dev/null +++ b/lib/parallel_matrix_formatter/orchestrator_message_handler.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Handles message processing for the Orchestrator + class OrchestratorMessageHandler + def initialize(output, renderer, message_processor, process_tracker, summary_collector) + @output = output + @renderer = renderer + @message_processor = message_processor + @process_tracker = process_tracker + @summary_collector = summary_collector + end + + def handle_message(message) + if summary_message?(message) + handle_summary_message(message) + else + handle_regular_message(message) + end + end + + def handle_io_error(error) + @output.puts "Error in IPC server: #{error.message}" + @output.puts error.backtrace.join("\n") + end + + def handle_standard_error(error) + @output.puts "Unexpected error in IPC server: #{error.message}" + @output.puts error.backtrace.join("\n") + end + + private + + def summary_message?(message) + message && message['message'] && message['message']['type'] == 'summary' + end + + def handle_summary_message(message) + @summary_collector.collect(message['process_number'], message['message']['data']) + end + + def handle_regular_message(message) + track_progress(message) + update = @renderer.update(message) + @output.print update + process_buffered_messages_if_complete + end + + def track_progress(message) + return unless message && message['process_number'] && message['message'] && message['message']['progress'] + + @process_tracker.track_completion(message['process_number'], message['message']['progress']) + end + + def process_buffered_messages_if_complete + @message_processor.process_if_complete(@process_tracker) + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/process_tracker.rb b/lib/parallel_matrix_formatter/process_tracker.rb new file mode 100644 index 0000000..1b6639b --- /dev/null +++ b/lib/parallel_matrix_formatter/process_tracker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Tracks completion status of parallel test processes + class ProcessTracker + def initialize(total_processes) + @total_processes = total_processes + @process_completion = {} + end + + def track_completion(process_number, progress) + @process_completion[process_number] = progress >= 1.0 + end + + def all_processes_complete? + return false if @process_completion.empty? + + expected_processes = (1..@total_processes).to_a + completed_processes = completed_process_numbers + expected_processes.all? { |process| completed_processes.include?(process) } + end + + private + + def completed_process_numbers + @process_completion.select { |_, complete| complete }.keys + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/summary_collector.rb b/lib/parallel_matrix_formatter/summary_collector.rb new file mode 100644 index 0000000..d5e03f3 --- /dev/null +++ b/lib/parallel_matrix_formatter/summary_collector.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Collects summary data from parallel test processes + class SummaryCollector + def initialize(total_processes) + @total_processes = total_processes + @process_summaries = {} + end + + def collect(process_number, summary_data) + @process_summaries[process_number] = summary_data + end + + def all_summaries_received? + expected_processes = (1..@total_processes).to_a + expected_processes.all? { |process| @process_summaries.key?(process) } + end + + def process_summaries + @process_summaries + end + + def missing_processes + expected_processes = (1..@total_processes).to_a + expected_processes - @process_summaries.keys + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/summary_data_builder.rb b/lib/parallel_matrix_formatter/summary_data_builder.rb new file mode 100644 index 0000000..496b31a --- /dev/null +++ b/lib/parallel_matrix_formatter/summary_data_builder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Builds summary data structure for IPC transmission + class SummaryDataBuilder + def initialize(process_number) + @process_number = process_number + end + + def build(summary_notification, failed_examples, pending_count, duration) + { + total_examples: extract_total_examples(summary_notification), + failed_examples: failed_examples, + pending_count: pending_count, + duration: duration, + process_number: @process_number + } + end + + private + + def extract_total_examples(summary_notification) + return 0 unless summary_notification.respond_to?(:example_count) + + summary_notification.example_count + end + end +end \ No newline at end of file diff --git a/lib/parallel_matrix_formatter/summary_waiter.rb b/lib/parallel_matrix_formatter/summary_waiter.rb new file mode 100644 index 0000000..f3c36a6 --- /dev/null +++ b/lib/parallel_matrix_formatter/summary_waiter.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ParallelMatrixFormatter + # Waits for summary data from all processes with timeout handling + class SummaryWaiter + def initialize(summary_collector, output, timeout = 30.0) + @summary_collector = summary_collector + @output = output + @timeout = timeout + end + + def wait_and_render(renderer) + wait_for_summaries + handle_missing_summaries + render_if_available(renderer) + end + + private + + def wait_for_summaries + start_time = Time.now + + while !@summary_collector.all_summaries_received? && !timeout_reached?(start_time) + sleep 0.1 + end + end + + def timeout_reached?(start_time) + (Time.now - start_time) >= @timeout + end + + def handle_missing_summaries + return if @summary_collector.all_summaries_received? + + missing_processes = @summary_collector.missing_processes + @output.puts "\nWarning: Did not receive summaries from process(es): #{missing_processes.join(', ')}" + end + + def render_if_available(renderer) + return unless @summary_collector.process_summaries.any? + + renderer.render(@summary_collector.process_summaries) + end + end +end \ No newline at end of file diff --git a/spec/integration/parallel_matrix_formatter_output.txt b/spec/integration/parallel_matrix_formatter_output.txt new file mode 100644 index 0000000..6d6e5f0 --- /dev/null +++ b/spec/integration/parallel_matrix_formatter_output.txt @@ -0,0 +1,34 @@ + +イク:ヨヌ:ラヨ メイスイヌ%ォァユワ ケサ🥄ア +イク:ヨヌ:ラヌ ーツレメヨ%シリッン ャ +イク:ヨヌ:ラメ ヘヲツイロロ%リコホ 🥄 +dump_profile + +dump_pending + +dump_failures + +Failures: + + 1) Unknown example + Unknown location + No message + No backtrace + + 2) Unknown example + Unknown location + No message + No backtrace + + 3) Unknown example + Unknown location + No message + No backtrace + + 4) Unknown example + Unknown location + No message + No backtrace + +6 examples, 4 failures, 2 pending +Finished in 6.0 seconds (files took 6.0 seconds to load) diff --git a/spec/parallel_matrix_formatter/formatter_spec.rb b/spec/parallel_matrix_formatter/formatter_spec.rb index 24d4465..e3346fc 100644 --- a/spec/parallel_matrix_formatter/formatter_spec.rb +++ b/spec/parallel_matrix_formatter/formatter_spec.rb @@ -74,8 +74,16 @@ end context 'when an example fails' do + let(:failed_notification) do + double('failed_notification', + description: 'example description', + example: double('example', location: 'spec/example_spec.rb:10'), + message_lines: ['Expected 1 to eq 2'], + formatted_backtrace: [' spec/example_spec.rb:10:in `block`']) + end + it 'sends a failed notification via IPC' do - formatter.example_failed(double) + formatter.example_failed(failed_notification) expect(ipc_client).to have_received(:notify).with(2, { status: :failed, progress: 0.1 }) end end @@ -89,9 +97,42 @@ end describe 'dump methods' do - it '#dump_summary sends message to orchestrator' do - formatter.dump_summary(double) - expect(orchestrator).to have_received(:puts).with("\ndump_summary") + let(:summary_notification) do + double('summary_notification', + example_count: 10, + duration: 2.5) + end + + before do + formatter.start(start_notification) + end + + it '#dump_summary sends summary data via IPC' do + # Simulate a failed example first + failed_notification = double('failed_notification', + description: 'example description', + example: double('example', location: 'spec/example_spec.rb:10'), + message_lines: ['Expected 1 to eq 2'], + formatted_backtrace: [' spec/example_spec.rb:10:in `block`']) + formatter.example_failed(failed_notification) + + # Check the summary message + formatter.dump_summary(summary_notification) + + expect(ipc_client).to have_received(:notify).with(2, hash_including( + type: :summary, + data: hash_including( + total_examples: 10, + failed_examples: array_including( + hash_including( + description: 'example description', + location: 'spec/example_spec.rb:10' + ) + ), + pending_count: 0, + process_number: 2 + ) + )) end it '#dump_failures sends message to orchestrator' do diff --git a/spec/parallel_matrix_formatter/orchestrator_spec.rb b/spec/parallel_matrix_formatter/orchestrator_spec.rb index eee9bf1..2481720 100644 --- a/spec/parallel_matrix_formatter/orchestrator_spec.rb +++ b/spec/parallel_matrix_formatter/orchestrator_spec.rb @@ -169,5 +169,98 @@ end end end + + describe 'summary message handling' do + let(:multi_process_orchestrator) { described_class.new(2, 1, output, renderer) } + let(:summary_message_1) do + { + 'process_number' => 1, + 'message' => { + 'type' => 'summary', + 'data' => { + 'total_examples' => 5, + 'failed_examples' => [ + { + 'description' => 'fails test 1', + 'location' => 'spec/test_spec.rb:10', + 'message' => 'Expected true to be false', + 'formatted_backtrace' => 'spec/test_spec.rb:10:in `block`' + } + ], + 'pending_count' => 1, + 'duration' => 1.5, + 'process_number' => 1 + } + } + } + end + let(:summary_message_2) do + { + 'process_number' => 2, + 'message' => { + 'type' => 'summary', + 'data' => { + 'total_examples' => 3, + 'failed_examples' => [ + { + 'description' => 'fails test 2', + 'location' => 'spec/test2_spec.rb:20', + 'message' => 'Expected 1 to equal 2', + 'formatted_backtrace' => 'spec/test2_spec.rb:20:in `block`' + } + ], + 'pending_count' => 0, + 'duration' => 2.0, + 'process_number' => 2 + } + } + } + end + + before do + allow(Thread).to receive(:new) { |&block| block.call } + allow(ipc_server).to receive(:start) do |&block| + block.call(summary_message_1) + block.call(summary_message_2) + end + allow(output).to receive(:puts) + allow(output).to receive(:print) + allow(output).to receive(:flush) + end + + it 'collects and renders consolidated summary when all processes report' do + multi_process_orchestrator.start + + expect(output).to have_received(:puts).with("\n") + expect(output).to have_received(:puts).with("Failures:") + expect(output).to have_received(:puts).with(no_args).at_least(1).times + expect(output).to have_received(:puts).with(" 1) fails test 1") + expect(output).to have_received(:puts).with(" spec/test_spec.rb:10") + expect(output).to have_received(:puts).with(" Expected true to be false") + expect(output).to have_received(:puts).with(" 2) fails test 2") + expect(output).to have_received(:puts).with(" spec/test2_spec.rb:20") + expect(output).to have_received(:puts).with(" Expected 1 to equal 2") + expect(output).to have_received(:puts).with("8 examples, 2 failures, 1 pending") + expect(output).to have_received(:puts).with(/Finished in .* seconds/) + end + + it 'handles missing summaries gracefully' do + # Create a new orchestrator with short timeout + timeout_orchestrator = described_class.new(2, 1, output, renderer) + timeout_orchestrator.instance_variable_set(:@summary_timeout, 0.1) # Very short timeout for testing + + # Mock the process completion to simulate all processes done + timeout_orchestrator.instance_variable_set(:@process_completion, {1 => true, 2 => true}) + + # Only provide summary from process 1 + timeout_orchestrator.instance_variable_set(:@process_summaries, {1 => summary_message_1['message']['data']}) + + allow(output).to receive(:puts) + + timeout_orchestrator.send(:wait_for_summaries) + + expect(output).to have_received(:puts).with(/Warning: Did not receive summaries from process/) + end + end end end