single_cov 1.3.2 → 1.11.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91538588abc7b40a632293cb64315edb846af54cee5b894925716c84b5209423
4
- data.tar.gz: 9e2cdd8fb800e72cd957eb80e29a631045aad84075d8c016b5e5ac40cec14829
3
+ metadata.gz: 3ffed2c03e29890449010148c9492563266983dd70d0f32e7a600692fa6b6a57
4
+ data.tar.gz: a9395fb02ed4dd4f5490cf4faf8a7c8ef3bd8b084081958c3a86fabb3cbbedcf
5
5
  SHA512:
6
- metadata.gz: 5daa193ee245aa4a55c49a5f5767286b5f0e1eb6a680f6977efe18eaaffa1867fd7c9fa941d4eafecb141995202a971df490fa4e6814b3662fc788c4509a939e
7
- data.tar.gz: fd0bbe8516e1bbf194886d87b38be3a5cf388d6afbecc796dbf6287a39778e81b251a44be890bd43ecc6f39ff8b93f246b272378430a7772b5cd06ee86274101
6
+ metadata.gz: dddfdd5778b9e3c34b92db87d4288970a6a0bd27154d0c40572bf5fa581f92806214827974b55c2612b19f1298bd1d5827072006fb1d131bf5feb36ccca33513
7
+ data.tar.gz: 6b82b17a84a018334c140027bab24ef3ac9c08cb0d52973f87d580006d19b0c7cfd3a80b8331b7d487e38af34c8e51f817066170ddee360378587b0b8af03981
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SingleCov
2
- VERSION = "1.3.2"
3
+ VERSION = "1.11.0"
3
4
  end
data/lib/single_cov.rb CHANGED
@@ -1,81 +1,66 @@
1
+ # frozen_string_literal: true
1
2
  module SingleCov
2
3
  COVERAGES = []
3
- MAX_OUTPUT = 40
4
- APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"]
5
- BRANCH_COVERAGE_SUPPORTED = (RUBY_VERSION >= "2.5.0")
6
- UNCOVERED_COMMENT_MARKER = "uncovered"
4
+ MAX_OUTPUT = Integer(ENV["SINGLE_COV_MAX_OUTPUT"] || "40")
5
+ RAILS_APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"]
6
+ UNCOVERED_COMMENT_MARKER = /#.*uncovered/
7
+ PREFIXES_TO_IGNORE = [] # things to not prefix with lib/ etc
7
8
 
8
9
  class << self
9
- # optionally rewrite the file we guessed with a lambda
10
+ # enable coverage reporting: path to output file, changed by forking-test-runner at runtime to combine many reports
11
+ attr_accessor :coverage_report
12
+
13
+ # emit only line coverage in coverage report for older coverage systems
14
+ attr_accessor :coverage_report_lines
15
+
16
+ # optionally rewrite the matching path single-cov guessed with a lambda
10
17
  def rewrite(&block)
11
18
  @rewrite = block
12
19
  end
13
20
 
21
+ # mark a test file as not covering anything to make assert_used pass
14
22
  def not_covered!
15
- store_pid
23
+ main_process!
16
24
  end
17
25
 
26
+ # mark the file under test as needing coverage
18
27
  def covered!(file: nil, uncovered: 0)
19
- file = guess_and_check_covered_file(file)
28
+ file = ensure_covered_file(file)
20
29
  COVERAGES << [file, uncovered]
21
- store_pid
30
+ main_process!
22
31
  end
23
32
 
24
33
  def all_covered?(result)
25
- errors = COVERAGES.map do |file, expected_uncovered|
26
- if coverage = result["#{root}/#{file}"]
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
43
-
44
- # branches are unsorted and added to the end, only sort when displayed
45
- if branch_coverage
46
- uncovered.sort_by! { |line_start, char_start, _, _| [line_start, char_start || 0] }
47
- end
34
+ errors = COVERAGES.flat_map do |file, expected_uncovered|
35
+ next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]
48
36
 
