Writing Tests
Setup
Import the fixture:
import { describe, test, expect } from "vitest"
import { createTestTerminal } from "@termless/test"createTestTerminal() wraps createTerminal() with the xterm.js backend and registers cleanup in afterEach -- no manual close() needed. Matchers are auto-registered when importing from "@termless/test".
For named backends (async -- handles WASM/native initialization):
import { createTestTerminalByName } from "@termless/test"
test("works on ghostty", async () => {
const term = await createTestTerminalByName({ backendName: "ghostty" })
term.feed("Hello")
expect(term.screen).toContainText("Hello")
})You can also pass a factory-created backend directly:
import { createGhosttyBackend, initGhostty } from "@termless/ghostty"
const ghostty = await initGhostty()
const term = createTestTerminal({ backend: createGhosttyBackend(undefined, ghostty) })Deprecated aliases
createTerminalFixture and createTerminalFixtureAsync still work as deprecated aliases for createTestTerminal and createTestTerminalByName respectively.
Locators: Region Selectors
Termless uses terminal-region locators: first choose where to look, then choose what to assert. This is the terminal equivalent of a Playwright locator, but it points at terminal buffers, rows, cells, and ranges instead of DOM elements.
// WHERE: region selectors
term.screen // visible rows x cols area
term.scrollback // history above screen
term.buffer // everything (scrollback + screen)
term.viewport // current scroll position view
term.row(n) // screen row (negative from bottom)
term.cell(r, c) // single cell
term.range(r1, c1, r2, c2) // rectangular region
term.firstRow() // convenience: first screen row
term.lastRow() // convenience: last screen row
// WHAT: matchers
expect(term.screen).toContainText("Hello") // text matcher on region
expect(term.cell(0, 0)).toBeBold() // style matcher on cell
expect(term).toHaveCursorAt(5, 0) // terminal matcherAssertions: Matchers Reference
Text Matchers (on RegionView / RowView)
// Contains text anywhere in the region
expect(term.screen).toContainText("Hello")
// Exact text match (trimmed)
expect(term.row(0)).toHaveText("Title")
// Line-by-line match (trailing whitespace trimmed)
expect(term.screen).toMatchLines(["Line 1", "Line 2", "Line 3"])Cell Style Matchers (on CellView)
// Colors — accepts "#rrggbb" string or { r, g, b } object
expect(term.cell(0, 0)).toHaveFg("#ff0000")
expect(term.cell(0, 0)).toHaveBg({ r: 0, g: 255, b: 0 })
// Text attributes
expect(term.cell(0, 0)).toBeBold()
expect(term.cell(0, 0)).toBeItalic()
expect(term.cell(0, 0)).toBeFaint()
expect(term.cell(0, 0)).toBeStrikethrough()
expect(term.cell(0, 0)).toBeInverse()
expect(term.cell(0, 0)).toBeWide() // Double-width character
// Underline -- optional style: "single" | "double" | "curly" | "dotted" | "dashed"
expect(term.cell(0, 0)).toHaveUnderline() // Any underline
expect(term.cell(0, 0)).toHaveUnderline("curly") // Specific styleCursor Matchers
// Position (x, y)
expect(term).toHaveCursorAt(5, 0)
// Visibility
expect(term).toHaveCursorVisible()
expect(term).toHaveCursorHidden()
// Style: "block" | "underline" | "beam"
expect(term).toHaveCursorStyle("beam")Terminal Mode Matchers
// Generic mode check (replaces toBeInAltScreen, toBeInBracketedPaste, toHaveMode)
expect(term).toBeInMode("altScreen")
expect(term).toBeInMode("bracketedPaste")
expect(term).toBeInMode("mouseTracking")
expect(term).toBeInMode("applicationCursor")Available modes: altScreen, cursorVisible, bracketedPaste, applicationCursor, applicationKeypad, autoWrap, mouseTracking, focusTracking, originMode, insertMode, reverseVideo. See terminfo.dev for which terminals support which features.
Title Matcher
// OSC 2 title
expect(term).toHaveTitle("My App - untitled")Scrollback Matchers
// Total lines in scrollback buffer
expect(term).toHaveScrollbackLines(100)
// Viewport at bottom (no scroll offset)
expect(term).toBeAtBottomOfScrollback()Snapshot Matcher
// Vitest snapshot of terminal state
expect(term).toMatchTerminalSnapshot()Negation
All matchers support .not:
expect(term.screen).not.toContainText("error")
expect(term.cell(0, 5)).not.toBeBold()
expect(term).not.toBeInMode("altScreen")Snapshot Serializer
For readable terminal snapshots in .snap files:
import { expect } from "vitest"
import { terminalSerializer, terminalSnapshot } from "@termless/test"
expect.addSnapshotSerializer(terminalSerializer)
test("renders correctly", () => {
const BOLD = (s: string) => `\x1b[1m${s}\x1b[0m`
const term = createTestTerminal({ cols: 40, rows: 5 })
term.feed(`${BOLD("Title")}\r\nContent`)
expect(terminalSnapshot(term)).toMatchSnapshot()
})Produces snapshots like:
# terminal 40x5 | cursor (7,1) visible block
--------------------------------------------------
1|Title
2|Content
3|
4|
5|With style annotations when cells have non-default attributes:
1|Title [0:bold] [1:bold] [2:bold] [3:bold] [4:bold]Common Patterns
Testing ANSI output
const BOLD_RED = (s: string) => `\x1b[1;31m${s}\x1b[0m`
test("error message is red and bold", () => {
const term = createTestTerminal()
term.feed(`${BOLD_RED("Error:")} file not found`)
expect(term.screen).toContainText("Error: file not found")
expect(term.cell(0, 0)).toBeBold()
expect(term.cell(0, 0)).toHaveFg("#800000") // ANSI red (palette index 1)
expect(term.cell(0, 7)).not.toBeBold() // space after "Error:" is not bold
})Testing cursor movement
test("cursor tracks input", () => {
const term = createTestTerminal()
term.feed("Hello")
expect(term).toHaveCursorAt(5, 0)
term.feed("\r\nWorld")
expect(term).toHaveCursorAt(5, 1)
})Testing interactive apps with PTY
test("app enters alt screen", async () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
await term.spawn(["my-tui"])
await term.waitForStable()
expect(term).toBeInMode("altScreen")
expect(term.screen).toContainText("Welcome")
term.press("q")
await term.waitForStable()
expect(term).not.toBeInMode("altScreen")
})Finding text positions
test("find specific text", () => {
const term = createTestTerminal()
term.feed("Line 0\r\nLine 1\r\nTarget here")
const pos = term.find("Target")
expect(pos).not.toBeNull()
expect(pos!.row).toBe(2)
expect(pos!.col).toBe(0)
// Regex search for multiple matches
const matches = term.findAll(/Line \d/g)
expect(matches).toHaveLength(2)
})Reading text from regions
test("read text from different regions", () => {
const term = createTestTerminal({ cols: 80, rows: 5 })
// Feed enough lines to create scrollback
for (let i = 0; i < 10; i++) {
term.feed(`Line ${i}\r\n`)
}
// Screen shows the last 5 rows
const screenText = term.screen.getText()
// Scrollback has the history
const scrollbackText = term.scrollback.getText()
// Buffer has everything
const bufferText = term.buffer.getText()
// Raw output has protocol bytes before terminal parsing
const outputText = term.out.getText()
// Single row
const rowText = term.row(0).getText()
})Lazy Views & Auto-Retry
Region selectors (term.screen, term.scrollback, term.row(n), etc.) return lazy views -- they re-read from the terminal backend on every access. You never need to "refresh" a view; it always reflects the current terminal state.
This makes them work naturally with auto-retry matchers. When you await a matcher with a { timeout } option, vitest polls the lazy view repeatedly until the assertion passes or the timeout expires:
// The lazy view re-reads the terminal on each poll iteration
await expect(term.screen).toContainText("ready", { timeout: 10000 })This replaces the deprecated waitFor() pattern:
// Deprecated -- no diff on failure, worse error messages
await term.waitFor("ready", 10000)
// Preferred -- integrates with vitest expect, shows diff on failure
await expect(term.screen).toContainText("ready", { timeout: 10000 })You can combine lazy views with any matcher. The { timeout } option is supported by all text matchers (toContainText, toHaveText, toMatchLines) and terminal matchers (toHaveCursorAt, toBeInMode, etc.):
// Wait for cursor to reach a specific position
await expect(term).toHaveCursorAt(0, 5, { timeout: 5000 })
// Wait for a specific row to contain text
await expect(term.row(0)).toContainText("Title", { timeout: 5000 })
// Wait for alt screen mode
await expect(term).toBeInMode("altScreen", { timeout: 5000 })
// Wait for raw protocol output that may not render as screen text
await expect(term.out).toContainOutput("\x1b_G", { timeout: 5000 })Without { timeout }, matchers run synchronously -- they pass or fail immediately without polling. Use the synchronous form for in-memory tests where the terminal state is already set:
term.feed("\x1b[1mBold\x1b[0m")
expect(term.cell(0, 0)).toBeBold() // sync, no pollingMigration from Old API
| Old | New |
|---|---|
await term.waitFor("x", 5000) | await expect(term.screen).toContainText("x", { timeout: 5000 }) |
expect(term).toContainText("x") | expect(term.screen).toContainText("x") |
expect(term).toBeBoldAt(r, c) | expect(term.cell(r, c)).toBeBold() |
expect(term).toHaveFgColor(r, c, color) | expect(term.cell(r, c)).toHaveFg(color) |
expect(term).toBeInAltScreen() | expect(term).toBeInMode("altScreen") |
expect(term).toMatchViewport(lines) | expect(term.screen).toMatchLines(lines) |
term.getViewportText() | term.screen.getText() |
term.getScrollbackText() | term.scrollback.getText() |
term.getRowText(n) | term.row(n).getText() |
Raw Protocol Output
Most assertions should use rendered terminal state: term.screen, term.buffer, term.row(n), term.cell(r, c), cursor, mode, and title matchers. Use term.out only when the behavior is the literal output stream itself.
That matters for protocols such as Kitty graphics, OSC 52 clipboard writes, OSC titles, terminal queries, or CSI mode changes. These bytes may be consumed by the emulator and never appear in screen text.
test("emits a Kitty graphics packet", async () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
app.renderImage()
await expect(term.out).toContainOutput("\x1b_G", { timeout: 5000 })
expect(term.out.getText()).toContain("a=p")
term.out.clear()
app.unmount()
await expect(term.out).toContainOutput("a=d,d=i", { timeout: 5000 })
})