simplecov 1.1.1 → 1.2.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 +148 -5
- data/exe/simplecov +0 -3
- data/lib/minitest/simplecov_plugin.rb +12 -9
- data/lib/simplecov/atomic_file.rb +16 -19
- data/lib/simplecov/autostart.rb +5 -7
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +23 -24
- data/lib/simplecov/cli/command_helpers.rb +55 -19
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +25 -28
- data/lib/simplecov/cli/coverage_file.rb +58 -14
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +35 -67
- data/lib/simplecov/cli/dotfile.rb +83 -38
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +34 -39
- data/lib/simplecov/cli/open.rb +12 -15
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +135 -0
- data/lib/simplecov/cli/patch.rb +169 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +18 -24
- data/lib/simplecov/cli/run.rb +8 -9
- data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
- data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
- data/lib/simplecov/cli/serve.rb +27 -24
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +37 -0
- data/lib/simplecov/cli/uncovered.rb +70 -32
- data/lib/simplecov/cli/usage.rb +152 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +43 -92
- data/lib/simplecov/color.rb +19 -37
- data/lib/simplecov/combine/branches_combiner.rb +7 -31
- data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
- data/lib/simplecov/combine/identity_interner.rb +10 -13
- data/lib/simplecov/combine/interned_counts.rb +7 -9
- data/lib/simplecov/combine/lines_combiner.rb +20 -44
- data/lib/simplecov/combine/methods_combiner.rb +9 -35
- data/lib/simplecov/combine/results_combiner.rb +4 -21
- data/lib/simplecov/combine.rb +0 -6
- data/lib/simplecov/command_guesser.rb +20 -34
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +91 -79
- data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +6 -10
- data/lib/simplecov/configuration/filters.rb +40 -73
- data/lib/simplecov/configuration/formatting.rb +74 -83
- data/lib/simplecov/configuration/groups.rb +19 -13
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +31 -22
- data/lib/simplecov/configuration/merging.rb +94 -62
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +33 -62
- data/lib/simplecov/configuration/view_coverage.rb +38 -0
- data/lib/simplecov/configuration.rb +70 -76
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +2 -5
- data/lib/simplecov/coverage_statistics.rb +14 -24
- data/lib/simplecov/coverage_violations.rb +157 -64
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +4 -23
- data/lib/simplecov/deprecation.rb +45 -27
- data/lib/simplecov/directive.rb +46 -62
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +17 -11
- data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
- data/lib/simplecov/exit_codes.rb +16 -10
- data/lib/simplecov/exit_handling.rb +94 -59
- data/lib/simplecov/file_list.rb +20 -51
- data/lib/simplecov/filter.rb +43 -66
- data/lib/simplecov/formatter/base.rb +37 -55
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
- data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
- data/lib/simplecov/formatter/html_formatter.rb +24 -41
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
- data/lib/simplecov/formatter/json_formatter.rb +7 -13
- data/lib/simplecov/formatter/multi_formatter.rb +5 -11
- data/lib/simplecov/formatter/simple_formatter.rb +2 -8
- data/lib/simplecov/formatter.rb +14 -10
- data/lib/simplecov/group_names.rb +13 -12
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +6 -9
- data/lib/simplecov/lines_classifier.rb +31 -16
- data/lib/simplecov/load_global_config.rb +7 -7
- data/lib/simplecov/parallel_adapters/base.rb +13 -41
- data/lib/simplecov/parallel_adapters/generic.rb +7 -19
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
- data/lib/simplecov/parallel_adapters.rb +24 -55
- data/lib/simplecov/parallel_coordination.rb +39 -49
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +67 -126
- data/lib/simplecov/process.rb +14 -32
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/rails.rb +1 -12
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +9 -19
- data/lib/simplecov/profiles.rb +11 -33
- data/lib/simplecov/report_deferral.rb +11 -15
- data/lib/simplecov/report_stamp.rb +6 -9
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +7 -12
- data/lib/simplecov/result.rb +68 -117
- data/lib/simplecov/result_adapter.rb +56 -68
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
- data/lib/simplecov/result_merger/resultset_file.rb +20 -27
- data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
- data/lib/simplecov/result_merger/resultset_store.rb +23 -14
- data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
- data/lib/simplecov/result_merger.rb +59 -58
- data/lib/simplecov/result_processing.rb +74 -119
- data/lib/simplecov/run_identity.rb +16 -18
- data/lib/simplecov/simulate_coverage.rb +34 -50
- data/lib/simplecov/source_file/branch.rb +8 -33
- data/lib/simplecov/source_file/branch_builder.rb +28 -47
- data/lib/simplecov/source_file/builder_context.rb +6 -13
- data/lib/simplecov/source_file/line.rb +13 -33
- data/lib/simplecov/source_file/line_builder.rb +9 -15
- data/lib/simplecov/source_file/method.rb +3 -7
- data/lib/simplecov/source_file/method_builder.rb +6 -15
- data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
- data/lib/simplecov/source_file/skip_chunks.rb +19 -28
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +10 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
- data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
- data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
- data/lib/simplecov/static_coverage_extractor.rb +46 -62
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +18 -31
- data/lib/simplecov/useless_results_remover.rb +7 -6
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +1 -0
- data/lib/simplecov.rb +57 -113
- data/lib/simplecov_json_formatter.rb +1 -0
- data/man/simplecov.1 +349 -0
- data/sig/simplecov.rbs +523 -595
- metadata +72 -5
|
@@ -3,61 +3,87 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
5
|
require_relative "command_helpers"
|
|
6
|
+
require_relative "patch/output"
|
|
7
|
+
require_relative "show/annotator"
|
|
8
|
+
require_relative "uncovered/misses"
|
|
6
9
|
|
|
7
10
|
module SimpleCov
|
|
8
11
|
module CLI
|
|
9
|
-
# `simplecov uncovered
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# opening a browser. Reads coverage.json directly.
|
|
12
|
+
# `simplecov uncovered`: the lowest-coverage files by the chosen criterion,
|
|
13
|
+
# ascending, so a developer can answer "where should I add tests next?"
|
|
14
|
+
# without opening a browser.
|
|
13
15
|
module Uncovered
|
|
14
16
|
extend CommandHelpers
|
|
15
17
|
|
|
16
18
|
DEFAULT_TOP = 10
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
extend self
|
|
19
21
|
|
|
20
22
|
def run(args, stdout:, stderr:, **)
|
|
21
23
|
opts = parse(args)
|
|
22
|
-
|
|
23
|
-
return
|
|
24
|
+
issue = precheck(opts)
|
|
25
|
+
return error(stderr, issue) if issue
|
|
24
26
|
|
|
27
|
+
keys = CoverageFile::CRITERIA[opts.fetch(:criterion)]
|
|
28
|
+
return unknown_criterion(opts.fetch(:criterion), stderr) unless keys
|
|
29
|
+
|
|
30
|
+
opts[:missing] = true if opts.fetch(:annotate)
|
|
25
31
|
report(opts, keys, stdout, stderr)
|
|
26
32
|
end
|
|
27
33
|
|
|
34
|
+
def precheck(opts)
|
|
35
|
+
return nil unless opts.fetch(:annotate)
|
|
36
|
+
unless opts.fetch(:annotate).eql?("github")
|
|
37
|
+
return "unknown --annotate #{opts.fetch(:annotate).inspect} (only github is supported)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
"cannot combine --annotate with --json" if opts.fetch(:json)
|
|
41
|
+
end
|
|
42
|
+
|
|
28
43
|
def report(opts, keys, stdout, stderr)
|
|
29
|
-
coverage = CoverageFile.load_coverage(opts
|
|
44
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "uncovered", stderr: stderr)
|
|
30
45
|
return 1 unless coverage
|
|
31
46
|
|
|
32
|
-
files = rank(coverage, opts
|
|
33
|
-
return
|
|
47
|
+
files = rank(coverage, opts, keys).first(opts.fetch(:top))
|
|
48
|
+
return empty(opts, stdout) if files.empty?
|
|
34
49
|
|
|
35
50
|
emit(stdout, files, opts)
|
|
36
51
|
0
|
|
37
52
|
end
|
|
38
53
|
|
|
54
|
+
def empty(opts, stdout)
|
|
55
|
+
stdout.puts(empty_message(opts.fetch(:json))) unless opts.fetch(:annotate)
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
39
59
|
def unknown_criterion(criterion, stderr)
|
|
40
60
|
stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
|
|
41
61
|
1
|
|
42
62
|
end
|
|
43
63
|
|
|
44
64
|
def emit(stdout, files, opts)
|
|
45
|
-
|
|
65
|
+
return Misses.annotate(stdout, files) if opts.fetch(:annotate)
|
|
66
|
+
|
|
67
|
+
opts.fetch(:json) ? emit_json(stdout, files) : emit_text(stdout, files, CLI.color_enabled?(opts, stdout))
|
|
46
68
|
end
|
|
47
69
|
|
|
48
70
|
def parse(args)
|
|
49
|
-
opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line
|
|
50
|
-
|
|
51
|
-
o.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
|
|
52
|
-
o.on("--criterion C") { |v| options[:criterion] = v.to_sym }
|
|
53
|
-
end
|
|
71
|
+
opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line,
|
|
72
|
+
missing: false, annotate: nil) { |o, options| own_options(o, options) }
|
|
54
73
|
opts
|
|
55
74
|
end
|
|
56
75
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
def own_options(parser, options)
|
|
77
|
+
parser.on("--threshold N", Float) { |v| options[:threshold] = v }
|
|
78
|
+
parser.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
|
|
79
|
+
parser.on("--criterion C") { |v| options[:criterion] = v.to_sym }
|
|
80
|
+
parser.on("--missing") { options[:missing] = true }
|
|
81
|
+
parser.on("--annotate KIND") { |v| options[:annotate] = v }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A negative count would raise from `Array#first`, so it is reported as the
|
|
85
|
+
# parse error it is. `--top 0` is allowed and shows nothing. OptionParser
|
|
86
|
+
# prepends the offending option's name, so the raise carries only the reason.
|
|
61
87
|
def validate_top(value)
|
|
62
88
|
raise OptionParser::InvalidArgument, "must not be negative" if value.negative?
|
|
63
89
|
|
|
@@ -65,12 +91,18 @@ module SimpleCov
|
|
|
65
91
|
end
|
|
66
92
|
|
|
67
93
|
def emit_text(stdout, files, color)
|
|
68
|
-
files.each
|
|
94
|
+
files.each do |fname, pct, covered, total, missed|
|
|
95
|
+
row = format_row(fname, pct, covered, total, color)
|
|
96
|
+
row += " missing #{Patch::Output.ranges(missed, ",")}" if missed&.any?
|
|
97
|
+
stdout.puts(row)
|
|
98
|
+
end
|
|
69
99
|
end
|
|
70
100
|
|
|
71
101
|
def emit_json(stdout, files)
|
|
72
|
-
rows = files.map do |fname, pct, covered, total|
|
|
73
|
-
{"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
|
|
102
|
+
rows = files.map do |fname, pct, covered, total, missed|
|
|
103
|
+
row = {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
|
|
104
|
+
row["missing"] = missed if missed
|
|
105
|
+
row
|
|
74
106
|
end
|
|
75
107
|
stdout.puts(JSON.pretty_generate(rows))
|
|
76
108
|
end
|
|
@@ -79,24 +111,30 @@ module SimpleCov
|
|
|
79
111
|
json ? "[]" : "simplecov uncovered: nothing to report"
|
|
80
112
|
end
|
|
81
113
|
|
|
82
|
-
def rank(coverage_hash,
|
|
83
|
-
rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload,
|
|
114
|
+
def rank(coverage_hash, opts, keys)
|
|
115
|
+
rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, opts, keys) }
|
|
84
116
|
rows.sort_by { |_fname, pct, _c, _t| pct }
|
|
85
117
|
end
|
|
86
118
|
|
|
87
|
-
def row_for(fname, payload,
|
|
88
|
-
return unless payload.
|
|
119
|
+
def row_for(fname, payload, opts, keys)
|
|
120
|
+
return unless payload.instance_of?(Hash) && payload[keys.fetch(:total)].to_i.positive?
|
|
121
|
+
|
|
122
|
+
pct = payload[keys.fetch(:percent)].to_f
|
|
123
|
+
return if pct >= opts.fetch(:threshold)
|
|
89
124
|
|
|
90
|
-
pct
|
|
91
|
-
|
|
125
|
+
build_row(fname, pct, payload, opts, keys)
|
|
126
|
+
end
|
|
92
127
|
|
|
93
|
-
|
|
128
|
+
def build_row(fname, pct, payload, opts, keys)
|
|
129
|
+
row = [fname, pct, payload[keys.fetch(:covered)].to_i, payload.fetch(keys.fetch(:total)).to_i]
|
|
130
|
+
row << Misses.missed_for(payload, opts.fetch(:criterion)) if opts.fetch(:missing)
|
|
131
|
+
row
|
|
94
132
|
end
|
|
95
133
|
|
|
96
134
|
def format_row(fname, pct, covered, total, color)
|
|
97
135
|
format("%<pct>s %<covered>d/%<total>d %<fname>s",
|
|
98
|
-
|
|
99
|
-
|
|
136
|
+
pct: Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
|
|
137
|
+
covered: covered, total: total, fname: fname)
|
|
100
138
|
end
|
|
101
139
|
end
|
|
102
140
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Usage
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def for(cli, command)
|
|
9
|
+
sections = text(cli).split("\n\n")
|
|
10
|
+
row = sections.fetch(1).lines.find { |line| line.match?(/\A #{Regexp.escape(command)}[ \[]/) }
|
|
11
|
+
options = sections.select { |section| section_for?(section, command) }
|
|
12
|
+
["Usage: simplecov #{command} [options]", row&.strip, *options].compact.join("\n\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def section_for?(section, command)
|
|
16
|
+
header = heading_of(section)
|
|
17
|
+
header.end_with?("options:") && header.delete_suffix("options:").split(%r{[\s/]+}).include?(command)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def heading_of(section)
|
|
21
|
+
section.lines.first.to_s.strip
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A method rather than a constant so its default paths resolve at call time
|
|
25
|
+
# against the active `.simplecov`.
|
|
26
|
+
def text(cli)
|
|
27
|
+
<<~USAGE
|
|
28
|
+
Usage: simplecov <command> [options]
|
|
29
|
+
|
|
30
|
+
Commands:
|
|
31
|
+
run <command...> Execute <command> with SimpleCov pre-loaded
|
|
32
|
+
(works without a test_helper hook)
|
|
33
|
+
coverage <path> Print coverage stats for the given file
|
|
34
|
+
show <path> Print the file's source annotated with hit counts and misses
|
|
35
|
+
report Print the overall summary and group totals
|
|
36
|
+
status Report freshness: age, commit distance, recorded tests
|
|
37
|
+
history Print the recorded coverage trend, a sparkline per criterion
|
|
38
|
+
badge Render the coverage percent as an SVG badge
|
|
39
|
+
uncovered List the lowest-coverage files
|
|
40
|
+
tests [<path>[:<line>]] List recorded tests for a file or line (needs track_tests)
|
|
41
|
+
affected List test files touching code changed since a git ref (needs track_tests)
|
|
42
|
+
merge <files...> Merge multiple .resultset.json files
|
|
43
|
+
diff <baseline> Show per-file coverage delta vs baseline
|
|
44
|
+
patch Show coverage of the lines a change touched
|
|
45
|
+
ratchet Rewrite the per-file coverage baseline, only ever tightening
|
|
46
|
+
dead-code Cross production coverage with the report to find dead code
|
|
47
|
+
open Open the HTML report in the default browser
|
|
48
|
+
serve Serve the coverage report over HTTP
|
|
49
|
+
watch <command...> Re-run <command> on save and live-reload the served report
|
|
50
|
+
clean Remove the coverage report directory
|
|
51
|
+
completions <shell> Emit the completion script for fish, bash, or zsh
|
|
52
|
+
help Show this message
|
|
53
|
+
version Print the version (also --version / -v)
|
|
54
|
+
|
|
55
|
+
Every command answers `--help` / `-h`. Default paths follow a project's
|
|
56
|
+
`.simplecov` SimpleCov.coverage_dir (#{cli.coverage_dir} for this run).
|
|
57
|
+
|
|
58
|
+
coverage / show / report / status / uncovered / tests / affected / diff / patch / ratchet / dead-code options:
|
|
59
|
+
--input PATH Read from PATH instead of #{cli.default_input}
|
|
60
|
+
--no-color Disable colorized percentages (also honors NO_COLOR / FORCE_COLOR env)
|
|
61
|
+
|
|
62
|
+
coverage options:
|
|
63
|
+
--json Print the file's JSON entry verbatim
|
|
64
|
+
|
|
65
|
+
show options:
|
|
66
|
+
--uncovered-only Print only <path>:<ranges> of the missed lines
|
|
67
|
+
--json Emit path, missed lines, per-line hits, and markers as JSON
|
|
68
|
+
|
|
69
|
+
report options:
|
|
70
|
+
--json Emit totals and group sections as JSON
|
|
71
|
+
|
|
72
|
+
status options:
|
|
73
|
+
--json Emit the freshness facts as a JSON object
|
|
74
|
+
|
|
75
|
+
history options:
|
|
76
|
+
--input PATH Read from PATH instead of the coverage directory's .history.json
|
|
77
|
+
--file PATH Follow one file's trajectory instead of the totals
|
|
78
|
+
--json Emit the entries (or the file's trajectory) as JSON
|
|
79
|
+
--no-color Disable colorized deltas (also honors NO_COLOR / FORCE_COLOR env)
|
|
80
|
+
|
|
81
|
+
badge options:
|
|
82
|
+
--input PATH Read from PATH instead of #{cli.default_input}
|
|
83
|
+
--output PATH Write the badge to PATH instead of stdout
|
|
84
|
+
--criterion C line, branch, or method (default: line)
|
|
85
|
+
--label TEXT The badge's left-side text (default: the criterion's, like "line coverage")
|
|
86
|
+
|
|
87
|
+
uncovered options:
|
|
88
|
+
--threshold N Only show files below N% coverage
|
|
89
|
+
--top N Show at most N files (default: 10)
|
|
90
|
+
--criterion C line, branch, or method (default: line)
|
|
91
|
+
--missing Append the missed line ranges to each row
|
|
92
|
+
--annotate github Emit ::warning workflow commands instead of rows
|
|
93
|
+
--json Emit results as a JSON array (for CI)
|
|
94
|
+
|
|
95
|
+
tests options:
|
|
96
|
+
--redundant List only tests whose covered lines other tests also cover
|
|
97
|
+
--json Emit the test ids as a JSON array
|
|
98
|
+
|
|
99
|
+
affected options:
|
|
100
|
+
--base REF Diff against the merge base of REF and HEAD (default: origin's HEAD, else main)
|
|
101
|
+
--run <command...> Run <command> (all args after --run) with the selection appended, or bare on a full-suite fallback, exiting with the command's status
|
|
102
|
+
--json Emit {full_suite, triggers, tests} as a JSON object
|
|
103
|
+
|
|
104
|
+
merge options:
|
|
105
|
+
--output PATH Write merged resultset to PATH (default: #{cli.default_resultset})
|
|
106
|
+
--honor-timeout Drop entries older than merge_timeout
|
|
107
|
+
--dry-run Print what would be written without writing
|
|
108
|
+
-q, --quiet Suppress the success status line
|
|
109
|
+
|
|
110
|
+
diff options:
|
|
111
|
+
--fail-on-drop Exit non-zero when any file's coverage dropped vs baseline (deletions don't count)
|
|
112
|
+
--json Emit results as a JSON array (for CI)
|
|
113
|
+
--threshold N Only show files whose absolute delta in any criterion is at least N%
|
|
114
|
+
|
|
115
|
+
patch options:
|
|
116
|
+
--base REF Diff against the merge-base of REF for the touched lines (default: origin's HEAD, else main, or in CI the PR's target branch)
|
|
117
|
+
--minimum N Exit non-zero when patch coverage on any measured criterion is below N%
|
|
118
|
+
--find-renames Follow a renamed file instead of counting the moved file as all-new
|
|
119
|
+
--json Emit results as a JSON array (for CI)
|
|
120
|
+
|
|
121
|
+
dead-code options:
|
|
122
|
+
--production PATH The production coverage file a SimpleCov::Production sink wrote (default: the project's `production_coverage`, required when none is configured)
|
|
123
|
+
--untested-in-production Print code production runs that no test covers, instead of the dead rows
|
|
124
|
+
--json Emit every category (dead, possibly dead, untested in production) as JSON
|
|
125
|
+
|
|
126
|
+
ratchet options:
|
|
127
|
+
--baseline PATH The baseline file to rewrite (default: the project's `baseline_file`, .simplecov_baseline.yml)
|
|
128
|
+
--init Regenerate from scratch: add entries for new files and reset every floor to the current state
|
|
129
|
+
--dry-run Print what would change without writing
|
|
130
|
+
--json Emit the summary (tightened / pruned / regressed entries) as JSON
|
|
131
|
+
|
|
132
|
+
open options:
|
|
133
|
+
--report PATH Open PATH instead of #{cli.default_report}
|
|
134
|
+
|
|
135
|
+
serve options:
|
|
136
|
+
--port N Bind to port N (default: random open port)
|
|
137
|
+
--host HOST Bind to HOST (default: 127.0.0.1)
|
|
138
|
+
|
|
139
|
+
watch options:
|
|
140
|
+
--port N Bind to port N (default: random open port)
|
|
141
|
+
--host HOST Bind to HOST (default: 127.0.0.1)
|
|
142
|
+
--interval SECONDS Poll the tracked files this often (default: 0.5)
|
|
143
|
+
--open Open the served report in the default browser
|
|
144
|
+
|
|
145
|
+
clean options:
|
|
146
|
+
--dry-run Print what would be removed without deleting
|
|
147
|
+
-q, --quiet Suppress status lines
|
|
148
|
+
USAGE
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Watch
|
|
6
|
+
# The live half of the watch server: an /events endpoint streaming
|
|
7
|
+
# server-sent events to every open report tab, and an index route that
|
|
8
|
+
# appends the reload listener to the artifact on the way out, so the report
|
|
9
|
+
# on disk stays byte-identical to a plain run's.
|
|
10
|
+
class LiveReport
|
|
11
|
+
RELOAD_SCRIPT = "\n<script>new EventSource('/events').onmessage = () => location.reload();</script>\n"
|
|
12
|
+
|
|
13
|
+
def initialize(dir)
|
|
14
|
+
@dir = dir
|
|
15
|
+
@queues = [] #: Array[Thread::Queue]
|
|
16
|
+
@lock = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def routes
|
|
20
|
+
page = method(:page)
|
|
21
|
+
{"/events" => method(:stream), "/" => page, "/index.html" => page}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def broadcast(event = "reload")
|
|
25
|
+
with_queues { |queues| queues.each { |queue| queue << event } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Holds the connection open, forwarding each broadcast as one SSE message,
|
|
31
|
+
# until the tab goes away and the write fails. A closed tab that never sees
|
|
32
|
+
# another broadcast parks its thread on the queue, a leak the dev-server
|
|
33
|
+
# trade accepts.
|
|
34
|
+
def stream(client)
|
|
35
|
+
queue = Queue.new #: Thread::Queue
|
|
36
|
+
begin
|
|
37
|
+
with_queues { |queues| queues << queue }
|
|
38
|
+
forward(client, queue)
|
|
39
|
+
ensure
|
|
40
|
+
with_queues { |queues| queues.delete(queue) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def forward(client, queue)
|
|
45
|
+
client.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" \
|
|
46
|
+
"Cache-Control: no-cache\r\n\r\n")
|
|
47
|
+
loop { client.write("data: #{next_event(queue)}\n\n") }
|
|
48
|
+
rescue IOError, SystemCallError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# mutant:disable
|
|
53
|
+
def next_event(queue)
|
|
54
|
+
queue.pop
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def page(client)
|
|
58
|
+
body = File.binread(File.join(@dir, "index.html")) + RELOAD_SCRIPT
|
|
59
|
+
Serve::StaticFileHandler.respond(client, 200, body, "text/html; charset=utf-8")
|
|
60
|
+
rescue SystemCallError
|
|
61
|
+
Serve::StaticFileHandler.respond(client, 404)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# mutant:disable
|
|
65
|
+
# Every touch of the queue registry goes through the lock: an accept thread
|
|
66
|
+
# adds and drops its own queue while the session thread walks them all.
|
|
67
|
+
def with_queues
|
|
68
|
+
@lock.synchronize { yield @queues }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Watch
|
|
6
|
+
class Narrator
|
|
7
|
+
def initialize(stdout, root)
|
|
8
|
+
@stdout = stdout
|
|
9
|
+
@root = root
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def banner(server, count)
|
|
13
|
+
host = Serve.url_host(server.addr.fetch(3))
|
|
14
|
+
@stdout.puts("watching #{count} file#{"s" unless count.eql?(1)}, " \
|
|
15
|
+
"serving http://#{host}:#{server.addr.fetch(1)}/")
|
|
16
|
+
@stdout.puts("Press Ctrl-C to stop.")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def change(changed, plan)
|
|
20
|
+
named = name(changed)
|
|
21
|
+
return @stdout.puts("#{named} changed, no recorded test touches it") unless plan.fetch(:run)
|
|
22
|
+
|
|
23
|
+
@stdout.print("#{named} changed, #{action(plan.fetch(:tests))}...")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def result(before, after)
|
|
27
|
+
return @stdout.puts unless after
|
|
28
|
+
|
|
29
|
+
delta = before ? format(" (%+.2f%%)", after - before) : ""
|
|
30
|
+
@stdout.puts(format(" %.2f%%", after) + delta)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def failed_refresh
|
|
34
|
+
@stdout.puts(" the report did not regenerate")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stopping
|
|
38
|
+
@stdout.puts("\nsimplecov watch: stopping")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def name(changed)
|
|
44
|
+
relative = changed.map { |path| path.delete_prefix("#{@root}/") }
|
|
45
|
+
named = relative.first
|
|
46
|
+
relative.size.eql?(1) ? named : "#{named} and #{relative.size - 1} more"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def action(tests)
|
|
50
|
+
tests ? "running #{tests.size} file#{"s" unless tests.one?}" : "running the full suite"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Watch
|
|
6
|
+
# Watches a fixed set of paths by polling mtimes, the trade chosen over a
|
|
7
|
+
# filesystem-event dependency for a command that is optional to begin with.
|
|
8
|
+
# Editors that save through a temporary file plus rename still move the final
|
|
9
|
+
# path's mtime, which is all this looks at.
|
|
10
|
+
class Poller
|
|
11
|
+
def initialize
|
|
12
|
+
@snapshot = {} #: Hash[String, untyped]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Replaces the watched set, snapshotting current mtimes. A path with no file
|
|
16
|
+
# snapshots as nil, so appearing counts as a change.
|
|
17
|
+
def watch(paths)
|
|
18
|
+
@snapshot = paths.to_h { |path| [path, stamp(path)] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def size
|
|
22
|
+
@snapshot.size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def changes
|
|
26
|
+
changed = [] #: Array[String]
|
|
27
|
+
@snapshot.each_key do |path|
|
|
28
|
+
now = stamp(path)
|
|
29
|
+
next if now.eql?(@snapshot.fetch(path))
|
|
30
|
+
|
|
31
|
+
@snapshot[path] = now
|
|
32
|
+
changed << path
|
|
33
|
+
end
|
|
34
|
+
changed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stamp(path)
|
|
38
|
+
File.mtime(path)
|
|
39
|
+
rescue SystemCallError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Watch
|
|
6
|
+
# One watch session: accept report connections in the background, poll the
|
|
7
|
+
# tracked set for changes, re-run tests on save (selecting over the recorded
|
|
8
|
+
# test map when the report carries one), and push a reload to every open tab
|
|
9
|
+
# when the report regenerates.
|
|
10
|
+
class Session
|
|
11
|
+
# The merge window the child runs get through SIMPLECOV_MERGE_TIMEOUT: a
|
|
12
|
+
# day, so subset re-runs keep merging into a whole report across a long
|
|
13
|
+
# session instead of eroding it after the default ten minutes.
|
|
14
|
+
MERGE_WINDOW = "86400"
|
|
15
|
+
|
|
16
|
+
TOTALS_KEYS = {"line" => "lines", "branch" => "branches", "method" => "methods"}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(command:, dir:, interval:, stdout:, stderr:)
|
|
19
|
+
@command = command
|
|
20
|
+
@dir = dir
|
|
21
|
+
@interval = interval
|
|
22
|
+
@stderr = stderr
|
|
23
|
+
@root = Dir.pwd
|
|
24
|
+
@narrator = Narrator.new(stdout, @root)
|
|
25
|
+
@live = LiveReport.new(dir)
|
|
26
|
+
@poller = Poller.new
|
|
27
|
+
@document = {} #: Hash[String, untyped]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(server)
|
|
31
|
+
accept_loop(server)
|
|
32
|
+
run_tests(nil) unless File.exist?(json_path)
|
|
33
|
+
return 1 unless refresh
|
|
34
|
+
|
|
35
|
+
@narrator.banner(server, @poller.size)
|
|
36
|
+
poll_forever
|
|
37
|
+
rescue Interrupt
|
|
38
|
+
@narrator.stopping
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def poll_forever
|
|
43
|
+
loop do
|
|
44
|
+
sleep(@interval)
|
|
45
|
+
step
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def step
|
|
50
|
+
changed = settled_changes
|
|
51
|
+
return if changed.empty?
|
|
52
|
+
|
|
53
|
+
plan = plan_for(changed)
|
|
54
|
+
@narrator.change(changed, plan)
|
|
55
|
+
return unless plan.fetch(:run)
|
|
56
|
+
|
|
57
|
+
before = total_percent
|
|
58
|
+
run_tests(plan.fetch(:tests))
|
|
59
|
+
return @narrator.failed_refresh unless refresh
|
|
60
|
+
|
|
61
|
+
@live.broadcast
|
|
62
|
+
@narrator.result(before, total_percent)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def accept_loop(server)
|
|
68
|
+
Thread.new do
|
|
69
|
+
loop do
|
|
70
|
+
Thread.new(server.accept) do |client|
|
|
71
|
+
Serve::StaticFileHandler.handle_connection(client, @dir, @live.routes)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
rescue IOError, SystemCallError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def plan_for(changed)
|
|
80
|
+
relative = changed.map { |path| path.delete_prefix("#{@root}/") }
|
|
81
|
+
TestPlan.build(relative, @document, root: @root, input: json_path, stderr: @stderr)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The command is the project's own test invocation and must produce the
|
|
85
|
+
# report itself. A selection is appended to it; no selection means the whole
|
|
86
|
+
# suite, which is the command on its own.
|
|
87
|
+
def run_tests(tests)
|
|
88
|
+
env = {"SIMPLECOV_MERGE_TIMEOUT" => MERGE_WINDOW}
|
|
89
|
+
Kernel.system(env, *(tests ? @command + tests : @command))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The regenerated report is both the watch set and the totals the result line
|
|
93
|
+
# diffs against. The previous document is kept when the read fails, so one
|
|
94
|
+
# bad write doesn't blind the session.
|
|
95
|
+
def refresh
|
|
96
|
+
document = CoverageFile.load_document(json_path, command: "watch", stderr: @stderr)
|
|
97
|
+
return false unless document
|
|
98
|
+
|
|
99
|
+
@document = document
|
|
100
|
+
@poller.watch(TestPlan.watched_paths(document, @root))
|
|
101
|
+
document
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Editors save in bursts, so after the first detection keep polling until a
|
|
105
|
+
# full interval passes with nothing new.
|
|
106
|
+
def settled_changes
|
|
107
|
+
changed = @poller.changes
|
|
108
|
+
return changed if changed.empty?
|
|
109
|
+
|
|
110
|
+
loop do
|
|
111
|
+
sleep(@interval)
|
|
112
|
+
more = @poller.changes
|
|
113
|
+
break if more.empty?
|
|
114
|
+
|
|
115
|
+
changed |= more
|
|
116
|
+
end
|
|
117
|
+
changed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Follows the report's own primary criterion, falling back to line coverage
|
|
121
|
+
# for documents from before meta carried one.
|
|
122
|
+
def total_percent
|
|
123
|
+
key = TOTALS_KEYS[@document.dig("meta", "primary_coverage")] || "lines"
|
|
124
|
+
value = @document.dig("total", key, "percent")
|
|
125
|
+
(_ = value).to_f if value.is_a?(Numeric)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def json_path
|
|
129
|
+
File.join(@dir, "coverage.json")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Watch
|
|
6
|
+
module TestPlan
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# {run:, tests:} where nil tests mean the full command. A report with no map,
|
|
10
|
+
# a malformed one, or any fail-open trigger runs everything; an empty
|
|
11
|
+
# selection runs nothing.
|
|
12
|
+
def build(changed, document, root:, input:, stderr:)
|
|
13
|
+
contexts = document["contexts"]
|
|
14
|
+
return {run: true, tests: nil} unless contexts.instance_of?(Array) && contexts.all?(String)
|
|
15
|
+
|
|
16
|
+
selection = Affected::Selection.build(changed, document, contexts, {input: input}, stderr, root: root)
|
|
17
|
+
return {run: true, tests: nil} if selection.nil? || !selection.fetch(:triggers).empty?
|
|
18
|
+
return {run: false, tests: []} if selection.fetch(:tests).empty?
|
|
19
|
+
|
|
20
|
+
{run: true, tests: selection.fetch(:tests)}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The watch set a report defines: its tracked files plus the files of its
|
|
24
|
+
# recorded tests.
|
|
25
|
+
def watched_paths(document, root)
|
|
26
|
+
none = [] #: Array[String]
|
|
27
|
+
coverage = document["coverage"]
|
|
28
|
+
sources = coverage.instance_of?(Hash) ? coverage.keys : none
|
|
29
|
+
tests = recorded_test_files(document).map { |path| File.expand_path(path, root) }
|
|
30
|
+
(sources + tests).uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def recorded_test_files(document)
|
|
34
|
+
none = [] #: Array[String]
|
|
35
|
+
contexts = document["contexts"]
|
|
36
|
+
return none unless contexts.instance_of?(Array)
|
|
37
|
+
|
|
38
|
+
contexts.filter_map { |id| Affected::Selection.test_file_of(id.to_s) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|