Files
hermes-agent/tests/hermes_cli/test_skin_cmd.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

126 lines
4.5 KiB
Python

"""`hermes skin set` — deterministic single-color tweak of the active skin.
The whole point is that changing one token never disturbs the rest of the look
(background especially), which hand-authoring kept getting wrong.
"""
import os
import pytest
import hermes_yaml as yaml
from hermes_cli import skin_cmd
from hermes_constants import get_hermes_home
def _skins():
d = get_hermes_home() / "skins"
d.mkdir(parents=True, exist_ok=True)
return d
def _activate(name: str) -> None:
(get_hermes_home() / "config.yaml").write_text(f"display:\n skin: {name}\n", encoding="utf-8")
def test_set_edits_active_user_skin_in_place_preserving_everything_else():
(_skins() / "oasis.yaml").write_text(
'name: oasis\ncolors:\n background: "#08201f"\n banner_title: "#f2dfb3"\n', encoding="utf-8"
)
_activate("oasis")
assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0
data = yaml.safe_load((_skins() / "oasis.yaml").read_text())
assert data["colors"]["ui_tool"] == "#00FFFF"
assert data["colors"]["background"] == "#08201f" # untouched — the whole point
assert data["colors"]["banner_title"] == "#f2dfb3"
assert data["name"] == "oasis" # no rename, no fork
assert not (_skins() / "oasis-custom.yaml").exists()
def test_set_forks_a_builtin_without_inventing_a_background():
_activate("default") # a built-in — no file
assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0
fork = _skins() / "default-custom.yaml"
assert fork.exists()
data = yaml.safe_load(fork.read_text())
assert data["colors"]["ui_tool"] == "#00FFFF"
# default has no background, so the fork must not invent one (terminal stays put).
assert "background" not in data["colors"]
# full palette carried over, and it became active.
assert data["colors"].get("banner_title")
assert (get_hermes_home() / "config.yaml").read_text().find("default-custom") != -1
def test_set_rejects_non_hex():
_activate("default")
assert skin_cmd._skin_set("ui_tool", "teal", None) == 1
def test_set_persists_the_skin_durably():
"""The palette must reach disk before ``skin set`` returns.
``write_text`` truncates and writes without ever calling ``fsync``, so a
power loss or container kill right after the command "succeeds" can leave
``<skin>.yaml`` zero-length. That is not transient: ``_skin_set`` is a
read-modify-write and ``safe_load("")`` returns ``None``, which its
``or {}`` turns into an empty palette — so the next tweak rewrites the
file from that empty state and the rest of the palette is gone for good.
The gateway's skin watcher repaints every live surface from this file
within ~1s either way.
"""
path = _skins() / "oasis.yaml"
path.write_text(
'name: oasis\ncolors:\n background: "#08201f"\n banner_title: "#f2dfb3"\n',
encoding="utf-8",
)
_activate("oasis")
synced = []
real_fsync = os.fsync
def _tracking_fsync(fd):
synced.append(fd)
return real_fsync(fd)
with pytest.MonkeyPatch.context() as mp:
mp.setattr(os, "fsync", _tracking_fsync)
assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0
assert synced, "skin file written without fsync — a crash can leave it empty"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
assert data["colors"]["ui_tool"] == "#00FFFF"
assert data["colors"]["background"] == "#08201f" # untouched — the whole point
assert data["colors"]["banner_title"] == "#f2dfb3"
assert [p.name for p in _skins().iterdir() if p.name.endswith(".tmp")] == []
@pytest.mark.require_symlinks
def test_set_preserves_a_symlinked_skin_file():
"""Guard on the conversion, not a behavior change.
``write_text`` wrote through a symlink; a naive ``os.replace`` would
detach it. ``atomic_replace`` resolves the link first (GitHub #16743),
so a skin file symlinked out to a dotfiles repo must stay a symlink.
"""
real_dir = _skins().parent / "dotfiles"
real_dir.mkdir(parents=True, exist_ok=True)
real = real_dir / "oasis.yaml"
real.write_text(
'name: oasis\ncolors:\n background: "#08201f"\n', encoding="utf-8"
)
link = _skins() / "oasis.yaml"
link.symlink_to(real)
_activate("oasis")
assert skin_cmd._skin_set("ui_tool", "#00FFFF", None) == 0
assert link.is_symlink()
data = yaml.safe_load(real.read_text(encoding="utf-8"))
assert data["colors"]["ui_tool"] == "#00FFFF"
assert data["colors"]["background"] == "#08201f"