/ tests / tools / test_local_shell_init.py
test_local_shell_init.py
  1  """Tests for terminal.shell_init_files / terminal.auto_source_bashrc.
  2  
  3  A bash ``-l -c`` invocation does NOT source ``~/.bashrc``, so tools that
  4  register themselves there (nvm, asdf, pyenv) stay invisible to the
  5  environment snapshot built by ``LocalEnvironment.init_session``.  These
  6  tests verify the config-driven prelude that fixes that.
  7  """
  8  
  9  import os
 10  from unittest.mock import patch
 11  
 12  import pytest
 13  
 14  from tools.environments.local import (
 15      LocalEnvironment,
 16      _prepend_shell_init,
 17      _read_terminal_shell_init_config,
 18      _resolve_shell_init_files,
 19  )
 20  
 21  
 22  class TestResolveShellInitFiles:
 23      def test_auto_sources_bashrc_when_present(self, tmp_path, monkeypatch):
 24          bashrc = tmp_path / ".bashrc"
 25          bashrc.write_text('export MARKER=seen\n')
 26          monkeypatch.setenv("HOME", str(tmp_path))
 27  
 28          # Default config: auto_source_bashrc on, no explicit list.
 29          with patch(
 30              "tools.environments.local._read_terminal_shell_init_config",
 31              return_value=([], True),
 32          ):
 33              resolved = _resolve_shell_init_files()
 34  
 35          assert resolved == [str(bashrc)]
 36  
 37      def test_auto_sources_profile_when_present(self, tmp_path, monkeypatch):
 38          """~/.profile is where ``n`` / ``nvm`` installers typically write
 39          their PATH export on Debian/Ubuntu, and it has no interactivity
 40          guard so a non-interactive source actually runs it.
 41          """
 42          profile = tmp_path / ".profile"
 43          profile.write_text('export PATH="$HOME/n/bin:$PATH"\n')
 44          monkeypatch.setenv("HOME", str(tmp_path))
 45  
 46          with patch(
 47              "tools.environments.local._read_terminal_shell_init_config",
 48              return_value=([], True),
 49          ):
 50              resolved = _resolve_shell_init_files()
 51  
 52          assert resolved == [str(profile)]
 53  
 54      def test_auto_sources_bash_profile_when_present(self, tmp_path, monkeypatch):
 55          bash_profile = tmp_path / ".bash_profile"
 56          bash_profile.write_text('export MARKER=bp\n')
 57          monkeypatch.setenv("HOME", str(tmp_path))
 58  
 59          with patch(
 60              "tools.environments.local._read_terminal_shell_init_config",
 61              return_value=([], True),
 62          ):
 63              resolved = _resolve_shell_init_files()
 64  
 65          assert resolved == [str(bash_profile)]
 66  
 67      def test_auto_sources_profile_before_bashrc(self, tmp_path, monkeypatch):
 68          """Both files present: profile runs first so PATH exports in
 69          profile take effect even if bashrc short-circuits on the
 70          non-interactive ``case $- in *i*) ;; *) return;; esac`` guard.
 71          """
 72          profile = tmp_path / ".profile"
 73          profile.write_text('export FROM_PROFILE=1\n')
 74          bash_profile = tmp_path / ".bash_profile"
 75          bash_profile.write_text('export FROM_BASH_PROFILE=1\n')
 76          bashrc = tmp_path / ".bashrc"
 77          bashrc.write_text('export FROM_BASHRC=1\n')
 78          monkeypatch.setenv("HOME", str(tmp_path))
 79  
 80          with patch(
 81              "tools.environments.local._read_terminal_shell_init_config",
 82              return_value=([], True),
 83          ):
 84              resolved = _resolve_shell_init_files()
 85  
 86          assert resolved == [str(profile), str(bash_profile), str(bashrc)]
 87  
 88      def test_skips_bashrc_when_missing(self, tmp_path, monkeypatch):
 89          # No rc files written.
 90          monkeypatch.setenv("HOME", str(tmp_path))
 91  
 92          with patch(
 93              "tools.environments.local._read_terminal_shell_init_config",
 94              return_value=([], True),
 95          ):
 96              resolved = _resolve_shell_init_files()
 97  
 98          assert resolved == []
 99  
