Skip to content

Commit a1e6299

Browse files
committed
feat(test): Add PaneWaiter utility for waiting on pane content
why: Tests need a reliable way to wait for pane content, especially with different shells what: - Add PaneWaiter class with wait_for_content, wait_for_prompt, wait_for_text methods - Add WaitResult class to handle success/failure/error states - Add comprehensive test suite for waiter functionality
1 parent 65c15f3 commit a1e6299

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed

src/libtmux/test/waiter.py

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Test utilities for waiting on tmux pane content.
2+
3+
This module provides utilities for waiting on tmux pane content in tests.
4+
Inspired by Playwright's sync API for waiting on page content.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import typing as t
10+
from dataclasses import dataclass
11+
from typing import Callable, TypeVar
12+
13+
from libtmux.test.retry import retry_until
14+
15+
if t.TYPE_CHECKING:
16+
from libtmux.pane import Pane
17+
18+
T = TypeVar("T")
19+
20+
21+
@dataclass
22+
class WaitResult(t.Generic[T]):
23+
"""Result of a wait operation."""
24+
25+
success: bool
26+
value: T | None = None
27+
error: Exception | None = None
28+
29+
30+
class PaneWaiter:
31+
"""Utility class for waiting on tmux pane content."""
32+
33+
def __init__(self, pane: Pane, timeout: float = 2.0) -> None:
34+
"""Initialize PaneWaiter.
35+
36+
Parameters
37+
----------
38+
pane : Pane
39+
The tmux pane to wait on
40+
timeout : float, optional
41+
Default timeout in seconds, by default 2.0
42+
"""
43+
self.pane = pane
44+
self.timeout = timeout
45+
46+
def wait_for_content(
47+
self,
48+
predicate: Callable[[str], bool],
49+
*,
50+
timeout: float | None = None,
51+
error_message: str | None = None,
52+
) -> WaitResult[str]:
53+
"""Wait for pane content to match predicate.
54+
55+
Parameters
56+
----------
57+
predicate : Callable[[str], bool]
58+
Function that takes pane content as string and returns bool
59+
timeout : float | None, optional
60+
Timeout in seconds, by default None (uses instance timeout)
61+
error_message : str | None, optional
62+
Custom error message if timeout occurs, by default None
63+
64+
Returns
65+
-------
66+
WaitResult[str]
67+
Result containing success status and pane content if successful
68+
"""
69+
timeout = timeout or self.timeout
70+
result = WaitResult[str](success=False)
71+
72+
def check_content() -> bool:
73+
try:
74+
content = "\n".join(self.pane.capture_pane())
75+
if predicate(content):
76+
result.success = True
77+
result.value = content
78+
return True
79+
else:
80+
return False
81+
except Exception as e:
82+
result.error = e
83+
return False
84+
85+
try:
86+
success = retry_until(check_content, timeout, raises=False)
87+
if not success:
88+
result.error = Exception(
89+
error_message or "Timed out waiting for content",
90+
)
91+
except Exception as e:
92+
result.error = e
93+
if error_message:
94+
result.error = Exception(error_message)
95+
96+
return result
97+
98+
def wait_for_prompt(
99+
self,
100+
prompt: str,
101+
*,
102+
timeout: float | None = None,
103+
error_message: str | None = None,
104+
) -> WaitResult[str]:
105+
"""Wait for specific prompt to appear in pane.
106+
107+
Parameters
108+
----------
109+
prompt : str
110+
The prompt text to wait for
111+
timeout : float | None, optional
112+
Timeout in seconds, by default None (uses instance timeout)
113+
error_message : str | None, optional
114+
Custom error message if timeout occurs, by default None
115+
116+
Returns
117+
-------
118+
WaitResult[str]
119+
Result containing success status and pane content if successful
120+
"""
121+
return self.wait_for_content(
122+
lambda content: prompt in content and len(content.strip()) > 0,
123+
timeout=timeout,
124+
error_message=error_message or f"Prompt '{prompt}' not found in pane",
125+
)
126+
127+
def wait_for_text(
128+
self,
129+
text: str,
130+
*,
131+
timeout: float | None = None,
132+
error_message: str | None = None,
133+
) -> WaitResult[str]:
134+
"""Wait for specific text to appear in pane.
135+
136+
Parameters
137+
----------
138+
text : str
139+
The text to wait for
140+
timeout : float | None, optional
141+
Timeout in seconds, by default None (uses instance timeout)
142+
error_message : str | None, optional
143+
Custom error message if timeout occurs, by default None
144+
145+
Returns
146+
-------
147+
WaitResult[str]
148+
Result containing success status and pane content if successful
149+
"""
150+
return self.wait_for_content(
151+
lambda content: text in content,
152+
timeout=timeout,
153+
error_message=error_message or f"Text '{text}' not found in pane",
154+
)

