module SingleCov

Constants

COVERAGES
MAX_OUTPUT
PREFIXES_TO_IGNORE
RAILS_APP_FOLDERS
UNCOVERED_COMMENT_MARKER
VERSION

Attributes

coverage_report[RW]

enable coverage reporting: path to output file, changed by forking-test-runner at runtime to combine many reports

coverage_report_lines[RW]

emit only line coverage in coverage report for older coverage systems

Public Class Methods

all_covered?(result) click to toggle source
# File lib/single_cov.rb, line 33
def all_covered?(result)
  errors = COVERAGES.flat_map do |file, expected_uncovered|
    next no_coverage_error(file) unless coverage = result["#{root}/#{file}"]

    uncovered = uncovered(coverage)
    next if uncovered.size == expected_uncovered

    # ignore lines that are marked as uncovered via comments
    # TODO: warn when using uncovered but the section is indeed covered
    content = File.readlines("#{root}/#{file}")
    uncovered.reject! do |line_start, _, _, _, _|
      content[line_start - 1].match?(UNCOVERED_COMMENT_MARKER)
    end
    next if uncovered.size == expected_uncovered

    bad_coverage_error(file, expected_uncovered, uncovered)
  end.compact

  return true if errors.empty?

  if errors.size >= MAX_OUTPUT
    errors[MAX_OUTPUT..-1] = "... coverage output truncated (use SINGLE_COV_MAX_OUTPUT=999 to expand)"
  end
  @error_logger.puts errors

  errors.all? { |l| warning?(l) }
