TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a single mount effect keyed on sessionId, tearing the socket and terminal down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context budget (acquire on mount, release on unmount, re-acquire on context loss while still mounted) and reconnect.ts's exponential 500ms->10s backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx. Pane chrome (title, disconnect, sessions drawer) stays out of scope; composition happens one level up.
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { cleanup, render } from "@testing-library/react";
|
|
import { createRef } from "react";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
|
import { attachWsUrl } from "../../routes.js";
|
|
import type { ClientControl } from "../../types.js";
|
|
|
|
class FakeTerminal {
|
|
static instances: FakeTerminal[] = [];
|
|
cols = 80;
|
|
rows = 24;
|
|
element: HTMLElement | undefined = undefined;
|
|
textarea: HTMLTextAreaElement | null = null;
|
|
disposed = false;
|
|
focus = vi.fn();
|
|
private dataCb: ((d: string) => void) | null = null;
|
|
|
|
constructor(public opts: unknown) {
|
|
FakeTerminal.instances.push(this);
|
|
}
|
|
loadAddon(): void {}
|
|
open(container: HTMLElement): void {
|
|
container.appendChild(document.createElement("div"));
|
|
}
|
|
onData(cb: (d: string) => void) {
|
|
this.dataCb = cb;
|
|
return { dispose: vi.fn() };
|
|
}
|
|
emitData(d: string): void {
|
|
this.dataCb?.(d);
|
|
}
|
|
onResize() {
|
|
return { dispose: vi.fn() };
|
|
}
|
|
onSelectionChange() {
|
|
return { dispose: vi.fn() };
|
|
}
|
|
write(_data: Uint8Array, cb?: () => void): void {
|
|
cb?.();
|
|
}
|
|
scrollToBottom(): void {}
|
|
resize(cols: number, rows: number): void {
|
|
this.cols = cols;
|
|
this.rows = rows;
|
|
}
|
|
clearTextureAtlas(): void {}
|
|
getSelection(): string {
|
|
return "";
|
|
}
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
class FakeFitAddon {
|
|
fit = vi.fn();
|
|
}
|
|
|
|
class FakeWebglAddon {
|
|
static instances: FakeWebglAddon[] = [];
|
|
disposed = false;
|
|
private lossCb: (() => void) | null = null;
|
|
constructor() {
|
|
FakeWebglAddon.instances.push(this);
|
|
}
|
|
onContextLoss(cb: () => void): void {
|
|
this.lossCb = cb;
|
|
}
|
|
triggerLoss(): void {
|
|
this.lossCb?.();
|
|
}
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
const policyEnabled = vi.fn(() => true);
|
|
const policyNoteLoss = vi.fn();
|
|
|
|
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
|
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
|
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
|
vi.mock("../../webglRenderer.js", () => ({
|
|
WebglPolicy: vi.fn().mockImplementation(() => ({
|
|
enabled: policyEnabled,
|
|
noteContextLoss: policyNoteLoss,
|
|
})),
|
|
}));
|
|
|
|
class FakeWebSocket {
|
|
static OPEN = 1;
|
|
static CONNECTING = 0;
|
|
static CLOSING = 2;
|
|
static CLOSED = 3;
|
|
static instances: FakeWebSocket[] = [];
|
|
readyState = FakeWebSocket.CONNECTING;
|
|
binaryType = "";
|
|
sent: (string | ArrayBufferView)[] = [];
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
|
|
|
constructor(public url: string) {
|
|
FakeWebSocket.instances.push(this);
|
|
}
|
|
send(data: string | ArrayBufferView): void {
|
|
this.sent.push(data);
|
|
}
|
|
close(): void {
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
}
|
|
open(): void {
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
this.onopen?.();
|
|
}
|
|
drop(code = 1006, reason = ""): void {
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
this.onclose?.({ code, reason });
|
|
}
|
|
lastControl(): ClientControl {
|
|
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
|
|
}
|
|
}
|
|
|
|
beforeAll(() => {
|
|
vi.stubGlobal(
|
|
"ResizeObserver",
|
|
class {
|
|
observe(): void {}
|
|
disconnect(): void {}
|
|
unobserve(): void {}
|
|
},
|
|
);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
FakeTerminal.instances = [];
|
|
FakeWebglAddon.instances = [];
|
|
FakeWebSocket.instances = [];
|
|
policyEnabled.mockReturnValue(true);
|
|
policyEnabled.mockClear();
|
|
policyNoteLoss.mockClear();
|
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
|
|
const { TerminalPane } = await import("./TerminalPane.js");
|
|
|
|
describe("TerminalPane lifecycle", () => {
|
|
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
|
|
expect(FakeTerminal.instances).toHaveLength(1);
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
|
|
});
|
|
|
|
it("sends the terminal size once the socket opens", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
const sock = FakeWebSocket.instances[0];
|
|
|
|
sock.open();
|
|
|
|
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
|
|
});
|
|
|
|
it("disposes the terminal and closes the socket on unmount", () => {
|
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
|
const term = FakeTerminal.instances[0];
|
|
const sock = FakeWebSocket.instances[0];
|
|
|
|
unmount();
|
|
|
|
expect(term.disposed).toBe(true);
|
|
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
|
|
});
|
|
|
|
it("reconnects to a fresh socket for a new sessionId", () => {
|
|
const { rerender } = render(<TerminalPane sessionId="s1" />);
|
|
rerender(<TerminalPane sessionId="s2" />);
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
|
|
expect(FakeTerminal.instances[0].disposed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("TerminalPane reconnect backoff", () => {
|
|
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
|
|
vi.useFakeTimers();
|
|
render(<TerminalPane sessionId="s1" />);
|
|
|
|
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
|
|
const expectStillWaiting = (ms: number, countBefore: number) => {
|
|
vi.advanceTimersByTime(ms - 1);
|
|
expect(FakeWebSocket.instances).toHaveLength(countBefore);
|
|
vi.advanceTimersByTime(1);
|
|
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
|
|
};
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
drop();
|
|
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
|
|
drop();
|
|
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
|
|
drop();
|
|
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
|
|
drop();
|
|
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
|
|
drop();
|
|
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
|
|
drop();
|
|
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
|
|
drop();
|
|
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
|
|
});
|
|
|
|
it("resets the backoff once a reconnected socket opens", () => {
|
|
vi.useFakeTimers();
|
|
render(<TerminalPane sessionId="s1" />);
|
|
|
|
FakeWebSocket.instances[0].drop(1006, "");
|
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
|
|
FakeWebSocket.instances[1].open();
|
|
FakeWebSocket.instances[1].drop(1006, "");
|
|
|
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
vi.advanceTimersByTime(1);
|
|
expect(FakeWebSocket.instances).toHaveLength(3);
|
|
});
|
|
|
|
it("does not retry a clean close, and reports it closed", () => {
|
|
vi.useFakeTimers();
|
|
const onClosed = vi.fn();
|
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
|
|
|
FakeWebSocket.instances[0].drop(1000, "");
|
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
expect(onClosed).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not retry an application-level rejection, and writes it into the terminal", () => {
|
|
vi.useFakeTimers();
|
|
const onClosed = vi.fn();
|
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
|
const term = FakeTerminal.instances[0];
|
|
const writeSpy = vi.spyOn(term, "write");
|
|
|
|
FakeWebSocket.instances[0].drop(4004, "unknown session");
|
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
expect(onClosed).not.toHaveBeenCalled();
|
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
|
|
});
|
|
|
|
it("honors a caller-supplied shouldReconnect policy", () => {
|
|
vi.useFakeTimers();
|
|
const onClosed = vi.fn();
|
|
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
|
|
|
|
FakeWebSocket.instances[0].drop(1006, "");
|
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
expect(onClosed).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("TerminalPane WebGL context budget", () => {
|
|
it("acquires a WebGL context on mount, shared policy allowing", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
|
|
expect(policyEnabled).toHaveBeenCalled();
|
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
|
});
|
|
|
|
it("never creates a context once the shared budget is exhausted", () => {
|
|
policyEnabled.mockReturnValue(false);
|
|
render(<TerminalPane sessionId="s1" />);
|
|
|
|
expect(FakeWebglAddon.instances).toHaveLength(0);
|
|
});
|
|
|
|
it("releases its context on unmount, handing the budget back", () => {
|
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
|
const addon = FakeWebglAddon.instances[0];
|
|
|
|
unmount();
|
|
|
|
expect(addon.disposed).toBe(true);
|
|
});
|
|
|
|
it("counts a context loss and re-acquires while still mounted", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
const first = FakeWebglAddon.instances[0];
|
|
|
|
first.triggerLoss();
|
|
|
|
expect(policyNoteLoss).toHaveBeenCalledOnce();
|
|
expect(first.disposed).toBe(true);
|
|
expect(FakeWebglAddon.instances).toHaveLength(2);
|
|
});
|
|
|
|
it("does not re-acquire a lost context once the budget is spent", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
const first = FakeWebglAddon.instances[0];
|
|
policyEnabled.mockReturnValue(false);
|
|
|
|
first.triggerLoss();
|
|
|
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("TerminalPane imperative handle", () => {
|
|
it("sendInput writes bytes to the PTY only once the socket is open", () => {
|
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
|
const sock = FakeWebSocket.instances[0];
|
|
|
|
ref.current!.sendInput("nope");
|
|
expect(sock.sent).toHaveLength(0);
|
|
|
|
sock.open();
|
|
sock.sent.length = 0; // drop the resize control frame from open()
|
|
ref.current!.sendInput("hi");
|
|
|
|
expect(sock.sent).toHaveLength(1);
|
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
|
|
});
|
|
|
|
it("focusTerminal focuses the underlying xterm instance", () => {
|
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
|
|
|
ref.current!.focusTerminal();
|
|
|
|
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("routes typed keystrokes through to the socket", () => {
|
|
render(<TerminalPane sessionId="s1" />);
|
|
const sock = FakeWebSocket.instances[0];
|
|
sock.open();
|
|
sock.sent.length = 0;
|
|
|
|
FakeTerminal.instances[0].emitData("q");
|
|
|
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
|
|
});
|
|
});
|