375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""Tests for lockfile.py - Lock file functionality."""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
import tempfile
|
|
import yaml
|
|
|
|
from cmdforge.lockfile import (
|
|
Lockfile, LockedPackage, LockfileMetadata,
|
|
generate_lockfile, verify_lockfile,
|
|
compute_file_hash
|
|
)
|
|
from cmdforge.hash_utils import compute_yaml_hash
|
|
|
|
|
|
class TestLockedPackage:
|
|
"""Tests for LockedPackage dataclass."""
|
|
|
|
def test_owner_and_name_qualified(self):
|
|
pkg = LockedPackage(
|
|
name="official/summarize",
|
|
version="1.0.0",
|
|
constraint="^1.0.0",
|
|
integrity="sha256:abc",
|
|
source="registry",
|
|
direct=True
|
|
)
|
|
assert pkg.owner == "official"
|
|
assert pkg.tool_name == "summarize"
|
|
|
|
def test_owner_and_name_unqualified(self):
|
|
pkg = LockedPackage(
|
|
name="summarize",
|
|
version="1.0.0",
|
|
constraint="*",
|
|
integrity="sha256:abc",
|
|
source="local",
|
|
direct=True
|
|
)
|
|
assert pkg.owner == ""
|
|
assert pkg.tool_name == "summarize"
|
|
|
|
def test_required_by_default_empty(self):
|
|
pkg = LockedPackage(
|
|
name="test",
|
|
version="1.0.0",
|
|
constraint="*",
|
|
integrity="",
|
|
source="registry",
|
|
direct=True
|
|
)
|
|
assert pkg.required_by == []
|
|
|
|
|
|
class TestLockfile:
|
|
"""Tests for Lockfile dataclass."""
|
|
|
|
def test_save_and_load(self, tmp_path):
|
|
lock = Lockfile(
|
|
metadata=LockfileMetadata(
|
|
generated_at="2026-01-26T00:00:00Z",
|
|
cmdforge_version="1.5.0",
|
|
manifest_hash="sha256:test123",
|
|
platform="linux",
|
|
python_version="3.12.0"
|
|
),
|
|
packages={
|
|
"official/summarize": LockedPackage(
|
|
name="official/summarize",
|
|
version="1.2.3",
|
|
constraint="^1.0.0",
|
|
integrity="sha256:abc123",
|
|
source="registry",
|
|
direct=True
|
|
)
|
|
}
|
|
)
|
|
|
|
lock_path = tmp_path / "cmdforge.lock"
|
|
lock.save(lock_path)
|
|
|
|
loaded = Lockfile.load(lock_path)
|
|
assert loaded is not None
|
|
assert "official/summarize" in loaded.packages
|
|
assert loaded.packages["official/summarize"].version == "1.2.3"
|
|
assert loaded.packages["official/summarize"].integrity == "sha256:abc123"
|
|
# Verify optional fields round-trip
|
|
assert loaded.packages["official/summarize"].required_by == []
|
|
assert loaded.packages["official/summarize"].path is None
|
|
|
|
def test_load_missing_file(self, tmp_path):
|
|
lock_path = tmp_path / "nonexistent.lock"
|
|
loaded = Lockfile.load(lock_path)
|
|
assert loaded is None
|
|
|
|
def test_is_stale_when_manifest_changed(self, tmp_path):
|
|
manifest_path = tmp_path / "cmdforge.yaml"
|
|
manifest_path.write_text("name: test\ndependencies: []")
|
|
|
|
original_hash = compute_file_hash(manifest_path)
|
|
|
|
lock = Lockfile(
|
|
metadata=LockfileMetadata(
|
|
generated_at="2026-01-26T00:00:00Z",
|
|
cmdforge_version="1.5.0",
|
|
manifest_hash=original_hash,
|
|
platform="linux",
|
|
python_version="3.12.0"
|
|
)
|
|
)
|
|
|
|
assert not lock.is_stale(manifest_path)
|
|
|
|
# Modify manifest
|
|
manifest_path.write_text("name: test\ndependencies:\n - official/foo")
|
|
|
|
assert lock.is_stale(manifest_path)
|
|
|
|
def test_is_stale_without_metadata(self):
|
|
lock = Lockfile()
|
|
assert lock.is_stale()
|
|
|
|
def test_get_package(self):
|
|
lock = Lockfile(
|
|
packages={
|
|
"official/summarize": LockedPackage(
|
|
name="official/summarize",
|
|
version="1.0.0",
|
|
constraint="*",
|
|
integrity="",
|
|
source="registry",
|
|
direct=True
|
|
)
|
|
}
|
|
)
|
|
assert lock.get_package("official/summarize") is not None
|
|
assert lock.get_package("nonexistent") is None
|
|
|
|
def test_lockfile_version(self):
|
|
lock = Lockfile()
|
|
assert lock.lockfile_version == 1
|
|
|
|
def test_save_includes_header(self, tmp_path):
|
|
lock = Lockfile()
|
|
lock_path = tmp_path / "cmdforge.lock"
|
|
lock.save(lock_path)
|
|
|
|
content = lock_path.read_text()
|
|
assert "# cmdforge.lock" in content
|
|
assert "# Auto-generated" in content
|
|
assert "do not edit manually" in content
|
|
|
|
|
|
class TestHashFunctions:
|
|
"""Tests for hash functions."""
|
|
|
|
def test_compute_file_hash(self, tmp_path):
|
|
test_file = tmp_path / "test.txt"
|
|
test_file.write_text("hello world")
|
|
|
|
hash1 = compute_file_hash(test_file)
|
|
assert hash1.startswith("sha256:")
|
|
|
|
# Same content = same hash
|
|
hash2 = compute_file_hash(test_file)
|
|
assert hash1 == hash2
|
|
|
|
# Different content = different hash
|
|
test_file.write_text("different")
|
|
hash3 = compute_file_hash(test_file)
|
|
assert hash1 != hash3
|
|
|
|
def test_compute_yaml_hash_normalized(self):
|
|
"""Test that YAML hash normalizes content (sorted keys, excluded fields)."""
|
|
# Same logical content, different formatting
|
|
yaml1 = "name: test\ndescription: foo"
|
|
yaml2 = "description: foo\nname: test" # Different key order
|
|
|
|
# Should produce same hash (normalized)
|
|
assert compute_yaml_hash(yaml1) == compute_yaml_hash(yaml2)
|
|
|
|
# Different content = different hash
|
|
yaml3 = "name: test\ndescription: bar"
|
|
assert compute_yaml_hash(yaml1) != compute_yaml_hash(yaml3)
|
|
|
|
# registry_hash field should be excluded
|
|
yaml4 = "name: test\ndescription: foo\nregistry_hash: sha256:abc"
|
|
assert compute_yaml_hash(yaml1) == compute_yaml_hash(yaml4)
|
|
|
|
|
|
class TestGenerateLockfile:
|
|
"""Tests for generate_lockfile function."""
|
|
|
|
def test_generates_complete_lockfile(self, tmp_path):
|
|
"""Test that lock file includes all graph nodes."""
|
|
from cmdforge.manifest import Manifest, Dependency
|
|
from cmdforge.dependency_graph import DependencyGraph, DependencyNode
|
|
from pathlib import Path
|
|
|
|
# Create mock manifest
|
|
manifest = Manifest(
|
|
name="test-project",
|
|
version="1.0.0",
|
|
dependencies=[
|
|
Dependency(name="official/summarize", version="^1.0.0")
|
|
]
|
|
)
|
|
|
|
# Create mock graph with local tool (has path for integrity)
|
|
tool_dir = tmp_path / "tool"
|
|
tool_dir.mkdir()
|
|
(tool_dir / "config.yaml").write_text("name: summarize\ndescription: test")
|
|
|
|
graph = DependencyGraph()
|
|
graph.root_dependencies = ["official/summarize"]
|
|
graph.nodes = {
|
|
"official/summarize": DependencyNode(
|
|
owner="official",
|
|
name="summarize",
|
|
version_constraint="^1.0.0",
|
|
resolved_version="1.2.3",
|
|
source="local", # Use local so we can compute hash from file
|
|
path=tool_dir,
|
|
children=["official/text-utils"]
|
|
),
|
|
"official/text-utils": DependencyNode(
|
|
owner="official",
|
|
name="text-utils",
|
|
version_constraint="*",
|
|
resolved_version="1.0.0",
|
|
source="registry",
|
|
path=None,
|
|
children=[]
|
|
)
|
|
}
|
|
|
|
# Mock registry client - won't be called for local tools
|
|
mock_client = MagicMock()
|
|
mock_client.download_tool.return_value = MagicMock(
|
|
config_hash="sha256:registry_hash",
|
|
config_yaml="name: text-utils"
|
|
)
|
|
|
|
# Create a cmdforge.yaml for manifest hash
|
|
manifest_path = tmp_path / "cmdforge.yaml"
|
|
manifest_path.write_text("name: test\ndependencies: []")
|
|
|
|
import os
|
|
old_cwd = os.getcwd()
|
|
os.chdir(tmp_path)
|
|
try:
|
|
lock = generate_lockfile(manifest, graph, mock_client)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert len(lock.packages) == 2
|
|
assert "official/summarize" in lock.packages
|
|
assert "official/text-utils" in lock.packages
|
|
assert lock.packages["official/summarize"].direct is True
|
|
assert lock.packages["official/text-utils"].direct is False
|
|
|
|
|
|
class TestVerifyLockfile:
|
|
"""Tests for verify_lockfile function."""
|
|
|
|
def test_reports_missing_tools(self):
|
|
"""Test that missing tools are reported."""
|
|
from cmdforge.resolver import ToolNotFoundError, ToolSpec
|
|
|
|
lock = Lockfile(
|
|
packages={
|
|
"official/missing": LockedPackage(
|
|
name="official/missing",
|
|
version="1.0.0",
|
|
constraint="*",
|
|
integrity="sha256:abc",
|
|
source="registry",
|
|
direct=True
|
|
)
|
|
}
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
|
|
with patch('cmdforge.resolver.ToolResolver') as MockResolver:
|
|
mock_instance = MockResolver.return_value
|
|
mock_instance.manifest = None
|
|
mock_instance.resolve.side_effect = ToolNotFoundError(
|
|
ToolSpec.parse("official/missing"),
|
|
[]
|
|
)
|
|
|
|
errors = verify_lockfile(lock, mock_client)
|
|
|
|
assert len(errors) == 1
|
|
assert "not installed" in errors[0]
|
|
|
|
|
|
class TestLockfileRoundTrip:
|
|
"""Tests for complete lock file round-trip."""
|
|
|
|
def test_full_round_trip(self, tmp_path):
|
|
"""Test saving and loading a complete lock file."""
|
|
lock = Lockfile(
|
|
lockfile_version=1,
|
|
metadata=LockfileMetadata(
|
|
generated_at="2026-01-26T10:30:00Z",
|
|
cmdforge_version="1.5.0",
|
|
manifest_hash="sha256:abc123def456",
|
|
platform="linux",
|
|
python_version="3.12.0"
|
|
),
|
|
packages={
|
|
"official/summarize": LockedPackage(
|
|
name="official/summarize",
|
|
version="1.2.3",
|
|
constraint="^1.0.0",
|
|
integrity="sha256:abc123",
|
|
source="registry",
|
|
direct=True,
|
|
required_by=[]
|
|
),
|
|
"official/text-utils": LockedPackage(
|
|
name="official/text-utils",
|
|
version="1.0.0",
|
|
constraint="*",
|
|
integrity="sha256:def456",
|
|
source="registry",
|
|
direct=False,
|
|
required_by=["official/summarize"]
|
|
),
|
|
"my-local-tool": LockedPackage(
|
|
name="my-local-tool",
|
|
version="0.1.0",
|
|
constraint="*",
|
|
integrity="sha256:ghi789",
|
|
source="local",
|
|
direct=True,
|
|
path=".cmdforge/my-local-tool"
|
|
)
|
|
}
|
|
)
|
|
|
|
lock_path = tmp_path / "cmdforge.lock"
|
|
lock.save(lock_path)
|
|
|
|
loaded = Lockfile.load(lock_path)
|
|
|
|
# Verify metadata
|
|
assert loaded.lockfile_version == 1
|
|
assert loaded.metadata.generated_at == "2026-01-26T10:30:00Z"
|
|
assert loaded.metadata.cmdforge_version == "1.5.0"
|
|
assert loaded.metadata.manifest_hash == "sha256:abc123def456"
|
|
assert loaded.metadata.platform == "linux"
|
|
assert loaded.metadata.python_version == "3.12.0"
|
|
|
|
# Verify packages
|
|
assert len(loaded.packages) == 3
|
|
|
|
summarize = loaded.packages["official/summarize"]
|
|
assert summarize.version == "1.2.3"
|
|
assert summarize.constraint == "^1.0.0"
|
|
assert summarize.integrity == "sha256:abc123"
|
|
assert summarize.source == "registry"
|
|
assert summarize.direct is True
|
|
|
|
text_utils = loaded.packages["official/text-utils"]
|
|
assert text_utils.direct is False
|
|
assert text_utils.required_by == ["official/summarize"]
|
|
|
|
local_tool = loaded.packages["my-local-tool"]
|
|
assert local_tool.source == "local"
|
|
assert local_tool.path == ".cmdforge/my-local-tool"
|