single_cov 1.3.2 → 1.11.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 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: []