mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
Two CI tests for the new `--yes` update flag (#18261) flaked under `pytest-xdist` on Linux/Python 3.11 even though they passed every local run on macOS/Python 3.14.4: FAILED tests/hermes_cli/test_update_yes_flag.py ::TestUpdateYesConfigMigration::test_no_yes_flag_still_prompts_in_tty `AssertionError: assert <MagicMock 'input'>.called is False` FAILED tests/hermes_cli/test_update_yes_flag.py ::TestUpdateYesStashRestore::test_yes_restores_stash_without_prompting `AssertionError: assert <MagicMock '_restore_stashed_changes'>.called is False` Captured stdout for the first failure shows `cmd_update` taking the "Non-interactive session \u2014 skipping config migration prompt." branch \u2014 i.e. the `sys.stdin.isatty() and sys.stdout.isatty()` check at `hermes_cli/main.py:7118` evaluated to `False` despite the test doing: with patch("hermes_cli.main.sys") as mock_sys: mock_sys.stdin.isatty.return_value = True mock_sys.stdout.isatty.return_value = True The whole-module mock is fragile under xdist worker reuse: a sibling test that imports `hermes_cli.main` first can leave another `sys` reference resolved inside the function (re-import in a helper, etc.), and the wholesale module replacement never gets consulted. Switch to `patch.object(_sys.stdin, "isatty", return_value=True)` (and the same for `stdout`). That patches the *attribute on the real stream object* \u2014 every call site, no matter how it reached `sys.stdin`, hits the patched method. Same fix applied to the stash-restore test (it took the "non-TTY \u2192 skip restore prompt" branch for the same reason). Validation: $ pytest tests/hermes_cli/test_update_yes_flag.py -q 3 passed in 5.47s No production code change. Fixes the two failures observed on `main` (run 25250051126): `tests/hermes_cli/test_update_yes_flag.py::TestUpdateYesConfigMigration::test_no_yes_flag_still_prompts_in_tty` `tests/hermes_cli/test_update_yes_flag.py::TestUpdateYesStashRestore::test_yes_restores_stash_without_prompting` Refs: #18261 (added the `--yes` flag + these tests).
183 lines
7.4 KiB
Python
183 lines
7.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)
|
|
|
|
# Patch ``sys.stdin.isatty`` and ``sys.stdout.isatty`` directly on the
|
|
# real ``sys`` module instead of replacing ``hermes_cli.main.sys`` with
|
|
# a MagicMock. The MagicMock approach was flaky under ``pytest-xdist``
|
|
# — a sibling test that imported ``hermes_cli.main`` first could leave
|
|
# a different ``sys`` reference resolved inside the function and the
|
|
# mock would never be consulted, with CI then taking the
|
|
# "Non-interactive session" branch instead of prompting.
|
|
import sys as _sys
|
|
|
|
with patch("builtins.input", return_value="n") as mock_input, patch.object(
|
|
_sys.stdin, "isatty", return_value=True
|
|
), patch.object(_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)
|
|
|
|
# Force a TTY-shaped session so the autostash-restore branch is
|
|
# reachable in CI workers regardless of inherited stdio (matches the
|
|
# isatty patching strategy in ``test_no_yes_flag_still_prompts_in_tty``
|
|
# — ``patch.object`` on the real streams is robust under xdist).
|
|
import sys as _sys
|
|
|
|
with patch.object(_sys.stdin, "isatty", return_value=True), patch.object(
|
|
_sys.stdout, "isatty", return_value=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}"
|
|
)
|