Skip to content

beorn/termless

Repository files navigation

Termless

Headless terminal testing library. Like Playwright, but for terminal apps.

Terminal apps are hard to test because the terminal is a black box — you can see text on screen but can't programmatically inspect colors, cursor position, scrollback history, terminal modes, or cell attributes. Termless opens up the entire terminal buffer for structured testing, and runs the same tests against multiple terminal emulators to catch cross-terminal compatibility issues.

Built alongside silvery, a React TUI framework, but works with any terminal app.

  • Full terminal internals -- access scrollback, cursor state, cell colors, terminal modes, alt screen, resize behavior — everything that's invisible to string matching
  • Cross-terminal conformance -- run the same tests against xterm.js, Ghostty, Alacritty, WezTerm, vt100, and Peekaboo to find where terminals disagree
  • Composable region selectors -- term.screen, term.scrollback, term.cell(r, c), term.row(n) for precise assertions
  • 21+ Vitest matchers -- text, cell style, cursor, mode, scrollback, and snapshot matchers
  • SVG & PNG screenshots -- no Chromium, no native deps (PNG via optional @resvg/resvg-js)
  • PTY support -- spawn real processes, send keypresses, wait for output
  • Fast -- typically under 1ms per unit-style test (in-memory backend, no PTY). No Chromium, no subprocesses
  • CLI + MCP -- termless capture for scripts, termless mcp for AI agents

Quick Start

import { createTerminal } from "@termless/core"
import { createXtermBackend } from "@termless/xtermjs"

const GREEN = (s: string) => `\x1b[38;2;0;255;0m${s}\x1b[0m`

const term = createTerminal({ backend: createXtermBackend(), cols: 80, rows: 24 })
term.feed(GREEN("● API online"))

// String matching sees text. termless sees everything.
term.screen.getText() // "● API online"
term.cell(0, 0).fg // { r: 0, g: 255, b: 0 } — the color getText() can't see

await term.close()

Spawn a real process

const term = createTerminal({ backend: createXtermBackend(), cols: 120, rows: 40 })
await term.spawn(["my-tui-app"])
await term.waitFor("ready>")

// Keyboard input
term.press("ArrowDown")
term.type("search query")

// Mouse input
term.click(10, 5) // click at column 10, row 5
await term.dblclick(10, 5) // double-click (async — two clicks with delay)
term.click(10, 5, { ctrl: true }) // ctrl+click

// Region selectors — inspect specific parts of the terminal
console.log(term.screen.getText()) // visible area
console.log(term.scrollback.getText()) // history above screen
console.log(term.row(0).getText()) // first row
console.log(term.lastRow().getText()) // last row

const svg = term.screenshotSvg()
const png = await term.screenshotPng() // requires: bun add -d @resvg/resvg-js
await term.close()

Write tests

import { test, expect } from "vitest"
import { createTerminalFixture } from "@termless/test"

// ANSI helpers — real apps use silvery or chalk, these are just for test data
const BOLD = (s: string) => `\x1b[1m${s}\x1b[0m`
const GREEN = (s: string) => `\x1b[38;2;0;255;0m${s}\x1b[0m`

test("inspect what string matching can't see", () => {
  // Creates an xterm.js terminal by default. Ghostty, Alacritty, WezTerm, vt100,
  // and Peekaboo backends are also available — see Multi-Backend Testing below.
  const term = createTerminalFixture({ cols: 40, rows: 3 })

  // Simulate a build pipeline — 4 lines overflow a 3-row terminal
  term.feed("Step 1: install\r\n")
  term.feed(`Step 2: ${GREEN("build ok")}\r\n`)
  term.feed(`Step 3: ${BOLD("test")}\r\n`)
  term.feed("Step 4: deploy")

  // Region selectors — screen, scrollback, buffer
  expect(term.scrollback).toContainText("install") // scrolled off, still in history
  expect(term.screen).toContainText("deploy") // visible area
  expect(term.buffer).toContainText("install") // everything (scrollback + screen)
  expect(term.row(0)).toHaveText("Step 2: build ok") // specific row

  // Cell styles — colors that getText() can't see
  expect(term.cell(0, 8)).toHaveFg("#00ff00") // "build ok" is green
  expect(term.cell(1, 8)).toBeBold() // "test" is bold

  // Scroll up, then assert on viewport
  term.backend.scrollViewport(1)
  expect(term.viewport).toContainText("install")

  // Resize — verify content survives
  term.resize(20, 3)
  expect(term.screen).toContainText("deploy")

  // Terminal state — window title, cursor, modes
  term.feed("\x1b]2;Build Pipeline\x07") // OSC 2 — set window title
  expect(term).toHaveTitle("Build Pipeline")
  expect(term).toHaveCursorAt(14, 2) // after "Step 4: deploy"
  expect(term).toBeInMode("autoWrap") // default mode
  expect(term).not.toBeInMode("altScreen") // not in alternate screen
})