49
- uncovered.map! do |line_start, char_start, line_end, char_end|
50
- if char_start # branch coverage
51
- if line_start == line_end
52
- "#{file}:#{line_start}:#{char_start}-#{char_end}"
53
- else # possibly unreachable since branches always seem to be on the same line
54
- "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
55
- end
56
- else
57
- "#{file}:#{line_start}"
58
- end
59
- end
37
+ uncovered = uncovered(coverage)
38
+ next if uncovered.size == expected_uncovered
60
39
 
61
- warn_about_bad_coverage(file, expected_uncovered, uncovered)
62
- else
63
- warn_about_no_coverage(file)
40
+ # ignore lines that are marked as uncovered via comments
41
+ # TODO: warn when using uncovered but the section is indeed covered
42
+ content = File.readlines("#{root}/#{file}")
43
+ uncovered.reject! do |line_start, _, _, _, _|
44
+ content[line_start - 1].match?(UNCOVERED_COMMENT_MARKER)
64
45
  end
46
+ next if uncovered.size == expected_uncovered
47
+
48
+ bad_coverage_error(file, expected_uncovered, uncovered)
65
49
  end.compact
66
50
 
67
51
  return true if errors.empty?
68
52
 
69
- errors = errors.join("\n").split("\n") # unify arrays with multiline strings
70
- errors[MAX_OUTPUT..-1] = "... coverage output truncated" if errors.size >= MAX_OUTPUT
71
- warn errors
53
+ if errors.size >= MAX_OUTPUT
54
+ errors[MAX_OUTPUT..-1] = "... coverage output truncated (use SINGLE_COV_MAX_OUTPUT=999 to expand)"
55
+ end
56
+ @error_logger.puts errors
72
57
 
73
- errors.all? { |l| l.end_with?('?') } # ok if we just have warnings
58
+ errors.all? { |l| warning?(l) }
74
59
  end
75
60
 
76
61
  def assert_used(tests: default_tests)
77
62
  bad = tests.select do |file|
78
- File.read(file) !~ /SingleCov.(not_)?covered\!/
63
+ File.read(file) !~ /SingleCov.(not_)?covered!/
79
64
  end
80
65
  unless bad.empty?
81
66
  raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n")
@@ -83,7 +68,7 @@ module SingleCov
83
68
  end
84
69
 
85
70
  def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: [])
86
- missing = files - tests.map { |t| file_under_test(t) }
71
+ missing = files - tests.map { |t| guess_covered_file(t) }
87
72
  fixed = untested - missing
88
73
  missing -= untested
89
74
 
@@ -94,13 +79,40 @@ module SingleCov
94
79
  end
95
80
  end
96
81
 
