Skip to main content

Playwright-style end-to-end testing for TUI applications. Drives any TUI binary via a real PTY + terminal emulator, with cell-grid and PNG snapshot regression.

Project description

tuiwright

Playwright-style end-to-end testing for terminal user interfaces.

tuiwright drives any TUI binary under a real PTY plus a faithful terminal emulator, then lets you assert on the rendered screen with an async pytest API. It covers keys, text, mouse, resize, bracketed paste, and focus events out of the box, with cell-grid and PNG snapshot regression.

async def test_save_flow(tui, snapshot):
    await tui.start("myapp", cols=120, rows=40)
    await tui.wait_for_text("Ready")

    await tui.type("hello world")
    await tui.press("ctrl+s")
    await tui.wait_for_text("Saved")

    await tui.click(row=5, col=12)
    await tui.assert_region(title="Logs", contains="saved hello world")

    assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)

Why

Existing tool Limitation
pexpect / expect Line/regex oriented — broken on cursor-addressed full-screen apps
vhs, asciinema Demo recording, not designed for assertions
Textual Pilot, teatest In-process — never exercise the real binary or PTY
ratatui::TestBackend Same — model-level only
insta, syrupy Assertion layer only, no driver

tuiwright is the missing piece: black-box, async, snapshot-aware, ergonomic.

Install

uv add --dev tuiwright
# or
pip install tuiwright

Optional, for PNG regression:

# macOS
brew install agg
# from source (recommended for latest)
cargo install --git https://github.com/asciinema/agg

Without agg, cell-grid snapshots still work; PNG assertions raise a clear FileNotFoundError.

Quick start

tuiwright registers itself as a pytest plugin — no conftest.py boilerplate. Just write async def test_*:

# tests/test_my_tui.py
from tuiwright._snapshot import ScreenSnapshotExtension

async def test_help_panel_opens(tui, snapshot):
    await tui.start(["myapp", "--no-color"], cols=100, rows=30)
    await tui.wait_for_text("Ready")
    await tui.press("?")
    await tui.wait_for_text("Help", region=tui.region(title="Help"))
    assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)

Run it:

pytest                       # red on first run — no snapshot yet
pytest --snapshot-update     # green; commit the .screen file
pytest                       # green forever, until the rendering changes

Snapshot files are plain text (an ASCII frame plus a small JSON sidecar of cell attributes) and live in tests/__snapshots__/<test_module>/. They diff cleanly in PR review.

API

TuiSession (the tui fixture)

