mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 10:53:08 +00:00
feat(presets): pluggable preset system with template/command overrides, catalog, and resolver
- Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing
This commit is contained in:
1568
tests/test_presets.py
Normal file
1568
tests/test_presets.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,923 +0,0 @@
|
||||
"""
|
||||
Unit tests for the template pack system.
|
||||
|
||||
Tests cover:
|
||||
- Template pack manifest validation
|
||||
- Template pack registry operations
|
||||
- Template pack manager installation/removal
|
||||
- Template catalog search
|
||||
- Template resolver priority stack
|
||||
- Extension-provided templates
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.templates import (
|
||||
TemplatePackManifest,
|
||||
TemplatePackRegistry,
|
||||
TemplatePackManager,
|
||||
TemplateCatalog,
|
||||
TemplateResolver,
|
||||
TemplateError,
|
||||
TemplateValidationError,
|
||||
TemplateCompatibilityError,
|
||||
VALID_TEMPLATE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_pack_data():
|
||||
"""Valid template pack manifest data."""
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"template_pack": {
|
||||
"id": "test-pack",
|
||||
"name": "Test Template Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "A test template pack",
|
||||
"author": "Test Author",
|
||||
"repository": "https://github.com/test/test-pack",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "artifact",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"description": "Custom spec template",
|
||||
"replaces": "spec-template",
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["testing", "example"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack_dir(temp_dir, valid_pack_data):
|
||||
"""Create a complete template pack directory structure."""
|
||||
p_dir = temp_dir / "test-pack"
|
||||
p_dir.mkdir()
|
||||
|
||||
# Write manifest
|
||||
manifest_path = p_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
|
||||
# Create templates directory
|
||||
templates_dir = p_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Write template file
|
||||
tmpl_file = templates_dir / "spec-template.md"
|
||||
tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n")
|
||||
|
||||
return p_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
|
||||
# Create .specify directory
|
||||
specify_dir = proj_dir / ".specify"
|
||||
specify_dir.mkdir()
|
||||
|
||||
# Create templates directory with core templates
|
||||
templates_dir = specify_dir / "templates"
|
||||
templates_dir.mkdir()
|
||||
|
||||
# Create core spec-template
|
||||
core_spec = templates_dir / "spec-template.md"
|
||||
core_spec.write_text("# Core Spec Template\n")
|
||||
|
||||
# Create core plan-template
|
||||
core_plan = templates_dir / "plan-template.md"
|
||||
core_plan.write_text("# Core Plan Template\n")
|
||||
|
||||
# Create commands subdirectory
|
||||
commands_dir = templates_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
|
||||
return proj_dir
|
||||
|
||||
|
||||
# ===== TemplatePackManifest Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManifest:
|
||||
"""Test TemplatePackManifest validation and parsing."""
|
||||
|
||||
def test_valid_manifest(self, pack_dir):
|
||||
"""Test loading a valid manifest."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manifest.name == "Test Template Pack"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.description == "A test template pack"
|
||||
assert manifest.author == "Test Author"
|
||||
assert manifest.requires_speckit_version == ">=0.1.0"
|
||||
assert len(manifest.templates) == 1
|
||||
assert manifest.tags == ["testing", "example"]
|
||||
|
||||
def test_missing_manifest(self, temp_dir):
|
||||
"""Test that missing manifest raises error."""
|
||||
with pytest.raises(TemplateValidationError, match="Manifest not found"):
|
||||
TemplatePackManifest(temp_dir / "nonexistent.yml")
|
||||
|
||||
def test_invalid_yaml(self, temp_dir):
|
||||
"""Test that invalid YAML raises error."""
|
||||
bad_file = temp_dir / "bad.yml"
|
||||
bad_file.write_text(": invalid: yaml: {{{")
|
||||
with pytest.raises(TemplateValidationError, match="Invalid YAML"):
|
||||
TemplatePackManifest(bad_file)
|
||||
|
||||
def test_missing_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing schema_version field."""
|
||||
del valid_pack_data["schema_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing required field: schema_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_wrong_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test unsupported schema version."""
|
||||
valid_pack_data["schema_version"] = "2.0"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Unsupported schema version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_pack_id(self, temp_dir, valid_pack_data):
|
||||
"""Test missing template_pack.id field."""
|
||||
del valid_pack_data["template_pack"]["id"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing template_pack.id"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_pack_id_format(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid pack ID format."""
|
||||
valid_pack_data["template_pack"]["id"] = "Invalid_ID"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template pack ID"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_version(self, temp_dir, valid_pack_data):
|
||||
"""Test invalid semantic version."""
|
||||
valid_pack_data["template_pack"]["version"] = "not-a-version"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_missing_speckit_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing requires.speckit_version."""
|
||||
del valid_pack_data["requires"]["speckit_version"]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Missing requires.speckit_version"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_no_templates_provided(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with no templates."""
|
||||
valid_pack_data["provides"]["templates"] = []
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="must provide at least one template"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_type(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid type."""
|
||||
valid_pack_data["provides"]["templates"][0]["type"] = "invalid"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template type"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_valid_template_types(self):
|
||||
"""Test that all expected template types are valid."""
|
||||
assert "artifact" in VALID_TEMPLATE_TYPES
|
||||
assert "command" in VALID_TEMPLATE_TYPES
|
||||
assert "script" in VALID_TEMPLATE_TYPES
|
||||
|
||||
def test_template_missing_required_fields(self, temp_dir, valid_pack_data):
|
||||
"""Test template missing required fields."""
|
||||
valid_pack_data["provides"]["templates"] = [{"type": "artifact"}]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="missing 'type', 'name', or 'file'"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_invalid_template_name_format(self, temp_dir, valid_pack_data):
|
||||
"""Test template with invalid name format."""
|
||||
valid_pack_data["provides"]["templates"][0]["name"] = "Invalid Name"
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(TemplateValidationError, match="Invalid template name"):
|
||||
TemplatePackManifest(manifest_path)
|
||||
|
||||
def test_get_hash(self, pack_dir):
|
||||
"""Test manifest hash calculation."""
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
hash_val = manifest.get_hash()
|
||||
assert hash_val.startswith("sha256:")
|
||||
assert len(hash_val) > 10
|
||||
|
||||
def test_multiple_templates(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with multiple templates of different types."""
|
||||
valid_pack_data["provides"]["templates"] = [
|
||||
{"type": "artifact", "name": "spec-template", "file": "templates/spec-template.md"},
|
||||
{"type": "artifact", "name": "plan-template", "file": "templates/plan-template.md"},
|
||||
{"type": "command", "name": "specify", "file": "commands/specify.md"},
|
||||
{"type": "script", "name": "create-new-feature", "file": "scripts/create-new-feature.sh"},
|
||||
]
|
||||
manifest_path = temp_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
manifest = TemplatePackManifest(manifest_path)
|
||||
assert len(manifest.templates) == 4
|
||||
|
||||
|
||||
# ===== TemplatePackRegistry Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackRegistry:
|
||||
"""Test TemplatePackRegistry operations."""
|
||||
|
||||
def test_empty_registry(self, temp_dir):
|
||||
"""Test empty registry initialization."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_add_and_get(self, temp_dir):
|
||||
"""Test adding and retrieving a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0", "source": "local"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
metadata = registry.get("test-pack")
|
||||
assert metadata is not None
|
||||
assert metadata["version"] == "1.0.0"
|
||||
assert "installed_at" in metadata
|
||||
|
||||
def test_remove(self, temp_dir):
|
||||
"""Test removing a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("test-pack", {"version": "1.0.0"})
|
||||
assert registry.is_installed("test-pack")
|
||||
|
||||
registry.remove("test-pack")
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_remove_nonexistent(self, temp_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
registry.remove("nonexistent") # Should not raise
|
||||
|
||||
def test_list(self, temp_dir):
|
||||
"""Test listing all packs."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-a", {"version": "1.0.0"})
|
||||
registry.add("pack-b", {"version": "2.0.0"})
|
||||
|
||||
all_packs = registry.list()
|
||||
assert len(all_packs) == 2
|
||||
assert "pack-a" in all_packs
|
||||
assert "pack-b" in all_packs
|
||||
|
||||
def test_persistence(self, temp_dir):
|
||||
"""Test that registry data persists across instances."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
# Add with first instance
|
||||
registry1 = TemplatePackRegistry(packs_dir)
|
||||
registry1.add("test-pack", {"version": "1.0.0"})
|
||||
|
||||
# Load with second instance
|
||||
registry2 = TemplatePackRegistry(packs_dir)
|
||||
assert registry2.is_installed("test-pack")
|
||||
|
||||
def test_corrupted_registry(self, temp_dir):
|
||||
"""Test recovery from corrupted registry file."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
|
||||
registry_file = packs_dir / ".registry"
|
||||
registry_file.write_text("not valid json{{{")
|
||||
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.list() == {}
|
||||
|
||||
def test_get_nonexistent(self, temp_dir):
|
||||
"""Test getting a nonexistent pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = TemplatePackRegistry(packs_dir)
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
|
||||
# ===== TemplatePackManager Tests =====
|
||||
|
||||
|
||||
class TestTemplatePackManager:
|
||||
"""Test TemplatePackManager installation and removal."""
|
||||
|
||||
def test_install_from_directory(self, project_dir, pack_dir):
|
||||
"""Test installing a template pack from a directory."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
# Verify files are copied
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert installed_dir.exists()
|
||||
assert (installed_dir / "template-pack.yml").exists()
|
||||
assert (installed_dir / "templates" / "spec-template.md").exists()
|
||||
|
||||
def test_install_already_installed(self, project_dir, pack_dir):
|
||||
"""Test installing an already-installed pack raises error."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
with pytest.raises(TemplateError, match="already installed"):
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
def test_install_incompatible(self, project_dir, temp_dir, valid_pack_data):
|
||||
"""Test installing an incompatible pack raises error."""
|
||||
valid_pack_data["requires"]["speckit_version"] = ">=99.0.0"
|
||||
incompat_dir = temp_dir / "incompat-pack"
|
||||
incompat_dir.mkdir()
|
||||
manifest_path = incompat_dir / "template-pack.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
(incompat_dir / "templates").mkdir()
|
||||
(incompat_dir / "templates" / "spec-template.md").write_text("test")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateCompatibilityError):
|
||||
manager.install_from_directory(incompat_dir, "0.1.5")
|
||||
|
||||
def test_install_from_zip(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from a ZIP file."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP with nested directory."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = Path("test-pack-v1.0.0") / file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_zip(zip_path, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
def test_install_from_zip_no_manifest(self, project_dir, temp_dir):
|
||||
"""Test installing from ZIP without manifest raises error."""
|
||||
zip_path = temp_dir / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
zf.writestr("readme.txt", "no manifest here")
|
||||
|
||||
manager = TemplatePackManager(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="No template-pack.yml found"):
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
def test_remove(self, project_dir, pack_dir):
|
||||
"""Test removing a template pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
result = manager.remove("test-pack")
|
||||
assert result is True
|
||||
assert not manager.registry.is_installed("test-pack")
|
||||
|
||||
installed_dir = project_dir / ".specify" / "templates" / "packs" / "test-pack"
|
||||
assert not installed_dir.exists()
|
||||
|
||||
def test_remove_nonexistent(self, project_dir):
|
||||
"""Test removing a pack that doesn't exist."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
result = manager.remove("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_list_installed(self, project_dir, pack_dir):
|
||||
"""Test listing installed packs."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["id"] == "test-pack"
|
||||
assert installed[0]["name"] == "Test Template Pack"
|
||||
assert installed[0]["version"] == "1.0.0"
|
||||
assert installed[0]["template_count"] == 1
|
||||
|
||||
def test_list_installed_empty(self, project_dir):
|
||||
"""Test listing when no packs installed."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.list_installed() == []
|
||||
|
||||
def test_get_pack(self, project_dir, pack_dir):
|
||||
"""Test getting a specific installed pack."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
pack = manager.get_pack("test-pack")
|
||||
assert pack is not None
|
||||
assert pack.id == "test-pack"
|
||||
|
||||
def test_get_pack_not_installed(self, project_dir):
|
||||
"""Test getting a non-installed pack returns None."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
assert manager.get_pack("nonexistent") is None
|
||||
|
||||
def test_check_compatibility_valid(self, pack_dir):
|
||||
"""Test compatibility check with valid version."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
assert manager.check_compatibility(manifest, "0.1.5") is True
|
||||
|
||||
def test_check_compatibility_invalid(self, pack_dir):
|
||||
"""Test compatibility check with invalid specifier."""
|
||||
manager = TemplatePackManager(Path(tempfile.mkdtemp()))
|
||||
manifest = TemplatePackManifest(pack_dir / "template-pack.yml")
|
||||
manifest.data["requires"]["speckit_version"] = "not-a-specifier"
|
||||
with pytest.raises(TemplateCompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.1.5")
|
||||
|
||||
|
||||
# ===== TemplateResolver Tests =====
|
||||
|
||||
|
||||
class TestTemplateResolver:
|
||||
"""Test TemplateResolver priority stack."""
|
||||
|
||||
def test_resolve_core_template(self, project_dir):
|
||||
"""Test resolving a core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert result.name == "spec-template.md"
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_nonexistent(self, project_dir):
|
||||
"""Test resolving a nonexistent template returns None."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("nonexistent-template")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_override_takes_priority(self, project_dir):
|
||||
"""Test that project overrides take priority over core."""
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
|
||||
"""Test that installed packs take priority over core templates."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
||||
"""Test that overrides take priority over installed packs."""
|
||||
# Install the pack
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Create override
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override Spec Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Override Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_extension_provided_templates(self, project_dir):
|
||||
"""Test resolving templates provided by extensions."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "custom-template.md"
|
||||
ext_template.write_text("# Extension Custom Template\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("custom-template")
|
||||
assert result is not None
|
||||
assert "Extension Custom Template" in result.read_text()
|
||||
|
||||
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
|
||||
"""Test that pack templates take priority over extension templates."""
|
||||
# Create extension with templates
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "spec-template.md"
|
||||
ext_template.write_text("# Extension Spec Template\n")
|
||||
|
||||
# Install a pack with the same template
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
# Pack should win over extension
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def test_resolve_with_source_core(self, project_dir):
|
||||
"""Test resolve_with_source for core template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "core"
|
||||
assert "spec-template.md" in result["path"]
|
||||
|
||||
def test_resolve_with_source_override(self, project_dir):
|
||||
"""Test resolve_with_source for override template."""
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
override = overrides_dir / "spec-template.md"
|
||||
override.write_text("# Override\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_resolve_with_source_pack(self, project_dir, pack_dir):
|
||||
"""Test resolve_with_source for pack template."""
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result is not None
|
||||
assert "test-pack" in result["source"]
|
||||
assert "v1.0.0" in result["source"]
|
||||
|
||||
def test_resolve_with_source_extension(self, project_dir):
|
||||
"""Test resolve_with_source for extension-provided template."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "unique-template.md"
|
||||
ext_template.write_text("# Unique\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("unique-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
def test_resolve_with_source_not_found(self, project_dir):
|
||||
"""Test resolve_with_source for nonexistent template."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve_with_source("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_resolve_skips_hidden_extension_dirs(self, project_dir):
|
||||
"""Test that hidden directories in extensions are skipped."""
|
||||
ext_dir = project_dir / ".specify" / "extensions" / ".backup"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
ext_template = ext_templates_dir / "hidden-template.md"
|
||||
ext_template.write_text("# Hidden\n")
|
||||
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("hidden-template")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===== TemplateCatalog Tests =====
|
||||
|
||||
|
||||
class TestTemplateCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "githubusercontent.com" in catalog.DEFAULT_CATALOG_URL
|
||||
assert "templates/catalog.json" in catalog.DEFAULT_CATALOG_URL
|
||||
|
||||
def test_community_catalog_url(self, project_dir):
|
||||
"""Test community catalog URL."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert "templates/catalog.community.json" in catalog.COMMUNITY_CATALOG_URL
|
||||
|
||||
def test_cache_validation_no_cache(self, project_dir):
|
||||
"""Test cache validation when no cache exists."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_valid(self, project_dir):
|
||||
"""Test cache validation with valid cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is True
|
||||
|
||||
def test_cache_validation_expired(self, project_dir):
|
||||
"""Test cache validation with expired cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text(json.dumps({
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {},
|
||||
}))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": "2020-01-01T00:00:00+00:00",
|
||||
}))
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_cache_validation_corrupted(self, project_dir):
|
||||
"""Test cache validation with corrupted metadata."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog.cache_file.write_text("not json")
|
||||
catalog.cache_metadata_file.write_text("not json")
|
||||
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_clear_cache(self, project_dir):
|
||||
"""Test clearing the cache."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}")
|
||||
catalog.cache_metadata_file.write_text("{}")
|
||||
|
||||
catalog.clear_cache()
|
||||
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
def test_search_with_cached_data(self, project_dir):
|
||||
"""Test search with cached catalog data."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"safe-agile": {
|
||||
"name": "SAFe Agile Templates",
|
||||
"description": "SAFe-aligned templates",
|
||||
"author": "agile-community",
|
||||
"version": "1.0.0",
|
||||
"tags": ["safe", "agile"],
|
||||
},
|
||||
"healthcare": {
|
||||
"name": "Healthcare Compliance",
|
||||
"description": "HIPAA-compliant templates",
|
||||
"author": "healthcare-org",
|
||||
"version": "1.0.0",
|
||||
"tags": ["healthcare", "hipaa"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
# Search by query
|
||||
results = catalog.search(query="agile")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "safe-agile"
|
||||
|
||||
# Search by tag
|
||||
results = catalog.search(tag="hipaa")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "healthcare"
|
||||
|
||||
# Search by author
|
||||
results = catalog.search(author="agile-community")
|
||||
assert len(results) == 1
|
||||
|
||||
# Search all
|
||||
results = catalog.search()
|
||||
assert len(results) == 2
|
||||
|
||||
def test_get_pack_info(self, project_dir):
|
||||
"""Test getting info for a specific pack."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"template_packs": {
|
||||
"test-pack": {
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
catalog.cache_metadata_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
info = catalog.get_pack_info("test-pack")
|
||||
assert info is not None
|
||||
assert info["name"] == "Test Pack"
|
||||
assert info["id"] == "test-pack"
|
||||
|
||||
assert catalog.get_pack_info("nonexistent") is None
|
||||
|
||||
def test_validate_catalog_url_https(self, project_dir):
|
||||
"""Test that HTTPS URLs are accepted."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("https://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_http_rejected(self, project_dir):
|
||||
"""Test that HTTP URLs are rejected."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
with pytest.raises(TemplateValidationError, match="must use HTTPS"):
|
||||
catalog._validate_catalog_url("http://example.com/catalog.json")
|
||||
|
||||
def test_validate_catalog_url_localhost_http_allowed(self, project_dir):
|
||||
"""Test that HTTP is allowed for localhost."""
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
catalog._validate_catalog_url("http://localhost:8080/catalog.json")
|
||||
catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json")
|
||||
|
||||
def test_env_var_catalog_url(self, project_dir, monkeypatch):
|
||||
"""Test catalog URL from environment variable."""
|
||||
monkeypatch.setenv("SPECKIT_TEMPLATE_CATALOG_URL", "https://custom.example.com/catalog.json")
|
||||
catalog = TemplateCatalog(project_dir)
|
||||
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
|
||||
|
||||
|
||||
# ===== Integration Tests =====
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for complete template pack workflows."""
|
||||
|
||||
def test_full_install_resolve_remove_cycle(self, project_dir, pack_dir):
|
||||
"""Test complete lifecycle: install → resolve → remove."""
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manifest = manager.install_from_directory(pack_dir, "0.1.5")
|
||||
assert manifest.id == "test-pack"
|
||||
|
||||
# Resolve — pack template should win over core
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
# Remove
|
||||
manager.remove("test-pack")
|
||||
|
||||
# Resolve — should fall back to core
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Core Spec Template" in result.read_text()
|
||||
|
||||
def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_dir):
|
||||
"""Test the full priority stack: override > pack > extension > core."""
|
||||
resolver = TemplateResolver(project_dir)
|
||||
|
||||
# Core should resolve
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "core"
|
||||
|
||||
# Add extension template
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "my-ext"
|
||||
ext_templates_dir = ext_dir / "templates"
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
# Install pack — should win over extension
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert "test-pack" in result["source"]
|
||||
|
||||
# Add override — should win over pack
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "spec-template.md").write_text("# Override\n")
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "project override"
|
||||
|
||||
def test_install_from_zip_then_resolve(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP and then resolving."""
|
||||
# Create ZIP
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
for file_path in pack_dir.rglob('*'):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(pack_dir)
|
||||
zf.write(file_path, arcname)
|
||||
|
||||
# Install
|
||||
manager = TemplatePackManager(project_dir)
|
||||
manager.install_from_zip(zip_path, "0.1.5")
|
||||
|
||||
# Resolve
|
||||
resolver = TemplateResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
Reference in New Issue
Block a user