97
- def setup(framework, root: nil, branches: BRANCH_COVERAGE_SUPPORTED)
82
+ def assert_full_coverage(tests: default_tests, currently_complete: [], location: nil)
83
+ location ||= caller(0..1)[1].split(':in').first
84
+ complete = tests.select { |file| File.read(file) =~ /SingleCov.covered!(?:(?!uncovered).)*(\s*|\s*\#.*)$/ }
85
+ missing_complete = currently_complete - complete
86
+ newly_complete = complete - currently_complete
87
+ errors = []
88
+
89
+ if missing_complete.any?
90
+ errors << <<~MSG
91
+ The following file(s) were previously marked as having 100% SingleCov test coverage (had no `coverage:` option) but are no longer marked as such.
92
+ #{missing_complete.join("\n")}
93
+ Please increase test coverage in these files to maintain 100% coverage and remove `coverage:` usage.
94
+
95
+ If this test fails during a file removal, make it pass by removing all references to the removed file's path from the code base.
96
+ MSG
97
+ end
98
+
99
+ if newly_complete.any?
100
+ errors << <<~MSG
101
+ The following files are newly at 100% SingleCov test coverage.
102
+ Please add the following to #{location} to ensure 100% coverage is maintained moving forward.
103
+ #{newly_complete.join("\n")}
104
+ MSG
105
+ end
106
+
107
+ raise errors.join("\n") if errors.any?
108
+ end
109
+
110
+ def setup(framework, root: nil, branches: true, err: $stderr)
111
+ @error_logger = err
112
+
98
113
  if defined?(SimpleCov)
99
114
  raise "Load SimpleCov after SingleCov"
100
115
  end
101
- if branches && !BRANCH_COVERAGE_SUPPORTED
102
- raise "Branch coverage needs ruby >= 2.5.0"
103
- end
104
116
 
105
117
  @branches = branches
106
118
  @root = root
@@ -118,7 +130,11 @@ module SingleCov
118
130
  start_coverage_recording
119
131
 
120
132
  override_at_exit do |status, _exception|
121
- exit 1 if enabled? && main_process? && status == 0 && !SingleCov.all_covered?(coverage_results)
133
+ if enabled? && main_process? && status == 0
134
+ results = coverage_results
135
+ generate_report results
136
+ exit 1 unless SingleCov.all_covered?(results)
137
+ end
122
138
  end
123
139
  end
124
140
 
@@ -129,32 +145,47 @@ module SingleCov
129
145
 
130
146
  private
131
147
 
148
+ def uncovered(coverage)
149
+ return coverage unless coverage.is_a?(Hash) # just lines
150
+
151
+ uncovered_lines = indexes(coverage.fetch(:lines), 0).map! { |i| i + 1 }
152
+ uncovered_branches = uncovered_branches(coverage[:branches] || {})
153
+ uncovered_branches.reject! { |br| uncovered_lines.include?(br[0]) } # ignore branch when whole line is uncovered
154
+
155
+ # combine lines and branches while keeping them sorted
156
+ all = uncovered_lines.concat uncovered_branches
157
+ all.sort_by! { |line_start, char_start, _, _, _| [line_start, char_start || 0] } # branches are unsorted
158
+ all
159
+ end
160
+
132
161
  def enabled?
133
162
  (!defined?(@disabled) || !@disabled)
134
163
  end
135
164
 
136
- def store_pid
137
- @pid = Process.pid
165
+ # assuming that the main process will load all the files, we store it's pid
166
+ def main_process!
167
+ @main_process_pid = Process.pid
138
168
  end
139
169
 
140
170
  def main_process?
141
- (!defined?(@pid) || @pid == Process.pid)
171
+ (!defined?(@main_process_pid) || @main_process_pid == Process.pid)
142
172
  end
143
173
 
144
- def uncovered_branches(coverage, uncovered_lines)
145
- # {[branch_id] => {[branch_part] => coverage}} --> {branch_part -> sum-of-coverage}
146
- sum = Hash.new(0)
174
+ # {[branch_id] => {[branch_part] => coverage}} --> uncovered location
175
+ def uncovered_branches(coverage)
176
+ sum = {}
147
177
  coverage.each_value do |branch|
148
- branch.each do |k, v|
149
- sum[k.slice(2, 4)] += v
178
+ branch.filter_map do |part, c|
179
+ location = [part[2], part[3] + 1, part[4], part[5] + 1] # locations can be duplicated
180
+ type = part[0]
181
+ info = (sum[location] ||= [0, nil])
182
+ info[0] += c
183
+ info[1] = type if type == :else # only else is important to track since it often is not in the code
150
184
  end
151
185
  end
152
186
 
153
- # show missing coverage
154
- sum.select! { |k, v| v.zero? && !uncovered_lines.include?(k[0]) }
155
- found = sum.map { |k, _| [k[0], k[1]+1, k[2], k[3]+1] }
156
- found.uniq!
157
- found
187
+ # keep location and type of missing coverage
188
+ sum.filter_map { |k, v| k + [v[1]] if v[0] == 0 }
158
189
  end
159
190
 
160
191
  def default_tests
@@ -165,10 +196,19 @@ module SingleCov
165
196
  Dir["#{root}/#{pattern}"].map! { |f| f.sub("#{root}/", '') }
166
197
  end
167
198
 
199
+ def indexes(list, find)
200
+ list.each_with_index.filter_map { |v, i| i if v == find }
201
+ end
202
+
168
203
  # do not ask for coverage when SimpleCov already does or it conflicts
169
204
  def coverage_results
170
205
  if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result))