None of this is possible with expect(output).toContain("text"). String matching can't see colors, can't inspect scrollback, can't verify cursor position, can't test resize behavior, and can't query terminal capabilities. Termless gives you the full terminal state machine.

Cross-terminal differences are real. Emoji width, color palette mapping, scroll region behavior, key encoding, Kitty keyboard protocol support, and hyperlink handling all differ between terminals. Run the same test against xterm.js and Ghostty and you'll find them. The cross-backend.test.ts suite runs 120+ conformance tests across all backends, catching differences automatically in CI.

Region Selectors

The composable API separates where to look from what to assert:

// Region selectors (getter properties — no parens)
term.screen // the rows x cols visible area
term.scrollback // history above screen
term.buffer // everything (scrollback + screen)
term.viewport // current scroll position view

// Region selectors (methods with args)
term.row(n) // screen row (negative from bottom)
term.cell(row, col) // single cell
term.range(r1, c1, r2, c2) // rectangular region
term.firstRow() // first screen row
term.lastRow() // last screen row

Then assert using the appropriate matchers for each view type:

// Text matchers work on RegionView (screen, scrollback, buffer, viewport, row, range)
expect(term.screen).toContainText("Hello")
expect(term.row(0)).toHaveText("Title")
expect(term.screen).toMatchLines(["Line 1", "Line 2"])

// Style matchers work on CellView
expect(term.cell(0, 0)).toBeBold()
expect(term.cell(0, 0)).toHaveFg("#ff0000")
expect(term.cell(2, 5)).toHaveUnderline("curly")

// Terminal matchers work on the terminal itself
expect(term).toHaveCursorAt(5, 0)
expect(term).toBeInMode("altScreen")
expect(term).toHaveTitle("My App")

Matchers Reference

Text Matchers (on RegionView / RowView)

Matcher Description
toContainText(text) Region contains text as substring
toHaveText(text) Region text matches exactly (trimmed)
toMatchLines(lines[]) Lines match expected array (trailing whitespace trimmed)

Cell Style Matchers (on CellView)

Matcher Description
toBeBold() Cell is bold
toBeItalic() Cell is italic
toBeFaint() Cell is faint/dim
toBeStrikethrough() Cell has strikethrough
toBeInverse() Cell has inverse video
toBeWide() Cell is double-width (CJK, emoji)
toHaveUnderline(style?) Cell has underline; optional style: "single", "double", "curly", "dotted", "dashed"
toHaveFg(color) Foreground color ("#rrggbb" or { r, g, b })
toHaveBg(color) Background color ("#rrggbb" or { r, g, b })

Terminal Matchers (on TerminalReadable)

Matcher Description
toHaveCursorAt(x, y) Cursor at position
toHaveCursorVisible() Cursor is visible
toHaveCursorHidden() Cursor is hidden
toHaveCursorStyle(style) Cursor style: "block", "underline", "beam"
toBeInMode(mode) Terminal mode is enabled
toHaveTitle(title) OSC 2 title matches
toHaveScrollbackLines(n) Scrollback has N total lines
toBeAtBottomOfScrollback() Viewport at bottom (no scroll offset)
toMatchTerminalSnapshot() Vitest snapshot of terminal state

Installation

npm install -D @termless/test               # Vitest matchers + fixtures (includes xterm.js backend)
npm install -D @resvg/resvg-js              # Optional: PNG screenshot support
npm install node-pty                         # Optional: PTY support on Node.js (not needed on Bun)

Runtime Compatibility

Termless works on both Bun (>=1.0) and Node.js (>=18).

Feature Bun Node.js
Pure backends (xtermjs, ghostty, vt100) Built-in Built-in
PTY spawn (terminal.spawn()) Built-in (native PTY) Requires node-pty
SVG screenshots Built-in Built-in
PNG screenshots @resvg/resvg-js @resvg/resvg-js
Peekaboo (OS automation) macOS only macOS only
napi-rs backends (alacritty, wezterm) Needs Rust build Needs Rust build

On Node.js, PTY support requires node-pty as an optional peer dependency. If not installed, terminal.spawn() throws a clear error with installation instructions. All other features (feeding data, backends, screenshots, matchers) work without it.

Which Package Do I Need?

