crawler

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Feb 7, 2026 License: MIT Imports: 13 Imported by: 0

README

crawler

Test TUIs through tmux.

A Go testing library for black-box testing of terminal user interfaces. Tests run binaries inside tmux sessions, send keystrokes, capture screen output, and assert against it. Uses the standard testing.TB interface.

Quick start

import "github.com/cboone/crawler"

func TestMyApp(t *testing.T) {
    term := crawler.Open(t, "./my-app")
    term.WaitFor(crawler.Text("Welcome"))
    term.Type("hello")
    term.Press(crawler.Enter)
    term.WaitFor(crawler.Text("hello"))
}

No defer, no Close(). Cleanup is automatic via t.Cleanup.

Install

go get github.com/cboone/crawler

Requires tmux 3.0+ installed on the system. No other dependencies.

Features

Framework-agnostic -- tests any TUI binary: bubbletea, tview, tcell, Python curses, Rust ratatui, raw ANSI programs, anything that runs in a terminal.

Go-native API -- first-class integration with testing.TB, subtests, table-driven tests, t.Helper(), t.Cleanup(). No DSLs.

Reliable waits -- deterministic polling with timeouts instead of time.Sleep. Like Playwright's auto-waiting locators.

Snapshot testing -- golden-file screen captures with CRAWLER_UPDATE=1.

Zero dependencies -- standard library only. No version conflicts for users.

API overview

Opening a session
term := crawler.Open(t, "./my-app",
    crawler.WithArgs("--verbose"),
    crawler.WithSize(120, 40),
    crawler.WithEnv("NO_COLOR=1"),
    crawler.WithDir("/tmp/workdir"),
    crawler.WithTimeout(10 * time.Second),
)
Sending input
term.Type("hello world")           // literal text
term.Press(crawler.Enter)           // special keys
term.Press(crawler.Ctrl('c'))       // Ctrl combinations
term.Press(crawler.Alt('x'))        // Alt combinations
term.Press(crawler.Tab, crawler.Tab, crawler.Enter)  // multiple keys
term.SendKeys("raw", "tmux", "keys")  // escape hatch
Capturing the screen
screen := term.Screen()
screen.String()           // full content as string
screen.Lines()            // []string, one per row
screen.Line(0)            // single row (0-indexed)
screen.Contains("hello")  // substring check
screen.Size()             // (width, height)
Waiting for content
term.WaitFor(crawler.Text("Loading complete"))
term.WaitFor(crawler.Regexp(`\d+ items`))
term.WaitFor(crawler.LineContains(0, "My App v1.0"))
term.WaitFor(crawler.Not(crawler.Text("Loading...")))
term.WaitFor(crawler.All(crawler.Text("Done"), crawler.Not(crawler.Text("Error"))))

// Capture the matching screen
screen := term.WaitForScreen(crawler.Text("Results"))

// Override timeout for a single call
term.WaitFor(crawler.Text("Done"), crawler.WithinTimeout(30*time.Second))

// Override poll interval for a single call
term.WaitFor(crawler.Text("Done"), crawler.WithWaitPollInterval(100*time.Millisecond))

On timeout, WaitFor calls t.Fatal with a diagnostic message showing what was expected and the most recent screen captures:

terminal_test.go:42: crawler: wait-for: timed out after 5s
    waiting for: screen to contain "Loading complete"
    recent screen captures (oldest to newest):
    capture 1/3:
    ┌────────────────────────────────────────────────────────────────────────────────┐
    │ My Application v1.0                                                            │
    │                                                                                │
    │ Loading...                                                                     │
    └────────────────────────────────────────────────────────────────────────────────┘
    capture 2/3:
    ┌────────────────────────────────────────────────────────────────────────────────┐
    │ My Application v1.0                                                            │
    │                                                                                │
    │ Loading...                                                                     │
    └────────────────────────────────────────────────────────────────────────────────┘
    capture 3/3:
    ┌────────────────────────────────────────────────────────────────────────────────┐
    │ My Application v1.0                                                            │
    │                                                                                │
    │ Loading...                                                                     │
    └────────────────────────────────────────────────────────────────────────────────┘
Built-in matchers
Matcher Description
Text(s) Screen contains substring
Regexp(pattern) Screen matches regex
Line(n, s) Row n equals s (trailing spaces trimmed)
LineContains(n, s) Row n contains substring
Not(m) Inverts a matcher
All(m...) All matchers must match
Any(m...) At least one matcher must match
Empty() Screen has no visible content
Cursor(row, col) Cursor is at position
Snapshot testing
term.WaitFor(crawler.Text("Welcome"))
term.MatchSnapshot("welcome-screen")

