Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/go.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
applyTo: "**/*.go"
---

- **Internal API defensive checks**: Do not suggest adding nil/empty guards on internal (unexported package) functions that are only called with known-safe arguments. The project trusts internal call sites and only validates at system boundaries.
- **tmux format string output parsing**: Do not flag missing length checks when parsing tmux output produced by a controlled format string (e.g., `list-panes -F "#{pane_dead} #{pane_dead_status}"`). The format string guarantees the output shape. Guards are applied selectively where tmux command semantics warrant them.
- **Functional option validation**: Do not suggest validating functional options (e.g., `WithTimeout`, `WithPollInterval`) at configuration time in `Open`. Validation happens at the point of use in wait methods, consistent with the project convention of trusting test authors over defensive input checking. Terminal-level defaults are intentionally not clamped or validated; only per-call wait overrides (`WithinTimeout`, `WithWaitPollInterval`) are validated/clamped.
- **Best-effort capture helpers**: Do not suggest surfacing or wrapping errors from `captureScreenRaw`. This function is intentionally best-effort, returning nil on failure so callers can handle the nil case explicitly. The nil return is a deliberate API contract, not a lost error.
- **fmt.Stringer on pointer receivers**: Do not flag `%s` formatting with pointer types that implement `String() string` via a pointer receiver. Go's `fmt` package correctly invokes the `Stringer` interface on pointer receivers (e.g., `*Screen` with `func (s *Screen) String() string`).
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Install tmux on Linux
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y tmux

- name: Install tmux on macOS
if: runner.os == 'macOS'
run: |
if ! command -v tmux >/dev/null 2>&1; then
brew install tmux
fi

- name: Run tests
run: go test ./...
87 changes: 87 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# crawler

A Go testing library for black-box testing of TUI programs via tmux.

## Project overview

- **Module**: `github.com/cboone/crawler`
- **Package**: Single public package `crawler`
- **Go version**: 1.24+
- **Dependencies**: Zero third-party Go dependencies (stdlib only)
- **Runtime requirement**: tmux 3.0+ on Linux or macOS

## Architecture

Each test gets an isolated tmux server via a unique socket path. All terminal
interaction goes through the `tmux` CLI (`capture-pane`, `send-keys`,
`resize-window`, `list-panes`, `display-message`). A temporary config file
sets `remain-on-exit`, `history-limit`, and `status off` before the session
starts.

### File layout

```
crawler.go Terminal type, Open(), core methods (Type, Press, WaitFor, etc.)
options.go Option/WaitOption types and functional option constructors
screen.go Screen type (immutable capture of terminal content)
keys.go Key type, constants (Enter, Tab, arrows, F1-F12), Ctrl/Alt helpers
match.go Matcher type and built-in matchers (Text, Regexp, Line, Not, All, etc.)
snapshot.go MatchSnapshot, golden file management, CRAWLER_UPDATE support
tmux.go tmux adapter layer: session lifecycle, version check, socket paths,
pane state queries, cursor position, sanitizeName
doc.go Package-level godoc documentation

internal/
tmuxcli/ Low-level tmux command runner (Runner, Error, Version, WaitForSession)
testbin/ Minimal line-based TUI fixture used by integration tests

crawler_test.go Integration tests (35 tests including 25-subtest parallel stress test)
testdata/ Golden files for snapshot tests (created by CRAWLER_UPDATE=1)
```

### Key design decisions

- `tmux.go` is the adapter between the public API and `internal/tmuxcli`. All
tmux details are contained there.
- `remain-on-exit` is set via config file (`-f`) rather than `set-option` after
session start, so fast-exiting processes still report exit codes.
- `status off` disables the tmux status bar so terminal dimensions match the
requested size exactly.
- Screen captures include cursor position on a best-effort basis for the
`Cursor` matcher. If `display-message` fails, cursor fields use sentinel
values (-1) and the `Cursor` matcher reports "cursor position unavailable."
- Socket paths include a sanitized test name and random suffix, truncated to
stay within Unix socket path limits.

## Development

### Running tests

```sh
go test ./...
```

Tests require tmux in `$PATH`. If tmux is not found, tests skip automatically.

### Updating snapshots

```sh
CRAWLER_UPDATE=1 go test ./...
```

### Key environment variables

- `CRAWLER_UPDATE` -- set to `1` to create/update golden files
- `CRAWLER_TMUX` -- override the tmux binary path

## Conventions

- All public methods that interact with tmux call `t.Fatal` on error; users
never check `err` returns.
- Error messages follow the format: `crawler: <operation>: <reason>`.
- `WaitFor` and `WaitForScreen` fail immediately if the pane dies before the
matcher succeeds.
- `WaitExit` is the expected API for tests that intentionally terminate the
process.
- Matchers return `(ok bool, description string)` where description is
human-readable for error messages.
248 changes: 247 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,247 @@
# crawler
# crawler

Playwright for TUIs via tmux.

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

## Quick start

```go
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

```sh
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

```go
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

```go
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

```go
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

```go
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

```go
term.WaitFor(crawler.Text("Welcome"))
term.MatchSnapshot("welcome-screen")
```

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

```sh
CRAWLER_UPDATE=1 go test ./...
```

### Other operations

```go
// 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:

```go
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

- [Getting started](docs/GETTING-STARTED.md) -- first-test tutorial
- [Matchers in depth](docs/MATCHERS.md) -- all built-in matchers, composition, and custom matchers
- [Snapshot testing](docs/SNAPSHOTS.md) -- golden-file testing guide
- [Recipes and patterns](docs/PATTERNS.md) -- common testing scenarios with complete examples
- [Troubleshooting](docs/TROUBLESHOOTING.md) -- debugging failures and CI setup
- [Architecture](docs/ARCHITECTURE.md) -- how crawler works under the hood

## 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 | | |
| | +----------------------+ | |
| +----------------------------+ |
+----------------------------------+
```
Loading
Loading