feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @file Tests for agentOriginLabel - verifies the parent-chain walk that
|
||||
* renders nested subagent attribution as "main › coder › explorer", so
|
||||
* tool events triggered by deeply nested subagents identify their full
|
||||
* lineage instead of collapsing to just the leaf agent.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { agentOriginLabel, projectFromCwd, type AgentInfo } from "../event-grouping";
|
||||
|
||||
function makeMap(entries: Array<[string, AgentInfo]>): Map<string, AgentInfo> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
describe("agentOriginLabel - parent chain walk", () => {
|
||||
it("returns 'main' for the main agent itself", () => {
|
||||
const map = makeMap([
|
||||
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
|
||||
]);
|
||||
expect(agentOriginLabel("sess-main", map)).toBe("main");
|
||||
});
|
||||
|
||||
it("renders 'main › coder' for a direct child of main", () => {
|
||||
const map = makeMap([
|
||||
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
|
||||
[
|
||||
"sub-coder",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: "coder",
|
||||
name: "Coder",
|
||||
parent_agent_id: "sess-main",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(agentOriginLabel("sub-coder", map)).toBe("main › coder");
|
||||
});
|
||||
|
||||
it("renders 'main › coder › explorer' for a 2-deep subagent", () => {
|
||||
const map = makeMap([
|
||||
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
|
||||
[
|
||||
"sub-coder",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: "coder",
|
||||
name: "Coder",
|
||||
parent_agent_id: "sess-main",
|
||||
},
|
||||
],
|
||||
[
|
||||
"sub-explorer",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: "explorer",
|
||||
name: "Explorer",
|
||||
parent_agent_id: "sub-coder",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(agentOriginLabel("sub-explorer", map)).toBe("main › coder › explorer");
|
||||
});
|
||||
|
||||
it("falls back to single-segment when the map is undefined (legacy callers)", () => {
|
||||
const info: AgentInfo = {
|
||||
type: "subagent",
|
||||
subagent_type: "coder",
|
||||
name: "Coder",
|
||||
parent_agent_id: "sess-main",
|
||||
};
|
||||
expect(agentOriginLabel("sub-coder", info)).toBe("coder");
|
||||
});
|
||||
|
||||
it("uses subagent name when subagent_type is null", () => {
|
||||
const map = makeMap([
|
||||
["sess-main", { type: "main", subagent_type: null, name: "Main", parent_agent_id: null }],
|
||||
[
|
||||
"sub-1",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: null,
|
||||
name: "Helper",
|
||||
parent_agent_id: "sess-main",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(agentOriginLabel("sub-1", map)).toBe("main › Helper");
|
||||
});
|
||||
|
||||
it("breaks cleanly on a parent-chain cycle", () => {
|
||||
// Pathological: a → b → a. Should not loop forever.
|
||||
const map = makeMap([
|
||||
[
|
||||
"a",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: "a",
|
||||
name: "A",
|
||||
parent_agent_id: "b",
|
||||
},
|
||||
],
|
||||
[
|
||||
"b",
|
||||
{
|
||||
type: "subagent",
|
||||
subagent_type: "b",
|
||||
name: "B",
|
||||
parent_agent_id: "a",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const result = agentOriginLabel("a", map);
|
||||
expect(result).toBe("b › a");
|
||||
});
|
||||
|
||||
it("returns null for null agentId regardless of map shape", () => {
|
||||
expect(agentOriginLabel(null, makeMap([]))).toBeNull();
|
||||
expect(agentOriginLabel(null, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to 'main' for IDs ending in -main when no info is available", () => {
|
||||
expect(agentOriginLabel("session-xyz-main", makeMap([]))).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectFromCwd - session-cwd fallback for the dir prefix", () => {
|
||||
it("returns the last path segment of a POSIX cwd", () => {
|
||||
expect(projectFromCwd("/Users/dev/WebstormProjects/Claude-Code-Agent-Monitor")).toBe(
|
||||
"Claude-Code-Agent-Monitor"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a trailing slash", () => {
|
||||
expect(projectFromCwd("/Users/dev/my-app/")).toBe("my-app");
|
||||
});
|
||||
|
||||
it("handles Windows backslash paths", () => {
|
||||
expect(projectFromCwd("C:\\Users\\dev\\my-app")).toBe("my-app");
|
||||
});
|
||||
|
||||
it("returns null for null, undefined, or empty cwd", () => {
|
||||
expect(projectFromCwd(null)).toBeNull();
|
||||
expect(projectFromCwd(undefined)).toBeNull();
|
||||
expect(projectFromCwd("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file dataScope.test.ts
|
||||
* @description Unit tests for the global data-scope store (which source machines
|
||||
* the dashboard shows). Covers persistence, the localStorage → query-param
|
||||
* mapping consumed by the API layer, subscriber notification, and malformed /
|
||||
* empty input handling. Each case re-imports the module so the singleton starts
|
||||
* clean.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const STORAGE_KEY = "ccam-data-scope";
|
||||
|
||||
// Fresh module instance (resets the module-level `current`) with localStorage
|
||||
// pre-seeded, so we can test load-from-storage behavior deterministically.
|
||||
async function freshModule(seed?: unknown) {
|
||||
localStorage.clear();
|
||||
if (seed !== undefined) localStorage.setItem(STORAGE_KEY, JSON.stringify(seed));
|
||||
vi.resetModules();
|
||||
return import("../dataScope");
|
||||
}
|
||||
|
||||
describe("dataScope store", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("defaults to scope 'all' (no filter) when nothing is stored", async () => {
|
||||
const m = await freshModule();
|
||||
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
|
||||
expect(m.activeSourcesParam()).toBeNull();
|
||||
});
|
||||
|
||||
it("activeSourcesParam maps each mode correctly", async () => {
|
||||
const m = await freshModule();
|
||||
m.setScope({ mode: "all", selected: [] });
|
||||
expect(m.activeSourcesParam()).toBeNull();
|
||||
m.setScope({ mode: "local", selected: [] });
|
||||
expect(m.activeSourcesParam()).toBe("local");
|
||||
m.setScope({ mode: "selected", selected: ["local", "src_abc"] });
|
||||
expect(m.activeSourcesParam()).toBe("local,src_abc");
|
||||
});
|
||||
|
||||
it("'selected' with an empty selection degrades to local-only (never empty app)", async () => {
|
||||
const m = await freshModule();
|
||||
m.setScope({ mode: "selected", selected: [] });
|
||||
expect(m.activeSourcesParam()).toBe("local");
|
||||
});
|
||||
|
||||
it("persists to localStorage and reloads on next module init", async () => {
|
||||
const m1 = await freshModule();
|
||||
m1.setScope({ mode: "selected", selected: ["src_1", "src_2"] });
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
|
||||
mode: "selected",
|
||||
selected: ["src_1", "src_2"],
|
||||
});
|
||||
// A fresh init (new tab) should read the persisted value back.
|
||||
const m2 = await import("../dataScope").then(async () => {
|
||||
vi.resetModules();
|
||||
return import("../dataScope");
|
||||
});
|
||||
expect(m2.getScope()).toEqual({ mode: "selected", selected: ["src_1", "src_2"] });
|
||||
});
|
||||
|
||||
it("notifies subscribers on change and stops after unsubscribe", async () => {
|
||||
const m = await freshModule();
|
||||
const cb = vi.fn();
|
||||
const unsub = m.subscribeScope(cb);
|
||||
m.setScope({ mode: "local", selected: [] });
|
||||
m.setScope({ mode: "all", selected: [] });
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
unsub();
|
||||
m.setScope({ mode: "local", selected: [] });
|
||||
expect(cb).toHaveBeenCalledTimes(2); // no further calls
|
||||
});
|
||||
|
||||
it("getScope returns a stable reference between changes (for useSyncExternalStore)", async () => {
|
||||
const m = await freshModule();
|
||||
const a = m.getScope();
|
||||
const b = m.getScope();
|
||||
expect(a).toBe(b);
|
||||
m.setScope({ mode: "local", selected: [] });
|
||||
expect(m.getScope()).not.toBe(a);
|
||||
});
|
||||
|
||||
it("setScope copies the selected array (no external mutation leaks in)", async () => {
|
||||
const m = await freshModule();
|
||||
const sel = ["src_1"];
|
||||
m.setScope({ mode: "selected", selected: sel });
|
||||
sel.push("src_2");
|
||||
expect(m.getScope().selected).toEqual(["src_1"]);
|
||||
});
|
||||
|
||||
it("tolerates malformed stored JSON and falls back to the default", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, "{not valid json");
|
||||
vi.resetModules();
|
||||
const m = await import("../dataScope");
|
||||
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
|
||||
});
|
||||
|
||||
it("sanitizes an unknown mode / non-array selected from storage", async () => {
|
||||
const m = await freshModule({ mode: "bogus", selected: "nope" });
|
||||
expect(m.getScope()).toEqual({ mode: "all", selected: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file eventBus.test.ts
|
||||
* @description Unit tests for the eventBus module to ensure correct subscription, publishing, and unsubscription behavior in the agent dashboard application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { eventBus } from "../eventBus";
|
||||
import type { WSMessage } from "../types";
|
||||
|
||||
function makeMsg(type: WSMessage["type"] = "new_event"): WSMessage {
|
||||
return {
|
||||
type,
|
||||
data: {
|
||||
id: 1,
|
||||
session_id: "s1",
|
||||
agent_id: null,
|
||||
event_type: "PreToolUse",
|
||||
tool_name: "Bash",
|
||||
summary: "test",
|
||||
data: null,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("eventBus", () => {
|
||||
it("should call subscriber when message is published", () => {
|
||||
const handler = vi.fn();
|
||||
const unsub = eventBus.subscribe(handler);
|
||||
|
||||
const msg = makeMsg();
|
||||
eventBus.publish(msg);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(msg);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("should support multiple subscribers", () => {
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
const u1 = eventBus.subscribe(h1);
|
||||
const u2 = eventBus.subscribe(h2);
|
||||
|
||||
eventBus.publish(makeMsg());
|
||||
|
||||
expect(h1).toHaveBeenCalledTimes(1);
|
||||
expect(h2).toHaveBeenCalledTimes(1);
|
||||
|
||||
u1();
|
||||
u2();
|
||||
});
|
||||
|
||||
it("should stop calling handler after unsubscribe", () => {
|
||||
const handler = vi.fn();
|
||||
const unsub = eventBus.subscribe(handler);
|
||||
|
||||
eventBus.publish(makeMsg());
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsub();
|
||||
|
||||
eventBus.publish(makeMsg());
|
||||
expect(handler).toHaveBeenCalledTimes(1); // still 1
|
||||
});
|
||||
|
||||
it("should not fail when publishing with no subscribers", () => {
|
||||
expect(() => eventBus.publish(makeMsg())).not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle unsubscribe called multiple times", () => {
|
||||
const handler = vi.fn();
|
||||
const unsub = eventBus.subscribe(handler);
|
||||
|
||||
unsub();
|
||||
unsub(); // second call should be harmless
|
||||
|
||||
eventBus.publish(makeMsg());
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not add duplicate handler references", () => {
|
||||
const handler = vi.fn();
|
||||
const u1 = eventBus.subscribe(handler);
|
||||
const u2 = eventBus.subscribe(handler); // same ref, Set deduplicates
|
||||
|
||||
eventBus.publish(makeMsg());
|
||||
expect(handler).toHaveBeenCalledTimes(1); // Set prevents double-add
|
||||
|
||||
u1();
|
||||
u2();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file format.test.ts
|
||||
* @description Unit tests for the format utility functions to ensure correct formatting of durations, time ago, truncation, and locale-aware date/time in the agent dashboard application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
formatMs,
|
||||
formatDuration,
|
||||
timeAgo,
|
||||
truncate,
|
||||
fmt,
|
||||
fmtCost,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
getCurrentLocale,
|
||||
formatModelName,
|
||||
} from "../format";
|
||||
|
||||
describe("formatMs", () => {
|
||||
it("should return 0s for negative values", () => {
|
||||
expect(formatMs(-1000)).toBe("0s");
|
||||
expect(formatMs(-1)).toBe("0s");
|
||||
});
|
||||
|
||||
it("should return 0s for zero", () => {
|
||||
expect(formatMs(0)).toBe("0s");
|
||||
});
|
||||
|
||||
it("should format seconds only", () => {
|
||||
expect(formatMs(1000)).toBe("1s");
|
||||
expect(formatMs(5000)).toBe("5s");
|
||||
expect(formatMs(59000)).toBe("59s");
|
||||
});
|
||||
|
||||
it("should format minutes and seconds", () => {
|
||||
expect(formatMs(60000)).toBe("1m 0s");
|
||||
expect(formatMs(90000)).toBe("1m 30s");
|
||||
expect(formatMs(125000)).toBe("2m 5s");
|
||||
expect(formatMs(3599000)).toBe("59m 59s");
|
||||
});
|
||||
|
||||
it("should format hours and minutes", () => {
|
||||
expect(formatMs(3600000)).toBe("1h 0m");
|
||||
expect(formatMs(5400000)).toBe("1h 30m");
|
||||
expect(formatMs(7260000)).toBe("2h 1m");
|
||||
});
|
||||
|
||||
it("should truncate sub-second precision", () => {
|
||||
expect(formatMs(1500)).toBe("1s");
|
||||
expect(formatMs(999)).toBe("0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("should compute duration between two ISO strings", () => {
|
||||
const start = "2026-03-05T10:00:00.000Z";
|
||||
const end = "2026-03-05T10:05:30.000Z";
|
||||
expect(formatDuration(start, end)).toBe("5m 30s");
|
||||
});
|
||||
|
||||
it("should handle zero duration", () => {
|
||||
const t = "2026-03-05T10:00:00.000Z";
|
||||
expect(formatDuration(t, t)).toBe("0s");
|
||||
});
|
||||
|
||||
it("should handle long durations", () => {
|
||||
const start = "2026-03-05T10:00:00.000Z";
|
||||
const end = "2026-03-05T12:30:00.000Z";
|
||||
expect(formatDuration(start, end)).toBe("2h 30m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeAgo", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return "just now" for recent times', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-05T10:00:30Z"));
|
||||
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("just now");
|
||||
});
|
||||
|
||||
it("should return minutes ago", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-05T10:05:00Z"));
|
||||
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("should return hours ago", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-05T13:00:00Z"));
|
||||
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("3h ago");
|
||||
});
|
||||
|
||||
it("should return days ago", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T10:00:00Z"));
|
||||
expect(timeAgo("2026-03-05T10:00:00Z")).toBe("2d ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locale-aware date formatting", () => {
|
||||
it("should map selected language to the expected locale", async () => {
|
||||
await i18n.changeLanguage("vi");
|
||||
expect(getCurrentLocale()).toBe("vi-VN");
|
||||
|
||||
await i18n.changeLanguage("en");
|
||||
expect(getCurrentLocale()).toBe("en-US");
|
||||
|
||||
// A language the dashboard no longer ships falls back to en-US.
|
||||
await i18n.changeLanguage("ko");
|
||||
expect(getCurrentLocale()).toBe("en-US");
|
||||
});
|
||||
|
||||
it("should format date-time using the active locale", async () => {
|
||||
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockReturnValue("formatted-datetime");
|
||||
await i18n.changeLanguage("vi");
|
||||
|
||||
expect(formatDateTime("2026-03-05T10:00:00.000Z")).toBe("formatted-datetime");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"vi-VN",
|
||||
expect.objectContaining({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should format time using the active locale", async () => {
|
||||
const spy = vi.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("formatted-time");
|
||||
await i18n.changeLanguage("vi");
|
||||
|
||||
expect(formatTime("2026-03-05T10:00:00.000Z")).toBe("formatted-time");
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"vi-VN",
|
||||
expect.objectContaining({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncate", () => {
|
||||
it("should return string unchanged when shorter than max", () => {
|
||||
expect(truncate("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
it("should return string unchanged when exactly max length", () => {
|
||||
expect(truncate("hello", 5)).toBe("hello");
|
||||
});
|
||||
|
||||
it("should truncate and add ellipsis when longer than max", () => {
|
||||
expect(truncate("hello world", 8)).toBe("hello w\u2026");
|
||||
});
|
||||
|
||||
it("should handle max of 1", () => {
|
||||
expect(truncate("hello", 1)).toBe("\u2026");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(truncate("", 5)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmt", () => {
|
||||
it("should return raw number below 1000", () => {
|
||||
expect(fmt(0)).toBe("0");
|
||||
expect(fmt(999)).toBe("999");
|
||||
});
|
||||
|
||||
it("should format thousands with K suffix", () => {
|
||||
expect(fmt(1000)).toBe("1.0K");
|
||||
expect(fmt(1957)).toBe("2.0K");
|
||||
expect(fmt(21986)).toBe("22.0K");
|
||||
});
|
||||
|
||||
it("should format millions with M suffix", () => {
|
||||
expect(fmt(1_000_000)).toBe("1.0M");
|
||||
expect(fmt(1_009_500_000)).toBe("1.0B");
|
||||
});
|
||||
|
||||
it("should format billions with B suffix", () => {
|
||||
expect(fmt(1_000_000_000)).toBe("1.0B");
|
||||
expect(fmt(2_500_000_000)).toBe("2.5B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtCost", () => {
|
||||
it("should format small costs with dollar sign", () => {
|
||||
expect(fmtCost(0)).toBe("$0.00");
|
||||
expect(fmtCost(833.97)).toBe("$833.97");
|
||||
expect(fmtCost(999.99)).toBe("$999.99");
|
||||
});
|
||||
|
||||
it("should format thousands with K suffix", () => {
|
||||
expect(fmtCost(1000)).toBe("$1.00K");
|
||||
expect(fmtCost(2500.5)).toBe("$2.50K");
|
||||
});
|
||||
|
||||
it("should format millions with M suffix", () => {
|
||||
expect(fmtCost(1_000_000)).toBe("$1.00M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatModelName", () => {
|
||||
it("returns null for falsy input", () => {
|
||||
expect(formatModelName(null)).toBeNull();
|
||||
expect(formatModelName(undefined)).toBeNull();
|
||||
expect(formatModelName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats Claude model names with version dots", () => {
|
||||
expect(formatModelName("claude-opus-4-7")).toBe("Claude Opus 4.7");
|
||||
expect(formatModelName("claude-sonnet-4-5")).toBe("Claude Sonnet 4.5");
|
||||
expect(formatModelName("claude-haiku-3-5")).toBe("Claude Haiku 3.5");
|
||||
});
|
||||
|
||||
it("strips date suffixes", () => {
|
||||
expect(formatModelName("claude-opus-4-7-20260101")).toBe("Claude Opus 4.7");
|
||||
expect(formatModelName("claude-sonnet-4-5-20250514")).toBe("Claude Sonnet 4.5");
|
||||
});
|
||||
|
||||
it("strips -latest suffix", () => {
|
||||
expect(formatModelName("claude-sonnet-4-5-latest")).toBe("Claude Sonnet 4.5");
|
||||
});
|
||||
|
||||
it("handles context-window [1m] tag", () => {
|
||||
expect(formatModelName("claude-opus-4-7[1m]")).toBe("Claude Opus 4.7 (1M)");
|
||||
expect(formatModelName("claude-opus-4-7-20260101[1m]")).toBe("Claude Opus 4.7 (1M)");
|
||||
});
|
||||
|
||||
it("formats GPT model names with hyphenated brand-version", () => {
|
||||
expect(formatModelName("gpt-4o")).toBe("GPT-4o");
|
||||
expect(formatModelName("gpt-4o-mini")).toBe("GPT-4o Mini");
|
||||
expect(formatModelName("gpt-4-turbo")).toBe("GPT-4 Turbo");
|
||||
});
|
||||
|
||||
it("formats Gemini model names", () => {
|
||||
expect(formatModelName("gemini-1-5-pro")).toBe("Gemini 1.5 Pro");
|
||||
});
|
||||
|
||||
it("strips provider prefix", () => {
|
||||
expect(formatModelName("anthropic/claude-opus-4-7")).toBe("Claude Opus 4.7");
|
||||
});
|
||||
|
||||
it("title-cases unknown models", () => {
|
||||
expect(formatModelName("o1-mini")).toBe("O1 Mini");
|
||||
expect(formatModelName("o1-preview")).toBe("O1 Preview");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file highlight.test.ts
|
||||
* @description Unit tests for the syntax highlighter used by the conversation viewer.
|
||||
* Ensures token classification stays stable for languages we actually render.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canonicalLang, highlight, tokenClass } from "../highlight";
|
||||
|
||||
function joinByType(tokens: { type: string; text: string }[], type: string): string {
|
||||
return tokens
|
||||
.filter((t) => t.type === type)
|
||||
.map((t) => t.text)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
describe("highlight()", () => {
|
||||
it("preserves the original source verbatim when concatenated", () => {
|
||||
const sources = [
|
||||
"const x = 42;\nconsole.log(x);",
|
||||
"def foo(a, b):\n return a + b\n",
|
||||
'{"a": 1, "b": [true, null]}',
|
||||
"echo $HOME && ls -la",
|
||||
];
|
||||
for (const src of sources) {
|
||||
const langs = ["js", "python", "json", "bash"];
|
||||
for (const lang of langs) {
|
||||
const out = highlight(src, lang)
|
||||
.map((t) => t.text)
|
||||
.join("");
|
||||
expect(out).toBe(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies JS keywords, strings, numbers, comments", () => {
|
||||
const tokens = highlight('const x = "hi"; // greet\nreturn 42;', "ts");
|
||||
expect(joinByType(tokens, "keyword")).toContain("const");
|
||||
expect(joinByType(tokens, "keyword")).toContain("return");
|
||||
expect(joinByType(tokens, "string")).toContain('"hi"');
|
||||
expect(joinByType(tokens, "comment")).toContain("// greet");
|
||||
expect(joinByType(tokens, "number")).toContain("42");
|
||||
});
|
||||
|
||||
it("classifies JSON keys as property and bare strings as string", () => {
|
||||
const tokens = highlight('{"name": "Son", "age": 30}', "json");
|
||||
expect(joinByType(tokens, "property")).toContain('"name"');
|
||||
expect(joinByType(tokens, "property")).toContain('"age"');
|
||||
expect(joinByType(tokens, "string")).toContain('"Son"');
|
||||
expect(joinByType(tokens, "number")).toContain("30");
|
||||
});
|
||||
|
||||
it("classifies bash builtins and variables", () => {
|
||||
const tokens = highlight("echo $USER", "bash");
|
||||
expect(joinByType(tokens, "builtin")).toContain("echo");
|
||||
expect(joinByType(tokens, "variable")).toContain("$USER");
|
||||
});
|
||||
|
||||
it("returns plain tokens for unknown languages", () => {
|
||||
const tokens = highlight("anything goes here", "klingon");
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0]!.type).toBe("plain");
|
||||
});
|
||||
|
||||
it("classifies diff lines", () => {
|
||||
const src = "diff --git a b\n+++ b\n--- a\n+added\n-removed\n unchanged";
|
||||
const tokens = highlight(src, "diff");
|
||||
expect(joinByType(tokens, "diff-add")).toContain("+added");
|
||||
expect(joinByType(tokens, "diff-del")).toContain("-removed");
|
||||
expect(joinByType(tokens, "diff-meta")).toContain("diff --git a b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalLang()", () => {
|
||||
it("maps common aliases to canonical keys", () => {
|
||||
expect(canonicalLang("JavaScript")).toBe("js");
|
||||
expect(canonicalLang("tsx")).toBe("ts");
|
||||
expect(canonicalLang("py")).toBe("python");
|
||||
expect(canonicalLang("zsh")).toBe("bash");
|
||||
expect(canonicalLang("yml")).toBe("yaml");
|
||||
expect(canonicalLang("")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokenClass()", () => {
|
||||
it("returns a non-empty class for every token type", () => {
|
||||
const types = [
|
||||
"plain",
|
||||
"comment",
|
||||
"string",
|
||||
"number",
|
||||
"keyword",
|
||||
"builtin",
|
||||
"function",
|
||||
"operator",
|
||||
"punctuation",
|
||||
"property",
|
||||
"tag",
|
||||
"attr",
|
||||
"variable",
|
||||
"boolean",
|
||||
"diff-add",
|
||||
"diff-del",
|
||||
"diff-meta",
|
||||
] as const;
|
||||
for (const t of types) {
|
||||
expect(tokenClass(t).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file Pins the URL and method `api.lanes.git` hits. The card renders whatever
|
||||
* this returns, so a wrong path would surface as a permanently missing git row
|
||||
* rather than as a visible failure — worth a test even though it is one line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { api } from "../api";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch(body: unknown) {
|
||||
const spy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => "application/json" },
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
});
|
||||
vi.stubGlobal("fetch", spy);
|
||||
return spy;
|
||||
}
|
||||
|
||||
describe("api.lanes.git", () => {
|
||||
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
|
||||
const facts = {
|
||||
available: true,
|
||||
branch: "feat/x",
|
||||
head: "abc1234",
|
||||
subject: "do a thing",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
};
|
||||
const spy = stubFetch(facts);
|
||||
|
||||
const result = await api.lanes.git(3);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
||||
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
|
||||
expect(call[1]?.method ?? "GET").toBe("GET");
|
||||
expect(result).toEqual(facts);
|
||||
});
|
||||
|
||||
it("passes an available:false body straight through", async () => {
|
||||
stubFetch({ available: false });
|
||||
expect(await api.lanes.git(9)).toEqual({ available: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for remote-data WebSocket refresh helpers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRemoteDataRefreshMessage } from "../remoteDataEvents";
|
||||
import type { ImportProgressMessage, WSMessage } from "../types";
|
||||
|
||||
function msg(type: WSMessage["type"], data: WSMessage["data"]): WSMessage {
|
||||
return { type, data, timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
}
|
||||
|
||||
describe("isRemoteDataRefreshMessage", () => {
|
||||
it("returns true for remote_data.updated", () => {
|
||||
expect(
|
||||
isRemoteDataRefreshMessage(
|
||||
msg("remote_data.updated", {
|
||||
sourceId: "src_1",
|
||||
source: "src_1",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when remote_source.status is ok", () => {
|
||||
expect(
|
||||
isRemoteDataRefreshMessage(msg("remote_source.status", { id: "src_1", status: "ok" }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when remote_source.status is syncing", () => {
|
||||
expect(
|
||||
isRemoteDataRefreshMessage(msg("remote_source.status", { id: "src_1", status: "syncing" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for remote import.progress complete", () => {
|
||||
expect(
|
||||
isRemoteDataRefreshMessage(
|
||||
msg("import.progress", {
|
||||
phase: "complete",
|
||||
source: "remote",
|
||||
importId: "remote-src_1",
|
||||
} as ImportProgressMessage)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for local import.progress complete", () => {
|
||||
expect(
|
||||
isRemoteDataRefreshMessage(
|
||||
msg("import.progress", {
|
||||
phase: "complete",
|
||||
source: "default",
|
||||
importId: "x",
|
||||
} as ImportProgressMessage)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user