"""Tests for terminal.shell_init_files / terminal.auto_source_bashrc. A bash ``-l -c`` invocation does NOT source ``~/.bashrc``, so tools that register themselves there (nvm, asdf, pyenv) stay invisible to the environment snapshot built by ``LocalEnvironment.init_session``. These tests verify the config-driven prelude that fixes that. """ import os from unittest.mock import patch import pytest from tools.environments.local import ( LocalEnvironment, _prepend_shell_init, _read_terminal_shell_init_config, _resolve_shell_init_files, ) class TestResolveShellInitFiles: def test_auto_sources_bashrc_when_present(self, tmp_path, monkeypatch): bashrc = tmp_path / ".bashrc" bashrc.write_text('export MARKER=seen\n') monkeypatch.setenv("HOME", str(tmp_path)) # Default config: auto_source_bashrc on, no explicit list. with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([], True), ): resolved = _resolve_shell_init_files() assert resolved == [str(bashrc)] def test_skips_bashrc_when_missing(self, tmp_path, monkeypatch): # No bashrc written. monkeypatch.setenv("HOME", str(tmp_path)) with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([], True), ): resolved = _resolve_shell_init_files() assert resolved == [] def test_auto_source_bashrc_off_suppresses_default(self, tmp_path, monkeypatch): bashrc = tmp_path / ".bashrc" bashrc.write_text('export MARKER=seen\n') monkeypatch.setenv("HOME", str(tmp_path)) with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([], False), ): resolved = _resolve_shell_init_files() assert resolved == [] def test_explicit_list_wins_over_auto(self, tmp_path, monkeypatch): bashrc = tmp_path / ".bashrc" bashrc.write_text('export FROM_BASHRC=1\n') custom = tmp_path / "custom.sh" custom.write_text('export FROM_CUSTOM=1\n') monkeypatch.setenv("HOME", str(tmp_path)) # auto_source_bashrc stays True but the explicit list takes precedence. with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([str(custom)], True), ): resolved = _resolve_shell_init_files() assert resolved == [str(custom)] assert str(bashrc) not in resolved def test_expands_home_and_env_vars(self, tmp_path, monkeypatch): target = tmp_path / "rc" / "custom.sh" target.parent.mkdir() target.write_text('export A=1\n') monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("CUSTOM_RC_DIR", str(tmp_path / "rc")) with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=(["~/rc/custom.sh"], False), ): resolved_home = _resolve_shell_init_files() with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=(["${CUSTOM_RC_DIR}/custom.sh"], False), ): resolved_var = _resolve_shell_init_files() assert resolved_home == [str(target)] assert resolved_var == [str(target)] def test_missing_explicit_files_are_skipped_silently(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([str(tmp_path / "does-not-exist.sh")], False), ): resolved = _resolve_shell_init_files() assert resolved == [] class TestPrependShellInit: def test_empty_list_returns_command_unchanged(self): assert _prepend_shell_init("echo hi", []) == "echo hi" def test_prepends_guarded_source_lines(self): wrapped = _prepend_shell_init("echo hi", ["/tmp/a.sh", "/tmp/b.sh"]) assert "echo hi" in wrapped # Each file is sourced through a guarded [ -r … ] && . '…' || true # pattern so a missing/broken rc can't abort the bootstrap. assert "/tmp/a.sh" in wrapped assert "/tmp/b.sh" in wrapped assert "|| true" in wrapped assert "set +e" in wrapped def test_escapes_single_quotes(self): wrapped = _prepend_shell_init("echo hi", ["/tmp/o'malley.sh"]) # The path must survive as the shell receives it; embedded single # quote is escaped as '\'' rather than breaking the outer quoting. assert "o'\\''malley" in wrapped @pytest.mark.skipif( os.environ.get("CI") == "true" and not os.path.isfile("/bin/bash"), reason="Requires bash; CI sandbox may strip it.", ) class TestSnapshotEndToEnd: """Spin up a real LocalEnvironment and confirm the snapshot sources extra init files.""" def test_snapshot_picks_up_init_file_exports(self, tmp_path, monkeypatch): init_file = tmp_path / "custom-init.sh" init_file.write_text( 'export HERMES_SHELL_INIT_PROBE="probe-ok"\n' 'export PATH="/opt/shell-init-probe/bin:$PATH"\n' ) with patch( "tools.environments.local._read_terminal_shell_init_config", return_value=([str(init_file)], False), ): env = LocalEnvironment(cwd=str(tmp_path), timeout=15) try: result = env.execute( 'echo "PROBE=$HERMES_SHELL_INIT_PROBE"; echo "PATH=$PATH"' ) finally: env.cleanup() output = result.get("output", "") assert "PROBE=probe-ok" in output assert "/opt/shell-init-probe/bin" in output