Skip to content

Latest commit

 

History

History
532 lines (417 loc) · 22.1 KB

README.md

File metadata and controls

532 lines (417 loc) · 22.1 KB

tui-test for Python

Control, inspect, and test terminal apps from Python.

Scoped queries and session context

For scope anchors, whole-match style, relative queries, and trace completion, see examples/parity.py.

from tui_test import ExecutionContext, TextAnchor, TextScope, TextStyle

save = terminal.get_by_text(
    "Save", scope=TextScope(after=TextAnchor("Start"), before=TextAnchor("End")),
).with_style(TextStyle(bold=True))
choice = save.relative_to(
    terminal.get_by_text("Header"), "after",
).with_style(TextStyle(italic=False)).or_(terminal.get_by_link("test:save"))

TextAnchor accepts text, regex, and occurrence. Occurrence defaults to "unique"; alternatives are "any", "first", "last", or a zero-based integer. with_style tests each whole match. It ignores blank cells when visible characters exist. It supports text, style, and link locators. Rust rejects scope or whole-match style on composite nodes. Apply these fields to the operands before composition. It does not replace and_(terminal.get_by_style(...)), which intersects cells. relative_to(parent, direction="within") also accepts "after" and "before". The parent must belong to the same TuiTest instance. Locator methods copy options and leave the original query unchanged.

The constructor and open/run accept context=ExecutionContext(...). Context fields are operation_name, diagnostic_context, artifacts, trace, and screen_history_limit. diagnostic_context maps string keys to string values. The engine sanitizes and limits those fields before it writes them. Per-call fields override constructor fields for that spawn only. open and run also accept recording=AutomaticRecording(directory=...). An explicit AutomaticRecording() restores the engine's default directory. AutomaticRecording.mode accepts "disabled", "on-failure", or "always" in the runtime API. The default mode is "disabled". This controls automatic casts without a trace. Explicit trace options take precedence over this mode. The CLI configuration still permits only recording.directory.

await terminal.finish_trace(failed=False) completes the trace and keeps the terminal open. await terminal.interrupt() cancels pending operations and terminates the child without an operation queue wait. It does not send a keyboard interrupt. Later waits report cancellation until restart or close. An absent session produces NoSessionError. Call close() afterward to release the named session. Use sessions() to check whether a named session is open. OpenResult["recording"] supplies the automatic recording path. get_recording(name) reads current or retained recording content. packed_screen(full=False) returns an owned, read-only UTF-8 memoryview, columns, and rows. locator.highlight_matches(timeout=...) returns the highlighted matches. The existing highlight() method still returns None. expect_snapshot(name, cwd=...) accepts an explicit working directory for snapshot files.

Run the example from the repository root:

PYTHONPATH=bindings/python/src python3 bindings/python/examples/parity.py

The example creates trace artifacts under artifacts/parity-python. Set TUI_TEST_PARITY_ARTIFACTS to choose another directory. Artifacts can contain terminal text.

Install

pip install tui-test

Python 3.8+ is supported.

The wheel contains an ordinary shared library. Python uses generated ctypes declarations for its typed C ABI. The package has no Python native extension.

Set TUI_TEST_NATIVE_LIBRARY to an absolute library path to override the bundled library. The loader checks the ABI version before the first operation.

Build from source

Install Rust 1.90 or later and the build dependencies for the supported terminal backends. Build a wheel with the standard Python build frontend:

python -m pip install build
env -u CARGO_TARGET_DIR python -m build bindings/python

The build uses setuptools-rust with Binding.NoBinding. Wheels use py3-none-<platform> tags. The source distribution includes the shared ABI, the Rust engine, their assets, and the Cargo configuration that separates backend symbols. Source builds need Cargo dependency access.

For cross builds, set CARGO_BUILD_TARGET and pass -C--build-option=--plat-name=<platform> to python -m build --wheel. Build Linux release wheels in the required manylinux or musllinux container. Use auditwheel repair before publication. Setuptools-rust disables static libc linkage for musl shared libraries.

For a checkout, use the shared library directly:

env -u CARGO_TARGET_DIR cargo build -p tui-test-ffi
PYTHONPATH=bindings/python/src \
TUI_TEST_NATIVE_LIBRARY="$PWD/target/debug/libtui_test_ffi.dylib" \
python bindings/python/examples/shared_library.py

Use libtui_test_ffi.so on Linux or tui_test_ffi.dll on Windows. The example proves that a copied screen remains valid after the session closes.

Each asynchronous call uses a separate worker thread. A blocked wait cannot prevent close() from interrupting waits. Cancellation releases each native result when its call returns.

