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;
}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,
};
}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: () => {},
// ...
};
}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: () => {},
};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
});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']);
});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;
}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
});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.