simplecov 1.0.2 → 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 -1425
- 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 +46 -21
- 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 +18 -9
- 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 +12 -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_adapter.rb +48 -16
- 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 +25 -3
- 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 +206 -67
- 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
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
|
|
4
|
+
require_relative "coverage_json_writer"
|
|
5
5
|
require "json"
|
|
6
|
-
require "time"
|
|
7
6
|
|
|
8
7
|
module SimpleCov
|
|
9
8
|
module Formatter
|
|
@@ -11,7 +10,7 @@ module SimpleCov
|
|
|
11
10
|
# standalone, alongside the HTML formatter, or by external tools that
|
|
12
11
|
# consume SimpleCov output.
|
|
13
12
|
class JSONFormatter < Base
|
|
14
|
-
FILENAME =
|
|
13
|
+
FILENAME = CoverageJSONWriter::FILENAME
|
|
15
14
|
|
|
16
15
|
# `include_source:` defaults to `SimpleCov.source_in_json` (true
|
|
17
16
|
# by default) so the historical payload shape is unchanged.
|
|
@@ -19,19 +18,12 @@ module SimpleCov
|
|
|
19
18
|
# setting (the HTML formatter, which feeds the client-side
|
|
20
19
|
# viewer) pass `include_source: true` explicitly.
|
|
21
20
|
def self.build_hash(result, include_source: SimpleCov.source_in_json)
|
|
22
|
-
ResultHashFormatter.
|
|
21
|
+
ResultHashFormatter.format(result, include_source: include_source)
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
def format(result)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
warn_if_concurrent_overwrite(path, result)
|
|
29
|
-
File.write(path, JSON.pretty_generate(self.class.build_hash(result)))
|
|
30
|
-
# stderr, not stdout: this is a status message, not the program's
|
|
31
|
-
# output. Keeps the line out of pipelines like `rspec -f json`. And
|
|
32
|
-
# $stderr.puts, not `warn`: a status line should not reach
|
|
33
|
-
# `Warning.warn` hooks or vanish under `-W0` (see #1225).
|
|
34
|
-
$stderr.puts output_message(result) unless @silent # rubocop:disable Style/StderrPuts
|
|
25
|
+
CoverageJSONWriter.write(output_path, self.class.build_hash(result), result)
|
|
26
|
+
emit_status(result)
|
|
35
27
|
end
|
|
36
28
|
|
|
37
29
|
private
|
|
@@ -43,41 +35,6 @@ module SimpleCov
|
|
|
43
35
|
def entry_point_filename
|
|
44
36
|
FILENAME
|
|
45
37
|
end
|
|
46
|
-
|
|
47
|
-
# Warns when the existing coverage.json has a timestamp newer than this
|
|
48
|
-
# process's start time — a strong signal that a sibling test process
|
|
49
|
-
# (e.g., parallel_tests) wrote it while we were running, and that our
|
|
50
|
-
# write is about to clobber their data.
|
|
51
|
-
def warn_if_concurrent_overwrite(path, result)
|
|
52
|
-
start_time = SimpleCov.process_start_time or return
|
|
53
|
-
existing = existing_meta(path) or return
|
|
54
|
-
return unless existing[:timestamp] > start_time
|
|
55
|
-
|
|
56
|
-
# The HTML formatter also writes coverage.json (it shares the file as
|
|
57
|
-
# a side artifact), so when both formatters are configured the file we
|
|
58
|
-
# find was just written by our own run, not a concurrent one. A
|
|
59
|
-
# matching command_name means the same merged result, so there's
|
|
60
|
-
# nothing to lose by overwriting. See issue #1171.
|
|
61
|
-
return if existing[:command_name] == result.command_name
|
|
62
|
-
|
|
63
|
-
warn "simplecov: #{path} was written at #{existing[:timestamp].iso8601} — after " \
|
|
64
|
-
"this process started at #{start_time.iso8601}. Overwriting " \
|
|
65
|
-
"likely loses coverage data from a concurrent test run. For " \
|
|
66
|
-
"parallel test setups, use SimpleCov::ResultMerger or run a single " \
|
|
67
|
-
"collation step after all workers finish."
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def existing_meta(path)
|
|
71
|
-
return nil unless File.exist?(path)
|
|
72
|
-
|
|
73
|
-
meta = JSON.parse(File.read(path), symbolize_names: true)
|
|
74
|
-
timestamp = meta.dig(:meta, :timestamp)
|
|
75
|
-
return nil unless timestamp
|
|
76
|
-
|
|
77
|
-
{timestamp: Time.iso8601(timestamp), command_name: meta.dig(:meta, :command_name)}
|
|
78
|
-
rescue JSON::ParserError, ArgumentError
|
|
79
|
-
nil
|
|
80
|
-
end
|
|
81
38
|
end
|
|
82
39
|
end
|
|
83
40
|
end
|
|
@@ -10,7 +10,7 @@ module SimpleCov
|
|
|
10
10
|
module InstanceMethods
|
|
11
11
|
def format(result)
|
|
12
12
|
formatters.map do |formatter|
|
|
13
|
-
|
|
13
|
+
Formatter.format(formatter, result)
|
|
14
14
|
rescue StandardError => e
|
|
15
15
|
warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{(_ = e.backtrace).first})")
|
|
16
16
|
nil
|
|
@@ -8,16 +8,19 @@ module SimpleCov
|
|
|
8
8
|
class SimpleFormatter
|
|
9
9
|
# Takes a SimpleCov::Result and generates a string out of it
|
|
10
10
|
def format(result)
|
|
11
|
-
|
|
11
|
+
criterion = SimpleCov.coverage_statistics_key(SimpleCov.primary_coverage)
|
|
12
|
+
result.groups.map { |name, files| format_group(name, files, criterion) }.join
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
private
|
|
15
16
|
|
|
16
|
-
def format_group(name, files)
|
|
17
|
+
def format_group(name, files, criterion)
|
|
17
18
|
header = "Group: #{name}\n#{'=' * 40}\n"
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
body
|
|
19
|
+
# The configured primary criterion is enabled, so every result file
|
|
20
|
+
# carries its corresponding statistic despite the nilable public API.
|
|
21
|
+
body = files.map do |file|
|
|
22
|
+
"#{file.filename} (coverage: #{(_ = file.covered_percent(criterion)).floor(2)}%)\n"
|
|
23
|
+
end.join
|
|
21
24
|
"#{header}#{body}\n"
|
|
22
25
|
end
|
|
23
26
|
end
|
data/lib/simplecov/formatter.rb
CHANGED
|
@@ -6,6 +6,18 @@ module SimpleCov
|
|
|
6
6
|
# and can be wired up via `SimpleCov.formatter=`.
|
|
7
7
|
# TODO: Documentation on how to build your own formatters
|
|
8
8
|
module Formatter
|
|
9
|
+
# Formatters can be configured either as classes (instantiated
|
|
10
|
+
# fresh for every report) or as ready-built instances — the only
|
|
11
|
+
# way to reach constructor options like
|
|
12
|
+
# `HTMLFormatter.new(silent: true)`. See #1240.
|
|
13
|
+
def self.instance_for(formatter)
|
|
14
|
+
formatter.is_a?(Class) ? formatter.new : formatter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Normalize a class or instance, then dispatch the result to it.
|
|
18
|
+
def self.format(formatter, result)
|
|
19
|
+
instance_for(formatter).format(result)
|
|
20
|
+
end
|
|
9
21
|
end
|
|
10
22
|
end
|
|
11
23
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
# Semantic names owned by result processing rather than user configuration.
|
|
5
|
+
module GroupNames
|
|
6
|
+
UNGROUPED = "Ungrouped"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Group names are Hash keys, report labels, and JSON object keys all at
|
|
11
|
+
# once, so they are normalized to Strings up front: a Symbol spelling
|
|
12
|
+
# (`group :Controllers`) means the String, and anything else has no
|
|
13
|
+
# sensible serialized form. Normalizing before `validate!` also keeps
|
|
14
|
+
# `group :Ungrouped` from slipping past the reservation below.
|
|
15
|
+
def normalize(group_name)
|
|
16
|
+
case group_name
|
|
17
|
+
when String then group_name
|
|
18
|
+
when Symbol then group_name.to_s
|
|
19
|
+
else
|
|
20
|
+
raise SimpleCov::ConfigurationError,
|
|
21
|
+
"Group names must be Strings, got #{group_name.inspect} (#{group_name.class})"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate!(group_names)
|
|
26
|
+
return group_names unless group_names.include?(UNGROUPED)
|
|
27
|
+
|
|
28
|
+
raise SimpleCov::ConfigurationError,
|
|
29
|
+
"#{UNGROUPED.inspect} is reserved for files that do not match a configured group"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
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
|