hermes-agent/tests/hermes_cli/test_cmd_update.py
kshitijk4poor d426b9ddfe fix: derive skip-key manifests from npm workspaces config
Review round 2 from @ethernet8023 on #61580:

1. The manifest list was a hardcoded root/ui-tui/web trio — desktop and
   any future workspace escaped the skip key even though step 1's root
   install hoists deps for every workspace. The list is now expanded
   from the root package.json 'workspaces' globs (npm's own source of
   truth): on the real repo that yields all 8 manifests incl.
   apps/desktop, apps/bootstrap-installer, apps/shared, and the nested
   ui-tui/packages/hermes-ink. Unreadable package.json falls back to
   root manifests only (never skips more than main would install).

2. --prefer-offline dropped entirely (this branch no longer carries
   #39399): local 3-run benchmarks on the repo's real manifests show
   the flag is noise on npm ci with a warm cache (root: 0.90s vs 0.84s
   avg; ws: 4.02s vs 4.00s avg) — npm ci does no resolution and the
   content-addressed cache already serves tarballs locally. It also
   carried the stale-resolution risk on the npm install fallback the
   reviewer flagged. All the real win is the skip itself (0s vs ~5s+).

Tests: workspace-glob edit (desktop), literal-listed edit, and
new-workspace-under-glob all defeat the skip; verified against the
real repo's workspace config (8 manifests picked up).
2026-07-14 16:58:26 +05:30

1028 lines
42 KiB
Python

"""Tests for cmd_update — branch fallback when remote branch doesn't exist."""
import hashlib
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import cmd_update, PROJECT_ROOT
def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"):
"""Build a side_effect function for subprocess.run that simulates git commands."""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
# git rev-parse --abbrev-ref HEAD (get current branch)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="")
# git rev-parse --verify origin/{branch} (check remote branch exists)
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 128
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
# git rev-list HEAD..origin/{branch} --count
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
# Fallback: return a successful CompletedProcess with empty stdout
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@pytest.fixture
def mock_args():
return SimpleNamespace()
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
return shutil.which("uv")
def _fake_update_managed_uv():
return None # never actually self-update in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
class TestCmdUpdateNpmLockfileCache:
@staticmethod
def _cache_file(hermes_root, project_root):
cache_key = hashlib.sha256(str(project_root).encode()).hexdigest()[:12]
return hermes_root / f".npm_lock_hash_{cache_key}"
def test_npm_lockfile_changed_no_cache(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "node_modules").mkdir()
assert hm._npm_lockfile_changed(tmp_path) is True
def test_npm_lockfile_changed_matching(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "node_modules").mkdir()
self._cache_file(tmp_path, tmp_path).write_text(hm._npm_manifests_digest())
assert hm._npm_lockfile_changed(tmp_path) is False
def test_npm_lockfile_changed_mismatch(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "node_modules").mkdir()
self._cache_file(tmp_path, tmp_path).write_text("old-digest")
assert hm._npm_lockfile_changed(tmp_path) is True
def test_npm_lockfile_changed_missing_node_modules(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
content = b'{"lockfileVersion": 3}'
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_bytes(content)
digest = hashlib.sha256(content).hexdigest()
self._cache_file(tmp_path, tmp_path).write_text(digest)
# node_modules missing: should report changed even though hash matches
assert hm._npm_lockfile_changed(tmp_path) is True
def test_record_npm_lockfile_hash(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
hm._record_npm_lockfile_hash(tmp_path)
assert (
self._cache_file(tmp_path, tmp_path).read_text()
== hm._npm_manifests_digest()
)
def test_package_json_only_edit_defeats_skip(self, tmp_path, monkeypatch):
"""Reviewer scenario (#61580): dev edits package.json WITHOUT running
npm — lockfile unchanged. `hermes update` must still install (the
npm-install fallback is what syncs node_modules in that state)."""
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "package.json").write_text('{"dependencies": {}}')
(tmp_path / "node_modules").mkdir()
hm._record_npm_lockfile_hash(tmp_path)
assert hm._npm_lockfile_changed(tmp_path) is False
(tmp_path / "package.json").write_text(
'{"dependencies": {"left-pad": "^1.0.0"}}'
)
assert hm._npm_lockfile_changed(tmp_path) is True
def test_workspace_package_json_edit_defeats_skip(self, tmp_path, monkeypatch):
"""The manifest list comes from the root package.json `workspaces`
globs (npm's source of truth), so ANY workspace (desktop included)
defeats the skip, not a hardcoded set."""
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "package.json").write_text(
'{"workspaces": ["apps/*", "ui-tui"]}'
)
(tmp_path / "ui-tui").mkdir()
(tmp_path / "ui-tui" / "package.json").write_text("{}")
(tmp_path / "apps" / "desktop").mkdir(parents=True)
(tmp_path / "apps" / "desktop" / "package.json").write_text("{}")
(tmp_path / "node_modules").mkdir()
hm._record_npm_lockfile_hash(tmp_path)
assert hm._npm_lockfile_changed(tmp_path) is False
# A glob-matched workspace (desktop) defeats the skip…
(tmp_path / "apps" / "desktop" / "package.json").write_text(
'{"name": "desktop"}'
)
assert hm._npm_lockfile_changed(tmp_path) is True
# …and so does a literal-listed one.
hm._record_npm_lockfile_hash(tmp_path)
assert hm._npm_lockfile_changed(tmp_path) is False
(tmp_path / "ui-tui" / "package.json").write_text('{"name": "x"}')
assert hm._npm_lockfile_changed(tmp_path) is True
def test_new_workspace_added_defeats_skip(self, tmp_path, monkeypatch):
"""Adding a whole new workspace dir under an existing glob changes
the manifest set itself — must also defeat the skip."""
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "package.json").write_text('{"workspaces": ["apps/*"]}')
(tmp_path / "node_modules").mkdir()
hm._record_npm_lockfile_hash(tmp_path)
assert hm._npm_lockfile_changed(tmp_path) is False
(tmp_path / "apps" / "newtool").mkdir(parents=True)
(tmp_path / "apps" / "newtool" / "package.json").write_text("{}")
assert hm._npm_lockfile_changed(tmp_path) is True
def test_npm_lockfile_changed_cache_read_error(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}')
(tmp_path / "node_modules").mkdir()
# Make cache file a directory to cause OSError on read
self._cache_file(tmp_path, tmp_path).mkdir(parents=True)
assert hm._npm_lockfile_changed(tmp_path) is True
def test_update_skips_npm_when_lockfile_unchanged(self, tmp_path, monkeypatch):
from hermes_cli import main as hm
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
(tmp_path / "package.json").write_text("{}")
with patch("shutil.which", return_value="/usr/bin/npm"), \
patch.object(hm, "_npm_lockfile_changed", return_value=False), \
patch("subprocess.run") as mock_run:
hm._update_node_dependencies()
mock_run.assert_not_called()
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateTermuxUvBootstrap:
"""Regression tests for Termux-specific uv bootstrap behavior."""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_termux_uv_bootstrap_uses_binary_only_install(
self, mock_run, _mock_which, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 1, stdout="", stderr="")
monkeypatch.setattr(hm, "_is_termux_env", lambda env=None: True)
uv_bin = hm._ensure_uv_for_termux(["/termux/python", "-m", "pip"])
assert uv_bin is None
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == [
"/termux/python",
"-m",
"pip",
"install",
"uv",
"--only-binary",
":all:",
]
assert mock_run.call_args.kwargs["cwd"] == PROJECT_ROOT
assert mock_run.call_args.kwargs["check"] is False
@patch("subprocess.run")
def test_termux_reuses_existing_path_uv_without_pip(self, mock_run, monkeypatch):
"""A uv already on PATH (e.g. ``pkg install uv``) is reused before pip runs."""
from hermes_cli import main as hm
pkg_uv = "/data/data/com.termux/files/usr/bin/uv"
monkeypatch.setattr(hm, "_is_termux_env", lambda env=None: True)
# Production resolve_uv only checks $HERMES_HOME/bin/uv; model an empty
# managed dir so the PATH probe is what surfaces the packaged uv.
monkeypatch.setattr("hermes_cli.managed_uv.resolve_uv", lambda: None)
monkeypatch.setattr("shutil.which", lambda name: pkg_uv if name == "uv" else None)
uv_bin = hm._ensure_uv_for_termux(["/termux/python", "-m", "pip"])
assert uv_bin == pkg_uv
mock_run.assert_not_called()
class TestCmdUpdateBranchFallback:
"""cmd_update falls back to main when current branch has no remote counterpart."""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_falls_back_to_main_when_branch_not_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="fix/stoicneko", verify_ok=False, commit_count="3"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list should use origin/main, not origin/fix/stoicneko
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
# pull should use main, not fix/stoicneko
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_uses_current_branch_when_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="2"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_already_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
cmd_update(mock_args)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
# Should NOT have called pull
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 0
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_on_fork_checks_upstream_when_origin_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
"""Regression for issue #26172: forks whose local HEAD already matches
origin/main must still consult upstream/main before printing
"Already up to date!" — otherwise a fork that's caught up to its own
origin but behind NousResearch/hermes-agent silently misses updates.
"""
from hermes_cli import main as hm
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
with patch.object(
hm,
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed") as sync_mock:
cmd_update(mock_args)
expected_git_cmd = (
["git", "-c", "windows.appendAtomically=false"] if hm._is_windows() else ["git"]
)
sync_mock.assert_called_once_with(expected_git_cmd, PROJECT_ROOT)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
@patch("shutil.which")
@patch("subprocess.run")
def test_update_refreshes_repo_and_tui_node_dependencies(
self, mock_run, mock_which, mock_args
):
from hermes_cli import main as hm
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
# The web UI build runs through _run_with_idle_timeout now (issue
# #33788) so it no longer appears in subprocess.run's call list.
# Mock it so the test doesn't actually shell out to ``tsc``.
import subprocess as _subprocess
build_ok = _subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch.object(hm, "_is_termux_env", return_value=False), \
patch.object(hm, "_run_with_idle_timeout", return_value=build_ok) as mock_idle:
cmd_update(mock_args)
npm_calls = [
(call.args[0], call.kwargs.get("cwd"))
for call in mock_run.call_args_list
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in these locations:
# 1. repo root — root-only install (--workspaces=false)
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
# 3. web/ — npm ci --silent (if lockfile not at root)
# via _build_web_ui (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# With a single workspace lockfile at the repo root, the root
# install covers all workspaces. The web/ ci call runs from the
# workspace root too (parent of web_dir) when the root lockfile
# exists.
#
# The root install omits `--silent` and runs without
# `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840).
root_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspaces=false",
]
ws_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspace",
"ui-tui",
"--workspace",
"web",
]
assert npm_calls[:2] == [
(root_flags, PROJECT_ROOT),
(ws_flags, PROJECT_ROOT),
]
if len(npm_calls) > 2:
# The web/ install runs from the workspace root when the root
# lockfile exists (npm workspaces hoist node_modules upward).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--workspace", "web", "--silent"], PROJECT_ROOT),
]
# The web UI build itself went through the streaming helper.
mock_idle.assert_called_once()
idle_args, idle_kwargs = mock_idle.call_args
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: root npm installs must stream output
# (capture_output=False) so postinstall progress is visible
# to the user. The _build_web_ui install uses --silent and
# capture_output=True, so exclude it.
root_install_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") == PROJECT_ROOT
and "--silent" not in call.args[0]
]
assert len(root_install_calls) == 2 # root-only + workspace install
for call in root_install_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"]
), patch(
"hermes_cli.config.get_missing_config_fields",
return_value=[{"key": "new.option", "default": True}],
), patch("hermes_cli.config.check_config_version", return_value=(1, 2)), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": ["new.option"]},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = False
mock_sys.stdout.isatty.return_value = False
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
from hermes_cli.config import migrate_config
migrate_config.assert_called_once_with(interactive=False, quiet=False)
captured = capsys.readouterr()
assert "applying safe config migrations" in captured.out
assert "API keys require manual entry" in captured.out
class TestCmdUpdateMigrationPrompt:
"""The config-migration prompt names what changed and skips the prompt
entirely when only the config format version moved.
Regression guard for the contentless-prompt report (ScottFive / Tt2021):
previously the prompt printed only counts ("1 new config option") and
asked "configure them now?" even for pure version bumps, where saying
yes looked like a no-op.
"""
def test_version_bump_only_applies_silently_without_prompt(
self, mock_args, capsys
):
"""Only the version moved → apply non-interactively, never prompt."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch(
"hermes_cli.config.check_config_version", return_value=(5, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
) as mock_migrate:
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
out = capsys.readouterr().out
assert "Updating config format (v5 → v24)" in out
assert "no new settings to configure" in out
# The misleading question must NOT appear for a pure version bump.
assert "configure them now" not in out.lower()
def test_new_options_are_listed_by_name_before_prompt(
self, mock_args, capsys
):
"""New env/config keys are printed by name so the user can decide."""
env_items = [
{"name": "FOO_API_KEY", "description": "Foo service API key"},
]
cfg_items = [
{"key": "display.new_widget", "description": "New config option: display.new_widget"},
]
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input", return_value="n"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=env_items
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=cfg_items
), patch(
"hermes_cli.config.check_config_version", return_value=(1, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
out = capsys.readouterr().out
# Names, not just counts.
assert "FOO_API_KEY" in out
assert "Foo service API key" in out
assert "display.new_widget" in out
class TestCmdUpdateProfileSkillSync:
"""cmd_update syncs bundled skills to all profiles, including the active one.
Regression guard for #16176: previously the active profile was excluded
from the seed_profile_skills loop, leaving it on stale skill content.
"""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_active_profile_included_in_skill_sync(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
all_profiles = [default_p, active_p, other_p]
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert active_p.path in synced_paths, (
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
)
assert set(synced_paths) == {p.path for p in all_profiles}, (
f"All profiles must be synced; got: {synced_paths}"
)
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_single_profile_default_is_synced(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert default_p.path in synced_paths
class TestCmdUpdateBranchFlag:
"""``hermes update --branch <name>`` targets the requested branch.
The CLI default stays 'main'; --branch lets callers pick a different
target without monkey-patching the implementation.
"""
def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"):
"""Mock side-effect that knows about checkout/track behavior.
- ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns
- ``target_branch`` passed via --branch; what we expect the code to switch to
- ``checkout_fails`` if True, ``git checkout <target>`` returns non-zero
(simulates branch absent locally; code should retry with -B)
- ``track_fails`` if True, ``git checkout -B <target> origin/<target>`` ALSO fails
(simulates branch absent on origin too)
- ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="")
if "checkout" in joined and "-B" in joined:
rc = 128 if track_fails else 0
err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "checkout" in joined and "-B" not in joined and "rev-parse" not in joined:
rc = 128 if checkout_fails else 0
err = f"error: pathspec '{target_branch}' did not match\n" if checkout_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, capsys):
"""--branch bb/gui makes rev-list and pull target origin/bb/gui."""
mock_run.side_effect = self._branch_side_effect(
current_branch="bb/gui", target_branch="bb/gui", commit_count="3"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list must compare against origin/bb/gui, not origin/main
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
# pull must target bb/gui
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_defaults_to_main_when_none(self, mock_run, _mock_which, capsys):
"""No --branch (or --branch=None) preserves the historical 'main' default."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="main", commit_count="0"
)
args = SimpleNamespace(branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert all("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_switches_from_different_branch(self, mock_run, _mock_which, capsys):
"""When HEAD is on main and --branch=bb/gui, switch to bb/gui first."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="bb/gui", commit_count="2"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# First checkout call should switch us to bb/gui (not -B; happy-path branch exists locally)
checkout_cmds = [c for c in commands if "checkout" in c and "rev-parse" not in c]
assert len(checkout_cmds) >= 1
assert "bb/gui" in checkout_cmds[0]
out = capsys.readouterr().out
assert "switching to bb/gui" in out
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_tracks_remote_when_branch_absent_locally(self, mock_run, _mock_which, capsys):
"""If local lacks the branch but origin has it, fall back to ``checkout -B``."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="bb/gui",
checkout_fails=True, # plain checkout fails
track_fails=False, # -B from origin/bb/gui succeeds
commit_count="2",
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have BOTH a failed `checkout bb/gui` AND a successful `checkout -B bb/gui origin/bb/gui`
track_cmds = [c for c in commands if "checkout" in c and "-B" in c]
assert len(track_cmds) == 1
assert "bb/gui" in track_cmds[0]
assert "origin/bb/gui" in track_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_fails_when_branch_missing_everywhere(self, mock_run, _mock_which, capsys):
"""If branch doesn't exist locally OR on origin, exit non-zero with clear error."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="nonexistent",
checkout_fails=True,
track_fails=True,
commit_count="0",
)
args = SimpleNamespace(branch="nonexistent")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "does not exist locally or on origin" in out
assert "nonexistent" in out
class TestCmdUpdateCheckBranchFlag:
"""``hermes update --check --branch <name>`` honors the branch override.
The check path used to call ``git rev-list HEAD..origin/<branch> --count``
with ``check=True``. When the branch didn't exist on origin, the fetch
silently succeeded (no refspec) but rev-list exited 128 and a raw
``CalledProcessError`` propagated to the user. These tests pin the
friendlier behavior: detect-the-missing-ref before rev-list, exit 1
with a clear message.
"""
def _check_side_effect(
self,
target_branch: str,
*,
verify_ok: bool = True,
commit_count: str = "0",
upstream_fetch_ok: bool = True,
):
"""Mock side-effect for the _cmd_update_check git pipeline.
- ``target_branch`` what we expect compare ref to point at
- ``verify_ok`` if False, ``git rev-parse --verify --quiet
origin/<branch>`` fails (branch missing
on origin)
- ``commit_count`` rev-list count (0 = up-to-date)
- ``upstream_fetch_ok`` if False, ``git fetch upstream`` fails
(forces fallback to origin on branch==main)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "fetch" in joined and "upstream" in joined:
rc = 0 if upstream_fetch_ok else 128
err = "" if upstream_fetch_ok else "fatal: 'upstream' does not appear to be a git repository\n"
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "fetch" in joined and "origin" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 1
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_compares_against_named_origin_branch(
self, mock_run, _mock_method, capsys
):
"""--check --branch bb/gui compares against origin/bb/gui, never origin/main."""
mock_run.side_effect = self._check_side_effect(
target_branch="bb/gui", verify_ok=True, commit_count="2"
)
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Non-main branch skips upstream probe entirely.
assert not any("fetch" in c and "upstream" in c for c in commands), commands
# Verify and rev-list both target origin/bb/gui.
verify_cmds = [c for c in commands if "rev-parse" in c and "--verify" in c]
assert any("origin/bb/gui" in c for c in verify_cmds), verify_cmds
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_missing_on_origin_exits_cleanly(
self, mock_run, _mock_method, capsys
):
"""If origin/<branch> doesn't exist, surface a friendly error and exit 1.
Pre-fix this case raised CalledProcessError from rev-list's check=True
and dumped a Python traceback to stdout.
"""
mock_run.side_effect = self._check_side_effect(
target_branch="ghost", verify_ok=False
)
args = SimpleNamespace(check=True, branch="ghost")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
# No raw Python traceback.
assert "Traceback" not in out
assert "CalledProcessError" not in out
# Friendly message naming the branch.
assert "ghost" in out
assert "not found" in out
# rev-list must never have been called once verify failed.
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
assert not any("rev-list" in c for c in commands), commands
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_default_main_still_prefers_upstream(
self, mock_run, _mock_method, capsys
):
"""No --branch (or --branch=None) preserves the upstream-then-origin probe."""
mock_run.side_effect = self._check_side_effect(
target_branch="main", verify_ok=True, commit_count="0"
)
args = SimpleNamespace(check=True, branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have tried upstream first.
assert any("fetch" in c and "upstream" in c for c in commands), commands
# Compare ref is upstream/main (upstream fetch succeeded).
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
@patch("subprocess.run")
def test_check_branch_warns_on_pypi_install(
self, mock_run, _mock_pypi, _mock_method, capsys
):
"""PyPI install + --branch=<non-main> surfaces a warning instead of silent drop."""
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
out = capsys.readouterr().out
assert "--branch is ignored for PyPI installs" in out
assert "bb/gui" in out
class TestCmdUpdateZipBranchRefusal:
"""``hermes update --branch=<non-main>`` must refuse on the ZIP fallback path.
The ZIP fallback hard-codes a GitHub archive URL for main.zip; honoring
--branch arbitrarily would require remote-branch existence checks the
fallback can't easily do. Refusing is the right move — silently lying
about which branch got installed is the bug --branch was meant to prevent.
"""
def test_zip_fallback_refuses_non_main_branch(self, capsys):
from hermes_cli.main import _update_via_zip
args = SimpleNamespace(branch="bb/gui")
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "bb/gui" in out
assert "not supported" in out
# No actual download attempted.
assert "Downloading latest version" not in out
def test_is_termux_env_true_for_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/data/data/com.termux/files/usr"}) is True
def test_is_termux_env_false_for_non_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/usr/local"}) is False
def test_load_installable_optional_extras_supports_termux_group(tmp_path, monkeypatch):
from hermes_cli import main as hm
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"""
[project]
name = "x"
version = "0.0.0"
[project.optional-dependencies]
all = ["x[mcp]"]
termux-all = ["x[termux]", "x[mcp]"]
mcp = ["mcp>=1"]
termux = ["rich>=14"]
""".strip()
)
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"]