simplecov 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +148 -5
- data/exe/simplecov +0 -3
- data/lib/minitest/simplecov_plugin.rb +12 -9
- data/lib/simplecov/atomic_file.rb +16 -19
- data/lib/simplecov/autostart.rb +5 -7
- data/lib/simplecov/baseline/parser.rb +58 -0
- data/lib/simplecov/baseline.rb +170 -0
- data/lib/simplecov/cli/affected/changed_files.rb +57 -0
- data/lib/simplecov/cli/affected/selection.rb +117 -0
- data/lib/simplecov/cli/affected.rb +148 -0
- data/lib/simplecov/cli/annotations.rb +166 -0
- data/lib/simplecov/cli/badge/svg.rb +70 -0
- data/lib/simplecov/cli/badge.rb +62 -0
- data/lib/simplecov/cli/clean.rb +23 -24
- data/lib/simplecov/cli/command_helpers.rb +55 -19
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +25 -28
- data/lib/simplecov/cli/coverage_file.rb +58 -14
- data/lib/simplecov/cli/dead_code/output.rb +100 -0
- data/lib/simplecov/cli/dead_code.rb +139 -0
- data/lib/simplecov/cli/diff/output.rb +45 -0
- data/lib/simplecov/cli/diff.rb +35 -67
- data/lib/simplecov/cli/dotfile.rb +83 -38
- data/lib/simplecov/cli/git.rb +41 -0
- data/lib/simplecov/cli/history/output.rb +135 -0
- data/lib/simplecov/cli/history.rb +67 -0
- data/lib/simplecov/cli/merge.rb +34 -39
- data/lib/simplecov/cli/open.rb +12 -15
- data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
- data/lib/simplecov/cli/patch/output.rb +151 -0
- data/lib/simplecov/cli/patch.rb +175 -0
- data/lib/simplecov/cli/ratchet/output.rb +44 -0
- data/lib/simplecov/cli/ratchet.rb +83 -0
- data/lib/simplecov/cli/report.rb +18 -24
- data/lib/simplecov/cli/run.rb +8 -9
- data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
- data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
- data/lib/simplecov/cli/serve.rb +27 -24
- data/lib/simplecov/cli/show/annotator.rb +87 -0
- data/lib/simplecov/cli/show/sweep.rb +36 -0
- data/lib/simplecov/cli/show.rb +133 -0
- data/lib/simplecov/cli/status/facts.rb +87 -0
- data/lib/simplecov/cli/status.rb +93 -0
- data/lib/simplecov/cli/tests/redundancy.rb +78 -0
- data/lib/simplecov/cli/tests.rb +148 -0
- data/lib/simplecov/cli/uncovered/misses.rb +33 -0
- data/lib/simplecov/cli/uncovered.rb +58 -34
- data/lib/simplecov/cli/usage.rb +153 -0
- data/lib/simplecov/cli/watch/live_report.rb +73 -0
- data/lib/simplecov/cli/watch/narrator.rb +55 -0
- data/lib/simplecov/cli/watch/poller.rb +45 -0
- data/lib/simplecov/cli/watch/session.rb +134 -0
- data/lib/simplecov/cli/watch/test_plan.rb +43 -0
- data/lib/simplecov/cli/watch.rb +85 -0
- data/lib/simplecov/cli.rb +43 -92
- data/lib/simplecov/color.rb +19 -37
- data/lib/simplecov/combine/branches_combiner.rb +7 -31
- data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
- data/lib/simplecov/combine/identity_interner.rb +10 -13
- data/lib/simplecov/combine/interned_counts.rb +7 -9
- data/lib/simplecov/combine/lines_combiner.rb +20 -44
- data/lib/simplecov/combine/methods_combiner.rb +9 -35
- data/lib/simplecov/combine/results_combiner.rb +4 -21
- data/lib/simplecov/combine.rb +0 -6
- data/lib/simplecov/command_guesser.rb +20 -34
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +91 -79
- data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +7 -11
- data/lib/simplecov/configuration/filters.rb +40 -73
- data/lib/simplecov/configuration/formatting.rb +74 -83
- data/lib/simplecov/configuration/groups.rb +19 -13
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +31 -22
- data/lib/simplecov/configuration/merging.rb +94 -62
- data/lib/simplecov/configuration/missed_caps.rb +51 -0
- data/lib/simplecov/configuration/production.rb +23 -0
- data/lib/simplecov/configuration/test_tracking.rb +48 -0
- data/lib/simplecov/configuration/thresholds.rb +33 -62
- data/lib/simplecov/configuration/view_coverage.rb +37 -0
- data/lib/simplecov/configuration.rb +70 -76
- data/lib/simplecov/configuration_error.rb +5 -0
- data/lib/simplecov/context_map/union.rb +81 -0
- data/lib/simplecov/context_map.rb +187 -0
- data/lib/simplecov/coverage_json.rb +2 -5
- data/lib/simplecov/coverage_statistics.rb +14 -24
- data/lib/simplecov/coverage_violations.rb +160 -65
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +4 -23
- data/lib/simplecov/deprecation.rb +45 -27
- data/lib/simplecov/directive/erb.rb +44 -0
- data/lib/simplecov/directive/haml.rb +33 -0
- data/lib/simplecov/directive/indented_template.rb +37 -0
- data/lib/simplecov/directive/slim.rb +35 -0
- data/lib/simplecov/directive/template.rb +29 -0
- data/lib/simplecov/directive.rb +47 -62
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +17 -11
- data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
- data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
- data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
- data/lib/simplecov/exit_codes.rb +16 -10
- data/lib/simplecov/exit_handling.rb +94 -59
- data/lib/simplecov/file_list.rb +20 -51
- data/lib/simplecov/filter.rb +43 -66
- data/lib/simplecov/formatter/base.rb +37 -55
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
- data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
- data/lib/simplecov/formatter/html_formatter.rb +24 -41
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
- data/lib/simplecov/formatter/json_formatter.rb +7 -13
- data/lib/simplecov/formatter/multi_formatter.rb +5 -11
- data/lib/simplecov/formatter/simple_formatter.rb +2 -8
- data/lib/simplecov/formatter.rb +14 -10
- data/lib/simplecov/group_names.rb +13 -12
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +6 -9
- data/lib/simplecov/lines_classifier.rb +31 -16
- data/lib/simplecov/load_global_config.rb +7 -7
- data/lib/simplecov/parallel_adapters/base.rb +13 -41
- data/lib/simplecov/parallel_adapters/generic.rb +7 -19
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
- data/lib/simplecov/parallel_adapters.rb +24 -55
- data/lib/simplecov/parallel_coordination.rb +39 -49
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +67 -126
- data/lib/simplecov/process.rb +14 -32
- data/lib/simplecov/production/error.rb +11 -0
- data/lib/simplecov/production/file_sink.rb +130 -0
- data/lib/simplecov/production.rb +249 -0
- data/lib/simplecov/profiles/rails.rb +4 -15
- data/lib/simplecov/profiles/root_filter.rb +0 -4
- data/lib/simplecov/profiles/strict.rb +11 -21
- data/lib/simplecov/profiles.rb +11 -33
- data/lib/simplecov/report_deferral.rb +11 -15
- data/lib/simplecov/report_stamp.rb +6 -9
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +7 -12
- data/lib/simplecov/result.rb +74 -117
- data/lib/simplecov/result_adapter.rb +56 -68
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
- data/lib/simplecov/result_merger/resultset_file.rb +20 -27
- data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
- data/lib/simplecov/result_merger/resultset_store.rb +23 -14
- data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
- data/lib/simplecov/result_merger.rb +59 -58
- data/lib/simplecov/result_processing.rb +92 -122
- data/lib/simplecov/run_identity.rb +16 -18
- data/lib/simplecov/simulate_coverage.rb +34 -50
- data/lib/simplecov/source_file/branch.rb +8 -33
- data/lib/simplecov/source_file/branch_builder.rb +28 -47
- data/lib/simplecov/source_file/builder_context.rb +13 -13
- data/lib/simplecov/source_file/line.rb +13 -33
- data/lib/simplecov/source_file/line_builder.rb +9 -15
- data/lib/simplecov/source_file/method.rb +3 -7
- data/lib/simplecov/source_file/method_builder.rb +6 -15
- data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
- data/lib/simplecov/source_file/skip_chunks.rb +29 -29
- data/lib/simplecov/source_file/source_loader.rb +45 -35
- data/lib/simplecov/source_file/statistics.rb +20 -9
- data/lib/simplecov/source_file.rb +21 -51
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +55 -139
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +9 -26
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
- data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
- data/lib/simplecov/static_coverage_extractor/visitor.rb +45 -65
- data/lib/simplecov/static_coverage_extractor.rb +43 -75
- data/lib/simplecov/test_tracker/accessors.rb +34 -0
- data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
- data/lib/simplecov/test_tracker/delta.rb +69 -0
- data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
- data/lib/simplecov/test_tracker.rb +162 -0
- data/lib/simplecov/unloaded_file_injector.rb +18 -31
- data/lib/simplecov/useless_results_remover.rb +7 -6
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
- data/lib/simplecov/view_coverage.rb +55 -0
- data/lib/simplecov-html.rb +1 -0
- data/lib/simplecov.rb +57 -113
- data/lib/simplecov_json_formatter.rb +1 -0
- data/man/simplecov.1 +354 -0
- data/sig/simplecov.rbs +528 -595
- metadata +79 -6
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module SimpleCov
|
|
4
4
|
module CLI
|
|
5
5
|
module Serve
|
|
6
|
-
# The HTTP mechanics behind `simplecov serve`: reads one request
|
|
7
|
-
#
|
|
8
|
-
# traversal-safe path resolution. Kept apart from the CLI wiring
|
|
9
|
-
#
|
|
6
|
+
# The HTTP mechanics behind `simplecov serve`: reads one request per
|
|
7
|
+
# connection and answers it from the report directory, with
|
|
8
|
+
# traversal-safe path resolution. Kept apart from the CLI wiring in
|
|
9
|
+
# serve.rb.
|
|
10
10
|
module StaticFileHandler
|
|
11
11
|
MIME = {
|
|
12
12
|
".html" => "text/html; charset=utf-8", ".htm" => "text/html; charset=utf-8",
|
|
@@ -24,96 +24,102 @@ module SimpleCov
|
|
|
24
24
|
STATUS_TEXT = {200 => "OK", 400 => "Bad Request", 403 => "Forbidden",
|
|
25
25
|
404 => "Not Found", 405 => "Method Not Allowed"}.freeze
|
|
26
26
|
|
|
27
|
-
# Seconds a connection may sit idle mid-request before its reads
|
|
28
|
-
#
|
|
27
|
+
# Seconds a connection may sit idle mid-request before its reads raise
|
|
28
|
+
# IO::TimeoutError and the connection is dropped.
|
|
29
29
|
READ_TIMEOUT = 5
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# Reads one HTTP request line, drains headers, serves the file or
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
#
|
|
31
|
+
extend self
|
|
32
|
+
|
|
33
|
+
# Reads one HTTP request line, drains headers, serves the file or writes a
|
|
34
|
+
# status response. Wide rescue so a misbehaving client can't crash the
|
|
35
|
+
# server.
|
|
36
|
+
#
|
|
37
|
+
# `routes` maps exact request paths (query string excluded) to callables
|
|
38
|
+
# that take over the connection, the seam `simplecov watch` mounts its
|
|
39
|
+
# /events stream on. A route may hold the socket for as long as it
|
|
40
|
+
# likes; the ensure below closes it when the route returns.
|
|
41
|
+
def handle_connection(client, root, routes = {})
|
|
42
|
+
# JRuby doesn't implement IO#timeout=. Without the guard the
|
|
43
|
+
# NoMethodError lands in the wide rescue below and every connection
|
|
44
|
+
# closes with an empty response. An idle connection then pins its
|
|
45
|
+
# thread instead of timing out, which only leaks a thread in an
|
|
46
|
+
# interactive dev server.
|
|
44
47
|
client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
|
|
45
|
-
# simplecov:enable branch
|
|
46
48
|
method, path = client.readline.split
|
|
47
49
|
drain_headers(client)
|
|
48
|
-
# A request line without both tokens used to raise on
|
|
49
|
-
#
|
|
50
|
-
#
|
|
50
|
+
# A request line without both tokens used to raise on `path.split` inside
|
|
51
|
+
# the wide rescue, closing the connection with an empty response
|
|
52
|
+
# instead of the 400 below.
|
|
51
53
|
return respond(client, 400) if path.nil?
|
|
52
|
-
return respond(client, 405) unless method == "GET"
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
rescue
|
|
56
|
-
# Misbehaving clients (truncated requests, connection resets,
|
|
57
|
-
#
|
|
55
|
+
dispatch(client, method, path, root, routes)
|
|
56
|
+
rescue
|
|
57
|
+
# Misbehaving clients (truncated requests, connection resets, invalid
|
|
58
|
+
# encoding) shouldn't take the whole server down.
|
|
58
59
|
nil
|
|
59
60
|
ensure
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
client.close
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dispatch(client, method, path, root, routes)
|
|
65
|
+
return respond(client, 405) unless method.eql?("GET")
|
|
66
|
+
|
|
67
|
+
route = routes[path.split("?").first]
|
|
68
|
+
return route.call(client) if route
|
|
69
|
+
|
|
70
|
+
serve_file(client, path, root)
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def serve_file(client, path, root)
|
|
67
74
|
file = resolve(path, root)
|
|
68
|
-
|
|
75
|
+
# `resolve` answers a path, nothing for a file that is not there, or the
|
|
76
|
+
# refusal itself.
|
|
77
|
+
return respond(client, file ? 403 : 404) unless file.instance_of?(String)
|
|
69
78
|
|
|
70
79
|
respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
|
|
71
80
|
end
|
|
72
81
|
|
|
73
82
|
def drain_headers(client)
|
|
74
|
-
loop { break if client.readline.
|
|
83
|
+
loop { break if client.readline.rstrip.empty? }
|
|
75
84
|
end
|
|
76
85
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
86
|
+
# Answers the absolute path of the file to serve, :forbidden for a
|
|
87
|
+
# traversal attempt (symlinks that escape root included), or nil for
|
|
88
|
+
# "not found".
|
|
80
89
|
#
|
|
81
|
-
# The request path is deliberately NOT percent-decoded: filenames
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
# check below.
|
|
90
|
+
# The request path is deliberately NOT percent-decoded: filenames needing
|
|
91
|
+
# escapes don't occur in generated reports, and keeping `%2e%2e%2f` as
|
|
92
|
+
# literal bytes is part of the traversal defense. If decoding is ever
|
|
93
|
+
# added, it must happen BEFORE the `inside?` check.
|
|
86
94
|
def resolve(request_path, root)
|
|
87
|
-
path = request_path.split("?"
|
|
95
|
+
path = request_path.split("?").first.to_s.delete_prefix("/")
|
|
88
96
|
absolute_root = File.realpath(root)
|
|
89
|
-
candidate = File.expand_path(path
|
|
90
|
-
#
|
|
91
|
-
#
|
|
97
|
+
candidate = File.expand_path(path, absolute_root)
|
|
98
|
+
# Rejected before touching disk, so traversal and absolute-path attempts
|
|
99
|
+
# are 403, not 404.
|
|
92
100
|
return :forbidden unless inside?(candidate, absolute_root)
|
|
93
101
|
|
|
94
102
|
candidate = File.join(candidate, "index.html") if File.directory?(candidate)
|
|
95
103
|
return nil unless File.file?(candidate)
|
|
96
104
|
|
|
97
|
-
#
|
|
98
|
-
#
|
|
105
|
+
# Symlinks are resolved last and re-checked: a file inside root could be
|
|
106
|
+
# a symlink pointing outside.
|
|
99
107
|
real = File.realpath(candidate)
|
|
100
108
|
inside?(real, absolute_root) ? real : :forbidden
|
|
101
109
|
rescue Errno::ENOENT
|
|
102
|
-
#
|
|
103
|
-
# File.file? and File.realpath. Treat as "not found".
|
|
110
|
+
# TOCTOU: candidate vanished between File.file? and File.realpath.
|
|
104
111
|
nil
|
|
105
|
-
# simplecov:enable
|
|
106
112
|
end
|
|
107
113
|
|
|
108
114
|
def inside?(path, root)
|
|
109
|
-
path
|
|
115
|
+
path.eql?(root) || path.start_with?(root + File::SEPARATOR)
|
|
110
116
|
end
|
|
111
117
|
|
|
112
118
|
def respond(client, status, body = "", content_type = "text/plain")
|
|
113
|
-
client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] ||
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
client.write("HTTP/1.1 #{status} #{STATUS_TEXT[status] || "Error"}\r\n",
|
|
120
|
+
"Content-Type: #{content_type || "application/octet-stream"}\r\n",
|
|
121
|
+
"Content-Length: #{body.bytesize}\r\n",
|
|
122
|
+
"Connection: close\r\n\r\n")
|
|
117
123
|
client.write(body)
|
|
118
124
|
end
|
|
119
125
|
end
|
data/lib/simplecov/cli/serve.rb
CHANGED
|
@@ -7,24 +7,22 @@ require_relative "serve/static_file_handler"
|
|
|
7
7
|
|
|
8
8
|
module SimpleCov
|
|
9
9
|
module CLI
|
|
10
|
-
# `simplecov serve
|
|
11
|
-
#
|
|
12
|
-
# `
|
|
13
|
-
#
|
|
14
|
-
# the CLI wiring (options, binding, the accept loop); the HTTP
|
|
15
|
-
# mechanics live in StaticFileHandler.
|
|
10
|
+
# `simplecov serve`: a small static file server backed by stdlib `socket`, so
|
|
11
|
+
# there is no extra dependency just for viewing a local report on a box where
|
|
12
|
+
# `file://` doesn't work. This module is the CLI wiring; the HTTP mechanics
|
|
13
|
+
# live in StaticFileHandler.
|
|
16
14
|
module Serve
|
|
17
15
|
extend CommandHelpers
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
extend self
|
|
20
18
|
|
|
21
19
|
def run(args, stdout:, stderr:, **)
|
|
22
20
|
opts = parse(args)
|
|
23
|
-
dir =
|
|
21
|
+
dir = CLI.coverage_dir
|
|
24
22
|
message = ReportPreparer.call(dir)
|
|
25
23
|
return error(stderr, message) if message
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
require_socket
|
|
28
26
|
with_server(opts, stderr) do |server|
|
|
29
27
|
announce(stdout, server, dir)
|
|
30
28
|
serve_loop(server, dir, stdout)
|
|
@@ -32,14 +30,20 @@ module SimpleCov
|
|
|
32
30
|
end
|
|
33
31
|
end
|
|
34
32
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
33
|
+
# Required here rather than at load time so the other subcommands do not pay
|
|
34
|
+
# for sockets.
|
|
35
|
+
def require_socket
|
|
36
|
+
require "socket"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Answers 1 when the socket can't be bound: port already taken, privileged
|
|
40
|
+
# port, unresolvable host. The receiver cast works around an rbs stdlib gap,
|
|
41
|
+
# where TCPSocket's explicit `self.new` shadows TCPServer#initialize's
|
|
42
|
+
# (host, port) form.
|
|
37
43
|
def with_server(opts, stderr)
|
|
38
|
-
|
|
39
|
-
# explicit `self.new` shadows TCPServer#initialize's (host, port) form.
|
|
40
|
-
server = (_ = TCPServer).new(opts[:host], opts[:port]) #: TCPServer
|
|
44
|
+
server = (_ = TCPServer).new(opts.fetch(:host), opts.fetch(:port)) #: TCPServer
|
|
41
45
|
rescue SystemCallError, SocketError => e
|
|
42
|
-
error(stderr, "cannot bind to #{opts
|
|
46
|
+
error(stderr, "cannot bind to #{opts.fetch(:host)}:#{opts.fetch(:port)} (#{e})")
|
|
43
47
|
else
|
|
44
48
|
begin
|
|
45
49
|
yield server
|
|
@@ -50,29 +54,28 @@ module SimpleCov
|
|
|
50
54
|
|
|
51
55
|
def parse(args)
|
|
52
56
|
opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
|
|
53
|
-
|
|
57
|
+
build_parser do |o|
|
|
54
58
|
o.on("--port N", Integer) { |v| opts[:port] = v }
|
|
55
|
-
o.on("--host HOST")
|
|
59
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
56
60
|
end.parse(args)
|
|
57
61
|
opts
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def announce(stdout, server, dir)
|
|
61
|
-
port = server.addr
|
|
62
|
-
host = url_host(server.addr
|
|
65
|
+
port = server.addr.fetch(1)
|
|
66
|
+
host = url_host(server.addr.fetch(3))
|
|
63
67
|
stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
|
|
64
68
|
stdout.puts("Press Ctrl-C to stop.")
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
# IPv6 literals need brackets in a URL
|
|
68
|
-
# http://[::1]:PORT/, not the invalid http://::1:PORT
|
|
71
|
+
# IPv6 literals need brackets in a URL: `--host ::1` must announce
|
|
72
|
+
# http://[::1]:PORT/, not the invalid http://::1:PORT/.
|
|
69
73
|
def url_host(host)
|
|
70
74
|
host.include?(":") ? "[#{host}]" : host
|
|
71
75
|
end
|
|
72
76
|
|
|
73
|
-
# One thread per connection: browsers open speculative sockets
|
|
74
|
-
#
|
|
75
|
-
# request (the report's assets included) behind them.
|
|
77
|
+
# One thread per connection: browsers open speculative sockets that send no
|
|
78
|
+
# bytes, and a serial loop would stall every real request behind them.
|
|
76
79
|
def serve_loop(server, dir, stdout)
|
|
77
80
|
loop do
|
|
78
81
|
Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Show
|
|
6
|
+
# Renders one file's annotated source: a right-aligned line-number gutter,
|
|
7
|
+
# the hit count beside it, and a caret line naming each miss under the line
|
|
8
|
+
# it happened on.
|
|
9
|
+
module Annotator
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
def call(source, entry, stdout, color:)
|
|
13
|
+
markers = markers_for(entry)
|
|
14
|
+
widths = {number: source.size.to_s.length, count: count_width(entry)}
|
|
15
|
+
source.each_with_index do |line, index|
|
|
16
|
+
rendered = row(index + 1, entry.fetch("lines").at(index), line, widths, color)
|
|
17
|
+
emit(stdout, rendered, markers[index + 1], widths, color)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def emit(stdout, rendered, labels, widths, color)
|
|
22
|
+
stdout.puts(rendered)
|
|
23
|
+
return if labels.empty?
|
|
24
|
+
|
|
25
|
+
gutter = " " * (widths.fetch(:number) + widths.fetch(:count) + 4)
|
|
26
|
+
stdout.puts(gutter + paint("^ #{labels.join(", ")}", :red, color))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def missed_lines(entry)
|
|
30
|
+
entry.fetch("lines").each_with_index.filter_map do |hit, index|
|
|
31
|
+
index + 1 if hit.instance_of?(Integer) && hit.zero?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def markers_for(entry)
|
|
36
|
+
markers = Hash.new { |hash, line| hash[line] = [] } #: Hash[Integer, Array[String]]
|
|
37
|
+
missed_lines(entry).each { |line| markers[line] << "missed" }
|
|
38
|
+
each_missed(entry["branches"]) { |line| markers[line] << "branch missed" }
|
|
39
|
+
each_missed(entry["methods"]) { |line| markers[line] << "method missed" }
|
|
40
|
+
markers
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def each_missed(items)
|
|
44
|
+
return unless items.instance_of?(Array)
|
|
45
|
+
|
|
46
|
+
items.each do |item|
|
|
47
|
+
line = missed_line_of(item)
|
|
48
|
+
yield line if line
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The reported line of a zero-hit item, nil for a covered or malformed one,
|
|
53
|
+
# the same tolerance the patch subcommand applies to branch entries.
|
|
54
|
+
def missed_line_of(item)
|
|
55
|
+
return nil unless item.instance_of?(Hash)
|
|
56
|
+
|
|
57
|
+
hits = item["coverage"]
|
|
58
|
+
return nil unless hits.instance_of?(Integer) && hits.zero?
|
|
59
|
+
|
|
60
|
+
line = item["report_line"] || item["start_line"]
|
|
61
|
+
line if line.instance_of?(Integer)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def count_width(entry)
|
|
65
|
+
entry.fetch("lines").grep(Integer) { |hit| hit.to_s.length }.max || 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def row(number, hit, line, widths, color)
|
|
69
|
+
format("%#{widths.fetch(:number)}d %s %s", number, count(hit, widths, color), line).rstrip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Padded to width before it is painted, so escape codes can't skew the
|
|
73
|
+
# column, and blank for a line carrying no count.
|
|
74
|
+
def count(hit, widths, color)
|
|
75
|
+
padded = (hit.instance_of?(Integer) ? hit.to_s : "").rjust(widths.fetch(:count))
|
|
76
|
+
return padded unless hit.instance_of?(Integer)
|
|
77
|
+
|
|
78
|
+
paint(padded, hit.zero? ? :red : :green, color)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def paint(text, tint, color)
|
|
82
|
+
Color.colorize(text, tint, enabled: color)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Show
|
|
6
|
+
module Sweep
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def misses(coverage)
|
|
10
|
+
rows = coverage.filter_map do |filename, entry|
|
|
11
|
+
next unless entry.instance_of?(Hash) && entry["lines"].instance_of?(Array)
|
|
12
|
+
|
|
13
|
+
missed = Annotator.missed_lines(entry)
|
|
14
|
+
[display_path(filename), missed] unless missed.empty?
|
|
15
|
+
end
|
|
16
|
+
rows.sort_by(&:first)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def emit(rows, opts, stdout, stderr)
|
|
20
|
+
if opts.fetch(:json)
|
|
21
|
+
stdout.puts(JSON.pretty_generate(rows.map { |path, missed| {path: path, missed: missed} }))
|
|
22
|
+
elsif rows.empty?
|
|
23
|
+
stderr.puts("simplecov show: nothing uncovered")
|
|
24
|
+
else
|
|
25
|
+
rows.each { |path, missed| stdout.puts("#{path}:#{Patch::Output.ranges(missed, ",")}") }
|
|
26
|
+
end
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def display_path(filename)
|
|
31
|
+
filename.delete_prefix("#{File.expand_path(SimpleCov.root)}/")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
require_relative "patch/output"
|
|
7
|
+
require_relative "show/annotator"
|
|
8
|
+
require_relative "show/sweep"
|
|
9
|
+
|
|
10
|
+
module SimpleCov
|
|
11
|
+
module CLI
|
|
12
|
+
# `simplecov show <path>`: annotated source in the terminal, the way
|
|
13
|
+
# `go tool cover` and `llvm-cov show` print it. `--uncovered-only` collapses
|
|
14
|
+
# the answer to `path:ranges`, a form that greps, fits in a commit message,
|
|
15
|
+
# and hands a coding agent exactly the lines whose tests are missing.
|
|
16
|
+
module Show
|
|
17
|
+
extend CommandHelpers
|
|
18
|
+
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
def run(args, stdout:, stderr:)
|
|
22
|
+
opts = parse(args)
|
|
23
|
+
return run_project(opts, stdout, stderr) unless opts.fetch(:path)
|
|
24
|
+
|
|
25
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
|
|
26
|
+
return 1 unless coverage
|
|
27
|
+
|
|
28
|
+
located = locate_or_report(coverage, opts, stderr)
|
|
29
|
+
return 1 unless located
|
|
30
|
+
|
|
31
|
+
render(located, opts, stdout, stderr)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# No path sweeps the whole project, in the compact forms only. The annotated
|
|
35
|
+
# text form still wants one file.
|
|
36
|
+
def run_project(opts, stdout, stderr)
|
|
37
|
+
unless opts.fetch(:uncovered_only) || opts.fetch(:json)
|
|
38
|
+
return error(stderr, "missing path (annotating needs one file; --uncovered-only sweeps the whole project)")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "show", stderr: stderr)
|
|
42
|
+
return 1 unless coverage
|
|
43
|
+
|
|
44
|
+
Sweep.emit(Sweep.misses(coverage), opts, stdout, stderr)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse(args)
|
|
48
|
+
opts = {input: CLI.default_input, no_color: false,
|
|
49
|
+
uncovered_only: false, json: false} #: Hash[Symbol, untyped]
|
|
50
|
+
rest = build_parser { |parser| options(parser, opts) }.parse(args)
|
|
51
|
+
opts[:path] = rest.first
|
|
52
|
+
opts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def options(parser, opts)
|
|
56
|
+
parser.on("--input PATH") { |v| opts[:input] = v }
|
|
57
|
+
parser.on("--no-color") { opts[:no_color] = true }
|
|
58
|
+
parser.on("--uncovered-only") { opts[:uncovered_only] = true }
|
|
59
|
+
parser.on("--json") { opts[:json] = true }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A line-less report has no per-line hits to put in a gutter, so it is
|
|
63
|
+
# reported like an unresolvable path is.
|
|
64
|
+
def locate_or_report(coverage, opts, stderr)
|
|
65
|
+
path, input = opts.fetch_values(:path, :input)
|
|
66
|
+
match = CoverageFile.lookup(coverage, path)
|
|
67
|
+
return error_nil(stderr, CoverageFile.not_found_message(coverage, path, input)) unless match
|
|
68
|
+
|
|
69
|
+
entry = match.last
|
|
70
|
+
unless entry.instance_of?(Hash)
|
|
71
|
+
return CoverageFile.report_invalid(stderr, "show", input, "entry for #{path} must be an object")
|
|
72
|
+
end
|
|
73
|
+
return match if entry["lines"].instance_of?(Array)
|
|
74
|
+
|
|
75
|
+
error_nil(stderr, "no line coverage for #{path} in #{input}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render(located, opts, stdout, stderr)
|
|
79
|
+
filename, entry = located
|
|
80
|
+
return emit_json(entry, opts, stdout) if opts.fetch(:json)
|
|
81
|
+
return show_uncovered(entry, opts, stdout, stderr) if opts.fetch(:uncovered_only)
|
|
82
|
+
|
|
83
|
+
source = source_for(filename, entry, opts, stderr)
|
|
84
|
+
return 1 unless source
|
|
85
|
+
|
|
86
|
+
Annotator.call(source, entry, stdout, color: CLI.color_enabled?(opts, stdout))
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def emit_json(entry, opts, stdout)
|
|
91
|
+
stdout.puts(JSON.pretty_generate(
|
|
92
|
+
path: opts.fetch(:path), missed: Annotator.missed_lines(entry),
|
|
93
|
+
lines: relevant_lines(entry), markers: Annotator.markers_for(entry)
|
|
94
|
+
))
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def relevant_lines(entry)
|
|
99
|
+
entry.fetch("lines").each_with_index.filter_map do |hit, index|
|
|
100
|
+
{number: index + 1, hits: hit} if hit.instance_of?(Integer)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def show_uncovered(entry, opts, stdout, stderr)
|
|
105
|
+
missed = Annotator.missed_lines(entry)
|
|
106
|
+
if missed.empty?
|
|
107
|
+
stderr.puts("simplecov show: nothing uncovered in #{opts.fetch(:path)}")
|
|
108
|
+
else
|
|
109
|
+
stdout.puts("#{opts.fetch(:path)}:#{Patch::Output.ranges(missed, ",")}")
|
|
110
|
+
end
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The report's own source when it carries one; otherwise the file on disk,
|
|
115
|
+
# accepted only while its line count still matches the report's, since
|
|
116
|
+
# annotating drifted source would put hit counts on the wrong lines.
|
|
117
|
+
def source_for(filename, entry, opts, stderr)
|
|
118
|
+
embedded = entry["source"]
|
|
119
|
+
return embedded if embedded.instance_of?(Array) && embedded.all?(String)
|
|
120
|
+
unless File.file?(filename)
|
|
121
|
+
return error_nil(stderr, "no source for #{opts.fetch(:path)} in #{opts.fetch(:input)} and no file " \
|
|
122
|
+
"at #{filename}, regenerate the report with `source_in_json true`")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
lines = File.readlines(filename, chomp: true)
|
|
126
|
+
return lines if lines.size.eql?(entry.fetch("lines").size)
|
|
127
|
+
|
|
128
|
+
error_nil(stderr, "#{filename} has changed since the report (#{lines.size} lines now, " \
|
|
129
|
+
"#{entry.fetch("lines").size} recorded), regenerate the report")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Status
|
|
6
|
+
module Facts
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def gather(document, resultset_path)
|
|
10
|
+
contexts = document["contexts"]
|
|
11
|
+
meta_facts(document).merge(
|
|
12
|
+
totals: totals(document["total"]),
|
|
13
|
+
contexts: (contexts.size if contexts.instance_of?(Array)),
|
|
14
|
+
resultset_path: resultset_path, resultset: readable_resultset(resultset_path)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def meta_facts(document)
|
|
19
|
+
none = {} #: Hash[String, untyped]
|
|
20
|
+
recorded = document["meta"]
|
|
21
|
+
meta = recorded.instance_of?(Hash) ? recorded : none
|
|
22
|
+
generated = parse_time(meta["timestamp"])
|
|
23
|
+
{
|
|
24
|
+
generated_at: meta["timestamp"], age: generated && whole_seconds(Time.now - generated),
|
|
25
|
+
version: meta["simplecov_version"], command_name: meta["command_name"],
|
|
26
|
+
commit: meta["commit"], behind: behind(meta["commit"])
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def age_of(timestamp)
|
|
31
|
+
whole_seconds(Process.clock_gettime(Process::CLOCK_REALTIME) - timestamp)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def whole_seconds(elapsed)
|
|
35
|
+
elapsed.truncate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_time(value)
|
|
39
|
+
Time.iso8601(value) if value.instance_of?(String)
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Commits between the report's commit and HEAD: 0 at HEAD, nil when
|
|
45
|
+
# unanswerable, meaning no commit recorded, no git, or a commit this
|
|
46
|
+
# repository has never seen.
|
|
47
|
+
def behind(commit)
|
|
48
|
+
return nil unless commit.instance_of?(String)
|
|
49
|
+
|
|
50
|
+
stdout, _detail, success = Git.capture("rev-list", "--count", "#{commit}..HEAD")
|
|
51
|
+
commit_count(_ = stdout) if success
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `Integer` ignores surrounding whitespace on its own, so git's trailing
|
|
55
|
+
# newline needs no trimming. The base is spelled out because bare `Integer`
|
|
56
|
+
# reads a leading zero as octal.
|
|
57
|
+
def commit_count(output)
|
|
58
|
+
Integer(output, 10)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def totals(total)
|
|
62
|
+
return {} unless total.instance_of?(Hash)
|
|
63
|
+
|
|
64
|
+
rows = {"line" => "lines", "branch" => "branches", "method" => "methods"}.filter_map do |name, key|
|
|
65
|
+
percent = total.dig(key, "percent")
|
|
66
|
+
[name, percent] if percent.is_a?(Numeric)
|
|
67
|
+
end
|
|
68
|
+
rows.to_h
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# An unreadable resultset answers nothing rather than raising: its
|
|
72
|
+
# absence is a fact, not an error.
|
|
73
|
+
def readable_resultset(path)
|
|
74
|
+
parsed = JSON.parse(File.read(path))
|
|
75
|
+
return nil unless parsed.instance_of?(Hash)
|
|
76
|
+
|
|
77
|
+
parsed.map do |command, data|
|
|
78
|
+
timestamp = data["timestamp"] if data.instance_of?(Hash)
|
|
79
|
+
{command: command, age: (age_of(timestamp) if timestamp.is_a?(Numeric))}
|
|
80
|
+
end
|
|
81
|
+
rescue SystemCallError, JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|