Method Purpose
await start(cmd, *, env=, cwd=, cols=, rows=, cast_path=) Spawn a binary under a PTY
await stop(timeout=2.0) Graceful SIGTERM → SIGKILL escalation
await press(key) "enter", "ctrl+s", "shift+tab", "alt+left", "f5", "ctrl+shift+f5"
await type(text, delay=0) Per-char input with optional delay
await paste(text) Wrapped in \x1b[200~ … \x1b[201~; falls back to type if app didn't enable bracketed paste
await click(row, col, button="left", modifiers=()) SGR 1006 mouse encoding, 0-based coords
await double_click(row, col) Two clicks within interval= seconds
await drag(from_row, from_col, to_row, to_col, steps=4) Press → motion events → release
await scroll(row, col, direction="down", lines=1) Mouse wheel
await hover(row, col) Motion-no-button (requires mode 1003)
await resize(cols, rows) TIOCSWINSZ + SIGWINCH
await focus(in_=True) Focus in/out (\x1b[I / \x1b[O)
await wait_for_text(needle, timeout=, region=, regex=False) Returns the re.Match
await wait_for_predicate(fn, timeout=) fn(screen) -> bool, sync or async
await wait_for_stable(quiet_ms=50, timeout=) Settle on no-change
screen Current Screen (sync property)
region(title=, rows=, cols=) Subview into the current screen
png() Render current cast to PNG via agg
cast_path Path to the live asciinema cast file
alive True until the child exits

Screen, Region, Cell

screen.text                      # all rows joined with '\n', trailing spaces stripped
screen.row(0)                    # one row as a string
screen.row_containing("Error")   # row index or None
screen.find(r"\d+", regex=True)  # list[Position]
screen.contains("Ready")
screen.region(title="Logs")      # heuristic detection of ┌─ Logs ─┐ ratatui frames
screen.region(rows=(3, 8), cols=(10, 40))

cell = screen.cells[row][col]
cell.char, cell.fg, cell.bg, cell.bold, cell.italic, cell.reverse, ...

CLI flags

--tui-trace=on|retain-on-failure|off   # default: retain-on-failure
--tui-trace-dir=DIR                     # where to keep cast files (default: tmp_path)
--tui-cols=N, --tui-rows=N              # default terminal size
--tui-timeout=SECONDS                   # default wait_for_* timeout
--snapshot-update                       # from syrupy: refresh all snapshots

Marker

@pytest.mark.tui(cols=120, rows=40, timeout=10, strict_mouse=True)
async def test_large_screen(tui):
    ...

strict_mouse=True raises if mouse input is sent before the app has enabled mouse tracking (DEC modes 1000/1002/1003). Off by default — a single warning is emitted.

How it works

┌─ pytest fixture (tui) ──────────────────────────────────────┐
│ TuiSession                                                  │
│  ├─ Input encoders ── press / type / paste / mouse / resize │
│  ├─ Emulator (pyte) ── parses PTY output → 2D cell grid     │
│  ├─ Cast recorder ─── asciinema v2 file for replay + PNG    │
│  └─ PTY transport ── ptyprocess, async via add_reader       │
└─────────────────────────────────────────────────────────────┘
              │ stdin (bytes)                ▲ stdout
              ▼                              │
      ┌──────────────── child process ─────────────────┐
      │   the TUI binary under test                     │
      └─────────────────────────────────────────────────┘
  • PTY (ptyprocess): real pseudo-terminal — the app cannot tell it isn't running under iTerm. SIGWINCH on resize, real flow control, the whole shape.
  • Emulator (pyte): VT102 parser. Exposes the cell grid plus DEC private modes (mouse, paste, focus) so input encoders know what the app will accept.
  • Cast recorder: tees PTY output into an asciinema v2 file. Renders to PNG on demand via agg, and can be replayed in asciinema-player for trace viewing.
  • Snapshot extensions: syrupy plugins for Screen (text + JSON sidecar) and PNG (with pixelmatch for pixel-tolerant diff).

Project layout

src/tuiwright/
├── session.py              # TuiSession — public API
├── screen.py               # Screen, Region, Cell, Color, Cursor
├── _pty.py                 # ptyprocess wrapper
├── _emulator.py            # pyte + DEC mode tracking
├── _input.py               # key/mouse/paste encoders
├── _trace/recorder.py      # asciinema cast writer
├── _snapshot/cells.py      # syrupy ext for Screen
├── _snapshot/png.py        # syrupy ext for PNG (pixelmatch)
└── pytest_plugin.py        # tui fixture, marker, CLI flags

Limitations (v0.1)

  • POSIX only (macOS + Linux). Windows ConPTY is on the roadmap.
  • Mouse encoding is SGR 1006 (the modern default). Legacy X10 / urxvt encodings are not implemented.
  • Sixel, Kitty graphics, OSC 52 clipboard are passed through but not parsed.
  • The region(title=...) heuristic looks for ratatui-style single-line box drawing borders (┌─ Title ─┐). For other border styles fall back to explicit rows=, cols=.

License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tuiwright-0.2.1.tar.gz (39.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tuiwright-0.2.1-py3-none-any.whl (47.1 kB view details)

Uploaded Python 3

File details

Details for the file tuiwright-0.2.1.tar.gz.

File metadata

  • Download URL: tuiwright-0.2.1.tar.gz
  • Upload date:
  • Size: 39.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tuiwright-0.2.1.tar.gz
Algorithm Hash digest
SHA256 a21d016b7ae9942ce10a5a220a2470006cb062a77dba12db658865cc1d949772
MD5 e668dad406ee889bc62b5e063cc43d10
BLAKE2b-256 39b4c933a6b69cbd06ffca72dd991a68320549e5f19a28881f6c1a9e3885cffb

See more details on using hashes here.

File details

Details for the file tuiwright-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: tuiwright-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 47.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tuiwright-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a612fdb30059e41026ae1bc8026db8959e3012edc2d737e63db0dbf84791a433
MD5 d0ffd3de2067bae075d07180b7810b09
BLAKE2b-256 efd8616d747c6c17f1031911e585bff41f75e3ee11933508200ae2e5fd4d8c61

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page