Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f080a16
py(deps[dev]): Add syrupy for snapshot testing
tony Dec 6, 2025
68893ba
tests(textframe): Add TextFrame ASCII frame prototype
tony Dec 6, 2025
89578c1
tests(textframe): Add snapshot baselines
tony Dec 6, 2025
7167311
TextFrame(feat[__post_init__]): Add dimension and fill_char validation
tony Dec 7, 2025
2d1aa73
TextFrame(feat[overflow_behavior]): Add truncate mode for content ove…
tony Dec 7, 2025
c7b481e
tests(textframe): Add truncate behavior test cases
tony Dec 7, 2025
af1fea7
tests(textframe): Add snapshot baselines for truncate tests
tony Dec 7, 2025
3422367
tests(textframe): Add pytest_assertrepr_compare hook
tony Dec 7, 2025
9a5146b
tests(textframe): Switch to SingleFileSnapshotExtension
tony Dec 7, 2025
e6e5568
tests(textframe): Remove old .ambr snapshot file
tony Dec 7, 2025
3582a74
tests(textframe): Add .frame snapshot baselines
tony Dec 7, 2025
5f70483
docs(textframe): Document assertion customization patterns
tony Dec 7, 2025
3025b9d
libtmux(textframe): Move core module to src/libtmux/textframe/
tony Dec 7, 2025
a539378
libtmux(textframe): Add pytest plugin with hooks and fixtures
tony Dec 7, 2025
d1daef1
py(deps): Add textframe extras with syrupy dependency
tony Dec 7, 2025
af7fa9d
docs(textframe): Update for distributable plugin
tony Dec 7, 2025
0383da9
py(deps): Update lockfile for textframe extras
tony Dec 7, 2025
67986a4
docs(CHANGES): Document TextFrame features for 0.52.x (#613)
tony Dec 7, 2025
83de8d3
Pane(feat[capture_frame]): Add capture_frame() method
tony Dec 7, 2025
51d59ce
tests(pane): Add capture_frame() integration tests
tony Dec 7, 2025
1c0d350
tests(pane): Add capture_frame snapshot baseline
tony Dec 7, 2025
d0dab42
docs(textframe): Document capture_frame() integration
tony Dec 7, 2025
69eeccd
tests(pane): Add exhaustive capture_frame() snapshot tests
tony Dec 7, 2025
b81aff3
tests(pane): Add capture_frame snapshot baselines
tony Dec 7, 2025
271b4df
Pane(docs[capture_frame]): Fix doctest to work without SKIP
tony Dec 7, 2025
5469a7e
Pane(feat[capture_frame]): Forward capture_pane() flags
tony Dec 7, 2025
6787365
tests(pane): Add capture_frame() flag forwarding tests
tony Dec 7, 2025
0ee4823
TextFrame(feat[display]): Add interactive curses viewer
tony Dec 8, 2025
7c60e7e
docs(textframe): Document display() method
tony Dec 8, 2025
2ab4510
TextFrame(fix[display]): Use shutil for terminal size detection
tony Dec 8, 2025
43cf83f
tests(textframe): Add shutil terminal size detection test
tony Dec 8, 2025
caa750e
textframe(fix): Make TextFrameExtension import conditional
tony Dec 8, 2025
c029fbe
textframe(fix): Inherit ContentOverflowError from LibTmuxException
tony Dec 8, 2025
3677f8e
textframe(style): Use namespace import for difflib
tony Dec 8, 2025
91d4f16
tests(textframe): Replace patch() with monkeypatch.setattr()
tony Dec 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### New features

#### TextFrame Terminal UI Testing (#613)

TextFrame is a new snapshot-based testing primitive for terminal UIs. It captures
pane content into structured frames for assertion in tests, with syrupy integration
for snapshot baselines and a fluent API for content matching.

## libtmux 0.58.0 (2026-05-23)

libtmux 0.58.0 fixes subprocess output decoding on non-UTF-8 locales.
Expand Down
1 change: 1 addition & 0 deletions docs/internals/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ api/libtmux._internal.dataclasses
api/libtmux._internal.query_list
api/libtmux._internal.constants
api/libtmux._internal.sparse_array
textframe
```

## Environmental variables
Expand Down
326 changes: 326 additions & 0 deletions docs/internals/textframe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
# TextFrame - ASCII Frame Simulator

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:

- 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 libtmux.textframe 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 |
+----------+
```

### 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:

**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
# src/libtmux/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

### Auto-Discovered Fixtures

When `libtmux[textframe]` is installed, the following fixture is available:

```python
@pytest.fixture
def textframe_snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
"""Snapshot fixture configured with TextFrameExtension."""
return snapshot.use_extension(TextFrameExtension)
```

### Snapshot Directory Structure

```
__snapshots__/
test_module/
test_frame_rendering[basic_success].frame
test_frame_rendering[overflow_width].frame
test_frame_rendering[empty_frame].frame
...
```

## pytest Assertion Hook

The `pytest_assertrepr_compare` hook provides rich diff output for TextFrame comparisons:

```python
# 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
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`

## 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

- **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
- **pytest11 entry points**: Auto-discovery of installed plugins
- **Fixture auto-discovery**: Fixtures defined in plugins are globally available

### 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 |
|------|---------|
| `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 |

## 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 libtmux.textframe import (
TextFrame, # Core dataclass
ContentOverflowError, # Exception with visual diagnostic
TextFrameExtension, # Syrupy extension for custom usage
)
```
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dev = [
"pytest-mock",
"pytest-watcher",
"pytest-xdist",
"syrupy",
# Coverage
"codecov",
"coverage",
Expand Down Expand Up @@ -99,8 +100,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"]
Expand Down
Loading
Loading