One-shot local execution built `printf FENCE; <cmd>; __hermes_rc=...`, so a command ending in a heredoc produced a closing line like `EOF; __hermes_rc=...`, which is not a valid delimiter. Bash then treated the rest of the wrapper as heredoc body, leaking it into tool output (e.g. gh issue/PR flows). Use newline-separated wrapper lines so the delimiter stays alone and the trailer runs after the heredoc completes. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""Tests for the local persistent shell backend."""
|
|
|
|
import glob as glob_mod
|
|
|
|
import pytest
|
|
|
|
from tools.environments.local import LocalEnvironment
|
|
from tools.environments.persistent_shell import PersistentShellMixin
|
|
|
|
|
|
class TestLocalConfig:
|
|
def test_local_persistent_default_false(self, monkeypatch):
|
|
monkeypatch.delenv("TERMINAL_LOCAL_PERSISTENT", raising=False)
|
|
from tools.terminal_tool import _get_env_config
|
|
assert _get_env_config()["local_persistent"] is False
|
|
|
|
def test_local_persistent_true(self, monkeypatch):
|
|
monkeypatch.setenv("TERMINAL_LOCAL_PERSISTENT", "true")
|
|
from tools.terminal_tool import _get_env_config
|
|
assert _get_env_config()["local_persistent"] is True
|
|
|
|
def test_local_persistent_yes(self, monkeypatch):
|
|
monkeypatch.setenv("TERMINAL_LOCAL_PERSISTENT", "yes")
|
|
from tools.terminal_tool import _get_env_config
|
|
assert _get_env_config()["local_persistent"] is True
|
|
|
|
|
|
class TestMergeOutput:
|
|
def test_stdout_only(self):
|
|
assert PersistentShellMixin._merge_output("out", "") == "out"
|
|
|
|
def test_stderr_only(self):
|
|
assert PersistentShellMixin._merge_output("", "err") == "err"
|
|
|
|
def test_both(self):
|
|
assert PersistentShellMixin._merge_output("out", "err") == "out\nerr"
|
|
|
|
def test_empty(self):
|
|
assert PersistentShellMixin._merge_output("", "") == ""
|
|
|
|
def test_strips_trailing_newlines(self):
|
|
assert PersistentShellMixin._merge_output("out\n\n", "err\n") == "out\nerr"
|
|
|
|
|
|
class TestLocalOneShotRegression:
|
|
def test_echo(self):
|
|
env = LocalEnvironment(persistent=False)
|
|
r = env.execute("echo hello")
|
|
assert r["returncode"] == 0
|
|
assert "hello" in r["output"]
|
|
env.cleanup()
|
|
|
|
def test_exit_code(self):
|
|
env = LocalEnvironment(persistent=False)
|
|
r = env.execute("exit 42")
|
|
assert r["returncode"] == 42
|
|
env.cleanup()
|
|
|
|
def test_state_does_not_persist(self):
|
|
env = LocalEnvironment(persistent=False)
|
|
env.execute("export HERMES_ONESHOT_LOCAL=yes")
|
|
r = env.execute("echo $HERMES_ONESHOT_LOCAL")
|
|
assert r["output"].strip() == ""
|
|
env.cleanup()
|
|
|
|
def test_oneshot_heredoc_does_not_leak_fence_wrapper(self):
|
|
"""Heredoc closing line must not be merged with the fence wrapper tail."""
|
|
env = LocalEnvironment(persistent=False)
|
|
cmd = "cat <<'H_EOF'\nheredoc body line\nH_EOF"
|
|
r = env.execute(cmd)
|
|
env.cleanup()
|
|
assert r["returncode"] == 0
|
|
assert "heredoc body line" in r["output"]
|
|
assert "__hermes_rc" not in r["output"]
|
|
assert "printf '" not in r["output"]
|
|
assert "exit $" not in r["output"]
|
|
|
|
|
|
class TestLocalPersistent:
|
|
@pytest.fixture
|
|
def env(self):
|
|
e = LocalEnvironment(persistent=True)
|
|
yield e
|
|
e.cleanup()
|
|
|
|
def test_echo(self, env):
|
|
r = env.execute("echo hello-persistent")
|
|
assert r["returncode"] == 0
|
|
assert "hello-persistent" in r["output"]
|
|
|
|
def test_env_var_persists(self, env):
|
|
env.execute("export HERMES_LOCAL_PERSIST_TEST=works")
|
|
r = env.execute("echo $HERMES_LOCAL_PERSIST_TEST")
|
|
assert r["output"].strip() == "works"
|
|
|
|
def test_cwd_persists(self, env):
|
|
env.execute("cd /tmp")
|
|
r = env.execute("pwd")
|
|
assert r["output"].strip() == "/tmp"
|
|
|
|
def test_exit_code(self, env):
|
|
r = env.execute("(exit 42)")
|
|
assert r["returncode"] == 42
|
|
|
|
def test_stderr(self, env):
|
|
r = env.execute("echo oops >&2")
|
|
assert r["returncode"] == 0
|
|
assert "oops" in r["output"]
|
|
|
|
def test_multiline_output(self, env):
|
|
r = env.execute("echo a; echo b; echo c")
|
|
lines = r["output"].strip().splitlines()
|
|
assert lines == ["a", "b", "c"]
|
|
|
|
def test_timeout_then_recovery(self, env):
|
|
r = env.execute("sleep 999", timeout=2)
|
|
assert r["returncode"] in (124, 130)
|
|
r = env.execute("echo alive")
|
|
assert r["returncode"] == 0
|
|
assert "alive" in r["output"]
|
|
|
|
def test_large_output(self, env):
|
|
r = env.execute("seq 1 1000")
|
|
assert r["returncode"] == 0
|
|
lines = r["output"].strip().splitlines()
|
|
assert len(lines) == 1000
|
|
assert lines[0] == "1"
|
|
assert lines[-1] == "1000"
|
|
|
|
def test_shell_variable_persists(self, env):
|
|
env.execute("MY_LOCAL_VAR=hello123")
|
|
r = env.execute("echo $MY_LOCAL_VAR")
|
|
assert r["output"].strip() == "hello123"
|
|
|
|
def test_cleanup_removes_temp_files(self, env):
|
|
env.execute("echo warmup")
|
|
prefix = env._temp_prefix
|
|
assert len(glob_mod.glob(f"{prefix}-*")) > 0
|
|
env.cleanup()
|
|
remaining = glob_mod.glob(f"{prefix}-*")
|
|
assert remaining == []
|
|
|
|
def test_state_does_not_leak_between_instances(self):
|
|
env1 = LocalEnvironment(persistent=True)
|
|
env2 = LocalEnvironment(persistent=True)
|
|
try:
|
|
env1.execute("export LEAK_TEST=from_env1")
|
|
r = env2.execute("echo $LEAK_TEST")
|
|
assert r["output"].strip() == ""
|
|
finally:
|
|
env1.cleanup()
|
|
env2.cleanup()
|
|
|
|
def test_special_characters_in_command(self, env):
|
|
r = env.execute("echo 'hello world'")
|
|
assert r["output"].strip() == "hello world"
|
|
|
|
def test_pipe_command(self, env):
|
|
r = env.execute("echo hello | tr 'h' 'H'")
|
|
assert r["output"].strip() == "Hello"
|
|
|
|
def test_multiple_commands_semicolon(self, env):
|
|
r = env.execute("X=42; echo $X")
|
|
assert r["output"].strip() == "42"
|