On Linux a freshly-built desktop app had no presence in the application launcher: no Hermes in the KDE/GNOME menu, no icon, nothing to pin. Users had to hand-write ~/.local/share/applications/hermes.desktop and remember to reindex the menu caches themselves. `hermes desktop` now writes that entry itself (best-effort, idempotent, never blocking a launch), and `hermes uninstall --gui` removes it again. Both fields that matter are absolute: - Exec — the launcher runs with a minimal environment and no shell PATH customizations, so a bare `hermes desktop` silently fails for anyone whose hermes lives in ~/.local/bin or a venv. We resolve the real binary via relaunch.resolve_hermes_bin(), falling back to an absolute interpreter + `-m hermes_cli.main`. - Icon — an unqualified name only resolves against an indexed icon theme, which we are not in. The spec allows an absolute path, so we point at apps/desktop/assets/icon.png in the checkout. No copy is installed: Exec already depends on that same tree, so a second copy would add bytes and an uninstall step without surviving anything Exec wouldn't. Menu-cache refresh is tool-gated — update-desktop-database, then kbuildsycoca6 or kbuildsycoca5 — each only when the binary is actually on PATH, because most desktops ship none of them and a missing one is not an error. The entry is only rewritten when its contents change, so a launch doesn't churn the caches every run. Verified on NixOS: the generated entry passes desktop-file-validate, a real kbuildsycoca6 on PATH is invoked with --noincremental, a real update-desktop-database writes mimeinfo.cache, absent tools are skipped cleanly, and removal leaves the checkout's icon untouched.
198 lines
6.9 KiB
Python
198 lines
6.9 KiB
Python
"""Tests for the Linux XDG desktop entry installed by ``hermes desktop``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import linux_desktop_entry as lde
|
|
|
|
|
|
@pytest.fixture
|
|
def xdg_home(tmp_path, monkeypatch) -> Path:
|
|
data_home = tmp_path / "xdg-data"
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(data_home))
|
|
monkeypatch.setattr(lde.sys, "platform", "linux")
|
|
return data_home
|
|
|
|
|
|
def _make_project(tmp_path: Path) -> Path:
|
|
root = tmp_path / "hermes-agent"
|
|
icon = root / "apps" / "desktop" / "assets" / "icon.png"
|
|
icon.parent.mkdir(parents=True)
|
|
icon.write_bytes(b"\x89PNG fake")
|
|
return root
|
|
|
|
|
|
def _parse(entry_text: str) -> dict:
|
|
values = {}
|
|
for line in entry_text.splitlines():
|
|
if "=" in line and not line.startswith("["):
|
|
key, val = line.split("=", 1)
|
|
values[key] = val
|
|
return values
|
|
|
|
|
|
def test_install_writes_entry_with_absolute_exec_and_icon(tmp_path, xdg_home, monkeypatch):
|
|
root = _make_project(tmp_path)
|
|
hermes_bin = tmp_path / "bin" / "hermes"
|
|
hermes_bin.parent.mkdir()
|
|
hermes_bin.write_text("", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
"hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin)
|
|
)
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
|
|
|
entry = lde.install_desktop_entry(root)
|
|
|
|
assert entry == xdg_home / "applications" / "hermes.desktop"
|
|
values = _parse(entry.read_text(encoding="utf-8"))
|
|
|
|
# Exec must be the absolute path of the resolved binary. The launcher
|
|
# runs with a minimal PATH, so a bare `hermes` would not resolve.
|
|
assert values["Exec"] == f"{hermes_bin} desktop"
|
|
assert Path(values["Exec"].split(" ")[0]).is_absolute()
|
|
|
|
# Icon must be an absolute path to the real icon in the checkout.
|
|
icon_path = Path(values["Icon"])
|
|
assert icon_path.is_absolute()
|
|
assert icon_path == lde.icon_path(root)
|
|
assert icon_path.read_bytes() == b"\x89PNG fake"
|
|
|
|
assert values["Type"] == "Application"
|
|
assert values["Name"] == "Hermes"
|
|
assert values["Terminal"] == "false"
|
|
|
|
|
|
def test_installed_entry_is_executable(tmp_path, xdg_home, monkeypatch):
|
|
root = _make_project(tmp_path)
|
|
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
|
|
|
entry = lde.install_desktop_entry(root)
|
|
|
|
assert entry.stat().st_mode & stat.S_IXUSR
|
|
|
|
|
|
def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch):
|
|
root = _make_project(tmp_path)
|
|
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: None)
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
|
|
|
entry = lde.install_desktop_entry(root)
|
|
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
|
|
|
assert exec_line.endswith("-m hermes_cli.main desktop")
|
|
assert Path(exec_line.split(" ")[0]).is_absolute()
|
|
|
|
|
|
def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch):
|
|
root = _make_project(tmp_path)
|
|
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
|
calls: list[Path] = []
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda d: calls.append(d) or [])
|
|
|
|
lde.install_desktop_entry(root)
|
|
assert len(calls) == 1
|
|
|
|
# Unchanged content → no rewrite, no menu-cache churn on every launch.
|
|
lde.install_desktop_entry(root)
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_install_without_source_icon_uses_themed_name(tmp_path, xdg_home, monkeypatch):
|
|
root = tmp_path / "hermes-agent"
|
|
root.mkdir()
|
|
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
|
|
|
entry = lde.install_desktop_entry(root)
|
|
|
|
# A broken absolute path renders as no icon. The themed name resolves
|
|
# when Hermes is installed some other way.
|
|
assert _parse(entry.read_text(encoding="utf-8"))["Icon"] == "hermes"
|
|
|
|
|
|
@pytest.mark.parametrize("platform", ["darwin", "win32"])
|
|
def test_install_is_a_noop_off_linux(tmp_path, monkeypatch, platform):
|
|
monkeypatch.setattr(lde.sys, "platform", platform)
|
|
assert lde.install_desktop_entry(_make_project(tmp_path)) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache refresh tool gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_tools(monkeypatch, available: "set[str]") -> "list[list[str]]":
|
|
ran: list[list[str]] = []
|
|
monkeypatch.setattr(
|
|
lde.shutil, "which", lambda name: f"/usr/bin/{name}" if name in available else None
|
|
)
|
|
monkeypatch.setattr(lde, "_run_quiet", lambda cmd: ran.append(cmd) or True)
|
|
return ran
|
|
|
|
|
|
def test_refresh_runs_kbuildsycoca6_when_present(monkeypatch, tmp_path):
|
|
ran = _stub_tools(monkeypatch, {"update-desktop-database", "kbuildsycoca6"})
|
|
|
|
tools = lde.refresh_desktop_databases(tmp_path)
|
|
|
|
assert tools == ["update-desktop-database", "kbuildsycoca6"]
|
|
assert ran == [
|
|
["/usr/bin/update-desktop-database", str(tmp_path)],
|
|
["/usr/bin/kbuildsycoca6", "--noincremental"],
|
|
]
|
|
|
|
|
|
def test_refresh_falls_back_to_kbuildsycoca5(monkeypatch, tmp_path):
|
|
ran = _stub_tools(monkeypatch, {"kbuildsycoca5"})
|
|
|
|
tools = lde.refresh_desktop_databases(tmp_path)
|
|
|
|
assert tools == ["kbuildsycoca5"]
|
|
assert ran == [["/usr/bin/kbuildsycoca5", "--noincremental"]]
|
|
|
|
|
|
def test_refresh_prefers_kbuildsycoca6_over_5(monkeypatch, tmp_path):
|
|
ran = _stub_tools(monkeypatch, {"kbuildsycoca6", "kbuildsycoca5"})
|
|
|
|
lde.refresh_desktop_databases(tmp_path)
|
|
|
|
assert [cmd[0] for cmd in ran] == ["/usr/bin/kbuildsycoca6"]
|
|
|
|
|
|
def test_refresh_skips_missing_tools(monkeypatch, tmp_path):
|
|
ran = _stub_tools(monkeypatch, set())
|
|
|
|
assert lde.refresh_desktop_databases(tmp_path) == []
|
|
assert ran == []
|
|
|
|
|
|
def test_refresh_reports_only_tools_that_succeeded(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(lde.shutil, "which", lambda name: f"/usr/bin/{name}")
|
|
# update-desktop-database fails (exit != 0). kbuildsycoca6 succeeds.
|
|
monkeypatch.setattr(lde, "_run_quiet", lambda cmd: "kbuildsycoca" in cmd[0])
|
|
|
|
assert lde.refresh_desktop_databases(tmp_path) == ["kbuildsycoca6"]
|
|
|
|
|
|
def test_run_quiet_swallows_missing_binary(tmp_path):
|
|
assert lde._run_quiet([str(tmp_path / "definitely-not-a-binary")]) is False
|
|
|
|
|
|
def test_exec_arg_quoting_handles_spaces(tmp_path, xdg_home, monkeypatch):
|
|
root = _make_project(tmp_path)
|
|
spaced = tmp_path / "my apps" / "hermes"
|
|
spaced.parent.mkdir()
|
|
spaced.write_text("", encoding="utf-8")
|
|
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(spaced))
|
|
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
|
|
|
entry = lde.install_desktop_entry(root)
|
|
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
|
|
|
assert exec_line == f'"{spaced}" desktop'
|