| name | user-e2e-testing-tui |
| description | Use when validating TUI or CLI behavior end-to-end via tmux. Provides the canonical pattern for spawning any terminal application inside tmux, sending keystrokes or shell commands, capturing pane output as plain text, and asserting on screen state. Works for full TUI apps (ratatui, bubbletea, etc.), interactive CLIs (REPLs, prompts), and non-interactive CLIs whose output needs visual inspection beyond what stdout parsing gives you. |
User E2E Testing — TUI / CLI
Your job: exercise and validate terminal-based user journeys by driving an application through tmux. No screenshots, no image analysis — tmux capture-pane gives you the full terminal content as plain text, including box-drawing characters, and that's sufficient for Claude to reason about layout, state, and correctness.
This skill is application-agnostic. It works for:
- Full TUI apps (ratatui, bubbletea, blessed, curses) — dialog navigation, form filling, list selection, panel switching.
- Interactive CLIs (REPLs, wizard prompts,
fzf-style selectors) — answering prompts, selecting options, verifying output.
- Non-interactive CLIs whose behavior is easier to validate by running them in a controlled terminal than by parsing raw stdout — colored output, progress bars, table formatting, multi-line layouts.
Core loop
Every test follows four steps:
- Spawn the app inside a detached tmux session with a known geometry.
- Act by sending keystrokes or text via
tmux send-keys.
- Observe by capturing the pane content via
tmux capture-pane -p.
- Assert by checking the captured text for expected content, patterns, or absence of errors.
Repeat steps 2–4 for each interaction in the user journey.
Prerequisites
tmux is installed and on PATH.
- The binary under test is built and accessible (on PATH or absolute path).
- No other tmux session with the chosen test session name exists.
Step 1 — Spawn
Create a detached tmux session with a fixed terminal size. The size matters: TUI layout depends on it, and text assertions break if the geometry changes between runs.
SESSION_NAME="e2e_test_${TEST_ID}"
tmux new-session -d -s "${SESSION_NAME}" -x 200 -y 50 "${BINARY_PATH} ${ARGS}"
sleep 1
- 200x50 is a safe default: wide enough for multi-panel layouts, tall enough for dialogs and long lists. Adjust if the app has specific requirements.
- The 1s sleep lets the app initialize and paint its first frame. For heavier apps (DB migrations, network handshakes), increase to 2–3s.
- Always use a unique
SESSION_NAME per test to avoid collisions. Use a descriptive prefix (e.g., e2e_myapp_, test_).
For CLI commands (non-persistent)
If you're testing a CLI that runs and exits, spawn a shell instead of the binary, then send the command as a keystroke:
tmux new-session -d -s "${SESSION_NAME}" -x 200 -y 50 'bash'
sleep 0.5
tmux send-keys -t "${SESSION_NAME}" "${CLI_COMMAND}" Enter
sleep 1
This keeps the pane alive after the command exits, so you can capture its output.
Step 2 — Send keystrokes
tmux send-keys -t "${SESSION_NAME}" <keys>
tmux key reference
| Syntax | Effect |
|---|
Up, Down, Left, Right | Arrow keys |
Tab, BTab | Tab / Shift+Tab |
Space | Spacebar |
Enter | Enter/Return |
Escape | Escape |
C-p, C-c, C-x | Ctrl+key |
M-x | Alt+key |
BSpace | Backspace |
DC | Delete |
Home, End | Home / End |
PPage, NPage | Page Up / Page Down |
Sending literal text
Use the -l flag for text input. Without it, tmux interprets special key names.
tmux send-keys -t "${SESSION_NAME}" -l "hello world"
tmux send-keys -t "${SESSION_NAME}" Enter
Never mix -l and control keys in the same send-keys call. Send text and control keys in separate calls.
Timing
Wait after sending keys that trigger state changes (dialog open, panel switch, list scroll, command execution):
| Context | Recommended sleep |
|---|
| App startup | 1–3s |
| Dialog open/close | 0.5s |
| Field toggle, list navigation | 0.3s |
| CLI command execution | 1–5s (depends on command) |
| Heavy operation (build, migration) | 5–30s |
Step 3 — Capture
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
The output is plain text: one line per terminal row. Box-drawing characters (│, ─, ┌, └) are preserved. ANSI color codes are stripped by default.
Scrollback (content above the visible area):
tmux capture-pane -t "${SESSION_NAME}" -p -S -100
With ANSI colors (when color matters for the assertion):
tmux capture-pane -t "${SESSION_NAME}" -p -e
Step 4 — Assert
Two assertion modes, use whichever fits:
Mode A — grep / regex checks (deterministic assertions)
echo "${SCREEN}" | grep -q "Expected Text" || echo "FAIL: 'Expected Text' not found"
echo "${SCREEN}" | grep -q '\[x\] Enable feature' || echo "FAIL: checkbox not checked"
echo "${SCREEN}" | grep -q '\[ \] Disable thing' || echo "FAIL: checkbox not unchecked"
echo "${SCREEN}" | grep -q 'Mode:.*production' || echo "FAIL: mode not production"
echo "${SCREEN}" | grep -qi 'error\|panic\|crash\|fatal' && echo "FAIL: error on screen"
tmux send-keys -t "${SESSION_NAME}" 'echo $?' Enter
sleep 0.3
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q '^0$' || echo "FAIL: non-zero exit code"
Mode B — Claude reads the screen (complex/layout-dependent assertions)
When assertions are complex, layout-dependent, or would require brittle regex chains, just capture and reason about the text directly. Claude can parse box-drawing layouts, table alignments, dialog structure, progress indicators, and multi-panel arrangements from raw terminal text.
This is the preferred mode for exploratory validation and for the end-user-simulator agent. Capture the screen, read it, describe what you see, and judge whether it matches the expected behavior.
Cleanup
Always kill the test session, even on failure:
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
Clean up any side effects the app created (files, child processes, sub-sessions, worktrees, Docker containers).
Example A — TUI app: navigate a dialog and configure options
SESSION_NAME="e2e_test_tui_dialog"
tmux new-session -d -s "${SESSION_NAME}" -x 200 -y 50 'my-tui-app'
sleep 1
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q 'Welcome' || { echo "FAIL: app didn't start"; exit 1; }
tmux send-keys -t "${SESSION_NAME}" n
sleep 0.5
tmux send-keys -t "${SESSION_NAME}" Tab
tmux send-keys -t "${SESSION_NAME}" -l "my-value"
sleep 0.3
tmux send-keys -t "${SESSION_NAME}" Tab
tmux send-keys -t "${SESSION_NAME}" Space
sleep 0.3
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q 'my-value' || echo "FAIL: text not entered"
echo "${SCREEN}" | grep -q '\[x\]' || echo "FAIL: checkbox not checked"
tmux send-keys -t "${SESSION_NAME}" Escape
sleep 0.3
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
Example B — Interactive CLI: answer prompts in a wizard
SESSION_NAME="e2e_test_cli_wizard"
tmux new-session -d -s "${SESSION_NAME}" -x 120 -y 30 'my-cli init'
sleep 1
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q 'Project name' || { echo "FAIL: first prompt"; exit 1; }
tmux send-keys -t "${SESSION_NAME}" -l "my-project"
tmux send-keys -t "${SESSION_NAME}" Enter
sleep 0.5
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q 'framework' || { echo "FAIL: framework prompt"; exit 1; }
tmux send-keys -t "${SESSION_NAME}" Down Down Enter
sleep 0.5
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p)
echo "${SCREEN}" | grep -q 'Created' || echo "FAIL: project not created"
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
Example C — Non-interactive CLI: validate formatted output
SESSION_NAME="e2e_test_cli_output"
tmux new-session -d -s "${SESSION_NAME}" -x 120 -y 30 'bash'
sleep 0.5
tmux send-keys -t "${SESSION_NAME}" 'cargo test --test integration 2>&1' Enter
sleep 15
SCREEN=$(tmux capture-pane -t "${SESSION_NAME}" -p -S -200)
echo "${SCREEN}" | grep -q 'test result: ok' || echo "FAIL: tests did not pass"
echo "${SCREEN}" | grep -q 'FAILED' && echo "FAIL: some tests failed"
tmux kill-session -t "${SESSION_NAME}" 2>/dev/null || true
Timing and flakiness
TUI rendering is async relative to keystroke delivery. The app processes the key, updates internal state, then renders on the next frame tick.
- Sleep after state-changing keys. See the timing table above.
- Retry capture if assertion fails. The render might not have completed. One retry with a 0.5s wait is reasonable before declaring failure.
- Use generous terminal size. A cramped terminal causes line wrapping that breaks text assertions.
- Don't assert on exact line positions. Assert on the presence of text, not its row number. Layout shifts between versions and terminal sizes.
- For long-running commands, prefer checking for a completion marker (prompt reappearing, "Done" message) over fixed sleeps.
Multi-pane testing
tmux supports splitting into panes for apps that need a server + client, or parallel processes:
tmux new-session -d -s "${SESSION_NAME}" -x 200 -y 50 'server start'
tmux split-window -t "${SESSION_NAME}" -h 'client connect'
sleep 2
SERVER_SCREEN=$(tmux capture-pane -t "${SESSION_NAME}.0" -p)
CLIENT_SCREEN=$(tmux capture-pane -t "${SESSION_NAME}.1" -p)
Panes are numbered .0, .1, etc. Use tmux list-panes -t "${SESSION_NAME}" to inspect layout.
Anti-patterns
- Asserting on ANSI escape codes —
capture-pane -p strips them by default. If you need color info, use -e, but prefer content-based assertions.
- Hardcoding line numbers — layout changes between versions and terminal sizes. Match text content, not coordinates.
- Skipping cleanup — leaked tmux sessions pollute the environment and interfere with subsequent tests.
- Sending keys too fast — the app's event loop may coalesce or drop rapid keystrokes. Always sleep between state-changing actions.
- Mixing
-l with control keys — tmux send-keys -l "text" Enter doesn't work as expected. Send literal text and control keys in separate calls.
- Running against a live user session — always spawn a dedicated test session. Never send-keys to a session the user is actively using.
- Fixed sleeps for unknown-duration operations — prefer polling the screen for a completion marker in a loop with a timeout over guessing how long an operation takes.