Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ Subclass `FixtureKit::Adapter` and implement:

`#identifier_for(identifier)`
- Receives non-string fixture identifier and returns normalized String identifier.
- `_anonymous/` prefixing is applied by `FixtureKit::Cache`.
- `_anonymous/` prefixing and the declaration-site digest suffix are applied by `FixtureKit::Cache`.

Adapter initialization:

Expand Down Expand Up @@ -332,12 +332,21 @@ Cache file path format:

Identifier behavior:
- Named fixture: identifier is the fixture name string.
- Anonymous fixture: identifier is `_anonymous/<adapter-normalized-scope>`.
- Anonymous fixture: identifier is `_anonymous/<adapter-normalized-scope>.<digest>`.

The digest is the first 12 hex characters of a SHA-256 of the declaration site
(the `fixture` block's file and line, plus its `extends:` target). The scope name
alone is not unique: test frameworks derive it from the group description, which
drops non-alphanumeric characters, and test runners that load specs in batches
call `RSpec::Core::World#reset` between them, which resets the counter RSpec uses
to disambiguate duplicate descriptions. Without the digest, two spec files
sharing a top-level description would share one cache entry, and the second
fixture to generate would mount the first one's records.

Examples:
- Named: `teams/basic` -> `tmp/cache/fixture_kit/teams/basic.json`
- Anonymous RSpec: `_anonymous/foo/with_fixture_kit/hello`
- Anonymous Minitest: `_anonymous/my_feature_test`
- Anonymous RSpec: `_anonymous/foo/with_fixture_kit/hello.3f2a9c1d4b8e`
- Anonymous Minitest: `_anonymous/my_feature_test.7c1b0e5a9d24`

## Runtime API in Tests

Expand Down
1 change: 1 addition & 0 deletions lib/fixture_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class DuplicateNameError < Error; end
class InvalidFixtureDeclaration < Error; end
class MultipleFixtures < Error; end
class CacheMissingError < Error; end
class CacheIdentifierCollision < Error; end
class FixtureDefinitionNotFound < Error; end
class RunnerAlreadyStartedError < Error; end
class CircularFixtureInheritance < Error; end
Expand Down
26 changes: 25 additions & 1 deletion lib/fixture_kit/cache.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# frozen_string_literal: true

require "digest"

module FixtureKit
class Cache
ANONYMOUS_DIRECTORY = "_anonymous"
DIGEST_LENGTH = 12

include ConfigurationHelper

Expand All @@ -22,7 +25,8 @@ def identifier
if raw_identifier.is_a?(String)
raw_identifier
else
File.join(ANONYMOUS_DIRECTORY, FixtureKit.runner.adapter.identifier_for(raw_identifier))
normalized_scope = FixtureKit.runner.adapter.identifier_for(raw_identifier)
File.join(ANONYMOUS_DIRECTORY, "#{normalized_scope}.#{definition_digest}")
end
end
end
Expand Down Expand Up @@ -62,6 +66,26 @@ def save

private

# The scope name alone does not identify a declaration site. Test
# frameworks derive it from the group description, which is lossy -- RSpec
# strips every non-alphanumeric character, so `Foo::Bar` and `"Foo Bar"`
# both become `FooBar` -- and, worse, not even unique within a process:
# test runners that load specs in batches call RSpec::Core::World#reset
# between them, which drops the constants RSpec disambiguates against, so
# the first group of a given description in *every* batch takes the
# unsuffixed name. The cache directory is cleared once per process, so
# without the digest two spec files sharing a top-level description share
# this entry, and the second fixture to generate silently mounts the
# first's records.
#
# Joined to the scope with a dot rather than an underscore: scope names are
# snake_case, so an underscore leaves no boundary between the two, while a
# dot cannot appear in one -- RSpec strips non-alphanumerics from the group
# description before it is underscored.
def definition_digest
Digest::SHA256.hexdigest(fixture.definition.fingerprint)[0, DIGEST_LENGTH]
end

def evaluate(coders, context, data = {}, &block)
if coders.empty?
fixture.definition.evaluate(context, parent: fixture.parent&.mount)
Expand Down
11 changes: 11 additions & 0 deletions lib/fixture_kit/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ def path
@definition.source_location.first
end

def location
file, line = @definition.source_location
"#{file}:#{line}"
end

# Two definitions sharing a fingerprint evaluate the same block against the
# same parent, so they are interchangeable and may share a cache entry.
def fingerprint
"#{location}:#{extends}"
end

def evaluate(context, parent: nil)
context.singleton_class.prepend(mixin(parent))
context.instance_exec(&@definition)
Expand Down
2 changes: 2 additions & 0 deletions lib/fixture_kit/fixture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ def initialize(identifier, definition)
end

def generate(force: false)
FixtureKit.runner.registry.claim_cache_identifier(self)

return if @cache.exists? && !force

parent&.generate
Expand Down
18 changes: 18 additions & 0 deletions lib/fixture_kit/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def initialize
@declarations = {}
@fixtures = {}
@resolving = []
@cache_owners = {}
end

def add(name_or_definition, scope = nil)
Expand All @@ -34,6 +35,23 @@ def fixtures
@fixtures.values
end

# Fixtures sharing a cache identifier share a cache file: the second one to
# generate finds the file already written, skips generation, and mounts the
# other's records. That is only safe when both declarations are the same, so
# anything else has to fail here -- otherwise it surfaces as a confusing
# NoMethodError on Repository, or not at all when both expose the same names.
# Identifiers are derived from the declaration site, so this is a backstop
# against a digest collision or a regression in that derivation.
def claim_cache_identifier(fixture)
owner = (@cache_owners[fixture.cache.identifier] ||= fixture)
return if owner.equal?(fixture)
return if owner.definition.fingerprint == fixture.definition.fingerprint

raise FixtureKit::CacheIdentifierCollision,
"fixtures declared at #{owner.definition.location} and #{fixture.definition.location} " \
"both resolve to the cache identifier '#{fixture.cache.identifier}'"
end

private

def fetch_named_fixture(name)
Expand Down
4 changes: 3 additions & 1 deletion spec/dummy/script/rspec_queue_mode_sim.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ module QueueModeSimulation

BATCHES = [
["spec/integration/queue_mode/batch_one.rb"],
["spec/integration/queue_mode/batch_two_late.rb"]
["spec/integration/queue_mode/batch_two_late.rb"],
["spec/integration/queue_mode/batch_three_collision.rb"],
["spec/integration/queue_mode/batch_four_collision.rb"]
]

def run
Expand Down
10 changes: 5 additions & 5 deletions spec/dummy/spec/integration/fixture_kit_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,11 @@ module FixtureKitIntegrationTimeHelpers
expect(fixture.anonymous_user.email).to eq("anonymous.fixture@example.com")

normalized_scope = self.class.to_s.sub(/\ARSpec::ExampleGroups::/, "")
cache_file = File.join(
FixtureKit.runner.configuration.cache_path,
"_anonymous/#{ActiveSupport::Inflector.underscore(normalized_scope)}.json"
)
expect(File.exist?(cache_file)).to be(true)
slug = ActiveSupport::Inflector.underscore(normalized_scope)
cache_files = Dir.glob(
File.join(FixtureKit.runner.configuration.cache_path, "_anonymous/**/*.json")
).grep(%r{/#{Regexp.escape(slug)}\.[0-9a-f]{12}\.json\z})
expect(cache_files.size).to eq(1)

puts "FKIT_ASSERT:ANONYMOUS_FIXTURE"
puts "FKIT_ASSERT:ANONYMOUS_CACHE_PATH"
Expand Down
18 changes: 18 additions & 0 deletions spec/dummy/spec/integration/queue_mode/batch_four_collision.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

require "rails_helper"

# Same description as batch_three_collision.rb. See the comment there.
RSpec.describe "Queue mode colliding description" do
fixture do
fourth = User.create!(name: "Queue Fourth", email: "queue.fourth@example.com")

expose(fourth: fourth)
end

it "mounts its own anonymous fixture rather than the earlier batch's" do
expect(fixture.fourth.name).to eq("Queue Fourth")

puts "FKIT_ASSERT:QMODE_BATCH4_RAN"
end
end
21 changes: 21 additions & 0 deletions spec/dummy/spec/integration/queue_mode/batch_three_collision.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require "rails_helper"

# Declares the same top-level description as batch_four_collision.rb, so both
# groups are named RSpec::ExampleGroups::QueueModeCollidingDescription: the
# constant is removed by RSpec::Core::World#reset between batches, so the
# duplicate-name counter never disambiguates them.
RSpec.describe "Queue mode colliding description" do
fixture do
third = User.create!(name: "Queue Third", email: "queue.third@example.com")

expose(third: third)
end

it "mounts its own anonymous fixture" do
expect(fixture.third.name).to eq("Queue Third")

puts "FKIT_ASSERT:QMODE_BATCH3_RAN"
end
end
10 changes: 5 additions & 5 deletions spec/dummy/test/integration/fixture_kit_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,11 @@ class FixtureKitAnonymousFixtureIntegrationTest < ActiveSupport::TestCase
assert_equal 1, @anonymous_user_count_in_setup
assert_equal "anonymous.fixture@example.com", fixture.anonymous_user.email

cache_file = File.join(
FixtureKit.runner.configuration.cache_path,
"_anonymous/#{ActiveSupport::Inflector.underscore(self.class.name)}.json"
)
assert File.exist?(cache_file)
slug = ActiveSupport::Inflector.underscore(self.class.name)
cache_files = Dir.glob(
File.join(FixtureKit.runner.configuration.cache_path, "_anonymous/**/*.json")
).grep(%r{/#{Regexp.escape(slug)}\.[0-9a-f]{12}\.json\z})
assert_equal 1, cache_files.size

puts "FKIT_ASSERT:ANONYMOUS_FIXTURE"
puts "FKIT_ASSERT:ANONYMOUS_CACHE_PATH"
Expand Down
2 changes: 2 additions & 0 deletions spec/integration/dummy_app_queue_mode_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def run_queue_mode_simulation
"FKIT_ASSERT:QMODE_LATE_CACHE_ABSENT_AFTER_BATCH1",
"FKIT_ASSERT:QMODE_BATCH2_RAN",
"FKIT_ASSERT:QMODE_LATE_CACHE_GENERATED",
"FKIT_ASSERT:QMODE_BATCH3_RAN",
"FKIT_ASSERT:QMODE_BATCH4_RAN",
"FKIT_ASSERT:QMODE_COMPLETE"
]

Expand Down
31 changes: 31 additions & 0 deletions spec/unit/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,37 @@
end
end

describe "#location" do
it "returns the file and line where the definition block was defined" do
definition = described_class.new {}

expect(definition.location).to eq("#{__FILE__}:#{__LINE__ - 2}")
end
end

describe "#fingerprint" do
it "differs between declarations on different lines" do
first = described_class.new {}
second = described_class.new {}

expect(first.fingerprint).not_to eq(second.fingerprint)
end

it "differs between declarations extending different parents" do
definition_for = ->(extends) { described_class.new(extends: extends) {} }

expect(definition_for.call("teams/basic").fingerprint).not_to eq(
definition_for.call("teams/admin").fingerprint
)
end

it "matches for two definitions built from the same declaration" do
definition_for = -> { described_class.new(extends: "teams/basic") {} }

expect(definition_for.call.fingerprint).to eq(definition_for.call.fingerprint)
end
end

describe "#expose" do
it "raises when the same name is exposed twice" do
alice = User.create!(name: "Alice", email: "alice-duplicate@example.com")
Expand Down
Loading
Loading