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.
126 lines
4.5 KiB
Python
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"
|