Testing Terminal Apps & TUIs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A command-line program that prints lines to a pipe is easy to test: run it, compare stdout, stderr and the exit status. A program that detects a terminal, switches to the alternate screen, reads single keys and redraws cells is not. Its behaviour depends on a terminal being present, so the test has to supply one. This page is about the tests that do: driving the real program through a pseudo-terminal (PTY), reading what it drew, and running that on Windows, macOS and Linux. The tests that do not need a terminal (golden --json output, exit codes, --help snapshots, "never prompts without a TTY") are on Testing CLIs and are not repeated here.

The page was checked on 2026-10-25 against the documentation of pexpect 4.9.0, pyte 0.8.x, Expect 5.45.4, expectrl 0.9.0, rexpect 0.7.1, portable-pty 0.9.0, vt100 0.18.1, the tmux manual, asciinema, VHS and the GitHub-hosted runner reference. The Tcl Expect script and the Python tests (pexpect 4.9.0, pyte, pytest, Python 3.9) were run against a small stand-in for the shelf tool on macOS, and all passed. The Rust examples, the tmux script, the VHS tape, the asciinema commands and the GitHub Actions workflow were not compiled or run while this page was written: their APIs and options were checked against the documentation, but run each one before relying on it. The terminal model underneath (master and subsidiary, line discipline, SIGWINCH) is on Terminals, TTYs & Pseudo-Terminals, and the Windows side on Windows Console & Processes.

What a terminal test has to provide

A full-screen program asks the terminal questions and acts on the answers: is stdin a terminal, what is the window size, which keys arrived, what may I draw. A test harness that wants the real program to run must therefore be a terminal. The harness opens a PTY, starts the program on the subsidiary end, writes keystrokes to the manager end, and reads the bytes the program draws. The bytes contain escape sequences (cursor moves, colours, the switch to the alternate screen), so "what is on the screen" is an interpretation of the bytes, which needs a terminal emulator.

flowchart LR T["Test code"] -->|"keys"| M["PTY manager end"] M --> S["PTY subsidiary end"] S --> P["shelf tui (the real binary)"] P -->|"bytes with escape sequences"| S S --> M M -->|"raw bytes"| E["Terminal emulator in the test (pyte, vt100)"] E -->|"screen rows"| A["Assertions"] M -->|"raw bytes"| A

Tests at different layers see different things, and a suite normally uses several.

Layer Sees Does not see

Logic tests (state update functions, handle_key, Update)

Every state transition, deterministically and in microseconds

Whether the real input source produces those events; anything about the terminal

In-process rendering tests (Ratatui TestBackend, Textual Pilot, teatest)

The cells the framework would draw, for a size and a key sequence you choose

Raw mode, the alternate screen, signal handling, the exit status, escape sequences actually written

Real-PTY tests (this page)

The compiled program as a user’s terminal would see it: setup and restore of the terminal, exit codes, resize, escape sequences

Nothing is mocked, but the tests are slower and depend on timing

tmux-driven tests

The same, inside a real terminal multiplexer, with its own interpretation of the screen

Whatever tmux itself changes (it is a terminal emulator with its own TERM)

Recorded sessions (VHS, asciinema)

The program running in someone’s terminal emulator, as a video or text capture

Assertions: a recording is evidence for a person, unless you diff its text output

Rendering tests per library are covered where each library is: Ratatui: Testing & Ecosystem, Python TUIs with Textual, Rich & prompt_toolkit and Go TUIs with Bubble Tea. Those pages each say, in their own words, that the in-process layer is not enough and point here for the rest.

The tools

Tool Language Platforms (as documented) Notes

Expect

Tcl

UNIX; a Windows port is available from ActiveState

The original. The Tcl project page says the repository is no longer maintained and points to an updated version for Tcl 8.6 and Tcl 9 under the tcltk-depot organisation.

pexpect

Python

Unix pseudo-terminals for spawn and run; on Windows only PopenSpawn and fdspawn, which use pipes and not a terminal

expect, expect_exact, sendline, sendcontrol, setwinsize, exitstatus. 4.9.0 is the current release.

pyte

Python

Pure Python

An in-memory VTxxx terminal emulator: feed it bytes, read screen.display.

