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 (withpixelmatchfor 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 explicitrows=,cols=.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a21d016b7ae9942ce10a5a220a2470006cb062a77dba12db658865cc1d949772
|
|
| MD5 |
e668dad406ee889bc62b5e063cc43d10
|
|
| BLAKE2b-256 |
39b4c933a6b69cbd06ffca72dd991a68320549e5f19a28881f6c1a9e3885cffb
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a612fdb30059e41026ae1bc8026db8959e3012edc2d737e63db0dbf84791a433
|
|
| MD5 |
d0ffd3de2067bae075d07180b7810b09
|
|
| BLAKE2b-256 |
efd8616d747c6c17f1031911e585bff41f75e3ee11933508200ae2e5fd4d8c61
|