1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
|
# frozen_string_literal: true
require 'tmpdir'
require 'digest'
require 'json'
require 'fileutils'
unless defined?(RSpec::Core::NullReporter)
module RSpec::Core
class NullReporter
def self.method_missing(*)
# ignore
end
private_class_method :method_missing
end
end
end
module RSpec::Puppet
class Coverage
attr_accessor :filters, :filters_regex
class << self
extend Forwardable
delegated_methods = %i[
instance
add
cover!
report!
filters
filters_regex
add_filter
add_filter_regex
add_from_catalog
results
]
def_delegators(*delegated_methods)
attr_writer :instance
def instance
@instance ||= new
end
end
def initialize
@collection = {}
@filters = ['Stage[main]', 'Class[Settings]', 'Class[main]', 'Node[default]']
@filters_regex = []
end
def save_results
slug = "#{Digest::MD5.hexdigest(Dir.pwd)}-#{Process.pid}"
File.open(File.join(Dir.tmpdir, "rspec-puppet-filter-#{slug}"), 'w+') do |f|
f.puts @filters.to_json
end
File.open(File.join(Dir.tmpdir, "rspec-puppet-filter_regex-#{slug}"), 'w+') do |f|
f.puts @filters_regex.to_json
end
File.open(File.join(Dir.tmpdir, "rspec-puppet-coverage-#{slug}"), 'w+') do |f|
f.puts @collection.to_json
end
end
def merge_results
pattern = File.join(Dir.tmpdir, "rspec-puppet-coverage-#{Digest::MD5.hexdigest(Dir.pwd)}-*")
Dir[pattern].each do |result_file|
load_results(result_file)
FileUtils.rm(result_file)
end
end
def merge_filters
pattern = File.join(Dir.tmpdir, "rspec-puppet-filter-#{Digest::MD5.hexdigest(Dir.pwd)}-*")
regex_filter_pattern = File.join(Dir.tmpdir, "rspec-puppet-filter_regex-#{Digest::MD5.hexdigest(Dir.pwd)}-*")
Dir[pattern].each do |result_file|
load_filters(result_file)
FileUtils.rm(result_file)
end
Dir[regex_filter_pattern].each do |result_file|
load_filters_regex(result_file)
FileUtils.rm(result_file)
end
end
def load_results(path)
saved_results = JSON.parse(File.read(path))
saved_results.each do |resource, data|
add(resource)
cover!(resource) if data['touched']
end
end
def load_filters(path)
saved_filters = JSON.parse(File.read(path))
saved_filters.each do |resource|
@filters << resource
@collection.delete(resource) if @collection.key?(resource)
end
end
def load_filters_regex(path)
saved_regex_filters = JSON.parse(File.read(path))
saved_regex_filters.each do |pattern|
regex = Regexp.new(pattern)
@filters_regex << regex
@collection.delete_if { |resource, _| resource =~ regex }
end
end
def add(resource)
return unless !exists?(resource) && !filtered?(resource)
@collection[resource.to_s] = ResourceWrapper.new(resource)
end
def add_filter(type, title)
type = capitalize_name(type)
title = capitalize_name(title) if type == 'Class'
@filters << "#{type}[#{title}]"
end
def add_filter_regex(type, pattern)
raise ArgumentError, 'pattern argument must be a Regexp' unless pattern.is_a?(Regexp)
type = capitalize_name(type)
# avoid recompiling the regular expression during processing
src = pattern.source
# switch from anchors to wildcards since it is embedded into a larger pattern
src = if src.start_with?('\\A', '^')
src.gsub(/\A(?:\\A|\^)/, '')
else
# no anchor at the start
".*#{src}"
end
# match an even number of backslashes before the anchor - this indicates that the anchor was not escaped
# note the necessity for the negative lookbehind `(?<!)` to assert that there is no backslash before this
src = if /(?<!\\)(\\\\)*(?:\\[zZ]|\$)\z/.match?(src)
src.gsub(/(?:\\[zZ]|\$)\z/, '')
else
# no anchor at the end
"#{src}.*"
end
@filters_regex << /\A#{Regexp.escape(type)}\[#{src}\]\z/
end
# add all resources from catalog declared in module test_module
def add_from_catalog(catalog, test_module)
coverable_resources = catalog.to_a.reject do |resource|
!test_module.nil? && filter_resource?(resource, test_module)
end
coverable_resources.each do |resource|
add(resource)
end
end
def filtered?(resource)
return true if filters.include?(resource.to_s)
return true if filters_regex.any? { |f| resource.to_s =~ f }
false
end
def cover!(resource)
return unless !filtered?(resource) && (wrapper = find(resource))
wrapper.touch!
end
def report!(coverage_desired = nil)
if parallel_tests?
require 'parallel_tests'
if ParallelTests.first_process?
ParallelTests.wait_for_other_processes_to_finish
run_report(coverage_desired)
else
save_results
end
else
run_report(coverage_desired)
end
end
def parallel_tests?
!!ENV['TEST_ENV_NUMBER']
end
def run_report(coverage_desired = nil)
if parallel_tests?
merge_filters
merge_results
end
report = results
coverage_test(coverage_desired, report)
puts "\n\nCoverage Report:\n\n#{report[:text]}"
end
def coverage_test(coverage_desired, report)
coverage_actual = report[:coverage]
coverage_desired ||= 0
if coverage_desired.is_a?(Numeric) && coverage_desired.to_f <= 100.00 && coverage_desired.to_f >= 0.0
coverage_test = RSpec.describe('Code coverage')
coverage_results = coverage_test.example("must cover at least #{coverage_desired}% of resources") do
expect(coverage_actual.to_f).to be >= coverage_desired.to_f
end
coverage_test.run(RSpec.configuration.reporter)
status = if coverage_results.execution_result.respond_to?(:status)
coverage_results.execution_result.status
else
coverage_results.execution_result[:status]
end
if status == :failed
RSpec.world.non_example_failure = true
RSpec.world.wants_to_quit = true
end
# This is not available on RSpec 2.x
if coverage_results.execution_result.respond_to?(:pending_message)
coverage_results.execution_result.pending_message = report[:text]
end
else
puts "The desired coverage must be 0 <= x <= 100, not '#{coverage_desired.inspect}'"
end
end
def results
report = {}
@collection.delete_if { |name, _| filtered?(name) }
report[:total] = @collection.size
report[:touched] = @collection.count { |_, resource| resource.touched? }
report[:untouched] = report[:total] - report[:touched]
coverage = report[:total].to_f.positive? ? ((report[:touched].to_f / report[:total]) * 100) : 100.0
report[:coverage] = '%5.2f' % coverage
report[:resources] = Hash[*@collection.map do |name, wrapper|
[name, wrapper.to_hash]
end.flatten]
text = [
"Total resources: #{report[:total]}",
"Touched resources: #{report[:touched]}",
"Resource coverage: #{report[:coverage]}%"
]
if (report[:untouched]).positive?
text += ['', 'Untouched resources:']
untouched_resources = report[:resources].reject { |_, r| r[:touched] }
text += untouched_resources.map { |name, _| " #{name}" }.sort
end
report[:text] = text.join("\n")
report
end
private
# Should this resource be excluded from coverage reports?
#
# The resource is not included in coverage reports if any of the conditions hold:
#
# * The resource has been explicitly filtered out.
# * Examples: autogenerated resources such as 'Stage[main]'
# * The resource is a class but does not belong to the module under test.
# * Examples: Class dependencies included from a fixture module
# * The resource was declared in a file outside of the test module or site.pp
# * Examples: Resources declared in a dependency of this module.
#
# @param resource [Puppet::Resource] The resource that may be filtered
# @param test_module [String] The name of the module under test
# @return [true, false]
def filter_resource?(resource, test_module)
return true if filtered?(resource)
if resource.type == 'Class'
module_name = resource.title.split('::').first.downcase
return true if module_name != test_module
end
if resource.file
paths = module_paths(test_module)
return true unless paths.any? { |path| resource.file.include?(path) }
end
false
end
# Find all paths that may contain testable resources for a module.
#
# @return [Array<String>]
def module_paths(test_module)
adapter = RSpec.configuration.adapter
paths = adapter.modulepath.map do |dir|
File.join(dir, test_module, 'manifests')
end
paths << adapter.manifest if adapter.manifest
paths
end
def find(resource)
@collection[resource.to_s]
end
def exists?(resource)
!find(resource).nil?
end
def capitalize_name(name)
name.split('::').map(&:capitalize).join('::')
end
class ResourceWrapper
attr_reader :resource
def initialize(resource = nil)
@resource = resource
end
def to_s
@resource.to_s
end
def to_hash
{
touched: touched?
}
end
def to_json(opts)
to_hash.to_json(opts)
end
def touch!
@touched = true
end
def touched?
!!@touched
end
end
end
end
|