Golden files are stored in testdata/<test-name>-<hash>/<name>.txt. Update them with:

CRAWLER_UPDATE=1 go test ./...
Other operations
// Resize the terminal (sends SIGWINCH)
term.Resize(120, 40)

// Wait for the process to exit
code := term.WaitExit()

// Capture full scrollback history
scrollback := term.Scrollback()

Subtests and parallel tests

Each call to Open starts a dedicated tmux server with its own socket path and creates a new session within it. Subtests and t.Parallel() work naturally:

func TestNavigation(t *testing.T) {
    tests := []struct {
        name string
        key  crawler.Key
        want string
    }{
        {"down moves to second item", crawler.Down, "> Item 2"},
        {"up moves to first item", crawler.Up, "> Item 1"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            term := crawler.Open(t, "./my-list-app")
            term.WaitFor(crawler.Text("> Item 1"))
            term.Press(tt.key)
            term.WaitFor(crawler.Text(tt.want))
        })
    }
}

Documentation

Requirements

  • Go 1.24+
  • tmux 3.0+ (checked at runtime; tests skip if tmux is not found)
  • OS: Linux, macOS, or any Unix-like system where tmux runs

The tmux binary is located by checking, in order:

  1. WithTmuxPath option
  2. CRAWLER_TMUX environment variable
  3. $PATH lookup

How it works

Each test gets its own tmux server via a unique socket path under os.TempDir(). All operations (capture-pane, send-keys, resize-window) go through the tmux CLI. No cgo, no terminfo parsing, no terminal emulator reimplementation.

Go test process
+-------------------------------------------------+
|  func TestFoo(t *testing.T) {                   |
|      term := crawler.Open(t, ...)               |---- tmux new-session -d ...
|      term.WaitFor(crawler.Text("hello"))        |---- tmux capture-pane -p
|      term.Type("world")                         |---- tmux send-keys -l ...
|  }                                              |
+-------------------------------------------------+
                  |
                  v
tmux server (per-test isolated socket)
+----------------------------------+
|  session: default                |
|  +----------------------------+  |
|  |  $ ./my-tui-binary --flag  |  |
|  |  +----------------------+  |  |
|  |  |  TUI rendering here |  |  |
|  |  +----------------------+  |  |
|  +----------------------------+  |
+----------------------------------+

Documentation

Overview

Package crawler provides black-box testing for terminal user interfaces.

crawler runs a real binary inside an isolated tmux server, sends keystrokes, captures screen output, and performs assertions through the standard testing.TB interface. It is framework-agnostic and works with any program that renders in a terminal.

Quick Start

func TestMyApp(t *testing.T) {
	term := crawler.Open(t, "./my-app")
	term.WaitFor(crawler.Text("Welcome"))
	term.Type("hello")
	term.Press(crawler.Enter)
	term.WaitFor(crawler.Text("hello"))
}

Cleanup is automatic through t.Cleanup; there is no Close method.

Session Lifecycle

Open creates a dedicated tmux server for each test, using a unique socket path under os.TempDir. This gives subtests and parallel tests full isolation.

Internally, crawler starts tmux with a temporary config file that enables:

  • remain-on-exit on
  • status off
  • deterministic history-limit

The tmux server is torn down with kill-server during cleanup.

Waiting and Matchers

Terminal.WaitFor and Terminal.WaitForScreen poll until a Matcher succeeds or a timeout expires. This is the core reliability mechanism and avoids ad hoc sleeps in tests.

Wait behavior:

  • Defaults: 5s timeout, 50ms poll interval
  • Per-terminal overrides: WithTimeout, WithPollInterval (trusted, not clamped)
  • Per-call overrides: WithinTimeout, WithWaitPollInterval
  • Per-call poll intervals under 10ms are clamped to 10ms
  • Per-call negative timeout or poll values fail the test immediately
  • If the process exits early, waits fail immediately with diagnostics

Built-in matchers include Text, Regexp, Line, LineContains, Not, All, Any, Empty, and Cursor.

Screen Capture

Terminal.Screen captures the visible pane. Terminal.Scrollback captures full scrollback history. A Screen is immutable and provides helpers such as Screen.String, Screen.Lines, Screen.Line, Screen.Contains, and Screen.Size.

Snapshots

Terminal.MatchSnapshot and Screen.MatchSnapshot compare screen content to golden files under testdata. Set CRAWLER_UPDATE=1 to create or update golden files.

Snapshot content is normalized for stable diffs by trimming trailing spaces, trimming trailing blank lines, and writing a single trailing newline.

Diagnostics

On wait failures, crawler reports:

  • expected matcher description
  • timeout or exit details
  • multiple recent screen captures (oldest to newest)

