lib/rspec/core/example_status_persister.rb
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