expectrl

Rust

The README says it works on Windows; the documentation does not name the backend

spawn, expect with Regex, Eof or a string, an async feature. Inspired by rexpect and pexpect.

rexpect

Rust

The documentation does not list platforms; its description is "Interact with unix processes/bash"

A loose port of pexpect: spawn, exp_string, exp_regex, exp_eof.

portable-pty

Rust

Part of the WezTerm project; the documentation says Windows has several PTY implementations and native_pty_system() chooses the platform one

Lower level: you read and write the PTY and parse the output yourself, for example with vt100.

vt100

Rust

Pure Rust

A parser that turns a byte stream into an in-memory screen of cells, with colours and attributes.

tmux

C (a program)

Unix-like systems (the manual and wiki target OpenBSD, Linux and macOS)

new-session -d, send-keys, capture-pane -p. A terminal multiplexer used as a test terminal.

VHS and asciinema

Go and Rust (programs)

VHS: macOS, Linux, Windows; asciinema: Linux, macOS and FreeBSD packages

Recording tools, for demonstrations and for text captures.

Go has no standard Expect-style package, and the Go test tool for Bubble Tea, teatest, is covered on the Bubble Tea page. This page does not recommend a third-party Go PTY library because none was checked against its documentation.

Expect

Expect is the oldest of these tools and the model for the rest: spawn starts the program on a PTY, expect waits for a pattern in its output, send writes to it. The script below drives shelf repl. It was run with the Expect installed at /usr/bin/expect on macOS.

#!/usr/bin/expect -f
# shelf-repl.exp: drives "shelf repl" through a pseudo-terminal.
set timeout 5
spawn shelf repl
expect "shelf> "
send "list\r"
expect "Dune"
expect "shelf> "
send "quit\r"
expect eof
catch wait result
exit [lindex $result 3]

Three details matter. The keyboard sends a carriage return, so send "list\r" is what pressing Enter produces; \n would also work with a terminal in the default mode but is not what a terminal sends. expect fails the script after $timeout seconds (the default is 10) so a hung program cannot hang the build. And the exit status of the program is the fourth element of the list that wait returns, which the script re-exports with exit.

pexpect with a terminal emulator

pexpect is the same model in Python. pexpect.spawn(command, args, timeout=30, encoding=None, dimensions=None, env=None, ...) starts the program on a PTY; dimensions is a (rows, columns) tuple. expect(pattern) treats a string as a regular expression, expect_exact(pattern) as plain text (no escaping, and documented as potentially faster), and both accept pexpect.EOF and pexpect.TIMEOUT in a list of patterns. sendline(s) appends os.linesep, send(s) writes the bytes as given, sendcontrol('c') sends Ctrl-C, setwinsize(rows, cols) changes the size the child sees and makes the kernel send SIGWINCH, and after close() the attributes exitstatus and signalstatus hold the result (exitstatus is None when a signal killed the child).

The shelf repl test, as plain pytest functions:

# test_repl.py
import subprocess
import sys

import pexpect


def test_repl_lists_books():
    child = pexpect.spawn(sys.executable, ["shelf.py", "repl"], encoding="utf-8", timeout=5, dimensions=(24, 80))
    child.expect_exact("shelf> ")
    child.sendline("list")
    child.expect_exact("Dune")
    child.expect_exact("shelf> ")
    child.sendline("quit")
    child.expect(pexpect.EOF)
    child.close()
    assert child.exitstatus == 0


def test_repl_refuses_without_a_terminal():
    result = subprocess.run([sys.executable, "shelf.py", "repl"], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=5)
    assert result.returncode == 2

The second test needs no PTY at all: it is the "no TTY" test of the CLI page, kept next to its PTY twin so that both sides of the isatty check are covered.

A TUI test needs more than expect: it must know where things are on the screen. pyte is an in-memory VTxxx terminal emulator: create a pyte.Screen(columns, lines), wrap it in a pyte.ByteStream, feed it the bytes read from the PTY, and read screen.display, a list of strings, one per row. The test below starts a stand-in shelf tui (it draws a title, three books, a highlighted selection, and quits on q), checks the first screen, moves the selection, checks that the program leaves the alternate screen, and checks that a resize reaches the program.

