Files
agent-radar 1a3a4ccde1
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:01:50 +08:00

2618 lines
88 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for agents/skill_system/hub.py pure logic & mocked I/O.
Coverage-driven backfill: hub.py sat at ~14% coverage. These tests
exercise the env config readers, cancellation hooks, GitHub response
cache, HTTP fetch primitives (retry/backoff/limits), bundle tree
normalization, provider URL parsers, zip→bundle converters, provider
routing and the install pipeline — with all network I/O mocked.
"""
# pylint: disable=wrong-import-position,protected-access,redefined-outer-name,too-many-public-methods,unused-argument,unused-import,unused-variable,use-implicit-booleaness-not-comparison # noqa: E501
from __future__ import annotations
import asyncio
import base64
import importlib
import io
import json
import time
import zipfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
import httpx
import pytest
hub = importlib.import_module("qwenpaw.agents.skill_system.hub")
from qwenpaw.exceptions import ( # noqa: E402
ConfigurationException,
SkillConflictError,
SkillImportCancelled,
SkillsError,
)
def _run(coro):
return (
asyncio.get_event_loop_policy()
.new_event_loop()
.run_until_complete(
coro,
)
)
@pytest.fixture(autouse=True)
def _clean_hub_state():
"""Reset module-level caches between tests."""
hub._github_cache.clear()
hub._github_cache_key_locks.clear()
hub._in_flight = 0
hub._drain_event.set()
yield
hub._github_cache.clear()
hub._github_cache_key_locks.clear()
hub._in_flight = 0
hub._drain_event.set()
def _make_zip(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in files.items():
zf.writestr(name, content)
return buf.getvalue()
SKILL_MD = "---\nname: demo-skill\n---\n# Demo\nbody"
# ---------------------------------------------------------------------------
# Env-driven config
# ---------------------------------------------------------------------------
class TestEnvConfig:
def test_github_cache_ttl_default(self, monkeypatch):
monkeypatch.delenv("QWENPAW_GITHUB_CACHE_TTL", raising=False)
assert hub._github_cache_ttl() == 300.0
def test_github_cache_ttl_custom(self, monkeypatch):
monkeypatch.setenv("QWENPAW_GITHUB_CACHE_TTL", "10")
assert hub._github_cache_ttl() == 10.0
def test_github_cache_ttl_negative_clamped(self, monkeypatch):
monkeypatch.setenv("QWENPAW_GITHUB_CACHE_TTL", "-5")
assert hub._github_cache_ttl() == 0.0
def test_github_cache_ttl_invalid(self, monkeypatch):
monkeypatch.setenv("QWENPAW_GITHUB_CACHE_TTL", "abc")
assert hub._github_cache_ttl() == 300.0
def test_http_timeout_default_and_min(self, monkeypatch):
monkeypatch.delenv("QWENPAW_SKILLS_HUB_HTTP_TIMEOUT", raising=False)
assert hub._hub_http_timeout() == 30.0
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_TIMEOUT", "1")
assert hub._hub_http_timeout() == 3.0
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_TIMEOUT", "bad")
assert hub._hub_http_timeout() == 30.0
def test_http_retries(self, monkeypatch):
monkeypatch.delenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", raising=False)
assert hub._hub_http_retries() == 3
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "0")
assert hub._hub_http_retries() == 0
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "x")
assert hub._hub_http_retries() == 3
def test_backoff_base_and_cap(self, monkeypatch):
monkeypatch.delenv(
"QWENPAW_SKILLS_HUB_HTTP_BACKOFF_BASE",
raising=False,
)
monkeypatch.delenv(
"QWENPAW_SKILLS_HUB_HTTP_BACKOFF_CAP",
raising=False,
)
assert hub._hub_http_backoff_base() == 0.8
assert hub._hub_http_backoff_cap() == 6.0
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_BASE", "0.05")
assert hub._hub_http_backoff_base() == 0.1 # clamped
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_BASE", "2")
assert hub._hub_http_backoff_base() == 2.0
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_BASE", "bad")
assert hub._hub_http_backoff_base() == 0.8
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_CAP", "0.1")
assert hub._hub_http_backoff_cap() == 0.5 # clamped
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_CAP", "bad")
assert hub._hub_http_backoff_cap() == 6.0
def test_compute_backoff_seconds(self, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_BASE", "1")
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_BACKOFF_CAP", "10")
assert hub._compute_backoff_seconds(1) == 1.0
assert hub._compute_backoff_seconds(2) == 2.0
assert hub._compute_backoff_seconds(3) == 4.0
assert hub._compute_backoff_seconds(10) == 10.0 # capped
assert hub._compute_backoff_seconds(0) == 1.0 # attempt clamped >=0
def test_hub_url_builders(self, monkeypatch):
monkeypatch.delenv("QWENPAW_SKILLS_HUB_BASE_URL", raising=False)
assert hub._hub_base_url() == "https://clawhub.ai"
assert hub._hub_search_path() == "/api/v1/search"
assert "{slug}" in hub._hub_version_path()
assert "{slug}" in hub._hub_detail_path()
assert "{slug}" in hub._hub_file_path()
monkeypatch.setenv("QWENPAW_SKILLS_HUB_BASE_URL", "http://h/")
assert hub._hub_base_url() == "http://h/"
def test_join_url(self):
assert hub._join_url("https://a.b/", "/x") == "https://a.b/x"
assert hub._join_url("https://a.b", "x") == "https://a.b/x"
# ---------------------------------------------------------------------------
# Cancellation hooks
# ---------------------------------------------------------------------------
class TestCancellation:
def test_no_checker_noop(self):
hub._ensure_not_cancelled() # must not raise
def test_checker_true_raises(self):
with hub._with_cancel_checker(lambda: True):
with pytest.raises(SkillImportCancelled):
hub._ensure_not_cancelled()
def test_checker_false_ok(self):
with hub._with_cancel_checker(lambda: False):
hub._ensure_not_cancelled()
def test_checker_restored_after_context(self):
with hub._with_cancel_checker(lambda: True):
pass
hub._ensure_not_cancelled() # checker gone again
def test_checker_exception_swallowed(self):
def _boom():
raise RuntimeError("checker exploded")
with hub._with_cancel_checker(_boom):
hub._ensure_not_cancelled() # must not raise
# ---------------------------------------------------------------------------
# GitHub response cache
# ---------------------------------------------------------------------------
class TestGithubCache:
def test_set_and_get(self):
hub._github_cache_set("k", {"v": 1})
assert hub._github_cache_get("k") == {"v": 1}
def test_get_expired(self):
hub._github_cache["k"] = (time.monotonic() - 10**6, "old")
assert hub._github_cache_get("k") is None
assert "k" not in hub._github_cache
def test_has_miss_sentinel(self):
assert hub._github_cached("missing") is hub._GITHUB_CACHE_MISS
def test_prune_expired(self):
hub._github_cache["old"] = (time.monotonic() - 10**6, "x")
hub._github_cache["fresh"] = (time.monotonic(), "y")
hub._github_cache_prune()
assert "old" not in hub._github_cache
assert "fresh" in hub._github_cache
def test_prune_lru_cap(self, monkeypatch):
monkeypatch.setattr(hub, "_GITHUB_CACHE_MAX_ENTRIES", 2)
now = time.monotonic()
hub._github_cache["a"] = (now, 1)
hub._github_cache["b"] = (now, 2)
hub._github_cache["c"] = (now, 3)
hub._github_cache_prune()
assert len(hub._github_cache) == 2
assert "a" not in hub._github_cache
def test_set_triggers_prune_over_cap(self, monkeypatch):
monkeypatch.setattr(hub, "_GITHUB_CACHE_MAX_ENTRIES", 1)
hub._github_cache_set("a", 1)
hub._github_cache_set("b", 2)
assert len(hub._github_cache) <= 2
def test_cache_lock_cleanup(self):
async def _go():
async with hub._github_cache_lock("k"):
assert "k" in hub._github_cache_key_locks
assert "k" not in hub._github_cache_key_locks
_run(_go())
def test_cached_call_hit(self):
hub._github_cache_set("k", "cached")
calls = []
async def _factory():
calls.append(1)
return "fresh"
assert _run(hub._github_cached_call("k", _factory)) == "cached"
assert calls == []
def test_cached_call_miss_then_factory(self):
calls = []
async def _factory():
calls.append(1)
return "fresh"
assert _run(hub._github_cached_call("k", _factory)) == "fresh"
assert calls == [1]
assert hub._github_cache_get("k") == "fresh"
def test_cached_call_thundering_herd_single_fetch(self):
calls = []
async def _factory():
calls.append(1)
await asyncio.sleep(0.02)
return "value"
async def _go():
return await asyncio.gather(
hub._github_cached_call("k", _factory),
hub._github_cached_call("k", _factory),
)
results = _run(_go())
assert results == ["value", "value"]
assert len(calls) == 1
# ---------------------------------------------------------------------------
# Shared client / request tracking / close
# ---------------------------------------------------------------------------
class TestClientLifecycle:
def test_build_async_client(self):
client = hub._build_async_client()
assert isinstance(client, httpx.AsyncClient)
def test_get_async_client_singleton(self):
async def _go():
hub._async_client = None
c1 = await hub._get_async_client()
c2 = await hub._get_async_client()
assert c1 is c2
await c1.aclose()
_run(_go())
def test_get_async_client_rebuilds_after_close(self):
async def _go():
hub._async_client = None
c1 = await hub._get_async_client()
await c1.aclose()
c2 = await hub._get_async_client()
assert c2 is not c1
await c2.aclose()
_run(_go())
def test_track_request_counts(self):
async def _go():
assert hub._in_flight == 0
async with hub._track_request():
assert hub._in_flight == 1
assert not hub._drain_event.is_set()
assert hub._in_flight == 0
assert hub._drain_event.is_set()
_run(_go())
def test_track_request_counts_on_error(self):
async def _go():
with pytest.raises(RuntimeError):
async with hub._track_request():
raise RuntimeError("boom")
assert hub._in_flight == 0
assert hub._drain_event.is_set()
_run(_go())
def test_aclose_hub_client_no_client(self):
async def _go():
hub._async_client = None
await hub.aclose_hub_client()
assert hub._async_client is None
_run(_go())
def test_aclose_hub_client_closes_open(self):
async def _go():
hub._async_client = None
client = await hub._get_async_client()
await hub.aclose_hub_client()
assert client.is_closed
assert hub._async_client is None
_run(_go())
def test_aclose_hub_client_drain_timeout_warns(self, monkeypatch):
async def _wait_for(fut, timeout=None):
raise asyncio.TimeoutError
monkeypatch.setattr(hub.asyncio, "wait_for", _wait_for)
async def _go():
hub._async_client = None
await hub.aclose_hub_client()
_run(_go()) # must not raise
# ---------------------------------------------------------------------------
# HTTP primitives: _maybe_retry / headers / limits / streaming
# ---------------------------------------------------------------------------
class TestMaybeRetry:
def test_no_retry_when_exhausted(self):
result = _run(
hub._maybe_retry(3, 3, "http://x", RuntimeError("e"), "reason"),
)
assert result is False
def test_retry_sleeps_and_returns_true(self, monkeypatch):
sleeps = []
async def _fake_sleep(delay):
sleeps.append(delay)
monkeypatch.setattr(hub.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(hub, "_compute_backoff_seconds", lambda a: 0.01)
result = _run(
hub._maybe_retry(1, 3, "http://x", RuntimeError("e"), "reason"),
)
assert result is True
assert sleeps == [0.01]
def test_retry_checks_cancellation(self, monkeypatch):
async def _fake_sleep(delay):
pass
monkeypatch.setattr(hub.asyncio, "sleep", _fake_sleep)
with hub._with_cancel_checker(lambda: True):
with pytest.raises(SkillImportCancelled):
_run(
hub._maybe_retry(
1,
3,
"http://x",
RuntimeError("e"),
"reason",
),
)
class TestRequestHeaders:
def test_accept_header(self, monkeypatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
headers = hub._request_headers("https://api.github.com/x", "app/json")
assert headers == {"Accept": "app/json"}
def test_github_token_added(self, monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "tok-1")
headers = hub._request_headers("https://api.github.com/x", "a")
assert headers["Authorization"] == "Bearer tok-1"
def test_token_not_added_for_other_hosts(self, monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "tok-1")
headers = hub._request_headers("https://example.com/x", "a")
assert "Authorization" not in headers
def test_gh_token_fallback(self, monkeypatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "tok-2")
headers = hub._request_headers("https://api.github.com/x", "a")
assert headers["Authorization"] == "Bearer tok-2"
class TestCheckMaxBytes:
def test_within_limit(self):
hub._check_max_bytes("http://x", 10, 100) # no raise
def test_over_limit(self):
with pytest.raises(SkillsError, match="too large"):
hub._check_max_bytes("http://x", 101, 100)
def test_none_values_skipped(self):
hub._check_max_bytes("http://x", None, None)
hub._check_max_bytes("http://x", 999, None)
class _FakeResponse:
def __init__(
self,
status_code: int = 200,
content: bytes = b"",
headers: dict | None = None,
url: str = "http://x.test/data",
):
self.status_code = status_code
self._content = content
self.headers = headers or {}
self.request = httpx.Request("GET", url)
async def aread(self) -> bytes:
return self._content
async def aiter_bytes(self, chunk_size=None):
yield self._content
@property
def content(self) -> bytes:
return self._content
@property
def text(self) -> str:
return self._content.decode("utf-8", errors="replace")
class _StreamCtx:
def __init__(self, response):
self._response = response
async def __aenter__(self):
if isinstance(self._response, Exception):
raise self._response
return self._response
async def __aexit__(self, *args):
return False
class _FakeClient:
def __init__(self, responses: list):
self._responses = list(responses)
self.calls = 0
def stream(self, method, url, **kwargs):
self.calls += 1
return _StreamCtx(self._responses.pop(0))
@pytest.fixture()
def no_sleep(monkeypatch):
async def _fake_sleep(delay):
pass
monkeypatch.setattr(hub.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(hub, "_compute_backoff_seconds", lambda a: 0.0)
class TestHttpFetch:
def _fetch(self, client, url="http://x.test/data", **kwargs):
async def _fake_client():
return client
with patch.object(hub, "_get_async_client", _fake_client):
return _run(hub._http_fetch(url, **kwargs))
def test_success(self):
client = _FakeClient(
[_FakeResponse(200, b"hello", {"Content-Length": "5"})],
)
assert self._fetch(client) == b"hello"
def test_invalid_max_bytes(self):
with pytest.raises(ConfigurationException):
self._fetch(_FakeClient([]), max_bytes=0)
def test_content_length_over_limit(self):
client = _FakeClient(
[_FakeResponse(200, b"x" * 10, {"Content-Length": "10"})],
)
with pytest.raises(SkillsError, match="too large"):
self._fetch(client, max_bytes=5)
def test_streaming_over_limit(self):
client = _FakeClient([_FakeResponse(200, b"x" * 10)])
with pytest.raises(SkillsError, match="exceeded limit"):
self._fetch(client, max_bytes=5)
def test_truncated_response(self, no_sleep, monkeypatch):
# RemoteProtocolError is retryable; exhaust retries with one response.
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "0")
client = _FakeClient(
[_FakeResponse(200, b"abc", {"Content-Length": "10"})],
)
with pytest.raises(httpx.RemoteProtocolError):
self._fetch(client)
def test_truncation_check_skipped_with_content_encoding(self):
client = _FakeClient(
[
_FakeResponse(
200,
b"abc",
{"Content-Length": "10", "Content-Encoding": "gzip"},
),
],
)
assert self._fetch(client) == b"abc"
def test_truncation_check_skipped_with_transfer_encoding(self):
client = _FakeClient(
[
_FakeResponse(
200,
b"abc",
{"Content-Length": "10", "Transfer-Encoding": "chunked"},
),
],
)
assert self._fetch(client) == b"abc"
def test_bad_content_length_ignored(self):
client = _FakeClient(
[_FakeResponse(200, b"abc", {"Content-Length": "abc"})],
)
assert self._fetch(client) == b"abc"
def test_4xx_raises(self):
client = _FakeClient([_FakeResponse(404, b"nope")])
with pytest.raises(httpx.HTTPStatusError):
self._fetch(client)
def test_github_403_rate_limit(self):
client = _FakeClient(
[
_FakeResponse(
403,
b'{"message": "API rate limit exceeded"}',
url="https://api.github.com/repos/x/y",
),
],
)
with pytest.raises(SkillsError, match="rate limit"):
self._fetch(client, url="https://api.github.com/repos/x/y")
def test_429_after_retries_with_github_hint(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "1")
client = _FakeClient(
[_FakeResponse(429, b"slow down"), _FakeResponse(429, b"again")],
)
with pytest.raises(SkillsError, match="429") as exc:
self._fetch(client, url="https://api.github.com/repos/x/y")
assert "GITHUB_TOKEN" in str(exc.value)
def test_429_non_github_no_hint(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "0")
client = _FakeClient([_FakeResponse(429, b"slow")])
with pytest.raises(SkillsError, match="429") as exc:
self._fetch(client, url="https://example.com/x")
assert "GITHUB_TOKEN" not in str(exc.value)
def test_5xx_after_retries(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "0")
client = _FakeClient([_FakeResponse(500, b"boom")])
with pytest.raises(SkillsError, match="500"):
self._fetch(client)
def test_retryable_then_success(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "1")
client = _FakeClient(
[
_FakeResponse(503, b"unavailable"),
_FakeResponse(200, b"ok", {"Content-Length": "2"}),
],
)
assert self._fetch(client) == b"ok"
assert client.calls == 2
def test_transport_error_retried(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "1")
client = _FakeClient(
[
httpx.ConnectError("conn refused"),
_FakeResponse(200, b"ok", {"Content-Length": "2"}),
],
)
assert self._fetch(client) == b"ok"
def test_transport_error_exhausted(self, no_sleep, monkeypatch):
monkeypatch.setenv("QWENPAW_SKILLS_HUB_HTTP_RETRIES", "0")
client = _FakeClient([httpx.ConnectError("conn refused")])
with pytest.raises(httpx.ConnectError):
self._fetch(client)
def test_cancel_before_fetch(self):
with hub._with_cancel_checker(lambda: True):
with pytest.raises(SkillImportCancelled):
self._fetch(_FakeClient([]))
def test_custom_timeout_accepted(self):
client = _FakeClient(
[_FakeResponse(200, b"ok", {"Content-Length": "2"})],
)
assert self._fetch(client, timeout=5.0) == b"ok"
class TestStreamToBytes:
def test_empty_chunks_skipped(self):
class _Resp:
headers = {}
request = httpx.Request("GET", "http://x")
async def aiter_bytes(self, chunk_size=None):
yield b""
yield b"ab"
out = _run(
hub._stream_to_bytes(
_Resp(),
full_url="http://x",
max_bytes=None,
expected_length=None,
),
)
assert out == b"ab"
def test_cancel_mid_stream(self):
class _Resp:
headers = {}
request = httpx.Request("GET", "http://x")
async def aiter_bytes(self, chunk_size=None):
yield b"ab"
with hub._with_cancel_checker(lambda: True):
with pytest.raises(SkillImportCancelled):
_run(
hub._stream_to_bytes(
_Resp(),
full_url="http://x",
max_bytes=None,
expected_length=None,
),
)
class TestHttpWrappers:
def _patch_fetch(self, payload):
async def _fake_fetch(
url,
params=None,
accept="application/json",
max_bytes=None,
timeout=None,
):
return payload
return patch.object(hub, "_http_fetch", _fake_fetch)
def test_http_get_decodes(self):
with self._patch_fetch("héllo".encode("utf-8")):
assert _run(hub._http_get("http://x")) == "héllo"
def test_http_bytes_get(self):
with self._patch_fetch(b"\x00\x01"):
assert _run(hub._http_bytes_get("http://x")) == b"\x00\x01"
def test_http_json_get(self):
with self._patch_fetch(json.dumps({"a": 1}).encode()):
assert _run(hub._http_json_get("http://x")) == {"a": 1}
def test_http_text_get(self):
with self._patch_fetch(b"plain"):
assert _run(hub._http_text_get("http://x")) == "plain"
def test_public_alias(self):
assert hub.http_json_get is hub._http_json_get
# ---------------------------------------------------------------------------
# Search normalization
# ---------------------------------------------------------------------------
class TestNormSearchItems:
def test_list_input(self):
assert hub._norm_search_items([{"a": 1}, "x"]) == [{"a": 1}]
def test_dict_with_items_key(self):
for key in ("items", "skills", "results", "data"):
assert hub._norm_search_items({key: [{"n": 1}]}) == [{"n": 1}]
def test_single_skill_dict(self):
assert hub._norm_search_items({"name": "a", "slug": "b"}) == [
{"name": "a", "slug": "b"},
]
def test_other(self):
assert hub._norm_search_items({"x": 1}) == []
assert hub._norm_search_items(None) == []
assert hub._norm_search_items("str") == []
# ---------------------------------------------------------------------------
# Bundle tree helpers
# ---------------------------------------------------------------------------
class TestSafePathParts:
@pytest.mark.parametrize(
"path,expected",
[
("a/b/c", ["a", "b", "c"]),
("a//b", ["a", "b"]),
("", None),
("/abs", None),
("a/./b", None),
("a/../b", None),
("//", None),
],
)
def test_paths(self, path, expected):
assert hub._safe_path_parts(path) == expected
class TestTreeInsert:
def test_nested(self):
tree: dict = {}
hub._tree_insert(tree, ["a", "b", "c.md"], "content")
assert tree == {"a": {"b": {"c.md": "content"}}}
def test_overwrites_non_dict_child(self):
tree: dict = {"a": "leaf"}
hub._tree_insert(tree, ["a", "b"], "x")
assert tree == {"a": {"b": "x"}}
class TestFilesToTree:
def test_split_references_scripts(self):
files = {
"references/r1.md": "r",
"scripts/s1.py": "s",
"SKILL.md": "m",
"other.txt": "o",
}
refs, scripts = hub._files_to_tree(files)
assert refs == {"r1.md": "r"}
assert scripts == {"s1.py": "s"}
def test_bad_entries_skipped(self):
files = {"/bad": "x", "..": "y", "references": "top-only"}
refs, scripts = hub._files_to_tree(files)
assert refs == {}
assert scripts == {}
def test_non_string_skipped(self):
refs, scripts = hub._files_to_tree({1: "x", "a": 2}) # type: ignore
assert refs == {}
assert scripts == {}
class TestSanitizeTree:
def test_filters_bad_keys(self):
tree = {
"ok": "v",
".": "x",
"..": "y",
"a/b": "z",
"a\\b": "w",
"nested": {"k": "v", 1: "bad"},
"num": 5,
}
out = hub._sanitize_tree(tree)
assert out == {"ok": "v", "nested": {"k": "v"}}
def test_non_dict(self):
assert hub._sanitize_tree("x") == {}
assert hub._sanitize_tree(None) == {}
class TestBundleHasContent:
@pytest.mark.parametrize(
"payload,expected",
[
({"content": "x"}, True),
({"skill_md": "x"}, True),
({"skillMd": "x"}, True),
({"content": " "}, False),
({"files": {"SKILL.md": "x"}}, True),
({"files": {"SKILL.md": 1}}, False),
({"files": "nope"}, False),
({}, False),
("notdict", False),
],
)
def test_variants(self, payload, expected):
assert hub._bundle_has_content(payload) == expected
class TestExtractVersionHint:
def test_requested_version_wins(self):
assert hub._extract_version_hint({}, "v1") == "v1"
def test_latest_version_dict(self):
detail = {"latestVersion": {"version": "2.0"}}
assert hub._extract_version_hint(detail, "") == "2.0"
def test_skill_tags_latest(self):
detail = {"skill": {"tags": {"latest": "3.0"}}}
assert hub._extract_version_hint(detail, "") == "3.0"
def test_nothing(self):
assert hub._extract_version_hint({}, "") == ""
assert hub._extract_version_hint({"latestVersion": "x"}, "") == ""
assert hub._extract_version_hint({"skill": {}}, "") == ""
class TestNormalizeBundle:
def test_full_bundle(self):
data = {
"name": "demo",
"content": SKILL_MD,
"references": {"r.md": "ref"},
"scripts": {"s.py": "script"},
}
name, content, refs, scripts, extra = hub._normalize_bundle(data)
assert name == "demo"
assert content == SKILL_MD
assert refs == {"r.md": "ref"}
assert scripts == {"s.py": "script"}
assert extra == {}
def test_files_fallback(self):
data = {
"files": {
"SKILL.md": SKILL_MD,
"references/a.md": "r",
"scripts/b.py": "s",
"assets/c.txt": "c",
},
}
name, content, refs, scripts, extra = hub._normalize_bundle(data)
assert name == "demo-skill" # from frontmatter
assert content == SKILL_MD
assert refs == {"a.md": "r"}
assert scripts == {"b.py": "s"}
assert extra == {"assets": {"c.txt": "c"}}
def test_existing_trees_win_over_files(self):
data = {
"content": SKILL_MD,
"name": "x",
"references": {"keep.md": "k"},
"files": {"references/other.md": "o"},
}
_, _, refs, _, _ = hub._normalize_bundle(data)
assert refs == {"keep.md": "k"}
def test_skill_wrapped_payload(self):
data = {"skill": {"name": "wrapped", "content": SKILL_MD}}
name, content, _, _, _ = hub._normalize_bundle(data)
assert name == "wrapped"
def test_non_dict_raises(self):
with pytest.raises(SkillsError, match="not a valid JSON object"):
hub._normalize_bundle([1, 2])
def test_missing_content_raises(self):
with pytest.raises(SkillsError, match="missing SKILL.md"):
hub._normalize_bundle({"name": "x"})
def test_missing_name_raises(self):
with pytest.raises(SkillsError, match="missing skill name"):
hub._normalize_bundle({"content": "no frontmatter here"})
def test_bad_yaml_frontmatter_name_empty(self):
with pytest.raises(SkillsError, match="missing skill name"):
hub._normalize_bundle({"content": "---\n: bad: [yaml\n---\n"})
def test_non_string_name_ignored(self):
data = {"name": 123, "content": SKILL_MD}
name, _, _, _, _ = hub._normalize_bundle(data)
assert name == "demo-skill"
def test_non_string_content_treated_empty(self):
data = {"content": 5, "files": {"SKILL.md": SKILL_MD}, "name": "n"}
_, content, _, _, _ = hub._normalize_bundle(data)
assert content == SKILL_MD
def test_files_bad_entries_skipped(self):
data = {
"name": "n",
"content": SKILL_MD,
"files": {"../evil": "x", 1: 2, "SKILL.md": "y"},
}
_, _, _, _, extra = hub._normalize_bundle(data)
assert extra == {}
# ---------------------------------------------------------------------------
# Text & name helpers
# ---------------------------------------------------------------------------
class TestNameHelpers:
def test_safe_fallback_name(self):
assert hub._safe_fallback_name("My Skill!") == "My-Skill"
assert hub._safe_fallback_name("///") == "imported-skill"
def test_normalize_skill_key(self):
assert hub._normalize_skill_key("Excel / XLSX") == "excel-xlsx"
assert hub._normalize_skill_key("--a--") == "a"
def test_sanitize_skill_dir_name(self):
assert hub._sanitize_skill_dir_name("plain") == "plain"
assert hub._sanitize_skill_dir_name("Excel / XLSX") == "excel-xlsx"
assert hub._sanitize_skill_dir_name("") == "imported-skill"
assert (
hub._sanitize_skill_dir_name(None) == "imported-skill"
) # type: ignore
def test_is_http_url(self):
assert hub._is_http_url("https://a.b/c") is True
assert hub._is_http_url("http://a.b") is True
assert hub._is_http_url("ftp://a.b") is False
assert hub._is_http_url("not a url") is False
def test_is_probably_text_blob(self):
assert hub._is_probably_text_blob(b"") is True
assert hub._is_probably_text_blob(b"hello world") is True
assert hub._is_probably_text_blob(b"\x00\x01binary") is False
def test_extract_error_message_from_payload(self):
assert hub._extract_error_message_from_payload(b"") == ""
assert hub._extract_error_message_from_payload(b"\x00\x01") == ""
assert (
hub._extract_error_message_from_payload(b"plain error")
== "plain error"
)
assert (
hub._extract_error_message_from_payload(
json.dumps({"error": "bad thing"}).encode(),
)
== "bad thing"
)
assert (
hub._extract_error_message_from_payload(
json.dumps({"message": "msg"}).encode(),
)
== "msg"
)
assert (
hub._extract_error_message_from_payload(
json.dumps({"other": 1}).encode(),
)
== '{"other": 1}'
)
assert hub._extract_error_message_from_payload(b" ") == ""
def test_format_http_error_body(self):
request = httpx.Request("GET", "http://x")
response = httpx.Response(
500,
content=b'{"error": "server exploded"}',
request=request,
)
err = httpx.HTTPStatusError(
"HTTP 500",
request=request,
response=response,
)
assert hub._format_http_error_body(err) == "server exploded"
def test_format_http_error_body_empty(self):
request = httpx.Request("GET", "http://x")
response = httpx.Response(500, content=b"", request=request)
err = httpx.HTTPStatusError(
"HTTP 500",
request=request,
response=response,
)
assert hub._format_http_error_body(err) == str(err)
# ---------------------------------------------------------------------------
# Provider URL parsers
# ---------------------------------------------------------------------------
class TestUrlParsers:
def test_clawhub_slug(self):
assert (
hub._extract_clawhub_slug_from_url("https://clawhub.ai/skills/foo")
== "foo"
)
assert (
hub._extract_clawhub_slug_from_url("https://clawhub.ai/owner/bar")
== "bar"
)
assert hub._extract_clawhub_slug_from_url("https://clawhub.ai/") == ""
assert (
hub._extract_clawhub_slug_from_url("https://other.ai/skills/foo")
== ""
)
def test_skills_sh_spec(self):
assert hub._extract_skills_sh_spec(
"https://skills.sh/owner/repo/skill",
) == ("owner", "repo", "skill")
assert hub._extract_skills_sh_spec("https://www.skills.sh/o/r/s") == (
"o",
"r",
"s",
)
assert hub._extract_skills_sh_spec("https://skills.sh/o/r") is None
assert hub._extract_skills_sh_spec("https://other.sh/o/r/s") is None
assert hub._extract_skills_sh_spec("https://skills.sh//r/s") is None
def test_skillsmp_slug(self):
assert (
hub._extract_skillsmp_slug(
"https://skillsmp.com/skills/abc-skill-md",
)
== "abc-skill-md"
)
assert (
hub._extract_skillsmp_slug(
"https://www.skillsmp.com/skills/x",
)
== "x"
)
assert hub._extract_skillsmp_slug("https://skillsmp.com/") == ""
assert hub._extract_skillsmp_slug("https://skillsmp.com/other/x") == ""
assert hub._extract_skillsmp_slug("https://other.com/skills/x") == ""
def test_lobehub_identifier(self):
assert (
hub._extract_lobehub_identifier(
"https://lobehub.com/skills/my-skill",
)
== "my-skill"
)
assert (
hub._extract_lobehub_identifier(
"https://market.lobehub.com/api/v1/skills/sk1/download",
)
== "sk1"
)
assert hub._extract_lobehub_identifier("https://lobehub.com/") == ""
assert (
hub._extract_lobehub_identifier("https://lobehub.com/other/x")
== ""
)
assert (
hub._extract_lobehub_identifier(
"https://market.lobehub.com/api/v1/skills/sk1",
)
== ""
)
assert hub._extract_lobehub_identifier("https://unknown.com/a") == ""
def test_modelscope_spec(self):
assert hub._extract_modelscope_skill_spec(
"https://modelscope.cn/skills/@owner/name",
) == ("@owner", "name", "")
assert hub._extract_modelscope_skill_spec(
"https://www.modelscope.cn/skills/o/n/archive/zip/master.zip",
) == ("o", "n", "master")
assert hub._extract_modelscope_skill_spec(
"https://modelscope.cn/skills/o/n/archive/zip/v1",
) == ("o", "n", "v1")
assert (
hub._extract_modelscope_skill_spec(
"https://modelscope.cn/other/o/n",
)
is None
)
assert (
hub._extract_modelscope_skill_spec(
"https://modelscope.cn/skills/o",
)
is None
)
assert (
hub._extract_modelscope_skill_spec(
"https://modelscope.cn/skills//n",
)
is None
)
assert (
hub._extract_modelscope_skill_spec("https://x.cn/skills/o/n")
is None
)
def test_qwenpaw_spec(self):
uuid = "12345678-1234-1234-1234-123456789abc"
assert hub._extract_qwenpaw_skill_spec(
f"https://platform.agentscope.io/skills/{uuid}",
) == ("", uuid, "")
assert hub._extract_qwenpaw_skill_spec(
"https://platform.agentscope.io/skills/@owner/name",
) == ("@owner", "name", "")
assert hub._extract_qwenpaw_skill_spec(
"https://platform.agentscope.io/skills/o/n/archive/zip/v2.zip",
) == ("o", "n", "v2")
assert (
hub._extract_qwenpaw_skill_spec(
"https://platform.agentscope.io/skills/onlyone",
)
is None
)
assert (
hub._extract_qwenpaw_skill_spec(
"https://platform.agentscope.io/other/x",
)
is None
)
assert (
hub._extract_qwenpaw_skill_spec(
"https://platform.agentscope.io/skills//n",
)
is None
)
assert (
hub._extract_qwenpaw_skill_spec("https://other.io/skills/x")
is None
)
def test_aliyun_spec(self):
assert (
hub._extract_aliyun_skill_spec(
"https://api.aliyun.com/agentexplorer/skills/sk-1",
)
== "sk-1"
)
assert (
hub._extract_aliyun_skill_spec(
"https://api.aliyun.com/AgentExplorer/Skills/sk-2",
)
== "sk-2"
)
assert (
hub._extract_aliyun_skill_spec("https://api.aliyun.com/x") is None
)
assert (
hub._extract_aliyun_skill_spec(
"https://api.aliyun.com/agentexplorer/other/sk",
)
is None
)
assert (
hub._extract_aliyun_skill_spec("https://other.com/a/b/c") is None
)
def test_github_spec(self):
assert hub._extract_github_spec(
"https://github.com/owner/repo",
) == ("owner", "repo", "", "")
assert hub._extract_github_spec(
"https://github.com/owner/repo/tree/dev/skills/x",
) == ("owner", "repo", "dev", "skills/x")
assert hub._extract_github_spec(
"https://github.com/owner/repo/blob/main/a.md",
) == ("owner", "repo", "main", "a.md")
assert hub._extract_github_spec(
"https://github.com/owner/repo/extra",
) == ("owner", "repo", "", "extra")
assert hub._extract_github_spec("https://github.com/owner") is None
assert hub._extract_github_spec("https://gitlab.com/o/r") is None
def test_resolve_clawhub_slug(self):
assert (
hub._resolve_clawhub_slug("https://clawhub.ai/skills/s1") == "s1"
)
assert hub._resolve_clawhub_slug("https://other.ai/x") == ""
# ---------------------------------------------------------------------------
# GitHub URL helpers & path helpers
# ---------------------------------------------------------------------------
class TestGithubHelpers:
def test_api_url(self):
assert (
hub._github_api_url("o", "r", "")
== "https://api.github.com/repos/o/r"
)
assert (
hub._github_api_url("o", "r", "/contents/x")
== "https://api.github.com/repos/o/r/contents/x"
)
def test_encode_path(self):
assert hub._github_encode_path("") == ""
assert hub._github_encode_path("a b/c") == "a%20b/c"
assert hub._github_encode_path("/lead/") == "lead"
def test_join_repo_path(self):
assert hub._join_repo_path("", "leaf") == "leaf"
assert hub._join_repo_path("root/", "/leaf") == "root/leaf"
def test_relative_from_root(self):
assert hub._relative_from_root("/x/y", "") == "x/y"
assert hub._relative_from_root("root/a/b", "root") == "a/b"
assert hub._relative_from_root("other/a", "root") == "other/a"
# ---------------------------------------------------------------------------
# GitHub API functions (mocked HTTP)
# ---------------------------------------------------------------------------
class TestGithubApiFunctions:
def _patch_json(self, monkeypatch, handler):
async def _fake(url, params=None, timeout=None):
return handler(url, params)
monkeypatch.setattr(hub, "_http_json_get", _fake)
def test_repo_exists_true(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: {"full_name": "o/r"})
assert _run(hub._github_repo_exists("o", "r")) is True
def test_repo_exists_false_on_error(self, monkeypatch):
async def _fake(url, params=None, timeout=None):
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404),
)
monkeypatch.setattr(hub, "_http_json_get", _fake)
assert _run(hub._github_repo_exists("o", "r")) is False
def test_repo_exists_empty_args(self):
assert _run(hub._github_repo_exists("", "r")) is False
def test_repo_exists_bad_payload(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: [1, 2])
assert _run(hub._github_repo_exists("o", "r")) is False
def test_default_branch_from_meta(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: {"default_branch": "dev"})
assert _run(hub._github_get_default_branch("o", "r")) == "dev"
def test_default_branch_fallback_main(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: {})
assert _run(hub._github_get_default_branch("o", "r")) == "main"
def test_list_skill_md_roots(self, monkeypatch):
tree = {
"tree": [
{"path": "SKILL.md"},
{"path": "skills/a/SKILL.md"},
{"path": "skills/a/other.md"},
{"path": "skills/a/SKILL.md"}, # duplicate root
"not-a-dict",
{"path": 5},
],
}
self._patch_json(monkeypatch, lambda u, p: tree)
roots = _run(hub._github_list_skill_md_roots("o", "r", "main"))
assert roots == ["", "skills/a"]
def test_list_skill_md_roots_404(self, monkeypatch):
async def _fake(url, params=None, timeout=None):
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404),
)
monkeypatch.setattr(hub, "_http_json_get", _fake)
assert _run(hub._github_list_skill_md_roots("o", "r", "main")) == []
def test_list_skill_md_roots_other_error_raises(self, monkeypatch):
async def _fake(url, params=None, timeout=None):
raise httpx.HTTPStatusError(
"500",
request=httpx.Request("GET", url),
response=httpx.Response(500),
)
monkeypatch.setattr(hub, "_http_json_get", _fake)
with pytest.raises(httpx.HTTPStatusError):
_run(hub._github_list_skill_md_roots("o", "r", "main"))
def test_list_skill_md_roots_bad_shape(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: "nope")
assert _run(hub._github_list_skill_md_roots("o", "r", "main")) == []
hub._github_cache.clear()
self._patch_json(monkeypatch, lambda u, p: {"tree": "x"})
assert _run(hub._github_list_skill_md_roots("o", "r", "main")) == []
def test_get_content_entry(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: {"type": "file"})
entry = _run(hub._github_get_content_entry("o", "r", "a b", "main"))
assert entry == {"type": "file"}
def test_get_content_entry_bad_shape(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: [1])
with pytest.raises(SkillsError, match="Unexpected GitHub response"):
_run(hub._github_get_content_entry("o", "r", "p", "main"))
def test_get_dir_entries(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: [{"type": "file"}, "x"])
entries = _run(hub._github_get_dir_entries("o", "r", "d", "main"))
assert entries == [{"type": "file"}]
def test_get_dir_entries_root_path(self, monkeypatch):
urls = []
async def _fake(url, params=None, timeout=None):
urls.append(url)
return []
monkeypatch.setattr(hub, "_http_json_get", _fake)
_run(hub._github_get_dir_entries("o", "r", "", "main"))
assert urls[0].endswith("/contents")
def test_get_dir_entries_non_list(self, monkeypatch):
self._patch_json(monkeypatch, lambda u, p: {"x": 1})
assert _run(hub._github_get_dir_entries("o", "r", "d", "main")) == []
def test_read_file_download_url(self, monkeypatch):
async def _fake_text(url, params=None):
return "downloaded"
monkeypatch.setattr(hub, "_http_text_get", _fake_text)
out = _run(hub._github_read_file({"download_url": "http://x/f"}))
assert out == "downloaded"
def test_read_file_base64_content(self):
encoded = base64.b64encode(b"decoded content").decode()
out = _run(hub._github_read_file({"content": encoded}))
assert out == "decoded content"
def test_read_file_bad_base64(self):
with pytest.raises(SkillsError, match="Unable to read"):
_run(hub._github_read_file({"content": "!!notbase64!!"}))
def test_read_file_nothing(self):
with pytest.raises(SkillsError, match="Unable to read"):
_run(hub._github_read_file({}))
def test_collect_tree_files(self, monkeypatch):
dir_map = {
"": [
{"type": "file", "path": "SKILL.md"},
{"type": "dir", "path": "sub"},
{"type": "other", "path": "skip"},
{"path": ""},
],
"sub": [{"type": "file", "path": "sub/a.txt"}],
}
async def _dirs(owner, repo, path, ref):
return dir_map.get(path, [])
async def _read(entry):
return f"content-of-{entry['path']}"
monkeypatch.setattr(hub, "_github_get_dir_entries", _dirs)
monkeypatch.setattr(hub, "_github_read_file", _read)
files = _run(hub._github_collect_tree_files("o", "r", "main", ""))
assert files == {
"SKILL.md": "content-of-SKILL.md",
"sub/a.txt": "content-of-sub/a.txt",
}
def test_collect_tree_files_max_cap(self, monkeypatch):
async def _dirs(owner, repo, path, ref):
return [{"type": "file", "path": f"f{i}"} for i in range(5)]
async def _read(entry):
return "x"
monkeypatch.setattr(hub, "_github_get_dir_entries", _dirs)
monkeypatch.setattr(hub, "_github_read_file", _read)
files = _run(
hub._github_collect_tree_files("o", "r", "main", "", max_files=2),
)
assert len(files) == 2
class TestResolveSkillsmpSpec:
def test_bad_url_none(self):
assert _run(hub._resolve_skillsmp_spec("https://x.com/")) is None
def test_too_few_tokens(self):
assert (
_run(
hub._resolve_skillsmp_spec("https://skillsmp.com/skills/a-b"),
)
is None
)
def test_repo_found(self, monkeypatch):
async def _exists(owner, repo):
return repo == "openclaw-skills"
monkeypatch.setattr(hub, "_github_repo_exists", _exists)
spec = _run(
hub._resolve_skillsmp_spec(
"https://skillsmp.com/skills/"
"openclaw-openclaw-skills-himalaya-skill-md",
),
)
assert spec == ("openclaw", "openclaw-skills", "himalaya")
def test_fallback_when_no_repo_found(self, monkeypatch):
async def _exists(owner, repo):
return False
monkeypatch.setattr(hub, "_github_repo_exists", _exists)
spec = _run(
hub._resolve_skillsmp_spec(
"https://skillsmp.com/skills/owner-repo-skill-name",
),
)
assert spec == ("owner", "repo", "skill-name")
# ---------------------------------------------------------------------------
# Zip → bundle converters
# ---------------------------------------------------------------------------
class TestLobehubZipToBundle:
def test_valid_zip_with_frontmatter_name(self):
payload = _make_zip(
{
"SKILL.md": SKILL_MD,
"references/r.md": "ref",
"scripts/s.py": "sc",
},
)
bundle = hub._lobehub_zip_to_bundle("fallback", payload)
assert bundle["name"] == "demo-skill"
assert bundle["files"]["SKILL.md"] == SKILL_MD
assert bundle["files"]["references/r.md"] == "ref"
def test_name_falls_back_to_identifier(self):
payload = _make_zip({"SKILL.md": "# no frontmatter"})
bundle = hub._lobehub_zip_to_bundle("fallback-id", payload)
assert bundle["name"] == "fallback-id"
def test_nested_paths_dropped(self):
payload = _make_zip(
{"SKILL.md": SKILL_MD, "deep/nested/file.txt": "x"},
)
bundle = hub._lobehub_zip_to_bundle("id", payload)
assert "deep/nested/file.txt" not in bundle["files"]
def test_binary_files_skipped(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("SKILL.md", SKILL_MD)
zf.writestr("img.bin", b"\x00\x01\x02binary")
bundle = hub._lobehub_zip_to_bundle("id", buf.getvalue())
assert "img.bin" not in bundle["files"]
def test_bad_zip_with_error_message(self):
payload = json.dumps({"error": "not found"}).encode()
with pytest.raises(SkillsError, match="not found"):
hub._lobehub_zip_to_bundle("id", payload)
def test_bad_zip_generic(self):
# Binary payload -> no extractable text -> generic zip message.
with pytest.raises(SkillsError, match="valid zip"):
hub._lobehub_zip_to_bundle("id", b"\x00\x01\x02notzip")
def test_bad_zip_with_text_message(self):
with pytest.raises(SkillsError, match="not a zip"):
hub._lobehub_zip_to_bundle("id", b"not a zip")
def test_missing_skill_md(self):
payload = _make_zip({"other.md": "x"})
with pytest.raises(SkillsError, match="missing SKILL.md"):
hub._lobehub_zip_to_bundle("id", payload)
def test_too_many_entries(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_ENTRIES", 0)
payload = _make_zip({"SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too many files"):
hub._lobehub_zip_to_bundle("id", payload)
def test_too_large(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_BYTES", 1)
payload = _make_zip({"SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too large"):
hub._lobehub_zip_to_bundle("id", payload)
class TestModelscopeArchiveToBundle:
def test_prefix_stripped(self):
payload = _make_zip(
{
"skills-owner.name-main-abc/SKILL.md": SKILL_MD,
"skills-owner.name-main-abc/references/r.md": "r",
},
)
bundle = hub._modelscope_archive_to_bundle(payload, "fallback")
assert bundle["name"] == "demo-skill"
assert bundle["files"]["SKILL.md"] == SKILL_MD
assert bundle["files"]["references/r.md"] == "r"
def test_top_level_files_skipped(self):
payload = _make_zip({"toplevel.txt": "x"})
with pytest.raises(SkillsError, match="missing SKILL.md"):
hub._modelscope_archive_to_bundle(payload, "f")
def test_bad_zip(self):
with pytest.raises(SkillsError, match="not a valid zip"):
hub._modelscope_archive_to_bundle(b"junk", "f")
def test_fallback_name_when_no_frontmatter(self):
payload = _make_zip({"p/SKILL.md": "# plain"})
bundle = hub._modelscope_archive_to_bundle(payload, "fallback-name")
assert bundle["name"] == "fallback-name"
def test_bad_yaml_fallback(self):
payload = _make_zip({"p/SKILL.md": "---\n: [bad\n---\n"})
bundle = hub._modelscope_archive_to_bundle(payload, "fb")
assert bundle["name"] == "fb"
def test_too_many_entries(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_ENTRIES", 0)
payload = _make_zip({"p/SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too many files"):
hub._modelscope_archive_to_bundle(payload, "f")
def test_too_large(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_BYTES", 1)
payload = _make_zip({"p/SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too large"):
hub._modelscope_archive_to_bundle(payload, "f")
def test_binary_skipped(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("p/SKILL.md", SKILL_MD)
zf.writestr("p/x.bin", b"\x00\x01")
bundle = hub._modelscope_archive_to_bundle(buf.getvalue(), "f")
assert "x.bin" not in bundle["files"]
class TestQwenpawDetailArchiveToBundle:
def test_flat_zip(self):
payload = _make_zip({"SKILL.md": SKILL_MD, "references/r.md": "r"})
bundle = hub._qwenpaw_detail_archive_to_bundle(payload, "fb")
assert bundle["name"] == "demo-skill"
assert bundle["files"]["references/r.md"] == "r"
def test_bad_zip(self):
with pytest.raises(SkillsError, match="not a valid zip"):
hub._qwenpaw_detail_archive_to_bundle(b"junk", "f")
def test_missing_skill_md(self):
payload = _make_zip({"other.md": "x"})
with pytest.raises(SkillsError, match="missing SKILL.md"):
hub._qwenpaw_detail_archive_to_bundle(payload, "f")
def test_fallback_name(self):
payload = _make_zip({"SKILL.md": "# plain"})
bundle = hub._qwenpaw_detail_archive_to_bundle(payload, "fb-name")
assert bundle["name"] == "fb-name"
def test_too_many(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_ENTRIES", 0)
payload = _make_zip({"SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too many files"):
hub._qwenpaw_detail_archive_to_bundle(payload, "f")
def test_too_large(self, monkeypatch):
monkeypatch.setattr(hub, "SKILL_PACKAGE_MAX_BYTES", 1)
payload = _make_zip({"SKILL.md": SKILL_MD})
with pytest.raises(SkillsError, match="too large"):
hub._qwenpaw_detail_archive_to_bundle(payload, "f")
class TestAliyunResponseToBundle:
def test_valid(self):
bundle = hub._aliyun_response_to_bundle(
"sk-1",
{"requestId": "r", "content": "# md"},
)
assert bundle == {"name": "sk-1", "files": {"SKILL.md": "# md"}}
def test_non_dict_body(self):
with pytest.raises(SkillsError, match="non-dict"):
hub._aliyun_response_to_bundle("sk", [1])
def test_missing_content(self):
with pytest.raises(SkillsError, match="missing `content`"):
hub._aliyun_response_to_bundle("sk", {"content": " "})
# ---------------------------------------------------------------------------
# Provider fetchers (mocked network)
# ---------------------------------------------------------------------------
class TestFetchBundleSkillsSh:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_skills_sh_url("https://x.com/", ""))
def test_success(self, monkeypatch):
async def _branch(owner, repo):
return "dev"
async def _fetch(**kwargs):
assert kwargs["default_branch"] == "dev"
return {"name": "skill", "files": {"SKILL.md": "x"}}, "https://src"
monkeypatch.setattr(hub, "_github_get_default_branch", _branch)
monkeypatch.setattr(
hub,
"_fetch_bundle_from_repo_and_skill_hint",
_fetch,
)
bundle, url = _run(
hub._fetch_bundle_from_skills_sh_url(
"https://skills.sh/o/r/skill",
"",
),
)
assert bundle["name"] == "skill"
assert url == "https://src"
class TestFetchBundleFromRepoAndSkillHint:
def test_direct_hit(self, monkeypatch):
async def _content(owner, repo, path, ref):
if path == "skills/myskill/SKILL.md":
return {"type": "file", "download_url": "http://x"}
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", "http://x"),
response=httpx.Response(404),
)
async def _read(entry):
return "# skill md"
async def _collect(**kwargs):
return {"references/r.md": "r"}
monkeypatch.setattr(hub, "_github_get_content_entry", _content)
monkeypatch.setattr(hub, "_github_read_file", _read)
monkeypatch.setattr(hub, "_github_collect_tree_files", _collect)
bundle, source = _run(
hub._fetch_bundle_from_repo_and_skill_hint(
owner="o",
repo="r",
skill_hint="myskill",
requested_version="",
default_branch="main",
),
)
assert bundle["name"] == "myskill"
assert bundle["files"]["SKILL.md"] == "# skill md"
assert bundle["files"]["references/r.md"] == "r"
assert source == "https://github.com/o/r"
def test_fallback_scan(self, monkeypatch):
async def _content(owner, repo, path, ref):
if path == "found/SKILL.md":
return {"type": "file"}
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", "http://x"),
response=httpx.Response(404),
)
async def _roots(owner, repo, ref):
return ["found"]
async def _read(entry):
return "# md"
async def _collect(**kwargs):
return {}
monkeypatch.setattr(hub, "_github_get_content_entry", _content)
monkeypatch.setattr(hub, "_github_list_skill_md_roots", _roots)
monkeypatch.setattr(hub, "_github_read_file", _read)
monkeypatch.setattr(hub, "_github_collect_tree_files", _collect)
bundle, _ = _run(
hub._fetch_bundle_from_repo_and_skill_hint(
owner="o",
repo="r",
skill_hint="found-skill",
requested_version="",
),
)
assert bundle["files"]["SKILL.md"] == "# md"
def test_not_found_raises(self, monkeypatch):
async def _content(owner, repo, path, ref):
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", "http://x"),
response=httpx.Response(404),
)
async def _roots(owner, repo, ref):
return []
monkeypatch.setattr(hub, "_github_get_content_entry", _content)
monkeypatch.setattr(hub, "_github_list_skill_md_roots", _roots)
with pytest.raises(SkillsError, match="Could not find SKILL.md"):
_run(
hub._fetch_bundle_from_repo_and_skill_hint(
owner="o",
repo="r",
skill_hint="ghost",
requested_version="",
),
)
def test_non_404_error_raises(self, monkeypatch):
async def _content(owner, repo, path, ref):
raise httpx.HTTPStatusError(
"500",
request=httpx.Request("GET", "http://x"),
response=httpx.Response(500),
)
monkeypatch.setattr(hub, "_github_get_content_entry", _content)
with pytest.raises(httpx.HTTPStatusError):
_run(
hub._fetch_bundle_from_repo_and_skill_hint(
owner="o",
repo="r",
skill_hint="s",
requested_version="v1",
),
)
class TestFetchBundleGithubUrl:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_github_url("https://x.com/", ""))
def test_skill_md_path_normalized(self, monkeypatch):
captured = {}
async def _branch(owner, repo):
return ""
async def _fetch(**kwargs):
captured.update(kwargs)
return {"name": "n", "files": {}}, "src"
monkeypatch.setattr(hub, "_github_get_default_branch", _branch)
monkeypatch.setattr(
hub,
"_fetch_bundle_from_repo_and_skill_hint",
_fetch,
)
_run(
hub._fetch_bundle_from_github_url(
"https://github.com/o/r/tree/main/skills/x/SKILL.md",
"",
),
)
assert captured["skill_hint"] == "skills/x"
def test_bare_skill_md_path(self, monkeypatch):
captured = {}
async def _branch(owner, repo):
raise RuntimeError("no meta")
async def _fetch(**kwargs):
captured.update(kwargs)
return {"name": "n"}, "src"
monkeypatch.setattr(hub, "_github_get_default_branch", _branch)
monkeypatch.setattr(
hub,
"_fetch_bundle_from_repo_and_skill_hint",
_fetch,
)
_run(
hub._fetch_bundle_from_github_url(
"https://github.com/o/r/tree/main/SKILL.md",
"",
),
)
assert captured["skill_hint"] == ""
assert captured["default_branch"] == "main"
class TestFetchBundleSkillsmpUrl:
def test_invalid(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_skillsmp_url("https://x.com/", ""))
class TestFetchBundleLobehub:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_lobehub_url("https://x.com/", ""))
def test_success(self, monkeypatch):
payload = _make_zip({"SKILL.md": SKILL_MD})
async def _bytes(url, params=None, accept="", max_bytes=None):
assert params is None
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
bundle, url = _run(
hub._fetch_bundle_from_lobehub_url(
"https://lobehub.com/skills/my-skill",
"",
),
)
assert bundle["name"] == "demo-skill"
assert "lobehub" in url
def test_version_param(self, monkeypatch):
payload = _make_zip({"SKILL.md": SKILL_MD})
seen = {}
async def _bytes(url, params=None, accept="", max_bytes=None):
seen["params"] = params
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
_run(
hub._fetch_bundle_from_lobehub_url(
"https://lobehub.com/skills/my-skill",
"v2",
),
)
assert seen["params"] == {"version": "v2"}
def test_http_error(self, monkeypatch):
async def _bytes(url, params=None, accept="", max_bytes=None):
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404, content=b"gone"),
)
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
with pytest.raises(SkillsError, match="download failed"):
_run(
hub._fetch_bundle_from_lobehub_url(
"https://lobehub.com/skills/x",
"",
),
)
def test_value_error(self, monkeypatch):
async def _bytes(url, params=None, accept="", max_bytes=None):
raise ValueError("bad value")
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
with pytest.raises(SkillsError, match="bad value"):
_run(
hub._fetch_bundle_from_lobehub_url(
"https://lobehub.com/skills/x",
"",
),
)
class TestFetchBundleModelscope:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_modelscope_url("https://x.com/", ""))
def test_success(self, monkeypatch):
payload = _make_zip({"wrap/SKILL.md": SKILL_MD})
async def _bytes(url, params=None, accept="", max_bytes=None):
assert "modelscope" in url
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
bundle, _ = _run(
hub._fetch_bundle_from_modelscope_url(
"https://modelscope.cn/skills/@owner/name",
"",
),
)
assert bundle["name"] == "demo-skill"
def test_version_from_hint(self, monkeypatch):
urls = []
payload = _make_zip({"wrap/SKILL.md": SKILL_MD})
async def _bytes(url, params=None, accept="", max_bytes=None):
urls.append(url)
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
_run(
hub._fetch_bundle_from_modelscope_url(
"https://modelscope.cn/skills/o/n/archive/zip/v9.zip",
"",
),
)
assert "v9" in urls[0]
def test_http_error(self, monkeypatch):
async def _bytes(url, params=None, accept="", max_bytes=None):
raise httpx.HTTPStatusError(
"404",
request=httpx.Request("GET", url),
response=httpx.Response(404, content=b"nope"),
)
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
with pytest.raises(SkillsError, match="download failed"):
_run(
hub._fetch_bundle_from_modelscope_url(
"https://modelscope.cn/skills/o/n",
"",
),
)
class TestFetchBundleQwenpaw:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_qwenpaw_url("https://x.com/", ""))
def test_owner_path_uses_modelscope_converter(self, monkeypatch):
payload = _make_zip({"wrap/SKILL.md": SKILL_MD})
urls = []
async def _bytes(url, params=None, accept="", max_bytes=None):
urls.append(url)
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
bundle, _ = _run(
hub._fetch_bundle_from_qwenpaw_url(
"https://platform.agentscope.io/skills/@owner/name",
"",
),
)
assert bundle["name"] == "demo-skill"
assert "/archive/zip/master" in urls[0]
def test_uuid_path_uses_detail_converter(self, monkeypatch):
uuid = "12345678-1234-1234-1234-123456789abc"
payload = _make_zip({"SKILL.md": SKILL_MD})
urls = []
async def _bytes(url, params=None, accept="", max_bytes=None):
urls.append(url)
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
bundle, _ = _run(
hub._fetch_bundle_from_qwenpaw_url(
f"https://platform.agentscope.io/skills/{uuid}",
"",
),
)
assert bundle["name"] == "demo-skill"
assert "download" in urls[0]
def test_requested_version_overrides(self, monkeypatch):
urls = []
payload = _make_zip({"wrap/SKILL.md": SKILL_MD})
async def _bytes(url, params=None, accept="", max_bytes=None):
urls.append(url)
return payload
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
_run(
hub._fetch_bundle_from_qwenpaw_url(
"https://platform.agentscope.io/skills/o/n",
"v5",
),
)
assert "v5" in urls[0]
def test_http_error(self, monkeypatch):
async def _bytes(url, params=None, accept="", max_bytes=None):
raise httpx.HTTPStatusError(
"500",
request=httpx.Request("GET", url),
response=httpx.Response(500, content=b"boom"),
)
monkeypatch.setattr(hub, "_http_bytes_get", _bytes)
with pytest.raises(SkillsError, match="download failed"):
_run(
hub._fetch_bundle_from_qwenpaw_url(
"https://platform.agentscope.io/skills/o/n",
"",
),
)
class TestFetchBundleAliyun:
def test_invalid_url(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_aliyun_url("https://x.com/", ""))
def test_success(self, monkeypatch):
import qwenpaw.market.providers.aliyun as aliyun_mod
async def _call(action, pathname, method):
assert action == "GetSkillContent"
return {"content": "# aliyun skill"}
monkeypatch.setattr(
aliyun_mod,
"call_aliyun_action_async",
_call,
)
bundle, url = _run(
hub._fetch_bundle_from_aliyun_url(
"https://api.aliyun.com/agentexplorer/skills/sk-9",
"",
),
)
assert bundle["files"]["SKILL.md"] == "# aliyun skill"
assert "aliyun" in url
def test_call_failure(self, monkeypatch):
import qwenpaw.market.providers.aliyun as aliyun_mod
async def _call(action, pathname, method):
raise RuntimeError("denied")
monkeypatch.setattr(
aliyun_mod,
"call_aliyun_action_async",
_call,
)
with pytest.raises(SkillsError, match="GetSkillContent failed"):
_run(
hub._fetch_bundle_from_aliyun_url(
"https://api.aliyun.com/agentexplorer/skills/sk-9",
"",
),
)
# ---------------------------------------------------------------------------
# ClawHub hydrate / fetch
# ---------------------------------------------------------------------------
class TestHydrateClawhubPayload:
def test_already_has_content(self):
data = {"content": "x"}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s",
requested_version="",
),
)
assert out == data
def test_non_dict_passthrough(self):
out = _run(
hub._hydrate_clawhub_payload("x", slug="s", requested_version=""),
)
assert out == "x"
def test_no_skill_dict(self):
data = {"other": 1}
out = _run(
hub._hydrate_clawhub_payload(data, slug="s", requested_version=""),
)
assert out == data
def test_no_slug_returns_data(self):
data = {"skill": {}}
out = _run(
hub._hydrate_clawhub_payload(data, slug="", requested_version=""),
)
assert out == data
def test_no_version_hint_returns_data(self, monkeypatch):
data = {"skill": {"slug": "s1"}}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="",
),
)
assert out == data
def test_full_hydration(self, monkeypatch):
version_payload = {
"version": {
"version": "1.0",
"files": [{"path": "SKILL.md"}, {"path": "references/r.md"}],
},
}
async def _json(url, params=None, timeout=None):
return version_payload
async def _text(url, params=None):
return f"content-of-{params['path']}"
monkeypatch.setattr(hub, "_http_json_get", _json)
monkeypatch.setattr(hub, "_http_text_get", _text)
data = {"skill": {"slug": "s1", "displayName": "Skill One"}}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="1.0",
),
)
assert out["name"] == "Skill One"
assert out["files"]["SKILL.md"] == "content-of-SKILL.md"
def test_version_response_bad_shape_returns_data(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return "garbage"
monkeypatch.setattr(hub, "_http_json_get", _json)
data = {"skill": {"slug": "s1"}}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="1.0",
),
)
assert out == data
def test_files_meta_not_list_returns_data(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return {"version": {"version": "1.0", "files": "bad"}}
monkeypatch.setattr(hub, "_http_json_get", _json)
data = {"skill": {"slug": "s1"}}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="1.0",
),
)
assert out == data
def test_skill_md_fetch_failed_raises(self, monkeypatch):
version_payload = {
"version": {"version": "1.0", "files": [{"path": "SKILL.md"}]},
}
async def _json(url, params=None, timeout=None):
return version_payload
async def _text(url, params=None):
raise RuntimeError("fetch failed")
monkeypatch.setattr(hub, "_http_json_get", _json)
monkeypatch.setattr(hub, "_http_text_get", _text)
data = {"skill": {"slug": "s1"}}
with pytest.raises(SkillsError, match="Failed to fetch SKILL.md"):
_run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="1.0",
),
)
def test_empty_files_no_error_returns_data(self, monkeypatch):
version_payload = {
"version": {"version": "1.0", "files": [{"path": ""}, "bad"]},
}
async def _json(url, params=None, timeout=None):
return version_payload
monkeypatch.setattr(hub, "_http_json_get", _json)
data = {"skill": {"slug": "s1"}}
out = _run(
hub._hydrate_clawhub_payload(
data,
slug="s1",
requested_version="1.0",
),
)
assert out == data
class TestFetchBundleClawhub:
def test_slug_required(self):
with pytest.raises(ConfigurationException, match="slug is required"):
_run(hub._fetch_bundle_from_clawhub_slug("", ""))
def test_success(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return {"content": "x", "name": "n"}
monkeypatch.setattr(hub, "_http_json_get", _json)
bundle, url = _run(hub._fetch_bundle_from_clawhub_slug("s1", ""))
assert bundle == {"content": "x", "name": "n"}
assert "/api/v1/skills/s1" in url
def test_all_candidates_fail(self, monkeypatch):
async def _json(url, params=None, timeout=None):
raise RuntimeError("down")
monkeypatch.setattr(hub, "_http_json_get", _json)
with pytest.raises(SkillsError, match="importing from ClawHub"):
_run(hub._fetch_bundle_from_clawhub_slug("s1", ""))
def test_url_adapter_invalid(self):
with pytest.raises(ConfigurationException):
_run(hub._fetch_bundle_from_clawhub_url("https://other.ai/", ""))
def test_url_adapter_success(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return {"content": "c"}
monkeypatch.setattr(hub, "_http_json_get", _json)
bundle, _ = _run(
hub._fetch_bundle_from_clawhub_url(
"https://clawhub.ai/skills/s2",
"",
),
)
assert bundle == {"content": "c"}
# ---------------------------------------------------------------------------
# Search API
# ---------------------------------------------------------------------------
class TestSearchHubSkills:
def test_results_mapping(self, monkeypatch):
async def _json(url, params=None, timeout=None):
assert params == {"q": "pdf", "limit": 2}
return {
"items": [
{
"slug": "pdf-tool",
"name": "PDF Tool",
"description": "d",
"version": "1.0",
"url": "http://x",
"owner": {
"handle": "h",
"displayName": "Display",
"image": "http://img",
},
},
{"name": "no-slug-but-name"},
{"nothing": True},
],
}
monkeypatch.setattr(hub, "_http_json_get", _json)
results = _run(hub.search_hub_skills("pdf", limit=2))
assert len(results) == 2
assert results[0].slug == "pdf-tool"
assert results[0].author == "Display"
assert results[0].icon_url == "http://img"
assert results[1].slug == "no-slug-but-name"
def test_owner_handle_fallback(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return [
{"slug": "s", "ownerHandle": "fallback-handle"},
]
monkeypatch.setattr(hub, "_http_json_get", _json)
results = _run(hub.search_hub_skills("x"))
assert results[0].author == "fallback-handle"
# ---------------------------------------------------------------------------
# Provider routing
# ---------------------------------------------------------------------------
class TestProviderRouting:
@pytest.mark.parametrize(
"url,expected",
[
("https://skills.sh/o/r/s", "skills-sh"),
("https://github.com/o/r", "github"),
("https://lobehub.com/skills/x", "lobehub"),
("https://platform.agentscope.io/skills/o/n", "qwenpaw"),
("https://modelscope.cn/skills/o/n", "modelscope"),
(
"https://api.aliyun.com/agentexplorer/skills/sk",
"aliyun",
),
("https://skillsmp.com/skills/slug", "skillsmp"),
("https://clawhub.ai/skills/s", "clawhub"),
("https://example.com/bundle.json", "url"),
],
)
def test_match_provider(self, url, expected):
name, fetcher = hub._match_provider(url)
assert name == expected
if expected == "url":
assert fetcher is None
else:
assert fetcher is not None
def test_classify_origin_empty(self):
assert hub._classify_install_origin("") == ""
def test_resolve_bundle_via_fetcher(self, monkeypatch):
async def _fetcher(url, requested_version=""):
return {"bundle": 1}, "resolved"
monkeypatch.setattr(
hub,
"_match_provider",
lambda url: ("github", _fetcher),
)
bundle, src = _run(hub._resolve_bundle_from_url("http://x", "v"))
assert bundle == {"bundle": 1}
assert src == "resolved"
def test_resolve_bundle_fallback_json(self, monkeypatch):
async def _json(url, params=None, timeout=None):
return {"raw": 1}
monkeypatch.setattr(hub, "_match_provider", lambda url: ("url", None))
monkeypatch.setattr(hub, "_http_json_get", _json)
bundle, src = _run(
hub._resolve_bundle_from_url("http://x/bundle.json", ""),
)
assert bundle == {"raw": 1}
assert src == "http://x/bundle.json"
# ---------------------------------------------------------------------------
# Install pipeline
# ---------------------------------------------------------------------------
class TestPrepareInstallPayload:
def test_invalid_url(self):
with pytest.raises(ConfigurationException, match="bundle_url"):
_run(hub._prepare_install_payload("not-a-url", "", None))
with pytest.raises(ConfigurationException):
_run(hub._prepare_install_payload("", "", None))
def test_full_flow(self, monkeypatch):
async def _resolve(url, version):
return (
{"name": "My Skill", "content": SKILL_MD},
"http://src",
)
monkeypatch.setattr(hub, "_resolve_bundle_from_url", _resolve)
payload = _run(
hub._prepare_install_payload("https://example.com/x", "", None),
)
assert payload.name == "My Skill"
assert payload.source_url == "http://src"
assert payload.installed_from == "url"
def test_target_name_overrides(self, monkeypatch):
async def _resolve(url, version):
return {"name": "orig", "content": SKILL_MD}, "src"
monkeypatch.setattr(hub, "_resolve_bundle_from_url", _resolve)
payload = _run(
hub._prepare_install_payload(
"https://example.com/x",
"",
"Custom/Name",
),
)
assert payload.name == "custom-name"
def test_name_sanitized(self, monkeypatch):
async def _resolve(url, version):
return {"name": "Excel / XLSX", "content": SKILL_MD}, "src"
monkeypatch.setattr(hub, "_resolve_bundle_from_url", _resolve)
payload = _run(
hub._prepare_install_payload("https://example.com/x", "", None),
)
assert payload.name == "excel-xlsx"
def test_cancelled_before_fetch(self):
with hub._with_cancel_checker(lambda: True):
with pytest.raises(SkillImportCancelled):
_run(
hub._prepare_install_payload(
"https://example.com/x",
"",
None,
),
)
class TestInstallSkillFromHub:
def test_success(self, monkeypatch, tmp_path):
async def _prepare(url, version, target):
return hub._InstallPayload(
name="demo",
content=SKILL_MD,
references={},
scripts={},
extra_files={},
source_url="http://src",
installed_from="github",
)
class _Svc:
def __init__(self, workspace_dir):
pass
def create_skill(self, **kwargs):
return kwargs["name"]
def enable_skill(self, name):
return {"success": True}
monkeypatch.setattr(hub, "_prepare_install_payload", _prepare)
monkeypatch.setattr(hub, "SkillService", _Svc)
result = _run(
hub.install_skill_from_hub(
workspace_dir=tmp_path,
bundle_url="https://github.com/o/r",
enable=True,
),
)
assert result.name == "demo"
assert result.enabled is True
assert result.installed_from == "github"
def test_enable_failed(self, monkeypatch, tmp_path):
async def _prepare(url, version, target):
return hub._InstallPayload(
name="demo",
content=SKILL_MD,
references={},
scripts={},
extra_files={},
source_url="s",
installed_from="",
)
class _Svc:
def __init__(self, workspace_dir):
pass
def create_skill(self, **kwargs):
return "demo"
def enable_skill(self, name):
return {"success": False}
monkeypatch.setattr(hub, "_prepare_install_payload", _prepare)
monkeypatch.setattr(hub, "SkillService", _Svc)
result = _run(
hub.install_skill_from_hub(
workspace_dir=tmp_path,
bundle_url="http://x",
enable=True,
),
)
assert result.enabled is False
def test_conflict(self, monkeypatch, tmp_path):
async def _prepare(url, version, target):
return hub._InstallPayload(
name="demo",
content=SKILL_MD,
references={},
scripts={},
extra_files={},
source_url="s",
installed_from="",
)
class _Svc:
def __init__(self, workspace_dir):
pass
def create_skill(self, **kwargs):
return None
monkeypatch.setattr(hub, "_prepare_install_payload", _prepare)
monkeypatch.setattr(hub, "SkillService", _Svc)
with pytest.raises(SkillConflictError):
_run(
hub.install_skill_from_hub(
workspace_dir=tmp_path,
bundle_url="http://x",
),
)
def test_cancelled(self, tmp_path):
with pytest.raises(SkillImportCancelled):
_run(
hub.install_skill_from_hub(
workspace_dir=tmp_path,
bundle_url="http://x",
cancel_checker=lambda: True,
),
)
class TestImportPoolSkillFromHub:
def test_success(self, monkeypatch):
async def _prepare(url, version, target):
return hub._InstallPayload(
name="pool-skill",
content=SKILL_MD,
references={},
scripts={},
extra_files={},
source_url="s",
installed_from="clawhub",
)
class _Pool:
def create_skill(self, **kwargs):
return kwargs["name"]
monkeypatch.setattr(hub, "_prepare_install_payload", _prepare)
monkeypatch.setattr(hub, "SkillPoolService", _Pool)
result = _run(
hub.import_pool_skill_from_hub(bundle_url="http://x"),
)
assert result.name == "pool-skill"
assert result.enabled is False
assert result.installed_from == "clawhub"
def test_conflict(self, monkeypatch):
async def _prepare(url, version, target):
return hub._InstallPayload(
name="dup",
content=SKILL_MD,
references={},
scripts={},
extra_files={},
source_url="s",
installed_from="",
)
class _Pool:
def create_skill(self, **kwargs):
return None
monkeypatch.setattr(hub, "_prepare_install_payload", _prepare)
monkeypatch.setattr(hub, "SkillPoolService", _Pool)
with pytest.raises(SkillConflictError):
_run(hub.import_pool_skill_from_hub(bundle_url="http://x"))
class TestBuildHubConflict:
def test_shape(self):
out = hub._build_hub_conflict("my-skill")
assert out["reason"] == "conflict"
assert out["skill_name"] == "my-skill"
assert out["suggested_name"].startswith("my-skill")
assert out["conflicts"][0]["reason"] == "conflict"
assert "already exists" in out["message"]