Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ main
====

## Bugfixes
* The `parallel_tests` adapter now only activates and uses the native wait API when the native pid-file synchronization contract is present. Processes that inherit `TEST_ENV_NUMBER` / `PARALLEL_TEST_GROUPS` without `PARALLEL_PID_FILE`, or lose `PARALLEL_PID_FILE` before SimpleCov's `at_exit` hook runs, now use the generic resultset polling path instead of calling `ParallelTests.wait_for_other_processes_to_finish` and failing when `parallel_tests` fetches the missing pid-file path.
* The default `at_exit` formatter now writes reports only from the final parallel-test worker while still storing each worker's resultset for the final merge, so JSON/XML/HTML formatters no longer clobber canonical coverage files from non-final workers. See #1210.
* `SimpleCov.parallel_tests false` now disables the generic `TEST_ENV_NUMBER` adapter as well as the `parallel_tests` gem adapter, so projects that use those environment variables for a different coverage collation flow can opt out consistently. See #1208.
* Parallel result coordination now stores the final worker's own resultset before waiting for sibling resultsets, preventing an off-by-one timeout where the final worker reported `N-1` of `N` workers and skipped threshold checks immediately before producing a complete merged report. See #1208.
* Static branch coverage now matches Ruby's runtime branch tuple identities for `unless` and safe-navigation calls, and resultset merges now combine serialized branch tuples by source location instead of by their local sequential ids. This prevents equivalent branches from being duplicated when static and runtime branch extraction assign different ids. See #1206.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,7 +866,7 @@ SimpleCov coordinates with parallel test runners through a small pluggable adapt

