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()