feat(web): integrate xterm.js with WebSocket terminal backend (#518)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
462
apps/web/src/hooks/useTerminal.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* @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<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
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
|
||||
// ==========================================
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user