single_cov 1.3.2 → 1.7.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/version.rb +2 -1
- data/lib/single_cov.rb +128 -82
- 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: 9b93f5dd228599154416958a87e4f96ddd97a5477d92b7fc6c43c80ff3d6485f
|
4
|
+
data.tar.gz: ccd0f0c8b95df16fae3c071000b2499cca41bffea096bd84a8d761444335b2c4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 827b9ed7610e88def4654d18d8e2d4fa01c1f6c296a260c64ef8f4a722d1a478e195fefbfaf45afbeb8d6710a644f071f48e117cb927daf2d5bb0b42decd14e9
|
7
|
+
data.tar.gz: 3a21bb814833a4a73b5303f4b354218014f0a7b5e05eac89863b3508f7b6b488a06177577ce383d6a706847716dbc6706b37583d499deafee1a516a5d8a1e5c2
|
data/lib/single_cov/version.rb
CHANGED
data/lib/single_cov.rb
CHANGED
@@ -1,81 +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_lines = line_coverage.each_with_index.map { |c, i| i + 1 if c == 0 }.compact
|
29
|
-
|
30
|
-
branch_coverage = (coverage.is_a?(Hash) && coverage[:branches])
|
31
|
-
uncovered_branches = (branch_coverage ? uncovered_branches(branch_coverage, uncovered_lines) : [])
|
32
|
-
|
33
|
-
uncovered = uncovered_lines.concat uncovered_branches
|
34
|
-
next if uncovered.size == expected_uncovered
|
35
|
-
|
36
|
-
# ignore lines that are marked as uncovered via comments
|
37
|
-
# NOTE: ideally we should also warn when using uncovered but the section is indeed covered
|
38
|
-
content = File.readlines(file)
|
39
|
-
uncovered.reject! do |line_start, _, _, _|
|
40
|
-
content[line_start - 1].include?(UNCOVERED_COMMENT_MARKER)
|
41
|
-
end
|
42
|
-
next if uncovered.size == expected_uncovered
|
33
|
+
errors = COVERAGES.flat_map do |file, expected_uncovered|
|
34
|
+
next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]
|
43
35
|
|
44
|
-
|
45
|
-
|
46
|
-
uncovered.sort_by! { |line_start, char_start, _, _| [line_start, char_start || 0] }
|
47
|
-
end
|
36
|
+
uncovered = uncovered(coverage)
|
37
|
+
next if uncovered.size == expected_uncovered
|
48
38
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
"#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
|
55
|
-
end
|
56
|
-
else
|
57
|
-
"#{file}:#{line_start}"
|
58
|
-
end
|
59
|
-
end
|
60
|
-
|
61
|
-
warn_about_bad_coverage(file, expected_uncovered, uncovered)
|
62
|
-
else
|
63
|
-
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)
|
64
44
|
end
|
45
|
+
next if uncovered.size == expected_uncovered
|
46
|
+
|
47
|
+
bad_coverage_error(file, expected_uncovered, uncovered)
|
65
48
|
end.compact
|
66
49
|
|
67
50
|
return true if errors.empty?
|
68
51
|
|
69
|
-
errors = errors.join("\n").split("\n") # unify arrays with multiline strings
|
70
52
|
errors[MAX_OUTPUT..-1] = "... coverage output truncated" if errors.size >= MAX_OUTPUT
|
71
|
-
|
53
|
+
@error_logger.puts errors
|
72
54
|
|
73
|
-
errors.all? { |l|
|
55
|
+
errors.all? { |l| warning?(l) }
|
74
56
|
end
|
75
57
|
|
76
58
|
def assert_used(tests: default_tests)
|
77
59
|
bad = tests.select do |file|
|
78
|
-
File.read(file) !~ /SingleCov.(not_)?covered
|
60
|
+
File.read(file) !~ /SingleCov.(not_)?covered!/
|
79
61
|
end
|
80
62
|
unless bad.empty?
|
81
63
|
raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n")
|
@@ -83,7 +65,7 @@ module SingleCov
|
|
83
65
|
end
|
84
66
|
|
85
67
|
def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: [])
|
86
|
-
missing = files - tests.map { |t|
|
68
|
+
missing = files - tests.map { |t| guess_covered_file(t) }
|
87
69
|
fixed = untested - missing
|
88
70
|
missing -= untested
|
89
71
|
|
@@ -94,13 +76,12 @@ module SingleCov
|
|
94
76
|
end
|
95
77
|
end
|
96
78
|
|
97
|
-
def setup(framework, root: nil, branches:
|
79
|
+
def setup(framework, root: nil, branches: true, err: $stderr)
|
80
|
+
@error_logger = err
|
81
|
+
|
98
82
|
if defined?(SimpleCov)
|
99
83
|
raise "Load SimpleCov after SingleCov"
|
100
84
|
end
|
101
|
-
if branches && !BRANCH_COVERAGE_SUPPORTED
|
102
|
-
raise "Branch coverage needs ruby >= 2.5.0"
|
103
|
-
end
|
104
85
|
|
105
86
|
@branches = branches
|
106
87
|
@root = root
|
@@ -118,7 +99,11 @@ module SingleCov
|
|
118
99
|
start_coverage_recording
|
119
100
|
|
120
101
|
override_at_exit do |status, _exception|
|
121
|
-
|
102
|
+
if enabled? && main_process? && status == 0
|
103
|
+
results = coverage_results
|
104
|
+
generate_report results
|
105
|
+
exit 1 unless SingleCov.all_covered?(results)
|
106
|
+
end
|
122
107
|
end
|
123
108
|
end
|
124
109
|
|
@@ -129,19 +114,38 @@ module SingleCov
|
|
129
114
|
|
130
115
|
private
|
131
116
|
|
117
|
+
def uncovered(coverage)
|
118
|
+
return coverage unless coverage.is_a?(Hash) # just lines
|
119
|
+
|
120
|
+
# [nil, 1, 0, 1, 0] -> [3, 5]
|
121
|
+
uncovered_lines = coverage.fetch(:lines)
|
122
|
+
.each_with_index
|
123
|
+
.select { |c, _| c == 0 }
|
124
|
+
.map { |_, i| i + 1 }
|
125
|
+
.compact
|
126
|
+
|
127
|
+
uncovered_branches = uncovered_branches(coverage[:branches] || {})
|
128
|
+
uncovered_branches.reject! { |k| uncovered_lines.include?(k[0]) } # remove duplicates
|
129
|
+
|
130
|
+
all = uncovered_lines.concat uncovered_branches
|
131
|
+
all.sort_by! { |line_start, char_start, _, _| [line_start, char_start || 0] } # branches are unsorted
|
132
|
+
all
|
133
|
+
end
|
134
|
+
|
132
135
|
def enabled?
|
133
136
|
(!defined?(@disabled) || !@disabled)
|
134
137
|
end
|
135
138
|
|
136
|
-
|
137
|
-
|
139
|
+
# assuming that the main process will load all the files, we store it's pid
|
140
|
+
def main_process!
|
141
|
+
@main_process_pid = Process.pid
|
138
142
|
end
|
139
143
|
|
140
144
|
def main_process?
|
141
|
-
(!defined?(@
|
145
|
+
(!defined?(@main_process_pid) || @main_process_pid == Process.pid)
|
142
146
|
end
|
143
147
|
|
144
|
-
def uncovered_branches(coverage
|
148
|
+
def uncovered_branches(coverage)
|
145
149
|
# {[branch_id] => {[branch_part] => coverage}} --> {branch_part -> sum-of-coverage}
|
146
150
|
sum = Hash.new(0)
|
147
151
|
coverage.each_value do |branch|
|
@@ -150,9 +154,8 @@ module SingleCov
|
|
150
154
|
end
|
151
155
|
end
|
152
156
|
|
153
|
-
#
|
154
|
-
sum.
|
155
|
-
found = sum.map { |k, _| [k[0], k[1]+1, k[2], k[3]+1] }
|
157
|
+
sum.select! { |_, v| v == 0 } # keep missing coverage
|
158
|
+
found = sum.map { |k, _| [k[0], k[1] + 1, k[2], k[3] + 1] }
|
156
159
|
found.uniq!
|
157
160
|
found
|
158
161
|
end
|
@@ -168,7 +171,12 @@ module SingleCov
|
|
168
171
|
# do not ask for coverage when SimpleCov already does or it conflicts
|
169
172
|
def coverage_results
|
170
173
|
if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result))
|
171
|
-
result.original_result
|
174
|
+
result = result.original_result
|
175
|
+
# singlecov 1.18+ puts string "lines" into the result that we cannot read
|
176
|
+
if result.each_value.first.is_a?(Hash)
|
177
|
+
result = result.transform_values { |v| v.transform_keys(&:to_sym) }
|
178
|
+
end
|
179
|
+
result
|
172
180
|
else
|
173
181
|
Coverage.result
|
174
182
|
end
|
@@ -181,7 +189,7 @@ module SingleCov
|
|
181
189
|
if @branches
|
182
190
|
Coverage.start(lines: true, branches: true)
|
183
191
|
else
|
184
|
-
Coverage.start
|
192
|
+
Coverage.start(lines: true)
|
185
193
|
end
|
186
194
|
end
|
187
195
|
|
@@ -239,7 +247,7 @@ module SingleCov
|
|
239
247
|
end
|
240
248
|
|
241
249
|
def rspec_running_subset_of_tests?
|
242
|
-
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~
|
250
|
+
(ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ }
|
243
251
|
end
|
244
252
|
|
245
253
|
# code stolen from SimpleCov
|
@@ -264,15 +272,13 @@ module SingleCov
|
|
264
272
|
end
|
265
273
|
end
|
266
274
|
|
267
|
-
def
|
268
|
-
|
269
|
-
raise "Use paths relative to root."
|
270
|
-
end
|
275
|
+
def ensure_covered_file(file)
|
276
|
+
raise "Use paths relative to project root." if file&.start_with?("/")
|
271
277
|
|
272
278
|
if file
|
273
|
-
raise "#{file} does not exist
|
279
|
+
raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
|
274
280
|
else
|
275
|
-
file =
|
281
|
+
file = guess_covered_file(caller[1])
|
276
282
|
if file.start_with?("/")
|
277
283
|
raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location."
|
278
284
|
elsif !File.exist?("#{root}/#{file}")
|
@@ -283,21 +289,39 @@ module SingleCov
|
|
283
289
|
file
|
284
290
|
end
|
285
291
|
|
286
|
-
def
|
287
|
-
details = "(#{
|
288
|
-
if expected_uncovered >
|
292
|
+
def bad_coverage_error(file, expected_uncovered, uncovered)
|
293
|
+
details = "(#{uncovered.size} current vs #{expected_uncovered} configured)"
|
294
|
+
if expected_uncovered > uncovered.size
|
289
295
|
if running_single_file?
|
290
|
-
"#{file} has less uncovered lines #{details}, decrement configured uncovered
|
296
|
+
warning "#{file} has less uncovered lines #{details}, decrement configured uncovered"
|
291
297
|
end
|
292
298
|
else
|
293
299
|
[
|
294
300
|
"#{file} new uncovered lines introduced #{details}",
|
295
301
|
red("Lines missing coverage:"),
|
296
|
-
*
|
297
|
-
|
302
|
+
*uncovered.map do |line_start, char_start, line_end, char_end|
|
303
|
+
if char_start # branch coverage
|
304
|
+
if line_start == line_end
|
305
|
+
"#{file}:#{line_start}:#{char_start}-#{char_end}"
|
306
|
+
else # possibly unreachable since branches always seem to be on the same line
|
307
|
+
"#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
|
308
|
+
end
|
309
|
+
else
|
310
|
+
"#{file}:#{line_start}"
|
311
|
+
end
|
312
|
+
end
|
313
|
+
]
|
298
314
|
end
|
299
315
|
end
|
300
316
|
|
317
|
+
def warning(msg)
|
318
|
+
"#{msg}?"
|
319
|
+
end
|
320
|
+
|
321
|
+
def warning?(msg)
|
322
|
+
msg.end_with?("?")
|
323
|
+
end
|
324
|
+
|
301
325
|
def red(text)
|
302
326
|
if $stdin.tty?
|
303
327
|
"\e[31m#{text}\e[0m"
|
@@ -306,17 +330,17 @@ module SingleCov
|
|
306
330
|
end
|
307
331
|
end
|
308
332
|
|
309
|
-
def
|
333
|
+
def no_coverage_error(file)
|
310
334
|
if $LOADED_FEATURES.include?("#{root}/#{file}")
|
311
335
|
# we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded
|
312
|
-
"#{file} was expected to be covered, but was already loaded before
|
336
|
+
"#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable."
|
313
337
|
else
|
314
|
-
"#{file} was expected to be covered, but never loaded."
|
338
|
+
"#{file} was expected to be covered, but was never loaded."
|
315
339
|
end
|
316
340
|
end
|
317
341
|
|
318
|
-
def
|
319
|
-
file =
|
342
|
+
def guess_covered_file(test)
|
343
|
+
file = test.dup
|
320
344
|
|
321
345
|
# remove caller junk to get nice error messages when something fails
|
322
346
|
file.sub!(/\.rb\b.*/, '.rb')
|
@@ -334,7 +358,7 @@ module SingleCov
|
|
334
358
|
end
|
335
359
|
|
336
360
|
# rails things live in app
|
337
|
-
file_part[0...0] = if file_part =~ /^(?:#{
|
361
|
+
file_part[0...0] = if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
|
338
362
|
"app/"
|
339
363
|
elsif file_part.start_with?("lib/") # don't add lib twice
|
340
364
|
""
|
@@ -358,5 +382,27 @@ module SingleCov
|
|
358
382
|
def root
|
359
383
|
@root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd
|
360
384
|
end
|
385
|
+
|
386
|
+
def generate_report(results)
|
387
|
+
return unless report = coverage_report
|
388
|
+
|
389
|
+
# not a hard dependency for the whole library
|
390
|
+
require "json"
|
391
|
+
require "fileutils"
|
392
|
+
|
393
|
+
used = COVERAGES.map { |f, _| "#{root}/#{f}" }
|
394
|
+
covered = results.select { |k, _| used.include?(k) }
|
395
|
+
|
396
|
+
if coverage_report_lines
|
397
|
+
covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v }
|
398
|
+
end
|
399
|
+
|
400
|
+
# chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break
|
401
|
+
data = JSON.pretty_generate(
|
402
|
+
"Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i }
|
403
|
+
)
|
404
|
+
FileUtils.mkdir_p(File.dirname(report))
|
405
|
+
File.write report, data
|
406
|
+
end
|
361
407
|
end
|
362
408
|
end
|
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.7.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: 2022-01-13 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.6
|
43
42
|
signing_key:
|
44
43
|
specification_version: 4
|
45
44
|
summary: Actionable code coverage.
|