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.

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.