simplecov 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +65 -1439
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/cli/clean.rb +43 -3
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +21 -28
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +43 -48
- data/lib/simplecov/cli/dotfile.rb +12 -6
- data/lib/simplecov/cli/merge.rb +12 -10
- data/lib/simplecov/cli/open.rb +3 -5
- data/lib/simplecov/cli/report.rb +31 -23
- data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
- data/lib/simplecov/cli/serve.rb +30 -94
- data/lib/simplecov/cli/uncovered.rb +20 -25
- data/lib/simplecov/cli.rb +15 -2
- data/lib/simplecov/combine/branches_combiner.rb +43 -19
- data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
- data/lib/simplecov/combine/identity_interner.rb +30 -0
- data/lib/simplecov/combine/interned_counts.rb +30 -0
- data/lib/simplecov/combine/lines_combiner.rb +48 -20
- data/lib/simplecov/combine/methods_combiner.rb +36 -12
- data/lib/simplecov/combine/results_combiner.rb +12 -37
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +66 -9
- data/lib/simplecov/configuration/coverage.rb +12 -15
- data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +11 -44
- data/lib/simplecov/configuration/formatting.rb +6 -3
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/merging.rb +8 -7
- data/lib/simplecov/configuration/thresholds.rb +14 -13
- data/lib/simplecov/configuration.rb +13 -43
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +1 -1
- data/lib/simplecov/coverage_violations.rb +15 -5
- data/lib/simplecov/defaults.rb +7 -3
- data/lib/simplecov/directive.rb +1 -1
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
- data/lib/simplecov/exit_codes.rb +1 -0
- data/lib/simplecov/exit_handling.rb +11 -41
- data/lib/simplecov/file_list.rb +5 -10
- data/lib/simplecov/filter.rb +43 -9
- data/lib/simplecov/formatter/base.rb +11 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +67 -47
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
- data/lib/simplecov/formatter/json_formatter.rb +5 -48
- data/lib/simplecov/formatter/multi_formatter.rb +1 -1
- data/lib/simplecov/formatter/simple_formatter.rb +8 -5
- data/lib/simplecov/formatter.rb +5 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +16 -9
- data/lib/simplecov/lines_classifier.rb +29 -8
- data/lib/simplecov/load_global_config.rb +5 -2
- data/lib/simplecov/parallel_adapters/base.rb +17 -0
- data/lib/simplecov/parallel_adapters/generic.rb +2 -2
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
- data/lib/simplecov/parallel_coordination.rb +6 -1
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result.rb +40 -10
- data/lib/simplecov/result_merger/resultset_file.rb +43 -7
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +15 -12
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +69 -42
- data/lib/simplecov/result_processing.rb +82 -43
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +35 -11
- data/lib/simplecov/source_file/method.rb +7 -1
- data/lib/simplecov/source_file/ruby_data_parser.rb +6 -2
- data/lib/simplecov/source_file/skip_chunks.rb +7 -10
- data/lib/simplecov/source_file/source_loader.rb +23 -7
- data/lib/simplecov/source_file/statistics.rb +24 -16
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
- data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov.rb +11 -5
- data/schemas/coverage-v1.0.schema.json +2 -2
- data/schemas/coverage.schema.json +2 -2
- data/sig/simplecov.rbs +200 -66
- metadata +28 -14
- data/doc/alternate-formatters.md +0 -66
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -70
- data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
- data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
data/lib/simplecov/last_run.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
3
|
require "json"
|
|
4
|
+
require_relative "atomic_file"
|
|
5
5
|
|
|
6
6
|
module SimpleCov
|
|
7
7
|
# Reads and writes coverage/.last_run.json — the previous run's coverage
|
|
@@ -18,17 +18,24 @@ module SimpleCov
|
|
|
18
18
|
json = File.read(last_run_path)
|
|
19
19
|
return nil if json.strip.empty?
|
|
20
20
|
|
|
21
|
-
JSON.parse(json, symbolize_names: true)
|
|
21
|
+
parsed = JSON.parse(json, symbolize_names: true)
|
|
22
|
+
# The maximum_coverage_drop check digs into a Hash, so anything
|
|
23
|
+
# else in a corrupt or hand-edited file counts as "no previous
|
|
24
|
+
# run" rather than crashing the at_exit hook.
|
|
25
|
+
parsed.is_a?(Hash) ? parsed : invalid_last_run
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
invalid_last_run
|
|
22
28
|
end
|
|
23
29
|
|
|
24
|
-
# Write to a process-private temp file, then atomically rename, so a
|
|
25
|
-
# concurrent reader (e.g. another parallel-tests worker checking
|
|
26
|
-
# MaximumCoverageDrop) never sees a half-written file.
|
|
27
30
|
def write(json)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
AtomicFile.write(last_run_path, "#{JSON.pretty_generate(json)}\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def invalid_last_run
|
|
37
|
+
warn "[SimpleCov]: Warning! Parsing JSON content of .last_run.json failed, ignoring the previous run"
|
|
38
|
+
nil
|
|
32
39
|
end
|
|
33
40
|
end
|
|
34
41
|
end
|
|
@@ -13,6 +13,16 @@ module SimpleCov
|
|
|
13
13
|
COMMENT_LINE = /^\s*#/
|
|
14
14
|
WHITESPACE_OR_COMMENT_LINE = Regexp.union(WHITESPACE_LINE, COMMENT_LINE)
|
|
15
15
|
|
|
16
|
+
# The leading `^(\s*)#` anchor is load-bearing beyond matching the marker:
|
|
17
|
+
# `classify_line` only reaches this for lines that already passed
|
|
18
|
+
# `whitespace_line?`, which is sound only while every marker is also a
|
|
19
|
+
# comment. Loosening this to match a trailing `x = 1 # :nocov:` would stop
|
|
20
|
+
# the toggle firing. `lines_classifier_spec.rb` pins the implication.
|
|
21
|
+
#
|
|
22
|
+
# The `/o` flag freezes the interpolated nocov token at this
|
|
23
|
+
# process's first classification; a `nocov_token` configured after
|
|
24
|
+
# coverage has classified a file would be silently ignored. Sound
|
|
25
|
+
# today because configuration always precedes classification.
|
|
16
26
|
def self.no_cov_line
|
|
17
27
|
/^(\s*)#(\s*)(:#{SimpleCov.current_nocov_token}:)/o
|
|
18
28
|
end
|
|
@@ -34,21 +44,32 @@ module SimpleCov
|
|
|
34
44
|
def classify(lines)
|
|
35
45
|
lines = lines.to_a
|
|
36
46
|
directive_disabled = directive_disabled_line_set(lines)
|
|
37
|
-
|
|
47
|
+
# The `:nocov:` skip state lives in a one-slot box owned by this
|
|
48
|
+
# call, not in an ivar, so one classifier instance can serve
|
|
49
|
+
# concurrent `classify` calls without the toggles interleaving.
|
|
50
|
+
skip_state = [false]
|
|
38
51
|
|
|
39
52
|
lines.map.with_index(1) do |line, line_number|
|
|
40
|
-
|
|
41
|
-
not_relevant_line?(line, line_number, skipping, directive_disabled) ? NOT_RELEVANT : RELEVANT
|
|
53
|
+
classify_line(line, line_number, directive_disabled, skip_state)
|
|
42
54
|
end
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
private
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
# A `:nocov:` marker is itself a comment, so the cheap
|
|
60
|
+
# whitespace-or-comment test can gate the token match: a line of real
|
|
61
|
+
# code cannot be a marker, and no longer pays to be checked against
|
|
62
|
+
# one. That matters because this runs per line of every
|
|
63
|
+
# tracked-but-unloaded file, once in every process of a parallel run.
|
|
64
|
+
def classify_line(line, line_number, directive_disabled, skip_state)
|
|
65
|
+
if self.class.whitespace_line?(line)
|
|
66
|
+
skip_state[0] = !skip_state[0] if self.class.no_cov_line?(line)
|
|
67
|
+
NOT_RELEVANT
|
|
68
|
+
elsif skip_state[0] || directive_disabled.include?(line_number)
|
|
69
|
+
NOT_RELEVANT
|
|
70
|
+
else
|
|
71
|
+
RELEVANT
|
|
72
|
+
end
|
|
52
73
|
end
|
|
53
74
|
|
|
54
75
|
def directive_disabled_line_set(lines)
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
# `~/.simplecov` was historically resolved via a three-step fallback chain
|
|
4
4
|
# (HOME, then `Etc.getpwuid.dir`, then `~$USER`) for hostile container
|
|
5
5
|
# environments circa 2017. Modern CRuby/JRuby/TruffleRuby all set HOME
|
|
6
|
-
# reliably, so trust it and skip silently when it isn't there.
|
|
7
|
-
|
|
6
|
+
# reliably, so trust it and skip silently when it isn't there. An ENV
|
|
7
|
+
# check rather than Dir.home: JRuby raises from Dir.home with HOME
|
|
8
|
+
# unset, and `File.expand_path("~")` raises for a set-but-empty HOME
|
|
9
|
+
# (some container/CI images), which must not break `require "simplecov"`.
|
|
10
|
+
unless ENV.fetch("HOME", "").empty?
|
|
8
11
|
# simplecov:disable — only fires when ~/.simplecov exists, which is
|
|
9
12
|
# developer-machine-dependent (we can't rely on it for the dogfood).
|
|
10
13
|
global_config_path = File.join(File.expand_path("~"), ".simplecov")
|
|
@@ -55,6 +55,23 @@ module SimpleCov
|
|
|
55
55
|
def expected_worker_count
|
|
56
56
|
1
|
|
57
57
|
end
|
|
58
|
+
|
|
59
|
+
# The user's explicit `SimpleCov.parallel_tests false` opt-out,
|
|
60
|
+
# which every adapter's `active?` honors before its own
|
|
61
|
+
# detection.
|
|
62
|
+
def forced_off?
|
|
63
|
+
SimpleCov.parallel_tests == false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Shared implementation of the PARALLEL_TEST_GROUPS convention
|
|
67
|
+
# both built-in adapters follow for `expected_worker_count`.
|
|
68
|
+
# Unset, empty, non-numeric, and non-positive values all mean 1:
|
|
69
|
+
# an unparseable value must not yield 0 workers, which would end
|
|
70
|
+
# the sibling wait before it started.
|
|
71
|
+
def parallel_test_groups_count
|
|
72
|
+
count = Integer(ENV.fetch("PARALLEL_TEST_GROUPS", ""), exception: false)
|
|
73
|
+
count&.positive? ? count : 1
|
|
74
|
+
end
|
|
58
75
|
end
|
|
59
76
|
end
|
|
60
77
|
end
|
|
@@ -23,7 +23,7 @@ module SimpleCov
|
|
|
23
23
|
class GenericAdapter < Base
|
|
24
24
|
class << self
|
|
25
25
|
def active?
|
|
26
|
-
return false if
|
|
26
|
+
return false if forced_off?
|
|
27
27
|
|
|
28
28
|
ENV.key?("TEST_ENV_NUMBER")
|
|
29
29
|
end
|
|
@@ -36,7 +36,7 @@ module SimpleCov
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def expected_worker_count
|
|
39
|
-
|
|
39
|
+
parallel_test_groups_count
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -16,7 +16,7 @@ module SimpleCov
|
|
|
16
16
|
class ParallelTestsAdapter < Base
|
|
17
17
|
class << self
|
|
18
18
|
def active?
|
|
19
|
-
return false if
|
|
19
|
+
return false if forced_off?
|
|
20
20
|
|
|
21
21
|
ensure_loaded
|
|
22
22
|
# !! to coerce `defined?` (returns nil or "constant") to a proper bool.
|
|
@@ -50,7 +50,7 @@ module SimpleCov
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def expected_worker_count
|
|
53
|
-
|
|
53
|
+
parallel_test_groups_count
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# Auto-require `parallel_tests` when it's installed AND the env
|
|
@@ -77,7 +77,7 @@ module SimpleCov
|
|
|
77
77
|
deadline = monotonic_time + parallel_wait_timeout
|
|
78
78
|
tracker = {count: 0, since: monotonic_time}
|
|
79
79
|
loop do
|
|
80
|
-
seen =
|
|
80
|
+
seen = current_parallel_worker_count
|
|
81
81
|
return true if seen >= expected
|
|
82
82
|
return true if native_wait && resultset_count_settled?(tracker, seen)
|
|
83
83
|
return false if parallel_wait_timed_out?(deadline, expected, seen)
|
|
@@ -86,6 +86,11 @@ module SimpleCov
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
def current_parallel_worker_count
|
|
90
|
+
resultset = SimpleCov::ResultMerger.read_resultset
|
|
91
|
+
SimpleCov::ResultMerger.worker_identities_for_run(resultset, SimpleCov.run_id, SimpleCov.process_start_time).size
|
|
92
|
+
end
|
|
93
|
+
|
|
89
94
|
# Track whether the resultset count has held steady (and positive) for
|
|
90
95
|
# `PARALLEL_RESULTS_SETTLE` seconds. `tracker` carries the last count and
|
|
91
96
|
# the time it last changed across poll iterations.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
#
|
|
5
|
+
# Folds a list of resultset files into one merged coverage table across
|
|
6
|
+
# forked worker processes. Drives `SimpleCov.collate(..., processes: N)`.
|
|
7
|
+
#
|
|
8
|
+
# `ResultMerger.absorb_results` is a fold over N independent
|
|
9
|
+
# read-parse-combine steps, so it splits cleanly: each worker runs that
|
|
10
|
+
# same fold over a contiguous slice of the file list and ships the pair
|
|
11
|
+
# back over a pipe, and the parent combines the handful of per-worker
|
|
12
|
+
# pairs it gets back. Reading and parsing the shards — where a collate
|
|
13
|
+
# over a few hundred CI jobs spends most of its time — is what actually
|
|
14
|
+
# parallelises.
|
|
15
|
+
#
|
|
16
|
+
# The slices are contiguous and merged back in order, so the fold visits
|
|
17
|
+
# the resultsets in the order the serial fold visits them and the merged
|
|
18
|
+
# result is identical to `SimpleCov.collate`'s, not merely equivalent.
|
|
19
|
+
#
|
|
20
|
+
# Every failure path returns nil rather than a partial merge, so the caller
|
|
21
|
+
# can redo the fold serially: reporting coverage for a subset of the
|
|
22
|
+
# resultsets would silently understate it.
|
|
23
|
+
#
|
|
24
|
+
module ParallelResultMerger
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# `ResultMerger.merge_and_store` across `processes` forked workers. One
|
|
29
|
+
# worker hands straight back to `ResultMerger`, so the default `collate`
|
|
30
|
+
# takes exactly the path it always has and never reaches this module's
|
|
31
|
+
# machinery at all.
|
|
32
|
+
#
|
|
33
|
+
def merge_and_store(*file_paths, processes:, ignore_timeout: false)
|
|
34
|
+
return ResultMerger.merge_and_store(*file_paths, ignore_timeout: ignore_timeout) if processes < 2
|
|
35
|
+
|
|
36
|
+
result = merge_results(*file_paths, processes: processes, ignore_timeout: ignore_timeout)
|
|
37
|
+
ResultMerger.store_result(result) if result
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# `ResultMerger.merge_results` across `processes` forked workers, merging
|
|
43
|
+
# in this process instead whenever the fan-out did not produce a complete
|
|
44
|
+
# merge — a runtime that cannot fork, nothing worth splitting, or a worker
|
|
45
|
+
# that died. The result is the same either way; only the time it took to
|
|
46
|
+
# get there differs.
|
|
47
|
+
#
|
|
48
|
+
def merge_results(*file_paths, processes:, ignore_timeout: false)
|
|
49
|
+
tracked_files = Set.new
|
|
50
|
+
pair = absorb_results(file_paths, processes: processes, ignore_timeout: ignore_timeout,
|
|
51
|
+
tracked_files: tracked_files)
|
|
52
|
+
# A nil pair means nothing was fanned out at all, so the serial path
|
|
53
|
+
# is exactly `ResultMerger.merge_results` — delegate rather than
|
|
54
|
+
# re-implement its collector wiring.
|
|
55
|
+
return ResultMerger.merge_results(*file_paths, ignore_timeout: ignore_timeout) unless pair
|
|
56
|
+
|
|
57
|
+
command_names, coverage = pair
|
|
58
|
+
ResultMerger.create_result(command_names, coverage, tracked_files: tracked_files)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
# `ResultMerger.absorb_results` across at most `processes` forked
|
|
63
|
+
# workers: same arguments, same `[command_names, coverage]` return.
|
|
64
|
+
#
|
|
65
|
+
# The tracked paths a worker's slice carried come back with its payload
|
|
66
|
+
# rather than through a collector block, since the block a serial absorb
|
|
67
|
+
# takes would be mutating a Set in the wrong process.
|
|
68
|
+
#
|
|
69
|
+
# @return [Array(Array<String>, Hash), nil] the pair
|
|
70
|
+
# `ResultMerger.create_result` consumes, or nil when the work could not
|
|
71
|
+
# be fanned out and the caller should merge in this process instead.
|
|
72
|
+
#
|
|
73
|
+
def absorb_results(file_paths, processes:, ignore_timeout: false, tracked_files: Set.new)
|
|
74
|
+
# One worker folds the whole list anyway, and one file is a fold of
|
|
75
|
+
# one — in both cases the fork and the round trip are pure overhead.
|
|
76
|
+
return nil if processes < 2 || file_paths.size < 2
|
|
77
|
+
# The portable feature test, and the one the rest of the ecosystem uses.
|
|
78
|
+
# CRuby leaves `fork` undefined on Windows; JRuby and TruffleRuby cannot
|
|
79
|
+
# fork on the JVM and deliberately answer false here so libraries can
|
|
80
|
+
# detect that without rescuing an exception — TruffleRuby's compatibility
|
|
81
|
+
# guide names this as the correct check. They do still define
|
|
82
|
+
# `Kernel#fork` and raise `NotImplementedError` from it, so probing that
|
|
83
|
+
# instead would answer true and send them down the fan-out.
|
|
84
|
+
return nil unless Process.respond_to?(:fork)
|
|
85
|
+
|
|
86
|
+
fan_out(chunk(file_paths, processes), ignore_timeout: ignore_timeout, tracked_files: tracked_files)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Contiguous slices whose sizes differ by at most one, so no worker is
|
|
90
|
+
# left folding twice its share while the others idle. There are never
|
|
91
|
+
# more slices than files: asking for more processes than there are
|
|
92
|
+
# resultsets just gives one resultset per process.
|
|
93
|
+
def chunk(file_paths, processes)
|
|
94
|
+
groups = [processes, file_paths.size].min
|
|
95
|
+
base, remainder = file_paths.size.divmod(groups)
|
|
96
|
+
remaining = file_paths.dup
|
|
97
|
+
|
|
98
|
+
Array.new(groups) { |index| remaining.shift(base + (index < remainder ? 1 : 0)) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A `fork` that fails here raises, and is left to. `absorb_results` has
|
|
102
|
+
# already excluded the runtimes that never fork, so what remains is the OS
|
|
103
|
+
# refusing a process we expected to get — EAGAIN at RLIMIT_NPROC, ENOMEM
|
|
104
|
+
# under memory pressure. That says something is wrong with the machine
|
|
105
|
+
# rather than with the merge, and quietly absorbing it would hide it.
|
|
106
|
+
def fan_out(chunks, ignore_timeout:, tracked_files: Set.new)
|
|
107
|
+
workers = spawn_workers(chunks, ignore_timeout: ignore_timeout)
|
|
108
|
+
payloads = collect(workers)
|
|
109
|
+
return nil unless payloads
|
|
110
|
+
|
|
111
|
+
payloads.each { |(_pair, tracked)| tracked_files.merge(tracked) }
|
|
112
|
+
ResultMerger.merge_coverage(*payloads.map(&:first))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def spawn_workers(chunks, ignore_timeout:)
|
|
116
|
+
workers = [] #: Array[Hash[Symbol, untyped]]
|
|
117
|
+
|
|
118
|
+
chunks.each do |chunk|
|
|
119
|
+
workers << spawn_worker(chunk, ignore_timeout: ignore_timeout)
|
|
120
|
+
rescue StandardError
|
|
121
|
+
abandon(workers)
|
|
122
|
+
raise
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
workers
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def abandon(workers)
|
|
129
|
+
workers.each do |worker|
|
|
130
|
+
worker[:reader].close
|
|
131
|
+
succeeded?(worker[:pid])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `fork` itself can fail (EAGAIN under a process limit). The caller
|
|
136
|
+
# cleans up the workers it knows about, but this pipe is ours: the
|
|
137
|
+
# parent's writer end always closes, and the reader closes too when
|
|
138
|
+
# no child was spawned to feed it. Steep cannot type body locals
|
|
139
|
+
# inside an ensure, hence the ignore markers; the safe navigation
|
|
140
|
+
# keeps the cleanup well-defined when `IO.pipe` itself raised.
|
|
141
|
+
def spawn_worker(chunk, ignore_timeout:)
|
|
142
|
+
reader, writer = IO.pipe
|
|
143
|
+
pid = fork { run_in_child(reader, writer, chunk, ignore_timeout) }
|
|
144
|
+
{pid: pid, reader: reader}
|
|
145
|
+
ensure
|
|
146
|
+
# steep:ignore:start
|
|
147
|
+
writer&.close
|
|
148
|
+
reader&.close unless pid
|
|
149
|
+
# steep:ignore:end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Everything the child does. `exit!` rather than `exit` because it must
|
|
153
|
+
# never fall through to the collating process's inherited `at_exit`
|
|
154
|
+
# handlers — SimpleCov's own report generation included.
|
|
155
|
+
def run_in_child(reader, writer, chunk, ignore_timeout)
|
|
156
|
+
reader.close
|
|
157
|
+
exit!(run_worker(chunk, writer, ignore_timeout: ignore_timeout))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The body of a worker: merge the slice, ship it back, and report the exit
|
|
161
|
+
# status the child should terminate with. Kept free of the exit itself so
|
|
162
|
+
# it can be exercised in-process.
|
|
163
|
+
#
|
|
164
|
+
# The slice's tracked paths travel with the pair because the parent needs
|
|
165
|
+
# the union across every worker to know what nothing loaded.
|
|
166
|
+
def run_worker(chunk, writer, ignore_timeout:)
|
|
167
|
+
tracked_files = Set.new
|
|
168
|
+
pair = ResultMerger.absorb_results(chunk, ignore_timeout: ignore_timeout,
|
|
169
|
+
&ResultMerger::UnloadedFiles.collector(tracked_files))
|
|
170
|
+
Marshal.dump([pair, tracked_files.to_a], writer)
|
|
171
|
+
writer.close
|
|
172
|
+
0
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
warn "[SimpleCov]: parallel merge worker failed: #{e.class}: #{e.message}" if SimpleCov.print_errors
|
|
175
|
+
1
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Deserializes on a thread per worker so every pipe is drained while the
|
|
179
|
+
# workers are still writing. A payload larger than the pipe buffer would
|
|
180
|
+
# otherwise block its worker mid-write, and the parent would block
|
|
181
|
+
# reaping a worker that can never finish.
|
|
182
|
+
#
|
|
183
|
+
# Returns nil if any worker failed, so the caller can fall back to the
|
|
184
|
+
# serial fold rather than report a subset of the resultsets as the whole.
|
|
185
|
+
def collect(workers)
|
|
186
|
+
payloads = drain(workers)
|
|
187
|
+
failed = workers.count { |worker| !succeeded?(worker[:pid]) }
|
|
188
|
+
return payloads if failed.zero? && payloads.all?
|
|
189
|
+
|
|
190
|
+
warn_about_failed_workers(failed, workers.size)
|
|
191
|
+
nil
|
|
192
|
+
ensure
|
|
193
|
+
workers.each { |worker| worker[:reader].close }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def drain(workers)
|
|
197
|
+
workers.map { |worker| Thread.new { read_payload(worker[:reader]) } }.map(&:value)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def read_payload(reader)
|
|
201
|
+
# The writer is a fork of this very process and the pipe never leaves
|
|
202
|
+
# it, so this is our own data coming back through our own kernel
|
|
203
|
+
# buffer, not input. A worker that died mid-write leaves the stream
|
|
204
|
+
# truncated, which Marshal reports by raising rather than returning.
|
|
205
|
+
# RBS types `Marshal.load`'s source as `_Source`, which IO satisfies
|
|
206
|
+
# structurally but not nominally.
|
|
207
|
+
# steep:ignore:start
|
|
208
|
+
Marshal.load(reader) # rubocop:disable Security/MarshalLoad
|
|
209
|
+
# steep:ignore:end
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def succeeded?(pid)
|
|
215
|
+
_pid, status = Process.wait2(pid)
|
|
216
|
+
status.success?
|
|
217
|
+
rescue SystemCallError
|
|
218
|
+
# Errno::ECHILD — nothing left to reap, so there is no status to judge
|
|
219
|
+
# this worker's slice by and we have to assume it did not finish.
|
|
220
|
+
false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def warn_about_failed_workers(failed, total)
|
|
224
|
+
return unless SimpleCov.print_errors
|
|
225
|
+
|
|
226
|
+
warn "[SimpleCov]: parallel merge did not complete (#{failed} of #{total} workers failed); " \
|
|
227
|
+
"merging the resultsets in this process instead."
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The clobber-prevention backstop: an empty process stands down instead
|
|
4
|
+
# of formatting over a fresher report a sibling produced. Split from
|
|
5
|
+
# exit_handling.rb, which orchestrates the at_exit flow that consults
|
|
6
|
+
# this. See issue #581.
|
|
7
|
+
module SimpleCov
|
|
8
|
+
class << self
|
|
9
|
+
# Returns true when our process has no coverage data to contribute
|
|
10
|
+
# (after the resultset merge) and a newer report already exists on
|
|
11
|
+
# disk. Typically fires when `SimpleCov.start` ran in a parent
|
|
12
|
+
# process that shelled out to the test runner. See issue #581.
|
|
13
|
+
def defer_to_existing_report?
|
|
14
|
+
return false unless existing_report_newer_than_us?
|
|
15
|
+
|
|
16
|
+
res = result
|
|
17
|
+
empty = res.nil? || res.files.empty?
|
|
18
|
+
warn_about_deferred_report if empty
|
|
19
|
+
empty
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `.last_run.json` only exists after fully successful runs, so alone
|
|
23
|
+
# it left the backstop inert when the child run failed — the case
|
|
24
|
+
# where clobbering its report hurts most. The report stamp is
|
|
25
|
+
# touched by every formatting process regardless of exit status. The
|
|
26
|
+
# rescue covers a file vanishing mid-at_exit (`rm -rf coverage`).
|
|
27
|
+
def existing_report_newer_than_us?
|
|
28
|
+
return false unless process_start_time
|
|
29
|
+
|
|
30
|
+
[SimpleCov::LastRun.last_run_path, SimpleCov::ReportStamp.path].any? do |path|
|
|
31
|
+
File.mtime(path) > process_start_time
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def warn_about_deferred_report
|
|
38
|
+
return unless print_errors
|
|
39
|
+
|
|
40
|
+
ExitCodes.print_error SimpleCov::Color.colorize(
|
|
41
|
+
"Skipping SimpleCov report — this process tracked no application code and a newer " \
|
|
42
|
+
"report already exists at #{coverage_path}. This usually means SimpleCov.start ran in a " \
|
|
43
|
+
"parent process (e.g. a Rakefile or Rails' Bundler.require) that shelled out to the test " \
|
|
44
|
+
"runner. See https://github.com/simplecov-ruby/simplecov/issues/581.",
|
|
45
|
+
:yellow
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
# Touches coverage/.report_stamp whenever a report is formatted, no
|
|
7
|
+
# matter how the run ends. The clobber-prevention backstop
|
|
8
|
+
# (`SimpleCov.defer_to_existing_report?`) needs an on-disk signal that
|
|
9
|
+
# a fresher report exists, and `.last_run.json` can't be it alone:
|
|
10
|
+
# that file is only written after fully successful runs, which left
|
|
11
|
+
# the backstop inert exactly when the child run failed and its report
|
|
12
|
+
# mattered most.
|
|
13
|
+
module ReportStamp
|
|
14
|
+
class << self
|
|
15
|
+
def path
|
|
16
|
+
File.join(SimpleCov.coverage_path, ".report_stamp")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def touch
|
|
20
|
+
FileUtils.touch(path)
|
|
21
|
+
rescue SystemCallError
|
|
22
|
+
# A read-only or vanished coverage dir must not crash reporting;
|
|
23
|
+
# the stamp only powers the deferral heuristic.
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/simplecov/result.rb
CHANGED
|
@@ -15,6 +15,13 @@ module SimpleCov
|
|
|
15
15
|
|
|
16
16
|
# Returns the original Coverage.result used for this instance of SimpleCov::Result
|
|
17
17
|
attr_reader :original_result
|
|
18
|
+
# Every path the producing process was told to track, loaded or not. Carried
|
|
19
|
+
# into the resultset so a merge elsewhere can inject the ones nobody loaded
|
|
20
|
+
# without needing that process's `cover` / `track_files` config. See #1250.
|
|
21
|
+
attr_reader :tracked_files
|
|
22
|
+
# Invocation and top-level worker identities used only for parallel-result
|
|
23
|
+
# coordination. They do not change which fresh suites are merged.
|
|
24
|
+
attr_reader :run_id, :worker_id
|
|
18
25
|
# Returns all files that are applicable to this result (sans filters!) as instances of
|
|
19
26
|
# SimpleCov::SourceFile. Aliased as :source_files
|
|
20
27
|
attr_reader :files
|
|
@@ -55,10 +62,11 @@ module SimpleCov
|
|
|
55
62
|
# FilterConfig to opt out — useful for tests that build synthetic Results
|
|
56
63
|
# and don't want the project's filters or groups applied.
|
|
57
64
|
def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
|
|
58
|
-
report: false, filter_config: FilterConfig.new)
|
|
65
|
+
tracked_files: [], run_id: nil, worker_id: nil, report: false, filter_config: FilterConfig.new)
|
|
59
66
|
@original_result = original_result.freeze
|
|
60
67
|
@command_name = command_name
|
|
61
68
|
@created_at = created_at
|
|
69
|
+
initialize_coordination_metadata(tracked_files, run_id, worker_id)
|
|
62
70
|
@groups_config = filter_config.groups
|
|
63
71
|
builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
|
|
64
72
|
@files = builder.call
|
|
@@ -102,7 +110,13 @@ module SimpleCov
|
|
|
102
110
|
formatter = SimpleCov.formatter
|
|
103
111
|
return nil if formatter.nil?
|
|
104
112
|
|
|
105
|
-
Formatter.
|
|
113
|
+
formatted = Formatter.format(formatter, self)
|
|
114
|
+
# Recorded regardless of how the run ends, so a parent process's
|
|
115
|
+
# clobber-prevention backstop can tell a report was produced even
|
|
116
|
+
# when this run's checks or tests failed (unlike .last_run.json,
|
|
117
|
+
# which only successful runs write).
|
|
118
|
+
SimpleCov::ReportStamp.touch
|
|
119
|
+
formatted
|
|
106
120
|
end
|
|
107
121
|
|
|
108
122
|
# Defines when this result has been created. Defaults to Time.now
|
|
@@ -118,23 +132,32 @@ module SimpleCov
|
|
|
118
132
|
|
|
119
133
|
# Returns a hash representation of this Result that can be used for marshalling it into JSON
|
|
120
134
|
def to_hash
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
data = {"coverage" => coverage, "timestamp" => created_at.to_f} #: Hash[String, untyped]
|
|
136
|
+
data["run_id"] = run_id if run_id
|
|
137
|
+
data["worker_id"] = worker_id if worker_id
|
|
138
|
+
# Omitted when empty so a run that tracks nothing writes the shape it
|
|
139
|
+
# always has, and so the key only appears where it carries information.
|
|
140
|
+
data["tracked_files"] = tracked_files unless tracked_files.empty?
|
|
141
|
+
{command_name => data}
|
|
127
142
|
end
|
|
128
143
|
|
|
129
144
|
# Loads a SimpleCov::Result#to_hash dump
|
|
130
145
|
def self.from_hash(hash)
|
|
131
146
|
hash.map do |command_name, data|
|
|
132
|
-
new(data.fetch("coverage"), command_name: command_name, created_at: Time.at(data["timestamp"])
|
|
147
|
+
new(data.fetch("coverage"), command_name: command_name, created_at: Time.at(data["timestamp"]),
|
|
148
|
+
tracked_files: data["tracked_files"] || [], run_id: data["run_id"],
|
|
149
|
+
worker_id: data["worker_id"])
|
|
133
150
|
end
|
|
134
151
|
end
|
|
135
152
|
|
|
136
153
|
private
|
|
137
154
|
|
|
155
|
+
def initialize_coordination_metadata(tracked_files, run_id, worker_id)
|
|
156
|
+
@tracked_files = tracked_files.to_a
|
|
157
|
+
@run_id = run_id
|
|
158
|
+
@worker_id = worker_id
|
|
159
|
+
end
|
|
160
|
+
|
|
138
161
|
def warn_about_missing_source_files(missing, input_size)
|
|
139
162
|
return if missing.empty?
|
|
140
163
|
|
|
@@ -156,8 +179,15 @@ module SimpleCov
|
|
|
156
179
|
).warn!
|
|
157
180
|
end
|
|
158
181
|
|
|
182
|
+
# A live result's criterion keys are Symbols (`:lines`, `:branches`),
|
|
183
|
+
# while entries parsed back from `.resultset.json` carry Strings, and
|
|
184
|
+
# the combiners read only String keys. Serialize with String keys so a
|
|
185
|
+
# live result merged against a stored entry contributes its counts
|
|
186
|
+
# instead of being silently dropped for every shared file.
|
|
159
187
|
def coverage
|
|
160
|
-
original_result.slice(*filenames)
|
|
188
|
+
original_result.slice(*filenames).transform_values do |file_coverage|
|
|
189
|
+
file_coverage.transform_keys(&:to_s)
|
|
190
|
+
end
|
|
161
191
|
end
|
|
162
192
|
|
|
163
193
|
# Applies the given filter chain to `@files`, dropping each source
|