diff --git a/pyproject.toml b/pyproject.toml index cd3f4a1..2f30955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,11 @@ dev = [ "ty>=0.0.47", ] +[tool.pytest.ini_options] +markers = [ + "tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server", +] + [tool.hqt.quality] required_after_code_or_test_changes = [ "uv run ruff format src tests", diff --git a/tests/test_tmux_smoke.py b/tests/test_tmux_smoke.py new file mode 100644 index 0000000..cd34237 --- /dev/null +++ b/tests/test_tmux_smoke.py @@ -0,0 +1,115 @@ +"""Real-tmux smoke tests. + +These drive the actual TmuxRunner against a throwaway tmux server whose socket +lives in a temp TMUX_TMPDIR, so they never touch the user's live tmux sessions. +They skip when no tmux binary is available. +""" + +import asyncio +import os +import shutil +import subprocess + +import pytest + +from hqt.tmux.runner import TmuxRunner + +pytestmark = [ + pytest.mark.tmux, + pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"), +] + +SESSION = "hqt-test" + + +def _tmux(env, *args): + """Run a raw tmux command against the isolated server, return the result.""" + return subprocess.run( + ["tmux", *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.fixture +def tmux_env(tmp_path, monkeypatch): + # Isolate tmux completely from the user's live server: + # - TMUX_TMPDIR points the default socket at a throwaway temp dir. + # - TMUX / TMUX_PANE are unset. When TMUX is set (i.e. the test runner is + # itself inside a tmux session), tmux talks to THAT server and ignores + # TMUX_TMPDIR, so new-session/kill-server would hit the user's live + # server and evict them. delenv removes them from os.environ so both the + # raw `_tmux` calls AND the TmuxRunner subprocesses (which inherit + # os.environ) stay on the isolated server. + monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path)) + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + env = {**os.environ, "TMUX_TMPDIR": str(tmp_path)} + env.pop("TMUX", None) + env.pop("TMUX_PANE", None) + _tmux(env, "new-session", "-d", "-s", SESSION, "-x", "200", "-y", "50") + # Tripwire: a freshly isolated server has exactly one session. If isolation + # silently failed we'd see the user's sessions here — fail loudly BEFORE any + # test does something destructive. (kill-server in teardown still targets + # only the isolated socket, because env has TMUX removed + TMUX_TMPDIR set.) + listed = _tmux(env, "list-sessions", "-F", "#{session_name}").stdout.split() + assert listed == [SESSION], f"tmux isolation failed; saw sessions: {listed}" + try: + yield env + finally: + _tmux(env, "kill-server") + + +@pytest.fixture +def runner(tmux_env): + return TmuxRunner(tmux_path="tmux", session_name=SESSION) + + +async def _wait_pane_dead(runner, window, timeout=2.0): + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await runner.is_pane_dead(window): + return True + await asyncio.sleep(0.05) + return await runner.is_pane_dead(window) + + +@pytest.mark.asyncio +async def test_new_window_appears_in_list(runner, tmp_path): + window_id = await runner.new_window("hqt-1", str(tmp_path), "sleep 60") + assert window_id is not None + assert "hqt-1" in await runner.list_windows() + + +@pytest.mark.asyncio +async def test_set_window_label_round_trips(runner, tmp_path, tmux_env): + await runner.new_window("hqt-1", str(tmp_path), "sleep 60") + await runner.set_window_label("hqt-1", "•hqt-1") + shown = _tmux( + tmux_env, + "show-options", + "-w", + "-t", + f"{SESSION}:=hqt-1", + "@hqt_label", + ).stdout + assert "•hqt-1" in shown + + +@pytest.mark.asyncio +async def test_respawn_revives_dead_pane(runner, tmp_path): + # Window whose command exits immediately -> pane dies (remain-on-exit keeps it). + await runner.new_window("hqt-1", str(tmp_path), "true") + assert await _wait_pane_dead(runner, "hqt-1") is True + assert await runner.respawn_pane("hqt-1", "sleep 60", str(tmp_path)) is True + alive, _ = await runner.verify_window_alive("hqt-1", timeout=1.0) + assert alive is True + + +@pytest.mark.asyncio +async def test_apply_theme_sets_session_options(runner, tmux_env): + await runner.apply_theme() + shown = _tmux(tmux_env, "show-options", "-t", SESSION, "status-justify").stdout + assert "left" in shown