diff --git a/docs/api/index.md b/docs/api/index.md index 99d614fee..4f8256bdc 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,6 +11,7 @@ servers sessions windows panes +snapshot constants common exceptions diff --git a/docs/api/snapshot.md b/docs/api/snapshot.md new file mode 100644 index 000000000..60582510e --- /dev/null +++ b/docs/api/snapshot.md @@ -0,0 +1,111 @@ +(snapshot)= + +# Snapshots + +The snapshot module provides functionality for capturing and analyzing the state of tmux panes. + +## Core Classes + +```{eval-rst} +.. autoclass:: libtmux.snapshot.PaneSnapshot + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.PaneRecording + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource +``` + +## Output Adapters + +```{eval-rst} +.. autoclass:: libtmux.snapshot.SnapshotOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.TerminalOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.CLIOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.PytestDiffAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.SyrupySnapshotAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource +``` + +## Examples + +### Basic Snapshot + +```python +>>> pane = session.active_window.active_pane +>>> snapshot = pane.snapshot() +>>> print(snapshot.content_str) +$ echo "Hello World" +Hello World +$ +``` + +### Recording Activity + +```python +>>> recording = pane.record() +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'Hello'") +>>> recording.add_snapshot(pane) +>>> print(recording.latest.content_str) +$ echo 'Hello' +Hello +$ +``` + +### Using Output Adapters + +```python +>>> from libtmux.snapshot import TerminalOutputAdapter +>>> print(snapshot.format(TerminalOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### Custom Adapter + +```python +>>> from libtmux.snapshot import SnapshotOutputAdapter +>>> class MyAdapter(SnapshotOutputAdapter): +... def format(self, snapshot): +... return f"Content: {snapshot.content_str}" +>>> print(snapshot.format(MyAdapter())) +Content: $ echo "Hello World" +Hello World +$ +``` diff --git a/docs/topics/index.md b/docs/topics/index.md index 0653bb57b..478c1c683 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -10,4 +10,5 @@ Explore libtmux’s core functionalities and underlying principles at a high lev context_managers traversal +snapshots ``` diff --git a/docs/topics/snapshots.md b/docs/topics/snapshots.md new file mode 100644 index 000000000..e1ee8dacd --- /dev/null +++ b/docs/topics/snapshots.md @@ -0,0 +1,162 @@ +(snapshots)= + +# Snapshots and Recordings + +libtmux provides functionality to capture and analyze the state of tmux panes through snapshots and recordings. + +## Taking Snapshots + +A snapshot captures the content and metadata of a pane at a specific point in time: + +```python +>>> pane = session.active_window.active_pane +>>> snapshot = pane.snapshot() +>>> print(snapshot.content_str) +$ echo "Hello World" +Hello World +$ +``` + +Snapshots are immutable and include: +- Pane content +- Timestamp (in UTC) +- Pane, window, session, and server IDs +- All tmux pane metadata + +You can also capture specific ranges of the pane history: + +```python +>>> # Capture lines 1-3 only +>>> snapshot = pane.snapshot(start=1, end=3) + +>>> # Capture from start of history +>>> snapshot = pane.snapshot(start="-") + +>>> # Capture up to current view +>>> snapshot = pane.snapshot(end="-") +``` + +## Recording Pane Activity + +To track changes in a pane over time, use recordings: + +```python +>>> recording = pane.record() +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'Hello'") +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'World'") +>>> recording.add_snapshot(pane) + +>>> # Access snapshots +>>> print(recording[0].content_str) # First snapshot +>>> print(recording.latest.content_str) # Most recent + +>>> # Filter by time +>>> recent = recording.get_snapshots_between( +... start_time=datetime.datetime.now() - datetime.timedelta(minutes=5), +... end_time=datetime.datetime.now(), +... ) +``` + +## Output Formats + +Snapshots can be formatted in different ways for various use cases: + +### Terminal Output + +```python +>>> from libtmux.snapshot import TerminalOutputAdapter +>>> print(snapshot.format(TerminalOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### CLI Output (No Colors) + +```python +>>> from libtmux.snapshot import CLIOutputAdapter +>>> print(snapshot.format(CLIOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### Pytest Assertion Diffs + +```python +>>> from libtmux.snapshot import PytestDiffAdapter +>>> expected = """ +... PaneSnapshot( +... pane_id='%1', +... window_id='@1', +... session_id='$1', +... server_name='default', +... timestamp='2024-01-01T12:00:00Z', +... content=[ +... '$ echo "Hello World"', +... 'Hello World', +... '$', +... ], +... metadata={ +... 'pane_height': '24', +... 'pane_width': '80', +... }, +... ) +... """ +>>> assert snapshot.format(PytestDiffAdapter()) == expected +``` + +### Syrupy Snapshot Testing + +```python +>>> from libtmux.snapshot import SyrupySnapshotAdapter +>>> snapshot.format(SyrupySnapshotAdapter()) +{ + "pane_id": "%1", + "window_id": "@1", + "session_id": "$1", + "server_name": "default", + "timestamp": "2024-01-01T12:00:00Z", + "content": [ + "$ echo \"Hello World\"", + "Hello World", + "$" + ], + "metadata": { + "pane_height": "24", + "pane_width": "80" + } +} +``` + +## Custom Output Formats + +You can create custom output formats by implementing the `SnapshotOutputAdapter` interface: + +```python +from libtmux.snapshot import SnapshotOutputAdapter + +class MyCustomAdapter(SnapshotOutputAdapter): + def format(self, snapshot: PaneSnapshot) -> str: + # Format snapshot data as needed + return f"Custom format: {snapshot.content_str}" + +# Use custom adapter +print(snapshot.format(MyCustomAdapter())) +``` diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index a60bb36f6..aba12795c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -22,6 +22,7 @@ ) from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import Obj, fetch_obj +from libtmux.snapshot import PaneRecording, PaneSnapshot from . import exc @@ -342,6 +343,43 @@ def capture_pane( cmd.extend(["-E", str(end)]) return self.cmd(*cmd).stdout + def snapshot( + self, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> PaneSnapshot: + """Create a snapshot of the pane's current state. + + This is a convenience method that creates a :class:`PaneSnapshot` instance + from the current pane state. + + Parameters + ---------- + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + + Returns + ------- + PaneSnapshot + A frozen snapshot of the pane's current state + """ + return PaneSnapshot.from_pane(self, start=start, end=end) + + def record(self) -> PaneRecording: + """Create a new recording for this pane. + + This is a convenience method that creates a :class:`PaneRecording` instance + for recording snapshots of this pane. + + Returns + ------- + PaneRecording + A new recording instance for this pane + """ + return PaneRecording() + def send_keys( self, cmd: str, diff --git a/src/libtmux/snapshot.py b/src/libtmux/snapshot.py new file mode 100644 index 000000000..99841a45f --- /dev/null +++ b/src/libtmux/snapshot.py @@ -0,0 +1,387 @@ +"""Snapshot and recording functionality for tmux panes.""" + +from __future__ import annotations + +import dataclasses +import datetime +import json +import typing as t +from abc import ABC, abstractmethod + +from typing_extensions import Self + +from libtmux.formats import PANE_FORMATS + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from libtmux.pane import Pane + + +class SnapshotOutputAdapter(ABC): + """Base class for snapshot output adapters. + + This class defines the interface for converting a PaneSnapshot + into different output formats. + """ + + @abstractmethod + def format(self, snapshot: PaneSnapshot) -> str: + """Format the snapshot for output. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + The formatted output + """ + + +class TerminalOutputAdapter(SnapshotOutputAdapter): + """Format snapshot for terminal output with ANSI colors.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot with ANSI colors for terminal display. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + ANSI-colored terminal output + """ + header = ( + f"\033[1;34m=== Pane Snapshot ===\033[0m\n" + f"\033[1;36mPane:\033[0m {snapshot.pane_id}\n" + f"\033[1;36mWindow:\033[0m {snapshot.window_id}\n" + f"\033[1;36mSession:\033[0m {snapshot.session_id}\n" + f"\033[1;36mServer:\033[0m {snapshot.server_name}\n" + f"\033[1;36mTimestamp:\033[0m {snapshot.timestamp.isoformat()}\n" + f"\033[1;33m=== Content ===\033[0m\n" + ) + return header + snapshot.content_str + + +class CLIOutputAdapter(SnapshotOutputAdapter): + """Format snapshot for plain text CLI output.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot as plain text. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + Plain text output suitable for CLI + """ + header = ( + f"=== Pane Snapshot ===\n" + f"Pane: {snapshot.pane_id}\n" + f"Window: {snapshot.window_id}\n" + f"Session: {snapshot.session_id}\n" + f"Server: {snapshot.server_name}\n" + f"Timestamp: {snapshot.timestamp.isoformat()}\n" + f"=== Content ===\n" + ) + return header + snapshot.content_str + + +class PytestDiffAdapter(SnapshotOutputAdapter): + """Format snapshot for pytest assertion diffs.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot for optimal pytest diff output. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + Pytest-friendly diff output + """ + lines = [ + "PaneSnapshot(", + f" pane_id={snapshot.pane_id!r},", + f" window_id={snapshot.window_id!r},", + f" session_id={snapshot.session_id!r},", + f" server_name={snapshot.server_name!r},", + f" timestamp={snapshot.timestamp.isoformat()!r},", + " content=[", + *(f" {line!r}," for line in snapshot.content), + " ],", + " metadata={", + *(f" {k!r}: {v!r}," for k, v in sorted(snapshot.metadata.items())), + " },", + ")", + ] + return "\n".join(lines) + + +class SyrupySnapshotAdapter(SnapshotOutputAdapter): + """Format snapshot for syrupy snapshot testing.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot for syrupy compatibility. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + JSON-serialized snapshot data + """ + data = { + "pane_id": snapshot.pane_id, + "window_id": snapshot.window_id, + "session_id": snapshot.session_id, + "server_name": snapshot.server_name, + "timestamp": snapshot.timestamp.isoformat(), + "content": snapshot.content, + "metadata": snapshot.metadata, + } + return json.dumps(data, indent=2, sort_keys=True) + + +@dataclasses.dataclass(frozen=True) +class PaneSnapshot: + """A frozen snapshot of a pane's state at a point in time. + + This class captures both the content and metadata of a tmux pane, + making it suitable for testing and debugging purposes. + + Attributes + ---------- + content : list[str] + The captured content of the pane + timestamp : datetime.datetime + When the snapshot was taken (in UTC) + pane_id : str + The ID of the pane + window_id : str + The ID of the window containing the pane + session_id : str + The ID of the session containing the window + server_name : str + The name of the tmux server + metadata : dict[str, str] + Additional pane metadata from tmux formats + """ + + content: list[str] + timestamp: datetime.datetime + pane_id: str + window_id: str + session_id: str + server_name: str + metadata: dict[str, str] + + @classmethod + def from_pane( + cls, + pane: Pane, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> Self: + """Create a snapshot from a pane. + + Parameters + ---------- + pane : Pane + The pane to snapshot + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + + Returns + ------- + PaneSnapshot + A frozen snapshot of the pane's state + """ + metadata = { + fmt: getattr(pane, fmt) + for fmt in PANE_FORMATS + if hasattr(pane, fmt) and getattr(pane, fmt) is not None + } + + content = pane.capture_pane(start=start, end=end) + if isinstance(content, str): + content = [content] + + return cls( + content=content, + timestamp=datetime.datetime.now(datetime.timezone.utc), + pane_id=str(pane.pane_id), + window_id=str(pane.window.window_id), + session_id=str(pane.session.session_id), + server_name=str(pane.server.socket_name), + metadata=metadata, + ) + + def format(self, adapter: SnapshotOutputAdapter | None = None) -> str: + """Format the snapshot using the specified adapter. + + If no adapter is provided, uses the default string representation. + + Parameters + ---------- + adapter : SnapshotOutputAdapter | None + The adapter to use for formatting + + Returns + ------- + str + The formatted output + """ + if adapter is None: + return str(self) + return adapter.format(self) + + def __str__(self) -> str: + """Return a string representation of the snapshot. + + Returns + ------- + str + A formatted string showing the snapshot content and metadata + """ + return ( + f"PaneSnapshot(pane={self.pane_id}, window={self.window_id}, " + f"session={self.session_id}, server={self.server_name}, " + f"timestamp={self.timestamp.isoformat()}, " + f"content=\n{self.content_str})" + ) + + @property + def content_str(self) -> str: + """Get the pane content as a single string. + + Returns + ------- + str + The pane content with lines joined by newlines + """ + return "\n".join(self.content) + + +@dataclasses.dataclass +class PaneRecording: + """A time-series recording of pane snapshots. + + This class maintains an ordered sequence of pane snapshots, + allowing for analysis of how a pane's content changes over time. + + Attributes + ---------- + snapshots : list[PaneSnapshot] + The sequence of snapshots in chronological order + """ + + snapshots: list[PaneSnapshot] = dataclasses.field(default_factory=list) + + def add_snapshot( + self, + pane: Pane, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> None: + """Add a new snapshot to the recording. + + Parameters + ---------- + pane : Pane + The pane to snapshot + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + """ + self.snapshots.append(PaneSnapshot.from_pane(pane, start=start, end=end)) + + def __len__(self) -> int: + """Get the number of snapshots in the recording. + + Returns + ------- + int + The number of snapshots + """ + return len(self.snapshots) + + def __iter__(self) -> Iterator[PaneSnapshot]: + """Iterate through snapshots in chronological order. + + Returns + ------- + Iterator[PaneSnapshot] + Iterator over the snapshots + """ + return iter(self.snapshots) + + def __getitem__(self, index: int) -> PaneSnapshot: + """Get a snapshot by index. + + Parameters + ---------- + index : int + The index of the snapshot to retrieve + + Returns + ------- + PaneSnapshot + The snapshot at the specified index + """ + return self.snapshots[index] + + @property + def latest(self) -> PaneSnapshot | None: + """Get the most recent snapshot. + + Returns + ------- + PaneSnapshot | None + The most recent snapshot, or None if no snapshots exist + """ + return self.snapshots[-1] if self.snapshots else None + + def get_snapshots_between( + self, + start_time: datetime.datetime, + end_time: datetime.datetime, + ) -> Sequence[PaneSnapshot]: + """Get snapshots between two points in time. + + Parameters + ---------- + start_time : datetime.datetime + The start of the time range + end_time : datetime.datetime + The end of the time range + + Returns + ------- + Sequence[PaneSnapshot] + Snapshots within the specified time range + """ + return [ + snapshot + for snapshot in self.snapshots + if start_time <= snapshot.timestamp <= end_time + ] diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py new file mode 100644 index 000000000..f226c8157 --- /dev/null +++ b/tests/test_snapshot.py @@ -0,0 +1,233 @@ +"""Tests for libtmux snapshot functionality.""" + +from __future__ import annotations + +import datetime +import json +import shutil +import time +import typing as t + +from libtmux.snapshot import ( + CLIOutputAdapter, + PaneRecording, + PaneSnapshot, + PytestDiffAdapter, + SyrupySnapshotAdapter, + TerminalOutputAdapter, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_pane_snapshot(session: Session) -> None: + """Test creating a PaneSnapshot from a pane.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="snapshot_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Take initial snapshot + snapshot = PaneSnapshot.from_pane(pane) + assert snapshot.content == ["$"] + assert snapshot.pane_id == pane.pane_id + assert snapshot.window_id == pane.window.window_id + assert snapshot.session_id == pane.session.session_id + assert snapshot.server_name == pane.server.socket_name + assert isinstance(snapshot.timestamp, datetime.datetime) + assert snapshot.timestamp.tzinfo == datetime.timezone.utc + + # Verify metadata + assert "pane_id" in snapshot.metadata + assert "pane_width" in snapshot.metadata + assert "pane_height" in snapshot.metadata + + # Test string representation + str_repr = str(snapshot) + assert "PaneSnapshot" in str_repr + assert snapshot.pane_id in str_repr + assert snapshot.window_id in str_repr + assert snapshot.session_id in str_repr + assert snapshot.server_name in str_repr + assert snapshot.timestamp.isoformat() in str_repr + assert "$" in str_repr + + +def test_pane_recording(session: Session) -> None: + """Test creating and managing a PaneRecording.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="recording_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + recording = PaneRecording() + assert len(recording) == 0 + assert recording.latest is None + + # Take initial snapshot + recording.add_snapshot(pane) + assert len(recording) == 1 + assert recording.latest is not None + assert recording.latest.content == ["$"] + + # Send some commands and take more snapshots + pane.send_keys("echo 'Hello'") + time.sleep(0.1) # Give tmux time to update + recording.add_snapshot(pane) + + pane.send_keys("echo 'World'") + time.sleep(0.1) # Give tmux time to update + recording.add_snapshot(pane) + + assert len(recording) == 3 + + # Test iteration + snapshots = list(recording) + assert len(snapshots) == 3 + assert snapshots[0].content == ["$"] + assert "Hello" in snapshots[1].content_str + assert "World" in snapshots[2].content_str + + # Test indexing + assert recording[0].content == ["$"] + assert "Hello" in recording[1].content_str + assert "World" in recording[2].content_str + + # Test time-based filtering + start_time = snapshots[0].timestamp + mid_time = snapshots[1].timestamp + end_time = snapshots[2].timestamp + + assert len(recording.get_snapshots_between(start_time, end_time)) == 3 + assert len(recording.get_snapshots_between(mid_time, end_time)) == 2 + + +def test_snapshot_output_adapters(session: Session) -> None: + """Test the various output adapters for PaneSnapshot.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="adapter_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Create a snapshot with some content + pane.send_keys("echo 'Test Content'") + time.sleep(0.1) + snapshot = pane.snapshot() + + # Test Terminal Output + terminal_output = snapshot.format(TerminalOutputAdapter()) + assert "\033[1;34m=== Pane Snapshot ===\033[0m" in terminal_output + assert "\033[1;36mPane:\033[0m" in terminal_output + assert "Test Content" in terminal_output + + # Test CLI Output + cli_output = snapshot.format(CLIOutputAdapter()) + assert "=== Pane Snapshot ===" in cli_output + assert "Pane: " in cli_output + assert "\033" not in cli_output # No ANSI codes + assert "Test Content" in cli_output + + # Test Pytest Diff Output + pytest_output = snapshot.format(PytestDiffAdapter()) + assert "PaneSnapshot(" in pytest_output + assert " pane_id=" in pytest_output + assert " content=[" in pytest_output + assert " metadata={" in pytest_output + assert "'Test Content'" in pytest_output + + # Test Syrupy Output + syrupy_output = snapshot.format(SyrupySnapshotAdapter()) + data = json.loads(syrupy_output) + assert isinstance(data, dict) + assert "pane_id" in data + assert "content" in data + assert "metadata" in data + assert "Test Content" in str(data["content"]) + + # Test default format (no adapter) + default_output = snapshot.format() + assert default_output == str(snapshot) + + +def test_pane_snapshot_convenience_method(session: Session) -> None: + """Test the Pane.snapshot() convenience method.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="snapshot_convenience_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Take snapshot using convenience method + snapshot = pane.snapshot() + assert snapshot.content == ["$"] + assert snapshot.pane_id == pane.pane_id + assert snapshot.window_id == pane.window.window_id + assert snapshot.session_id == pane.session.session_id + assert snapshot.server_name == pane.server.socket_name + + # Test with start/end parameters + pane.send_keys("echo 'Line 1'") + time.sleep(0.1) + pane.send_keys("echo 'Line 2'") + time.sleep(0.1) + pane.send_keys("echo 'Line 3'") + time.sleep(0.1) + + snapshot_partial = pane.snapshot(start=1, end=2) + assert len(snapshot_partial.content) == 2 + assert "Line 1" in snapshot_partial.content_str + assert "Line 2" in snapshot_partial.content_str + assert "Line 3" not in snapshot_partial.content_str + + +def test_pane_record_convenience_method(session: Session) -> None: + """Test the Pane.record() convenience method.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="record_convenience_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Create recording using convenience method + recording = pane.record() + assert isinstance(recording, PaneRecording) + assert len(recording) == 0 + + # Add snapshots to recording + recording.add_snapshot(pane) + pane.send_keys("echo 'Test'") + time.sleep(0.1) + recording.add_snapshot(pane) + + assert len(recording) == 2 + assert recording[0].content == ["$"] + assert "Test" in recording[1].content_str