getcov 0.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 +7 -0
- data/.github/workflows/pages.yml +54 -0
- data/.github/workflows/pr_coverage.yml +94 -0
- data/.github/workflows/release.yml +84 -0
- data/.github/workflows/ruby.yml +24 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +10 -0
- data/exe/getcov +10 -0
- data/lib/getcov/auto.rb +9 -0
- data/lib/getcov/configuration.rb +98 -0
- data/lib/getcov/formatter/badge.rb +38 -0
- data/lib/getcov/formatter/cobertura.rb +69 -0
- data/lib/getcov/formatter/html.rb +170 -0
- data/lib/getcov/formatter/lcov.rb +33 -0
- data/lib/getcov/formatter/pr_comment.rb +43 -0
- data/lib/getcov/formatter/summary_json.rb +33 -0
- data/lib/getcov/result.rb +114 -0
- data/lib/getcov/version.rb +4 -0
- data/lib/getcov.rb +165 -0
- data/test/auto_integration_test.rb +13 -0
- data/test/branch_summary_test.rb +17 -0
- data/test/configuration_test.rb +32 -0
- data/test/formatters_export_test.rb +33 -0
- data/test/html_formatter_detail_test.rb +32 -0
- data/test/html_formatter_test.rb +23 -0
- data/test/ignore_only_test.rb +14 -0
- data/test/merge_test.rb +20 -0
- data/test/per_file_minimum_test.rb +28 -0
- data/test/pr_comment_and_json_test.rb +25 -0
- data/test/result_test.rb +31 -0
- data/test/test_helper.rb +20 -0
- data/test/thresholds_test.rb +14 -0
- metadata +79 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
6
|
+
module Getcov
|
|
7
|
+
module Formatter
|
|
8
|
+
class HTML
|
|
9
|
+
INDEX_TEMPLATE = <<~'HTML'
|
|
10
|
+
<!doctype html>
|
|
11
|
+
<html>
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8">
|
|
14
|
+
<title>Getcov Coverage</title>
|
|
15
|
+
<style>
|
|
16
|
+
#{CSS}
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<h1>Getcov Coverage</h1>
|
|
21
|
+
<div class="total">
|
|
22
|
+
Total Coverage: <strong><%= format('%.2f%%', result.total_coverage) %></strong>
|
|
23
|
+
<% if result.branch_total_percent %>
|
|
24
|
+
• Branch: <strong><%= format('%.2f%%', result.branch_total_percent) %></strong>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<% grouped = result.files.group_by(&:group) %>
|
|
29
|
+
<% grouped.keys.sort_by { |g| g.to_s }.each do |group_name| %>
|
|
30
|
+
<div class="group">
|
|
31
|
+
<h2><%= group_name || 'Ungrouped' %></h2>
|
|
32
|
+
<table class="index-table">
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<th>File</th>
|
|
36
|
+
<th class="pct">Coverage</th>
|
|
37
|
+
<th class="pct">Branch</th>
|
|
38
|
+
<th class="pct">Hits</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<% grouped[group_name].each do |fr| %>
|
|
43
|
+
<tr>
|
|
44
|
+
<td><a href="<%= file_href(fr) %>"><%= fr.path %></a></td>
|
|
45
|
+
<td class="pct">
|
|
46
|
+
<div class="bar"><span style="width:<%= fr.percent %>%"></span></div>
|
|
47
|
+
<div><%= format('%.2f%%', fr.percent) %></div>
|
|
48
|
+
</td>
|
|
49
|
+
<td class="pct"><%= fr.branch_total ? format('%.2f%%', fr.branch_percent) : '-' %></td>
|
|
50
|
+
<td class="pct"><%= fr.covered %>/<%= fr.relevant %></td>
|
|
51
|
+
</tr>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
59
|
+
HTML
|
|
60
|
+
|
|
61
|
+
FILE_TEMPLATE = <<~'HTML'
|
|
62
|
+
<!doctype html>
|
|
63
|
+
<html>
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8">
|
|
66
|
+
<title><%= fr.path %> - Getcov Coverage</title>
|
|
67
|
+
<style>
|
|
68
|
+
#{CSS}
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div class="breadcrumb"><a href="index.html">« Back to index</a></div>
|
|
73
|
+
<h1><%= fr.path %></h1>
|
|
74
|
+
<% if @config.repo_url %>
|
|
75
|
+
<div class="muted">
|
|
76
|
+
<a href="<%= File.join(@config.repo_url, 'blob', @config.repo_branch, fr.path) %>#L1" target="_blank">View on repo</a>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
<div class="total">
|
|
80
|
+
File Coverage: <strong><%= format('%.2f%%', fr.percent) %></strong>
|
|
81
|
+
(<%= fr.covered %>/<%= fr.relevant %> relevant)
|
|
82
|
+
<% if fr.branch_total && fr.branch_total > 0 %>
|
|
83
|
+
• Branch: <strong><%= format('%.2f%%', fr.branch_percent) %></strong>
|
|
84
|
+
(<%= fr.branch_covered %>/<%= fr.branch_total %>)
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<table class="code-table">
|
|
89
|
+
<thead>
|
|
90
|
+
<tr>
|
|
91
|
+
<th class="ln">#</th>
|
|
92
|
+
<th class="hit">Hit</th>
|
|
93
|
+
<th>Source</th>
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
<% lines = source_lines(fr) %>
|
|
98
|
+
<% lines.each_with_index do |src, idx| %>
|
|
99
|
+
<% hit = fr.hits && fr.hits[idx] %>
|
|
100
|
+
<% cls = hit.nil? ? 'irrelevant' : (hit.to_i > 0 ? 'covered' : 'missed') %>
|
|
101
|
+
<tr class="<%= cls %>">
|
|
102
|
+
<td class="ln"><a id="L<%= idx+1 %>" href="#L<%= idx+1 %>"><%= idx+1 %></a></td>
|
|
103
|
+
<td class="hit"><%= hit.nil? ? '-' : hit.to_i %></td>
|
|
104
|
+
<td class="src"><pre><code><%= CGIFilter.escape(src) %></code></pre></td>
|
|
105
|
+
</tr>
|
|
106
|
+
<% end %>
|
|
107
|
+
</tbody>
|
|
108
|
+
</table>
|
|
109
|
+
</body>
|
|
110
|
+
</html>
|
|
111
|
+
HTML
|
|
112
|
+
|
|
113
|
+
CSS = <<~'CSS'
|
|
114
|
+
body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 2rem; }
|
|
115
|
+
h1 { margin-top: 0; }
|
|
116
|
+
.total { font-size: 1.1rem; margin-bottom: 1rem; }
|
|
117
|
+
.group { margin-top: 1.5rem; }
|
|
118
|
+
.index-table { border-collapse: collapse; width: 100%; }
|
|
119
|
+
.index-table th, .index-table td { padding: 8px 10px; border-bottom: 1px solid #ddd; text-align: left; }
|
|
120
|
+
.pct { text-align: right; white-space: nowrap; }
|
|
121
|
+
.bar { background: #eee; height: 10px; position: relative; border-radius: 4px; overflow: hidden; }
|
|
122
|
+
.bar > span { position: absolute; top: 0; left: 0; bottom: 0; background: #4caf50; }
|
|
123
|
+
.code-table { border-collapse: collapse; width: 100%; table-layout: fixed; }
|
|
124
|
+
.code-table th, .code-table td { border-bottom: 1px solid #eee; padding: 2px 6px; vertical-align: top; }
|
|
125
|
+
.code-table .ln { width: 4em; color: #777; }
|
|
126
|
+
.code-table .hit { width: 4em; text-align: right; color: #555; }
|
|
127
|
+
.code-table .src pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
128
|
+
tr.covered { background: #f6fff6; }
|
|
129
|
+
tr.missed { background: #fff6f6; }
|
|
130
|
+
tr.irrelevant { background: #fafafa; color: #777; }
|
|
131
|
+
.breadcrumb { margin-bottom: 0.5rem; }
|
|
132
|
+
a { color: #0a58ca; text-decoration: none; }
|
|
133
|
+
a:hover { text-decoration: underline; }
|
|
134
|
+
CSS
|
|
135
|
+
|
|
136
|
+
module CGIFilter
|
|
137
|
+
module_function
|
|
138
|
+
def escape(s) CGI.escapeHTML(s.to_s) end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def initialize(config) @config = config end
|
|
142
|
+
|
|
143
|
+
def write(result)
|
|
144
|
+
@result = result
|
|
145
|
+
out_dir = @config.output_dir
|
|
146
|
+
FileUtils.mkdir_p(out_dir)
|
|
147
|
+
|
|
148
|
+
File.write(File.join(out_dir, 'index.html'), ERB.new(INDEX_TEMPLATE).result(binding))
|
|
149
|
+
result.files.each do |fr|
|
|
150
|
+
html = ERB.new(FILE_TEMPLATE).result(binding)
|
|
151
|
+
File.write(file_path(fr), html)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def file_href(fr) fr.path.gsub(File::SEPARATOR, '__') + '.html' end
|
|
158
|
+
def file_path(fr) File.join(@config.output_dir, file_href(fr)) end
|
|
159
|
+
|
|
160
|
+
def source_lines(fr)
|
|
161
|
+
abs = File.expand_path(fr.path, @config.root)
|
|
162
|
+
if File.exist?(abs)
|
|
163
|
+
File.read(abs).split(/\r?\n/, -1)
|
|
164
|
+
else
|
|
165
|
+
Array.new(fr.hits ? fr.hits.length : 0, '')
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Getcov
|
|
5
|
+
module Formatter
|
|
6
|
+
class LCOV
|
|
7
|
+
def initialize(config) @config = config end
|
|
8
|
+
|
|
9
|
+
def write(result)
|
|
10
|
+
out_dir = @config.output_dir
|
|
11
|
+
FileUtils.mkdir_p(out_dir)
|
|
12
|
+
File.open(File.join(out_dir, 'lcov.info'), 'w') do |f|
|
|
13
|
+
result.files.each do |fr|
|
|
14
|
+
f.puts "TN:"
|
|
15
|
+
f.puts "SF:#{fr.path}"
|
|
16
|
+
total = 0
|
|
17
|
+
covered = 0
|
|
18
|
+
fr.hits&.each_with_index do |hit, idx|
|
|
19
|
+
next if hit.nil?
|
|
20
|
+
line_no = idx + 1
|
|
21
|
+
f.puts "DA:#{line_no},#{hit.to_i}"
|
|
22
|
+
total += 1
|
|
23
|
+
covered += 1 if hit.to_i > 0
|
|
24
|
+
end
|
|
25
|
+
f.puts "LH:#{covered}"
|
|
26
|
+
f.puts "LF:#{total}"
|
|
27
|
+
f.puts "end_of_record"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Getcov
|
|
5
|
+
module Formatter
|
|
6
|
+
class PRComment
|
|
7
|
+
def initialize(config) @config = config end
|
|
8
|
+
|
|
9
|
+
def write(result)
|
|
10
|
+
out_dir = @config.output_dir
|
|
11
|
+
FileUtils.mkdir_p(out_dir)
|
|
12
|
+
md = build_markdown(result)
|
|
13
|
+
File.write(File.join(out_dir, 'pr_comment.md'), md)
|
|
14
|
+
# GitHub Actions step summary support if available
|
|
15
|
+
if ENV['GITHUB_STEP_SUMMARY'] && !ENV['GITHUB_STEP_SUMMARY'].empty?
|
|
16
|
+
File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') { |f| f.puts(md) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_markdown(result)
|
|
21
|
+
lines = []
|
|
22
|
+
lines << "## Getcov Coverage\n"
|
|
23
|
+
lines << "**Total**: #{format('%.2f%%', result.total_coverage)}"
|
|
24
|
+
if result.branch_total_percent
|
|
25
|
+
lines << " • **Branches**: #{format('%.2f%%', result.branch_total_percent)}"
|
|
26
|
+
end
|
|
27
|
+
lines << "\n"
|
|
28
|
+
lines << "### Groups\n"
|
|
29
|
+
gc = result.group_coverage
|
|
30
|
+
gc.keys.sort.each do |g|
|
|
31
|
+
lines << "- **#{g}**: #{format('%.2f%%', gc[g])}"
|
|
32
|
+
end
|
|
33
|
+
lines << "\n### Files (top misses)\n"
|
|
34
|
+
misses = result.files.sort_by { |fr| fr.percent }.take(10)
|
|
35
|
+
misses.each do |fr|
|
|
36
|
+
lines << "- `#{fr.path}` — #{format('%.2f%%', fr.percent)} (#{fr.covered}/#{fr.relevant})"
|
|
37
|
+
end
|
|
38
|
+
lines << "\n_Report generated by getcov_.\n"
|
|
39
|
+
lines.join("\n")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Getcov
|
|
6
|
+
module Formatter
|
|
7
|
+
class SummaryJSON
|
|
8
|
+
def initialize(config) @config = config end
|
|
9
|
+
|
|
10
|
+
def write(result)
|
|
11
|
+
out_dir = @config.output_dir
|
|
12
|
+
FileUtils.mkdir_p(out_dir)
|
|
13
|
+
data = {
|
|
14
|
+
total: result.total_coverage,
|
|
15
|
+
branch_total: result.branch_total_percent,
|
|
16
|
+
groups: result.group_coverage,
|
|
17
|
+
files: result.files.map { |fr|
|
|
18
|
+
{
|
|
19
|
+
path: fr.path,
|
|
20
|
+
percent: fr.percent,
|
|
21
|
+
covered: fr.covered,
|
|
22
|
+
relevant: fr.relevant,
|
|
23
|
+
branch_percent: fr.branch_percent,
|
|
24
|
+
branch_covered: fr.branch_covered,
|
|
25
|
+
branch_total: fr.branch_total
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
File.write(File.join(out_dir, 'summary.json'), JSON.pretty_generate(data))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'pathname'
|
|
3
|
+
|
|
4
|
+
module Getcov
|
|
5
|
+
class Result
|
|
6
|
+
FileReport = Struct.new(:path, :covered, :relevant, :percent,
|
|
7
|
+
:group, :hits,
|
|
8
|
+
:branch_covered, :branch_total, :branch_percent,
|
|
9
|
+
keyword_init: true)
|
|
10
|
+
|
|
11
|
+
attr_reader :files, :total_coverage, :branch_total_percent
|
|
12
|
+
|
|
13
|
+
def initialize(raw_result, config)
|
|
14
|
+
@config = config
|
|
15
|
+
@files = []
|
|
16
|
+
|
|
17
|
+
tracked = config.tracked_paths
|
|
18
|
+
tracked.each { |t| raw_result[t] ||= nil }
|
|
19
|
+
|
|
20
|
+
raw_result.each do |path, lines_or_hash|
|
|
21
|
+
next unless path && File.extname(path) == '.rb'
|
|
22
|
+
rel = relative(path)
|
|
23
|
+
next if @config.filtered?(rel)
|
|
24
|
+
|
|
25
|
+
lines, branches = normalize(lines_or_hash)
|
|
26
|
+
|
|
27
|
+
hits = (lines || [])
|
|
28
|
+
relevant = hits.count { |h| !h.nil? }
|
|
29
|
+
covered = hits.count { |h| h && h > 0 }
|
|
30
|
+
percent = relevant.zero? ? 100.0 : (covered.to_f / relevant * 100.0)
|
|
31
|
+
|
|
32
|
+
b_cov = b_tot = b_pct = nil
|
|
33
|
+
if branches && branches['total'].to_i > 0
|
|
34
|
+
b_cov = branches['covered'].to_i
|
|
35
|
+
b_tot = branches['total'].to_i
|
|
36
|
+
b_pct = (b_cov.to_f / b_tot * 100.0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
group = @config.group_for(rel)
|
|
40
|
+
|
|
41
|
+
@files << FileReport.new(path: rel, covered: covered, relevant: relevant,
|
|
42
|
+
percent: percent, group: group, hits: hits,
|
|
43
|
+
branch_covered: b_cov, branch_total: b_tot, branch_percent: b_pct)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
total_rel = @files.sum(&:relevant)
|
|
47
|
+
total_cov = @files.sum(&:covered)
|
|
48
|
+
@total_coverage = total_rel.zero? ? 100.0 : (total_cov.to_f / total_rel * 100.0)
|
|
49
|
+
|
|
50
|
+
tot_bcov = @files.sum { |fr| fr.branch_covered.to_i }
|
|
51
|
+
tot_btot = @files.sum { |fr| fr.branch_total.to_i }
|
|
52
|
+
@branch_total_percent = (tot_btot.zero? ? nil : (tot_bcov.to_f / tot_btot * 100.0))
|
|
53
|
+
|
|
54
|
+
@files.sort_by! { |fr| [fr.group.to_s, fr.path] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_summary(io = $stdout)
|
|
58
|
+
io.puts '== Getcov Coverage Summary =='
|
|
59
|
+
io.puts format('Total Coverage: %.2f%%', total_coverage)
|
|
60
|
+
if @branch_total_percent
|
|
61
|
+
io.puts format('Total Branch Coverage: %.2f%%', @branch_total_percent)
|
|
62
|
+
end
|
|
63
|
+
unless @files.empty?
|
|
64
|
+
io.puts ''
|
|
65
|
+
current_group = nil
|
|
66
|
+
@files.each do |fr|
|
|
67
|
+
if fr.group != current_group
|
|
68
|
+
io.puts '' unless current_group.nil?
|
|
69
|
+
io.puts "[#{fr.group || 'Ungrouped'}]"
|
|
70
|
+
current_group = fr.group
|
|
71
|
+
end
|
|
72
|
+
line = format(' %-60s %6.2f%% (%d/%d)', fr.path, fr.percent, fr.covered, fr.relevant)
|
|
73
|
+
if fr.branch_total && fr.branch_total > 0
|
|
74
|
+
line += format(' | Branch: %6.2f%% (%d/%d)', fr.branch_percent, fr.branch_covered, fr.branch_total)
|
|
75
|
+
end
|
|
76
|
+
io.puts line
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
io
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def group_coverage
|
|
83
|
+
groups = Hash.new { |h,k| h[k] = { cov: 0, rel: 0 } }
|
|
84
|
+
@files.each do |fr|
|
|
85
|
+
g = fr.group || 'Ungrouped'
|
|
86
|
+
groups[g][:cov] += fr.covered
|
|
87
|
+
groups[g][:rel] += fr.relevant
|
|
88
|
+
end
|
|
89
|
+
groups.transform_values { |v| v[:rel].zero? ? 100.0 : (v[:cov].to_f / v[:rel] * 100.0) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def relative(path)
|
|
95
|
+
Pathname(path).relative_path_from(Pathname(@config.root)).to_s rescue path
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize(v)
|
|
99
|
+
if v.is_a?(Array)
|
|
100
|
+
[v, nil]
|
|
101
|
+
elsif v.is_a?(Hash)
|
|
102
|
+
lines = v['lines'] || v[:lines]
|
|
103
|
+
branches = v['branches'] || v[:branches]
|
|
104
|
+
if branches && !branches.is_a?(Hash) && branches.respond_to?(:to_h)
|
|
105
|
+
branches = branches.to_h
|
|
106
|
+
end
|
|
107
|
+
branches = nil unless branches.nil? || branches.is_a?(Hash)
|
|
108
|
+
[lines, branches]
|
|
109
|
+
else
|
|
110
|
+
[nil, nil]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/getcov.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'coverage'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'getcov/configuration'
|
|
7
|
+
require 'getcov/result'
|
|
8
|
+
|
|
9
|
+
module Getcov
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield(configuration)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
return if @started
|
|
21
|
+
begin
|
|
22
|
+
Coverage.start(lines: true, branches: true)
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
Coverage.start(lines: true)
|
|
25
|
+
end
|
|
26
|
+
@started = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def started? = !!@started
|
|
30
|
+
|
|
31
|
+
def stop
|
|
32
|
+
Coverage.result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Merge multiple raw results (array of Hash[path => hits or { 'lines'=>[], 'branches'=>... }])
|
|
36
|
+
def merge_raw_results(raws)
|
|
37
|
+
out = {}
|
|
38
|
+
raws.each do |raw|
|
|
39
|
+
raw.each do |path, v|
|
|
40
|
+
out[path] ||= nil
|
|
41
|
+
out[path] = merge_value(out[path], v)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
out
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_partial!(raw)
|
|
48
|
+
dir = configuration.parallel_merge_dir
|
|
49
|
+
raise 'parallel_merge_dir not configured' unless dir
|
|
50
|
+
FileUtils.mkdir_p(dir)
|
|
51
|
+
File.write(File.join(dir, "getcov-#{Process.pid}.json"), JSON.pretty_generate(raw))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def load_and_merge_partials
|
|
55
|
+
dir = configuration.parallel_merge_dir
|
|
56
|
+
raise 'parallel_merge_dir not configured' unless dir
|
|
57
|
+
raws = Dir[File.join(dir, '*.json')].map { |f| JSON.parse(File.read(f)) }
|
|
58
|
+
merge_raw_results(raws)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def finalize!
|
|
62
|
+
raw = stop
|
|
63
|
+
|
|
64
|
+
if configuration.parallel_merge_dir
|
|
65
|
+
if ENV['GETCOV_ROLE'] == 'worker'
|
|
66
|
+
write_partial!(raw)
|
|
67
|
+
return
|
|
68
|
+
elsif ENV['GETCOV_ROLE'] == 'master'
|
|
69
|
+
parts = []
|
|
70
|
+
parts << raw if raw && !raw.empty?
|
|
71
|
+
parts << load_and_merge_partials
|
|
72
|
+
raw = merge_raw_results(parts)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result = Result.new(raw, configuration)
|
|
77
|
+
result.print_summary
|
|
78
|
+
|
|
79
|
+
configuration.formatters.each do |fmt|
|
|
80
|
+
formatter = fmt.respond_to?(:new) ? fmt.new(configuration) : fmt
|
|
81
|
+
formatter.write(result)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
failure = false
|
|
85
|
+
per_file_failures = []
|
|
86
|
+
if configuration.minimum_coverage && result.total_coverage < configuration.minimum_coverage
|
|
87
|
+
warn format('Coverage %.2f%% is below the minimum of %.2f%%', result.total_coverage, configuration.minimum_coverage)
|
|
88
|
+
failure = true
|
|
89
|
+
end
|
|
90
|
+
# Per-file minimum
|
|
91
|
+
if configuration.per_file_minimum
|
|
92
|
+
result.files.each do |fr|
|
|
93
|
+
if fr.percent < configuration.per_file_minimum
|
|
94
|
+
warn format('File %s coverage %.2f%% is below per-file minimum of %.2f%%', fr.path, fr.percent, configuration.per_file_minimum)
|
|
95
|
+
per_file_failures << fr.path
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
failure ||= per_file_failures.any?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
result.group_coverage.each do |name, pct|
|
|
102
|
+
min = configuration.group_minimum(name)
|
|
103
|
+
if min && pct < min
|
|
104
|
+
warn format('Group "%s" coverage %.2f%% is below the minimum of %.2f%%', name, pct, min)
|
|
105
|
+
failure = true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
exit(2) if failure
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def merge_value(a, b)
|
|
114
|
+
ah = normalize_value(a)
|
|
115
|
+
bh = normalize_value(b)
|
|
116
|
+
lines = merge_lines(ah['lines'], bh['lines'])
|
|
117
|
+
branches = merge_branches(ah['branches'], bh['branches'])
|
|
118
|
+
branches ? { 'lines' => lines, 'branches' => branches } : lines
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def merge_lines(la, lb)
|
|
122
|
+
la ||= []
|
|
123
|
+
lb ||= []
|
|
124
|
+
n = [la.length, lb.length].max
|
|
125
|
+
out = Array.new(n)
|
|
126
|
+
n.times do |i|
|
|
127
|
+
va, vb = la[i], lb[i]
|
|
128
|
+
if va.nil? && vb.nil?
|
|
129
|
+
out[i] = nil
|
|
130
|
+
else
|
|
131
|
+
out[i] = (va || 0).to_i + (vb || 0).to_i
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
out
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def merge_branches(ba, bb)
|
|
138
|
+
return bb || ba unless ba && bb
|
|
139
|
+
{ 'covered' => ba['covered'].to_i + bb['covered'].to_i,
|
|
140
|
+
'total' => ba['total'].to_i + bb['total'].to_i }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_value(v)
|
|
144
|
+
if v.is_a?(Array)
|
|
145
|
+
{ 'lines' => v, 'branches' => nil }
|
|
146
|
+
elsif v.is_a?(Hash)
|
|
147
|
+
lines = v['lines'] || v[:lines]
|
|
148
|
+
branches = v['branches'] || v[:branches]
|
|
149
|
+
if branches && !branches.is_a?(Hash) && branches.respond_to?(:to_h)
|
|
150
|
+
branches = branches.to_h
|
|
151
|
+
end
|
|
152
|
+
branches = nil unless branches.nil? || branches.is_a?(Hash)
|
|
153
|
+
{ 'lines' => lines, 'branches' => branches }
|
|
154
|
+
else
|
|
155
|
+
{ 'lines' => nil, 'branches' => nil }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
at_exit do
|
|
162
|
+
if Getcov.started?
|
|
163
|
+
Getcov.finalize!
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'test_helper'
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
class AutoIntegrationTest < Minitest::Test
|
|
6
|
+
def test_auto_starts_and_prints_summary
|
|
7
|
+
ruby = RbConfig.ruby
|
|
8
|
+
cmd = [ruby, '-Ilib', '-r', 'getcov/auto', '-e', 'x=1; y=2; z=x+y;']
|
|
9
|
+
out, err, status = Open3.capture3(*cmd)
|
|
10
|
+
assert_match(/Getcov Coverage Summary/, out)
|
|
11
|
+
assert status.success?, err
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'test_helper'
|
|
3
|
+
|
|
4
|
+
class BranchSummaryTest < Minitest::Test
|
|
5
|
+
def test_branch_summary_is_printed_when_present
|
|
6
|
+
cfg = Getcov::Configuration.new
|
|
7
|
+
raw = {
|
|
8
|
+
File.expand_path('a.rb') => {
|
|
9
|
+
'lines' => [1, 0, nil],
|
|
10
|
+
'branches' => { 'covered' => 3, 'total' => 4 }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
result = Getcov::Result.new(raw, cfg)
|
|
14
|
+
out, _ = capture_io { result.print_summary }
|
|
15
|
+
assert_match(/Total Branch Coverage: 75.00%/, out)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'test_helper'
|
|
3
|
+
|
|
4
|
+
class ConfigurationTest < Minitest::Test
|
|
5
|
+
include TestSupport
|
|
6
|
+
|
|
7
|
+
def test_add_filter_with_regex
|
|
8
|
+
cfg = Getcov::Configuration.new
|
|
9
|
+
cfg.add_filter(%r{(^|/)spec/})
|
|
10
|
+
assert cfg.filtered?('spec/models/user_spec.rb')
|
|
11
|
+
refute cfg.filtered?('app/models/user.rb')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_add_group_and_group_for
|
|
15
|
+
cfg = Getcov::Configuration.new
|
|
16
|
+
cfg.add_group('Models', %r{^app/models/})
|
|
17
|
+
assert_equal 'Models', cfg.group_for('app/models/user.rb')
|
|
18
|
+
assert_nil cfg.group_for('lib/tasks.rb')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_track_files_globs
|
|
22
|
+
Dir.mktmpdir do |dir|
|
|
23
|
+
File.write(File.join(dir, 'a.rb'), "puts :a\n")
|
|
24
|
+
File.write(File.join(dir, 'b.rb'), "puts :b\n")
|
|
25
|
+
cfg = Getcov::Configuration.new
|
|
26
|
+
cfg.root = dir
|
|
27
|
+
cfg.track_files '*.rb'
|
|
28
|
+
tracked = cfg.tracked_paths.map { |p| File.basename(p) }.sort
|
|
29
|
+
assert_equal %w[a.rb b.rb], tracked
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'test_helper'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'getcov/formatter/cobertura'
|
|
5
|
+
require 'getcov/formatter/lcov'
|
|
6
|
+
require 'getcov/formatter/badge'
|
|
7
|
+
|
|
8
|
+
class FormattersExportTest < Minitest::Test
|
|
9
|
+
def setup
|
|
10
|
+
@cfg = Getcov::Configuration.new
|
|
11
|
+
@cfg.root = Dir.pwd
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def sample_result
|
|
15
|
+
raw = { File.expand_path('a.rb') => [1, 0, nil] }
|
|
16
|
+
Getcov::Result.new(raw, @cfg)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_cobertura_and_lcov_and_badge_written
|
|
20
|
+
Dir.mktmpdir do |dir|
|
|
21
|
+
@cfg.output_dir = dir
|
|
22
|
+
res = sample_result
|
|
23
|
+
|
|
24
|
+
Getcov::Formatter::Cobertura.new(@cfg).write(res)
|
|
25
|
+
Getcov::Formatter::LCOV.new(@cfg).write(res)
|
|
26
|
+
Getcov::Formatter::Badge.new(@cfg).write(res)
|
|
27
|
+
|
|
28
|
+
assert File.exist?(File.join(dir, 'coverage.xml')), 'coverage.xml missing'
|
|
29
|
+
assert File.exist?(File.join(dir, 'lcov.info')), 'lcov.info missing'
|
|
30
|
+
assert File.exist?(File.join(dir, 'coverage.svg')), 'coverage.svg missing'
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|