/** * @file TerminalPanel.test.tsx * @description Unit tests for the TerminalPanel component — multi-tab scenarios */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import type { ReactElement } from "react"; // ========================================== // Mocks // ========================================== // Mock XTerminal to avoid xterm.js DOM dependencies in panel tests vi.mock("./XTerminal", () => ({ XTerminal: vi.fn( ({ sessionId, isVisible, sessionStatus, }: { sessionId: string; isVisible: boolean; sessionStatus: string; }) => (
) ), })); // Mock AgentTerminal to avoid complexity in panel tests vi.mock("./AgentTerminal", () => ({ AgentTerminal: vi.fn( ({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
) ), })); // Mock useTerminalSessions const mockCreateSession = vi.fn(); const mockCloseSession = vi.fn(); const mockRenameSession = vi.fn(); const mockSetActiveSession = vi.fn(); const mockSendInput = vi.fn(); const mockResize = vi.fn(); const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // Mutable state for the mock — tests update these let mockSessions = new Map< string, { sessionId: string; name: string; status: "active" | "exited"; exitCode?: number; } >(); let mockActiveSessionId: string | null = null; let mockIsConnected = false; let mockConnectionError: string | null = null; vi.mock("@/hooks/useTerminalSessions", () => ({ useTerminalSessions: vi.fn(() => ({ sessions: mockSessions, activeSessionId: mockActiveSessionId, isConnected: mockIsConnected, connectionError: mockConnectionError, createSession: mockCreateSession, closeSession: mockCloseSession, renameSession: mockRenameSession, setActiveSession: mockSetActiveSession, sendInput: mockSendInput, resize: mockResize, registerOutputCallback: mockRegisterOutputCallback, })), })); // Mock useAgentStream const mockDismissAgent = vi.fn(); let mockAgents = new Map< string, { agentId: string; agentType: string; status: "spawning" | "running" | "completed" | "error"; outputLines: string[]; startedAt: number; } >(); let mockAgentStreamConnected = false; vi.mock("@/hooks/useAgentStream", () => ({ useAgentStream: vi.fn(() => ({ agents: mockAgents, isConnected: mockAgentStreamConnected, connectionError: null, dismissAgent: mockDismissAgent, })), })); import { TerminalPanel } from "./TerminalPanel"; // ========================================== // Helpers // ========================================== function setTwoSessions(): void { mockSessions = new Map([ ["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }], ["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }], ]); mockActiveSessionId = "session-1"; } // ========================================== // Tests // ========================================== describe("TerminalPanel", () => { const onClose = vi.fn(); beforeEach(() => { vi.clearAllMocks(); mockSessions = new Map(); mockActiveSessionId = null; mockIsConnected = false; mockConnectionError = null; mockRegisterOutputCallback.mockReturnValue(vi.fn()); mockAgents = new Map(); mockAgentStreamConnected = false; }); // ========================================== // Rendering // ========================================== describe("rendering", () => { it("renders the terminal panel", () => { render(() as ReactElement); expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument(); }); it("renders with height 280 when open", () => { render(() as ReactElement); const panel = screen.getByRole("region", { name: "Terminal panel" }); expect(panel).toHaveStyle({ height: "280px" }); }); it("renders with height 0 when closed", () => { const { container } = render( () as ReactElement ); const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); expect(panel).toHaveStyle({ height: "0px" }); }); it("renders empty state when no sessions exist", () => { render(() as ReactElement); // No XTerminal instances should be mounted expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument(); }); it("shows connecting message in empty state when not connected", () => { mockIsConnected = false; render(() as ReactElement); expect(screen.getByText("Connecting...")).toBeInTheDocument(); }); it("shows creating message in empty state when connected", () => { mockIsConnected = true; render(() as ReactElement); expect(screen.getByText("Creating terminal...")).toBeInTheDocument(); }); }); // ========================================== // Tab bar from sessions // ========================================== describe("tab bar", () => { it("renders a tab for each session", () => { setTwoSessions(); render(() as ReactElement); expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument(); }); it("marks the active session tab as selected", () => { setTwoSessions(); mockActiveSessionId = "session-2"; render(() as ReactElement); expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute( "aria-selected", "true" ); expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute( "aria-selected", "false" ); }); it("calls setActiveSession when a tab is clicked", () => { setTwoSessions(); render(() as ReactElement); fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" })); expect(mockSetActiveSession).toHaveBeenCalledWith("session-2"); }); it("has tablist role on the tab bar", () => { render(() as ReactElement); expect(screen.getByRole("tablist")).toBeInTheDocument(); }); }); // ========================================== // New tab button // ========================================== describe("new tab button", () => { it("renders the new tab button", () => { render(() as ReactElement); expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument(); }); it("calls createSession when new tab button is clicked", () => { setTwoSessions(); render(() as ReactElement); fireEvent.click(screen.getByRole("button", { name: "New terminal tab" })); expect(mockCreateSession).toHaveBeenCalledWith( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect.objectContaining({ name: expect.any(String) }) ); }); }); // ========================================== // Per-tab close button // ========================================== describe("per-tab close button", () => { it("renders a close button for each tab", () => { setTwoSessions(); render(() as ReactElement); expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument(); }); it("calls closeSession with the correct sessionId when tab close is clicked", () => { setTwoSessions(); render(() as ReactElement); fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" })); expect(mockCloseSession).toHaveBeenCalledWith("session-1"); }); }); // ========================================== // Panel close button // ========================================== describe("panel close button", () => { it("renders the close panel button", () => { render(() as ReactElement); expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument(); }); it("calls onClose when close panel button is clicked", () => { render(() as ReactElement); fireEvent.click(screen.getByRole("button", { name: "Close terminal" })); expect(onClose).toHaveBeenCalledTimes(1); }); }); // ========================================== // Multi-tab XTerminal rendering // ========================================== describe("multi-tab terminal rendering", () => { it("renders an XTerminal for each session", () => { setTwoSessions(); render(() as ReactElement); const terminals = screen.getAllByTestId("mock-xterminal"); expect(terminals).toHaveLength(2); }); it("shows the active session terminal as visible", () => { setTwoSessions(); mockActiveSessionId = "session-1"; render(() as ReactElement); const terminal1 = screen .getAllByTestId("mock-xterminal") .find((el) => el.getAttribute("data-session-id") === "session-1"); expect(terminal1).toHaveAttribute("data-visible", "true"); }); it("hides inactive session terminals", () => { setTwoSessions(); mockActiveSessionId = "session-1"; render(() as ReactElement); const terminal2 = screen .getAllByTestId("mock-xterminal") .find((el) => el.getAttribute("data-session-id") === "session-2"); expect(terminal2).toHaveAttribute("data-visible", "false"); }); it("passes sessionStatus to XTerminal", () => { mockSessions = new Map([ [ "session-1", { sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 }, ], ]); mockActiveSessionId = "session-1"; render(() as ReactElement); const terminal = screen.getByTestId("mock-xterminal"); expect(terminal).toHaveAttribute("data-status", "exited"); }); it("passes isVisible=false to all terminals when panel is closed", () => { setTwoSessions(); const { container } = render( () as ReactElement ); const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]'); terminals.forEach((terminal) => { expect(terminal).toHaveAttribute("data-visible", "false"); }); }); }); // ========================================== // Inline tab rename // ========================================== describe("tab rename", () => { it("shows a rename input when a tab is double-clicked", () => { setTwoSessions(); render(() as ReactElement); fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument(); }); it("calls renameSession when rename input loses focus", () => { setTwoSessions(); render(() as ReactElement); fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); const input = screen.getByTestId("tab-rename-input"); fireEvent.change(input, { target: { value: "Custom Shell" } }); fireEvent.blur(input); expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell"); }); it("calls renameSession when Enter is pressed in the rename input", () => { setTwoSessions(); render(() as ReactElement); fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); const input = screen.getByTestId("tab-rename-input"); fireEvent.change(input, { target: { value: "New Name" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name"); }); it("cancels rename when Escape is pressed", () => { setTwoSessions(); render(() as ReactElement); fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); const input = screen.getByTestId("tab-rename-input"); fireEvent.change(input, { target: { value: "Abandoned Name" } }); fireEvent.keyDown(input, { key: "Escape" }); expect(mockRenameSession).not.toHaveBeenCalled(); expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument(); }); }); // ========================================== // Connection error banner // ========================================== describe("connection error", () => { it("shows a connection error banner when connectionError is set", () => { mockConnectionError = "WebSocket connection failed"; render(() as ReactElement); const alert = screen.getByRole("alert"); expect(alert).toBeInTheDocument(); expect(alert).toHaveTextContent(/WebSocket connection failed/); }); it("does not show the error banner when connectionError is null", () => { mockConnectionError = null; render(() as ReactElement); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); // ========================================== // Accessibility // ========================================== describe("accessibility", () => { it("has aria-hidden=true when closed", () => { const { container } = render( () as ReactElement ); const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); expect(panel).toHaveAttribute("aria-hidden", "true"); }); it("has aria-hidden=false when open", () => { render(() as ReactElement); const panel = screen.getByRole("region", { name: "Terminal panel" }); expect(panel).toHaveAttribute("aria-hidden", "false"); }); }); // ========================================== // Auto-create session // ========================================== describe("auto-create first session", () => { it("calls createSession when connected and no sessions exist", () => { mockIsConnected = true; mockSessions = new Map(); render(() as ReactElement); expect(mockCreateSession).toHaveBeenCalledWith( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect.objectContaining({ name: expect.any(String) }) ); }); it("does not call createSession when sessions already exist", () => { mockIsConnected = true; setTwoSessions(); render(() as ReactElement); expect(mockCreateSession).not.toHaveBeenCalled(); }); it("does not call createSession when not connected", () => { mockIsConnected = false; mockSessions = new Map(); render(() as ReactElement); expect(mockCreateSession).not.toHaveBeenCalled(); }); it("does not call createSession when panel is closed", () => { mockIsConnected = true; mockSessions = new Map(); render(() as ReactElement); expect(mockCreateSession).not.toHaveBeenCalled(); }); }); // ========================================== // Agent tab integration // ========================================== describe("agent tab integration", () => { function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void { mockAgents = new Map([ [ "agent-1", { agentId: "agent-1", agentType: "worker", status, outputLines: ["Hello from agent\n"], startedAt: Date.now() - 3000, }, ], ]); } it("renders an agent tab when an agent is active", () => { setOneAgent("running"); render(() as ReactElement); expect(screen.getAllByTestId("agent-tab")).toHaveLength(1); }); it("renders no agent tabs when agents map is empty", () => { mockAgents = new Map(); render(() as ReactElement); expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument(); }); it("agent tab button has the agent type as label", () => { setOneAgent("running"); render(() as ReactElement); expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument(); }); it("agent tab has role=tab", () => { setOneAgent("running"); render(() as ReactElement); expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument(); }); it("shows dismiss button for completed agents", () => { setOneAgent("completed"); render(() as ReactElement); expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument(); }); it("shows dismiss button for error agents", () => { setOneAgent("error"); render(() as ReactElement); expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument(); }); it("does not show dismiss button for running agents", () => { setOneAgent("running"); render(() as ReactElement); expect( screen.queryByRole("button", { name: "Dismiss worker agent" }) ).not.toBeInTheDocument(); }); it("does not show dismiss button for spawning agents", () => { setOneAgent("spawning"); render(() as ReactElement); expect( screen.queryByRole("button", { name: "Dismiss worker agent" }) ).not.toBeInTheDocument(); }); it("calls dismissAgent when dismiss button is clicked", () => { setOneAgent("completed"); render(() as ReactElement); fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" })); expect(mockDismissAgent).toHaveBeenCalledWith("agent-1"); }); it("renders AgentTerminal when agent tab is active", () => { setOneAgent("running"); render(() as ReactElement); // Click the agent tab to make it active fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" })); // AgentTerminal should be rendered (mock shows mock-agent-terminal) expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument(); }); it("shows a divider between terminal and agent tabs", () => { setTwoSessions(); setOneAgent("running"); render(() as ReactElement); // The divider div is aria-hidden; check it's present in the DOM const tablist = screen.getByRole("tablist"); const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]'); expect(divider).toBeInTheDocument(); }); it("agent tabs show correct data-agent-status", () => { setOneAgent("running"); render(() as ReactElement); const tab = screen.getByTestId("agent-tab"); expect(tab).toHaveAttribute("data-agent-status", "running"); }); it("empty state not shown when agents exist but no terminal sessions", () => { mockSessions = new Map(); setOneAgent("running"); mockIsConnected = false; render(() as ReactElement); expect(screen.queryByText("Connecting...")).not.toBeInTheDocument(); }); }); });