|
27 | 27 | RunResult, |
28 | 28 | ShellflowError, |
29 | 29 | SSHConfig, |
| 30 | + _build_executable_script, |
30 | 31 | _clean_commands, |
31 | 32 | _is_valid_env_name, |
32 | 33 | create_parser, |
@@ -688,6 +689,43 @@ def test_ssh_config_used_in_command( |
688 | 689 | assert "-i" in call_args |
689 | 690 | assert "/path/to/key" in call_args |
690 | 691 |
|
| 692 | + def test_custom_remote_shell_runs_as_login_shell( |
| 693 | + self, |
| 694 | + execution_context: ExecutionContext, |
| 695 | + ) -> None: |
| 696 | + """Test custom remote shells run in login mode so host PATH initialization is loaded.""" |
| 697 | + block = Block(target="REMOTE:myhost", commands=["mise --version"], shell="zsh") # noqa: S604 |
| 698 | + ssh_config = SSHConfig(host="myhost") |
| 699 | + |
| 700 | + mock_result = mock.Mock(returncode=0, stdout="2026.1.0\n", stderr="") |
| 701 | + |
| 702 | + with mock.patch("shellflow.subprocess.run", return_value=mock_result) as mock_run: |
| 703 | + result = execute_remote(block, execution_context, ssh_config) |
| 704 | + |
| 705 | + assert result.success is True |
| 706 | + call_args = mock_run.call_args[0][0] |
| 707 | + assert call_args[-4:] == ["zsh", "-l", "-s", "-e"] |
| 708 | + |
| 709 | + def test_remote_zsh_bootstraps_zshrc_before_commands( |
| 710 | + self, |
| 711 | + execution_context: ExecutionContext, |
| 712 | + ) -> None: |
| 713 | + """Test remote zsh execution sources ~/.zshrc so non-login PATH customizations are available.""" |
| 714 | + block = Block(target="REMOTE:myhost", commands=["mise --version"], shell="zsh") # noqa: S604 |
| 715 | + ssh_config = SSHConfig(host="myhost") |
| 716 | + |
| 717 | + mock_result = mock.Mock(returncode=0, stdout="2026.1.0\n", stderr="") |
| 718 | + |
| 719 | + with mock.patch("shellflow.subprocess.run", return_value=mock_result) as mock_run: |
| 720 | + result = execute_remote(block, execution_context, ssh_config) |
| 721 | + |
| 722 | + assert result.success is True |
| 723 | + sent_script = mock_run.call_args.kwargs["input"] |
| 724 | + assert "test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }" in sent_script |
| 725 | + assert sent_script.index( |
| 726 | + "test -f ~/.zshrc && { source ~/.zshrc >/dev/null 2>&1 || true; }" |
| 727 | + ) < sent_script.index("mise --version") |
| 728 | + |
691 | 729 | def test_remote_execution_only_exports_explicit_context( |
692 | 730 | self, |
693 | 731 | execution_context: ExecutionContext, |
@@ -792,6 +830,68 @@ def test_no_ssh_config_uses_manual_lookup( |
792 | 830 | assert "2222" in call_args |
793 | 831 |
|
794 | 832 |
|
| 833 | +class TestShellBootstrapIntegration: |
| 834 | + """Integration-style tests for non-interactive shell bootstrap behavior.""" |
| 835 | + |
| 836 | + def test_zsh_bootstrap_ignores_nonzero_zshrc_and_keeps_path_customizations(self, tmp_path: Path) -> None: |
| 837 | + """Test guarded zshrc bootstrap still exposes commands after a non-zero rc return.""" |
| 838 | + bin_dir = tmp_path / "bin" |
| 839 | + bin_dir.mkdir() |
| 840 | + fake_mise = bin_dir / "mise" |
| 841 | + fake_mise.write_text("#!/bin/sh\necho fake-mise\n") |
| 842 | + fake_mise.chmod(0o755) |
| 843 | + |
| 844 | + (tmp_path / ".zshrc").write_text(f'export PATH="{bin_dir}:$PATH"\nfalse\n') |
| 845 | + |
| 846 | + script = _build_executable_script( # noqa: S604 |
| 847 | + ["command -v mise"], |
| 848 | + ExecutionContext(), |
| 849 | + include_context_exports=False, |
| 850 | + shell="zsh", |
| 851 | + ) |
| 852 | + |
| 853 | + result = subprocess.run( |
| 854 | + ["/bin/zsh", "-l", "-s", "-e"], |
| 855 | + input=script, |
| 856 | + capture_output=True, |
| 857 | + text=True, |
| 858 | + env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}, |
| 859 | + check=False, |
| 860 | + ) |
| 861 | + |
| 862 | + assert result.returncode == 0 |
| 863 | + assert str(fake_mise) in result.stdout |
| 864 | + |
| 865 | + def test_bash_bootstrap_ignores_nonzero_bashrc_and_keeps_path_customizations(self, tmp_path: Path) -> None: |
| 866 | + """Test guarded bashrc bootstrap still exposes commands after a non-zero rc return.""" |
| 867 | + bin_dir = tmp_path / "bin" |
| 868 | + bin_dir.mkdir() |
| 869 | + fake_tool = bin_dir / "tool-from-bashrc" |
| 870 | + fake_tool.write_text("#!/bin/sh\necho fake-bash-tool\n") |
| 871 | + fake_tool.chmod(0o755) |
| 872 | + |
| 873 | + (tmp_path / ".bashrc").write_text(f'export PATH="{bin_dir}:$PATH"\nfalse\n') |
| 874 | + |
| 875 | + script = _build_executable_script( # noqa: S604 |
| 876 | + ["command -v tool-from-bashrc"], |
| 877 | + ExecutionContext(), |
| 878 | + include_context_exports=False, |
| 879 | + shell="bash", |
| 880 | + ) |
| 881 | + |
| 882 | + result = subprocess.run( |
| 883 | + ["/bin/bash", "-l", "-s", "-e"], |
| 884 | + input=script, |
| 885 | + capture_output=True, |
| 886 | + text=True, |
| 887 | + env={"HOME": str(tmp_path), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}, |
| 888 | + check=False, |
| 889 | + ) |
| 890 | + |
| 891 | + assert result.returncode == 0 |
| 892 | + assert str(fake_tool) in result.stdout |
| 893 | + |
| 894 | + |
795 | 895 | # ============================================================================= |
796 | 896 | # parse_script Advanced Tests |
797 | 897 | # ============================================================================= |
|
0 commit comments