/** * @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 { LaneConsolePane } from "../LaneConsolePane"; import { api } from "../../../lib/api"; import type { Lane } from "../../../lib/types"; vi.mock("../TerminalView", () => ({ TerminalView: ({ runId }: { runId: string }) => (
), })); 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("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("pre-fills the prompt with the pipeline's driving skill, and keeps a typed prompt when the template changes", async () => { const lane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" }; const props = { ...baseProps(), lanes: [lane] }; const { rerender } = render(); const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature ")); // Untouched prefill follows the template switch. const laneShip = { ...lane, pipeline_skill: "/agenthub:ship-feature-lane" }; rerender(); await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane ")); // A typed requirement is never eaten by a later template switch. fireEvent.change(prompt, { target: { value: "/agenthub:ship-feature-lane add a button" } }); rerender(); await waitFor(() => expect(prompt.value).toBe("/agenthub:ship-feature-lane add a button")); }); it("never pre-fills a claude slash command into a codex pane", async () => { // pipeline_skill is a Claude Code slash command. Codex would take it as // literal prose and run nothing, which is how a codex lane sits for half an // hour with an untouched pipeline map. No pipeline_nodes here either, so // there's nothing to build a stage checklist from — the box stays empty. const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/agenthub:ship-feature-lane" }; render(); const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; await waitFor(() => expect(prompt.value).toBe("")); }); it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => { // Codex has no slash commands, so it can't run pipeline_skill as-is — it // gets the same priming Claude gets (an opening nudge to declare stage) // as plain text instead, naming this lane's actual node ids. const lane = { ...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 }, ], }; render(); const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; await waitFor(() => expect(prompt.value).toContain("agenthub stage --tool codex")); expect(prompt.value).toContain("implement -> tests"); expect(prompt.value).not.toContain("/agenthub:ship-feature-lane"); }); 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(); }); });