/ tests / hermes_cli / test_cmd_update.py
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