Files
hermes-webui/tests/test_skills_toggle.py
T

240 lines
9.3 KiB
Python

"""Tests for skill toggle (enable/disable) API and frontend."""
from pathlib import Path
PANELS_JS = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text("utf-8")
I18N_JS = (Path(__file__).resolve().parent.parent / "static" / "i18n.js").read_text("utf-8")
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text("utf-8")
def test_toggle_endpoint_signature_in_routes():
"""Verify the toggle endpoint code exists in routes.py."""
from api.routes import _handle_skill_toggle
assert callable(_handle_skill_toggle)
def test_toggle_path_registered():
"""Verify /api/skills/toggle path is registered in POST routing."""
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
assert '/api/skills/toggle' in routes_source
def test_skills_list_includes_disabled_flag():
"""Each skill dict in the API response must have a 'disabled' boolean field."""
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
assert '"disabled": name in disabled' in routes_source
def test_i18n_keys_added():
"""The three new i18n keys must exist in the English locale."""
assert "skill_enabled" in I18N_JS
assert "skill_disabled" in I18N_JS
assert "skill_toggle_failed" in I18N_JS
def test_toggle_css_classes_exist():
"""Toggle switch CSS classes must be in style.css."""
assert ".skill-toggle" in STYLE_CSS
assert ".skill-toggle.enabled" in STYLE_CSS
assert ".skill-item.disabled" in STYLE_CSS
def test_render_skills_produces_toggle_buttons():
"""renderSkills() must include toggleSkill and skill-toggle."""
assert "toggleSkill(" in PANELS_JS
assert "skill-toggle" in PANELS_JS
def test_toggle_skill_function_defined():
"""toggleSkill() async function must be defined."""
assert "async function toggleSkill(" in PANELS_JS
def test_disabled_list_round_trip(tmp_path):
"""Verify that writing and reading the disabled list through the config
module's YAML functions preserves values correctly, including normalization
of None/str/list shapes."""
from api.config import _load_yaml_config_file, _save_yaml_config_file
config_path = tmp_path / "config.yaml"
# Write initial config
_save_yaml_config_file(config_path, {"skills": {"disabled": []}})
# Read, add skill, write
cfg = _load_yaml_config_file(config_path)
cfg.setdefault("skills", {})
disabled = cfg["skills"].get("disabled", [])
disabled.append("skill-a")
disabled.append("skill-b")
cfg["skills"]["disabled"] = disabled
_save_yaml_config_file(config_path, cfg)
# Read back and verify
cfg2 = _load_yaml_config_file(config_path)
assert cfg2["skills"]["disabled"] == ["skill-a", "skill-b"]
# Remove one skill, write, verify
cfg2["skills"]["disabled"] = [d for d in cfg2["skills"]["disabled"] if d != "skill-a"]
_save_yaml_config_file(config_path, cfg2)
cfg3 = _load_yaml_config_file(config_path)
assert cfg3["skills"]["disabled"] == ["skill-b"]
def test_normalize_names_list():
"""_normalize_names_list handles None, str, list, and deduplicates."""
from api.routes import _normalize_names_list
assert _normalize_names_list(None) == []
assert _normalize_names_list("foo") == ["foo"]
assert _normalize_names_list(["a", "b"]) == ["a", "b"]
assert _normalize_names_list(["a", "a"]) == ["a"]
assert _normalize_names_list([" a ", "b"]) == ["a", "b"]
assert _normalize_names_list("") == []
assert _normalize_names_list([]) == []
def test_toggle_name_in_list():
"""_toggle_name_in_list adds when enabled=False, removes when enabled=True."""
from api.routes import _toggle_name_in_list
# Add to empty list
assert _toggle_name_in_list([], "foo", enabled=False) == ["foo"]
# Add to existing list
assert _toggle_name_in_list(["bar"], "foo", enabled=False) == ["bar", "foo"]
# Idempotent add
assert _toggle_name_in_list(["foo"], "foo", enabled=False) == ["foo"]
# Remove from list
assert _toggle_name_in_list(["foo", "bar"], "foo", enabled=True) == ["bar"]
# Remove non-existent is a no-op
assert _toggle_name_in_list(["bar"], "foo", enabled=True) == ["bar"]
# Remove from empty is a no-op
assert _toggle_name_in_list([], "foo", enabled=True) == []
# Handles str input (normalization)
assert _toggle_name_in_list("foo", "bar", enabled=False) == ["foo", "bar"]
assert _toggle_name_in_list("foo", "foo", enabled=True) == []
def test_platform_disabled_write_through(tmp_path, monkeypatch):
"""Toggle writes through to platform_disabled.webui when that key exists."""
from unittest.mock import MagicMock
from api.routes import _handle_skill_toggle
from api.config import _load_yaml_config_file
config_path = tmp_path / "config.yaml"
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
import yaml
config = {
"skills": {
"disabled": ["skill-a"],
"platform_disabled": {
"webui": ["skill-a", "skill-b"],
"telegram": ["skill-c"],
},
}
}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.dump(config), encoding="utf-8")
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
fake_dir = tmp_path / "skills" / "skill-a"
fake_dir.mkdir(parents=True, exist_ok=True)
fake_md = fake_dir / "SKILL.md"
fake_md.write_text("---\nname: skill-a\n---\nA skill", encoding="utf-8")
monkeypatch.setattr(
"api.routes._find_skill_in_dirs",
lambda name, dirs: (fake_dir, fake_md),
)
handler = MagicMock()
# Toggle skill-a OFF — it's already disabled, verify idempotency
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": False})
cfg_after = _load_yaml_config_file(config_path)
assert "skill-a" in cfg_after["skills"]["disabled"]
assert "skill-a" in cfg_after["skills"]["platform_disabled"]["webui"]
# Toggle skill-a ON
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": True})
cfg_after2 = _load_yaml_config_file(config_path)
assert "skill-a" not in cfg_after2["skills"]["disabled"]
assert "skill-a" not in cfg_after2["skills"]["platform_disabled"]["webui"]
# Other platform keys are untouched
assert cfg_after2["skills"]["platform_disabled"]["telegram"] == ["skill-c"]
def test_platform_disabled_no_write_through_when_key_absent(tmp_path, monkeypatch):
"""Toggle does NOT create platform_disabled.webui when it doesn't exist."""
from unittest.mock import MagicMock
from api.routes import _handle_skill_toggle
from api.config import _load_yaml_config_file
config_path = tmp_path / "config.yaml"
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
import yaml
config = {"skills": {"disabled": []}}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.dump(config), encoding="utf-8")
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
fake_dir = tmp_path / "skills" / "skill-b"
fake_dir.mkdir(parents=True, exist_ok=True)
fake_md = fake_dir / "SKILL.md"
fake_md.write_text("---\nname: skill-b\n---\nB skill", encoding="utf-8")
monkeypatch.setattr(
"api.routes._find_skill_in_dirs",
lambda name, dirs: (fake_dir, fake_md),
)
handler = MagicMock()
_handle_skill_toggle(handler, {"name": "skill-b", "enabled": False})
cfg_after = _load_yaml_config_file(config_path)
assert "skill-b" in cfg_after["skills"]["disabled"]
# platform_disabled was never created
assert "platform_disabled" not in cfg_after.get("skills", {})
def test_toggle_preserves_json_array_string_entries(tmp_path, monkeypatch):
"""Issue #7120: when skills.disabled is stored as a JSON-array string,
a toggle must decode it before add/remove so the other entries survive
the write (previously the whole string was treated as one name and the
list was destroyed on the next save)."""
from unittest.mock import MagicMock
from api.routes import _handle_skill_toggle
from api.config import _load_yaml_config_file
config_path = tmp_path / "config.yaml"
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
import yaml
config = {"skills": {"disabled": '["skill-a", "skill-b"]'}}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.dump(config), encoding="utf-8")
fake_dir = tmp_path / "skills" / "skill-c"
fake_dir.mkdir(parents=True, exist_ok=True)
fake_md = fake_dir / "SKILL.md"
fake_md.write_text("---\nname: skill-c\n---\nC skill", encoding="utf-8")
monkeypatch.setattr(
"api.routes._find_skill_in_dirs",
lambda name, dirs: (fake_dir, fake_md),
)
handler = MagicMock()
# Toggle skill-c OFF — must append without dropping skill-a/skill-b
_handle_skill_toggle(handler, {"name": "skill-c", "enabled": False})
cfg_after = _load_yaml_config_file(config_path)
assert cfg_after["skills"]["disabled"] == ["skill-a", "skill-b", "skill-c"]
# Toggle skill-a ON — must remove only skill-a, preserving skill-b
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": True})
cfg_after2 = _load_yaml_config_file(config_path)
assert cfg_after2["skills"]["disabled"] == ["skill-b", "skill-c"]