Cancellation stops the Python await, not the native operation. A canceled open(), run(), or restart() can still complete. The adapter does not close the session automatically after cancellation. This preserves any replacement session with the same name. The caller remains responsible for explicit session cleanup; cancellation does not confirm that a spawn stopped.

Quick start

from tui_test import TuiTest

async with TuiTest.ephemeral() as terminal:
    await terminal.run("my-app")
    await terminal.get_by_text("Ready").expect()
    await terminal.get_by_text("Continue").click()
    await terminal.get_by_text("Done").expect()

API

TuiTest

TuiTest(session=None, *, backend=None, timeouts=None, profile=None, screen_history_limit=None, artifacts=None, recording=None)
Option Type Default
session str TUI_TEST_SESSION or "default"
backend "alacritty" | "ghostty" | "rio" | "xtermjs" "alacritty"
timeouts Timeouts | dict built-in defaults
profile Profile | dict built-in profile
screen_history_limit int | None core default
artifacts dict off
recording AutomaticRecording | dict (directory, optional mode) default directory, disabled mode
trace TraceOptions | dict {"mode": "off", "directory": ".tui-test/traces"}

Set trace.mode to "on" for every session or "on-failure" for failures. Users can open trace.html; agents should read trace.md, trace.json, and timeline.json.

artifacts["on_failure"] selects "none", "text", "html", or "all". Use include_recording to include the cast in failure artifacts.

Properties

Property Type
session str
keyboard keyboard helper
mouse mouse helper

Lifecycle

Method Description
TuiTest.ephemeral(prefix=None, **options) Create a unique session.
await open(**options) Open a shell.
await run(program, *args, **options) Run a program.
await restart(graceful_timeout=5000) Restart the session.
await close() Close the session.
await close_quiet() Close without raising.
async with TuiTest() Close on exit.

open() options are shell, backend, cols, rows, cwd, env, wait_ready, restart, retries, profile, style, and timeouts. run() accepts the same options except shell.

The default size is 80 by 30. Timeout defaults are 5 seconds for text and idle, and 30 seconds for command, exit, and ready.

Input

Method Description
await submit(text=None) Type text and press Enter.
await type(text) Type text.
await write(data) Write raw bytes.
await press(*keys) Alias for keyboard.press().
await resize(cols, rows) Resize the terminal.
await scroll(lines) Move through native history; positive values show older output.
await scroll_to_top() / await scroll_to_bottom() Show the oldest output or the live screen.
await get_viewport() Return a Viewport dictionary with offset and history, in rows.
await signal(name) Send INT, TERM, KILL, or QUIT.
await kill() Kill the child process.

Native history

scroll(lines) accepts integers from -2147483648 through 2147483647. Positive values show older output. Negative values move toward the live screen. Zero leaves the viewport unchanged. Movement stops at the available history boundaries. get_viewport() returns a Viewport dictionary with offset and history integer fields. Both fields count rows. An offset of zero means the live screen.

Visible text, locators, and captures use this viewport. text(full=True) remains independent: it returns history plus the live screen. New output preserves the historical view until retained history expires. Keyboard input returns to the live screen. Mouse input and historical locator clicks fail while the viewport shows history. Call scroll_to_bottom() before mouse input. This API is separate from mouse.scroll().

get_cursor() and State.cursor retain the application cursor coordinates. Captures project that cursor into the viewport and hide it outside the view. Raster recordings capture viewport changes. Asciicast remains a PTY stream.

await terminal.scroll_to_top()
print(await terminal.get_viewport())
print(await terminal.text())
print(await terminal.text(full=True))
await terminal.scroll(-2)
await terminal.scroll_to_bottom()

State

Method Returns
await state() State
await text(full=False) str
await cells(x, y, w=1, h=1) list[Cell]
await get_command() str | None
await get_output() str | None
await get_exit_code() int | None
await get_cwd() str | None
await get_cursor() Cursor
await get_modes() dict[str, bool]
await get_colors() dict
await get_size() dict
await get_title() str | None
await get_clipboard() str
await get_bell_count() int
await get_bell_events() list[BellEvent]

Waits and assertions

Method Description
await wait_title(text, regex=False, not_=False, timeout=None) Wait for a title.
await wait_clipboard(text=None, timeout=None) Wait for a clipboard change or match.
await wait_idle(timeout=None) Wait for the screen to stop changing.
await wait_command(timeout=None) Wait for a submitted command.
await wait_exit(timeout=None) Wait for the program to exit.
await wait_ready(timeout=None) Wait for a shell prompt.
await wait_bell(timeout=None) Wait for a bell.
await expect_title(text, regex=False, not_=False, timeout=None) Assert the title.
await expect_exit_code(code, timeout=None) Assert the last exit code.
await expect_output(text, regex=False) Assert command output.
await expect_bell_count(count, timeout=None) Wait until the cumulative bell count reaches count.
await expect_snapshot(name, **options) Assert or update a snapshot.
await expect_cursor(**options) Wait for cursor position, visibility, or shape.
await expect_mode(mode, enabled, timeout=...) Wait for a native terminal mode.
await expect_colors(**options) Wait for foreground, background, cursor, or palette colors.

