340 lines
7.9 KiB
TypeScript
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");
|
|
});
|
|
});
|