/** * @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"); }); }); }); });