mirror of
https://github.com/github/spec-kit.git
synced 2026-03-16 18:33:07 +00:00
* feat: add kiro-cli and AGENT_CONFIG consistency coverage * fix: address PR feedback for kiro-cli migration * test: assert init invocation result in --here mode test * test: capture init result in here-mode command test * chore: save local unapproved work in progress * fix: resolve remaining PR1690 review threads * Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: reduce brittleness in ai help alias assertion * fix: resolve PR1690 ruff syntax regression --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1136 lines
37 KiB
Python
1136 lines
37 KiB
Python
"""
|
|
Unit tests for the extension system.
|
|
|
|
Tests cover:
|
|
- Extension manifest validation
|
|
- Extension registry operations
|
|
- Extension manager installation/removal
|
|
- Command registration
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
from specify_cli.extensions import (
|
|
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."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
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."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
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."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
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."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
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."""
|
|
project_dir = temp_dir / "project"
|
|
project_dir.mkdir()
|
|
(project_dir / ".specify").mkdir()
|
|
|
|
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()
|