1102 lines
40 KiB
Python
1102 lines
40 KiB
Python
"""Tests for the Linux XDG desktop entry installed by ``hermes desktop``."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import os
|
||
import stat
|
||
import struct
|
||
import sys
|
||
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))
|
||
# Isolate the known-wrapper probe too: tests must never see the real
|
||
# ~/.local/bin/hermes on the dev machine.
|
||
monkeypatch.setenv("HOME", str(tmp_path))
|
||
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 _png_ihdr(width: int, height: int) -> bytes:
|
||
"""Minimal PNG prefix whose IHDR the installer can parse (no pixels)."""
|
||
return (
|
||
b"\x89PNG\r\n\x1a\n"
|
||
+ b"\x00\x00\x00\r"
|
||
+ b"IHDR"
|
||
+ struct.pack(">II", width, height)
|
||
)
|
||
|
||
|
||
def _stub_install(tmp_path, monkeypatch) -> None:
|
||
hermes_bin = tmp_path / "bin" / "hermes"
|
||
hermes_bin.parent.mkdir(exist_ok=True)
|
||
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: [])
|
||
|
||
|
||
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: [])
|
||
# Keep the icon install out of the way: this test pins the
|
||
# absolute-path FALLBACK (copy impossible / not attempted here).
|
||
monkeypatch.setattr(lde, "_install_icon_to_hicolor", lambda _icon: False)
|
||
|
||
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)
|
||
|
||
|
||
def test_install_prefers_themed_icon_from_hicolor(tmp_path, xdg_home, monkeypatch):
|
||
"""When the icon installs into hicolor, the entry uses the themed name.
|
||
|
||
The themed name survives a moved/archived checkout; an absolute
|
||
Icon= path does not (the same durability class the Exec line was
|
||
fixed for).
|
||
"""
|
||
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)
|
||
|
||
values = _parse(entry.read_text(encoding="utf-8"))
|
||
assert values["Icon"] == "hermes"
|
||
|
||
# And the icon really landed in the hicolor tree: the fixture icon is
|
||
# a fake PNG (no valid IHDR), so the size is unknown and the icon
|
||
# lands under 256x256/ (indexed; never scalable, which is SVG-only).
|
||
dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png"
|
||
assert dest.is_file()
|
||
assert dest.read_bytes() == lde.icon_path(root).read_bytes()
|
||
|
||
|
||
def test_install_icon_copy_failure_falls_back_to_absolute(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""An impossible icon copy keeps the absolute path (never breaks)."""
|
||
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: [])
|
||
|
||
def _boom(src, dst):
|
||
raise OSError("read-only tree")
|
||
|
||
monkeypatch.setattr(lde.shutil, "copyfile", _boom)
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
values = _parse(entry.read_text(encoding="utf-8"))
|
||
# The real helper catches the copy OSError and returns False, so the
|
||
# caller falls back to the absolute path without raising.
|
||
assert values["Icon"] == str(lde.icon_path(root))
|
||
|
||
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()
|
||
|
||
|
||
# #90292: the shell installer's bash wrapper makes argv[0] the repo `hermes`
|
||
# python script whose `#!/usr/bin/env python3` shebang resolves to the SYSTEM
|
||
# interpreter when the DE spawns the .desktop entry → ModuleNotFoundError,
|
||
# silent (Terminal=false). The Exec line must prefix sys.executable for any
|
||
# resolved bin that is a python script escaping the running venv.
|
||
def test_exec_prefixes_interpreter_for_env_shebang_python_script(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
hermes_bin = tmp_path / "bin" / "hermes"
|
||
hermes_bin.parent.mkdir()
|
||
hermes_bin.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
hermes_bin.chmod(0o755)
|
||
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)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
interpreter = os.path.abspath(sys.executable)
|
||
assert exec_line.split(" ")[0].strip('"') == interpreter
|
||
assert str(hermes_bin) in exec_line
|
||
assert exec_line.endswith("desktop")
|
||
|
||
|
||
def test_exec_leaves_shell_wrapper_launchers_alone(tmp_path, xdg_home, monkeypatch):
|
||
root = _make_project(tmp_path)
|
||
hermes_bin = tmp_path / "bin" / "hermes"
|
||
hermes_bin.parent.mkdir()
|
||
hermes_bin.write_text(
|
||
'#!/bin/bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8"
|
||
)
|
||
hermes_bin.chmod(0o755)
|
||
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)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# A bash wrapper execs the venv python itself — no interpreter prefix.
|
||
assert exec_line == f"{hermes_bin} desktop"
|
||
|
||
|
||
def test_exec_leaves_venv_shebang_scripts_alone(tmp_path, xdg_home, monkeypatch):
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
hermes_bin = tmp_path / "bin" / "hermes"
|
||
hermes_bin.parent.mkdir()
|
||
interpreter = os.path.abspath(sys.executable)
|
||
hermes_bin.write_text(f"#!{interpreter}\nimport hermes_cli\n", encoding="utf-8")
|
||
hermes_bin.chmod(0o755)
|
||
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)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# Console-script with the venv's own interpreter in the shebang: correct
|
||
# as-is, prefixing would only add noise.
|
||
assert exec_line == f"{hermes_bin} desktop"
|
||
|
||
|
||
# The persisted entry must be launch-context independent: whatever process
|
||
# writes it, the next launch reads and rewrites the same bytes. argv[0]
|
||
# differs per launch path (wrapper / repo script / python -m), so a
|
||
# checkout-internal argv[0] must not be persisted — the resolver falls
|
||
# through to PATH, where the installer's durable wrapper lives.
|
||
def _argv0_context(monkeypatch, argv0: str) -> None:
|
||
import sys
|
||
|
||
monkeypatch.setattr(sys, "argv", [argv0, "desktop"])
|
||
|
||
|
||
def test_exec_converges_from_repo_script_argv0_to_installed_wrapper(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""A broken interpreter-form entry must self-heal to the wrapper form.
|
||
|
||
Launching with argv[0] = <checkout>/hermes (what the broken entry
|
||
itself spawns) previously re-persisted the same broken form forever —
|
||
the bootstrap loop that kept #90492 from repairing existing installs.
|
||
"""
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes" # checkout-internal launcher candidate
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
wrapper = tmp_path / "installed" / "bin" / "hermes"
|
||
wrapper.parent.mkdir(parents=True)
|
||
wrapper.write_text(f'#!/bin/bash\nexec {sys.executable} "$@"\n', encoding="utf-8")
|
||
wrapper.chmod(0o755)
|
||
|
||
# argv[0] = repo script; PATH lookup finds the installed wrapper.
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr(
|
||
"shutil.which", lambda name: str(wrapper) if name == "hermes" else 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"]
|
||
|
||
# Converged on the durable wrapper — NOT the repo script, and NOT an
|
||
# interpreter-prefixed form pinning sys.executable.
|
||
assert exec_line == f"{wrapper} desktop"
|
||
|
||
|
||
def test_exec_never_persists_a_bare_interpreter_command(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""The `python -m hermes_cli.main` relaunch context must not write
|
||
`Exec=<python> desktop` — a command line no DE can run."""
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
wrapper = tmp_path / "installed" / "bin" / "hermes"
|
||
wrapper.parent.mkdir(parents=True)
|
||
wrapper.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
|
||
wrapper.chmod(0o755)
|
||
|
||
interpreter = tmp_path / "uv" / "cpython-3.11.15" / "bin" / "python3.11"
|
||
interpreter.parent.mkdir(parents=True)
|
||
interpreter.write_bytes(b"\x7fELF fake")
|
||
interpreter.chmod(0o755)
|
||
|
||
# argv[0] IS the interpreter (python -m context); PATH has the wrapper.
|
||
_argv0_context(monkeypatch, str(interpreter))
|
||
monkeypatch.setattr(
|
||
"shutil.which", lambda name: str(wrapper) if name == "hermes" else 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"]
|
||
|
||
first_token = exec_line.split(" ")[0].strip('"')
|
||
assert Path(first_token) != interpreter
|
||
assert not (
|
||
Path(first_token).name.startswith("python")
|
||
and "desktop" in exec_line.split(" ", 1)[1]
|
||
), f"persisted an unrunnable bare-interpreter Exec: {exec_line}"
|
||
assert exec_line == f"{wrapper} desktop"
|
||
|
||
|
||
def test_exec_keeps_resolver_fallback_when_no_wrapper_on_path(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""No wrapper anywhere → #90492's runnable fallback, never a dead Exec.
|
||
|
||
With argv[0] checkout-internal and PATH + known locations both empty,
|
||
the resolver returns None and resolve_exec_command emits the runnable
|
||
`sys.executable -m hermes_cli.main desktop` fallback. Persisting the
|
||
interpreter itself (`<python> desktop`) would be unrunnable by any DE;
|
||
persisting the repo script alone dies on its env shebang.
|
||
"""
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes"
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
||
|
||
def fake_resolve():
|
||
# Mirror resolve_hermes_bin's chain: argv[0] → relative → PATH → None.
|
||
return sys.argv[0] if sys.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# The runnable module fallback — NOT the bare repo script (its env
|
||
# shebang would escape the venv under a DE) and NOT `<python> desktop`.
|
||
assert exec_line.endswith("-m hermes_cli.main desktop")
|
||
assert Path(exec_line.split(" ")[0].strip('"')).is_absolute()
|
||
assert str(repo_script) not in exec_line
|
||
|
||
|
||
def test_exec_uses_known_wrapper_when_path_lookup_misses(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""Stripped-PATH session + wrapper at the known installer location.
|
||
|
||
systemd user sessions and autostart relaunches often run without
|
||
~/.local/bin on PATH. When shutil.which finds nothing, the resolver
|
||
must probe the known durable locations directly instead of silently
|
||
persisting a checkout-internal Exec line.
|
||
"""
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes"
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
|
||
# The wrapper exists at the known location but is NOT on PATH.
|
||
# Realistic installer shim: execs this checkout's venv python on the
|
||
# checkout's hermes script (the aidiyet check requires it to target
|
||
# the writing checkout).
|
||
known_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
|
||
known_wrapper.parent.mkdir(parents=True)
|
||
known_wrapper.write_text(
|
||
f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
|
||
encoding="utf-8",
|
||
)
|
||
known_wrapper.chmod(0o755)
|
||
monkeypatch.setenv("HOME", str(tmp_path / "known-home"))
|
||
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
|
||
def fake_resolve():
|
||
return sys.argv[0] if sys.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# The probe found the wrapper despite the PATH miss.
|
||
assert exec_line == f"{known_wrapper} desktop"
|
||
|
||
|
||
def test_exec_rejects_known_wrapper_from_another_checkout(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""A known-location wrapper that targets a DIFFERENT checkout is skipped.
|
||
|
||
On machines with multiple installs over time, ~/.local/bin/hermes may
|
||
belong to another checkout. Persisting it would make the entry stable
|
||
but silently point at that other installation — the failure class the
|
||
aidiyet check exists to prevent. The runnable module fallback must win
|
||
instead.
|
||
"""
|
||
import sys
|
||
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes"
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
|
||
# A shim belonging to a DIFFERENT checkout.
|
||
other_root = tmp_path / "other-install"
|
||
other_root.mkdir()
|
||
foreign_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
|
||
foreign_wrapper.parent.mkdir(parents=True)
|
||
foreign_wrapper.write_text(
|
||
f"#!/bin/bash\nexec {other_root / 'venv' / 'bin' / 'python'} "
|
||
f'{other_root / "hermes"} "$@"\n',
|
||
encoding="utf-8",
|
||
)
|
||
foreign_wrapper.chmod(0o755)
|
||
monkeypatch.setenv("HOME", str(tmp_path / "known-home"))
|
||
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
|
||
def fake_resolve():
|
||
return sys.argv[0] if sys.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# The foreign wrapper was rejected; the runnable module fallback won.
|
||
assert str(foreign_wrapper) not in exec_line
|
||
assert exec_line.endswith("-m hermes_cli.main desktop")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("layout", "env_overrides", "expected"),
|
||
[
|
||
pytest.param(
|
||
"user",
|
||
{},
|
||
"HOME-SET-BY-TEST/.local/bin/hermes",
|
||
id="user-layout",
|
||
),
|
||
pytest.param(
|
||
"termux",
|
||
{"PREFIX": "PREFIX-SET-BY-TEST"},
|
||
"PREFIX-SET-BY-TEST/bin/hermes",
|
||
id="termux-prefix-first",
|
||
),
|
||
pytest.param(
|
||
"root-fhs",
|
||
{"__EUID0__": "1"},
|
||
"/usr/local/bin/hermes",
|
||
id="root-fhs",
|
||
),
|
||
pytest.param(
|
||
"non-root-no-fhs",
|
||
{"__EUID0__": "0"},
|
||
"HOME-SET-BY-TEST/.local/bin/hermes",
|
||
id="non-root-excludes-fhs",
|
||
),
|
||
],
|
||
)
|
||
def test_known_wrapper_candidates_cover_installer_layouts(
|
||
layout, env_overrides, expected, monkeypatch
|
||
):
|
||
"""_known_wrapper_candidates mirrors get_command_link_dir() layouts.
|
||
|
||
Termux ($PREFIX/bin) outranks everything; root FHS (/usr/local/bin)
|
||
applies only to euid 0; the user layout (~/.local/bin) is always a
|
||
candidate. Locking these in protects against silent regressions in
|
||
the stripped-PATH probe path.
|
||
"""
|
||
import os
|
||
|
||
sentinel_home = "/home/__sentinel_home__"
|
||
monkeypatch.setenv("HOME", sentinel_home)
|
||
for key, value in env_overrides.items():
|
||
if key == "__EUID0__":
|
||
monkeypatch.setattr(lde.os, "geteuid", lambda: 0 if value == "1" else 1000)
|
||
else:
|
||
monkeypatch.setenv(key, value)
|
||
|
||
candidates = [str(c) for c in lde._known_wrapper_candidates()]
|
||
|
||
expected_resolved = expected.replace("HOME-SET-BY-TEST", sentinel_home)
|
||
assert expected_resolved in candidates
|
||
if layout == "termux":
|
||
# PREFIX outranks the user layout.
|
||
assert candidates[0] == expected_resolved
|
||
if layout == "root-fhs":
|
||
# Root FHS outranks the user layout.
|
||
assert candidates.index("/usr/local/bin/hermes") < candidates.index(
|
||
f"{sentinel_home}/.local/bin/hermes"
|
||
)
|
||
if layout == "non-root-no-fhs":
|
||
# Non-root euid: /usr/local/bin must be excluded outright.
|
||
assert "/usr/local/bin/hermes" not in candidates
|
||
|
||
|
||
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.macos_only
|
||
def test_install_is_a_noop_on_macos(tmp_path):
|
||
"""Faking darwin only renamed the host — the real macOS runner is the
|
||
only place the `sys.platform` guard is exercised against a real host."""
|
||
assert lde.install_desktop_entry(_make_project(tmp_path)) is None
|
||
|
||
|
||
@pytest.mark.windows_only
|
||
def test_install_is_a_noop_on_windows(tmp_path):
|
||
"""As above for Windows: a fake left POSIX paths and a POSIX XDG layout
|
||
in place, so the no-op was never proven against a real one."""
|
||
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'
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
sys.platform == "win32", reason="Symlinks require elevated privileges on Windows"
|
||
)
|
||
def test_running_interpreter_keeps_venv_semantic_path(tmp_path, monkeypatch):
|
||
"""Lexical preserved only when pyvenv.cfg marks the path as a venv."""
|
||
# venv layout: bin/python symlink -> base, pyvenv.cfg at venv root
|
||
base = tmp_path / "base" / "python3.11"
|
||
base.parent.mkdir(parents=True)
|
||
base.write_text("", encoding="utf-8")
|
||
venv_root = tmp_path / "venv"
|
||
venv_bin = venv_root / "bin"
|
||
venv_bin.mkdir(parents=True)
|
||
(venv_root / "pyvenv.cfg").write_text("home = /base\n", encoding="utf-8")
|
||
venv_python = venv_bin / "python"
|
||
venv_python.symlink_to(base)
|
||
|
||
monkeypatch.setattr(lde.sys, "executable", str(venv_python))
|
||
assert lde._running_interpreter() == str(venv_python)
|
||
|
||
# non-venv symlink: resolve instead (durability over lexical)
|
||
plain_root = tmp_path / "plain" / "bin"
|
||
plain_root.mkdir(parents=True)
|
||
plain_link = plain_root / "python3"
|
||
plain_link.symlink_to(base)
|
||
monkeypatch.setattr(lde.sys, "executable", str(plain_link))
|
||
assert lde._running_interpreter() == str(base)
|
||
|
||
|
||
def test_running_interpreter_resolves_plain_interpreter(monkeypatch):
|
||
"""A non-symlinked, non-venv executable resolves to itself."""
|
||
monkeypatch.setattr(lde.sys, "executable", "/usr/bin/python3")
|
||
out = lde._running_interpreter()
|
||
assert Path(out).is_absolute()
|
||
|
||
|
||
def test_can_import_probe_runs_and_caches(tmp_path):
|
||
"""The probe executes the real interpreter and memoizes the answer.
|
||
|
||
Asserts the two things that hold on ANY host: the probe returns a
|
||
definite boolean for a real interpreter (not None, not an exception
|
||
path), and the per-path cache is populated so the second call pays
|
||
no subprocess. Host-dependent capability itself (True vs False) is
|
||
deliberately NOT asserted - a CI host with hermes pip-installed
|
||
system-wide would legitimately answer True.
|
||
"""
|
||
import time
|
||
|
||
real = Path("/usr/bin/python3")
|
||
if not real.exists():
|
||
pytest.skip("no system python to probe")
|
||
lde._probe_cache.pop(str(real), None)
|
||
try:
|
||
first = lde._can_import_hermes_cli(real)
|
||
assert isinstance(first, bool)
|
||
assert str(real) in lde._probe_cache
|
||
t0 = time.monotonic()
|
||
second = lde._can_import_hermes_cli(real)
|
||
assert second is first
|
||
assert time.monotonic() - t0 < 0.05 # cache hit: no subprocess
|
||
finally:
|
||
lde._probe_cache.pop(str(real), None)
|
||
|
||
|
||
def test_exec_falls_back_to_running_interpreter_when_probe_fails(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""A candidate interpreter that fails the import probe is not persisted."""
|
||
import sys as _s
|
||
|
||
root = _make_project(tmp_path)
|
||
interpreter = tmp_path / "uv" / "bin" / "python3.11"
|
||
interpreter.parent.mkdir(parents=True)
|
||
interpreter.write_bytes(b"\x7fELF fake")
|
||
interpreter.chmod(0o755)
|
||
|
||
_argv0_context(monkeypatch, str(interpreter))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
||
|
||
def fake_resolve():
|
||
return _s.argv[0] if _s.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
# Force the probe to fail for whatever interpreter gets chosen first.
|
||
monkeypatch.setattr(lde, "_can_import_hermes_cli", lambda p: False)
|
||
# And the fallback interpreter must itself pass (it always should).
|
||
monkeypatch.setattr(
|
||
lde,
|
||
"_running_interpreter_fallback",
|
||
lambda: os.path.abspath(sys.executable),
|
||
)
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
|
||
|
||
# Runnable module form under the RUNNING interpreter - never the
|
||
# unprobeable ELF fake, never a bare "<python> desktop".
|
||
assert exec_line.endswith("-m hermes_cli.main desktop")
|
||
first = exec_line.split(" ")[0].strip('"')
|
||
assert first == os.path.abspath(sys.executable)
|
||
assert str(interpreter) not in exec_line
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"suffix",
|
||
["-old", ".bak", "-copy"],
|
||
)
|
||
def test_wrapper_ownership_rejects_sibling_extensions(suffix, tmp_path):
|
||
"""A shim execing `<checkout><suffix>/...` must NOT pass ownership.
|
||
|
||
Bare substring matching accepted these; the boundary-aware matcher
|
||
must reject them (stable-but-wrong entry pointing at the renamed
|
||
old install).
|
||
"""
|
||
checkout = tmp_path / "hermes-agent"
|
||
checkout.mkdir()
|
||
evil = tmp_path / "evil-shim"
|
||
evil.write_text(
|
||
f"#!/bin/bash\n"
|
||
f"exec {checkout}{suffix}/venv/bin/python "
|
||
f'{checkout}{suffix}/hermes "$@"\n',
|
||
encoding="utf-8",
|
||
)
|
||
assert lde._wrapper_targets_checkout(evil, checkout) is False
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
sys.platform == "win32", reason="Symlinks require elevated privileges on Windows"
|
||
)
|
||
def test_wrapper_ownership_accepts_shim_via_symlinked_home(tmp_path, monkeypatch):
|
||
"""Installer writes $INSTALL_DIR lexically; the root stays lexical too.
|
||
|
||
With /home -> /real-home, a shim that references the lexical
|
||
checkout path must match the lexical checkout root (the resolved
|
||
root alone would never match the shim's text).
|
||
"""
|
||
home_link = tmp_path / "home-link"
|
||
home_real = tmp_path / "home-real"
|
||
home_real.mkdir()
|
||
home_link.symlink_to(home_real)
|
||
lexical_checkout = home_link / "hermes-agent"
|
||
(home_real / "hermes-agent").mkdir()
|
||
|
||
shim = home_link / ".local" / "bin" / "hermes"
|
||
shim.parent.mkdir(parents=True)
|
||
shim.write_text(
|
||
f"#!/bin/bash\n"
|
||
f"exec {lexical_checkout}/venv/bin/python "
|
||
f'{lexical_checkout}/hermes "$@"\n',
|
||
encoding="utf-8",
|
||
)
|
||
assert lde._wrapper_targets_checkout(shim, lexical_checkout) is True
|
||
# And the end-to-end probe finds it via the lexical root: make the
|
||
# shim executable, point HOME at the symlinked home, and give the
|
||
# resolver a checkout-internal primary (the repo script) so the
|
||
# probe leg actually engages.
|
||
shim.chmod(0o755)
|
||
monkeypatch.setenv("HOME", str(home_link))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
repo_script = lexical_checkout / "hermes"
|
||
repo_script.write_text("#!/usr/bin/env python3\n", encoding="utf-8")
|
||
monkeypatch.setattr(sys, "argv", [str(repo_script), "desktop"])
|
||
assert lde._resolve_hermes_bin_for_desktop_entry(
|
||
resolve_fn=lambda: sys.argv[0] if sys.argv[0] else None,
|
||
checkout_root=lexical_checkout,
|
||
) == str(shim)
|
||
|
||
|
||
def test_needs_interpreter_case_insensitive_match(tmp_path, monkeypatch):
|
||
"""Interpreter paths with uppercase must not flag own venv scripts.
|
||
|
||
The shebang is lowercased for comparison; the interpreter dir must
|
||
be too (conda env names, usernames, uv's ephemeral .tmpXXX dirs all
|
||
carry uppercase - an asymmetric compare would prefix the venv's own
|
||
console script spuriously).
|
||
"""
|
||
venv_bin = tmp_path / "MyEnv" / "bin"
|
||
venv_bin.mkdir(parents=True)
|
||
interpreter = venv_bin / "python"
|
||
interpreter.write_text("", encoding="utf-8")
|
||
|
||
console_script = venv_bin / "hermes"
|
||
console_script.write_text(f"#!{interpreter}\nimport hermes_cli\n", encoding="utf-8")
|
||
monkeypatch.setattr(lde.sys, "executable", str(interpreter))
|
||
|
||
assert lde._needs_interpreter(console_script) is False
|
||
|
||
|
||
def test_needs_interpreter_rejects_sibling_directory(tmp_path, monkeypatch):
|
||
"""``<venv>/bin-extra/python`` is NOT inside ``<venv>/bin``.
|
||
|
||
Substring matching accepted it (the parent dir appears verbatim inside
|
||
the sibling path), skipping the interpreter prefix for a script whose
|
||
shebang actually points OUTSIDE the venv. Path-component comparison
|
||
rejects it.
|
||
"""
|
||
venv_bin = tmp_path / "venv" / "bin"
|
||
venv_bin.mkdir(parents=True)
|
||
interp = venv_bin / "python"
|
||
interp.write_text("", encoding="utf-8")
|
||
monkeypatch.setattr(lde.sys, "executable", str(interp))
|
||
|
||
sibling_script = tmp_path / "sibling"
|
||
sibling_script.write_text(
|
||
f"#!{tmp_path}/venv/bin-extra/python\nimport hermes_cli\n",
|
||
encoding="utf-8",
|
||
)
|
||
assert lde._needs_interpreter(sibling_script) is True
|
||
|
||
|
||
def test_needs_interpreter_strips_flags_before_comparing(tmp_path, monkeypatch):
|
||
"""A flagged own-venv shebang is not misclassified by the flag token."""
|
||
venv_bin = tmp_path / "venv" / "bin"
|
||
venv_bin.mkdir(parents=True)
|
||
interp = venv_bin / "python"
|
||
interp.write_text("", encoding="utf-8")
|
||
monkeypatch.setattr(lde.sys, "executable", str(interp))
|
||
|
||
flagged = tmp_path / "flagged"
|
||
flagged.write_text(f"#!{interp} -S\nimport hermes_cli\n", encoding="utf-8")
|
||
assert lde._needs_interpreter(flagged) is False
|
||
|
||
|
||
def test_needs_interpreter_env_shebang_always_escapes(tmp_path, monkeypatch):
|
||
"""``env`` resolves through the DE's PATH - not the installer's."""
|
||
venv_bin = tmp_path / "venv" / "bin"
|
||
venv_bin.mkdir(parents=True)
|
||
interp = venv_bin / "python"
|
||
interp.write_text("", encoding="utf-8")
|
||
monkeypatch.setattr(lde.sys, "executable", str(interp))
|
||
|
||
# Even when env itself sits in the venv bin (parent equality would
|
||
# pass), the PATH resolution semantics mean the shebang escapes.
|
||
env_script = tmp_path / "envscript"
|
||
env_script.write_text(
|
||
f"#!{venv_bin}/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
assert lde._needs_interpreter(env_script) is True
|
||
|
||
# ...unless env carries an absolute venv interpreter after -S.
|
||
env_abs = tmp_path / "envabs"
|
||
env_abs.write_text(
|
||
f"#!/usr/bin/env -S {interp}\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
assert lde._needs_interpreter(env_abs) is False
|
||
|
||
|
||
def test_probe_skips_wrapper_with_escaping_python_shebang(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""A checkout-referencing wrapper with an env shebang is skipped.
|
||
|
||
Ownership alone would accept it (the body references this checkout),
|
||
but its `#!/usr/bin/env python3` shebang dies in the DE context.
|
||
The shebang-safety gate skips it; the module fallback wins. Idea
|
||
credited to autumn8's #92122 rung-2 check.
|
||
"""
|
||
import sys as _s
|
||
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes"
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
|
||
# A wrapper that targets this checkout but cannot run itself.
|
||
broken_wrapper = xdg_home / ".local" / "bin" / "hermes"
|
||
broken_wrapper.parent.mkdir(parents=True)
|
||
broken_wrapper.write_text(
|
||
f"#!/usr/bin/env python3\n# launcher for {root}\nimport hermes_cli\n",
|
||
encoding="utf-8",
|
||
)
|
||
broken_wrapper.chmod(0o755)
|
||
monkeypatch.setenv("HOME", str(xdg_home))
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
|
||
def fake_resolve():
|
||
return sys.argv[0] if sys.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
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 str(broken_wrapper) not in exec_line
|
||
assert exec_line.endswith("-m hermes_cli.main desktop")
|
||
|
||
|
||
def test_probe_accepts_shell_launcher_wrapper(tmp_path, xdg_home, monkeypatch):
|
||
"""A bash launcher is safe by construction and still wins the probe."""
|
||
root = _make_project(tmp_path)
|
||
repo_script = root / "hermes"
|
||
repo_script.write_text(
|
||
"#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8"
|
||
)
|
||
repo_script.chmod(0o755)
|
||
|
||
good_wrapper = xdg_home / ".local" / "bin" / "hermes"
|
||
good_wrapper.parent.mkdir(parents=True)
|
||
good_wrapper.write_text(
|
||
f"#!/bin/bash\nexec {root / 'venv' / 'bin' / 'python'} "
|
||
f'{root / "hermes"} "$@"\n',
|
||
encoding="utf-8",
|
||
)
|
||
good_wrapper.chmod(0o755)
|
||
monkeypatch.setenv("HOME", str(xdg_home))
|
||
_argv0_context(monkeypatch, str(repo_script))
|
||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||
|
||
def fake_resolve():
|
||
return sys.argv[0] if sys.argv[0] else None
|
||
|
||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve)
|
||
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"{good_wrapper} desktop"
|
||
|
||
|
||
def test_install_icon_handles_truncated_png_header(tmp_path, xdg_home, monkeypatch):
|
||
"""A truncated PNG (valid signature + IHDR tag, <24 bytes) must not
|
||
raise struct.error out of the fail-safe: it lands in 256x256/ like
|
||
any other unknown-size raster."""
|
||
root = _make_project(tmp_path)
|
||
icon = lde.icon_path(root)
|
||
icon.write_bytes(
|
||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00" # 22 bytes
|
||
)
|
||
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)
|
||
|
||
values = _parse(entry.read_text(encoding="utf-8"))
|
||
assert values["Icon"] == "hermes"
|
||
dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png"
|
||
assert dest.is_file()
|
||
|
||
|
||
def test_hicolor_subdir_puts_rasters_in_indexed_dirs_never_scalable():
|
||
"""Panel lookup uses fixed sizes. scalable/ is SVG-only."""
|
||
assert lde._hicolor_subdir(None) == "256x256"
|
||
assert lde._hicolor_subdir((1024, 1024)) == "256x256"
|
||
assert lde._hicolor_subdir((512, 512)) == "512x512"
|
||
assert lde._hicolor_subdir((256, 256)) == "256x256"
|
||
assert lde._hicolor_subdir((48, 48)) == "48x48"
|
||
assert lde._hicolor_subdir((24, 24)) == "24x24"
|
||
assert lde._hicolor_subdir((64, 32)) == "256x256"
|
||
|
||
|
||
def test_install_places_1024_png_in_256x256_not_scalable(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""The shipped desktop asset is 1024×1024. A PNG in scalable/ is what
|
||
Cinnamon's panel rasterizes as a mangled low-res icon."""
|
||
root = _make_project(tmp_path)
|
||
lde.icon_path(root).write_bytes(_png_ihdr(1024, 1024))
|
||
_stub_install(tmp_path, monkeypatch)
|
||
|
||
entry = lde.install_desktop_entry(root)
|
||
values = _parse(entry.read_text(encoding="utf-8"))
|
||
|
||
dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png"
|
||
stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png"
|
||
assert values["Icon"] == "hermes"
|
||
assert dest.is_file()
|
||
assert dest.read_bytes() == lde.icon_path(root).read_bytes()
|
||
assert not stale.exists()
|
||
|
||
|
||
def test_install_removes_stale_scalable_png(tmp_path, xdg_home, monkeypatch):
|
||
"""v2026.8.31 wrote the PNG into scalable/. A later hermes desktop
|
||
must delete that leftover so Cinnamon does not keep using it."""
|
||
root = _make_project(tmp_path)
|
||
lde.icon_path(root).write_bytes(_png_ihdr(1024, 1024))
|
||
_stub_install(tmp_path, monkeypatch)
|
||
|
||
stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png"
|
||
stale.parent.mkdir(parents=True)
|
||
stale.write_bytes(b"old scalable png")
|
||
|
||
lde.install_desktop_entry(root)
|
||
|
||
dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png"
|
||
assert dest.is_file()
|
||
assert not stale.exists()
|
||
|
||
|
||
def test_install_exact_48_png_uses_48x48_dir(tmp_path, xdg_home, monkeypatch):
|
||
root = _make_project(tmp_path)
|
||
lde.icon_path(root).write_bytes(_png_ihdr(48, 48))
|
||
_stub_install(tmp_path, monkeypatch)
|
||
|
||
lde.install_desktop_entry(root)
|
||
|
||
dest = xdg_home / "icons" / "hicolor" / "48x48" / "apps" / "hermes.png"
|
||
assert dest.is_file()
|
||
assert not (
|
||
xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png"
|
||
).exists()
|
||
|
||
|
||
def test_install_resizes_decodable_png_to_panel_sizes(
|
||
tmp_path, xdg_home, monkeypatch
|
||
):
|
||
"""A decodeable PNG is Lanczos-resized so the 24px slot is actually 24px."""
|
||
from PIL import Image
|
||
|
||
root = _make_project(tmp_path)
|
||
im = Image.new("RGBA", (64, 64), (255, 255, 255, 255))
|
||
for x in range(16, 48):
|
||
for y in range(16, 48):
|
||
im.putpixel((x, y), (0, 0, 0, 255))
|
||
buf = io.BytesIO()
|
||
im.save(buf, format="PNG")
|
||
lde.icon_path(root).write_bytes(buf.getvalue())
|
||
_stub_install(tmp_path, monkeypatch)
|
||
|
||
lde.install_desktop_entry(root)
|
||
|
||
dest_24 = xdg_home / "icons" / "hicolor" / "24x24" / "apps" / "hermes.png"
|
||
dest_256 = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png"
|
||
stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png"
|
||
assert dest_24.is_file()
|
||
assert dest_256.is_file()
|
||
assert not stale.exists()
|
||
assert struct.unpack(">II", dest_24.read_bytes()[16:24]) == (24, 24)
|
||
assert struct.unpack(">II", dest_256.read_bytes()[16:24]) == (256, 256)
|