Native assertions accept timeout in milliseconds and use the text timeout by default. Palette expectations use a dictionary of indices and colors.

wait_clipboard() waits for the next change. A string matches text. A compiled text re.Pattern uses Python's search() semantics; bytes patterns are rejected.

Snapshot options are update, include_style, and include_title.

Capture

Method Description
await screenshot(path=None, full=False, zoom=None, background=None, transparent=False) Return text or save SVG or PNG.
await start_recording(path, **options) Start APNG, GIF, MP4, or asciinema recording.
await pause_recording() Pause APNG, GIF, or MP4 capture while the terminal continues.
await resume_recording() Resume raster capture from the live terminal without paused time.
await stop_recording() Finish the recording and return its path.

Recording options: format, fps, speed, idle_time_limit, zoom, background, and transparent. MP4 requires ffmpeg and does not support transparency.

Asciicast pause and resume return usage errors without changing the recording. Automatic diagnostic recordings and recovery casts remain continuous, including output during pauses.

Raster recording samples the live terminal at fps (default: 30) and preserves active elapsed time until stop. There is no default idle cap or extra final hold. Set idle_time_limit to cap unchanged visual intervals before speed scales playback time. Asciicast remains a timestamped output stream; FPS sampling does not change it.

The extension selects the format: .png or .apng, .gif, .mp4, or .cast. format overrides it.

Rendering settings

Pass a RenderStyle to the client constructor, open(), or run(). It supports fonts, colors, padding, window controls, borders, shadows, and cursor animation. A per-call style replaces the constructor style. Omitted nested fields use Rust defaults. Explicit False and zero remain unchanged. Restart preserves the resolved style. These settings affect screenshots and raster recordings, not terminal colors or locator assertions.

from tui_test import TuiTest, RenderStyle, WindowStyle, RecordingCursorStyle

terminal = TuiTest.ephemeral(
    style=RenderStyle(
        font_size=24,
        window=WindowStyle(title_bar=False),
        cursor=RecordingCursorStyle(blink=False, blink_interval_ms=250),
    )
)

blink permits application-requested animation; it does not make a steady cursor blink. Each phase uses blink_interval_ms before playback speed scaling. The default values are True and 500. Screenshots always use the visible phase. Run examples/rendering.py for a complete example.

Locator

Locators resolve against the latest terminal screen before every read or action.

from tui_test import TextStyle

save = (
    terminal
    .get_by_text("Settings")
    .get_by_text("Save", direction="after")
    .get_by_style(TextStyle(foreground="green"))
    .unique()
)

await save.click()

Create a locator

Method Options
terminal.get_by_text(text, **options) regex, full, whitespace
terminal.get_by_style(style, **options) full
terminal.get_by_link(uri, **options) full
locator.get_by_text(text, **options) regex, full, whitespace, direction
locator.get_by_style(style, **options) full, direction
locator.get_by_link(uri, **options) full, direction

whitespace is "exact" or "normalize". direction is "within", "after", or "before".

TextStyle fields are foreground, background, bold, dim, italic, underline_style, underline_color, inverse, hidden, strikethrough, and blink.

get_by_link(uri) matches an exact OSC 8 target, not visible URL text; get_by_link("") requires no link. Root style/link selectors find runs within each row. Chained calls with the default within direction check whole matches. Styles skip blanks if visible text exists; links check every cell.

Compose locators

link = terminal.get_by_link("https://example.com")
bold = terminal.get_by_style(TextStyle(bold=True))
bold_link_cells = bold.and_(link)
either = link.or_(terminal.get_by_text("Help"))
sections = terminal.get_by_text("Docs and Help")
contains_link = sections.filter(has=link)
without_old_text = sections.filter(has_not=terminal.get_by_text("old"))
entirely_linked = sections.get_by_link("https://example.com")

and_() keeps shared cells; or_() combines cells without duplicates. Adjacent cells merge within each physical row, even across original matches. Gaps and row breaks split runs. Counts and clicks use these runs; text keeps exact whitespace.

filter accepts only locators. has requires a match inside each candidate; has_not requires none. Both conditions apply when supplied, and the inner match may cover the whole candidate. For partially linked "Docs", filter(has=link) keeps the whole word, get_by_link(uri) rejects it, and and_(link) returns its linked cells.

Use locators from one TuiTest. Composition leaves them unchanged and reads one fresh snapshot when used. Selection order matters: a.first().and_(b) differs from a.and_(b).first(). Any full branch includes scrollback for the whole query. Errors propagate.

Select matches

Method Description
any() Keep all matches.
unique() Require one match.
first() Select the first match.
last() Select the last match.
nth(index) Select a zero-based match.

Read and act

Method Description
await locations() Return all selected locations.
await location() Return one location.
await count() Return the current count.
await all() Return one locator per current match.
await wait(state="visible", timeout=None) Wait for "visible" or "hidden".
await expect(not_=False, timeout=None) Assert the locator.
await click(**options) Click the middle cell.
await highlight(timeout=None) Highlight matches.

click() accepts button, alt, ctrl, shift, clicks, and timeout. button is "left", "middle", or "right".

location() and click() require one match. all() does not wait.

Keyboard

Method Description
await keyboard.press(*keys) Press keys.
await keyboard.down(*keys) Send keydown events.
await keyboard.repeat(*keys) Send repeat events.
await keyboard.up(*keys) Send keyup events.
await terminal.keyboard.press("Ctrl+C")
await terminal.keyboard.press("Escape", ":", "w", "q", "Enter")

Named keys include Up, Down, Left, Right, Home, End, PageUp, PageDown, Insert, Delete, Backspace, Tab, Enter, Space, Escape, and F1 through F12. Join modifiers such as Ctrl, Alt, Shift, Super, Meta, or Hyper with +.

Mouse

Coordinates are zero-based terminal cells.

Method Description
await mouse.click(x=None, y=None, **options) Click a cell or on_text.
await mouse.move(x, y) Move the pointer.
await mouse.down(x, y, **options) Press a button.
await mouse.up(x, y, **options) Release a button.
await mouse.drag(x1, y1, x2, y2, **options) Drag between cells.
await mouse.scroll("up" | "down", amount=3) Scroll.

Button options are button, alt, ctrl, and shift. Click also accepts on_text and clicks.

await terminal.mouse.click(10, 5, button="right", ctrl=True)

Module functions

Function Description
await sessions() List sessions in this process.
await close_all() Close all sessions in this process.
await get_recording(session=None) Return an automatic asciinema recording.
unique_session(prefix=None) Create a unique session name.

Test helpers

Import from tui_test.testing.

Function Description
await create_terminal(**options) Create, open, and track a terminal.
async with terminal(**options) Open and close a terminal.
await close_all_tracked() Close tracked terminals.
set_terminal_defaults(**options) Set suite defaults.
reset_terminal_defaults() Reset suite defaults.
track_terminal(terminal) Track a terminal.
untrack_terminal(terminal) Stop tracking a terminal.
tracked_count() Count tracked terminals.
terminal_snapshot(text) Normalize text for snapshots.

TerminalOptions adds shell, program, session, and prefix to the client and spawn options.

DEFAULT_SHELL is the platform default.

from tui_test.testing import terminal

async with terminal(program=("my-app",)) as app:
    await app.get_by_text("Ready").expect()

Configuration

from tui_test import AutomaticRecording, Colors, Profile, Timeouts

terminal = TuiTest(
    profile=Profile(
        scrollback=500,
        colors=Colors(foreground="#ffffff", background="#000000"),
    ),
    timeouts=Timeouts(text=10_000, command=60_000),
    artifacts={"dir": "artifacts", "on_failure": "all"},
    trace={"mode": "on-failure", "directory": "artifacts/traces"},
    recording=AutomaticRecording(directory="artifacts/casts"),
)

Types

Type Description
State Session state and visible text.
Cursor Typed dictionary with integer x/y, boolean visible, and string shape/color.
Cell One terminal cell and its style.
TextMatch Matched text, positions, and spans.
TextStyle Locator style fields.
Profile Scrollback and colors.
Timeouts Text, idle, command, exit, and ready timeouts.
AutomaticRecording Automatic recording mode and directory.
Colors Terminal palette.
MouseButton "left", "middle", or "right".
TextPosition, TextSpan Match coordinates.
FailureDetails Structured operation, locator, process, runtime, and screen evidence.
FailureArtifactRef Paths and write status for a failure artifact.

__version__ contains the package version.

Errors

Error Exit code
ExpectationError 1
UsageError 2
NoSessionError 3
InternalError 5

All errors extend TuiTestError. Structured native failures expose details and artifact; expectation errors continue to populate compatibility terminal.text and terminal.screenshot fields. Failure artifacts can contain terminal output, titles, locator operands, and recordings, so review them before uploading.

Sessions are local to the current process and cannot be controlled by the CLI. Cancelling a task does not stop an active terminal operation.