171
- result.original_result
206
+ result = result.original_result
207
+ # singlecov 1.18+ puts string "lines" into the result that we cannot read
208
+ if result.each_value.first.is_a?(Hash)
209
+ result = result.transform_values { |v| v.transform_keys(&:to_sym) }
210
+ end
211
+ result
172
212
  else
173
213
  Coverage.result
174
214
  end
@@ -181,7 +221,7 @@ module SingleCov
181
221
  if @branches
182
222
  Coverage.start(lines: true, branches: true)
183
223
  else
184
- Coverage.start
224
+ Coverage.start(lines: true)
185
225
  end
186
226
  end
187
227
 
@@ -239,7 +279,7 @@ module SingleCov
239
279
  end
240
280
 
241
281
  def rspec_running_subset_of_tests?
242
- (ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /\:\d+$|\[[\d:]+\]$/ }
282
+ (ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ }
243
283
  end
244
284
 
245
285
  # code stolen from SimpleCov
@@ -264,15 +304,12 @@ module SingleCov
264
304
  end
265
305
  end
266
306
 
267
- def guess_and_check_covered_file(file)
268
- if file && file.start_with?("/")
269
- raise "Use paths relative to root."
270
- end
271
-
307
+ def ensure_covered_file(file)
272
308
  if file
273
- raise "#{file} does not exist and cannot be covered." unless File.exist?("#{root}/#{file}")
309
+ raise "Use paths relative to project root." if file.start_with?("/")
310
+ raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
274
311
  else
275
- file = file_under_test(caller[1])
312
+ file = guess_covered_file(caller[1])
276
313
  if file.start_with?("/")
277
314
  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
315
  elsif !File.exist?("#{root}/#{file}")
@@ -283,21 +320,39 @@ module SingleCov
283
320
  file
284
321
  end
285
322
 
286
- def warn_about_bad_coverage(file, expected_uncovered, uncovered_lines)
287
- details = "(#{uncovered_lines.size} current vs #{expected_uncovered} configured)"
288
- if expected_uncovered > uncovered_lines.size
323
+ def bad_coverage_error(file, expected_uncovered, uncovered)
324
+ details = "(#{uncovered.size} current vs #{expected_uncovered} configured)"
325
+ if expected_uncovered > uncovered.size
289
326
  if running_single_file?
290
- "#{file} has less uncovered lines #{details}, decrement configured uncovered?"
327
+ warning "#{file} has less uncovered lines #{details}, decrement configured uncovered"
291
328
  end
292
329
  else
293
330
  [
294
331
  "#{file} new uncovered lines introduced #{details}",
295
332
  red("Lines missing coverage:"),
296
- *uncovered_lines
297
- ].join("\n")
333
+ *uncovered.map do |line_start, char_start, line_end, char_end, type|
334
+ if char_start # branch coverage
335
+ if line_start == line_end
336
+ "#{file}:#{line_start}:#{char_start}-#{char_end}"
337
+ else # possibly unreachable since branches always seem to be on the same line
338
+ "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
339
+ end + (type ? " # #{type}" : "")
340
+ else
341
+ "#{file}:#{line_start}"
342
+ end
343
+ end
344
+ ]
298
345
  end
299
346
  end
300
347
 
348
+ def warning(msg)
349
+ "#{msg}?"
350
+ end
351
+
352
+ def warning?(msg)
353
+ msg.end_with?("?")
354
+ end
355
+
301
356
  def red(text)
302
357
  if $stdin.tty?
303
358
  "\e[31m#{text}\e[0m"
@@ -306,17 +361,17 @@ module SingleCov
306
361
  end
307
362
  end
308
363
 
