test_cmd_update.py
1 """Tests for cmd_update — branch fallback when remote branch doesn't exist.""" 2 3 import subprocess 4 from types import SimpleNamespace 5 from unittest.mock import patch 6 7 import pytest 8 9 from hermes_cli.main import cmd_update, PROJECT_ROOT 10 11 12 def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"): 13 """Build a side_effect function for subprocess.run that simulates git commands.""" 14 15 def side_effect(cmd, **kwargs): 16 joined = " ".join(str(c) for c in cmd) 17 18 # git rev-parse --abbrev-ref HEAD (get current branch) 19 if "rev-parse" in joined and "--abbrev-ref" in joined: 20 return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="") 21 22 # git rev-parse --verify origin/{branch} (check remote branch exists) 23 if "rev-parse" in joined and "--verify" in joined: 24 rc = 0 if verify_ok else 128 25 return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="") 26 27 # git rev-list HEAD..origin/{branch} --count 28 if "rev-list" in joined: 29 return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="") 30 31 # Fallback: return a successful CompletedProcess with empty stdout 32 return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") 33 34 return side_effect 35 36 37 @pytest.fixture 38 def mock_args(): 39 return SimpleNamespace() 40 41 42 class TestCmdUpdateBranchFallback: 43 """cmd_update falls back to main when current branch has no remote counterpart.""" 44 45 @patch("shutil.which", return_value=None) 46 @patch("subprocess.run") 47 def test_update_falls_back_to_main_when_branch_not_on_remote( 48 self, mock_run, _mock_which, mock_args, capsys 49 ): 50 mock_run.side_effect = _make_run_side_effect( 51 branch="fix/stoicneko", verify_ok=False, commit_count="3" 52 ) 53 54 cmd_update(mock_args) 55 56 commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] 57 58 # rev-list should use origin/main, not origin/fix/stoicneko 59 rev_list_cmds = [c for c in commands if "rev-list" in c] 60 assert len(rev_list_cmds) == 1 61 assert "origin/main" in rev_list_cmds[0] 62 assert "origin/fix/stoicneko" not in rev_list_cmds[0] 63 64 # pull should use main, not fix/stoicneko 65 pull_cmds = [c for c in commands if "pull" in c] 66 assert len(pull_cmds) == 1 67 assert "main" in pull_cmds[0] 68 69 @patch("shutil.which", return_value=None) 70 @patch("subprocess.run") 71 def test_update_uses_current_branch_when_on_remote( 72 self, mock_run, _mock_which, mock_args, capsys 73 ): 74 mock_run.side_effect = _make_run_side_effect( 75 branch="main", verify_ok=True, commit_count="2" 76 ) 77 78 cmd_update(mock_args) 79 80 commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] 81 82 rev_list_cmds = [c for c in commands if "rev-list" in c] 83 assert len(rev_list_cmds) == 1 84 assert "origin/main" in rev_list_cmds[0] 85 86 pull_cmds = [c for c in commands if "pull" in c] 87 assert len(pull_cmds) == 1 88 assert "main" in pull_cmds[0] 89 90 @patch("shutil.which", return_value=None) 91 @patch("subprocess.run") 92 def test_update_already_up_to_date( 93 self, mock_run, _mock_which, mock_args, capsys 94 ): 95 mock_run.side_effect = _make_run_side_effect( 96 branch="main", verify_ok=True, commit_count="0" 97 ) 98 99 cmd_update(mock_args) 100 101 captured = capsys.readouterr() 102 assert "Already up to date!" in captured.out 103 104 # Should NOT have called pull 105 commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list] 106 pull_cmds = [c for c in commands if "pull" in c] 107 assert len(pull_cmds) == 0 108 109 @patch("shutil.which") 110 @patch("subprocess.run") 111 def test_update_refreshes_repo_and_tui_node_dependencies( 112 self, mock_run, mock_which, mock_args 113 ): 114 mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get 115 mock_run.side_effect = _make_run_side_effect( 116 branch="main", verify_ok=True, commit_count="1" 117 ) 118 119 cmd_update(mock_args) 120 121 npm_calls = [ 122 (call.args[0], call.kwargs.get("cwd")) 123 for call in mock_run.call_args_list 124 if call.args and call.args[0][0] == "/usr/bin/npm" 125 ] 126 127 # cmd_update runs npm commands in three locations: 128 # 1. repo root — slash-command / TUI bridge deps 129 # 2. ui-tui/ — Ink TUI deps 130 # 3. web/ — install + "npm run build" for the web frontend 131 full_flags = [ 132 "/usr/bin/npm", 133 "ci", 134 "--silent", 135 "--no-fund", 136 "--no-audit", 137 "--progress=false", 138 ] 139 assert npm_calls == [ 140 (full_flags, PROJECT_ROOT), 141 (full_flags, PROJECT_ROOT / "ui-tui"), 142 (["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"), 143 (["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"), 144 ] 145 146 def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys): 147 """When stdin/stdout aren't TTYs, config migration prompt is skipped.""" 148 with patch("shutil.which", return_value=None), patch( 149 "subprocess.run" 150 ) as mock_run, patch("builtins.input") as mock_input, patch( 151 "hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"] 152 ), patch("hermes_cli.config.get_missing_config_fields", return_value=[]), patch( 153 "hermes_cli.config.check_config_version", return_value=(1, 2) 154 ), patch("hermes_cli.main.sys") as mock_sys: 155 mock_sys.stdin.isatty.return_value = False 156 mock_sys.stdout.isatty.return_value = False 157 mock_run.side_effect = _make_run_side_effect( 158 branch="main", verify_ok=True, commit_count="1" 159 ) 160 161 cmd_update(mock_args) 162 163 mock_input.assert_not_called() 164 captured = capsys.readouterr() 165 assert "Non-interactive session" in captured.out