end
assert_full_coverage(tests: default_tests, currently_complete: [], location: nil) click to toggle source
# File lib/single_cov.rb, line 82
    def assert_full_coverage(tests: default_tests, currently_complete: [], location: nil)
      location ||= caller(0..1)[1].split(':in').first
      complete = tests.select { |file| File.read(file) =~ /SingleCov.covered!(?:(?!uncovered).)*(\s*|\s*\#.*)$/ }
      missing_complete = currently_complete - complete
      newly_complete = complete - currently_complete
      errors = []

      if missing_complete.any?
        errors << <<~MSG
          The following file(s) were previously marked as having 100% SingleCov test coverage (had no `coverage:` option) but are no longer marked as such.
          #{missing_complete.join("\n")}
          Please increase test coverage in these files to maintain 100% coverage and remove `coverage:` usage.

          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.
        MSG
      end

      if newly_complete.any?
        errors << <<~MSG
          The following files are newly at 100% SingleCov test coverage.
          Please add the following to #{location} to ensure 100% coverage is maintained moving forward.
          #{newly_complete.join("\n")}
        MSG
      end

      raise errors.join("\n") if errors.any?
    end
assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: []) click to toggle source
# File lib/single_cov.rb, line 70
def assert_tested(files: glob('{app,lib}/**/*.rb'), tests: default_tests, untested: [])
  missing = files - tests.map { |t| guess_covered_file(t) }
  fixed = untested - missing
  missing -= untested

  if fixed.any?
    raise "Remove #{fixed.inspect} from untested!"
  elsif missing.any?
    raise missing.map { |f| "missing test for #{f}" }.join("\n")
  end
end
assert_used(tests: default_tests) click to toggle source
# File lib/single_cov.rb, line 61
def assert_used(tests: default_tests)
  bad = tests.select do |file|
    File.read(file) !~ /SingleCov.(not_)?covered!/
  end
  unless bad.empty?
    raise bad.map { |f| "#{f}: needs to use SingleCov.covered!" }.join("\n")
  end
end
covered!(file: nil, uncovered: 0) click to toggle source

mark the file under test as needing coverage

# File lib/single_cov.rb, line 27
def covered!(file: nil, uncovered: 0)
  file = ensure_covered_file(file)
  COVERAGES << [file, uncovered]
  main_process!
end
disable() click to toggle source

use this in forks when using rspec to silence duplicated output

# File lib/single_cov.rb, line 142
def disable
  @disabled = true
end
not_covered!() click to toggle source

mark a test file as not covering anything to make assert_used pass

# File lib/single_cov.rb, line 22
def not_covered!
  main_process!
end
rewrite(&block) click to toggle source

optionally rewrite the matching path single-cov guessed with a lambda

# File lib/single_cov.rb, line 17
def rewrite(&block)
  @rewrite = block
end
setup(framework, root: nil, branches: true, err: $stderr) click to toggle source
# File lib/single_cov.rb, line 110
def setup(framework, root: nil, branches: true, err: $stderr)
  @error_logger = err

  if defined?(SimpleCov)
    raise "Load SimpleCov after SingleCov"
  end

  @branches = branches
  @root = root

  case framework
  when :minitest
    minitest_should_not_be_running!
    return if minitest_running_subset_of_tests?
  when :rspec
    return if rspec_running_subset_of_tests?
  else
    raise "Unsupported framework #{framework.inspect}"
  end

  start_coverage_recording

  override_at_exit do |status, _exception|
    if enabled? && main_process? && status == 0
      results = coverage_results
      generate_report results
      exit 1 unless SingleCov.all_covered?(results)
    end
  end
end

Private Class Methods

bad_coverage_error(file, expected_uncovered, uncovered) click to toggle source
# File lib/single_cov.rb, line 323
def bad_coverage_error(file, expected_uncovered, uncovered)
  details = "(#{uncovered.size} current vs #{expected_uncovered} configured)"
  if expected_uncovered > uncovered.size
    if running_single_file?
      warning "#{file} has less uncovered lines #{details}, decrement configured uncovered"
    end
  else
    [
      "#{file} new uncovered lines introduced #{details}",
      red("Lines missing coverage:"),
      *uncovered.map do |line_start, char_start, line_end, char_end, type|
        if char_start # branch coverage
          if line_start == line_end
            "#{file}:#{line_start}:#{char_start}-#{char_end}"
          else # possibly unreachable since branches always seem to be on the same line
            "#{file}:#{line_start}:#{char_start}-#{line_end}:#{char_end}"
          end + (type ? " # #{type}" : "")
        else
          "#{file}:#{line_start}"
        end
      end
    ]
  end
end
coverage_results() click to toggle source

do not ask for coverage when SimpleCov already does or it conflicts

# File lib/single_cov.rb, line 204
def coverage_results
  if defined?(SimpleCov) && (result = SimpleCov.instance_variable_get(:@result))
    result = result.original_result
    # singlecov 1.18+ puts string "lines" into the result that we cannot read
    if result.each_value.first.is_a?(Hash)
      result = result.transform_values { |v| v.transform_keys(&:to_sym) }
    end
    result
  else
    Coverage.result
  end
end
default_tests() click to toggle source
# File lib/single_cov.rb, line 191
def default_tests
  glob("{test,spec}/**/*_{test,spec}.rb")
end
enabled?() click to toggle source
# File lib/single_cov.rb, line 161
def enabled?
  (!defined?(@disabled) || !@disabled)
end
ensure_covered_file(file) click to toggle source
# File lib/single_cov.rb, line 307
def ensure_covered_file(file)
  if file
    raise "Use paths relative to project root." if file.start_with?("/")
    raise "#{file} does not exist, use paths relative to project root." unless File.exist?("#{root}/#{file}")
  else
    file = guess_covered_file(caller[1])
    if file.start_with?("/")
      raise "Found file #{file} which is not relative to the root #{root}.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location."
    elsif !File.exist?("#{root}/#{file}")
      raise "Tried to guess covered file as #{file}, but it does not exist.\nUse `SingleCov.covered! file: 'target_file.rb'` to set covered file location."
    end
  end

  file
end
faked_by_forking_test_runner?() click to toggle source

ForkingTestRunner fakes an initialized minitest to avoid multiple hooks being installed so hooks still get added in order github.com/grosser/forking_test_runner/pull/4

# File lib/single_cov.rb, line 259
def faked_by_forking_test_runner?
  defined?(Coverage) && Coverage.respond_to?(:capture_coverage!)
end
generate_report(results) click to toggle source
# File lib/single_cov.rb, line 420
def generate_report(results)
  return unless report = coverage_report

  # not a hard dependency for the whole library
  require "json"
  require "fileutils"

  used = COVERAGES.map { |f, _| "#{root}/#{f}" }
  covered = results.select { |k, _| used.include?(k) }

  if coverage_report_lines
    covered = covered.transform_values { |v| v.is_a?(Hash) ? v.fetch(:lines) : v }
  end

  # chose "Minitest" because it is what simplecov uses for reports and "Unit Tests" makes sonarqube break
  data = JSON.pretty_generate(
    "Minitest" => { "coverage" => covered, "timestamp" => Time.now.to_i }
  )
  FileUtils.mkdir_p(File.dirname(report))
  File.write report, data
end
glob(pattern) click to toggle source
# File lib/single_cov.rb, line 195
def glob(pattern)
  Dir["#{root}/#{pattern}"].map! { |f| f.sub("#{root}/", '') }
end
guess_covered_file(test) click to toggle source
# File lib/single_cov.rb, line 373
def guess_covered_file(test)
  file = test.dup

  # remove caller junk to get nice error messages when something fails
  file.sub!(/\.rb\b.*/, '.rb')

  # resolve all kinds of relativity
  file = File.expand_path(file)

  # remove project root
  file.sub!("#{root}/", '')

  # preserve subfolders like foobar/test/xxx_test.rb -> foobar/lib/xxx_test.rb
  subfolder, file_part = file.split(%r{(?:^|/)(?:test|spec)/}, 2)
  unless file_part
    raise "#{file} includes neither 'test' nor 'spec' folder ... unable to resolve"
  end

  without_ignored_prefixes file_part do
    # rails things live in app
    file_part[0...0] =
      if file_part =~ /^(?:#{RAILS_APP_FOLDERS.map { |f| Regexp.escape(f) }.join('|')})\//
        "app/"
      elsif file_part.start_with?("lib/") # don't add lib twice
        ""
      else # everything else lives in lib
        "lib/"
      end

    # remove test extension
    if !file_part.sub!(/_(?:test|spec)\.rb\b.*/, '.rb') && !file_part.sub!(/\/test_/, "/")
      raise "Unable to remove test extension from #{file} ... /test_, _test.rb and _spec.rb are supported"
    end
  end

  # put back the subfolder
  file_part[0...0] = "#{subfolder}/" unless subfolder.empty?

  file_part = @rewrite.call(file_part) if defined?(@rewrite) && @rewrite

  file_part
end
indexes(list, find) click to toggle source
# File lib/single_cov.rb, line 199
def indexes(list, find)
  list.each_with_index.filter_map { |v, i| i if v == find }
end
main_process!() click to toggle source

assuming that the main process will load all the files, we store it’s pid

# File lib/single_cov.rb, line 166
def main_process!
  @main_process_pid = Process.pid
end
main_process?() click to toggle source
# File lib/single_cov.rb, line 170
def main_process?
  (!defined?(@main_process_pid) || @main_process_pid == Process.pid)
end
minitest_running_subset_of_tests?() click to toggle source

do not record or verify when only running selected tests since it would be missing data

# File lib/single_cov.rb, line 264
def minitest_running_subset_of_tests?
  # via direct option (ruby test.rb -n /foo/)
  (ARGV.map { |a| a.split('=', 2).first } & ['-n', '--name', '-l', '--line']).any? ||

  # via testrbl or mtest or rails with direct line number (mtest test.rb:123)
  (ARGV.first =~ /:\d+\Z/) ||

  # via rails test which preloads mintest, removes ARGV and fills options
  (
    defined?(Minitest) &&
    defined?(Minitest.reporter) &&
    Minitest.reporter &&
    (reporter = Minitest.reporter.reporters.first) &&
    reporter.options[:filter]
  )
end
minitest_should_not_be_running!() click to toggle source

we cannot insert our hooks when minitest is already running

# File lib/single_cov.rb, line 234
def minitest_should_not_be_running!
  return unless defined?(Minitest)
  return unless Minitest.class_variable_defined?(:@@installed_at_exit)
  return unless Minitest.class_variable_get(:@@installed_at_exit)

  # untested
  # https://github.com/rails/rails/pull/26515 rails loads autorun before test
  # but it works out for some reason
  return if Minitest.extensions.include?('rails')

  # untested
  # forking test runner does some hacky acrobatics to fake minitest status
  # and the resets it ... works out ok in the end ...
  return if faked_by_forking_test_runner?

  # ... but only if it's used with `--merge-coverage` otherwise the coverage reporting is useless
  if $0.end_with?("/forking-test-runner")
    raise "forking-test-runner only work with single_cov when using --merge-coverage"
  end

  raise "Load minitest after setting up SingleCov"
end
no_coverage_error(file) click to toggle source
# File lib/single_cov.rb, line 364
def no_coverage_error(file)
  if $LOADED_FEATURES.include?("#{root}/#{file}")
    # we cannot enforce $LOADED_FEATURES during covered! since it would fail when multiple files are loaded
    "#{file} was expected to be covered, but was already loaded before coverage started, which makes it uncoverable."
  else
    "#{file} was expected to be covered, but was never loaded."
  end
end
override_at_exit() { |exit_status, $!| ... } click to toggle source

code stolen from SimpleCov

# File lib/single_cov.rb, line 286
def override_at_exit
  at_exit do
    exit_status = if $! # was an exception thrown?
      # if it was a SystemExit, use the accompanying status
      # otherwise set a non-zero status representing termination by
      # some other exception (see github issue 41)
      $!.is_a?(SystemExit) ? $!.status : 1
    else
      # Store the exit status of the test run since it goes away
      # after calling the at_exit proc...
      0
    end

    yield exit_status, $!

    # Force exit with stored status (see github issue #5)
    # unless it's nil or 0 (see github issue #281)
    Kernel.exit exit_status if exit_status && exit_status > 0
  end
end
red(text) click to toggle source
# File lib/single_cov.rb, line 356
def red(text)
  if $stdin.tty?
    "\e[31m#{text}\e[0m"
  else
    text
  end
end
root() click to toggle source
# File lib/single_cov.rb, line 416
def root
  @root ||= (defined?(Bundler) && Bundler.root.to_s.sub(/\/gemfiles$/, '')) || Dir.pwd
end
rspec_running_subset_of_tests?() click to toggle source
# File lib/single_cov.rb, line 281
def rspec_running_subset_of_tests?
  (ARGV & ['-t', '--tag', '-e', '--example']).any? || ARGV.any? { |a| a =~ /:\d+$|\[[\d:]+\]$/ }
end
running_single_file?() click to toggle source

not running rake or a whole folder

# File lib/single_cov.rb, line 229
def running_single_file?
  COVERAGES.size == 1
end
start_coverage_recording() click to toggle source

start recording before classes are loaded or nothing can be recorded SimpleCov might start coverage again, but that does not hurt …

# File lib/single_cov.rb, line 219
def start_coverage_recording
  require 'coverage'
  if @branches
    Coverage.start(lines: true, branches: true)
  else
    Coverage.start(lines: true)
  end
end
uncovered(coverage) click to toggle source
# File lib/single_cov.rb, line 148
def uncovered(coverage)
  return coverage unless coverage.is_a?(Hash) # just lines

  uncovered_lines = indexes(coverage.fetch(:lines), 0).map! { |i| i + 1 }
  uncovered_branches = uncovered_branches(coverage[:branches] || {})
  uncovered_branches.reject! { |br| uncovered_lines.include?(br[0]) } # ignore branch when whole line is uncovered

  # combine lines and branches while keeping them sorted
  all = uncovered_lines.concat uncovered_branches
  all.sort_by! { |line_start, char_start, _, _, _| [line_start, char_start || 0] } # branches are unsorted
  all
end
uncovered_branches(coverage) click to toggle source

{[branch_id] => {[branch_part] => coverage}} –> uncovered location

# File lib/single_cov.rb, line 175
def uncovered_branches(coverage)
  sum = {}
  coverage.each_value do |branch|
    branch.filter_map do |part, c|
      location = [part[2], part[3] + 1, part[4], part[5] + 1] # locations can be duplicated
      type = part[0]
      info = (sum[location] ||= [0, nil])
      info[0] += c
      info[1] = type if type == :else # only else is important to track since it often is not in the code
    end
  end

  # keep location and type of missing coverage
  sum.filter_map { |k, v| k + [v[1]] if v[0] == 0 }
end
warning(msg) click to toggle source
# File lib/single_cov.rb, line 348
def warning(msg)
  "#{msg}?"
end
warning?(msg) click to toggle source
# File lib/single_cov.rb, line 352
def warning?(msg)
  msg.end_with?("?")
end
without_ignored_prefixes(file_part) { || ... } click to toggle source

file_part is modified during yield so we have to make sure to also modify in place

# File lib/single_cov.rb, line 443
def without_ignored_prefixes(file_part)
  folders = file_part.split('/')
  return yield unless PREFIXES_TO_IGNORE.include?(folders.first)

  prefix = folders.shift
  file_part.replace folders.join('/')

  yield

  file_part[0...0] = "#{prefix}/"
end