The High Quality Terminal UI Cookbook

Chapter 9 of 11

Testing a terminal UI

Rendering headlessly, asserting on cells rather than screenshots, and the traps.

A terminal UI is unusually easy to test, because the output is a grid of characters and the renderer is a pure function of your state. There is no browser, no pseudo-terminal, and no waiting.

Render to a string

import { renderToText } from "@profullstack/hqtui";

const screen = renderToText(({ ui }) => dashboard(ui, state), { width: 80, height: 24 });
expect(screen).toContain("postgres");

That is the whole setup. renderToText runs the same code path the real application does, so a layout bug shows up here exactly as it would on screen.

For anything beyond substring matching, take the screen object:

import { renderToScreen } from "@profullstack/hqtui";

const screen = renderToScreen(view, { width: 80, height: 24 });

screen.text();          // the whole grid, trailing spaces trimmed
screen.line(3);         // one row
screen.find("CPU");     // { x, y } or null
screen.cell(4, 3);      // { char, fg, bg, attrs }
screen.regions;         // the mouse regions widgets registered

Assert on meaning, not on pixels

The temptation is to snapshot the whole screen. Resist it for anything but the smallest components. A full-screen snapshot fails when someone changes a padding, so it gets regenerated without being read, and then it is not a test.

Prefer assertions that say what you meant:

// The selected row is highlighted, wherever it ended up.
const found = screen.find("postgres")!;
expect(screen.cell(0, found.y).bg).not.toBe(screen.cell(0, found.y + 1).bg);

// The value is right-aligned in its column.
expect(screen.line(2).endsWith("4.2 KB")).toBe(true);

// The wheel can actually reach the table.
expect(screen.regions.some((r) => r.id === "procs")).toBe(true);

That last one is worth calling out. A scroll handler that is never registered because the widget was drawn in a zero-height region is invisible in a text snapshot and obvious in regions.

Colour and attributes

cell() gives you the resolved colour, so you can assert that a value went red without asserting on escape sequences:

const cell = screen.cell(screen.find("98%")!.x, screen.find("98%")!.y);
expect(cell.fg).toBe(theme.danger);

Compare against the theme's colour rather than a literal. A test containing 0xff5555 is a test that fails when the palette is adjusted, for no reason.

Animation and frames

import { renderFrames } from "@profullstack/hqtui";

const frames = renderFrames(view, 10, { width: 40, height: 8 });
expect(frames[0].text()).not.toBe(frames[9].text());

The frame number is passed to your view, so anything driven by it is reproducible: frame 7 is always the same picture. Do not test animation by sleeping.

Degraded terminals

Force the capabilities rather than hoping:

const ascii = renderToText(view, {
  width: 80, height: 24,
  capabilities: { unicode: false, braille: false, colors: "16" },
});
expect(ascii).not.toMatch(/[⠀-⣿]/);   // no Braille survived

One test like this per project catches the whole class of "it is mojibake over SSH from the build box".

The traps

Height matters more than you think. A widget in a region of zero rows draws nothing and throws nothing. If an assertion fails because the text is not there, print screen.text() before assuming the widget is broken; nine times out of ten the layout gave it no room.

Trailing spaces are trimmed by text() and not by line(). Comparing a whole screen against a template literal fails on invisible whitespace forever. Use toContain, or trim both sides deliberately.

A wide character is two cells. screen.cell(x, y) on the second half of a CJK glyph or an emoji returns an empty char, not the character. That is correct, and it means indexing by character offset into a line with emoji in it will drift.

The theme is resolved per render. If you pass theme: "light" to one call and compare colours against the dark theme's constants, everything fails in a confusing way. Resolve the theme once in the test and use that object.