lib/simplecov/exit_handling.rb
# frozen_string_literal: true require "English" # `at_exit` orchestration: post-suite report generation, threshold # checks, deferral when a sibling subprocess already wrote a fresher # report, and exit-status propagation. module SimpleCov class << self # @api private CoverageLimits = Data.define( :minimum_coverage, :minimum_coverage_by_file, :minimum_coverage_by_file_overrides, :minimum_coverage_by_group, :maximum_coverage, :maximum_coverage_drop ) def at_exit_behavior # If we are in a different process than called start, don't interfere. return if SimpleCov.pid != Process.pid # If Coverage is no longer running (e.g. someone manually stopped it # or a test consumed the result) then don't run exit tasks. return unless Coverage.running? # Stand down when we'd only clobber a fresher report. See # `defer_to_existing_report?` and issue #581. return if defer_to_existing_report? SimpleCov.run_exit_tasks! end # Returns true when our process has no coverage data to contribute # (after the resultset merge) and a newer report already exists on # disk. Typically fires when `SimpleCov.start` ran in a parent # process — e.g. a Rakefile or Rails' `Bundler.require` — that # shelled out to the test runner. See issue #581. def defer_to_existing_report? return false unless existing_report_newer_than_us? res = result empty = res.nil? || res.files.empty? warn_about_deferred_report if empty empty end def existing_report_newer_than_us? return false unless process_start_time last_run_path = File.join(coverage_path, ".last_run.json") File.exist?(last_run_path) && File.mtime(last_run_path) > process_start_time end def warn_about_deferred_report return unless print_errors ExitCodes.print_error SimpleCov::Color.colorize( "Skipping SimpleCov report — this process tracked no application code and a newer " \ "report already exists at #{coverage_path}. This usually means SimpleCov.start ran in a " \ "parent process (e.g. a Rakefile or Rails' Bundler.require) that shelled out to the test " \ "runner. See https://github.com/simplecov-ruby/simplecov/issues/581.", :yellow ) end # @api private — called from the at_exit block. def run_exit_tasks! error_exit_status = exit_status_from_exception at_exit.call exit_and_report_previous_error(error_exit_status) if previous_error?(error_exit_status) process_results_and_report_error if ready_to_process_results? end # @api private — returns the exit status from the exit exception. def exit_status_from_exception @exit_exception = $ERROR_INFO return nil unless @exit_exception if @exit_exception.is_a?(SystemExit) @exit_exception.status else SimpleCov::ExitCodes::EXCEPTION end end # @api private — strict boolean so rspec-mocks 4's predicate matcher # accepts it. test_unit sets status 0 on success, so SUCCESS must # also be treated as "not a previous error". def previous_error?(error_exit_status) !!(error_exit_status && error_exit_status != SimpleCov::ExitCodes::SUCCESS) end # @api private def exit_and_report_previous_error(exit_status) if print_errors ExitCodes.print_error SimpleCov::Color.colorize( "Stopped processing SimpleCov as a previous error not related to SimpleCov has been detected", :yellow ) end Kernel.exit(exit_status) end # @api private — the process that owns final merge processing is the # only one that reports against thresholds, and only when its # `wait_for_other_processes` confirmed every sibling reported. # When the wait times out, the merged total is partial and # comparing it against `minimum_coverage` / `maximum_coverage` # would surface a spurious "below minimum" violation about the # missing slice rather than a real shortfall. def ready_to_process_results? merge_finalization_owner? && final_result_process? && result? && (collating_result? || parallel_results_complete?) end def process_results_and_report_error exit_status = process_result(result) # Force exit with stored status (see github issue #5) return unless exit_status.positive? if print_errors ExitCodes.print_error SimpleCov::Color.colorize( "SimpleCov failed with exit #{exit_status} due to a coverage related error", :red ) end Kernel.exit exit_status end # @api private — `exit_status = SimpleCov.process_result(SimpleCov.result)`. def process_result(result) result_exit_status = result_exit_status(result) write_last_run(result) if result_exit_status == SimpleCov::ExitCodes::SUCCESS result_exit_status end def result_exit_status(result) ExitCodes::ExitCodeHandling.call(result, coverage_limits: build_coverage_limits) end private def build_coverage_limits CoverageLimits.new( minimum_coverage: minimum_coverage, minimum_coverage_by_file: minimum_coverage_by_file, minimum_coverage_by_file_overrides: minimum_coverage_by_file_overrides, minimum_coverage_by_group: minimum_coverage_by_group, maximum_coverage: maximum_coverage, maximum_coverage_drop: maximum_coverage_drop ) end end end