agent-orchestrator/frontend/src/renderer/components/XtermTerminal.test.tsx

498 lines
17 KiB
TypeScript

import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { XtermTerminal } from "./XtermTerminal";
const state = vi.hoisted(() => ({
lastTerminal: null as null | {
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
selection: string;
options: Record<string, unknown>;
modes: { bracketedPasteMode: boolean };
dataListeners: Set<(data: string) => void>;
keyListeners: Set<(event: { key: string }) => void>;
selectionListeners: Set<() => void>;
_core: {
element: { classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> } };
_selectionService: {
enable: ReturnType<typeof vi.fn>;
shouldForceSelection: (event: MouseEvent) => boolean;
};
};
},
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class FakeTerminal {
options: Record<string, unknown>;
cols = 80;
rows = 24;
selection = "";
keyHandler?: (event: KeyboardEvent) => boolean;
wheelHandler?: (event: WheelEvent) => boolean;
modes = { bracketedPasteMode: false };
dataListeners = new Set<(data: string) => void>();
keyListeners = new Set<(event: { key: string }) => void>();
selectionListeners = new Set<() => void>();
_core = {
element: { classList: { add: vi.fn(), remove: vi.fn() } },
_selectionService: {
enable: vi.fn(),
shouldForceSelection: () => false,
},
};
constructor(options: Record<string, unknown>) {
this.options = options;
state.lastTerminal = this;
}
loadAddon() {}
open(host: HTMLElement) {
host.appendChild(document.createElement("textarea"));
}
write() {}
writeln() {}
dispose() {}
onData(listener: (data: string) => void) {
this.dataListeners.add(listener);
return { dispose: () => this.dataListeners.delete(listener) };
}
onResize() {
return { dispose: () => undefined };
}
onRender() {
return { dispose: () => undefined };
}
onKey(listener: (event: { key: string }) => void) {
this.keyListeners.add(listener);
return { dispose: () => this.keyListeners.delete(listener) };
}
onSelectionChange(listener: () => void) {
this.selectionListeners.add(listener);
return { dispose: () => this.selectionListeners.delete(listener) };
}
hasSelection() {
return this.selection.length > 0;
}
getSelection() {
return this.selection;
}
attachCustomKeyEventHandler(listener: (event: KeyboardEvent) => boolean) {
this.keyHandler = listener;
}
attachCustomWheelEventHandler(listener: (event: WheelEvent) => boolean) {
this.wheelHandler = listener;
}
unicode = { activeVersion: "" };
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class FakeFitAddon {
fit() {}
},
}));
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class FakeSearchAddon {},
}));
vi.mock("@xterm/addon-unicode11", () => ({
Unicode11Addon: class FakeUnicode11Addon {},
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: class FakeWebLinksAddon {},
}));
vi.mock("@xterm/addon-canvas", () => ({
CanvasAddon: class FakeCanvasAddon {},
}));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class FakeWebglAddon {
onContextLoss() {}
dispose() {}
},
}));
function setNavigatorPlatform(platform: string) {
Object.defineProperty(window.navigator, "platform", {
configurable: true,
value: platform,
});
Object.defineProperty(window.navigator, "userAgentData", {
configurable: true,
value: { platform },
});
}
describe("XtermTerminal", () => {
beforeEach(() => {
state.lastTerminal = null;
setNavigatorPlatform("Linux x86_64");
window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined);
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("");
});
it("copies selected terminal text on the terminal copy shortcut", () => {
render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "copied selection";
const event = {
key: "c",
metaKey: true,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("copied selection");
});
it("handles native copy events from inside the terminal", () => {
const { container } = render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "native copied selection";
const setData = vi.fn();
const event = new Event("copy", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, "clipboardData", {
value: { setData },
});
container.firstElementChild!.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(setData).toHaveBeenCalledWith("text/plain", "native copied selection");
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("native copied selection");
});
it("copies from the focused xterm textarea when the window receives the copy shortcut", () => {
const { container } = render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "focused copied selection";
container.querySelector("textarea")!.focus();
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "c",
metaKey: true,
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("focused copied selection");
});
it("auto-copies new selections and retries explicit copy if the auto-copy failed", async () => {
render(<XtermTerminal theme="dark" />);
const writeText = vi.fn().mockRejectedValueOnce(new Error("clipboard failed")).mockResolvedValueOnce(undefined);
window.ao!.clipboard.writeText = writeText;
state.lastTerminal!.selection = "retry me";
state.lastTerminal!.selectionListeners.forEach((listener) => listener());
await new Promise((resolve) => window.setTimeout(resolve, 0));
const event = {
key: "c",
metaKey: true,
ctrlKey: false,
shiftKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(writeText).toHaveBeenCalledTimes(2);
expect(writeText).toHaveBeenLastCalledWith("retry me");
});
it("leaves plain Ctrl+C as terminal input on non-Windows even when text is selected", () => {
render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "selected text";
const event = {
key: "c",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(true);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopPropagation).not.toHaveBeenCalled();
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
});
it("copies selected text with plain Ctrl+C on Windows", () => {
setNavigatorPlatform("Win32");
render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "windows copy";
const event = {
key: "c",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("windows copy");
});
it("leaves plain Ctrl+C as terminal input on Windows when nothing is selected", () => {
setNavigatorPlatform("Win32");
render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "";
const event = {
key: "c",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(true);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopPropagation).not.toHaveBeenCalled();
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
});
it.each(["Linux x86_64", "Win32"])(
"pastes once from the Electron clipboard on Ctrl+Shift+V for %s",
async (platform) => {
setNavigatorPlatform(platform);
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
const { container } = render(
<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />,
);
const event = {
key: "v",
metaKey: false,
ctrlKey: true,
shiftKey: true,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, "clipboardData", {
value: { getData: vi.fn().mockReturnValue("native paste") },
});
container.firstElementChild!.dispatchEvent(pasteEvent);
await Promise.resolve();
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(window.ao!.clipboard.readText).toHaveBeenCalledTimes(1);
expect(pasteEvent.defaultPrevented).toBe(true);
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
},
);
it("supports plain Ctrl+V paste on Windows", async () => {
setNavigatorPlatform("Win32");
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("windows paste");
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
const event = {
key: "v",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
await Promise.resolve();
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
expect(onInput).toHaveBeenCalledWith("windows paste", "paste");
});
it("suppresses a queued native paste event after a handled paste shortcut", async () => {
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("shortcut paste");
const { container } = render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
const event = {
key: "v",
metaKey: false,
ctrlKey: true,
shiftKey: true,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
expect(state.lastTerminal!.keyHandler!(event)).toBe(false);
await new Promise((resolve) => window.setTimeout(resolve, 0));
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, "clipboardData", {
value: { getData: vi.fn().mockReturnValue("native paste") },
});
container.firstElementChild!.dispatchEvent(pasteEvent);
await Promise.resolve();
expect(pasteEvent.defaultPrevented).toBe(true);
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith("shortcut paste", "paste");
});
it("supports classic Windows terminal copy and paste shortcuts", async () => {
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("insert paste");
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.selection = "insert copy";
const copyEvent = {
key: "Insert",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
expect(state.lastTerminal!.keyHandler!(copyEvent)).toBe(false);
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("insert copy");
const pasteEvent = {
key: "Insert",
metaKey: false,
ctrlKey: false,
shiftKey: true,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
expect(state.lastTerminal!.keyHandler!(pasteEvent)).toBe(false);
await Promise.resolve();
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
expect(onInput).toHaveBeenCalledWith("insert paste", "paste");
});
it.each([
["Option/Alt+Left", { key: "ArrowLeft", altKey: true }, "\x1bb"],
["Option/Alt+Right", { key: "ArrowRight", altKey: true }, "\x1bf"],
["Option/Alt+Backspace", { key: "Backspace", altKey: true }, "\x1b\x7f"],
["Option/Alt+Delete", { key: "Delete", altKey: true }, "\x1bd"],
["Ctrl+Left", { key: "ArrowLeft", ctrlKey: true }, "\x1b[1;5D"],
["Ctrl+Right", { key: "ArrowRight", ctrlKey: true }, "\x1b[1;5C"],
["Ctrl+Backspace", { key: "Backspace", ctrlKey: true }, "\x1b\x7f"],
["Ctrl+Delete", { key: "Delete", ctrlKey: true }, "\x1bd"],
])("normalizes %s into terminal input", (_name, init, expected) => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
const event = {
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...init,
} as unknown as KeyboardEvent;
const allowed = state.lastTerminal!.keyHandler!(event);
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(onInput).toHaveBeenCalledWith(expected, "shortcut");
});
it("forwards keyboard input from explicit key events", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
state.lastTerminal!.keyListeners.forEach((listener) => listener({ key: "a" }));
expect(onInput).toHaveBeenCalledWith("a", "keyboard");
});
it("does not forward raw xterm data/control bytes as user input", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
expect(state.lastTerminal!.dataListeners.size).toBe(0);
state.lastTerminal!.dataListeners.forEach((listener) => listener("\x1b[A"));
expect(onInput).not.toHaveBeenCalled();
});
it("translates wheel motion into SGR wheel reports for zellij scrollback", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// rowHeight = fontSize(12) * lineHeight(1.35) = 16.2px; -50px => 3 lines up.
const suppressed = state.lastTerminal!.wheelHandler!({ deltaY: -50 } as WheelEvent);
expect(suppressed).toBe(false);
expect(onInput).toHaveBeenCalledWith("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M", "wheel");
});
it("handles line- and page-mode wheels (Linux/Windows mice), not just pixel deltas", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
// DOM_DELTA_LINE: deltaY is already in lines, so one notch up => one report.
expect(state.lastTerminal!.wheelHandler!({ deltaY: -1, deltaMode: 1 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[<64;1;1M", "wheel");
// DOM_DELTA_PAGE: one page down => rows (24) line reports down.
onInput.mockClear();
expect(state.lastTerminal!.wheelHandler!({ deltaY: 1, deltaMode: 2 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenLastCalledWith("\x1b[<65;1;1M".repeat(24), "wheel");
});
it("scrolls down on positive wheel delta and leaves zoom (ctrl/meta) wheel alone", () => {
const onInput = vi.fn();
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
expect(state.lastTerminal!.wheelHandler!({ deltaY: 20 } as WheelEvent)).toBe(false);
expect(onInput).toHaveBeenCalledWith("\x1b[<65;1;1M", "wheel");
onInput.mockClear();
expect(state.lastTerminal!.wheelHandler!({ deltaY: -50, ctrlKey: true } as WheelEvent)).toBe(false);
expect(onInput).not.toHaveBeenCalled();
});
it("forces plain drag selection without raw xterm data forwarding", () => {
render(<XtermTerminal theme="dark" />);
expect(state.lastTerminal!.options.macOptionClickForcesSelection).toBe(true);
expect(state.lastTerminal!._core._selectionService.enable).toHaveBeenCalled();
expect(state.lastTerminal!._core.element.classList.remove).toHaveBeenCalledWith("enable-mouse-events");
expect(state.lastTerminal!._core._selectionService.shouldForceSelection({} as MouseEvent)).toBe(true);
});
});