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; modes: { bracketedPasteMode: boolean }; dataListeners: Set<(data: string) => void>; keyListeners: Set<(event: { key: string }) => void>; selectionListeners: Set<() => void>; _core: { element: { classList: { add: ReturnType; remove: ReturnType } }; _selectionService: { enable: ReturnType; shouldForceSelection: (event: MouseEvent) => boolean; }; }; }, })); vi.mock("@xterm/xterm", () => ({ Terminal: class FakeTerminal { options: Record; 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) { 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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(); 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); }); });