Skip to content

Commit 395b1e1

Browse files
committed
test(builder,cli[load]): Add robust tests for --here respawn-pane and error recovery
New builder tests (NamedTuple + test_id pattern): - HereRespawnFixture: parametrized over 4 scenarios (dir-only, env-only, dir-and-env, nothing-to-provision) verifying PID changes on respawn, directory provisioning, and session environment - test_here_mode_respawn_multiple_env_vars: 3 env vars via set_environment - test_here_mode_respawn_warns_on_running_processes: background sleep job triggers pgrep WARNING before respawn-pane -k - test_here_mode_no_warning_when_pane_idle: idle pane produces no warning New load CLI tests (NamedTuple + test_id pattern): - HereErrorRecoveryFixture: parametrized over 2 scenarios verifying --here mode skips (k)ill option (choices=[a,d], default=d) while normal mode retains it (choices=[k,a,d], default=k)
1 parent 0504d1b commit 395b1e1

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

tests/cli/test_load.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,3 +1504,102 @@ def test_load_append_and_detached_mutually_exclusive() -> None:
15041504
parser = create_parser()
15051505
with pytest.raises(SystemExit):
15061506
parser.parse_args(["load", "--append", "-d", "myconfig"])
1507+
1508+
1509+
# --- --here error recovery tests (535ca944) ---
1510+
1511+
1512+
class HereErrorRecoveryFixture(t.NamedTuple):
1513+
"""Fixture for --here error recovery prompt behavior."""
1514+
1515+
test_id: str
1516+
here: bool
1517+
expected_choices: list[str]
1518+
expected_default: str
1519+
kill_option_present: bool
1520+
1521+
1522+
HERE_ERROR_RECOVERY_FIXTURES: list[HereErrorRecoveryFixture] = [
1523+
HereErrorRecoveryFixture(
1524+
test_id="here-mode-no-kill",
1525+
here=True,
1526+
expected_choices=["a", "d"],
1527+
expected_default="d",
1528+
kill_option_present=False,
1529+
),
1530+
HereErrorRecoveryFixture(
1531+
test_id="normal-mode-has-kill",
1532+
here=False,
1533+
expected_choices=["k", "a", "d"],
1534+
expected_default="k",
1535+
kill_option_present=True,
1536+
),
1537+
]
1538+
1539+
1540+
@pytest.mark.parametrize(
1541+
list(HereErrorRecoveryFixture._fields),
1542+
HERE_ERROR_RECOVERY_FIXTURES,
1543+
ids=[f.test_id for f in HERE_ERROR_RECOVERY_FIXTURES],
1544+
)
1545+
def test_here_error_recovery_prompt(
1546+
monkeypatch: pytest.MonkeyPatch,
1547+
test_id: str,
1548+
here: bool,
1549+
expected_choices: list[str],
1550+
expected_default: str,
1551+
kill_option_present: bool,
1552+
) -> None:
1553+
"""--here error recovery skips (k)ill to protect user's live session."""
1554+
from unittest.mock import MagicMock
1555+
1556+
from tmuxp._internal.colors import ColorMode, Colors
1557+
from tmuxp.cli.load import _dispatch_build
1558+
1559+
captured_kwargs: dict[str, t.Any] = {}
1560+
1561+
def _capture_prompt_choices(*args: t.Any, **kwargs: t.Any) -> str:
1562+
captured_kwargs.update(kwargs)
1563+
captured_kwargs["choices"] = kwargs.get("choices", [])
1564+
return "d" # Always detach to exit cleanly
1565+
1566+
monkeypatch.setattr(
1567+
"tmuxp.cli.load.prompt_choices",
1568+
_capture_prompt_choices,
1569+
)
1570+
1571+
# Create a mock builder that raises TmuxpException when built
1572+
from tmuxp import exc
1573+
1574+
mock_builder = MagicMock()
1575+
mock_builder.session = None
1576+
1577+
# Simulate the here path raising an error
1578+
if here:
1579+
monkeypatch.setattr(
1580+
"tmuxp.cli.load._load_here_in_current_session",
1581+
MagicMock(side_effect=exc.TmuxpException("test error")),
1582+
)
1583+
monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0")
1584+
else:
1585+
monkeypatch.setattr(
1586+
"tmuxp.cli.load._load_attached",
1587+
MagicMock(side_effect=exc.TmuxpException("test error")),
1588+
)
1589+
monkeypatch.delenv("TMUX", raising=False)
1590+
1591+
cli_colors = Colors(ColorMode.NEVER)
1592+
1593+
with pytest.raises(SystemExit):
1594+
_dispatch_build(
1595+
builder=mock_builder,
1596+
detached=False,
1597+
append=False,
1598+
answer_yes=not here, # answer_yes triggers _load_attached path
1599+
cli_colors=cli_colors,
1600+
here=here,
1601+
)
1602+
1603+
assert captured_kwargs["choices"] == expected_choices
1604+
assert captured_kwargs.get("default") == expected_default
1605+
assert ("k" in captured_kwargs["choices"]) == kill_option_present

