feat(web): implement multi-session terminal tab management (#520)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #520.
This commit is contained in:
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
690
apps/web/src/hooks/useTerminalSessions.test.ts
Normal file
@@ -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<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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
381
apps/web/src/hooks/useTerminalSessions.ts
Normal file
@@ -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<string, SessionInfo>;
|
||||
/** 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<Socket | null>(null);
|
||||
// Per-session output callback registry; keyed by sessionId
|
||||
const outputCallbacksRef = useRef<Map<string, Set<(data: string) => void>>>(new Map());
|
||||
|
||||
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
|
||||
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(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<string, unknown> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user