forked from retoor/devplacepy
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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# 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")
|
||||
Reference in New Issue
Block a user