# 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