Conflicts resolved toward the PM model: main's lazy_deps/update_cmd_deps/npm
stamp machinery stays deleted (PM + scripts/build/node-deps.mjs own it), the
systemd ExecStop stop-mark rides the installation launcher, legacy
linux_only/macos_only/windows_only markers are rewritten to platforms(), and
finalize_update_receipt carries pending manual-serve obligations forward
again (lost when the ContextVar receipt rewrite crossed c0aa3ce354).
Test harness: the real-home I/O guard exempts /proc/<pid>/fd metadata reads
(deleted-WAL holder scans) and run_tests.sh drops ~/.hermes PATH entries so
shutil.which() cannot trip the tripwire.
222 lines
8.6 KiB
Python
222 lines
8.6 KiB
Python
"""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 hermes_yaml as 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"
|
|
|
|
|
|
def test_version_and_image_are_emitted_and_offhost_image_is_dropped_not_fatal(mod, tmp_path):
|
|
catalog = tmp_path / "plugin-catalog"
|
|
catalog.mkdir()
|
|
_write_entry(catalog, "labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png")
|
|
_write_entry(catalog, "offhost", version="1.4.0", image="https://cdn.example.com/banner.png")
|
|
|
|
entries = {e["name"]: e for e in mod.load_catalog_entries(catalog)}
|
|
assert entries["labelled"]["version"] == "1.4.0" and entries["labelled"]["image"] == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"
|
|
assert entries["offhost"]["image"] == "" and entries["offhost"]["version"] == "1.4.0"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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: ranking is stars only; the official tier gets no boost.
|
|
(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] == ["gamma", "beta", "alpha"] # stars desc, unknown stars last
|
|
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()
|