This keeps failures actionable without extra debug tooling.

Requirements

  • Go 1.24+
  • tmux 3.0+
  • Linux or macOS

tmux is resolved in this order:

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Key

type Key string

Key represents a tmux key sequence.

const (
	Enter     Key = "Enter"
	Escape    Key = "Escape"
	Tab       Key = "Tab"
	Backspace Key = "BSpace"
	Up        Key = "Up"
	Down      Key = "Down"
	Left      Key = "Left"
	Right     Key = "Right"
	Home      Key = "Home"
	End       Key = "End"
	PageUp    Key = "PageUp"
	PageDown  Key = "PageDown"
	Space     Key = "Space"
	Delete    Key = "DC"

	F1  Key = "F1"
	F2  Key = "F2"
	F3  Key = "F3"
	F4  Key = "F4"
	F5  Key = "F5"
	F6  Key = "F6"
	F7  Key = "F7"
	F8  Key = "F8"
	F9  Key = "F9"
	F10 Key = "F10"
	F11 Key = "F11"
	F12 Key = "F12"
)

Special key constants for use with Press.

func Alt

func Alt(c byte) Key

Alt returns the key sequence for Alt+<char>.

func Ctrl

func Ctrl(c byte) Key

Ctrl returns the key sequence for Ctrl+<char>.

type Matcher

type Matcher func(s *Screen) (ok bool, description string)

A Matcher reports whether a Screen satisfies a condition. The string return is a human-readable description for error messages.

func All

func All(matchers ...Matcher) Matcher

All matches when every provided matcher matches.

func Any

func Any(matchers ...Matcher) Matcher

Any matches when at least one provided matcher matches.

func Cursor

func Cursor(row, col int) Matcher

Cursor matches if the cursor is at the given position. Uses tmux display-message to query cursor position. Note: row and col are 0-indexed. This matcher takes (row, col) to follow the usual row-then-column convention.

func Empty

func Empty() Matcher

Empty matches when the screen has no visible content.

func Line

func Line(n int, s string) Matcher

Line matches if the given line (0-indexed) equals s after trimming trailing spaces from the screen line.

func LineContains

func LineContains(n int, substr string) Matcher

LineContains matches if the given line (0-indexed) contains the substring.

func Not

func Not(m Matcher) Matcher

Not inverts a matcher.

func Regexp

func Regexp(pattern string) Matcher

Regexp matches if the screen content matches the regular expression. The pattern is compiled once; an invalid pattern causes a panic.

func Text

func Text(s string) Matcher

Text matches if the screen contains the given substring anywhere.

type Option

type Option func(*options)

Option configures a Terminal created by Open.

func WithArgs

func WithArgs(args ...string) Option

WithArgs sets the arguments passed to the binary.

func WithDir

func WithDir(dir string) Option

WithDir sets the working directory for the binary.

func WithEnv

func WithEnv(env ...string) Option

WithEnv appends environment variables to the process environment. Each entry should be in "KEY=VALUE" format.

func WithHistoryLimit

func WithHistoryLimit(limit int) Option

WithHistoryLimit sets the tmux scrollback history limit for the test session. A value of 0 uses the default set by Open (10000).

func WithPollInterval

func WithPollInterval(d time.Duration) Option

WithPollInterval sets the default polling interval for WaitFor and WaitForScreen.

func WithSize

func WithSize(width, height int) Option

WithSize sets the terminal dimensions (columns x rows).

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the default timeout for WaitFor and WaitForScreen.

func WithTmuxPath

func WithTmuxPath(path string) Option

WithTmuxPath sets the path to the tmux binary. Defaults to "tmux" (resolved via $PATH). The CRAWLER_TMUX environment variable can also be used as a fallback before the default.

type Screen

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

Screen is an immutable capture of terminal content.

func (*Screen) Contains

func (s *Screen) Contains(substr string) bool

Contains reports whether the screen contains the substring.

func (*Screen) Line

func (s *Screen) Line(n int) string

Line returns the content of a single row (0-indexed). Panics if n is out of range.

func (*Screen) Lines

func (s *Screen) Lines() []string

Lines returns a copy of the screen content as a slice of strings, one per row. The returned slice is a shallow copy; callers may modify it without affecting the Screen.

func (*Screen) MatchSnapshot

func (s *Screen) MatchSnapshot(t testing.TB, name string)

MatchSnapshot on Screen allows snapshotting a previously captured screen.

func (*Screen) Size

func (s *Screen) Size() (width, height int)

Size returns the width and height.

func (*Screen) String

func (s *Screen) String() string

String returns the full screen content as a string.

type Terminal

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

