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,262 @@
|
||||
/**
|
||||
* @file Regression test for the lane card's status badge. Every lane status
|
||||
* the server can set must have a real translated word behind
|
||||
* `t("status." + lane.status)`; before this test existed, no locale defined
|
||||
* any `status.*` key, and i18next's default missing-key behavior (return the
|
||||
* key itself) hid that from the `||` fallback, so the badge showed literal
|
||||
* text like `status.active`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard from "../LaneCard";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) },
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
id: 1,
|
||||
title: "demo",
|
||||
cwd: "/work/demo",
|
||||
branch: "lane/demo",
|
||||
kind: "adopted",
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "plan",
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LaneCard status badge", () => {
|
||||
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
|
||||
it(`renders a real word for status "${status}", not the raw key`, () => {
|
||||
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
|
||||
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
|
||||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
||||
];
|
||||
|
||||
describe("LaneCard auto: chip", () => {
|
||||
it("shows the auto chip when the detected stage is ahead of the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "tests" })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("auto: tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when the detected stage matches the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: "plan" })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("auto: plan")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when the detected stage trails the declared stage", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({
|
||||
stage: "implement",
|
||||
pipeline_nodes: pipelineNodes,
|
||||
detected_stage: "intake",
|
||||
})}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("auto: intake")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the auto chip when nothing is detected", () => {
|
||||
render(
|
||||
<LaneCard
|
||||
lane={makeLane({ stage: "plan", pipeline_nodes: pipelineNodes, detected_stage: null })}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard rebuilt layout", () => {
|
||||
const full = (over: Partial<Lane> = {}) =>
|
||||
makeLane({
|
||||
id: 7,
|
||||
title: "Rename Metric to Rule",
|
||||
kind: "managed",
|
||||
status: "running",
|
||||
liveness: "active",
|
||||
stage: "plan",
|
||||
stage_seconds: 152,
|
||||
progress: 48,
|
||||
ci_status: "green",
|
||||
pipeline_nodes: pipelineNodes,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("labels the card with the lane id", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the declared stage, the progress percentage and the time on stage", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-stage").textContent).toBe("plan");
|
||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives the progress bar a width matching the lane's progress", () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires a plain action with its own name", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
|
||||
expect(onAction).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("keeps the destructive verbs out of the card until the menu is opened", async () => {
|
||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
// A wall of red buttons makes none of them read as the dangerous one, so
|
||||
// reset/remove/purge live behind the ⋯ menu.
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-action-remove")).toBeNull();
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||||
const onAction = vi.fn();
|
||||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
await user.click(screen.getByTestId("lane-action-reset"));
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers reset only for a managed lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||||
await user.click(screen.getByTestId("lane-more"));
|
||||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("offline lane")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user