Files
hermes-webui/tests/test_issue1880_profile_scoped_skills.py
2026-05-14 22:28:20 +08:00

194 lines
6.7 KiB
Python

import json
import os
import pathlib
import shutil
import urllib.error
import urllib.parse
import urllib.request
from tests._pytest_port import BASE
from tests.conftest import requires_agent_modules
pytestmark = requires_agent_modules
def _state_dir() -> pathlib.Path:
return pathlib.Path(os.environ["HERMES_WEBUI_TEST_STATE_DIR"])
def _remove_path(path: pathlib.Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink()
elif path.exists():
shutil.rmtree(path)
class _IsolatedSkillsDirs:
def __init__(self, profile: str):
self.profile = profile
self.state = _state_dir()
self.root_skills = self.state / "skills"
self.profile_home = self.state / "profiles" / profile
self.profile_skills = self.profile_home / "skills"
self._root_was_symlink = False
self._root_symlink_target = None
def __enter__(self):
self._root_was_symlink = self.root_skills.is_symlink()
if self._root_was_symlink:
self._root_symlink_target = self.root_skills.resolve()
_remove_path(self.root_skills)
_remove_path(self.profile_home)
self.root_skills.mkdir(parents=True, exist_ok=True)
self.profile_skills.mkdir(parents=True, exist_ok=True)
return self
def __exit__(self, exc_type, exc, tb):
_remove_path(self.profile_home)
_remove_path(self.root_skills)
if self._root_was_symlink and self._root_symlink_target is not None:
self.root_skills.symlink_to(self._root_symlink_target)
def _write_skill(skills_dir: pathlib.Path, name: str, description: str, body: str) -> pathlib.Path:
skill_dir = skills_dir / name
(skill_dir / "references").mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\n{body}\n",
encoding="utf-8",
)
(skill_dir / "references" / "note.md").write_text(
f"linked file for {name}\n",
encoding="utf-8",
)
return skill_dir
def _get(path: str, *, profile: str | None = None):
headers = {}
if profile:
headers["Cookie"] = f"hermes_profile={profile}"
req = urllib.request.Request(BASE + path, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read()), resp.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code
def _post(path: str, body: dict, *, profile: str | None = None):
headers = {"Content-Type": "application/json"}
if profile:
headers["Cookie"] = f"hermes_profile={profile}"
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode("utf-8"),
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read()), resp.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code
def test_api_skills_list_and_content_respect_profile_cookie():
profile = "skills1880"
with _IsolatedSkillsDirs(profile) as dirs:
_write_skill(
dirs.root_skills,
"root-only-skill-1880",
"Root profile skill",
"This skill belongs to the root profile.",
)
_write_skill(
dirs.profile_skills,
"profile-only-skill-1880",
"Secondary profile skill",
"This skill belongs to the selected browser profile.",
)
data, status = _get("/api/skills", profile=profile)
assert status == 200
names = {skill.get("name") for skill in data.get("skills", [])}
assert "profile-only-skill-1880" in names
assert "root-only-skill-1880" not in names
root_data, root_status = _get("/api/skills")
assert root_status == 200
root_names = {skill.get("name") for skill in root_data.get("skills", [])}
assert "root-only-skill-1880" in root_names
assert "profile-only-skill-1880" not in root_names
detail, detail_status = _get(
"/api/skills/content?name=profile-only-skill-1880",
profile=profile,
)
assert detail_status == 200
assert detail.get("name") == "profile-only-skill-1880"
assert "selected browser profile" in detail.get("content", "")
linked_path = urllib.parse.quote("references/note.md", safe="")
linked, linked_status = _get(
f"/api/skills/content?name=profile-only-skill-1880&file={linked_path}",
profile=profile,
)
assert linked_status == 200
assert linked.get("content") == "linked file for profile-only-skill-1880\n"
def test_skill_detail_reads_resolved_file_without_skill_view_absolute_path(monkeypatch):
profile = "skills1880direct"
with _IsolatedSkillsDirs(profile) as dirs:
_write_skill(
dirs.profile_skills,
"profile-direct-skill-1880",
"Direct profile skill",
"This skill should be read from the resolved SKILL.md file.",
)
from api import routes
import tools.skills_tool as skills_tool
monkeypatch.setattr(routes, "_active_skills_dir", lambda: dirs.profile_skills)
def fail_if_skill_view_called(*_args, **_kwargs):
raise AssertionError("WebUI local skill details must not call skill_view()")
monkeypatch.setattr(skills_tool, "skill_view", fail_if_skill_view_called)
detail = routes._skill_view_from_active_dir("profile-direct-skill-1880")
assert detail["success"] is True
assert detail["name"] == "profile-direct-skill-1880"
assert "resolved SKILL.md file" in detail["content"]
assert isinstance(detail["linked_files"], dict)
def test_skill_save_and_delete_respect_profile_cookie():
profile = "skills1880save"
with _IsolatedSkillsDirs(profile) as dirs:
content = "---\nname: profile-saved-skill-1880\ndescription: Saved profile skill\n---\n\n# Saved\n"
saved, save_status = _post(
"/api/skills/save",
{"name": "profile-saved-skill-1880", "content": content},
profile=profile,
)
assert save_status == 200
saved_path = pathlib.Path(saved["path"]).resolve()
saved_path.relative_to(dirs.profile_skills.resolve())
assert saved_path.read_text(encoding="utf-8") == content
assert not (dirs.root_skills / "profile-saved-skill-1880" / "SKILL.md").exists()
deleted, delete_status = _post(
"/api/skills/delete",
{"name": "profile-saved-skill-1880"},
profile=profile,
)
assert delete_status == 200
assert deleted.get("ok") is True
assert not saved_path.exists()