simplecov 0.22.0 → 1.1.1
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/LICENSE +1 -1
- data/README.md +71 -916
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +87 -0
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +86 -0
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +147 -0
- data/lib/simplecov/cli/dotfile.rb +111 -0
- data/lib/simplecov/cli/merge.rb +120 -0
- data/lib/simplecov/cli/open.rb +48 -0
- data/lib/simplecov/cli/report.rb +97 -0
- data/lib/simplecov/cli/run.rb +36 -0
- 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 +85 -0
- data/lib/simplecov/cli/uncovered.rb +103 -0
- data/lib/simplecov/cli.rb +163 -0
- data/lib/simplecov/color.rb +83 -0
- data/lib/simplecov/combine/branches_combiner.rb +54 -7
- 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 +53 -23
- data/lib/simplecov/combine/methods_combiner.rb +74 -0
- data/lib/simplecov/combine/results_combiner.rb +13 -36
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +104 -33
- data/lib/simplecov/configuration/coverage.rb +172 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +164 -0
- data/lib/simplecov/configuration/formatting.rb +138 -0
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +165 -0
- data/lib/simplecov/configuration/thresholds.rb +179 -0
- data/lib/simplecov/configuration.rb +83 -420
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +16 -10
- data/lib/simplecov/coverage_violations.rb +159 -0
- data/lib/simplecov/defaults.rb +34 -23
- data/lib/simplecov/deprecation.rb +45 -0
- data/lib/simplecov/directive.rb +167 -0
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
- data/lib/simplecov/exit_codes.rb +20 -0
- data/lib/simplecov/exit_handling.rb +128 -0
- data/lib/simplecov/file_list.rb +70 -24
- data/lib/simplecov/filter.rb +105 -26
- data/lib/simplecov/formatter/base.rb +118 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +105 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
- data/lib/simplecov/formatter/json_formatter.rb +46 -0
- data/lib/simplecov/formatter/multi_formatter.rb +12 -8
- data/lib/simplecov/formatter/simple_formatter.rb +14 -11
- data/lib/simplecov/formatter.rb +16 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +18 -4
- data/lib/simplecov/lines_classifier.rb +48 -15
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +78 -0
- data/lib/simplecov/parallel_adapters/generic.rb +45 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
- data/lib/simplecov/parallel_adapters.rb +85 -0
- data/lib/simplecov/parallel_coordination.rb +134 -0
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/process.rb +52 -12
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +24 -10
- data/lib/simplecov/profiles/root_filter.rb +6 -5
- data/lib/simplecov/profiles/strict.rb +32 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +32 -3
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +145 -26
- data/lib/simplecov/result_adapter.rb +139 -8
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +74 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +53 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +109 -114
- data/lib/simplecov/result_processing.rb +218 -0
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +83 -12
- data/lib/simplecov/source_file/branch.rb +1 -3
- data/lib/simplecov/source_file/branch_builder.rb +114 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +9 -4
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +58 -0
- data/lib/simplecov/source_file/method_builder.rb +60 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +79 -0
- data/lib/simplecov/source_file/statistics.rb +65 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
- data/lib/simplecov/static_coverage_extractor.rb +110 -0
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/useless_results_remover.rb +16 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov-html.rb +4 -0
- data/lib/simplecov.rb +185 -380
- data/lib/simplecov_json_formatter.rb +4 -0
- data/sig/simplecov.rbs +1777 -0
- metadata +111 -62
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../atomic_file"
|
|
6
|
+
require_relative "command_helpers"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov merge <files...>` — wrap SimpleCov::ResultMerger so a
|
|
11
|
+
# CI matrix that produces one .resultset.json per worker can stitch
|
|
12
|
+
# them together from the shell instead of dropping a Rake task into
|
|
13
|
+
# every project. Requires the full simplecov library to be on the
|
|
14
|
+
# load path; lazy-required so the read-only subcommands above don't
|
|
15
|
+
# pay for ResultMerger (and its Coverage runtime guard).
|
|
16
|
+
module Merge
|
|
17
|
+
extend CommandHelpers
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(args, stdout:, stderr:, **)
|
|
22
|
+
opts = parse(args)
|
|
23
|
+
return error(stderr, "missing input files") if opts[:files].empty?
|
|
24
|
+
return 1 unless valid_inputs?(opts[:files], stderr)
|
|
25
|
+
|
|
26
|
+
require "simplecov"
|
|
27
|
+
result = SimpleCov::ResultMerger.merge_results(*opts[:files], ignore_timeout: !opts[:honor_timeout])
|
|
28
|
+
return error(stderr, "no mergeable results in input files") unless result
|
|
29
|
+
|
|
30
|
+
commit(opts, result, stdout)
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def commit(opts, result, stdout)
|
|
35
|
+
verb = opts[:dry_run] ? "would write" : "wrote"
|
|
36
|
+
write(opts[:output], result) unless opts[:dry_run]
|
|
37
|
+
stdout.puts("simplecov merge: #{verb} #{opts[:output]}") unless opts[:quiet]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid_inputs?(files, stderr)
|
|
41
|
+
parsed = parse_inputs(files, stderr) or return false
|
|
42
|
+
|
|
43
|
+
warn_about_duplicate_command_names(parsed, stderr)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse(args)
|
|
48
|
+
opts = {output: SimpleCov::CLI.default_resultset, honor_timeout: false, dry_run: false, quiet: false}
|
|
49
|
+
files =
|
|
50
|
+
OptionParser.new do |o|
|
|
51
|
+
o.on("--output PATH") { |v| opts[:output] = v }
|
|
52
|
+
o.on("--honor-timeout") { opts[:honor_timeout] = true }
|
|
53
|
+
o.on("--dry-run") { opts[:dry_run] = true }
|
|
54
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
55
|
+
end.parse(args)
|
|
56
|
+
opts.merge(files: files)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Validate every input file up-front and return a {path => parsed}
|
|
60
|
+
# hash. Surfacing per-file errors here turns ResultMerger's
|
|
61
|
+
# generic "no mergeable results" into a message that points at
|
|
62
|
+
# the specific input causing the failure.
|
|
63
|
+
def parse_inputs(files, stderr)
|
|
64
|
+
parsed = {} #: Hash[String, Hash[String, untyped]]
|
|
65
|
+
files.each_with_object(parsed) do |path, memo|
|
|
66
|
+
data = parse_input(path, stderr) or return nil
|
|
67
|
+
|
|
68
|
+
memo[path] = data
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Read first, classify by the exception: an exist?-then-read pair
|
|
73
|
+
# is racy, and rescuing only ENOENT crashed with a backtrace on a
|
|
74
|
+
# directory (EISDIR) or an unreadable file (EACCES).
|
|
75
|
+
def parse_input(path, stderr)
|
|
76
|
+
data = JSON.parse(File.read(path))
|
|
77
|
+
return data if data.is_a?(Hash) && !data.empty?
|
|
78
|
+
|
|
79
|
+
parse_input_error(stderr, path, "has no resultset entries")
|
|
80
|
+
rescue Errno::ENOENT
|
|
81
|
+
parse_input_error(stderr, path, "not found")
|
|
82
|
+
rescue JSON::ParserError => e
|
|
83
|
+
parse_input_error(stderr, path, "isn't valid JSON (#{e.message})")
|
|
84
|
+
rescue SystemCallError => e
|
|
85
|
+
parse_input_error(stderr, path, "cannot be read (#{e.message.lines.first.to_s.strip})")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_input_error(stderr, path, reason)
|
|
89
|
+
stderr.puts("simplecov merge: input file #{path.inspect} #{reason}")
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# When two input files share a command_name, ResultMerger folds
|
|
94
|
+
# them together with last-write-wins on the timestamp — easy to
|
|
95
|
+
# mistake for "no merge happened." Surface the overlap so the
|
|
96
|
+
# operator can rename the workers or accept the merge knowingly.
|
|
97
|
+
def warn_about_duplicate_command_names(parsed, stderr)
|
|
98
|
+
files_per_command = {} #: Hash[String, Array[String]]
|
|
99
|
+
parsed.each do |path, data|
|
|
100
|
+
data.each_key { |command_name| (files_per_command[command_name] ||= []) << path }
|
|
101
|
+
end
|
|
102
|
+
files_per_command.each do |command_name, paths|
|
|
103
|
+
next if paths.size < 2
|
|
104
|
+
|
|
105
|
+
stderr.puts(duplicate_warning(command_name, paths))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def duplicate_warning(command_name, paths)
|
|
110
|
+
"simplecov merge: warning — command_name #{command_name.inspect} " \
|
|
111
|
+
"appears in #{paths.size} input files (#{paths.join(', ')}); " \
|
|
112
|
+
"entries will be merged"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def write(path, result)
|
|
116
|
+
AtomicFile.write(path, JSON.pretty_generate(result.to_hash))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov open [--report PATH]` — open the HTML report in the
|
|
9
|
+
# platform's default browser. Tiny QoL wrapper around `xdg-open` /
|
|
10
|
+
# `open` / `start` so users don't have to type a file:// URL.
|
|
11
|
+
module Open
|
|
12
|
+
extend CommandHelpers
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run(args, stderr:, **)
|
|
17
|
+
path = parse(args)
|
|
18
|
+
return error(stderr, "#{path} not found") unless File.exist?(path)
|
|
19
|
+
|
|
20
|
+
opener = browser_opener
|
|
21
|
+
return error(stderr, "no known opener for #{RbConfig::CONFIG['host_os']}") unless opener
|
|
22
|
+
|
|
23
|
+
system(*opener, path) ? 0 : 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(args)
|
|
27
|
+
path = SimpleCov::CLI.default_report
|
|
28
|
+
OptionParser.new do |o|
|
|
29
|
+
o.on("--report PATH") { |v| path = v }
|
|
30
|
+
end.parse(args)
|
|
31
|
+
path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the argv for the platform's "open this file" command, or
|
|
35
|
+
# nil if the host OS isn't recognized. On Windows, `start` is a
|
|
36
|
+
# cmd.exe builtin (not an executable), so route through `cmd /c`;
|
|
37
|
+
# the empty string is the window-title positional `start` takes
|
|
38
|
+
# before the path so a quoted path isn't mis-parsed as the title.
|
|
39
|
+
def browser_opener
|
|
40
|
+
case RbConfig::CONFIG["host_os"]
|
|
41
|
+
when /darwin/ then ["open"]
|
|
42
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
|
|
43
|
+
when /linux|bsd|solaris/ then ["xdg-open"]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
# `simplecov report [--input PATH]` — pretty-print the overall
|
|
10
|
+
# totals row plus per-group totals from a JSONFormatter
|
|
11
|
+
# coverage.json. Same numbers as the HTML report's totals row, for
|
|
12
|
+
# contexts where opening a browser isn't an option (CI logs, ssh
|
|
13
|
+
# sessions, terminal-only workflows).
|
|
14
|
+
module Report
|
|
15
|
+
extend CommandHelpers
|
|
16
|
+
|
|
17
|
+
TOTAL_LABEL = "All Files"
|
|
18
|
+
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def run(args, stdout:, stderr:)
|
|
23
|
+
opts, = parse_common(args)
|
|
24
|
+
return 1 unless (data = load_data(opts[:input], stderr))
|
|
25
|
+
|
|
26
|
+
if opts[:json]
|
|
27
|
+
emit_json(stdout, data)
|
|
28
|
+
else
|
|
29
|
+
emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
30
|
+
end
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Valid JSON isn't enough: a wrong-typed "total" or "groups" (or a
|
|
35
|
+
# wrong-typed group entry) used to escape here and crash the
|
|
36
|
+
# emitters with a backtrace instead of the one-line error the
|
|
37
|
+
# sibling commands print.
|
|
38
|
+
def load_data(input, stderr)
|
|
39
|
+
data = CoverageFile.load_document(input, command: "report", stderr: stderr)
|
|
40
|
+
return unless data
|
|
41
|
+
|
|
42
|
+
none = {} #: Hash[String, untyped]
|
|
43
|
+
unless data.fetch("total", none).is_a?(Hash)
|
|
44
|
+
return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
groups = data.fetch("groups", none)
|
|
48
|
+
return data if groups.is_a?(Hash) && groups.each_value.all?(Hash)
|
|
49
|
+
|
|
50
|
+
CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emit_text(stdout, data, color)
|
|
54
|
+
none = {} #: Hash[String, untyped]
|
|
55
|
+
emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
|
|
56
|
+
data.fetch("groups", none).each do |name, group|
|
|
57
|
+
label = name == TOTAL_LABEL ? "#{name} (group)" : name
|
|
58
|
+
emit_totals(stdout, label, group, color)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def emit_totals(stdout, label, totals, color)
|
|
63
|
+
stdout.puts(label)
|
|
64
|
+
SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
|
|
65
|
+
stdout.puts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def emit_section(stdout, display, section, color)
|
|
69
|
+
return unless section.is_a?(Hash) && section["total"].to_i.positive?
|
|
70
|
+
|
|
71
|
+
stdout.puts(stats_row(display,
|
|
72
|
+
SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
|
|
73
|
+
section["covered"],
|
|
74
|
+
section["total"]))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def emit_json(stdout, data)
|
|
78
|
+
none = {} #: Hash[String, untyped]
|
|
79
|
+
groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
|
|
80
|
+
payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
|
|
81
|
+
stdout.puts(JSON.pretty_generate(payload))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def collect_section(totals)
|
|
85
|
+
sections = {} #: Hash[String, untyped]
|
|
86
|
+
SECTIONS.each_with_object(sections) do |(_, key), out|
|
|
87
|
+
section = totals[key]
|
|
88
|
+
next unless section.is_a?(Hash) && section["total"].to_i.positive?
|
|
89
|
+
|
|
90
|
+
out[key.to_s] = {
|
|
91
|
+
"percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
# `simplecov run <command...>` — exec the given command with
|
|
6
|
+
# simplecov auto-loaded so a coverage report drops into the
|
|
7
|
+
# project's coverage/ directory at the end. Useful for projects
|
|
8
|
+
# without a test_helper that already calls SimpleCov.start (e.g.
|
|
9
|
+
# plain `bundle exec rake test` on an unconfigured library).
|
|
10
|
+
module Run
|
|
11
|
+
AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def run(args, stderr:, **)
|
|
16
|
+
cmd = args.first == "--" ? args.drop(1) : args
|
|
17
|
+
if cmd.empty?
|
|
18
|
+
stderr.puts("simplecov run: missing command")
|
|
19
|
+
return 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Kernel.exec(rubyopt_env, *cmd)
|
|
23
|
+
rescue Errno::ENOENT => e
|
|
24
|
+
stderr.puts("simplecov run: #{e.message}")
|
|
25
|
+
127
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def rubyopt_env
|
|
29
|
+
existing = ENV["RUBYOPT"].to_s.strip
|
|
30
|
+
injection = "-r#{AUTOSTART}"
|
|
31
|
+
merged = existing.empty? ? injection : "#{existing} #{injection}"
|
|
32
|
+
ENV.to_hash.merge("RUBYOPT" => merged)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../coverage_json"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
module Serve
|
|
8
|
+
# Ensures the coverage directory has a renderable entry point before
|
|
9
|
+
# the HTTP server binds. Existing self-contained reports take priority
|
|
10
|
+
# over their optional JSON sidecar.
|
|
11
|
+
module ReportPreparer
|
|
12
|
+
def self.call(dir)
|
|
13
|
+
index_path = File.join(dir, "index.html")
|
|
14
|
+
json_path = File.join(dir, "coverage.json")
|
|
15
|
+
return "#{dir} doesn't exist; run your test suite first" unless File.directory?(dir)
|
|
16
|
+
return if File.file?(index_path)
|
|
17
|
+
|
|
18
|
+
return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
|
|
19
|
+
|
|
20
|
+
require_relative "../../formatter/html_formatter"
|
|
21
|
+
SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
|
|
22
|
+
nil
|
|
23
|
+
rescue SimpleCov::CoverageJSON::Error, SystemCallError => e
|
|
24
|
+
"cannot build index.html from #{json_path}: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Serve
|
|
6
|
+
# The HTTP mechanics behind `simplecov serve`: reads one request
|
|
7
|
+
# per connection and answers it from the report directory, with
|
|
8
|
+
# traversal-safe path resolution. Kept apart from the CLI wiring
|
|
9
|
+
# (option parsing, binding, the accept loop) in serve.rb.
|
|
10
|
+
module StaticFileHandler
|
|
11
|
+
MIME = {
|
|
12
|
+
".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
|
|
13
|
+
".css" => "text/css",
|
|
14
|
+
".js" => "application/javascript",
|
|
15
|
+
".json" => "application/json",
|
|
16
|
+
".svg" => "image/svg+xml",
|
|
17
|
+
".png" => "image/png",
|
|
18
|
+
".gif" => "image/gif",
|
|
19
|
+
".jpg" => "image/jpeg",
|
|
20
|
+
".jpeg" => "image/jpeg",
|
|
21
|
+
".ico" => "image/x-icon",
|
|
22
|
+
".txt" => "text/plain; charset=utf-8"
|
|
23
|
+
}.freeze
|
|
24
|
+
STATUS_TEXT = {200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
|
|
25
|
+
404 => "Not Found", 405 => "Method Not Allowed"}.freeze
|
|
26
|
+
|
|
27
|
+
# Seconds a connection may sit idle mid-request before its reads
|
|
28
|
+
# raise IO::TimeoutError and the connection is dropped.
|
|
29
|
+
READ_TIMEOUT = 5
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Reads one HTTP request line, drains headers, serves the file or
|
|
34
|
+
# writes a status response. Wide rescue so a misbehaving client
|
|
35
|
+
# can't crash the server. The read timeout keeps idle connections
|
|
36
|
+
# from pinning their threads forever.
|
|
37
|
+
def handle_connection(client, root)
|
|
38
|
+
# simplecov:disable branch — support for IO#timeout= is fixed by the engine
|
|
39
|
+
# JRuby and TruffleRuby don't implement IO#timeout=. Without
|
|
40
|
+
# the guard the NoMethodError lands in the wide rescue below
|
|
41
|
+
# and every connection closes with an empty response. On those
|
|
42
|
+
# engines an idle connection pins its thread instead of timing
|
|
43
|
+
# out, which only leaks a thread in an interactive dev server.
|
|
44
|
+
client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
|
|
45
|
+
# simplecov:enable branch
|
|
46
|
+
method, path = client.readline.split
|
|
47
|
+
drain_headers(client)
|
|
48
|
+
# A request line without both tokens used to raise on
|
|
49
|
+
# `path.split` inside the wide rescue, closing the connection
|
|
50
|
+
# with an empty response instead of the 400 defined below.
|
|
51
|
+
return respond(client, 400) if path.nil?
|
|
52
|
+
return respond(client, 405) unless method == "GET"
|
|
53
|
+
|
|
54
|
+
serve_file(client, path, root)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
# Misbehaving clients (truncated requests, connection resets,
|
|
57
|
+
# invalid encoding) shouldn't take the whole server down.
|
|
58
|
+
nil
|
|
59
|
+
ensure
|
|
60
|
+
# simplecov:disable — `client` is the parameter, never nil here;
|
|
61
|
+
# the `&.` is purely defensive in case of future refactors
|
|
62
|
+
client&.close
|
|
63
|
+
# simplecov:enable
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def serve_file(client, path, root)
|
|
67
|
+
file = resolve(path, root)
|
|
68
|
+
return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
|
|
69
|
+
|
|
70
|
+
respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def drain_headers(client)
|
|
74
|
+
loop { break if client.readline.strip.empty? }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the absolute path of the file to serve, :forbidden for
|
|
78
|
+
# a traversal attempt (including symlinks that escape root), or
|
|
79
|
+
# nil for "not found".
|
|
80
|
+
#
|
|
81
|
+
# The request path is deliberately NOT percent-decoded: filenames
|
|
82
|
+
# needing escapes don't occur in generated reports, and keeping
|
|
83
|
+
# `%2e%2e%2f` as literal bytes is part of the traversal defense.
|
|
84
|
+
# If decoding is ever added, it must happen BEFORE the `inside?`
|
|
85
|
+
# check below.
|
|
86
|
+
def resolve(request_path, root)
|
|
87
|
+
path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
|
|
88
|
+
absolute_root = File.realpath(root)
|
|
89
|
+
candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
|
|
90
|
+
# Reject `..` traversal and absolute-path attempts before
|
|
91
|
+
# touching disk so they're 403, not 404.
|
|
92
|
+
return :forbidden unless inside?(candidate, absolute_root)
|
|
93
|
+
|
|
94
|
+
candidate = File.join(candidate, "index.html") if File.directory?(candidate)
|
|
95
|
+
return nil unless File.file?(candidate)
|
|
96
|
+
|
|
97
|
+
# Resolve symlinks last and re-check: a file inside root could
|
|
98
|
+
# be a symlink pointing outside (e.g. /etc/passwd).
|
|
99
|
+
real = File.realpath(candidate)
|
|
100
|
+
inside?(real, absolute_root) ? real : :forbidden
|
|
101
|
+
rescue Errno::ENOENT
|
|
102
|
+
# simplecov:disable — TOCTOU: candidate vanished between
|
|
103
|
+
# File.file? and File.realpath. Treat as "not found".
|
|
104
|
+
nil
|
|
105
|
+
# simplecov:enable
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def inside?(path, root)
|
|
109
|
+
path == root || path.start_with?(root + File::SEPARATOR)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def respond(client, status, body = "", content_type = "text/plain")
|
|
113
|
+
client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
|
|
114
|
+
"Content-Type: #{content_type || 'application/octet-stream'}\r\n",
|
|
115
|
+
"Content-Length: #{body.bytesize}\r\n",
|
|
116
|
+
"Connection: close\r\n\r\n")
|
|
117
|
+
client.write(body)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
require_relative "serve/report_preparer"
|
|
6
|
+
require_relative "serve/static_file_handler"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov serve [--port N] [--host HOST]` — serve the coverage
|
|
11
|
+
# report over HTTP. A small static file server backed by stdlib
|
|
12
|
+
# `socket`, so there's no extra dependency just for "view a local
|
|
13
|
+
# report on a CI box where `file://` doesn't work." This module is
|
|
14
|
+
# the CLI wiring (options, binding, the accept loop); the HTTP
|
|
15
|
+
# mechanics live in StaticFileHandler.
|
|
16
|
+
module Serve
|
|
17
|
+
extend CommandHelpers
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(args, stdout:, stderr:, **)
|
|
22
|
+
opts = parse(args)
|
|
23
|
+
dir = SimpleCov::CLI.coverage_dir
|
|
24
|
+
message = ReportPreparer.call(dir)
|
|
25
|
+
return error(stderr, message) if message
|
|
26
|
+
|
|
27
|
+
require "socket"
|
|
28
|
+
with_server(opts, stderr) do |server|
|
|
29
|
+
announce(stdout, server, dir)
|
|
30
|
+
serve_loop(server, dir, stdout)
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the block's exit status, or 1 when the socket can't be
|
|
36
|
+
# bound (port already taken, privileged port, unresolvable host).
|
|
37
|
+
def with_server(opts, stderr)
|
|
38
|
+
# The receiver cast works around an rbs stdlib gap: TCPSocket's
|
|
39
|
+
# explicit `self.new` shadows TCPServer#initialize's (host, port) form.
|
|
40
|
+
server = (_ = TCPServer).new(opts[:host], opts[:port]) #: TCPServer
|
|
41
|
+
rescue SystemCallError, SocketError => e
|
|
42
|
+
error(stderr, "cannot bind to #{opts[:host]}:#{opts[:port]} (#{e.message})")
|
|
43
|
+
else
|
|
44
|
+
begin
|
|
45
|
+
yield server
|
|
46
|
+
ensure
|
|
47
|
+
server.close
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse(args)
|
|
52
|
+
opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
|
|
53
|
+
OptionParser.new do |o|
|
|
54
|
+
o.on("--port N", Integer) { |v| opts[:port] = v }
|
|
55
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
56
|
+
end.parse(args)
|
|
57
|
+
opts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def announce(stdout, server, dir)
|
|
61
|
+
port = server.addr[1]
|
|
62
|
+
host = url_host(server.addr[3])
|
|
63
|
+
stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
|
|
64
|
+
stdout.puts("Press Ctrl-C to stop.")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# IPv6 literals need brackets in a URL (--host ::1 must announce
|
|
68
|
+
# http://[::1]:PORT/, not the invalid http://::1:PORT/).
|
|
69
|
+
def url_host(host)
|
|
70
|
+
host.include?(":") ? "[#{host}]" : host
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One thread per connection: browsers open speculative sockets
|
|
74
|
+
# that send no bytes, and a serial loop would stall every real
|
|
75
|
+
# request (the report's assets included) behind them.
|
|
76
|
+
def serve_loop(server, dir, stdout)
|
|
77
|
+
loop do
|
|
78
|
+
Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
|
|
79
|
+
end
|
|
80
|
+
rescue Interrupt
|
|
81
|
+
stdout.puts("\nsimplecov serve: stopping")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
# `simplecov uncovered [--threshold N] [--top N] [--criterion C]` — list
|
|
10
|
+
# the lowest-coverage files (by the chosen criterion, ascending), so a
|
|
11
|
+
# developer can answer "where should I add tests next?" without
|
|
12
|
+
# opening a browser. Reads coverage.json directly.
|
|
13
|
+
module Uncovered
|
|
14
|
+
extend CommandHelpers
|
|
15
|
+
|
|
16
|
+
DEFAULT_TOP = 10
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def run(args, stdout:, stderr:, **)
|
|
21
|
+
opts = parse(args)
|
|
22
|
+
keys = CoverageFile::CRITERIA[opts[:criterion]]
|
|
23
|
+
return unknown_criterion(opts[:criterion], stderr) unless keys
|
|
24
|
+
|
|
25
|
+
report(opts, keys, stdout, stderr)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def report(opts, keys, stdout, stderr)
|
|
29
|
+
coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
|
|
30
|
+
return 1 unless coverage
|
|
31
|
+
|
|
32
|
+
files = rank(coverage, opts[:threshold], keys).first(opts[:top])
|
|
33
|
+
return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
|
|
34
|
+
|
|
35
|
+
emit(stdout, files, opts)
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unknown_criterion(criterion, stderr)
|
|
40
|
+
stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
|
|
41
|
+
1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def emit(stdout, files, opts)
|
|
45
|
+
opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(args)
|
|
49
|
+
opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line) do |o, options|
|
|
50
|
+
o.on("--threshold N", Float) { |v| options[:threshold] = v }
|
|
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
|
|
54
|
+
opts
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A negative count would raise from `Array#first`; report it as
|
|
58
|
+
# the parse error it is (`--top 0` is allowed and shows nothing).
|
|
59
|
+
# OptionParser prepends the offending option's name, so the raise
|
|
60
|
+
# carries only the reason.
|
|
61
|
+
def validate_top(value)
|
|
62
|
+
raise OptionParser::InvalidArgument, "must not be negative" if value.negative?
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def emit_text(stdout, files, color)
|
|
68
|
+
files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def emit_json(stdout, files)
|
|
72
|
+
rows = files.map do |fname, pct, covered, total|
|
|
73
|
+
{"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
|
|
74
|
+
end
|
|
75
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def empty_message(json)
|
|
79
|
+
json ? "[]" : "simplecov uncovered: nothing to report"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def rank(coverage_hash, threshold, keys)
|
|
83
|
+
rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
|
|
84
|
+
rows.sort_by { |_fname, pct, _c, _t| pct }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def row_for(fname, payload, threshold, keys)
|
|
88
|
+
return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
|
|
89
|
+
|
|
90
|
+
pct = payload[keys[:percent]].to_f
|
|
91
|
+
return if pct >= threshold
|
|
92
|
+
|
|
93
|
+
[fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_row(fname, pct, covered, total, color)
|
|
97
|
+
format("%<pct>s %<covered>d/%<total>d %<fname>s",
|
|
98
|
+
pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
|
|
99
|
+
covered: covered, total: total, fname: fname)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|