Co-authored-by: Jason Woltje <[email protected]> Co-committed-by: Jason Woltje <[email protected]>
691 lines
19 KiB
TypeScript
691 lines
19 KiB
TypeScript
/**
|
|
* @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<typeof vi.fn>;
|
|
off: ReturnType<typeof vi.fn>;
|
|
emit: ReturnType<typeof vi.fn>;
|
|
disconnect: ReturnType<typeof vi.fn>;
|
|
connected: boolean;
|
|
}
|
|
|
|
describe("useTerminalSessions", () => {
|
|
let mockSocket: MockSocket;
|
|
let socketEventHandlers: Record<string, (data: unknown) => void>;
|
|
let mockIo: ReturnType<typeof vi.fn>;
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|
|
});
|