Terminal is a handle to a TUI program running inside a tmux session. It is created with Open and cleaned up automatically via t.Cleanup.

func Open

func Open(t testing.TB, binary string, userOpts ...Option) *Terminal

Open starts the binary in a new tmux session. Cleanup is automatic via t.Cleanup — no defer needed.

Example
package main

import (
	"testing"
	"time"

	"github.com/cboone/crawler"
)

func main() {
	_ = func(t *testing.T) {
		term := crawler.Open(t, "./my-app",
			crawler.WithArgs("--verbose"),
			crawler.WithSize(120, 40),
			crawler.WithTimeout(10*time.Second),
		)
		term.WaitFor(crawler.Text("Welcome"))
	}
}

func (*Terminal) MatchSnapshot

func (term *Terminal) MatchSnapshot(name string)

MatchSnapshot compares the current screen against a golden file stored in testdata/<sanitized-test-name>/<sanitized-name>.txt.

Set CRAWLER_UPDATE=1 to create or update golden files.

Example
package main

import (
	"testing"

	"github.com/cboone/crawler"
)

func main() {
	_ = func(t *testing.T) {
		term := crawler.Open(t, "./my-app")
		term.WaitFor(crawler.Text("Dashboard"))
		term.MatchSnapshot("dashboard")
	}
}

func (*Terminal) Press

func (term *Terminal) Press(keys ...Key)

Press sends one or more special keys.

func (*Terminal) Resize

func (term *Terminal) Resize(width, height int)

Resize changes the terminal dimensions. This sends a SIGWINCH to the running program.

func (*Terminal) Screen

func (term *Terminal) Screen() *Screen

Screen captures the current terminal content and returns it.

func (*Terminal) Scrollback

func (term *Terminal) Scrollback() *Screen

Scrollback captures the full scrollback buffer, not just the visible screen.

The returned Screen has one line per scrollback row (oldest to newest). Its height (and len(Lines())) reflects the total number of captured lines, which is typically larger than the pane's visible height. Width is the maximum line width across all captured lines. Callers should use len(s.Lines()) to reason about scrollback length, rather than relying on the visible height returned by s.Size().

func (*Terminal) SendKeys

func (term *Terminal) SendKeys(keys ...string)

SendKeys sends raw tmux key sequences. Escape hatch for advanced use.

func (*Terminal) Type

func (term *Terminal) Type(s string)

Type sends a string as sequential keypresses.

func (*Terminal) WaitExit

func (term *Terminal) WaitExit(wopts ...WaitOption) int

WaitExit waits for the TUI process to exit and returns its exit code. Useful for testing that a program terminates cleanly.

func (*Terminal) WaitFor

func (term *Terminal) WaitFor(m Matcher, wopts ...WaitOption)

WaitFor polls the screen until the matcher succeeds or the timeout expires. On timeout it calls t.Fatal with a description of what was expected and the last screen content.

Example
package main

import (
	"testing"

	"github.com/cboone/crawler"
)

func main() {
	_ = func(t *testing.T) {
		term := crawler.Open(t, "./my-app")
		term.WaitFor(crawler.Text("Name:"))
		term.Type("Alice")
		term.Press(crawler.Enter)
		term.WaitFor(crawler.All(
			crawler.Text("Saved"),
			crawler.Not(crawler.Text("Error")),
		))
	}
}

func (*Terminal) WaitForScreen

func (term *Terminal) WaitForScreen(m Matcher, wopts ...WaitOption) *Screen

WaitForScreen has the same timeout behavior as WaitFor: it polls until the matcher succeeds or the timeout expires, calling t.Fatal on timeout. On success it returns the matching Screen.

type WaitOption

type WaitOption func(*waitOptions)

WaitOption configures a single WaitFor, WaitForScreen, or WaitExit call.

func WithWaitPollInterval

func WithWaitPollInterval(d time.Duration) WaitOption

WithWaitPollInterval overrides the polling interval for a single wait call. A value of 0 means "use defaults". Negative values cause t.Fatal. Positive values under 10ms are clamped to 10ms.

func WithinTimeout

func WithinTimeout(d time.Duration) WaitOption

WithinTimeout overrides the call timeout for a single wait call. A value of 0 means "use defaults". Negative values cause t.Fatal.

Directories

Path Synopsis
internal
testbin command
Command testbin is a minimal TUI fixture program for testing the crawler library.
Command testbin is a minimal TUI fixture program for testing the crawler library.
tmuxcli
Package tmuxcli provides low-level tmux command execution and socket-path management.
Package tmuxcli provides low-level tmux command execution and socket-path management.

Jump to

Keyboard shortcuts

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