/** * @file LaneConsolePane.test.tsx * @description Test suite for the LaneConsolePane component: a tab's body shows * its lane's terminal or an empty state whose New session goes to the * workspace home — no run form and no header of its own. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { LaneConsolePane } from "../LaneConsolePane"; import { api } from "../../../lib/api"; import type { Lane } from "../../../lib/types"; import { __resetAiTools } from "../../../hooks/useAiTools"; import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi"; vi.mock("../TerminalView", () => ({ // The exit button stands in for the server's `exit` frame (a double Ctrl+C // out of claude), which the real TerminalView reports through `onExit`. TerminalView: ({ runId, onExit }: { runId: string; onExit?: () => void }) => (
), })); vi.mock("../PaneChat", () => ({ PaneChat: ({ sessionId }: { sessionId: string }) => (
), })); vi.mock("../../../lib/api", () => ({ api: { me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }), savePrefs: vi.fn(), }, lanes: { ensure: vi.fn(), action: vi.fn(), list: vi.fn(), }, aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) }, aiTools: { list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }), models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }), }, run: { list: vi.fn().mockResolvedValue({ items: [] }), get: vi.fn(), start: vi.fn(), }, }, RUN_MODEL_CHOICES: [], RUN_EFFORT_CHOICES: [], })); const LANE: Lane = { id: 1, router_profile: null, title: "demo", cwd: "/workspace/a", branch: null, kind: "adopted", tool: "claude", source_repo: null, pipeline: "default", session_id: null, run_id: null, stage: "idle", 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" as Lane["liveness"], detected_stage: null, detected_signal: null, slot: null, ports: {}, active_feature_id: null, }; function baseProps() { return { lanes: [LANE], laneId: 1, binaryStatus: { claude: { found: true, path: "/usr/local/bin/claude" }, codex: { found: true, path: "/usr/local/bin/codex" }, antigravity: { found: true, path: "/usr/local/bin/agy" }, }, activeRuns: { items: [] }, }; } describe("LaneConsolePane", () => { beforeEach(() => { vi.clearAllMocks(); }); it("has no run form: an empty pane's New session goes to the workspace home", () => { const onNewSession = vi.fn(); render(); expect(screen.getByTestId("pane-no-run")).toBeInTheDocument(); expect(screen.queryByRole("textbox")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "New session" })); expect(onNewSession).toHaveBeenCalledOnce(); expect(api.lanes.action).not.toHaveBeenCalled(); }); it("has no header: no title, no Active runs, no New run - the tab bar is the header", async () => { const live = { id: "agenthub-lane-1", laneId: 1, status: "running" as const, cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: null, promptPreview: null, tool: "claude" as const, }; const onRunExit = vi.fn(); render( ); await screen.findByTestId("terminal-view"); expect(screen.queryByRole("heading")).toBeNull(); expect(screen.queryByRole("button", { name: "New run" })).toBeNull(); expect(screen.queryByRole("button", { name: /active run/i })).toBeNull(); // The run exits: the tab bar is told, and the pane is left empty. fireEvent.click(screen.getByTestId("fake-pane-exit")); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); expect(onRunExit).toHaveBeenCalledOnce(); expect(screen.getByTestId("pane-no-run")).toBeInTheDocument(); }); it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => { // Unmounting the terminal would drop the attach the chat sends through. vi.mocked(api.me.prefs).mockResolvedValueOnce({ prefs: { "agenthub.chatUi": "1" }, onboardedAt: null, }); __resetChatUi(); const live = { id: "agenthub-lane-1", laneId: 1, status: "running" as const, cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: "s-1", startedAt: null, promptPreview: null, tool: "claude" as const, }; render(); expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1"); expect(screen.getByTestId("terminal-view")).toBeInTheDocument(); __resetChatUi(); }); it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => { vi.mocked(api.me.prefs).mockResolvedValueOnce({ prefs: { "agenthub.chatUi": "1" }, onboardedAt: null, }); __resetChatUi(); const live = { id: "agenthub-lane-1", laneId: 1, status: "running" as const, cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: "s-1", startedAt: null, promptPreview: null, tool: "claude" as const, }; render(); expect(await screen.findByTestId("pane-chat")).toBeInTheDocument(); // Seen once, the session keeps chat: turning the default off leaves it alone. await waitFor(() => expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true }) ); vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null }); await act(() => saveChatUi(false)); expect(screen.getByTestId("pane-chat")).toBeInTheDocument(); // Its own toggle switches it. act(() => setSessionChat("s-1", false)); expect(screen.queryByTestId("pane-chat")).toBeNull(); __resetChatUi(); }); it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => { const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" }; const run = (id: string, laneId: number) => ({ id, laneId, status: "running" as const, cwd: null, model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: null, promptPreview: null, tool: "claude" as const, }); const props = { ...baseProps(), lanes: [LANE, LANE2], activeRuns: { items: [run("agenthub-lane-1", 1), run("agenthub-lane-2", 2)] }, }; const { rerender } = render(); await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-1") ); rerender(); await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2") ); // A lane with no live run falls back to an empty pane, not the previous // lane's terminal. rerender(); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); }); it("renders the console body when laneId is null (fresh install)", () => { render(); expect(screen.getByTestId("console-body")).toBeInTheDocument(); }); describe("tabs on one lane", () => { const run = (id: string, promptPreview = id) => ({ id, laneId: 1, status: "running" as const, cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: "2026-09-24T01:00:00Z", promptPreview, tool: "claude" as const, }); it("attaches a pane only to its own slot's session", async () => { const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] }; render(); await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute( "data-run-id", "agenthub-lane-1-2" ) ); }); }); it("attaches to a run picked in the sidebar without starting or resuming anything", async () => { vi.mocked(api.run.get).mockResolvedValue({ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: "s-9", startedAt: null, promptPreview: null, tool: "claude", }); render( ); await screen.findByTestId("terminal-view"); expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-2"); expect(api.lanes.action).not.toHaveBeenCalled(); expect(api.run.start).not.toHaveBeenCalled(); }); it("attaches once the lane arrives on a cold load, and ignores another lane's run", async () => { vi.mocked(api.run.get).mockResolvedValue({ id: "agenthub-lane-1-1", laneId: 1, slot: 1, status: "running", cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: "s-9", startedAt: null, promptPreview: null, tool: "claude", }); const req = { runId: "agenthub-lane-1-1", nonce: "k2" }; const { rerender } = render( ); // The lane list lands only after the pane mounted (and after any early // attach would have resolved) — the switch effect must not win. await new Promise((r) => setTimeout(r, 20)); rerender( ); await screen.findByTestId("terminal-view"); expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-1"); vi.mocked(api.run.get).mockClear(); rerender( ); await new Promise((r) => setTimeout(r, 10)); expect(api.run.get).not.toHaveBeenCalled(); }); });