Files
hermes-agent/tests/hermes_cli/test_update_yes_flag.py
Teknium 50c046331d feat(update): add --yes/-y flag to skip interactive prompts (#18261)
hermes update had two interactive [Y/n] prompts with no bypass:
  1. Config migration (after new env/config options are added)
  2. Autostash restore (when uncommitted work was stashed before pull)

hermes uninstall already has --yes/-y; mirrors that.

Under --yes:
  - Config-migrate prompt → auto-yes, migrate_config(interactive=False)
    so new config fields are applied but API-key prompts are skipped
    (user runs 'hermes config migrate' later for those). Matches
    gateway-mode semantics.
  - Stash-restore prompt → auto-yes, git stash apply runs automatically.

Closes the 'can I hermes update -y, No ! Fix' gap reported by @murelux.
2026-04-30 23:06:32 -07:00

168 lines
6.4 KiB
Python

"""Tests for `hermes update --yes / -y` — assume yes for interactive prompts.
Covers:
1. argparse parses the flag
2. Config-migration prompt is auto-answered (no input() call) and migrate_config
runs with interactive=False so API-key prompts are skipped
3. Autostash restore prompt is auto-answered (prompt_for_restore == False, no
input() call) and the stash is applied automatically
"""
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
from hermes_cli.main import cmd_update
def _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1", dirty=False
):
"""Minimal subprocess.run side_effect for the update flow."""
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"{branch}\n", stderr="")
if "rev-parse" in joined and "--verify" in joined:
return subprocess.CompletedProcess(
cmd, 0 if verify_ok else 128, stdout="", stderr=""
)
if "rev-list" in joined:
return subprocess.CompletedProcess(
cmd, 0, stdout=f"{commit_count}\n", stderr=""
)
# `git status --porcelain` for dirty-tree detection during autostash.
if "status" in joined and "--porcelain" in joined:
out = " M hermes_cli/main.py\n" if dirty else ""
return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="")
# `git stash list` — return a stash ref when dirty (so _stash_local_changes
# gets something to return). _stash_local_changes_if_needed is what we
# actually patch in tests that exercise restore, so this is a catch-all.
if "stash" in joined and "list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
class TestUpdateYesConfigMigration:
"""--yes auto-answers the config-migration prompt and skips API-key prompts."""
@patch("hermes_cli.config.migrate_config")
@patch("hermes_cli.config.check_config_version", return_value=(1, 2))
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
@patch("hermes_cli.config.get_missing_env_vars", return_value=["NEW_KEY"])
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_yes_auto_migrates_without_input(
self,
mock_run,
_mock_which,
_mock_missing_env,
_mock_missing_cfg,
_mock_version,
mock_migrate,
capsys,
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
mock_migrate.return_value = {"env_added": [], "config_added": []}
args = SimpleNamespace(yes=True)
with patch("builtins.input") as mock_input:
cmd_update(args)
# Never prompted the user.
mock_input.assert_not_called()
# migrate_config was invoked with interactive=False — API-key prompts
# are suppressed, matching gateway-mode semantics.
assert mock_migrate.call_count == 1
_, kwargs = mock_migrate.call_args
assert kwargs.get("interactive") is False
out = capsys.readouterr().out
assert "--yes: auto-applying config migration" in out
# The "Would you like to configure them now?" prompt text never appears.
assert "Would you like to configure them now?" not in out
@patch("hermes_cli.config.migrate_config")
@patch("hermes_cli.config.check_config_version", return_value=(1, 2))
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
@patch("hermes_cli.config.get_missing_env_vars", return_value=["NEW_KEY"])
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_no_yes_flag_still_prompts_in_tty(
self,
mock_run,
_mock_which,
_mock_missing_env,
_mock_missing_cfg,
_mock_version,
mock_migrate,
capsys,
):
"""Regression guard: without --yes, the TTY prompt path still fires."""
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
mock_migrate.return_value = {"env_added": [], "config_added": []}
args = SimpleNamespace(yes=False)
with patch("builtins.input", return_value="n") as mock_input, patch(
"hermes_cli.main.sys"
) as mock_sys:
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
cmd_update(args)
# The user was actually prompted.
assert mock_input.called
prompts = [c.args[0] if c.args else "" for c in mock_input.call_args_list]
assert any("configure them now" in p for p in prompts)
class TestUpdateYesStashRestore:
"""--yes auto-restores the pre-update autostash without prompting."""
@patch("hermes_cli.main._restore_stashed_changes")
@patch(
"hermes_cli.main._stash_local_changes_if_needed",
return_value="stash@{0}",
)
@patch("hermes_cli.config.check_config_version", return_value=(1, 1))
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
@patch("hermes_cli.config.get_missing_env_vars", return_value=[])
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_yes_restores_stash_without_prompting(
self,
mock_run,
_mock_which,
_mock_missing_env,
_mock_missing_cfg,
_mock_version,
_mock_stash,
mock_restore,
capsys,
):
# Not on main → cmd_update switches to main → autostash fires.
mock_run.side_effect = _make_run_side_effect(
branch="feature-branch", verify_ok=True, commit_count="1", dirty=True
)
args = SimpleNamespace(yes=True)
cmd_update(args)
# _restore_stashed_changes was called, and called with prompt_user=False
# every time (so the user never sees "Restore local changes now?").
assert mock_restore.called
for call in mock_restore.call_args_list:
assert call.kwargs.get("prompt_user") is False, (
f"Expected prompt_user=False under --yes, got {call.kwargs}"
)