From e2c5c8e9bfe257d28a51c971e63f115359b0226f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:14 -0600 Subject: [PATCH 01/40] py(deps[dev]): Add syrupy for snapshot testing why: Enable snapshot testing for ASCII frame visualization what: - Add syrupy to dev dependencies --- pyproject.toml | 1 + uv.lock | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 212bdde2f..3674756d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + "syrupy", # Coverage "codecov", "coverage", diff --git a/uv.lock b/uv.lock index a4d33c04b..fed703f1d 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -516,6 +516,7 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, + { name = "syrupy" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -578,6 +579,7 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, + { name = "syrupy" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] docs = [ @@ -1325,6 +1327,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "syrupy" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, +] + [[package]] name = "tomli" version = "2.3.0" From 260fe906ff47f7a1aa34a41f915e152256680b15 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:22 -0600 Subject: [PATCH 02/40] tests(textframe): Add TextFrame ASCII frame prototype why: Validate Syrupy snapshot testing for terminal frame visualization what: - Add TextFrame dataclass with content overflow detection - Add ContentOverflowError with Reality vs Mask visual - Add TextFrameSerializer extending AmberDataSerializer - Add TextFrameExtension for Syrupy integration - Add parametrized tests for rendering and nested serialization --- tests/textframe/__init__.py | 3 + tests/textframe/conftest.py | 25 ++++++ tests/textframe/core.py | 160 +++++++++++++++++++++++++++++++++++ tests/textframe/plugin.py | 70 +++++++++++++++ tests/textframe/test_core.py | 100 ++++++++++++++++++++++ 5 files changed, 358 insertions(+) create mode 100644 tests/textframe/__init__.py create mode 100644 tests/textframe/conftest.py create mode 100644 tests/textframe/core.py create mode 100644 tests/textframe/plugin.py create mode 100644 tests/textframe/test_core.py diff --git a/tests/textframe/__init__.py b/tests/textframe/__init__.py new file mode 100644 index 000000000..12528d217 --- /dev/null +++ b/tests/textframe/__init__.py @@ -0,0 +1,3 @@ +"""TextFrame ASCII terminal frame testing prototype.""" + +from __future__ import annotations diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py new file mode 100644 index 000000000..a5ed0a14e --- /dev/null +++ b/tests/textframe/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration for TextFrame tests.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from .plugin import TextFrameExtension + + +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Override default snapshot fixture to use TextFrameExtension. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + """ + return snapshot.use_extension(TextFrameExtension) diff --git a/tests/textframe/core.py b/tests/textframe/core.py new file mode 100644 index 000000000..3f919036c --- /dev/null +++ b/tests/textframe/core.py @@ -0,0 +1,160 @@ +"""TextFrame - ASCII terminal frame simulator. + +This module provides a fixed-size ASCII frame for visualizing terminal content +with overflow detection and diagnostic rendering. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class ContentOverflowError(ValueError): + """Raised when content does not fit into the configured frame dimensions. + + Attributes + ---------- + overflow_visual : str + A diagnostic ASCII visualization showing the content and a mask + of the valid/invalid areas. + """ + + def __init__(self, message: str, overflow_visual: str) -> None: + super().__init__(message) + self.overflow_visual = overflow_visual + + +@dataclass(slots=True) +class TextFrame: + """A fixed-size ASCII terminal frame simulator. + + Attributes + ---------- + content_width : int + Width of the inner content area. + content_height : int + Height of the inner content area. + fill_char : str + Character to pad empty space. Defaults to space. + content : list[str] + The current content lines. + + Examples + -------- + >>> frame = TextFrame(content_width=10, content_height=2) + >>> frame.set_content(["hello", "world"]) + >>> print(frame.render()) + +----------+ + |hello | + |world | + +----------+ + """ + + content_width: int + content_height: int + fill_char: str = " " + content: list[str] = field(default_factory=list) + + def set_content(self, lines: Sequence[str]) -> None: + """Set content, applying validation logic. + + Parameters + ---------- + lines : Sequence[str] + Lines of content to set. + + Raises + ------ + ContentOverflowError + If content exceeds frame dimensions. + """ + input_lines = list(lines) + + # Calculate dimensions + max_w = max((len(line) for line in input_lines), default=0) + max_h = len(input_lines) + + is_overflow = max_w > self.content_width or max_h > self.content_height + + if is_overflow: + visual = self._render_overflow(input_lines, max_w, max_h) + raise ContentOverflowError( + f"Content ({max_w}x{max_h}) exceeds frame " + f"({self.content_width}x{self.content_height})", + overflow_visual=visual, + ) + + self.content = input_lines + + def render(self) -> str: + """Render the frame as ASCII art. + + Returns + ------- + str + The rendered frame with borders. + """ + return self._draw_frame(self.content, self.content_width, self.content_height) + + def _render_overflow(self, lines: list[str], max_w: int, max_h: int) -> str: + """Render the diagnostic overflow view (Reality vs Mask). + + Parameters + ---------- + lines : list[str] + The overflow content lines. + max_w : int + Maximum width of content. + max_h : int + Maximum height of content. + + Returns + ------- + str + A visualization showing content frame and valid/invalid mask. + """ + display_w = max(self.content_width, max_w) + display_h = max(self.content_height, max_h) + + # 1. Reality Frame - shows actual content + reality = self._draw_frame(lines, display_w, display_h) + + # 2. Mask Frame - shows valid vs invalid areas + mask_lines = [] + for r in range(display_h): + row = [] + for c in range(display_w): + is_valid = r < self.content_height and c < self.content_width + row.append(" " if is_valid else ".") + mask_lines.append("".join(row)) + + mask = self._draw_frame(mask_lines, display_w, display_h) + return f"{reality}\n{mask}" + + def _draw_frame(self, lines: list[str], w: int, h: int) -> str: + """Draw a bordered frame around content. + + Parameters + ---------- + lines : list[str] + Content lines to frame. + w : int + Frame width (excluding borders). + h : int + Frame height (excluding borders). + + Returns + ------- + str + Bordered ASCII frame. + """ + border = f"+{'-' * w}+" + body = [] + for r in range(h): + line = lines[r] if r < len(lines) else "" + body.append(f"|{line.ljust(w, self.fill_char)}|") + return "\n".join([border, *body, border]) diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py new file mode 100644 index 000000000..bd43bf73c --- /dev/null +++ b/tests/textframe/plugin.py @@ -0,0 +1,70 @@ +"""Syrupy snapshot extension for TextFrame objects. + +This module provides a custom serializer that renders TextFrame objects +and ContentOverflowError exceptions as ASCII art in snapshot files. +""" + +from __future__ import annotations + +import typing as t + +from syrupy.extensions.amber import AmberSnapshotExtension +from syrupy.extensions.amber.serializer import AmberDataSerializer + +from .core import ContentOverflowError, TextFrame + + +class TextFrameSerializer(AmberDataSerializer): + """Custom serializer that renders TextFrame objects as ASCII frames. + + This serializer intercepts TextFrame and ContentOverflowError objects, + converting them to their ASCII representation before passing them + to the base serializer for formatting. + + Notes + ----- + By subclassing AmberDataSerializer, we ensure TextFrame objects are + correctly rendered even when nested inside lists, dicts, or other + data structures. + """ + + @classmethod + def _serialize( + cls, + data: t.Any, + *, + depth: int = 0, + **kwargs: t.Any, + ) -> str: + """Serialize data, converting TextFrame objects to ASCII. + + Parameters + ---------- + data : Any + The data to serialize. + depth : int + Current indentation depth. + **kwargs : Any + Additional serialization options. + + Returns + ------- + str + Serialized representation. + """ + # Intercept TextFrame: Render it to ASCII + if isinstance(data, TextFrame): + return super()._serialize(data.render(), depth=depth, **kwargs) + + # Intercept ContentOverflowError: Render the visual diff + if isinstance(data, ContentOverflowError): + return super()._serialize(data.overflow_visual, depth=depth, **kwargs) + + # Default behavior for all other types + return super()._serialize(data, depth=depth, **kwargs) + + +class TextFrameExtension(AmberSnapshotExtension): + """Syrupy extension that uses the TextFrameSerializer.""" + + serializer_class = TextFrameSerializer diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py new file mode 100644 index 000000000..1702613f8 --- /dev/null +++ b/tests/textframe/test_core.py @@ -0,0 +1,100 @@ +"""Integration tests for TextFrame Syrupy snapshot testing.""" + +from __future__ import annotations + +import typing as t +from contextlib import nullcontext as does_not_raise + +import pytest +from syrupy.assertion import SnapshotAssertion + +from .core import ContentOverflowError, TextFrame + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class Case(t.NamedTuple): + """Test case definition for parametrized tests.""" + + id: str + width: int + height: int + lines: Sequence[str] + expected_exception: type[BaseException] | None + + +CASES: tuple[Case, ...] = ( + Case( + id="basic_success", + width=10, + height=2, + lines=["hello", "world"], + expected_exception=None, + ), + Case( + id="overflow_width", + width=10, + height=2, + lines=["this line is too long", "row 2", "row 3"], + expected_exception=ContentOverflowError, + ), + Case( + id="empty_frame", + width=5, + height=2, + lines=[], + expected_exception=None, + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_frame_rendering(case: Case, snapshot: SnapshotAssertion) -> None: + """Verify TextFrame rendering with Syrupy snapshot. + + Parameters + ---------- + case : Case + Test case with frame dimensions and content. + snapshot : SnapshotAssertion + Syrupy snapshot fixture configured with TextFrameExtension. + """ + frame = TextFrame(content_width=case.width, content_height=case.height) + + ctx: t.Any = ( + pytest.raises(case.expected_exception) + if case.expected_exception + else does_not_raise() + ) + + with ctx as exc_info: + frame.set_content(case.lines) + + if case.expected_exception: + # The Plugin detects the Exception type and renders the ASCII visual diff + assert exc_info.value == snapshot + else: + # The Plugin detects the TextFrame type and renders the ASCII frame + assert frame == snapshot + + +def test_nested_serialization(snapshot: SnapshotAssertion) -> None: + """Verify that nested TextFrame objects serialize correctly. + + This demonstrates that the custom serializer works when TextFrame + objects are inside collections (lists, dicts). + + Parameters + ---------- + snapshot : SnapshotAssertion + Syrupy snapshot fixture configured with TextFrameExtension. + """ + f1 = TextFrame(content_width=5, content_height=1) + f1.set_content(["one"]) + + f2 = TextFrame(content_width=5, content_height=1) + f2.set_content(["two"]) + + # The serializer will find the frames inside this list and render them + assert [f1, f2] == snapshot From 821ad870c83042200542c453eb416958a63e7d26 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:29 -0600 Subject: [PATCH 03/40] tests(textframe): Add snapshot baselines why: Store expected ASCII frame output for regression testing what: - Add snapshots for basic, empty, and overflow frame rendering - Add snapshot for nested TextFrame serialization --- tests/textframe/__snapshots__/test_core.ambr | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/textframe/__snapshots__/test_core.ambr diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr new file mode 100644 index 000000000..e221afb5d --- /dev/null +++ b/tests/textframe/__snapshots__/test_core.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_frame_rendering[basic_success] + ''' + +----------+ + |hello | + |world | + +----------+ + ''' +# --- +# name: test_frame_rendering[empty_frame] + ''' + +-----+ + | | + | | + +-----+ + ''' +# --- +# name: test_frame_rendering[overflow_width] + ''' + +---------------------+ + |this line is too long| + |row 2 | + |row 3 | + +---------------------+ + +---------------------+ + | ...........| + | ...........| + |.....................| + +---------------------+ + ''' +# --- +# name: test_nested_serialization + list([ + ''' + +-----+ + |one | + +-----+ + ''', + ''' + +-----+ + |two | + +-----+ + ''', + ]) +# --- From c4e869ec9b6561f1d0c5f19313d98a9f2f7cf3c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 02:55:33 -0600 Subject: [PATCH 04/40] py(deps[dev]) Bump dev packages --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index fed703f1d..559e0f557 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ From 285db33ff2ea5e8759250c710dd01cf39670cde8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 08:52:27 -0600 Subject: [PATCH 05/40] TextFrame(feat[__post_init__]): Add dimension and fill_char validation why: Prevent invalid TextFrame instances from being created with zero/negative dimensions or multi-character fill strings. what: - Add __post_init__ to validate content_width > 0 - Add __post_init__ to validate content_height > 0 - Add __post_init__ to validate fill_char is single character --- tests/textframe/core.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/textframe/core.py b/tests/textframe/core.py index 3f919036c..667689a62 100644 --- a/tests/textframe/core.py +++ b/tests/textframe/core.py @@ -59,6 +59,24 @@ class TextFrame: fill_char: str = " " content: list[str] = field(default_factory=list) + def __post_init__(self) -> None: + """Validate frame dimensions and fill character. + + Raises + ------ + ValueError + If dimensions are not positive or fill_char is not a single character. + """ + if self.content_width <= 0: + msg = "content_width must be positive" + raise ValueError(msg) + if self.content_height <= 0: + msg = "content_height must be positive" + raise ValueError(msg) + if len(self.fill_char) != 1: + msg = "fill_char must be a single character" + raise ValueError(msg) + def set_content(self, lines: Sequence[str]) -> None: """Set content, applying validation logic. From b11274df4e603b5bcad4ea51fb664876222cc19d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 08:53:09 -0600 Subject: [PATCH 06/40] TextFrame(feat[overflow_behavior]): Add truncate mode for content overflow why: Allow flexible handling of oversized content - either error with visual diagnostic or silently truncate to fit. what: - Add OverflowBehavior type alias for "error" | "truncate" - Add overflow_behavior parameter with default "error" (backward compatible) - Implement truncate logic to clip width and height - Update docstrings to reflect new behavior --- tests/textframe/core.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/textframe/core.py b/tests/textframe/core.py index 667689a62..e6e94056d 100644 --- a/tests/textframe/core.py +++ b/tests/textframe/core.py @@ -12,6 +12,8 @@ if t.TYPE_CHECKING: from collections.abc import Sequence +OverflowBehavior = t.Literal["error", "truncate"] + class ContentOverflowError(ValueError): """Raised when content does not fit into the configured frame dimensions. @@ -38,6 +40,10 @@ class TextFrame: Width of the inner content area. content_height : int Height of the inner content area. + overflow_behavior : OverflowBehavior + How to handle content that exceeds frame dimensions. + - "error": Raise ContentOverflowError with visual diagnostic. + - "truncate": Silently clip content to fit. fill_char : str Character to pad empty space. Defaults to space. content : list[str] @@ -56,6 +62,7 @@ class TextFrame: content_width: int content_height: int + overflow_behavior: OverflowBehavior = "error" fill_char: str = " " content: list[str] = field(default_factory=list) @@ -78,7 +85,7 @@ def __post_init__(self) -> None: raise ValueError(msg) def set_content(self, lines: Sequence[str]) -> None: - """Set content, applying validation logic. + """Set content, applying validation or truncation based on overflow_behavior. Parameters ---------- @@ -88,7 +95,7 @@ def set_content(self, lines: Sequence[str]) -> None: Raises ------ ContentOverflowError - If content exceeds frame dimensions. + If content exceeds frame dimensions and overflow_behavior is "error". """ input_lines = list(lines) @@ -99,12 +106,18 @@ def set_content(self, lines: Sequence[str]) -> None: is_overflow = max_w > self.content_width or max_h > self.content_height if is_overflow: - visual = self._render_overflow(input_lines, max_w, max_h) - raise ContentOverflowError( - f"Content ({max_w}x{max_h}) exceeds frame " - f"({self.content_width}x{self.content_height})", - overflow_visual=visual, - ) + if self.overflow_behavior == "error": + visual = self._render_overflow(input_lines, max_w, max_h) + msg = ( + f"Content ({max_w}x{max_h}) exceeds frame " + f"({self.content_width}x{self.content_height})" + ) + raise ContentOverflowError(msg, overflow_visual=visual) + # Truncate mode: clip to frame dimensions + input_lines = [ + line[: self.content_width] + for line in input_lines[: self.content_height] + ] self.content = input_lines From cc60f54c0dff09ab883cbd9f081aa7e47003254b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 09:10:54 -0600 Subject: [PATCH 07/40] tests(textframe): Add truncate behavior test cases why: Verify the new overflow_behavior="truncate" mode works correctly for width, height, and combined overflow scenarios. what: - Add overflow_behavior field to Case NamedTuple with default "error" - Add truncate_width test case (clips horizontal overflow) - Add truncate_height test case (clips vertical overflow) - Add truncate_both test case (clips both dimensions) - Update test to pass overflow_behavior to TextFrame constructor --- tests/textframe/test_core.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py index 1702613f8..c6d1a38d3 100644 --- a/tests/textframe/test_core.py +++ b/tests/textframe/test_core.py @@ -22,6 +22,7 @@ class Case(t.NamedTuple): height: int lines: Sequence[str] expected_exception: type[BaseException] | None + overflow_behavior: t.Literal["error", "truncate"] = "error" CASES: tuple[Case, ...] = ( @@ -46,6 +47,30 @@ class Case(t.NamedTuple): lines=[], expected_exception=None, ), + Case( + id="truncate_width", + width=5, + height=2, + lines=["hello world", "foo"], + expected_exception=None, + overflow_behavior="truncate", + ), + Case( + id="truncate_height", + width=10, + height=1, + lines=["row 1", "row 2", "row 3"], + expected_exception=None, + overflow_behavior="truncate", + ), + Case( + id="truncate_both", + width=5, + height=2, + lines=["hello world", "foo bar baz", "extra row"], + expected_exception=None, + overflow_behavior="truncate", + ), ) @@ -60,7 +85,11 @@ def test_frame_rendering(case: Case, snapshot: SnapshotAssertion) -> None: snapshot : SnapshotAssertion Syrupy snapshot fixture configured with TextFrameExtension. """ - frame = TextFrame(content_width=case.width, content_height=case.height) + frame = TextFrame( + content_width=case.width, + content_height=case.height, + overflow_behavior=case.overflow_behavior, + ) ctx: t.Any = ( pytest.raises(case.expected_exception) From f5f25eff41b9274e644dd22d8d2c80a8fc533019 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 09:11:02 -0600 Subject: [PATCH 08/40] tests(textframe): Add snapshot baselines for truncate tests why: Capture expected output for truncate behavior test cases. what: - Add truncate_width snapshot (5x2 frame, clipped "hello") - Add truncate_height snapshot (10x1 frame, single row) - Add truncate_both snapshot (5x2 frame, both dimensions clipped) --- tests/textframe/__snapshots__/test_core.ambr | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr index e221afb5d..b164695c3 100644 --- a/tests/textframe/__snapshots__/test_core.ambr +++ b/tests/textframe/__snapshots__/test_core.ambr @@ -29,6 +29,29 @@ +---------------------+ ''' # --- +# name: test_frame_rendering[truncate_both] + ''' + +-----+ + |hello| + |foo b| + +-----+ + ''' +# --- +# name: test_frame_rendering[truncate_height] + ''' + +----------+ + |row 1 | + +----------+ + ''' +# --- +# name: test_frame_rendering[truncate_width] + ''' + +-----+ + |hello| + |foo | + +-----+ + ''' +# --- # name: test_nested_serialization list([ ''' From e5f088f18e0cad538a141bf680450360d6f4ca9b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 10:59:45 -0600 Subject: [PATCH 09/40] tests(textframe): Add pytest_assertrepr_compare hook why: Provide rich assertion output for TextFrame comparisons without requiring syrupy for basic equality checks. what: - Add pytest_assertrepr_compare hook for TextFrame == TextFrame - Show dimension mismatches (width, height) - Show content diff using difflib.ndiff --- tests/textframe/conftest.py | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index a5ed0a14e..9a69ce407 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -2,12 +2,67 @@ from __future__ import annotations +import typing as t +from difflib import ndiff + import pytest from syrupy.assertion import SnapshotAssertion +from .core import TextFrame from .plugin import TextFrameExtension +def pytest_assertrepr_compare( + config: pytest.Config, + op: str, + left: t.Any, + right: t.Any, +) -> list[str] | None: + """Provide rich assertion output for TextFrame comparisons. + + This hook provides detailed diff output when two TextFrame objects + are compared with ==, showing dimension mismatches and content diffs. + + Parameters + ---------- + config : pytest.Config + The pytest configuration object. + op : str + The comparison operator (e.g., "==", "!="). + left : Any + The left operand of the comparison. + right : Any + The right operand of the comparison. + + Returns + ------- + list[str] | None + List of explanation lines, or None to use default behavior. + """ + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines + + @pytest.fixture def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: """Override default snapshot fixture to use TextFrameExtension. From 868b31c7472b2d49b533fb93fde88eb71b7ee834 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:20 -0600 Subject: [PATCH 10/40] tests(textframe): Switch to SingleFileSnapshotExtension why: Individual .frame files provide cleaner git diffs and easier review than a single .ambr file with all snapshots. what: - Replace AmberSnapshotExtension with SingleFileSnapshotExtension - Set file extension to .frame - Simplify serialize() method (removed nested serializer class) --- tests/textframe/plugin.py | 67 ++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py index bd43bf73c..bb2d696d7 100644 --- a/tests/textframe/plugin.py +++ b/tests/textframe/plugin.py @@ -1,70 +1,63 @@ """Syrupy snapshot extension for TextFrame objects. -This module provides a custom serializer that renders TextFrame objects -and ContentOverflowError exceptions as ASCII art in snapshot files. +This module provides a single-file snapshot extension that renders TextFrame +objects and ContentOverflowError exceptions as ASCII art in .frame files. """ from __future__ import annotations import typing as t -from syrupy.extensions.amber import AmberSnapshotExtension -from syrupy.extensions.amber.serializer import AmberDataSerializer +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode from .core import ContentOverflowError, TextFrame -class TextFrameSerializer(AmberDataSerializer): - """Custom serializer that renders TextFrame objects as ASCII frames. +class TextFrameExtension(SingleFileSnapshotExtension): + """Single-file extension for TextFrame snapshots (.frame files). - This serializer intercepts TextFrame and ContentOverflowError objects, - converting them to their ASCII representation before passing them - to the base serializer for formatting. + Each test snapshot is stored in its own .frame file, providing cleaner + git diffs compared to the multi-snapshot .ambr format. Notes ----- - By subclassing AmberDataSerializer, we ensure TextFrame objects are - correctly rendered even when nested inside lists, dicts, or other - data structures. + This extension serializes: + - TextFrame objects → their render() output + - ContentOverflowError → their overflow_visual attribute + - Other types → str() representation """ - @classmethod - def _serialize( - cls, + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize( + self, data: t.Any, *, - depth: int = 0, - **kwargs: t.Any, + exclude: t.Any = None, + include: t.Any = None, + matcher: t.Any = None, ) -> str: - """Serialize data, converting TextFrame objects to ASCII. + """Serialize data to ASCII frame representation. Parameters ---------- data : Any The data to serialize. - depth : int - Current indentation depth. - **kwargs : Any - Additional serialization options. + exclude : Any + Properties to exclude (unused for TextFrame). + include : Any + Properties to include (unused for TextFrame). + matcher : Any + Custom matcher (unused for TextFrame). Returns ------- str - Serialized representation. + ASCII representation of the data. """ - # Intercept TextFrame: Render it to ASCII if isinstance(data, TextFrame): - return super()._serialize(data.render(), depth=depth, **kwargs) - - # Intercept ContentOverflowError: Render the visual diff + return data.render() if isinstance(data, ContentOverflowError): - return super()._serialize(data.overflow_visual, depth=depth, **kwargs) - - # Default behavior for all other types - return super()._serialize(data, depth=depth, **kwargs) - - -class TextFrameExtension(AmberSnapshotExtension): - """Syrupy extension that uses the TextFrameSerializer.""" - - serializer_class = TextFrameSerializer + return data.overflow_visual + return str(data) From 58b97696c245bc51c66ac890f9c078f24963ca6c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:27 -0600 Subject: [PATCH 11/40] tests(textframe): Remove old .ambr snapshot file why: Replaced by individual .frame files from SingleFileSnapshotExtension. what: - Delete test_core.ambr --- tests/textframe/__snapshots__/test_core.ambr | 68 -------------------- 1 file changed, 68 deletions(-) delete mode 100644 tests/textframe/__snapshots__/test_core.ambr diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr deleted file mode 100644 index b164695c3..000000000 --- a/tests/textframe/__snapshots__/test_core.ambr +++ /dev/null @@ -1,68 +0,0 @@ -# serializer version: 1 -# name: test_frame_rendering[basic_success] - ''' - +----------+ - |hello | - |world | - +----------+ - ''' -# --- -# name: test_frame_rendering[empty_frame] - ''' - +-----+ - | | - | | - +-----+ - ''' -# --- -# name: test_frame_rendering[overflow_width] - ''' - +---------------------+ - |this line is too long| - |row 2 | - |row 3 | - +---------------------+ - +---------------------+ - | ...........| - | ...........| - |.....................| - +---------------------+ - ''' -# --- -# name: test_frame_rendering[truncate_both] - ''' - +-----+ - |hello| - |foo b| - +-----+ - ''' -# --- -# name: test_frame_rendering[truncate_height] - ''' - +----------+ - |row 1 | - +----------+ - ''' -# --- -# name: test_frame_rendering[truncate_width] - ''' - +-----+ - |hello| - |foo | - +-----+ - ''' -# --- -# name: test_nested_serialization - list([ - ''' - +-----+ - |one | - +-----+ - ''', - ''' - +-----+ - |two | - +-----+ - ''', - ]) -# --- From 85e879c2f41599100ed05872fa1b5303b3be16ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:40 -0600 Subject: [PATCH 12/40] tests(textframe): Add .frame snapshot baselines why: New snapshot format from SingleFileSnapshotExtension. what: - Add test_frame_rendering[basic_success].frame - Add test_frame_rendering[overflow_width].frame - Add test_frame_rendering[empty_frame].frame - Add test_frame_rendering[truncate_width].frame - Add test_frame_rendering[truncate_height].frame - Add test_frame_rendering[truncate_both].frame - Add test_nested_serialization.frame --- .../test_frame_rendering[basic_success].frame | 4 ++++ .../test_core/test_frame_rendering[empty_frame].frame | 4 ++++ .../test_frame_rendering[overflow_width].frame | 10 ++++++++++ .../test_frame_rendering[truncate_both].frame | 4 ++++ .../test_frame_rendering[truncate_height].frame | 3 +++ .../test_frame_rendering[truncate_width].frame | 4 ++++ .../test_core/test_nested_serialization.frame | 1 + 7 files changed, 30 insertions(+) create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_nested_serialization.frame diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame new file mode 100644 index 000000000..3eab75f59 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame @@ -0,0 +1,4 @@ ++----------+ +|hello | +|world | ++----------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame new file mode 100644 index 000000000..cf1ef75e4 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame @@ -0,0 +1,4 @@ ++-----+ +| | +| | ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame new file mode 100644 index 000000000..b0d9eea5d --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame @@ -0,0 +1,10 @@ ++---------------------+ +|this line is too long| +|row 2 | +|row 3 | ++---------------------+ ++---------------------+ +| ...........| +| ...........| +|.....................| ++---------------------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame new file mode 100644 index 000000000..60f5caf67 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame @@ -0,0 +1,4 @@ ++-----+ +|hello| +|foo b| ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame new file mode 100644 index 000000000..db3bb2c36 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame @@ -0,0 +1,3 @@ ++----------+ +|row 1 | ++----------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame new file mode 100644 index 000000000..0b31cc26a --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame @@ -0,0 +1,4 @@ ++-----+ +|hello| +|foo | ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame b/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame new file mode 100644 index 000000000..d2f9adc7b --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame @@ -0,0 +1 @@ +[TextFrame(content_width=5, content_height=1, overflow_behavior='error', fill_char=' ', content=['one']), TextFrame(content_width=5, content_height=1, overflow_behavior='error', fill_char=' ', content=['two'])] \ No newline at end of file From 1584418ea71c9533e9c890d9130dc318abfd3106 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:06:13 -0600 Subject: [PATCH 13/40] docs(textframe): Document assertion customization patterns why: Provide reference for TextFrame usage and architectural decisions. what: - Document syrupy integration (SingleFileSnapshotExtension) - Document pytest_assertrepr_compare hook pattern - Document overflow_behavior modes - Include examples and architectural insights from syrupy/pytest/CPython - Add to internals toctree --- docs/internals/index.md | 1 + docs/internals/textframe.md | 190 ++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 docs/internals/textframe.md diff --git a/docs/internals/index.md b/docs/internals/index.md index 0d19d3763..ef4590f2f 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -13,6 +13,7 @@ dataclasses query_list constants sparse_array +textframe ``` ## Environmental variables diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md new file mode 100644 index 000000000..2f999567b --- /dev/null +++ b/docs/internals/textframe.md @@ -0,0 +1,190 @@ +# TextFrame - ASCII Frame Simulator + +:::{warning} +This is a testing utility in `tests/textframe/`. It is **not** part of the public API. +::: + +TextFrame provides a fixed-size ASCII frame simulator for visualizing terminal content with overflow detection and diagnostic rendering. It integrates with [syrupy](https://github.com/tophat/syrupy) for snapshot testing and pytest for rich assertion output. + +## Overview + +TextFrame is designed for testing terminal UI components. It provides: + +- Fixed-dimension ASCII frames with borders +- Configurable overflow behavior (error or truncate) +- Syrupy snapshot testing with `.frame` files +- Rich pytest assertion output for frame comparisons + +## Core Components + +### TextFrame Dataclass + +```python +from tests.textframe.core import TextFrame, ContentOverflowError + +# Create a frame with fixed dimensions +frame = TextFrame(content_width=10, content_height=2) +frame.set_content(["hello", "world"]) +print(frame.render()) +``` + +Output: +``` ++----------+ +|hello | +|world | ++----------+ +``` + +### Overflow Behavior + +TextFrame supports two overflow behaviors: + +**Error mode (default):** Raises `ContentOverflowError` with a visual diagnostic showing the content and a mask of valid/invalid areas. + +```python +frame = TextFrame(content_width=5, content_height=2, overflow_behavior="error") +frame.set_content(["this line is too long"]) # Raises ContentOverflowError +``` + +The exception includes an `overflow_visual` attribute showing: +1. A "Reality" frame with the actual content +2. A "Mask" frame showing valid (space) vs invalid (dot) areas + +**Truncate mode:** Silently clips content to fit the frame dimensions. + +```python +frame = TextFrame(content_width=5, content_height=1, overflow_behavior="truncate") +frame.set_content(["hello world", "extra row"]) +print(frame.render()) +``` + +Output: +``` ++-----+ +|hello| ++-----+ +``` + +## Syrupy Integration + +### SingleFileSnapshotExtension + +TextFrame uses syrupy's `SingleFileSnapshotExtension` to store each snapshot in its own `.frame` file. This provides: + +- Cleaner git diffs (one file per test vs all-in-one `.ambr`) +- Easier code review of snapshot changes +- Human-readable ASCII art in snapshot files + +### Extension Implementation + +```python +# tests/textframe/plugin.py +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode + +class TextFrameExtension(SingleFileSnapshotExtension): + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize(self, data, **kwargs): + if isinstance(data, TextFrame): + return data.render() + if isinstance(data, ContentOverflowError): + return data.overflow_visual + return str(data) +``` + +Key design decisions: + +1. **`file_extension = "frame"`**: Uses `.frame` suffix for snapshot files instead of the default `.raw` +2. **`_write_mode = WriteMode.TEXT`**: Stores snapshots as text (not binary) +3. **Custom serialization**: Renders TextFrame objects and ContentOverflowError exceptions as ASCII art + +### Fixture Override Pattern + +The snapshot fixture is overridden in `conftest.py` using syrupy's `use_extension()` pattern: + +```python +# tests/textframe/conftest.py +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + return snapshot.use_extension(TextFrameExtension) +``` + +This pattern works because pytest fixtures that request themselves receive the parent scope's version. + +### Snapshot Directory Structure + +``` +tests/textframe/__snapshots__/ + test_core/ + test_frame_rendering[basic_success].frame + test_frame_rendering[overflow_width].frame + test_frame_rendering[empty_frame].frame + ... +``` + +## Pure pytest Assertion Hook + +For TextFrame-to-TextFrame comparisons (without syrupy), a `pytest_assertrepr_compare` hook provides rich diff output: + +```python +# tests/textframe/conftest.py +def pytest_assertrepr_compare(config, op, left, right): + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff using difflib.ndiff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines +``` + +This hook intercepts `assert frame1 == frame2` comparisons and shows: +- Dimension mismatches (width/height) +- Line-by-line diff using `difflib.ndiff` + +## Architecture Patterns + +### From syrupy + +- **Extension hierarchy**: `SingleFileSnapshotExtension` extends `AbstractSyrupyExtension` +- **Serialization**: Override `serialize()` for custom data types +- **File naming**: `file_extension` class attribute controls snapshot file suffix + +### From pytest + +- **`pytest_assertrepr_compare` hook**: Return `list[str]` for custom assertion output +- **Fixture override pattern**: Request same-named fixture to get parent scope's version +- **`ndiff` for diffs**: Character-level diff with `+`/`-` prefixes + +### From CPython dataclasses + +- **`@dataclass(slots=True)`**: Memory-efficient, prevents accidental attribute assignment +- **`__post_init__`**: Validation after dataclass initialization +- **Type aliases**: `OverflowBehavior = Literal["error", "truncate"]` + +## Files + +| File | Purpose | +|------|---------| +| `tests/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | +| `tests/textframe/plugin.py` | Syrupy `TextFrameExtension` | +| `tests/textframe/conftest.py` | Fixture override and `pytest_assertrepr_compare` hook | +| `tests/textframe/test_core.py` | Parametrized tests with snapshot assertions | +| `tests/textframe/__snapshots__/test_core/*.frame` | Snapshot baselines | From 26c9b9a45352a99d7ecdf8f59d80f92e4544df9c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:23:24 -0600 Subject: [PATCH 14/40] libtmux(textframe): Move core module to src/libtmux/textframe/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Enable distribution of TextFrame for downstream users. what: - Move tests/textframe/core.py → src/libtmux/textframe/core.py - Create src/libtmux/textframe/__init__.py with public API exports - Update test imports to use libtmux.textframe --- src/libtmux/textframe/__init__.py | 7 +++++++ {tests => src/libtmux}/textframe/core.py | 0 tests/textframe/conftest.py | 3 ++- tests/textframe/plugin.py | 2 +- tests/textframe/test_core.py | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/libtmux/textframe/__init__.py rename {tests => src/libtmux}/textframe/core.py (100%) diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py new file mode 100644 index 000000000..d4f833c27 --- /dev/null +++ b/src/libtmux/textframe/__init__.py @@ -0,0 +1,7 @@ +"""TextFrame - ASCII terminal frame simulator with pytest/syrupy integration.""" + +from __future__ import annotations + +from libtmux.textframe.core import ContentOverflowError, TextFrame + +__all__ = ["ContentOverflowError", "TextFrame"] diff --git a/tests/textframe/core.py b/src/libtmux/textframe/core.py similarity index 100% rename from tests/textframe/core.py rename to src/libtmux/textframe/core.py diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index 9a69ce407..09e06f073 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -8,7 +8,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from .core import TextFrame +from libtmux.textframe import TextFrame + from .plugin import TextFrameExtension diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py index bb2d696d7..6a2c7387a 100644 --- a/tests/textframe/plugin.py +++ b/tests/textframe/plugin.py @@ -10,7 +10,7 @@ from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode -from .core import ContentOverflowError, TextFrame +from libtmux.textframe import ContentOverflowError, TextFrame class TextFrameExtension(SingleFileSnapshotExtension): diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py index c6d1a38d3..a5c99858b 100644 --- a/tests/textframe/test_core.py +++ b/tests/textframe/test_core.py @@ -8,7 +8,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from .core import ContentOverflowError, TextFrame +from libtmux.textframe import ContentOverflowError, TextFrame if t.TYPE_CHECKING: from collections.abc import Sequence From 4130a614f311fe7897da075e36a58308df0bbacf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:24:49 -0600 Subject: [PATCH 15/40] libtmux(textframe): Add pytest plugin with hooks and fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Auto-register TextFrame assertion hooks and snapshot fixture for downstream users who install libtmux[textframe]. what: - Move tests/textframe/plugin.py → src/libtmux/textframe/plugin.py - Add pytest_assertrepr_compare hook for rich diff output - Add textframe_snapshot fixture for downstream users - Export TextFrameExtension from __init__.py - Simplify tests/textframe/conftest.py (hooks now in plugin) --- src/libtmux/textframe/__init__.py | 3 +- src/libtmux/textframe/plugin.py | 152 ++++++++++++++++++++++++++++++ tests/textframe/conftest.py | 58 +----------- tests/textframe/plugin.py | 63 ------------- 4 files changed, 155 insertions(+), 121 deletions(-) create mode 100644 src/libtmux/textframe/plugin.py delete mode 100644 tests/textframe/plugin.py diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py index d4f833c27..bd5c31144 100644 --- a/src/libtmux/textframe/__init__.py +++ b/src/libtmux/textframe/__init__.py @@ -3,5 +3,6 @@ from __future__ import annotations from libtmux.textframe.core import ContentOverflowError, TextFrame +from libtmux.textframe.plugin import TextFrameExtension -__all__ = ["ContentOverflowError", "TextFrame"] +__all__ = ["ContentOverflowError", "TextFrame", "TextFrameExtension"] diff --git a/src/libtmux/textframe/plugin.py b/src/libtmux/textframe/plugin.py new file mode 100644 index 000000000..917176526 --- /dev/null +++ b/src/libtmux/textframe/plugin.py @@ -0,0 +1,152 @@ +"""Syrupy snapshot extension and pytest hooks for TextFrame. + +This module provides: +- TextFrameExtension: A syrupy extension for .frame snapshot files +- pytest_assertrepr_compare: Rich assertion output for TextFrame comparisons +- textframe_snapshot: Pre-configured snapshot fixture + +When installed via `pip install libtmux[textframe]`, this plugin is +auto-discovered by pytest through the pytest11 entry point. +""" + +from __future__ import annotations + +import typing as t +from difflib import ndiff + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode + +from libtmux.textframe.core import ContentOverflowError, TextFrame + + +class TextFrameExtension(SingleFileSnapshotExtension): + """Single-file extension for TextFrame snapshots (.frame files). + + Each test snapshot is stored in its own .frame file, providing cleaner + git diffs compared to the multi-snapshot .ambr format. + + Notes + ----- + This extension serializes: + - TextFrame objects → their render() output + - ContentOverflowError → their overflow_visual attribute + - Other types → str() representation + """ + + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize( + self, + data: t.Any, + *, + exclude: t.Any = None, + include: t.Any = None, + matcher: t.Any = None, + ) -> str: + """Serialize data to ASCII frame representation. + + Parameters + ---------- + data : Any + The data to serialize. + exclude : Any + Properties to exclude (unused for TextFrame). + include : Any + Properties to include (unused for TextFrame). + matcher : Any + Custom matcher (unused for TextFrame). + + Returns + ------- + str + ASCII representation of the data. + """ + if isinstance(data, TextFrame): + return data.render() + if isinstance(data, ContentOverflowError): + return data.overflow_visual + return str(data) + + +# pytest hooks (auto-discovered via pytest11 entry point) + + +def pytest_assertrepr_compare( + config: pytest.Config, + op: str, + left: t.Any, + right: t.Any, +) -> list[str] | None: + """Provide rich assertion output for TextFrame comparisons. + + This hook provides detailed diff output when two TextFrame objects + are compared with ==, showing dimension mismatches and content diffs. + + Parameters + ---------- + config : pytest.Config + The pytest configuration object. + op : str + The comparison operator (e.g., "==", "!="). + left : Any + The left operand of the comparison. + right : Any + The right operand of the comparison. + + Returns + ------- + list[str] | None + List of explanation lines, or None to use default behavior. + """ + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines + + +@pytest.fixture +def textframe_snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Snapshot fixture configured with TextFrameExtension. + + This fixture is auto-discovered when libtmux[textframe] is installed. + It provides a pre-configured snapshot for TextFrame objects. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + + Examples + -------- + >>> def test_my_frame(textframe_snapshot): + ... frame = TextFrame(content_width=10, content_height=2) + ... frame.set_content(["hello", "world"]) + ... assert frame == textframe_snapshot + """ + return snapshot.use_extension(TextFrameExtension) diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index 09e06f073..fe57d3b42 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -2,66 +2,10 @@ from __future__ import annotations -import typing as t -from difflib import ndiff - import pytest from syrupy.assertion import SnapshotAssertion -from libtmux.textframe import TextFrame - -from .plugin import TextFrameExtension - - -def pytest_assertrepr_compare( - config: pytest.Config, - op: str, - left: t.Any, - right: t.Any, -) -> list[str] | None: - """Provide rich assertion output for TextFrame comparisons. - - This hook provides detailed diff output when two TextFrame objects - are compared with ==, showing dimension mismatches and content diffs. - - Parameters - ---------- - config : pytest.Config - The pytest configuration object. - op : str - The comparison operator (e.g., "==", "!="). - left : Any - The left operand of the comparison. - right : Any - The right operand of the comparison. - - Returns - ------- - list[str] | None - List of explanation lines, or None to use default behavior. - """ - if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): - return None - if op != "==": - return None - - lines = ["TextFrame comparison failed:"] - - # Dimension mismatch - if left.content_width != right.content_width: - lines.append(f" width: {left.content_width} != {right.content_width}") - if left.content_height != right.content_height: - lines.append(f" height: {left.content_height} != {right.content_height}") - - # Content diff - left_render = left.render().splitlines() - right_render = right.render().splitlines() - if left_render != right_render: - lines.append("") - lines.append("Content diff:") - lines.extend(ndiff(right_render, left_render)) - - return lines +from libtmux.textframe import TextFrameExtension @pytest.fixture diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py deleted file mode 100644 index 6a2c7387a..000000000 --- a/tests/textframe/plugin.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Syrupy snapshot extension for TextFrame objects. - -This module provides a single-file snapshot extension that renders TextFrame -objects and ContentOverflowError exceptions as ASCII art in .frame files. -""" - -from __future__ import annotations - -import typing as t - -from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode - -from libtmux.textframe import ContentOverflowError, TextFrame - - -class TextFrameExtension(SingleFileSnapshotExtension): - """Single-file extension for TextFrame snapshots (.frame files). - - Each test snapshot is stored in its own .frame file, providing cleaner - git diffs compared to the multi-snapshot .ambr format. - - Notes - ----- - This extension serializes: - - TextFrame objects → their render() output - - ContentOverflowError → their overflow_visual attribute - - Other types → str() representation - """ - - _write_mode = WriteMode.TEXT - file_extension = "frame" - - def serialize( - self, - data: t.Any, - *, - exclude: t.Any = None, - include: t.Any = None, - matcher: t.Any = None, - ) -> str: - """Serialize data to ASCII frame representation. - - Parameters - ---------- - data : Any - The data to serialize. - exclude : Any - Properties to exclude (unused for TextFrame). - include : Any - Properties to include (unused for TextFrame). - matcher : Any - Custom matcher (unused for TextFrame). - - Returns - ------- - str - ASCII representation of the data. - """ - if isinstance(data, TextFrame): - return data.render() - if isinstance(data, ContentOverflowError): - return data.overflow_visual - return str(data) From 3176317050eb0a9d00e73b188fd8f08c0d8910ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:25:29 -0600 Subject: [PATCH 16/40] py(deps): Add textframe extras with syrupy dependency why: Allow opt-in installation of textframe pytest plugin. what: - Add [project.optional-dependencies] textframe = ["syrupy>=4.0.0"] - Add [project.entry-points.pytest11] libtmux-textframe entry point - Downstream users can now: pip install libtmux[textframe] --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3674756d2..6e30fb6ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,8 +113,12 @@ lint = [ "mypy", ] +[project.optional-dependencies] +textframe = ["syrupy>=4.0.0"] + [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +libtmux-textframe = "libtmux.textframe.plugin" [build-system] requires = ["hatchling"] From 14610d8af0b1039884973aef4c6f8ad5a2756e32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:26:36 -0600 Subject: [PATCH 17/40] docs(textframe): Update for distributable plugin why: Document opt-in mechanism for downstream users. what: - Update import paths from tests/ to src/libtmux/textframe/ - Add installation section: pip install libtmux[textframe] - Document auto-discovered fixtures and hooks - Add Plugin Discovery section explaining pytest11 entry points - Update file paths table --- docs/internals/textframe.md | 92 +++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 2f999567b..7c7384899 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -1,11 +1,30 @@ # TextFrame - ASCII Frame Simulator -:::{warning} -This is a testing utility in `tests/textframe/`. It is **not** part of the public API. -::: - TextFrame provides a fixed-size ASCII frame simulator for visualizing terminal content with overflow detection and diagnostic rendering. It integrates with [syrupy](https://github.com/tophat/syrupy) for snapshot testing and pytest for rich assertion output. +## Installation + +TextFrame is available as an optional extra: + +```bash +pip install libtmux[textframe] +``` + +This installs syrupy and registers the pytest plugin automatically. + +## Quick Start + +After installation, the `textframe_snapshot` fixture and `pytest_assertrepr_compare` hook are auto-discovered by pytest: + +```python +from libtmux.textframe import TextFrame + +def test_my_terminal_ui(textframe_snapshot): + frame = TextFrame(content_width=20, content_height=5) + frame.set_content(["Hello", "World"]) + assert frame == textframe_snapshot +``` + ## Overview TextFrame is designed for testing terminal UI components. It provides: @@ -20,7 +39,7 @@ TextFrame is designed for testing terminal UI components. It provides: ### TextFrame Dataclass ```python -from tests.textframe.core import TextFrame, ContentOverflowError +from libtmux.textframe import TextFrame, ContentOverflowError # Create a frame with fixed dimensions frame = TextFrame(content_width=10, content_height=2) @@ -79,7 +98,7 @@ TextFrame uses syrupy's `SingleFileSnapshotExtension` to store each snapshot in ### Extension Implementation ```python -# tests/textframe/plugin.py +# src/libtmux/textframe/plugin.py from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode class TextFrameExtension(SingleFileSnapshotExtension): @@ -100,36 +119,34 @@ Key design decisions: 2. **`_write_mode = WriteMode.TEXT`**: Stores snapshots as text (not binary) 3. **Custom serialization**: Renders TextFrame objects and ContentOverflowError exceptions as ASCII art -### Fixture Override Pattern +### Auto-Discovered Fixtures -The snapshot fixture is overridden in `conftest.py` using syrupy's `use_extension()` pattern: +When `libtmux[textframe]` is installed, the following fixture is available: ```python -# tests/textframe/conftest.py @pytest.fixture -def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: +def textframe_snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Snapshot fixture configured with TextFrameExtension.""" return snapshot.use_extension(TextFrameExtension) ``` -This pattern works because pytest fixtures that request themselves receive the parent scope's version. - ### Snapshot Directory Structure ``` -tests/textframe/__snapshots__/ - test_core/ +__snapshots__/ + test_module/ test_frame_rendering[basic_success].frame test_frame_rendering[overflow_width].frame test_frame_rendering[empty_frame].frame ... ``` -## Pure pytest Assertion Hook +## pytest Assertion Hook -For TextFrame-to-TextFrame comparisons (without syrupy), a `pytest_assertrepr_compare` hook provides rich diff output: +The `pytest_assertrepr_compare` hook provides rich diff output for TextFrame comparisons: ```python -# tests/textframe/conftest.py +# Auto-registered via pytest11 entry point def pytest_assertrepr_compare(config, op, left, right): if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): return None @@ -159,6 +176,25 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: - Dimension mismatches (width/height) - Line-by-line diff using `difflib.ndiff` +## Plugin Discovery + +The textframe plugin is registered via pytest's entry point mechanism: + +```toml +# pyproject.toml +[project.entry-points.pytest11] +libtmux-textframe = "libtmux.textframe.plugin" + +[project.optional-dependencies] +textframe = ["syrupy>=4.0.0"] +``` + +When installed with `pip install libtmux[textframe]`: +1. syrupy is installed as a dependency +2. The pytest11 entry point is registered +3. pytest auto-discovers the plugin on startup +4. `textframe_snapshot` fixture and assertion hooks are available + ## Architecture Patterns ### From syrupy @@ -170,8 +206,8 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: ### From pytest - **`pytest_assertrepr_compare` hook**: Return `list[str]` for custom assertion output -- **Fixture override pattern**: Request same-named fixture to get parent scope's version -- **`ndiff` for diffs**: Character-level diff with `+`/`-` prefixes +- **pytest11 entry points**: Auto-discovery of installed plugins +- **Fixture auto-discovery**: Fixtures defined in plugins are globally available ### From CPython dataclasses @@ -183,8 +219,16 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: | File | Purpose | |------|---------| -| `tests/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | -| `tests/textframe/plugin.py` | Syrupy `TextFrameExtension` | -| `tests/textframe/conftest.py` | Fixture override and `pytest_assertrepr_compare` hook | -| `tests/textframe/test_core.py` | Parametrized tests with snapshot assertions | -| `tests/textframe/__snapshots__/test_core/*.frame` | Snapshot baselines | +| `src/libtmux/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | +| `src/libtmux/textframe/plugin.py` | Syrupy extension, pytest hooks, and fixtures | +| `src/libtmux/textframe/__init__.py` | Public API exports | + +## Public API + +```python +from libtmux.textframe import ( + TextFrame, # Core dataclass + ContentOverflowError, # Exception with visual diagnostic + TextFrameExtension, # Syrupy extension for custom usage +) +``` From 0c8d73fc6615c776a2a3c4eb81ee69d567c3f3a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:38:55 -0600 Subject: [PATCH 18/40] py(deps): Update lockfile for textframe extras why: Lockfile reflects new optional dependency. what: - Add syrupy to textframe extras in uv.lock --- uv.lock | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 559e0f557..1ba1f43f1 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -485,6 +485,11 @@ name = "libtmux" version = "0.52.1" source = { editable = "." } +[package.optional-dependencies] +textframe = [ + { name = "syrupy" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -550,6 +555,8 @@ testing = [ ] [package.metadata] +requires-dist = [{ name = "syrupy", marker = "extra == 'textframe'", specifier = ">=4.0.0" }] +provides-extras = ["textframe"] [package.metadata.requires-dev] coverage = [ From 82723ce55eec1f7e4ccb99afbd1292ceac823edd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:41:38 -0600 Subject: [PATCH 19/40] docs(CHANGES): Document TextFrame features for 0.52.x (#613) why: Document new features for release notes. what: - TextFrame primitive for terminal UI testing - pytest assertion hook with rich diff output - syrupy snapshot extension with .frame files - Optional install via libtmux[textframe] --- CHANGES | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/CHANGES b/CHANGES index c68281c03..56396ba03 100644 --- a/CHANGES +++ b/CHANGES @@ -32,6 +32,71 @@ $ uvx --from 'libtmux' --prerelease allow python +### New features + +#### TextFrame primitive (#613) + +New {class}`~libtmux.textframe.TextFrame` dataclass for testing terminal UI output. +Provides a fixed-size ASCII frame simulator with overflow detection - useful for +validating `capture_pane()` output and terminal rendering in tests. + +```python +from libtmux.textframe import TextFrame + +frame = TextFrame(content_width=10, content_height=2) +frame.set_content(["hello", "world"]) +print(frame.render()) +# +----------+ +# |hello | +# |world | +# +----------+ +``` + +**Features:** + +- Configurable dimensions with `content_width` and `content_height` +- Overflow handling: `overflow_behavior="error"` raises {class}`~libtmux.textframe.ContentOverflowError` + with visual diagnostic, `overflow_behavior="truncate"` clips content silently +- Dimension validation via `__post_init__` + +#### pytest assertion hook for TextFrame (#613) + +Rich assertion output when comparing {class}`~libtmux.textframe.TextFrame` objects. +Shows dimension mismatches and line-by-line content diffs using `difflib.ndiff`. + +``` +TextFrame comparison failed: + width: 20 != 10 +Content diff: +- +----------+ ++ +--------------------+ +``` + +#### syrupy snapshot extension for TextFrame (#613) + +{class}`~libtmux.textframe.TextFrameExtension` stores snapshots as `.frame` files - +one file per test for cleaner git diffs. + +**Installation:** + +```console +$ pip install libtmux[textframe] +``` + +**Usage:** + +```python +from libtmux.textframe import TextFrame + +def test_pane_output(textframe_snapshot): + frame = TextFrame(content_width=20, content_height=5) + frame.set_content(["Hello", "World"]) + assert frame == textframe_snapshot +``` + +The `textframe_snapshot` fixture and assertion hooks are auto-discovered via +pytest's `pytest11` entry point when the `textframe` extra is installed. + ## libtmux 0.52.1 (2025-12-07) ### CI From 27e0a8d3347089d50ba72adae9956abdbb4a280d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:53:30 -0600 Subject: [PATCH 20/40] Pane(feat[capture_frame]): Add capture_frame() method why: Enable capturing pane content as TextFrame for visualization and snapshot testing. This bridges capture_pane() with the TextFrame dataclass for a more ergonomic testing workflow. what: - Add capture_frame() method that wraps capture_pane() - Default to pane dimensions when width/height not specified - Default to truncate mode for robustness in CI environments - Add comprehensive docstring with examples --- src/libtmux/pane.py | 89 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 351a3333f..ada92fd47 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -31,6 +31,8 @@ import types from libtmux._internal.types import StrPath + from libtmux.textframe import TextFrame + from libtmux.textframe.core import OverflowBehavior from .server import Server from .session import Session @@ -420,6 +422,93 @@ def capture_pane( ) return self.cmd(*cmd).stdout + def capture_frame( + self, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + *, + content_width: int | None = None, + content_height: int | None = None, + overflow_behavior: OverflowBehavior = "truncate", + ) -> TextFrame: + """Capture pane content as a TextFrame. + + Combines :meth:`capture_pane` with :class:`~libtmux.textframe.TextFrame` + for visualization and snapshot testing. + + Parameters + ---------- + start : str | int, optional + Starting line number (same as :meth:`capture_pane`). + Zero is the first line of the visible pane. + Positive numbers are lines in the visible pane. + Negative numbers are lines in the history. + ``-`` is the start of the history. + Default: None + end : str | int, optional + Ending line number (same as :meth:`capture_pane`). + Zero is the first line of the visible pane. + Positive numbers are lines in the visible pane. + Negative numbers are lines in the history. + ``-`` is the end of the visible pane. + Default: None + content_width : int, optional + Frame width. Defaults to pane's current width. + content_height : int, optional + Frame height. Defaults to pane's current height. + overflow_behavior : OverflowBehavior, optional + How to handle content that exceeds frame dimensions. + Defaults to ``"truncate"`` since pane content may exceed + nominal dimensions during terminal transitions. + + Returns + ------- + :class:`~libtmux.textframe.TextFrame` + Frame containing captured pane content. + + Examples + -------- + >>> pane.send_keys('echo "Hello"', enter=True) + >>> import time; time.sleep(0.1) + >>> frame = pane.capture_frame(content_width=20, content_height=5) + >>> 'Hello' in frame.render() + True + + >>> print(frame.render()) # doctest: +SKIP + +--------------------+ + |$ echo "Hello" | + |Hello | + |$ | + | | + | | + +--------------------+ + """ + from libtmux.textframe import TextFrame as TextFrameClass + + # Capture content + lines = self.capture_pane(start=start, end=end) + + # Use pane dimensions if not specified + self.refresh() + width = ( + content_width if content_width is not None else int(self.pane_width or 80) + ) + height = ( + content_height + if content_height is not None + else int(self.pane_height or 24) + ) + + # Create and populate frame + frame = TextFrameClass( + content_width=width, + content_height=height, + overflow_behavior=overflow_behavior, + ) + frame.set_content(lines) + + return frame + def send_keys( self, cmd: str, From 855a5ff656e9b1cd0e81b3c645fbdf58bd98fd10 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:15 -0600 Subject: [PATCH 21/40] 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 --- tests/test_pane_capture_frame.py | 369 +++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 tests/test_pane_capture_frame.py diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py new file mode 100644 index 000000000..1ef218738 --- /dev/null +++ b/tests/test_pane_capture_frame.py @@ -0,0 +1,369 @@ +"""Tests for Pane.capture_frame() method.""" + +from __future__ import annotations + +import shutil +import typing as t + +import pytest +from syrupy.assertion import SnapshotAssertion + +from libtmux.test.retry import retry_until +from libtmux.textframe import TextFrame, TextFrameExtension + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Override default snapshot fixture to use TextFrameExtension. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + """ + return snapshot.use_extension(TextFrameExtension) + + +class CaptureFrameCase(t.NamedTuple): + """Test case for capture_frame() parametrized tests.""" + + test_id: str + content_to_send: str + content_width: int | None # None = use pane width + content_height: int | None # None = use pane height + overflow_behavior: t.Literal["error", "truncate"] + expected_in_frame: list[str] # Substrings expected in rendered frame + description: str + + +CAPTURE_FRAME_CASES: list[CaptureFrameCase] = [ + CaptureFrameCase( + test_id="basic_echo", + content_to_send='echo "hello"', + content_width=40, + content_height=10, + overflow_behavior="truncate", + expected_in_frame=["hello"], + description="Basic echo command output", + ), + CaptureFrameCase( + test_id="multiline_output", + content_to_send='printf "line1\\nline2\\nline3\\n"', + content_width=40, + content_height=10, + overflow_behavior="truncate", + expected_in_frame=["line1", "line2", "line3"], + description="Multi-line printf output", + ), + CaptureFrameCase( + test_id="custom_small_dimensions", + content_to_send='echo "test"', + content_width=20, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["test"], + description="Custom small frame dimensions", + ), + CaptureFrameCase( + test_id="truncate_long_line", + content_to_send='echo "' + "x" * 50 + '"', + content_width=15, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["xxxxxxxxxxxxxxx"], # Truncated to 15 chars + description="Long output truncated to frame width", + ), + CaptureFrameCase( + test_id="empty_pane", + content_to_send="", + content_width=20, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["$"], # Just shell prompt + description="Empty pane with just prompt", + ), +] + + +@pytest.mark.parametrize( + list(CaptureFrameCase._fields), + CAPTURE_FRAME_CASES, + ids=[case.test_id for case in CAPTURE_FRAME_CASES], +) +def test_capture_frame_parametrized( + test_id: str, + content_to_send: str, + content_width: int | None, + content_height: int | None, + overflow_behavior: t.Literal["error", "truncate"], + expected_in_frame: list[str], + description: str, + session: Session, +) -> None: + """Verify capture_frame() with various content and dimensions. + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + content_to_send : str + Command to send to the pane. + content_width : int | None + Frame width (None = use pane width). + content_height : int | None + Frame height (None = use pane height). + overflow_behavior : OverflowBehavior + How to handle overflow. + expected_in_frame : list[str] + Substrings expected in the rendered frame. + description : str + Human-readable test description. + session : Session + pytest fixture providing tmux session. + """ + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name=f"capture_frame_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send content if provided + if content_to_send: + pane.send_keys(content_to_send, literal=True, suppress_history=False) + + # Wait for command output to appear + def output_appeared() -> bool: + lines = pane.capture_pane() + content = "\n".join(lines) + # Check that at least one expected substring is present + return any(exp in content for exp in expected_in_frame) + + retry_until(output_appeared, 2, raises=True) + + # Capture frame with specified dimensions + frame = pane.capture_frame( + content_width=content_width, + content_height=content_height, + overflow_behavior=overflow_behavior, + ) + + # Verify frame type + assert isinstance(frame, TextFrame) + + # Verify dimensions + if content_width is not None: + assert frame.content_width == content_width + if content_height is not None: + assert frame.content_height == content_height + + # Verify expected content in rendered frame + rendered = frame.render() + for expected in expected_in_frame: + assert expected in rendered, f"Expected '{expected}' not found in frame" + + +def test_capture_frame_returns_textframe(session: Session) -> None: + """Verify capture_frame() returns a TextFrame instance.""" + pane = session.active_window.active_pane + assert pane is not None + + frame = pane.capture_frame(content_width=20, content_height=5) + + assert isinstance(frame, TextFrame) + assert frame.content_width == 20 + assert frame.content_height == 5 + + +def test_capture_frame_default_dimensions(session: Session) -> None: + """Verify capture_frame() uses pane dimensions by default.""" + pane = session.active_window.active_pane + assert pane is not None + pane.refresh() + + # Get actual pane dimensions + expected_width = int(pane.pane_width or 80) + expected_height = int(pane.pane_height or 24) + + # Capture without specifying dimensions + frame = pane.capture_frame() + + assert frame.content_width == expected_width + assert frame.content_height == expected_height + + +def test_capture_frame_with_start_end(session: Session) -> None: + """Verify capture_frame() works with start/end parameters.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_start_end", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send multiple lines + pane.send_keys('echo "line1"', enter=True) + pane.send_keys('echo "line2"', enter=True) + pane.send_keys('echo "line3"', enter=True) + + # Wait for all output + def all_lines_present() -> bool: + content = "\n".join(pane.capture_pane()) + return "line3" in content + + retry_until(all_lines_present, 2, raises=True) + + # Capture with start parameter (visible pane only) + frame = pane.capture_frame(start=0, content_width=40, content_height=10) + rendered = frame.render() + + # Should capture visible content + assert isinstance(frame, TextFrame) + assert "line" in rendered # At least some output + + +def test_capture_frame_overflow_truncate(session: Session) -> None: + """Verify capture_frame() truncates content when overflow_behavior='truncate'.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_truncate", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send long line + long_text = "x" * 100 + pane.send_keys(f'echo "{long_text}"', literal=True, suppress_history=False) + + def output_appeared() -> bool: + return "xxxx" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture with small width, truncate mode + frame = pane.capture_frame( + content_width=10, + content_height=5, + overflow_behavior="truncate", + ) + + # Should not raise, content should be truncated + assert isinstance(frame, TextFrame) + rendered = frame.render() + + # Frame should have the specified width (10 chars + borders) + lines = rendered.splitlines() + # Border line should be +----------+ (10 dashes) + assert lines[0] == "+----------+" + + +def test_capture_frame_snapshot(session: Session, snapshot: SnapshotAssertion) -> None: + """Verify capture_frame() output matches snapshot.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_snapshot", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send a predictable command + pane.send_keys('echo "Hello, TextFrame!"', literal=True, suppress_history=False) + + # Wait for output + def output_appeared() -> bool: + return "Hello, TextFrame!" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture as frame - use fixed dimensions for reproducible snapshot + frame = pane.capture_frame(content_width=30, content_height=5) + + # Compare against snapshot + assert frame == snapshot + + +def test_capture_frame_with_retry_pattern(session: Session) -> None: + """Demonstrate capture_frame() in retry_until pattern.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_retry", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send command that produces multi-line output + pane.send_keys('for i in 1 2 3; do echo "line $i"; done', enter=True) + + # Use capture_frame in retry pattern + def all_lines_in_frame() -> bool: + frame = pane.capture_frame(content_width=40, content_height=10) + rendered = frame.render() + return all(f"line {i}" in rendered for i in [1, 2, 3]) + + # Should eventually pass + result = retry_until(all_lines_in_frame, 3, raises=True) + assert result is True + + +def test_capture_frame_preserves_content(session: Session) -> None: + """Verify capture_frame() content matches capture_pane() content.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_content", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + pane.send_keys('echo "test content"', literal=True, suppress_history=False) + + def output_appeared() -> bool: + return "test content" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture both ways + pane_lines = pane.capture_pane() + frame = pane.capture_frame( + content_width=40, + content_height=len(pane_lines), + overflow_behavior="truncate", + ) + + # Frame content should contain the same lines (possibly truncated) + for line in pane_lines[:5]: # Check first few lines + # Truncated lines should match up to frame width + truncated = line[: frame.content_width] + if truncated.strip(): # Non-empty lines + assert truncated in frame.render() From 3ebf78ed2b6f0d4e9b9004548e4402259a9ba65d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:21 -0600 Subject: [PATCH 22/40] tests(pane): Add capture_frame snapshot baseline why: Baseline snapshot for capture_frame() visual regression testing. what: - Add test_capture_frame_snapshot.frame baseline --- .../test_capture_frame_snapshot.frame | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame new file mode 100644 index 000000000..48a6251fc --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "Hello, TextFrame!" | +|Hello, TextFrame! | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file From e629cbfabe35a43e0656b71ef5a962115cd43e32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:26 -0600 Subject: [PATCH 23/40] docs(textframe): Document capture_frame() integration why: Show users how to use capture_frame() for testing terminal output. what: - Add Pane.capture_frame() integration section - Document parameters with table - Explain design decisions (truncate default, refresh) - Add retry_until usage example --- docs/internals/textframe.md | 68 +++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 7c7384899..69bcecca2 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -223,6 +223,74 @@ When installed with `pip install libtmux[textframe]`: | `src/libtmux/textframe/plugin.py` | Syrupy extension, pytest hooks, and fixtures | | `src/libtmux/textframe/__init__.py` | Public API exports | +## Pane.capture_frame() Integration + +The `Pane.capture_frame()` method provides a high-level way to capture pane content as a TextFrame: + +```python +from libtmux.test.retry import retry_until + +def test_cli_output(pane, textframe_snapshot): + """Test CLI output with visual snapshot.""" + pane.send_keys("echo 'Hello, World!'", enter=True) + + # Wait for output to appear + def output_appeared(): + return "Hello" in "\n".join(pane.capture_pane()) + retry_until(output_appeared, 2, raises=True) + + # Capture as frame for snapshot comparison + frame = pane.capture_frame(content_width=40, content_height=10) + assert frame == textframe_snapshot +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `start` | `int \| "-" \| None` | `None` | Starting line (same as `capture_pane`) | +| `end` | `int \| "-" \| None` | `None` | Ending line (same as `capture_pane`) | +| `content_width` | `int \| None` | Pane width | Frame width in characters | +| `content_height` | `int \| None` | Pane height | Frame height in lines | +| `overflow_behavior` | `"error" \| "truncate"` | `"truncate"` | How to handle overflow | + +### Design Decisions + +**Why `overflow_behavior="truncate"` by default?** + +Pane content can exceed nominal dimensions during: +- Terminal resize transitions +- Shell startup (MOTD, prompts) +- ANSI escape sequences in output + +Using `truncate` avoids spurious test failures in CI environments. + +**Why does it call `self.refresh()`?** + +Pane dimensions can change (resize, zoom). `refresh()` ensures we use current values when `content_width` or `content_height` are not specified. + +### Using with retry_until + +For asynchronous terminal output, combine with `retry_until`: + +```python +from libtmux.test.retry import retry_until + +def test_async_output(session): + """Wait for output using capture_frame in retry loop.""" + window = session.new_window() + pane = window.active_pane + + pane.send_keys('for i in 1 2 3; do echo "line $i"; done', enter=True) + + def all_lines_present(): + frame = pane.capture_frame(content_width=40, content_height=10) + rendered = frame.render() + return all(f"line {i}" in rendered for i in [1, 2, 3]) + + retry_until(all_lines_present, 3, raises=True) +``` + ## Public API ```python From 807df38a452205ce18be7200634409ed6f55b8c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 12:06:46 -0600 Subject: [PATCH 24/40] docs(CHANGES): Document Pane.capture_frame() for 0.52.x (#613) why: Document the new capture_frame() method for users. what: - Add Pane.capture_frame() section under New features - Include usage example with textframe_snapshot - Document key features (default dimensions, truncate mode) --- CHANGES | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGES b/CHANGES index 56396ba03..56859364b 100644 --- a/CHANGES +++ b/CHANGES @@ -97,6 +97,27 @@ def test_pane_output(textframe_snapshot): The `textframe_snapshot` fixture and assertion hooks are auto-discovered via pytest's `pytest11` entry point when the `textframe` extra is installed. +#### Pane.capture_frame() (#613) + +New {meth}`~libtmux.pane.Pane.capture_frame` method that wraps +{meth}`~libtmux.pane.Pane.capture_pane` and returns a +{class}`~libtmux.textframe.TextFrame` for visualization and snapshot testing. + +```python +def test_cli_output(pane, textframe_snapshot): + pane.send_keys("echo 'Hello'", enter=True) + + # Wait for output, then capture as frame + frame = pane.capture_frame(content_width=40, content_height=10) + assert frame == textframe_snapshot +``` + +**Features:** + +- Defaults to pane dimensions when `content_width` / `content_height` not specified +- Uses `overflow_behavior="truncate"` by default for CI robustness +- Accepts same `start` / `end` parameters as `capture_pane()` + ## libtmux 0.52.1 (2025-12-07) ### CI From f162f9239bbecd4b5a56bf953d6c6425c0b3535a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 12:12:06 -0600 Subject: [PATCH 25/40] tests(pane): Add exhaustive capture_frame() snapshot tests why: Comprehensive visual regression testing for all capture_frame() variations. what: - Add SnapshotCase NamedTuple for parametrized snapshot testing - Add 18 snapshot test cases covering: - Dimension variations: prompt_only, wide/narrow/tall/short frames - start/end parameters: start=0, end=0, end="-", start_end_range - Truncation: width and height truncation - Special characters and edge cases - Use retry_until for robust async output handling --- tests/test_pane_capture_frame.py | 318 +++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py index 1ef218738..eecb4cf60 100644 --- a/tests/test_pane_capture_frame.py +++ b/tests/test_pane_capture_frame.py @@ -367,3 +367,321 @@ def output_appeared() -> bool: truncated = line[: frame.content_width] if truncated.strip(): # Non-empty lines assert truncated in frame.render() + + +# ============================================================================= +# Exhaustive Snapshot Tests +# ============================================================================= + + +class SnapshotCase(t.NamedTuple): + """Snapshot test case for exhaustive capture_frame() variations. + + This NamedTuple defines the parameters for parametrized snapshot tests + that cover all combinations of capture_frame() options. + + Attributes + ---------- + test_id : str + Unique identifier for the test case, used in snapshot filenames. + command : str + Shell command to execute (empty string for prompt-only tests). + content_width : int + Frame width in characters. + content_height : int + Frame height in lines. + start : t.Literal["-"] | int | None + Starting line for capture (None = default). + end : t.Literal["-"] | int | None + Ending line for capture (None = default). + overflow_behavior : t.Literal["error", "truncate"] + How to handle content exceeding frame dimensions. + wait_for : str + String to wait for before capturing (ensures output is ready). + """ + + test_id: str + command: str + content_width: int + content_height: int + start: t.Literal["-"] | int | None + end: t.Literal["-"] | int | None + overflow_behavior: t.Literal["error", "truncate"] + wait_for: str + + +SNAPSHOT_CASES: list[SnapshotCase] = [ + # --- Dimension Variations --- + SnapshotCase( + test_id="prompt_only", + command="", + content_width=20, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="echo_simple", + command='echo "hello"', + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="hello", + ), + SnapshotCase( + test_id="echo_multiline", + command='printf "a\\nb\\nc\\n"', + content_width=20, + content_height=6, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="c", + ), + SnapshotCase( + test_id="wide_frame", + command='echo "test"', + content_width=60, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="test", + ), + SnapshotCase( + test_id="narrow_frame", + command='echo "test"', + content_width=10, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="test", + ), + SnapshotCase( + test_id="tall_frame", + command='echo "x"', + content_width=20, + content_height=10, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="x", + ), + SnapshotCase( + test_id="short_frame", + command='echo "x"', + content_width=20, + content_height=2, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="x", + ), + # --- Start/End Parameter Variations --- + SnapshotCase( + test_id="start_zero", + command='echo "line"', + content_width=30, + content_height=5, + start=0, + end=None, + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="end_zero", + command='echo "line"', + content_width=30, + content_height=3, + start=None, + end=0, + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="end_dash", + command='echo "line"', + content_width=30, + content_height=5, + start=None, + end="-", + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="start_end_range", + command='printf "L1\\nL2\\nL3\\nL4\\n"', + content_width=30, + content_height=5, + start=0, + end=2, + overflow_behavior="truncate", + wait_for="L4", + ), + # --- Truncation Behavior --- + SnapshotCase( + test_id="truncate_width", + command='echo "' + "x" * 50 + '"', + content_width=15, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="xxxx", + ), + SnapshotCase( + test_id="truncate_height", + command='printf "L1\\nL2\\nL3\\nL4\\nL5\\nL6\\nL7\\nL8\\nL9\\nL10\\n"', + content_width=30, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="L10", + ), + # --- Special Characters --- + SnapshotCase( + test_id="special_chars", + command='echo "!@#$%"', + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="!@#$%", + ), + SnapshotCase( + test_id="unicode_basic", + command='echo "cafe"', # Using ASCII to avoid shell encoding issues + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="cafe", + ), + # --- Edge Cases --- + SnapshotCase( + test_id="empty_lines", + command='printf "\\n\\n\\n"', + content_width=20, + content_height=6, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="spaces_only", + command='echo " "', + content_width=20, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="mixed_content", + command='echo "abc 123 !@#"', + content_width=30, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="abc 123 !@#", + ), +] + + +@pytest.mark.parametrize( + list(SnapshotCase._fields), + SNAPSHOT_CASES, + ids=[case.test_id for case in SNAPSHOT_CASES], +) +def test_capture_frame_snapshot_parametrized( + test_id: str, + command: str, + content_width: int, + content_height: int, + start: t.Literal["-"] | int | None, + end: t.Literal["-"] | int | None, + overflow_behavior: t.Literal["error", "truncate"], + wait_for: str, + session: Session, + snapshot: SnapshotAssertion, +) -> None: + """Exhaustive snapshot tests for capture_frame() parameter variations. + + This parametrized test covers all combinations of capture_frame() options + including dimensions, start/end parameters, truncation, special characters, + and edge cases. + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + command : str + Shell command to execute (empty for prompt-only). + content_width : int + Frame width in characters. + content_height : int + Frame height in lines. + start : t.Literal["-"] | int | None + Starting line for capture. + end : t.Literal["-"] | int | None + Ending line for capture. + overflow_behavior : t.Literal["error", "truncate"] + How to handle overflow. + wait_for : str + String to wait for before capturing. + session : Session + pytest fixture providing tmux session. + snapshot : SnapshotAssertion + syrupy snapshot fixture with TextFrameExtension. + """ + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name=f"snap_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Wait for shell prompt to appear + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Send command if provided + if command: + pane.send_keys(command, literal=True, suppress_history=False) + + # Wait for expected content + if wait_for: + + def content_ready() -> bool: + return wait_for in "\n".join(pane.capture_pane()) + + retry_until(content_ready, 2, raises=True) + + # Capture frame with specified parameters + frame = pane.capture_frame( + start=start, + end=end, + content_width=content_width, + content_height=content_height, + overflow_behavior=overflow_behavior, + ) + + # Compare against snapshot + assert frame == snapshot From a9e01607f1989a4d8882629e60ca1527121df65d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 12:12:13 -0600 Subject: [PATCH 26/40] tests(pane): Add capture_frame snapshot baselines why: Baseline snapshots for exhaustive visual regression testing. what: - Add 18 .frame snapshot files for parametrized test cases - Covers dimensions, start/end params, truncation, special chars --- ...frame_snapshot_parametrized[echo_multiline].frame | 8 ++++++++ ...re_frame_snapshot_parametrized[echo_simple].frame | 6 ++++++ ...re_frame_snapshot_parametrized[empty_lines].frame | 8 ++++++++ ...pture_frame_snapshot_parametrized[end_dash].frame | 7 +++++++ ...pture_frame_snapshot_parametrized[end_zero].frame | 5 +++++ ..._frame_snapshot_parametrized[mixed_content].frame | 6 ++++++ ...e_frame_snapshot_parametrized[narrow_frame].frame | 5 +++++ ...re_frame_snapshot_parametrized[prompt_only].frame | 5 +++++ ...re_frame_snapshot_parametrized[short_frame].frame | 4 ++++ ...re_frame_snapshot_parametrized[spaces_only].frame | 6 ++++++ ..._frame_snapshot_parametrized[special_chars].frame | 6 ++++++ ...rame_snapshot_parametrized[start_end_range].frame | 7 +++++++ ...ure_frame_snapshot_parametrized[start_zero].frame | 7 +++++++ ...ure_frame_snapshot_parametrized[tall_frame].frame | 12 ++++++++++++ ...rame_snapshot_parametrized[truncate_height].frame | 5 +++++ ...frame_snapshot_parametrized[truncate_width].frame | 6 ++++++ ..._frame_snapshot_parametrized[unicode_basic].frame | 6 ++++++ ...ure_frame_snapshot_parametrized[wide_frame].frame | 5 +++++ 18 files changed, 114 insertions(+) create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame new file mode 100644 index 000000000..d23c10d5e --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame @@ -0,0 +1,8 @@ ++--------------------+ +|$ printf "a\nb\nc\n"| +|a | +|b | +|c | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame new file mode 100644 index 000000000..6e6b48dbd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "hello" | +|hello | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame new file mode 100644 index 000000000..07fc5d743 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame @@ -0,0 +1,8 @@ ++--------------------+ +|$ printf "\n\n\n" | +| | +| | +| | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame new file mode 100644 index 000000000..15a62ba31 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "line" | +|line | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame new file mode 100644 index 000000000..996b8b5cb --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame @@ -0,0 +1,5 @@ ++------------------------------+ +|$ echo "line" | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame new file mode 100644 index 000000000..cb6870cdd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame @@ -0,0 +1,6 @@ ++------------------------------+ +|$ echo "abc 123 !@#" | +|abc 123 !@# | +|$ | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame new file mode 100644 index 000000000..0a608abfd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame @@ -0,0 +1,5 @@ ++----------+ +|$ echo "te| +|test | +|$ | ++----------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame new file mode 100644 index 000000000..c8298bd59 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame @@ -0,0 +1,5 @@ ++--------------------+ +|$ | +| | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame new file mode 100644 index 000000000..71092ada4 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame @@ -0,0 +1,4 @@ ++--------------------+ +|$ echo "x" | +|x | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame new file mode 100644 index 000000000..9bb6e1592 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame @@ -0,0 +1,6 @@ ++--------------------+ +|$ echo " " | +| | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame new file mode 100644 index 000000000..18574aaae --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "!@#$%" | +|!@#$% | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame new file mode 100644 index 000000000..dd79e6623 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ printf "L1\nL2\nL3\nL4\n" | +|L1 | +|L2 | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame new file mode 100644 index 000000000..15a62ba31 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "line" | +|line | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame new file mode 100644 index 000000000..354fe9fb4 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame @@ -0,0 +1,12 @@ ++--------------------+ +|$ echo "x" | +|x | +|$ | +| | +| | +| | +| | +| | +| | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame new file mode 100644 index 000000000..21b5dfc9a --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame @@ -0,0 +1,5 @@ ++------------------------------+ +|$ printf "L1\nL2\nL3\nL4\nL5\n| +|L1 | +|L2 | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame new file mode 100644 index 000000000..aeecfa7af --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame @@ -0,0 +1,6 @@ ++---------------+ +|$ echo "xxxxxxx| +|xxxxxxxxxxxxxxx| +|$ | +| | ++---------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame new file mode 100644 index 000000000..a018a9f32 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "cafe" | +|cafe | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame new file mode 100644 index 000000000..71ee6aed9 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame @@ -0,0 +1,5 @@ ++------------------------------------------------------------+ +|$ echo "test" | +|test | +|$ | ++------------------------------------------------------------+ \ No newline at end of file From 748173b079f760d54e3a7e344fa22761bd47c2a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 13:51:07 -0600 Subject: [PATCH 27/40] docs(CHANGES): Add visual examples for capture_frame() why: Show actual frame output to help users understand the feature. what: - Add basic usage example with rendered ASCII frame output - Add multiline output example demonstrating printf capture - Add truncation example showing long lines clipped to frame width - Reorganize into sections: Basic, Multiline, Truncation, Snapshot testing --- CHANGES | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 56859364b..5a5f05295 100644 --- a/CHANGES +++ b/CHANGES @@ -103,11 +103,56 @@ New {meth}`~libtmux.pane.Pane.capture_frame` method that wraps {meth}`~libtmux.pane.Pane.capture_pane` and returns a {class}`~libtmux.textframe.TextFrame` for visualization and snapshot testing. +**Basic usage:** + +```python +pane.send_keys('echo "Hello, TextFrame!"', enter=True) +frame = pane.capture_frame(content_width=30, content_height=5) +print(frame.render()) +# +------------------------------+ +# |$ echo "Hello, TextFrame!" | +# |Hello, TextFrame! | +# |$ | +# | | +# | | +# +------------------------------+ +``` + +**Multiline output:** + +```python +pane.send_keys('printf "a\\nb\\nc\\n"', enter=True) +frame = pane.capture_frame(content_width=20, content_height=6) +print(frame.render()) +# +--------------------+ +# |$ printf "a\nb\nc\n"| +# |a | +# |b | +# |c | +# |$ | +# | | +# +--------------------+ +``` + +**Truncation (long lines clipped to frame width):** + +```python +pane.send_keys('echo "' + "x" * 50 + '"', enter=True) +frame = pane.capture_frame(content_width=15, content_height=4) +print(frame.render()) +# +---------------+ +# |$ echo "xxxxxxx| +# |xxxxxxxxxxxxxxx| +# |$ | +# | | +# +---------------+ +``` + +**Snapshot testing:** + ```python def test_cli_output(pane, textframe_snapshot): pane.send_keys("echo 'Hello'", enter=True) - - # Wait for output, then capture as frame frame = pane.capture_frame(content_width=40, content_height=10) assert frame == textframe_snapshot ``` From 4530e8fd967541644f43b5b529d4025e37f83181 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 13:57:47 -0600 Subject: [PATCH 28/40] Pane(docs[capture_frame]): Fix doctest to work without SKIP why: Enable doctest verification of capture_frame() output. what: - Create new pane with shell='sh' for predictable prompt - Remove # doctest: +SKIP since output is now deterministic - Follow established pattern from capture_pane() doctest --- src/libtmux/pane.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index ada92fd47..a4482a525 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -468,13 +468,14 @@ def capture_frame( Examples -------- + >>> pane = window.split(shell='sh') >>> pane.send_keys('echo "Hello"', enter=True) >>> import time; time.sleep(0.1) >>> frame = pane.capture_frame(content_width=20, content_height=5) >>> 'Hello' in frame.render() True - >>> print(frame.render()) # doctest: +SKIP + >>> print(frame.render()) +--------------------+ |$ echo "Hello" | |Hello | From 557681c857d3bf71e24d7144adf1e308da56a9f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 16:09:35 -0600 Subject: [PATCH 29/40] Pane(feat[capture_frame]): Forward capture_pane() flags why: Allow users to control capture behavior when using capture_frame() for snapshot testing, such as capturing colored output or joining wrapped lines. what: - Add escape_sequences parameter for ANSI escape sequences - Add escape_non_printable parameter for octal escapes - Add join_wrapped parameter for joining wrapped lines - Add preserve_trailing parameter for trailing spaces - Add trim_trailing parameter with tmux 3.4+ version check - Forward all flags to capture_pane() call --- src/libtmux/pane.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index a4482a525..82afb3ae2 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -430,8 +430,13 @@ def capture_frame( content_width: int | None = None, content_height: int | None = None, overflow_behavior: OverflowBehavior = "truncate", + escape_sequences: bool = False, + escape_non_printable: bool = False, + join_wrapped: bool = False, + preserve_trailing: bool = False, + trim_trailing: bool = False, ) -> TextFrame: - """Capture pane content as a TextFrame. + r"""Capture pane content as a TextFrame. Combines :meth:`capture_pane` with :class:`~libtmux.textframe.TextFrame` for visualization and snapshot testing. @@ -460,6 +465,19 @@ def capture_frame( How to handle content that exceeds frame dimensions. Defaults to ``"truncate"`` since pane content may exceed nominal dimensions during terminal transitions. + escape_sequences : bool, optional + Include ANSI escape sequences for text and background attributes. + Useful for capturing colored output. Default: False + escape_non_printable : bool, optional + Escape non-printable characters as octal ``\\xxx`` format. + Default: False + join_wrapped : bool, optional + Join wrapped lines back together. Default: False + preserve_trailing : bool, optional + Preserve trailing spaces at each line's end. Default: False + trim_trailing : bool, optional + Trim trailing positions with no characters. + Requires tmux 3.4+. Default: False Returns ------- @@ -486,8 +504,16 @@ def capture_frame( """ from libtmux.textframe import TextFrame as TextFrameClass - # Capture content - lines = self.capture_pane(start=start, end=end) + # Capture content with all flags forwarded + lines = self.capture_pane( + start=start, + end=end, + escape_sequences=escape_sequences, + escape_non_printable=escape_non_printable, + join_wrapped=join_wrapped, + preserve_trailing=preserve_trailing, + trim_trailing=trim_trailing, + ) # Use pane dimensions if not specified self.refresh() From 49aa016209bea50e1e18f02fcc6d47b30d936cc8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 16:09:43 -0600 Subject: [PATCH 30/40] tests(pane): Add capture_frame() flag forwarding tests why: Verify that capture_frame() correctly forwards all capture_pane() flags for proper behavior in snapshot testing scenarios. what: - Add CaptureFrameFlagCase NamedTuple for parametrized tests - Add 4 test cases covering key flag behaviors - Test escape_sequences, join_wrapped, preserve_trailing flags - Verify flag absence behavior (no_escape_sequences) --- tests/test_pane_capture_frame.py | 178 +++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py index eecb4cf60..43ba686e9 100644 --- a/tests/test_pane_capture_frame.py +++ b/tests/test_pane_capture_frame.py @@ -685,3 +685,181 @@ def content_ready() -> bool: # Compare against snapshot assert frame == snapshot + + +# ============================================================================= +# Flag Forwarding Tests +# ============================================================================= + + +class CaptureFrameFlagCase(t.NamedTuple): + """Test case for capture_frame() flag forwarding to capture_pane().""" + + test_id: str + command: str + escape_sequences: bool + escape_non_printable: bool + join_wrapped: bool + preserve_trailing: bool + trim_trailing: bool + expected_pattern: str | None + not_expected_pattern: str | None + min_tmux_version: str | None + + +CAPTURE_FRAME_FLAG_CASES: list[CaptureFrameFlagCase] = [ + CaptureFrameFlagCase( + test_id="escape_sequences_color", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[31m", + not_expected_pattern=None, + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="no_escape_sequences", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"RED", + not_expected_pattern=r"\x1b\[", + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="join_wrapped_long_line", + command="printf '%s' \"$(seq 1 30 | tr -d '\\n')\"", + escape_sequences=False, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=False, + trim_trailing=False, + # With join_wrapped, wrapped lines are joined - verify contiguous sequence + expected_pattern=r"123456789101112131415161718192021222324252627282930", + not_expected_pattern=None, + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="preserve_trailing_spaces", + command='printf "text \\n"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=True, + trim_trailing=False, + expected_pattern=r"text ", + not_expected_pattern=None, + min_tmux_version=None, + ), +] + + +@pytest.mark.parametrize( + list(CaptureFrameFlagCase._fields), + CAPTURE_FRAME_FLAG_CASES, + ids=[case.test_id for case in CAPTURE_FRAME_FLAG_CASES], +) +def test_capture_frame_flag_forwarding( + test_id: str, + command: str, + escape_sequences: bool, + escape_non_printable: bool, + join_wrapped: bool, + preserve_trailing: bool, + trim_trailing: bool, + expected_pattern: str | None, + not_expected_pattern: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Test that capture_frame() correctly forwards flags to capture_pane(). + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + command : str + Shell command to execute. + escape_sequences : bool + Include ANSI escape sequences. + escape_non_printable : bool + Escape non-printable characters. + join_wrapped : bool + Join wrapped lines. + preserve_trailing : bool + Preserve trailing spaces. + trim_trailing : bool + Trim trailing positions. + expected_pattern : str | None + Regex pattern expected in output. + not_expected_pattern : str | None + Regex pattern that should NOT be in output. + min_tmux_version : str | None + Minimum tmux version required. + session : Session + pytest fixture providing tmux session. + """ + import re + + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + env = shutil.which("env") + assert env is not None + + window = session.new_window( + attach=True, + window_name=f"flag_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Wait for shell prompt + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Send command and wait for completion marker + marker = f"__DONE_{test_id}__" + pane.send_keys(f"{command}; echo {marker}", literal=True) + + def marker_ready() -> bool: + return marker in "\n".join(pane.capture_pane()) + + retry_until(marker_ready, 3, raises=True) + + # Capture frame with specified flags + frame = pane.capture_frame( + content_width=80, + content_height=24, + escape_sequences=escape_sequences, + escape_non_printable=escape_non_printable, + join_wrapped=join_wrapped, + preserve_trailing=preserve_trailing, + trim_trailing=trim_trailing, + ) + + # Get rendered content (without frame borders) + rendered = frame.render() + + # Verify expected pattern + if expected_pattern: + assert re.search(expected_pattern, rendered, re.DOTALL), ( + f"Expected pattern '{expected_pattern}' not found in:\n{rendered}" + ) + + # Verify not_expected pattern is absent + if not_expected_pattern: + assert not re.search(not_expected_pattern, rendered, re.DOTALL), ( + f"Unexpected pattern '{not_expected_pattern}' found in:\n{rendered}" + ) From 3d16cee809f26d64fbb44f4420efe4522dd4e524 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 16:09:51 -0600 Subject: [PATCH 31/40] docs(CHANGES): Document capture_frame() flag forwarding why: Document the new capture_frame() parameters for users. what: - Add flag forwarding bullet point to capture_frame() feature list --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 5a5f05295..824b9bddd 100644 --- a/CHANGES +++ b/CHANGES @@ -162,6 +162,8 @@ def test_cli_output(pane, textframe_snapshot): - Defaults to pane dimensions when `content_width` / `content_height` not specified - Uses `overflow_behavior="truncate"` by default for CI robustness - Accepts same `start` / `end` parameters as `capture_pane()` +- Forwards all `capture_pane()` flags: `escape_sequences`, `escape_non_printable`, + `join_wrapped`, `preserve_trailing`, `trim_trailing` ## libtmux 0.52.1 (2025-12-07) From 90010ab06b88c3a1ac7e74d7a8427dc43ebf7f4a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 18:35:12 -0600 Subject: [PATCH 32/40] TextFrame(feat[display]): Add interactive curses viewer why: Enable interactive exploration of large frame content in terminal what: - Add display() method with TTY detection - Add _curses_display() with scrolling support - Navigation: arrows, WASD, vim keys (hjkl) - Page navigation: PgUp/PgDn, Home/End - Exit: q, Esc, Ctrl-C --- src/libtmux/textframe/core.py | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index e6e94056d..1190f7800 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -6,6 +6,9 @@ from __future__ import annotations +import contextlib +import curses +import sys import typing as t from dataclasses import dataclass, field @@ -189,3 +192,122 @@ def _draw_frame(self, lines: list[str], w: int, h: int) -> str: line = lines[r] if r < len(lines) else "" body.append(f"|{line.ljust(w, self.fill_char)}|") return "\n".join([border, *body, border]) + + def display(self) -> None: + """Display frame in interactive scrollable curses viewer. + + Opens a full-screen terminal viewer with scrolling support for + exploring large frame content interactively. + + Controls + -------- + Navigation: + - Arrow keys: Scroll up/down/left/right + - w/a/s/d: Scroll up/left/down/right + - k/h/j/l (vim): Scroll up/left/down/right + - PgUp/PgDn: Page up/down + - Home/End: Jump to top/bottom + + Exit: + - q: Quit + - Esc: Quit + - Ctrl-C: Quit + + Raises + ------ + RuntimeError + If stdout is not a TTY (not an interactive terminal). + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> frame = pane.capture_frame() + >>> frame.display() # Opens interactive viewer # doctest: +SKIP + """ + if not sys.stdout.isatty(): + msg = "display() requires an interactive terminal" + raise RuntimeError(msg) + + curses.wrapper(self._curses_display) + + def _curses_display(self, stdscr: curses.window) -> None: + """Curses main loop for interactive display. + + Parameters + ---------- + stdscr : curses.window + The curses standard screen window. + """ + curses.curs_set(0) # Hide cursor + + # Render full frame once + rendered = self.render() + lines = rendered.split("\n") + + # Scroll state + scroll_y = 0 + scroll_x = 0 + + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + # Calculate scroll bounds + max_scroll_y = max(0, len(lines) - max_y + 1) + max_scroll_x = max(0, max(len(line) for line in lines) - max_x) + + # Clamp scroll position + scroll_y = max(0, min(scroll_y, max_scroll_y)) + scroll_x = max(0, min(scroll_x, max_scroll_x)) + + # Draw visible portion + for i, line in enumerate(lines[scroll_y : scroll_y + max_y - 1]): + if i >= max_y - 1: + break + display_line = line[scroll_x : scroll_x + max_x] + with contextlib.suppress(curses.error): + stdscr.addstr(i, 0, display_line) + + # Status line + status = ( + f" [{scroll_y + 1}/{len(lines)}] " + f"{self.content_width}x{self.content_height} | q:quit " + ) + with contextlib.suppress(curses.error): + stdscr.addstr(max_y - 1, 0, status[: max_x - 1], curses.A_REVERSE) + + stdscr.refresh() + + # Handle input + try: + key = stdscr.getch() + except KeyboardInterrupt: + break + + # Exit keys + if key in (ord("q"), 27): # q or Esc + break + + # Vertical navigation + if key in (curses.KEY_UP, ord("w"), ord("k")): + scroll_y = max(0, scroll_y - 1) + elif key in (curses.KEY_DOWN, ord("s"), ord("j")): + scroll_y = min(max_scroll_y, scroll_y + 1) + + # Horizontal navigation + elif key in (curses.KEY_LEFT, ord("a"), ord("h")): + scroll_x = max(0, scroll_x - 1) + elif key in (curses.KEY_RIGHT, ord("d"), ord("l")): + scroll_x = min(max_scroll_x, scroll_x + 1) + + # Page navigation + elif key == curses.KEY_PPAGE: # Page Up + scroll_y = max(0, scroll_y - (max_y - 2)) + elif key == curses.KEY_NPAGE: # Page Down + scroll_y = min(max_scroll_y, scroll_y + (max_y - 2)) + + # Jump navigation + elif key == curses.KEY_HOME: + scroll_y = 0 + elif key == curses.KEY_END: + scroll_y = max_scroll_y From 3dc3375d8de89fe37bd89a7d336fbb7244af42c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 18:37:14 -0600 Subject: [PATCH 33/40] docs(textframe): Document display() method why: Enable users to discover interactive viewer feature what: - Add Interactive Display section with usage example - Document all keyboard controls in table format - Note TTY requirement and RuntimeError behavior --- docs/internals/textframe.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 69bcecca2..f9e5f23b9 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -55,6 +55,30 @@ Output: +----------+ ``` +### Interactive Display + +For exploring large frames interactively, use `display()` to open a scrollable curses viewer: + +```python +frame = TextFrame(content_width=80, content_height=50) +frame.set_content(["line %d" % i for i in range(50)]) +frame.display() # Opens interactive viewer +``` + +**Controls:** + +| Key | Action | +|-----|--------| +| ↑/↓ or w/s or k/j | Scroll up/down | +| ←/→ or a/d or h/l | Scroll left/right | +| PgUp/PgDn | Page up/down | +| Home/End | Jump to top/bottom | +| q, Esc, Ctrl-C | Quit | + +The viewer shows a status bar at the bottom with scroll position, frame dimensions, and help text. + +**Note:** `display()` requires an interactive terminal (TTY). It raises `RuntimeError` if stdout is not a TTY (e.g., when piped or in CI environments). + ### Overflow Behavior TextFrame supports two overflow behaviors: From 398f9fd91b2e003baa6af0f485166f0ce6c51834 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 18:38:12 -0600 Subject: [PATCH 34/40] docs(CHANGES): Document TextFrame.display() why: Include display() in 0.53.x feature list what: - Add interactive curses viewer to TextFrame features --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 824b9bddd..3a1d7098f 100644 --- a/CHANGES +++ b/CHANGES @@ -58,6 +58,7 @@ print(frame.render()) - Overflow handling: `overflow_behavior="error"` raises {class}`~libtmux.textframe.ContentOverflowError` with visual diagnostic, `overflow_behavior="truncate"` clips content silently - Dimension validation via `__post_init__` +- Interactive curses viewer via `display()` for exploring large frames #### pytest assertion hook for TextFrame (#613) From 79e423642dafbc35d706b8991a20c2821a15d9ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:34:00 -0600 Subject: [PATCH 35/40] TextFrame(fix[display]): Use shutil for terminal size detection why: curses KEY_RESIZE only fires on getch(), missing resize events when terminal is resized but no key is pressed what: - Replace stdscr.getmaxyx() with shutil.get_terminal_size() - Remove KEY_RESIZE handling (now redundant) This follows Rich's approach: query terminal size directly via ioctl(TIOCGWINSZ) on each loop iteration, which works reliably in tmux and other terminal multiplexers. --- src/libtmux/textframe/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index 1190f7800..eccb40d21 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -8,6 +8,7 @@ import contextlib import curses +import shutil import sys import typing as t from dataclasses import dataclass, field @@ -250,7 +251,10 @@ def _curses_display(self, stdscr: curses.window) -> None: while True: stdscr.clear() - max_y, max_x = stdscr.getmaxyx() + + # Query terminal size directly (handles resize without signals) + term_size = shutil.get_terminal_size() + max_x, max_y = term_size.columns, term_size.lines # Calculate scroll bounds max_scroll_y = max(0, len(lines) - max_y + 1) From d335fc9011485025a96033137fe7f95fb8fad278 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:34:06 -0600 Subject: [PATCH 36/40] tests(textframe): Add shutil terminal size detection test why: Verify display() uses shutil.get_terminal_size() for resize what: - Add test_terminal_resize_via_shutil test - Mock shutil.get_terminal_size to verify it's called --- tests/textframe/test_display.py | 152 ++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/textframe/test_display.py diff --git a/tests/textframe/test_display.py b/tests/textframe/test_display.py new file mode 100644 index 000000000..d0c96278d --- /dev/null +++ b/tests/textframe/test_display.py @@ -0,0 +1,152 @@ +"""Tests for TextFrame.display() interactive viewer.""" + +from __future__ import annotations + +import curses +import io +import os +import typing as t +from unittest.mock import MagicMock, patch + +import pytest + +from libtmux.textframe import TextFrame + + +class ExitKeyCase(t.NamedTuple): + """Test case for exit key handling.""" + + id: str + key: int | None + side_effect: type[BaseException] | None = None + + +EXIT_KEY_CASES: tuple[ExitKeyCase, ...] = ( + ExitKeyCase( + id="quit_on_q", + key=ord("q"), + ), + ExitKeyCase( + id="quit_on_escape", + key=27, + ), + ExitKeyCase( + id="quit_on_ctrl_c", + key=None, + side_effect=KeyboardInterrupt, + ), +) + + +@pytest.fixture +def mock_curses_env() -> t.Generator[None, None, None]: + """Mock curses module-level functions that require initscr().""" + with ( + patch("curses.curs_set"), + patch("curses.A_REVERSE", 0), + ): + yield + + +def test_display_raises_when_not_tty() -> None: + """Verify display() raises RuntimeError when stdout is not a TTY.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + with ( + patch("sys.stdout", new=io.StringIO()), + pytest.raises(RuntimeError, match="interactive terminal"), + ): + frame.display() + + +def test_display_calls_curses_wrapper_when_tty() -> None: + """Verify display() calls curses.wrapper when stdout is a TTY.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + with ( + patch("sys.stdout.isatty", return_value=True), + patch("curses.wrapper") as mock_wrapper, + ): + frame.display() + mock_wrapper.assert_called_once() + args = mock_wrapper.call_args[0] + assert args[0].__name__ == "_curses_display" + + +@pytest.mark.parametrize("case", EXIT_KEY_CASES, ids=lambda c: c.id) +def test_curses_display_exit_keys( + case: ExitKeyCase, + mock_curses_env: None, +) -> None: + """Verify viewer exits on various exit keys/events.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + + if case.side_effect: + mock_stdscr.getch.side_effect = case.side_effect + else: + mock_stdscr.getch.return_value = case.key + + # Should exit cleanly without error + frame._curses_display(mock_stdscr) + mock_stdscr.clear.assert_called() + + +def test_curses_display_scroll_navigation(mock_curses_env: None) -> None: + """Verify scroll navigation works with arrow keys.""" + frame = TextFrame(content_width=10, content_height=10) + frame.set_content([f"line {i}" for i in range(10)]) + + mock_stdscr = MagicMock() + + # Simulate: down arrow, then quit + mock_stdscr.getch.side_effect = [curses.KEY_DOWN, ord("q")] + + frame._curses_display(mock_stdscr) + + # Verify multiple refresh cycles occurred (initial + after navigation) + assert mock_stdscr.refresh.call_count >= 2 + + +def test_curses_display_status_line(mock_curses_env: None) -> None: + """Verify status line shows position and dimensions.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + mock_stdscr.getch.return_value = ord("q") + + frame._curses_display(mock_stdscr) + + # Find the addstr call that contains status info + status_calls = [ + call + for call in mock_stdscr.addstr.call_args_list + if len(call[0]) >= 3 and "q:quit" in str(call[0][2]) + ] + assert len(status_calls) > 0, "Status line should be displayed" + + +def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None: + """Verify terminal size is queried via shutil.get_terminal_size(). + + This approach works reliably in tmux/multiplexers because it directly + queries the terminal via ioctl(TIOCGWINSZ) on each loop iteration, + rather than relying on curses KEY_RESIZE events. + """ + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + mock_stdscr.getch.return_value = ord("q") + + with patch( + "libtmux.textframe.core.shutil.get_terminal_size", + return_value=os.terminal_size((120, 40)), + ) as mock_get_size: + frame._curses_display(mock_stdscr) + mock_get_size.assert_called() From cdb274fa48c56c6c54012fcd0c9d9b91a4f24a72 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:50:05 -0600 Subject: [PATCH 37/40] textframe(fix): Make TextFrameExtension import conditional why: Users without libtmux[textframe] get ImportError on capture_frame() what: - Wrap TextFrameExtension import in try/except ImportError - Only add to __all__ when syrupy is available - Core TextFrame functionality works without optional dependency --- src/libtmux/textframe/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py index bd5c31144..a1331b362 100644 --- a/src/libtmux/textframe/__init__.py +++ b/src/libtmux/textframe/__init__.py @@ -3,6 +3,13 @@ from __future__ import annotations from libtmux.textframe.core import ContentOverflowError, TextFrame -from libtmux.textframe.plugin import TextFrameExtension -__all__ = ["ContentOverflowError", "TextFrame", "TextFrameExtension"] +__all__ = ["ContentOverflowError", "TextFrame"] + +# Conditionally export TextFrameExtension when syrupy is available +try: + from libtmux.textframe.plugin import TextFrameExtension + + __all__.append("TextFrameExtension") +except ImportError: + pass From e8c7ca892b45f73b7a60c2c64df8adf76b0c9bc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:50:34 -0600 Subject: [PATCH 38/40] textframe(fix): Inherit ContentOverflowError from LibTmuxException why: Follow established exception pattern for libtmux exceptions what: - Add LibTmuxException as base class alongside ValueError - Matches pattern of AdjustmentDirectionRequiresAdjustment, etc. - Enables catching all libtmux exceptions with LibTmuxException --- src/libtmux/textframe/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index eccb40d21..2118992c3 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -13,13 +13,15 @@ import typing as t from dataclasses import dataclass, field +from libtmux.exc import LibTmuxException + if t.TYPE_CHECKING: from collections.abc import Sequence OverflowBehavior = t.Literal["error", "truncate"] -class ContentOverflowError(ValueError): +class ContentOverflowError(LibTmuxException, ValueError): """Raised when content does not fit into the configured frame dimensions. Attributes From 50a6f9767d77c04787997b8e509ac2077c990efa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:51:08 -0600 Subject: [PATCH 39/40] textframe(style): Use namespace import for difflib why: Follow CLAUDE.md guideline for stdlib namespace imports what: - Change from difflib import ndiff to import difflib - Use difflib.ndiff() instead of ndiff() --- src/libtmux/textframe/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/textframe/plugin.py b/src/libtmux/textframe/plugin.py index 917176526..14eef48b1 100644 --- a/src/libtmux/textframe/plugin.py +++ b/src/libtmux/textframe/plugin.py @@ -11,8 +11,8 @@ from __future__ import annotations +import difflib import typing as t -from difflib import ndiff import pytest from syrupy.assertion import SnapshotAssertion @@ -120,7 +120,7 @@ def pytest_assertrepr_compare( if left_render != right_render: lines.append("") lines.append("Content diff:") - lines.extend(ndiff(right_render, left_render)) + lines.extend(difflib.ndiff(right_render, left_render)) return lines From 912288b4123c20caef9810d447a395e39137ad6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:53:02 -0600 Subject: [PATCH 40/40] tests(textframe): Replace patch() with monkeypatch.setattr() why: Follow pytest best practices from CLAUDE.md guidelines what: - Use import unittest.mock namespace style - Replace patch() context managers with monkeypatch.setattr() - Document MagicMock necessity for curses window simulation --- tests/textframe/test_display.py | 74 +++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/tests/textframe/test_display.py b/tests/textframe/test_display.py index d0c96278d..2b91bafc4 100644 --- a/tests/textframe/test_display.py +++ b/tests/textframe/test_display.py @@ -1,12 +1,19 @@ -"""Tests for TextFrame.display() interactive viewer.""" +"""Tests for TextFrame.display() interactive viewer. + +Note on MagicMock usage: These tests require MagicMock to create mock curses.window +objects with configurable return values and side effects. pytest's monkeypatch +fixture patches existing attributes but doesn't create mock objects with the +call tracking and behavior configuration needed for curses window simulation. +""" from __future__ import annotations import curses import io import os +import sys import typing as t -from unittest.mock import MagicMock, patch +import unittest.mock import pytest @@ -39,40 +46,38 @@ class ExitKeyCase(t.NamedTuple): @pytest.fixture -def mock_curses_env() -> t.Generator[None, None, None]: +def mock_curses_env(monkeypatch: pytest.MonkeyPatch) -> None: """Mock curses module-level functions that require initscr().""" - with ( - patch("curses.curs_set"), - patch("curses.A_REVERSE", 0), - ): - yield + monkeypatch.setattr(curses, "curs_set", lambda x: None) + monkeypatch.setattr(curses, "A_REVERSE", 0) -def test_display_raises_when_not_tty() -> None: +def test_display_raises_when_not_tty(monkeypatch: pytest.MonkeyPatch) -> None: """Verify display() raises RuntimeError when stdout is not a TTY.""" frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - with ( - patch("sys.stdout", new=io.StringIO()), - pytest.raises(RuntimeError, match="interactive terminal"), - ): + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + with pytest.raises(RuntimeError, match="interactive terminal"): frame.display() -def test_display_calls_curses_wrapper_when_tty() -> None: +def test_display_calls_curses_wrapper_when_tty( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Verify display() calls curses.wrapper when stdout is a TTY.""" frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - with ( - patch("sys.stdout.isatty", return_value=True), - patch("curses.wrapper") as mock_wrapper, - ): - frame.display() - mock_wrapper.assert_called_once() - args = mock_wrapper.call_args[0] - assert args[0].__name__ == "_curses_display" + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + mock_wrapper = unittest.mock.MagicMock() + monkeypatch.setattr(curses, "wrapper", mock_wrapper) + + frame.display() + mock_wrapper.assert_called_once() + args = mock_wrapper.call_args[0] + assert args[0].__name__ == "_curses_display" @pytest.mark.parametrize("case", EXIT_KEY_CASES, ids=lambda c: c.id) @@ -84,7 +89,7 @@ def test_curses_display_exit_keys( frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() if case.side_effect: mock_stdscr.getch.side_effect = case.side_effect @@ -101,7 +106,7 @@ def test_curses_display_scroll_navigation(mock_curses_env: None) -> None: frame = TextFrame(content_width=10, content_height=10) frame.set_content([f"line {i}" for i in range(10)]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() # Simulate: down arrow, then quit mock_stdscr.getch.side_effect = [curses.KEY_DOWN, ord("q")] @@ -117,7 +122,7 @@ def test_curses_display_status_line(mock_curses_env: None) -> None: frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() mock_stdscr.getch.return_value = ord("q") frame._curses_display(mock_stdscr) @@ -131,7 +136,10 @@ def test_curses_display_status_line(mock_curses_env: None) -> None: assert len(status_calls) > 0, "Status line should be displayed" -def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None: +def test_curses_display_uses_shutil_terminal_size( + mock_curses_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: """Verify terminal size is queried via shutil.get_terminal_size(). This approach works reliably in tmux/multiplexers because it directly @@ -141,12 +149,14 @@ def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() mock_stdscr.getch.return_value = ord("q") - with patch( + mock_get_size = unittest.mock.MagicMock(return_value=os.terminal_size((120, 40))) + monkeypatch.setattr( "libtmux.textframe.core.shutil.get_terminal_size", - return_value=os.terminal_size((120, 40)), - ) as mock_get_size: - frame._curses_display(mock_stdscr) - mock_get_size.assert_called() + mock_get_size, + ) + + frame._curses_display(mock_stdscr) + mock_get_size.assert_called()