Waiting (no sleep!)¶
The #1 cause of flaky TUI tests is asyncio.sleep(N). tuiwright has
three primitives that replace every legitimate use of sleep. Learn
these; use nothing else.
Don't do this
wait_for_text¶
Polls until a substring or regex appears.
await tui.wait_for_text("Ready")
await tui.wait_for_text("Ready", timeout=10)
await tui.wait_for_text(r"v\d+\.\d+", regex=True)
await tui.wait_for_text("error", region=tui.region(title="Logs"))
Returns the re.Match object so you can extract groups:
On timeout, the error includes a full dump of the last screen so you can see exactly what the app rendered.
wait_for_predicate¶
For conditions that aren't a text match:
await tui.wait_for_predicate(
lambda screen: screen.cursor.row == 10,
timeout=3,
description="cursor reaches row 10",
)
The predicate is called with the current Screen. It can be sync or
return an awaitable.
Use this for:
- Cursor position checks
- Cell-attribute checks ("the OK button should turn green")
- Cross-region invariants ("all error rows should have a ✗ in col 0")
- Process state ("the app should have exited") — pass
lambda _: not tui.alive
wait_for_stable¶
Returns when no output has arrived for quiet_ms.
Use this:
-
After startup, to make sure the entire first frame has been drawn (the bottom status bar often paints last):
-
Between rapid input bursts, because many TUIs process one event per render tick:
-
Before snapshotting, so the captured screen is stable:
How polling works¶
Internally, every wait_for_* uses the same loop:
- Check the condition.
- If true → return.
- If timeout exceeded → raise
TuiTimeoutErrorwith the last screen. - Otherwise → wait on a "screen changed" event with a short backoff (default 20 ms), then loop.
The "screen changed" event fires every time bytes arrive from the PTY. So the loop is event-driven, not a fixed sleep — you typically wake up the same millisecond the data arrives.
Tuning the poll interval¶
The default poll interval (20 ms) is right for almost every test. You can change it per-session:
from tuiwright import TuiSession
from tuiwright.session import TuiConfig
session = TuiSession(TuiConfig(poll_interval=0.005)) # 5 ms polls
But honestly, if you're tweaking this, you probably have a different problem.
TuiTimeoutError¶
When a wait times out, you get:
TuiTimeoutError: timed out after 5.0s waiting for text 'Saved'.
--- last screen ---
gode gemini/gemini-3.1-pro-preview-customtools high idle
No transcript yet. Ask gode to inspect, edit, or run something.
... (full screen) ...
--- end ---
Read the screen. The answer is usually right there: a modal you didn't dismiss, a status that says "loading", a popup the test didn't account for.
Common timeout situations¶
I'm waiting for text that's clearly on screen
You're probably checking a region that was captured before the
screen updated. Pass the region by kwarg to wait_for_text —
we re-derive it from the current screen on every poll:
I'm waiting for the app to exit
My snapshot tests are flaky
Add a wait_for_stable before the assertion. Cursor position
twitches are the most common culprit (the cursor is part of the
snapshot header).
Next: Keys →