single_cov 1.0.3 → 1.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/single_cov.rb +136 -62
- 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,63 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module SingleCov
|
2
3
|
COVERAGES = []
|
3
4
|
MAX_OUTPUT = 40
|
4
|
-
|
5
|
-
|
5
|
+
RAILS_APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"]
|
6
|
+
UNCOVERED_COMMENT_MARKER = /#.*uncovered/
|
6
7
|
|
7
8
|
class << self
|
8
|
-
#
|
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
|
9
16
|
def rewrite(&block)
|
10
17
|
@rewrite = block
|
11
18
|
end
|
12
19
|
|
20
|
+
# mark a test file as not covering anything to make assert_used pass
|
13
21
|
def not_covered!
|
22
|
+
main_process!
|
14
23
|
end
|
15
24
|
|
25
|
+
# mark the file under test as needing coverage
|
16
26
|
def covered!(file: nil, uncovered: 0)
|
17
|
-
file =
|
27
|
+
file = ensure_covered_file(file)
|
18
28
|
COVERAGES << [file, uncovered]
|
29
|
+
main_process!
|
19
30
|
end
|
20
31
|
|
21
32
|
def all_covered?(result)
|
22
|
-
errors = COVERAGES.
|
23
|
-
|
24
|
-
line_coverage = (coverage.is_a?(Hash) ? coverage.fetch(:lines) : coverage)
|
25
|
-
uncovered = line_coverage.each_with_index.map { |c, i| i + 1 if c == 0 }.compact
|
26
|
-
branch_coverage = (coverage.is_a?(Hash) && coverage[:branches])
|
27
|
-
|
28
|
-
if branch_coverage
|
29
|
-
uncovered.concat uncovered_branches(file, branch_coverage, uncovered)
|
30
|
-
end
|
31
|
-
|
32
|
-
next if uncovered.size == expected_uncovered
|
33
|
-
|
34
|
-
# branches are unsorted and added to the end, only sort when necessary
|
35
|
-
if branch_coverage
|
36
|
-
uncovered.sort_by! { |line_start, char_start, line_end, char_end| [line_start, char_start || 0] }
|
37
|
-
end
|
33
|
+
errors = COVERAGES.flat_map do |file, expected_uncovered|
|
34
|
+
next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]
|
38
35
|
|
39
|
-
|
40
|
-
|
41
|
-
end
|
36
|
+
uncovered = uncovered(coverage)
|
37
|
+
next if uncovered.size == expected_uncovered
|
42
38
|
|
43
|
-
|
44
|
-
|
45
|
-
|
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)
|
46
44
|
end
|
45
|
+
next if uncovered.size == expected_uncovered
|
46
|
+
|
47
|
+
bad_coverage_error(file, expected_uncovered, uncovered)
|
47
48
|
end.compact
|
48
49
|
|
49
50
|
return true if errors.empty?
|
50
51
|
|
51
|
-
errors = errors.join("\n").split("\n") # unify arrays with multiline strings
|
52
52
|
errors[MAX_OUTPUT..-1] = "... coverage output truncated" if errors.size >= MAX_OUTPUT
|
53
53
|
warn errors
|
54
54
|
|
55
|
-
errors.all? { |l|
|
55
|
+
errors.all? { |l| warning?(l) }
|
56
56
|
end
|
57
57
|
|
58
58
|
def assert_used(tests: default_tests)
|
59
59
|
bad = tests.select do |file|
|
60
|
-
File.read(file) !~ /SingleCov.(not_)?covered
|
60
|
+
File.read(file) !~ /SingleCov.(not_)?covered!/
|
61
61
|
end
|
62
62
|
unless bad.empty?
|
63
63
|
raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n")
|
@@ -65,7 +65,7 @@ module SingleCov
|
|
65
65
|
end
|
66
66
|
|
67
67
|
def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: [])
|
68
|
-
missing = files - tests.map { |t|
|
68
|
+
missing = files - tests.map { |t| guess_covered_file(t) }
|
69
69
|
fixed = untested - missing
|
70
70
|
missing -= untested
|
71
71
|
|
@@ -76,13 +76,10 @@ module SingleCov
|
|
76
76
|
end
|
77
77
|
end
|
78
78
|
|
79
|
-
def setup(framework, root: nil, branches:
|
79
|
+
def setup(framework, root: nil, branches: true)
|
80
80
|
if defined?(SimpleCov)
|
81
81
|
raise "Load SimpleCov after SingleCov"
|
82
82
|
end
|
83
|
-
if branches && !BRANCH_COVERAGE_SUPPORTED
|
84
|
-
raise "Branch coverage needs ruby >= 2.5.0"
|
85
|
-
end
|
86
83
|
|
87
84
|
@branches = branches
|
88
85
|
@root = root
|
@@ -100,7 +97,11 @@ module SingleCov
|
|
100
97
|
start_coverage_recording
|
101
98
|
|
102
99
|
override_at_exit do |status, _exception|
|
103
|
-
|
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
|
104
105
|
end
|
105
106
|
end
|
106
107
|
|
@@ -111,7 +112,38 @@ module SingleCov
|
|
111
112
|
|
112
113
|
private
|
113
114
|
|
114
|
-
def
|
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
|
+
|
133
|
+
def enabled?
|
134
|
+
(!defined?(@disabled) || !@disabled)
|
135
|
+
end
|
136
|
+
|
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
|
140
|
+
end
|
141
|
+
|
142
|
+
def main_process?
|
143
|
+
(!defined?(@main_process_pid) || @main_process_pid == Process.pid)
|
144
|
+
end
|
145
|
+
|
146
|
+
def uncovered_branches(coverage)
|
115
147
|
# {[branch_id] => {[branch_part] => coverage}} --> {branch_part -> sum-of-coverage}
|
116
148
|
sum = Hash.new(0)
|
117
149
|
coverage.each_value do |branch|
|
@@ -120,9 +152,8 @@ module SingleCov
|
|
120
152
|
end
|
121
153
|
end
|
122
154
|
|
123
|
-
#
|
124
|
-
found = sum.
|
125
|
-
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] }
|
126
157
|
found.uniq!
|
127
158
|
found
|
128
159
|
end
|
@@ -138,7 +169,12 @@ module SingleCov
|
|
138
169
|
# do not ask for coverage when SimpleCov already does or it conflicts
|
139
170
|
def coverage_results
|
140
171
|
if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result))
|
141
|
-
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
|
142
178
|
else
|
143
179
|
Coverage.result
|
144
180
|
end
|
@@ -151,7 +187,7 @@ module SingleCov
|
|
151
187
|
if @branches
|
152
188
|
Coverage.start(lines: true, branches: true)
|
153
189
|
else
|
154
|
-
Coverage.start
|
190
|
+
Coverage.start(lines: true)
|
155
191
|
end
|
156
192
|
end
|
157
193
|
|
@@ -209,7 +245,7 @@ module SingleCov
|
|
209
245
|
end
|
210
246
|
|
211
247
|
def rspec_running_subset_of_tests?
|
212
|
-
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~
|
248
|
+
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ }
|
213
249
|
end
|
214
250
|
|
215
251
|
# code stolen from SimpleCov
|
@@ -234,15 +270,13 @@ module SingleCov
|
|
234
270
|
end
|
235
271
|
end
|
236
272
|
|
237
|
-
def
|
238
|
-
|
239
|
-
raise "Use paths relative to root."
|
240
|
-
end
|
273
|
+
def ensure_covered_file(file)
|
274
|
+
raise "Use paths relative to project root." if file&.start_with?("/")
|
241
275
|
|
242
276
|
if file
|
243
|
-
raise "#{file} does not exist
|
277
|
+
raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
|
244
278
|
else
|
245
|
-
file =
|
279
|
+
file = guess_covered_file(caller[1])
|
246
280
|
if file.start_with?("/")
|
247
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."
|
248
282
|
elsif !File.exist?("#{root}/#{file}")
|
@@ -253,21 +287,39 @@ module SingleCov
|
|
253
287
|
file
|
254
288
|
end
|
255
289
|
|
256
|
-
def
|
257
|
-
details = "(#{
|
258
|
-
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
|
259
293
|
if running_single_file?
|
260
|
-
"#{file} has less uncovered lines #{details}, decrement configured uncovered
|
294
|
+
warning "#{file} has less uncovered lines #{details}, decrement configured uncovered"
|
261
295
|
end
|
262
296
|
else
|
263
297
|
[
|
264
298
|
"#{file} new uncovered lines introduced #{details}",
|
265
299
|
red("Lines missing coverage:"),
|
266
|
-
*
|
267
|
-
|
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
|
+
]
|
268
312
|
end
|
269
313
|
end
|
270
314
|
|
315
|
+
def warning(msg)
|
316
|
+
"#{msg}?"
|
317
|
+
end
|
318
|
+
|
319
|
+
def warning?(msg)
|
320
|
+
msg.end_with?("?")
|
321
|
+
end
|
322
|
+
|
271
323
|
def red(text)
|
272
324
|
if $stdin.tty?
|
273
325
|
"\e[31m#{text}\e[0m"
|
@@ -276,17 +328,17 @@ module SingleCov
|
|
276
328
|
end
|
277
329
|
end
|
278
330
|
|
279
|
-
def
|
331
|
+
def no_coverage_error(file)
|
280
332
|
if $LOADED_FEATURES.include?("#{root}/#{file}")
|
281
333
|
# we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded
|
282
|
-
"#{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."
|
283
335
|
else
|
284
|
-
"#{file} was expected to be covered, but never loaded."
|
336
|
+
"#{file} was expected to be covered, but was never loaded."
|
285
337
|
end
|
286
338
|
end
|
287
339
|
|
288
|
-
def
|
289
|
-
file =
|
340
|
+
def guess_covered_file(test)
|
341
|
+
file = test.dup
|
290
342
|
|
291
343
|
# remove caller junk to get nice error messages when something fails
|
292
344
|
file.sub!(/\.rb\b.*/, '.rb')
|
@@ -304,7 +356,7 @@ module SingleCov
|
|
304
356
|
end
|
305
357
|
|
306
358
|
# rails things live in app
|
307
|
-
file_part[0...0] = if file_part =~ /^(?:#{
|
359
|
+
file_part[0...0] = if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
|
308
360
|
"app/"
|
309
361
|
elsif file_part.start_with?("lib/") # don't add lib twice
|
310
362
|
""
|
@@ -313,8 +365,8 @@ module SingleCov
|
|
313
365
|
end
|
314
366
|
|
315
367
|
# remove test extension
|
316
|
-
|
317
|
-
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"
|
318
370
|
end
|
319
371
|
|
320
372
|
# put back the subfolder
|
@@ -328,5 +380,27 @@ module SingleCov
|
|
328
380
|
def root
|
329
381
|
@root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd
|
330
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
|
331
405
|
end
|
332
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.0
|
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.
|