simplecov 0.22.0 → 1.0.0.rc1
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/CHANGELOG.md +81 -1
- data/LICENSE +1 -1
- data/README.md +1009 -511
- data/doc/alternate-formatters.md +0 -5
- data/doc/commercial-services.md +5 -5
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +47 -0
- data/lib/simplecov/cli/coverage.rb +91 -0
- data/lib/simplecov/cli/diff.rb +151 -0
- data/lib/simplecov/cli/dotfile.rb +100 -0
- data/lib/simplecov/cli/merge.rb +116 -0
- data/lib/simplecov/cli/open.rb +50 -0
- data/lib/simplecov/cli/report.rb +84 -0
- data/lib/simplecov/cli/run.rb +36 -0
- data/lib/simplecov/cli/serve.rb +139 -0
- data/lib/simplecov/cli/uncovered.rb +107 -0
- data/lib/simplecov/cli.rb +150 -0
- data/lib/simplecov/color.rb +74 -0
- data/lib/simplecov/combine/branches_combiner.rb +3 -2
- data/lib/simplecov/combine/files_combiner.rb +7 -1
- data/lib/simplecov/combine/lines_combiner.rb +19 -17
- data/lib/simplecov/combine/methods_combiner.rb +26 -0
- data/lib/simplecov/combine/results_combiner.rb +5 -4
- data/lib/simplecov/command_guesser.rb +46 -32
- data/lib/simplecov/configuration/coverage.rb +171 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
- data/lib/simplecov/configuration/filters.rb +195 -0
- data/lib/simplecov/configuration/formatting.rb +119 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +74 -0
- data/lib/simplecov/configuration/thresholds.rb +174 -0
- data/lib/simplecov/configuration.rb +79 -405
- data/lib/simplecov/coverage_statistics.rb +12 -9
- data/lib/simplecov/coverage_violations.rb +148 -0
- data/lib/simplecov/defaults.rb +27 -20
- data/lib/simplecov/directive.rb +162 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
- data/lib/simplecov/exit_codes.rb +3 -0
- data/lib/simplecov/exit_handling.rb +158 -0
- data/lib/simplecov/file_list.rb +61 -17
- data/lib/simplecov/filter.rb +69 -24
- data/lib/simplecov/formatter/base.rb +101 -0
- data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
- data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
- data/lib/simplecov/formatter/html_formatter.rb +79 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
- data/lib/simplecov/formatter/json_formatter.rb +77 -0
- data/lib/simplecov/formatter/multi_formatter.rb +4 -5
- data/lib/simplecov/formatter/simple_formatter.rb +9 -11
- data/lib/simplecov/formatter.rb +4 -0
- data/lib/simplecov/last_run.rb +10 -3
- data/lib/simplecov/lines_classifier.rb +26 -13
- data/lib/simplecov/load_global_config.rb +9 -4
- data/lib/simplecov/parallel_adapters/base.rb +51 -0
- data/lib/simplecov/parallel_adapters/generic.rb +42 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
- data/lib/simplecov/parallel_adapters.rb +83 -0
- data/lib/simplecov/parallel_coordination.rb +95 -0
- data/lib/simplecov/process.rb +20 -14
- 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/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +97 -19
- data/lib/simplecov/result_adapter.rb +68 -6
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +38 -0
- data/lib/simplecov/result_merger/resultset_store.rb +50 -0
- data/lib/simplecov/result_merger.rb +46 -90
- data/lib/simplecov/result_processing.rb +162 -0
- data/lib/simplecov/simulate_coverage.rb +54 -8
- 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 +7 -2
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +52 -0
- data/lib/simplecov/source_file/method_builder.rb +58 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
- data/lib/simplecov/source_file/skip_chunks.rb +77 -0
- data/lib/simplecov/source_file/source_loader.rb +63 -0
- data/lib/simplecov/source_file/statistics.rb +57 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
- data/lib/simplecov/static_coverage_extractor.rb +111 -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 +131 -377
- data/lib/simplecov_json_formatter.rb +4 -0
- data/schemas/coverage-v1.0.schema.json +300 -0
- data/schemas/coverage.schema.json +300 -0
- metadata +88 -56
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module SimpleCov
|
|
7
|
+
module CLI
|
|
8
|
+
# `simplecov report [--input PATH]` — pretty-print the overall
|
|
9
|
+
# totals row plus per-group totals from a JSONFormatter
|
|
10
|
+
# coverage.json. Same numbers as the HTML report's totals row, for
|
|
11
|
+
# contexts where opening a browser isn't an option (CI logs, ssh
|
|
12
|
+
# sessions, terminal-only workflows).
|
|
13
|
+
module Report
|
|
14
|
+
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def run(args, stdout:, stderr:)
|
|
19
|
+
opts = parse(args)
|
|
20
|
+
return 1 unless (data = load_data(opts[:input], stderr))
|
|
21
|
+
|
|
22
|
+
if opts[:json]
|
|
23
|
+
emit_json(stdout, data)
|
|
24
|
+
else
|
|
25
|
+
emit_text(stdout, data, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse(args)
|
|
31
|
+
opts = {input: SimpleCov::CLI.default_input, json: false, no_color: false}
|
|
32
|
+
OptionParser.new do |o|
|
|
33
|
+
o.on("--input PATH") { |v| opts[:input] = v }
|
|
34
|
+
o.on("--json") { opts[:json] = true }
|
|
35
|
+
o.on("--no-color") { opts[:no_color] = true }
|
|
36
|
+
end.parse(args)
|
|
37
|
+
opts
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_data(input, stderr)
|
|
41
|
+
return JSON.parse(File.read(input)) if File.exist?(input)
|
|
42
|
+
|
|
43
|
+
stderr.puts("simplecov report: #{input} not found")
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def emit_text(stdout, data, color)
|
|
48
|
+
emit_totals(stdout, "All Files", data.fetch("total", {}), color)
|
|
49
|
+
data.fetch("groups", {}).each { |name, group| emit_totals(stdout, name, group, color) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def emit_totals(stdout, label, totals, color)
|
|
53
|
+
stdout.puts(label)
|
|
54
|
+
SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
|
|
55
|
+
stdout.puts
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def emit_section(stdout, display, section, color)
|
|
59
|
+
return unless section.is_a?(Hash) && section["total"].to_i.positive?
|
|
60
|
+
|
|
61
|
+
stdout.puts(format(" %<label>-7s %<pct>s (%<covered>d / %<total>d)",
|
|
62
|
+
label: "#{display}:",
|
|
63
|
+
pct: SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
|
|
64
|
+
covered: section["covered"] || 0,
|
|
65
|
+
total: section["total"] || 0))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def emit_json(stdout, data)
|
|
69
|
+
payload = {"All Files" => collect_section(data.fetch("total", {}))}
|
|
70
|
+
data.fetch("groups", {}).each { |name, group| payload[name] = collect_section(group) }
|
|
71
|
+
stdout.puts(JSON.pretty_generate(payload))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def collect_section(totals)
|
|
75
|
+
SECTIONS.each_with_object({}) do |(_, key), out|
|
|
76
|
+
section = totals[key]
|
|
77
|
+
next unless section.is_a?(Hash) && section["total"].to_i.positive?
|
|
78
|
+
|
|
79
|
+
out[key] = {"percent" => section["percent"], "covered" => section["covered"], "total" => section["total"]}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
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__)
|
|
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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
# `simplecov serve [--port N] [--host HOST]` — serve the coverage
|
|
8
|
+
# report over HTTP. A 30-line static file server backed by stdlib
|
|
9
|
+
# `socket`, so there's no extra dependency just for "view a local
|
|
10
|
+
# report on a CI box where `file://` doesn't work."
|
|
11
|
+
module Serve
|
|
12
|
+
MIME = {
|
|
13
|
+
".html" => "text/html; charset=utf-8",
|
|
14
|
+
".htm" => "text/html; charset=utf-8",
|
|
15
|
+
".css" => "text/css",
|
|
16
|
+
".js" => "application/javascript",
|
|
17
|
+
".json" => "application/json",
|
|
18
|
+
".svg" => "image/svg+xml",
|
|
19
|
+
".png" => "image/png",
|
|
20
|
+
".gif" => "image/gif",
|
|
21
|
+
".jpg" => "image/jpeg",
|
|
22
|
+
".jpeg" => "image/jpeg",
|
|
23
|
+
".ico" => "image/x-icon",
|
|
24
|
+
".txt" => "text/plain; charset=utf-8"
|
|
25
|
+
}.freeze
|
|
26
|
+
STATUS_TEXT = {
|
|
27
|
+
200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
|
|
28
|
+
404 => "Not Found", 405 => "Method Not Allowed"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def run(args, stdout:, stderr:, **)
|
|
34
|
+
opts = parse(args)
|
|
35
|
+
dir = SimpleCov::CLI.coverage_dir
|
|
36
|
+
return error(stderr, "#{dir} doesn't exist; run your test suite first") unless File.directory?(dir)
|
|
37
|
+
|
|
38
|
+
require "socket"
|
|
39
|
+
server = TCPServer.new(opts[:host], opts[:port])
|
|
40
|
+
announce(stdout, server, dir)
|
|
41
|
+
serve_loop(server, dir, stdout)
|
|
42
|
+
0
|
|
43
|
+
ensure
|
|
44
|
+
server&.close
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse(args)
|
|
48
|
+
opts = {port: 0, host: "127.0.0.1"}
|
|
49
|
+
OptionParser.new do |o|
|
|
50
|
+
o.on("--port N", Integer) { |v| opts[:port] = v }
|
|
51
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
52
|
+
end.parse(args)
|
|
53
|
+
opts
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def announce(stdout, server, dir)
|
|
57
|
+
port = server.addr[1]
|
|
58
|
+
host = server.addr[3]
|
|
59
|
+
stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
|
|
60
|
+
stdout.puts("Press Ctrl-C to stop.")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serve_loop(server, dir, stdout)
|
|
64
|
+
loop { handle_connection(server.accept, dir) }
|
|
65
|
+
rescue Interrupt
|
|
66
|
+
stdout.puts("\nsimplecov serve: stopping")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reads one HTTP request line, drains headers, serves the file or
|
|
70
|
+
# writes a status response. Wide rescue so a misbehaving client
|
|
71
|
+
# can't crash the server.
|
|
72
|
+
def handle_connection(client, root)
|
|
73
|
+
method, path = client.readline.split
|
|
74
|
+
drain_headers(client)
|
|
75
|
+
return respond(client, 405) unless method == "GET"
|
|
76
|
+
|
|
77
|
+
file = resolve(path, root)
|
|
78
|
+
return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
|
|
79
|
+
|
|
80
|
+
respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# Misbehaving clients (truncated requests, connection resets,
|
|
83
|
+
# invalid encoding) shouldn't take the whole server down.
|
|
84
|
+
nil
|
|
85
|
+
ensure
|
|
86
|
+
# simplecov:disable — `client` is the parameter, never nil here;
|
|
87
|
+
# the `&.` is purely defensive in case of future refactors
|
|
88
|
+
client&.close
|
|
89
|
+
# simplecov:enable
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def drain_headers(client)
|
|
93
|
+
loop { break if client.readline.strip.empty? }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the absolute path of the file to serve, :forbidden for
|
|
97
|
+
# a traversal attempt (including symlinks that escape root), or
|
|
98
|
+
# nil for "not found".
|
|
99
|
+
def resolve(request_path, root)
|
|
100
|
+
path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
|
|
101
|
+
absolute_root = File.realpath(root)
|
|
102
|
+
candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
|
|
103
|
+
# Reject `..` traversal and absolute-path attempts before
|
|
104
|
+
# touching disk so they're 403, not 404.
|
|
105
|
+
return :forbidden unless inside?(candidate, absolute_root)
|
|
106
|
+
|
|
107
|
+
candidate = File.join(candidate, "index.html") if File.directory?(candidate)
|
|
108
|
+
return nil unless File.file?(candidate)
|
|
109
|
+
|
|
110
|
+
# Resolve symlinks last and re-check: a file inside root could
|
|
111
|
+
# be a symlink pointing outside (e.g. /etc/passwd).
|
|
112
|
+
real = File.realpath(candidate)
|
|
113
|
+
inside?(real, absolute_root) ? real : :forbidden
|
|
114
|
+
rescue Errno::ENOENT
|
|
115
|
+
# simplecov:disable — TOCTOU: candidate vanished between
|
|
116
|
+
# File.file? and File.realpath. Treat as "not found".
|
|
117
|
+
nil
|
|
118
|
+
# simplecov:enable
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inside?(path, root)
|
|
122
|
+
path == root || path.start_with?(root + File::SEPARATOR)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def respond(client, status, body = "", content_type = "text/plain")
|
|
126
|
+
client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
|
|
127
|
+
"Content-Type: #{content_type || 'application/octet-stream'}\r\n",
|
|
128
|
+
"Content-Length: #{body.bytesize}\r\n",
|
|
129
|
+
"Connection: close\r\n\r\n")
|
|
130
|
+
client.write(body)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def error(stderr, message)
|
|
134
|
+
stderr.puts("simplecov serve: #{message}")
|
|
135
|
+
1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "report"
|
|
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
|
+
DEFAULT_TOP = 10
|
|
15
|
+
|
|
16
|
+
# The coverage.json fields backing each criterion.
|
|
17
|
+
CRITERION_KEYS = {
|
|
18
|
+
line: {percent: "lines_covered_percent", covered: "covered_lines", total: "total_lines"},
|
|
19
|
+
branch: {percent: "branches_covered_percent", covered: "covered_branches", total: "total_branches"},
|
|
20
|
+
method: {percent: "methods_covered_percent", covered: "covered_methods", total: "total_methods"}
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def run(args, stdout:, stderr:, **)
|
|
26
|
+
opts = parse(args)
|
|
27
|
+
keys = CRITERION_KEYS[opts[:criterion]]
|
|
28
|
+
return unknown_criterion(opts[:criterion], stderr) unless keys
|
|
29
|
+
|
|
30
|
+
report(opts, keys, stdout, stderr)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def report(opts, keys, stdout, stderr)
|
|
34
|
+
return 1 unless (data = Report.load_data(opts[:input], stderr))
|
|
35
|
+
|
|
36
|
+
files = rank(data.fetch("coverage", {}), opts[:threshold], keys).first(opts[:top])
|
|
37
|
+
return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
|
|
38
|
+
|
|
39
|
+
emit(stdout, files, opts)
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def unknown_criterion(criterion, stderr)
|
|
44
|
+
stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def emit(stdout, files, opts)
|
|
49
|
+
opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse(args)
|
|
53
|
+
opts = {input: SimpleCov::CLI.default_input, threshold: 100.0, top: DEFAULT_TOP, criterion: :line, no_color: false}
|
|
54
|
+
build_parser(opts).parse(args)
|
|
55
|
+
opts
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Option parsing with per-flag coercions is inherently ABC-heavy; the
|
|
59
|
+
# metric is noise here.
|
|
60
|
+
def build_parser(opts) # rubocop:disable Metrics/AbcSize
|
|
61
|
+
OptionParser.new do |o|
|
|
62
|
+
o.on("--input PATH") { |v| opts[:input] = v }
|
|
63
|
+
o.on("--threshold N", Float) { |v| opts[:threshold] = v }
|
|
64
|
+
o.on("--top N", Integer) { |v| opts[:top] = v }
|
|
65
|
+
o.on("--criterion C") { |v| opts[:criterion] = v.to_sym }
|
|
66
|
+
o.on("--json") { opts[:json] = true }
|
|
67
|
+
o.on("--no-color") { opts[:no_color] = true }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def emit_text(stdout, files, color)
|
|
72
|
+
files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def emit_json(stdout, files)
|
|
76
|
+
rows = files.map do |fname, pct, covered, total|
|
|
77
|
+
{"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
|
|
78
|
+
end
|
|
79
|
+
stdout.puts(JSON.pretty_generate(rows))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def empty_message(json)
|
|
83
|
+
json ? "[]" : "simplecov uncovered: nothing to report"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def rank(coverage_hash, threshold, keys)
|
|
87
|
+
rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
|
|
88
|
+
rows.sort_by { |_fname, pct, _c, _t| pct }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def row_for(fname, payload, threshold, keys)
|
|
92
|
+
return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
|
|
93
|
+
|
|
94
|
+
pct = payload[keys[:percent]].to_f
|
|
95
|
+
return if pct >= threshold
|
|
96
|
+
|
|
97
|
+
[fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_row(fname, pct, covered, total, color)
|
|
101
|
+
format("%<pct>s %<covered>d/%<total>d %<fname>s",
|
|
102
|
+
pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
|
|
103
|
+
covered: covered, total: total, fname: fname)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "color"
|
|
4
|
+
require_relative "cli/dotfile"
|
|
5
|
+
require_relative "cli/clean"
|
|
6
|
+
require_relative "cli/coverage"
|
|
7
|
+
require_relative "cli/diff"
|
|
8
|
+
require_relative "cli/merge"
|
|
9
|
+
require_relative "cli/open"
|
|
10
|
+
require_relative "cli/report"
|
|
11
|
+
require_relative "cli/run"
|
|
12
|
+
require_relative "cli/serve"
|
|
13
|
+
require_relative "cli/uncovered"
|
|
14
|
+
|
|
15
|
+
module SimpleCov
|
|
16
|
+
# Lightweight command-line front-end. `run` dispatches a subcommand
|
|
17
|
+
# (`coverage`, `report`, `uncovered`, `merge`, `diff`, `open`, etc.) —
|
|
18
|
+
# see the `usage` text below for the full list, or run `simplecov help`.
|
|
19
|
+
#
|
|
20
|
+
# Read-only subcommands consume JSONFormatter output (`coverage.json`),
|
|
21
|
+
# which the bundled HTMLFormatter already drops alongside the HTML, so
|
|
22
|
+
# no runtime hooking is needed for those. Default paths follow the
|
|
23
|
+
# project's `.simplecov` `SimpleCov.coverage_dir` setting when one is
|
|
24
|
+
# present, so a project that writes its report somewhere other than
|
|
25
|
+
# `coverage/` doesn't have to pass `--input` / `--report` every
|
|
26
|
+
# invocation.
|
|
27
|
+
module CLI
|
|
28
|
+
COMMANDS = {
|
|
29
|
+
"coverage" => Coverage,
|
|
30
|
+
"run" => Run,
|
|
31
|
+
"open" => Open,
|
|
32
|
+
"report" => Report,
|
|
33
|
+
"uncovered" => Uncovered,
|
|
34
|
+
"merge" => Merge,
|
|
35
|
+
"diff" => Diff,
|
|
36
|
+
"serve" => Serve,
|
|
37
|
+
"clean" => Clean
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Resolved once per process. Walks up from cwd looking for a
|
|
43
|
+
# `.simplecov`; if present, the file is loaded with
|
|
44
|
+
# `SimpleCov.start` neutered so it can't trigger coverage tracking
|
|
45
|
+
# or an at_exit hook just because we asked it for a config value.
|
|
46
|
+
def coverage_dir
|
|
47
|
+
@coverage_dir ||= Dotfile.coverage_dir
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def default_input
|
|
51
|
+
File.join(coverage_dir, "coverage.json")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve "should this subcommand colorize?" once per invocation.
|
|
55
|
+
# `--no-color` (opts[:no_color]) is the per-invocation kill-switch;
|
|
56
|
+
# otherwise we defer to `SimpleCov::Color.enabled?`, which honors
|
|
57
|
+
# `NO_COLOR` / `FORCE_COLOR` and falls back to `stream.tty?`.
|
|
58
|
+
def color_enabled?(opts, stream)
|
|
59
|
+
return false if opts[:no_color]
|
|
60
|
+
|
|
61
|
+
SimpleCov::Color.enabled?(stream)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def default_report
|
|
65
|
+
File.join(coverage_dir, "index.html")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_resultset
|
|
69
|
+
File.join(coverage_dir, ".resultset.json")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns a process exit status (0 on success, non-zero on error).
|
|
73
|
+
def run(argv, stdout: $stdout, stderr: $stderr)
|
|
74
|
+
command, *rest = argv
|
|
75
|
+
handler = COMMANDS[command]
|
|
76
|
+
return handler.run(rest, stdout: stdout, stderr: stderr) if handler
|
|
77
|
+
return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
|
|
78
|
+
|
|
79
|
+
stderr.puts("simplecov: unknown command #{command.inspect}", usage)
|
|
80
|
+
1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage
|
|
84
|
+
<<~USAGE
|
|
85
|
+
Usage: simplecov <command> [options]
|
|
86
|
+
|
|
87
|
+
Commands:
|
|
88
|
+
run <command...> Execute <command> with simplecov pre-loaded
|
|
89
|
+
(so a coverage report is generated even
|
|
90
|
+
when the project has no test_helper hook)
|
|
91
|
+
coverage <path> Print coverage stats for the given file
|
|
92
|
+
report Print the overall summary and group totals
|
|
93
|
+
uncovered List the lowest-coverage files
|
|
94
|
+
merge <files...> Merge multiple .resultset.json files
|
|
95
|
+
diff <baseline> Show per-file coverage delta vs baseline
|
|
96
|
+
open Open the HTML report in the default browser
|
|
97
|
+
serve Serve the coverage report over HTTP
|
|
98
|
+
clean Remove the coverage report directory
|
|
99
|
+
help Show this message
|
|
100
|
+
|
|
101
|
+
Default paths follow SimpleCov.coverage_dir from a project's
|
|
102
|
+
`.simplecov` when one is present (#{coverage_dir} for this run).
|
|
103
|
+
|
|
104
|
+
coverage / report / uncovered / diff options:
|
|
105
|
+
--input PATH Read from PATH instead of #{default_input}
|
|
106
|
+
--no-color Disable colorized percentages
|
|
107
|
+
(also honors NO_COLOR / FORCE_COLOR env)
|
|
108
|
+
|
|
109
|
+
coverage options:
|
|
110
|
+
--json Print the file's JSON entry verbatim
|
|
111
|
+
|
|
112
|
+
report options:
|
|
113
|
+
--json Emit totals and group sections as JSON
|
|
114
|
+
|
|
115
|
+
uncovered options:
|
|
116
|
+
--threshold N Only show files below N% coverage
|
|
117
|
+
--top N Show at most N files (default: 10)
|
|
118
|
+
--criterion C line, branch, or method (default: line)
|
|
119
|
+
--json Emit results as a JSON array (for CI)
|
|
120
|
+
|
|
121
|
+
merge options:
|
|
122
|
+
--output PATH Write merged resultset to PATH
|
|
123
|
+
(default: #{default_resultset})
|
|
124
|
+
--honor-timeout Drop entries older than merge_timeout
|
|
125
|
+
--dry-run Print what would be written without
|
|
126
|
+
actually writing
|
|
127
|
+
-q, --quiet Suppress the success status line
|
|
128
|
+
|
|
129
|
+
diff options:
|
|
130
|
+
--fail-on-drop Exit non-zero when any file's coverage
|
|
131
|
+
dropped vs the baseline
|
|
132
|
+
--json Emit results as a JSON array (for CI)
|
|
133
|
+
--threshold N Only show files whose absolute delta
|
|
134
|
+
in any criterion is at least N%
|
|
135
|
+
|
|
136
|
+
open options:
|
|
137
|
+
--report PATH Open PATH instead of #{default_report}
|
|
138
|
+
|
|
139
|
+
serve options:
|
|
140
|
+
--port N Bind to port N (default: random open port)
|
|
141
|
+
--host HOST Bind to HOST (default: 127.0.0.1)
|
|
142
|
+
|
|
143
|
+
clean options:
|
|
144
|
+
--dry-run Print what would be removed without
|
|
145
|
+
deleting anything
|
|
146
|
+
-q, --quiet Suppress status lines
|
|
147
|
+
USAGE
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
# ANSI colorization for stderr diagnostics. Thresholds mirror the
|
|
5
|
+
# HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
|
|
6
|
+
# team's mental model of "what's the cutoff" is the same whether
|
|
7
|
+
# they're reading the terminal output or the HTML report.
|
|
8
|
+
#
|
|
9
|
+
# Color precedence, highest first:
|
|
10
|
+
#
|
|
11
|
+
# - `SimpleCov.color = true` / `false` (programmatic override, wins
|
|
12
|
+
# over everything; default is `:auto` which falls through)
|
|
13
|
+
# - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
|
|
14
|
+
# - `FORCE_COLOR` env var (any non-empty value) → on
|
|
15
|
+
# - `stream.tty?` fallback
|
|
16
|
+
#
|
|
17
|
+
# `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
|
|
18
|
+
module Color
|
|
19
|
+
GREEN_THRESHOLD = 90
|
|
20
|
+
YELLOW_THRESHOLD = 75
|
|
21
|
+
|
|
22
|
+
ANSI = {
|
|
23
|
+
red: "\e[31m",
|
|
24
|
+
yellow: "\e[33m",
|
|
25
|
+
green: "\e[32m",
|
|
26
|
+
reset: "\e[0m"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# `stream` is the IO that the colorized text is destined for. The
|
|
32
|
+
# formatter writes to stderr, so that's the default. CLI subcommands
|
|
33
|
+
# that print to stdout should pass `$stdout` so a redirected pipe
|
|
34
|
+
# doesn't get ANSI sequences. See the module-level comment for
|
|
35
|
+
# precedence.
|
|
36
|
+
def enabled?(stream = $stderr)
|
|
37
|
+
config = SimpleCov.color
|
|
38
|
+
return config if [true, false].include?(config)
|
|
39
|
+
return false if env_set?("NO_COLOR")
|
|
40
|
+
return true if env_set?("FORCE_COLOR")
|
|
41
|
+
|
|
42
|
+
stream.tty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def for_percent(percent)
|
|
46
|
+
return :green if percent >= GREEN_THRESHOLD
|
|
47
|
+
return :yellow if percent >= YELLOW_THRESHOLD
|
|
48
|
+
|
|
49
|
+
:red
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
|
|
53
|
+
# Returns the bare text if color is disabled. The `enabled:`
|
|
54
|
+
# keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
|
|
55
|
+
# override the auto-detection without touching env vars.
|
|
56
|
+
def colorize(text, color, enabled: enabled?)
|
|
57
|
+
return text unless enabled
|
|
58
|
+
|
|
59
|
+
"#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Render `percent` as a fixed "NN.NN%" string colored by which
|
|
63
|
+
# threshold band it falls into. Callers that want a different
|
|
64
|
+
# rendering of the number can pass the pre-rendered `text`.
|
|
65
|
+
def colorize_percent(percent, text = nil, enabled: enabled?)
|
|
66
|
+
colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def env_set?(name)
|
|
70
|
+
value = ENV.fetch(name, nil)
|
|
71
|
+
value && !value.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -10,12 +10,13 @@ module SimpleCov
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
#
|
|
13
|
-
# Return merged branches or the existed
|
|
13
|
+
# Return merged branches or the existed branch if other is missing.
|
|
14
14
|
#
|
|
15
15
|
# Branches inside files are always same if they exist, the difference only in coverage count.
|
|
16
16
|
# Branch coverage report for any conditional case is built from hash, it's key is a condition and
|
|
17
17
|
# it's body is a hash << keys from condition and value is coverage rate >>.
|
|
18
|
-
# ex: branches =>{ [:if, 3, 8, 6, 8, 36] =>
|
|
18
|
+
# ex: branches => { [:if, 3, 8, 6, 8, 36] =>
|
|
19
|
+
# {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
|
|
19
20
|
# We create copy of result and update it values depending on the combined branches coverage values.
|
|
20
21
|
#
|
|
21
22
|
# @return [Hash]
|
|
@@ -16,7 +16,13 @@ module SimpleCov
|
|
|
16
16
|
#
|
|
17
17
|
def combine(coverage_a, coverage_b)
|
|
18
18
|
combination = {"lines" => Combine.combine(LinesCombiner, coverage_a["lines"], coverage_b["lines"])}
|
|
19
|
-
|
|
19
|
+
if SimpleCov.branch_coverage?
|
|
20
|
+
combined_branches = Combine.combine(BranchesCombiner, coverage_a["branches"], coverage_b["branches"])
|
|
21
|
+
combination["branches"] = combined_branches || {}
|
|
22
|
+
end
|
|
23
|
+
if SimpleCov.method_coverage?
|
|
24
|
+
combination["methods"] = Combine.combine(MethodsCombiner, coverage_a["methods"], coverage_b["methods"])
|
|
25
|
+
end
|
|
20
26
|
combination
|
|
21
27
|
end
|
|
22
28
|
end
|
|
@@ -9,34 +9,36 @@ module SimpleCov
|
|
|
9
9
|
module LinesCombiner
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
+
# Build a fresh array sized to the longer input. The previous
|
|
13
|
+
# implementation mutated whichever input was longer in place,
|
|
14
|
+
# which could surprise callers holding a reference to that array
|
|
15
|
+
# (e.g. the parsed `coverage` key of a resultset hash being
|
|
16
|
+
# passed into a second merge).
|
|
12
17
|
def combine(coverage_a, coverage_b)
|
|
13
|
-
coverage_a
|
|
14
|
-
|
|
15
|
-
.map do |coverage_a_val, coverage_b_val|
|
|
16
|
-
merge_line_coverage(coverage_a_val, coverage_b_val)
|
|
17
|
-
end
|
|
18
|
+
size = [coverage_a.size, coverage_b.size].max
|
|
19
|
+
Array.new(size) { |i| merge_line_coverage(coverage_a[i], coverage_b[i]) }
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Two runs of the same source file should agree on which lines
|
|
23
|
+
# are coverage-relevant (`nil` for comments / whitespace, `0`+
|
|
24
|
+
# for executable). When they don't, treat "relevant on either
|
|
25
|
+
# side" as relevant rather than masking a real `0` as `nil`,
|
|
26
|
+
# which would silently drop an uncovered line from the
|
|
27
|
+
# denominator and inflate the percentage.
|
|
24
28
|
#
|
|
25
29
|
# Logic:
|
|
26
30
|
#
|
|
27
|
-
# => nil + 0 = nil
|
|
28
31
|
# => nil + nil = nil
|
|
29
|
-
# =>
|
|
32
|
+
# => nil + int = int (preserves a relevant-but-uncovered 0)
|
|
33
|
+
# => int + int = int (sum)
|
|
30
34
|
#
|
|
35
|
+
# @param [Integer || nil] first_val
|
|
36
|
+
# @param [Integer || nil] second_val
|
|
31
37
|
# @return [Integer || nil]
|
|
32
38
|
def merge_line_coverage(first_val, second_val)
|
|
33
|
-
|
|
39
|
+
return nil if first_val.nil? && second_val.nil?
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
nil
|
|
37
|
-
else
|
|
38
|
-
sum
|
|
39
|
-
end
|
|
41
|
+
first_val.to_i + second_val.to_i
|
|
40
42
|
end
|
|
41
43
|
end
|
|
42
44
|
end
|