You want to... Install
Test a terminal UI in Vitest @termless/test (includes xterm.js backend)
Use the core Terminal API without test matchers @termless/core + a backend (@termless/xtermjs, etc.)
Test against Ghostty's VT parser @termless/ghostty
Test with a zero-dependency emulator @termless/vt100
Take SVG/PNG screenshots Built into @termless/core (PNG needs @resvg/resvg-js)
Spawn and test real processes via PTY Built into @termless/core (used via any backend)
Automate a real terminal app (OS-level) @termless/peekaboo
Use the CLI or MCP server @termless/cli

Most users only need @termless/test.

Multi-Backend Testing

Test your TUI against multiple terminal emulators with a single test suite. Write tests once, configure backends via vitest workspace:

// vitest.workspace.ts — add as many backends as you want
export default [
  { test: { name: "xterm", setupFiles: ["./test/setup-xterm.ts"] } },
  { test: { name: "ghostty", setupFiles: ["./test/setup-ghostty.ts"] } },
  { test: { name: "vt100", setupFiles: ["./test/setup-vt100.ts"] } },
  // Also available: alacritty, wezterm (require Rust build), peekaboo (OS-level)
]
// test/setup-xterm.ts                         // test/setup-ghostty.ts
import { createXtermBackend }                  import { createGhosttyBackend }
  from "@termless/xtermjs"                       from "@termless/ghostty"
globalThis.createBackend =                     globalThis.createBackend =
  () => createXtermBackend()                     () => createGhosttyBackend()

// test/setup-vt100.ts — pure TypeScript, zero native deps
import { createVt100Backend } from "@termless/vt100"
globalThis.createBackend = () => createVt100Backend()

Your tests use globalThis.createBackend() and run against every configured backend automatically. vitest runs the entire test suite once per workspace entry — same tests, different terminal emulators. See docs/guide/multi-backend.md.

Cross-Backend Conformance

All backends are tested for conformance via cross-backend.test.ts — text rendering, SGR styles, cursor positioning, modes, scrollback, capabilities, key encoding, unicode, and cross-backend output comparison. Run with:

bun vitest run tests/cross-backend.test.ts

Packages

Package Description
termless Core: Terminal, PTY, SVG/PNG screenshots, key mapping, region views
@termless/xtermjs xterm.js backend (@xterm/headless)
@termless/ghostty Ghostty backend (ghostty-web WASM)
@termless/vt100 Pure TypeScript VT100 emulator (zero native deps)
@termless/alacritty Alacritty backend (alacritty_terminal via napi-rs)
@termless/wezterm WezTerm backend (wezterm-term via napi-rs)
@termless/peekaboo OS-level terminal automation (xterm.js + real app)
@termless/test Vitest matchers, fixtures, and snapshot serializer
@termless/cli CLI (termless capture) + MCP server (termless mcp)

How Termless Compares

Termless is the only headless terminal testing library that supports multi-backend testing with composable matchers:

Feature Termless Playwright + xterm.js TUI Test ttytest2 pexpect Textual Ink
Terminal internals ✅ scrollback, cursor, modes, cell attrs ⚠️ xterm.js buffer only ⚠️
Multi-backend ✅ 6 backends ❌ xterm.js only ❌ xterm.js only ❌ tmux only
Composable selectors ✅ 8 types ⚠️
Visual matchers ✅ 21+ ❌ DIY ⚠️ ⚠️
Protocol capabilities ✅ Kitty, sixel, OSC 8, reflow ❌ xterm.js subset
SVG & PNG screenshots
No browser/Chromium ❌ needs Chromium
Framework-agnostic
TypeScript

Documentation

Full documentation site

CLI & MCP Server

For scripting and AI agents, @termless/cli provides terminal capture and an MCP server:

# Capture terminal output as text or screenshot
termless capture --command "ls -la" --wait-for "total" --text
termless capture --command "vim file.txt" --keys "i,Hello,Escape,:,w,q,Enter" --screenshot /tmp/vim.svg

# MCP server for AI agents (Claude Code, etc.)
termless mcp

See the CLI & MCP docs for full options.

See Also

silvery -- if Termless is for testing terminal apps, silvery is for building them. A React TUI framework that fully leverages modern terminal features (truecolor, Kitty keyboard protocol, mouse events, images, scroll regions) and generates all the ANSI codes automatically. Write terminal UIs in familiar React/JSX — silvery handles the terminal complexity. Use @termless/test to verify your silvery app renders correctly across terminals.

License

MIT

About

Like Playwright, but for terminal apps — headless testing across xterm.js, Ghostty, Alacritty, WezTerm, and more

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors