tuispec

Playwright-like black-box testing for terminal UIs over PTY.
tuispec is a Haskell library that lets you write reliable TUI tests as normal
Haskell programs. Interact with any terminal application via PTY — send
keystrokes, wait for text, and capture snapshots — without instrumenting the
target app.
Example output
Snapshots captured from the included Brick demo app
using tuispec:

Features
- PTY transport — tests interact with real terminal apps over PTY
- Per-test isolation — fresh PTY process per test, no shared state
- Snapshot assertions — baseline comparison with ANSI text + PNG artifacts
- Text selectors —
Exact, Regex, At, Within, Nth
tasty integration — tests are regular tasty test trees
- JSON-RPC server — agentic orchestration of TUIs via
tuispec server
- Server notifications — optional
view.changed push events (debounced)
- Batch + replay — JSON-RPC batch execution and JSONL recording/replay
- REPL sessions — ad-hoc exploration with
withTuiSession
Quick start
Add tuispec to your build-depends and write a test:
{-# LANGUAGE OverloadedStrings #-}
import Test.Tasty (defaultMain, testGroup)
import TuiSpec
main :: IO ()
main =
defaultMain $ testGroup "demo"
[ tuiTest defaultRunOptions "counter" $ \tui -> do
launch tui (app "my-tui" [])
waitForText tui (Exact "Ready")
press tui (CharKey '+')
expectSnapshot tui "counter-updated"
press tui (CharKey 'q')
]
Run:
cabal test
Building from source
cabal build
Run root smoke tests:
cabal test
Run the Brick demo integration suite:
cd example
cabal test
DSL overview
Actions
launch :: Tui -> App -> IO ()
app :: FilePath -> [String] -> App
press :: Tui -> Key -> IO ()
pressCombo :: Tui -> [Modifier] -> Key -> IO ()
typeText :: Tui -> Text -> IO ()
sendLine :: Tui -> Text -> IO ()
Waits and assertions
waitForText :: Tui -> Selector -> IO ()
waitForStable :: Tui -> WaitOptions -> Int -> IO ()
expectVisible :: Tui -> Selector -> IO ()
expectNotVisible :: Tui -> Selector -> IO ()
expectSnapshot :: Tui -> SnapshotName -> IO ()
dumpView :: Tui -> SnapshotName -> IO FilePath
Selectors
Exact Text -- exact substring match
Regex Text -- lightweight regex (|, .*, literal parens stripped)
At Int Int -- position-based (col, row)
Within Rect Selector -- restrict to a rectangle
Nth Int Selector -- pick the Nth match
waitForText/selector matching runs against the rendered viewport text
(after ANSI control/style escapes are interpreted), not raw escape bytes.
Keys
Named keys: Enter, Esc, Tab, Backspace, arrows, FunctionKey 1..12
Character keys: CharKey c, Ctrl c, AltKey c
Combos: pressCombo [Control] (CharKey 'c')
Snapshots
For a test named my test (slug: my-test) with artifactsDir = "artifacts":
- Baselines:
artifacts/snapshots/my-test/<snapshot>.ansi.txt
- Per-run:
artifacts/tests/my-test/snapshots/<snapshot>.ansi.txt
Render any ANSI snapshot to PNG:
cabal run tuispec -- render artifacts/tests/my-test/snapshots/<snapshot>.ansi.txt
Specify an explicit font file:
cabal run tuispec -- render --font /path/to/YourMono.ttf artifacts/tests/my-test/snapshots/<snapshot>.ansi.txt
Render visible plain text:
cabal run tuispec -- render-text artifacts/tests/my-test/snapshots/<snapshot>.ansi.txt
JSON-RPC server
cabal run tuispec -- server --artifact-dir artifacts/server
Newline-delimited JSON-RPC 2.0 on stdin/stdout for agentic orchestration of
TUIs. See SERVER.md for the full protocol reference.
Ping example:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"server.ping","params":{}}' \
| cabal run tuispec -- server --artifact-dir artifacts/server
End-to-end session example:
cat <<'JSON' | cabal run tuispec -- server --artifact-dir artifacts/server
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"name":"rpc-demo","terminalCols":134,"terminalRows":40}}
{"jsonrpc":"2.0","id":2,"method":"launch","params":{"command":"sh","args":["-lc","printf 'READY\\n'; exec sh"],"cwd":".","env":{"DEMO_FLAG":"1","CLAUDECODE":null},"readySelector":{"type":"exact","text":"READY"}}}
{"jsonrpc":"2.0","id":3,"method":"sendLine","params":{"text":"printf 'hello from rpc\\n'"}}
{"jsonrpc":"2.0","id":4,"method":"waitForText","params":{"selector":{"type":"exact","text":"hello from rpc"}}}
{"jsonrpc":"2.0","id":5,"method":"dumpView","params":{"name":"after-hello","format":"both"}}
{"jsonrpc":"2.0","id":6,"method":"currentView","params":{"entireRow":1}}
{"jsonrpc":"2.0","id":7,"method":"server.shutdown","params":{}}
JSON
launch.params.env is optional. Values set/override inherited variables and
null unsets inherited variables for that launch. launch.params.cwd sets the
child working directory.
currentView coordinates are 1-based. For row/col-oriented filters, 0 means
"entire row range" / "entire column range".
Input examples:
{"jsonrpc":"2.0","id":7,"method":"sendKey","params":{"key":"+"}}
{"jsonrpc":"2.0","id":8,"method":"sendKey","params":{"key":"Ctrl+C"}}
{"jsonrpc":"2.0","id":9,"method":"sendText","params":{"text":"hello"}}
Replay a recording JSONL file:
cabal run tuispec -- replay artifacts/server-tests/recording/session.jsonl --speed as-fast-as-possible
REPL-style sessions
For ad-hoc exploration outside tasty:
withTuiSession defaultRunOptions "demo" $ \tui -> do
launch tui (app "sh" [])
sendLine tui "echo hello"
_ <- dumpView tui "step-1"
pure ()
Configuration
RunOptions fields (all overridable via environment variables):
| Field |
Default |
Env var |
timeoutSeconds |
5 |
TUISPEC_TIMEOUT_SECONDS |
retries |
0 |
TUISPEC_RETRIES |
stepRetries |
0 |
TUISPEC_STEP_RETRIES |
terminalCols |
134 |
TUISPEC_TERMINAL_COLS |
terminalRows |
40 |
TUISPEC_TERMINAL_ROWS |
artifactsDir |
"artifacts" |
TUISPEC_ARTIFACTS_DIR |
updateSnapshots |
False |
TUISPEC_UPDATE_SNAPSHOTS |
ambiguityMode |
FailOnAmbiguous |
TUISPEC_AMBIGUITY_MODE |
snapshotTheme |
"auto" |
TUISPEC_SNAPSHOT_THEME |
Requirements
- GHC 9.12+
- Linux terminal environment (PTY-based)
python3 with Pillow for PNG rendering
- a host monospace TTF/TTC font (or pass
--font, or TUISPEC_FONT_PATH)
License
MIT — Matthias Pall Gissurarson