mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 19:03:08 +00:00
* Initial plan * feat(extensions): implement multi-catalog stack support - Add CatalogEntry dataclass to represent catalog entries - Add get_active_catalogs() reading SPECKIT_CATALOG_URL, project config, user config, or built-in default stack (org-approved + community) - Add _load_catalog_config() to parse .specify/extension-catalogs.yml - Add _validate_catalog_url() HTTPS validation helper - Add _fetch_single_catalog() with per-URL caching, backward-compat for DEFAULT_CATALOG_URL - Add _get_merged_extensions() that merges all catalogs (priority wins on conflict) - Update search() and get_extension_info() to use merged results annotated with _catalog_name and _install_allowed - Update clear_cache() to also remove per-URL hash cache files - Add extension_catalogs CLI command to list active catalogs - Add catalog add/remove sub-commands for .specify/extension-catalogs.yml - Update extension_add to enforce install_allowed=false policy - Update extension_search to show source catalog per result - Update extension_info to show source catalog with install_allowed status - Add 13 new tests covering catalog stack, merge conflict resolution, install_allowed enforcement, and catalog metadata Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs: update RFC, user guide, and API reference for multi-catalog support - RFC: replace FUTURE FEATURE section with full implementation docs, add catalog stack resolution order, config file examples, merge conflict resolution, and install_allowed behavior - EXTENSION-USER-GUIDE.md: add multi-catalog section with CLI examples for catalogs/catalog-add/catalog-remove, update catalog config docs - EXTENSION-API-REFERENCE.md: add CatalogEntry class docs, update ExtensionCatalog docs with new methods and result annotations, add catalog CLI commands (catalogs, catalog add, catalog remove) Also fix extension_catalogs command to correctly show "Using built-in default catalog stack" when config file exists but has empty catalogs Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: remove extraneous f-string prefixes (ruff F541) Remove f-prefix from strings with no placeholders in catalog_remove and extension_search commands. Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: address PR review feedback for multi-catalog support - Rename 'org-approved' catalog to 'default' - Move 'catalogs' command to 'catalog list' for consistency - Add 'description' field to CatalogEntry dataclass - Add --description option to 'catalog add' CLI command - Align install_allowed default to False in _load_catalog_config - Add user-level config detection in catalog list footer - Fix _load_catalog_config docstring (document ValidationError) - Fix test isolation for test_search_by_tag, test_search_by_query, test_search_verified_only, test_get_extension_info - Update version to 0.1.14 and CHANGELOG - Update all docs (RFC, User Guide, API Reference) * fix: wrap _load_catalog_config() calls in catalog_list with try/except - Check SPECKIT_CATALOG_URL first (matching get_active_catalogs() resolution order) - Wrap both _load_catalog_config() calls in try/except ValidationError so a malformed config file cannot crash `specify extension catalog list` after the active catalogs have already been printed successfully Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1601 lines
54 KiB
Python
1601 lines
54 KiB
Python
"""
|
|
Unit tests for the extension system.
|
|
|
|
Tests cover:
|
|
- Extension manifest validation
|
|
- Extension registry operations
|
|
- Extension manager installation/removal
|
|
- Command registration
|
|
- Catalog stack (multi-catalog support)
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
from specify_cli.extensions import (
|
|
CatalogEntry,
|
|
ExtensionManifest,
|
|
ExtensionRegistry,
|
|
ExtensionManager,
|
|
CommandRegistrar,
|
|
ExtensionCatalog,
|
|
ExtensionError,
|
|
ValidationError,
|
|
CompatibilityError,
|
|
version_satisfies,
|
|
)
|
|
|
|
|
|
# ===== 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_manifest_data():
|
|
"""Valid extension manifest data."""
|
|
return {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "test-ext",
|
|
"name": "Test Extension",
|
|
"version": "1.0.0",
|
|
"description": "A test extension",
|
|
"author": "Test Author",
|
|
"repository": "https://github.com/test/test-ext",
|
|
"license": "MIT",
|
|
},
|
|
"requires": {
|
|
"speckit_version": ">=0.1.0",
|
|
"commands": ["speckit.tasks"],
|
|
},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.test.hello",
|
|
"file": "commands/hello.md",
|
|
"description": "Test command",
|
|
}
|
|
]
|
|
},
|
|
"hooks": {
|
|
"after_tasks": {
|
|
"command": "speckit.test.hello",
|
|
"optional": True,
|
|
"prompt": "Run test?",
|
|
}
|
|
},
|
|
"tags": ["testing", "example"],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def extension_dir(temp_dir, valid_manifest_data):
|
|
"""Create a complete extension directory structure."""
|
|
ext_dir = temp_dir / "test-ext"
|
|
ext_dir.mkdir()
|
|
|
|
# Write manifest
|
|
import yaml
|
|
manifest_path = ext_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_manifest_data, f)
|
|
|
|
# Create commands directory
|
|
commands_dir = ext_dir / "commands"
|
|
commands_dir.mkdir()
|
|
|
|
# Write command file
|
|
cmd_file = commands_dir / "hello.md"
|
|
cmd_file.write_text("""---
|
|
description: "Test hello command"
|
|
---
|
|
|
|
# Test Hello Command
|
|
|
|
$ARGUMENTS
|
|
""")
|
|
|
|
return ext_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()
|
|
|
|
return proj_dir
|
|
|
|
|
|
# ===== ExtensionManifest Tests =====
|
|
|
|
class TestExtensionManifest:
|
|
"""Test ExtensionManifest validation and parsing."""
|
|
|
|
def test_valid_manifest(self, extension_dir):
|
|
"""Test loading a valid manifest."""
|
|
manifest_path = extension_dir / "extension.yml"
|
|
manifest = ExtensionManifest(manifest_path)
|
|
|
|
assert manifest.id == "test-ext"
|
|
assert manifest.name == "Test Extension"
|
|
assert manifest.version == "1.0.0"
|
|
assert manifest.description == "A test extension"
|
|
assert len(manifest.commands) == 1
|
|
assert manifest.commands[0]["name"] == "speckit.test.hello"
|
|
|
|
def test_missing_required_field(self, temp_dir):
|
|
"""Test manifest missing required field."""
|
|
import yaml
|
|
|
|
manifest_path = temp_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump({"schema_version": "1.0"}, f) # Missing 'extension'
|
|
|
|
with pytest.raises(ValidationError, match="Missing required field"):
|
|
ExtensionManifest(manifest_path)
|
|
|
|
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
|
|
"""Test manifest with invalid extension ID format."""
|
|
import yaml
|
|
|
|
valid_manifest_data["extension"]["id"] = "Invalid_ID" # Uppercase not allowed
|
|
|
|
manifest_path = temp_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_manifest_data, f)
|
|
|
|
with pytest.raises(ValidationError, match="Invalid extension ID"):
|
|
ExtensionManifest(manifest_path)
|
|
|
|
def test_invalid_version(self, temp_dir, valid_manifest_data):
|
|
"""Test manifest with invalid semantic version."""
|
|
import yaml
|
|
|
|
valid_manifest_data["extension"]["version"] = "invalid"
|
|
|
|
manifest_path = temp_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_manifest_data, f)
|
|
|
|
with pytest.raises(ValidationError, match="Invalid version"):
|
|
ExtensionManifest(manifest_path)
|
|
|
|
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
|
|
"""Test manifest with invalid command name format."""
|
|
import yaml
|
|
|
|
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
|
|
|
|
manifest_path = temp_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_manifest_data, f)
|
|
|
|
with pytest.raises(ValidationError, match="Invalid command name"):
|
|
ExtensionManifest(manifest_path)
|
|
|
|
def test_no_commands(self, temp_dir, valid_manifest_data):
|
|
"""Test manifest with no commands provided."""
|
|
import yaml
|
|
|
|
valid_manifest_data["provides"]["commands"] = []
|
|
|
|
manifest_path = temp_dir / "extension.yml"
|
|
with open(manifest_path, 'w') as f:
|
|
yaml.dump(valid_manifest_data, f)
|
|
|
|
with pytest.raises(ValidationError, match="must provide at least one command"):
|
|
ExtensionManifest(manifest_path)
|
|
|
|
def test_manifest_hash(self, extension_dir):
|
|
"""Test manifest hash calculation."""
|
|
manifest_path = extension_dir / "extension.yml"
|
|
manifest = ExtensionManifest(manifest_path)
|
|
|
|
hash_value = manifest.get_hash()
|
|
assert hash_value.startswith("sha256:")
|
|
assert len(hash_value) > 10
|
|
|
|
|
|
# ===== ExtensionRegistry Tests =====
|
|
|
|
class TestExtensionRegistry:
|
|
"""Test ExtensionRegistry operations."""
|
|
|
|
def test_empty_registry(self, temp_dir):
|
|
"""Test creating a new empty registry."""
|
|
extensions_dir = temp_dir / "extensions"
|
|
extensions_dir.mkdir()
|
|
|
|
registry = ExtensionRegistry(extensions_dir)
|
|
|
|
assert registry.data["schema_version"] == "1.0"
|
|
assert registry.data["extensions"] == {}
|
|
assert len(registry.list()) == 0
|
|
|
|
def test_add_extension(self, temp_dir):
|
|
"""Test adding an extension to registry."""
|
|
extensions_dir = temp_dir / "extensions"
|
|
extensions_dir.mkdir()
|
|
|
|
registry = ExtensionRegistry(extensions_dir)
|
|
|
|
metadata = {
|
|
"version": "1.0.0",
|
|
"source": "local",
|
|
"enabled": True,
|
|
}
|
|
registry.add("test-ext", metadata)
|
|
|
|
assert registry.is_installed("test-ext")
|
|
ext_data = registry.get("test-ext")
|
|
assert ext_data["version"] == "1.0.0"
|
|
assert "installed_at" in ext_data
|
|
|
|
def test_remove_extension(self, temp_dir):
|
|
"""Test removing an extension from registry."""
|
|
extensions_dir = temp_dir / "extensions"
|
|
extensions_dir.mkdir()
|
|
|
|
registry = ExtensionRegistry(extensions_dir)
|
|
registry.add("test-ext", {"version": "1.0.0"})
|
|
|
|
assert registry.is_installed("test-ext")
|
|
|
|
registry.remove("test-ext")
|
|
|
|
assert not registry.is_installed("test-ext")
|
|
assert registry.get("test-ext") is None
|
|
|
|
def test_registry_persistence(self, temp_dir):
|
|
"""Test that registry persists to disk."""
|
|
extensions_dir = temp_dir / "extensions"
|
|
extensions_dir.mkdir()
|
|
|
|
# Create registry and add extension
|
|
registry1 = ExtensionRegistry(extensions_dir)
|
|
registry1.add("test-ext", {"version": "1.0.0"})
|
|
|
|
# Load new registry instance
|
|
registry2 = ExtensionRegistry(extensions_dir)
|
|
|
|
# Should still have the extension
|
|
assert registry2.is_installed("test-ext")
|
|
assert registry2.get("test-ext")["version"] == "1.0.0"
|
|
|
|
|
|
# ===== ExtensionManager Tests =====
|
|
|
|
class TestExtensionManager:
|
|
"""Test ExtensionManager installation and removal."""
|
|
|
|
def test_check_compatibility_valid(self, extension_dir, project_dir):
|
|
"""Test compatibility check with valid version."""
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
# Should not raise
|
|
result = manager.check_compatibility(manifest, "0.1.0")
|
|
assert result is True
|
|
|
|
def test_check_compatibility_invalid(self, extension_dir, project_dir):
|
|
"""Test compatibility check with invalid version."""
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
# Requires >=0.1.0, but we have 0.0.1
|
|
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
|
|
manager.check_compatibility(manifest, "0.0.1")
|
|
|
|
def test_install_from_directory(self, extension_dir, project_dir):
|
|
"""Test installing extension from directory."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
manifest = manager.install_from_directory(
|
|
extension_dir,
|
|
"0.1.0",
|
|
register_commands=False # Skip command registration for now
|
|
)
|
|
|
|
assert manifest.id == "test-ext"
|
|
assert manager.registry.is_installed("test-ext")
|
|
|
|
# Check extension directory was copied
|
|
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
|
assert ext_dir.exists()
|
|
assert (ext_dir / "extension.yml").exists()
|
|
assert (ext_dir / "commands" / "hello.md").exists()
|
|
|
|
def test_install_duplicate(self, extension_dir, project_dir):
|
|
"""Test installing already installed extension."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Install once
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
|
|
# Try to install again
|
|
with pytest.raises(ExtensionError, match="already installed"):
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
|
|
def test_remove_extension(self, extension_dir, project_dir):
|
|
"""Test removing an installed extension."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Install extension
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
|
|
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
|
assert ext_dir.exists()
|
|
|
|
# Remove extension
|
|
result = manager.remove("test-ext", keep_config=False)
|
|
|
|
assert result is True
|
|
assert not manager.registry.is_installed("test-ext")
|
|
assert not ext_dir.exists()
|
|
|
|
def test_remove_nonexistent(self, project_dir):
|
|
"""Test removing non-existent extension."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
result = manager.remove("nonexistent")
|
|
assert result is False
|
|
|
|
def test_list_installed(self, extension_dir, project_dir):
|
|
"""Test listing installed extensions."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Initially empty
|
|
assert len(manager.list_installed()) == 0
|
|
|
|
# Install extension
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
|
|
# Should have one extension
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["id"] == "test-ext"
|
|
assert installed[0]["name"] == "Test Extension"
|
|
assert installed[0]["version"] == "1.0.0"
|
|
assert installed[0]["command_count"] == 1
|
|
assert installed[0]["hook_count"] == 1
|
|
|
|
def test_config_backup_on_remove(self, extension_dir, project_dir):
|
|
"""Test that config files are backed up on removal."""
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Install extension
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
|
|
# Create a config file
|
|
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
|
config_file = ext_dir / "test-ext-config.yml"
|
|
config_file.write_text("test: config")
|
|
|
|
# Remove extension (without keep_config)
|
|
manager.remove("test-ext", keep_config=False)
|
|
|
|
# Check backup was created (now in subdirectory per extension)
|
|
backup_dir = project_dir / ".specify" / "extensions" / ".backup" / "test-ext"
|
|
backup_file = backup_dir / "test-ext-config.yml"
|
|
assert backup_file.exists()
|
|
assert backup_file.read_text() == "test: config"
|
|
|
|
|
|
# ===== CommandRegistrar Tests =====
|
|
|
|
class TestCommandRegistrar:
|
|
"""Test CommandRegistrar command registration."""
|
|
|
|
def test_kiro_cli_agent_config_present(self):
|
|
"""Kiro CLI should be mapped to .kiro/prompts and legacy q removed."""
|
|
assert "kiro-cli" in CommandRegistrar.AGENT_CONFIGS
|
|
assert CommandRegistrar.AGENT_CONFIGS["kiro-cli"]["dir"] == ".kiro/prompts"
|
|
assert "q" not in CommandRegistrar.AGENT_CONFIGS
|
|
|
|
def test_parse_frontmatter_valid(self):
|
|
"""Test parsing valid YAML frontmatter."""
|
|
content = """---
|
|
description: "Test command"
|
|
tools:
|
|
- tool1
|
|
- tool2
|
|
---
|
|
|
|
# Command body
|
|
$ARGUMENTS
|
|
"""
|
|
registrar = CommandRegistrar()
|
|
frontmatter, body = registrar.parse_frontmatter(content)
|
|
|
|
assert frontmatter["description"] == "Test command"
|
|
assert frontmatter["tools"] == ["tool1", "tool2"]
|
|
assert "Command body" in body
|
|
assert "$ARGUMENTS" in body
|
|
|
|
def test_parse_frontmatter_no_frontmatter(self):
|
|
"""Test parsing content without frontmatter."""
|
|
content = "# Just a command\n$ARGUMENTS"
|
|
|
|
registrar = CommandRegistrar()
|
|
frontmatter, body = registrar.parse_frontmatter(content)
|
|
|
|
assert frontmatter == {}
|
|
assert body == content
|
|
|
|
def test_render_frontmatter(self):
|
|
"""Test rendering frontmatter to YAML."""
|
|
frontmatter = {
|
|
"description": "Test command",
|
|
"tools": ["tool1", "tool2"]
|
|
}
|
|
|
|
registrar = CommandRegistrar()
|
|
output = registrar.render_frontmatter(frontmatter)
|
|
|
|
assert output.startswith("---\n")
|
|
assert output.endswith("---\n")
|
|
assert "description: Test command" in output
|
|
|
|
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
|
"""Test registering commands for Claude agent."""
|
|
# Create .claude directory
|
|
claude_dir = project_dir / ".claude" / "commands"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
ExtensionManager(project_dir) # Initialize manager (side effects only)
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
registrar = CommandRegistrar()
|
|
registered = registrar.register_commands_for_claude(
|
|
manifest,
|
|
extension_dir,
|
|
project_dir
|
|
)
|
|
|
|
assert len(registered) == 1
|
|
assert "speckit.test.hello" in registered
|
|
|
|
# Check command file was created
|
|
cmd_file = claude_dir / "speckit.test.hello.md"
|
|
assert cmd_file.exists()
|
|
|
|
content = cmd_file.read_text()
|
|
assert "description: Test hello command" in content
|
|
assert "<!-- Extension: test-ext -->" in content
|
|
assert "<!-- Config: .specify/extensions/test-ext/ -->" in content
|
|
|
|
def test_command_with_aliases(self, project_dir, temp_dir):
|
|
"""Test registering a command with aliases."""
|
|
import yaml
|
|
|
|
# Create extension with command alias
|
|
ext_dir = temp_dir / "ext-alias"
|
|
ext_dir.mkdir()
|
|
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "ext-alias",
|
|
"name": "Extension with Alias",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {
|
|
"speckit_version": ">=0.1.0",
|
|
},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.alias.cmd",
|
|
"file": "commands/cmd.md",
|
|
"aliases": ["speckit.shortcut"],
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
with open(ext_dir / "extension.yml", 'w') as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest")
|
|
|
|
claude_dir = project_dir / ".claude" / "commands"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
|
registrar = CommandRegistrar()
|
|
registered = registrar.register_commands_for_claude(manifest, ext_dir, project_dir)
|
|
|
|
assert len(registered) == 2
|
|
assert "speckit.alias.cmd" in registered
|
|
assert "speckit.shortcut" in registered
|
|
assert (claude_dir / "speckit.alias.cmd.md").exists()
|
|
assert (claude_dir / "speckit.shortcut.md").exists()
|
|
|
|
def test_register_commands_for_copilot(self, extension_dir, project_dir):
|
|
"""Test registering commands for Copilot agent with .agent.md extension."""
|
|
# Create .github/agents directory (Copilot project)
|
|
agents_dir = project_dir / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
registrar = CommandRegistrar()
|
|
registered = registrar.register_commands_for_agent(
|
|
"copilot", manifest, extension_dir, project_dir
|
|
)
|
|
|
|
assert len(registered) == 1
|
|
assert "speckit.test.hello" in registered
|
|
|
|
# Verify command file uses .agent.md extension
|
|
cmd_file = agents_dir / "speckit.test.hello.agent.md"
|
|
assert cmd_file.exists()
|
|
|
|
# Verify NO plain .md file was created
|
|
plain_md_file = agents_dir / "speckit.test.hello.md"
|
|
assert not plain_md_file.exists()
|
|
|
|
content = cmd_file.read_text()
|
|
assert "description: Test hello command" in content
|
|
assert "<!-- Extension: test-ext -->" in content
|
|
|
|
def test_copilot_companion_prompt_created(self, extension_dir, project_dir):
|
|
"""Test that companion .prompt.md files are created in .github/prompts/."""
|
|
agents_dir = project_dir / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
registrar = CommandRegistrar()
|
|
registrar.register_commands_for_agent(
|
|
"copilot", manifest, extension_dir, project_dir
|
|
)
|
|
|
|
# Verify companion .prompt.md file exists
|
|
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
|
|
assert prompt_file.exists()
|
|
|
|
# Verify content has correct agent frontmatter
|
|
content = prompt_file.read_text()
|
|
assert content == "---\nagent: speckit.test.hello\n---\n"
|
|
|
|
def test_copilot_aliases_get_companion_prompts(self, project_dir, temp_dir):
|
|
"""Test that aliases also get companion .prompt.md files for Copilot."""
|
|
import yaml
|
|
|
|
ext_dir = temp_dir / "ext-alias-copilot"
|
|
ext_dir.mkdir()
|
|
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "ext-alias-copilot",
|
|
"name": "Extension with Alias",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.alias-copilot.cmd",
|
|
"file": "commands/cmd.md",
|
|
"aliases": ["speckit.shortcut-copilot"],
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "cmd.md").write_text(
|
|
"---\ndescription: Test\n---\n\nTest"
|
|
)
|
|
|
|
# Set up Copilot project
|
|
(project_dir / ".github" / "agents").mkdir(parents=True)
|
|
|
|
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
|
registrar = CommandRegistrar()
|
|
registered = registrar.register_commands_for_agent(
|
|
"copilot", manifest, ext_dir, project_dir
|
|
)
|
|
|
|
assert len(registered) == 2
|
|
|
|
# Both primary and alias get companion .prompt.md
|
|
prompts_dir = project_dir / ".github" / "prompts"
|
|
assert (prompts_dir / "speckit.alias-copilot.cmd.prompt.md").exists()
|
|
assert (prompts_dir / "speckit.shortcut-copilot.prompt.md").exists()
|
|
|
|
def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir):
|
|
"""Test that non-copilot agents do NOT create .prompt.md files."""
|
|
claude_dir = project_dir / ".claude" / "commands"
|
|
claude_dir.mkdir(parents=True)
|
|
|
|
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
|
|
|
registrar = CommandRegistrar()
|
|
registrar.register_commands_for_agent(
|
|
"claude", manifest, extension_dir, project_dir
|
|
)
|
|
|
|
# No .github/prompts directory should exist
|
|
prompts_dir = project_dir / ".github" / "prompts"
|
|
assert not prompts_dir.exists()
|
|
|
|
|
|
# ===== Utility Function Tests =====
|
|
|
|
class TestVersionSatisfies:
|
|
"""Test version_satisfies utility function."""
|
|
|
|
def test_version_satisfies_simple(self):
|
|
"""Test simple version comparison."""
|
|
assert version_satisfies("1.0.0", ">=1.0.0")
|
|
assert version_satisfies("1.0.1", ">=1.0.0")
|
|
assert not version_satisfies("0.9.9", ">=1.0.0")
|
|
|
|
def test_version_satisfies_range(self):
|
|
"""Test version range."""
|
|
assert version_satisfies("1.5.0", ">=1.0.0,<2.0.0")
|
|
assert not version_satisfies("2.0.0", ">=1.0.0,<2.0.0")
|
|
assert not version_satisfies("0.9.0", ">=1.0.0,<2.0.0")
|
|
|
|
def test_version_satisfies_complex(self):
|
|
"""Test complex version specifier."""
|
|
assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3")
|
|
assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3")
|
|
|
|
def test_version_satisfies_invalid(self):
|
|
"""Test invalid version strings."""
|
|
assert not version_satisfies("invalid", ">=1.0.0")
|
|
assert not version_satisfies("1.0.0", "invalid specifier")
|
|
|
|
|
|
# ===== Integration Tests =====
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for complete workflows."""
|
|
|
|
def test_full_install_and_remove_workflow(self, extension_dir, project_dir):
|
|
"""Test complete installation and removal workflow."""
|
|
# Create Claude directory
|
|
(project_dir / ".claude" / "commands").mkdir(parents=True)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Install
|
|
manager.install_from_directory(
|
|
extension_dir,
|
|
"0.1.0",
|
|
register_commands=True
|
|
)
|
|
|
|
# Verify installation
|
|
assert manager.registry.is_installed("test-ext")
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["id"] == "test-ext"
|
|
|
|
# Verify command registered
|
|
cmd_file = project_dir / ".claude" / "commands" / "speckit.test.hello.md"
|
|
assert cmd_file.exists()
|
|
|
|
# Verify registry has registered commands (now a dict keyed by agent)
|
|
metadata = manager.registry.get("test-ext")
|
|
registered_commands = metadata["registered_commands"]
|
|
# Check that the command is registered for at least one agent
|
|
assert any(
|
|
"speckit.test.hello" in cmds
|
|
for cmds in registered_commands.values()
|
|
)
|
|
|
|
# Remove
|
|
result = manager.remove("test-ext")
|
|
assert result is True
|
|
|
|
# Verify removal
|
|
assert not manager.registry.is_installed("test-ext")
|
|
assert not cmd_file.exists()
|
|
assert len(manager.list_installed()) == 0
|
|
|
|
def test_copilot_cleanup_removes_prompt_files(self, extension_dir, project_dir):
|
|
"""Test that removing a Copilot extension also removes .prompt.md files."""
|
|
agents_dir = project_dir / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
|
|
|
|
# Verify copilot was detected and registered
|
|
metadata = manager.registry.get("test-ext")
|
|
assert "copilot" in metadata["registered_commands"]
|
|
|
|
# Verify files exist before cleanup
|
|
agent_file = agents_dir / "speckit.test.hello.agent.md"
|
|
prompt_file = project_dir / ".github" / "prompts" / "speckit.test.hello.prompt.md"
|
|
assert agent_file.exists()
|
|
assert prompt_file.exists()
|
|
|
|
# Use the extension manager to remove — exercises the copilot prompt cleanup code
|
|
result = manager.remove("test-ext")
|
|
assert result is True
|
|
|
|
assert not agent_file.exists()
|
|
assert not prompt_file.exists()
|
|
|
|
def test_multiple_extensions(self, temp_dir, project_dir):
|
|
"""Test installing multiple extensions."""
|
|
import yaml
|
|
|
|
# Create two extensions
|
|
for i in range(1, 3):
|
|
ext_dir = temp_dir / f"ext{i}"
|
|
ext_dir.mkdir()
|
|
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": f"ext{i}",
|
|
"name": f"Extension {i}",
|
|
"version": "1.0.0",
|
|
"description": f"Extension {i}",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": f"speckit.ext{i}.cmd",
|
|
"file": "commands/cmd.md",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
with open(ext_dir / "extension.yml", 'w') as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\nTest")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
|
|
# Install both
|
|
manager.install_from_directory(temp_dir / "ext1", "0.1.0", register_commands=False)
|
|
manager.install_from_directory(temp_dir / "ext2", "0.1.0", register_commands=False)
|
|
|
|
# Verify both installed
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 2
|
|
assert {ext["id"] for ext in installed} == {"ext1", "ext2"}
|
|
|
|
# Remove first
|
|
manager.remove("ext1")
|
|
|
|
# Verify only second remains
|
|
installed = manager.list_installed()
|
|
assert len(installed) == 1
|
|
assert installed[0]["id"] == "ext2"
|
|
|
|
|
|
# ===== Extension Catalog Tests =====
|
|
|
|
|
|
class TestExtensionCatalog:
|
|
"""Test extension catalog functionality."""
|
|
|
|
def test_catalog_initialization(self, temp_dir):
|
|
"""Test catalog initialization."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
assert catalog.project_root == project_dir
|
|
assert catalog.cache_dir == project_dir / ".specify" / "extensions" / ".cache"
|
|
|
|
def test_cache_directory_creation(self, temp_dir):
|
|
"""Test catalog cache directory is created when fetching."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog data
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"test-ext": {
|
|
"name": "Test Extension",
|
|
"id": "test-ext",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
}
|
|
},
|
|
}
|
|
|
|
# Manually save to cache to test cache reading
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com/catalog.json",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Should use cache
|
|
result = catalog.fetch_catalog()
|
|
assert result == catalog_data
|
|
|
|
def test_cache_expiration(self, temp_dir):
|
|
"""Test that expired cache is not used."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create expired cache
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
|
|
# Set cache time to 2 hours ago (expired)
|
|
expired_time = datetime.now(timezone.utc).timestamp() - 7200
|
|
expired_datetime = datetime.fromtimestamp(expired_time, tz=timezone.utc)
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": expired_datetime.isoformat(),
|
|
"catalog_url": "http://test.com/catalog.json",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Cache should be invalid
|
|
assert not catalog.is_cache_valid()
|
|
|
|
def test_search_all_extensions(self, temp_dir):
|
|
"""Test searching all extensions without filters."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
# Use a single-catalog config so community extensions don't interfere
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": "test-catalog",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
},
|
|
f,
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira integration",
|
|
"author": "Stats Perform",
|
|
"tags": ["issue-tracking", "jira"],
|
|
"verified": True,
|
|
},
|
|
"linear": {
|
|
"name": "Linear Integration",
|
|
"id": "linear",
|
|
"version": "0.9.0",
|
|
"description": "Linear integration",
|
|
"author": "Community",
|
|
"tags": ["issue-tracking"],
|
|
"verified": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
# Save to cache
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Search without filters
|
|
results = catalog.search()
|
|
assert len(results) == 2
|
|
|
|
def test_search_by_query(self, temp_dir):
|
|
"""Test searching by query text."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
# Use a single-catalog config so community extensions don't interfere
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": "test-catalog",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
},
|
|
f,
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira issue tracking",
|
|
"tags": ["jira"],
|
|
},
|
|
"linear": {
|
|
"name": "Linear Integration",
|
|
"id": "linear",
|
|
"version": "1.0.0",
|
|
"description": "Linear project management",
|
|
"tags": ["linear"],
|
|
},
|
|
},
|
|
}
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Search for "jira"
|
|
results = catalog.search(query="jira")
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "jira"
|
|
|
|
def test_search_by_tag(self, temp_dir):
|
|
"""Test searching by tag."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
# Use a single-catalog config so community extensions don't interfere
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": "test-catalog",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
},
|
|
f,
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira",
|
|
"tags": ["issue-tracking", "jira"],
|
|
},
|
|
"linear": {
|
|
"name": "Linear",
|
|
"id": "linear",
|
|
"version": "1.0.0",
|
|
"description": "Linear",
|
|
"tags": ["issue-tracking", "linear"],
|
|
},
|
|
"github": {
|
|
"name": "GitHub",
|
|
"id": "github",
|
|
"version": "1.0.0",
|
|
"description": "GitHub",
|
|
"tags": ["vcs", "github"],
|
|
},
|
|
},
|
|
}
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Search by tag "issue-tracking"
|
|
results = catalog.search(tag="issue-tracking")
|
|
assert len(results) == 2
|
|
assert {r["id"] for r in results} == {"jira", "linear"}
|
|
|
|
def test_search_verified_only(self, temp_dir):
|
|
"""Test searching verified extensions only."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
# Use a single-catalog config so community extensions don't interfere
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": "test-catalog",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
},
|
|
f,
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira",
|
|
"verified": True,
|
|
},
|
|
"linear": {
|
|
"name": "Linear",
|
|
"id": "linear",
|
|
"version": "1.0.0",
|
|
"description": "Linear",
|
|
"verified": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Search verified only
|
|
results = catalog.search(verified_only=True)
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "jira"
|
|
|
|
def test_get_extension_info(self, temp_dir):
|
|
"""Test getting specific extension info."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
# Use a single-catalog config so community extensions don't interfere
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump(
|
|
{
|
|
"catalogs": [
|
|
{
|
|
"name": "test-catalog",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
]
|
|
},
|
|
f,
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create mock catalog
|
|
catalog_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira integration",
|
|
"author": "Stats Perform",
|
|
},
|
|
},
|
|
}
|
|
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": "http://test.com",
|
|
}
|
|
)
|
|
)
|
|
|
|
# Get extension info
|
|
info = catalog.get_extension_info("jira")
|
|
assert info is not None
|
|
assert info["id"] == "jira"
|
|
assert info["name"] == "Jira Integration"
|
|
|
|
# Non-existent extension
|
|
info = catalog.get_extension_info("nonexistent")
|
|
assert info is None
|
|
|
|
def test_clear_cache(self, temp_dir):
|
|
"""Test clearing catalog cache."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Create cache
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text("{}")
|
|
catalog.cache_metadata_file.write_text("{}")
|
|
|
|
assert catalog.cache_file.exists()
|
|
assert catalog.cache_metadata_file.exists()
|
|
|
|
# Clear cache
|
|
catalog.clear_cache()
|
|
|
|
assert not catalog.cache_file.exists()
|
|
assert not catalog.cache_metadata_file.exists()
|
|
|
|
|
|
# ===== CatalogEntry Tests =====
|
|
|
|
class TestCatalogEntry:
|
|
"""Test CatalogEntry dataclass."""
|
|
|
|
def test_catalog_entry_creation(self):
|
|
"""Test creating a CatalogEntry."""
|
|
entry = CatalogEntry(
|
|
url="https://example.com/catalog.json",
|
|
name="test",
|
|
priority=1,
|
|
install_allowed=True,
|
|
)
|
|
assert entry.url == "https://example.com/catalog.json"
|
|
assert entry.name == "test"
|
|
assert entry.priority == 1
|
|
assert entry.install_allowed is True
|
|
|
|
|
|
# ===== Catalog Stack Tests =====
|
|
|
|
class TestCatalogStack:
|
|
"""Test multi-catalog stack support."""
|
|
|
|
def _make_project(self, temp_dir: Path) -> Path:
|
|
"""Create a minimal spec-kit project directory."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
return project_dir
|
|
|
|
def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:
|
|
"""Write extension-catalogs.yml to project .specify dir."""
|
|
import yaml as yaml_module
|
|
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump({"catalogs": catalogs}, f)
|
|
|
|
def _write_valid_cache(
|
|
self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com"
|
|
) -> None:
|
|
"""Populate the primary cache file with mock extension data."""
|
|
catalog_data = {"schema_version": "1.0", "extensions": extensions}
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(catalog_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
"catalog_url": url,
|
|
}
|
|
)
|
|
)
|
|
|
|
# --- get_active_catalogs ---
|
|
|
|
def test_default_stack(self, temp_dir):
|
|
"""Default stack includes default and community catalogs."""
|
|
project_dir = self._make_project(temp_dir)
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
assert len(entries) == 2
|
|
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
|
assert entries[0].name == "default"
|
|
assert entries[0].priority == 1
|
|
assert entries[0].install_allowed is True
|
|
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
|
|
assert entries[1].name == "community"
|
|
assert entries[1].priority == 2
|
|
assert entries[1].install_allowed is False
|
|
|
|
def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):
|
|
"""SPECKIT_CATALOG_URL replaces the entire default stack."""
|
|
project_dir = self._make_project(temp_dir)
|
|
custom_url = "https://example.com/catalog.json"
|
|
monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
assert len(entries) == 1
|
|
assert entries[0].url == custom_url
|
|
assert entries[0].install_allowed is True
|
|
|
|
def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):
|
|
"""SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError."""
|
|
project_dir = self._make_project(temp_dir)
|
|
monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json")
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
with pytest.raises(ValidationError, match="HTTPS"):
|
|
catalog.get_active_catalogs()
|
|
|
|
def test_project_config_overrides_defaults(self, temp_dir):
|
|
"""Project-level extension-catalogs.yml overrides default stack."""
|
|
project_dir = self._make_project(temp_dir)
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "custom",
|
|
"url": "https://example.com/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
assert len(entries) == 1
|
|
assert entries[0].url == "https://example.com/catalog.json"
|
|
assert entries[0].name == "custom"
|
|
|
|
def test_project_config_sorted_by_priority(self, temp_dir):
|
|
"""Catalog entries are sorted by priority (ascending)."""
|
|
project_dir = self._make_project(temp_dir)
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "secondary",
|
|
"url": "https://example.com/secondary.json",
|
|
"priority": 5,
|
|
"install_allowed": False,
|
|
},
|
|
{
|
|
"name": "primary",
|
|
"url": "https://example.com/primary.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
},
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
assert len(entries) == 2
|
|
assert entries[0].name == "primary"
|
|
assert entries[1].name == "secondary"
|
|
|
|
def test_project_config_invalid_url_raises(self, temp_dir):
|
|
"""Project config with HTTP (non-localhost) URL raises ValidationError."""
|
|
project_dir = self._make_project(temp_dir)
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "bad",
|
|
"url": "http://example.com/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
with pytest.raises(ValidationError, match="HTTPS"):
|
|
catalog.get_active_catalogs()
|
|
|
|
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
|
|
"""Empty catalogs list in config falls back to default stack."""
|
|
import yaml as yaml_module
|
|
|
|
project_dir = self._make_project(temp_dir)
|
|
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
|
with open(config_path, "w") as f:
|
|
yaml_module.dump({"catalogs": []}, f)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
# Falls back to default stack
|
|
assert len(entries) == 2
|
|
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
|
|
|
|
# --- _load_catalog_config ---
|
|
|
|
def test_load_catalog_config_missing_file(self, temp_dir):
|
|
"""Returns None when config file doesn't exist."""
|
|
project_dir = self._make_project(temp_dir)
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
|
|
assert result is None
|
|
|
|
def test_load_catalog_config_localhost_allowed(self, temp_dir):
|
|
"""Localhost HTTP URLs are allowed in config."""
|
|
project_dir = self._make_project(temp_dir)
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "local",
|
|
"url": "http://localhost:8000/catalog.json",
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
entries = catalog.get_active_catalogs()
|
|
|
|
assert len(entries) == 1
|
|
assert entries[0].url == "http://localhost:8000/catalog.json"
|
|
|
|
# --- Merge conflict resolution ---
|
|
|
|
def test_merge_conflict_higher_priority_wins(self, temp_dir):
|
|
"""When same extension id is in two catalogs, higher priority wins."""
|
|
project_dir = self._make_project(temp_dir)
|
|
|
|
# Write project config with two catalogs
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "primary",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
},
|
|
{
|
|
"name": "secondary",
|
|
"url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
|
|
"priority": 2,
|
|
"install_allowed": False,
|
|
},
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
|
|
# Write primary cache with jira v2.0.0
|
|
primary_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "2.0.0",
|
|
"description": "Primary Jira",
|
|
}
|
|
},
|
|
}
|
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
catalog.cache_file.write_text(json.dumps(primary_data))
|
|
catalog.cache_metadata_file.write_text(
|
|
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
|
|
)
|
|
|
|
# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
|
|
import hashlib
|
|
|
|
url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
|
|
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
|
|
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
|
|
secondary_data = {
|
|
"schema_version": "1.0",
|
|
"extensions": {
|
|
"jira": {
|
|
"name": "Jira Integration Community",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Community Jira",
|
|
},
|
|
"linear": {
|
|
"name": "Linear",
|
|
"id": "linear",
|
|
"version": "0.9.0",
|
|
"description": "Linear from secondary",
|
|
},
|
|
},
|
|
}
|
|
secondary_cache.write_text(json.dumps(secondary_data))
|
|
secondary_meta.write_text(
|
|
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
|
|
)
|
|
|
|
results = catalog.search()
|
|
jira_results = [r for r in results if r["id"] == "jira"]
|
|
assert len(jira_results) == 1
|
|
# Primary catalog wins
|
|
assert jira_results[0]["version"] == "2.0.0"
|
|
assert jira_results[0]["_catalog_name"] == "primary"
|
|
assert jira_results[0]["_install_allowed"] is True
|
|
|
|
# linear comes from secondary
|
|
linear_results = [r for r in results if r["id"] == "linear"]
|
|
assert len(linear_results) == 1
|
|
assert linear_results[0]["_catalog_name"] == "secondary"
|
|
assert linear_results[0]["_install_allowed"] is False
|
|
|
|
def test_install_allowed_false_from_get_extension_info(self, temp_dir):
|
|
"""get_extension_info includes _install_allowed from source catalog."""
|
|
project_dir = self._make_project(temp_dir)
|
|
|
|
# Single catalog that is install_allowed=False
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "discovery",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": False,
|
|
}
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
self._write_valid_cache(
|
|
catalog,
|
|
{
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira integration",
|
|
}
|
|
},
|
|
)
|
|
|
|
info = catalog.get_extension_info("jira")
|
|
assert info is not None
|
|
assert info["_install_allowed"] is False
|
|
assert info["_catalog_name"] == "discovery"
|
|
|
|
def test_search_results_include_catalog_metadata(self, temp_dir):
|
|
"""Search results include _catalog_name and _install_allowed."""
|
|
project_dir = self._make_project(temp_dir)
|
|
self._write_catalog_config(
|
|
project_dir,
|
|
[
|
|
{
|
|
"name": "org",
|
|
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
|
"priority": 1,
|
|
"install_allowed": True,
|
|
}
|
|
],
|
|
)
|
|
|
|
catalog = ExtensionCatalog(project_dir)
|
|
self._write_valid_cache(
|
|
catalog,
|
|
{
|
|
"jira": {
|
|
"name": "Jira Integration",
|
|
"id": "jira",
|
|
"version": "1.0.0",
|
|
"description": "Jira integration",
|
|
}
|
|
},
|
|
)
|
|
|
|
results = catalog.search()
|
|
assert len(results) == 1
|
|
assert results[0]["_catalog_name"] == "org"
|
|
assert results[0]["_install_allowed"] is True
|