Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part IV: 81 Tests, Zero Terminals

The old workflow.ts was untestable. Every function called console.log with hardcoded ANSI codes, execSync with stdio: 'inherit', and process.stdout.write directly. The only way to "test" it was to run it in a terminal and look at the output.

The new architecture has 81 unit tests that run in 600ms. Zero real processes spawned. Zero terminal required. Zero file I/O.

How? Two abstractions: ConsoleIO for output and mock callbacks for everything else.

The ConsoleIO Interface

All CLI output goes through this interface — nothing writes to process.stdout directly:

export interface ConsoleIO {
  // Output
  write(text: string): void;
  writeLine(text: string): void;

  // Colors (no-op when disabled)
  red(s: string): string;
  green(s: string): string;
  cyan(s: string): string;
  bold(s: string): string;
  dim(s: string): string;

  // Symbols (ASCII fallback when no unicode)
  dot(running: boolean): string;    // ● / ○  or  * / o
  check(): string;                  // ✓ or OK
  cross(): string;                  // ✗ or FAIL
  spinner(frame: number): string;   // ⠋⠙⠹... or -\|/

  // Cursor (no-op when no TTY)
  hideCursor(): void;
  showCursor(): void;
  moveTo(row: number, col: number): void;

  // Capabilities
  readonly caps: ConsoleCapabilities;
}

This follows the same DI pattern we use in build-static.ts, which has an interface IO with readFile, writeFile, log, warn — all injected, all mockable.

Production: createConsoleIO()

Detects terminal capabilities at startup and wraps process.stdout:

function detectCapabilities(): ConsoleCapabilities {
  const tty = process.stdout.isTTY ?? false;
  const noColor = !!process.env['NO_COLOR'];
  return {
    color: tty && !noColor,
    unicode: tty,
    cursor: tty,
    scrollRegion: tty,
    interactive: process.stdin.isTTY ?? false,
  };
}

When color is false, io.red('text') returns 'text' unchanged. When unicode is false, io.check() returns 'OK' instead of '✓'. When cursor is false, io.hideCursor() is a no-op.

This handles: NO_COLOR env, piped output (npm run work | cat), CI environments, Windows legacy console, and SSH sessions — all from one detection point.

Test: createTestConsole(output)

Captures everything into a string array. No ANSI. No cursor movement. No process access:

export function createTestConsole(output?: string[]): ConsoleIO {
  const lines = output ?? [];
  return {
    write(text) { lines.push(text); },
    writeLine(text) { lines.push(text); },
    red: (s) => s,      // identity — no color codes
    green: (s) => s,
    cyan: (s) => s,
    dot(running) { return running ? '*' : 'o'; },
    check() { return 'OK'; },
    cross() { return 'FAIL'; },
    // cursor methods are all no-ops
    hideCursor: () => {},
    showCursor: () => {},
    // ...
  };
}

Testing State Machines: Mock Callbacks

The machines don't know about ConsoleIO. They receive callbacks. In tests, the callbacks are simple recorders:

const nullCallbacks: RunnerCallbacks = {
  onStateChange: () => {},
  spawn: () => {},
  writeOutput: () => {},
  updateStatusLine: () => {},
  showResult: () => {},
  killProcess: () => {},
};

Override only what you're testing:

it('idle -> running on start', () => {
  const spawned: string[] = [];
  const machine = createRunnerMachine({
    ...nullCallbacks,
    spawn: (cmd) => spawned.push(cmd),
  });

  machine.start('npx vitest run', 'test unit');
  expect(machine.getState()).toBe('running');
  expect(spawned).toEqual(['npx vitest run']);
});

it('ignores stdout when idle (guard clause)', () => {
  const output: string[] = [];
  const machine = createRunnerMachine({
    ...nullCallbacks,
    writeOutput: (c) => output.push(c),
  });

  machine.stdout('should be ignored');
  expect(output).toEqual([]);  // guard prevented callback
});

No process. No terminal. No timer. Just send events and assert state + callbacks.

Testing Pure Functions: Zero Mocks

The purest layer needs no mocks at all:

import { resolveFlags } from '../commands/build';

it('all defaults -> empty flags', () => {
  expect(resolveFlags({})).toEqual([]);
});

it('--no-mermaid --no-images', () => {
  expect(resolveFlags({ noMermaid: true, noImages: true }))
    .toEqual(['--no-images', '--no-mermaid']);
});

Five lines per test. No setup, no teardown, no mocks.

Testing Config: Mock Filesystem

The config module also uses DI — a ConfigFS interface:

export interface ConfigFS {
  readFile: (p: string) => string;
  writeFile: (p: string, data: string) => void;
  exists: (p: string) => boolean;
}

Tests inject a mock filesystem:

function mockFS(content?: string): ConfigFS {
  return {
    readFile: () => content ?? '',
    writeFile: (_, data) => { /* capture */ },
    exists: () => content !== undefined,
  };
}

it('merges partial config over defaults', () => {
  const fs = mockFS(JSON.stringify({ serve: { devPort: 9999 } }));
  const config = loadConfig(fs);
  expect(config.serve.devPort).toBe(9999);
  expect(config.serve.staticPort).toBe(4000);  // default preserved
});

The Numbers

What Count
Test files 9
Total tests 81
Duration ~600ms
Real processes spawned 0
Files read from disk 0
Terminal required No

The payoff: refactoring is safe. Change a machine, a callback, a renderer — if you break something, a test catches it in under a second. The old 1270-line file? Change one line and pray.


Next: Part V: Commander.js as a Thin Shell — Pure functions, DI composition root, and why the CLI framework is the thinnest layer.