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,133 @@
|
||||
/**
|
||||
* @file Run.defaultCwd.test.tsx
|
||||
* @description The Run page must default its working directory to the user's
|
||||
* HOME directory (a neutral spawn location) rather than the dashboard's own
|
||||
* cwd, which would make ad-hoc runs inherit this repo's project context
|
||||
* (.claude/agents, skills, rules, CLAUDE.md, .mcp.json) — issue #202. Falls
|
||||
* back to the dashboard cwd only when no home suggestion is available.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
const HOME = { kind: "home", path: "/Users/tester", label: "Home" };
|
||||
const DASHBOARD = {
|
||||
kind: "dashboard",
|
||||
path: "/Users/tester/projects/dashboard",
|
||||
label: "Dashboard server",
|
||||
};
|
||||
const RECENT = { kind: "recent", path: "/Users/tester/projects/other", label: "other" };
|
||||
|
||||
// Mutable per-test cwd suggestion payload, read by the api mock below.
|
||||
let cwdItems: Array<{ kind: string; path: string; label: string }> = [];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: {
|
||||
list: r({ runs: [], items: [] }),
|
||||
history: r({ items: [] }),
|
||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockImplementation(() => Promise.resolve({ items: cwdItems })),
|
||||
files: r({ items: [] }),
|
||||
start: r({ id: "run-1", status: "running" }),
|
||||
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
|
||||
send: r({ messageId: "m-1" }),
|
||||
kill: r({ ok: true }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({ items: [] }),
|
||||
plugins: r({ plugins: [] }),
|
||||
file: r({ content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: r({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Workspace as Run } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderRun() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Run />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
function cwdInput(): HTMLInputElement {
|
||||
const placeholder = i18n.t("run:fields.cwdPlaceholder");
|
||||
return screen.getByPlaceholderText(placeholder) as HTMLInputElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Run page — default working directory (issue #202)", () => {
|
||||
it("pre-fills cwd with the HOME suggestion, not the dashboard cwd", async () => {
|
||||
cwdItems = [DASHBOARD, HOME, RECENT];
|
||||
await renderRun();
|
||||
expect(cwdInput().value).toBe(HOME.path);
|
||||
});
|
||||
|
||||
it("falls back to the dashboard cwd when no home suggestion exists", async () => {
|
||||
cwdItems = [DASHBOARD, RECENT];
|
||||
await renderRun();
|
||||
expect(cwdInput().value).toBe(DASHBOARD.path);
|
||||
});
|
||||
|
||||
it("leaves cwd empty when there are no suggestions at all", async () => {
|
||||
cwdItems = [];
|
||||
await renderRun();
|
||||
expect(cwdInput().value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* @file SessionDetail.nestedAgents.test.tsx
|
||||
* @description Tests for SessionDetail page focusing on correct rendering of nested agent hierarchies, including edge cases like orphaned subagents and multiple main agents. Validates expand/collapse behavior and descendant counts in the UI.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
import type { Agent, Session, DashboardEvent } from "../../lib/types";
|
||||
import { fmtCost } from "../../lib/format";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const mockSession: Session = {
|
||||
id: "sess-1",
|
||||
name: "Test Session",
|
||||
status: "active",
|
||||
cwd: "/test",
|
||||
model: "claude-opus-4-6",
|
||||
started_at: "2026-03-05T10:00:00.000Z",
|
||||
ended_at: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
// ── Mock API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let mockAgents: Agent[] = [];
|
||||
let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, breakdown: [] };
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: {
|
||||
get: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
session: mockSession,
|
||||
agents: mockAgents,
|
||||
events: [] as DashboardEvent[],
|
||||
})
|
||||
),
|
||||
transcripts: vi.fn(() => Promise.resolve({ transcripts: [] })),
|
||||
stats: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
session_id: mockSession.id,
|
||||
total_events: 0,
|
||||
events_by_type: [],
|
||||
tools_used: [],
|
||||
error_count: 0,
|
||||
first_event_at: null,
|
||||
last_event_at: null,
|
||||
agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} },
|
||||
subagent_types: [],
|
||||
tokens: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
},
|
||||
})
|
||||
),
|
||||
},
|
||||
pricing: {
|
||||
sessionCost: vi.fn(() => Promise.resolve(mockCost)),
|
||||
},
|
||||
events: {
|
||||
list: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
events: [] as DashboardEvent[],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
})
|
||||
),
|
||||
facets: vi.fn(() => Promise.resolve({ event_types: [], tool_names: [] })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/sessions/sess-1"]}>
|
||||
<Routes>
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the agent-tree region for scoped queries (avoids matches in the active-agent banner). */
|
||||
async function findTree() {
|
||||
return await screen.findByTestId("agent-tree");
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SessionDetail - Nested Agent Tree Rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockAgents = [];
|
||||
mockCost = { total_cost: 0, breakdown: [] };
|
||||
});
|
||||
|
||||
it("shows the session total cost on the main agent card (cost is loaded separately)", async () => {
|
||||
// Regression: /api/sessions/:id has no cost column, so the main card (which
|
||||
// renders session.cost) showed nothing until SessionDetail injected the
|
||||
// separately-loaded total into the card's session prop.
|
||||
mockCost = { total_cost: 42.5, breakdown: [] };
|
||||
mockAgents = [makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "waiting" })];
|
||||
renderPage();
|
||||
const tree = await findTree();
|
||||
await waitFor(() => expect(within(tree).getByText(fmtCost(42.5))).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders a flat main → subagent hierarchy (depth 1)", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "working" }),
|
||||
makeAgent({
|
||||
id: "sub-1",
|
||||
name: "Explorer",
|
||||
type: "subagent",
|
||||
subagent_type: "Explore",
|
||||
status: "working",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
expect(await screen.findByText("Main Agent")).toBeInTheDocument();
|
||||
// Subagent should be visible (auto-expanded because it's working)
|
||||
expect(await screen.findByText("Explorer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders deeply nested agents (depth 3: main → L1 → L2 → L3)", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
|
||||
makeAgent({
|
||||
id: "l1",
|
||||
name: "Level-1",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l2",
|
||||
name: "Level-2",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "l1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l3",
|
||||
name: "Level-3",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "l2",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
const tree = await findTree();
|
||||
// All levels should render in the tree (auto-expanded because they have working children).
|
||||
// The active-agent banner may also display "Level-1", so we scope to the tree.
|
||||
await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument());
|
||||
expect(within(tree).getByText("Level-1")).toBeInTheDocument();
|
||||
expect(within(tree).getByText("Level-2")).toBeInTheDocument();
|
||||
expect(within(tree).getByText("Level-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows descendant count in collapsed badge for nested agents", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
|
||||
makeAgent({
|
||||
id: "l1",
|
||||
name: "Level-1",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l2",
|
||||
name: "Level-2",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "l1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l3a",
|
||||
name: "Level-3a",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "l2",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l3b",
|
||||
name: "Level-3b",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "l2",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
// Main has 4 total descendants (L1, L2, L3a, L3b) - should show "4 subagents" when collapsed
|
||||
expect(await screen.findByText("4 subagents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands and collapses nested agent groups", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
|
||||
makeAgent({
|
||||
id: "l1",
|
||||
name: "Level-1",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l2",
|
||||
name: "Level-2",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "l1",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
// Initially collapsed (agents are completed, no auto-expand)
|
||||
expect(await screen.findByText("2 subagents")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Level-1")).not.toBeInTheDocument();
|
||||
|
||||
// Click the count badge to expand
|
||||
fireEvent.click(screen.getByText("2 subagents"));
|
||||
expect(await screen.findByText("Level-1")).toBeInTheDocument();
|
||||
// Level-2 is still nested under Level-1 which is collapsed
|
||||
expect(screen.getByText("1 subagent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders orphaned subagents in dedicated section", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
|
||||
makeAgent({
|
||||
id: "orphan-1",
|
||||
name: "Orphan Agent",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "nonexistent-parent",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
const tree = await findTree();
|
||||
expect(within(tree).getByText("Main")).toBeInTheDocument();
|
||||
expect(within(tree).getByText("Orphan Agent")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Unparented Subagents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-expands all ancestors when a deeply nested agent is active", async () => {
|
||||
// Level-3 is working → Level-2, Level-1, and Main should all auto-expand
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
|
||||
makeAgent({
|
||||
id: "l1",
|
||||
name: "Level-1",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l2",
|
||||
name: "Level-2",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "l1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "l3",
|
||||
name: "Deep Active",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "l2",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
const tree = await findTree();
|
||||
// All levels should be visible because l3 is working, triggering ancestor expansion
|
||||
await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument());
|
||||
expect(within(tree).getByText("Level-1")).toBeInTheDocument();
|
||||
expect(within(tree).getByText("Level-2")).toBeInTheDocument();
|
||||
expect(within(tree).getByText("Deep Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles agents with no children (leaf nodes)", async () => {
|
||||
mockAgents = [makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" })];
|
||||
|
||||
renderPage();
|
||||
expect(await screen.findByText("Main")).toBeInTheDocument();
|
||||
// No subagent-count button should exist for a leaf node. Match the
|
||||
// "{{count}} subagent(s)" label specifically so we don't collide with
|
||||
// the word "subagent" in unrelated explanatory copy elsewhere on the page.
|
||||
expect(screen.queryByText(/\d+ subagent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple main agents in the same session", async () => {
|
||||
// Edge case: import/resume could create multiple "main" entries
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main-A", type: "main", status: "completed" }),
|
||||
makeAgent({ id: "main-2", name: "Main-B", type: "main", status: "working" }),
|
||||
makeAgent({
|
||||
id: "sub-a",
|
||||
name: "Sub of A",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "sub-b",
|
||||
name: "Sub of B",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "main-2",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
const tree = await findTree();
|
||||
await waitFor(() => expect(within(tree).getByText("Main-A")).toBeInTheDocument());
|
||||
expect(within(tree).getByText("Main-B")).toBeInTheDocument();
|
||||
// Sub of B auto-expanded (working)
|
||||
expect(within(tree).getByText("Sub of B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sibling subagents at the same depth", async () => {
|
||||
mockAgents = [
|
||||
makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" }),
|
||||
makeAgent({
|
||||
id: "sub-a",
|
||||
name: "Sibling-A",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "sub-b",
|
||||
name: "Sibling-B",
|
||||
type: "subagent",
|
||||
status: "working",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
makeAgent({
|
||||
id: "sub-c",
|
||||
name: "Sibling-C",
|
||||
type: "subagent",
|
||||
status: "completed",
|
||||
parent_agent_id: "main-1",
|
||||
}),
|
||||
];
|
||||
|
||||
renderPage();
|
||||
expect(await screen.findByText("Main")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Sibling-A")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Sibling-B")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Sibling-C")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* @file Workspace.test.tsx
|
||||
* @description Workspace page integration tests covering the lane-based run flow.
|
||||
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
|
||||
* and that no /stage endpoint is called.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Track all API calls made by recording the method and endpoint
|
||||
const recordedCalls: Array<{ method: string; path: string }> = [];
|
||||
|
||||
// Mutable test state. `run_id` is widened explicitly: the fixtures start with
|
||||
// no run, and one case assigns run ids to drive the console.
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
let nextRunId = 1;
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string) => {
|
||||
recordedCalls.push({ method, path });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||
}),
|
||||
ensure: vi.fn().mockImplementation(async ({ cwd }: { cwd: string }) => {
|
||||
recordCall("POST", "/api/lanes/ensure");
|
||||
const newLane = {
|
||||
id: Math.max(...lanesToReturn.map((l) => l.id), 0) + 1,
|
||||
cwd,
|
||||
title: `Lane for ${cwd}`,
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
};
|
||||
lanesToReturn = [...lanesToReturn, newLane];
|
||||
return { lane: newLane, created: true };
|
||||
}),
|
||||
action: vi.fn().mockImplementation(async (id: number, action: string) => {
|
||||
recordCall("POST", `/api/lanes/${id}/${action}`);
|
||||
if (action === "start") {
|
||||
const runId = `run-${nextRunId++}`;
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
if (lane) {
|
||||
lane.run_id = runId;
|
||||
}
|
||||
return { lane: { ...lane, run_id: runId } };
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
// LaneCard reads its own working-copy facts per card. Nothing here
|
||||
// asserts on them, so report the "not a git repo" shape.
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn().mockImplementation(async () => {
|
||||
recordCall("POST", `/api/lanes/stage`);
|
||||
return { ok: true };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
|
||||
recordCall(
|
||||
"GET",
|
||||
`/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
|
||||
);
|
||||
return { items: [] };
|
||||
}),
|
||||
binary: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/binary");
|
||||
return { found: true, path: "/usr/bin/claude" };
|
||||
}),
|
||||
cwds: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/cwds");
|
||||
return { items: [{ kind: "home", path: "/home/user", label: "Home" }] };
|
||||
}),
|
||||
files: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/files");
|
||||
return { items: [] };
|
||||
}),
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
recordCall("POST", "/api/run/start");
|
||||
const runId = `run-${nextRunId++}`;
|
||||
return { id: runId, status: "running" };
|
||||
}),
|
||||
get: vi.fn().mockImplementation(async (id: string) => {
|
||||
recordCall("GET", `/api/run/${id}`);
|
||||
return {
|
||||
id,
|
||||
pid: 12345,
|
||||
mode: "conversation",
|
||||
cwd: "/workspace",
|
||||
model: "claude-opus-5",
|
||||
permissionMode: "acceptEdits",
|
||||
effort: "medium",
|
||||
prompt: "test prompt",
|
||||
argv: [],
|
||||
resumeSessionId: null,
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
sessionId: "sess-1",
|
||||
envelopeCount: 0,
|
||||
stdoutTail: "",
|
||||
stderrTail: "",
|
||||
messages: [],
|
||||
envelopes: [],
|
||||
};
|
||||
}),
|
||||
send: vi.fn().mockImplementation(async (id: string) => {
|
||||
recordCall("POST", `/api/run/${id}/send`);
|
||||
return { messageId: "msg-1" };
|
||||
}),
|
||||
kill: vi.fn().mockImplementation(async (id: string) => {
|
||||
recordCall("POST", `/api/run/${id}/kill`);
|
||||
return { ok: true };
|
||||
}),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/cc-config/commands");
|
||||
return { items: [] };
|
||||
}),
|
||||
plugins: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/cc-config/plugins");
|
||||
return { plugins: [] };
|
||||
}),
|
||||
file: vi.fn().mockImplementation(async (path: string) => {
|
||||
recordCall("GET", `/api/cc-config/file?path=${path}`);
|
||||
return { text: "", content: "" };
|
||||
}),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Set up DOM polyfills for tests
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
nextRunId = 1;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists lanes and counters match the API response", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify lanes are rendered
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
|
||||
// Verify counters are rendered with correct values
|
||||
const counterTexts = screen.getAllByText((_, element) => {
|
||||
if (!element) return false;
|
||||
return element.textContent?.includes("2 lanes") || false;
|
||||
});
|
||||
expect(counterTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
lanesToReturn = lanesToReturn.map((l, i) => ({
|
||||
...l,
|
||||
run_id: i === 0 ? "run-100" : "run-200",
|
||||
}));
|
||||
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Selection is announced on the tile itself, not inferred from styling.
|
||||
const laneATile = screen.getByTestId("lane-tile-1");
|
||||
const laneBTile = screen.getByTestId("lane-tile-2");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
await user.click(laneBTile);
|
||||
await settle();
|
||||
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
|
||||
// And the detail panel follows: only the selected lane gets a full card.
|
||||
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
|
||||
await renderWorkspace();
|
||||
recordedCalls.length = 0; // Clear initial setup calls
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Get all textboxes and find the ones we need
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
const cwdInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("absolute path")
|
||||
);
|
||||
const promptInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
|
||||
);
|
||||
|
||||
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
|
||||
|
||||
// Set cwd to an existing lane's cwd
|
||||
await user.clear(cwdInput as HTMLInputElement);
|
||||
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
|
||||
await settle();
|
||||
|
||||
// Set prompt
|
||||
await user.type(promptInput as HTMLInputElement, "test prompt");
|
||||
await settle();
|
||||
|
||||
// Find the Run button in the RunSetup form
|
||||
const runButton = screen.getByRole("button", { name: /^Run$/i });
|
||||
const startButton = runButton;
|
||||
|
||||
await user.click(startButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Verify /api/run/start was NOT called
|
||||
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
|
||||
expect(runStartCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
|
||||
await renderWorkspace();
|
||||
recordedCalls.length = 0; // Clear initial setup calls
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Get all textboxes and find the ones we need
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
const cwdInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("absolute path")
|
||||
);
|
||||
const promptInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
|
||||
);
|
||||
|
||||
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
|
||||
|
||||
// Set cwd to a path no lane owns
|
||||
await user.clear(cwdInput as HTMLInputElement);
|
||||
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
|
||||
await settle();
|
||||
|
||||
// Set prompt
|
||||
await user.type(promptInput as HTMLInputElement, "test prompt");
|
||||
await settle();
|
||||
|
||||
// Find the Run button in the RunSetup form
|
||||
const runButton = screen.getByRole("button", { name: /^Run$/i });
|
||||
|
||||
await user.click(runButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
|
||||
expect(
|
||||
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Verify ensure was called BEFORE start
|
||||
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
|
||||
const startIndex = recordedCalls.findIndex(
|
||||
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
|
||||
);
|
||||
expect(ensureIndex).toBeLessThan(startIndex);
|
||||
});
|
||||
|
||||
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
|
||||
await renderWorkspace();
|
||||
recordedCalls.length = 0; // Clear initial setup calls
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Get all textboxes and find the ones we need
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
const cwdInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("absolute path")
|
||||
);
|
||||
const promptInput = textboxes.find((el) =>
|
||||
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
|
||||
);
|
||||
|
||||
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
|
||||
|
||||
// Set cwd to an existing lane's cwd
|
||||
await user.clear(cwdInput as HTMLInputElement);
|
||||
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
|
||||
await settle();
|
||||
|
||||
// Set prompt
|
||||
await user.type(promptInput as HTMLInputElement, "test prompt");
|
||||
await settle();
|
||||
|
||||
// Find the Run button in the RunSetup form
|
||||
const runButton = screen.getByRole("button", { name: /^Run$/i });
|
||||
|
||||
await user.click(runButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// After start, verify no /stage call was made
|
||||
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
|
||||
expect(stageCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("shows the four counters with the values the API reported", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
||||
});
|
||||
|
||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
await renderWorkspace();
|
||||
// Lane 1 is selected by default. Its full card — controls, git facts — is
|
||||
// the detail panel; the other lane stays a tile.
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-card-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the selected lane's pipeline detail panel", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("lane-detail")).toBeInTheDocument();
|
||||
// The colour legend was removed: read once, noise thereafter.
|
||||
expect(screen.queryByTestId("pipeline-legend")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the console body without any collapse toggle", async () => {
|
||||
await renderWorkspace();
|
||||
// The disclosure was removed - the console is always attached and visible.
|
||||
expect(screen.queryByTestId("console-toggle")).toBeNull();
|
||||
const body = screen.getByTestId("console-body");
|
||||
expect(body).toBeInTheDocument();
|
||||
expect(body.className).not.toContain("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — the console is its own section", () => {
|
||||
it("does not nest the console inside any lane card", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
const body = screen.getByTestId("console-body");
|
||||
// The console is a window onto a process, not a property of a card. Nesting
|
||||
// it in one made the owning card span the row and left a hole when closed.
|
||||
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
|
||||
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
|
||||
});
|
||||
|
||||
it("has no header line of its own - the pipeline panel already names the lane", async () => {
|
||||
lanesToReturn = lanesToReturn.map((l, i) => ({ ...l, run_id: i === 0 ? "run-100" : null }));
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.queryByTestId("console-owner")).toBeNull();
|
||||
expect(screen.queryByText("Claude console")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the console reachable when no lane exists at all", async () => {
|
||||
lanesToReturn = [];
|
||||
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("console-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("attaches under the selected lane's pipeline, not as a separate section", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
const detail = screen.getByTestId("lane-detail");
|
||||
const body = screen.getByTestId("console-body");
|
||||
expect(detail.contains(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* @file screens.snapshot.test.tsx
|
||||
* @description Render snapshot tests for every routed screen. Each page is
|
||||
* rendered inside a MemoryRouter with the API layer mocked to a deterministic
|
||||
* loaded-empty state (empty collections + zeroed scalars) so snapshots capture
|
||||
* real structure / layout / i18n without noisy chart DOM or live data. The
|
||||
* system clock and timezone are pinned so any relative/absolute timestamps are
|
||||
* stable across machines and CI.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// Pin timezone before anything reads it, so date formatting is machine-stable
|
||||
// (referenced via globalThis so the browser-targeted tsconfig doesn't need node types).
|
||||
const nodeProcess = (
|
||||
globalThis as unknown as { process?: { env: Record<string, string | undefined> } }
|
||||
).process;
|
||||
if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// ── Mock the API layer with deterministic, crash-safe empty fixtures ──────────
|
||||
// Keep the module's other named exports (RUN_EFFORT_CHOICES, etc.) real and
|
||||
// override only `api`.
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
const items = { items: [] };
|
||||
const cost = {
|
||||
total_cost: 0,
|
||||
breakdown: [],
|
||||
daily_costs: [],
|
||||
feature_costs: {},
|
||||
unpriced_models: [],
|
||||
};
|
||||
const emptyWorkflow = {
|
||||
stats: {
|
||||
totalSessions: 0,
|
||||
totalAgents: 0,
|
||||
totalSubagents: 0,
|
||||
avgSubagents: 0,
|
||||
successRate: 0,
|
||||
avgDepth: 0,
|
||||
avgDurationSec: 0,
|
||||
totalCompactions: 0,
|
||||
avgCompactions: 0,
|
||||
topFlow: null,
|
||||
},
|
||||
orchestration: {
|
||||
sessionCount: 0,
|
||||
mainCount: 0,
|
||||
subagentTypes: [],
|
||||
edges: [],
|
||||
outcomes: [],
|
||||
compactions: { total: 0, sessions: 0 },
|
||||
},
|
||||
toolFlow: { transitions: [], toolCounts: [] },
|
||||
effectiveness: [],
|
||||
patterns: { patterns: [], soloSessionCount: 0, soloPercentage: 0 },
|
||||
modelDelegation: { mainModels: [], subagentModels: [], tokensByModel: [] },
|
||||
errorPropagation: {
|
||||
byDepth: [],
|
||||
byType: [],
|
||||
eventErrors: [],
|
||||
sessionsWithErrors: 0,
|
||||
totalSessions: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
concurrency: { aggregateLanes: [] },
|
||||
complexity: [],
|
||||
compaction: {
|
||||
totalCompactions: 0,
|
||||
tokensRecovered: 0,
|
||||
perSession: [],
|
||||
sessionsWithCompactions: 0,
|
||||
totalSessions: 0,
|
||||
},
|
||||
cooccurrence: [],
|
||||
};
|
||||
const analytics = {
|
||||
tokens: { total_input: 0, total_output: 0, total_cache_read: 0, total_cache_write: 0 },
|
||||
tool_usage: [],
|
||||
daily_events: [],
|
||||
daily_sessions: [],
|
||||
agent_types: [],
|
||||
event_types: [],
|
||||
avg_events_per_session: 0,
|
||||
total_subagents: 0,
|
||||
overview: {
|
||||
total_sessions: 0,
|
||||
active_sessions: 0,
|
||||
active_agents: 0,
|
||||
total_agents: 0,
|
||||
total_events: 0,
|
||||
},
|
||||
agents_by_status: {},
|
||||
sessions_by_status: {},
|
||||
};
|
||||
const settingsInfo = {
|
||||
db: {
|
||||
path: "/tmp/test.db",
|
||||
size: 0,
|
||||
counts: {},
|
||||
pragmas: {
|
||||
journal_mode: "wal",
|
||||
synchronous: 1,
|
||||
auto_vacuum: 0,
|
||||
encoding: "UTF-8",
|
||||
foreign_keys: 1,
|
||||
busy_timeout: 5000,
|
||||
},
|
||||
load_stats: { m5: 0, m15: 0, h1: 0 },
|
||||
},
|
||||
hooks: { installed: true, path: "/tmp/settings.json", hooks: {} },
|
||||
server: {
|
||||
uptime: 0,
|
||||
node_version: "v22.0.0",
|
||||
platform: "linux",
|
||||
ws_connections: 0,
|
||||
memory: { rss: 0, heapTotal: 0, heapUsed: 0, external: 0 },
|
||||
cpu_load: [0, 0, 0],
|
||||
arch: "x64",
|
||||
total_mem: 0,
|
||||
free_mem: 0,
|
||||
cpus: 1,
|
||||
},
|
||||
transcript_cache: { size: 0, maxSize: 100, hits: 0, misses: 0, keys: [] },
|
||||
};
|
||||
const session = {
|
||||
id: "sess-1",
|
||||
name: "Test Session",
|
||||
status: "active",
|
||||
cwd: "/test",
|
||||
model: "claude-opus-4-6",
|
||||
started_at: "2026-06-10T12:00:00.000Z",
|
||||
ended_at: null,
|
||||
metadata: null,
|
||||
};
|
||||
const stats = {
|
||||
total_sessions: 0,
|
||||
active_sessions: 0,
|
||||
active_agents: 0,
|
||||
total_agents: 0,
|
||||
total_events: 0,
|
||||
events_today: 0,
|
||||
ws_connections: 0,
|
||||
agents_by_status: {},
|
||||
sessions_by_status: {},
|
||||
};
|
||||
|
||||
const sampleWorkflowRun = {
|
||||
run_id: "wf_sample1",
|
||||
session_id: "sess-1",
|
||||
task_id: "task-1",
|
||||
name: "review-changes",
|
||||
status: "completed",
|
||||
default_model: "claude-opus-4-6",
|
||||
started_at: "2026-06-10T12:30:00.000Z",
|
||||
ended_at: "2026-06-10T12:35:00.000Z",
|
||||
duration_ms: 300000,
|
||||
agent_count: 2,
|
||||
total_tokens: 48000,
|
||||
total_tool_calls: 9,
|
||||
phases: [{ title: "Review" }, { title: "Verify" }],
|
||||
progress: [
|
||||
{ type: "workflow_phase", index: 1, title: "Review" },
|
||||
{ type: "workflow_phase", index: 2, title: "Verify" },
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a1",
|
||||
agentType: "reviewer",
|
||||
state: "done",
|
||||
label: "review:bugs",
|
||||
phaseTitle: "Review",
|
||||
tokens: 22000,
|
||||
toolCalls: 5,
|
||||
durationMs: 120000,
|
||||
lastToolName: "Read",
|
||||
resultPreview: "Found 3 issues",
|
||||
},
|
||||
{
|
||||
type: "workflow_agent",
|
||||
agentId: "a2",
|
||||
agentType: "verifier",
|
||||
state: "done",
|
||||
label: "verify:bugs",
|
||||
phaseTitle: "Verify",
|
||||
tokens: 26000,
|
||||
toolCalls: 4,
|
||||
durationMs: 180000,
|
||||
lastToolName: "Bash",
|
||||
resultPreview: "All confirmed",
|
||||
},
|
||||
],
|
||||
script_path: null,
|
||||
journal_path: "/x/wf_sample1.json",
|
||||
source: "journal",
|
||||
created_at: "2026-06-10T12:30:00.000Z",
|
||||
updated_at: "2026-06-10T12:35:00.000Z",
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
stats: { get: r(stats), facets: r({ cwds: [] }) },
|
||||
sessions: {
|
||||
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
facets: r({ cwds: [], sources: ["local"] }),
|
||||
get: r({ session, agents: [], events: [], workflows: [sampleWorkflowRun] }),
|
||||
stats: r({
|
||||
session_id: "sess-1",
|
||||
total_events: 0,
|
||||
events_by_type: [],
|
||||
tools_used: [],
|
||||
error_count: 0,
|
||||
first_event_at: null,
|
||||
last_event_at: null,
|
||||
agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} },
|
||||
subagent_types: [],
|
||||
tokens: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
},
|
||||
}),
|
||||
transcripts: r({ transcripts: [] }),
|
||||
transcript: r({ messages: [], session_id: "sess-1" }),
|
||||
},
|
||||
agents: { list: r({ agents: [] }) },
|
||||
remoteSources: {
|
||||
list: r({ sources: [] }),
|
||||
create: r({ source: {} }),
|
||||
update: r({ source: {} }),
|
||||
remove: r({ ok: true, purged: 0 }),
|
||||
test: r({ ok: true, message: "" }),
|
||||
sync: r({ ok: true }),
|
||||
},
|
||||
events: {
|
||||
list: r({ events: [], total: 0, limit: 50, offset: 0 }),
|
||||
facets: r({ event_types: [], tool_names: [] }),
|
||||
},
|
||||
analytics: { get: r(analytics) },
|
||||
workflows: {
|
||||
get: r(emptyWorkflow),
|
||||
session: r({}),
|
||||
runs: r({
|
||||
runs: [sampleWorkflowRun],
|
||||
total: 1,
|
||||
counts: { completed: 1 },
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
}),
|
||||
run: r({ workflow: sampleWorkflowRun, agents: [], events: [] }),
|
||||
},
|
||||
pricing: {
|
||||
list: r({ pricing: [] }),
|
||||
upsert: r({ pricing: {} }),
|
||||
delete: r({ ok: true }),
|
||||
totalCost: r(cost),
|
||||
sessionCost: r(cost),
|
||||
},
|
||||
settings: {
|
||||
info: r(settingsInfo),
|
||||
claudeHome: {
|
||||
get: r({ claude_home: "/home/test/.claude" }),
|
||||
set: r({ ok: true, claude_home: "/home/test/.claude" }),
|
||||
},
|
||||
clearData: r({ ok: true, cleared: {} }),
|
||||
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
|
||||
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
|
||||
resetPricing: r({ ok: true, pricing: [] }),
|
||||
exportData: () => "/api/settings/export",
|
||||
cleanup: r({
|
||||
ok: true,
|
||||
abandoned: 0,
|
||||
purged_sessions: 0,
|
||||
purged_events: 0,
|
||||
purged_agents: 0,
|
||||
}),
|
||||
},
|
||||
import: {
|
||||
guide: r({
|
||||
platform: "linux",
|
||||
default_projects_dir: "/home/test/.claude/projects",
|
||||
default_projects_dir_display: "~/.claude/projects",
|
||||
default_projects_dir_exists: true,
|
||||
default_projects_dir_stats: { projects: 0, jsonl_files: 0 },
|
||||
archive_command: "tar",
|
||||
supported_extensions: [".jsonl"],
|
||||
max_upload_bytes: 1000000,
|
||||
max_upload_files: 10,
|
||||
steps: [],
|
||||
}),
|
||||
rescan: r({}),
|
||||
scanPath: r({}),
|
||||
},
|
||||
ccConfig: {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/test/.claude",
|
||||
projectClaudeDir: "/test/.claude",
|
||||
projectRoot: "/test",
|
||||
claudeJson: "/home/test/.claude.json",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
plugins: 0,
|
||||
pluginsEnabled: 0,
|
||||
pluginsDisabled: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
memory: 0,
|
||||
settingsFiles: 0,
|
||||
},
|
||||
}),
|
||||
skills: r(items),
|
||||
agents: r(items),
|
||||
commands: r(items),
|
||||
outputStyles: r(items),
|
||||
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
|
||||
mcp: r({ servers: [], items: [] }),
|
||||
hooks: r(items),
|
||||
settings: r(items),
|
||||
memory: r(items),
|
||||
file: r({
|
||||
scope: "user",
|
||||
name: "x",
|
||||
path: "/x",
|
||||
size: 0,
|
||||
mtime: 0,
|
||||
truncated: false,
|
||||
frontmatter: {},
|
||||
preview: "",
|
||||
content: "",
|
||||
}),
|
||||
write: r({ ok: true }),
|
||||
delete: r({ ok: true }),
|
||||
marketplaces: r({ marketplaces: [], items: [] }),
|
||||
keybindings: r({ items: [], bindings: [] }),
|
||||
statusline: r({ configured: false }),
|
||||
hookScripts: r({ items: [], scripts: [] }),
|
||||
backups: r({ items: [] }),
|
||||
},
|
||||
run: {
|
||||
list: r({ runs: [], items: [] }),
|
||||
history: r({ items: [] }),
|
||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: r({ items: [] }),
|
||||
files: r({ items: [] }),
|
||||
start: r({ id: "run-1", status: "running" }),
|
||||
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
|
||||
send: r({ messageId: "m-1" }),
|
||||
kill: r({ ok: true }),
|
||||
},
|
||||
alerts: {
|
||||
list: r({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
|
||||
ack: r({ alert: {} }),
|
||||
ackAll: r({ ok: true, acknowledged: 0 }),
|
||||
rules: {
|
||||
list: r({ rules: [] }),
|
||||
create: r({ rule: {} }),
|
||||
update: r({ rule: {} }),
|
||||
remove: r({ ok: true }),
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
list: r({ targets: [] }),
|
||||
providers: r({ providers: [] }),
|
||||
create: r({ target: {} }),
|
||||
update: r({ target: {} }),
|
||||
remove: r({ ok: true }),
|
||||
test: r({ ok: true, status: 200, attempts: 1, error: null }),
|
||||
deliveries: r({ deliveries: [], limit: 20, offset: 0 }),
|
||||
},
|
||||
lanes: {
|
||||
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
|
||||
get: r({ lane: {} }),
|
||||
create: r({ lane: {} }),
|
||||
update: r({ lane: {} }),
|
||||
stage: r({ lane: {} }),
|
||||
// A realistic shape: `{}` would crash blockingReason on `blocked` if any
|
||||
// screen ever opened the destructive modal.
|
||||
preflight: r({
|
||||
action: "reset",
|
||||
lane: 1,
|
||||
kind: "managed",
|
||||
branch: "feat/demo",
|
||||
head: "abc1234",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 0,
|
||||
blocked: [],
|
||||
warnings: [],
|
||||
}),
|
||||
action: r({ ok: true }),
|
||||
},
|
||||
updates: { check: r({ behind: 0, ahead: 0, current: "", upstream: "" }), status: r({}) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// eventBus: no-op pub/sub so pages mount without a live socket. onConnection +
|
||||
// connected back the useSyncExternalStore(eventBus.onConnection, …) reads.
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
// push notifications: avoid real service-worker / Notification calls.
|
||||
vi.mock("../../lib/push", () => ({
|
||||
subscribeToPush: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromPush: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Page components (imported after the mocks above; vi.mock is hoisted).
|
||||
import { Dashboard } from "../Dashboard";
|
||||
import { KanbanBoard } from "../KanbanBoard";
|
||||
import { Sessions } from "../Sessions";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
import { ActivityFeed } from "../ActivityFeed";
|
||||
import { Analytics } from "../Analytics";
|
||||
import { Workflows } from "../Workflows";
|
||||
import { CcConfig } from "../CcConfig";
|
||||
import { Workspace } from "../Workspace";
|
||||
import { Settings } from "../Settings";
|
||||
import { NotFound } from "../NotFound";
|
||||
|
||||
// jsdom lacks these browser APIs that chart / responsive components rely on.
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
globalThis.IntersectionObserver =
|
||||
globalThis.IntersectionObserver || (ObserverStub as unknown as typeof IntersectionObserver);
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {
|
||||
return false;
|
||||
},
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending promises (mocked API resolves) + effects so the loaded state
|
||||
// is rendered before snapshotting.
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function snapshot(ui: ReactNode, route = "/") {
|
||||
const { container } = render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
|
||||
await settle();
|
||||
expect(container).toMatchSnapshot();
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Fake only Date so relative/absolute times are deterministic; leave timers
|
||||
// real so setTimeout-based flushing in settle() still works.
|
||||
vi.useFakeTimers({ now: new Date("2026-06-10T13:00:00.000Z"), toFake: ["Date"] });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("screen snapshots", () => {
|
||||
it("Dashboard", async () => {
|
||||
await snapshot(<Dashboard />, "/");
|
||||
});
|
||||
it("Kanban board", async () => {
|
||||
await snapshot(<KanbanBoard />, "/kanban");
|
||||
});
|
||||
it("Sessions", async () => {
|
||||
await snapshot(<Sessions />, "/sessions");
|
||||
});
|
||||
it("Session detail", async () => {
|
||||
await snapshot(
|
||||
<Routes>
|
||||
<Route path="/sessions/:id" element={<SessionDetail />} />
|
||||
</Routes>,
|
||||
"/sessions/sess-1"
|
||||
);
|
||||
});
|
||||
it("Activity feed", async () => {
|
||||
await snapshot(<ActivityFeed />, "/activity");
|
||||
});
|
||||
it("Analytics", async () => {
|
||||
await snapshot(<Analytics />, "/analytics");
|
||||
});
|
||||
it("Workflows", async () => {
|
||||
await snapshot(<Workflows />, "/workflows");
|
||||
});
|
||||
it("Claude Config", async () => {
|
||||
await snapshot(<CcConfig />, "/cc-config");
|
||||
});
|
||||
it("Run", async () => {
|
||||
await snapshot(<Workspace />, "/run");
|
||||
});
|
||||
it("Settings", async () => {
|
||||
await snapshot(<Settings />, "/settings");
|
||||
});
|
||||
it("Not found", async () => {
|
||||
await snapshot(<NotFound />, "/nope");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user