Files
stack/apps/web/src/hooks/useTerminal.test.ts
Jason Woltje 417c6ab49c
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
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>
2026-02-26 02:55:53 +00:00

463 lines
11 KiB
TypeScript

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