Files
headquarter/apps/web/src/hooks/use-terminal-connection.test.ts
T
2026-06-03 08:51:02 +00:00

340 lines
7.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTerminalConnection } from "./use-terminal-connection";
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState: number = WebSocket.CONNECTING;
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sent: (string | ArrayBuffer | Blob)[] = [];
url = "";
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
send(data: string | ArrayBuffer | Blob) {
this.sent.push(data);
}
close(code?: number, reason?: string) {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose(new CloseEvent("close", { code: code ?? 1000, reason }));
}
}
simulateOpen() {
this.readyState = WebSocket.OPEN;
if (this.onopen) this.onopen(new Event("open"));
}
simulateMessage(data: string | ArrayBuffer | Blob) {
if (this.onmessage) {
this.onmessage(new MessageEvent("message", { data }));
}
}
simulateError() {
if (this.onerror) this.onerror(new Event("error"));
}
}
describe("useTerminalConnection", () => {
let originalWebSocket: typeof WebSocket;
beforeEach(() => {
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
MockWebSocket.instances = [];
vi.useFakeTimers();
vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } });
});
afterEach(() => {
globalThis.WebSocket = originalWebSocket;
MockWebSocket.instances = [];
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts in connecting state", () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
expect(result.current.state.status).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
});
it("transitions to connected on websocket open", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
expect(result.current.state.status).toBe("connected");
});
it("sends ping after interval", async () => {
renderHook(() => useTerminalConnection({ instanceId: "inst-1" }));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
vi.advanceTimersByTime(15000);
});
const pings = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("ping") : false,
);
expect(pings.length).toBeGreaterThanOrEqual(1);
});
it("handles pong and updates latency", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
vi.advanceTimersByTime(15000);
});
act(() => {
MockWebSocket.instances[0].simulateMessage(
JSON.stringify({ type: "pong", id: 1 }),
);
});
expect(result.current.state.latency).not.toBeNull();
expect(result.current.state.latency).toBeGreaterThanOrEqual(0);
});
it("reconnects with exponential backoff on close", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].close(1006, "Abnormal closure");
});
expect(result.current.state.status).toBe("reconnecting");
expect(result.current.state.attempt).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(MockWebSocket.instances).toHaveLength(2);
});
it("max reconnect attempts leads to disconnected", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
for (let i = 0; i < 11; i++) {
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.close(1006, "Abnormal closure");
});
const delay = Math.min(1000 * 2 ** i, 30000);
act(() => {
vi.advanceTimersByTime(delay);
});
}
expect(result.current.state.status).toBe("disconnected");
expect(result.current.state.error).toContain("Max reconnection");
}, 30000);
it("sends resize message with debounce", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendResize(120, 40);
});
// Before debounce
expect(
MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
),
).toHaveLength(0);
act(() => {
vi.advanceTimersByTime(250);
});
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
);
expect(resizes.length).toBeGreaterThanOrEqual(1);
});
it("throttles resize messages", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendResize(100, 30);
});
act(() => {
vi.advanceTimersByTime(250);
});
act(() => {
result.current.sendResize(101, 31);
});
act(() => {
vi.advanceTimersByTime(250);
});
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
typeof s === "string" ? s.includes("resize") : false,
);
// Second resize throttled (within 500ms)
expect(resizes.length).toBe(1);
});
it("sendInput sends data over websocket", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.sendInput("a");
});
expect(MockWebSocket.instances[0].sent).toContain("a");
});
it("triggers manual reconnect on reconnect()", async () => {
const { result } = renderHook(() =>
useTerminalConnection({ instanceId: "inst-1" }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
result.current.reconnect();
});
expect(MockWebSocket.instances).toHaveLength(2);
});
it("calls onData callback with binary data", async () => {
const onData = vi.fn();
renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData }));
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
const buffer = new ArrayBuffer(3);
act(() => {
MockWebSocket.instances[0].simulateMessage(buffer);
});
expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array));
});
it("calls onControl callback with control messages", async () => {
const onControl = vi.fn();
renderHook(() =>
useTerminalConnection({ instanceId: "inst-1", onControl }),
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
act(() => {
MockWebSocket.instances[0].simulateMessage(
JSON.stringify({ type: "set_echo_state", enabled: false }),
);
});
expect(onControl).toHaveBeenCalledWith(
expect.objectContaining({ type: "set_echo_state", enabled: false }),
);
});
it("serializes and restores scrollback", async () => {
const serializeFn = vi.fn(() => "scrollback-content");
const onRestoreScrollback = vi.fn();
renderHook(
() =>
useTerminalConnection({
instanceId: "inst-1",
serializeFn,
onRestoreScrollback,
}),
{ initialProps: {} },
);
act(() => {
MockWebSocket.instances[0].simulateOpen();
});
// Disconnect
act(() => {
MockWebSocket.instances[0].close(1006, "gone");
});
expect(serializeFn).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1000);
});
// New connection opens
act(() => {
MockWebSocket.instances[
MockWebSocket.instances.length - 1
].simulateOpen();
});
expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content");
});
});