single_cov 1.3.0 → 1.6.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/lib/single_cov.rb +127 -77
- data/lib/single_cov/version.rb +2 -1
- metadata +4 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1061e2ee0841966f31a082926db0474550414ac8e06f395696152ba3b985aa5
|
|
4
|
+
data.tar.gz: 836d974a4b39cbbf4b5454334997f60ac4d2afe5aafd4609bc2e04fb57f5a991
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e30055d56774315c96b907ef0d930c83a4ac921855d0000760ade00d47af0e3394a96853f13f669e4fd797e0d8d2d5aeadbfb1e8a8549f45ca36629a80befae6
|
|
7
|
+
data.tar.gz: 6e42c196b4cbb0eccb982b3bb942cf1664d8aa99925eabc7c9fb6165d10dd94de36640527556a9209739058501a36e191fc9136f21c903e0aa57307c63b2bd73
|
data/lib/single_cov.rb
CHANGED
|
@@ -1,75 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module SingleCov
|
|
2
3
|
COVERAGES = []
|
|
3
4
|
MAX_OUTPUT = 40
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
UNCOVERED_COMMENT_MARKER = "uncovered"
|
|
5
|
+
RAILS_APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"]
|
|
6
|
+
UNCOVERED_COMMENT_MARKER = /#.*uncovered/
|
|
7
7
|
|
|
8
8
|
class << self
|
|
9
|
-
#
|
|
9
|
+
# enable coverage reporting: path to output file, changed by forking-test-runner at runtime to combine many reports
|
|
10
|
+
attr_accessor :coverage_report
|
|
11
|
+
|
|
12
|
+
# emit only line coverage in coverage report for older coverage systems
|
|
13
|
+
attr_accessor :coverage_report_lines
|
|
14
|
+
|
|
15
|
+
# optionally rewrite the matching path single-cov guessed with a lambda
|
|
10
16
|
def rewrite(&block)
|
|
11
17
|
@rewrite = block
|
|
12
18
|
end
|
|
13
19
|
|
|
20
|
+
# mark a test file as not covering anything to make assert_used pass
|
|
14
21
|
def not_covered!
|
|
15
|
-
|
|
22
|
+
main_process!
|
|
16
23
|
end
|
|
17
24
|
|
|
25
|
+
# mark the file under test as needing coverage
|
|
18
26
|
def covered!(file: nil, uncovered: 0)
|
|
19
|
-
file =
|
|
27
|
+
file = ensure_covered_file(file)
|
|
20
28
|
COVERAGES << [file, uncovered]
|
|
21
|
-
|
|
29
|
+
main_process!
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def all_covered?(result)
|
|
25
|
-
errors = COVERAGES.
|
|
26
|
-
|
|
27
|
-
line_coverage = (coverage.is_a?(Hash) ? coverage.fetch(:lines) : coverage)
|
|
28
|
-
uncovered = line_coverage.each_with_index.map { |c, i| i + 1 if c == 0 }.compact
|
|
29
|
-
branch_coverage = (coverage.is_a?(Hash) && coverage[:branches])
|
|
30
|
-
|
|
31
|
-
if branch_coverage
|
|
32
|
-
uncovered.concat uncovered_branches(branch_coverage, uncovered)
|
|
33
|
-
end
|
|
33
|
+
errors = COVERAGES.flat_map do |file, expected_uncovered|
|
|
34
|
+
next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# ignore lines that are marked as uncovered via comments
|
|
38
|
-
# NOTE: ideally we should also warn when using uncovered but the section is indeed covered
|
|
39
|
-
content = File.readlines(file)
|
|
40
|
-
uncovered.reject! do |line_start, _, _, _|
|
|
41
|
-
content[line_start - 1].include?(UNCOVERED_COMMENT_MARKER)
|
|
42
|
-
end
|
|
36
|
+
uncovered = uncovered(coverage)
|
|
37
|
+
next if uncovered.size == expected_uncovered
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
uncovered.map! do |line_start, char_start, line_end, char_end|
|
|
52
|
-
char_start ? "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}" : "#{file}:#{line_start}"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
warn_about_bad_coverage(file, expected_uncovered, uncovered)
|
|
56
|
-
else
|
|
57
|
-
warn_about_no_coverage(file)
|
|
39
|
+
# ignore lines that are marked as uncovered via comments
|
|
40
|
+
# TODO: warn when using uncovered but the section is indeed covered
|
|
41
|
+
content = File.readlines(file)
|
|
42
|
+
uncovered.reject! do |line_start, _, _, _|
|
|
43
|
+
content[line_start - 1].match?(UNCOVERED_COMMENT_MARKER)
|
|
58
44
|
end
|
|
45
|
+
next if uncovered.size == expected_uncovered
|
|
46
|
+
|
|
47
|
+
bad_coverage_error(file, expected_uncovered, uncovered)
|
|
59
48
|
end.compact
|
|
60
49
|
|
|
61
50
|
return true if errors.empty?
|
|
62
51
|
|
|
63
|
-
errors = errors.join("\n").split("\n") # unify arrays with multiline strings
|
|
64
52
|
errors[MAX_OUTPUT..-1] = "... coverage output truncated" if errors.size >= MAX_OUTPUT
|
|
65
53
|
warn errors
|
|
66
54
|
|
|
67
|
-
errors.all? { |l|
|
|
55
|
+
errors.all? { |l| warning?(l) }
|
|
68
56
|
end
|
|
69
57
|
|
|
70
58
|
def assert_used(tests: default_tests)
|
|
71
59
|
bad = tests.select do |file|
|
|
72
|
-
File.read(file) !~ /SingleCov.(not_)?covered
|
|
60
|
+
File.read(file) !~ /SingleCov.(not_)?covered!/
|
|
73
61
|
end
|
|
74
62
|
unless bad.empty?
|
|
75
63
|
raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n")
|
|
@@ -77,7 +65,7 @@ module SingleCov
|
|
|
77
65
|
end
|
|
78
66
|
|
|
79
67
|
def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: [])
|
|
80
|
-
missing = files - tests.map { |t|
|
|
68
|
+
missing = files - tests.map { |t| guess_covered_file(t) }
|
|
81
69
|
fixed = untested - missing
|
|
82
70
|
missing -= untested
|
|
83
71
|
|
|
@@ -88,13 +76,10 @@ module SingleCov
|
|
|
88
76
|
end
|
|
89
77
|
end
|
|
90
78
|
|
|
91
|
-
def setup(framework, root: nil, branches:
|
|
79
|
+
def setup(framework, root: nil, branches: true)
|
|
92
80
|
if defined?(SimpleCov)
|
|
93
81
|
raise "Load SimpleCov after SingleCov"
|
|
94
82
|
end
|
|
95
|
-
if branches && !BRANCH_COVERAGE_SUPPORTED
|
|
96
|
-
raise "Branch coverage needs ruby >= 2.5.0"
|
|
97
|
-
end
|
|
98
83
|
|
|
99
84
|
@branches = branches
|
|
100
85
|
@root = root
|
|
@@ -112,7 +97,11 @@ module SingleCov
|
|
|
112
97
|
start_coverage_recording
|
|
113
98
|
|
|
114
99
|
override_at_exit do |status, _exception|
|
|
115
|
-
|
|
100
|
+
if enabled? && main_process? && status == 0
|
|
101
|
+
results = coverage_results
|
|
102
|
+
generate_report results
|
|
103
|
+
exit 1 unless SingleCov.all_covered?(results)
|
|
104
|
+
end
|
|
116
105
|
end
|
|
117
106
|
end
|
|
118
107
|
|
|
@@ -123,19 +112,38 @@ module SingleCov
|
|
|
123
112
|
|
|
124
113
|
private
|
|
125
114
|
|
|
115
|
+
def uncovered(coverage)
|
|
116
|
+
return coverage unless coverage.is_a?(Hash) # just lines
|
|
117
|
+
|
|
118
|
+
# [nil, 1, 0, 1, 0] -> [3, 5]
|
|
119
|
+
uncovered_lines = coverage.fetch(:lines)
|
|
120
|
+
.each_with_index
|
|
121
|
+
.select { |c, _| c == 0 }
|
|
122
|
+
.map { |_, i| i + 1 }
|
|
123
|
+
.compact
|
|
124
|
+
|
|
125
|
+
uncovered_branches = uncovered_branches(coverage[:branches] || {})
|
|
126
|
+
uncovered_branches.reject! { |k| uncovered_lines.include?(k[0]) } # remove duplicates
|
|
127
|
+
|
|
128
|
+
all = uncovered_lines.concat uncovered_branches
|
|
129
|
+
all.sort_by! { |line_start, char_start, _, _| [line_start, char_start || 0] } # branches are unsorted
|
|
130
|
+
all
|
|
131
|
+
end
|
|
132
|
+
|
|
126
133
|
def enabled?
|
|
127
134
|
(!defined?(@disabled) || !@disabled)
|
|
128
135
|
end
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
# assuming that the main process will load all the files, we store it's pid
|
|
138
|
+
def main_process!
|
|
139
|
+
@main_process_pid = Process.pid
|
|
132
140
|
end
|
|
133
141
|
|
|
134
142
|
def main_process?
|
|
135
|
-
(!defined?(@
|
|
143
|
+
(!defined?(@main_process_pid) || @main_process_pid == Process.pid)
|
|
136
144
|
end
|
|
137
145
|
|
|
138
|
-
def uncovered_branches(coverage
|
|
146
|
+
def uncovered_branches(coverage)
|
|
139
147
|
# {[branch_id] => {[branch_part] => coverage}} --> {branch_part -> sum-of-coverage}
|
|
140
148
|
sum = Hash.new(0)
|
|
141
149
|
coverage.each_value do |branch|
|
|
@@ -144,9 +152,8 @@ module SingleCov
|
|
|
144
152
|
end
|
|
145
153
|
end
|
|
146
154
|
|
|
147
|
-
#
|
|
148
|
-
found = sum.
|
|
149
|
-
map { |k, _| [k[0], k[1]+1, k[2], k[3]+1] }
|
|
155
|
+
sum.select! { |_, v| v == 0 } # keep missing coverage
|
|
156
|
+
found = sum.map { |k, _| [k[0], k[1] + 1, k[2], k[3] + 1] }
|
|
150
157
|
found.uniq!
|
|
151
158
|
found
|
|
152
159
|
end
|
|
@@ -162,7 +169,12 @@ module SingleCov
|
|
|
162
169
|
# do not ask for coverage when SimpleCov already does or it conflicts
|
|
163
170
|
def coverage_results
|
|
164
171
|
if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result))
|
|
165
|
-
result.original_result
|
|
172
|
+
result = result.original_result
|
|
173
|
+
# singlecov 1.18+ puts string "lines" into the result that we cannot read
|
|
174
|
+
if result.each_value.first.is_a?(Hash)
|
|
175
|
+
result = result.transform_values { |v| v.transform_keys(&:to_sym) }
|
|
176
|
+
end
|
|
177
|
+
result
|
|
166
178
|
else
|
|
167
179
|
Coverage.result
|
|
168
180
|
end
|
|
@@ -175,7 +187,7 @@ module SingleCov
|
|
|
175
187
|
if @branches
|
|
176
188
|
Coverage.start(lines: true, branches: true)
|
|
177
189
|
else
|
|
178
|
-
Coverage.start
|
|
190
|
+
Coverage.start(lines: true)
|
|
179
191
|
end
|
|
180
192
|
end
|
|
181
193
|
|
|
@@ -233,7 +245,7 @@ module SingleCov
|
|
|
233
245
|
end
|
|
234
246
|
|
|
235
247
|
def rspec_running_subset_of_tests?
|
|
236
|
-
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~
|
|
248
|
+
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ }
|
|
237
249
|
end
|
|
238
250
|
|
|
239
251
|
# code stolen from SimpleCov
|
|
@@ -258,15 +270,13 @@ module SingleCov
|
|
|
258
270
|
end
|
|
259
271
|
end
|
|
260
272
|
|
|
261
|
-
def
|
|
262
|
-
|
|
263
|
-
raise "Use paths relative to root."
|
|
264
|
-
end
|
|
273
|
+
def ensure_covered_file(file)
|
|
274
|
+
raise "Use paths relative to project root." if file&.start_with?("/")
|
|
265
275
|
|
|
266
276
|
if file
|
|
267
|
-
raise "#{file} does not exist
|
|
277
|
+
raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
|
|
268
278
|
else
|
|
269
|
-
file =
|
|
279
|
+
file = guess_covered_file(caller[1])
|
|
270
280
|
if file.start_with?("/")
|
|
271
281
|
raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location."
|
|
272
282
|
elsif !File.exist?("#{root}/#{file}")
|
|
@@ -277,21 +287,39 @@ module SingleCov
|
|
|
277
287
|
file
|
|
278
288
|
end
|
|
279
289
|
|
|
280
|
-
def
|
|
281
|
-
details = "(#{
|
|
282
|
-
if expected_uncovered >
|
|
290
|
+
def bad_coverage_error(file, expected_uncovered, uncovered)
|
|
291
|
+
details = "(#{uncovered.size} current vs #{expected_uncovered} configured)"
|
|
292
|
+
if expected_uncovered > uncovered.size
|
|
283
293
|
if running_single_file?
|
|
284
|
-
"#{file} has less uncovered lines #{details}, decrement configured uncovered
|
|
294
|
+
warning "#{file} has less uncovered lines #{details}, decrement configured uncovered"
|
|
285
295
|
end
|
|
286
296
|
else
|
|
287
297
|
[
|
|
288
298
|
"#{file} new uncovered lines introduced #{details}",
|
|
289
299
|
red("Lines missing coverage:"),
|
|
290
|
-
*
|
|
291
|
-
|
|
300
|
+
*uncovered.map do |line_start, char_start, line_end, char_end|
|
|
301
|
+
if char_start # branch coverage
|
|
302
|
+
if line_start == line_end
|
|
303
|
+
"#{file}:#{line_start}:#{char_start}-#{char_end}"
|
|
304
|
+
else # possibly unreachable since branches always seem to be on the same line
|
|
305
|
+
"#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
|
|
306
|
+
end
|
|
307
|
+
else
|
|
308
|
+
"#{file}:#{line_start}"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
]
|
|
292
312
|
end
|
|
293
313
|
end
|
|
294
314
|
|
|
315
|
+
def warning(msg)
|
|
316
|
+
"#{msg}?"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def warning?(msg)
|
|
320
|
+
msg.end_with?("?")
|
|
321
|
+
end
|
|
322
|
+
|
|
295
323
|
def red(text)
|
|
296
324
|
if $stdin.tty?
|
|
297
325
|
"\e[31m#{text}\e[0m"
|
|
@@ -300,17 +328,17 @@ module SingleCov
|
|
|
300
328
|
end
|
|
301
329
|
end
|
|
302
330
|
|
|
303
|
-
def
|
|
331
|
+
def no_coverage_error(file)
|
|
304
332
|
if $LOADED_FEATURES.include?("#{root}/#{file}")
|
|
305
333
|
# we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded
|
|
306
|
-
"#{file} was expected to be covered, but was already loaded before
|
|
334
|
+
"#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable."
|
|
307
335
|
else
|
|
308
|
-
"#{file} was expected to be covered, but never loaded."
|
|
336
|
+
"#{file} was expected to be covered, but was never loaded."
|
|
309
337
|
end
|
|
310
338
|
end
|
|
311
339
|
|
|
312
|
-
def
|
|
313
|
-
file =
|
|
340
|
+
def guess_covered_file(test)
|
|
341
|
+
file = test.dup
|
|
314
342
|
|
|
315
343
|
# remove caller junk to get nice error messages when something fails
|
|
316
344
|
file.sub!(/\.rb\b.*/, '.rb')
|
|
@@ -328,7 +356,7 @@ module SingleCov
|
|
|
328
356
|
end
|
|
329
357
|
|
|
330
358
|
# rails things live in app
|
|
331
|
-
file_part[0...0] = if file_part =~ /^(?:#{
|
|
359
|
+
file_part[0...0] = if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
|
|
332
360
|
"app/"
|
|
333
361
|
elsif file_part.start_with?("lib/") # don't add lib twice
|
|
334
362
|
""
|
|
@@ -337,8 +365,8 @@ module SingleCov
|
|
|
337
365
|
end
|
|
338
366
|
|
|
339
367
|
# remove test extension
|
|
340
|
-
|
|
341
|
-
raise "Unable to remove test extension from #{file} ... _test.rb and _spec.rb are supported"
|
|
368
|
+
if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/")
|
|
369
|
+
raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported"
|
|
342
370
|
end
|
|
343
371
|
|
|
344
372
|
# put back the subfolder
|
|
@@ -352,5 +380,27 @@ module SingleCov
|
|
|
352
380
|
def root
|
|
353
381
|
@root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd
|
|
354
382
|
end
|
|
383
|
+
|
|
384
|
+
def generate_report(results)
|
|
385
|
+
return unless report = coverage_report
|
|
386
|
+
|
|
387
|
+
# not a hard dependency for the whole library
|
|
388
|
+
require "json"
|
|
389
|
+
require "fileutils"
|
|
390
|
+
|
|
391
|
+
used = COVERAGES.map { |f, _| "#{root}/#{f}" }
|
|
392
|
+
covered = results.select { |k, _| used.include?(k) }
|
|
393
|
+
|
|
394
|
+
if coverage_report_lines
|
|
395
|
+
covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break
|
|
399
|
+
data = JSON.pretty_generate(
|
|
400
|
+
"Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i }
|
|
401
|
+
)
|
|
402
|
+
FileUtils.mkdir_p(File.dirname(report))
|
|
403
|
+
File.write report, data
|
|
404
|
+
end
|
|
355
405
|
end
|
|
356
406
|
end
|
data/lib/single_cov/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: single_cov
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Grosser
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2020-11-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description:
|
|
14
14
|
email: michael@grosser.it
|
|
@@ -31,15 +31,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
31
31
|
requirements:
|
|
32
32
|
- - ">="
|
|
33
33
|
- !ruby/object:Gem::Version
|
|
34
|
-
version: 2.
|
|
34
|
+
version: 2.5.0
|
|
35
35
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
40
|
requirements: []
|
|
41
|
-
|
|
42
|
-
rubygems_version: 2.7.6
|
|
41
|
+
rubygems_version: 3.1.3
|
|
43
42
|
signing_key:
|
|
44
43
|
specification_version: 4
|
|
45
44
|
summary: Actionable code coverage.
|