simplecov 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +65 -1425
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/cli/clean.rb +43 -3
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +21 -28
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +43 -48
- data/lib/simplecov/cli/dotfile.rb +12 -6
- data/lib/simplecov/cli/merge.rb +12 -10
- data/lib/simplecov/cli/open.rb +3 -5
- data/lib/simplecov/cli/report.rb +31 -23
- data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
- data/lib/simplecov/cli/serve.rb +30 -94
- data/lib/simplecov/cli/uncovered.rb +20 -25
- data/lib/simplecov/cli.rb +15 -2
- data/lib/simplecov/combine/branches_combiner.rb +43 -19
- data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
- data/lib/simplecov/combine/identity_interner.rb +30 -0
- data/lib/simplecov/combine/interned_counts.rb +30 -0
- data/lib/simplecov/combine/lines_combiner.rb +48 -20
- data/lib/simplecov/combine/methods_combiner.rb +46 -21
- data/lib/simplecov/combine/results_combiner.rb +12 -37
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +66 -9
- data/lib/simplecov/configuration/coverage.rb +12 -15
- data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +11 -44
- data/lib/simplecov/configuration/formatting.rb +18 -9
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/merging.rb +8 -7
- data/lib/simplecov/configuration/thresholds.rb +14 -13
- data/lib/simplecov/configuration.rb +13 -43
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +1 -1
- data/lib/simplecov/coverage_violations.rb +15 -5
- data/lib/simplecov/defaults.rb +7 -3
- data/lib/simplecov/directive.rb +1 -1
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
- data/lib/simplecov/exit_codes.rb +1 -0
- data/lib/simplecov/exit_handling.rb +11 -41
- data/lib/simplecov/file_list.rb +5 -10
- data/lib/simplecov/filter.rb +43 -9
- data/lib/simplecov/formatter/base.rb +11 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +67 -47
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
- data/lib/simplecov/formatter/json_formatter.rb +5 -48
- data/lib/simplecov/formatter/multi_formatter.rb +1 -1
- data/lib/simplecov/formatter/simple_formatter.rb +8 -5
- data/lib/simplecov/formatter.rb +12 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +16 -9
- data/lib/simplecov/lines_classifier.rb +29 -8
- data/lib/simplecov/load_global_config.rb +5 -2
- data/lib/simplecov/parallel_adapters/base.rb +17 -0
- data/lib/simplecov/parallel_adapters/generic.rb +2 -2
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
- data/lib/simplecov/parallel_coordination.rb +6 -1
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result.rb +40 -10
- data/lib/simplecov/result_adapter.rb +48 -16
- data/lib/simplecov/result_merger/resultset_file.rb +43 -7
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +15 -12
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +69 -42
- data/lib/simplecov/result_processing.rb +82 -43
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +35 -11
- data/lib/simplecov/source_file/method.rb +7 -1
- data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
- data/lib/simplecov/source_file/skip_chunks.rb +7 -10
- data/lib/simplecov/source_file/source_loader.rb +23 -7
- data/lib/simplecov/source_file/statistics.rb +24 -16
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
- data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov.rb +11 -5
- data/schemas/coverage-v1.0.schema.json +2 -2
- data/schemas/coverage.schema.json +2 -2
- data/sig/simplecov.rbs +206 -67
- metadata +28 -14
- data/doc/alternate-formatters.md +0 -66
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -70
- data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
- data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
data/lib/simplecov/cli/report.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
5
6
|
|
|
6
7
|
module SimpleCov
|
|
7
8
|
module CLI
|
|
@@ -11,12 +12,15 @@ module SimpleCov
|
|
|
11
12
|
# contexts where opening a browser isn't an option (CI logs, ssh
|
|
12
13
|
# sessions, terminal-only workflows).
|
|
13
14
|
module Report
|
|
15
|
+
extend CommandHelpers
|
|
16
|
+
|
|
17
|
+
TOTAL_LABEL = "All Files"
|
|
14
18
|
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze
|
|
15
19
|
|
|
16
20
|
module_function
|
|
17
21
|
|
|
18
22
|
def run(args, stdout:, stderr:)
|
|
19
|
-
opts =
|
|
23
|
+
opts, = parse_common(args)
|
|
20
24
|
return 1 unless (data = load_data(opts[:input], stderr))
|
|
21
25
|
|
|
22
26
|
if opts[:json]
|
|
@@ -27,27 +31,32 @@ module SimpleCov
|
|
|
27
31
|
0
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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.
|
|
40
38
|
def load_data(input, stderr)
|
|
41
|
-
|
|
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
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
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')
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def emit_text(stdout, data, color)
|
|
48
54
|
none = {} #: Hash[String, untyped]
|
|
49
|
-
emit_totals(stdout,
|
|
50
|
-
data.fetch("groups", none).each
|
|
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
|
|
51
60
|
end
|
|
52
61
|
|
|
53
62
|
def emit_totals(stdout, label, totals, color)
|
|
@@ -59,17 +68,16 @@ module SimpleCov
|
|
|
59
68
|
def emit_section(stdout, display, section, color)
|
|
60
69
|
return unless section.is_a?(Hash) && section["total"].to_i.positive?
|
|
61
70
|
|
|
62
|
-
stdout.puts(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
total: section["total"] || 0))
|
|
71
|
+
stdout.puts(stats_row(display,
|
|
72
|
+
SimpleCov::Color.colorize_percent(section["percent"].to_f, enabled: color),
|
|
73
|
+
section["covered"],
|
|
74
|
+
section["total"]))
|
|
67
75
|
end
|
|
68
76
|
|
|
69
77
|
def emit_json(stdout, data)
|
|
70
78
|
none = {} #: Hash[String, untyped]
|
|
71
|
-
|
|
72
|
-
data.fetch("
|
|
79
|
+
groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
|
|
80
|
+
payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
|
|
73
81
|
stdout.puts(JSON.pretty_generate(payload))
|
|
74
82
|
end
|
|
75
83
|
|
|
@@ -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
|
data/lib/simplecov/cli/serve.rb
CHANGED
|
@@ -1,52 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
require_relative "serve/report_preparer"
|
|
6
|
+
require_relative "serve/static_file_handler"
|
|
4
7
|
|
|
5
8
|
module SimpleCov
|
|
6
9
|
module CLI
|
|
7
10
|
# `simplecov serve [--port N] [--host HOST]` — serve the coverage
|
|
8
|
-
# report over HTTP. A
|
|
11
|
+
# report over HTTP. A small static file server backed by stdlib
|
|
9
12
|
# `socket`, so there's no extra dependency just for "view a local
|
|
10
|
-
# report on a CI box where `file://` doesn't work."
|
|
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.
|
|
11
16
|
module Serve
|
|
12
|
-
|
|
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
|
|
17
|
+
extend CommandHelpers
|
|
30
18
|
|
|
31
19
|
module_function
|
|
32
20
|
|
|
33
21
|
def run(args, stdout:, stderr:, **)
|
|
34
22
|
opts = parse(args)
|
|
35
23
|
dir = SimpleCov::CLI.coverage_dir
|
|
36
|
-
|
|
24
|
+
message = ReportPreparer.call(dir)
|
|
25
|
+
return error(stderr, message) if message
|
|
37
26
|
|
|
38
27
|
require "socket"
|
|
39
|
-
with_server(opts) do |server|
|
|
28
|
+
with_server(opts, stderr) do |server|
|
|
40
29
|
announce(stdout, server, dir)
|
|
41
30
|
serve_loop(server, dir, stdout)
|
|
42
31
|
0
|
|
43
32
|
end
|
|
44
33
|
end
|
|
45
34
|
|
|
46
|
-
|
|
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)
|
|
47
38
|
# The receiver cast works around an rbs stdlib gap: TCPSocket's
|
|
48
39
|
# explicit `self.new` shadows TCPServer#initialize's (host, port) form.
|
|
49
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
|
|
50
44
|
begin
|
|
51
45
|
yield server
|
|
52
46
|
ensure
|
|
@@ -65,85 +59,27 @@ module SimpleCov
|
|
|
65
59
|
|
|
66
60
|
def announce(stdout, server, dir)
|
|
67
61
|
port = server.addr[1]
|
|
68
|
-
host = server.addr[3]
|
|
62
|
+
host = url_host(server.addr[3])
|
|
69
63
|
stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
|
|
70
64
|
stdout.puts("Press Ctrl-C to stop.")
|
|
71
65
|
end
|
|
72
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.
|
|
73
76
|
def serve_loop(server, dir, stdout)
|
|
74
|
-
loop
|
|
77
|
+
loop do
|
|
78
|
+
Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
|
|
79
|
+
end
|
|
75
80
|
rescue Interrupt
|
|
76
81
|
stdout.puts("\nsimplecov serve: stopping")
|
|
77
82
|
end
|
|
78
|
-
|
|
79
|
-
# Reads one HTTP request line, drains headers, serves the file or
|
|
80
|
-
# writes a status response. Wide rescue so a misbehaving client
|
|
81
|
-
# can't crash the server.
|
|
82
|
-
def handle_connection(client, root)
|
|
83
|
-
method, path = client.readline.split
|
|
84
|
-
drain_headers(client)
|
|
85
|
-
return respond(client, 405) unless method == "GET"
|
|
86
|
-
|
|
87
|
-
file = resolve(path, root)
|
|
88
|
-
return respond(client, file == :forbidden ? 403 : 404) unless file.is_a?(String)
|
|
89
|
-
|
|
90
|
-
respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
|
|
91
|
-
rescue StandardError
|
|
92
|
-
# Misbehaving clients (truncated requests, connection resets,
|
|
93
|
-
# invalid encoding) shouldn't take the whole server down.
|
|
94
|
-
nil
|
|
95
|
-
ensure
|
|
96
|
-
# simplecov:disable — `client` is the parameter, never nil here;
|
|
97
|
-
# the `&.` is purely defensive in case of future refactors
|
|
98
|
-
client&.close
|
|
99
|
-
# simplecov:enable
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def drain_headers(client)
|
|
103
|
-
loop { break if client.readline.strip.empty? }
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Returns the absolute path of the file to serve, :forbidden for
|
|
107
|
-
# a traversal attempt (including symlinks that escape root), or
|
|
108
|
-
# nil for "not found".
|
|
109
|
-
def resolve(request_path, root)
|
|
110
|
-
path = request_path.split("?", 2).first.to_s.sub(%r{^/}, "")
|
|
111
|
-
absolute_root = File.realpath(root)
|
|
112
|
-
candidate = File.expand_path(path.empty? ? "index.html" : path, absolute_root)
|
|
113
|
-
# Reject `..` traversal and absolute-path attempts before
|
|
114
|
-
# touching disk so they're 403, not 404.
|
|
115
|
-
return :forbidden unless inside?(candidate, absolute_root)
|
|
116
|
-
|
|
117
|
-
candidate = File.join(candidate, "index.html") if File.directory?(candidate)
|
|
118
|
-
return nil unless File.file?(candidate)
|
|
119
|
-
|
|
120
|
-
# Resolve symlinks last and re-check: a file inside root could
|
|
121
|
-
# be a symlink pointing outside (e.g. /etc/passwd).
|
|
122
|
-
real = File.realpath(candidate)
|
|
123
|
-
inside?(real, absolute_root) ? real : :forbidden
|
|
124
|
-
rescue Errno::ENOENT
|
|
125
|
-
# simplecov:disable — TOCTOU: candidate vanished between
|
|
126
|
-
# File.file? and File.realpath. Treat as "not found".
|
|
127
|
-
nil
|
|
128
|
-
# simplecov:enable
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def inside?(path, root)
|
|
132
|
-
path == root || path.start_with?(root + File::SEPARATOR)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def respond(client, status, body = "", content_type = "text/plain")
|
|
136
|
-
client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || 'Error'}\r\n",
|
|
137
|
-
"Content-Type: #{content_type || 'application/octet-stream'}\r\n",
|
|
138
|
-
"Content-Length: #{body.bytesize}\r\n",
|
|
139
|
-
"Connection: close\r\n\r\n")
|
|
140
|
-
client.write(body)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def error(stderr, message)
|
|
144
|
-
stderr.puts("simplecov serve: #{message}")
|
|
145
|
-
1
|
|
146
|
-
end
|
|
147
83
|
end
|
|
148
84
|
end
|
|
149
85
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "optparse"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "command_helpers"
|
|
6
6
|
|
|
7
7
|
module SimpleCov
|
|
8
8
|
module CLI
|
|
@@ -11,30 +11,25 @@ module SimpleCov
|
|
|
11
11
|
# developer can answer "where should I add tests next?" without
|
|
12
12
|
# opening a browser. Reads coverage.json directly.
|
|
13
13
|
module Uncovered
|
|
14
|
-
|
|
14
|
+
extend CommandHelpers
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
16
|
+
DEFAULT_TOP = 10
|
|
22
17
|
|
|
23
18
|
module_function
|
|
24
19
|
|
|
25
20
|
def run(args, stdout:, stderr:, **)
|
|
26
21
|
opts = parse(args)
|
|
27
|
-
keys =
|
|
22
|
+
keys = CoverageFile::CRITERIA[opts[:criterion]]
|
|
28
23
|
return unknown_criterion(opts[:criterion], stderr) unless keys
|
|
29
24
|
|
|
30
25
|
report(opts, keys, stdout, stderr)
|
|
31
26
|
end
|
|
32
27
|
|
|
33
28
|
def report(opts, keys, stdout, stderr)
|
|
34
|
-
|
|
29
|
+
coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
|
|
30
|
+
return 1 unless coverage
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
files = rank(data.fetch("coverage", none), opts[:threshold], keys).first(opts[:top])
|
|
32
|
+
files = rank(coverage, opts[:threshold], keys).first(opts[:top])
|
|
38
33
|
return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
|
|
39
34
|
|
|
40
35
|
emit(stdout, files, opts)
|
|
@@ -51,22 +46,22 @@ module SimpleCov
|
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
def parse(args)
|
|
54
|
-
opts =
|
|
55
|
-
|
|
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
|
|
56
54
|
opts
|
|
57
55
|
end
|
|
58
56
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
o.on("--json") { opts[:json] = true }
|
|
68
|
-
o.on("--no-color") { opts[:no_color] = true }
|
|
69
|
-
end
|
|
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
|
|
70
65
|
end
|
|
71
66
|
|
|
72
67
|
def emit_text(stdout, files, color)
|
data/lib/simplecov/cli.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "optparse"
|
|
3
4
|
require_relative "color"
|
|
4
5
|
require_relative "cli/dotfile"
|
|
5
6
|
require_relative "cli/clean"
|
|
7
|
+
require_relative "cli/coverage_file"
|
|
6
8
|
require_relative "cli/coverage"
|
|
7
9
|
require_relative "cli/diff"
|
|
8
10
|
require_relative "cli/merge"
|
|
@@ -73,13 +75,23 @@ module SimpleCov
|
|
|
73
75
|
def run(argv, stdout: $stdout, stderr: $stderr)
|
|
74
76
|
command, *rest = argv
|
|
75
77
|
handler = COMMANDS[command]
|
|
76
|
-
return handler
|
|
78
|
+
return dispatch(handler, command, rest, stdout: stdout, stderr: stderr) if handler
|
|
77
79
|
return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
|
|
78
80
|
|
|
79
81
|
stderr.puts("simplecov: unknown command #{command.inspect}", usage)
|
|
80
82
|
1
|
|
81
83
|
end
|
|
82
84
|
|
|
85
|
+
# One rescue covers every subcommand's OptionParser, so a typo'd
|
|
86
|
+
# flag or a malformed typed argument becomes a one-line error and
|
|
87
|
+
# exit status 1 instead of an unhandled-exception backtrace.
|
|
88
|
+
def dispatch(handler, command, rest, stdout:, stderr:)
|
|
89
|
+
handler.run(rest, stdout: stdout, stderr: stderr)
|
|
90
|
+
rescue OptionParser::ParseError => e
|
|
91
|
+
stderr.puts("simplecov #{command}: #{e.message} (run `simplecov help` for usage)")
|
|
92
|
+
1
|
|
93
|
+
end
|
|
94
|
+
|
|
83
95
|
def usage
|
|
84
96
|
<<~USAGE
|
|
85
97
|
Usage: simplecov <command> [options]
|
|
@@ -128,7 +140,8 @@ module SimpleCov
|
|
|
128
140
|
|
|
129
141
|
diff options:
|
|
130
142
|
--fail-on-drop Exit non-zero when any file's coverage
|
|
131
|
-
dropped vs the baseline
|
|
143
|
+
dropped vs the baseline (deleted
|
|
144
|
+
files don't count as drops)
|
|
132
145
|
--json Emit results as a JSON array (for CI)
|
|
133
146
|
--threshold N Only show files whose absolute delta
|
|
134
147
|
in any criterion is at least N%
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../source_file/ruby_data_parser"
|
|
4
|
+
require_relative "identity_interner"
|
|
5
|
+
require_relative "interned_counts"
|
|
4
6
|
|
|
5
7
|
module SimpleCov
|
|
6
8
|
module Combine
|
|
7
9
|
#
|
|
8
10
|
# Combine different branch coverage results on single file.
|
|
9
11
|
#
|
|
10
|
-
# Should be called through `
|
|
12
|
+
# Should be called through `CoverageAccumulator`.
|
|
11
13
|
module BranchesCombiner
|
|
12
14
|
module_function
|
|
13
15
|
|
|
@@ -24,31 +26,53 @@ module SimpleCov
|
|
|
24
26
|
# @return [Hash]
|
|
25
27
|
#
|
|
26
28
|
def combine(coverage_a, coverage_b)
|
|
27
|
-
merged = {} #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
|
|
30
|
+
materialize(absorb(merged, coverage_b))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Folds `coverage` into `target`, an interned table keyed by condition
|
|
34
|
+
# identity. Kept interned (rather than turned back into tuple keys per
|
|
35
|
+
# merge) so a fold over N resultsets interns each condition once
|
|
36
|
+
# instead of re-interning the whole accumulated table N times.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash] `target`
|
|
39
|
+
def absorb(target, coverage)
|
|
40
|
+
return target unless coverage
|
|
41
|
+
|
|
42
|
+
coverage.each do |condition, branches_inside|
|
|
43
|
+
entry = target[identities[condition]] ||= new_condition(condition)
|
|
44
|
+
InternedCounts.absorb_counts(entry[1], branches_inside, identities)
|
|
35
45
|
end
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
target
|
|
38
48
|
end
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
# Split out so the empty arm table has somewhere to carry its type
|
|
51
|
+
# annotation. Only allocated the first time a condition is seen.
|
|
52
|
+
def new_condition(condition)
|
|
53
|
+
arms = {} #: Hash[untyped, untyped]
|
|
54
|
+
[condition, arms]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Turns an interned table back into the tuple-keyed hash the rest of
|
|
58
|
+
# SimpleCov reads. Done once, at the end of a fold.
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash]
|
|
61
|
+
def materialize(target)
|
|
62
|
+
target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Bounded by the project's branch count, like `RubyDataParser`'s parse
|
|
66
|
+
# cache.
|
|
67
|
+
def identities
|
|
68
|
+
@identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
|
|
46
69
|
end
|
|
47
70
|
|
|
71
|
+
# Branches match on source span, whatever ids the recording processes
|
|
72
|
+
# handed them (issue #1233).
|
|
48
73
|
def tuple_identity(tuple)
|
|
49
|
-
|
|
50
|
-
type,
|
|
51
|
-
[type, start_line, start_column, end_line, end_column]
|
|
74
|
+
type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
|
|
75
|
+
[type, start_line, start_column, end_line, end_column].freeze
|
|
52
76
|
end
|
|
53
77
|
end
|
|
54
78
|
end
|