# test_tui.py
import sys

import pexpect
import pyte

ROWS, COLS = 24, 80


def start():
    child = pexpect.spawn(sys.executable, ["shelf.py", "tui"], timeout=5, dimensions=(ROWS, COLS))
    screen = pyte.Screen(COLS, ROWS)
    return child, screen, pyte.ByteStream(screen)


def read_until(child, stream, text):
    """Wait for text in the raw output and feed everything read so far to the emulator."""
    child.expect_exact(text)
    stream.feed(child.before + child.after)


def test_selection_moves():
    child, screen, stream = start()
    read_until(child, stream, b"Neuromancer")
    assert screen.display[0].startswith("Bookshelf (80x24)")
    assert screen.display[2].startswith("> Dune")

    child.send("j")
    read_until(child, stream, b"> The Dispossessed")
    assert screen.display[3].startswith("> The Dispossessed")

    child.send("q")
    child.expect(pexpect.EOF)
    child.close()
    assert child.exitstatus == 0
    assert b"\x1b[?1049l" in child.before            # the alternate screen was left


def test_resize_is_reported():
    child, screen, stream = start()
    read_until(child, stream, b"Neuromancer")
    child.setwinsize(30, 100)                         # rows, cols
    child.send("j")
    child.expect_exact(b"(100x30)")
    child.send("q")
    child.expect(pexpect.EOF)

Source: pyte — documentation and the pexpect pages above. The pyte documentation page names the classes (pyte.Screen, pyte.ByteStream) and points to a tutorial for their methods; the feed and display usage shown here was confirmed by running it.

The pattern generalises to any language: wait for a known piece of text, feed the bytes to an emulator, assert on rows and cells, send keys, repeat. Two points keep it reliable. Waiting is always for output, never for time, because the program and the machine run at unpredictable speeds. And an assertion on the emulated screen belongs after the wait that guarantees the frame is complete: the test above waits for the last book of the first frame and for the highlighted title of the second one, which are the last things each frame draws.

Rust: expectrl, rexpect and portable-pty

The Rust crates split into two kinds. expectrl and rexpect follow the Expect model; portable-pty gives you the PTY and nothing else.

# Cargo.toml
[dev-dependencies]
expectrl = "0.9"
portable-pty = "0.9"
vt100 = "0.18"

A test with expectrl, adapted from its README example:

// tests/repl.rs
use expectrl::{Eof, Error, Expect, Regex};

#[test]
fn repl_lists_books() -> Result<(), Error> {
    let mut p = expectrl::spawn(env!("CARGO_BIN_EXE_shelf").to_string() + " repl")?;
    p.expect(Regex("shelf> "))?;
    p.send_line("list")?;
    p.expect("Dune")?;
    p.send_line("quit")?;
    p.expect(Eof)?;
    Ok(())
}

CARGO_BIN_EXE_<name> is the variable Cargo sets, for integration tests, to the path of the binary of the package; the Ratatui page uses it for the same purpose. expectrl’s expect accepts a plain string, a Regex or Eof; with the async feature the same calls are .await`ed. rexpect offers `spawn(command, Some(timeout_ms)) and the methods exp_string, exp_regex and exp_eof with the same shape; its documentation does not state a Windows target, so a suite that must also run on Windows should use expectrl or portable-pty.

portable-pty is the right layer when you want to see the exact bytes, change the size mid-test, or read the screen yourself. The API checked on docs.rs: native_pty_system().openpty(PtySize { rows, cols, pixel_width, pixel_height }) returns a pair of manager and subsidiary ends; CommandBuilder::new(program) describes the child; pair.slave.spawn_command(cmd) starts it and returns a Child with try_wait, wait and process_id; the manager end gives try_clone_reader() for the output, take_writer() for the input (callable once, and dropping the writer sends EOF to the child) and resize(PtySize), which updates the kernel’s size and signals the child to re-read it. The bytes can be fed to a vt100::Parser, which keeps an in-memory screen of cells with colours and attributes.

// tests/tui.rs
use std::io::{Read, Write};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

use portable_pty::{native_pty_system, CommandBuilder, PtySize};

