Skip to content

Commit 1cdad53

Browse files
committed
ControlMode(test): Add control-mode regression coverage and helpers
why: exercise control-mode engine behaviour, env propagation, logging, and sandbox usage. what: - add control sandbox fixture and wait_for_line helper - integrate control engine into pytest markers/fixtures and adjust legacy env tests - add protocol, regression, logging, and sandbox test suites plus tweaks to existing pane/server/session/window tests
1 parent 7c4e635 commit 1cdad53

13 files changed

+1265
-11
lines changed

conftest.py

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

1111
from __future__ import annotations
1212

13+
import contextlib
14+
import pathlib
1315
import shutil
16+
import subprocess
1417
import typing as t
18+
import uuid
1519

1620
import pytest
1721
from _pytest.doctest import DoctestItem
1822

23+
from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol
1924
from libtmux.pane import Pane
2025
from libtmux.pytest_plugin import USING_ZSH
2126
from libtmux.server import Server
@@ -73,3 +78,123 @@ def setup_session(
7378
"""Session-level test configuration for pytest."""
7479
if USING_ZSH:
7580
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)

tests/helpers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Test helpers for control-mode flakiness handling."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
import typing as t
7+
8+
from libtmux.pane import Pane
9+
10+
11+
def wait_for_line(
12+
pane: Pane,
13+
predicate: t.Callable[[str], bool],
14+
*,
15+
timeout: float = 1.0,
16+
interval: float = 0.05,
17+
) -> list[str]:
18+
"""Poll capture_pane until a line satisfies ``predicate``.
19+
20+
Returns the final capture buffer (may be empty if timeout elapses).
21+
"""
22+
deadline = time.monotonic() + timeout
23+
last: list[str] = []
24+
while time.monotonic() < deadline:
25+
captured = pane.capture_pane()
26+
last = [captured] if isinstance(captured, str) else list(captured)
27+
if any(predicate(line) for line in last):
28+
break
29+
time.sleep(interval)
30+
return last

tests/legacy_api/test_window.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import logging
66
import shutil
7-
import time
87
import typing as t
98

109
import pytest
@@ -382,8 +381,6 @@ def test_split_window_with_environment(
382381
environment=environment,
383382
)
384383
assert pane is not None
385-
# wait a bit for the prompt to be ready as the test gets flaky otherwise
386-
time.sleep(0.05)
387384
for k, v in environment.items():
388385
pane.send_keys(f"echo ${k}")
389386
assert pane.capture_pane()[-2] == v

tests/test/test_retry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def call_me_three_times() -> bool:
2929

3030
end = time()
3131

32-
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
32+
assert 0.9 <= (end - ini) <= 1.3 # Allow for small timing variations
3333

3434

3535
def test_function_times_out() -> None:
@@ -47,7 +47,7 @@ def never_true() -> bool:
4747

4848
end = time()
4949

50-
assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations
50+
assert 0.9 <= (end - ini) <= 1.3 # Allow for small timing variations
5151

5252

5353
def test_function_times_out_no_raise() -> None:

tests/test_control_client_logs.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Diagnostic tests using raw control client logs."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
7+
import pytest
8+
9+
from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol
10+
from libtmux.common import has_lt_version
11+
12+
13+
@pytest.mark.engines(["control"])
14+
def test_control_client_lists_clients(
15+
control_client_logs: tuple[t.Any, ControlProtocol],
16+
) -> None:
17+
"""Raw control client should report itself with control-mode flag."""
18+
proc, proto = control_client_logs
19+
20+
assert proc.stdin is not None
21+
list_ctx = CommandContext(
22+
argv=[
23+
"tmux",
24+
"list-clients",
25+
"-F",
26+
"#{client_pid} #{client_flags} #{session_name}",
27+
],
28+
)
29+
proto.register_command(list_ctx)
30+
detach_ctx = CommandContext(argv=["tmux", "detach-client"])
31+
proto.register_command(detach_ctx)
32+
proc.stdin.write('list-clients -F"#{client_pid} #{client_flags} #{session_name}"\n')
33+
proc.stdin.write("detach-client\n")
34+
proc.stdin.flush()
35+
36+
stdout_data, _ = proc.communicate(timeout=5)
37+
for line in stdout_data.splitlines():
38+
proto.feed_line(line.rstrip("\n"))
39+
40+
assert list_ctx.done.wait(timeout=0.5)
41+
result = proto.build_result(list_ctx)
42+
if has_lt_version("3.2"):
43+
pytest.xfail("tmux < 3.2 omits client_flags field in list-clients")
44+
45+
saw_control_flag = any(
46+
len(parts := line.split()) >= 2
47+
and ("C" in parts[1] or "control-mode" in parts[1])
48+
for line in result.stdout
49+
)
50+
assert saw_control_flag
51+
52+
53+
@pytest.mark.engines(["control"])
54+
def test_control_client_capture_stream_parses(
55+
control_client_logs: tuple[t.Any, ControlProtocol],
56+
) -> None:
57+
"""Ensure ControlProtocol can parse raw stream from the logged control client."""
58+
proc, proto = control_client_logs
59+
assert proc.stdin is not None
60+
61+
display_ctx = CommandContext(argv=["tmux", "display-message", "-p", "hello"])
62+
proto.register_command(display_ctx)
63+
detach_ctx = CommandContext(argv=["tmux", "detach-client"])
64+
proto.register_command(detach_ctx)
65+
proc.stdin.write("display-message -p hello\n")
66+
proc.stdin.write("detach-client\n")
67+
proc.stdin.flush()
68+
69+
stdout_data, _ = proc.communicate(timeout=5)
70+
71+
for line in stdout_data.splitlines():
72+
proto.feed_line(line.rstrip("\n"))
73+
74+
assert display_ctx.done.wait(timeout=0.5)
75+
result = proto.build_result(display_ctx)
76+
assert "hello" in result.stdout or "hello" in "".join(result.stdout)

0 commit comments

Comments
 (0)