Files
fusion/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts
gsxdsm b890c57a29 feat(cli): merge @fusion/tui into fn dashboard with Ink TUI
Replace the legacy ANSI-based DashboardTUI with an Ink/React rewrite
under packages/cli/src/commands/dashboard-tui/, delete the standalone
@fusion/tui package, and make `fn` (no args) launch the dashboard.

The new TUI keeps the existing 5-panel status mode (system, logs,
utilities, stats, settings) but adds an interactive mode (b/a/g) with
three views: a kanban board with project picker and per-task detail,
an agents list+detail with state management, and a settings editor.
Bordered focus-aware panels, solid-background help overlay, static
all-blue FUSION splash that adapts to small terminals. DashboardTUI
and DashboardLogSink public API are unchanged so dashboard.ts only
needed import-path updates plus interactiveData/loadingStatus wiring.

Also adds zod to @fusion/dashboard to satisfy a peer dep introduced
by pi-coding-agent 0.70.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

256 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { DashboardLogSink, formatConsoleArgs } from "../log-sink.js";
import { DashboardTUI } from "../controller.js";
import type { LogRingBuffer } from "../log-ring-buffer.js";
import { createLogger } from "@fusion/engine";
// ── DashboardLogSink Tests ─────────────────────────────────────────────────
describe("DashboardLogSink", () => {
it("logs to console in non-TTY mode", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const sink = new DashboardLogSink();
sink.log("test message");
expect(consoleLogSpy).toHaveBeenCalledWith("test message");
consoleLogSpy.mockRestore();
});
it("includes prefix in non-TTY mode", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const sink = new DashboardLogSink();
sink.log("test message", "dashboard");
expect(consoleLogSpy).toHaveBeenCalledWith("[dashboard] test message");
consoleLogSpy.mockRestore();
});
it("warns to console.warn in non-TTY mode", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const sink = new DashboardLogSink();
sink.warn("warning message");
expect(consoleWarnSpy).toHaveBeenCalledWith("warning message");
consoleWarnSpy.mockRestore();
});
it("errors to console.error in non-TTY mode", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const sink = new DashboardLogSink();
sink.error("error message");
expect(consoleErrorSpy).toHaveBeenCalledWith("error message");
consoleErrorSpy.mockRestore();
});
it("handles empty message", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const sink = new DashboardLogSink();
sink.log("");
expect(consoleLogSpy).toHaveBeenCalledWith("");
consoleLogSpy.mockRestore();
});
});
// ── DashboardLogSink.captureConsole ─────────────────────────────────────────
describe("DashboardLogSink.captureConsole", () => {
let originalLog: typeof console.log;
let originalWarn: typeof console.warn;
let originalError: typeof console.error;
beforeEach(() => {
originalLog = console.log;
originalWarn = console.warn;
originalError = console.error;
});
afterEach(() => {
console.log = originalLog;
console.warn = originalWarn;
console.error = originalError;
});
it("routes console.log to sink.log, splitting a [prefix] tag", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
console.log("[executor] task moved to in-progress");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries).toHaveLength(1);
expect(entries[0].level).toBe("info");
expect(entries[0].prefix).toBe("executor");
expect(entries[0].message).toBe("task moved to in-progress");
sink.releaseConsole();
});
it("routes console.warn to sink.warn and preserves level", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
console.warn("[merger] retry 2/3");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries[0].level).toBe("warn");
expect(entries[0].prefix).toBe("merger");
sink.releaseConsole();
});
it("keeps raw console.error lines as error", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
console.error("[scheduler] could not claim lease");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries[0].level).toBe("error");
expect(entries[0].prefix).toBe("scheduler");
expect(entries[0].message).toBe("could not claim lease");
sink.releaseConsole();
});
it("treats structured logger.log routed via console.error as info", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
const logger = createLogger("executor");
logger.log("task moved to in-progress");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries).toHaveLength(1);
expect(entries[0].level).toBe("info");
expect(entries[0].prefix).toBe("executor");
expect(entries[0].message).toBe("task moved to in-progress");
sink.releaseConsole();
});
it("keeps structured logger.error as error", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
const logger = createLogger("executor");
logger.error("task failed");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries).toHaveLength(1);
expect(entries[0].level).toBe("error");
expect(entries[0].prefix).toBe("executor");
expect(entries[0].message).toBe("task failed");
sink.releaseConsole();
});
it("handles untagged messages without a prefix", () => {
const tui = new DashboardTUI();
const sink = new DashboardLogSink();
sink.setTUI(tui);
sink.captureConsole();
console.log("a raw line with no bracket tag");
const entries = (tui as unknown as {
logBuffer: LogRingBuffer;
}).logBuffer.getAll();
expect(entries[0].prefix).toBeUndefined();
expect(entries[0].message).toBe("a raw line with no bracket tag");
sink.releaseConsole();
});
it("releaseConsole restores the original console functions", () => {
const sink = new DashboardLogSink();
sink.captureConsole();
expect(console.log).not.toBe(originalLog);
expect(console.warn).not.toBe(originalWarn);
expect(console.error).not.toBe(originalError);
sink.releaseConsole();
expect(console.log).toBe(originalLog);
expect(console.warn).toBe(originalWarn);
expect(console.error).toBe(originalError);
});
it("captureConsole is idempotent (calling twice does not double-wrap)", () => {
const sink = new DashboardLogSink();
sink.captureConsole();
const firstPatched = console.log;
sink.captureConsole();
expect(console.log).toBe(firstPatched);
sink.releaseConsole();
});
});
// ── formatConsoleArgs ─────────────────────────────────────────────────────────
describe("formatConsoleArgs", () => {
it("joins multiple args with a space", () => {
const { message, prefix, level } = formatConsoleArgs(["hello", "world"]);
expect(prefix).toBeUndefined();
expect(level).toBe("info");
expect(message).toBe("hello world");
});
it("extracts a leading [prefix] tag", () => {
const { message, prefix, level } = formatConsoleArgs(["[executor] starting task FN-123"], "warn");
expect(prefix).toBe("executor");
expect(level).toBe("warn");
expect(message).toBe("starting task FN-123");
});
it("strips the internal severity marker and returns explicit level", () => {
const { message, prefix, level } = formatConsoleArgs(["fnlvl=error[executor] task failed"], "info");
expect(prefix).toBe("executor");
expect(level).toBe("error");
expect(message).toBe("task failed");
});
it("stringifies objects via JSON", () => {
const { message } = formatConsoleArgs(["result:", { ok: true, count: 3 }]);
expect(message).toBe('result: {"ok":true,"count":3}');
});
it("uses error.stack when given an Error", () => {
const err = new Error("boom");
const { message } = formatConsoleArgs([err]);
expect(message).toContain("boom");
});
it("falls back to String() for circular objects", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const { message } = formatConsoleArgs([circular]);
expect(typeof message).toBe("string");
});
});