- **`ParallelTestsAdapter`** — wraps the [grosser/parallel_tests](https://github.com/grosser/parallel_tests) gem and
uses its `ParallelTests.first_process?` / `ParallelTests.wait_for_other_processes_to_finish` APIs for precise worker
coordination.
coordination. Activates only when the native `parallel_tests` pid-file contract is present.
- **`GenericAdapter`** — catch-all for any runner that follows the `TEST_ENV_NUMBER` / `PARALLEL_TEST_GROUPS` env-var
convention but doesn't ship a Ruby API (parallel_rspec, knapsack-style splitters, custom CI sharding scripts).
Activates when `TEST_ENV_NUMBER` is set and no more-specific adapter is.
Expand Down
8 changes: 6 additions & 2 deletions lib/simplecov/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,18 @@ def configure(&block)

#
# Gets or sets the behavior to process coverage results.
# By default, it calls SimpleCov.result.format!
# By default, it stores/merges the current result and formats only
# from the final reporting process.
#
def at_exit(&block)
@at_exit = block if block
return @at_exit if @at_exit
return proc {} unless active_session?

@at_exit = proc { SimpleCov.result.format! }
@at_exit = proc do
result = SimpleCov.result
result.format! if result && SimpleCov.final_result_process?
end
end

# Whether SimpleCov has anything to do at exit: the Coverage module
Expand Down
18 changes: 13 additions & 5 deletions lib/simplecov/parallel_adapters/parallel_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ module ParallelAdapters
# Adapter for [grosser/parallel_tests](https://github.com/grosser/parallel_tests).
# This is the historical default — SimpleCov has special-cased
# parallel_tests since 0.18 — and remains the most precise option for
# projects on it. Detection is the standard pair: the `ParallelTests`
# constant has been loaded AND `TEST_ENV_NUMBER` is set. The gem itself
# is autoloaded lazily on first `active?` check so users who don't have
# it installed see no warnings (see #1018).
# projects on it. Detection requires the full native coordination
# contract: the `ParallelTests` constant has been loaded,
# `TEST_ENV_NUMBER` is set, and `PARALLEL_PID_FILE` is set. The pid-file
# path is required because the native wait API reads it with `ENV.fetch`.
# When a runner only provides the env-var convention, GenericAdapter is
# the correct coordination path.
class ParallelTestsAdapter < Base
class << self
def active?
return false if SimpleCov.parallel_tests == false

ensure_loaded
# !! to coerce `defined?` (returns nil or "constant") to a proper bool.
!!(defined?(::ParallelTests) && ENV.key?("TEST_ENV_NUMBER"))
!!(defined?(::ParallelTests) && native_parallel_tests_environment?)
end

# Pick the *first* started process to do the final-result work,
Expand All @@ -36,6 +38,8 @@ def first_worker?
end

def wait_for_siblings
return unless native_parallel_tests_environment?

::ParallelTests.wait_for_other_processes_to_finish
end

Expand Down Expand Up @@ -73,6 +77,10 @@ def ensure_loaded
def env_suggests_parallel_tests?
ENV.key?("TEST_ENV_NUMBER") && ENV.key?("PARALLEL_TEST_GROUPS")
end

def native_parallel_tests_environment?
ENV.key?("TEST_ENV_NUMBER") && ENV.key?("PARALLEL_PID_FILE")
end
end
end
end
Expand Down
41 changes: 40 additions & 1 deletion spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1244,12 +1244,51 @@
config.instance_variable_set(:@at_exit, previous)
end

it "returns a default proc (formats the result) when called with no block while Coverage is running" do
it "returns a default proc when called with no block while Coverage is running" do
allow(Coverage).to receive(:running?).and_return(true)
proc_returned = config.at_exit
expect(proc_returned).to be_a(Proc)
end

it "formats from the default proc when this process owns the final report" do
result = instance_double(SimpleCov::Result)
allow(result).to receive(:format!)
allow(Coverage).to receive(:running?).and_return(true)
allow(SimpleCov).to receive_messages(result: result, final_result_process?: true)

config.at_exit.call

expect(SimpleCov).to have_received(:result)
expect(result).to have_received(:format!)
end

it "still formats from the final process when parallel results are incomplete" do
result = instance_double(SimpleCov::Result)
allow(result).to receive(:format!)
allow(Coverage).to receive(:running?).and_return(true)
allow(SimpleCov).to receive_messages(
result: result,
final_result_process?: true,
ready_to_process_results?: false
)

config.at_exit.call

expect(result).to have_received(:format!)
end

it "stores and merges the result but does not format from non-final parallel workers" do
result = instance_double(SimpleCov::Result)
allow(result).to receive(:format!)
allow(Coverage).to receive(:running?).and_return(true)
allow(SimpleCov).to receive_messages(result: result, final_result_process?: false)

config.at_exit.call

expect(SimpleCov).to have_received(:result)
expect(result).not_to have_received(:format!)
end

it "remembers an explicit block across calls" do
explicit = proc {}
config.at_exit(&explicit)
Expand Down
40 changes: 38 additions & 2 deletions spec/parallel_adapters_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
prev_adapters = SimpleCov::ParallelAdapters.instance_variable_get(:@adapters)&.dup
prev_test_env_number = ENV.fetch("TEST_ENV_NUMBER", nil)
prev_parallel_test_groups = ENV.fetch("PARALLEL_TEST_GROUPS", nil)
prev_parallel_pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)

SimpleCov::ParallelAdapters.instance_variable_set(:@adapters, nil)
SimpleCov::ParallelAdapters.reset_current!
Expand All @@ -22,6 +23,7 @@
SimpleCov::ParallelAdapters.reset_current!
ENV["TEST_ENV_NUMBER"] = prev_test_env_number
ENV["PARALLEL_TEST_GROUPS"] = prev_parallel_test_groups
ENV["PARALLEL_PID_FILE"] = prev_parallel_pid_file
end
# rubocop:enable RSpec/DescribedClass

Expand Down Expand Up @@ -70,12 +72,22 @@
expect(described_class.current).to be_nil
end

