flagstaff
Recipes

Testing terminal output

Assert a spinner's terminal output byte for byte with manualClock and a literal runtime — no fake timers, no terminal.

The loop takes its clock and its streams from the runtime, so a test can hand it a clock that only moves when told and streams that collect what is written. The terminal transcript is then a fixed string.

spinner-output.mjs
import assert from 'node:assert/strict';

import { hoist, manualClock } from 'flagstaff/loop';
import { spinner } from 'flagstaff/spinner';

function world({ tty }) {
  const out = [];
  const clock = manualClock();
  const rt = { env: {}, isTTY: { stdout: tty }, stdout: { write: (s) => out.push(s) }, stderr: { write: () => {} }, clock };
  return { rt, clock, text: () => out.join('') };
}

const terminal = world({ tty: true });
const flag = hoist(spinner(), terminal.rt, { text: 'building' });
terminal.clock.tick(170);
flag.lower({ text: 'built', status: 'ok' });
assert.equal(terminal.text(), '\u001B[?25l⠋ building\u001B[1G\u001B[0J⠙ building\u001B[1G\u001B[0J⠹ building\u001B[1G\u001B[0J✔ built\n\u001B[?25h');

const pipe = world({ tty: false });
hoist(spinner(), pipe.rt, { text: 'building' }).lower({ text: 'built', status: 'ok' });
assert.equal(pipe.text(), '… building\n✔ built\n');

console.log('ok');
node spinner-output.mjs
ok

tick(170) runs every frame due by 170 ms — the dots style repaints every 80 — and nothing else. flagstaff's own suite asserts the same transcript twenty runs over (loop.test.ts). Set env: { CI: 'true' } or { CLI_ACCESSIBLE: '1' } to test the other modes, or pass { json: true } to hoist() and read stderr.