Skip to content

Commit 715a09a

Browse files
committed
!squsah wip
1 parent a1e6299 commit 715a09a

File tree

2 files changed

+217
-77
lines changed

2 files changed

+217
-77
lines changed

src/libtmux/test/waiter.py

+81-76
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,34 @@
66

77
from __future__ import annotations
88

9+
import time
910
import typing as t
1011
from dataclasses import dataclass
11-
from typing import Callable, TypeVar
12+
from typing import (
13+
TypeVar,
14+
)
1215

13-
from libtmux.test.retry import retry_until
16+
from libtmux.exc import LibTmuxException
17+
from libtmux.test.retry import WaitTimeout, retry_until
1418

1519
if t.TYPE_CHECKING:
1620
from libtmux.pane import Pane
1721

1822
T = TypeVar("T")
1923

2024

25+
class WaiterError(LibTmuxException):
26+
"""Base exception for waiter errors."""
27+
28+
29+
class WaiterTimeoutError(WaiterError):
30+
"""Exception raised when waiting for content times out."""
31+
32+
33+
class WaiterContentError(WaiterError):
34+
"""Exception raised when there's an error getting or checking content."""
35+
36+
2137
@dataclass
2238
class WaitResult(t.Generic[T]):
2339
"""Result of a wait operation."""
@@ -43,112 +59,101 @@ def __init__(self, pane: Pane, timeout: float = 2.0) -> None:
4359
self.pane = pane
4460
self.timeout = timeout
4561

