---
url: /guide/multi-backend.md
description: >-
  Write tests once and run them against multiple terminal emulator backends to
  catch cross-platform rendering differences.
---

# Multi-Backend Testing

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

::: tip 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](/guide/backends) for the full list of backends, their capabilities, and per-backend usage examples. For a comprehensive interactive feature matrix, visit [terminfo.dev](https://terminfo.dev).

## Three Approaches

### 1. describeBackends (recommended)

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](/advanced/compat-matrix) 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

| System                        | What it matrices                           | How it works                                                                    |
| ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
| **Playwright `projects`**     | Browsers (Chromium, Firefox, WebKit)       | Same tests injected with different browser launcher                             |
| **Vitest `workspace`**        | Any axis (backends, configs, environments) | Named projects with different setup files                                       |
| **BrowserStack / Sauce Labs** | Browsers + devices + OS combinations       | Cloud farms running tests across hundreds of targets                            |
| **Termless cross-backend**    | Terminal emulator VT parsers               | Same 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.
