Skip to content

Multi-Backend Testing

Termless separates the test API from the terminal emulator. Write tests once, run them against any backend.

Single-backend testing

If you only need the default xterm.js backend, you don't need any of this. Just use import { createTestTerminal } from "@termless/test" -- it handles the backend automatically. (createTerminalFixture still works as a deprecated alias.)

Getting Started

Install the backends you want to test against:

bash
bunx termless backends                  # See what's available
bunx termless backends install ghostty vt100   # Install specific backends

See Backend Capabilities for the full list of backends, their capabilities, and per-backend usage examples. For a comprehensive interactive feature matrix, visit terminfo.dev.

Three Approaches

The most ergonomic way to run the same tests across all installed backends. Creates a describe block per backend with a factory that handles resolution and cleanup:

typescript
import { describeBackends } from "@termless/test"

describeBackends((ctx) => {
  test("renders bold", async () => {
    const term = await ctx.createTerminal({ cols: 80, rows: 24 })
    term.feed("\x1b[1mBold\x1b[0m")
    expect(term.cell(0, 0)).toBeBold()
  })
})

// Or filter to specific backends:
describeBackends(["ghostty", "vt100"], (ctx) => {
  test("italic works", async () => {
    const term = await ctx.createTerminal()
    term.feed("\x1b[3mI")
    expect(term.cell(0, 0)).toBeItalic()
  })
})

2. Programmatic (per-test control)

Use createTestTerminalByName() to select a specific backend for individual tests:

typescript
import { createTestTerminalByName } from "@termless/test"

test("works on ghostty", async () => {
  const term = await createTestTerminalByName({ backendName: "ghostty" })
  term.feed("Hello")
  expect(term.screen).toContainText("Hello")
})

Or use backendCases() to iterate over installed backends manually:

typescript
import { backendCases } from "@termless/test"

const cases = await backendCases()
for (const { name, createTerminal } of cases) {
  test(`renders correctly on ${name}`, async () => {
    const term = await createTerminal({ cols: 80, rows: 24 })
    term.feed("\x1b[1mBold\x1b[0m")
    expect(term.cell(0, 0)).toBeBold()
  })
}

3. Vitest Workspace (full control)

Each backend gets its own vitest project with a setup file. This gives you per-backend configuration, separate test runs, and CI matrix support.

Create setup files per backend

typescript
// test/setup-xterm.ts
import { createXtermBackend } from "@termless/xtermjs"

declare global {
  var createBackend: () => import("termless").TerminalBackend
}

globalThis.createBackend = () => createXtermBackend()
typescript
// test/setup-ghostty.ts
import { createGhosttyBackend, initGhostty } from "@termless/ghostty"

declare global {
  var createBackend: () => import("termless").TerminalBackend
}

const ghostty = await initGhostty()
globalThis.createBackend = () => createGhosttyBackend(undefined, ghostty)

Configure vitest workspace

typescript
// vitest.workspace.ts
export default [
  {
    test: {
      name: "xterm",
      setupFiles: ["./test/setup-xterm.ts"],
      include: ["test/**/*.test.ts"],
    },
  },
  {
    test: {
      name: "ghostty",
      setupFiles: ["./test/setup-ghostty.ts"],
      include: ["test/**/*.test.ts"],
    },
  },
]

Write backend-agnostic tests

typescript
// test/my-app.test.ts
import { test, expect } from "vitest"
import { createTerminal } from "@termless/core"
import "@termless/test/matchers"

function createTerm(cols = 80, rows = 24) {
  return createTerminal({ backend: globalThis.createBackend(), cols, rows })
}

test("renders text correctly", () => {
  const term = createTerm()
  term.feed("Hello, world!")
  expect(term.screen).toContainText("Hello, world!")
  term.close()
})

test("bold text renders as bold", () => {
  const term = createTerm()
  term.feed("\x1b[1mBold\x1b[0m Normal")
  expect(term.cell(0, 0)).toBeBold()
  expect(term.cell(0, 5)).not.toBeBold()
  term.close()
})

Run

bash
bun vitest run              # Runs all workspace projects
bun vitest run --project xterm   # Run xterm only

What Multi-Backend Testing Catches

All backends implement the same TerminalBackend interface, so Terminal behavior should be identical. Differences surface as test failures, revealing compatibility issues:

  • Different color palette handling
  • Reflow behavior on resize
  • Unicode/wide character edge cases
  • Escape sequence support differences
  • Key encoding variations

See Cross-Backend Conformance for the 120+ conformance tests that Termless runs across backends.

Cross-Backend Canvas Compare

Conformance tests catch divergence as a test failure — but they don't show you what the divergence looks like. The play --compare modes do: they replay the same .tape through several backends and render the results into a single labelled image.

One renderer, N parsers

The key design: every backend parses the tape with its own VT engine, but all of the resulting terminal states are painted through the same canvas renderer (Ghostty's CanvasRenderer, native Skia — no browser). Because the renderer is held constant, any pixel difference between two panels is attributable to a parser divergence, not a rendering-engine difference.

bash
# Side-by-side: one labelled panel per backend.
termless play demo.tape -b ghostty,xtermjs --compare side-by-side -o compare.png

# Diff: the backend panels PLUS a "divergence" panel where every pixel that
# differs between any pair of backends is highlighted red.
termless play demo.tape -b ghostty,xtermjs --compare diff -o diff.png

# Stitched GIF: when -o ends in .gif, every Screenshot command becomes a
# time-synced frame across all backends.
termless play demo.tape -b ghostty,xtermjs --compare diff -o compare.gif

diff mode prints the divergent-pixel count:

Canvas-comparing across 2 backends (ghostty, xtermjs)...
Divergent pixels: 912/44928 (2.030%)
Composed comparison saved: diff.png
Text match: NO — output differs between backends

Backends that aren't installed/built (e.g. wezterm, which needs a Rust build) are skipped with a warning — the comparison runs with whatever is ready.

Programmatic API

typescript
import { parseTape, compareCanvas } from "@termless/core"

const tape = parseTape('Type "ab…cd"\nScreenshot')
const result = await compareCanvas(tape, {
  backends: ["ghostty", "xtermjs"],
  mode: "diff",
})

result.textMatch // false — the C1 NEL byte is parsed differently
result.divergentPixels // > 0 — the diff overlay lit up
result.composedPng // Uint8Array — the labelled comparison image

What it catches

--compare diff makes real parser bugs visible at a glance — OSC8 hyperlink handling, wide-character width disagreements, DEC private mode interpretation, and C1 control-byte handling. For example, the C1 NEL byte (U+0085): xterm.js honours it as "next line", Ghostty treats it as inert — the diff overlay highlights exactly the rows that drift.

How Termless Compares

SystemWhat it matricesHow it works
Playwright projectsBrowsers (Chromium, Firefox, WebKit)Same tests injected with different browser launcher
Vitest workspaceAny axis (backends, configs, environments)Named projects with different setup files
BrowserStack / Sauce LabsBrowsers + devices + OS combinationsCloud farms running tests across hundreds of targets
Termless cross-backendTerminal emulator VT parsersSame VT sequences fed to different WASM/native parsers, cell-by-cell comparison

Playwright is the closest analog — "do different browsers render the same HTML?" maps to "do different terminals parse the same escape sequences?" But Termless additionally compares backends side-by-side in the same test run. No existing tool does automated cross-terminal-emulator conformance testing.