tests/workspace/test_builder.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,236 @@ def test_here_mode_provisions_environment(
639639
)
640640

641641

642+
# --- respawn-pane provisioning tests (f5f490a8, 0504d1b4) ---
643+
644+
645+
class HereRespawnFixture(t.NamedTuple):
646+
"""Fixture for --here respawn-pane provisioning scenarios."""
647+
648+
test_id: str
649+
start_directory: bool
650+
environment: dict[str, str] | None
651+
window_shell: str | None
652+
expect_respawn: bool
653+
654+
655+
HERE_RESPAWN_FIXTURES: list[HereRespawnFixture] = [
656+
HereRespawnFixture(
657+
test_id="dir-only",
658+
start_directory=True,
659+
environment=None,
660+
window_shell=None,
661+
expect_respawn=True,
662+
),
663+
HereRespawnFixture(
664+
test_id="env-only",
665+
start_directory=False,
666+
environment={"TMUXP_TEST_VAR": "respawn_val"},
667+
window_shell=None,
668+
expect_respawn=True,
669+
),
670+
HereRespawnFixture(
671+
test_id="dir-and-env",
672+
start_directory=True,
673+
environment={"TMUXP_DIR_ENV": "combined"},
674+
window_shell=None,
675+
expect_respawn=True,
676+
),
677+
HereRespawnFixture(
678+
test_id="nothing-to-provision",
679+
start_directory=False,
680+
environment=None,
681+
window_shell=None,
682+
expect_respawn=False,
683+
),
684+
]
685+
686+
687+
@pytest.mark.parametrize(
688+
list(HereRespawnFixture._fields),
689+
HERE_RESPAWN_FIXTURES,
690+
ids=[f.test_id for f in HERE_RESPAWN_FIXTURES],
691+
)
692+
def test_here_mode_respawn_provisioning(
693+
session: Session,
694+
tmp_path: pathlib.Path,
695+
test_id: str,
696+
start_directory: bool,
697+
environment: dict[str, str] | None,
698+
window_shell: str | None,
699+
expect_respawn: bool,
700+
) -> None:
701+
"""--here mode uses respawn-pane for provisioning, not send_keys."""
702+
test_dir = tmp_path / "here_respawn"
703+
test_dir.mkdir()
704+
705+
workspace: dict[str, t.Any] = {
706+
"session_name": session.name,
707+
"windows": [
708+
{
709+
"window_name": "respawn-test",
710+
"panes": [{"shell_command": []}],
711+
},
712+
],
713+
}
714+
if start_directory:
715+
workspace["windows"][0]["start_directory"] = str(test_dir)
716+
if environment:
717+
workspace["windows"][0]["environment"] = environment
718+
719+
workspace = loader.expand(workspace)
720+
workspace = loader.trickle(workspace)
721+
722+
original_pane = session.active_window.active_pane
723+
assert original_pane is not None
724+
original_pid = original_pane.pane_pid
725+
726+
builder = WorkspaceBuilder(session_config=workspace, server=session.server)
727+
builder.build(session=session, here=True)
728+
729+
pane = session.active_window.active_pane
730+
assert pane is not None
731+
732+
if expect_respawn:
733+
# respawn-pane -k replaces the shell process, so PID changes
734+
assert pane.pane_pid != original_pid, (
735+
f"Expected new PID after respawn, got same: {pane.pane_pid}"
736+
)
737+
else:
738+
# No provisioning needed — pane process should be unchanged
739+
assert pane.pane_pid == original_pid
740+
741+
if start_directory:
742+
expected_path = os.path.realpath(str(test_dir))
743+
assert retry_until(
744+
lambda: pane.pane_current_path == expected_path,
745+
seconds=5,
746+
), f"Expected {expected_path}, got {pane.pane_current_path}"
747+
748+
if environment:
749+
env = session.show_environment()
750+
for key, val in environment.items():
751+
assert env.get(key) == val
752+
753+
754+
def test_here_mode_respawn_multiple_env_vars(
755+
session: Session,
756+
) -> None:
757+
"""--here mode sets multiple environment variables via set_environment."""
758+
workspace: dict[str, t.Any] = {
759+
"session_name": session.name,
760+
"windows": [
761+
{
762+
"window_name": "multi-env",
763+
"environment": {
764+
"TMUXP_A": "alpha",
765+
"TMUXP_B": "bravo",
766+
"TMUXP_C": "charlie",
767+
},
768+
"panes": [{"shell_command": []}],
769+
},
770+
],
771+
}
772+
workspace = loader.expand(workspace)
773+
774+
builder = WorkspaceBuilder(session_config=workspace, server=session.server)
775+
builder.build(session=session, here=True)
776+
777+
env = session.show_environment()
778+
assert env.get("TMUXP_A") == "alpha"
779+
assert env.get("TMUXP_B") == "bravo"
780+
assert env.get("TMUXP_C") == "charlie"
781+
782+
783+
def test_here_mode_respawn_warns_on_running_processes(
784+
session: Session,
785+
caplog: pytest.LogCaptureFixture,
786+
tmp_path: pathlib.Path,
787+
) -> None:
788+
"""--here mode warns when respawn-pane will kill child processes."""
789+
# Start a background process in the active pane so pgrep finds children
790+
pane = session.active_window.active_pane
791+
assert pane is not None
792+
pane.send_keys("sleep 300 &", enter=True)
793+
794+
# Give the shell time to fork the background job
795+
assert (
796+
retry_until(
797+
lambda: (
798+
"sleep" in (pane.pane_current_command or "")
799+
or retry_until(
800+
lambda: len(pane.capture_pane()) > 1,
801+
seconds=2,
802+
)
803+
),
804+
seconds=3,
805+
)
806+
or True
807+
) # Best-effort; pgrep check below is the real assertion
808+
809+
test_dir = tmp_path / "warn_test"
810+
test_dir.mkdir()
811+
812+
workspace: dict[str, t.Any] = {
813+
"session_name": session.name,
814+
"windows": [
815+
{
816+
"window_name": "warn-test",
817+
"start_directory": str(test_dir),
818+
"panes": [{"shell_command": []}],
819+
},
820+
],
821+
}
822+
workspace = loader.expand(workspace)
823+
workspace = loader.trickle(workspace)
824+
825+
with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"):
826+
builder = WorkspaceBuilder(session_config=workspace, server=session.server)
827+
builder.build(session=session, here=True)
828+
829+
warning_records = [
830+
r
831+
for r in caplog.records
832+
if r.levelno == logging.WARNING and "kill running processes" in r.message
833+
]
834+
# pgrep should find the sleep background job and emit a warning
835+
assert len(warning_records) >= 1
836+
837+
838+
def test_here_mode_no_warning_when_pane_idle(
839+
session: Session,
840+
caplog: pytest.LogCaptureFixture,
841+
tmp_path: pathlib.Path,
842+
) -> None:
843+
"""--here mode does not warn when pane has no child processes."""
844+
test_dir = tmp_path / "idle_test"
845+
test_dir.mkdir()
846+
847+
workspace: dict[str, t.Any] = {
848+
"session_name": session.name,
849+
"windows": [
850+
{
851+
"window_name": "idle-test",
852+
"start_directory": str(test_dir),
853+
"panes": [{"shell_command": []}],
854+
},
855+
],
856+
}
857+
workspace = loader.expand(workspace)
858+
workspace = loader.trickle(workspace)
859+
860+
with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"):
861+
builder = WorkspaceBuilder(session_config=workspace, server=session.server)
862+
builder.build(session=session, here=True)
863+
864+
warning_records = [
865+
r
866+
for r in caplog.records
867+
if r.levelno == logging.WARNING and "kill running processes" in r.message
868+
]
869+
assert len(warning_records) == 0
870+
871+
642872
def test_window_shell(
643873
session: Session,
644874
) -> None:

0 commit comments

Comments
 (0)