A flowtty app renders into a Backend, and TestBackend is one that keeps the
frames in memory. No terminal, no snapshots of escape codes — a frame is a string,
and a cell is a character with a style.
import { render, Text, useInput } from '@flowtty/react';
import { TestBackend, flush } from '@flowtty/core/testing';
function Counter() {
const [n, setN] = useState(0);
useInput((key) => { if (key.name === 'up') setN((v) => v + 1); });
return <Text>{`count ${n}`}</Text>;
}
test('up increments', async () => {
const backend = new TestBackend(20, 3); // columns, rows
const app = await render(<Counter />, backend);
backend.press({ name: 'up' });
await flush();
expect(backend.lastFrame).toBe('count 1');
app.unmount();
});The examples use vitest; nothing here depends on it.
A key handler runs synchronously, but the repaint is coalesced into a microtask.
await flush()— drains microtasks. Enough after apresswhose handler only callssetState.await flushAsync(backend)— also lets effects and their follow-up renders settle: it keeps yielding until two rounds in a row add no new frame. Use it after mount, after anything that goes throughuseEffect(a form field registering, a component measuring itself withonLayout), and wheneverflush()leaves you with a stale frame. Always pass the backend: it is what makes the wait adaptive.await flushAsync()— the bare form waits a fixed amount (onesetTimeout(0)) instead of watching for frames. It cannot settle an effect cascade, and on a loaded machine it can return before the repaint it was meant to wait for, so a test written against it passes locally and fails now and then in CI. Use it only where there is genuinely no backend to watch.
For work on a real timer (a spinner, a streamed reply) wait for the text you expect rather than for a duration — see the script runner below.
backend.lastFrame— the last frame as text, rows joined by\n, trailing spaces and trailing blank rows trimmed.backend.frameshas every frame.backend.lastBuffer— the last frame's cells.get(x, y)returns{ char, style }, wherestylecarriesfg,bg,bold,dim,underline,inverse,strikethrough,link.
Assert on text for content and layout, on cells for styling:
expect(backend.lastFrame).toBe('▸ [ ] a\n [ ] b');
expect(backend.lastBuffer!.get(0, 0).style).toMatchObject({ fg: 'cyan', bold: true }); // the focused markerx in get(x, y) is a display column: a wide glyph (CJK, emoji) holds its
cell and the next, whose char is '' — see Display width.
backend.bells— how many times the app rang the bell;backend.notifications— every{ title, body? }it posted, in order, exactly as the app passed them (see Getting attention).
For a component that takes no input, there is a shorter way: renderToString
mounts it, waits for its effects and returns the frame — a snapshot with no
backend and no unmount to remember, as tall as the content, so nothing is cut off.
expect(await renderToString(<Report data={rows} />, { width: 60 })).toMatchSnapshot();See Rendering to a string. Reach for
TestBackend when the test presses keys.
A frame row that holds a wide glyph (日本) reads shorter than the grid is
wide: the glyph's second cell is '' and vanishes in the text, exactly as it
takes no extra column on screen (see Display width).
backend.press({ name: 'return' });
backend.press({ name: 'c', ctrl: true });
backend.type('hello'); // one key per character
backend.paste('two\nlines'); // ONE 'paste' key, as a terminal delivers it
backend.wheel('down', 10, 4); // a wheel notch at cell (10, 4)
backend.wheel('down', 10, 4, 3); // a flick: three notches as ONE key, as a TTY backend hands them over
backend.mouse('down', 4, 2); // press the left button at cell (4, 2)
backend.mouse('drag', 9, 2); // …drag across to (9, 2)…
backend.mouse('up', 9, 2); // …and release
backend.mouse('move', 6, 2); // move with no button held (hover)
backend.mouse('leave'); // the pointer leaves the windowbackend.mouse('move', x, y) moves the pointer with no button held (what
{ hover: true } reports); backend.mouse('leave') takes it out of the
window.
press() returns whether a useInput handler consumed the key (returned
true) — the answer a TTY backend acts on to skip its Ctrl+C / Ctrl+Z default;
see Keys and useInput. It throws on a name no terminal produces — 'space', 'enter', 'esc' —
and says what to use instead (' ', 'return', 'escape'). A printable key is
named by its character; the rest come from NAMED_KEYS. A test that presses an
impossible name exercises a branch real input never reaches, and would pass.
wheel() and mouse() default to cell (0, 0). A <ScrollBox> only reacts while
the pointer is over it, so pass coordinates inside the box unless it sits at the
origin. wheel() takes a fourth argument, the count of notches the key stands
for (default 1). mouse() takes a fourth argument, { button, shift, meta, ctrl, clicks } —
button defaults to 'left', and clicks: 2 (or 3) on a 'down' is the
double-click (triple-click) a TTY backend would have counted. A drag is a 'down', one 'drag' per cell
crossed and an 'up', which is how a terminal reports one; see
Paste and the mouse.
A drag also selects and copies. The cells it covered come back with inverse
toggled in lastBuffer; the text lands in backend.clipboard and goes to the
onCopy render option, which a test can pass a spy to:
const onCopy = vi.fn();
const backend = new TestBackend(20, 3);
await render(<App />, backend, { onCopy });
backend.mouse('down', 0, 0);
backend.mouse('drag', 4, 0);
backend.mouse('up', 4, 0);
await flush();
expect(backend.clipboard).toEqual(['hello']);
expect(onCopy).toHaveBeenCalledWith({ text: 'hello', delivered: true, source: 'selection' });backend.clipboard records the text exactly as the app passed it — encoding and
the size cap are a TTY backend's job. Set backend.clipboardAvailable = false to
stand in for a terminal with no clipboard sequence: copy() then refuses,
nothing is recorded, and onCopy still fires with delivered: false — which is
how an app's pbcopy fallback gets tested. See Selection
and The clipboard.
A hand-over of the terminal (useApp().suspend(fn) — an editor, a pager) is
recorded the same way: backend.suspended is true while fn runs, and
backend.suspensions counts the hand-overs. Nothing is written anywhere. See
Handing the terminal over.
The selection API works against a TestBackend too: handle.select(…),
selectWord, selectLine return the text and put the highlight on the frame,
and onCopy fires with source: 'api'. See
Selecting from code.
TestBackend never answers the light-or-dark question by itself — a component
sees 'unknown', as it would in a terminal that does not answer. Set the answer
to test the other branches:
backend.setColorScheme('dark'); // { scheme: 'dark' }, every useColorScheme() re-renders
backend.setColorScheme('light', '#fdf6e3'); // …with the background the terminal would have reportedSetting what is already set tells no one, as a TTY backend tells no one about an unchanged reply. See The color scheme.
For end-to-end tests, wrap the backend and feed it a script. The showcase in
packages/examples/showcase does this, and its director.ts is small enough to
copy:
ScriptedBackendwraps anyBackend. Drawing and size pass through;onKeydelivers the real keyboard and keys injected withinject(). It also keeps the last frame's text.play(backend, steps, { speed })runs a list of steps —type,press,paste,wheel,mouse,wait, andwaitFor(text), which blocks until the frame shows that text and throws, with the frame, if it never does.
const inner = new TestBackend(100, 30);
const backend = new ScriptedBackend(inner);
const app = await render(<App />, backend);
await play(backend, [type('Ada'), press('tab'), press('return'), waitFor('Saved ✓')], { speed: Infinity });
expect(inner.lastFrame).toContain('"Ada"');Because the wrapper is just a Backend, the same script runs against a real
TtyBackend — that is how the showcase plays itself, and how its recording is
made from the very steps its tests run.