File: result_merger.rb

package info (click to toggle)
ruby-simplecov 0.22.0-4
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 1,512 kB
  • sloc: ruby: 5,550; makefile: 10
file content (194 lines) | stat: -rw-r--r-- 6,451 bytes parent folder | download | duplicates (4)
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
# frozen_string_literal: true

require "json"

module SimpleCov
  #
  # Singleton that is responsible for caching, loading and merging
  # SimpleCov::Results into a single result for coverage analysis based
  # upon multiple test suites.
  #
  module ResultMerger
    class << self
      # The path to the .resultset.json cache file
      def resultset_path
        File.join(SimpleCov.coverage_path, ".resultset.json")
      end

      def resultset_writelock
        File.join(SimpleCov.coverage_path, ".resultset.json.lock")
      end

      def merge_and_store(*file_paths, ignore_timeout: false)
        result = merge_results(*file_paths, ignore_timeout: ignore_timeout)
        store_result(result) if result
        result
      end

      def merge_results(*file_paths, ignore_timeout: false)
        # It is intentional here that files are only read in and parsed one at a time.
        #
        # In big CI setups you might deal with 100s of CI jobs and each one producing Megabytes
        # of data. Reading them all in easily produces Gigabytes of memory consumption which
        # we want to avoid.
        #
        # For similar reasons a SimpleCov::Result is only created in the end as that'd create
        # even more data especially when it also reads in all source files.
        initial_memo = valid_results(file_paths.shift, ignore_timeout: ignore_timeout)

        command_names, coverage = file_paths.reduce(initial_memo) do |memo, file_path|
          merge_coverage(memo, valid_results(file_path, ignore_timeout: ignore_timeout))
        end

        create_result(command_names, coverage)
      end

      def valid_results(file_path, ignore_timeout: false)
        results = parse_file(file_path)
        merge_valid_results(results, ignore_timeout: ignore_timeout)
      end

      def parse_file(path)
        data = read_file(path)
        parse_json(data)
      end

      def read_file(path)
        return unless File.exist?(path)

        data = File.read(path)
        return if data.nil? || data.length < 2

        data
      end

      def parse_json(content)
        return {} unless content

        JSON.parse(content) || {}
      rescue StandardError
        warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
        {}
      end

      def merge_valid_results(results, ignore_timeout: false)
        results = results.select { |_command_name, data| within_merge_timeout?(data) } unless ignore_timeout

        command_plus_coverage = results.map do |command_name, data|
          [[command_name], adapt_result(data.fetch("coverage"))]
        end

        # one file itself _might_ include multiple test runs
        merge_coverage(*command_plus_coverage)
      end

      def within_merge_timeout?(data)
        time_since_result_creation(data) < SimpleCov.merge_timeout
      end

      def time_since_result_creation(data)
        Time.now - Time.at(data.fetch("timestamp"))
      end

      def create_result(command_names, coverage)
        return nil unless coverage

        command_name = command_names.reject(&:empty?).sort.join(", ")
        SimpleCov::Result.new(coverage, command_name: command_name)
      end

      def merge_coverage(*results)
        return [[""], nil] if results.empty?
        return results.first if results.size == 1

        results.reduce do |(memo_command, memo_coverage), (command, coverage)|
          # timestamp is dropped here, which is intentional (we merge it, it gets a new time stamp as of now)
          merged_coverage = Combine.combine(Combine::ResultsCombiner, memo_coverage, coverage)
          merged_command = memo_command + command

          [merged_command, merged_coverage]
        end
      end

      #
      # Gets all SimpleCov::Results stored in resultset, merges them and produces a new
      # SimpleCov::Result with merged coverage data and the command_name
      # for the result consisting of a join on all source result's names
      def merged_result
        # conceptually this is just doing `merge_results(resultset_path)`
        # it's more involved to make syre `synchronize_resultset` is only used around reading
        resultset_hash = read_resultset
        command_names, coverage = merge_valid_results(resultset_hash)

        create_result(command_names, coverage)
      end

      def read_resultset
        resultset_content =
          synchronize_resultset do
            read_file(resultset_path)
          end

        parse_json(resultset_content)
      end

      # Saves the given SimpleCov::Result in the resultset cache
      def store_result(result)
        synchronize_resultset do
          # Ensure we have the latest, in case it was already cached
          new_resultset = read_resultset

          # A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
          command_name, data = result.to_hash.first
          new_resultset[command_name] = data
          File.open(resultset_path, "w+") do |f_|
            f_.puts JSON.pretty_generate(new_resultset)
          end
        end
        true
      end

      # Ensure only one process is reading or writing the resultset at any
      # given time
      def synchronize_resultset
        # make it reentrant
        return yield if defined?(@resultset_locked) && @resultset_locked

        begin
          @resultset_locked = true
          File.open(resultset_writelock, "w+") do |f|
            f.flock(File::LOCK_EX)
            yield
          end
        ensure
          @resultset_locked = false
        end
      end

      # We changed the format of the raw result data in simplecov, as people are likely
      # to have "old" resultsets lying around (but not too old so that they're still
      # considered we can adapt them).
      # See https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
      def adapt_result(result)
        if pre_simplecov_0_18_result?(result)
          adapt_pre_simplecov_0_18_result(result)
        else
          result
        end
      end

      # pre 0.18 coverage data pointed from file directly to an array of line coverage
      def pre_simplecov_0_18_result?(result)
        _key, data = result.first

        data.is_a?(Array)
      end

      def adapt_pre_simplecov_0_18_result(result)
        result.transform_values do |line_coverage_data|
          {"lines" => line_coverage_data}
        end
      end
    end
  end
end