Skip to content

Commit 8ca960d

Browse files
committed
test(control_mode): add ControlMode context manager for client-dependent tests
why: Commands like display-popup and detach-client require an attached tmux client. ControlMode spawns a real control-mode client via tmux -C, avoiding mocks while enabling tests for client-dependent commands. what: - Add ControlMode context manager in src/libtmux/test/control_mode.py using FIFO + subprocess.Popen for tmux -C attach-session - Add control_mode pytest fixture as a factory in pytest_plugin.py - Add ControlMode and control_mode to doctest_namespace in conftest.py - Add tests verifying client creation, cleanup, and client name - No time.sleep — uses retry_until for client registration
1 parent f1f984b commit 8ca960d

4 files changed

Lines changed: 208 additions & 0 deletions

File tree

conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010

1111
from __future__ import annotations
1212

13+
import functools
1314
import shutil
1415
import typing as t
1516

1617
import pytest
1718
from _pytest.doctest import DoctestItem
1819

20+
from libtmux._internal.control_mode import ControlMode
1921
from libtmux.pane import Pane
2022
from libtmux.pytest_plugin import USING_ZSH
2123
from libtmux.server import Server
@@ -47,6 +49,12 @@ def add_doctest_fixtures(
4749
doctest_namespace["window"] = session.active_window
4850
doctest_namespace["pane"] = session.active_pane
4951
doctest_namespace["request"] = request
52+
doctest_namespace["ControlMode"] = ControlMode
53+
doctest_namespace["control_mode"] = functools.partial(
54+
ControlMode,
55+
server=session.server,
56+
session=session,
57+
)
5058

5159

5260
@pytest.fixture(autouse=True)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Control-mode client context manager for tmux testing.
2+
3+
Provides a context manager that spawns a ``tmux -C attach-session``
4+
subprocess, creating a real tmux client that satisfies commands
5+
requiring an attached client (e.g. ``display-popup``, ``detach-client``).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import pathlib
12+
import subprocess
13+
import tempfile
14+
import typing as t
15+
16+
from libtmux.test.retry import retry_until
17+
18+
if t.TYPE_CHECKING:
19+
import types
20+
21+
from libtmux.server import Server
22+
from libtmux.session import Session
23+
24+
25+
class ControlMode:
26+
"""Context manager that spawns a tmux control-mode client.
27+
28+
Creates a real client attached to the session, visible in
29+
``Server.list_clients()``. The client communicates via the tmux
30+
control protocol on stdout.
31+
32+
While active, ``Server.list_clients()`` will include this client.
33+
34+
Parameters
35+
----------
36+
server : Server
37+
The tmux server instance.
38+
session : Session
39+
The session to attach to.
40+
41+
Examples
42+
--------
43+
>>> with ControlMode(server=server, session=session) as ctl:
44+
... clients = server.list_clients()
45+
... assert len(clients) > 0
46+
... assert ctl.client_name != ''
47+
"""
48+
49+
server: Server
50+
session: Session
51+
client_name: str
52+
stdout: t.IO[str]
53+
54+
_proc: subprocess.Popen[str]
55+
_fifo_path: str
56+
_write_fd: int
57+
58+
def __init__(self, server: Server, session: Session) -> None:
59+
self.server = server
60+
self.session = session
61+
62+
def __enter__(self) -> ControlMode:
63+
"""Spawn control-mode client and wait for registration."""
64+
self._fifo_path = tempfile.mktemp(prefix="libtmux_ctl_")
65+
os.mkfifo(self._fifo_path)
66+
67+
tmux_bin = self.server.tmux_bin or "tmux"
68+
cmd = [
69+
tmux_bin,
70+
"-L",
71+
str(self.server.socket_name),
72+
"-C",
73+
"attach-session",
74+
"-t",
75+
str(self.session.session_id),
76+
]
77+
78+
# Open read end for subprocess stdin
79+
read_fd = os.open(self._fifo_path, os.O_RDONLY | os.O_NONBLOCK)
80+
81+
self._proc = subprocess.Popen(
82+
cmd,
83+
stdin=read_fd,
84+
stdout=subprocess.PIPE,
85+
stderr=subprocess.PIPE,
86+
text=True,
87+
)
88+
os.close(read_fd)
89+
90+
# Open write end to keep FIFO alive
91+
self._write_fd = os.open(self._fifo_path, os.O_WRONLY)
92+
93+
self.stdout = self._proc.stdout # type: ignore[assignment]
94+
95+
# Wait for client to register
96+
def client_registered() -> bool:
97+
clients = self.server.list_clients()
98+
return len(clients) > 0
99+
100+
retry_until(client_registered, 3, raises=True)
101+
102+
# Capture client name
103+
result = self.server.cmd(
104+
"list-clients",
105+
"-F",
106+
"#{client_name}",
107+
)
108+
self.client_name = result.stdout[0].strip() if result.stdout else ""
109+
110+
return self
111+
112+
def __exit__(
113+
self,
114+
exc_type: type[BaseException] | None,
115+
exc_val: BaseException | None,
116+
exc_tb: types.TracebackType | None,
117+
) -> None:
118+
"""Terminate control-mode client and clean up FIFO."""
119+
# Close write end — causes the control-mode client to exit
120+
os.close(self._write_fd)
121+
122+
# Terminate and wait for subprocess
123+
self._proc.terminate()
124+
try:
125+
self._proc.wait(timeout=5)
126+
except subprocess.TimeoutExpired:
127+
self._proc.kill()
128+
self._proc.wait()
129+
130+
# Remove FIFO
131+
fifo = pathlib.Path(self._fifo_path)
132+
if fifo.exists():
133+
fifo.unlink()

src/libtmux/pytest_plugin.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import pytest
1414

1515
from libtmux import exc
16+
from libtmux._internal.control_mode import ControlMode
1617
from libtmux.server import Server
1718
from libtmux.test.constants import TEST_SESSION_PREFIX
1819
from libtmux.test.random import get_test_session_name, namer
@@ -265,6 +266,30 @@ def session(
265266
return session
266267

267268

269+
@pytest.fixture
270+
def control_mode(
271+
server: Server,
272+
session: Session,
273+
) -> t.Callable[[], ControlMode]:
274+
"""Return :class:`ControlMode` context manager factory.
275+
276+
Returns a callable that creates :class:`ControlMode` context managers
277+
bound to the test's server and session. Use as a context manager to
278+
spawn a control-mode tmux client.
279+
280+
While the control-mode client is active, ``Server.list_clients()``
281+
will include it.
282+
283+
Examples
284+
--------
285+
>>> from libtmux._internal.control_mode import ControlMode
286+
>>> def test_example(control_mode):
287+
... with control_mode() as ctl:
288+
... assert ctl.client_name != ''
289+
"""
290+
return functools.partial(ControlMode, server=server, session=session)
291+
292+
268293
@pytest.fixture
269294
def TestServer(
270295
request: pytest.FixtureRequest,

tests/test_control_mode.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Tests for ControlMode context manager."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
7+
from libtmux._internal.control_mode import ControlMode
8+
9+
if t.TYPE_CHECKING:
10+
from libtmux.server import Server
11+
12+
13+
def test_control_mode_creates_client(
14+
control_mode: t.Callable[[], ControlMode],
15+
server: Server,
16+
) -> None:
17+
"""ControlMode creates a client visible in list-clients."""
18+
with control_mode() as ctl:
19+
clients = server.list_clients()
20+
assert len(clients) > 0
21+
assert ctl.client_name != ""
22+
23+
24+
def test_control_mode_cleanup(
25+
control_mode: t.Callable[[], ControlMode],
26+
server: Server,
27+
) -> None:
28+
"""Client is removed after ControlMode context exits."""
29+
with control_mode():
30+
assert len(server.list_clients()) > 0
31+
32+
# After context exit, client should be gone
33+
clients = server.list_clients()
34+
assert len(clients) == 0
35+
36+
37+
def test_control_mode_client_name(
38+
control_mode: t.Callable[[], ControlMode],
39+
) -> None:
40+
"""ControlMode.client_name contains the tmux client identifier."""
41+
with control_mode() as ctl:
42+
assert "client-" in ctl.client_name

0 commit comments

Comments
 (0)