feat(skills): store immutable plugin upstream snapshots
This commit is contained in:
174
app-instance/backend/tests/unit/test_plugin_skill_storage.py
Normal file
174
app-instance/backend/tests/unit/test_plugin_skill_storage.py
Normal file
@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from beaver.plugins.transaction import PluginSkillTransaction
|
||||
from beaver.skills.specs import SkillSpecStore, SkillVersion
|
||||
|
||||
|
||||
def _create_source_skill(root: Path, *, template_text: str = "panel") -> Path:
|
||||
source = root / "plugin" / "skills" / "comic"
|
||||
source.mkdir(parents=True)
|
||||
(source / "SKILL.md").write_text("# Comic\n\nOriginal.\n", encoding="utf-8")
|
||||
(source / "templates").mkdir()
|
||||
(source / "templates" / "panel.txt").write_text(template_text, encoding="utf-8")
|
||||
return source
|
||||
|
||||
|
||||
def test_write_upstream_snapshot_copies_skill_without_mutating_source(tmp_path: Path) -> None:
|
||||
source = _create_source_skill(tmp_path)
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
transaction = PluginSkillTransaction(tmp_path / "workspace")
|
||||
|
||||
snapshot = store.stage_upstream_snapshot(
|
||||
transaction,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.0",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
store.promote_upstream_snapshot(transaction, snapshot)
|
||||
|
||||
loaded = store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash)
|
||||
assert loaded is not None
|
||||
assert loaded.content == "# Comic\n\nOriginal.\n"
|
||||
assert (loaded.root / "templates" / "panel.txt").read_text(encoding="utf-8") == "panel"
|
||||
assert (source / "SKILL.md").read_text(encoding="utf-8") == "# Comic\n\nOriginal.\n"
|
||||
|
||||
|
||||
def test_upstream_snapshot_tree_hash_tracks_supporting_files(tmp_path: Path) -> None:
|
||||
source = _create_source_skill(tmp_path, template_text="v1")
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
first_tx = PluginSkillTransaction(tmp_path / "workspace")
|
||||
first = store.stage_upstream_snapshot(
|
||||
first_tx,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.0",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
store.promote_upstream_snapshot(first_tx, first)
|
||||
|
||||
(source / "templates" / "panel.txt").write_text("v2", encoding="utf-8")
|
||||
second_tx = PluginSkillTransaction(tmp_path / "workspace")
|
||||
second = store.stage_upstream_snapshot(
|
||||
second_tx,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.1",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
|
||||
assert first.skill_content_hash == second.skill_content_hash
|
||||
assert first.skill_tree_hash != second.skill_tree_hash
|
||||
|
||||
|
||||
def test_staged_upstream_snapshot_is_not_visible_until_promoted(tmp_path: Path) -> None:
|
||||
source = _create_source_skill(tmp_path)
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
transaction = PluginSkillTransaction(tmp_path / "workspace")
|
||||
|
||||
snapshot = store.stage_upstream_snapshot(
|
||||
transaction,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.0",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
|
||||
assert store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash) is None
|
||||
|
||||
|
||||
def test_promote_upstream_snapshot_is_idempotent_for_identical_snapshot(tmp_path: Path) -> None:
|
||||
source = _create_source_skill(tmp_path)
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
transaction = PluginSkillTransaction(tmp_path / "workspace")
|
||||
snapshot = store.stage_upstream_snapshot(
|
||||
transaction,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.0",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
|
||||
store.promote_upstream_snapshot(transaction, snapshot)
|
||||
store.promote_upstream_snapshot(transaction, snapshot)
|
||||
|
||||
loaded = store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash)
|
||||
assert loaded is not None
|
||||
assert loaded.snapshot.skill_tree_hash == snapshot.skill_tree_hash
|
||||
|
||||
|
||||
def test_stage_upstream_snapshot_rejects_symlinks(tmp_path: Path) -> None:
|
||||
source = _create_source_skill(tmp_path)
|
||||
(source / "linked").symlink_to(source / "SKILL.md")
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
transaction = PluginSkillTransaction(tmp_path / "workspace")
|
||||
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
store.stage_upstream_snapshot(
|
||||
transaction,
|
||||
skill_name="baoyu-comic",
|
||||
source_kind="plugin",
|
||||
source_id="baoyu-comic",
|
||||
source_version="1.0.0",
|
||||
source_path="skills/comic",
|
||||
source_root=source,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_skill_version_without_tree_hash_derives_tree_hash_on_read(tmp_path: Path) -> None:
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
version_dir = store.root / "debug" / "versions" / "v0001"
|
||||
version_dir.mkdir(parents=True)
|
||||
(version_dir / "SKILL.md").write_text("# Debug\n", encoding="utf-8")
|
||||
(version_dir / "version.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"skill_name": "debug",
|
||||
"version": "v0001",
|
||||
"content_hash": "old",
|
||||
"summary_hash": "old-summary",
|
||||
"created_at": "now",
|
||||
"created_by": "tester",
|
||||
"change_reason": "legacy",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
store.set_current_version("debug", "v0001")
|
||||
|
||||
loaded = store.read_published_skill("debug")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.version.tree_hash.startswith("sha256:")
|
||||
|
||||
|
||||
def test_atomic_json_write_does_not_leave_temp_file(tmp_path: Path) -> None:
|
||||
store = SkillSpecStore(tmp_path / "workspace")
|
||||
version = SkillVersion(
|
||||
skill_name="debug",
|
||||
version="v0001",
|
||||
content_hash="hash",
|
||||
summary_hash="summary",
|
||||
created_at="now",
|
||||
created_by="tester",
|
||||
change_reason="test",
|
||||
)
|
||||
|
||||
store.write_skill_version(version, "# Debug\n")
|
||||
|
||||
assert not list((store.root / "debug" / "versions" / "v0001").glob("*.tmp"))
|
||||
Reference in New Issue
Block a user