diff --git a/apps/web/src/components/terminal/TerminalPanel.test.tsx b/apps/web/src/components/terminal/TerminalPanel.test.tsx index 3ee9d83..7fc5902 100644 --- a/apps/web/src/components/terminal/TerminalPanel.test.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.test.tsx @@ -1,6 +1,6 @@ /** * @file TerminalPanel.test.tsx - * @description Unit tests for the TerminalPanel component + * @description Unit tests for the TerminalPanel component — multi-tab scenarios */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -13,27 +13,93 @@ import type { ReactElement } from "react"; // Mock XTerminal to avoid xterm.js DOM dependencies in panel tests vi.mock("./XTerminal", () => ({ - XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => ( -
- )), + XTerminal: vi.fn( + ({ + sessionId, + isVisible, + sessionStatus, + }: { + sessionId: string; + isVisible: boolean; + sessionStatus: 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, + })), })); 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(); - const onTabChange = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + mockSessions = new Map(); + mockActiveSessionId = null; + mockIsConnected = false; + mockConnectionError = null; + mockRegisterOutputCallback.mockReturnValue(vi.fn()); }); // ========================================== @@ -60,112 +126,249 @@ describe("TerminalPanel", () => { expect(panel).toHaveStyle({ height: "0px" }); }); - it("passes isVisible=true to XTerminal when open", () => { + it("renders empty state when no sessions exist", () => { render(() as ReactElement); - const xterm = screen.getByTestId("mock-xterminal"); - expect(xterm).toHaveAttribute("data-visible", "true"); + // No XTerminal instances should be mounted + expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument(); }); - it("passes isVisible=false to XTerminal when closed", () => { - const { container } = render( - () as ReactElement - ); - // Use container query since the element is inside an aria-hidden region - const xterm = container.querySelector('[data-testid="mock-xterminal"]'); - expect(xterm).toHaveAttribute("data-visible", "false"); + it("shows connecting message in empty state when not connected", () => { + mockIsConnected = false; + render(() as ReactElement); + expect(screen.getByText("Connecting...")).toBeInTheDocument(); }); - it("passes token to XTerminal", () => { - render( - () as ReactElement - ); - const xterm = screen.getByTestId("mock-xterminal"); - expect(xterm).toHaveAttribute("data-token", "my-auth-token"); + it("shows creating message in empty state when connected", () => { + mockIsConnected = true; + render(() as ReactElement); + expect(screen.getByText("Creating terminal...")).toBeInTheDocument(); }); }); // ========================================== - // Tab bar + // Tab bar from sessions // ========================================== describe("tab bar", () => { - it("renders default tabs when none provided", () => { + it("renders a tab for each session", () => { + setTwoSessions(); render(() as ReactElement); - expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument(); - }); - - it("renders custom tabs", () => { - const tabs = [ - { id: "tab1", label: "Terminal 1" }, - { id: "tab2", label: "Terminal 2" }, - ]; - render( - ( - - ) as ReactElement - ); expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument(); }); - it("marks the active tab as selected", () => { - const tabs = [ - { id: "tab1", label: "Tab 1" }, - { id: "tab2", label: "Tab 2" }, - ]; - render( - ( - - ) as ReactElement + 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" ); - const tab2 = screen.getByRole("tab", { name: "Tab 2" }); - expect(tab2).toHaveAttribute("aria-selected", "true"); }); - it("calls onTabChange when a tab is clicked", () => { - const tabs = [ - { id: "tab1", label: "Tab 1" }, - { id: "tab2", label: "Tab 2" }, - ]; - render( - ( - - ) as ReactElement - ); - fireEvent.click(screen.getByRole("tab", { name: "Tab 2" })); - expect(onTabChange).toHaveBeenCalledWith("tab2"); + 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(); }); }); // ========================================== - // Close button + // New tab button // ========================================== - describe("close button", () => { - it("renders the close 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 button is clicked", () => { + 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 // ========================================== @@ -175,8 +378,6 @@ describe("TerminalPanel", () => { const { container } = render( () as ReactElement ); - // When aria-hidden=true, testing-library role queries ignore the element's aria-label. - // Use a direct DOM query to verify the attribute. const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); expect(panel).toHaveAttribute("aria-hidden", "true"); }); @@ -186,10 +387,42 @@ describe("TerminalPanel", () => { const panel = screen.getByRole("region", { name: "Terminal panel" }); expect(panel).toHaveAttribute("aria-hidden", "false"); }); + }); - it("has tablist role on the tab bar", () => { + // ========================================== + // 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(screen.getByRole("tablist")).toBeInTheDocument(); + 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(); }); }); }); diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx index 8da853a..1309db7 100644 --- a/apps/web/src/components/terminal/TerminalPanel.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.tsx @@ -3,35 +3,38 @@ /** * TerminalPanel * - * Shell panel that wraps the XTerminal component with a tab bar and close button. - * Replaces the former mock terminal with a real xterm.js PTY terminal. + * Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions, + * rendering one XTerminal per session and keeping all instances mounted (for + * scrollback preservation) while switching visibility with display:none. + * + * Features: + * - "+" button to open a new tab + * - Per-tab close button + * - Double-click tab label for inline rename + * - Auto-creates the first session on connect + * - Connection error state */ -import type { ReactElement, CSSProperties } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import type { ReactElement, CSSProperties, KeyboardEvent } from "react"; import { XTerminal } from "./XTerminal"; +import { useTerminalSessions } from "@/hooks/useTerminalSessions"; // ========================================== -// Types (retained for backwards compatibility) +// Types // ========================================== -export interface TerminalTab { - id: string; - label: string; -} - export interface TerminalPanelProps { + /** Whether the panel is visible */ open: boolean; + /** Called when the user closes the panel */ onClose: () => void; - tabs?: TerminalTab[]; - activeTab?: string; - onTabChange?: (id: string) => void; /** Authentication token for the WebSocket connection */ token?: string; + /** Optional CSS class name */ className?: string; } -const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }]; - // ========================================== // Component // ========================================== @@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }]; export function TerminalPanel({ open, onClose, - tabs, - activeTab, - onTabChange, token = "", className = "", }: TerminalPanelProps): ReactElement { - const resolvedTabs = tabs ?? defaultTabs; - const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? ""; + const { + sessions, + activeSessionId, + isConnected, + connectionError, + createSession, + closeSession, + renameSession, + setActiveSession, + sendInput, + resize, + registerOutputCallback, + } = useTerminalSessions({ token }); + + // ========================================== + // Inline rename state + // ========================================== + + const [editingTabId, setEditingTabId] = useState(null); + const [editingName, setEditingName] = useState(""); + const editInputRef = useRef(null); + + // Focus the rename input when editing starts + useEffect(() => { + if (editingTabId !== null) { + editInputRef.current?.select(); + } + }, [editingTabId]); + + // ========================================== + // Auto-create first session on connect + // ========================================== + + useEffect(() => { + if (open && isConnected && sessions.size === 0) { + createSession({ name: "Terminal 1" }); + } + }, [open, isConnected, sessions.size, createSession]); + + // ========================================== + // Tab rename helpers + // ========================================== + + const commitRename = useCallback((): void => { + if (editingTabId !== null) { + const trimmed = editingName.trim(); + if (trimmed.length > 0) { + renameSession(editingTabId, trimmed); + } + setEditingTabId(null); + setEditingName(""); + } + }, [editingTabId, editingName, renameSession]); + + const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => { + setEditingTabId(sessionId); + setEditingName(currentName); + }, []); + + const handleRenameKeyDown = useCallback( + (e: KeyboardEvent): void => { + if (e.key === "Enter") { + commitRename(); + } else if (e.key === "Escape") { + setEditingTabId(null); + setEditingName(""); + } + }, + [commitRename] + ); + + // ========================================== + // Session control helpers + // ========================================== + + const handleCreateTab = useCallback((): void => { + const tabNumber = sessions.size + 1; + createSession({ name: `Terminal ${tabNumber.toString()}` }); + }, [sessions.size, createSession]); + + const handleCloseTab = useCallback( + (sessionId: string): void => { + closeSession(sessionId); + }, + [closeSession] + ); + + const handleRestart = useCallback( + (sessionId: string, name: string): void => { + closeSession(sessionId); + createSession({ name }); + }, + [closeSession, createSession] + ); + + // ========================================== + // Styles + // ========================================== const panelStyle: CSSProperties = { background: "var(--bg-deep)", @@ -71,12 +167,15 @@ export function TerminalPanel({ const tabBarStyle: CSSProperties = { display: "flex", gap: 2, + alignItems: "center", + flex: 1, + overflow: "hidden", }; const actionsStyle: CSSProperties = { - marginLeft: "auto", display: "flex", gap: 4, + alignItems: "center", }; const bodyStyle: CSSProperties = { @@ -85,8 +184,13 @@ export function TerminalPanel({ display: "flex", flexDirection: "column", minHeight: 0, + position: "relative", }; + // ========================================== + // Render + // ========================================== + return (
{/* Tab bar */}
- {resolvedTabs.map((tab) => { - const isActive = tab.id === resolvedActiveTab; + {[...sessions.entries()].map(([sessionId, sessionInfo]) => { + const isActive = sessionId === activeSessionId; + const isEditing = sessionId === editingTabId; + const tabStyle: CSSProperties = { - padding: "3px 10px", + display: "flex", + alignItems: "center", + gap: 4, + padding: "3px 6px 3px 10px", borderRadius: 4, fontSize: "0.75rem", fontFamily: "var(--mono)", color: isActive ? "var(--success)" : "var(--muted)", - cursor: "pointer", background: isActive ? "var(--surface)" : "transparent", border: "none", outline: "none", + flexShrink: 0, }; return ( - + )} + + {/* Per-tab close button */} + + }} + > + + +
); })} + + {/* New tab button */} +
{/* Action buttons */}
+ {/* Close panel button */}
- {/* Terminal body */} + {/* Connection error banner */} + {connectionError !== null && ( +
+ Connection error: {connectionError} +
+ )} + + {/* Terminal body — keep all XTerminal instances mounted for scrollback */}
- + {[...sessions.entries()].map(([sessionId, sessionInfo]) => { + const isActive = sessionId === activeSessionId; + const termStyle: CSSProperties = { + display: isActive ? "flex" : "none", + flex: 1, + flexDirection: "column", + minHeight: 0, + }; + + return ( +
+ { + handleRestart(sessionId, sessionInfo.name); + }} + style={{ flex: 1, minHeight: 0 }} + /> +
+ ); + })} + + {/* Empty state */} + {sessions.size === 0 && ( +
+ {isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")} +
+ )}
); diff --git a/apps/web/src/components/terminal/XTerminal.test.tsx b/apps/web/src/components/terminal/XTerminal.test.tsx index 201fb8e..5f2da13 100644 --- a/apps/web/src/components/terminal/XTerminal.test.tsx +++ b/apps/web/src/components/terminal/XTerminal.test.tsx @@ -4,21 +4,13 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import type { ReactElement } from "react"; // ========================================== // Mocks — set up before importing components // ========================================== -// Mock socket.io-client -vi.mock("socket.io-client"); - -// Mock lib/config -vi.mock("@/lib/config", () => ({ - API_BASE_URL: "http://localhost:3001", -})); - // Mock xterm packages — they require a DOM canvas not available in jsdom const mockTerminalDispose = vi.fn(); const mockTerminalWrite = vi.fn(); @@ -76,26 +68,6 @@ vi.mock("@xterm/addon-web-links", () => ({ // Mock the CSS import vi.mock("@xterm/xterm/css/xterm.css", () => ({})); -// Mock useTerminal hook -const mockCreateSession = vi.fn(); -const mockSendInput = vi.fn(); -const mockResize = vi.fn(); -const mockCloseSession = vi.fn(); -let mockIsConnected = false; -let mockSessionId: string | null = null; - -vi.mock("@/hooks/useTerminal", () => ({ - useTerminal: vi.fn(() => ({ - isConnected: mockIsConnected, - sessionId: mockSessionId, - createSession: mockCreateSession, - sendInput: mockSendInput, - resize: mockResize, - closeSession: mockCloseSession, - connectionError: null, - })), -})); - // Mock ResizeObserver const mockObserve = vi.fn(); const mockUnobserve = vi.fn(); @@ -128,6 +100,31 @@ vi.stubGlobal( import { XTerminal } from "./XTerminal"; +// ========================================== +// Default props factory +// ========================================== + +const mockSendInput = vi.fn(); +const mockResize = vi.fn(); +const mockCloseSession = vi.fn(); +const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn +const mockOnRestart = vi.fn(); + +function makeDefaultProps( + overrides: Partial[0]> = {} +): Parameters[0] { + return { + sessionId: "session-test", + sendInput: mockSendInput, + resize: mockResize, + closeSession: mockCloseSession, + registerOutputCallback: mockRegisterOutputCallback, + isConnected: false, + sessionStatus: "active" as const, + ...overrides, + }; +} + // ========================================== // Tests // ========================================== @@ -135,10 +132,9 @@ import { XTerminal } from "./XTerminal"; describe("XTerminal", () => { beforeEach(() => { vi.clearAllMocks(); - mockIsConnected = false; - mockSessionId = null; mockTerminalCols = 80; mockTerminalRows = 24; + mockRegisterOutputCallback.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -151,66 +147,101 @@ describe("XTerminal", () => { describe("rendering", () => { it("renders the terminal container", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); }); it("renders the xterm viewport div", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument(); }); it("applies the className prop to the container", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class"); }); - it("shows connecting message when not connected", () => { - mockIsConnected = false; + it("sets data-session-id on the container", () => { + render(() as ReactElement); + expect(screen.getByTestId("xterminal-container")).toHaveAttribute( + "data-session-id", + "my-session" + ); + }); - render(() as ReactElement); + it("shows connecting message when not connected and session is active", () => { + render(() as ReactElement); expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument(); }); - it("does not show connecting message when connected", async () => { - mockIsConnected = true; + it("does not show connecting message when connected", () => { + render(() as ReactElement); + expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); + }); - const { useTerminal } = await import("@/hooks/useTerminal"); - vi.mocked(useTerminal).mockReturnValue({ - isConnected: true, - sessionId: "session-xyz", - createSession: mockCreateSession, - sendInput: mockSendInput, - resize: mockResize, - closeSession: mockCloseSession, - connectionError: null, - }); - - render(() as ReactElement); + it("does not show connecting message when session has exited", () => { + render( + ( + + ) as ReactElement + ); expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); }); }); // ========================================== - // useTerminal integration + // Exit overlay // ========================================== - describe("useTerminal integration", () => { - it("passes the token to useTerminal", async () => { - const { useTerminal } = await import("@/hooks/useTerminal"); - render(() as ReactElement); - expect(vi.mocked(useTerminal)).toHaveBeenCalledWith( - expect.objectContaining({ token: "my-auth-token" }) - ); + describe("exit overlay", () => { + it("shows restart button when session has exited", () => { + render(() as ReactElement); + expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument(); }); - it("passes onOutput, onExit, onError callbacks to useTerminal", async () => { - const { useTerminal } = await import("@/hooks/useTerminal"); - render(() as ReactElement); - const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0]; - expect(typeof callArgs?.onOutput).toBe("function"); - expect(typeof callArgs?.onExit).toBe("function"); - expect(typeof callArgs?.onError).toBe("function"); + it("does not show restart button when session is active", () => { + render(() as ReactElement); + expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument(); + }); + + it("shows exit code in restart button when provided", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument(); + }); + + it("calls onRestart when restart button is clicked", () => { + render( + ( + + ) as ReactElement + ); + fireEvent.click(screen.getByRole("button", { name: /restart terminal/i })); + expect(mockOnRestart).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================== + // Output callback registration + // ========================================== + + describe("registerOutputCallback", () => { + it("registers a callback for its sessionId on mount", () => { + render(() as ReactElement); + expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function)); + }); + + it("calls the returned unsubscribe function on unmount", () => { + const unsubscribe = vi.fn(); + mockRegisterOutputCallback.mockReturnValue(unsubscribe); + + const { unmount } = render(() as ReactElement); + unmount(); + + expect(unsubscribe).toHaveBeenCalled(); }); }); @@ -220,8 +251,20 @@ describe("XTerminal", () => { describe("accessibility", () => { it("has an accessible region role", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument(); }); }); + + // ========================================== + // Visibility + // ========================================== + + describe("isVisible", () => { + it("renders with isVisible=true by default", () => { + render(() as ReactElement); + // Container is present; isVisible affects re-fit timing + expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/terminal/XTerminal.tsx b/apps/web/src/components/terminal/XTerminal.tsx index b11dd2e..b25a2f6 100644 --- a/apps/web/src/components/terminal/XTerminal.tsx +++ b/apps/web/src/components/terminal/XTerminal.tsx @@ -3,29 +3,55 @@ /** * XTerminal component * - * Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace. - * Handles resize, copy/paste, theme, and session lifecycle. + * Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions) + * owns the WebSocket connection and session lifecycle. This component receives the + * sessionId and control functions as props and registers for output data specific + * to its session. + * + * Handles resize, copy/paste, theme, exit overlay, and reconnect. */ -import { useEffect, useRef, useCallback, useState } from "react"; +import { useEffect, useRef, useCallback } from "react"; import type { ReactElement, CSSProperties } from "react"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm } from "@xterm/xterm"; import type { FitAddon as XFitAddon } from "@xterm/addon-fit"; -import { useTerminal } from "@/hooks/useTerminal"; +import type { SessionStatus } from "@/hooks/useTerminalSessions"; // ========================================== // Types // ========================================== export interface XTerminalProps { - /** Authentication token for the WebSocket connection */ - token: string; + /** Session identifier (provided by useTerminalSessions) */ + sessionId: string; + /** Send keyboard input to this session */ + sendInput: (sessionId: string, data: string) => void; + /** Notify the server of a terminal resize */ + resize: (sessionId: string, cols: number, rows: number) => void; + /** Close this PTY session */ + closeSession: (sessionId: string) => void; + /** + * Register a callback to receive output for this session. + * Returns an unsubscribe function. + */ + registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; + /** Whether the WebSocket is currently connected */ + isConnected: boolean; + /** Current PTY process status */ + sessionStatus: SessionStatus; + /** Exit code, populated when sessionStatus === 'exited' */ + exitCode?: number; + /** + * Called when the user clicks the restart button after the session has exited. + * The parent is responsible for closing the old session and creating a new one. + */ + onRestart?: () => void; /** Optional CSS class name for the outer container */ className?: string; /** Optional inline styles for the outer container */ style?: CSSProperties; - /** Whether the terminal is visible (used to trigger re-fit) */ + /** Whether the terminal is visible (used to trigger re-fit on tab switch) */ isVisible?: boolean; } @@ -45,8 +71,6 @@ function getCssVar(varName: string, fallback: string): string { /** * Build an xterm.js ITheme object from the current design system CSS variables. - * All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives - * which flow through to the semantic aliases we read here. */ function buildXtermTheme(): Record { return { @@ -82,11 +106,20 @@ function buildXtermTheme(): Record { // ========================================== /** - * XTerminal renders a real PTY terminal powered by xterm.js, - * connected to the backend /terminal WebSocket namespace. + * XTerminal renders a real PTY terminal powered by xterm.js. + * The parent provides the sessionId and control functions; this component + * registers for output data and manages the xterm.js instance lifecycle. */ export function XTerminal({ - token, + sessionId, + sendInput, + resize, + closeSession: _closeSession, + registerOutputCallback, + isConnected, + sessionStatus, + exitCode, + onRestart, className = "", style, isVisible = true, @@ -97,41 +130,7 @@ export function XTerminal({ const resizeObserverRef = useRef(null); const isTerminalMountedRef = useRef(false); - const [hasExited, setHasExited] = useState(false); - const [exitCode, setExitCode] = useState(null); - - // ========================================== - // Terminal session callbacks - // ========================================== - - const handleOutput = useCallback((sessionId: string, data: string): void => { - void sessionId; // sessionId is single-session in this component - terminalRef.current?.write(data); - }, []); - - const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => { - void event.sessionId; - setHasExited(true); - setExitCode(event.exitCode); - const term = terminalRef.current; - if (term) { - term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`); - } - }, []); - - const handleError = useCallback((message: string): void => { - const term = terminalRef.current; - if (term) { - term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`); - } - }, []); - - const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({ - token, - onOutput: handleOutput, - onExit: handleExit, - onError: handleError, - }); + const hasExited = sessionStatus === "exited"; // ========================================== // Fit helper @@ -144,11 +143,11 @@ export function XTerminal({ try { fitAddon.fit(); - resize(terminal.cols, terminal.rows); + resize(sessionId, terminal.cols, terminal.rows); } catch { // Ignore fit errors (e.g., when container has zero dimensions) } - }, [resize]); + }, [resize, sessionId]); // ========================================== // Mount xterm.js terminal (client-only) @@ -217,8 +216,20 @@ export function XTerminal({ return (): void => { cancelled = true; }; + // Intentionally empty dep array — mount once only }, []); + // ========================================== + // Register output callback for this session + // ========================================== + + useEffect(() => { + const unregister = registerOutputCallback(sessionId, (data: string) => { + terminalRef.current?.write(data); + }); + return unregister; + }, [sessionId, registerOutputCallback]); + // ========================================== // Re-fit when visibility changes // ========================================== @@ -243,39 +254,13 @@ export function XTerminal({ if (!terminal) return; const disposable = terminal.onData((data: string): void => { - sendInput(data); + sendInput(sessionId, data); }); return (): void => { disposable.dispose(); }; - }, [sendInput]); - - // ========================================== - // Create PTY session when connected - // ========================================== - - useEffect(() => { - if (!isConnected || sessionId !== null) return; - - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - - let cols = 80; - let rows = 24; - - if (terminal && fitAddon) { - try { - fitAddon.fit(); - cols = terminal.cols; - rows = terminal.rows; - } catch { - // Use defaults - } - } - - createSession({ cols, rows }); - }, [isConnected, sessionId, createSession]); + }, [sendInput, sessionId]); // ========================================== // Update xterm theme when data-theme attribute changes @@ -309,16 +294,26 @@ export function XTerminal({ resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; - // Close PTY session - closeSession(); - // Dispose xterm terminal terminalRef.current?.dispose(); terminalRef.current = null; fitAddonRef.current = null; isTerminalMountedRef.current = false; }; - }, [closeSession]); + }, []); + + // ========================================== + // Restart handler + // ========================================== + + const handleRestart = useCallback((): void => { + const terminal = terminalRef.current; + if (terminal) { + terminal.clear(); + } + // Notify parent to close old session and create a new one + onRestart?.(); + }, [onRestart]); // ========================================== // Render @@ -339,8 +334,9 @@ export function XTerminal({ role="region" aria-label="Terminal" data-testid="xterminal-container" + data-session-id={sessionId} > - {/* Status bar */} + {/* Status bar — show when not connected and not exited */} {!isConnected && !hasExited && (
{ - setHasExited(false); - setExitCode(null); - if (isConnected) { - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - let cols = 80; - let rows = 24; - if (terminal && fitAddon) { - try { - cols = terminal.cols; - rows = terminal.rows; - } catch { - // Use defaults - } - } - terminal?.clear(); - createSession({ cols, rows }); - } - }} + onClick={handleRestart} > - Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""} + Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
)} diff --git a/apps/web/src/components/terminal/index.ts b/apps/web/src/components/terminal/index.ts index 5bb8a0a..9c0adeb 100644 --- a/apps/web/src/components/terminal/index.ts +++ b/apps/web/src/components/terminal/index.ts @@ -1,4 +1,4 @@ -export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel"; +export type { TerminalPanelProps } from "./TerminalPanel"; export { TerminalPanel } from "./TerminalPanel"; export type { XTerminalProps } from "./XTerminal"; export { XTerminal } from "./XTerminal"; diff --git a/apps/web/src/hooks/useTerminalSessions.test.ts b/apps/web/src/hooks/useTerminalSessions.test.ts new file mode 100644 index 0000000..db858f3 --- /dev/null +++ b/apps/web/src/hooks/useTerminalSessions.test.ts @@ -0,0 +1,690 @@ +/** + * @file useTerminalSessions.test.ts + * @description Unit tests for the useTerminalSessions hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useTerminalSessions } from "./useTerminalSessions"; +import type { Socket } from "socket.io-client"; + +// ========================================== +// Mock socket.io-client +// ========================================== + +vi.mock("socket.io-client"); + +// ========================================== +// Mock lib/config +// ========================================== + +vi.mock("@/lib/config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +// ========================================== +// Helpers +// ========================================== + +interface MockSocket { + on: ReturnType; + off: ReturnType; + emit: ReturnType; + disconnect: ReturnType; + connected: boolean; +} + +describe("useTerminalSessions", () => { + let mockSocket: MockSocket; + let socketEventHandlers: Record void>; + let mockIo: ReturnType; + + beforeEach(async () => { + socketEventHandlers = {}; + + mockSocket = { + on: vi.fn((event: string, handler: (data: unknown) => void) => { + socketEventHandlers[event] = handler; + return mockSocket; + }), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + connected: true, + }; + + const socketIo = await import("socket.io-client"); + mockIo = vi.mocked(socketIo.io); + mockIo.mockReturnValue(mockSocket as unknown as Socket); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ========================================== + // Connection lifecycle + // ========================================== + + describe("connection lifecycle", () => { + it("should connect to the /terminal namespace with auth token", () => { + renderHook(() => useTerminalSessions({ token: "test-token" })); + + expect(mockIo).toHaveBeenCalledWith( + expect.stringContaining("/terminal"), + expect.objectContaining({ + auth: { token: "test-token" }, + }) + ); + }); + + it("should start disconnected", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + expect(result.current.isConnected).toBe(false); + }); + + it("should update isConnected when connect event fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(true); + }); + }); + + it("should set connectionError when connect_error fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect_error?.(new Error("Connection refused")); + }); + + await waitFor(() => { + expect(result.current.connectionError).toBe("Connection refused"); + expect(result.current.isConnected).toBe(false); + }); + }); + + it("should not connect when token is empty", () => { + renderHook(() => useTerminalSessions({ token: "" })); + + expect(mockIo).not.toHaveBeenCalled(); + }); + + it("should disconnect socket on unmount", () => { + const { unmount } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + unmount(); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + }); + + // ========================================== + // Session creation + // ========================================== + + describe("createSession", () => { + it("should emit terminal:create when connected", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.createSession({ name: "bash", cols: 120, rows: 40 }); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", { + name: "bash", + cols: 120, + rows: 40, + }); + }); + + it("should not emit terminal:create when disconnected", () => { + mockSocket.connected = false; + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + result.current.createSession(); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything()); + }); + + it("should add session to sessions map when terminal:created fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(true); + expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1"); + expect(result.current.sessions.get("session-1")?.status).toBe("active"); + }); + }); + + it("should set first created session as active", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + }); + + it("should not change active session when a second session is created", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + // Active session should remain session-1 + expect(result.current.activeSessionId).toBe("session-1"); + }); + }); + + it("should manage multiple sessions in the sessions map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + expect(result.current.sessions.has("session-1")).toBe(true); + expect(result.current.sessions.has("session-2")).toBe(true); + }); + }); + }); + + // ========================================== + // Session close + // ========================================== + + describe("closeSession", () => { + it("should emit terminal:close and remove session from map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(true); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { + sessionId: "session-1", + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(false); + }); + }); + + it("should switch active session to another when active is closed", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + await waitFor(() => { + // Should switch to session-2 + expect(result.current.activeSessionId).toBe("session-2"); + expect(result.current.sessions.has("session-1")).toBe(false); + }); + }); + + it("should set activeSessionId to null when last session is closed", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(0); + expect(result.current.activeSessionId).toBeNull(); + }); + }); + }); + + // ========================================== + // Rename session + // ========================================== + + describe("renameSession", () => { + it("should update the session name in the sessions map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1"); + }); + + act(() => { + result.current.renameSession("session-1", "My Custom Shell"); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("My Custom Shell"); + }); + }); + + it("should not affect other session names", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + act(() => { + result.current.renameSession("session-1", "Custom"); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("Custom"); + expect(result.current.sessions.get("session-2")?.name).toBe("Terminal 2"); + }); + }); + }); + + // ========================================== + // setActiveSession + // ========================================== + + describe("setActiveSession", () => { + it("should update activeSessionId", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.setActiveSession("session-2"); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-2"); + }); + }); + }); + + // ========================================== + // sendInput + // ========================================== + + describe("sendInput", () => { + it("should emit terminal:input with sessionId and data", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.sendInput("session-1", "ls -la\n"); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", { + sessionId: "session-1", + data: "ls -la\n", + }); + }); + + it("should not emit when disconnected", () => { + mockSocket.connected = false; + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + result.current.sendInput("session-1", "ls\n"); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything()); + }); + }); + + // ========================================== + // resize + // ========================================== + + describe("resize", () => { + it("should emit terminal:resize with sessionId, cols, and rows", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.resize("session-1", 120, 40); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", { + sessionId: "session-1", + cols: 120, + rows: 40, + }); + }); + }); + + // ========================================== + // Output callback routing + // ========================================== + + describe("registerOutputCallback", () => { + it("should call the registered callback when terminal:output fires for that session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cb); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "hello world\r\n", + }); + }); + + expect(cb).toHaveBeenCalledWith("hello world\r\n"); + }); + + it("should not call callback for a different session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cbSession1 = vi.fn(); + const cbSession2 = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cbSession1); + result.current.registerOutputCallback("session-2", cbSession2); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "output for session 1", + }); + }); + + expect(cbSession1).toHaveBeenCalledWith("output for session 1"); + expect(cbSession2).not.toHaveBeenCalled(); + }); + + it("should stop calling callback after unsubscribing", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb = vi.fn(); + let unsubscribe: (() => void) | undefined; + + act(() => { + unsubscribe = result.current.registerOutputCallback("session-1", cb); + }); + + act(() => { + unsubscribe?.(); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "should not arrive", + }); + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it("should support multiple callbacks for the same session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cb1); + result.current.registerOutputCallback("session-1", cb2); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "broadcast", + }); + }); + + expect(cb1).toHaveBeenCalledWith("broadcast"); + expect(cb2).toHaveBeenCalledWith("broadcast"); + }); + }); + + // ========================================== + // Exit event + // ========================================== + + describe("terminal:exit handling", () => { + it("should mark session as exited when terminal:exit fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.status).toBe("active"); + }); + + act(() => { + socketEventHandlers["terminal:exit"]?.({ + sessionId: "session-1", + exitCode: 0, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.status).toBe("exited"); + expect(result.current.sessions.get("session-1")?.exitCode).toBe(0); + }); + }); + + it("should not remove the session from the map on exit", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + act(() => { + socketEventHandlers["terminal:exit"]?.({ + sessionId: "session-1", + exitCode: 1, + }); + }); + + await waitFor(() => { + // Session remains in map — user can restart or close it manually + expect(result.current.sessions.has("session-1")).toBe(true); + }); + }); + }); + + // ========================================== + // Disconnect handling + // ========================================== + + describe("disconnect handling", () => { + it("should mark all active sessions as exited on disconnect", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + }); + + act(() => { + socketEventHandlers.disconnect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(false); + expect(result.current.sessions.get("session-1")?.status).toBe("exited"); + expect(result.current.sessions.get("session-2")?.status).toBe("exited"); + }); + }); + }); +}); diff --git a/apps/web/src/hooks/useTerminalSessions.ts b/apps/web/src/hooks/useTerminalSessions.ts new file mode 100644 index 0000000..0bf8ab5 --- /dev/null +++ b/apps/web/src/hooks/useTerminalSessions.ts @@ -0,0 +1,381 @@ +/** + * useTerminalSessions hook + * + * Manages multiple PTY terminal sessions over a single WebSocket connection + * to the /terminal namespace. Supports creating, closing, renaming, and switching + * between sessions, with per-session output callback multiplexing. + * + * Protocol (from terminal.gateway.ts): + * 1. Connect with auth token in handshake + * 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows } + * 3. Emit terminal:input { sessionId, data } to send keystrokes + * 4. Receive terminal:output { sessionId, data } for stdout/stderr + * 5. Emit terminal:resize { sessionId, cols, rows } on window resize + * 6. Emit terminal:close { sessionId } to terminate the PTY + * 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit + * 8. Receive terminal:error { message } on errors + */ + +import { useEffect, useRef, useState, useCallback } from "react"; +import type { Socket } from "socket.io-client"; +import { io } from "socket.io-client"; +import { API_BASE_URL } from "@/lib/config"; + +// ========================================== +// Types +// ========================================== + +export type SessionStatus = "active" | "exited"; + +export interface SessionInfo { + /** Session identifier returned by the server */ + sessionId: string; + /** Human-readable tab label */ + name: string; + /** Whether the PTY process is still running */ + status: SessionStatus; + /** Exit code, populated when status === 'exited' */ + exitCode?: number; +} + +export interface CreateSessionOptions { + /** Optional label for the new session */ + name?: string; + /** Terminal columns */ + cols?: number; + /** Terminal rows */ + rows?: number; + /** Working directory */ + cwd?: string; +} + +export interface UseTerminalSessionsOptions { + /** Authentication token for WebSocket handshake */ + token: string; +} + +export interface UseTerminalSessionsReturn { + /** Map of sessionId → SessionInfo */ + sessions: Map; + /** Currently active (visible) session id, or null if none */ + activeSessionId: string | null; + /** Whether the WebSocket is connected */ + isConnected: boolean; + /** Connection error message, if any */ + connectionError: string | null; + /** Create a new PTY session */ + createSession: (options?: CreateSessionOptions) => void; + /** Close an existing PTY session */ + closeSession: (sessionId: string) => void; + /** Rename a session (local label only, not persisted to server) */ + renameSession: (sessionId: string, name: string) => void; + /** Switch the visible session */ + setActiveSession: (sessionId: string) => void; + /** Send keyboard input to a session */ + sendInput: (sessionId: string, data: string) => void; + /** Notify the server of a terminal resize */ + resize: (sessionId: string, cols: number, rows: number) => void; + /** + * Register a callback that receives output data for a specific session. + * Returns an unsubscribe function — call it during cleanup. + */ + registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; +} + +// ========================================== +// Payload shapes matching terminal.dto.ts +// ========================================== + +interface TerminalCreatedPayload { + sessionId: string; + name: string; + cols: number; + rows: number; +} + +interface TerminalOutputPayload { + sessionId: string; + data: string; +} + +interface TerminalExitPayload { + sessionId: string; + exitCode: number; + signal?: string; +} + +interface TerminalErrorPayload { + message: string; +} + +// ========================================== +// Security validation +// ========================================== + +function validateWebSocketSecurity(url: string): void { + const isProduction = process.env.NODE_ENV === "production"; + const isSecure = url.startsWith("https://") || url.startsWith("wss://"); + + if (isProduction && !isSecure) { + console.warn( + "[Security Warning] Terminal WebSocket using insecure protocol (ws://). " + + "Authentication tokens may be exposed. Use wss:// in production." + ); + } +} + +// ========================================== +// Hook +// ========================================== + +/** + * Hook for managing multiple PTY terminal sessions over a single WebSocket connection. + * + * @param options - Configuration including auth token + * @returns Multi-session terminal state and control functions + */ +export function useTerminalSessions( + options: UseTerminalSessionsOptions +): UseTerminalSessionsReturn { + const { token } = options; + + const socketRef = useRef(null); + // Per-session output callback registry; keyed by sessionId + const outputCallbacksRef = useRef void>>>(new Map()); + + const [sessions, setSessions] = useState>(new Map()); + const [activeSessionId, setActiveSessionIdState] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + // ========================================== + // Auto-select first available session when active becomes null + // ========================================== + + useEffect(() => { + if (activeSessionId === null && sessions.size > 0) { + const firstId = sessions.keys().next().value; + if (firstId !== undefined) { + setActiveSessionIdState(firstId); + } + } + }, [activeSessionId, sessions]); + + // ========================================== + // WebSocket connection + // ========================================== + + useEffect(() => { + if (!token) { + return; + } + + const wsUrl = API_BASE_URL; + validateWebSocketSecurity(wsUrl); + + setConnectionError(null); + + const socket = io(`${wsUrl}/terminal`, { + auth: { token }, + transports: ["websocket", "polling"], + }); + + socketRef.current = socket; + + const handleConnect = (): void => { + setIsConnected(true); + setConnectionError(null); + }; + + const handleDisconnect = (): void => { + setIsConnected(false); + // Sessions remain in the Map but are no longer interactive + setSessions((prev) => { + const next = new Map(prev); + for (const [id, info] of next) { + if (info.status === "active") { + next.set(id, { ...info, status: "exited" }); + } + } + return next; + }); + }; + + const handleConnectError = (error: Error): void => { + setConnectionError(error.message || "Terminal connection failed"); + setIsConnected(false); + }; + + const handleTerminalCreated = (payload: TerminalCreatedPayload): void => { + setSessions((prev) => { + const next = new Map(prev); + next.set(payload.sessionId, { + sessionId: payload.sessionId, + name: payload.name, + status: "active", + }); + return next; + }); + // Set as active session if none is currently active + setActiveSessionIdState((prev) => prev ?? payload.sessionId); + }; + + const handleTerminalOutput = (payload: TerminalOutputPayload): void => { + const callbacks = outputCallbacksRef.current.get(payload.sessionId); + if (callbacks) { + for (const cb of callbacks) { + cb(payload.data); + } + } + }; + + const handleTerminalExit = (payload: TerminalExitPayload): void => { + setSessions((prev) => { + const next = new Map(prev); + const session = next.get(payload.sessionId); + if (session) { + next.set(payload.sessionId, { + ...session, + status: "exited", + exitCode: payload.exitCode, + }); + } + return next; + }); + }; + + const handleTerminalError = (payload: TerminalErrorPayload): void => { + console.error("[Terminal] Error:", payload.message); + }; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect_error", handleConnectError); + socket.on("terminal:created", handleTerminalCreated); + socket.on("terminal:output", handleTerminalOutput); + socket.on("terminal:exit", handleTerminalExit); + socket.on("terminal:error", handleTerminalError); + + return (): void => { + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect_error", handleConnectError); + socket.off("terminal:created", handleTerminalCreated); + socket.off("terminal:output", handleTerminalOutput); + socket.off("terminal:exit", handleTerminalExit); + socket.off("terminal:error", handleTerminalError); + + // Close all active sessions before disconnecting + const currentSessions = sessions; + for (const [id, info] of currentSessions) { + if (info.status === "active") { + socket.emit("terminal:close", { sessionId: id }); + } + } + + socket.disconnect(); + socketRef.current = null; + }; + // Intentional: token is the only dep that should trigger reconnection + }, [token]); + + // ========================================== + // Control functions + // ========================================== + + const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + + const payload: Record = {}; + if (createOptions.name !== undefined) payload.name = createOptions.name; + if (createOptions.cols !== undefined) payload.cols = createOptions.cols; + if (createOptions.rows !== undefined) payload.rows = createOptions.rows; + if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd; + + socket.emit("terminal:create", payload); + }, []); + + const closeSession = useCallback((sessionId: string): void => { + const socket = socketRef.current; + if (socket?.connected) { + socket.emit("terminal:close", { sessionId }); + } + + setSessions((prev) => { + const next = new Map(prev); + next.delete(sessionId); + return next; + }); + + // If closing the active session, activeSessionId becomes null + // and the auto-select useEffect will pick the first remaining session + setActiveSessionIdState((prev) => (prev === sessionId ? null : prev)); + }, []); + + const renameSession = useCallback((sessionId: string, name: string): void => { + setSessions((prev) => { + const next = new Map(prev); + const session = next.get(sessionId); + if (session) { + next.set(sessionId, { ...session, name }); + } + return next; + }); + }, []); + + const setActiveSession = useCallback((sessionId: string): void => { + setActiveSessionIdState(sessionId); + }, []); + + const sendInput = useCallback((sessionId: string, data: string): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + socket.emit("terminal:input", { sessionId, data }); + }, []); + + const resize = useCallback((sessionId: string, cols: number, rows: number): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + socket.emit("terminal:resize", { sessionId, cols, rows }); + }, []); + + const registerOutputCallback = useCallback( + (sessionId: string, cb: (data: string) => void): (() => void) => { + const registry = outputCallbacksRef.current; + if (!registry.has(sessionId)) { + registry.set(sessionId, new Set()); + } + // Safe: we just ensured the key exists + const callbackSet = registry.get(sessionId); + if (callbackSet) { + callbackSet.add(cb); + } + + return (): void => { + registry.get(sessionId)?.delete(cb); + }; + }, + [] + ); + + return { + sessions, + activeSessionId, + isConnected, + connectionError, + createSession, + closeSession, + renameSession, + setActiveSession, + sendInput, + resize, + registerOutputCallback, + }; +}