it "returns ParallelTestsAdapter when parallel_tests gem is loaded + TEST_ENV_NUMBER set" do
it "returns ParallelTestsAdapter when parallel_tests gem is loaded and native env is set" do
stub_const("ParallelTests", Class.new)
ENV["TEST_ENV_NUMBER"] = "1"
ENV["PARALLEL_PID_FILE"] = "tmp/parallel_tests.pid"
expect(described_class.current).to eq(SimpleCov::ParallelAdapters::ParallelTestsAdapter)
end

it "returns GenericAdapter when parallel_tests gem is loaded but the pid-file contract is absent" do
stub_const("ParallelTests", Class.new)
ENV["TEST_ENV_NUMBER"] = "1"
ENV["PARALLEL_TEST_GROUPS"] = "2"
ENV.delete("PARALLEL_PID_FILE")

expect(described_class.current).to eq(SimpleCov::ParallelAdapters::GenericAdapter)
end

it "returns GenericAdapter when TEST_ENV_NUMBER is set but parallel_tests isn't loaded" do
hide_const("ParallelTests") if defined?(ParallelTests)
ENV["TEST_ENV_NUMBER"] = "1"
Expand Down Expand Up @@ -187,16 +199,19 @@
around do |example|
prev = ENV.fetch("TEST_ENV_NUMBER", nil)
prev_groups = ENV.fetch("PARALLEL_TEST_GROUPS", nil)
prev_pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)
example.run
ensure
ENV["TEST_ENV_NUMBER"] = prev
ENV["PARALLEL_TEST_GROUPS"] = prev_groups
ENV["PARALLEL_PID_FILE"] = prev_pid_file
end

describe ".active?" do
it "is true when ParallelTests is loaded AND TEST_ENV_NUMBER is set" do
it "is true when ParallelTests is loaded and the native env contract is set" do
stub_const("ParallelTests", Class.new)
ENV["TEST_ENV_NUMBER"] = "1"
ENV["PARALLEL_PID_FILE"] = "tmp/parallel_tests.pid"
allow(described_class).to receive(:ensure_loaded)
expect(described_class.active?).to be true
end
Expand All @@ -208,16 +223,26 @@
expect(described_class.active?).to be false
end

it "is false when PARALLEL_PID_FILE is unset" do
stub_const("ParallelTests", Class.new)
ENV["TEST_ENV_NUMBER"] = "1"
ENV.delete("PARALLEL_PID_FILE")
allow(described_class).to receive(:ensure_loaded)
expect(described_class.active?).to be false
end

it "is false when TEST_ENV_NUMBER is unset" do
stub_const("ParallelTests", Class.new)
ENV.delete("TEST_ENV_NUMBER")
ENV["PARALLEL_PID_FILE"] = "tmp/parallel_tests.pid"
allow(described_class).to receive(:ensure_loaded)
expect(described_class.active?).to be false
end

it "is false when SimpleCov.parallel_tests is false even if ParallelTests is already loaded" do
stub_const("ParallelTests", Class.new)
ENV["TEST_ENV_NUMBER"] = "1"
ENV["PARALLEL_PID_FILE"] = "tmp/parallel_tests.pid"
previous = SimpleCov.parallel_tests
SimpleCov.parallel_tests false

Expand All @@ -243,8 +268,19 @@
it "delegates to ParallelTests.wait_for_other_processes_to_finish" do
fake = Class.new { def self.wait_for_other_processes_to_finish = :sentinel }
stub_const("ParallelTests", fake)
ENV["TEST_ENV_NUMBER"] = "1"
ENV["PARALLEL_PID_FILE"] = "tmp/parallel_tests.pid"
expect(described_class.wait_for_siblings).to eq(:sentinel)
end

it "does not call the native wait API after the pid-file contract has been removed" do
fake = Class.new { def self.wait_for_other_processes_to_finish = raise "should not wait natively" }
stub_const("ParallelTests", fake)
ENV["TEST_ENV_NUMBER"] = "1"
ENV.delete("PARALLEL_PID_FILE")

expect(described_class.wait_for_siblings).to be_nil
end
end

describe ".expected_worker_count" do
Expand Down
Loading