tuitest

package module
v0.0.0-...-797f5bb Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 19 Imported by: 0

README

tuitest

Go reference

A headless testing harness for terminal programs in Go.

every keypress, click, scroll and assertion above is tuitest driving the real program, and the pane below it is tuitest's own trace of what it sent

You bring a terminal program, any language, any framework, and a tape script or a Go test function. tuitest gives back a real pseudo-terminal to run it on, a VT emulator that turns its output into a grid of cells, waits that block on screen state instead of sleeping, and assertions that compare what a user would see. The importable library has four direct dependencies (charmbracelet/ultraviolet for the cell model, charmbracelet/x/ansi for color parsing, charmbracelet/x/xpty for PTY allocation, charmbracelet/x/term for the recorder's raw mode). The command line adds spf13/cobra and charmbracelet/fang, which appear in go.mod because the binary and the library share one module, but nothing outside cmd/tuitest and internal/cli imports them, so they never reach a consumer's binary. There is one binary and one importable package, and nothing to run alongside them.

It is built to be taken apart. The emulator sits behind internal/emu.Emulator, a nine-method interface; PTY and process lifetime live in internal/ptyproc with no knowledge of screens; the tape language, the cobra command tree, and the fuzzer are each their own package layered on the same public Terminal.

The command line and the Go package are two ways in, and neither is the lesser one. tuitest run login.tape tests a TUI with no Go anywhere; tuitest.StartT does the same thing from a test function when you want the program's own language. Both drive the same Terminal, so a tape and a Go test fail for the same reasons and print the same screens.

What it does

  • Spawns the program under test on a real pseudo-terminal through xpty, so isatty is true, TERM means something, and the program takes its interactive code path instead of the piped-output one.
  • Interprets everything the program writes with a full VT emulator: cursor motion, scroll regions, SGR styling, the alternate screen, wide runes, scrollback, mouse mode state, OSC 133 semantic markers.
  • Blocks on conditions rather than sleeping. WaitForText, WaitForMatch, WaitFor and WaitForOutput are woken by the output pump the moment new bytes are interpreted, with a 5ms poll as a backstop for wall-clock conditions.
  • Reports a wait failure as a *TimeoutError or *ClosedError carrying the full screen and the last 4KB of PTY traffic, so a CI log shows what was on screen instead of a bare "timeout".
  • Sends named keys as typed Key constants, so a misspelled key is a compile error; Ctrl('b') builds a control byte and Alt(k) prefixes with ESC.
  • Sends mouse events as SGR (mode 1006) sequences, and pastes as bracketed paste (mode 2004), which is the code path a program handles differently from typed text and usually tests less.
  • Resizes the PTY so the child receives a genuine SIGWINCH, and resizes the emulator grid to match in the same call.
  • Tears the child down by process group: the child is started under setsid with the PTY as its controlling terminal, and Close signals the whole group with SIGTERM then SIGKILL, so a multiplexer's daemon and its pane processes do not survive the test.
  • Reports whether the program restored the terminal. TermState.Dirty() is true when the alternate screen, mouse tracking (modes 9/1000/1001/1002/1003), bracketed paste, focus reporting, or a hidden cursor is left set on exit.
  • Separates signal death from a non-zero exit through ExitStatus, which ExitCode alone flattens to -1, and treats SIGTERM, SIGKILL, SIGINT, SIGHUP and SIGPIPE as routine teardown rather than a crash.
  • Writes golden files in two encodings: plain text, and a styled encoding of each row's text followed by indented attribute runs, diffed in-process with a line LCS so nothing shells out to system diff.
  • Runs tape scripts, a line-oriented language of 19 verbs covering exactly the harness primitives, with parse errors reported by file, line, column, and a caret under the offending token.
  • Records a live session into a tape: it connects the program to your terminal, decodes the input you send back into Key and Type commands, and chooses a Wait on new distinctive screen text wherever the screen settled, falling back to WaitStable and never emitting Sleep unless asked.
  • Replays a tape onto your terminal so you can watch it, rendering assertion failures as two screens side by side with a | against every differing row.
  • Fuzzes any terminal program with structured input (text mixing ASCII, CJK, emoji and combining marks; coherent mouse drags; degenerate resizes; malformed UTF-8 and truncated escape sequences), detects crashes, hangs, dirty terminals, inconsistent screen state and RSS growth, and minimises each finding by delta debugging into a tape that replays it.
  • Diagnoses the environment with tuitest doctor: PTY allocation, platform, TERM, size handling, emulator capabilities, and the conditions that make a suite flaky. It spawns nothing and writes nothing.
  • Exits with codes CI can branch on: 0 pass, 1 assertion failed, 2 bad usage or malformed tape, 3 harness error, 4 wait timed out.

Design goals

  • Black box. The program under test is a binary behind a PTY. Nothing in the harness knows about Bubble Tea, ratatui, ncurses, or any framework, so the same test works against a Go TUI, a C one, or vim.
  • Deterministic. Waits block on conditions and are woken by output, so a test runs as fast as the program does and does not get slower or flakier on a loaded runner. Sleep exists in the tape language and -strict rejects it.
  • Legible failure. Every failure carries the screen. A timeout names what it waited for and for how long; a failed Expect finds the closest line and marks the first differing column; a parse error points at the token.
  • Replaceable parts. The emulator, the PTY layer, the tape language and the CLI are separate packages with narrow seams, and the emulator in particular is internal on purpose so swapping it is not a breaking change.
  • Honest. The docs state which waits are exact conditions and which are heuristics, which platform is unsupported and why, and where the vendored emulator can drift. Performance numbers carry the machine they were measured on. See docs/limits.md.

Architecture

flowchart TB
  subgraph Bring["Bring your own"]
    PROG[program under test<br/>any binary, any language]
    TAPE[tape file<br/>or a Go test function]
  end

  subgraph CLI["cmd/tuitest + internal/cli"]
    REG[cobra command tree<br/>run, record, replay, snap, fuzz, doctor]
  end

  subgraph Lang["tape"]
    PARSE[parse<br/>lexer, positions, verb suggestions]
    PLAY[player<br/>executes commands, asserts]
    REC[recorder<br/>session to tape, timing policy]
  end

  subgraph Core["tuitest root package"]
    TERM[Terminal<br/>waits, input, snapshots, goldens]
  end

  subgraph Low["internal"]
    PTY[ptyproc<br/>spawn, pump, resize, group teardown]
    EMU[emu.Emulator<br/>nine-method interface]
    VT[vt<br/>vendored VT interpreter]
  end

  FUZZ[fuzz<br/>generator, detectors, shrinker]
  VTGEN[fuzz/vtgen<br/>VT sequence generator, shrinker]

  TAPE --> PARSE --> PLAY --> TERM
  REG --> PLAY
  REG --> REC --> PARSE
  REG --> FUZZ --> TERM
  TERM --> PTY --> PROG
  PROG --> PTY --> TERM --> EMU --> VT
  VTGEN -.-> VT

Only the root package is public API; internal/emu, internal/vt and internal/ptyproc are not importable, which is deliberate. The emulator choice is not part of the contract, so replacing it is not a breaking change, and the vt copy can be re-synced from upstream without any downstream ceremony.

ptyproc owns process and PTY lifetime and knows nothing about screens; Terminal owns screens and waits and knows nothing about exec. That split is what lets the fuzzer drive a Terminal while watching the process from outside it, using Progress() for liveness and ExitStatus() for cause of death.

The fuzz package generates tape.Command values, not bytes. Candidates replay through the same player tuitest run uses, which is what makes a minimised reproduction trustworthy: it is not a description of what the fuzzer did, it is the same execution path.

fuzz/vtgen points the other way. It generates the bytes a program writes, by grammar rather than by byte, for testing whatever parses them: tuitest aims it at its own emulator, and it is public so anything else with a VT parser can aim it at theirs. See docs/fuzzing.md.

How a tape becomes assertions

flowchart LR
  T[tape file] --> P[parse<br/>one Command per line]
  P --> R{verb?}
  R -- Spawn --> S[ptyproc.Start<br/>setsid, PTY, pump goroutine]
  R -- "Type / Key / Mouse / Paste / Raw" --> W[Terminal.write<br/>marks lastInput]
  R -- "Wait / WaitStable / Expect" --> C[waitLoop<br/>cond.Wait on the screen]
  R -- Snapshot --> G[golden compare<br/>line LCS diff]
  S --> PT[PTY master]
  W --> PT
  PT --> PUMP[pump goroutine<br/>32KB reads]
  PUMP --> E[emu.Write<br/>cell grid updated]
  E --> B[cond.Broadcast]
  B --> C
  C --> G

Every wait shares one loop. It holds the terminal lock, evaluates its condition, and blocks on a sync.Cond that the output pump broadcasts after each chunk is interpreted; a 5ms timer re-broadcasts so wall-clock conditions such as WaitStable still make progress when the program is silent. Conditions build a screen snapshot only if they need one, so a cheap condition does not pay to rebuild the grid on every write during a heavy burst.

WaitStable is the one heuristic here, and it is easy to misuse. It measures its quiet window from the later of the last output byte and the last input tuitest sent, which stops it from reporting the pre-keystroke screen as stable, but a program that takes longer than the interval (150ms by default) to produce its first byte is still reported stable early. WaitForOutput is the primitive for "wait until the program reacts to what I just sent"; prefer waiting on the content you expect whenever you know it.

Quick start

# install the command line tool (no Go needed afterwards to run tapes)
go install github.com/Gaurav-Gosain/tuitest/cmd/tuitest@latest

# check this machine can run a TUI at all; exits 3 if not, so it gates CI
tuitest doctor

# look at what a program actually draws, asserting nothing
tuitest snap -- htop

# write what you saw as a tape
cat > login.tape <<'EOF'
Set Size 60 10
Spawn less README.md
Wait /tuitest/
Expect /headless testing harness/
Key q
ExpectExit 0
EOF

# run it: exits 0 when every assertion holds, prints the screen when one does not
tuitest run login.tape

The loop is snap to look, record or an editor to write, run in CI, replay to debug, fuzz to go looking for trouble:

tuitest record -o login.tape -- ./myapp   # drive it by hand, Ctrl+] to stop
tuitest replay login.tape                 # watch the tape run
tuitest fuzz -duration 30s -corpus ./corpus -- ./myapp

From Go, go get github.com/Gaurav-Gosain/tuitest and:

func TestGreeting(t *testing.T) {
    term := tuitest.StartT(t, []string{"./myapp"}, tuitest.WithSize(80, 24))

    if err := term.WaitForText("ready", 5*time.Second); err != nil {
        t.Fatal(err)
    }
    term.SendKeys("hello", tuitest.Enter)
    if err := term.WaitForText("you said hello", 3*time.Second); err != nil {
        t.Fatal(err)
    }
    term.AssertGolden(t, "greeting") // testdata/greeting.golden
}

StartT mirrors PTY traffic into t.Log, registers Close through t.Cleanup, and fails the test if the spawn itself fails. Record the golden once with UPDATE_GOLDEN=1 go test ./..., then review it as part of the diff. The full Go surface is in docs/api.md.

Requirements: a Unix-like OS that can open PTYs (/dev/ptmx), and Go 1.25 or newer to install. Windows deliberately fails to build; see docs/limits.md.

What it looks like

Every recording below drives the real binary against a real program: less paging this repository's README, vim opening a file from scripts/, and the deliberately broken fixture in testdata/buggytui. The recording at the top of this page drives lazygit the same way. The tapes that produce them are in scripts/demo and regenerate with scripts/demo/record.sh.

tuitest snap runs vim on a tape file at 84 columns and prints the screen as text, then runs the same file at 52 columns where the comment lines wrap and vim truncates the filename in its status line
a tape testing a program with no Go anywhere, and what a stale assertion prints when it fails
snap printing what a program draws, then the same program at a second width
fuzzing a fixture that panics on F5, minimised to a two-line tape that replays it

Command line

tuitest run         play a tape script against a program            # exit 0/1/2/3/4
tuitest record      drive a program by hand and write a tape        # Ctrl+] to stop
tuitest replay      play a tape onto this terminal so you can watch # -step, -speed
tuitest snap        spawn, wait for quiet, print the screen         # asserts nothing
tuitest fuzz        drive with randomised input, report what breaks # writes tape repros
tuitest doctor      report on the environment tests will run in     # spawns nothing
tuitest completion  print a bash, zsh, fish or powershell script    # cobra generated
tuitest version     print the tuitest version                       # set by -ldflags -X
tuitest help        show help for a command                         # tuitest help run

Every command has its own help with examples (tuitest help run). Commands, help and completion are built on spf13/cobra and rendered by charmbracelet/fang; completion is resolved by calling the binary back rather than from a script baked at build time, so it cannot fall out of step with the commands. Flags take either spelling: -size and --size both work. run, snap and doctor accept -json and print one object to stdout: run reports status, a kind naming the exit code, durationMs, and the full error text including the screen at the moment of failure.

A flag beats the tape's own Set line for the same setting, which is what makes tuitest run -size 120x40 login.tape useful for checking a layout at a second size without editing the file; -env accumulates instead, since environment entries add up. Put -- before the program in snap, record and fuzz so its own flags are not read as tuitest's. run -strict rejects Sleep, which is a cheap way to keep a suite honest. An unknown subcommand or a misspelled tape verb gets a nearest-match suggestion rather than a bare rejection.

Exit codes are the contract with CI, separating "your program is wrong" from "the tool could not run it":

Code Meaning
0 every assertion passed
1 an assertion failed, or the program exited before a wait was satisfied
2 bad usage, or a tape that would not parse
3 harness error: no PTY, a program that would not start, an unreadable golden
4 a wait timed out

The full flag reference for every subcommand is in docs/cli.md.

The tape language

A tape is line oriented, one command per line, # starts a comment.

Set Size 40 10
Set Term xterm-256color
Spawn ./myapp
Wait /ready/ +Screen @5s
Type hello
Key Enter
Wait /you said hello/ @5s
Snapshot after-hello +Styled
Resize 60 20
Mouse Press Left 10 5 +Ctrl
Raw "\x1b[1;2;3m"
ExpectExit 0

The 19 verbs are Set, Spawn, Type, Key, Wait, WaitStable, WaitOutput, WaitPrompt, WaitCommand, Expect, ExpectExit, Snapshot, Resize, Mouse, Paste, Raw, Hide, Show and Sleep. Wait-like commands take an optional /regex/, a +Screen or +Line scope, and an @timeout such as @5s. Paste and Raw take a Go-quoted string, which is what lets them carry arbitrary bytes including malformed UTF-8 and embedded escape sequences. The grammar, the Set keys, and the validation limits are in docs/tape.md.

A recording never loses input. Every input sequence is decoded by a registered protocol (the legacy keys, xterm modifyOtherKeys, the kitty keyboard protocol, the X10, SGR, SGR-pixel and urxvt mouse encodings, bracketed paste and focus reporting) or, failing that, captured verbatim as a Raw command that replays byte for byte. So a tape is a faithful replay whether or not a decoder exists for everything in it, and terminal replies to capability queries are never mistaken for keystrokes. See docs/input-protocols.md for the guarantees, the round-trip property, what happens when replay negotiates different keyboard modes than the recording did, and how to add a protocol.

Fuzzing a TUI

tuitest fuzz drives a program with randomised but structured input and reports seven kinds of finding: crash, hang, dirty-terminal, screen-inconsistent, memory-growth (Linux only, off unless -max-memory-growth is set), replacement-char (off unless -detect-replacement-chars is set), and invariant. A clean exit is never a finding, because the fuzzer sends keys that legitimately quit a program and treating that as a bug would make every run a false positive.

dirty-terminal is the highest-value check in practice: it is a real bug class, it is common, and unlike the others it has almost no false-positive surface, because a program that turned a mode on is unambiguously responsible for turning it off. Hang detection is the one heuristic, and it is tuned to stay quiet rather than to catch everything.

invariant is the only oracle that knows anything about your program. Pass func(tuitest.Screen) error closures in fuzz.Options.Invariants and a session can find that a status bar disappeared or a modal was left open, not just that the program died. A violation is an ordinary finding, so the shrinker minimises it like any other, and the report names the command after which the property first failed rather than the one where the checker noticed. It is checked only after a settle, because a screen caught mid-redraw fails a reasonable invariant. There is no CLI flag: a tape file cannot carry a Go closure.

replacement-char reports U+FFFD reaching the screen, which means the program mangled a byte sequence between reading it and drawing it. It is off by default and goes quiet for a run as soon as the fuzzer sends malformed UTF-8, because against malformed input a replacement character is the correct output rather than a bug. Both of these are documented with their limits in docs/fuzzing.md.

Every finding is minimised by delta debugging and written as an ordinary tape:

# crash: program killed by aborted
# found by tuitest fuzz at seed 13064056694810536104, iteration 6
# minimised from 31 commands to 3
#
# replay with: tuitest run <this file>

Spawn htop
Resize 1 1
Raw "hel"

That is a real reproduction, minimised from 31 commands to 3: a buffer overflow in htop 3.5.1, caught by glibc's fortify check. With -corpus dir findings are saved there and replayed first on the next run, so a fix is confirmed when the corpus stops reproducing. See docs/fuzzing.md.

Performance

Measured on an Intel i7-10700 (16 threads, Linux), 80-column grid, five runs of go test -run '^$' -bench . -benchtime 3s ., reproducible from bench_test.go in the root package. Ranges rather than single figures, because this was an otherwise-busy desktop and the spread is real.

Workload Lines per second Bytes per second
Plain 80-column text lines 64,000 to 68,000 5.2 to 5.5 MB/s
Same with an SGR change per line 44,000 to 66,000 4.3 to 6.4 MB/s

The emulator is the only component in the read path that scales with output volume, and it is single-threaded by construction: a VT interpreter is a state machine over an ordered byte stream, so adding concurrency cannot make this faster. A program that emits far more than this feels PTY backpressure rather than losing data, so heavy-output tests need timeouts sized for the volume, not for the harness.

Waits themselves cost nothing while idle: they block on a condition variable and are woken by the pump, so a suite's wall-clock time is the program's own latency plus at most the 5ms poll interval per wall-clock condition.

Limitations

The full list, with the reasoning, is in docs/limits.md. The ones most likely to matter:

  • Unix only, and it fails to build on Windows on purpose. There is no ConPTY backend and no process group to signal, so teardown could not keep its promise; the package produces a named compile error rather than building into something that looks supported and leaks every grandchild. Use WSL or a Unix runner.
  • WaitStable is a heuristic and always will be. A program slower than the stabilize interval to produce its first byte is reported stable early. Wait on content when you know it.
  • The VT emulator is a vendored copy of tuios's interpreter, not a dependency, so it does not pick up upstream fixes automatically. The exact commit is in internal/vt/UPSTREAM, the policy in internal/vt/VENDOR.md, and scripts/vendor-vt.sh -n /path/to/tuios reports drift without changing anything. Fixes go to tuios first; a change made only in the copy is lost at the next sync.
  • Screen.Line returns one physical row and does not de-wrap, and Cell exposes only a cell's first rune, so combining marks are invisible to assertions.
  • Mouse mode 1005 (UTF-8 coordinates) is not decoded as itself. It is indistinguishable from X10 by construction, so it is read as X10 and the coordinates on the Mouse line are wrong above column 95. The bytes still replay exactly, so this costs readability rather than fidelity.
  • Two of the fuzzer's own tests are flaky under load. They assert that a minimised reproduction re-reproduced on the confirmation replay, which is a property the fuzzer does not guarantee: confirmation drives a real program through a real PTY. They pass in isolation and fail intermittently when the machine is busy. See docs/limits.md.
  • The fuzzer's two oracles are gated, and each gate costs coverage. The replacement-character check goes quiet for a whole run once one malformed byte has been sent, which with the default generator is almost immediately. User-supplied invariants are judged only at a settle, so a violation that repairs itself before the end of an iteration is never seen. Both gates were chosen over the alternative because a fuzzer that reports things that are not bugs trains you to stop reading it.
  • Fuzz generation is blind. There is no coverage instrumentation of the program under test, so input comes from a structural model rather than being steered toward new code paths. It finds shallow bugs quickly and deep ones only by luck.

Comparison

teatest (charmbracelet/x/exp/teatest) drives a Bubble Tea program in process, which is fast and lets it reach into the model, but it only works for Bubble Tea and it tests the program rather than the terminal: no PTY, so it cannot tell you what a real terminal would show. tuitest is the opposite trade, a black box behind a real PTY, slower, with no access to internal state. If you write Bubble Tea and want fast unit tests of your update loop, use teatest; if you want to know what the user sees, or you do not control the source, use this.

expect and its descendants (expect, pexpect, go-expect) also drive a PTY and are excellent at line-oriented conversations: log in, wait for a prompt, send a password. They match against the byte stream, which is exactly wrong for a full-screen program, because a TUI's bytes are cursor movements and partial redraws that never contain the final text in reading order. tuitest interprets those bytes into a screen first, which is the whole difference.

VHS records terminal sessions to GIFs and has a tape format that inspired this one. It is a demo tool, not an assertion tool; tuitest's tape language covers the harness primitives and produces golden text, not video.

Extending

Each seam is narrow on purpose:

  • Swap the VT emulator (implement internal/emu.Emulator, nine methods).
  • Add a CLI subcommand (one *cobra.Command added in newRootCommand; help, completion and typo suggestions follow automatically).
  • Add a tape verb (one Kind, one Verb() case, one parse case, one player case, one printer case).
  • Drive the harness from your own runner (import the root package; tape and fuzz are both ordinary callers of *Terminal).
  • Add project-specific helpers alongside tuiosx (69 lines) rather than in the core.

See docs/architecture.md and docs/extending.md.

Tests

go build ./...
go vet ./...
go test -race ./...

367 test cases across 163 test functions and 5 fuzz targets. The default suite is hermetic: it spawns a small Go echo-TUI fixture under testdata/echotui, a deliberately buggy fixture with individually selectable bugs under testdata/buggytui, and a plain sh. Nothing external is required.

Everything that parses input tuitest does not control has a fuzz target (FuzzParse, FuzzResolveKey, FuzzDiff, FuzzStyledEncode, FuzzEmulatorScreen). Their seed corpora live in testdata/fuzz, so go test runs them as ordinary unit tests and they act as regression guards with no fuzzing session. To actually fuzz:

go test -run '^$' -fuzz FuzzParse ./tape
go test -run '^$' -fuzz FuzzEmulatorScreen .

Two suites are opt-in because they need a multiplexer. TUITEST_TUIOS=1 go test -race ./tuiosx/... runs the tuios acceptance tests, and the examples under examples/tuios skip themselves unless a tuios binary is found through TUIOS_BIN or PATH. They are worth reading as realistic usage even if you never run them: boot and window management, a control plane driven over a unix socket with a TUI later attached to the same session, and a flood-plus-resize stress test. Set TUITEST_TUIOS_SRC to a tuios checkout to have the suite also check the vendored emulator against the commit recorded in internal/vt/UPSTREAM.

Project

License

MIT. See LICENSE.

The vendored VT emulator under internal/vt is copied from tuios, which is also MIT licensed by the same author.

Documentation

Overview

Package tuitest is a headless testing harness for terminal programs. It drives a program under test through a real pseudo-terminal, interprets its output with a VT emulator, and lets tests assert on the resulting screen as a grid of cells rather than as a raw byte stream.

The typical flow: Start (or StartT under go test) spawns the program, SendKeys and Type drive input, the WaitFor family synchronizes on screen state without sleeping, and Snapshot / AssertGolden capture the result. Close (registered automatically by StartT) tears down the whole process group.

Index

Constants

View Source
const DefaultStabilizeInterval = 150 * time.Millisecond

DefaultStabilizeInterval is the quiet window WaitStable uses unless overridden.

Variables

View Source
var (
	// ErrTimeout is wrapped by every wait that runs out of time.
	ErrTimeout = errors.New("tuitest: timed out")
	// ErrChildExited is wrapped when the program under test exits before a
	// wait's condition is met.
	ErrChildExited = errors.New("tuitest: child exited before the condition was met")
	// ErrSemanticMarkers is wrapped by the OSC 133 waits when the terminal was
	// started without WithSemanticMarkers.
	ErrSemanticMarkers = errors.New("tuitest: semantic markers are not enabled (use WithSemanticMarkers)")
)

Sentinel errors for the three ways a wait can fail. Every wait returns an error that wraps one of these, so a caller can branch on the kind of failure without type-asserting the concrete error:

if err := term.WaitForText("ready", time.Second); errors.Is(err, tuitest.ErrTimeout) {
	// the program is merely slow
}

Functions

func Diff

func Diff(want, got string) string

Diff returns a compact line-oriented diff of want versus got, the same encoding AssertGolden uses in its failure messages. It is exported so out-of-package golden runners (such as the tape player) can reuse it.

Types

type Cell

type Cell struct {
	// Rune is the cell's first rune. A cell can hold a whole grapheme cluster,
	// so this is not always the character a user sees: "e" plus a combining
	// acute reports 'e' here. Use Content to compare against text.
	Rune rune
	// Content is the cell's full grapheme cluster: the base rune together with
	// any combining marks, joiners and modifiers that attach to it. It is what
	// Line and Text render, and what a caller should match against. Empty for
	// the continuation column of a wide rune.
	Content string
	// Width is 1 for normal runes, 2 for wide runes, and 0 for the
	// continuation column that follows a wide rune.
	Width int
	// Fg and Bg are the foreground and background colors.
	Fg, Bg        Color
	Bold          bool
	Faint         bool
	Italic        bool
	Underline     bool
	Reverse       bool
	Strikethrough bool
	Blink         bool
	// Conceal reports SGR 8 (hidden). A real terminal draws a concealed cell as
	// a blank, so Line and Text render these cells as spaces; the rune is still
	// available here for a caller that needs to know what was concealed.
	Conceal bool
}

Cell is a single grid cell with its rune and visual attributes.

type ClosedError

type ClosedError struct {
	// Op is the wait that failed, such as "WaitForText".
	Op string
	// Want describes the condition in words.
	Want string
	// ExitCode is the child's exit code, or -1 if it could not be determined.
	ExitCode int
	// Screen is the plain-text screen at the moment of the failure.
	Screen string
	// TailLog is the tail of the mirrored PTY I/O.
	TailLog string
}

ClosedError is returned when the child exits before a wait's condition is met. It unwraps to ErrChildExited.

func (*ClosedError) Error

func (e *ClosedError) Error() string

func (*ClosedError) Unwrap

func (e *ClosedError) Unwrap() error

Unwrap makes errors.Is(err, ErrChildExited) true for every early exit.

type Color

type Color struct {
	// Kind selects which of the remaining fields is meaningful.
	Kind ColorKind
	// Index is the palette entry when Kind is ColorIndexed.
	Index uint8
	// R, G and B are the channel values when Kind is ColorRGB.
	R, G, B uint8
}

Color is a cell color in one of three encodings. Only the fields matching Kind carry meaning; the others are zero.

type ColorKind

type ColorKind int

ColorKind distinguishes the three color encodings a cell can carry.

const (
	// ColorDefault is the terminal's default foreground or background.
	ColorDefault ColorKind = iota
	// ColorIndexed is a palette color 0-255.
	ColorIndexed
	// ColorRGB is a 24-bit true color.
	ColorRGB
)

type ExitStatus

type ExitStatus struct {
	// Code is the exit status, or -1 when the child died from a signal.
	Code int
	// Signaled is true when the child was killed by a signal rather than
	// exiting on its own.
	Signaled bool
	// Signal is the killing signal when Signaled is true.
	Signal syscall.Signal
}

ExitStatus describes how the child finished.

func (ExitStatus) Crashed

func (s ExitStatus) Crashed() bool

Crashed reports whether the child died in a way that indicates a bug: killed by a fault signal, or exited non-zero. A clean zero exit is not a crash.

func (ExitStatus) String

func (s ExitStatus) String() string

type Key

type Key string

Key is a named key or chord expressed as the escape sequence it sends. Using typed values means a mistyped key name is a compile error, not a silent mismatch at runtime.

const (
	Enter     Key = "\r"
	Tab       Key = "\t"
	Esc       Key = "\x1b"
	Space     Key = " "
	Backspace Key = "\x7f"
	Delete    Key = "\x1b[3~"
	Up        Key = "\x1b[A"
	Down      Key = "\x1b[B"
	Right     Key = "\x1b[C"
	Left      Key = "\x1b[D"
	Home      Key = "\x1b[H"
	End       Key = "\x1b[F"
	PageUp    Key = "\x1b[5~"
	PageDown  Key = "\x1b[6~"
	Insert    Key = "\x1b[2~"

	F1  Key = "\x1bOP"
	F2  Key = "\x1bOQ"
	F3  Key = "\x1bOR"
	F4  Key = "\x1bOS"
	F5  Key = "\x1b[15~"
	F6  Key = "\x1b[17~"
	F7  Key = "\x1b[18~"
	F8  Key = "\x1b[19~"
	F9  Key = "\x1b[20~"
	F10 Key = "\x1b[21~"
	F11 Key = "\x1b[23~"
	F12 Key = "\x1b[24~"
)

Named keys. Values are the byte sequences a terminal sends for each key.

func Alt

func Alt(k any) Key

Alt prefixes a key or rune with ESC, the conventional meta encoding. It accepts the same items as SendKeys; an item of an unsupported type yields a bare Esc, since Alt has no way to report an error. Pass a string, rune, Key, or slice of those and that cannot happen.

func Ctrl

func Ctrl(r rune) Key

Ctrl returns the control-key byte for a rune, so Ctrl('b') is 0x02. Letters are case-insensitive.

type KeyMods

type KeyMods int

KeyMods is a bitmask of held modifier keys.

const (
	// ModShift is the shift key.
	ModShift KeyMods = 1 << iota
	// ModAlt is the alt or meta key.
	ModAlt
	// ModCtrl is the control key.
	ModCtrl
)

type MouseAction

type MouseAction int

MouseAction is what the button did.

const (
	// MousePress is a button going down.
	MousePress MouseAction = iota
	// MouseRelease is a button coming up.
	MouseRelease
	// MouseMove is motion with no button held.
	MouseMove
	// MouseDrag is motion with a button held. On the wire it is the same
	// motion bit as MouseMove; the two are distinguished by whether a button
	// is reported, and separating them is what lets a tape read as a drag.
	MouseDrag
)

type MouseButton

type MouseButton int

MouseButton identifies a mouse button or wheel direction.

const (
	// MouseLeft is the primary button.
	MouseLeft MouseButton = iota
	// MouseMiddle is the middle button or wheel click.
	MouseMiddle
	// MouseRight is the secondary button.
	MouseRight
	// MouseWheelUp is one wheel notch away from the user.
	MouseWheelUp
	// MouseWheelDown is one wheel notch toward the user.
	MouseWheelDown
	// MouseWheelLeft is one horizontal wheel notch to the left.
	MouseWheelLeft
	// MouseWheelRight is one horizontal wheel notch to the right.
	MouseWheelRight
	// MouseBackward is the fourth button, "back" on most mice.
	MouseBackward
	// MouseForward is the fifth button, "forward" on most mice.
	MouseForward
	// MouseNone is no button: the button field of a motion report with nothing
	// held, and of a legacy release report, which does not say which button
	// came up.
	MouseNone
)

type MouseEncoding

type MouseEncoding int

MouseEncoding is the wire format a mouse report used.

It is part of the event because replaying a recorded session has to send the program the same bytes it originally received: a program that enabled only mode 1000 does not understand an SGR report, so re-encoding a captured X10 report as SGR would silently change what the test exercises.

const (
	// MouseSGR is the modern SGR encoding (mode 1006), and the default for
	// events constructed by hand. It is the only encoding with no coordinate
	// limit and the only one that reports which button was released.
	MouseSGR MouseEncoding = iota
	// MouseX10 is the original encoding (modes 9, 1000, 1002 and 1003), which
	// packs each field into one byte offset by 32.
	MouseX10
	// MouseURXVT is the urxvt encoding (mode 1015): X10's packing written as
	// decimal parameters, so it has no coordinate limit but still cannot say
	// which button was released.
	MouseURXVT
)

type MouseEvent

type MouseEvent struct {
	// Col and Row are zero-based cell coordinates; the wire format's 1-based
	// coordinates are produced during encoding. When Pixel is set they are
	// zero-based pixel offsets instead.
	Col, Row int
	// Button is the button or wheel direction involved. It is ignored for
	// MouseMove, which is motion with nothing held: to send motion with a
	// button down, use MouseDrag.
	Button MouseButton
	// Action is what the button did.
	Action MouseAction
	// Mods are the modifier keys held at the time.
	Mods KeyMods
	// Pixel reports coordinates in pixels rather than cells (mode 1016). The
	// wire format is identical to SGR's, so this can only ever be known from
	// whether the program asked for pixel reporting.
	Pixel bool
	// Enc is the wire encoding to use. The zero value is MouseSGR.
	Enc MouseEncoding
}

MouseEvent is a single mouse event at a zero-based cell coordinate.

func (MouseEvent) Encode

func (e MouseEvent) Encode() (string, bool)

Encode renders the event in whichever wire encoding Enc names.

func (MouseEvent) EncodeSGR

func (e MouseEvent) EncodeSGR() (string, bool)

EncodeSGR renders the event as an SGR (1006) or SGR-pixel (1016) sequence. Coordinates in the wire format are 1-based. It reports false for an event the encoding cannot express.

func (MouseEvent) EncodeURXVT

func (e MouseEvent) EncodeURXVT() (string, bool)

EncodeURXVT renders the event in the urxvt encoding (mode 1015): X10's control byte and 1-based coordinates written as decimal parameters, which lifts X10's coordinate limit but keeps its inability to name a released button.

func (MouseEvent) EncodeX10

func (e MouseEvent) EncodeX10() (string, bool)

EncodeX10 renders the event in the original encoding, where the control byte and both coordinates are single bytes offset by 32.

It reports false for anything that packing cannot hold: a coordinate past column 223, which has no representation at all, and a release, which the encoding reports as the button-3 code without saying which button came up. Falling back rather than approximating is what keeps a recorded session's bytes intact.

type Option

type Option func(*config)

Option configures a spawn.

func WithDir

func WithDir(path string) Option

WithDir sets the child's working directory.

func WithEnv

func WithEnv(kv ...string) Option

WithEnv adds or overrides environment entries ("KEY=VALUE").

func WithInheritEnv

func WithInheritEnv() Option

WithInheritEnv starts from the parent process environment instead of the minimal hermetic default.

func WithLog

func WithLog(w io.Writer) Option

WithLog mirrors all PTY I/O to w for debugging failing tests.

func WithOutputMirror

func WithOutputMirror(w io.Writer) Option

WithOutputMirror copies the child's output to w as it arrives. Unlike WithLog, which mirrors both directions for debugging, this carries only what the program wrote, so w can be a real terminal the program is rendered onto while the harness still drives it headlessly. Used by tuitest record and tuitest replay.

func WithSemanticMarkers

func WithSemanticMarkers() Option

WithSemanticMarkers enables OSC 133 tracking so the WaitForPrompt / WaitForCommand / LastCommandExit primitives work.

func WithSize

func WithSize(cols, rows int) Option

WithSize sets the initial PTY size in cells.

func WithStabilizeInterval

func WithStabilizeInterval(d time.Duration) Option

WithStabilizeInterval sets the quiet window used by WaitStable.

func WithTerm

func WithTerm(term string) Option

WithTerm overrides the TERM value (default "xterm-256color").

func WithTrueColor

func WithTrueColor() Option

WithTrueColor sets COLORTERM=truecolor for programs that gate 24-bit color.

type Scope

type Scope int

Scope selects what part of the screen a match runs against.

const (
	// ScopeScreen matches against the whole rendered screen.
	ScopeScreen Scope = iota
	// ScopeLastLine matches against the last non-blank line only.
	ScopeLastLine
)

type Screen

type Screen interface {
	// Size returns the grid size in cells.
	Size() (cols, rows int)
	// Cell returns the cell at the given zero-based column and row. Out-of-bounds
	// coordinates return the zero Cell.
	Cell(col, row int) Cell
	// Cursor returns the cursor position (zero-based) and whether it is visible.
	Cursor() (col, row int, visible bool)
	// Text returns the plain-text screen, one row per line, with each line's
	// trailing blanks trimmed and trailing blank lines dropped.
	Text() string
	// Line returns the plain text of a single physical row with trailing blanks
	// trimmed, or "" for an out-of-range row. It does not de-wrap: a logical
	// line that soft-wrapped at the right margin occupies several rows and will
	// not match as one string. Match per row, or use Text and account for the
	// wrap, or widen the terminal with WithSize so the line fits.
	Line(row int) string
	// ExitCode reports the child's exit code and whether it has exited.
	ExitCode() (code int, exited bool)
}

Screen is a read-only view of the terminal grid handed to wait conditions and returned by snapshots. Every Screen value is an immutable copy taken under the terminal's lock, so a condition callback may stash it without observing a torn write from the output pump.

type TermState

type TermState struct {
	AltScreen      bool
	MouseTracking  bool
	BracketedPaste bool
	FocusReporting bool
	CursorHidden   bool
	// contains filtered or unexported fields
}

TermState is a snapshot of the terminal modes a program has left set, plus cursor visibility. It answers "did this TUI restore the terminal?", which is a common and user-visible bug class: a program that exits without leaving the alternate screen, or with mouse reporting still on, leaves the user's shell unusable.

func (TermState) Describe

func (s TermState) Describe() string

Describe lists the offending modes in a stable order, for error messages.

func (TermState) Dirty

func (s TermState) Dirty() bool

Dirty reports whether any mode is left in a state that would visibly damage the user's shell after the program exits.

func (TermState) Mode

func (s TermState) Mode(n int) bool

Mode reports whether the given DEC private mode number is currently set.

type Terminal

type Terminal struct {
	// contains filtered or unexported fields
}

Terminal is the harness handle for one spawned program.

func Start

func Start(argv []string, opts ...Option) (*Terminal, error)

Start spawns argv[0] with argv[1:] in a PTY and begins pumping output.

func StartT

func StartT(tb testing.TB, argv []string, opts ...Option) *Terminal

StartT is the testing.TB-friendly constructor: it wires the debug log to t.Log, registers Close via t.Cleanup, and fails the test on spawn error.

func (*Terminal) AssertGolden

func (t *Terminal) AssertGolden(tb testing.TB, name string)

AssertGolden compares the plain-text snapshot against testdata/<name>.golden, failing the test on mismatch with a unified diff. When UPDATE_GOLDEN is set in the environment or -update is passed, it rewrites the golden instead.

func (*Terminal) AssertGoldenStyled

func (t *Terminal) AssertGoldenStyled(tb testing.TB, name string)

AssertGoldenStyled is AssertGolden for the styled encoding.

func (*Terminal) Close

func (t *Terminal) Close() error

Close tears down the child process group and PTY. It is idempotent.

func (*Terminal) Done

func (t *Terminal) Done() <-chan struct{}

Done returns a channel closed once the child has exited and been reaped. It lets a caller select on program exit alongside its own events, which Wait cannot express because it blocks.

func (*Terminal) ExitCode

func (t *Terminal) ExitCode() (int, bool)

ExitCode reports the child's exit code and whether it has exited.

The code is -1 for a child killed by a signal, not the shell's 128+signal convention, so that it can never be confused with a program that exited with that number itself. It is -1 before the child has exited too, and the second return value is the only thing that separates those two cases. Use ExitStatus to tell a crash apart from an ordinary non-zero exit.

func (*Terminal) ExitStatus

func (t *Terminal) ExitStatus() (ExitStatus, bool)

ExitStatus reports how the child finished and whether it has exited at all.

func (*Terminal) LastCommandExit

func (t *Terminal) LastCommandExit() (int, bool)

LastCommandExit returns the exit code of the last finished command (OSC 133 D) and whether one has been seen. It reports false both when no command has finished and when the terminal was started without WithSemanticMarkers, so enable that option before relying on it; the WaitForPrompt and WaitForCommand waits return ErrSemanticMarkers in that case and are the better signal.

func (*Terminal) Paste

func (t *Terminal) Paste(s string) error

Paste sends text wrapped in bracketed-paste markers, the way a terminal delivers a real paste. Programs that enable mode 2004 take a different code path for pasted text than for typed text, and that path is often the less tested one.

func (*Terminal) Pid

func (t *Terminal) Pid() int

Pid returns the child's process id, or 0 if it is not running.

func (*Terminal) Progress

func (t *Terminal) Progress() (bytes int64, last time.Time)

Progress reports how many bytes the child has written so far and when the most recent write landed. A caller that sends input and then sees neither counter move has evidence the program stopped responding.

func (*Terminal) Resize

func (t *Terminal) Resize(cols, rows int) error

Resize changes the PTY window size and the emulator grid; the child receives SIGWINCH. Like sending keys, a resize counts as input for WaitStable, since the redraw it provokes has not arrived yet.

func (*Terminal) Screen

func (t *Terminal) Screen() Screen

Screen returns an immutable view of the current screen.

func (*Terminal) SendKeys

func (t *Terminal) SendKeys(items ...any) error

SendKeys types a sequence of named keys, chords, runes, and strings. Plain strings and runes are sent literally; Key values carry their own escape sequences. Items may be string, rune, Key, []string, []Key, or []any of those; anything else is rejected with an error rather than sent. Example:

term.SendKeys("git status", tuitest.Enter)
term.SendKeys(tuitest.Ctrl('b'), "%")

func (*Terminal) SendMouse

func (t *Terminal) SendMouse(ev MouseEvent) error

SendMouse encodes a mouse event and sends it to the child, using the wire encoding named by the event's Enc field. The program under test must have enabled the matching mouse reporting mode for it to react.

func (*Terminal) Snapshot

func (t *Terminal) Snapshot() string

Snapshot returns the current plain-text screen.

func (*Terminal) SnapshotStyled

func (t *Terminal) SnapshotStyled() string

SnapshotStyled returns the styled, diff-friendly encoding of the screen: each row's plain text followed by indented attribute runs for spans that differ from the default style. A screen with no styling degrades to the plain form.

func (*Terminal) TermState

func (t *Terminal) TermState() TermState

TermState returns the current mode state of the emulated terminal. Call it after the child has exited to check that it restored the terminal.

func (*Terminal) Type

func (t *Terminal) Type(s string) error

Type sends literal text with no key-name interpretation (tmux send-keys -l).

func (*Terminal) Wait deprecated

func (t *Terminal) Wait(timeout time.Duration) (int, error)

Wait is the former name of WaitExit, kept so existing tests keep compiling.

Deprecated: use WaitExit. "Wait" read as a sibling of the WaitFor family, which wait on screen state, when in fact it waits for process exit.

func (*Terminal) WaitExit

func (t *Terminal) WaitExit(timeout time.Duration) (int, error)

WaitExit blocks until the child exits or timeout elapses, returning the exit code. On timeout it returns -1 and a *TimeoutError, which unwraps to ErrTimeout like every other wait. A child killed by a signal also returns -1, with a nil error; ExitStatus is what tells the two apart.

func (*Terminal) WaitFor

func (t *Terminal) WaitFor(cond func(Screen) bool, timeout time.Duration) error

WaitFor blocks until cond returns true on the current screen, or timeout.

func (*Terminal) WaitForCommand

func (t *Terminal) WaitForCommand(timeout time.Duration) error

WaitForCommand blocks until the current command finishes (OSC 133 D). Requires WithSemanticMarkers.

func (*Terminal) WaitForMatch

func (t *Terminal) WaitForMatch(re *regexp.Regexp, scope Scope, timeout time.Duration) error

WaitForMatch blocks until re matches within the given scope.

func (*Terminal) WaitForOutput

func (t *Terminal) WaitForOutput(timeout time.Duration) error

WaitForOutput blocks until the child writes anything at all after the call begins, or it exits, or timeout elapses.

This is the primitive for "wait until the program reacts to what I just sent", which WaitStable does not express: WaitStable asks whether output has been quiet for a window, and after a pause that is already true, so it returns immediately without the program having done anything. Use WaitStable to wait for a burst of output to finish, and WaitForOutput to wait for a reaction to begin. A child that has exited counts as settled, since no further output can arrive.

func (*Terminal) WaitForPrompt

func (t *Terminal) WaitForPrompt(timeout time.Duration) error

WaitForPrompt blocks until a new shell prompt (OSC 133 A) is drawn. Requires WithSemanticMarkers.

func (*Terminal) WaitForText

func (t *Terminal) WaitForText(substr string, timeout time.Duration) error

WaitForText blocks until the plain-text screen contains substr.

func (*Terminal) WaitStable

func (t *Terminal) WaitStable(timeout time.Duration) error

WaitStable blocks until the terminal has been quiet for the stabilize interval (see WithStabilizeInterval), or until timeout. A child that has exited counts as stable.

The quiet window is measured from the later of the last output byte and the last input tuitest sent. That matters: measured from output alone, calling WaitStable immediately after SendKeys would return against the pre-keystroke screen whenever the program had already been idle for the interval. Waiting out the window from the keystroke instead gives the program that long to start reacting, and any byte it produces restarts the window.

It is still a heuristic. A program that takes longer than the stabilize interval to produce its first byte will be reported stable too early, and no quiescence rule can distinguish that from a program with nothing to say. Prefer WaitForText, WaitForMatch or WaitFor whenever the expected end state is known, and reach for WaitStable only after heavy output where it is not.

type TimeoutError

type TimeoutError struct {
	// Op is the wait that failed, such as "WaitForText".
	Op string
	// Want describes the condition in words, such as `text "ready"`.
	Want string
	// Elapsed is how long the wait actually took.
	Elapsed time.Duration
	// Screen is the plain-text screen at the moment of the failure.
	Screen string
	// TailLog is the tail of the mirrored PTY I/O.
	TailLog string
}

TimeoutError is returned by every wait that times out. Its message includes a full screen dump and the tail of the mirrored I/O log, so a failing CI run shows exactly what was on screen instead of a bare "timeout". It unwraps to ErrTimeout.

func (*TimeoutError) Error

func (e *TimeoutError) Error() string

func (*TimeoutError) Unwrap

func (e *TimeoutError) Unwrap() error

Unwrap makes errors.Is(err, ErrTimeout) true for every wait timeout.

Directories

Path Synopsis
cmd
tuitest command
Command tuitest tests terminal programs from the command line.
Command tuitest tests terminal programs from the command line.
Package fixtures provides testing utilities for tuitest, including a fake shell that produces predictable output and sends/receives ANSI sequences, plus an ANSI escape-sequence builder.
Package fixtures provides testing utilities for tuitest, including a fake shell that produces predictable output and sends/receives ANSI sequences, plus an ANSI escape-sequence builder.
Package fuzz drives a terminal program with randomised but structured input and watches for the ways a TUI breaks: crashing, hanging, corrupting the screen model, growing without bound, or exiting without restoring the terminal.
Package fuzz drives a terminal program with randomised but structured input and watches for the ways a TUI breaks: crashing, hanging, corrupting the screen model, growing without bound, or exiting without restoring the terminal.
vtgen
Package vtgen generates terminal input that looks like something a program would actually emit.
Package vtgen generates terminal input that looks like something a program would actually emit.
internal
cli
Package cli implements the tuitest command line.
Package cli implements the tuitest command line.
emu
Package emu holds the VT emulator adapter used by tuitest.
Package emu holds the VT emulator adapter used by tuitest.
ptyproc
Package ptyproc owns the process and PTY lifecycle for tuitest: spawning a child attached to a pseudo-terminal, pumping its output, resizing, EOF and exit-code handling, and process-tree teardown.
Package ptyproc owns the process and PTY lifecycle for tuitest: spawning a child attached to a pseudo-terminal, pumping its output, resizing, EOF and exit-code handling, and process-tree teardown.
textdist
Package textdist provides the string distance behind "did you mean" hints.
Package textdist provides the string distance behind "did you mean" hints.
vt
Package vt provides a virtual terminal implementation.
Package vt provides a virtual terminal implementation.
Package tape implements the small VHS-inspired tape language for tuitest's CLI and a player that drives a tuitest.Terminal.
Package tape implements the small VHS-inspired tape language for tuitest's CLI and a player that drives a tuitest.Terminal.
Package tuiosx holds the tuios-specific conveniences for tuitest: a prefix chord helper and a spawn helper that isolates each tuios instance in its own temporary XDG state so parallel tests do not collide on a shared daemon socket.
Package tuiosx holds the tuios-specific conveniences for tuitest: a prefix chord helper and a spawn helper that isolates each tuios instance in its own temporary XDG state so parallel tests do not collide on a shared daemon socket.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL