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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91538588abc7b40a632293cb64315edb846af54cee5b894925716c84b5209423
4
- data.tar.gz: 9e2cdd8fb800e72cd957eb80e29a631045aad84075d8c016b5e5ac40cec14829
3
+ metadata.gz: 9b93f5dd228599154416958a87e4f96ddd97a5477d92b7fc6c43c80ff3d6485f
4
+ data.tar.gz: ccd0f0c8b95df16fae3c071000b2499cca41bffea096bd84a8d761444335b2c4
5
5
  SHA512:
6
- metadata.gz: 5daa193ee245aa4a55c49a5f5767286b5f0e1eb6a680f6977efe18eaaffa1867fd7c9fa941d4eafecb141995202a971df490fa4e6814b3662fc788c4509a939e
7
- data.tar.gz: fd0bbe8516e1bbf194886d87b38be3a5cf388d6afbecc796dbf6287a39778e81b251a44be890bd43ecc6f39ff8b93f246b272378430a7772b5cd06ee86274101
6
+ metadata.gz: 827b9ed7610e88def4654d18d8e2d4fa01c1f6c296a260c64ef8f4a722d1a478e195fefbfaf45afbeb8d6710a644f071f48e117cb927daf2d5bb0b42decd14e9
7
+ data.tar.gz: 3a21bb814833a4a73b5303f4b354218014f0a7b5e05eac89863b3508f7b6b488a06177577ce383d6a706847716dbc6706b37583d499deafee1a516a5d8a1e5c2
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module SingleCov
2
- VERSION = "1.3.2"
3
+ VERSION = "1.7.0"
3
4
  end
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
- APP_FOLDERS = ["models", "serializers", "helpers", "controllers", "mailers", "views", "jobs", "channels"]
5
- BRANCH_COVERAGE_SUPPORTED = (RUBY_VERSION >= "2.5.0")
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
- # optionally rewrite the file we guessed with a lambda
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
- store_pid
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 = guess_and_check_covered_file(file)
27
+ file = ensure_covered_file(file)
20
28
  COVERAGES << [file, uncovered]
21
- store_pid
29
+ main_process!
22
30
  end
23
31
 
24
32
  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
33
+ errors = COVERAGES.flat_map do |file, expected_uncovered|
34
+ next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]
43
35
 
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
36
+ uncovered = uncovered(coverage)
37
+ next if uncovered.size == expected_uncovered
48
38
 
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
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
- warn errors
53
+ @error_logger.puts errors
72
54
 
73
- errors.all? { |l| l.end_with?('?') } # ok if we just have warnings
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| file_under_test(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: BRANCH_COVERAGE_SUPPORTED)
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
- exit 1 if enabled? && main_process? && status == 0 && !SingleCov.all_covered?(coverage_results)
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
- def store_pid
137
- @pid = Process.pid
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?(@pid) || @pid == Process.pid)
145
+ (!defined?(@main_process_pid) || @main_process_pid == Process.pid)
142
146
  end
143
147
 
144
- def uncovered_branches(coverage, uncovered_lines)
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
- # 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] }
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 =~ /\:\d+$|\[[\d:]+\]$/ }
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 guess_and_check_covered_file(file)
268
- if file && file.start_with?("/")
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 and cannot be covered." unless File.exist?("#{root}/#{file}")
279
+ raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
274
280
  else
275
- file = file_under_test(caller[1])
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 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
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
- *uncovered_lines
297
- ].join("\n")
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 warn_about_no_coverage(file)
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 tests started, which makes it uncoverable."
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 file_under_test(file)
319
- file = file.dup
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 =~ /^(?:#{APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
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.3.2
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: 2019-05-09 00:00:00.000000000 Z
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.0.0
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
- rubyforge_project:
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.