/** * @file LaneConsolePane.test.tsx * @description Test suite for the LaneConsolePane component * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { 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"; 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("../../../lib/api", () => ({ api: { lanes: { ensure: vi.fn(), action: vi.fn(), list: vi.fn(), }, run: { list: vi.fn().mockResolvedValue({ items: [] }), history: vi.fn().mockResolvedValue({ items: [] }), get: vi.fn(), start: vi.fn(), }, }, RUN_MODEL_CHOICES: [], RUN_MODEL_CHOICES_CODEX: [], 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, showLaneSelector: false, onLaneIdChange: vi.fn(), onLaneCreated: vi.fn(), binaryStatus: { claude: { found: true, path: "/usr/local/bin/claude" }, codex: { found: true, path: "/usr/local/bin/codex" }, }, cwdSuggestions: [], activeRuns: { items: [] }, wsConnected: true, }; } describe("LaneConsolePane", () => { beforeEach(() => { vi.clearAllMocks(); }); it("starts a run through /api/lanes//start, not /api/run/start", async () => { (api.lanes.action as ReturnType).mockResolvedValue({ lane: { ...LANE, run_id: "run-1" }, }); (api.run.get as ReturnType).mockResolvedValue({ id: "run-1", laneId: 1, status: "running", cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: null, promptPreview: null, }); render(); // Set cwd and prompt const cwdInput = screen.getByPlaceholderText(/type to search/i); fireEvent.change(cwdInput, { target: { value: "/workspace/a" } }); const promptTextarea = screen.getByPlaceholderText(/ask claude/i); fireEvent.change(promptTextarea, { target: { value: "test prompt" } }); // Find and click the Run button (the main start button in RunSetup) fireEvent.click(screen.getByRole("button", { name: /^run$/i })); await waitFor(() => expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object)) ); expect(api.run.start).not.toHaveBeenCalled(); await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1") ); }); it("returns to the setup form when the pane exits, without a New run click", async () => { (api.lanes.action as ReturnType).mockResolvedValue({ lane: { ...LANE, run_id: "run-1" }, }); (api.run.get as ReturnType).mockResolvedValue({ id: "run-1", laneId: 1, status: "running", cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: null, promptPreview: null, }); render(); fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } }); fireEvent.click(screen.getByRole("button", { name: /^run$/i })); await screen.findByTestId("terminal-view"); fireEvent.click(screen.getByTestId("fake-pane-exit")); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument(); }); 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 its setup form, not the previous // lane's terminal. rerender(); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); }); it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => { // Split view restores its pane lanes from localStorage, so a pane can // render with a laneId before GET /api/lanes has answered. laneId never // changes afterwards — only the resolved lane does. const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" }; const { rerender } = render(); const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement; expect(cwdInput.value).toBe("/home/tester"); rerender(); await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd)); }); it("shows a lane dropdown only when showLaneSelector is true", () => { const { rerender } = render(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); rerender(); expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument(); }); it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => { render(); expect(screen.getByTestId("console-body")).toBeInTheDocument(); expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument(); }); it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => { // Picking a pipeline used to write its driving slash command (claude) or a // stage checklist (codex) into the box — an extra prompt sent on start. const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" }; const codexLane = { ...LANE, tool: "codex" as const, pipeline_skill: "/agenthub:ship-feature-lane", pipeline_nodes: [ { id: "implement", label: "Implement", icon: "code", gate: false, state: "pending" as const, }, { id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const }, ], }; const props = { ...baseProps(), lanes: [claudeLane] }; const { rerender } = render(); const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; await waitFor(() => expect(prompt.value).toBe("")); // A typed requirement still survives a template switch. fireEvent.change(prompt, { target: { value: "add a button" } }); rerender( ); await waitFor(() => expect(prompt.value).toBe("add a button")); rerender(); await waitFor(() => expect(prompt.value).toBe("add a button")); }); it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => { const HISTORY_ITEM = { id: "run-old", session_id: "sess-old", cwd: "/workspace/a", model: null, permission_mode: null, effort: null, resume_session_id: null, prompt_preview: "earlier prompt", status: "abandoned" as const, exit_code: null, started_at: new Date(1000).toISOString(), ended_at: new Date(2000).toISOString(), isLive: false, tool: "claude" as const, }; (api.run.history as ReturnType).mockResolvedValue({ items: [HISTORY_ITEM] }); (api.lanes.action as ReturnType).mockRejectedValue( // Matches the real shape `request()` throws: a plain, human-readable // `.message` plus a `.code` a caller actually branches on. Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 }) ); (api.lanes.list as ReturnType).mockResolvedValue({ lanes: [{ ...LANE, run_id: "run-live" }], }); (api.run.get as ReturnType).mockResolvedValue({ id: "run-live", laneId: 1, status: "running", cwd: "/workspace/a", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: null, promptPreview: null, }); render( ); fireEvent.click(await screen.findByRole("button", { name: /active runs/i })); const resumeButtons = await screen.findAllByRole("button", { name: "Resume existing session", }); fireEvent.click(resumeButtons[0]!); await waitFor(() => expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live") ); expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument(); }); it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { render(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); expect(screen.queryByTestId("console-body")).not.toBeInTheDocument(); }); it("lists only this lane's runs and sessions in Active runs, not every lane's", () => { const run = (id: string, laneId: number, promptPreview: string) => ({ id, laneId, status: "running", cwd: "/workspace/x", model: null, permissionMode: null, effort: null, resumeSessionId: null, sessionId: null, startedAt: "2026-09-24T01:00:00Z", promptPreview, }); const session = (id: string, cwd: string, name: string) => ({ id, cwd, name, model: null, source: "local", started_at: "2026-09-24T01:00:00Z", }) as never; render( ); fireEvent.click(screen.getByRole("button", { name: /2 active runs/ })); expect(screen.getByText("mine")).toBeTruthy(); expect(screen.getByText("my session")).toBeTruthy(); expect(screen.queryByText("other lane's")).toBeNull(); expect(screen.queryByText("b session")).toBeNull(); expect(screen.queryByText("nested session")).toBeNull(); expect(screen.queryByText("sibling prefix")).toBeNull(); }); });