import { render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TerminalPane, terminalWsUrl } from "./TerminalPane"; const { terminalInstances, TerminalMock, FitAddonMock } = vi.hoisted(() => { class TerminalStub { cols = 80; rows = 24; opened: HTMLElement | null = null; written: unknown[] = []; disposed = false; dataHandler: ((data: string) => void) | null = null; loadAddon() {} open(element: HTMLElement) { this.opened = element; } write(data: unknown) { this.written.push(data); } onData(handler: (data: string) => void) { this.dataHandler = handler; return { dispose: () => {} }; } onResize() { return { dispose: () => {} }; } dispose() { this.disposed = true; } } const instances: TerminalStub[] = []; return { terminalInstances: instances, TerminalMock: class extends TerminalStub { constructor() { super(); instances.push(this); } }, FitAddonMock: class { fit() {} }, }; }); vi.mock("@xterm/xterm", () => ({ Terminal: TerminalMock })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: FitAddonMock })); vi.mock("@xterm/xterm/css/xterm.css", () => ({})); class FakeWebSocket { static OPEN = 1; static instances: FakeWebSocket[] = []; url: string; readyState = FakeWebSocket.OPEN; binaryType = ""; sent: string[] = []; onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; onopen: (() => void) | null = null; onclose: (() => void) | null = null; constructor(url: string) { this.url = url; FakeWebSocket.instances.push(this); } send(data: string) { this.sent.push(data); } close() {} } describe("TerminalPane", () => { beforeEach(() => { terminalInstances.length = 0; FakeWebSocket.instances.length = 0; vi.stubGlobal("WebSocket", FakeWebSocket); vi.stubGlobal( "ResizeObserver", class { observe() {} disconnect() {} }, ); }); afterEach(() => { vi.unstubAllGlobals(); }); it("opens xterm in the pane and connects to /ws/terminal", async () => { render(); await waitFor(() => expect(terminalInstances).toHaveLength(1)); expect(terminalInstances[0].opened).not.toBeNull(); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); expect(FakeWebSocket.instances[0].url).toBe(terminalWsUrl()); expect(FakeWebSocket.instances[0].binaryType).toBe("arraybuffer"); }); it("announces its size on open and forwards keystrokes as input frames", async () => { render(); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); const socket = FakeWebSocket.instances[0]; socket.onopen?.(); expect(socket.sent[0]).toBe( JSON.stringify({ type: "resize", cols: 80, rows: 24 }), ); terminalInstances[0].dataHandler?.("ls\r"); expect(socket.sent[1]).toBe( JSON.stringify({ type: "input", data: "ls\r" }), ); }); it("writes incoming binary frames to the terminal", async () => { render(); await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); FakeWebSocket.instances[0].onmessage?.({ data: new TextEncoder().encode("hello").buffer as ArrayBuffer, }); expect(terminalInstances[0].written).toHaveLength(1); }); it("disposes the terminal on unmount", async () => { const { unmount } = render(); await waitFor(() => expect(terminalInstances).toHaveLength(1)); unmount(); await waitFor(() => expect(terminalInstances[0].disposed).toBe(true)); }); });