rspec/rspec-core

View on GitHub
lib/rspec/core/example_status_persister.rb

Summary

Maintainability
A
25 mins
Test Coverage
RSpec::Support.require_rspec_support "directory_maker"

module RSpec
  module Core
    # Persists example ids and their statuses so that we can filter
    # to just the ones that failed the last time they ran.
    # @private
    class ExampleStatusPersister
      def self.load_from(file_name)
        return [] unless File.exist?(file_name)
        ExampleStatusParser.parse(File.read(file_name))
      end

      def self.persist(examples, file_name)
        new(examples, file_name).persist
      end

      def initialize(examples, file_name)
        @examples  = examples
        @file_name = file_name
      end

      def persist
        RSpec::Support::DirectoryMaker.mkdir_p(File.dirname(@file_name))
        File.open(@file_name, File::RDWR | File::CREAT) do |f|
          # lock the file while reading / persisting to avoid a race
          # condition where parallel or unrelated spec runs race to
          # update the same file
          f.flock(File::LOCK_EX)
          unparsed_previous_runs = f.read
          f.rewind
          f.write(dump_statuses(unparsed_previous_runs))
          f.flush
          f.truncate(f.pos)
        end
      end

    private

      def dump_statuses(unparsed_previous_runs)
        statuses_from_previous_runs = ExampleStatusParser.parse(unparsed_previous_runs)
        merged_statuses = ExampleStatusMerger.merge(statuses_from_this_run, statuses_from_previous_runs)
        ExampleStatusDumper.dump(merged_statuses)
      end

      def statuses_from_this_run
        @examples.map do |ex|
          result = ex.execution_result

          {
            :example_id => ex.id,
            :status     => result.status ? result.status.to_s : Configuration::UNKNOWN_STATUS,
            :run_time   => result.run_time ? Formatters::Helpers.format_duration(result.run_time) : ""
          }
        end
      end
    end

    # Merges together a list of example statuses from this run
    # and a list from previous runs (presumably loaded from disk).
    # Each example status object is expected to be a hash with
    # at least an `:example_id` and a `:status` key. Examples that
    # were loaded but not executed (due to filtering, `--fail-fast`
    # or whatever) should have a `:status` of `UNKNOWN_STATUS`.
    #
    # This willl produce a new list that:
    #   - Will be missing examples from previous runs that we know for sure
    #     no longer exist.
    #   - Will have the latest known status for any examples that either
    #     definitively do exist or may still exist.
    #   - Is sorted by file name and example definition order, so that
    #     the saved file is easily scannable if users want to inspect it.
    # @private
    class ExampleStatusMerger
      def self.merge(this_run, from_previous_runs)
        new(this_run, from_previous_runs).merge
      end

      def initialize(this_run, from_previous_runs)
        @this_run           = hash_from(this_run)
        @from_previous_runs = hash_from(from_previous_runs)
        @file_exists_cache  = Hash.new { |hash, file| hash[file] = File.exist?(file) }
      end

      def merge
        delete_previous_examples_that_no_longer_exist

        @this_run.merge(@from_previous_runs) do |_ex_id, new, old|
          new.fetch(:status) == Configuration::UNKNOWN_STATUS ? old : new
        end.values.sort_by(&method(:sort_value_from))
      end

    private

      def hash_from(example_list)
        example_list.inject({}) do |hash, example|
          hash[example.fetch(:example_id)] = example
          hash
        end
      end

      def delete_previous_examples_that_no_longer_exist
        @from_previous_runs.delete_if do |ex_id, _|
          example_must_no_longer_exist?(ex_id)
        end
      end

      def example_must_no_longer_exist?(ex_id)
        # Obviously, it exists if it was loaded for this spec run...
        return false if @this_run.key?(ex_id)

        spec_file = spec_file_from(ex_id)

        # `this_run` includes examples that were loaded but not executed.
        # Given that, if the spec file for this example was loaded,
        # but the id does not still exist, it's safe to assume that
        # the example must no longer exist.
        return true if loaded_spec_files.include?(spec_file)

        # The example may still exist as long as the file exists...
        !@file_exists_cache[spec_file]
      end

      def loaded_spec_files
        @loaded_spec_files ||= Set.new(@this_run.keys.map(&method(:spec_file_from)))
      end

      def spec_file_from(ex_id)
        ex_id.split("[").first
      end

      def sort_value_from(example)
        file, scoped_id = Example.parse_id(example.fetch(:example_id))
        [file, *scoped_id.split(":").map(&method(:Integer))]
      end
    end

    # Dumps a list of hashes in a pretty, human readable format
    # for later parsing. The hashes are expected to have symbol
    # keys and string values, and each hash should have the same
    # set of keys.
    # @private
    class ExampleStatusDumper
      def self.dump(examples)
        new(examples).dump
      end

      def initialize(examples)
        @examples = examples
      end

      def dump
        return nil if @examples.empty?
        (formatted_header_rows + formatted_value_rows).join("\n") << "\n"
      end

    private

      def formatted_header_rows
        @formatted_header_rows ||= begin
          dividers = column_widths.map { |w| "-" * w }
          [formatted_row_from(headers.map(&:to_s)), formatted_row_from(dividers)]
        end
      end

      def formatted_value_rows
        @foramtted_value_rows ||= rows.map do |row|
          formatted_row_from(row)
        end
      end

      def rows
        @rows ||= @examples.map { |ex| ex.values_at(*headers) }
      end

      def formatted_row_from(row_values)
        padded_values = row_values.each_with_index.map do |value, index|
          value.ljust(column_widths[index])
        end

        padded_values.join(" | ") << " |"
      end

      def headers
        @headers ||= @examples.first.keys
      end

      def column_widths
        @column_widths ||= begin
          value_sets = rows.transpose

          headers.each_with_index.map do |header, index|
            values = value_sets[index] << header.to_s
            values.map(&:length).max
          end
        end
      end
    end

    # Parses a string that has been previously dumped by ExampleStatusDumper.
    # Note that this parser is a bit naive in that it does a simple split on
    # "\n" and " | ", with no concern for handling escaping. For now, that's
    # OK because the values we plan to persist (example id, status, and perhaps
    # example duration) are highly unlikely to contain "\n" or " | " -- after
    # all, who puts those in file names?
    # @private
    class ExampleStatusParser
      def self.parse(string)
        new(string).parse
      end

      def initialize(string)
        @header_line, _, *@row_lines = string.lines.to_a
      end

      def parse
        @row_lines.map { |line| parse_row(line) }
      end

    private

      def parse_row(line)
        Hash[headers.zip(split_line(line))]
      end

      def headers
        @headers ||= split_line(@header_line).grep(/\S/).map(&:to_sym)
      end

      def split_line(line)
        line.split(/\s+\|\s+?/, -1)
      end
    end
  end
end