Skip to content

Commit e6ad71a

Browse files
committed
tests(pane): Add capture_frame() integration tests
why: Verify capture_frame() works with real tmux panes and integrates properly with syrupy snapshot testing. what: - Add 12 comprehensive tests using NamedTuple parametrization - Test basic usage, custom dimensions, overflow behavior - Demonstrate retry_until integration pattern
1 parent 127b4f8 commit e6ad71a

File tree

1 file changed

+369
-0
lines changed

1 file changed

+369
-0
lines changed

tests/test_pane_capture_frame.py

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
"""Tests for Pane.capture_frame() method."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
import typing as t
7+
8+
import pytest
9+
from syrupy.assertion import SnapshotAssertion
10+
11+
from libtmux.test.retry import retry_until
12+
from libtmux.textframe import TextFrame, TextFrameExtension
13+
14+
if t.TYPE_CHECKING:
15+
from libtmux.session import Session
16+
17+
18+
@pytest.fixture
19+
def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
20+
"""Override default snapshot fixture to use TextFrameExtension.
21+
22+
Parameters
23+
----------
24+
snapshot : SnapshotAssertion
25+
The default syrupy snapshot fixture.
26+
27+
Returns
28+
-------
29+
SnapshotAssertion
30+
Snapshot configured with TextFrame serialization.
31+
"""
32+
return snapshot.use_extension(TextFrameExtension)
33+
34+
35+
class CaptureFrameCase(t.NamedTuple):
36+
"""Test case for capture_frame() parametrized tests."""
37+
38+
test_id: str
39+
content_to_send: str
40+
content_width: int | None # None = use pane width
41+
content_height: int | None # None = use pane height
42+
overflow_behavior: t.Literal["error", "truncate"]
43+
expected_in_frame: list[str] # Substrings expected in rendered frame
44+
description: str
45+
46+
47+
CAPTURE_FRAME_CASES: list[CaptureFrameCase] = [
48+
CaptureFrameCase(
49+
test_id="basic_echo",
50+
content_to_send='echo "hello"',
51+
content_width=40,
52+
content_height=10,
53+
overflow_behavior="truncate",
54+
expected_in_frame=["hello"],
55+
description="Basic echo command output",
56+
),
57+
CaptureFrameCase(
58+
test_id="multiline_output",
59+
content_to_send='printf "line1\\nline2\\nline3\\n"',
60+
content_width=40,
61+
content_height=10,
62+
overflow_behavior="truncate",
63+
expected_in_frame=["line1", "line2", "line3"],
64+
description="Multi-line printf output",
65+
),
66+
CaptureFrameCase(
67+
test_id="custom_small_dimensions",
68+
content_to_send='echo "test"',
69+
content_width=20,
70+
content_height=5,
71+
overflow_behavior="truncate",
72+
expected_in_frame=["test"],
73+
description="Custom small frame dimensions",
74+
),
75+
CaptureFrameCase(
76+
test_id="truncate_long_line",
77+
content_to_send='echo "' + "x" * 50 + '"',
78+
content_width=15,
79+
content_height=5,
80+
overflow_behavior="truncate",
81+
expected_in_frame=["xxxxxxxxxxxxxxx"], # Truncated to 15 chars
82+
description="Long output truncated to frame width",
83+
),
84+
CaptureFrameCase(
85+
test_id="empty_pane",
86+
content_to_send="",
87+
content_width=20,
88+
content_height=5,
89+
overflow_behavior="truncate",
90+
expected_in_frame=["$"], # Just shell prompt
91+
description="Empty pane with just prompt",
92+
),
93+
]
94+
95+
96+
@pytest.mark.parametrize(
97+
list(CaptureFrameCase._fields),
98+
CAPTURE_FRAME_CASES,
99+
ids=[case.test_id for case in CAPTURE_FRAME_CASES],
100+
)
101+
def test_capture_frame_parametrized(
102+
test_id: str,
103+
content_to_send: str,
104+
content_width: int | None,
105+
content_height: int | None,
106+
overflow_behavior: t.Literal["error", "truncate"],
107+
expected_in_frame: list[str],
108+
description: str,
109+
session: Session,
110+
) -> None:
111+
"""Verify capture_frame() with various content and dimensions.
112+
113+
Parameters
114+
----------
115+
test_id : str
116+
Unique identifier for the test case.
117+
content_to_send : str
118+
Command to send to the pane.
119+
content_width : int | None
120+
Frame width (None = use pane width).
121+
content_height : int | None
122+
Frame height (None = use pane height).
123+
overflow_behavior : OverflowBehavior
124+
How to handle overflow.
125+
expected_in_frame : list[str]
126+
Substrings expected in the rendered frame.
127+
description : str
128+
Human-readable test description.
129+
session : Session
130+
pytest fixture providing tmux session.
131+
"""
132+
env = shutil.which("env")
133+
assert env is not None, "Cannot find usable `env` in PATH."
134+
135+
window = session.new_window(
136+
attach=True,
137+
window_name=f"capture_frame_{test_id}",
138+
window_shell=f"{env} PS1='$ ' sh",
139+
)
140+
pane = window.active_pane
141+
assert pane is not None
142+
143+
# Send content if provided
144+
if content_to_send:
145+
pane.send_keys(content_to_send, literal=True, suppress_history=False)
146+
147+
# Wait for command output to appear
148+
def output_appeared() -> bool:
149+
lines = pane.capture_pane()
150+
content = "\n".join(lines)
151+
# Check that at least one expected substring is present
152+
return any(exp in content for exp in expected_in_frame)
153+
154+
retry_until(output_appeared, 2, raises=True)
155+
156+
# Capture frame with specified dimensions
157+
frame = pane.capture_frame(
158+
content_width=content_width,
159+
content_height=content_height,
160+
overflow_behavior=overflow_behavior,
161+
)
162+
163+
# Verify frame type
164+
assert isinstance(frame, TextFrame)
165+
166+
# Verify dimensions
167+
if content_width is not None:
168+
assert frame.content_width == content_width
169+
if content_height is not None:
170+
assert frame.content_height == content_height
171+
172+
# Verify expected content in rendered frame
173+
rendered = frame.render()
174+
for expected in expected_in_frame:
175+
assert expected in rendered, f"Expected '{expected}' not found in frame"
176+
177+
178+
def test_capture_frame_returns_textframe(session: Session) -> None:
179+
"""Verify capture_frame() returns a TextFrame instance."""
180+
pane = session.active_window.active_pane
181+
assert pane is not None
182+
183+
frame = pane.capture_frame(content_width=20, content_height=5)
184+
185+
assert isinstance(frame, TextFrame)
186+
assert frame.content_width == 20
187+
assert frame.content_height == 5
188+
189+
190+
def test_capture_frame_default_dimensions(session: Session) -> None:
191+
"""Verify capture_frame() uses pane dimensions by default."""
192+
pane = session.active_window.active_pane
193+
assert pane is not None
194+
pane.refresh()
195+
196+
# Get actual pane dimensions
197+
expected_width = int(pane.pane_width or 80)
198+
expected_height = int(pane.pane_height or 24)
199+
200+
# Capture without specifying dimensions
201+
frame = pane.capture_frame()
202+
203+
assert frame.content_width == expected_width
204+
assert frame.content_height == expected_height
205+
206+
207+
def test_capture_frame_with_start_end(session: Session) -> None:
208+
"""Verify capture_frame() works with start/end parameters."""
209+
env = shutil.which("env")
210+
assert env is not None, "Cannot find usable `env` in PATH."
211+
212+
window = session.new_window(
213+
attach=True,
214+
window_name="capture_frame_start_end",
215+
window_shell=f"{env} PS1='$ ' sh",
216+
)
217+
pane = window.active_pane
218+
assert pane is not None
219+
220+
# Send multiple lines
221+
pane.send_keys('echo "line1"', enter=True)
222+
pane.send_keys('echo "line2"', enter=True)
223+
pane.send_keys('echo "line3"', enter=True)
224+
225+
# Wait for all output
226+
def all_lines_present() -> bool:
227+
content = "\n".join(pane.capture_pane())
228+
return "line3" in content
229+
230+
retry_until(all_lines_present, 2, raises=True)
231+
232+
# Capture with start parameter (visible pane only)
233+
frame = pane.capture_frame(start=0, content_width=40, content_height=10)
234+
rendered = frame.render()
235+
236+
# Should capture visible content
237+
assert isinstance(frame, TextFrame)
238+
assert "line" in rendered # At least some output
239+
240+
241+
def test_capture_frame_overflow_truncate(session: Session) -> None:
242+
"""Verify capture_frame() truncates content when overflow_behavior='truncate'."""
243+
env = shutil.which("env")
244+
assert env is not None, "Cannot find usable `env` in PATH."
245+
246+
window = session.new_window(
247+
attach=True,
248+
window_name="capture_frame_truncate",
249+
window_shell=f"{env} PS1='$ ' sh",
250+
)
251+
pane = window.active_pane
252+
assert pane is not None
253+
254+
# Send long line
255+
long_text = "x" * 100
256+
pane.send_keys(f'echo "{long_text}"', literal=True, suppress_history=False)
257+
258+
def output_appeared() -> bool:
259+
return "xxxx" in "\n".join(pane.capture_pane())
260+
261+
retry_until(output_appeared, 2, raises=True)
262+
263+
# Capture with small width, truncate mode
264+
frame = pane.capture_frame(
265+
content_width=10,
266+
content_height=5,
267+
overflow_behavior="truncate",
268+
)
269+
270+
# Should not raise, content should be truncated
271+
assert isinstance(frame, TextFrame)
272+
rendered = frame.render()
273+
274+
# Frame should have the specified width (10 chars + borders)
275+
lines = rendered.splitlines()
276+
# Border line should be +----------+ (10 dashes)
277+
assert lines[0] == "+----------+"
278+
279+
280+
def test_capture_frame_snapshot(session: Session, snapshot: SnapshotAssertion) -> None:
281+
"""Verify capture_frame() output matches snapshot."""
282+
env = shutil.which("env")
283+
assert env is not None, "Cannot find usable `env` in PATH."
284+
285+
window = session.new_window(
286+
attach=True,
287+
window_name="capture_frame_snapshot",
288+
window_shell=f"{env} PS1='$ ' sh",
289+
)
290+
pane = window.active_pane
291+
assert pane is not None
292+
293+
# Send a predictable command
294+
pane.send_keys('echo "Hello, TextFrame!"', literal=True, suppress_history=False)
295+
296+
# Wait for output
297+
def output_appeared() -> bool:
298+
return "Hello, TextFrame!" in "\n".join(pane.capture_pane())
299+
300+
retry_until(output_appeared, 2, raises=True)
301+
302+
# Capture as frame - use fixed dimensions for reproducible snapshot
303+
frame = pane.capture_frame(content_width=30, content_height=5)
304+
305+
# Compare against snapshot
306+
assert frame == snapshot
307+
308+
309+
def test_capture_frame_with_retry_pattern(session: Session) -> None:
310+
"""Demonstrate capture_frame() in retry_until pattern."""
311+
env = shutil.which("env")
312+
assert env is not None, "Cannot find usable `env` in PATH."
313+
314+
window = session.new_window(
315+
attach=True,
316+
window_name="capture_frame_retry",
317+
window_shell=f"{env} PS1='$ ' sh",
318+
)
319+
pane = window.active_pane
320+
assert pane is not None
321+
322+
# Send command that produces multi-line output
323+
pane.send_keys('for i in 1 2 3; do echo "line $i"; done', enter=True)
324+
325+
# Use capture_frame in retry pattern
326+
def all_lines_in_frame() -> bool:
327+
frame = pane.capture_frame(content_width=40, content_height=10)
328+
rendered = frame.render()
329+
return all(f"line {i}" in rendered for i in [1, 2, 3])
330+
331+
# Should eventually pass
332+
result = retry_until(all_lines_in_frame, 3, raises=True)
333+
assert result is True
334+
335+
336+
def test_capture_frame_preserves_content(session: Session) -> None:
337+
"""Verify capture_frame() content matches capture_pane() content."""
338+
env = shutil.which("env")
339+
assert env is not None, "Cannot find usable `env` in PATH."
340+
341+
window = session.new_window(
342+
attach=True,
343+
window_name="capture_frame_content",
344+
window_shell=f"{env} PS1='$ ' sh",
345+
)
346+
pane = window.active_pane
347+
assert pane is not None
348+
349+
pane.send_keys('echo "test content"', literal=True, suppress_history=False)
350+
351+
def output_appeared() -> bool:
352+
return "test content" in "\n".join(pane.capture_pane())
353+
354+
retry_until(output_appeared, 2, raises=True)
355+
356+
# Capture both ways
357+
pane_lines = pane.capture_pane()
358+
frame = pane.capture_frame(
359+
content_width=40,
360+
content_height=len(pane_lines),
361+
overflow_behavior="truncate",
362+
)
363+
364+
# Frame content should contain the same lines (possibly truncated)
365+
for line in pane_lines[:5]: # Check first few lines
366+
# Truncated lines should match up to frame width
367+
truncated = line[: frame.content_width]
368+
if truncated.strip(): # Non-empty lines
369+
assert truncated in frame.render()

0 commit comments

Comments
 (0)