/ tests / hermes_cli / test_relaunch.py
test_relaunch.py
  1  """Tests for hermes_cli.relaunch — unified self-relaunch utility."""
  2  
  3  import sys
  4  
  5  import pytest
  6  
  7  from hermes_cli import relaunch as relaunch_mod
  8  
  9  
 10  class TestResolveHermesBin:
 11      def test_prefers_absolute_argv0_when_executable(self, monkeypatch):
 12          fake = "/nix/store/abc/bin/hermes"
 13          monkeypatch.setattr(sys, "argv", [fake])
 14          monkeypatch.setattr(relaunch_mod.os.path, "isfile", lambda p: p == fake)
 15          monkeypatch.setattr(relaunch_mod.os, "access", lambda p, mode: p == fake)
 16          assert relaunch_mod.resolve_hermes_bin() == fake
 17  
 18      def test_resolves_relative_argv0(self, monkeypatch, tmp_path):
 19          fake = tmp_path / "hermes"
 20          fake.write_text("#!/bin/sh\n")
 21          fake.chmod(0o755)
 22          monkeypatch.setattr(sys, "argv", [str(fake.name)])
 23          monkeypatch.chdir(tmp_path)
 24          # Ensure we don't accidentally match a real 'hermes' on PATH
 25          monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
 26          assert relaunch_mod.resolve_hermes_bin() == str(fake)
 27  
 28      def test_falls_back_to_path_which(self, monkeypatch):
 29          monkeypatch.setattr(sys, "argv", ["-c"])  # not a real path
 30          monkeypatch.setattr(
 31              relaunch_mod.shutil, "which", lambda name: "/usr/bin/hermes" if name == "hermes" else None
 32          )
 33          assert relaunch_mod.resolve_hermes_bin() == "/usr/bin/hermes"
 34  
 35      def test_returns_none_when_unresolvable(self, monkeypatch):
 36          monkeypatch.setattr(sys, "argv", ["-c"])
 37          monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
 38          assert relaunch_mod.resolve_hermes_bin() is None
 39  
 40  
 41  class TestExtractInheritedFlags:
 42      def test_extracts_tui_and_dev(self):
 43          argv = ["--tui", "--dev", "chat"]
 44          assert relaunch_mod._extract_inherited_flags(argv) == ["--tui", "--dev"]
 45  
 46      def test_extracts_profile_with_value(self):
 47          argv = ["--profile", "work", "chat"]
 48          assert relaunch_mod._extract_inherited_flags(argv) == ["--profile", "work"]
 49  
 50      def test_extracts_short_p_with_value(self):
 51          argv = ["-p", "work"]
 52          assert relaunch_mod._extract_inherited_flags(argv) == ["-p", "work"]
 53  
 54      def test_extracts_equals_form(self):
 55          argv = ["--profile=work", "--model=anthropic/claude-sonnet-4"]
 56          assert relaunch_mod._extract_inherited_flags(argv) == [
 57              "--profile=work",
 58              "--model=anthropic/claude-sonnet-4",
 59          ]
 60  
 61      def test_skips_unknown_flags(self):
 62          argv = ["--foo", "bar", "--tui"]
 63          assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"]
 64  
 65      def test_does_not_consume_flag_like_value(self):
 66          argv = ["--tui", "--resume", "abc123"]
 67          assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"]
 68  
 69      def test_preserves_multiple_skills(self):
 70          argv = ["-s", "foo", "-s", "bar", "--tui"]
 71          assert relaunch_mod._extract_inherited_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"]
 72  
 73  
 74  class TestInheritedFlagTable:
 75      """Sanity-check the argparse-introspected table that drives extraction."""
 76  
 77      def test_short_and_long_aliases_are_paired(self):
 78          table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
 79          # Each pair declared together in the parser shares takes_value.
 80          for short, long_ in [
 81              ("-p", "--profile"),
 82              ("-m", "--model"),
 83              ("-s", "--skills"),
 84          ]:
 85              assert table[short] == table[long_], f"{short}/{long_} disagree"
 86  
 87      def test_store_true_flags_do_not_take_value(self):
 88          table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
 89          for flag in ["--tui", "--dev", "--yolo", "--ignore-user-config", "--ignore-rules"]:
 90              assert table[flag] is False, f"{flag} should not take a value"
 91  
 92      def test_value_flags_take_value(self):
 93          table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
 94          for flag in ["--profile", "--model", "--provider", "--skills"]:
 95              assert table[flag] is True, f"{flag} should take a value"
 96  
 97      def test_excluded_flags_are_not_inherited(self):
 98          table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
 99          # --worktree creates a new worktree per process; inheriting would
100          # orphan the parent's. Chat-only flags (--quiet/-Q, --verbose/-v,
101          # --source) can't be in argv at the existing relaunch callsites.
102          for flag in ["-w", "--worktree", "-Q", "--quiet", "-v", "--verbose", "--source"]:
103              assert flag not in table, f"{flag} should not be inherited"
104  
105  
106  class TestBuildRelaunchArgv:
107      def test_uses_bin_when_available(self, monkeypatch):
108          monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
109          argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
110          assert argv[0] == "/usr/bin/hermes"
111  
112      def test_falls_back_to_python_module(self, monkeypatch):
113          monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
114          argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
115          assert argv == [sys.executable, "-m", "hermes_cli.main", "--resume", "abc"]
116  
117      def test_preserves_inherited_flags(self, monkeypatch):
118          monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
119          original = ["--tui", "--dev", "--profile", "work", "sessions", "browse"]
120          argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"], original_argv=original)
121          assert "--tui" in argv
122          assert "--dev" in argv
123          assert "--profile" in argv
124          assert "work" in argv
125          assert "--resume" in argv
126          assert "abc" in argv
127          # The original subcommand should not survive
128          assert "sessions" not in argv
129          assert "browse" not in argv
130  
131      def test_can_disable_preserve(self, monkeypatch):
132          monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
133          original = ["--tui", "chat"]
134          argv = relaunch_mod.build_relaunch_argv(
135              ["--resume", "abc"], preserve_inherited=False, original_argv=original
136          )
137          assert "--tui" not in argv
138          assert argv == ["/usr/bin/hermes", "--resume", "abc"]
139  
140  
141  class TestRelaunch:
142      def test_calls_execvp(self, monkeypatch):
143          calls = []
144  
145          def fake_execvp(path, argv):
146              calls.append((path, argv))
147              raise SystemExit(0)
148  
149          monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
150          monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
151  
152          with pytest.raises(SystemExit):
153              relaunch_mod.relaunch(["--resume", "abc"])
154  
155          assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])]