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