498 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|