Files
AgentHub/client/src/components/lanes/__tests__/LaneCard.test.tsx
T
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00

443 lines
17 KiB
TypeScript

/**
* @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, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
git: vi.fn(),
runtime: vi.fn(),
up: vi.fn(),
down: vi.fn(),
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
integration: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
},
locks: {
list: vi.fn(),
},
},
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset();
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
// A lane with no profile is the default fixture: most lanes never run a
// stack, so the runtime strip and its button stay absent unless a test opts in.
vi.mocked(api.lanes.runtime).mockReset();
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
vi.mocked(api.lanes.integration).mockReset();
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
function makeLane(overrides: Partial<Lane> = {}): Lane {
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
// `T | undefined`, which no longer satisfies Lane's required fields. The
// base object below still lists all of them, so the cast asserts what the
// literal already proves.
return {
id: 1,
title: "demo",
cwd: "/work/demo",
branch: "lane/demo",
kind: "adopted",
tool: "claude",
source_repo: null,
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,
slot: null,
ports: {},
...overrides,
} as Lane;
}
describe("LaneCard lane tool badge", () => {
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
});
});
describe("LaneCard child worktrees", () => {
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
const onSelectLane = vi.fn();
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
render(
<LaneCard
lane={makeLane({ id: 1 })}
onAction={vi.fn()}
childWorktrees={[worktree]}
onSelectLane={onSelectLane}
/>
);
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
expect(onSelectLane).toHaveBeenCalledWith(8);
});
it("renders nothing when there are no child worktrees", () => {
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-child-worktrees")).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 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 progress percentage", () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.getByText("48%")).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("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
render(<LaneCard lane={full()} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
await userEvent.setup().click(screen.getByTestId("lane-more"));
expect(screen.getByTestId("lane-action-reset")).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 the ⋯ menu 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();
// An adopted lane has no worktree to reset, so there is no ⋯ to open.
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-more")).toBeNull();
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({ id: 2 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-2")).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({ id: 3 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-3")).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();
}
});
});
describe("LaneCard — the lane's own application stack", () => {
const upRuntime = {
available: true as const,
provisioned: true as const,
slot: 3,
kind: "managed" as const,
hooks: ["boot", "health"],
profileDir: "/work/demo/.agenthub/profile",
services: [{ name: "web", pid: 4242, alive: true }],
ports: { api: { port: 8003, expected: 8003, listening: true } },
steppedAside: false,
up: true,
healthy: true,
logs: ["boot.log"],
logDir: "/lanes/.state/lane3/logs",
lastError: null,
};
it("shows nothing at all for a lane whose repo declares no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
});
it("lists each declared port with its listening state", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
expect(screen.getByText(":8003")).toBeInTheDocument();
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
});
it("flags a port that stepped aside from its base, showing the expected number", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
steppedAside: true,
ports: { api: { port: 8103, expected: 8003, listening: true } },
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText(":8103")).toBeInTheDocument();
expect(screen.getByText(/8003/)).toBeInTheDocument();
});
it("surfaces the last boot error when nothing is streaming", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
...upRuntime,
up: false,
healthy: false,
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
});
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
});
});
describe("LaneCard — named locks", () => {
it("shows a lock badge when this lane holds a named lock", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
expect(screen.getByText(/lock/i)).toBeInTheDocument();
});
it("shows no lock badge when locks belong to a different lane", async () => {
vi.mocked(api.locks.list).mockResolvedValue({
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
});
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
});
});
describe("LaneCard — integration badges", () => {
beforeEach(() => {
// Only the fields this describe block's assertions read; `as LaneRuntime`
// keeps the double from having to restate a shape the component never
// touches here.
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.agenthub/profile",
ports: {},
} as unknown as LaneRuntime);
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
Promise.resolve({ enabled: name === "tracker" })
);
});
it("shows integration badges reflecting each toggle's state", async () => {
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
const tracker = await screen.findByTestId("lane-integration-tracker");
const devQc = await screen.findByTestId("lane-integration-dev_qc");
expect(tracker.className).toContain("status-success");
expect(devQc.className).not.toContain("status-success");
});
it("hides integration badges when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
});
});
describe("LaneToolsMenu", () => {
beforeEach(() => {
vi.mocked(api.lanes.runtime).mockResolvedValue({
available: true as const,
provisioned: true as const,
up: false,
slot: 1,
profileDir: "/work/demo/.agenthub/profile",
ports: {},
} as unknown as LaneRuntime);
});
it("clicking Install agents calls the API and shows the result", async () => {
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
installed: ["qc-local.md", "senior-gate-reviewer.md"],
});
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-agents-install"));
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
});
it("clicking Sync MCP calls the API and shows the result", async () => {
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
servers: ["playwright"],
profilesSeeded: [],
});
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-mcp-sync"));
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
});
it("clicking Check dev sync reports a clean result", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 0,
devDelta: ["a.txt", "b.txt"],
overlap: [],
});
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-sync-check"));
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
});
it("clicking Check dev sync reports a migration collision", async () => {
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
code: 5,
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
});
render(<LaneToolsMenu lane={makeLane()} />);
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
await userEvent.click(await screen.findByTestId("lane-sync-check"));
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
});
it("renders nothing when the lane has no profile", async () => {
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});