import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { TerminalModal } from "../TerminalModal"; import * as useTerminalModule from "../../hooks/useTerminal"; import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions"; import * as apiModule from "../../api"; // Mock hooks and API vi.mock("../../hooks/useTerminal", () => ({ useTerminal: vi.fn(), })); vi.mock("../../hooks/useTerminalSessions", () => ({ useTerminalSessions: vi.fn(), })); vi.mock("../../api", () => ({ createTerminalSession: vi.fn(), killPtyTerminalSession: vi.fn(), listTerminalSessions: vi.fn().mockResolvedValue([]), })); // Mock xterm modules to prevent DOM errors in jsdom const mockTerminalInstance = { loadAddon: vi.fn(), open: vi.fn(), onData: vi.fn(() => ({ dispose: vi.fn() })), dispose: vi.fn(), write: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 }, cols: 80, rows: 24, }; vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerminalInstance), })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn(), dispose: vi.fn(), })), })); vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: vi.fn(() => ({ dispose: vi.fn(), })), })); vi.mock("@xterm/addon-webgl", () => { throw new Error("WebGL not available"); }); // Suppress xterm CSS import vi.mock("@xterm/xterm/css/xterm.css", () => ({})); const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal); const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions); const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession); const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession); // Default tab state const defaultTab = { id: "tab-1", sessionId: "test-session-123", title: "bash", isActive: true, createdAt: Date.now(), }; const defaultSessionState = { tabs: [defaultTab], activeTab: defaultTab, isReady: true, createTab: vi.fn(), closeTab: vi.fn(), setActiveTab: vi.fn(), updateTabTitle: vi.fn(), restartActiveTab: vi.fn(), }; describe("TerminalModal", () => { const mockOnClose = vi.fn(); const mockSendInput = vi.fn(); const mockResize = vi.fn(); const mockReconnect = vi.fn(); const createMockTerminalState = (overrides = {}) => ({ connectionStatus: "disconnected" as const, sendInput: mockSendInput, resize: mockResize, onData: vi.fn(() => vi.fn()), onExit: vi.fn(() => vi.fn()), onConnect: vi.fn(() => vi.fn()), onScrollback: vi.fn(() => vi.fn()), reconnect: mockReconnect, ...overrides, }); beforeEach(() => { vi.clearAllMocks(); mockCreateTerminalSession.mockResolvedValue({ sessionId: "test-session-123", shell: "/bin/bash", cwd: "/project", }); mockKillPtyTerminalSession.mockResolvedValue({ killed: true }); mockUseTerminal.mockReturnValue(createMockTerminalState()); mockUseTerminalSessions.mockReturnValue(defaultSessionState); }); afterEach(() => { vi.restoreAllMocks(); }); it("renders without crashing when open", async () => { render(); await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); }); it("does not render when closed", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); it("shows loading state while sessions are not ready", async () => { mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, isReady: false, }); render(); await waitFor(() => { expect(screen.getByTestId("terminal-loading")).toBeTruthy(); }); }); it("shows tabs when multiple sessions exist", async () => { mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, tabs: [ defaultTab, { id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() }, ], }); render(); await waitFor(() => { expect(screen.getByText("bash")).toBeTruthy(); expect(screen.getByText("zsh")).toBeTruthy(); }); }); it("shows active tab styling", async () => { mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, tabs: [ { ...defaultTab, isActive: true }, { id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() }, ], }); render(); await waitFor(() => { const activeTab = screen.getByText("bash").closest(".terminal-tab"); expect(activeTab).toHaveClass("terminal-tab--active"); }); }); it("tab click switches active tab", async () => { const mockSetActiveTab = vi.fn(); mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, tabs: [ { ...defaultTab, isActive: true }, { id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() }, ], setActiveTab: mockSetActiveTab, }); render(); await waitFor(() => { const zshTab = screen.getByText("zsh"); fireEvent.click(zshTab); }); expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2"); }); it("tab close button closes tab", async () => { const mockCloseTab = vi.fn(); mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, tabs: [ { ...defaultTab, isActive: true }, { id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() }, ], closeTab: mockCloseTab, }); render(); await waitFor(() => { // Find the close button for the zsh tab (second tab) const closeButtons = screen.getAllByTitle("Close tab"); const zshCloseBtn = closeButtons[1]; // Second close button (for zsh tab) if (zshCloseBtn) { fireEvent.click(zshCloseBtn); } }); expect(mockCloseTab).toHaveBeenCalledWith("tab-2"); }); it("new tab button creates new tab", async () => { const mockCreateTab = vi.fn().mockResolvedValue({ id: "tab-new", sessionId: "new-session", title: "Terminal 2", isActive: true, createdAt: Date.now(), }); mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, createTab: mockCreateTab, }); render(); await waitFor(() => { const newTabBtn = screen.getByTitle("New terminal"); fireEvent.click(newTabBtn); }); expect(mockCreateTab).toHaveBeenCalled(); }); it("sessions are NOT killed when modal closes (session persistence)", async () => { const { rerender } = render(); await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); await act(async () => { rerender(); }); // With multi-tab support, sessions should persist when modal closes expect(mockKillPtyTerminalSession).not.toHaveBeenCalled(); }); it("closes modal on close button click", async () => { render(); await waitFor(() => { const closeBtn = screen.getByTestId("terminal-close-btn"); fireEvent.click(closeBtn); }); expect(mockOnClose).toHaveBeenCalled(); }); it("closes modal on escape key", async () => { render(); await act(async () => { fireEvent.keyDown(document, { key: "Escape" }); }); expect(mockOnClose).toHaveBeenCalled(); }); it("closes modal on overlay click", async () => { render(); await waitFor(() => { const overlay = screen.getByTestId("terminal-modal-overlay"); fireEvent.click(overlay); }); expect(mockOnClose).toHaveBeenCalled(); }); it("shows reconnect button when disconnected", async () => { mockUseTerminal.mockReturnValue( createMockTerminalState({ connectionStatus: "disconnected", }) ); render(); await waitFor(() => { expect(screen.getByTestId("terminal-reconnect-btn")).toBeTruthy(); }); }); it("reconnects when reconnect button clicked", async () => { mockUseTerminal.mockReturnValue( createMockTerminalState({ connectionStatus: "disconnected", }) ); render(); await waitFor(() => { const reconnectBtn = screen.getByTestId("terminal-reconnect-btn"); fireEvent.click(reconnectBtn); }); expect(mockReconnect).toHaveBeenCalled(); }); it("WebSocket connects on mount with sessionId from active tab", async () => { render(); await waitFor(() => { expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123"); }); }); it("initializes xterm after session is ready", async () => { render(); // Wait for session to be ready and xterm to initialize await waitFor(() => { expect(mockTerminalInstance.open).toHaveBeenCalled(); }); // Verify xterm was opened with the terminal container div const terminalDiv = screen.getByTestId("terminal-xterm"); expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv); }); it("xterm container is hidden while loading", async () => { mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, isReady: false, }); render(); await waitFor(() => { const xtermDiv = screen.getByTestId("terminal-xterm"); expect(xtermDiv.style.display).toBe("none"); }); }); it("xterm container becomes visible when ready", async () => { render(); await waitFor(() => { const xtermDiv = screen.getByTestId("terminal-xterm"); expect(xtermDiv.style.display).not.toBe("none"); }); }); it("subscribes to terminal data after xterm is ready", async () => { const mockOnData = vi.fn(() => vi.fn()); const mockOnConnect = vi.fn(() => vi.fn()); const mockOnExit = vi.fn(() => vi.fn()); const mockOnScrollback = vi.fn(() => vi.fn()); mockUseTerminal.mockReturnValue( createMockTerminalState({ onData: mockOnData, onConnect: mockOnConnect, onExit: mockOnExit, onScrollback: mockOnScrollback, }) ); render(); // Wait for xterm initialization to complete await waitFor(() => { expect(mockTerminalInstance.open).toHaveBeenCalled(); }); // After xterm is ready, data subscriptions should be established await waitFor(() => { expect(mockOnData).toHaveBeenCalled(); expect(mockOnConnect).toHaveBeenCalled(); expect(mockOnExit).toHaveBeenCalled(); expect(mockOnScrollback).toHaveBeenCalled(); }); }); it("calls restartActiveTab when New Session button clicked", async () => { const mockRestartActiveTab = vi.fn(); let exitCallback: ((code: number) => void) | null = null; mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, restartActiveTab: mockRestartActiveTab, }); // Create a custom mock that captures the exit callback const customOnExit = vi.fn((cb: (code: number) => void) => { exitCallback = cb; return vi.fn(); }); mockUseTerminal.mockReturnValue({ connectionStatus: "connected", sendInput: mockSendInput, resize: mockResize, onData: vi.fn(() => vi.fn()), onExit: customOnExit, onConnect: vi.fn(() => vi.fn()), onScrollback: vi.fn(() => vi.fn()), reconnect: mockReconnect, }); render(); await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); // Wait for xterm to initialize await waitFor(() => { expect(mockTerminalInstance.open).toHaveBeenCalled(); }); // Trigger the exit callback to simulate terminal exit act(() => { if (exitCallback) { exitCallback(0); } }); await waitFor(() => { expect(screen.getByTestId("terminal-restart-btn")).toBeTruthy(); }); const restartBtn = screen.getByTestId("terminal-restart-btn"); fireEvent.click(restartBtn); expect(mockRestartActiveTab).toHaveBeenCalled(); }); });