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));
});
});