fn size(rows: u16, cols: u16) -> PtySize {
    PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }
}

#[test]
fn quit_key_ends_the_program() -> Result<(), Box<dyn std::error::Error>> {
    let pair = native_pty_system().openpty(size(24, 80))?;

    let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_shelf"));
    cmd.arg("tui");
    cmd.env("TERM", "xterm-256color");
    let mut child = pair.slave.spawn_command(cmd)?;
    drop(pair.slave);                                  // keep only the manager end in the test

    // Read on a thread: reading blocks, and a test must not.
    let mut reader = pair.master.try_clone_reader()?;
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
        let mut buf = [0u8; 4096];
        while let Ok(n) = reader.read(&mut buf) {
            if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
                break;
            }
        }
    });

    let mut screen = vt100::Parser::new(24, 80, 0);
    let deadline = Instant::now() + Duration::from_secs(5);
    while !screen.screen().contents().contains("Dune") {
        let remaining = deadline.saturating_duration_since(Instant::now());
        screen.process(&rx.recv_timeout(remaining)?);
    }

    let mut writer = pair.master.take_writer()?;
    writer.write_all(b"q")?;
    writer.flush()?;

    let status = child.wait()?;
    assert!(status.success());
    Ok(())
}

Source: portable-pty on docs.rs and vt100 on docs.rs. The vt100 page documents Parser::process, screen(), cell(row, col), contents_formatted() and contents_diff(); Parser::new(rows, cols, scrollback) and contents() are used here from the crate’s API and are not described on that page, so check them against your version.

Snapshot and rendering tests per library

Each TUI library ships a way to test rendering without a terminal. They answer "did this frame contain these cells", and the pages below hold the complete examples.

Library In-process tool Where it is documented here

Ratatui

TestBackend and Buffer assertions, snapshots of the buffer text with insta

Ratatui: Testing & Ecosystem. The snapshot records text, not colours.

Textual

App.run_test() yielding a Pilot, and the snap_compare fixture of pytest-textual-snapshot

Python TUIs with Textual, Rich & prompt_toolkit. The first run has no baseline and fails; save it with --snapshot-update.

Bubble Tea

Calling Update with a message, and teatest, which runs the program against a fake terminal

Go TUIs with Bubble Tea. teatest lives under x/exp and has no tagged version.

Two things these tests cannot catch are the reason for the real-PTY layer: the program starting on a terminal and restoring it (raw mode, alternate screen, cursor visibility), and the translation of cells into escape sequences by the backend. Snapshot the cells in-process, run a handful of end-to-end scenarios through a PTY, and keep the PTY tests few. This page does not cover testing for ncurses, FTXUI, Spectre.Console or Terminal.Gui; their documentation was not checked for test facilities, and a PTY test as described above works for any of them because it never looks inside the program.

tmux as a test terminal

tmux is a terminal multiplexer, and it can be driven from a script: it keeps a pane, accepts keys, and prints the pane as text. That makes it a PTY harness with a screen emulator included, usable from any language and from shell. The commands, from the manual:

Command Behaviour

new-session -d -s name -x 100 -y 30 'shell-command'

Creates the session detached. -x and -y set the width and height, and set the default-size option; without them the size comes from that option. The command is run instead of the default shell.

send-keys -t target keys

Sends keys to a pane. Key names such as Enter, Escape, Space, Tab and F1-F12 are recognised and C-c is Ctrl-C; any other string is sent as characters. -l disables key-name lookup and sends the arguments literally; -H takes hexadecimal ASCII values; -N repeats.

capture-pane -p -t target

Writes the pane’s visible contents to standard output (-p) instead of a paste buffer. -e includes the escape sequences for attributes; -J joins wrapped lines and keeps trailing spaces; -S and -E choose start and end lines, with negative numbers for history; -a captures the alternate screen.

resize-window -t target -x width -y height

Sets an absolute size for a window.

kill-server

Destroys all sessions and the server.

A script that waits for text, presses keys and checks the final screen:

#!/usr/bin/env bash
# tui-smoke.sh: shelf tui under tmux
set -euo pipefail
S=shelf-test
trap 'tmux kill-session -t "$S" 2>/dev/null || true' EXIT

