forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Tests for website/scripts/extract-skills.py helpers.
|
||||
|
||||
Covers the two behavioral contracts added when the Skills Hub page gained
|
||||
per-skill source links and a cleaned-up category sidebar:
|
||||
|
||||
1. ``_source_url`` — every community skill must resolve to a clickable
|
||||
origin URL (explicit ``extra`` URL preferred, else synthesized from the
|
||||
identifier shape). Built-in/optional skills intentionally return "" —
|
||||
they have a generated docs page (docsPath) instead.
|
||||
|
||||
2. ``_guess_category`` — tags only map to a curated category bucket;
|
||||
unknown tags fall to ``uncategorized`` (folded into "Other" later) so the
|
||||
sidebar doesn't fill with one-off junk like version strings or brand
|
||||
names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-skills.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mod():
|
||||
spec = importlib.util.spec_from_file_location("extract_skills", EXTRACT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _source_url
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_source_url_prefers_explicit_detail_url(mod):
|
||||
extra = {"detail_url": "https://skills.sh/owner/repo/skill"}
|
||||
assert (
|
||||
mod._source_url("skills.sh", "skills-sh/owner/repo/skill", extra)
|
||||
== "https://skills.sh/owner/repo/skill"
|
||||
)
|
||||
|
||||
|
||||
def test_source_url_prefers_browse_sh_source_url(mod):
|
||||
# browse.sh adapter carries its origin under extra["source_url"].
|
||||
extra = {"source_url": "https://airbnb.com/host"}
|
||||
assert (
|
||||
mod._source_url("browse-sh", "browse-sh/airbnb.com/login-abc", extra)
|
||||
== "https://airbnb.com/host"
|
||||
)
|
||||
|
||||
|
||||
def test_source_url_synthesizes_github_tree_url(mod):
|
||||
url = mod._source_url("github", "anthropics/skills/skills/algorithmic-art", {})
|
||||
assert url == "https://github.com/anthropics/skills/tree/main/skills/algorithmic-art"
|
||||
|
||||
|
||||
def test_source_url_synthesizes_github_root_when_no_subpath(mod):
|
||||
assert mod._source_url("github", "owner/repo", {}) == "https://github.com/owner/repo"
|
||||
|
||||
|
||||
def test_source_url_synthesizes_clawhub(mod):
|
||||
assert mod._source_url("clawhub", "go-music-skill", {}) == "https://clawhub.ai/skills/go-music-skill"
|
||||
|
||||
|
||||
def test_source_url_synthesizes_clawhub_strips_prefix(mod):
|
||||
# identifier may arrive already prefixed; we must not double-prefix.
|
||||
assert (
|
||||
mod._source_url("clawhub", "clawhub/go-music-skill", {})
|
||||
== "https://clawhub.ai/skills/go-music-skill"
|
||||
)
|
||||
|
||||
|
||||
def test_source_url_synthesizes_lobehub(mod):
|
||||
assert mod._source_url("lobehub", "lobehub/chinese-paper", {}) == "https://lobehub.com/agent/chinese-paper"
|
||||
|
||||
|
||||
def test_source_url_empty_for_unknown_source_without_identifier(mod):
|
||||
assert mod._source_url("mystery", "", {}) == ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# _guess_category
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_guess_category_maps_known_tag(mod):
|
||||
assert mod._guess_category(["security"]) == "security"
|
||||
assert mod._guess_category(["machine-learning"]) == "mlops"
|
||||
assert mod._guess_category(["crypto"]) == "blockchain"
|
||||
|
||||
|
||||
def test_guess_category_accepts_literal_curated_key(mod):
|
||||
# A skill tagged literally with a curated category key should route there.
|
||||
assert mod._guess_category(["devops"]) == "devops"
|
||||
|
||||
|
||||
def test_guess_category_rejects_junk_tag(mod):
|
||||
# This is the whole point: version strings / brand names must NOT become
|
||||
# their own sidebar category. They land in "uncategorized" → "Other".
|
||||
assert mod._guess_category(["0.10.7 Dev"]) == "uncategorized"
|
||||
assert mod._guess_category(["Doramagic Crystal"]) == "uncategorized"
|
||||
assert mod._guess_category(["Ap2"]) == "uncategorized"
|
||||
|
||||
|
||||
def test_guess_category_empty_tags(mod):
|
||||
assert mod._guess_category([]) == "uncategorized"
|
||||
|
||||
|
||||
def test_guess_category_skips_first_junk_tag_for_later_known_tag(mod):
|
||||
# First tag is junk, second is curated — we should still find the curated one.
|
||||
assert mod._guess_category(["Some Brand", "security"]) == "security"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for website/scripts/generate-skill-docs.py.
|
||||
|
||||
The generator turns every `skills/**/SKILL.md` into a Docusaurus page before
|
||||
the `docs-site-checks` CI workflow runs `ascii-guard lint` on the result. If
|
||||
a SKILL.md contains ASCII diagrams (box-drawing chars in a fenced code block)
|
||||
without its own `<!-- ascii-guard-ignore -->` markers, the generator must
|
||||
add them defensively — otherwise every PR touching `website/**` fails lint
|
||||
on unrelated skill content.
|
||||
|
||||
Regression for issue #15305.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
GENERATOR = REPO_ROOT / "website" / "scripts" / "generate-skill-docs.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gen_module():
|
||||
"""Load generate-skill-docs.py as a module (hyphenated filename, not importable via normal import)."""
|
||||
spec = importlib.util.spec_from_file_location("generate_skill_docs", GENERATOR)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_code_block_without_box_chars_is_not_wrapped(gen_module):
|
||||
"""Plain bash/python code blocks should stay uncluttered."""
|
||||
body = "Intro.\n\n```bash\npip install foo\nfoo --run\n```\n\nOutro."
|
||||
result = gen_module.mdx_escape_body(body)
|
||||
assert "ascii-guard-ignore" not in result
|
||||
assert "pip install foo" in result
|
||||
|
||||
|
||||
def test_code_block_with_box_chars_gets_wrapped(gen_module):
|
||||
"""A code fence containing Unicode box-drawing chars must be wrapped in
|
||||
ascii-guard-ignore comments so the docs-site-checks lint can't fail on
|
||||
a skill's own diagram (issue #15305)."""
|
||||
body = (
|
||||
"Some text.\n\n"
|
||||
"```\n"
|
||||
"┌─────────┐\n"
|
||||
"│ diagram │\n"
|
||||
"└─────────┘\n"
|
||||
"```\n\n"
|
||||
"More text."
|
||||
)
|
||||
result = gen_module.mdx_escape_body(body)
|
||||
assert "<!-- ascii-guard-ignore -->" in result
|
||||
assert "<!-- ascii-guard-ignore-end -->" in result
|
||||
# The wrapper must sit OUTSIDE the fence, not inside.
|
||||
wrap_open = result.index("<!-- ascii-guard-ignore -->")
|
||||
fence_open = result.index("```\n┌")
|
||||
assert wrap_open < fence_open
|
||||
|
||||
|
||||
def test_multiple_code_blocks_only_box_ones_wrapped(gen_module):
|
||||
"""Mixed body: plain code stays plain, box code gets wrapped."""
|
||||
body = (
|
||||
"```bash\necho hi\n```\n\n"
|
||||
"```\n┌──┐\n│ │\n└──┘\n```\n\n"
|
||||
"```python\nprint('ok')\n```"
|
||||
)
|
||||
result = gen_module.mdx_escape_body(body)
|
||||
# exactly one wrap pair
|
||||
assert result.count("<!-- ascii-guard-ignore -->") == 1
|
||||
assert result.count("<!-- ascii-guard-ignore-end -->") == 1
|
||||
# plain blocks untouched
|
||||
assert "echo hi" in result
|
||||
assert "print('ok')" in result
|
||||
|
||||
|
||||
def test_tilde_fenced_box_is_wrapped(gen_module):
|
||||
"""The generator supports both ``` and ~~~ fences — both must be covered."""
|
||||
body = "~~~\n│ box │\n~~~"
|
||||
result = gen_module.mdx_escape_body(body)
|
||||
assert "<!-- ascii-guard-ignore -->" in result
|
||||
|
||||
|
||||
def test_already_wrapped_source_double_wraps_harmlessly(gen_module):
|
||||
"""If the SKILL.md already has ascii-guard-ignore markers, the generator's
|
||||
extra wrap is harmless (ascii-guard tolerates adjacent duplicate markers).
|
||||
The test just verifies we don't crash and the content survives."""
|
||||
body = (
|
||||
"<!-- ascii-guard-ignore -->\n"
|
||||
"```\n┌─┐\n└─┘\n```\n"
|
||||
"<!-- ascii-guard-ignore-end -->"
|
||||
)
|
||||
result = gen_module.mdx_escape_body(body)
|
||||
assert "┌─┐" in result
|
||||
# At least one marker pair survives
|
||||
assert "<!-- ascii-guard-ignore -->" in result
|
||||
assert "<!-- ascii-guard-ignore-end -->" in result
|
||||
|
||||
|
||||
def test_box_drawing_detection_covers_common_chars(gen_module):
|
||||
"""Smoke-test that the char set covers box-drawing ranges actually used
|
||||
in skill diagrams."""
|
||||
# Sample from real SKILL.md diagrams (segment-anything, research-paper-writing, etc.)
|
||||
for ch in "┌┐└┘─│├┤┬┴┼═║╔╗╚╝╭╮╯╰▶◀▲▼":
|
||||
assert ch in gen_module._BOX_DRAWING_CHARS, f"missing: {ch!r}"
|
||||
|
||||
|
||||
def test_bundled_catalog_explains_missing_local_skills(gen_module):
|
||||
"""The bundled catalog should explain how to restore a listed skill that
|
||||
was removed from the local profile's skills tree."""
|
||||
result = gen_module.build_catalog_md_bundled([])
|
||||
assert "respects local deletions and user edits" in result
|
||||
assert "hermes skills reset <name> --restore" in result
|
||||
Reference in New Issue
Block a user