46-
def wait_for_content(
62+
def _check_content(
4763
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.
64+
predicate: t.Callable[[str], bool],
65+
result: WaitResult,
66+
) -> bool:
67+
"""Check pane content against predicate.
5468
5569
Parameters
5670
----------
5771
predicate : Callable[[str], bool]
5872
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
73+
result : WaitResult
74+
Result object to store content if predicate matches
6375
6476
Returns
6577
-------
66-
WaitResult[str]
67-
Result containing success status and pane content if successful
78+
bool
79+
True if predicate matches, False otherwise
80+
81+
Raises
82+
------
83+
WaiterContentError
84+
If there's an error capturing pane content
6885
"""
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
86+
try:
87+
content = "\n".join(self.pane.capture_pane())
88+
if predicate(content):
89+
result.value = content
90+
return True
91+
return False
92+
except Exception as e:
93+
error = WaiterContentError("Error capturing pane content")
94+
error.__cause__ = e
95+
raise error from e
8496

97+
def wait_for_content(
98+
self,
99+
predicate: t.Callable[[str], bool],
100+
timeout_seconds: float | None = None,
101+
interval_seconds: float | None = None,
102+
error_message: str | None = None,
103+
) -> WaitResult:
104+
"""Wait for content in the pane to match a predicate."""
105+
result = WaitResult(success=False, value=None, error=None)
85106
try:
86-
success = retry_until(check_content, timeout, raises=False)
107+
# Give the shell a moment to be ready
108+
time.sleep(0.1)
109+
success = retry_until(
110+
lambda: self._check_content(predicate, result),
111+
seconds=timeout_seconds or self.timeout,
112+
interval=interval_seconds,
113+
raises=True,
114+
)
115+
result.success = success
87116
if not success:
88-
result.error = Exception(
117+
result.error = WaiterTimeoutError(
89118
error_message or "Timed out waiting for content",
90119
)
91-
except Exception as e:
120+
except WaitTimeout as e:
121+
result.error = WaiterTimeoutError(error_message or str(e))
122+
result.success = False
123+
except WaiterContentError as e:
92124
result.error = e
93-
if error_message:
94-
result.error = Exception(error_message)
95-
125+
result.success = False
126+
except Exception as e:
127+
if isinstance(e, (WaiterTimeoutError, WaiterContentError)):
128+
result.error = e
129+
else:
130+
result.error = WaiterContentError("Error capturing pane content")
131+
result.error.__cause__ = e
132+
result.success = False
96133
return result
97134

98135
def wait_for_prompt(
99136
self,
100137
prompt: str,
101-
*,
102-
timeout: float | None = None,
138+
timeout_seconds: float | None = None,
103139
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-
"""
140+
) -> WaitResult:
141+
"""Wait for a specific prompt to appear in the pane."""
121142
return self.wait_for_content(
122143
lambda content: prompt in content and len(content.strip()) > 0,
123-
timeout=timeout,
144+
timeout_seconds=timeout_seconds,
124145
error_message=error_message or f"Prompt '{prompt}' not found in pane",
125146
)
126147

127148
def wait_for_text(
128149
self,
129150
text: str,
130-
*,
131-
timeout: float | None = None,
151+
timeout_seconds: float | None = None,
132152
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-
"""
153+
) -> WaitResult:
154+
"""Wait for specific text to appear in the pane."""
150155
return self.wait_for_content(
151156
lambda content: text in content,
152-
timeout=timeout,
157+
timeout_seconds=timeout_seconds,
153158
error_message=error_message or f"Text '{text}' not found in pane",
154159
)

tests/test/test_waiter.py

+136-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
import shutil
66
import typing as t
77

8-
from libtmux.test.waiter import PaneWaiter
8+
from libtmux.test.retry import WaitTimeout
9+
from libtmux.test.waiter import (
10+
PaneWaiter,
11+
WaiterContentError,
12+
WaiterTimeoutError,
13+
)
914

1015
if t.TYPE_CHECKING:
16+
from pytest import MonkeyPatch
17+
1118
from libtmux.session import Session
1219

1320

@@ -72,6 +79,8 @@ def test_wait_timeout(session: Session) -> None:
7279
assert not result.success
7380
assert result.value is None
7481
assert result.error is not None
82+
assert isinstance(result.error, WaiterTimeoutError)
83+
assert str(result.error) == "Text 'this text will never appear' not found in pane"
7584

7685

7786
def test_custom_error_message(session: Session) -> None:
@@ -96,6 +105,7 @@ def test_custom_error_message(session: Session) -> None:
96105
assert not result.success
97106
assert result.value is None
98107
assert result.error is not None
108+
assert isinstance(result.error, WaiterTimeoutError)
99109
assert str(result.error) == custom_message
100110

101111

@@ -120,3 +130,128 @@ def test_wait_for_content_predicate(session: Session) -> None:
120130
assert result.success
121131
assert result.value is not None
122132
assert "123" in result.value
133+
134+
135+
def test_wait_for_content_inner_exception(
136+
session: Session,
137+
monkeypatch: MonkeyPatch,
138+
) -> None:
139+
"""Test exception handling in wait_for_content's inner try-except."""
140+
env = shutil.which("env")
141+
assert env is not None, "Cannot find usable `env` in PATH."
142+
143+
session.new_window(
144+
attach=True,
145+
window_name="test_waiter",
146+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
147+
)
148+
pane = session.active_window.active_pane
149+
assert pane is not None
150+
151+
waiter = PaneWaiter(pane)
152+
153+
def mock_capture_pane(*args: t.Any, **kwargs: t.Any) -> list[str]:
154+
"""Mock capture_pane that raises an exception."""
155+
msg = "Test error"
156+
raise Exception(msg)
157+
158+
monkeypatch.setattr(pane, "capture_pane", mock_capture_pane)
159+
result = waiter.wait_for_text("some text")
160+
assert not result.success
161+
assert result.value is None
162+
assert result.error is not None
163+
assert isinstance(result.error, WaiterContentError)
164+
assert str(result.error) == "Error capturing pane content"
165+
assert isinstance(result.error.__cause__, Exception)
166+
assert str(result.error.__cause__) == "Test error"
167+
168+
169+
def test_wait_for_content_outer_exception(
170+
session: Session,
171+
monkeypatch: MonkeyPatch,
172+
) -> None:
173+
"""Test exception handling in wait_for_content's outer try-except."""
174+
env = shutil.which("env")
175+
assert env is not None, "Cannot find usable `env` in PATH."
176+
177+
session.new_window(
178+
attach=True,
179+
window_name="test_waiter",
180+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
181+
)
182+
pane = session.active_window.active_pane
183+
assert pane is not None
184+
185+
waiter = PaneWaiter(pane)
186+
187+
def mock_retry_until(*args: t.Any, **kwargs: t.Any) -> bool:
188+
"""Mock retry_until that raises an exception."""
189+
msg = "Custom error"
190+
raise WaitTimeout(msg)
191+
192+
monkeypatch.setattr("libtmux.test.waiter.retry_until", mock_retry_until)
193+
result = waiter.wait_for_text(
194+
"some text",
195+
error_message="Custom error",
196+
)
197+
assert not result.success
198+
assert result.value is None
199+
assert result.error is not None
200+
assert isinstance(result.error, WaiterTimeoutError)
201+
assert str(result.error) == "Custom error"
202+
203+
204+
def test_wait_for_content_outer_exception_no_custom_message(
205+
session: Session,
206+
monkeypatch: MonkeyPatch,
207+
) -> None:
208+
"""Test exception handling in wait_for_content's outer try-except without custom message."""
209+
env = shutil.which("env")
210+
assert env is not None, "Cannot find usable `env` in PATH."
211+
212+
session.new_window(
213+
attach=True,
214+
window_name="test_waiter",
215+
window_shell=f"{env} PROMPT_COMMAND='' PS1='READY>' sh",
216+
)
217+
pane = session.active_window.active_pane
218+
assert pane is not None
219+
220+
waiter = PaneWaiter(pane)
221+
222+
def mock_capture_pane(*args: t.Any, **kwargs: t.Any) -> list[str]:
223+
"""Mock capture_pane that raises an exception."""
224+
msg = "Test error"
225+
raise Exception(msg)
226+
227+
monkeypatch.setattr(pane, "capture_pane", mock_capture_pane)
228+
result = waiter.wait_for_text("some text") # No custom error message
229+
assert not result.success
230+
assert result.value is None
231+
assert result.error is not None
232+
assert isinstance(result.error, WaiterContentError)
233+
assert str(result.error) == "Error capturing pane content"
234+
assert isinstance(result.error.__cause__, Exception)
235+
assert str(result.error.__cause__) == "Test error"
236+
237+
238+
def test_wait_for_content_retry_exception(monkeypatch, session) -> None:
239+
"""Test that retry exceptions are handled correctly."""
240+
pane = session.new_window("test_waiter").active_pane
241+
242+
def mock_retry_until(
243+
predicate,
244+
timeout_seconds=None,
245+
interval_seconds=None,
246+
raises=None,
247+
) -> t.NoReturn:
248+
msg = "Text 'some text' not found in pane"
249+
raise WaitTimeout(msg)
250+
251+
monkeypatch.setattr("libtmux.test.waiter.retry_until", mock_retry_until)
252+
waiter = PaneWaiter(pane)
253+
result = waiter.wait_for_content(lambda content: "some text" in content)
254+
255+
assert not result.success
256+
assert result.value is None
257+
assert str(result.error) == "Text 'some text' not found in pane"

0 commit comments

Comments
 (0)