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,107 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.services.skills import limits
|
||||
from devplacepy.services.skills.parser import SkillFormatError, parse_skill_md
|
||||
|
||||
|
||||
def skill(frontmatter: str, body: str = "# Body\n\nDo the thing.") -> str:
|
||||
return f"---\n{frontmatter}\n---\n{body}"
|
||||
|
||||
|
||||
def test_minimal_skill_parses():
|
||||
parsed = parse_skill_md(skill("name: my-skill\ndescription: Does a thing."))
|
||||
assert parsed.name == "my-skill"
|
||||
assert parsed.description == "Does a thing."
|
||||
assert parsed.body.startswith("# Body")
|
||||
assert parsed.metadata == {}
|
||||
assert parsed.warnings == ()
|
||||
|
||||
|
||||
def test_all_known_fields_parse():
|
||||
parsed = parse_skill_md(
|
||||
skill(
|
||||
"name: full\n"
|
||||
"description: Full skill\n"
|
||||
"license: MIT\n"
|
||||
"compatibility: any agent\n"
|
||||
"allowed-tools: Read Write\n"
|
||||
"metadata:\n"
|
||||
" author: someone\n"
|
||||
" version: '1.0'"
|
||||
)
|
||||
)
|
||||
assert parsed.license == "MIT"
|
||||
assert parsed.compatibility == "any agent"
|
||||
assert parsed.allowed_tools == "Read Write"
|
||||
assert parsed.metadata == {"author": "someone", "version": "1.0"}
|
||||
|
||||
|
||||
def test_block_scalars():
|
||||
folded = parse_skill_md(skill("name: a\ndescription: >\n first line\n second line"))
|
||||
assert folded.description == "first line second line"
|
||||
literal = parse_skill_md(skill("name: a\ndescription: |\n first\n second"))
|
||||
assert literal.description == "first\nsecond"
|
||||
|
||||
|
||||
def test_quoted_values_and_inline_comments():
|
||||
parsed = parse_skill_md(skill('name: a\ndescription: "Quoted: value" '))
|
||||
assert parsed.description == "Quoted: value"
|
||||
parsed = parse_skill_md(skill("name: a\ndescription: plain # a comment"))
|
||||
assert parsed.description == "plain"
|
||||
|
||||
|
||||
def test_unknown_key_is_ignored_with_a_warning():
|
||||
parsed = parse_skill_md(skill("name: a\ndescription: d\nfuture-key: x"))
|
||||
assert parsed.warnings == ("Ignored unknown frontmatter key 'future-key'.",)
|
||||
|
||||
|
||||
def test_bom_and_crlf_are_accepted():
|
||||
text = "---\r\nname: a\r\ndescription: d\r\n---\r\nBody"
|
||||
assert parse_skill_md(text).body == "Body"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,fragment",
|
||||
[
|
||||
("name: a\ndescription: d", "no YAML frontmatter"),
|
||||
("---\nname: a\ndescription: d\n", "not closed"),
|
||||
(skill("description: d"), "'name' is required"),
|
||||
(skill("name: a"), "'description' is required"),
|
||||
(skill("name: A_B\ndescription: d"), "lowercase letters"),
|
||||
(skill("name: -a\ndescription: d"), "lowercase letters"),
|
||||
(skill("name: a--b\ndescription: d"), "lowercase letters"),
|
||||
(skill("name: a\ndescription: d\nname: b"), "duplicate frontmatter key"),
|
||||
(skill("name: a\ndescription: [x]"), "flow collections"),
|
||||
(skill("name: a\ndescription: d\nmetadata: x"), "indented map"),
|
||||
(skill("name: a\ndescription: d\nmetadata:\n a: 1\n b: 2"), "flat map"),
|
||||
(skill("name: a\ndescription: d\nmetadata:\n a: 1\n a: 2"), "duplicate metadata key"),
|
||||
(skill("name: a\ndescription: \"unterminated"), "unterminated"),
|
||||
(skill("name: a\ndescription: d\n\tbad: x"), "tab"),
|
||||
(skill("name: a\ndescription: d") + "\x00", "NUL"),
|
||||
],
|
||||
)
|
||||
def test_malformed_skills_are_rejected_with_a_reason(text, fragment):
|
||||
with pytest.raises(SkillFormatError) as error:
|
||||
parse_skill_md(text)
|
||||
assert fragment in str(error.value)
|
||||
|
||||
|
||||
def test_overlong_fields_are_rejected():
|
||||
with pytest.raises(SkillFormatError):
|
||||
parse_skill_md(skill(f"name: {'a' * (limits.NAME_MAX_CHARS + 1)}\ndescription: d"))
|
||||
with pytest.raises(SkillFormatError):
|
||||
parse_skill_md(skill(f"name: a\ndescription: {'d' * (limits.DESCRIPTION_MAX_CHARS + 1)}"))
|
||||
|
||||
|
||||
def test_metadata_entry_cap():
|
||||
entries = "\n".join(f" k{i}: v" for i in range(limits.METADATA_MAX_ENTRIES + 1))
|
||||
with pytest.raises(SkillFormatError):
|
||||
parse_skill_md(skill(f"name: a\ndescription: d\nmetadata:\n{entries}"))
|
||||
|
||||
|
||||
def test_frontmatter_scan_is_bounded():
|
||||
padding = "\n".join("# c" for _ in range(limits.FRONTMATTER_MAX_LINES + 5))
|
||||
with pytest.raises(SkillFormatError):
|
||||
parse_skill_md(f"---\nname: a\ndescription: d\n{padding}\n---\nBody")
|
||||
Reference in New Issue
Block a user