tuiwright 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tuiwright-0.1.0/LICENSE +21 -0
- tuiwright-0.1.0/PKG-INFO +246 -0
- tuiwright-0.1.0/README.md +209 -0
- tuiwright-0.1.0/pyproject.toml +82 -0
- tuiwright-0.1.0/src/tuiwright/__init__.py +31 -0
- tuiwright-0.1.0/src/tuiwright/_emulator.py +146 -0
- tuiwright-0.1.0/src/tuiwright/_input.py +330 -0
- tuiwright-0.1.0/src/tuiwright/_pty.py +223 -0
- tuiwright-0.1.0/src/tuiwright/_snapshot/__init__.py +6 -0
- tuiwright-0.1.0/src/tuiwright/_snapshot/cells.py +103 -0
- tuiwright-0.1.0/src/tuiwright/_snapshot/png.py +77 -0
- tuiwright-0.1.0/src/tuiwright/_trace/__init__.py +1 -0
- tuiwright-0.1.0/src/tuiwright/_trace/recorder.py +106 -0
- tuiwright-0.1.0/src/tuiwright/py.typed +0 -0
- tuiwright-0.1.0/src/tuiwright/pytest_plugin.py +204 -0
- tuiwright-0.1.0/src/tuiwright/screen.py +321 -0
- tuiwright-0.1.0/src/tuiwright/session.py +575 -0
tuiwright-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pandelis Zembashis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tuiwright-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuiwright
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 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.
|
|
5
|
+
Keywords: tui,testing,terminal,pty,snapshot,ratatui,crossterm,pytest,e2e,playwright
|
|
6
|
+
Author: Pandelis Zembashis
|
|
7
|
+
Author-email: Pandelis Zembashis <p@ndel.is>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Classifier: Topic :: Terminals
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: ptyprocess>=0.7.0
|
|
22
|
+
Requires-Dist: pyte>=0.8.2
|
|
23
|
+
Requires-Dist: pytest>=8.0
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23
|
|
25
|
+
Requires-Dist: syrupy>=4.6
|
|
26
|
+
Requires-Dist: pillow>=10.0
|
|
27
|
+
Requires-Dist: pixelmatch>=0.3.0
|
|
28
|
+
Requires-Dist: urwid>=2.6 ; extra == 'examples'
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Project-URL: Homepage, https://pandelisz.github.io/tuiwright/
|
|
31
|
+
Project-URL: Documentation, https://pandelisz.github.io/tuiwright/
|
|
32
|
+
Project-URL: Repository, https://github.com/PandelisZ/tuiwright
|
|
33
|
+
Project-URL: Issues, https://github.com/PandelisZ/tuiwright/issues
|
|
34
|
+
Project-URL: Changelog, https://github.com/PandelisZ/tuiwright/blob/master/CHANGELOG.md
|
|
35
|
+
Provides-Extra: examples
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# tuiwright
|
|
39
|
+
|
|
40
|
+
**Playwright-style end-to-end testing for terminal user interfaces.**
|
|
41
|
+
|
|
42
|
+
`tuiwright` drives any TUI binary under a real PTY plus a faithful
|
|
43
|
+
terminal emulator, then lets you assert on the rendered screen with an
|
|
44
|
+
async pytest API. It covers keys, text, mouse, resize, bracketed paste,
|
|
45
|
+
and focus events out of the box, with cell-grid and PNG snapshot
|
|
46
|
+
regression.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
async def test_save_flow(tui, snapshot):
|
|
50
|
+
await tui.start("myapp", cols=120, rows=40)
|
|
51
|
+
await tui.wait_for_text("Ready")
|
|
52
|
+
|
|
53
|
+
await tui.type("hello world")
|
|
54
|
+
await tui.press("ctrl+s")
|
|
55
|
+
await tui.wait_for_text("Saved")
|
|
56
|
+
|
|
57
|
+
await tui.click(row=5, col=12)
|
|
58
|
+
await tui.assert_region(title="Logs", contains="saved hello world")
|
|
59
|
+
|
|
60
|
+
assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Why
|
|
64
|
+
|
|
65
|
+
| Existing tool | Limitation |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `pexpect` / `expect` | Line/regex oriented — broken on cursor-addressed full-screen apps |
|
|
68
|
+
| `vhs`, `asciinema` | Demo recording, not designed for assertions |
|
|
69
|
+
| Textual `Pilot`, `teatest` | In-process — never exercise the real binary or PTY |
|
|
70
|
+
| `ratatui::TestBackend` | Same — model-level only |
|
|
71
|
+
| `insta`, `syrupy` | Assertion layer only, no driver |
|
|
72
|
+
|
|
73
|
+
`tuiwright` is the missing piece: **black-box, async, snapshot-aware,
|
|
74
|
+
ergonomic**.
|
|
75
|
+
|
|
76
|
+
## Install
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv add --dev tuiwright
|
|
80
|
+
# or
|
|
81
|
+
pip install tuiwright
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Optional, for PNG regression:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# macOS
|
|
88
|
+
brew install agg
|
|
89
|
+
# from source (recommended for latest)
|
|
90
|
+
cargo install --git https://github.com/asciinema/agg
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Without `agg`, cell-grid snapshots still work; PNG assertions raise a
|
|
94
|
+
clear `FileNotFoundError`.
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
`tuiwright` registers itself as a `pytest` plugin — no
|
|
99
|
+
`conftest.py` boilerplate. Just write `async def test_*`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# tests/test_my_tui.py
|
|
103
|
+
from tuiwright._snapshot import ScreenSnapshotExtension
|
|
104
|
+
|
|
105
|
+
async def test_help_panel_opens(tui, snapshot):
|
|
106
|
+
await tui.start(["myapp", "--no-color"], cols=100, rows=30)
|
|
107
|
+
await tui.wait_for_text("Ready")
|
|
108
|
+
await tui.press("?")
|
|
109
|
+
await tui.wait_for_text("Help", region=tui.region(title="Help"))
|
|
110
|
+
assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run it:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pytest # red on first run — no snapshot yet
|
|
117
|
+
pytest --snapshot-update # green; commit the .screen file
|
|
118
|
+
pytest # green forever, until the rendering changes
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Snapshot files are plain text (an ASCII frame plus a small JSON sidecar
|
|
122
|
+
of cell attributes) and live in `tests/__snapshots__/<test_module>/`.
|
|
123
|
+
They diff cleanly in PR review.
|
|
124
|
+
|
|
125
|
+
## API
|
|
126
|
+
|
|
127
|
+
### `TuiSession` (the `tui` fixture)
|
|
128
|
+
|
|
129
|
+
| Method | Purpose |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `await start(cmd, *, env=, cwd=, cols=, rows=, cast_path=)` | Spawn a binary under a PTY |
|
|
132
|
+
| `await stop(timeout=2.0)` | Graceful SIGTERM → SIGKILL escalation |
|
|
133
|
+
| `await press(key)` | `"enter"`, `"ctrl+s"`, `"shift+tab"`, `"alt+left"`, `"f5"`, `"ctrl+shift+f5"` |
|
|
134
|
+
| `await type(text, delay=0)` | Per-char input with optional delay |
|
|
135
|
+
| `await paste(text)` | Wrapped in `\x1b[200~ … \x1b[201~`; falls back to `type` if app didn't enable bracketed paste |
|
|
136
|
+
| `await click(row, col, button="left", modifiers=())` | SGR 1006 mouse encoding, 0-based coords |
|
|
137
|
+
| `await double_click(row, col)` | Two clicks within `interval=` seconds |
|
|
138
|
+
| `await drag(from_row, from_col, to_row, to_col, steps=4)` | Press → motion events → release |
|
|
139
|
+
| `await scroll(row, col, direction="down", lines=1)` | Mouse wheel |
|
|
140
|
+
| `await hover(row, col)` | Motion-no-button (requires mode 1003) |
|
|
141
|
+
| `await resize(cols, rows)` | `TIOCSWINSZ` + SIGWINCH |
|
|
142
|
+
| `await focus(in_=True)` | Focus in/out (`\x1b[I` / `\x1b[O`) |
|
|
143
|
+
| `await wait_for_text(needle, timeout=, region=, regex=False)` | Returns the `re.Match` |
|
|
144
|
+
| `await wait_for_predicate(fn, timeout=)` | `fn(screen) -> bool`, sync or async |
|
|
145
|
+
| `await wait_for_stable(quiet_ms=50, timeout=)` | Settle on no-change |
|
|
146
|
+
| `screen` | Current `Screen` (sync property) |
|
|
147
|
+
| `region(title=, rows=, cols=)` | Subview into the current screen |
|
|
148
|
+
| `png()` | Render current cast to PNG via `agg` |
|
|
149
|
+
| `cast_path` | Path to the live asciinema cast file |
|
|
150
|
+
| `alive` | `True` until the child exits |
|
|
151
|
+
|
|
152
|
+
### `Screen`, `Region`, `Cell`
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
screen.text # all rows joined with '\n', trailing spaces stripped
|
|
156
|
+
screen.row(0) # one row as a string
|
|
157
|
+
screen.row_containing("Error") # row index or None
|
|
158
|
+
screen.find(r"\d+", regex=True) # list[Position]
|
|
159
|
+
screen.contains("Ready")
|
|
160
|
+
screen.region(title="Logs") # heuristic detection of ┌─ Logs ─┐ ratatui frames
|
|
161
|
+
screen.region(rows=(3, 8), cols=(10, 40))
|
|
162
|
+
|
|
163
|
+
cell = screen.cells[row][col]
|
|
164
|
+
cell.char, cell.fg, cell.bg, cell.bold, cell.italic, cell.reverse, ...
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### CLI flags
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
--tui-trace=on|retain-on-failure|off # default: retain-on-failure
|
|
171
|
+
--tui-trace-dir=DIR # where to keep cast files (default: tmp_path)
|
|
172
|
+
--tui-cols=N, --tui-rows=N # default terminal size
|
|
173
|
+
--tui-timeout=SECONDS # default wait_for_* timeout
|
|
174
|
+
--snapshot-update # from syrupy: refresh all snapshots
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Marker
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
@pytest.mark.tui(cols=120, rows=40, timeout=10, strict_mouse=True)
|
|
181
|
+
async def test_large_screen(tui):
|
|
182
|
+
...
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`strict_mouse=True` raises if mouse input is sent before the app has
|
|
186
|
+
enabled mouse tracking (DEC modes 1000/1002/1003). Off by default — a
|
|
187
|
+
single warning is emitted.
|
|
188
|
+
|
|
189
|
+
## How it works
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
┌─ pytest fixture (tui) ──────────────────────────────────────┐
|
|
193
|
+
│ TuiSession │
|
|
194
|
+
│ ├─ Input encoders ── press / type / paste / mouse / resize │
|
|
195
|
+
│ ├─ Emulator (pyte) ── parses PTY output → 2D cell grid │
|
|
196
|
+
│ ├─ Cast recorder ─── asciinema v2 file for replay + PNG │
|
|
197
|
+
│ └─ PTY transport ── ptyprocess, async via add_reader │
|
|
198
|
+
└─────────────────────────────────────────────────────────────┘
|
|
199
|
+
│ stdin (bytes) ▲ stdout
|
|
200
|
+
▼ │
|
|
201
|
+
┌──────────────── child process ─────────────────┐
|
|
202
|
+
│ the TUI binary under test │
|
|
203
|
+
└─────────────────────────────────────────────────┘
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- **PTY** (`ptyprocess`): real pseudo-terminal — the app cannot tell it
|
|
207
|
+
isn't running under iTerm. SIGWINCH on resize, real flow control, the
|
|
208
|
+
whole shape.
|
|
209
|
+
- **Emulator** (`pyte`): VT102 parser. Exposes the cell grid plus DEC
|
|
210
|
+
private modes (mouse, paste, focus) so input encoders know what the
|
|
211
|
+
app will accept.
|
|
212
|
+
- **Cast recorder**: tees PTY output into an asciinema v2 file. Renders
|
|
213
|
+
to PNG on demand via `agg`, and can be replayed in
|
|
214
|
+
asciinema-player for trace viewing.
|
|
215
|
+
- **Snapshot extensions**: syrupy plugins for `Screen` (text + JSON
|
|
216
|
+
sidecar) and PNG (with `pixelmatch` for pixel-tolerant diff).
|
|
217
|
+
|
|
218
|
+
## Project layout
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
src/tuiwright/
|
|
222
|
+
├── session.py # TuiSession — public API
|
|
223
|
+
├── screen.py # Screen, Region, Cell, Color, Cursor
|
|
224
|
+
├── _pty.py # ptyprocess wrapper
|
|
225
|
+
├── _emulator.py # pyte + DEC mode tracking
|
|
226
|
+
├── _input.py # key/mouse/paste encoders
|
|
227
|
+
├── _trace/recorder.py # asciinema cast writer
|
|
228
|
+
├── _snapshot/cells.py # syrupy ext for Screen
|
|
229
|
+
├── _snapshot/png.py # syrupy ext for PNG (pixelmatch)
|
|
230
|
+
└── pytest_plugin.py # tui fixture, marker, CLI flags
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Limitations (v0.1)
|
|
234
|
+
|
|
235
|
+
- POSIX only (macOS + Linux). Windows ConPTY is on the roadmap.
|
|
236
|
+
- Mouse encoding is SGR 1006 (the modern default). Legacy X10 / urxvt
|
|
237
|
+
encodings are not implemented.
|
|
238
|
+
- Sixel, Kitty graphics, OSC 52 clipboard are passed through but not
|
|
239
|
+
parsed.
|
|
240
|
+
- The `region(title=...)` heuristic looks for ratatui-style
|
|
241
|
+
single-line box drawing borders (`┌─ Title ─┐`). For other border
|
|
242
|
+
styles fall back to explicit `rows=`, `cols=`.
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# tuiwright
|
|
2
|
+
|
|
3
|
+
**Playwright-style end-to-end testing for terminal user interfaces.**
|
|
4
|
+
|
|
5
|
+
`tuiwright` drives any TUI binary under a real PTY plus a faithful
|
|
6
|
+
terminal emulator, then lets you assert on the rendered screen with an
|
|
7
|
+
async pytest API. It covers keys, text, mouse, resize, bracketed paste,
|
|
8
|
+
and focus events out of the box, with cell-grid and PNG snapshot
|
|
9
|
+
regression.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
async def test_save_flow(tui, snapshot):
|
|
13
|
+
await tui.start("myapp", cols=120, rows=40)
|
|
14
|
+
await tui.wait_for_text("Ready")
|
|
15
|
+
|
|
16
|
+
await tui.type("hello world")
|
|
17
|
+
await tui.press("ctrl+s")
|
|
18
|
+
await tui.wait_for_text("Saved")
|
|
19
|
+
|
|
20
|
+
await tui.click(row=5, col=12)
|
|
21
|
+
await tui.assert_region(title="Logs", contains="saved hello world")
|
|
22
|
+
|
|
23
|
+
assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
| Existing tool | Limitation |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `pexpect` / `expect` | Line/regex oriented — broken on cursor-addressed full-screen apps |
|
|
31
|
+
| `vhs`, `asciinema` | Demo recording, not designed for assertions |
|
|
32
|
+
| Textual `Pilot`, `teatest` | In-process — never exercise the real binary or PTY |
|
|
33
|
+
| `ratatui::TestBackend` | Same — model-level only |
|
|
34
|
+
| `insta`, `syrupy` | Assertion layer only, no driver |
|
|
35
|
+
|
|
36
|
+
`tuiwright` is the missing piece: **black-box, async, snapshot-aware,
|
|
37
|
+
ergonomic**.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add --dev tuiwright
|
|
43
|
+
# or
|
|
44
|
+
pip install tuiwright
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Optional, for PNG regression:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# macOS
|
|
51
|
+
brew install agg
|
|
52
|
+
# from source (recommended for latest)
|
|
53
|
+
cargo install --git https://github.com/asciinema/agg
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Without `agg`, cell-grid snapshots still work; PNG assertions raise a
|
|
57
|
+
clear `FileNotFoundError`.
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
`tuiwright` registers itself as a `pytest` plugin — no
|
|
62
|
+
`conftest.py` boilerplate. Just write `async def test_*`:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# tests/test_my_tui.py
|
|
66
|
+
from tuiwright._snapshot import ScreenSnapshotExtension
|
|
67
|
+
|
|
68
|
+
async def test_help_panel_opens(tui, snapshot):
|
|
69
|
+
await tui.start(["myapp", "--no-color"], cols=100, rows=30)
|
|
70
|
+
await tui.wait_for_text("Ready")
|
|
71
|
+
await tui.press("?")
|
|
72
|
+
await tui.wait_for_text("Help", region=tui.region(title="Help"))
|
|
73
|
+
assert tui.screen == snapshot(extension_class=ScreenSnapshotExtension)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run it:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pytest # red on first run — no snapshot yet
|
|
80
|
+
pytest --snapshot-update # green; commit the .screen file
|
|
81
|
+
pytest # green forever, until the rendering changes
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Snapshot files are plain text (an ASCII frame plus a small JSON sidecar
|
|
85
|
+
of cell attributes) and live in `tests/__snapshots__/<test_module>/`.
|
|
86
|
+
They diff cleanly in PR review.
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `TuiSession` (the `tui` fixture)
|
|
91
|
+
|
|
92
|
+
| Method | Purpose |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `await start(cmd, *, env=, cwd=, cols=, rows=, cast_path=)` | Spawn a binary under a PTY |
|
|
95
|
+
| `await stop(timeout=2.0)` | Graceful SIGTERM → SIGKILL escalation |
|
|
96
|
+
| `await press(key)` | `"enter"`, `"ctrl+s"`, `"shift+tab"`, `"alt+left"`, `"f5"`, `"ctrl+shift+f5"` |
|
|
97
|
+
| `await type(text, delay=0)` | Per-char input with optional delay |
|
|
98
|
+
| `await paste(text)` | Wrapped in `\x1b[200~ … \x1b[201~`; falls back to `type` if app didn't enable bracketed paste |
|
|
99
|
+
| `await click(row, col, button="left", modifiers=())` | SGR 1006 mouse encoding, 0-based coords |
|
|
100
|
+
| `await double_click(row, col)` | Two clicks within `interval=` seconds |
|
|
101
|
+
| `await drag(from_row, from_col, to_row, to_col, steps=4)` | Press → motion events → release |
|
|
102
|
+
| `await scroll(row, col, direction="down", lines=1)` | Mouse wheel |
|
|
103
|
+
| `await hover(row, col)` | Motion-no-button (requires mode 1003) |
|
|
104
|
+
| `await resize(cols, rows)` | `TIOCSWINSZ` + SIGWINCH |
|
|
105
|
+
| `await focus(in_=True)` | Focus in/out (`\x1b[I` / `\x1b[O`) |
|
|
106
|
+
| `await wait_for_text(needle, timeout=, region=, regex=False)` | Returns the `re.Match` |
|
|
107
|
+
| `await wait_for_predicate(fn, timeout=)` | `fn(screen) -> bool`, sync or async |
|
|
108
|
+
| `await wait_for_stable(quiet_ms=50, timeout=)` | Settle on no-change |
|
|
109
|
+
| `screen` | Current `Screen` (sync property) |
|
|
110
|
+
| `region(title=, rows=, cols=)` | Subview into the current screen |
|
|
111
|
+
| `png()` | Render current cast to PNG via `agg` |
|
|
112
|
+
| `cast_path` | Path to the live asciinema cast file |
|
|
113
|
+
| `alive` | `True` until the child exits |
|
|
114
|
+
|
|
115
|
+
### `Screen`, `Region`, `Cell`
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
screen.text # all rows joined with '\n', trailing spaces stripped
|
|
119
|
+
screen.row(0) # one row as a string
|
|
120
|
+
screen.row_containing("Error") # row index or None
|
|
121
|
+
screen.find(r"\d+", regex=True) # list[Position]
|
|
122
|
+
screen.contains("Ready")
|
|
123
|
+
screen.region(title="Logs") # heuristic detection of ┌─ Logs ─┐ ratatui frames
|
|
124
|
+
screen.region(rows=(3, 8), cols=(10, 40))
|
|
125
|
+
|
|
126
|
+
cell = screen.cells[row][col]
|
|
127
|
+
cell.char, cell.fg, cell.bg, cell.bold, cell.italic, cell.reverse, ...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### CLI flags
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
--tui-trace=on|retain-on-failure|off # default: retain-on-failure
|
|
134
|
+
--tui-trace-dir=DIR # where to keep cast files (default: tmp_path)
|
|
135
|
+
--tui-cols=N, --tui-rows=N # default terminal size
|
|
136
|
+
--tui-timeout=SECONDS # default wait_for_* timeout
|
|
137
|
+
--snapshot-update # from syrupy: refresh all snapshots
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Marker
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
@pytest.mark.tui(cols=120, rows=40, timeout=10, strict_mouse=True)
|
|
144
|
+
async def test_large_screen(tui):
|
|
145
|
+
...
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`strict_mouse=True` raises if mouse input is sent before the app has
|
|
149
|
+
enabled mouse tracking (DEC modes 1000/1002/1003). Off by default — a
|
|
150
|
+
single warning is emitted.
|
|
151
|
+
|
|
152
|
+
## How it works
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
┌─ pytest fixture (tui) ──────────────────────────────────────┐
|
|
156
|
+
│ TuiSession │
|
|
157
|
+
│ ├─ Input encoders ── press / type / paste / mouse / resize │
|
|
158
|
+
│ ├─ Emulator (pyte) ── parses PTY output → 2D cell grid │
|
|
159
|
+
│ ├─ Cast recorder ─── asciinema v2 file for replay + PNG │
|
|
160
|
+
│ └─ PTY transport ── ptyprocess, async via add_reader │
|
|
161
|
+
└─────────────────────────────────────────────────────────────┘
|
|
162
|
+
│ stdin (bytes) ▲ stdout
|
|
163
|
+
▼ │
|
|
164
|
+
┌──────────────── child process ─────────────────┐
|
|
165
|
+
│ the TUI binary under test │
|
|
166
|
+
└─────────────────────────────────────────────────┘
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- **PTY** (`ptyprocess`): real pseudo-terminal — the app cannot tell it
|
|
170
|
+
isn't running under iTerm. SIGWINCH on resize, real flow control, the
|
|
171
|
+
whole shape.
|
|
172
|
+
- **Emulator** (`pyte`): VT102 parser. Exposes the cell grid plus DEC
|
|
173
|
+
private modes (mouse, paste, focus) so input encoders know what the
|
|
174
|
+
app will accept.
|
|
175
|
+
- **Cast recorder**: tees PTY output into an asciinema v2 file. Renders
|
|
176
|
+
to PNG on demand via `agg`, and can be replayed in
|
|
177
|
+
asciinema-player for trace viewing.
|
|
178
|
+
- **Snapshot extensions**: syrupy plugins for `Screen` (text + JSON
|
|
179
|
+
sidecar) and PNG (with `pixelmatch` for pixel-tolerant diff).
|
|
180
|
+
|
|
181
|
+
## Project layout
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
src/tuiwright/
|
|
185
|
+
├── session.py # TuiSession — public API
|
|
186
|
+
├── screen.py # Screen, Region, Cell, Color, Cursor
|
|
187
|
+
├── _pty.py # ptyprocess wrapper
|
|
188
|
+
├── _emulator.py # pyte + DEC mode tracking
|
|
189
|
+
├── _input.py # key/mouse/paste encoders
|
|
190
|
+
├── _trace/recorder.py # asciinema cast writer
|
|
191
|
+
├── _snapshot/cells.py # syrupy ext for Screen
|
|
192
|
+
├── _snapshot/png.py # syrupy ext for PNG (pixelmatch)
|
|
193
|
+
└── pytest_plugin.py # tui fixture, marker, CLI flags
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Limitations (v0.1)
|
|
197
|
+
|
|
198
|
+
- POSIX only (macOS + Linux). Windows ConPTY is on the roadmap.
|
|
199
|
+
- Mouse encoding is SGR 1006 (the modern default). Legacy X10 / urxvt
|
|
200
|
+
encodings are not implemented.
|
|
201
|
+
- Sixel, Kitty graphics, OSC 52 clipboard are passed through but not
|
|
202
|
+
parsed.
|
|
203
|
+
- The `region(title=...)` heuristic looks for ratatui-style
|
|
204
|
+
single-line box drawing borders (`┌─ Title ─┐`). For other border
|
|
205
|
+
styles fall back to explicit `rows=`, `cols=`.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tuiwright"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "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."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Pandelis Zembashis", email = "p@ndel.is" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
keywords = ["tui", "testing", "terminal", "pty", "snapshot", "ratatui", "crossterm", "pytest", "e2e", "playwright"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: Pytest",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
"Topic :: Terminals",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"ptyprocess>=0.7.0",
|
|
28
|
+
"pyte>=0.8.2",
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"pytest-asyncio>=0.23",
|
|
31
|
+
"syrupy>=4.6",
|
|
32
|
+
"pillow>=10.0",
|
|
33
|
+
"pixelmatch>=0.3.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
examples = [
|
|
38
|
+
"urwid>=2.6",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.entry-points.pytest11]
|
|
42
|
+
tuiwright = "tuiwright.pytest_plugin"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://pandelisz.github.io/tuiwright/"
|
|
46
|
+
Documentation = "https://pandelisz.github.io/tuiwright/"
|
|
47
|
+
Repository = "https://github.com/PandelisZ/tuiwright"
|
|
48
|
+
Issues = "https://github.com/PandelisZ/tuiwright/issues"
|
|
49
|
+
Changelog = "https://github.com/PandelisZ/tuiwright/blob/master/CHANGELOG.md"
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = [
|
|
57
|
+
"pytest>=8.0",
|
|
58
|
+
"pytest-asyncio>=0.23",
|
|
59
|
+
"pytest-timeout>=2.3",
|
|
60
|
+
"ruff>=0.6",
|
|
61
|
+
"mypy>=1.10",
|
|
62
|
+
"urwid>=2.6",
|
|
63
|
+
]
|
|
64
|
+
docs = [
|
|
65
|
+
"mkdocs>=1.6",
|
|
66
|
+
"mkdocs-material>=9.5",
|
|
67
|
+
"mkdocstrings[python]>=0.27",
|
|
68
|
+
"pymdown-extensions>=10.0",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
asyncio_mode = "auto"
|
|
73
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff]
|
|
77
|
+
line-length = 100
|
|
78
|
+
target-version = "py311"
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint]
|
|
81
|
+
select = ["E", "F", "I", "W", "B", "UP", "RUF"]
|
|
82
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""tuiwright — Playwright-style end-to-end testing for TUI applications.
|
|
2
|
+
|
|
3
|
+
Drives any TUI binary under a real PTY + terminal emulator, with cell-grid
|
|
4
|
+
and PNG snapshot regression.
|
|
5
|
+
|
|
6
|
+
Quick start:
|
|
7
|
+
|
|
8
|
+
async def test_app(tui, snapshot):
|
|
9
|
+
await tui.start("myapp")
|
|
10
|
+
await tui.wait_for_text("Ready")
|
|
11
|
+
await tui.type("hello")
|
|
12
|
+
await tui.press("enter")
|
|
13
|
+
assert tui.screen == snapshot
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from tuiwright.screen import Cell, Color, Cursor, Position, Region, Screen
|
|
17
|
+
from tuiwright.session import TuiSession, TuiTimeoutError
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cell",
|
|
21
|
+
"Color",
|
|
22
|
+
"Cursor",
|
|
23
|
+
"Position",
|
|
24
|
+
"Region",
|
|
25
|
+
"Screen",
|
|
26
|
+
"TuiSession",
|
|
27
|
+
"TuiTimeoutError",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|