Files
devplacepy/tests/unit/services/skills/fetcher.py
T
retoorandClaude Sonnet 5.5 f55c7ee5f4
DevPlace CI / test (push) Failing after 31m42s
Add community node protocol spec, Agent Skills, and make the failing tests hermetic
Community nodes (Phase 1 of plan.md): the protocol specification in
services/nodes/spec.py as the single source of truth, GET /nodes/spec.json,
a generated protocol docs page, the Community nodes API docs group, and the
implementation plan plus the residential proxy research. The no-open-proxy rule
is enforced in the spec (open_proxy cannot be set).

Agent Skills: register the Skills API docs group, cascade skills on account
deletion, add the nested CLAUDE.md, README and routes docs, and cover the
parser, the fetcher (end to end against a mock origin), the data layer and the
routes with 73 unit and 16 api tests.

Tests made hermetic with the repo's existing patterns instead of ambient
environment: embeddings tests set OPENROUTER_API_KEY via monkeypatch, the
workspace tests install FakeBackend via runtime.set_backend, and the skills
install tests use run_async. DockerCliBackend now returns 127 (command not
found) when the CLI is missing instead of raising FileNotFoundError, so a host
without docker answers with a clean error rather than a 500. The Providers tab
empty state no longer names the Model routes tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:13:25 +02:00

95 lines
3.0 KiB
Python

# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.services.skills import limits
from devplacepy.services.skills.fetcher import (
SkillFetchError,
classify_candidate,
decode_text,
directory_url,
extract_candidates,
normalize_input_url,
origin_of,
)
from devplacepy.services.skills.parser import SkillFormatError
def test_normalize_adds_scheme_and_strips_fragment():
assert normalize_input_url("example.org/skill#frag") == "https://example.org/skill"
assert normalize_input_url(" http://example.org/a/SKILL.md ") == "http://example.org/a/SKILL.md"
@pytest.mark.parametrize(
"raw",
["", " ", "ftp://example.org/x", "https://user:pw@example.org/x", "x" * (limits.URL_MAX_CHARS + 1)],
)
def test_normalize_rejects_bad_urls(raw):
with pytest.raises(SkillFetchError):
normalize_input_url(raw)
@pytest.mark.parametrize(
"url,expected",
[
("https://e.org/a/SKILL.md", "https://e.org/a/"),
("https://e.org/a/index.html", "https://e.org/a/"),
("https://e.org/a", "https://e.org/a/"),
("https://e.org/a/", "https://e.org/a/"),
("https://e.org", "https://e.org/"),
],
)
def test_directory_url(url, expected):
assert directory_url(url) == expected
def test_origin_uses_default_ports():
assert origin_of("https://Example.org/x") == ("https", "example.org", 443)
assert origin_of("http://example.org:8080/x") == ("http", "example.org", 8080)
def test_candidates_come_from_links_and_code_spans_once():
body = (
"See [the script](scripts/run.py) and `references/guide.md`.\n"
"Again [dup](scripts/run.py#anchor) and [other](docs/readme.md) and `not a path`."
)
assert extract_candidates(body) == ["scripts/run.py", "references/guide.md"]
@pytest.mark.parametrize(
"candidate,accepted",
[
("scripts/run.py", True),
("./references/guide.md", True),
("assets/templates/SPEC.md", True),
("assets/img/hero.png", False),
("../scripts/run.py", False),
("scripts/../../etc/passwd", False),
("scripts/%2e%2e/x.py", False),
("scripts/a.py?x=1", False),
("/scripts/run.py", False),
("https://evil.org/scripts/run.py", False),
("scripts\\run.py", False),
("other/run.py", False),
("scripts", False),
("scripts/sp ace.py", False),
],
)
def test_classify_candidate_only_accepts_safe_relative_text_paths(candidate, accepted):
path, reason = classify_candidate(candidate)
assert (path is not None) is accepted
assert (reason == "") is accepted
def test_overlong_path_is_rejected():
path, reason = classify_candidate("scripts/" + "a" * limits.BUNDLED_PATH_MAX_CHARS + ".py")
assert path is None and reason
def test_decode_text():
assert decode_text("héllo".encode("utf-8")) == "héllo"
with pytest.raises(SkillFormatError):
decode_text(b"a\x00b")
with pytest.raises(SkillFormatError):
decode_text(b"\xff\xfe\xfa")