wait_for() {   # wait_for TEXT [SECONDS]
  local deadline=$((SECONDS + ${2:-5}))
  until tmux capture-pane -p -t "$S" | grep -qF -- "$1"; do
    if (( SECONDS >= deadline )); then
      echo "timed out waiting for: $1" >&2
      tmux capture-pane -p -t "$S" >&2
      return 1
    fi
    sleep 0.1
  done
}

tmux new-session -d -s "$S" -x 100 -y 30 'shelf tui'
wait_for "Neuromancer"
tmux send-keys -t "$S" j
wait_for "> The Dispossessed"
tmux send-keys -t "$S" q
until ! tmux has-session -t "$S" 2>/dev/null; do sleep 0.1; done
echo "ok"

Pitfalls of this approach: it is Unix-only, so Windows needs another route; tmux sets its own TERM inside the session (check what your program sees before asserting colours); the session ends when the program exits, so capture the final screen before that, or use the remain-on-exit option that the manual describes for keeping a pane after its command exits; and a stale server from a crashed run can make new-session fail, which is what the trap cleans up. The manual excerpt read for this page did not include the wait-for command or the history-limit option, so they are not described here.

Recording: VHS and asciinema

A recording is not an assertion, but two uses make it part of a test strategy: keeping documentation screenshots current, and capturing the final text screen for a diff.

VHS runs a script (a tape) in a terminal and produces a GIF, MP4, WebM or PNG sequence, or a text file of the final screen. The Ratatui and Bubble Tea pages have complete tapes; a tape that records the shelf tui and writes a .txt capture is:

# shelf-tui.tape
Output shelf-tui.gif
Output shelf-tui.txt

Require shelf

Set Shell "bash"
Set FontSize 18
Set Width 1000
Set Height 500

Type "shelf tui"
Enter
Sleep 1s
Down 1
Sleep 500ms
Type "q"
Sleep 500ms
vhs shelf-tui.tape
diff -u golden/shelf-tui.txt shelf-tui.txt

The VHS README describes the text output as a way to keep golden files for integration tests. Its output depends on timing (Sleep), so start from a known state: fixed data, no clock, no random ids. VHS needs ttyd and ffmpeg on the PATH unless you use its Docker image, and it is installed with Homebrew, winget, Scoop or go install. The charmbracelet/vhs-action GitHub Action regenerates recordings in CI.

asciinema records a terminal session into a text recording in the asciicast format and plays it back. From its quick start:

asciinema rec -c "shelf tui" shelf-tui.cast     # record one command instead of a shell
asciinema play -s 2 shelf-tui.cast              # play at double speed
asciinema play -i 2 shelf-tui.cast              # cap idle time at 2 seconds

The quick start shows rec -c (--command), play -s (--speed), -i (idle time limit) and upload, which sends the file to asciinema.org and prints a secret link. A recording contains everything the program printed, including anything private, so review it before uploading. Packages exist for Linux, macOS and FreeBSD, and asciinema stream needs CLI 3.0 or newer. The documentation pages read for this page do not describe a headless mode or an option for the window size, so use asciinema rec --help for what your version offers. A .cast file records timing too, so it is not a stable text to diff; for that use the VHS .txt output or a PTY test with an emulator.

Windows, macOS and Linux

The tests above do not change shape across systems; the PTY underneath does.

Aspect Linux macOS Windows

PTY mechanism

UNIX 98 pseudo-terminals (/dev/ptmx, /dev/pts/N)

The same API, with /dev/ttysNNN subsidiary names

ConPTY (CreatePseudoConsole, Windows 10 1809 and later), which hosts the program in a console and translates between it and a stream of virtual-terminal sequences

Python pexpect

spawn works

spawn works

spawn and run are not available; PopenSpawn uses pipes, so programs that need a terminal behave differently

Rust

expectrl, rexpect, portable-pty

expectrl, rexpect, portable-pty

expectrl (its README says it works on Windows) or portable-pty; rexpect’s documentation does not state Windows support

tmux

Available in package repositories

Available through Homebrew

Not a native option; use another harness

Resize

TIOCSWINSZ and SIGWINCH

The same

