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>
256 lines
8.2 KiB
TypeScript
256 lines
8.2 KiB
TypeScript
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([" |