test_setup_noninteractive.py
1 """Tests for non-interactive setup and first-run headless behavior.""" 2 3 from argparse import Namespace 4 from unittest.mock import MagicMock, patch 5 6 import pytest 7 from hermes_cli.config import DEFAULT_CONFIG, load_config, save_config 8 9 10 def _make_setup_args(**overrides): 11 return Namespace( 12 non_interactive=overrides.get("non_interactive", False), 13 section=overrides.get("section", None), 14 reset=overrides.get("reset", False), 15 ) 16 17 18 def _make_chat_args(**overrides): 19 return Namespace( 20 continue_last=overrides.get("continue_last", None), 21 resume=overrides.get("resume", None), 22 model=overrides.get("model", None), 23 provider=overrides.get("provider", None), 24 toolsets=overrides.get("toolsets", None), 25 verbose=overrides.get("verbose", False), 26 query=overrides.get("query", None), 27 worktree=overrides.get("worktree", False), 28 yolo=overrides.get("yolo", False), 29 pass_session_id=overrides.get("pass_session_id", False), 30 quiet=overrides.get("quiet", False), 31 checkpoints=overrides.get("checkpoints", False), 32 ) 33 34 35 class TestNonInteractiveSetup: 36 """Verify setup paths exit cleanly in headless/non-interactive environments.""" 37 38 def test_cmd_setup_allows_noninteractive_flag_without_tty(self): 39 """The CLI entrypoint should not block --non-interactive before setup.py handles it.""" 40 from hermes_cli.main import cmd_setup 41 42 args = _make_setup_args(non_interactive=True) 43 44 with ( 45 patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup, 46 patch("sys.stdin") as mock_stdin, 47 ): 48 mock_stdin.isatty.return_value = False 49 cmd_setup(args) 50 51 mock_run_setup.assert_called_once_with(args) 52 53 def test_cmd_setup_defers_no_tty_handling_to_setup_wizard(self): 54 """Bare `hermes setup` should reach setup.py, which prints headless guidance.""" 55 from hermes_cli.main import cmd_setup 56 57 args = _make_setup_args(non_interactive=False) 58 59 with ( 60 patch("hermes_cli.setup.run_setup_wizard") as mock_run_setup, 61 patch("sys.stdin") as mock_stdin, 62 ): 63 mock_stdin.isatty.return_value = False 64 cmd_setup(args) 65 66 mock_run_setup.assert_called_once_with(args) 67 68 def test_non_interactive_flag_skips_wizard(self, capsys): 69 """--non-interactive should print guidance and not enter the wizard.""" 70 from hermes_cli.setup import run_setup_wizard 71 72 args = _make_setup_args(non_interactive=True) 73 74 with ( 75 patch("hermes_cli.setup.ensure_hermes_home"), 76 patch("hermes_cli.setup.load_config", return_value={}), 77 patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), 78 patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), 79 patch("builtins.input", side_effect=AssertionError("input should not be called")), 80 ): 81 run_setup_wizard(args) 82 83 out = capsys.readouterr().out 84 assert "hermes config set model.provider custom" in out 85 86 def test_no_tty_skips_wizard(self, capsys): 87 """When stdin has no TTY, the setup wizard should print guidance and return.""" 88 from hermes_cli.setup import run_setup_wizard 89 90 args = _make_setup_args(non_interactive=False) 91 92 with ( 93 patch("hermes_cli.setup.ensure_hermes_home"), 94 patch("hermes_cli.setup.load_config", return_value={}), 95 patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), 96 patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), 97 patch("sys.stdin") as mock_stdin, 98 patch("builtins.input", side_effect=AssertionError("input should not be called")), 99 ): 100 mock_stdin.isatty.return_value = False 101 run_setup_wizard(args) 102 103 out = capsys.readouterr().out 104 assert "hermes config set model.provider custom" in out 105 106 def test_reset_flag_rewrites_config_before_noninteractive_exit(self, tmp_path, monkeypatch, capsys): 107 """--reset should rewrite config.yaml even when the wizard cannot run interactively.""" 108 from hermes_cli.setup import run_setup_wizard 109 110 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 111 cfg = load_config() 112 cfg["model"] = {"provider": "custom", "base_url": "http://localhost:8080/v1", "default": "llama3"} 113 cfg["agent"]["max_turns"] = 12 114 save_config(cfg) 115 116 args = _make_setup_args(non_interactive=True, reset=True) 117 118 run_setup_wizard(args) 119 120 reloaded = load_config() 121 assert reloaded["model"] == DEFAULT_CONFIG["model"] 122 assert reloaded["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"] 123 out = capsys.readouterr().out 124 assert "Configuration reset to defaults." in out 125 126 def test_chat_first_run_headless_skips_setup_prompt(self, capsys): 127 """Bare `hermes` should not prompt for input when no provider exists and stdin is headless.""" 128 from hermes_cli.main import cmd_chat 129 130 args = _make_chat_args() 131 132 with ( 133 patch("hermes_cli.main._has_any_provider_configured", return_value=False), 134 patch("hermes_cli.main.cmd_setup") as mock_setup, 135 patch("sys.stdin") as mock_stdin, 136 patch("builtins.input", side_effect=AssertionError("input should not be called")), 137 ): 138 mock_stdin.isatty.return_value = False 139 with pytest.raises(SystemExit) as exc: 140 cmd_chat(args) 141 142 assert exc.value.code == 1 143 mock_setup.assert_not_called() 144 out = capsys.readouterr().out 145 assert "hermes config set model.provider custom" in out 146 147 def test_main_accepts_tts_setup_section(self, monkeypatch): 148 """`hermes setup tts` should parse and dispatch like other setup sections.""" 149 from hermes_cli import main as main_mod 150 151 received = {} 152 153 def fake_cmd_setup(args): 154 received["section"] = args.section 155 156 monkeypatch.setattr(main_mod, "cmd_setup", fake_cmd_setup) 157 monkeypatch.setattr("sys.argv", ["hermes", "setup", "tts"]) 158 159 main_mod.main() 160 161 assert received["section"] == "tts"