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:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
@@ -0,0 +1,328 @@
/**
* @file AgentCard.test.tsx
* @description Unit tests for the AgentCard component, which displays information about an agent in the application. The tests cover rendering of agent details such as name, status, subagent type, task, and current tool, as well as interaction handling like click events. The tests use React Testing Library and Vitest for assertions and mocking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
// render is used inside renderCard helper
import { MemoryRouter } from "react-router-dom";
import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>);
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
session_id: "sess-1",
name: "Main Agent",
type: "main",
subagent_type: null,
status: "working",
task: null,
current_tool: null,
started_at: "2026-03-05T10:00:00.000Z",
ended_at: null,
updated_at: "2026-03-05T10:00:00.000Z",
parent_agent_id: null,
metadata: null,
...overrides,
};
}
describe("AgentCard", () => {
it("should render agent name", () => {
renderCard(<AgentCard agent={makeAgent({ name: "Test Agent" })} />);
expect(screen.getByText("Test Agent")).toBeInTheDocument();
});
it("should render status badge", () => {
renderCard(<AgentCard agent={makeAgent({ status: "working" })} />);
expect(screen.getByText("Working")).toBeInTheDocument();
});
it("should render subagent_type when present", () => {
renderCard(
<AgentCard
agent={makeAgent({
type: "subagent",
subagent_type: "Explore",
})}
/>
);
expect(screen.getByText("Explore")).toBeInTheDocument();
});
it("should show the subagent's own model from metadata, not the session model (issue #185)", () => {
renderCard(
<AgentCard
agent={makeAgent({
type: "subagent",
subagent_type: "qa",
metadata: JSON.stringify({ model: "claude-haiku-4-5-20251001" }),
})}
// Session is Opus, but the subagent card must read Haiku from metadata.
session={
{
id: "sess-1",
name: "S",
status: "active",
cwd: "/x",
model: "claude-opus-4-8",
} as never
}
/>
);
// Subtitle is the subagent type + project (cwd); the model badge shows the
// subagent's OWN model.
expect(screen.getByText("qa · x")).toBeInTheDocument();
expect(screen.getByText(formatModelName("claude-haiku-4-5-20251001")!)).toBeInTheDocument();
// The Opus session model must NOT appear on a subagent card.
expect(screen.queryByText(formatModelName("claude-opus-4-8")!)).not.toBeInTheDocument();
});
it("main agent subtitle shows project + subagent count, with the model only once (#185)", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "main", name: "Main" })}
session={
{
id: "s",
name: "S",
status: "active",
cwd: "/Users/dev/proj",
model: "claude-opus-4-8",
agent_count: 4,
metadata: JSON.stringify({ turn_count: 12 }),
} as never
}
/>
);
// Subtitle: project basename + SUBAGENT count + turn count (model excluded).
// agent_count includes the main agent itself, so 4 agents => 3 subagents.
// Showing subagents (not agents) reconciles the card with the "Active
// Subagents" dashboard stat, which excludes main agents.
expect(screen.getByText("proj · 3 subagents · 12 turns")).toBeInTheDocument();
// The model appears exactly once — in the footer badge, not duplicated in
// the subtitle the way main cards used to.
expect(screen.getAllByText(formatModelName("claude-opus-4-8")!)).toHaveLength(1);
});
it("shows a subagent's OWN cost, not the session total (avoids misleading spend)", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "subagent", subagent_type: "qa", cost: 3.5 })}
session={
{
id: "s",
name: "S",
status: "active",
cwd: "/x",
model: "claude-opus-4-8",
cost: 646.5,
} as never
}
/>
);
expect(screen.getByText(fmtCost(3.5))).toBeInTheDocument();
// The session total must NOT appear on a subagent card.
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
});
it("shows the session total on a main-agent card", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "main" })}
session={
{
id: "s",
name: "S",
status: "active",
cwd: "/x",
model: "claude-opus-4-8",
cost: 646.5,
} as never
}
/>
);
expect(screen.getByText(fmtCost(646.5))).toBeInTheDocument();
});
it("shows no cost on a subagent card with no recorded usage", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "subagent", subagent_type: "qa" })}
session={{ id: "s", name: "S", status: "active", cwd: "/x", cost: 646.5 } as never}
/>
);
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
});
it("swaps the real session title into the hook-style placeholder (Session <id8>)", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "main", name: "Main Agent - Session 329c4d24" })}
session={{ id: "s", name: "Resumable runs UI", status: "active" } as never}
/>
);
expect(screen.getByText("Main Agent - Resumable runs UI")).toBeInTheDocument();
});
it("swaps the real session title into the import-style placeholder (<folder> - <id8>)", () => {
// Regression: imported / background-synced main agents are named
// "Main Agent - <cwd-folder> - <id8>", which the old Session-only regex
// could not rewrite, so they kept showing "work - e3f8e613" forever even
// though the session title was known.
renderCard(
<AgentCard
agent={makeAgent({ type: "main", name: "Main Agent - work - e3f8e613" })}
session={
{ id: "s", name: "Implement in-process libdocs MCP server", status: "active" } as never
}
/>
);
expect(
screen.getByText("Main Agent - Implement in-process libdocs MCP server")
).toBeInTheDocument();
expect(screen.queryByText("Main Agent - work - e3f8e613")).not.toBeInTheDocument();
});
it("keeps the placeholder when the session name is still auto-generated", () => {
renderCard(
<AgentCard
agent={makeAgent({ type: "main", name: "Main Agent - work - e3f8e613" })}
session={{ id: "s", name: "Session e3f8e613", status: "active" } as never}
/>
);
// "Session <id8>" is suppressed as a non-name, so nothing to swap in.
expect(screen.getByText("Main Agent - work - e3f8e613")).toBeInTheDocument();
});
it("should not render subagent_type when null", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ subagent_type: null })} />);
// Only the name should be in the name container, no subagent type
expect(container.querySelectorAll(".text-\\[11px\\].text-gray-500.truncate")).toHaveLength(0);
});
it("should render task when present", () => {
renderCard(<AgentCard agent={makeAgent({ task: "Searching for patterns" })} />);
expect(screen.getByText("Searching for patterns")).toBeInTheDocument();
});
it("should not render task when null", () => {
renderCard(<AgentCard agent={makeAgent({ task: null })} />);
expect(screen.queryByText("Searching for patterns")).not.toBeInTheDocument();
});
it("should render current_tool when present", () => {
renderCard(<AgentCard agent={makeAgent({ current_tool: "Bash", status: "working" })} />);
expect(screen.getByText("Bash")).toBeInTheDocument();
});
it("should not render current_tool when null", () => {
renderCard(<AgentCard agent={makeAgent({ current_tool: null })} />);
expect(screen.queryByText("Bash")).not.toBeInTheDocument();
});
it("should apply active border for working agents", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "working" })} />);
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-2");
});
it("should apply yellow border for waiting agents even without awaiting_input_since", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-2");
expect(card?.className).toContain("border-l-yellow-500/60");
});
it("should not apply active border for completed agents", () => {
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "completed" })} />);
const card = container.querySelector(".card-hover");
expect(card?.className).not.toContain("border-l-2");
});
it("should call onClick when clicked", () => {
const onClick = vi.fn();
renderCard(<AgentCard agent={makeAgent()} onClick={onClick} />);
fireEvent.click(screen.getByText("Main Agent"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("renders waiting badge and yellow accent when awaiting_input_since is set", () => {
const { container } = renderCard(
<AgentCard
agent={makeAgent({
status: "waiting",
awaiting_input_since: "2026-03-05T10:01:00.000Z",
})}
/>
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
const card = container.querySelector(".card-hover");
expect(card?.className).toContain("border-l-yellow-500/60");
});
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
renderCard(
<AgentCard
agent={makeAgent({
status: "waiting",
awaiting_input_since: "2026-03-05T10:01:00.000Z",
awaiting_reason: "notification",
})}
/>
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
// Cards are narrow — the inline chip would squeeze the title, so the
// reason must NOT render inline here (hover tooltip only).
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
});
it("degrades to a plain Waiting badge on an unknown awaiting_reason", () => {
renderCard(
<AgentCard
agent={makeAgent({
status: "waiting",
awaiting_input_since: "2026-03-05T10:01:00.000Z",
awaiting_reason: "some_future_reason",
})}
/>
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
});
it("ignores awaiting_input_since once the agent has completed", () => {
renderCard(
<AgentCard
agent={makeAgent({
status: "completed",
awaiting_input_since: "2026-03-05T10:01:00.000Z",
ended_at: "2026-03-05T10:02:00.000Z",
})}
/>
);
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.queryByText("Waiting")).not.toBeInTheDocument();
});
it("should show duration for completed agents with ended_at", () => {
renderCard(
<AgentCard
agent={makeAgent({
status: "completed",
started_at: "2026-03-05T10:00:00.000Z",
ended_at: "2026-03-05T10:05:30.000Z",
})}
/>
);
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
});
});
@@ -0,0 +1,44 @@
/**
* @file EmptyState.test.tsx
* @description Unit tests for the EmptyState component, which is a reusable React component that displays an empty state with an icon, title, description, and an optional action. The tests cover rendering of the title, description, icon, and action button when provided. The tests use React Testing Library and Vitest for assertions and mocking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { EmptyState } from "../EmptyState";
import { Bot } from "lucide-react";
describe("EmptyState", () => {
it("should render title", () => {
render(
<EmptyState icon={Bot} title="No agents" description="Start a session to see agents." />
);
expect(screen.getByText("No agents")).toBeInTheDocument();
});
it("should render description", () => {
render(
<EmptyState icon={Bot} title="No agents" description="Start a session to see agents." />
);
expect(screen.getByText("Start a session to see agents.")).toBeInTheDocument();
});
it("should render the icon", () => {
const { container } = render(<EmptyState icon={Bot} title="Title" description="Desc" />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
it("should render action when provided", () => {
render(
<EmptyState icon={Bot} title="Title" description="Desc" action={<button>Retry</button>} />
);
expect(screen.getByText("Retry")).toBeInTheDocument();
});
it("should not render action when not provided", () => {
render(<EmptyState icon={Bot} title="Title" description="Desc" />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
/**
* @file EventDetail.test.tsx
* @description Unit tests for the EventDetail component. Verifies the uniform
* label/value row rendering: event-level fields appear first, payload keys
* follow, scalars render inline, objects/arrays/multiline strings render in a
* terminal-styled code view, and JSON parse failures fall back to showing the
* raw data as a single row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { EventDetail } from "../EventDetail";
import type { DashboardEvent } from "../../lib/types";
const baseEvent: DashboardEvent = {
id: 42,
session_id: "sess-123",
agent_id: "agent-abc",
event_type: "PreToolUse",
tool_name: "Bash",
summary: "Using tool: Bash",
data: JSON.stringify({
cwd: "/tmp",
permission_mode: "bypassPermissions",
hook_event_name: "PreToolUse",
tool_name: "Bash",
tool_input: { command: "ls -la", description: "list files" },
stop_hook_active: false,
}),
created_at: "2026-04-22T10:00:00.000Z",
};
describe("EventDetail", () => {
it("renders event-level fields first: event_id, session_id, agent_id", () => {
render(<EventDetail event={baseEvent} />);
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("sess-123")).toBeInTheDocument();
expect(screen.getByText("agent-abc")).toBeInTheDocument();
});
it("renders scalar payload fields with humanized i18n labels", () => {
render(<EventDetail event={baseEvent} />);
// Translated labels from common:eventDetail.* - never raw snake_case keys.
expect(screen.getByText("Working directory")).toBeInTheDocument();
expect(screen.getByText("/tmp")).toBeInTheDocument();
expect(screen.getByText("Permission mode")).toBeInTheDocument();
expect(screen.getByText("bypassPermissions")).toBeInTheDocument();
expect(screen.getByText("Hook Event Name")).toBeInTheDocument();
});
it("renders boolean values as pills", () => {
render(<EventDetail event={baseEvent} />);
expect(screen.getByText("false")).toBeInTheDocument();
});
it("renders Bash tool_input as a terminal block (command + description)", () => {
render(<EventDetail event={baseEvent} />);
expect(screen.getByText("Tool Input")).toBeInTheDocument();
// Terminal renderer shows the raw command and the `# description` line,
// not the pretty-printed JSON. Description appears both in the Summary
// block and in the terminal - `getAllByText` allows both.
expect(screen.getByText("ls -la")).toBeInTheDocument();
expect(screen.getAllByText(/list files/).length).toBeGreaterThan(0);
});
it("renders multiline strings in a text code view", () => {
const event = {
...baseEvent,
data: JSON.stringify({ last_assistant_message: "line 1\nline 2\nline 3" }),
};
render(<EventDetail event={event} />);
expect(screen.getByText("Last Assistant Message")).toBeInTheDocument();
expect(screen.getByText(/line 1/)).toBeInTheDocument();
expect(screen.getByText(/line 3/)).toBeInTheDocument();
});
it("does not duplicate session_id or agent_id from payload", () => {
const event = {
...baseEvent,
data: JSON.stringify({ session_id: "sess-123", agent_id: "agent-abc", cwd: "/tmp" }),
};
render(<EventDetail event={event} />);
// "sess-123" should appear exactly once (from event-level row).
expect(screen.getAllByText("sess-123")).toHaveLength(1);
expect(screen.getAllByText("agent-abc")).toHaveLength(1);
});
it("humanizes unknown payload keys instead of showing raw snake_case", () => {
const event = {
...baseEvent,
// A key that's NOT in PAYLOAD_LABEL_KEYS should still get a tidy
// Title-Cased label rather than appearing as `some_unknown_field`.
data: JSON.stringify({ some_unknown_field: "hello" }),
};
render(<EventDetail event={event} />);
expect(screen.getByText("Some Unknown Field")).toBeInTheDocument();
expect(screen.queryByText("some_unknown_field")).not.toBeInTheDocument();
});
it("translates known payload keys (tool_use_id → Tool Use ID)", () => {
const event = {
...baseEvent,
data: JSON.stringify({ tool_use_id: "toolu_01ABC", tool_name: "Bash" }),
};
render(<EventDetail event={event} />);
expect(screen.getByText("Tool Use ID")).toBeInTheDocument();
expect(screen.getByText("Tool")).toBeInTheDocument();
expect(screen.queryByText("tool_use_id")).not.toBeInTheDocument();
expect(screen.queryByText("tool_name")).not.toBeInTheDocument();
});
it("falls back to a raw-payload row when JSON parsing fails", () => {
const event = { ...baseEvent, data: "not-json-at-all" };
render(<EventDetail event={event} />);
expect(screen.getByText(/raw payload/i)).toBeInTheDocument();
expect(screen.getByText(/not-json-at-all/)).toBeInTheDocument();
});
it("handles null `data` gracefully without crashing", () => {
const event = { ...baseEvent, data: null };
render(<EventDetail event={event} />);
// Still renders event-level rows.
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("sess-123")).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
/**
* @file EventFilters.test.tsx
* @description Smoke tests for the EventFilters toolbar. Verifies that the
* toolbar renders its inputs, emits debounced text search changes, toggles
* selected chips, fires the clear-all handler, and fetches facet options on
* mount via the events API (mocked).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
import type { EventFiltersValue } from "../EventFilters";
import { api } from "../../lib/api";
describe("EventFilters", () => {
beforeEach(() => {
vi.spyOn(api.events, "facets").mockResolvedValue({
event_types: ["PreToolUse", "PostToolUse", "Stop"],
tool_names: ["Bash", "Edit"],
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("isEmptyFilters treats EMPTY_FILTERS as empty", () => {
expect(isEmptyFilters(EMPTY_FILTERS)).toBe(true);
expect(isEmptyFilters({ ...EMPTY_FILTERS, q: "curl" })).toBe(false);
});
it("renders the search input with a translated placeholder", () => {
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} />);
expect(screen.getByPlaceholderText(/search summary/i)).toBeInTheDocument();
});
it("fetches facets on mount and opens the event-type dropdown", async () => {
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} />);
await waitFor(() => expect(api.events.facets).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: /event type/i }));
expect(await screen.findByText("PreToolUse")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument();
});
it("debounces text search by 300ms", async () => {
vi.useFakeTimers();
try {
const onChange = vi.fn();
render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
target: { value: "curl" },
});
expect(onChange).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ q: "curl" }));
} finally {
vi.useRealTimers();
}
});
it("toggles an event_type chip and emits the updated array", async () => {
const onChange = vi.fn();
render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
await waitFor(() => expect(api.events.facets).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: /event type/i }));
const option = await screen.findByText("PreToolUse");
fireEvent.click(option);
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ event_type: ["PreToolUse"] }));
});
it("shows the clear-all button only when filters are non-empty", () => {
const withFilter: EventFiltersValue = { ...EMPTY_FILTERS, q: "curl" };
const onChange = vi.fn();
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument();
rerender(<EventFilters value={withFilter} onChange={onChange} />);
const clear = screen.getByRole("button", { name: /clear filters/i });
fireEvent.click(clear);
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
});
});
@@ -0,0 +1,100 @@
/**
* @file Sidebar.test.tsx
* @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(wsConnected: boolean, collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={() => {}} />
</MemoryRouter>
);
}
describe("Sidebar", () => {
it("should render the brand name", () => {
renderSidebar(true);
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
});
it("should render the subtitle", () => {
renderSidebar(true);
expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument();
});
it("should render all navigation links", () => {
renderSidebar(true);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Kanban Board")).toBeInTheDocument();
expect(screen.getByText("Sessions")).toBeInTheDocument();
expect(screen.getByText("Activity Feed")).toBeInTheDocument();
});
it('should show "Live" when WebSocket is connected', () => {
renderSidebar(true);
expect(screen.getByText("Live")).toBeInTheDocument();
});
it('should show "Disconnected" when WebSocket is not connected', () => {
renderSidebar(false);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
it("should show version number", () => {
// `__APP_VERSION__` is injected by Vite from the repo-root package.json
// (see vite.config.ts) and replaced at transform time in tests too, so this
// stays correct as the project version changes.
renderSidebar(true);
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
});
it("should have correct navigation hrefs", () => {
renderSidebar(true);
const links = screen.getAllByRole("link");
const hrefs = links.map((link) => link.getAttribute("href"));
expect(hrefs).toContain("/");
expect(hrefs).toContain("/kanban");
expect(hrefs).toContain("/sessions");
expect(hrefs).toContain("/activity");
});
it("should render both language options in expanded mode", () => {
renderSidebar(true);
expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument();
// Chinese and Korean were dropped; offering them would render raw keys.
expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull();
expect(screen.queryByRole("button", { name: "Korean" })).toBeNull();
});
it("should switch to Vietnamese when Vietnamese option is clicked", async () => {
const user = userEvent.setup();
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Vietnamese" }));
await waitFor(() => {
expect(screen.getByText("Tổng quan")).toBeInTheDocument();
expect(screen.getByText("Bảng Kanban")).toBeInTheDocument();
});
});
it("should cycle language in collapsed mode", async () => {
const user = userEvent.setup();
renderSidebar(true, true);
expect(screen.getByText("EN")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" }));
await waitFor(() => {
expect(screen.getByText("VI")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,74 @@
/**
* @file StatCard.test.tsx
* @description Unit tests for the StatCard component, which is a reusable React component that displays a statistic with a label, value, icon, and optional trend information. The tests cover rendering of the label, value (both numeric and string), trend information, and the icon. The tests also verify that custom accent colors are applied correctly. The tests use React Testing Library and Vitest for assertions and mocking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { StatCard } from "../StatCard";
import { Activity } from "lucide-react";
describe("StatCard", () => {
it("should render label", () => {
render(<StatCard label="Total Sessions" value={42} icon={Activity} />);
expect(screen.getByText("Total Sessions")).toBeInTheDocument();
});
it("should render numeric value", () => {
render(<StatCard label="Events" value={156} icon={Activity} />);
expect(screen.getByText("156")).toBeInTheDocument();
});
it("should render string value", () => {
render(<StatCard label="Status" value="-" icon={Activity} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should render trend when provided", () => {
render(<StatCard label="Sessions" value={10} icon={Activity} trend="3 active" />);
expect(screen.getByText("3 active")).toBeInTheDocument();
});
it("should not render trend when not provided", () => {
render(<StatCard label="Sessions" value={10} icon={Activity} />);
expect(screen.queryByText("active")).not.toBeInTheDocument();
});
it("should render the icon", () => {
const { container } = render(<StatCard label="Test" value={0} icon={Activity} />);
// Lucide renders as SVG
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
it("should apply custom accent color", () => {
const { container } = render(
<StatCard label="Test" value={0} icon={Activity} accentColor="text-emerald-400" />
);
const svg = container.querySelector("svg");
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-emerald-400");
});
it("should apply default accent color when not specified", () => {
const { container } = render(<StatCard label="Test" value={0} icon={Activity} />);
const svg = container.querySelector("svg");
expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-accent");
});
it("should render a skeleton placeholder when loading and hide the real value", () => {
const { container } = render(<StatCard label="Total" value="" icon={Activity} loading />);
// value text should NOT appear so users never see a flash of "-" or 0
expect(screen.queryByText("-")).not.toBeInTheDocument();
expect(screen.queryByText("0")).not.toBeInTheDocument();
// skeleton primitive renders an aria-busy node
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument();
});
it("should swap from skeleton to value when loading flips false", () => {
const { rerender } = render(<StatCard label="Total" value="" icon={Activity} loading />);
expect(screen.queryByText("42")).not.toBeInTheDocument();
rerender(<StatCard label="Total" value={42} icon={Activity} loading={false} />);
expect(screen.getByText("42")).toBeInTheDocument();
});
});
@@ -0,0 +1,159 @@
/**
* @file StatusBadge.test.tsx
* @description Unit tests for the StatusBadge component, which includes AgentStatusBadge and SessionStatusBadge. These components are responsible for displaying the status of agents and sessions in the dashboard. The tests cover rendering of different statuses, application of pulse animation based on status, respect for explicit pulse overrides, and the awaiting-reason suffix (icon + short label + hover tooltip) that explains WHY a row is in the Waiting state. The tests use React Testing Library and Vitest for assertions and mocking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AgentStatusBadge, SessionStatusBadge } from "../StatusBadge";
describe("AgentStatusBadge", () => {
it("should render waiting status", () => {
render(<AgentStatusBadge status="waiting" />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
});
it("should render working status", () => {
render(<AgentStatusBadge status="working" />);
expect(screen.getByText("Working")).toBeInTheDocument();
});
it("should render completed status", () => {
render(<AgentStatusBadge status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("should render error status", () => {
render(<AgentStatusBadge status="error" />);
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should apply pulse animation for working status by default", () => {
const { container } = render(<AgentStatusBadge status="working" />);
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
});
it("should not apply pulse for connected status (now working - has pulse)", () => {
const { container } = render(<AgentStatusBadge status="working" />);
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
});
it("should apply pulse animation for waiting status by default", () => {
const { container } = render(<AgentStatusBadge status="waiting" />);
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
});
it("should respect explicit pulse=false override", () => {
const { container } = render(<AgentStatusBadge status="working" pulse={false} />);
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).not.toBeInTheDocument();
});
it("should respect explicit pulse=true override", () => {
const { container } = render(<AgentStatusBadge status="waiting" pulse={true} />);
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
});
it("should render waiting status with yellow dot and pulse by default", () => {
const { container } = render(<AgentStatusBadge status="waiting" />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
});
});
describe("SessionStatusBadge", () => {
it("should render active status", () => {
render(<SessionStatusBadge status="active" />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("should render completed status", () => {
render(<SessionStatusBadge status="completed" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("should render error status", () => {
render(<SessionStatusBadge status="error" />);
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should render abandoned status", () => {
render(<SessionStatusBadge status="abandoned" />);
expect(screen.getByText("Abandoned")).toBeInTheDocument();
});
it("should render waiting status with pulsing yellow dot", () => {
const { container } = render(<SessionStatusBadge status="waiting" />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
const dot = container.querySelector(".animate-pulse-dot");
expect(dot).toBeInTheDocument();
expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument();
});
});
describe("awaiting-reason suffix", () => {
it("renders the reason label next to Waiting on AgentStatusBadge", () => {
render(<AgentStatusBadge status="waiting" reason="notification" />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.getByText("Needs input")).toBeInTheDocument();
});
it("renders the reason label next to Waiting on SessionStatusBadge", () => {
render(<SessionStatusBadge status="waiting" reason="stop" />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.getByText("Turn done")).toBeInTheDocument();
});
it("ignores the reason on non-waiting statuses", () => {
render(<AgentStatusBadge status="working" reason="notification" />);
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
render(<SessionStatusBadge status="active" reason="stop" />);
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
});
it("renders no suffix when reason is null/omitted", () => {
render(<AgentStatusBadge status="waiting" reason={null} />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
});
it("shows the full reason description in a tooltip on hover", () => {
const { container } = render(<AgentStatusBadge status="waiting" reason="interrupted" />);
expect(screen.getByText("Interrupted")).toBeInTheDocument();
// Tip attaches its handlers to the wrapper element and portals the tooltip
// body into document.body.
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
expect(screen.getByText(/The last turn was interrupted/)).toBeInTheDocument();
});
it("marks urgent reasons with the hotter amber tint", () => {
const { container } = render(<AgentStatusBadge status="waiting" reason="notification" />);
expect(container.querySelector(".text-amber-300")).toBeInTheDocument();
const { container: calm } = render(<AgentStatusBadge status="waiting" reason="stop" />);
expect(calm.querySelector(".text-amber-300")).not.toBeInTheDocument();
});
it("compact mode suppresses the inline chip but keeps the hover tooltip", () => {
const { container } = render(
<AgentStatusBadge status="waiting" reason="notification" compact />
);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.queryByText("Needs input")).not.toBeInTheDocument();
fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 });
expect(screen.getByText(/Blocked on a permission prompt/)).toBeInTheDocument();
});
it("compact mode works on SessionStatusBadge too", () => {
render(<SessionStatusBadge status="waiting" reason="stop" compact />);
expect(screen.getByText("Waiting")).toBeInTheDocument();
expect(screen.queryByText("Turn done")).not.toBeInTheDocument();
});
});