simplecov 0.22.0 → 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/LICENSE +1 -1
- data/README.md +173 -875
- data/exe/simplecov +8 -0
- data/lib/minitest/simplecov_plugin.rb +18 -7
- data/lib/simplecov/atomic_file.rb +67 -0
- data/lib/simplecov/autostart.rb +9 -0
- 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 +86 -0
- data/lib/simplecov/cli/command_helpers.rb +91 -0
- data/lib/simplecov/cli/completions/scripts.rb +114 -0
- data/lib/simplecov/cli/completions.rb +83 -0
- data/lib/simplecov/cli/coverage.rb +83 -0
- data/lib/simplecov/cli/coverage_file.rb +109 -0
- 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 +115 -0
- data/lib/simplecov/cli/dotfile.rb +156 -0
- 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 +115 -0
- data/lib/simplecov/cli/open.rb +45 -0
- 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 +91 -0
- data/lib/simplecov/cli/run.rb +35 -0
- data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
- data/lib/simplecov/cli/serve.rb +88 -0
- 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 +127 -0
- 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 +114 -0
- data/lib/simplecov/color.rb +65 -0
- data/lib/simplecov/combine/branches_combiner.rb +44 -21
- data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
- data/lib/simplecov/combine/identity_interner.rb +27 -0
- data/lib/simplecov/combine/interned_counts.rb +28 -0
- data/lib/simplecov/combine/lines_combiner.rb +35 -29
- data/lib/simplecov/combine/methods_combiner.rb +48 -0
- data/lib/simplecov/combine/results_combiner.rb +9 -49
- data/lib/simplecov/combine.rb +0 -24
- data/lib/simplecov/command_guesser.rb +99 -42
- data/lib/simplecov/configuration/baseline.rb +31 -0
- data/lib/simplecov/configuration/coverage.rb +184 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
- data/lib/simplecov/configuration/deprecations.rb +21 -0
- data/lib/simplecov/configuration/eval_coverage.rb +37 -0
- data/lib/simplecov/configuration/filters.rb +131 -0
- data/lib/simplecov/configuration/formatting.rb +129 -0
- data/lib/simplecov/configuration/groups.rb +48 -0
- data/lib/simplecov/configuration/history.rb +44 -0
- data/lib/simplecov/configuration/ignored_entries.rb +72 -0
- data/lib/simplecov/configuration/merging.rb +197 -0
- 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 +150 -0
- data/lib/simplecov/configuration/view_coverage.rb +37 -0
- data/lib/simplecov/configuration.rb +100 -443
- 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 +21 -0
- data/lib/simplecov/coverage_statistics.rb +24 -28
- data/lib/simplecov/coverage_violations.rb +254 -0
- data/lib/simplecov/current_run.rb +61 -0
- data/lib/simplecov/defaults.rb +20 -28
- data/lib/simplecov/deprecation.rb +63 -0
- 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 +152 -0
- data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
- data/lib/simplecov/exit_codes/check.rb +39 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
- 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 +27 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
- data/lib/simplecov/exit_codes.rb +26 -0
- data/lib/simplecov/exit_handling.rb +163 -0
- data/lib/simplecov/file_list.rb +63 -48
- data/lib/simplecov/filter.rb +98 -42
- data/lib/simplecov/formatter/base.rb +100 -0
- data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
- data/lib/simplecov/formatter/html_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
- data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
- data/lib/simplecov/formatter/json_formatter.rb +40 -0
- data/lib/simplecov/formatter/multi_formatter.rb +8 -10
- data/lib/simplecov/formatter/simple_formatter.rb +12 -15
- data/lib/simplecov/formatter.rb +21 -1
- data/lib/simplecov/group_names.rb +33 -0
- data/lib/simplecov/history.rb +115 -0
- data/lib/simplecov/last_run.rb +16 -5
- data/lib/simplecov/lines_classifier.rb +67 -19
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +50 -0
- data/lib/simplecov/parallel_adapters/generic.rb +33 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
- data/lib/simplecov/parallel_adapters.rb +54 -0
- data/lib/simplecov/parallel_coordination.rb +124 -0
- data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
- data/lib/simplecov/parallel_result_merger.rb +171 -0
- data/lib/simplecov/process.rb +34 -12
- 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/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +13 -10
- data/lib/simplecov/profiles/root_filter.rb +2 -5
- data/lib/simplecov/profiles/strict.rb +22 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +26 -19
- data/lib/simplecov/report_deferral.rb +45 -0
- data/lib/simplecov/report_stamp.rb +25 -0
- data/lib/simplecov/result/filter_config.rb +19 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
- data/lib/simplecov/result/serialization.rb +43 -0
- data/lib/simplecov/result/source_file_builder.rb +46 -0
- data/lib/simplecov/result.rb +123 -47
- data/lib/simplecov/result_adapter.rb +130 -11
- data/lib/simplecov/result_merger/contexts.rb +22 -0
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
- data/lib/simplecov/result_merger/resultset_file.rb +67 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
- data/lib/simplecov/result_merger/resultset_store.rb +62 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
- data/lib/simplecov/result_merger.rb +120 -124
- data/lib/simplecov/result_processing.rb +188 -0
- data/lib/simplecov/run_identity.rb +75 -0
- data/lib/simplecov/simulate_coverage.rb +72 -17
- data/lib/simplecov/source_file/branch.rb +8 -35
- data/lib/simplecov/source_file/branch_builder.rb +95 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +17 -32
- data/lib/simplecov/source_file/line_builder.rb +37 -0
- data/lib/simplecov/source_file/method.rb +54 -0
- data/lib/simplecov/source_file/method_builder.rb +51 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +89 -0
- data/lib/simplecov/source_file/statistics.rb +76 -0
- data/lib/simplecov/source_file.rb +64 -260
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
- data/lib/simplecov/static_coverage_extractor.rb +78 -0
- 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 +62 -0
- data/lib/simplecov/useless_results_remover.rb +17 -7
- 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 +5 -0
- data/lib/simplecov.rb +143 -394
- data/lib/simplecov_json_formatter.rb +5 -0
- data/man/simplecov.1 +354 -0
- data/sig/simplecov.rbs +1710 -0
- metadata +183 -61
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "command_helpers"
|
|
6
|
+
|
|
7
|
+
module SimpleCov
|
|
8
|
+
module CLI
|
|
9
|
+
module Report
|
|
10
|
+
extend CommandHelpers
|
|
11
|
+
|
|
12
|
+
TOTAL_LABEL = "All Files"
|
|
13
|
+
SECTIONS = [%w[Line lines], %w[Branch branches], %w[Method methods]].freeze #: Array[[String, String]]
|
|
14
|
+
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
def run(args, stdout:, stderr:)
|
|
18
|
+
opts, = parse_common(args)
|
|
19
|
+
return 1 unless (data = load_data(opts.fetch(:input), stderr))
|
|
20
|
+
|
|
21
|
+
if opts.fetch(:json)
|
|
22
|
+
emit_json(stdout, data)
|
|
23
|
+
else
|
|
24
|
+
emit_text(stdout, data, CLI.color_enabled?(opts, stdout))
|
|
25
|
+
end
|
|
26
|
+
0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Valid JSON isn't enough: a wrong-typed "total" or "groups" used to escape
|
|
30
|
+
# here and crash the emitters with a backtrace instead of the one-line error
|
|
31
|
+
# the sibling commands print.
|
|
32
|
+
def load_data(input, stderr)
|
|
33
|
+
data = CoverageFile.load_document(input, command: "report", stderr: stderr)
|
|
34
|
+
return unless data
|
|
35
|
+
|
|
36
|
+
none = {} #: Hash[String, untyped]
|
|
37
|
+
unless data.fetch("total", none).instance_of?(Hash)
|
|
38
|
+
return CoverageFile.report_invalid(stderr, "report", input, '"total" must be an object')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
groups = data.fetch("groups", none)
|
|
42
|
+
return data if groups.instance_of?(Hash) && groups.each_value.all?(Hash)
|
|
43
|
+
|
|
44
|
+
CoverageFile.report_invalid(stderr, "report", input, '"groups" must be an object of objects')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def emit_text(stdout, data, color)
|
|
48
|
+
none = {} #: Hash[String, untyped]
|
|
49
|
+
emit_totals(stdout, TOTAL_LABEL, data.fetch("total", none), color)
|
|
50
|
+
data.fetch("groups", none).each do |name, group|
|
|
51
|
+
label = name.eql?(TOTAL_LABEL) ? "#{name} (group)" : name
|
|
52
|
+
emit_totals(stdout, label, group, color)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def emit_totals(stdout, label, totals, color)
|
|
57
|
+
stdout.puts(label)
|
|
58
|
+
SECTIONS.each { |display, key| emit_section(stdout, display, totals[key], color) }
|
|
59
|
+
stdout.puts
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def emit_section(stdout, display, section, color)
|
|
63
|
+
return unless section.instance_of?(Hash) && section["total"].to_i.positive?
|
|
64
|
+
|
|
65
|
+
stdout.puts(stats_row(display,
|
|
66
|
+
Color.colorize_percent(section["percent"].to_f, enabled: color),
|
|
67
|
+
section["covered"],
|
|
68
|
+
section.fetch("total")))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def emit_json(stdout, data)
|
|
72
|
+
none = {} #: Hash[String, untyped]
|
|
73
|
+
groups = data.fetch("groups", none).transform_values { |group| collect_section(group) }
|
|
74
|
+
payload = {"total" => collect_section(data.fetch("total", none)), "groups" => groups}
|
|
75
|
+
stdout.puts(JSON.pretty_generate(payload))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def collect_section(totals)
|
|
79
|
+
sections = {} #: Hash[String, untyped]
|
|
80
|
+
SECTIONS.each_with_object(sections) do |(_, key), out|
|
|
81
|
+
section = totals[key]
|
|
82
|
+
next unless section.instance_of?(Hash) && section["total"].to_i.positive?
|
|
83
|
+
|
|
84
|
+
out[key] = {
|
|
85
|
+
"percent" => section["percent"], "covered" => section["covered"], "total" => section.fetch("total")
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
module CLI
|
|
5
|
+
module Run
|
|
6
|
+
AUTOSTART = File.expand_path("../autostart", __dir__.to_s)
|
|
7
|
+
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
def run(args, stderr:, **)
|
|
11
|
+
cmd = args.first.eql?("--") ? args.drop(1) : args
|
|
12
|
+
if cmd.empty?
|
|
13
|
+
stderr.puts("simplecov run: missing command")
|
|
14
|
+
return 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
exec_command(rubyopt_env, cmd)
|
|
18
|
+
rescue Errno::ENOENT => e
|
|
19
|
+
stderr.puts("simplecov run: #{e}")
|
|
20
|
+
127
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def exec_command(env, cmd)
|
|
24
|
+
Kernel.exec(env, *cmd)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rubyopt_env
|
|
28
|
+
existing = ENV["RUBYOPT"].to_s.strip
|
|
29
|
+
injection = "-r#{AUTOSTART}"
|
|
30
|
+
merged = existing.empty? ? injection : "#{existing} #{injection}"
|
|
31
|
+
ENV.to_hash.merge("RUBYOPT" => merged)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../coverage_json"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
module CLI
|
|
7
|
+
module Serve
|
|
8
|
+
module ReportPreparer
|
|
9
|
+
def self.call(dir)
|
|
10
|
+
index_path = File.join(dir, "index.html")
|
|
11
|
+
json_path = File.join(dir, "coverage.json")
|
|
12
|
+
return "#{dir} doesn't exist; run your test suite first" unless File.directory?(dir)
|
|
13
|
+
return if File.file?(index_path)
|
|
14
|
+
|
|
15
|
+
return "#{dir} has no index.html or coverage.json; run your test suite first" unless File.file?(json_path)
|
|
16
|
+
|
|
17
|
+
build_index(json_path, dir)
|
|
18
|
+
rescue CoverageJSON::Error, SystemCallError => e
|
|
19
|
+
"cannot build index.html from #{json_path}: #{e}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.build_index(json_path, dir)
|
|
23
|
+
require_relative "../../formatter/html_formatter"
|
|
24
|
+
Formatter::HTMLFormatter.new.format_from_json(json_path, dir)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
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 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
|
+
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 raise
|
|
28
|
+
# IO::TimeoutError and the connection is dropped.
|
|
29
|
+
READ_TIMEOUT = 5
|
|
30
|
+
|
|
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.
|
|
47
|
+
client.timeout = READ_TIMEOUT if client.respond_to?(:timeout=)
|
|
48
|
+
method, path = client.readline.split
|
|
49
|
+
drain_headers(client)
|
|
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.
|
|
53
|
+
return respond(client, 400) if path.nil?
|
|
54
|
+
|
|
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.
|
|
59
|
+
nil
|
|
60
|
+
ensure
|
|
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)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def serve_file(client, path, root)
|
|
74
|
+
file = resolve(path, root)
|
|
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)
|
|
78
|
+
|
|
79
|
+
respond(client, 200, File.binread(file), MIME[File.extname(file).downcase])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def drain_headers(client)
|
|
83
|
+
loop { break if client.readline.rstrip.empty? }
|
|
84
|
+
end
|
|
85
|
+
|
|
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".
|
|
89
|
+
#
|
|
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.
|
|
94
|
+
def resolve(request_path, root)
|
|
95
|
+
path = request_path.split("?").first.to_s.delete_prefix("/")
|
|
96
|
+
absolute_root = File.realpath(root)
|
|
97
|
+
candidate = File.expand_path(path, absolute_root)
|
|
98
|
+
# Rejected before touching disk, so traversal and absolute-path attempts
|
|
99
|
+
# are 403, not 404.
|
|
100
|
+
return :forbidden unless inside?(candidate, absolute_root)
|
|
101
|
+
|
|
102
|
+
candidate = File.join(candidate, "index.html") if File.directory?(candidate)
|
|
103
|
+
return nil unless File.file?(candidate)
|
|
104
|
+
|
|
105
|
+
# Symlinks are resolved last and re-checked: a file inside root could be
|
|
106
|
+
# a symlink pointing outside.
|
|
107
|
+
real = File.realpath(candidate)
|
|
108
|
+
inside?(real, absolute_root) ? real : :forbidden
|
|
109
|
+
rescue Errno::ENOENT
|
|
110
|
+
# TOCTOU: candidate vanished between File.file? and File.realpath.
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def inside?(path, root)
|
|
115
|
+
path.eql?(root) || path.start_with?(root + File::SEPARATOR)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def respond(client, status, body = "", content_type = "text/plain")
|
|
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")
|
|
123
|
+
client.write(body)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "command_helpers"
|
|
5
|
+
require_relative "serve/report_preparer"
|
|
6
|
+
require_relative "serve/static_file_handler"
|
|
7
|
+
|
|
8
|
+
module SimpleCov
|
|
9
|
+
module CLI
|
|
10
|
+
# `simplecov serve`: 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.
|
|
14
|
+
module Serve
|
|
15
|
+
extend CommandHelpers
|
|
16
|
+
|
|
17
|
+
extend self
|
|
18
|
+
|
|
19
|
+
def run(args, stdout:, stderr:, **)
|
|
20
|
+
opts = parse(args)
|
|
21
|
+
dir = CLI.coverage_dir
|
|
22
|
+
message = ReportPreparer.call(dir)
|
|
23
|
+
return error(stderr, message) if message
|
|
24
|
+
|
|
25
|
+
require_socket
|
|
26
|
+
with_server(opts, stderr) do |server|
|
|
27
|
+
announce(stdout, server, dir)
|
|
28
|
+
serve_loop(server, dir, stdout)
|
|
29
|
+
0
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
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.
|
|
43
|
+
def with_server(opts, stderr)
|
|
44
|
+
server = (_ = TCPServer).new(opts.fetch(:host), opts.fetch(:port)) #: TCPServer
|
|
45
|
+
rescue SystemCallError, SocketError => e
|
|
46
|
+
error(stderr, "cannot bind to #{opts.fetch(:host)}:#{opts.fetch(:port)} (#{e})")
|
|
47
|
+
else
|
|
48
|
+
begin
|
|
49
|
+
yield server
|
|
50
|
+
ensure
|
|
51
|
+
server.close
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse(args)
|
|
56
|
+
opts = {port: 0, host: "127.0.0.1"} #: Hash[Symbol, untyped]
|
|
57
|
+
build_parser do |o|
|
|
58
|
+
o.on("--port N", Integer) { |v| opts[:port] = v }
|
|
59
|
+
o.on("--host HOST") { |v| opts[:host] = v }
|
|
60
|
+
end.parse(args)
|
|
61
|
+
opts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def announce(stdout, server, dir)
|
|
65
|
+
port = server.addr.fetch(1)
|
|
66
|
+
host = url_host(server.addr.fetch(3))
|
|
67
|
+
stdout.puts("simplecov serve: serving #{dir} at http://#{host}:#{port}/")
|
|
68
|
+
stdout.puts("Press Ctrl-C to stop.")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# IPv6 literals need brackets in a URL: `--host ::1` must announce
|
|
72
|
+
# http://[::1]:PORT/, not the invalid http://::1:PORT/.
|
|
73
|
+
def url_host(host)
|
|
74
|
+
host.include?(":") ? "[#{host}]" : host
|
|
75
|
+
end
|
|
76
|
+
|
|
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.
|
|
79
|
+
def serve_loop(server, dir, stdout)
|
|
80
|
+
loop do
|
|
81
|
+
Thread.new(server.accept) { |client| StaticFileHandler.handle_connection(client, dir) }
|
|
82
|
+
end
|
|
83
|
+
rescue Interrupt
|
|
84
|
+
stdout.puts("\nsimplecov serve: stopping")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -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
|