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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user