883 lines
30 KiB
Python
883 lines
30 KiB
Python
# ruff: noqa: INP001
|
|
"""Integration tests for skills marketplace APIs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import APIRouter, FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
|
from sqlmodel import SQLModel, col, select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.api.deps import require_org_admin
|
|
from app.api.gateways import router as gateways_router
|
|
from app.api.skills_marketplace import (
|
|
PackSkillCandidate,
|
|
_collect_pack_skills_from_repo,
|
|
_validate_pack_source_url,
|
|
)
|
|
from app.api.skills_marketplace import router as skills_marketplace_router
|
|
from app.db.session import get_session
|
|
from app.models.gateways import Gateway
|
|
from app.models.organization_members import OrganizationMember
|
|
from app.models.organizations import Organization
|
|
from app.models.skills import GatewayInstalledSkill, MarketplaceSkill, SkillPack
|
|
from app.services.organizations import OrganizationContext
|
|
|
|
|
|
async def _make_engine() -> AsyncEngine:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.connect() as conn, conn.begin():
|
|
await conn.run_sync(SQLModel.metadata.create_all)
|
|
return engine
|
|
|
|
|
|
def _build_test_app(
|
|
session_maker: async_sessionmaker[AsyncSession],
|
|
*,
|
|
organization: Organization,
|
|
) -> FastAPI:
|
|
app = FastAPI()
|
|
api_v1 = APIRouter(prefix="/api/v1")
|
|
api_v1.include_router(gateways_router)
|
|
api_v1.include_router(skills_marketplace_router)
|
|
app.include_router(api_v1)
|
|
|
|
async def _override_get_session() -> AsyncSession:
|
|
async with session_maker() as session:
|
|
yield session
|
|
|
|
async def _override_require_org_admin() -> OrganizationContext:
|
|
return OrganizationContext(
|
|
organization=organization,
|
|
member=OrganizationMember(
|
|
organization_id=organization.id,
|
|
user_id=uuid4(),
|
|
role="owner",
|
|
all_boards_read=True,
|
|
all_boards_write=True,
|
|
),
|
|
)
|
|
|
|
app.dependency_overrides[get_session] = _override_get_session
|
|
app.dependency_overrides[require_org_admin] = _override_require_org_admin
|
|
return app
|
|
|
|
|
|
async def _seed_base(
|
|
session: AsyncSession,
|
|
) -> tuple[Organization, Gateway]:
|
|
organization = Organization(id=uuid4(), name="Org One")
|
|
gateway = Gateway(
|
|
id=uuid4(),
|
|
organization_id=organization.id,
|
|
name="Gateway One",
|
|
url="https://gateway.example.local",
|
|
workspace_root="/workspace/openclaw",
|
|
)
|
|
session.add(organization)
|
|
session.add(gateway)
|
|
await session.commit()
|
|
return organization, gateway
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_skill_dispatches_instruction_and_persists_installation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, gateway = await _seed_base(session)
|
|
skill = MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Deploy Helper",
|
|
source_url="https://example.com/skills/deploy-helper.git",
|
|
description="Handles deploy workflow checks.",
|
|
)
|
|
session.add(skill)
|
|
await session.commit()
|
|
await session.refresh(skill)
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
sent_messages: list[dict[str, str | bool]] = []
|
|
|
|
async def _fake_send_agent_message(
|
|
_self: object,
|
|
*,
|
|
session_key: str,
|
|
config: object,
|
|
agent_name: str,
|
|
message: str,
|
|
deliver: bool = False,
|
|
) -> None:
|
|
del config
|
|
sent_messages.append(
|
|
{
|
|
"session_key": session_key,
|
|
"agent_name": agent_name,
|
|
"message": message,
|
|
"deliver": deliver,
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.skills_marketplace.GatewayDispatchService.send_agent_message",
|
|
_fake_send_agent_message,
|
|
)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.post(
|
|
f"/api/v1/skills/marketplace/{skill.id}/install",
|
|
params={"gateway_id": str(gateway.id)},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["installed"] is True
|
|
assert body["gateway_id"] == str(gateway.id)
|
|
assert len(sent_messages) == 1
|
|
assert sent_messages[0]["agent_name"] == "Gateway Agent"
|
|
assert sent_messages[0]["deliver"] is True
|
|
assert sent_messages[0]["session_key"] == f"agent:mc-gateway-{gateway.id}:main"
|
|
message = str(sent_messages[0]["message"])
|
|
assert "SKILL INSTALL REQUEST" in message
|
|
assert str(skill.source_url) in message
|
|
assert "/workspace/openclaw/skills" in message
|
|
|
|
async with session_maker() as session:
|
|
installed_rows = (
|
|
await session.exec(
|
|
select(GatewayInstalledSkill).where(
|
|
col(GatewayInstalledSkill.gateway_id) == gateway.id,
|
|
col(GatewayInstalledSkill.skill_id) == skill.id,
|
|
),
|
|
)
|
|
).all()
|
|
assert len(installed_rows) == 1
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_gateway_removes_installed_skill_rows() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, gateway = await _seed_base(session)
|
|
skill = MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Deploy Helper",
|
|
source_url="https://example.com/skills/deploy-helper.git",
|
|
)
|
|
session.add(skill)
|
|
await session.commit()
|
|
await session.refresh(skill)
|
|
session.add(
|
|
GatewayInstalledSkill(
|
|
gateway_id=gateway.id,
|
|
skill_id=skill.id,
|
|
),
|
|
)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.delete(f"/api/v1/gateways/{gateway.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True}
|
|
|
|
async with session_maker() as session:
|
|
deleted_gateway = await session.get(Gateway, gateway.id)
|
|
assert deleted_gateway is None
|
|
remaining_installs = (
|
|
await session.exec(
|
|
select(GatewayInstalledSkill).where(
|
|
col(GatewayInstalledSkill.gateway_id) == gateway.id,
|
|
),
|
|
)
|
|
).all()
|
|
assert remaining_installs == []
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_marketplace_skills_marks_installed_cards() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, gateway = await _seed_base(session)
|
|
first = MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="First Skill",
|
|
source_url="https://example.com/skills/first",
|
|
)
|
|
second = MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Second Skill",
|
|
source_url="https://example.com/skills/second",
|
|
)
|
|
session.add(first)
|
|
session.add(second)
|
|
await session.commit()
|
|
await session.refresh(first)
|
|
await session.refresh(second)
|
|
|
|
session.add(
|
|
GatewayInstalledSkill(
|
|
gateway_id=gateway.id,
|
|
skill_id=first.id,
|
|
),
|
|
)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.get(
|
|
"/api/v1/skills/marketplace",
|
|
params={"gateway_id": str(gateway.id)},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
cards = response.json()
|
|
assert len(cards) == 2
|
|
cards_by_id = {item["id"]: item for item in cards}
|
|
assert cards_by_id[str(first.id)]["installed"] is True
|
|
assert cards_by_id[str(first.id)]["installed_at"] is not None
|
|
assert cards_by_id[str(second.id)]["installed"] is False
|
|
assert cards_by_id[str(second.id)]["installed_at"] is None
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_pack_clones_and_upserts_skills(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
pack = SkillPack(
|
|
organization_id=organization.id,
|
|
name="Antigravity Awesome Skills",
|
|
source_url="https://github.com/sickn33/antigravity-awesome-skills",
|
|
)
|
|
session.add(pack)
|
|
await session.commit()
|
|
await session.refresh(pack)
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
collected = [
|
|
PackSkillCandidate(
|
|
name="Skill Alpha",
|
|
description="Alpha description",
|
|
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha",
|
|
category="testing",
|
|
risk="low",
|
|
source="skills/alpha",
|
|
),
|
|
PackSkillCandidate(
|
|
name="Skill Beta",
|
|
description="Beta description",
|
|
source_url="https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta",
|
|
category="automation",
|
|
risk="medium",
|
|
source="skills/beta",
|
|
),
|
|
]
|
|
|
|
def _fake_collect_pack_skills(source_url: str) -> list[PackSkillCandidate]:
|
|
assert source_url == "https://github.com/sickn33/antigravity-awesome-skills"
|
|
return collected
|
|
|
|
monkeypatch.setattr(
|
|
"app.api.skills_marketplace._collect_pack_skills",
|
|
_fake_collect_pack_skills,
|
|
)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
first_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
|
|
second_sync = await client.post(f"/api/v1/skills/packs/{pack.id}/sync")
|
|
|
|
assert first_sync.status_code == 200
|
|
first_body = first_sync.json()
|
|
assert first_body["pack_id"] == str(pack.id)
|
|
assert first_body["synced"] == 2
|
|
assert first_body["created"] == 2
|
|
assert first_body["updated"] == 0
|
|
|
|
assert second_sync.status_code == 200
|
|
second_body = second_sync.json()
|
|
assert second_body["pack_id"] == str(pack.id)
|
|
assert second_body["synced"] == 2
|
|
assert second_body["created"] == 0
|
|
assert second_body["updated"] == 0
|
|
|
|
async with session_maker() as session:
|
|
synced_skills = (
|
|
await session.exec(
|
|
select(MarketplaceSkill).where(
|
|
col(MarketplaceSkill.organization_id) == organization.id,
|
|
),
|
|
)
|
|
).all()
|
|
assert len(synced_skills) == 2
|
|
by_source = {skill.source_url: skill for skill in synced_skills}
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
|
|
].name
|
|
== "Skill Alpha"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
|
|
].category
|
|
== "testing"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha"
|
|
].risk
|
|
== "low"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
|
|
].description
|
|
== "Beta description"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beta"
|
|
].source
|
|
== "skills/beta"
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def test_validate_pack_source_url_allows_https_github_repo_with_optional_dot_git() -> None:
|
|
_validate_pack_source_url("https://github.com/org/repo")
|
|
_validate_pack_source_url("https://github.com/org/repo.git")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://github.com/org/repo",
|
|
"file:///tmp/repo",
|
|
"ssh://github.com/org/repo",
|
|
"https://localhost/repo",
|
|
"https://127.0.0.1/repo",
|
|
"https://[::1]/repo",
|
|
],
|
|
)
|
|
def test_validate_pack_source_url_rejects_unsafe_urls(url: str) -> None:
|
|
with pytest.raises(ValueError):
|
|
_validate_pack_source_url(url)
|
|
|
|
|
|
def test_validate_pack_source_url_rejects_git_ssh_scp_like_syntax() -> None:
|
|
# Not a URL, but worth asserting we fail closed.
|
|
with pytest.raises(ValueError):
|
|
_validate_pack_source_url("git@github.com:org/repo.git")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_skill_pack_rejects_non_https_source_url() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": "http://github.com/sickn33/antigravity-awesome-skills"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert (
|
|
"scheme" in response.json()["detail"].lower()
|
|
or "https" in response.json()["detail"].lower()
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_skill_pack_rejects_localhost_source_url() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": "https://localhost/skills-pack"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert (
|
|
"hostname" in response.json()["detail"].lower()
|
|
or "not allowed" in response.json()["detail"].lower()
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_skill_pack_is_unique_by_normalized_source_url() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
first = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": "https://github.com/org/repo"},
|
|
)
|
|
spaced = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": " https://github.com/org/repo.git "},
|
|
)
|
|
second = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": "https://github.com/org/repo/"},
|
|
)
|
|
third = await client.post(
|
|
"/api/v1/skills/packs",
|
|
json={"source_url": "https://github.com/org/repo.git"},
|
|
)
|
|
packs = await client.get("/api/v1/skills/packs")
|
|
|
|
assert first.status_code == 200
|
|
assert spaced.status_code == 200
|
|
assert second.status_code == 200
|
|
assert third.status_code == 200
|
|
assert spaced.json()["id"] == first.json()["id"]
|
|
assert spaced.json()["source_url"] == first.json()["source_url"]
|
|
assert second.json()["id"] == first.json()["id"]
|
|
assert second.json()["source_url"] == first.json()["source_url"]
|
|
assert third.json()["id"] == first.json()["id"]
|
|
assert third.json()["source_url"] == first.json()["source_url"]
|
|
assert packs.status_code == 200
|
|
pack_items = packs.json()
|
|
assert len(pack_items) == 1
|
|
assert pack_items[0]["source_url"] == "https://github.com/org/repo"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_skill_packs_includes_skill_count() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
pack = SkillPack(
|
|
organization_id=organization.id,
|
|
name="Pack One",
|
|
source_url="https://github.com/sickn33/antigravity-awesome-skills",
|
|
)
|
|
session.add(pack)
|
|
session.add(
|
|
MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Skill One",
|
|
source_url=(
|
|
"https://github.com/sickn33/antigravity-awesome-skills"
|
|
"/tree/main/skills/alpha"
|
|
),
|
|
)
|
|
)
|
|
session.add(
|
|
MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Skill Two",
|
|
source_url=(
|
|
"https://github.com/sickn33/antigravity-awesome-skills"
|
|
"/tree/main/skills/beta"
|
|
),
|
|
)
|
|
)
|
|
session.add(
|
|
MarketplaceSkill(
|
|
organization_id=organization.id,
|
|
name="Other Repo Skill",
|
|
source_url="https://github.com/other/repo/tree/main/skills/other",
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.get("/api/v1/skills/packs")
|
|
|
|
assert response.status_code == 200
|
|
items = response.json()
|
|
assert len(items) == 1
|
|
assert items[0]["name"] == "Pack One"
|
|
assert items[0]["skill_count"] == 2
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_skill_pack_rejects_duplicate_normalized_source_url() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
pack_a = SkillPack(
|
|
organization_id=organization.id,
|
|
source_url="https://github.com/org/repo",
|
|
name="Pack A",
|
|
)
|
|
pack_b = SkillPack(
|
|
organization_id=organization.id,
|
|
source_url="https://github.com/org/other-repo",
|
|
name="Pack B",
|
|
)
|
|
session.add(pack_a)
|
|
session.add(pack_b)
|
|
await session.commit()
|
|
await session.refresh(pack_a)
|
|
await session.refresh(pack_b)
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.patch(
|
|
f"/api/v1/skills/packs/{pack_b.id}",
|
|
json={"source_url": "https://github.com/org/repo/"},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "already exists" in response.json()["detail"].lower()
|
|
|
|
async with session_maker() as session:
|
|
pack_rows = (
|
|
await session.exec(
|
|
select(SkillPack)
|
|
.where(col(SkillPack.organization_id) == organization.id)
|
|
.order_by(col(SkillPack.created_at).asc())
|
|
)
|
|
).all()
|
|
assert len(pack_rows) == 2
|
|
assert {str(pack.source_url) for pack in pack_rows} == {
|
|
"https://github.com/org/repo",
|
|
"https://github.com/org/other-repo",
|
|
}
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_skill_pack_normalizes_source_url_on_update() -> None:
|
|
engine = await _make_engine()
|
|
session_maker = async_sessionmaker(
|
|
engine,
|
|
class_=AsyncSession,
|
|
expire_on_commit=False,
|
|
)
|
|
try:
|
|
async with session_maker() as session:
|
|
organization, _gateway = await _seed_base(session)
|
|
pack = SkillPack(
|
|
organization_id=organization.id,
|
|
source_url="https://github.com/org/old",
|
|
name="Initial",
|
|
)
|
|
session.add(pack)
|
|
await session.commit()
|
|
await session.refresh(pack)
|
|
|
|
app = _build_test_app(session_maker, organization=organization)
|
|
|
|
async with AsyncClient(
|
|
transport=ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
) as client:
|
|
response = await client.patch(
|
|
f"/api/v1/skills/packs/{pack.id}",
|
|
json={"source_url": " https://github.com/org/new.git/ "},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["source_url"] == "https://github.com/org/new"
|
|
|
|
async with session_maker() as session:
|
|
updated = (
|
|
await session.exec(
|
|
select(SkillPack).where(col(SkillPack.id) == pack.id),
|
|
)
|
|
).one()
|
|
assert str(updated.source_url) == "https://github.com/org/new"
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def test_collect_pack_skills_from_repo_uses_root_index_when_present(tmp_path: Path) -> None:
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "skills").mkdir()
|
|
indexed_dir = repo_dir / "skills" / "indexed-fallback"
|
|
indexed_dir.mkdir()
|
|
(indexed_dir / "SKILL.md").write_text("# Should Not Be Used\n", encoding="utf-8")
|
|
|
|
(repo_dir / "skills_index.json").write_text(
|
|
json.dumps(
|
|
[
|
|
{
|
|
"id": "first",
|
|
"name": "Index First",
|
|
"description": "From index one",
|
|
"path": "skills/index-first",
|
|
"category": "uncategorized",
|
|
"risk": "unknown",
|
|
},
|
|
{
|
|
"id": "second",
|
|
"name": "Index Second",
|
|
"description": "From index two",
|
|
"path": "skills/index-second/SKILL.md",
|
|
"category": "catalog",
|
|
"risk": "low",
|
|
},
|
|
{
|
|
"id": "root",
|
|
"name": "Root Skill",
|
|
"description": "Root from index",
|
|
"path": "SKILL.md",
|
|
"category": "uncategorized",
|
|
"risk": "unknown",
|
|
},
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
skills = _collect_pack_skills_from_repo(
|
|
repo_dir=repo_dir,
|
|
source_url="https://github.com/sickn33/antigravity-awesome-skills",
|
|
branch="main",
|
|
)
|
|
|
|
assert len(skills) == 3
|
|
by_source = {skill.source_url: skill for skill in skills}
|
|
assert (
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
|
|
in by_source
|
|
)
|
|
assert (
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-second"
|
|
in by_source
|
|
)
|
|
assert "https://github.com/sickn33/antigravity-awesome-skills/tree/main" in by_source
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
|
|
].name
|
|
== "Index First"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
|
|
].category
|
|
== "uncategorized"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
|
|
].risk
|
|
== "unknown"
|
|
)
|
|
assert (
|
|
by_source[
|
|
"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/index-first"
|
|
].source
|
|
== "skills/index-first"
|
|
)
|
|
assert (
|
|
by_source["https://github.com/sickn33/antigravity-awesome-skills/tree/main"].name
|
|
== "Root Skill"
|
|
)
|
|
|
|
|
|
def test_collect_pack_skills_from_repo_supports_root_skill_md(tmp_path: Path) -> None:
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "SKILL.md").write_text(
|
|
"---\nname: x-research-skill\ndescription: Root skill package\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
skills = _collect_pack_skills_from_repo(
|
|
repo_dir=repo_dir,
|
|
source_url="https://github.com/rohunvora/x-research-skill",
|
|
branch="main",
|
|
)
|
|
|
|
assert len(skills) == 1
|
|
only_skill = skills[0]
|
|
assert only_skill.name == "x-research-skill"
|
|
assert only_skill.description == "Root skill package"
|
|
assert only_skill.source_url == "https://github.com/rohunvora/x-research-skill/tree/main"
|
|
|
|
|
|
def test_collect_pack_skills_from_repo_supports_top_level_skill_folders(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
first = repo_dir / "content-idea-generator"
|
|
second = repo_dir / "homepage-audit"
|
|
first.mkdir()
|
|
second.mkdir()
|
|
(first / "SKILL.md").write_text("# Content Idea Generator\n", encoding="utf-8")
|
|
(second / "SKILL.md").write_text("# Homepage Audit\n", encoding="utf-8")
|
|
|
|
skills = _collect_pack_skills_from_repo(
|
|
repo_dir=repo_dir,
|
|
source_url="https://github.com/BrianRWagner/ai-marketing-skills",
|
|
branch="main",
|
|
)
|
|
|
|
assert len(skills) == 2
|
|
by_source = {skill.source_url: skill for skill in skills}
|
|
assert (
|
|
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/content-idea-generator"
|
|
in by_source
|
|
)
|
|
assert (
|
|
"https://github.com/BrianRWagner/ai-marketing-skills/tree/main/homepage-audit" in by_source
|
|
)
|
|
|
|
|
|
def test_collect_pack_skills_from_repo_streams_large_index(tmp_path: Path) -> None:
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
(repo_dir / "SKILL.md").write_text("# Fallback Skill\n", encoding="utf-8")
|
|
|
|
huge_description = "x" * (300 * 1024)
|
|
(repo_dir / "skills_index.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"skills": [
|
|
{
|
|
"id": "oversized",
|
|
"name": "Huge Index Skill",
|
|
"description": huge_description,
|
|
"path": "skills/ignored",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
skills = _collect_pack_skills_from_repo(
|
|
repo_dir=repo_dir,
|
|
source_url="https://github.com/example/oversized-pack",
|
|
branch="main",
|
|
)
|
|
|
|
assert len(skills) == 1
|
|
assert (
|
|
skills[0].source_url == "https://github.com/example/oversized-pack/tree/main/skills/ignored"
|
|
)
|
|
assert skills[0].name == "Huge Index Skill"
|