/** * @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ĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } 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(), }, run: { kill: vi.fn(), start: 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 { // `as Lane`: spreading a Partial 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( ); expect(screen.getByText("Codex")).toBeInTheDocument(); rerender(); 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( ); 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(); 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 = {}) => 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(); expect(screen.getByTestId("lane-card-7")).toBeInTheDocument(); }); // The pipeline map below this card draws the same progress as named nodes, // so the card no longer repeats it as a bar and a percent. it("leaves progress to the pipeline map rather than drawing its own bar", () => { render(); expect(screen.queryByTestId("lane-progress-fill")).toBeNull(); expect(screen.queryByText("48%")).toBeNull(); }); it("surfaces a needs-you message", () => { render(); expect(screen.getByText(/waiting on approval/)).toBeInTheDocument(); }); it("keeps reset behind the ⋯ menu rather than showing it directly", async () => { render(); 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(); 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(); 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(); 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, files: [ { path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 }, { path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 }, { path: "scratch.txt", status: "??", added: null, deleted: null }, ], // Tracking facts ride along on the same endpoint; a branch that has never // been pushed reports nulls rather than zeros. hasRemote: true, upstream: null, ahead: null, behind: null, }; it("renders the live branch, head, subject and uncommitted counts", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); render(); 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("names the files behind the counts on hover, with each one's line delta", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); render(); const counts = await screen.findByTestId("lane-git-counts"); // Hidden until asked for: the row is a one-line summary until you hover. expect(screen.queryByTestId("lane-git-files")).toBeNull(); fireEvent.mouseEnter(counts); const panel = screen.getByTestId("lane-git-files"); expect(panel.textContent).toContain("alerts.js"); expect(panel.textContent).toContain("+12"); expect(panel.textContent).toContain("−3"); // Totals across the listed files. expect(panel.textContent).toContain("+13"); // An untracked file has no delta to report and says so rather than "+0". expect(panel.textContent).toContain("new"); expect(panel.textContent).toContain("3 files changed"); // Closing is delayed so the pointer can travel into the panel and scroll // it — the panel is portalled to the body, away from its trigger. fireEvent.mouseLeave(counts); expect(screen.queryByTestId("lane-git-files")).not.toBeNull(); await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull()); }); it("stays open while the pointer is inside the panel", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); render(); const counts = await screen.findByTestId("lane-git-counts"); fireEvent.mouseEnter(counts); fireEvent.mouseLeave(counts); // The pointer landed on the panel before the pending close fired. fireEvent.mouseEnter(screen.getByTestId("lane-git-files")); await new Promise((r) => setTimeout(r, 200)); expect(screen.queryByTestId("lane-git-files")).not.toBeNull(); fireEvent.mouseLeave(screen.getByTestId("lane-git-files")); await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull()); }); it("says how many files the server-side cap left out", async () => { // 40 uncommitted files, 3 listed: the counts are exact, the listing is not. vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 }); render(); fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts")); expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more"); }); it("omits the uncommitted line when the tree is clean", async () => { vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 }); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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("clicking Create/update readme opens the lane's terminal on the docs skill", async () => { // Moved here from the Add-lane modal: a skill only runs inside a CLI // session, and writing the docs is worth re-running long after creation. vi.mocked(api.run.start).mockResolvedValue({} as never); render(); await userEvent.click(await screen.findByTestId("lane-tools-menu")); await userEvent.click(await screen.findByTestId("lane-write-docs")); await waitFor(() => expect(api.run.start).toHaveBeenCalledWith({ laneId: 1, cwd: "/work/demo", initialPrompt: "/sg-all:sg-md-writing", }) ); }); it("hides Create/update readme on a codex lane, which has no slash commands", async () => { render(); await userEvent.click(await screen.findByTestId("lane-tools-menu")); expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument(); expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument(); }); it("renders nothing when the lane has no profile", async () => { vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); const { container } = render(); 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(); 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(); 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", restart_required: false, run_id: null, }); render(); 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("offers a restart when a live pane still holds the old router's models", async () => { // Claude Code re-applies `env` to a running session but keeps the model it // resolved at startup, so the switch is only half-applied until the pane is // restarted — the server says so and the row must act on it. 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", restart_required: true, run_id: "agenthub-lane-1", }); vi.mocked(api.run.kill).mockResolvedValue({ ok: true }); vi.mocked(api.run.start).mockResolvedValue({} as never); render(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", ]); const restart = await screen.findByRole("button", { name: "Restart" }); await userEvent.click(restart); await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1")); // Started by lane id alone: replaying the old run's --model would name a // model of the router the user just left. expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 }); }); it("leaves the restart button out when no pane is running", 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", restart_required: false, run_id: null, }); render(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", ]); await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled()); expect(screen.queryByRole("button", { name: "Restart" })).toBeNull(); }); it("does not carry a pick made on one lane back to the lane selected before it", async () => { // One LaneRouterRow instance serves every lane (it lives in the Workspace's // lane-detail header), so its selected value is component state that // outlives a lane switch unless the sync effect puts the new lane's own // profile back. vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); vi.mocked(api.lanes.routerApply).mockResolvedValue({ tool: "claude", file: "/work/b/.claude/settings.local.json", profile: "9router", restart_required: false, run_id: null, }); const laneA = makeLane({ id: 1, router_profile: null }); const laneB = makeLane({ id: 2, router_profile: null }); const { rerender } = render(); await screen.findByDisplayValue("Native (Claude / Codex)"); rerender(); await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]); await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router")); rerender(); expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); }); it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => { // The row is one instance shared by every lane, so an apply still in flight // when the user moves on would paint its rollback and its error on whoever // is on screen by then — lane B's failure changing lane A's picker. vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); let reject: (e: Error) => void = () => {}; vi.mocked(api.lanes.routerApply).mockImplementation( () => new Promise((_, r) => (reject = r)) as ReturnType ); const laneA = makeLane({ id: 1, router_profile: null }); const laneB = makeLane({ id: 2, router_profile: null }); const { rerender } = render(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", ]); await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router")); rerender(); await act(async () => { reject(new Error("EBADJSON")); await Promise.resolve(); }); expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); expect(screen.queryByTitle("EBADJSON")).toBeNull(); // And the picker must not be left locked by a request for another lane. expect(screen.getByRole("combobox")).not.toBeDisabled(); }); 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(); await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [ "9router", ]); await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument()); expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument(); }); });