tests/test/test_waiter.py

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Tests for libtmux test waiter utilities."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
import typing as t
7+
8+
from libtmux.test.waiter import PaneWaiter
9+
10+
if t.TYPE_CHECKING:
11+
from libtmux.session import Session
12+
13+
14+
def test_wait_for_prompt(session: Session) -> None:
15+
"""Test waiting for prompt."""
16+
env = shutil.which("env")
17+
assert env is not None, "Cannot find usable `env` in PATH."
18+
19+
session.new_window(
20+
attach=True,
21+
window_name="test_waiter",
22+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
23+
)
24+
pane = session.active_window.active_pane
25+
assert pane is not None
26+
27+
waiter = PaneWaiter(pane)
28+
result = waiter.wait_for_prompt("READY>")
29+
assert result.success
30+
assert result.value is not None
31+
assert "READY>" in result.value
32+
33+
34+
def test_wait_for_text(session: Session) -> None:
35+
"""Test waiting for text."""
36+
env = shutil.which("env")
37+
assert env is not None, "Cannot find usable `env` in PATH."
38+
39+
session.new_window(
40+
attach=True,
41+
window_name="test_waiter",
42+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
43+
)
44+
pane = session.active_window.active_pane
45+
assert pane is not None
46+
47+
waiter = PaneWaiter(pane)
48+
waiter.wait_for_prompt("READY>") # Wait for shell to be ready
49+
50+
pane.send_keys("echo 'Hello World'", literal=True)
51+
result = waiter.wait_for_text("Hello World")
52+
assert result.success
53+
assert result.value is not None
54+
assert "Hello World" in result.value
55+
56+
57+
def test_wait_timeout(session: Session) -> None:
58+
"""Test timeout behavior."""
59+
env = shutil.which("env")
60+
assert env is not None, "Cannot find usable `env` in PATH."
61+
62+
session.new_window(
63+
attach=True,
64+
window_name="test_waiter",
65+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
66+
)
67+
pane = session.active_window.active_pane
68+
assert pane is not None
69+
70+
waiter = PaneWaiter(pane, timeout=0.1) # Short timeout
71+
result = waiter.wait_for_text("this text will never appear")
72+
assert not result.success
73+
assert result.value is None
74+
assert result.error is not None
75+
76+
77+
def test_custom_error_message(session: Session) -> None:
78+
"""Test custom error message."""
79+
env = shutil.which("env")
80+
assert env is not None, "Cannot find usable `env` in PATH."
81+
82+
session.new_window(
83+
attach=True,
84+
window_name="test_waiter",
85+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
86+
)
87+
pane = session.active_window.active_pane
88+
assert pane is not None
89+
90+
waiter = PaneWaiter(pane, timeout=0.1) # Short timeout
91+
custom_message = "Custom error message"
92+
result = waiter.wait_for_text(
93+
"this text will never appear",
94+
error_message=custom_message,
95+
)
96+
assert not result.success
97+
assert result.value is None
98+
assert result.error is not None
99+
assert str(result.error) == custom_message
100+
101+
102+
def test_wait_for_content_predicate(session: Session) -> None:
103+
"""Test waiting with custom predicate."""
104+
env = shutil.which("env")
105+
assert env is not None, "Cannot find usable `env` in PATH."
106+
107+
session.new_window(
108+
attach=True,
109+
window_name="test_waiter",
110+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
111+
)
112+
pane = session.active_window.active_pane
113+
assert pane is not None
114+
115+
waiter = PaneWaiter(pane)
116+
waiter.wait_for_prompt("READY>") # Wait for shell to be ready
117+
118+
pane.send_keys("echo '123'", literal=True)
119+
result = waiter.wait_for_content(lambda content: "123" in content)
120+
assert result.success
121+
assert result.value is not None
122+
assert "123" in result.value

0 commit comments

Comments
 (0)