single_cov 1.0.3 → 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 +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.
|