100      def test_auto_source_bashrc_off_suppresses_default(self, tmp_path, monkeypatch):
101          bashrc = tmp_path / ".bashrc"
102          bashrc.write_text('export MARKER=seen\n')
103          profile = tmp_path / ".profile"
104          profile.write_text('export MARKER=p\n')
105          monkeypatch.setenv("HOME", str(tmp_path))
106  
107          with patch(
108              "tools.environments.local._read_terminal_shell_init_config",
109              return_value=([], False),
110          ):
111              resolved = _resolve_shell_init_files()
112  
113          assert resolved == []
114  
115      def test_explicit_list_wins_over_auto(self, tmp_path, monkeypatch):
116          bashrc = tmp_path / ".bashrc"
117          bashrc.write_text('export FROM_BASHRC=1\n')
118          custom = tmp_path / "custom.sh"
119          custom.write_text('export FROM_CUSTOM=1\n')
120          monkeypatch.setenv("HOME", str(tmp_path))
121  
122          # auto_source_bashrc stays True but the explicit list takes precedence.
123          with patch(
124              "tools.environments.local._read_terminal_shell_init_config",
125              return_value=([str(custom)], True),
126          ):
127              resolved = _resolve_shell_init_files()
128  
129          assert resolved == [str(custom)]
130          assert str(bashrc) not in resolved
131  
132      def test_expands_home_and_env_vars(self, tmp_path, monkeypatch):
133          target = tmp_path / "rc" / "custom.sh"
134          target.parent.mkdir()
135          target.write_text('export A=1\n')
136          monkeypatch.setenv("HOME", str(tmp_path))
137          monkeypatch.setenv("CUSTOM_RC_DIR", str(tmp_path / "rc"))
138  
139          with patch(
140              "tools.environments.local._read_terminal_shell_init_config",
141              return_value=(["~/rc/custom.sh"], False),
142          ):
143              resolved_home = _resolve_shell_init_files()
144  
145          with patch(
146              "tools.environments.local._read_terminal_shell_init_config",
147              return_value=(["${CUSTOM_RC_DIR}/custom.sh"], False),
148          ):
149              resolved_var = _resolve_shell_init_files()
150  
151          assert resolved_home == [str(target)]
152          assert resolved_var == [str(target)]
153  
154      def test_missing_explicit_files_are_skipped_silently(self, tmp_path, monkeypatch):
155          monkeypatch.setenv("HOME", str(tmp_path))
156          with patch(
157              "tools.environments.local._read_terminal_shell_init_config",
158              return_value=([str(tmp_path / "does-not-exist.sh")], False),
159          ):
160              resolved = _resolve_shell_init_files()
161  
162          assert resolved == []
163  
164  
165  class TestPrependShellInit:
166      def test_empty_list_returns_command_unchanged(self):
167          assert _prepend_shell_init("echo hi", []) == "echo hi"
168  
169      def test_prepends_guarded_source_lines(self):
170          wrapped = _prepend_shell_init("echo hi", ["/tmp/a.sh", "/tmp/b.sh"])
171          assert "echo hi" in wrapped
172          # Each file is sourced through a guarded [ -r … ] && . '…' || true
173          # pattern so a missing/broken rc can't abort the bootstrap.
174          assert "/tmp/a.sh" in wrapped
175          assert "/tmp/b.sh" in wrapped
176          assert "|| true" in wrapped
177          assert "set +e" in wrapped
178  
179      def test_escapes_single_quotes(self):
180          wrapped = _prepend_shell_init("echo hi", ["/tmp/o'malley.sh"])
181          # The path must survive as the shell receives it; embedded single
182          # quote is escaped as '\'' rather than breaking the outer quoting.
183          assert "o'\\''malley" in wrapped
184  
185  
186  @pytest.mark.skipif(
187      os.environ.get("CI") == "true" and not os.path.isfile("/bin/bash"),
188      reason="Requires bash; CI sandbox may strip it.",
189  )
190  class TestSnapshotEndToEnd:
191      """Spin up a real LocalEnvironment and confirm the snapshot sources
192      extra init files."""
193  
194      def test_snapshot_picks_up_init_file_exports(self, tmp_path, monkeypatch):
195          init_file = tmp_path / "custom-init.sh"
196          init_file.write_text(
197              'export HERMES_SHELL_INIT_PROBE="probe-ok"\n'
198              'export PATH="/opt/shell-init-probe/bin:$PATH"\n'
199          )
200  
201          with patch(
202              "tools.environments.local._read_terminal_shell_init_config",
203              return_value=([str(init_file)], False),
204          ):
205              env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
206              try:
207                  result = env.execute(
208                      'echo "PROBE=$HERMES_SHELL_INIT_PROBE"; echo "PATH=$PATH"'
209                  )
210              finally:
211                  env.cleanup()
212  
213          output = result.get("output", "")
214          assert "PROBE=probe-ok" in output
215          assert "/opt/shell-init-probe/bin" in output
216  
217      def test_profile_path_export_survives_bashrc_interactive_guard(
218          self, tmp_path, monkeypatch
219      ):
220          """Reproduces the Debian/Ubuntu + ``n``/``nvm`` case.
221  
222          Setup:
223            - ``~/.bashrc`` starts with ``case $- in *i*) ;; *) return;; esac``
224              (the default on Debian/Ubuntu) and would happily export a PATH
225              entry below that guard — but never gets there because a
226              non-interactive source short-circuits.
227            - ``~/.profile`` exports ``$HOME/fake-n/bin`` onto PATH, no guard.
228  
229          Expectation: auto-sourced rc list picks up ``~/.profile`` before
230          ``~/.bashrc``, so the snapshot ends up with ``fake-n/bin`` on PATH
231          even though the bashrc export is silently skipped.
232          """
233          fake_n_bin = tmp_path / "fake-n" / "bin"
234          fake_n_bin.mkdir(parents=True)
235  
236          profile = tmp_path / ".profile"
237          profile.write_text(
238              f'export PATH="{fake_n_bin}:$PATH"\n'
239              'export FROM_PROFILE=profile-ok\n'
240          )
241          bashrc = tmp_path / ".bashrc"
242          bashrc.write_text(
243              'case $- in\n'
244              '    *i*) ;;\n'
245              '      *) return;;\n'
246              'esac\n'
247              'export FROM_BASHRC=bashrc-should-not-appear\n'
248          )
249  
250          monkeypatch.setenv("HOME", str(tmp_path))
251  
252          with patch(
253              "tools.environments.local._read_terminal_shell_init_config",
254              return_value=([], True),
255          ):
256              env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
257              try:
258                  result = env.execute(
259                      'echo "PATH=$PATH"; '
260                      'echo "FROM_PROFILE=$FROM_PROFILE"; '
261                      'echo "FROM_BASHRC=$FROM_BASHRC"'
262                  )
263              finally:
264                  env.cleanup()
265  
266          output = result.get("output", "")
267          assert "FROM_PROFILE=profile-ok" in output
268          assert str(fake_n_bin) in output
269          # bashrc short-circuited on the interactive guard — its export never ran
270          assert "FROM_BASHRC=bashrc-should-not-appear" not in output