"""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"