A resize event in the console input; ConPTY exposes it through ResizePseudoConsole

Newlines in the output

The PTY converts \n to \r\n; expect \r\n

The same

Sequences and text come through the pseudo-console; do not assume the same bytes as on UNIX for the same screen

On Windows the safest way to compare screens is to feed the bytes to an emulator (vt100 or pyte) and assert on the emulated cells, not on the bytes, because the console host may redraw regions or reposition the cursor differently from a UNIX terminal. Asserting colours is also fragile for the same reason, and the legacy console and a virtual-terminal terminal may colour the same cell differently, as the Ratatui testing page notes.

In CI, run the same suite on all three systems with a matrix. The runner labels in the GitHub documentation include ubuntu-latest, windows-latest and macos-latest; the reference does not say what the -latest labels map to in prose and notes that they are the latest stable images GitHub provides, so pin an exact label (for example ubuntu-24.04, windows-2025, macos-15) when a new image must not change your results. The Windows virtual machines run as administrator with User Account Control disabled, so a test that passes there proves little about running as an ordinary user, and the reference says nothing about terminals on Windows runners.

# .github/workflows/tui-tests.yml
name: tui-tests
on: [push, pull_request]

jobs:
  pty:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-24.04, macos-15, windows-2025]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --test tui --test repl

The workflow is a skeleton: the action versions and the toolchain step are the common ones, not taken from the pages cited here. Keep tests that need a platform-specific harness (tmux) in a job limited to ubuntu-24.04 and macos-15, with if: runner.os != 'Windows'.

Making PTY tests reliable

  • Wait for output, never for time. Every action is followed by an expect for text that proves the program reacted. A fixed sleep is either too short on a loaded runner or wastes time everywhere else.

  • Always set a timeout. pexpect’s default is 30 seconds for the expect family and None blocks forever; give the whole test a timeout of its own as well, and kill the child on failure (close(force=True) in pexpect sends SIGKILL if the child ignores SIGHUP and SIGINT).

  • Fix the terminal. Choose the size (80x24 plus a narrow and a wide one) and TERM explicitly, and set NO_COLOR when colour is not what the test is about. The environment you pass decides which branch of the program’s terminal detection runs.

  • Control the data. Point the program at a fixture file, not the user’s real configuration, home directory or data, and give it a fixed clock if it prints dates.

  • Match line ends correctly. The PTY turns \n into \r\n in the output, so patterns that span lines contain \r\n. pexpect compiles patterns with re.DOTALL, treats $ as unreliable because it cannot look ahead, and matches patterns ending in a plus sign or an asterisk minimally, so end patterns with a delimiter or use expect_exact.

  • Read the output. A child that writes more than the PTY buffer holds blocks until someone reads. Calling wait() on a pexpect child with unread output can block forever, as its documentation says; read to EOF first.

  • Keep the failure evidence. Log the raw bytes (pexpect has logfile_read) and, on failure, print the emulated screen. A timeout with no screen dump is almost impossible to diagnose.

  • Security. A PTY harness runs the real binary with the permissions of the test process. Do not run it against production data or credentials, do not echo secrets into CI logs through a logfile, and treat recordings as data that may contain what the program printed.

  • Limits. The number of UNIX 98 pairs is limited (/proc/sys/kernel/pty/max on Linux), which matters for a suite that opens thousands of terminals.

What the official documentation does not provide

  • None of the tools above is a standard. POSIX defines the pseudo-terminal functions, not any harness, and there is no official guidance on how a TUI should be tested.

  • The pyte documentation page names its classes and points to a tutorial but does not describe the methods used here; the usage on this page was confirmed by running it.

  • The portable-pty and expectrl pages on docs.rs list only a Linux build target; the Windows behaviour of both is stated only by the projects' own descriptions, and expectrl’s README does not name the backend.

  • rexpect states no platform support. The Go ecosystem has no counterpart that was checked.

  • The tmux manual pages read did not include wait-for or history-limit, and the asciinema pages read did not document recording options beyond -c, -i and -s.

  • There is no official documentation of how the Windows console host re-renders a screen compared with a UNIX terminal; the advice to assert on an emulated screen is a practical rule, not a documented guarantee.