|
10 | 10 |
|
11 | 11 | from __future__ import annotations |
12 | 12 |
|
| 13 | +import contextlib |
| 14 | +import pathlib |
13 | 15 | import shutil |
| 16 | +import subprocess |
14 | 17 | import typing as t |
| 18 | +import uuid |
15 | 19 |
|
16 | 20 | import pytest |
17 | 21 | from _pytest.doctest import DoctestItem |
18 | 22 |
|
| 23 | +from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol |
19 | 24 | from libtmux.pane import Pane |
20 | 25 | from libtmux.pytest_plugin import USING_ZSH |
21 | 26 | from libtmux.server import Server |
@@ -73,3 +78,123 @@ def setup_session( |
73 | 78 | """Session-level test configuration for pytest.""" |
74 | 79 | if USING_ZSH: |
75 | 80 | request.getfixturevalue("zshrc") |
| 81 | + |
| 82 | + |
| 83 | +# --------------------------------------------------------------------------- |
| 84 | +# Control-mode sandbox helper |
| 85 | +# --------------------------------------------------------------------------- |
| 86 | + |
| 87 | + |
| 88 | +@pytest.fixture |
| 89 | +@contextlib.contextmanager |
| 90 | +def control_sandbox( |
| 91 | + monkeypatch: pytest.MonkeyPatch, |
| 92 | + tmp_path_factory: pytest.TempPathFactory, |
| 93 | +) -> t.Iterator[Server]: |
| 94 | + """Provide an isolated control-mode server for a test. |
| 95 | +
|
| 96 | + - Creates a unique tmux socket name per invocation |
| 97 | + - Isolates HOME and TMUX_TMPDIR under a per-test temp directory |
| 98 | + - Clears TMUX env var to avoid inheriting user sessions |
| 99 | + - Uses ControlModeEngine; on exit, kills the server best-effort |
| 100 | + """ |
| 101 | + socket_name = f"libtmux_test_{uuid.uuid4().hex[:8]}" |
| 102 | + base = tmp_path_factory.mktemp("ctrl_sandbox") |
| 103 | + home = base / "home" |
| 104 | + tmux_tmpdir = base / "tmux" |
| 105 | + home.mkdir() |
| 106 | + tmux_tmpdir.mkdir() |
| 107 | + |
| 108 | + monkeypatch.setenv("HOME", str(home)) |
| 109 | + monkeypatch.setenv("TMUX_TMPDIR", str(tmux_tmpdir)) |
| 110 | + monkeypatch.delenv("TMUX", raising=False) |
| 111 | + |
| 112 | + from libtmux._internal.engines.control_mode import ControlModeEngine |
| 113 | + |
| 114 | + server = Server(socket_name=socket_name, engine=ControlModeEngine()) |
| 115 | + |
| 116 | + try: |
| 117 | + yield server |
| 118 | + finally: |
| 119 | + with contextlib.suppress(Exception): |
| 120 | + server.kill() |
| 121 | + |
| 122 | + |
| 123 | +@pytest.fixture |
| 124 | +def control_client_logs( |
| 125 | + control_sandbox: t.ContextManager[Server], |
| 126 | + tmp_path_factory: pytest.TempPathFactory, |
| 127 | +) -> t.Iterator[tuple[subprocess.Popen[str], ControlProtocol]]: |
| 128 | + """Spawn a raw tmux -C client against the sandbox and log stdout/stderr.""" |
| 129 | + base = tmp_path_factory.mktemp("ctrl_logs") |
| 130 | + stdout_path = base / "control_stdout.log" |
| 131 | + stderr_path = base / "control_stderr.log" |
| 132 | + |
| 133 | + with control_sandbox as server: |
| 134 | + cmd = [ |
| 135 | + "tmux", |
| 136 | + "-L", |
| 137 | + server.socket_name or "", |
| 138 | + "-C", |
| 139 | + "attach-session", |
| 140 | + "-t", |
| 141 | + "ctrltest", |
| 142 | + ] |
| 143 | + # Ensure ctrltest exists |
| 144 | + server.cmd("new-session", "-d", "-s", "ctrltest") |
| 145 | + stdout_path.open("w+", buffering=1) |
| 146 | + stderr_f = stderr_path.open("w+", buffering=1) |
| 147 | + proc = subprocess.Popen( |
| 148 | + cmd, |
| 149 | + stdin=subprocess.PIPE, |
| 150 | + stdout=subprocess.PIPE, |
| 151 | + stderr=stderr_f, |
| 152 | + text=True, |
| 153 | + bufsize=1, |
| 154 | + ) |
| 155 | + proto = ControlProtocol() |
| 156 | + # tmux -C will emit a %begin/%end pair for this initial attach-session; |
| 157 | + # queue a matching context so the parser has a pending command. |
| 158 | + proto.register_command(CommandContext(argv=list(cmd))) |
| 159 | + try: |
| 160 | + yield proc, proto |
| 161 | + finally: |
| 162 | + with contextlib.suppress(Exception): |
| 163 | + if proc.stdin: |
| 164 | + proc.stdin.write("kill-session -t ctrltest\n") |
| 165 | + proc.stdin.flush() |
| 166 | + proc.terminate() |
| 167 | + proc.wait(timeout=2) |
| 168 | + |
| 169 | + |
| 170 | +def pytest_addoption(parser: pytest.Parser) -> None: |
| 171 | + """Add CLI options for selecting tmux engine.""" |
| 172 | + parser.addoption( |
| 173 | + "--engine", |
| 174 | + action="store", |
| 175 | + default="subprocess", |
| 176 | + choices=["subprocess", "control"], |
| 177 | + help="Select tmux engine for fixtures (default: subprocess).", |
| 178 | + ) |
| 179 | + |
| 180 | + |
| 181 | +def pytest_configure(config: pytest.Config) -> None: |
| 182 | + """Register custom markers.""" |
| 183 | + config.addinivalue_line( |
| 184 | + "markers", |
| 185 | + ( |
| 186 | + "engines(names): run the test once for each engine in 'names' " |
| 187 | + "(e.g. ['control', 'subprocess'])." |
| 188 | + ), |
| 189 | + ) |
| 190 | + |
| 191 | + |
| 192 | +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: |
| 193 | + """Parametrize engine_name when requested by tests.""" |
| 194 | + if "engine_name" in metafunc.fixturenames: |
| 195 | + marker = metafunc.definition.get_closest_marker("engines") |
| 196 | + if marker: |
| 197 | + params = list(marker.args[0]) |
| 198 | + else: |
| 199 | + params = [metafunc.config.getoption("--engine")] |
| 200 | + metafunc.parametrize("engine_name", params, indirect=True) |
0 commit comments