File size: 8,014 Bytes
7acbd80 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | """Tests for website/scripts/extract-plugins.py.
Behavioral contracts for the /docs/plugins catalog extractor:
1. Reads ``plugin-catalog/*.yaml`` entries (skipping ``removed.yaml``) and
emits ``plugins.json`` rows carrying name/repo/sha/tier/capabilities plus
a synthesized ``hermes plugins install <name>`` command.
2. Entries missing any of name/repo/sha are skipped (logged, not fatal).
3. A missing ``plugin-catalog/`` directory degrades gracefully: empty
catalog list, zero counts in the meta sidecar, exit 0 — the docs build
must stay green before the catalog directory lands on main.
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-plugins.py"
@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("extract_plugins", 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
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
import yaml
entry = {
"name": name,
"repo": f"https://github.com/example/{name}",
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
"description": f"{name} does things.",
"maintainer": "Example",
"tier": "community",
}
entry.update(overrides)
# Drop keys explicitly set to None so tests can simulate missing fields.
entry = {k: v for k, v in entry.items() if v is not None}
path = catalog_dir / f"{name}.yaml"
path.write_text(yaml.safe_dump(entry), encoding="utf-8")
return path
# --------------------------------------------------------------------------
# Entry loading + validation
# --------------------------------------------------------------------------
def test_valid_entry_is_extracted_with_install_command(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(
catalog,
"example-plugin",
tier="official",
docs_url="https://example.com/docs",
requires_hermes=">=0.19",
platforms=["linux"],
capabilities={
"provides_tools": ["do_thing"],
"provides_hooks": ["on_start"],
"provides_middleware": [],
"requires_env": ["EXAMPLE_TOKEN"],
},
)
entries = mod.load_catalog_entries(catalog)
assert len(entries) == 1
e = entries[0]
assert e["name"] == "example-plugin"
assert e["repo"] == "https://github.com/example/example-plugin"
assert e["sha"] == "38fe0fb53eff98d477f807432e965429e665ca33"
assert e["shaShort"] == "38fe0fb"
assert e["tier"] == "official"
assert e["maintainer"] == "Example"
assert e["requiresHermes"] == ">=0.19"
assert e["platforms"] == ["linux"]
assert e["docsUrl"] == "https://example.com/docs"
assert e["capabilities"]["providesTools"] == ["do_thing"]
assert e["capabilities"]["providesHooks"] == ["on_start"]
assert e["capabilities"]["requiresEnv"] == ["EXAMPLE_TOKEN"]
assert e["installCommand"] == "hermes plugins install example-plugin"
def test_entries_missing_required_fields_are_skipped(mod, tmp_path, capsys):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "good-plugin")
_write_entry(catalog, "no-sha", sha=None)
_write_entry(catalog, "no-repo", repo=None)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["good-plugin"]
err = capsys.readouterr().err
assert "no-sha" in err
assert "no-repo" in err
def test_removed_yaml_is_not_treated_as_an_entry(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "kept-plugin")
(catalog / "removed.yaml").write_text(
"removed:\n - name: evil-plugin\n repo: https://github.com/evil/x\n"
' reason: "bad"\n date: "2026-07-02"\n',
encoding="utf-8",
)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["kept-plugin"]
assert mod.count_removed(catalog) == 1
def test_unknown_tier_normalizes_to_community(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "weird-tier", tier="platinum")
entries = mod.load_catalog_entries(catalog)
assert entries[0]["tier"] == "community"
# --------------------------------------------------------------------------
# Full run: outputs + graceful degradation
# --------------------------------------------------------------------------
def test_main_writes_catalog_and_meta(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "alpha", tier="official", category="memory")
_write_entry(catalog, "beta") # no category → default "desktop" shelf
_write_entry(catalog, "gamma")
# Star cache from fetch-plugin-stars.py: gamma outranks beta within the community tier.
(tmp_path / "api").mkdir()
(tmp_path / "api" / "plugin-stars.json").write_text(json.dumps({
"fetched_at": "2026-09-15T00:00:00+00:00",
"stars": {"example/gamma": 50, "example/beta": 3}}), encoding="utf-8")
(catalog / "removed.yaml").write_text(
"removed:\n - name: gone\n", encoding="utf-8"
)
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=catalog, output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert [p["name"] for p in plugins] == ["alpha", "gamma", "beta"] # official first, then stars desc
assert {p["name"]: p["stars"] for p in plugins} == {"alpha": None, "gamma": 50, "beta": 3}
assert meta["total"] == 3
assert meta["byTier"] == {"official": 1, "community": 2}
assert {p["name"]: p["category"] for p in plugins} == {"alpha": "memory", "beta": "desktop", "gamma": "desktop"}
assert meta["byCategory"] == {"desktop": 2, "memory": 1}
assert meta["starsFetchedAt"] == "2026-09-15T00:00:00+00:00"
assert meta["removedCount"] == 1
assert meta["generatedAt"]
# The live-refresh document consumed by installed clients: loader-schema entries + the kill list.
from hermes_cli.plugin_catalog import entry_from_mapping
live = json.loads((out_dir / "plugin-catalog.json").read_text(encoding="utf-8"))
assert [entry_from_mapping(raw, "live").name for raw in live["entries"]] == ["alpha", "beta", "gamma"]
assert live["removed"] == [{"name": "gone"}]
def test_missing_catalog_dir_degrades_to_empty_outputs_exit_zero(mod, tmp_path):
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=tmp_path / "does-not-exist", output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert plugins == []
assert meta["total"] == 0
assert meta["byTier"] == {"official": 0, "community": 0}
assert meta["removedCount"] == 0
def test_script_exits_zero_as_subprocess_when_catalog_missing(tmp_path):
"""CLI contract: the deploy step runs the script hard (no `|| true`);
it must exit 0 even when plugin-catalog/ hasn't landed yet."""
out_dir = tmp_path / "api"
result = subprocess.run(
[
sys.executable,
str(EXTRACT),
"--catalog-dir",
str(tmp_path / "missing"),
"--output-dir",
str(out_dir),
],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert (out_dir / "plugins.json").exists()
assert (out_dir / "plugins-meta.json").exists()
|