802 lines
31 KiB
Python
802 lines
31 KiB
Python
"""
|
|
Tests for skill_manager.py - Skill management functionality.
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
import tempfile
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
import httpx
|
|
|
|
from app.core.skill_manager import (
|
|
Skill,
|
|
SkillTemplate,
|
|
SkillManager,
|
|
CommunitySkill,
|
|
CommunitySkillsManager,
|
|
COMMUNITY_SOURCES,
|
|
list_skills,
|
|
list_skill_templates,
|
|
list_community_skills,
|
|
install_community_skill,
|
|
get_community_manager,
|
|
)
|
|
|
|
|
|
class TestSkillDataclass:
|
|
"""Tests for Skill dataclass."""
|
|
|
|
def test_skill_creation(self):
|
|
"""Test basic Skill creation."""
|
|
skill = Skill(
|
|
name="test-skill",
|
|
path="/path/to/skill",
|
|
description="A test skill",
|
|
scope="global",
|
|
source="custom"
|
|
)
|
|
assert skill.name == "test-skill"
|
|
assert skill.path == "/path/to/skill"
|
|
assert skill.description == "A test skill"
|
|
assert skill.scope == "global"
|
|
assert skill.source == "custom"
|
|
assert skill.content == ""
|
|
assert skill.enabled is True
|
|
assert skill.category is None
|
|
assert skill.tags == []
|
|
|
|
def test_skill_with_all_fields(self):
|
|
"""Test Skill with all fields populated."""
|
|
skill = Skill(
|
|
name="full-skill",
|
|
path="/path/to/skill",
|
|
description="Full skill",
|
|
scope="project",
|
|
source="template",
|
|
content="# Skill content",
|
|
enabled=False,
|
|
category="testing",
|
|
tags=["test", "demo"],
|
|
created="2025-01-01T00:00:00",
|
|
modified="2025-01-02T00:00:00"
|
|
)
|
|
assert skill.enabled is False
|
|
assert skill.category == "testing"
|
|
assert skill.tags == ["test", "demo"]
|
|
assert skill.created == "2025-01-01T00:00:00"
|
|
assert skill.modified == "2025-01-02T00:00:00"
|
|
|
|
def test_skill_to_dict(self):
|
|
"""Test Skill.to_dict() method."""
|
|
skill = Skill(
|
|
name="test-skill",
|
|
path="/path",
|
|
description="Test",
|
|
scope="global",
|
|
source="custom",
|
|
category="testing",
|
|
tags=["a", "b"]
|
|
)
|
|
result = skill.to_dict()
|
|
|
|
assert isinstance(result, dict)
|
|
assert result["name"] == "test-skill"
|
|
assert result["path"] == "/path"
|
|
assert result["description"] == "Test"
|
|
assert result["scope"] == "global"
|
|
assert result["source"] == "custom"
|
|
assert result["category"] == "testing"
|
|
assert result["tags"] == ["a", "b"]
|
|
|
|
|
|
class TestSkillTemplateDataclass:
|
|
"""Tests for SkillTemplate dataclass."""
|
|
|
|
def test_template_creation(self):
|
|
"""Test basic SkillTemplate creation."""
|
|
template = SkillTemplate(
|
|
name="template-name",
|
|
category="productivity",
|
|
description="A template",
|
|
path="/path/to/template"
|
|
)
|
|
assert template.name == "template-name"
|
|
assert template.category == "productivity"
|
|
assert template.description == "A template"
|
|
assert template.path == "/path/to/template"
|
|
assert template.tags == []
|
|
assert template.version == "1.0.0"
|
|
|
|
def test_template_to_dict(self):
|
|
"""Test SkillTemplate.to_dict() method."""
|
|
template = SkillTemplate(
|
|
name="test",
|
|
category="test-cat",
|
|
description="Test template",
|
|
path="/path",
|
|
tags=["tag1"],
|
|
version="2.0.0"
|
|
)
|
|
result = template.to_dict()
|
|
|
|
assert result["name"] == "test"
|
|
assert result["category"] == "test-cat"
|
|
assert result["description"] == "Test template"
|
|
assert result["tags"] == ["tag1"]
|
|
assert result["version"] == "2.0.0"
|
|
|
|
|
|
class TestCommunitySkillDataclass:
|
|
"""Tests for CommunitySkill dataclass."""
|
|
|
|
def test_community_skill_creation(self):
|
|
"""Test CommunitySkill creation."""
|
|
skill = CommunitySkill(
|
|
name="frontend-design",
|
|
description="Design skills",
|
|
source="anthropic",
|
|
repo="anthropics/skills",
|
|
path="skills/frontend-design"
|
|
)
|
|
assert skill.name == "frontend-design"
|
|
assert skill.source == "anthropic"
|
|
assert skill.repo == "anthropics/skills"
|
|
assert skill.tags == []
|
|
assert skill.url == ""
|
|
|
|
def test_community_skill_to_dict(self):
|
|
"""Test CommunitySkill.to_dict() method."""
|
|
skill = CommunitySkill(
|
|
name="test-skill",
|
|
description="Test",
|
|
source="superpowers",
|
|
repo="obra/superpowers",
|
|
path="skills/test",
|
|
tags=["testing"],
|
|
url="https://github.com/obra/superpowers"
|
|
)
|
|
result = skill.to_dict()
|
|
|
|
assert result["name"] == "test-skill"
|
|
assert result["source"] == "superpowers"
|
|
assert result["repo"] == "obra/superpowers"
|
|
assert result["tags"] == ["testing"]
|
|
assert result["url"] == "https://github.com/obra/superpowers"
|
|
|
|
|
|
class TestSkillManager:
|
|
"""Tests for SkillManager class."""
|
|
|
|
@pytest.fixture
|
|
def temp_dir(self):
|
|
"""Create a temporary directory for skills."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield Path(tmpdir)
|
|
|
|
@pytest.fixture
|
|
def skill_manager(self, temp_dir):
|
|
"""Create SkillManager with mocked paths."""
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', temp_dir / "global_skills"):
|
|
with patch.object(SkillManager, 'TEMPLATES_DIR', temp_dir / "templates"):
|
|
manager = SkillManager(project_path=str(temp_dir / "project"))
|
|
manager.GLOBAL_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
yield manager
|
|
|
|
def test_init_without_project(self, temp_dir):
|
|
"""Test SkillManager initialization without project path."""
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', temp_dir / "global"):
|
|
manager = SkillManager()
|
|
assert manager.project_path is None
|
|
assert manager.project_skills_dir is None
|
|
|
|
def test_init_with_project(self, temp_dir):
|
|
"""Test SkillManager initialization with project path."""
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', temp_dir / "global"):
|
|
manager = SkillManager(project_path=str(temp_dir / "myproject"))
|
|
assert manager.project_path == temp_dir / "myproject"
|
|
assert manager.project_skills_dir == temp_dir / "myproject" / ".claude" / "skills"
|
|
|
|
def test_list_global_skills_empty(self, skill_manager):
|
|
"""Test listing global skills when none exist."""
|
|
skills = skill_manager.list_global_skills()
|
|
assert skills == []
|
|
|
|
def test_list_global_skills_with_skills(self, skill_manager):
|
|
"""Test listing global skills with existing skills."""
|
|
# Create a skill directory
|
|
skill_dir = skill_manager.GLOBAL_SKILLS_DIR / "test-skill"
|
|
skill_dir.mkdir(parents=True)
|
|
|
|
# Create skill.md
|
|
(skill_dir / "skill.md").write_text("# Test Skill\n\nThis is a test skill.")
|
|
|
|
skills = skill_manager.list_global_skills()
|
|
assert len(skills) == 1
|
|
assert skills[0].name == "test-skill"
|
|
assert skills[0].scope == "global"
|
|
|
|
def test_list_global_skills_with_metadata(self, skill_manager):
|
|
"""Test listing global skills with metadata.json."""
|
|
skill_dir = skill_manager.GLOBAL_SKILLS_DIR / "documented-skill"
|
|
skill_dir.mkdir(parents=True)
|
|
|
|
# Create metadata
|
|
metadata = {
|
|
"name": "documented-skill",
|
|
"description": "A well documented skill",
|
|
"category": "productivity",
|
|
"tags": ["demo", "test"],
|
|
"source": "custom"
|
|
}
|
|
(skill_dir / "metadata.json").write_text(json.dumps(metadata))
|
|
(skill_dir / "skill.md").write_text("Skill content")
|
|
|
|
skills = skill_manager.list_global_skills()
|
|
assert len(skills) == 1
|
|
assert skills[0].description == "A well documented skill"
|
|
assert skills[0].category == "productivity"
|
|
assert skills[0].tags == ["demo", "test"]
|
|
|
|
def test_list_project_skills_no_project(self, temp_dir):
|
|
"""Test listing project skills without project path."""
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', temp_dir / "global"):
|
|
manager = SkillManager()
|
|
skills = manager.list_project_skills()
|
|
assert skills == []
|
|
|
|
def test_list_project_skills_with_skills(self, skill_manager, temp_dir):
|
|
"""Test listing project skills with existing skills."""
|
|
# Create project skills directory
|
|
project_skills_dir = temp_dir / "project" / ".claude" / "skills" / "my-skill"
|
|
project_skills_dir.mkdir(parents=True)
|
|
(project_skills_dir / "skill.md").write_text("# My Skill")
|
|
|
|
skills = skill_manager.list_project_skills()
|
|
assert len(skills) == 1
|
|
assert skills[0].name == "my-skill"
|
|
assert skills[0].scope == "project"
|
|
|
|
def test_list_all_skills(self, skill_manager, temp_dir):
|
|
"""Test listing all skills (global and project)."""
|
|
# Create global skill
|
|
global_skill = skill_manager.GLOBAL_SKILLS_DIR / "global-skill"
|
|
global_skill.mkdir(parents=True)
|
|
(global_skill / "skill.md").write_text("Global")
|
|
|
|
# Create project skill
|
|
project_skill = temp_dir / "project" / ".claude" / "skills" / "project-skill"
|
|
project_skill.mkdir(parents=True)
|
|
(project_skill / "skill.md").write_text("Project")
|
|
|
|
all_skills = skill_manager.list_all_skills()
|
|
assert "global" in all_skills
|
|
assert "project" in all_skills
|
|
assert len(all_skills["global"]) == 1
|
|
assert len(all_skills["project"]) == 1
|
|
|
|
def test_get_skill_global(self, skill_manager):
|
|
"""Test getting a global skill by name."""
|
|
skill_dir = skill_manager.GLOBAL_SKILLS_DIR / "my-skill"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "skill.md").write_text("# Skill Content\n\nDetails here")
|
|
|
|
skill = skill_manager.get_skill("my-skill", "global")
|
|
assert skill is not None
|
|
assert skill.name == "my-skill"
|
|
assert "Skill Content" in skill.content
|
|
|
|
def test_get_skill_not_found(self, skill_manager):
|
|
"""Test getting a non-existent skill."""
|
|
skill = skill_manager.get_skill("nonexistent", "global")
|
|
assert skill is None
|
|
|
|
def test_get_skill_invalid_scope(self, skill_manager):
|
|
"""Test getting skill with invalid scope."""
|
|
skill = skill_manager.get_skill("test", "invalid")
|
|
assert skill is None
|
|
|
|
def test_create_skill(self, skill_manager, temp_dir):
|
|
"""Test creating a new custom skill."""
|
|
# Ensure project skills directory can be created
|
|
skill = skill_manager.create_skill(
|
|
name="new-skill",
|
|
description="A new skill",
|
|
content="# New Skill\n\nContent here",
|
|
category="productivity",
|
|
tags=["new", "test"]
|
|
)
|
|
|
|
assert skill.name == "new-skill"
|
|
assert skill.description == "A new skill"
|
|
assert skill.category == "productivity"
|
|
assert skill.scope == "project"
|
|
|
|
# Verify files were created
|
|
skill_path = temp_dir / "project" / ".claude" / "skills" / "new-skill"
|
|
assert (skill_path / "skill.md").exists()
|
|
assert (skill_path / "metadata.json").exists()
|
|
|
|
def test_create_skill_no_project(self, temp_dir):
|
|
"""Test creating skill without project path."""
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', temp_dir / "global"):
|
|
manager = SkillManager()
|
|
with pytest.raises(ValueError, match="No project path set"):
|
|
manager.create_skill("test", "desc", "content")
|
|
|
|
def test_create_skill_already_exists(self, skill_manager, temp_dir):
|
|
"""Test creating skill that already exists."""
|
|
# Create existing skill
|
|
skill_path = temp_dir / "project" / ".claude" / "skills" / "existing"
|
|
skill_path.mkdir(parents=True)
|
|
(skill_path / "skill.md").write_text("Existing")
|
|
|
|
with pytest.raises(FileExistsError, match="Skill already exists"):
|
|
skill_manager.create_skill("existing", "desc", "content")
|
|
|
|
def test_update_skill(self, skill_manager, temp_dir):
|
|
"""Test updating an existing skill."""
|
|
# Create skill first
|
|
skill_path = temp_dir / "project" / ".claude" / "skills" / "update-me"
|
|
skill_path.mkdir(parents=True)
|
|
(skill_path / "skill.md").write_text("Original content")
|
|
(skill_path / "metadata.json").write_text(json.dumps({"name": "update-me"}))
|
|
|
|
# Update it
|
|
skill = skill_manager.update_skill(
|
|
name="update-me",
|
|
content="Updated content",
|
|
description="New description",
|
|
tags=["updated"]
|
|
)
|
|
|
|
assert skill.name == "update-me"
|
|
assert "Updated content" in (skill_path / "skill.md").read_text()
|
|
|
|
def test_update_skill_not_found(self, skill_manager):
|
|
"""Test updating non-existent skill."""
|
|
with pytest.raises(FileNotFoundError, match="Skill not found"):
|
|
skill_manager.update_skill("nonexistent", content="new")
|
|
|
|
def test_delete_skill(self, skill_manager, temp_dir):
|
|
"""Test deleting a skill."""
|
|
# Create skill
|
|
skill_path = temp_dir / "project" / ".claude" / "skills" / "delete-me"
|
|
skill_path.mkdir(parents=True)
|
|
(skill_path / "skill.md").write_text("Delete me")
|
|
|
|
result = skill_manager.delete_skill("delete-me")
|
|
assert result is True
|
|
assert not skill_path.exists()
|
|
|
|
def test_delete_skill_not_found(self, skill_manager):
|
|
"""Test deleting non-existent skill."""
|
|
with pytest.raises(FileNotFoundError, match="Skill not found"):
|
|
skill_manager.delete_skill("nonexistent")
|
|
|
|
def test_list_templates(self, skill_manager, temp_dir):
|
|
"""Test listing skill templates."""
|
|
# Create template directories
|
|
templates_dir = skill_manager.TEMPLATES_DIR
|
|
templates_dir.mkdir(parents=True)
|
|
|
|
category_dir = templates_dir / "productivity"
|
|
category_dir.mkdir()
|
|
|
|
template_dir = category_dir / "time-tracker"
|
|
template_dir.mkdir()
|
|
(template_dir / "skill.md").write_text("# Time Tracker\n\nTrack your time.")
|
|
(template_dir / "metadata.json").write_text(json.dumps({
|
|
"name": "time-tracker",
|
|
"description": "Track your time"
|
|
}))
|
|
|
|
templates = skill_manager.list_templates()
|
|
assert len(templates) == 1
|
|
assert templates[0].name == "time-tracker"
|
|
assert templates[0].category == "productivity"
|
|
|
|
def test_list_templates_by_category(self, skill_manager, temp_dir):
|
|
"""Test listing templates filtered by category."""
|
|
templates_dir = skill_manager.TEMPLATES_DIR
|
|
templates_dir.mkdir(parents=True)
|
|
|
|
# Create two categories
|
|
for cat in ["productivity", "testing"]:
|
|
cat_dir = templates_dir / cat
|
|
cat_dir.mkdir()
|
|
(cat_dir / "skill-1").mkdir()
|
|
(cat_dir / "skill-1" / "skill.md").write_text(f"# {cat} skill")
|
|
|
|
# Filter by category
|
|
templates = skill_manager.list_templates(category="testing")
|
|
assert len(templates) == 1
|
|
assert templates[0].category == "testing"
|
|
|
|
def test_get_template_categories(self, skill_manager, temp_dir):
|
|
"""Test getting template categories."""
|
|
templates_dir = skill_manager.TEMPLATES_DIR
|
|
templates_dir.mkdir(parents=True)
|
|
|
|
for cat in ["productivity", "testing", "debugging"]:
|
|
(templates_dir / cat).mkdir()
|
|
|
|
categories = skill_manager.get_template_categories()
|
|
assert sorted(categories) == ["debugging", "productivity", "testing"]
|
|
|
|
def test_install_template(self, skill_manager, temp_dir):
|
|
"""Test installing a template to project."""
|
|
# Create template
|
|
templates_dir = skill_manager.TEMPLATES_DIR
|
|
templates_dir.mkdir(parents=True)
|
|
cat_dir = templates_dir / "testing"
|
|
cat_dir.mkdir()
|
|
template_dir = cat_dir / "tdd-skill"
|
|
template_dir.mkdir()
|
|
(template_dir / "skill.md").write_text("# TDD Skill")
|
|
(template_dir / "metadata.json").write_text(json.dumps({
|
|
"name": "tdd-skill",
|
|
"description": "Test-driven development"
|
|
}))
|
|
|
|
# Install it
|
|
skill = skill_manager.install_template("tdd-skill")
|
|
assert skill.name == "tdd-skill"
|
|
assert skill.scope == "project"
|
|
|
|
# Verify installed
|
|
installed_path = temp_dir / "project" / ".claude" / "skills" / "tdd-skill"
|
|
assert installed_path.exists()
|
|
|
|
def test_install_template_with_custom_name(self, skill_manager, temp_dir):
|
|
"""Test installing template with custom name."""
|
|
templates_dir = skill_manager.TEMPLATES_DIR
|
|
templates_dir.mkdir(parents=True)
|
|
cat_dir = templates_dir / "productivity"
|
|
cat_dir.mkdir()
|
|
template_dir = cat_dir / "original-name"
|
|
template_dir.mkdir()
|
|
(template_dir / "skill.md").write_text("# Skill")
|
|
(template_dir / "metadata.json").write_text(json.dumps({"name": "original-name"}))
|
|
|
|
skill = skill_manager.install_template("original-name", custom_name="my-custom-name")
|
|
assert skill.name == "my-custom-name"
|
|
|
|
def test_install_template_not_found(self, skill_manager):
|
|
"""Test installing non-existent template."""
|
|
with pytest.raises(ValueError, match="Template not found"):
|
|
skill_manager.install_template("nonexistent")
|
|
|
|
def test_parse_skill_with_symlink(self, skill_manager, temp_dir):
|
|
"""Test parsing skill that is a symlink."""
|
|
# Create actual skill
|
|
real_skill = temp_dir / "real-skill"
|
|
real_skill.mkdir()
|
|
(real_skill / "skill.md").write_text("# Real Skill")
|
|
|
|
# Create symlink
|
|
link_path = skill_manager.GLOBAL_SKILLS_DIR / "linked-skill"
|
|
link_path.symlink_to(real_skill)
|
|
|
|
skills = skill_manager.list_global_skills()
|
|
assert len(skills) == 1
|
|
assert skills[0].name == "linked-skill"
|
|
|
|
def test_parse_skill_extracts_description_from_content(self, skill_manager):
|
|
"""Test that description is extracted from content if not in metadata."""
|
|
skill_dir = skill_manager.GLOBAL_SKILLS_DIR / "no-meta"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "skill.md").write_text("# Header\n\nThis is the description paragraph.\n\nMore content.")
|
|
|
|
skill = skill_manager.get_skill("no-meta", "global")
|
|
assert skill is not None
|
|
assert "This is the description paragraph" in skill.description
|
|
|
|
def test_core_skills_marked_correctly(self, skill_manager):
|
|
"""Test that core skills are marked with correct source."""
|
|
# Create a core skill (memory is in CORE_SKILLS)
|
|
skill_dir = skill_manager.GLOBAL_SKILLS_DIR / "memory"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "skill.md").write_text("Memory skill")
|
|
|
|
skills = skill_manager.list_global_skills()
|
|
memory_skill = next(s for s in skills if s.name == "memory")
|
|
assert memory_skill.source == "claude-os-core"
|
|
|
|
|
|
class TestCommunitySkillsManager:
|
|
"""Tests for CommunitySkillsManager class."""
|
|
|
|
@pytest.fixture
|
|
def manager(self):
|
|
"""Create CommunitySkillsManager instance."""
|
|
return CommunitySkillsManager()
|
|
|
|
def test_cache_validity(self, manager):
|
|
"""Test cache validity checking."""
|
|
# Initially cache should be invalid
|
|
assert manager._is_cache_valid("test_key") is False
|
|
|
|
# After setting, should be valid
|
|
manager._set_cache("test_key", "value")
|
|
assert manager._is_cache_valid("test_key") is True
|
|
|
|
def test_set_cache(self, manager):
|
|
"""Test setting cache values."""
|
|
manager._set_cache("key1", {"data": "value"})
|
|
assert manager._cache["key1"] == {"data": "value"}
|
|
assert "key1" in manager._cache_times
|
|
|
|
def test_extract_description_from_frontmatter(self, manager):
|
|
"""Test extracting description from YAML frontmatter."""
|
|
content = """---
|
|
description: "This is the skill description"
|
|
version: 1.0
|
|
---
|
|
# Skill Title
|
|
|
|
Content here."""
|
|
|
|
description = manager._extract_description(content)
|
|
assert description == "This is the skill description"
|
|
|
|
def test_extract_description_from_paragraph(self, manager):
|
|
"""Test extracting description from first paragraph."""
|
|
content = """# Skill Title
|
|
|
|
This is the first paragraph that should be used as description.
|
|
|
|
More content here."""
|
|
|
|
description = manager._extract_description(content)
|
|
assert description == "This is the first paragraph that should be used as description."
|
|
|
|
def test_extract_description_truncates_long_text(self, manager):
|
|
"""Test that description is truncated to 200 chars."""
|
|
long_text = "A" * 500
|
|
content = f"# Title\n\n{long_text}"
|
|
|
|
description = manager._extract_description(content)
|
|
assert len(description) == 200
|
|
|
|
def test_infer_tags_testing(self, manager):
|
|
"""Test tag inference for testing-related skills."""
|
|
tags = manager._infer_tags("tdd-helper", "Test-driven development workflow")
|
|
assert "testing" in tags
|
|
|
|
def test_infer_tags_frontend(self, manager):
|
|
"""Test tag inference for frontend skills."""
|
|
tags = manager._infer_tags("react-components", "Build React UI components")
|
|
assert "frontend" in tags
|
|
|
|
def test_infer_tags_multiple(self, manager):
|
|
"""Test inferring multiple tags."""
|
|
tags = manager._infer_tags("debug-tests", "Debug and fix failing tests")
|
|
assert "testing" in tags
|
|
assert "debugging" in tags
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_community_skills_cached(self, manager):
|
|
"""Test that cache is used when valid."""
|
|
# The _fetch_skills_from_repo method checks cache internally
|
|
# So we need to test that _is_cache_valid returns True
|
|
cached_skills = [
|
|
CommunitySkill("skill1", "desc", "anthropic", "anthropics/skills", "skills/skill1"),
|
|
]
|
|
|
|
# Set cache directly and verify it's valid
|
|
manager._set_cache("skills_anthropic", cached_skills)
|
|
assert manager._is_cache_valid("skills_anthropic") is True
|
|
|
|
# The internal _fetch_skills_from_repo will return cached data
|
|
# We verify by checking the cache mechanism works
|
|
assert manager._cache["skills_anthropic"] == cached_skills
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_skills_from_repo_success(self, manager):
|
|
"""Test fetching skills from GitHub repo."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = [
|
|
{"type": "dir", "name": "skill-one"},
|
|
{"type": "dir", "name": "skill-two"},
|
|
{"type": "file", "name": "README.md"}, # Should be skipped
|
|
]
|
|
|
|
with patch.object(httpx.AsyncClient, 'get', new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
# Also mock description fetching
|
|
with patch.object(manager, '_fetch_skill_description', new_callable=AsyncMock) as mock_desc:
|
|
mock_desc.return_value = "Skill description"
|
|
|
|
skills = await manager._fetch_skills_from_repo(
|
|
"anthropic",
|
|
{"repo": "anthropics/skills", "skills_path": "skills"}
|
|
)
|
|
|
|
assert len(skills) == 2
|
|
assert skills[0].name == "skill-one"
|
|
assert skills[1].name == "skill-two"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_skills_from_repo_api_error(self, manager):
|
|
"""Test handling GitHub API errors."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 404
|
|
|
|
with patch.object(httpx.AsyncClient, 'get', new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = mock_response
|
|
|
|
skills = await manager._fetch_skills_from_repo(
|
|
"anthropic",
|
|
{"repo": "anthropics/skills", "skills_path": "skills"}
|
|
)
|
|
|
|
assert skills == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_skill_content(self, manager):
|
|
"""Test fetching skill content from GitHub."""
|
|
# Mock directory listing
|
|
dir_response = MagicMock()
|
|
dir_response.status_code = 200
|
|
dir_response.json.return_value = [
|
|
{"type": "file", "name": "skill.md"},
|
|
{"type": "file", "name": "metadata.json"},
|
|
]
|
|
|
|
# Mock file content
|
|
file_response = MagicMock()
|
|
file_response.status_code = 200
|
|
file_response.text = "File content"
|
|
|
|
with patch.object(httpx.AsyncClient, 'get', new_callable=AsyncMock) as mock_get:
|
|
mock_get.side_effect = [dir_response, file_response, file_response]
|
|
|
|
contents = await manager.get_skill_content("anthropics/skills", "skills/test")
|
|
|
|
assert "skill.md" in contents
|
|
assert "metadata.json" in contents
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_community_skill(self, manager):
|
|
"""Test installing a community skill."""
|
|
skill = CommunitySkill(
|
|
name="test-skill",
|
|
description="Test",
|
|
source="anthropic",
|
|
repo="anthropics/skills",
|
|
path="skills/test-skill",
|
|
tags=["testing"]
|
|
)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Mock get_skill_content
|
|
with patch.object(manager, 'get_skill_content', new_callable=AsyncMock) as mock_content:
|
|
mock_content.return_value = {
|
|
"SKILL.md": "# Skill Content",
|
|
"metadata.json": json.dumps({"name": "test-skill"})
|
|
}
|
|
|
|
installed = await manager.install_community_skill(skill, tmpdir)
|
|
|
|
assert installed.name == "test-skill"
|
|
assert installed.scope == "project"
|
|
|
|
# Verify files created
|
|
skill_path = Path(tmpdir) / ".claude" / "skills" / "test-skill"
|
|
assert skill_path.exists()
|
|
assert (skill_path / "skill.md").exists() # Normalized from SKILL.md
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_community_skill_already_exists(self, manager):
|
|
"""Test installing skill that already exists."""
|
|
skill = CommunitySkill("existing", "desc", "anthropic", "repo", "path")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create existing skill
|
|
existing = Path(tmpdir) / ".claude" / "skills" / "existing"
|
|
existing.mkdir(parents=True)
|
|
|
|
# Mock get_skill_content to avoid HTTP request
|
|
with patch.object(manager, 'get_skill_content', new_callable=AsyncMock) as mock_content:
|
|
mock_content.return_value = {"skill.md": "Content"}
|
|
|
|
with pytest.raises(FileExistsError, match="Skill already exists"):
|
|
await manager.install_community_skill(skill, tmpdir)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_community_skill_empty_content(self, manager):
|
|
"""Test installing skill with no content."""
|
|
skill = CommunitySkill("empty", "desc", "anthropic", "repo", "path")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch.object(manager, 'get_skill_content', new_callable=AsyncMock) as mock_content:
|
|
mock_content.return_value = {}
|
|
|
|
with pytest.raises(ValueError, match="No content found"):
|
|
await manager.install_community_skill(skill, tmpdir)
|
|
|
|
|
|
class TestConvenienceFunctions:
|
|
"""Tests for module-level convenience functions."""
|
|
|
|
def test_list_skills(self):
|
|
"""Test list_skills function."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch.object(SkillManager, 'GLOBAL_SKILLS_DIR', Path(tmpdir) / "global"):
|
|
Path(tmpdir, "global").mkdir()
|
|
|
|
result = list_skills()
|
|
assert "global" in result
|
|
assert "project" in result
|
|
assert isinstance(result["global"], list)
|
|
assert isinstance(result["project"], list)
|
|
|
|
def test_list_skill_templates(self):
|
|
"""Test list_skill_templates function."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
templates_dir = Path(tmpdir) / "templates"
|
|
templates_dir.mkdir()
|
|
|
|
with patch.object(SkillManager, 'TEMPLATES_DIR', templates_dir):
|
|
result = list_skill_templates()
|
|
assert isinstance(result, list)
|
|
|
|
def test_get_community_manager_singleton(self):
|
|
"""Test that get_community_manager returns singleton."""
|
|
manager1 = get_community_manager()
|
|
manager2 = get_community_manager()
|
|
assert manager1 is manager2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_community_skills_function(self):
|
|
"""Test list_community_skills convenience function."""
|
|
mock_skills = [CommunitySkill("test", "desc", "anthropic", "repo", "path")]
|
|
|
|
with patch.object(CommunitySkillsManager, 'list_community_skills', new_callable=AsyncMock) as mock_list:
|
|
mock_list.return_value = mock_skills
|
|
|
|
result = await list_community_skills()
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "test"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_community_skill_function(self):
|
|
"""Test install_community_skill convenience function."""
|
|
mock_skill = CommunitySkill("test-skill", "desc", "anthropic", "repo", "path")
|
|
mock_installed = Skill("test-skill", "/path", "desc", "project", "community:anthropic")
|
|
|
|
with patch.object(CommunitySkillsManager, 'list_community_skills', new_callable=AsyncMock) as mock_list:
|
|
mock_list.return_value = [mock_skill]
|
|
|
|
with patch.object(CommunitySkillsManager, 'install_community_skill', new_callable=AsyncMock) as mock_install:
|
|
mock_install.return_value = mock_installed
|
|
|
|
result = await install_community_skill("anthropic", "test-skill", "/project")
|
|
assert result["name"] == "test-skill"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_community_skill_not_found(self):
|
|
"""Test install_community_skill with non-existent skill."""
|
|
with patch.object(CommunitySkillsManager, 'list_community_skills', new_callable=AsyncMock) as mock_list:
|
|
mock_list.return_value = []
|
|
|
|
with pytest.raises(ValueError, match="Skill not found"):
|
|
await install_community_skill("anthropic", "nonexistent", "/project")
|
|
|
|
|
|
class TestCommunitySourcesConfig:
|
|
"""Tests for COMMUNITY_SOURCES configuration."""
|
|
|
|
def test_anthropic_source_config(self):
|
|
"""Test Anthropic source is configured correctly."""
|
|
assert "anthropic" in COMMUNITY_SOURCES
|
|
assert COMMUNITY_SOURCES["anthropic"]["repo"] == "anthropics/skills"
|
|
|
|
def test_superpowers_source_config(self):
|
|
"""Test Superpowers source is configured correctly."""
|
|
assert "superpowers" in COMMUNITY_SOURCES
|
|
assert COMMUNITY_SOURCES["superpowers"]["repo"] == "obra/superpowers"
|