File: coverage.rb

package info (click to toggle)
ruby-rspec-puppet 4.0.2%2Bds-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,444 kB
  • sloc: ruby: 6,377; makefile: 6
file content (361 lines) | stat: -rw-r--r-- 10,082 bytes parent folder | download
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