Files
AgentHub/client/src/components/lanes/__tests__/LaneCard.test.tsx
T
nntrivi2001 27923db4e0 refactor(lanes): compact the lane strip tile and the lane-detail header
The Workspace lane panel spent four rows saying what fits in two.

Strip tile: the stage badges move onto the id row and the progress bar
and percent go away — the detail panel's pipeline map right below shows
the same progress with the stage names attached.

Lane detail: the pipeline picker, stage badges, feature picker, router
select and the remove button merge into the single header row instead of
a second row under it, and the pipeline's name is no longer interpolated
into `laneHeader` — the picker beside it already says it. The git block
collapses from four lines to one and drops the cwd, which the header
above it already spells out.

The router select is relabelled Model, moves out of LaneCard into that
header row (`LaneRouterRow` is now exported), and reports its apply
outcome as a ✓/⚠ marker with the message in a tooltip rather than a
wrapped sentence that pushes every row below it down.

Drops the tracker / dev QC / CI wait badge row and everything feeding it
(`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*`
strings): two of the three toggles have no consumer at all, and a badge
reporting a toggle nothing acts on costs a row of height to say nothing.
`agenthub lanes integration` and the API route are untouched.
2026-09-17 09:41:03 +07:00

474 lines
18 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, LaneRouterRow } 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: [] }),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
syncBaseCheck: vi.fn(),
routerApply: vi.fn(),
},
locks: {
list: vi.fn(),
},
aiRouters: {
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 });
// Only the built-in profile by default: a user who never opened the AI Router
// tab has nothing to switch between, so the row stays out of every other test.
vi.mocked(api.aiRouters.list).mockReset();
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
});
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("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();
});
});
describe("LaneRouterRow — the Model picker", () => {
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
it("stays out of the card when native is the only profile", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
render(<LaneRouterRow lane={makeLane()} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("lane-router-1")).toBeNull();
});
it("shows the lane's current profile selected", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("applies a switch through the router route, not a lane PATCH", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/demo/.claude/settings.local.json",
profile: "9router",
});
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
});
it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
render(<LaneRouterRow lane={makeLane()} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
});