/** * @file useTerminal.test.ts * @description Unit tests for the useTerminal hook */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useTerminal } from "./useTerminal"; 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("useTerminal", () => { 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 // ========================================== describe("connection lifecycle", () => { it("should connect to the /terminal namespace with auth token", () => { renderHook(() => useTerminal({ token: "test-token", }) ); expect(mockIo).toHaveBeenCalledWith( expect.stringContaining("/terminal"), expect.objectContaining({ auth: { token: "test-token" }, }) ); }); it("should start disconnected and update when connected event fires", async () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); expect(result.current.isConnected).toBe(false); act(() => { socketEventHandlers.connect?.(undefined); }); await waitFor(() => { expect(result.current.isConnected).toBe(true); }); }); it("should update sessionId when terminal:created event fires", async () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); await waitFor(() => { expect(result.current.sessionId).toBe("session-abc"); }); }); it("should clear sessionId when disconnect event fires", async () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); await waitFor(() => { expect(result.current.sessionId).toBe("session-abc"); }); act(() => { socketEventHandlers.disconnect?.(undefined); }); await waitFor(() => { expect(result.current.isConnected).toBe(false); expect(result.current.sessionId).toBeNull(); }); }); it("should set connectionError when connect_error fires", async () => { const { result } = renderHook(() => useTerminal({ 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(() => useTerminal({ token: "", }) ); expect(mockIo).not.toHaveBeenCalled(); }); }); // ========================================== // Output and exit callbacks // ========================================== describe("event callbacks", () => { it("should call onOutput when terminal:output fires", () => { const onOutput = vi.fn(); renderHook(() => useTerminal({ token: "test-token", onOutput, }) ); act(() => { socketEventHandlers["terminal:output"]?.({ sessionId: "session-abc", data: "hello world\r\n", }); }); expect(onOutput).toHaveBeenCalledWith("session-abc", "hello world\r\n"); }); it("should call onExit when terminal:exit fires and clear sessionId", async () => { const onExit = vi.fn(); const { result } = renderHook(() => useTerminal({ token: "test-token", onExit, }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); act(() => { socketEventHandlers["terminal:exit"]?.({ sessionId: "session-abc", exitCode: 0, }); }); await waitFor(() => { expect(onExit).toHaveBeenCalledWith({ sessionId: "session-abc", exitCode: 0 }); expect(result.current.sessionId).toBeNull(); }); }); it("should call onError when terminal:error fires", () => { const onError = vi.fn(); renderHook(() => useTerminal({ token: "test-token", onError, }) ); act(() => { socketEventHandlers["terminal:error"]?.({ message: "PTY spawn failed", }); }); expect(onError).toHaveBeenCalledWith("PTY spawn failed"); }); }); // ========================================== // Control functions // ========================================== describe("createSession", () => { it("should emit terminal:create with options when connected", () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); }); act(() => { result.current.createSession({ cols: 120, rows: 40, name: "test" }); }); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", { cols: 120, rows: 40, name: "test", }); }); it("should not emit terminal:create when disconnected", () => { mockSocket.connected = false; const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { result.current.createSession({ cols: 80, rows: 24 }); }); expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything()); }); }); describe("sendInput", () => { it("should emit terminal:input with sessionId and data", () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); act(() => { result.current.sendInput("ls -la\n"); }); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", { sessionId: "session-abc", data: "ls -la\n", }); }); it("should not emit when no sessionId is set", () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); }); act(() => { result.current.sendInput("ls -la\n"); }); expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything()); }); }); describe("resize", () => { it("should emit terminal:resize with sessionId, cols, and rows", () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); act(() => { result.current.resize(100, 30); }); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", { sessionId: "session-abc", cols: 100, rows: 30, }); }); }); describe("closeSession", () => { it("should emit terminal:close and clear sessionId", async () => { const { result } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); await waitFor(() => { expect(result.current.sessionId).toBe("session-abc"); }); act(() => { result.current.closeSession(); }); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { sessionId: "session-abc", }); await waitFor(() => { expect(result.current.sessionId).toBeNull(); }); }); }); // ========================================== // Cleanup // ========================================== describe("cleanup", () => { it("should disconnect socket on unmount", () => { const { unmount } = renderHook(() => useTerminal({ token: "test-token", }) ); unmount(); expect(mockSocket.disconnect).toHaveBeenCalled(); }); it("should emit terminal:close for active session on unmount", () => { const { result, unmount } = renderHook(() => useTerminal({ token: "test-token", }) ); act(() => { socketEventHandlers.connect?.(undefined); socketEventHandlers["terminal:created"]?.({ sessionId: "session-abc", name: "main", cols: 80, rows: 24, }); }); expect(result.current.sessionId).toBe("session-abc"); unmount(); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { sessionId: "session-abc", }); }); }); });