309
- def warn_about_no_coverage(file)
364
+ def no_coverage_error(file)
310
365
  if $LOADED_FEATURES.include?("#{root}/#{file}")
311
366
  # 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 tests started, which makes it uncoverable."
367
+ "#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable."
313
368
  else
314
- "#{file} was expected to be covered, but never loaded."
369
+ "#{file} was expected to be covered, but was never loaded."
315
370
  end
316
371
  end
317
372
 
318
- def file_under_test(file)
319
- file = file.dup
373
+ def guess_covered_file(test)
374
+ file = test.dup
320
375
 
321
376
  # remove caller junk to get nice error messages when something fails
322
377
  file.sub!(/\.rb\b.*/, '.rb')
@@ -333,18 +388,21 @@ module SingleCov
333
388
  raise "#{file} includes neither 'test' nor 'spec' folder ... unable to resolve"
334
389
  end
335
390
 
336
- # rails things live in app
337
- file_part[0...0] = if file_part =~ /^(?:#{APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
338
- "app/"
339
- elsif file_part.start_with?("lib/") # don't add lib twice
340
- ""
341
- else # everything else lives in lib
342
- "lib/"
343
- end
391
+ without_ignored_prefixes file_part do
392
+ # rails things live in app
393
+ file_part[0...0] =
394
+ if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
395
+ "app/"
396
+ elsif file_part.start_with?("lib/") # don't add lib twice
397
+ ""
398
+ else # everything else lives in lib
399
+ "lib/"
400
+ end
344
401
 
345
- # remove test extension
346
- if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/")
347
- raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported"
402
+ # remove test extension
403
+ if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/")
404
+ raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported"
405
+ end
348
406
  end
349
407
 
350
408
  # put back the subfolder
@@ -358,5 +416,40 @@ module SingleCov
358
416
  def root
359
417
  @root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd
360
418
  end
419
+
420
+ def generate_report(results)
421
+ return unless report = coverage_report
422
+
423
+ # not a hard dependency for the whole library
424
+ require "json"
425
+ require "fileutils"
426
+
427
+ used = COVERAGES.map { |f, _| "#{root}/#{f}" }
428
+ covered = results.select { |k, _| used.include?(k) }
429
+
430
+ if coverage_report_lines
431
+ covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v }
432
+ end
433
+
434
+ # chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break
435
+ data = JSON.pretty_generate(
436
+ "Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i }
437
+ )
438
+ FileUtils.mkdir_p(File.dirname(report))
439
+ File.write report, data
440
+ end
441
+
442
+ # file_part is modified during yield so we have to make sure to also modify in place
443
+ def without_ignored_prefixes(file_part)
444
+ folders = file_part.split('/')
445
+ return yield unless PREFIXES_TO_IGNORE.include?(folders.first)
446
+
447
+ prefix = folders.shift
448
+ file_part.replace folders.join('/')
449
+
450
+ yield
451
+
452
+ file_part[0...0] = "#{prefix}/"
453
+ end
361
454
  end
362
455
  end
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: single_cov
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Grosser
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-05-09 00:00:00.000000000 Z
11
+ date: 2023-06-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description:
13
+ description:
14
14
  email: michael@grosser.it
15
15
  executables: []
16
16
  extensions: []
@@ -23,7 +23,7 @@ homepage: https://github.com/grosser/single_cov
23
23
  licenses:
24
24
  - MIT
25
25
  metadata: {}
26
- post_install_message:
26
+ post_install_message:
27
27
  rdoc_options: []
28
28
  require_paths:
29
29
  - lib
@@ -31,16 +31,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
31
31
  requirements:
32
32
  - - ">="
33
33
  - !ruby/object:Gem::Version
34
- version: 2.0.0
34
+ version: 2.7.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
- rubyforge_project:
42
- rubygems_version: 2.7.6
43
- signing_key:
41
+ rubygems_version: 3.3.3
42
+ signing_key:
44
43
  specification_version: 4
45
44
  summary: Actionable code coverage.
46
45
  test_files: []